Repository navigation
design skin system
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.
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. 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. |
-
Mode axis (light/dark) —
next-themes, class on<html>, storage keytheme,systemdefault. -
Skin axis —
<html data-skin="signal|bay|…">, set pre-paint by an inline script inapps/web/index.html(which also closes the pre-existing theme-flash window), persisted ashnx.skin, driven bySkinProvider(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).
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.
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) |
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-8with--control-h-smmade 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 expandingmin-width/min-heightunder(pointer: coarse)inflated the well (242×50), its row (33 → 51px) and pushed the affordance under the pinned column. The extra area is an::afteroverlay (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.
-
A Well hugs its value. In a flex container an inline-flex well stretches (a black bar
across a card in BAY);
self-startat the call site or a block parent restores the comps' behaviour. Table cells use the chip scale for protocol values — the comps'wchip widecarries whole session ids — whilewellbelongs 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-dimnow (#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.
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-noneand 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 standalonetranslate:property, which does NOT overwritetransform:. BAY's switch thumb was placed withtransform: translateX(22px)while the base animatedtranslate: 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 iwithleft; the skin does the same and resetstranslate: none. Same family as the unlayered-rule lesson: the layer that owns the mechanism is the layer that defines it.
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 followsmanifest.statusStyle(dot / LED / phosphor lamp / printed word). -
StateSignal(components/state-signal.tsx) — the inline mark: the samedata-slot="lamp-body"element carrying the samedata-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).
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.
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 linewhen · 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--touchwhile 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 fromsmup.
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.
A skin may not:
-
inject text with
content:— text is data the CSS cannot know (the bay numbers were injected that way and could never follow the manifest); - 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.
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.
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.
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).
- 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-*.cssfrom Fontsource); the CJK chain in--font-sansstays 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 engravedlabel. 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.