# Architecture — @dolphinspired/solid-stage This is the canonical record of the library's architectural decisions: the load-bearing principles, the cross-cutting systems, every primitive's design, and the gotchas worth not relearning. It is both reference and roadmap. **Read this before adding a feature; update it whenever an architectural decision is made.** When a change touches the public API, update `README.md` too. `@dolphinspired/solid-stage` is a small library of Solid + Three editor-chrome primitives, extracted from `osrs-engine-study` and `3d-model-exporter` after both independently grew the same shape. --- ## 1. Architecture thesis The load-bearing principles. Everything else is downstream of these; a new feature that violates one is almost certainly wrong. 1. **Pre-composed children, not registries.** `EditorPanels` takes `leftPanel?: JSX.Element`, not `leftSections: SectionId[]`; `Section` takes `children`, not a field schema; every bar is a pre-composed `Toolbar` (or plain JSX), not a bar config object. Naming the collapsible container `Section` does **not** imply a section registry / id-keyed lookup — that mapping is app-specific. Composition stays child-based throughout. 2. **Controlled + uncontrolled, mirrored everywhere.** Anything with state (collapse, active tab) accepts optional `open?: () => boolean` + `onToggle?` with an `internalOpen()` signal fallback. Canonical accessor: `isOpen = () => props.open?.() ?? internalOpen()`. In uncontrolled mode the internal signal seeds from / writes back to localStorage where a primitive persists (`Section` collapse, panel collapse); controlled mode leaves persistence to the parent (never caches). 3. **CSS classes + variables, no inline styles.** Every classname is `stage-*`. Every dimension and colour is a CSS custom property on `:where(.stage-root)`. Consumers override by setting variables on any container. The library never writes `!important` — `:where()` (specificity 0) lets consumer overrides win without it. The library ships no inline-`style` escape hatch; the style hook is a `class` prop (e.g. `Label`). 4. **Bundled curated icon set + consumer-supplied icons.** The library ships a small, curated subset of **Phosphor** icons as a tree-shakeable subpath export (`@dolphinspired/solid-stage/icons`), generated from the `@phosphor-icons/core` dev dependency into `src/icons/generated/` (committed; codegen in `scripts/gen-icons.mjs` driven by the `scripts/icons.json` manifest). Phosphor glyphs are **filled vector shapes** (not strokes, viewBox 256), so they anti-alias cleanly at small chrome sizes — the reason we moved off lucide, whose thin strokes softened at 14–16px. The codegen's **default weight is `bold`** (a single caret reads thinner than a double at the lighter weight; bold evens the chrome out); a manifest entry overrides per icon via `@weight` (`circle@fill`). The set spans library chrome glyphs **plus common editor-app icons** (copy, lock, eye, trash, …) so consumers — and the playground — reach for icons without a separate package. Each is a `Component<{ size?; class? }>` carrying a `stage-icon stage-icon-` class, so it sizes off `--stage-icon-size` (the `.stage-icon` base rule) and is addressable in CSS/tests. Library chrome renders these (collapse/disclosure carets, close `X`, `Plus`, the modified-tab dot as a fill-weight `CircleFill`, scroll/step carets) — no hand-rolled SVGs or unicode glyph affordances, and no house icons (the `fill` weight gives a real solid disc). The artwork is vendored under Phosphor's MIT licence; the notice is retained in `NOTICE` (shipped in the package). Icon-*button* components take an `IconComponent` prop so consumers can pass their own icons (any `Component<{ size? }>`) — never hard-`import` an icon set or a styling helper (incl. `clsx`) in library source. **Scope boundary (the line for "add this icon?"):** a glyph is bundled only when a library *component* renders it, plus the small band of generic editor-app icons already shipped. **Domain icons stay consumer-supplied** — media-transport (play/pause/skip), app-specific marks, rotation-axis arrows, etc. There's no principled stopping point once we bundle for one consumer's feature, and the `IconComponent` / thunk contract already lets a consumer pass any icon at zero maintenance cost; a curated Phosphor subset we hand-maintain for every use case would be strictly worse than the consumer importing `lucide-solid` directly. A domain glyph earns a slot only if/when a library component starts rendering it. (Surfaced by the dsts-viewer media-transport request, 2026-06-18 — declined for bundling on this basis.) 5. **Browser-only.** `ResizeObserver` + `getBoundingClientRect` are not SSR-safe. Documented in `README.md`. Do not add SSR shims or `typeof window` dances beyond the one `typeof ResizeObserver !== 'undefined'` guard. 6. **The chrome is centre-agnostic; the *world* is a canvas.** `EditorPanels` takes an arbitrary `center: JSX.Element` — the chrome could host any middle (DOM page-builder preview, code buffer, node graph). Do **not** narrow `EditorPanels` to canvases. But `CanvasStage` and the focus model that treats "the world" as a single non-tabbable boundary *are* canvas-specific: the multi-layer world/HUD/pointer z-stack + viewport ResizeObserver describe a viewport onto a continuous spatial scene. The tell: the moment a centre is *editable DOM* it stops being "a world" (an opaque region you never tab into) and becomes the primary tabbable content — which inverts the focus model. So wherever the focus/Tab design needs an answer for the centre, assume a canvas; everywhere structural, keep the centre open. ### Scope discipline A feature that lives in only one consumer stays consumer-side until a **second** consumer asks for it (the field framework cleared that bar — both apps had grown it). Everything else is consumer-side or deferred; see `docs/feature-ideas.md` for the deferred list. Resist scope creep. --- ## 2. Theming & class conventions - **`.stage-root` is the token scope.** All `stage-*` custom properties are declared on `:where(.stage-root)`. Anything that must read those tokens has to live inside a `.stage-root` subtree. - **Portalled content mounts into the nearest `.stage-root`**, not `document.body` — otherwise tokens don't resolve and consumer overrides don't cascade. It also escapes panel `overflow` clipping and sits above chrome via `--stage-z-popover`. The modal extends this: lookups are `.closest('.stage-root, .stage-modal-container')` so fields opened *inside* a modal portal into the container (see §7). - **Resolve the portal mount at *open* time, not once at mount.** `SelectControl`, `ColorControl`, and `SearchControl` each `.closest('.stage-root, …')` off their trigger/input to find the mount target. Caching that once in `onMount` is a trap: on mobile WebKit `onMount` can fire before the trigger is spliced under `.stage-root` (first seen when `Panel` still detached collapsed bodies), so `.closest` returns `null` and it falls back to `document.body`. Cached, the dropdown then portals outside the token scope forever (transparent bg, UA-black text, no overflow scroll) until a remount. The fix: keep the `onMount` resolve to prime the common case, but **re-resolve on every open** (`onOpenChange` for Kobalte Select/Popover; the `panelOpen` effect for the hand-rolled Search panel) — the trigger is always connected and laid out by then. Desktop hid this (insertion beats `onMount` there). Guarded by a mount-outside-then-move-under-root test. - **The surface ramp derives from one root, parent-by-parent.** `--stage-color-bg` is the single root background the whole grayscale ground keys off. Each surface/border token mixes off its *logical parent*, not flat off the root: panel lifts off bg, popover = panel, header darkens off panel, header-hover lifts off header, border lifts off panel, border-strong lifts off border, border-hover lifts off strong, the input well darkens off panel, control-hover (the shared hover fill) lifts off the well, readonly lifts off the well. Same `color-mix` pattern as `--stage-color-accent-readonly` and the tooltip skins; each token stays individually overridable, so retinting one parent flows to its children while pinning any single token stops the chain there. The accent family (`--stage-color-accent` + `-readonly`/`-on-accent`/`-text-on-accent`/ `-icon-on-accent`) is the separate "primary" axis and does **not** derive from bg. - **Surface colours are opaque — no alpha.** `--stage-color-modal-backdrop` is the one intentionally translucent token (a scrim must let the dimmed page show through). Every other background is a solid, legible colour; don't reintroduce alpha into the ramp. - **Foreground text derives from one base, like the surfaces.** Three tiers (`--stage-color-text` / `-text-label` / `-text-muted`); label + muted mix off `--stage-color-text` (darkened toward black), so retinting the base text colour carries the whole type-colour scheme. - **Content on an accent fill has its own colours, defaulting to the text colour.** `--stage-color-text-on-accent` (the "on"/active button label, the highlighted dropdown option, the active search row) and `--stage-color-icon-on-accent` (icons in those spots, defaulting to `-text-on-accent`) both **default to `--stage-color-text`** — so the default mid-dark accent is unchanged, but a theme with a **bright accent + light text** overrides them to a dark colour without touching every rule (the legibility gap that surfaced in a bright-accent consumer). Distinct from `--stage-color-on-accent`, which is *not* part of the text group: it's the bright fill for the **decorative** switch thumb / checkbox tick (a knob/check that reads best at full contrast regardless of theme), pinned to contrast the accent, not the text. - **Icons carry a colour token too.** `--stage-color-icon` defaults to `currentColor` so a bundled `.stage-icon` matches its surrounding text and any context that sets its own colour (the accent info bubble, the select indicator) still wins — `.stage-icon`'s rule loads in `base.css`, before every consumer of it. Set the token to recolour all icons at once; `-icon-on-accent` handles the accent-fill case. No glyph-colour hover shift anywhere by design — section headers are flat (their former hover-brighten was dropped when the chevron became the collapse control and the rest a drag grip), so don't add a `--stage-color-text-hover` nothing consumes. (`--stage-color-section-header-hover-bg` survives only as the close button's press-flash, not a header hover.) - **`--stage-colorpicker-*` is dimensions, not colour.** The ColorField popover's width/area/track/thumb sizes use the `colorpicker` stem (no hyphen after `color`) deliberately, so a `--stage-color-*` grep for themeable colours skips them. Don't reintroduce a `--stage-color-picker-*` form. - **Corner rounding is one derived knob.** `--stage-border-radius` (default 2px) is the base control radius (inputs, the select trigger, accents — badges, checkbox, slider track/fill, colorpicker area/track — and focus outlines); `--stage-border-radius-lg` (`calc(× 2)` → 4px) covers the button family, the tooltip, and the select/search dropdowns; `--stage-border-radius-modal` (`calc(× 4)` → 8px) the modal. Set `--stage-border-radius: 0` for a fully-square aesthetic, or override `-lg`/`-modal` directly to decouple. Pills/circles (slider/switch/colorpicker thumbs, the switch track) are intentional *shapes* (`50%` / `9999px`), not corner-rounding, and don't derive. Flush-joined controls (ColorField hex+swatch, SearchControl input+stepper) square their inner seam and round only the unit's outer corners so the radius reads as one pill. - **Class taxonomy:** `.stage-field-*` is the shared row/layout vocabulary; `.stage-{type}-*` is one control's own internals; a trailing segment is a state/variant modifier (`-active`, `-on`, `-center`). - **Z-index tokens:** `--stage-z-popover` (1000) < `--stage-z-modal` (1050) < tooltip (1100). The modal backdrop's `--stage-z-modal` opens a stacking context lifting the whole modal above page-level popovers while keeping a modal field's tooltip on top. - **Motion derives from one base, like the surfaces.** `--stage-motion-duration` (0.18s) + `--stage-motion-ease` (ease) are the base knobs; each animated interaction has its own `--stage-*-motion` token — a ` ` pair fed straight into `transition` — that derives from the base (`--stage-panel-motion`, `--stage-section-motion`, `--stage-toast-motion`). A consumer retunes all motion by overriding the base, or one interaction by pinning its own token; setting any duration to `0s` disables that motion. Animations are **on by default**; `@media (prefers-reduced-motion: reduce)` zeroes `--stage-motion-duration` (in tokens.css), collapsing every derived transition to instant. The tooltip predates this and keeps its own `--stage-transition-fade` + reduced-motion block (§7) — don't fold it in. The three animated interactions: **Panel** collapse transitions `grid-template-columns` on `.stage-panels` (the strip↔expanded column track slides; only columns animate, rows snap) — the panel box fills its track (`width:100%`, `overflow:hidden`) while `.stage-panel-inner` holds the true width pinned to the pane's outer edge, so the body slides in and out clipped rather than reflowing. **Section** collapse uses the `grid-template-rows: 1fr↔0fr` height trick on `.stage-section-collapse` (the `.stage-section-body` grid item carries `overflow:hidden`/`min-height:0`, padding lives one level in on `.stage-section-body-content` so the row reaches a true zero); the body is also `inert` when closed — that, not `display:none`, is now what drops collapsed content from the tab order + a11y tree (compatible with the height animation, and `focusField` clears it on expand). **Toast** slides+fades via opacity/transform between a resting state and an edge-offset `.stage-toast-enter`/`.stage-toast-leaving` state (offset direction keys off the host placement); the exit is two-phase (§7). --- ## 3. Sizing — the type-scale tier system The library **owns the type scale**: row height is a deliberate, font-size-coupled primitive, not the emergent "whichever control is tallest" it once was. ### The two coupled tokens - `--stage-font-size` — base field font-size. The library sets its own; it no longer `font: inherit`s size from the consumer. - `--stage-control-height` — the row / control-height every field row and tall control snaps to. They are **coupled** (a 1px hairline border doesn't scale with `em`), so they ship as hand-tested **discrete tiers**, not a `height = font × k` formula: | Tier | font / height | Selector | |---|---|---| | `small` | 11px / 16px | `.stage-size-small` | | `normal` (default) | 13px / 20px | unclassed `:where(.stage-root)` | | `large` (= touch) | 16px / 24px | `.stage-size-large` | Preset classes are plain-specificity (beat the `:where()` 0-specificity defaults without `!important`); raw tokens stay overridable for retuning. **Typography split:** font-*family* is 100% consumer; font-*size* is a library-owned knob (the tier) because layout is downstream of it — an off-tier font-size forfeits the row-height guarantee. ### How each part snaps to the tier - **Compact + bar rows:** `min-height: var(--stage-control-height)` + `align-items: center` — short controls (boolean / checkbox / slider / icon) centre in the row rather than shrinking it. - **Tall controls** (`.stage-input` text/number/colour-hex + `.stage-select-trigger`): pin `height: var(--stage-control-height)`. - **Short controls scale via tokens derived from control-height** (matching today's `normal` values): `--stage-checkbox-size` + `--stage-slider-thumb-size` use a constant `control-height − 6px` inset (14px at `normal`; an even inset off the even control-height so they integer-centre in the row); the switch derives width/thumb/travel from `--stage-switch-height` (`control-height − 5px`); the slider's rail thickness is `control-height / 3.5`, its label allowance is in `ch` (tracks font-size) and its thumb-clearance gap is thumb-derived. - **Exception — booleans don't shrink at `small`.** The switch / checkbox value is a single glyph, so shrinking it past `normal` costs legibility for no density gain. `.stage-size-small` pins `--stage-checkbox-size` / `--stage-switch-height` back to their `normal`-tier px (so the switch width + thumb re-derive); the label still shrinks off `--stage-font-size`, so a small boolean row is a smaller label beside a normal-sized control. `normal` / `large` are unchanged. - **The whole button family** joins the grid: shared `.stage-button` base is `box-sizing: border-box`, each skin sets `height: var(--stage-control-height)` with vertical padding 0 (horizontal kept). The base owns the button font — `font: inherit` (family) + `font-size: var(--stage-font-size)` (tier) — so button text *and* a text button's content-driven width scale with the tier (no width set on text buttons; `fill` / `width: 100%` opts into stretch). - **Icon buttons are square at control-height** (`width = height`; the old `sm`/`md` width split is gone — classes kept for API compat, now equivalent). The glyph is sized by `--stage-icon-size` = `control-height − 4px` → 12 / 16 / 20px at small / normal / large. The control-heights are all **even** (16/20/24), so an even-inset icon always integer-centres in the square button (content box `ch − 2px` border, icon `ch − 4px` → exactly 1px each side, no sub-pixel lean — the reason the tiers are even). Applied via `.stage-button svg` (CSS overrides the icon's own attrs). - **Section header is one control-height** (`min-height`, horizontal-only padding) so it lines up with its field rows. Styled as *a field label rendered bold* — `--stage-color-text-muted`, `--stage-font-size` (tier), `font-weight: 700`. Header height + font read the *inherited* (panel) tier, so a section re-tiering its own body never resizes its header. - **One bar height derives from control-height:** `--stage-bar-height: calc(control-height + 8px)` is the single height EVERY bar snaps to — toolbars, the title bar, the tab menu, sub-bars. It's **border-inclusive**: each bar is `box-sizing: border-box` so its 1px separator is absorbed (no off-by-one between a `TitleBar` and a toolbar-stack row). The bar **component** carries the height now, not the slot: `.stage-toolbar` and `.stage-title-bar` each set `min-height: var(--stage-bar-height)`. The full-width `.stage-top-bar`/`.stage-bottom-bar` slots are **transparent** cells that only stretch their child to fill width (see TitleBar below) — they no longer enforce height or paint a background. A custom bar host (e.g. a TabMenu wrapper that isn't a Toolbar) opts in with `min-height: var(--stage-bar-height)` + `box-sizing: border-box`. (Replaced the old `--stage-toolbar-height`; the unused `--stage-top-bar-height` / `-bottom-bar-height` / `-sub-pane-height` fixed tokens were removed.) - **Expanded fields** ≈ 2 control-heights + gap (the `.stage-field-name-row` pins one, the value control below pins another). - **Wrapping value controls tile to whole control-heights:** `.stage-icon-row` (expanded IconButton/IconToggle) wraps with `row-gap: 0`, so K buttons over M lines occupy exactly M × control-height (horizontal `column-gap` unchanged). - **TextareaField is exempt** — multi-row by design, sized by its own `--stage-textarea-{min,max}-height`. ### The cascade & the `size` prop The three tier tokens inherit, so setting a tier (a `.stage-size-*` class or the raw token) on any element resizes everything inside it; a nested element can re-tier to override its ancestor. This is the whole cascade — stage → panel → section → field, plus bars — for free. `size?: ControlSize` (`'small' | 'normal' | 'large'`) exposes it at three levels: - `EditorPanels.size` (whole stage, the `.stage-panels` root); `leftSize` / `rightSize` forward into the internal `Panel`s (consumer composes panels via `leftPanel`/`rightPanel`, not a `Panel` directly). - `Panel.size` (header + sections + fields). - `Section.size` — **applied to `.stage-section-body`, not the section root** — so it resizes the section's *fields* while the header keeps the inherited *panel* size (an info-dense section can go `small` without its header falling out of line). This body-scoping is why no structural change was needed. Helper `layout/size.ts` `sizeClassList(size)` returns a 3-key classList fragment (all keys present so Solid's diff swaps tiers reactively); spread into a `classList`. `.stage-size-normal` exists as a reset because the default is *unclassed*, so a subtree under a small/large ancestor needs an explicit class to return to normal. ### The derived-token cascade gotcha The control-derived tokens (switch/checkbox/slider/icon) are declared on a **selector list** (`:where(.stage-root), .stage-size-{small,normal,large}`, mirrored in the touch media), **not just the root**. A custom property's `var()` is substituted at the element where it's *declared*, then the resolved value inherits. Declaring `--stage-switch-height: calc(var(--stage-control-height) − 5px)` only on the root would freeze the inner control-height at the root's value, so a subtree `size` override would resize the row + direct-use controls (text/number/select height, label font — these read the tokens directly at the use-site) but **not** the derived-token controls (switch/checkbox/slider/icon) — the label would shrink while the toggle stayed put. Re-declaring per tier re-resolves each at its own control-height. (So a *raw* `--stage-control-height` override on a non-`.stage-size-*` element only resizes the direct-use controls; use a `.stage-size-*` class / `size` prop to move everything.) ### Root font size `.stage-root` and the tier classes set `font-size: var(--stage-font-size)`, so custom markup reads at the tier's size rather than the page's. The library's rows set it themselves anyway. The playground must not set a body font size, because that hides any gap here. ### Touch-device input bump (iOS zoom) iOS Safari auto-zooms (and won't cleanly zoom back) on focus of a text input whose computed font-size is under 16px — it reads the *real* computed size, so there's no faking it smaller. `fields.css @media (pointer: coarse)` bumps just `.stage-input`'s **font-size** to `--stage-font-size-touch` (16px) so focusing one never zooms the viewport. Deliberately **narrow** — three earlier-rejected over-corrections inform the shape: - **Font only, not the whole tier.** Only the focusable text inputs zoom (not buttons / select trigger / slider / checkbox), so only they need the bump. The row, label, bar height, icons, and panel width stay dense. - **No row growth.** A bare 16px font would clip in the 20px control-height box (content = 20 − 2px border − 4px padding = 14px). Single-line inputs also pin `line-height: 1` and `padding-block: 0`, so 16px seats inside the *unchanged* row (18px content) — no reflow, no clip. (Textarea is excluded from that rule: it's multi-row and grows.) - **`pointer: coarse`, not `max-width`.** The zoom is a *touch-device* problem, not a narrow-window one — a desktop window dragged narrow shouldn't fatten its inputs. (An earlier `max-width: 640px` version also wrongly set `--stage-bar-height` on the field *row*, an ancestor-cascade dead end — the bar never saw it.) The **`small` tier opts out**: a 16px line can't fit a 16px-tall bordered row, so `.stage-size-small .stage-input` reverts to its dense size and *accepts* the zoom — a conscious tradeoff for a UI that chooses small inputs on touch (the revert rules sit after the bumps and win on source order at equal specificity). Per control, `touchSizing={false}` adds `.stage-touch-sizing-off`, which the bump selectors exclude (`:not(.stage-touch-sizing-off)`); the shared `TouchSizingProps` + `touchSizingClassList` (`controls/touchSizing.ts`) thread it through the five input-bearing controls (text / number / textarea / color / search) and wrappers. ### Narrow stage: one side panel at a time `EditorPanels` closes one panel rather than letting two open panels clip off a narrow stage (a phone). On by default (`exclusiveWhenNarrow`), since a clipped panel is never wanted; codified from dsts-viewer, which built the same rule by hand. - **Fit test:** left expanded width + right expanded width + `minCenterWidth` (320) ≤ stage width, the same in either `PanelMode`. Widths are measured off each panel's `.stage-panel-inner`, which keeps its expanded width while collapsed, so tier tokens and dragged widths count without a px number in props. An unmeasured stage (0 width, no `ResizeObserver`) counts as roomy, so headless tests see no change. - **Opening closes the other side first, inside one `batch`.** Solid runs effects on each write outside a batch, so opening first would let the crowding effect see both open and close the right panel, possibly the one just opened. - **Both open anyway → the right one closes.** This is re-checked on mount, on a `ResizeObserver` over the root and both inners, and when either collapse state changes. - **Exclusivity only, no auto-overlay.** dsts-viewer also switches panels to overlay when cramped; the library leaves the mode to the consumer. ### Text assistance off; Enter is "done" on single-line inputs - **No spellcheck / autocorrect / autocapitalize / autofill on any text input.** Editor values are identifiers, not prose. Number gets these from Kobalte's defaults (plus a numeric keyboard, where capitalization is moot); text / textarea / color / search set them by hand. Search also needs it because its input text is transparent at rest, but the spellcheck underline still paints through. - **Single-line inputs label the Enter key** (`enterkeyhint`): `done` on text / number / color, `go` on search (Enter picks the highlighted result). Without a hint, the on-screen key is a grey "return", which suggests a newline. Textarea is the one place Enter is a newline. - **A touch-started session releases focus on Enter** (and, for search, on a pick by tap). Desktop keeps focus so the Tab flow and search's committed rest survive, but a focused input keeps the on-screen keyboard open. One shared helper, `createTouchKeyboard()` (`controls/touchKeyboard.ts`), owns the rule. Arming is per session, not per device: a `touch`/`pen` pointerdown arms it and blur disarms it, so a keyboard tab-in on a touch device (an iPad with a keyboard) still keeps focus. Check `=== 'touch' || 'pen'`, never `!== 'mouse'`: synthetic and some real pointer events carry an empty `pointerType`. Enter during IME composition is ignored. Color keeps its own Enter handler, which blurs on every device because Enter also closes its picker. --- ## 4. Focus & keyboard model - **Focus indicator is white, not the accent** (`--stage-color-focus`, default `#ffffff`, consumer-overridable). A pink (accent) ring blends into accent-filled controls, so focus is intentionally accent-independent. - **The white focus border is the TOP of an explicit border-state hierarchy, enforced with CSS cascade layers — for value controls AND buttons.** A control's border shows several competing colours — resting, a hover lift, a checked/active accent, a readonly dim, an error/warn validation flag, and the white focus border. Rather than juggle selector specificity (fragile, and it caused the old hover-erases-focus bug), the precedence is declared once in `fields.css` as an ordered layer list — a later layer wins over an earlier one *regardless of specificity*: ``` @layer stage-cb-rest, stage-cb-hover, stage-cb-selected, stage-cb-readonly, stage-cb-validation, stage-cb-button, stage-cb-focus; ``` So for value controls, **focus ▸ validation ▸ readonly ▸ selected ▸ hover ▸ rest**. The white active-control border overrides every other border state while focused; because each state's selector (`:focus`, `[data-stage-level]`, `[data-checked]`, …) only matches while that state holds, the cascade **falls back to the next-highest matching layer the instant a state clears** — tabbing out drops `:focus`, so a still-invalid field restores its error border with zero extra code. `readonly` sits above `selected` so a checked+readonly toggle dims instead of staying bright; a disabled control isn't focusable/validated, so its position below those never bites. - **Value controls use the fine-grained tiers; buttons fold in via one `stage-cb-button` layer.** A value control's state set is small and semantic, so it tiers cleanly. A button's border cascade is skin-driven and mature (subtle overrides active, tone variants, ghost overrides the action hover, on-toggle overrides toggle hover), already correct by specificity + source order — so it's kept **verbatim in one bucket** between `validation` and `focus`. Folding it into a layer changes exactly one thing: the `focus` layer now overrides every button state too, so a focused button shows the white border regardless of hover/active and restores its skin border on blur — the same rule every control follows. A borderless button's transparent rest border simply recolours white; the mouse-only `untabbable` buttons are excluded (`:not(.stage-button-untabbable)`) and show no cue. - **The layers own only `border-color`.** Each control sets its border *width/style* in its own (unlayered) rule and its resting *colour* in a layer — never a `border:` shorthand or an unlayered `border-color`, either of which would beat every layer (unlayered always wins over layered) and break the hierarchy. Hover for the value controls is fill-only (never touches the border); only the select trigger lifts its border on hover, via the `hover` layer. - **The colour field folds in as one box.** It reads as a single input (a hex box + a swatch tile), so its hex↔swatch **seam is dropped** (the hex loses its right border, the swatch its left) and the hex's three borders + the swatch's three form one continuous perimeter. Both segments' colours join the layers (the hex is a `.stage-input`, already there; the swatch's rest/readonly/validation and the coordinated `:has()` focus highlight are added to the matching layers), so the whole unit recolours together — border-strong at rest, error/warn when the hex is invalid, white when the hex is focused or the picker is open. - **Joined controls (input + attached action buttons) coordinate the seam.** An input-action button shares each seam with the previous element (`border-left: none`, so the seam *is* the previous element's right border). On focus it whites its own three drawn borders, and the seam it borrows is whited on the previous element by two coordinated `focus`-layer rules: the button↔button seam via a direct-sibling `.stage-input-action:has(+ .stage-input-action:focus)`, and the input↔first-action seam via a wrapper scope (`:is(.stage-input-group, .stage-search):has(.stage-input-action:first-child:focus) .stage-input-grouped` — the search input nests inside `.stage-search-field`, so scope, not a sibling combinator). The input's own focus already whites its right seam edge. So a focused segment shows a complete white outline with **real border recolour** — no box-shadow ring anywhere. (The same pattern would extend to any future joined group: a focused segment claims the seam it borrows.) - **Not in the border system at all:** the **glyph micro-affordances** (info icon, scroll hint, search-clear) and **tabs** keep their own lighter-weight cue (an offset outline / accent bar), by design — they're not bordered controls. - **Every interactive spot resolves to exactly one affordance: it either lights up on hover (preferred) OR takes the hand cursor (`pointer`) — never both, never neither.** The rule is keyed off *what a click does*: if clicking there produces a stage change, the spot must advertise it one way. Lighting up is the default (a desktop editor's pointer stays a plain arrow); the hand cursor is the fallback for a spot where a hover lift reads unclearly. - **Lights up (arrow cursor + `:hover` brighten):** the button family, popover triggers (select / colour-swatch), menu option rows, and tabs — each a `:hover` lift off the **one shared fill** `--stage-color-control-hover-bg` (plus `--stage-color-border-hover` on bordered ones), so the "lit" state is uniform and reads clearly against the panel. Also the **boolean/range value controls**: switch + checkbox brighten on hover (off → the neutral hover tone; on → the accent lifted toward white via `--stage-color-accent-hover`) **including while checked**, because a click there turns them OFF — a real change; and the slider lights its **thumb** whenever the bar is hovered (the grabbable knob is the affordance), to a tone a step below white. These three hover-brighten the **fill only, never the border**, on purpose: the border carries the control's *state* (a checked/accent fill, an error/warn validation colour, the focus border), so leaving it to hover would fight those — the fill lights and whichever border the state hierarchy picks stays. That hierarchy (the cascade layers above) is exactly what keeps hover and the focus/validation borders from clobbering each other. The slider fill stays below pure white so that white focus border reads against the lit knob. An **active toggle that untoggles** — `IconToggle` / `IconToggleField`, both of which turn a pressed button OFF on click — likewise stays lit while active (marked with the `stage-icon-button-untoggles` class → an active-`:hover` brightens the fill to `--stage-color-accent-hover`, per tone). - **Hand cursor (`pointer`, no hover lift):** the **collapse affordances** — the static section-header toggle, the section chevron button, and the panel collapse bars (header / edge / collapsed-strip) — and a **module (section) header's action icons** (the subtle glyph-brighten alone read as *no* affordance, so these take the pointer instead and drop the hover styling; their active-fill + press-flash still show as state). A pointer is the strong native convention for "click to collapse", so these stay flat + pointered. The **colour swatch** joins this group: it opens the picker popover on click and doesn't hover-brighten (it lights only while the picker is open), so `cursor: pointer` is what signals "click to open". The **info icon** is the one control on `cursor: help` — it isn't a stage change at all, it reveals a tooltip, so the help cursor is its honest hint. - **Neither (no change on hover):** a **radio-style active option** — a `ButtonGroupControl` option matching `value()` — because re-clicking it does nothing; only its *inactive* siblings light up. This is the one case that reads as "already selected, no-op", so it deliberately advertises nothing. (Contrast the untoggling toggles above, which DO change on re-click.) - Menu rows keep their accent `[data-highlighted]`/`aria-selected` bar; the glyph-only micro-affordances (scroll hint, search-clear ×, info icon) stay on their lighter-weight brighten by design. Disabled/`data-disabled` states resolve to `default`. Grab/resize/move affordances keep their `grab` / `col-resize` / `ew-resize` cursors — including the **section drag-handle title**, now a pure `grab` move handle (it no longer doubles as a collapse target; the chevron is the sole collapse control). - **`:focus`, not `:focus-visible`, on every value control** — the cue shows on click *and* tab, deliberately. In dense editor chrome a persistent "you are here" marker makes the next Tab predictable and doubles as "last thing I touched". (macOS Safari/Firefox don't focus `