design skin system - sinrimin/harness-nexus GitHub Wiki

Skin system β€” design

Issue #23. Status (2026-09-25): Signal + BAY shipped. LEDGER and NIGHTWATCH are not built β€” the skeleton was proven flexible instead (P7's drill, see "Skeleton flexibility"). The exploration comps (three static-HTML theme directions + the full research) live locally on the dev box under docs/dev/theme-designs/ (git-ignored by policy) and are the spec for anything skin-visible: a conformance question is settled by the comp and by measurement, not by taste. The phase record is local too (docs/dev/ui-refactor/); the code is the authority and apps/web/src/skins/registry.test.tsx holds the promises below as assertions.

The rules a skin rides under β€” the shell skeleton, the content language, the interaction language β€” are in design-ui-system.md. This page is the second axis on top of them: what a skin may change, and what it may never touch.

What this is

A multi-skin architecture for the web console: the shipped Signal system stays the default and gains the ability to host additional skins (BAY shipped, LEDGER / NIGHTWATCH later). A skin is a token override sheet + a scoped style package + a manifest β€” it never restructures the app shell, and it can never alter a status value, only its rendering.

Decisions (2026-09-23, amended after P7/P8 and the shell round of 2026-09-25):

# Decision
1 Architecture = token contract + scoped skin packages + topology-renderer registry. Layout skeleton never changes. Fidelity is reached through two controlled extension points: (a) data-* annotations and the base's empty Region slots, (b) the topology registry. manifest-declared widgets β€” the widgets field was deleted in P7: the regions the skins-of-record want (marginalia, watchbar, and the mobile strip region β€” baystrip then, navstrip now) are base slots that render nothing while empty, so a skin never needs a manifest field to own DOM.
2 Ship order: BAY shipped (P7). NIGHTWATCH / LEDGER deferred β€” but the skeleton they need was proven by a placeholder skin that ran a real browser walk (P7 Β§Skeleton flexibility), not by a document.
3 Topology style is locked to the skin (constellation / engraved plate / radar); not a standalone user option. All three keys are real renderers.
4 Skin preference = localStorage['hnx.skin'] (same family as hnx.lang). No server-side user field.
5 Visual snapshot rig (scripts/ui-snap.mjs) ships as a local/manual tool; CI adoption deferred.
6 The hexagonal brand mark's shape is frozen; --brand-bright/mid/deep are per-skin overrides. Wordmark + auth-page banner material follow the skin.

The two axes

  • Mode axis (light/dark) β€” next-themes, class on <html>, storage key theme, system default.
  • Skin axis β€” <html data-skin="signal|bay|…">, set pre-paint by an inline script in apps/web/index.html (which also closes the pre-existing theme-flash window), persisted as hnx.skin, driven by SkinProvider (src/components/skin-provider.tsx) + src/skins/registry.ts.

