# 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`](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 ``, storage key `theme`, `system` default. - **Skin axis** — ``, 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) ```ts interface SkinManifest { id: SkinId; // 'signal' | 'bay' | 'ledger' | 'nightwatch' nameKey: `app.skin${Capitalize}`; // 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; // 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//` (`index.ts`, `tokens.css`, `skin.css`, `fonts.ts`). Skin CSS may use exactly two selector prefixes: `:root[data-skin='']` (tokens) and `[data-skin='']` (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` | `` | 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=""`; `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 ``. 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 `div`s 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.