A manifest declares modes: ['light'] etc. A single-mode skin HIDES the theme toggle (#41 β€” ThemeToggle returns null; it used to render a disabled icon, which on touch devices is a dead target whose slipped taps hit the neighbor: the star link in the corner, the skin picker in the drawer) and switching onto it forces the mode it ships. The rig does not mirror this: it asks the document which mode it resolved to and skips the shot when the skin locked the other one (Β§Snapshot rig).

Manifest (the contract every skin declares)

interface SkinManifest {
  id: SkinId;                    // 'signal' | 'bay' | 'ledger' | 'nightwatch'
  nameKey: `app.skin${Capitalize<SkinId>}`; // i18n; product names untranslated
  modes: SkinMode[];             // locks the light/dark toggle when length 1
  statusStyle: 'dot' | 'led' | 'lamp' | 'word';
  topology: 'constellation' | 'plate' | 'radar';
  load: () => Promise<unknown>;  // runtime-injects tokens.css / skin.css / fonts
}

signal is the zero-package baseline (load is a no-op); every other skin is pure addition under apps/web/src/skins/<id>/ (index.ts, tokens.css, skin.css, fonts.ts). Skin CSS may use exactly two selector prefixes: :root[data-skin='<id>'] (tokens) and [data-skin='<id>'] (signature surfaces). Nothing else β€” no bare classes, no !important wars with utilities.

Every manifest field must have a consumer; the registry test fails on a field nothing reads, which is how widgets was caught.

Token contract

Frozen: every variable in apps/web/src/index.css (surfaces, text, borders, --signal, --ok/--warn/--danger, --brand-*, --sidebar-*, --radius, …). Components reference names only; skins override values only. The set that carries the skin system:

Token Role
--scrim overlay base color (alpha at use site: bg-scrim/50 replaced the last hardcoded bg-black/50 in shadcn primitives)
--surface-inverted / -ink / -dim the "well": a surface darker than the page for terminal/code/diff readouts. -dim is that window's secondary ink β€” and, by consequence, the only correct tone for depth inside a well (Β§Material)
--tray / --tray-ink the neutral step between card and muted (table headers, list feet, row hover, group strips). --tray-ink exists because a skin that rebinds ink inside a well cannot reach the chip that sits on the tray
--app-font-sans / -mono / -display / -label runtime font hooks β€” the @theme inline layer maps --font-* onto them, so a skin swaps families by overriding a variable
--spine-w, --bay-h the rack: the spine column's width and the floor height of one bay (Β§Addresses)
--panel-pad(-wide), --panel-head-h, --table-head-h, --row-h, --control-h(-sm/-lg), --row-ctl-h, --gap(-tight), --touch density contract: panel chrome, table headers, rows, and every control's height (Β§The control scale)
--radius-panel / --radius-well, --shadow-panel, --press-shift / --shadow-press material contract. --press-shift/--shadow-press are read by [data-slot='button']:active; Signal sets them to zero (no displacement), BAY to 2px (a key that goes down)
--lamp-ok/warn/fail/off, --state-ok/warn/fail-ink status device colours: the lamp body (tones) and the ink the state word is printed in
--marginalia-w the folio column's width (the slot is the base's; the width is the skin's)
--label-size(-sm), --data-size(-sm), --readout-size* type scale for the three roles (.role-label, .role-data, .role-readout)
--motion-seat (180ms) / --motion-breath (2.4s) the two sanctioned motions: a panel seating, a lamp breathing (both zeroed by the global reduced-motion block)

The control scale

Heights are a closed set, and the scale is only real if every control reads it: ui/button did from P7, while ui/input (h-9) and ui/select (h-9/h-8) kept literals until #23 β€” which is how BAY's inputs came out a tier taller than its own buttons (36 vs 30px) and its densest toolbar (the chat Sender) 7px too tall. A literal is invisible to a skin; a token is the skin's only handle on density.

  • A token fixes WHO decides a size, not WHETHER it fits. Replacing a row's h-8 with --control-h-sm made BAY's rows right and left Signal's flush against the cell rules: Signal's small control is 32px and its row is 30px. The constraint therefore belongs in the token β€” --row-ctl-h: min(var(--control-h-sm), calc(var(--row-h) - 4px)), "the skin's small control, never taller than the row that holds it" (Signal 26, BAY 25 β€” the cap never bites there, because BAY's own small control is smaller). Every row-level control reads it: the list pages' row menus and the MCP connect/disconnect buttons.
  • The fingertip is --touch (40px), and it arrives as an AREA, not as a bigger box. A control inside a table row or a Well is behind a pinned/opaque overlay, so expanding min-width/min-height under (pointer: coarse) inflated the well (242Γ—50), its row (33 β†’ 51px) and pushed the affordance under the pinned column. The extra area is an ::after overlay (inset: 50% + min-*: 2.5rem + translate(-50%,-50%)); the visuals stay at their designed size. Where a control IS the phone's primary action (the Sender's send/stop) the token is taken directly instead.

Material

  • A Well hugs its value. In a flex container an inline-flex well stretches (a black bar across a card in BAY); self-start at the call site or a block parent restores the comps' behaviour. Table cells use the chip scale for protocol values β€” the comps' wchip wide carries whole session ids β€” while well belongs to kv rows and labelled fields.
  • A shadow is only a plate if it is a different tone. BAY's figure shadow started as the aluminium --bezel (#c3cad4) β€” but the figures sit inside inverted wells, whose ink is already light (#c8d4e0): 20 lightness units apart is not depth, it is the same word printed twice. It is --surface-inverted-dim now (#7b8899 against ink 200,212,224) β€” the ASK is a shadow, so the property is tone, and the tone has to come from the window the thing sits in.

Hook vocabulary β€” the only things a skin may depend on

A device a skin cannot see does not exist. Every kit device therefore carries a stable socket, and the sockets are the frozen half of the contract (components own them; skins read them):

Hook Carried by Meaning
data-skin <html> the skin anchor; every skin rule is scoped by it
data-region shell chrome, spine, brand, nav, plate, header, main, signed-in, navstrip, marginalia, watchbar β€” empty slots cost 0. The composer's controls are a data-slot device (composer-tools), not a region: the region list is exactly what the shell renders
data-nav-group / data-nav-heading nav items + plate headings which address an item belongs to (spine bays, plate headings, strip items)
data-bay-number spine + strip items the bay's number, rendered from data β€” CSS content: injection is forbidden
data-surface kit panel, table, tray, well, readout, mesh β€” the material class of a block
data-slot kit + shadcn primitives the device: panel-header/panel-title/panel-body, table-*, lamp/lamp-body/lamp-word, switch/switch-thumb, select-trigger/select-value/select-chevron, composer-tools, page-actions, chip, meter/meter-fill, meter-ring/meter-ring-track/meter-ring-fill, copy, …
data-state status devices + controls the status truth (18 states) and control state (checked, …) β€” styling never rewrites it
data-tone kit the accent intent, typed per device (chip: default/muted/live; panel: default/live; note: the status family). A skin that withdraws the accent (LEDGER) maps live onto ink β€” the hook stays, the colour changes
data-variant kit a material variant the base intends (Button: ghost/link/outline; SelectTrigger: bare) β€” the skin hands back
data-density tables default / compact
data-live shell the single live thing (a running session, the current bay) β€” a skin may spend the accent on it
  • When the base must opt out of a skin, the opt-out is a variant. Skin rules are unlayered, so no Tailwind utility can out-vote one: the chat Sender's config selects carried border-none bg-transparent shadow-none and BAY still painted them as inverted LCD windows. SelectTrigger variant="bare" (data-variant="bare") is the socket β€” the skin scopes its window rule with :not([data-variant='bare']), and any future bare select gets the exemption by asking for it.
  • A skin that moves something must know which property the base moves it with. Tailwind v4 compiles translate-x-* into the standalone translate: property, which does NOT overwrite transform:. BAY's switch thumb was placed with transform: translateX(22px) while the base animated translate: calc(100% - 2px) β€” the ON knob landed at the SUM (34px) of a 38px track and poked out of the switch. The comp positions .sw i with left; the skin does the same and resets translate: none. Same family as the unlayered-rule lesson: the layer that owns the mechanism is the layer that defines it.

Status truth is data, never style

One device, two call shapes (P8 closed this β€” D-13):

  • Lamp (components/kit/lamp.tsx) β€” the status readout: wrapper + data-slot="lamp-body" + an optional word/label. Its shape follows manifest.statusStyle (dot / LED / phosphor lamp / printed word).
  • StateSignal (components/state-signal.tsx) β€” the inline mark: the same data-slot="lamp-body" element carrying the same data-state, with no wrapper and no word channel, so it fits a 2px gutter or a tool row.

Both carry data-state="<semantic state>"; SIGNAL_TONE maps the 18 states onto five tones and both devices read it, so a new state cannot be added without choosing a tone, and a skin writes one rule family ([data-skin='x'] [data-slot='lamp-body'][data-state=…]) instead of one per device. Before P8 they were two element shapes: BAY carried every LED rule twice, and a word-style skin could only neutralize one of them.

A skin that replaces colour with a printed word (statusStyle: 'word') gets no lamp body from Lamp, and hides the mark itself β€” [data-skin='ledger'] [data-slot='lamp-body'] { display: none } β€” because only the skin knows whether a word can be printed where the mark sits. The word strings live in i18n.

SIGNAL_FILL_CLASS remains the SVG equivalent (the mesh topology fills nodes from the same map).

Addresses & navigation (the rack, the strip, the action)

A nav group is an address engraved on a rack, not prose. nav.ts carries number (01…05) + label (NAV MESH ASSET ACCESS ADMIN) for every group; the label is English and uppercase in both locales β€” translating it is how one section ends up with two names. The manifest keeps label (the engraving, constant) beside labelKey (the section as a LINK in the page trail, translated) β€” the same split as wire values that stay English. One source (nav.ts) feeds every surface that names a section β€” spine bays, the plate heading, the strip's number prefix β€” so they cannot disagree; 01-skeleton.md Β§6 drew the group strip as NAV MESH ASSET ACCES ADMIN from the start.

The spine runs a 30px column with its name down (--spine-w, writing-mode: vertical-rl on .role-label-vertical): the comp's 72px was mostly air once the name turned sideways. Bays keep a --bay-h (72px) floor so the rack still reads as units; the active bay carries a 3px accent edge.

A piloted bay never moves its address. The live bay's lamp sits in the module's top corner (absolute), not in the number's row: a lamp beside the address pushed the address sideways on the one bay the eye is on. Same rule as the hit areas β€” a marker may not re-lay-out the thing it marks.

navstrip β€” the phone's jump bar (<900px). Every destination, not one entry per section: a section-only strip lands every tap on that section's first page, so switching between interior pages is a drawer trip β€” and a caret menu beside the page title is not the answer, it duplicates the drawer button without adding destinations. The strip keeps the bay strip's dress (each item led by its bay number, hairline separated, the current item underlined 2px in the accent, one horizontal scroller with its own scrollbar hidden, the current item scrolled into view on arrival, 2.5rem minimum height under (pointer: coarse)) and reads 03 ζŠ€θƒ½ β”‚ 03 子代理 for two pages of the same section. The drawer stays: it is the only surface that also carries the account block.

A topbar action shrinks to its icon on a phone (shell/page-action.tsx): the bar is 48px tall and already holds the drawer button, the identity and three toggles. Icon + label from sm up, the icon alone below with the name in aria-label/title. It is one component because "uniform" has to be a property of the code, not a habit β€” two pages had grown their own hidden sm:inline span and six had not.

marginalia is a base slot outside the content frame: empty it is display: none and costs nothing; a page fills it with <PageSlot slot="margin">. That is how LEDGER's folio column arrives without touching the skeleton.

Devices: bands, rows and lists are composed, never hand-rolled

A page's bands and lists are devices β€” panel nameplates, tray strips, wells, lamps, the label/data roles β€” and a band built out of raw divs with literal type sizes reads as foreign no matter which skin is on, because it is the one element on screen that no skin can reach. Two worked examples from the shell round (2026-09-25):

  • The chat session rail was hand-rolled: a group header printing a directory's basename with the path in a title, and one-line rows with a rounded hover block. The comp has .sgroup-h (tray strip + LabelText + the directory as a Well, copyable) and .sess (lamp + title over a mono meta line when Β· short-id, hairline separators, active row on the tray with heavier ink). Rebuilt on the comp's devices, both skins read as the same product (measured: tray 233,236,241 = the comp's #e9ecf1).
  • The Sender's toolbar sits on its baseline, not on its centre. items-end: the config words line up with the Send control beside them; centring them against a taller button floats them above the card's edge. The row's height comes from the control scale (--control-h, not a one-off 36px circle), the shape from the skin's radius (a rounded square, not a circle the comps never had), and on a phone the primary action takes --touch while the config controls trade their values for icons: the value is read in the picker, which is also where it is set, so the strip never spends horizontal room on a word that is one tap away β€” icon alone at phone width, icon + value from sm up.

BAY's switch is a device too (data-slot="switch" + switch-thumb): a rectangular slot with a machined knob and a green pilot lamp on the left when it is on (the comp's .sw). Shape is the skin's business β€” which is why the slot exists at all.

Skin CSS rules (the two hard rules, plus what a skin must read)

A skin may not:

  1. inject text with content: β€” text is data the CSS cannot know (the bay numbers were injected that way and could never follow the manifest);
  2. tear a component open with negative margins β€” the remedy is a base hook, not surgery.

Both are asserted by the registry test over every skins/*/skin.css (comments stripped), so a new skin cannot break them by accident. Beyond them, the two positive rules: a skin reads the property the base moves with (Β§Hook vocabulary) and a skin that needs a structural difference means the base is missing a hook β€” add the hook, do not reach over it.

Topology registry

components/topology.tsx maps the manifest's topology onto a renderer: constellation (the SVG fan-in/out, the product's signature), plate (the same mesh as a two-column list; also what constellation degrades to on narrow screens) and radar (an instrument sweep, built in P7). All three are real; radar has no shipped consumer β€” kept because the key is part of the frozen contract and a third skin gets it by editing its manifest alone.

Skeleton flexibility (how the deferred skins are still safe)

P7 proved the skeleton can host NIGHTWATCH / LEDGER without structural change by running a placeholder skin in a real browser, one line of the promise at a time: region slots (the folio column appeared and moved main without breaking it), statusStyle: 'word', the via-token topology swap, no light-mode assumptions in structure, the accent withdrawn to ink (with "current" still carried by position and weight), and density/fonts entirely through tokens. Two real gaps surfaced that way β€” the missing folio mount and the two status devices β€” and both were fixed rather than documented.

Snapshot rig

scripts/ui-snap.mjs boots the server (memory driver) + web dev server, seeds honest data over the API, and captures route Γ— skin Γ— mode Γ— viewport (desktop 1440Γ—900, mobile 390Γ—844 via CDP device emulation β€” headless Chrome clamps raw windows at 500px, so raw --window-size=390 screenshots lie). --update refreshes the baseline (docs/dev/ui-baselines/, machine-local by gitignore policy) and prunes entries the matrix no longer produces; without it the rig compares with pixelmatch and fails on real drift. Run it before merging skin-visible changes.

Determinism is enforced where it can be: reduced motion + frozen clock, an injected mask for timestamps/ids, document.fonts gated on the active skin's families, and a settle gate that waits for network-quiet and two byte-identical frames (socket.io long-polls forever, so networkidle never fires). Single-mode skins are not mirrored in the rig β€” the capture reads the resolved mode off the document and skips the duplicate shot (measured before P8: 22 of 88 baselines were byte-identical pairs).

Companion instruments: scripts/test-touch-targets.mjs (a CDP hasTouch probe for the coarse-pointer rules β€” set device in agent-browser does not make (pointer: coarse) true, so the rig has to emulate touch itself).

Non-goals / future

  • No layout-variant mechanism, no per-skin component code.
  • NIGHTWATCH / LEDGER themselves: product decisions, deferred. Their contract slots (statusStyle: 'word' | 'lamp', marginalia, the topology keys) are in place and exercised by tests.
  • Server-side per-user skin preference β€” explicitly not built (localStorage only).
  • Font subsetting is per-package (latin-*.css from Fontsource); the CJK chain in --font-sans stays system fonts, and extended-Latin characters beyond the shipped subset fall back to the system stack.
  • Sections carry two names on purpose: the breadcrumb's crumb is the translated labelKey (θ΅„δΊ§ / ζŠ€θƒ½) because it sits in a sentence-like page trail, while the spine, the plate heading and the strip's number use the engraved label. Where both are on screen at once, each reads as what it is β€” a reference in the reader's language, next to an address that never moves.
⚠️ **GitHub.com Fallback** ⚠️