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.
-
Pre-composed children, not registries.
EditorPanelstakesleftPanel?: JSX.Element, notleftSections: SectionId[];Sectiontakeschildren, not a field schema; every bar is a pre-composedToolbar(or plain JSX), not a bar config object. Naming the collapsible containerSectiondoes not imply a section registry / id-keyed lookup — that mapping is app-specific. Composition stays child-based throughout. -
Controlled + uncontrolled, mirrored everywhere. Anything with state (collapse, active tab) accepts optional
open?: () => boolean+onToggle?with aninternalOpen()signal fallback. Canonical accessor:isOpen = () => props.open?.() ?? internalOpen(). In uncontrolled mode the internal signal seeds from / writes back to localStorage where a primitive persists (Sectioncollapse, panel collapse); controlled mode leaves persistence to the parent (never caches). -
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-styleescape hatch; the style hook is aclassprop (e.g.Label). -
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/coredev dependency intosrc/icons/generated/(committed; codegen inscripts/gen-icons.mjsdriven by thescripts/icons.jsonmanifest). 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 isbold(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 aComponent<{ size?; class? }>carrying astage-icon stage-icon-<name>class, so it sizes off--stage-icon-size(the.stage-iconbase rule) and is addressable in CSS/tests. Library chrome renders these (collapse/disclosure carets, closeX,Plus, the modified-tab dot as a fill-weightCircleFill, scroll/step carets) — no hand-rolled SVGs or unicode glyph affordances, and no house icons (thefillweight gives a real solid disc). The artwork is vendored under Phosphor's MIT licence; the notice is retained inNOTICE(shipped in the package). Icon-button components take anIconComponentprop so consumers can pass their own icons (anyComponent<{ size? }>) — never hard-importan 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 theIconComponent/ 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 importinglucide-soliddirectly. 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.) -
Browser-only.
ResizeObserver+getBoundingClientRectare not SSR-safe. Documented inREADME.md. Do not add SSR shims ortypeof windowdances beyond the onetypeof ResizeObserver !== 'undefined'guard. -
The chrome is centre-agnostic; the world is a canvas.
EditorPanelstakes an arbitrarycenter: JSX.Element— the chrome could host any middle (DOM page-builder preview, code buffer, node graph). Do not narrowEditorPanelsto canvases. ButCanvasStageand 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-rootis the token scope. Allstage-*custom properties are declared on:where(.stage-root). Anything that must read those tokens has to live inside a.stage-rootsubtree.- Portalled content mounts into the nearest
.stage-root, notdocument.body— otherwise tokens don't resolve and consumer overrides don't cascade. It also escapes paneloverflowclipping 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, andSearchControleach.closest('.stage-root, …')off their trigger/input to find the mount target. Caching that once inonMountis a trap: on mobile WebKitonMountcan fire before the trigger is spliced under.stage-root(first seen whenPanelstill detached collapsed bodies), so.closestreturnsnulland it falls back todocument.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 theonMountresolve to prime the common case, but re-resolve on every open (onOpenChangefor Kobalte Select/Popover; thepanelOpeneffect for the hand-rolled Search panel) — the trigger is always connected and laid out by then. Desktop hid this (insertion beatsonMountthere). Guarded by a mount-outside-then-move-under-root test. - The surface ramp derives from one root, parent-by-parent.
--stage-color-bgis 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. Samecolor-mixpattern as--stage-color-accent-readonlyand 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-backdropis 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-icondefaults tocurrentColorso a bundled.stage-iconmatches 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 inbase.css, before every consumer of it. Set the token to recolour all icons at once;-icon-on-accenthandles 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-hovernothing consumes. (--stage-color-section-header-hover-bgsurvives 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 thecolorpickerstem (no hyphen aftercolor) 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: 0for a fully-square aesthetic, or override-lg/-modaldirectly 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-modalopens 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-*-motiontoken — a<duration> <easing>pair fed straight intotransition— 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 to0sdisables 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 transitionsgrid-template-columnson.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-innerholds the true width pinned to the pane's outer edge, so the body slides in and out clipped rather than reflowing. Section collapse uses thegrid-template-rows: 1fr↔0frheight trick on.stage-section-collapse(the.stage-section-bodygrid item carriesoverflow:hidden/min-height:0, padding lives one level in on.stage-section-body-contentso the row reaches a true zero); the body is alsoinertwhen closed — that, notdisplay:none, is now what drops collapsed content from the tab order + a11y tree (compatible with the height animation, andfocusFieldclears it on expand). Toast slides+fades via opacity/transform between a resting state and an edge-offset.stage-toast-enter/.stage-toast-leavingstate (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 longerfont: inherits 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-inputtext/number/colour-hex +.stage-select-trigger): pinheight: var(--stage-control-height). - Short controls scale via tokens derived from control-height (matching
today's
normalvalues):--stage-checkbox-size+--stage-slider-thumb-sizeuse a constantcontrol-height − 6pxinset (14px atnormal; 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 iscontrol-height / 3.5, its label allowance is inch(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 pastnormalcosts legibility for no density gain..stage-size-smallpins--stage-checkbox-size/--stage-switch-heightback to theirnormal-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/largeare unchanged.
- Exception — booleans don't shrink at
- The whole button family joins the grid: shared
.stage-buttonbase isbox-sizing: border-box, each skin setsheight: 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 oldsm/mdwidth 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 boxch − 2pxborder, iconch − 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 isbox-sizing: border-boxso its 1px separator is absorbed (no off-by-one between aTitleBarand a toolbar-stack row). The bar component carries the height now, not the slot:.stage-toolbarand.stage-title-bareach setmin-height: var(--stage-bar-height). The full-width.stage-top-bar/.stage-bottom-barslots 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 withmin-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-heightfixed tokens were removed.) - Expanded fields ≈ 2 control-heights + gap (the
.stage-field-name-rowpins one, the value control below pins another). - Wrapping value controls tile to whole control-heights:
.stage-icon-row(expanded IconButton/IconToggle) wraps withrow-gap: 0, so K buttons over M lines occupy exactly M × control-height (horizontalcolumn-gapunchanged). - 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-panelsroot);leftSize/rightSizeforward into the internalPanels (consumer composes panels vialeftPanel/rightPanel, not aPaneldirectly).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 gosmallwithout 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: 1andpadding-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, notmax-width. The zoom is a touch-device problem, not a narrow-window one — a desktop window dragged narrow shouldn't fatten its inputs. (An earliermax-width: 640pxversion also wrongly set--stage-bar-heighton 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 eitherPanelMode. 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, noResizeObserver) 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
ResizeObserverover 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):doneon text / number / color,goon 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: atouch/penpointerdown 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 emptypointerType. 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.cssas 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.readonlysits aboveselectedso 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-buttonlayer. 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 betweenvalidationandfocus. Folding it into a layer changes exactly one thing: thefocuslayer 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-onlyuntabbablebuttons 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 aborder:shorthand or an unlayeredborder-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 thehoverlayer. - 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 coordinatedfocus-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.
- Value controls use the fine-grained tiers; buttons fold in via one
-
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 +
:hoverbrighten): the button family, popover triggers (select / colour-swatch), menu option rows, and tabs — each a:hoverlift off the one shared fill--stage-color-control-hover-bg(plus--stage-color-border-hoveron 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 thestage-icon-button-untogglesclass → an active-:hoverbrightens 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), socursor: pointeris what signals "click to open". The info icon is the one control oncursor: 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
ButtonGroupControloption matchingvalue()— 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-selectedbar; the glyph-only micro-affordances (scroll hint, search-clear ×, info icon) stay on their lighter-weight brighten by design. Disabled/data-disabledstates resolve todefault. Grab/resize/move affordances keep theirgrab/col-resize/ew-resizecursors — including the section drag-handle title, now a puregrabmove handle (it no longer doubles as a collapse target; the chevron is the sole collapse control).
- Lights up (arrow cursor +
-
: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<button>s on click, which would make the button ring keyboard-only there, soBaseButtonforcesfocus()on click; value fields focus on click natively everywhere.) -
Section header is the deliberate
:focus-visibleexception — a structural section toggle, not a value you tab between; a lingering full-width ring on every click would be noise (the chevron already signals open/closed). Borderless, so its cue is an inset outline (outline-offset: -1px— a real border would shift layout and fight the open/closed bottom-border divider). -
Space + Enter both activate every toggle/button-like control. Section headers + IconButton/ActionButton/ToggleButton are native
<button>s (free).BooleanFieldsits on a native checkbox (Space toggles, Enter doesn't), so it adds an Enter→toggleonKeyDown(no-op when readonly).
Stage-wide tab order #
Keyboard tab order is native, not a registry: a section header toggle is a
<button> and a section body only renders when open — actually mounted-but-inert
when collapsed — so DOM-order tabbing already gives toggle → its fields → next toggle; a collapsed (inert) section tabs straight to the next header;
reorder-proof (native reads live DOM). Header action icons are mouse-only
(tabindex=-1), so they're skipped.
The whole stage flows in one natural traversal because EditorPanels's DOM source
order is the target order: top bars → left panel → centre (canvas-aligned
sub-bars + the centre content, including any nested stage, recursively) →
right panel → bottom bars. Panels no longer trap Tab at their edges — focus flows
panel → centre → panel like any DOM. A nested stage nests in place: its full
cycle sits between the parent's left and right panels, so the parent's right panel
comes after the child's last stop. (DOM order equals tab order because the library
never uses a positive tabindex — only 0 / -1.)
The custom layer is small:
- Chrome is removed from the tab order (
tabindex=-1on the expanded chevron + both scroll-hint buttons). The collapsed-strip button stays tabbable so a keyboard user can still expand a closed panel. - The scrollable
.stage-panel-bodyis itselftabindex=-1: browsers that make scrollable regions keyboard-focusable (Chrome 127+, Firefox) would otherwise insert a phantom stop there, before the first section. Our fields are the stops. - Tabbing to a field below the fold must bring it into view. Native focus-scroll
is unreliable for nested scrollers, so an
onFocusInon the body callsscrollIntoView({block:'nearest'})(a no-op when already visible);scroll-padding-block: var(--stage-scroll-hint-height)keeps the landed field clear of the overlay hint bars. - An
onKeyDownon.stage-panel-scroll-contenthandles Escape only: it blurs the active element — a quick "jump out of this panel's fields" (the next Tab resumes outward from there). Tab itself is left fully native. Kobalte overlays (select/colour) portal outside the subtree, so their own Escape-to-close + focus management never reach the handler.
Optional wrap-around (wrapFocus): off by default — focus exits to the
browser/page at the ends. Set wrapFocus on EditorPanels to loop Tab from the
last stop back to the first (and Shift+Tab first→last). It's installed only on the
outermost stage (a stage whose root has no .stage-panels ancestor); a nested
stage ignores it so focus flows out into the parent's traversal — the loop belongs
to the whole stage, not a region. The handler computes the global tab stops
(stageTabbables — focusable, not disabled, not tabindex="-1" by attribute,
and not inside an [inert] subtree so collapsed-section fields are skipped) and
acts only at the global first/last; the nesting check is structural (once at mount),
while whether the loop fires is read reactively, so a bound wrapFocus toggles live.
The modal runs its own hard focus trap that always wraps within its container
(focusables(container)), independent of stage wrapFocus (see §7); its host
mounts as a sibling of .stage-panels, so its Tab events never reach the stage
wrap handler.
- No native
titleattribute anywhere — the library emits zero OS tooltips. The browser'stitlehover bubble is unstyleable, slow to appear, and inconsistent across platforms, so it's banned. Every control's accessible name is anaria-label(icon-only buttons via theirtitleprop, which maps toaria-label) or the visible label text.BaseButtonrenders onlyaria-label; the labelled skins (ActionButton/ToggleButton) have notitleprop at all — their visible label is the name. A styled, deliberate hover tooltip goes throughstageTooltip(the floating-UI singleton) instead — see §7.IconButton'stitledoes both jobs: an icon alone rarely says what a button does, so the same text is its name and its tooltip, shown on hover and keyboard focus. Its bubble passes the pointer through, so it never blocks the neighboring buttons.
5. Chrome primitives #
EditorPanels #
Outer CSS-grid shell, five rows: full-width top bar / full-width topToolbars
stack / panel band (1fr) / full-width bottomToolbars stack / full-width
bottom bar. Side panels occupy the band (grid-row: 3) only. topSub /
bottomSub are canvas-aligned toolbar stacks inside the centre cell (column
2) so their edges match the stage.
- A root
EditorPanelsisposition: fixed; inset: 0(owns the window).embeddedmakes itposition: relativeto fill a parent slot — the recursion point, sincecenterisJSX.Elementand already accepts anotherEditorPanels. - A root stage turns off page overscroll.
htmlandbodygetoverscroll-behavior: nonethrough:has()while a non-embedded stage is mounted, because a missed touch drag would otherwise pull-to-refresh an editor away. Scoped to root stages since an embedded stage may sit in a host page that scrolls normally, and zero-specificity so a consumer can override it. Tested on a real iPhone: suppresses refresh from dead space and from short and long panels, and pinch zoom still works, despite WebKit bug 275947 reporting otherwise.touch-actionon the shell was rejected: it only covered dead space, because a gesture starting inside a panel's scroller is outside its reach. Home-screen web apps haven't been tested, and reports suggest they may differ. - Every slot prop is resolved once via
children(), not read offpropsdirectly. A slot is needed in two places — the grid-track sizing (undefined ? '0px' : 'auto') and the render<Show>— and Solid getter-wraps an inline-JSX prop, so reading it twice re-evaluates the JSX and creates a new element each time. If the slot holds a shared DOM node (e.g.top={<TitleBar left={titleLeft} />}wheretitleLeftis a JSXconst), that node gets moved into the latest throwaway instance and the inserted bar renders blank — a silent footgun the consumer couldn't reasonably foresee (inline JSX in a slot is the obvious thing to write).children()memoizes the resolved nodes, so every read returns the same instance and the getter runs once — robust whether the consumer passes inline JSX or aconst. Applied to all slots (top,topToolbars,topSub,center,bottomSub,bottomToolbars,bottom,leftPanel,rightPanel). - Multi-stage composition (still a sketch): an outer stage with bars + no
panels whose
centerholds two embedded stages (e.g. left-panel-only primary + right-panel-only secondary), all sharing onecreateEditorStore. See §8 for the open questions. persistKeycaches each panel's collapse state (uncontrolled only) understage-panel:<persistKey>:left/:right, via the sharedreadFlag/writeFlagkernel instate/persist.ts. Seed precedence: cached →defaultLeftCollapsed→ expanded; a controlled panel never caches.persistKeyis the per-stage namespace — give each stage in a split a distinct one.- Forwards
leftSize/rightSizeandleftToggleVariant/rightToggleVariantinto the internal panels. - Expanded width is tier-derived:
--stage-expanded-width = control-height × 11.5(≈207/241.5/299px at small/normal/large), declared in the per-tier token block so it re-resolves per size class — alargestage gets wider panels. (--stage-modal-max-widthkeys off it, so modals scale too.) - Overlay panels (
leftPanelMode/rightPanelMode). The panel's track holds only its collapsed width, open or not, and its cell becomes an absolutely positioned grid item spanning every column in the band row, so it stays inside its own stage. It's the same element in both modes, which is what lets the mode switch live without a remount. The collapse animation moves to the cell'swidth, so it never touches the center. The cell is its own stacking context above the center's HUD, but a floating panel always draws above it:.stage-float-hostand.stage-panelsare separate stacking contexts, and lifting the stage over the host would also lift its opaque center over every floating panel. - Collapsed variants (
leftCollapsedVariant/rightCollapsedVariant). A full-height bar laid over the center permanently covered part of it, and no surveyed editor does that (seedocs/editor-panel-docking-patterns.md). So'strip'always has its own column, and it stays that wide while an overlay panel is open, because shrinking it would resize the center.'tab'and'none'have no column. A tab drags along the edge under the section layout'spanelsLockedflag, the same lock that freezes floating panels, andlayout.reset()restores it. It lives instage-panel:<persistKey>:<side>:tabrather than the layout's blob because the blob has no per-stage key. On release it re-anchors to the third of the band holding its center, so the position survives a window resize. The tab is a sibling of.stage-panel, which clips to its collapsed width of zero. layout.reset()reaches panels and static sections. They follow an internalresetCountsignal, each restoring its own default and clearing its own key. A signal alone would miss anything unmounted, such as a hidden split stage, which would restore its saved state on its next mount. So panels and sections record their keys withtrackSavedKey, stored understage-layout:<persistKey>:saved-keysto survive a reload, andresetclears that list first. The list itself stays: it's names, not choices. Only uncontrolled state is recorded, because controlled state belongs to the app.resizable(per-stage opt-in) turns on drag-to-resize, with the handle matching the collapse-toggle placement:- Header variant —
Panelrenders a wide transparent gutter on the inner edge and thickens that edge's divider to 3px as the affordance (no colour hint; thecol-resizecursor confirms it). Double-click the gutter resets to the authored default, or the tier token without one. - Edge variant — the full-height collapse button itself is the drag
handle (no separate gutter, no visual change). Click collapses, drag
resizes; a small movement threshold makes them mutually exclusive — a real
drag sets a flag that swallows the synthetic click pointer-capture fires on
release.
EditorPanelsowns the grid track and clamps to[140px, 40% of the stage](stage width read off the captured root). Width plumbing mirrors the collapse trio per side — uncontrolled +persistKeypersists the dragged px understage-panel:<persistKey>:left:width/:width(viareadNumber/writeNumber/removeStored), written on release only; passleftWidth/onLeftResize(+right*) to control it, and the parent owns persistence. Aundefinedinternal width means "use the token" (the default state when nodefaultLeftWidth/defaultRightWidthis set). The dragged px also overrides--stage-expanded-widthon the panel's cell so the fixed-width.stage-panel-innerfollows the track (not just the cell). During a drag the root carries.stage-panels-resizingto suppress text selection.
- Header variant —
Panel #
Collapsible side panel. Tab/focus behaviour is §4. toggleVariant
('header' default | 'edge', fixed per instance like side) moves the
collapse toggle: 'edge' drops the top header strip and puts a full-height
vertical toggle bar on the panel's inside edge (right edge for a left panel,
left for a right — .stage-panel-edge-button), reclaiming the header's vertical
space; collapsed, the strip toggle fills full panel height
(.stage-panel-strip-edge). The edge button is chrome (tabindex=-1). No grid
change — the edge bar sits inside the existing expanded-width track.
Overflow is tracked with an IntersectionObserver over top/bottom sentinels
(guarded for happy-dom), not a scroll handler — an onScroll that positions
elements is a scroll-linked effect Firefox warns about.
Collapse is animated: the strip↔expanded column track transitions (see the motion
bullet in §2). The expanded content lives in .stage-panel-inner (the flex column
for header, a row for edge), holding the panel's true width while
.stage-panel clips to the animating track so the body slides rather than
reflowing; the inner is pinned to the pane's outer (screen) edge.
Collapsed = in the document, not torn down. Same mental model as Section: one
root element and one always-rendered inner, with collapse only visual. What holds that
together:
- The inner is
inertandvisibility: hiddenwhen collapsed. Thevisibilitytransition lives on the closing side only: it keeps the body visible while the track closes, and on the opening side it would leave the body hidden at progress 0, sofocus()right after an expand would silently fail. - Portaled overlays live outside the inner, so inertness doesn't reach them and they
would stay open, pointing at a hidden trigger.
closeWhenInertcloses them from the DOM state rather than a collapse signal, so it coversSection,Paneland any consumer-appliedinertalike, with no context to thread throughEditorPanels' slots.stageTooltipwatches its anchor the same way. .stage-panel-strip-buttonis absolutely positioned over the inner, and its fill fades in with the collapse..stage-panel-stripisposition: relativeto anchor it, which is safe because the resize gutter (which must anchor to the cell) only exists while expanded..stage-panelusesoverflow: clip, nothidden. A hidden box is still a scroll container, so focusing a field while the panel opens scrolls it sideways for good..stage-panel-striphascontain: inline-size+min-width, or the laid-out inner would prop a standalone panel open in a content-sized parent.- Geometry must skip collapsed docks. A collapsed panel's dock keeps its full rect,
reaching out over the centre, so
hitTestDockskips[inert]zones. Any new rect-based hit testing needs the same check. focusFieldexpands collapsed panels as well as sections.- Effects in a collapsed body keep running, and its content width doesn't change, so a
ResizeObserverinside doesn't fire on collapse.
Section #
Collapsible container (header + chevron + body); the unit a consumer builds
editor UI from. Holds anything — naming it Section does not imply a registry
(see thesis §1).
- Persists collapse to localStorage out of the box (
stage-section:<persistKey ?? title>;persist={false}opts out). Seed precedence: cached →defaultOpen→ open (default expanded; "no cached value" reads as expanded). Controlled sections (open()supplied) never cache. - Collapsed = mounted but hidden, not torn down. The body is always rendered;
collapsing animates its height to zero (the
grid-template-rows: 1fr↔0frtrick on.stage-section-collapse, keyed off.stage-section-open) rather than unmounting (<Show>). Mental model: a section is always there, collapse just tucks it away — so a field keeps its state and stays deep-linkable (focusFieldcan expand + focus it; §6). The collapsed body is alsoinert— that drops it from the tab order + a11y tree (the roledisplay:noneused to play, swapped out so the height animation works; see the motion bullet in §2). Trade: collapsed content's effects stay live and it renders at load — fine for field panels; avoid parking something genuinely expensive in a section expecting unmount to free it. - Dismissable
onClosemode: header static (no chevron/toggle/hover), body always open, persistence skipped, a×button top-right callsonClose. This is the shape the global modal hosts; the prop lives onSectiongenerally. headerActions(SectionHeaderAction[]:{ icon, title*, onClick, active?, disabled?, tone? }) puts icon buttons on the title bar — each a borderless IconButton (borderis never exposed; the header skin is always borderless), with thesubtlevariant +tabbable={false}(secondary meta-actions: mouse-only, out of tab order, no focus ring; subtle skin = muted glyph, shape brightens on hover with no bg, press flashes faint bg, active stays bright-shape with no accent fill).titleis required (icon-only → the accessible name, rendered asaria-label). In a dismissable section they sit left of the×.- The header is a
<div>row with three label shapes + a.stage-section-header-actionscluster of sibling buttons on the right (so clicking an action never bubbles to the toggle — no nested<button>). The label shape is context-driven:- Default collapsible: one toggle
<button>(chevron + title,flex:1+align-self:stretch) — a click anywhere across the header collapses (the forgiving target for a section that can't be dragged). - Drag-handle (
dragHandleprop, set byDockZone): the chevron is its own.stage-section-chevron-button(the keyboard + AT collapse control —aria-expanded), and the title is wrapped in a.stage-section-drag-handlegrip (grabcursor,user-select:none,touch-action:none,aria-hiddensince the chevron button is already labelled by the title). The grip only moves; the chevron alone collapses. A press with no travel does nothing, so the title reads purely as a move handle and a fumbled drag can't toggle anything. Keyboard/AT collapse stays on the chevron button, so there's no second tab stop. The grip is whatDockZone's pointer gesture looks for; the chevron and the actions are outside it, so neither drags. - Dismissable (
onClose): a static title, no toggle (see below).
- Default collapsible: one toggle
- No header hover affordance. Lighting the whole bar on hover would misread the
grip-vs-actions split, so the header stays flat. Keyboard focus is the exception: the
collapse control draws an inset
--stage-color-focusring (:focus-visible, offset-1px). (There is no per-section custom glyph — the chevron is the single collapsed/expanded indicator. A consumer icon slot may return when the activity rail is revisited.)
Movable sections (dock model) #
A section can be defined as data and rendered through a model that owns which
sections exist and where each lives, instead of as static <Section> JSX children
of a panel. This is the substrate for reorder / float / cross-panel transfer
(the movable-sections work). Built in phases; Phase 1 is reorder-within-panel + persist,
Phase 2 is float over canvas + re-dock, Phase 3 is cross-panel / cross-stage
transfer, Phase 4 is floating panels (the subsections at the end). The
canonical API description here reflects Phase 4 — Phases 2–3 are kept as historical
narrative (a floated section was a single always-open card; Phase 4 reframed it as
an ordinary section inside a floating panel-that-is-a-dock).
Why a model at all. Static authoring (leftPanel={<><Section/><Section/></>}) has
no notion of placement, so move/reorder with a single-instance guarantee (a section
is never duplicated) is unrepresentable. The dock model is the single source of truth
for placement; panels render from it.
createSectionLayout(opts)→SectionLayoutController(layout/sectionLayout.ts). A Solid store over a placement map.opts.sections: Record<id, SectionDef>— the render registry.SectionDef = { title, render: () => JSX.Element, dock, defaultOpen?, size?, headerActions? }.headerActionsis forwarded to the underlyingSectionwhether the section is docked or floating, so a movable section keeps fullSectioncapability (no per-state loss).renderis a thunk (not eager JSX) so a section's body instantiates under the renderingDockZone's owner — a section that moves docks remounts cleanly, effects never orphaned.dockis the section's home dock (seeds the default placement; declaration order within a dock sets the default order). The other fields forward straight to the underlyingSection. The id is also the section's collapse-persist namespace, so keep it stable.- App panels (
opts.appPanels: Record<id, { movable?; sortable? }>). For panels with no claim to a permanent slot (summoned on demand, then dismissed). Every section still has a home: a section whosedocknames an app panel lives in it, and the app opens and closes the panel (openPanel(id, x?, y?, w?)/closePanel/isPanelOpen), never a section. Two kinds of floating panel sharefloatingPanels: user panels (float-N, made by floating a section out, pruned when empty) and app panels (declared id; the entry appears on first open and stays while closed, which is the position memory). Why not homeless sections (dockomitted,open/closeper section): they need per-section position memory captured on every exit from a panel the section is alone in, and once a homeless section can share a user panel with homed ones, dock-all no longer does what it says. Making the panel the unit removes both.- The docking rule (
canDock(id, dockId), viadockAllowed): an app panel's section stays in its panel; any other section goes anywhere except an app panel. So a user panel only ever holds side-homed sections and dock-all is always honest. Enforced in the model (setDockOrderskips refused ids,move/dockPanelreturn before their force-expand so a refused drop can't collapse an accordion's siblings,floatOutrefuses app-panel sections) and in the gestures (hitTestDock'sacceptsoption: a refused dock still occludes, so a point over it hits nothing and a section dropped there floats out as on empty space; an app panel's header drag never previews a dock).loadSeedresets a stored placement the rule refuses to its default, for a section whose home changed in code, and drops stored app-panel entries no longer declared. openPanelon an open panel only raises it, so a repeated shortcut never yanks a panel the user moved. Position: args, else the stored entry, else the float region's top-left- 16px (
StageFloatHosthands the controller itsfloatRegionviasetFloatHost); the panel's mount-time clamp covers a window that shrank since.
- 16px (
- Chrome: the panel header shows × (
closePanel) where a user panel shows dock-all; its sections never get the redock magnet (already home). A section's grip shows in an app panel only whensortable !== falseand the panel renders more than one section, since the grip can only reorder there; without it a header click collapses. openPanels: Record<id, true>is persisted in the blob (open across reload);reset()clears it and the entries, so every app panel closes and forgets its spot.- Ids starting with
float-throw at construction: they'd collide with the user-panel counter. - Controlled
open(AppPanelDef.open: () => boolean), the library's usual controlled/uncontrolled split:isPanelOpenreads it,openPanelignores the panel (else it would seat an entry for a hidden panel), it never entersopenPanels, and an effect on[open, resetCount]seats it (position + topz) each time it turns true and after a reset clears its entry while showing.reset()writes inside abatchso that effect sees the finished reset rather than re-seating before the entries are cleared. Its × callsonClose(none without one). - Declared geometry. Opening position: args → last entry (skipped when
movable: falseorframed: false, so a fixed panel can't drift from its declared spot after a code change;appPanelMovableis the one rule both the model and the host read) →x/y→ float region top-left + 16.widthis the panel's authored width (authoredDockWidthreturns it), i.e. the width its sections are designed for: panel width and minimum, notw, which stays "the user resized it". Seedingwinstead was wrong: a declared width below the library default lost to the designed-width minimum. - Frame and close are explicit (
framed,closable, both defaulttrue). The header strip is the frame: shown iffframed, even when it holds nothing, because it is the thing that reads as "a panel". Inferring it ("omit the strip when empty") made a fixed HUD silently look like a bare section, a look the app should choose, not stumble into. Grip glyph only while the panel can move (movable, framed, not panel-locked). The × comes fromappPanelClose(none whenclosable: false, or controlled withoutonClose); framed, it's on the strip, frameless,DockZoneappends it to the top rendered section's header actions, so it follows a reorder or a hidden section with no bookkeeping. A frameless panel is never user-movable: there's no surface to drag that isn't already a section's (header = collapse/reorder).
- The docking rule (
SectionDef.collapsible: false→Section'scollapsible={false}: always open, no chevron; with a grip, the grip alone (.stage-section-drag-handle-barerestores the title inset the chevron used to provide). It joinsalwaysOpenwith the modal and floating-card modes, so nothing is cached. In an accordion dock it's open beside the one open collapsible section:openInDraftdoesn't collapse siblings for it.- Two locks (
sectionsLocked/panelsLocked, each controlled or uncontrolled with adefault…seed). The section lock hides grips; the panel lock freezes floating-panel move/resize/dock and the side-panel tab drag. Why split: onelockedflag meant a user who wanted to stop accidental section drags (to click-collapse a floating section safely) also lost the ability to move their floating panels. An app panel'smovable: falsefreezes it regardless of the panel lock. - Hidden sections (
SectionDef.visible?: () => boolean). For sections that belong to one app mode: not rendered, placement / order / open state kept. Visibility is not arrangement state (never in the store, nothing forreset()to clear); it's purely a render filter. One derived accessor,renderedDockOrder(dockId)(@internal), is what every piece of chrome that draws or counts a dock's sections reads:DockZone's<For>, the float host's mount filter, panel width, dock-all, the sole-section rule.dockOrderkeeps returning every id, since the model ops index into it.- Index mapping.
hitTestDockcounts over the cards it sees (the zone's direct children, so a dock nested in a section body doesn't skew the count), then maps that visible index to the full order: just before the card below the line, or the end of the list below the last card. SoDockHit.indexindexesdockOrder(hidden included, ignored section excluded) andmove/ a reorder /dockPaneltake it unchanged. - All-hidden floating panel: not mounted (no hit, fusion or Tab target) but not
pruned. The prune reads placements, which hidden sections keep, so this needs no
special case. On show it remounts at its stored
x/y/w/z; the mount-time clamp re-fits it to the float region; it isn't raised. - Sole visible section dragged to empty space = the panel moves:
floatOut(keepingw), thensetDockOrder(newPanel, oldFullOrder)carries the hidden companions, a reorder write so their open state is untouched. A panel header drag (dockPanel) already moves every section, hidden ones too. - Visibility never writes open state, so an accordion dock whose open section is hidden shows the rest collapsed; nothing auto-opens. Hiding mid-drag ends the gesture through the existing "grip left the document" per-frame check.
- Index mapping.
placements: Record<id, Placement>wherePlacement = { dock; order }— uniform (no floating variant). A section is always docked somewhere; a floating panel is itself a dock, so a floated section is{ dock: <panelId>, order }. Single-instance holds by construction: an id appears in exactly one dock's list.floatingPanels: Record<panelId, { x; y; z; w? }>— the floating-container registry. Each entry is a free-positioned panel-that-is-a-dock; itspanelIdkeys both this map and thedockof every section it holds.x/yare host-local,za dense paint rank,wthe dragged width (omitted → default CSS width).open: Record<id, boolean>— every section's open state, owned by the layout (not eachSection). EveryMovableSectionrenders controlled, so collapse is plain reactive model state — a drop force-expands via one write the section reflects with no remount and no per-SectionlocalStorage cache to race. (The old design left non- accordion sections uncontrolled, seeding open state from a cache at mount; "expand on drop" then wrote the cache and hoped for a remount, which raced the old drag library and lost in many edge cases. Unifying to controlled made drop-expand bulletproof.)- Methods:
dockOrder(dockId) → id[](derived, reactive, sorted by order),setDockOrder(dockId, ids)(the reorder sink — each id re-docked at its index; unknown ids ignored; a within-dock reorder, so open state untouched),move(id, dock, index)(imperative, clamped; always force-expands the section as it lands, overriding any prior/stored collapse — see the drop-clarity note below),redock(id, dockId?)(back to a dock viamove, defaults to the home dock),reset(). Floating-panel methods:floatingPanelIds()(panel ids sorted byzascending — paint order),floatOut(id, x, y, w?)(liftidinto a fresh panel at x/y — new panel id +move(id, panelId, 0);wseeds the panel width so it doesn't jump to the default expanded width on first drop),setFloatingPanel(panelId, x, y)(x/y only, keeps z/w),setFloatingPanelWidth(panelId, w)(width only),raisePanel(panelId)(z to top),isFloatingDock(dockId)(true ifdockIdis a floating panel),dockPanel(panelId, dockId, index)(relocate all of a panel's sections intodockIdatindexin one store write, then force-expand each — the batch op behind panel drag-to-dock + panel→panel fusion; emptying the source panel prunes it; no-op for self / empty panel / either side an app panel). Panelzis re-normalized to a dense0..n-1on eachfloatOut/raisePanelso it never drifts. Panel ids arefloat-<n>(a monotonic counter seeded past the max stored suffix on load, so a reload never reissues a live id). - Draw order is window focus. A capture-phase
pointerdownand afocusinanywhere in a panel callraisePanel, capture so nothing inside can stop it. Raising only changesz: the host renders panels in creation order, not paint order, because moving a node would drop its focus and reshuffle the Tab order.zonly orders panels within one float host:position: fixedmakes the host and.stage-panelsseparate stacking contexts, so nothing in a stage can sit between two floating panels. - Floating width comes from the home dock.
SectionDefhas no width, because a section is designed for its home panel.EditorPanelsprovidesdefaultLeftWidth/defaultRightWidththrough context around each panel slot, since it doesn't know which docks a slot holds, and eachDockZoneregisters its width on the layout. The registry is reactive, not read from the DOM, because a panel restored on load renders before its home dock is in the document. It's kept out of the persisted store (the markup declares it on every load) and survives a dock unmounting.FloatingPanelderives its width from its current sections, which covers every path into a panel (move,dockPanel, a reorder, a home panel mounting late). The widest width is the panel's width and a hard minimum, kept as a CSSmax()so a library-default section follows the size tier.wonly ever means an explicit choice (a resize drag, orfloatOut's argument): nothing derived is stored, so a section leaving shrinks an unresized panel with no extra logic, and a resized one keeps its width above the minimum. Nothing is seeded from a measured section, since that would carry a leftover width along. A home dock that has never mounted, or an app panel (which has no authored width), counts as the library default. - Empty-panel prune. A user panel exists only to host sections; when its last
one leaves (redocked, moved to another panel, or floated into a new one) the container
disappears. An app panel's sections never leave, so it's never pruned while declared
with sections.
pruneEmptyPanels(placements, floatingPanels)runs insidesetDockOrder'sproduce(so every placement mutation, incl.move, prunes), and once more on the persisted seed (a panel whose sections were all removed from code is dropped on load). Open-state methods:isExpanded(id)(reactive — drives every controlled section),setExpanded(id, open)(the chevron's toggle sink: in an accordion dock, opening collapses its siblings + closing leaves the dock all-collapsed; in any other dock it just sets this section's own state). - Accordion docks (
opts.accordionDocks: string[]). A listed dock holds at most one open section:setExpanded(id, true)(whether from the chevron or a drop's force-expand) collapses the dock's other sections, so opening one closes the rest and a section dropped in becomes the open one. The default-open section is the dock's first. Because allMovableSections are controlled now (not just accordion ones), accordion is purely a setter constraint —setExpanded/movecollapse siblings for an accordion dock and don't for any other; the section's render path is identical either way (no controlled-vs-uncontrolled flip on crossing docks). - Persistence (
persistKeyset): one versioned blob understage-layout:<persistKey>viasavePersisted+ a local read/version-gate (loadSeed). The placement map and the open map each seed viaseedById(defaults, stored): a per-id replace — start from defaults, then for every id in both stored and defaults take the stored value wholesale (a stored placement may target afloat-Npanel id; kept as-is). Replace, notdeepMerge, so a code-side change wins over a stale stored value cleanly. A section added in code but absent from storage falls to its declared dock + default open state; an id in storage but no longer insectionsis dropped. Storage is an override: while the arrangement equals the declared defaults the key is removed — so the default layout persists nothing, areset()leaves storage clean, and a code-side default change wins on next load. Writes are deferred + debounced (the seed isn't echoed back; a panel move/resize's per-frame churn coalesces to one write). The blob carries{ placements, open, floatingPanels, openPanels }(open= per-section expanded bool;floatingPanelsrestored within the same version, thenpruneEmptyPanelsdrops any container left with no sections). Bumpopts.versionon a breaking blob- shape change to discard stale blobs. Phase 3 bumped it (float coords went host-local → global); Phase 4 (uniform{ dock, order }placement) and the controlled-open refactor (accordionmap →openmap) each bumped it — a blob holding the old floating-section variant or accordion map is invalid (the arrangement is throwaway, discarded cleanly; the playground is4).
SectionLayoutProvider/useSectionLayout()(layout/SectionLayoutContext.tsx). Context exposing the controller; wrap the whole app, above every stage, so allDockZones and the single app-root float host (StageFloatHost) share one model, which is what lets a section move between any panels under the provider (cross-stage reach).DockZone id(layout/DockZone.tsx). A drop zone that (a) renders<For each={layout.renderedDockOrder(id)}>ofMovableSections, and (b) starts a drag on a press of a section's grip. It drops into anyleftPanel/rightPanelslot (those already take arbitrary JSX) —EditorPanelsneeds no change. Each section renders through one stable component per id (MovableSection, aSectionwrapper carryingdata-section-id) so a within-dock reorder is a node-move, not a rebuild.
Single-instance enforcement. placements maps each id to at most one location (exactly
one for a homed section); a DockZone renders only visible ids whose placement targets its dock. An
id is in at most one rendered list by construction — duplication is unrepresentable.
One pointer model, no drag library. Sections (layout/sectionDrag.ts) and floating
panels (FloatingPanel in StageFloatHost.tsx) both drag with pointer events and pointer
capture, and share one hit test (layout/dockHitTest.ts) and one insert line
(layout/insertLine.ts).
Why not native HTML5 drag. Sections used to drag natively through
@formkit/drag-and-drop, and heavily lagged in Chromium only. Traces on Linux/X11 (Chromium
vs Waterfox, same scene): our handlers cost 0.1-0.2ms in both and Chromium's renderer main
thread sat at 1-6%, but its GPU main thread sat near 100% for the first 600ms after
dragstart, then 2-15%, with dragover arriving about every 15ms against Waterfox's 4ms.
That cost lives in the browser's native drag session and can't be tuned from our side, so we
left the path. It was also why touch only half worked and why float-out needed a second
system beside the library. Synthetic drags (CDP, Input.setInterceptDrags) never showed the
lag, so a person's eyes in Chromium are the acceptance test for this class of problem.
The gesture (startSectionDrag). A direct pointerdown on .stage-dock-zone, for the
primary button, when the target is inside this dock's own .stage-section-drag-handle (a dock
nested in a section body handles its own grips). Only the grip starts anything, so a press in a
section body bubbles untouched: text selection and document-delegated Kobalte selects just work.
Nothing is draggable, and a dragstart is prevented during a press, since a native drag starting
over selected text would pointercancel ours (Gecko hit this on panel headers).
- 4px of travel starts a drag. A press with no travel does nothing.
- Commit on release only. Nothing in the model changes mid-drag, so nothing unmounts and a
source panel can't prune early: the dragged node stays in place, dimmed
(
.stage-movable-section-dragging), with the ghost and line as the only feedback. This is an insertion-line model rather than live-sort, because reshuffling sections under the cursor reads as jumpy. - One animation-frame loop. Pointer events only record the latest point. Each frame moves the ghost, hit-tests and writes the line, skipping all of it while nothing has moved, and a release hit-tests again at its final point.
- Outcomes on release. Same dock:
setDockOrder, keeping collapse state (no write if the order is unchanged). Another dock:move, landing expanded (a sole open arrival in an accordion). No dock, inside the float host:floatOut. Outside the host or with no host mounted: nothing. - Cancel. Escape (captured and stopped, so it doesn't also close something beneath),
pointercanceland a lost capture end with no change. So does the grip leaving the document (a lock, a reset, a redock button), checked each frame. Exactly one gesture runs at a time. Thelostpointercapturelistener must checktarget === handle: a touch press implicitly captures to the grip's inner title, and moving that capture to the grip reports a loss from the child. - Selection and cursor are pinned by
.stage-section-drag-activeon<html>while dragging.
The ghost. A clone of the section dressed as a .stage-float-panel, built once at drag start
inside .stage-root so tokens resolve, then moved only by translate3d on its own layer
(will-change: transform), with its height capped so a tall section stays cheap to paint.
pointer-events:none, so hit-testing sees through it; z-index matches the line's so the line
(later in the document) draws over it. Rejected: the popover top layer, which beats any stacking
context but hides the line. The clone is inert with every id and name stripped before it's
inserted, because a cloned radio sharing a name unchecks the real one the moment it's in the
document. Its width is what a float-out would land at: the panel's w if the section is alone in a
resized panel, else sectionDesignedWidth, the same value FloatingPanel derives from. The grab
offset is clamped into that width so the pointer stays on the ghost, and it's subtracted from the
drop point, so a floated panel's top-left lands exactly where the ghost's was.
The hit test (hitTestDock). Geometry against every dock of the dragging layout, spanning all
stages, so it works mid-drag with no event target to read. Traps it handles:
- Stacking. Floating docks are checked first, highest
zfirst. Side docks only overlap when an overlay panel covers a nested stage; they have no rank in the model, soelementsFromPointsettles those few cases (ghost and line take no pointer events, so they never appear in that stack). - Drop area. A side panel accepts a drop anywhere in its
.stage-panel-body. A floating panel accepts one anywhere in the panel, so its header strip can't let a drop fall through to a side dock beneath it. - Same-dock index.
ignoreSectionIdleaves the dragged section out of the count, which is whatmoveand a reorder expect (counting it was an off-by-one). - Ownership. A module
WeakMap<zoneEl, layout>thatDockZonefills. An app can run one layout per pane, and a hit on another layout's dock would place a section in a dock its layout never renders. - Inert docks. A collapsed panel's dock keeps its full rect, reaching out over the center.
- The line stays inside the visible part of the dock, so a scrolled-away card can't push it out of
the panel. It lives on
document.bodyin page coords so it never reflows a panel or gets clipped, which puts it outside the token scope: colour and z are copied inline from the nearest stage root.
Auto-scroll (autoScrollDock). Within 32px of the top or bottom of the hovered dock's
.stage-panel-body, it scrolls at up to 12px/frame, ramping toward the edge, and the caller hit-tests
again since every card moved. Shared by section and panel drags. A floating panel has no scroller.
Float-out bounds. StageFloatHost registers its element through the @internal
layout.setFloatHost / layout.floatHost(), and sectionDrag reads it on release. floatRegion keeps
working through FloatingPanel's mount clamp.
Touch. touch-action:none on the grip only, so a finger on the grip drags and a finger anywhere
else in a section scrolls the panel. No long-press gesture.
The remount tradeoff. Within-dock reorder → Solid's keyed <For> moves the node (no remount).
Cross-dock / float transfer → the id leaves one list and joins another, so that section remounts.
Acceptable: field values live in createEditorStore (external) and collapse state is in the layout,
so it reconstitutes; at no point do two copies exist. If a future expensive section makes cross-dock
remount visibly janky, revisit with a stable-wrapper-pool + reparent — not now.
The static leftPanel/rightPanel JSX path is untouched and remains the
non-movable default; the two authoring modes coexist (the playground runs one panel
each way).
Superseded by Phase 4 (floating panels). Phases 2–3 below describe the original float card model — a floated section was one always-open card with a left-side magnet and no collapse, carrying its own
floatingplacement variant +floatingIds/setFloating/setFloatingWidth/raise. Phase 4 removed all of that: a floated section is now an ordinaryMovableSectioninside a floating panel (a dock that floats). The panel-level drop area andfloatRegioncarry over. The drag mechanics these phases built on were replaced by the pointer model above.
Phase 2 — float over canvas + re-dock #
A section can be dragged out of a dock to float over the canvas, moved + resized
freely, and dragged back into a dock. Move semantics throughout: a section floats by
relocating the one instance (the single-instance invariant holds), never by cloning.
Float x/y + z + width w persist alongside the docked placements.
StageFloatHost(layout/StageFloatHost.tsx). The single click-through overlay (.stage-float-host,position:fixed,inset:0,pointer-events:none) the consumer mounts once at the app root under the provider, inside a.stage-rootfor the token scope — not inside a stage. Its fixed box is the global coordinate origin for every float'sx/y, so a card floats over the whole app and is draggable across stages (Phase 3). It renders<For each={layout.floatingIds()}>of positioned.stage-float-cards; a card'sz-indexiscalc(var(--stage-z-float) + var(--stage-float-z))so a raised card paints over its neighbours. New token--stage-z-float: 40(above the stage pointer layer, below popovers). The host needs no drop-target element of its own: a section drag reads its box as the float-out bounds.- The float card. The registry section rendered through the ordinary
SectionindragHandlemode, but withleadingActionset — a genericSectioncapability that replaces the disclosure chevron with a custom leading icon button (here the magnet that redocks) and forces the section always open (a float never collapses). The header is therefore[magnet=dock] [grip=move | title]. - Redock clarity — a dropped section always lands expanded.
movewrites the section'sopen[id]totrue(in the layout's own open map — see the controlled-open note in § Movable sections) after re-docking it, so a section dropped / transferred / floated into a panel is always expanded, never reverted to its last collapse state (which could drop it back in collapsed and hide where it went). Because the section is rendered controlled, that one model write is the whole mechanism — noSectionlocalStorage cache, no remount timing to race. Scoped tomove(the drop path, incl. magnet redock + cross-dock transfer); a within-dock reorder goes throughsetDockOrder, which leaves open state alone. - Float move + resize + redock.
- Move + resize + redock is ours. The header grip is a pointer handler
(
pointerdownon.stage-section-drag-handle→raise+setPointerCapture;pointermove→ reposition viasetFloating, live-clamped to the float region (clampToRegion) so the preview always follows the cursor but never leaves the region;pointerup→ redock or rest). On each move it independently hit-tests the docks (see panel-level drop, below): over a dock it previews the shared insertion line (createInsertLine) and arms a redock target, but no longer freezes the card — you keep seeing where you're dropping. On release: a hit redocks viamove(id, dock, index)(overwrites the floating placement — redock-by-drag reusesmove, no new path); otherwise the card floats at its current (already-clamped) position. The two.stage-float-card-resizeside-edge grips resize the width viasetFloatingWidth(left edge holds the right fixed by shiftingx; floored atMIN_FLOAT_WIDTH, ceiled at the viewport;clampIntoViewon release). The magnet leading button is the keyboard/click redock. - Panel-level drop area. The dock hit-test (
hitTestDock/overDock, both viadockDropRect) targets the enclosing.stage-panel-body, not the dock's short content box — so a panel that contains aDockZoneaccepts a drop anywhere in it (over a section or the empty space below). Floatability is thus effectively a panel property: a panel with a dock lets any of its sections pull off (grip) and any float drop in; a panel without one does neither. Falls back to the zone's own rect for a bare dock (no panel ancestor / tests). The insertion line uses this rect, so it spans the panel width; the insert index still counts the dock's section midpoints.
- Move + resize + redock is ours. The header grip is a pointer handler
(
Phase 3 — cross-panel / cross-stage transfer #
A floating section can be re-docked into any panel on any stage, not just its origin stage — so float-out-then-redock is the transfer mechanism. Two changes unlock it; both build on Phase 2 rather than adding a new drag system.
- One global float host. The host moved from per-stage (mounted over a stage's
center) to a single app-root overlay (position:fixed, above). With one global coordinate origin a card can be dragged anywhere on screen, and the dock hit-test scans every.stage-dock-zonedocument-wide (targeting the enclosing panel body, see Phase-2 panel-level drop), so a release over a panel on a different stage redocks there viamove(id, dock, index). - Two distinct regions — float-rest vs dock-drop. Where a card may come to rest and
where a drop re-docks are deliberately separate sets that need not overlap. The
optional
floatRegion?: () => DOMRectprop onStageFloatHostis the rest region (a viewport-space rect; default = the host box). The drag live-clamps the preview into it (clampToRegion, shared with the release/resizeclampIntoView), translating it into the host-local framex/ylive in. Dock targets are the panel-body rects, hit-tested independently each move and checked inonPointerUpbefore the rest position — so a drop over a panel outside the float region still re-docks (the preview rests at the region edge meanwhile). This lets a consumer allow floating only over the canvas while side panels remain valid drop targets. Multi-rect / predicate regions are deferred (docs/feature-ideas.md) until a consumer needs them.
Out of scope (Phase 3): tabbed/stacked dock groups; floating panels and direct panel↔panel transfer arrived in Phase 4 (below).
Phase 4 — floating panels #
A floating element is now a panel of its own, not a single card: multiple sections stack in it, each collapses independently (chevron back), each carries a right-side magnet to snap itself home, and a section can be dragged straight from a side panel into a floating panel (and between floating panels) with no intermediate float-out step. The whole floating panel moves/resizes as a unit via a slim drag-strip header (+ a dock-all button that sends every section home and closes the panel).
Core reframe — a floating panel is a dock that floats. This collapses most of the work onto the existing dock machinery:
Placementbecame uniform{ dock; order }(thefloatingvariant is gone). A section in a floating panel is{ dock: <panelId>, order }.- A new registry
floatingPanels: Record<panelId, { x; y; z; w? }>holds the containers.StageFloatHostrenders<For each={layout.floatingPanelIds()}>of positioned.stage-float-panels, each wrapping a real<DockZone id={panelId}>— so the sections inside are ordinaryMovableSections and chevron-collapse + grip-reorder return for free. A panel'sz-indexis stillcalc(var(--stage-z-float) + var(--stage-float-z)). - The magnet (the redock affordance — rendered as an
arrows-in-simpleglyph) is just a right-sideheaderActionsentry merged on inMovableSectionwhenlayout.isFloatingDock(currentDock)— not aleadingAction, so the disclosure chevron stays and each section collapses on its own. (Phase 2's always-open card usedleadingActionto replace the chevron; that path is unused by the layout now.)
Drag outcomes. A section's release follows § the gesture: another dock is a direct move,
covering side→floating, floating→side and floating→floating with no intermediate float-out.
- The panel's header drag is dock-aware. A panel's header strip
(
.stage-float-panel-header) is a pointer-drag (pointerdownnot on its dock-all button →raisePanel+setPointerCapture;pointermove→setFloatingPanel, live-clamped tofloatRegionvia the sharedclampToRegion). On each move it also hit-tests the docks (hitTestDockwithexcludeDockId, so never the panel's own zone): over a dock it arms a drop- previews the shared insertion line (
createInsertLine, ondocument.bodyat--stage-z-popover1000 > the float z, so it shows above the panel); over none it clears both. Onpointerup: an armed drop callsdockPanel(panelId, dockId, index)— every section relocates into that dock (the source panel then prunes, unmounting theFloatingPanel); otherwise the panel comes to a free rest (clampIntoView). Escape andpointercancelclear the line + drop and put the panel back at its startingx/y.hitTestDockfinds every dock, including other floating panels — so panel→dock (put away) and panel→panel (fusion) are the same operation, exactly as cross-stage section transfer fell out of float→redock; a panel is a dock. The two.stage-float-card-resizeside edges resize viasetFloatingPanelWidth(left edge holds the right fixed by shiftingx; floored atMIN_FLOAT_WIDTH, ceiled at the host width). Dock-all iteratesdockOrder(panelId)andredocks each — emptying + pruning the panel.
- previews the shared insertion line (
Persistence / version. The blob gained floatingPanels; the uniform Placement shape
is breaking, so opts.version bumps (playground 2 → 3) and stale floats discard cleanly.
See the persistence note above.
TabMenu #
Horizontal tab-strip bar control (not a field/section), modelled on a document/object tab bar.
- Each
.stage-tabhas a mouse-only×close — a<span role="button">out of the tab order (middle-click closes too viaonAuxClickbutton===1). An optional+new-tab button renders inside the strip at the end (scrolls with the tabs; only whenonNewTabis set) as a realIconButton(a local scalablePlusIconSVG — no icon pack —subtlevariant) so it gets the shared button chrome + focus ring; it stays a tab stop. Overflow surfaces‹/›scroll arrows (aResizeObserverover the strip toggles them, guarded for happy-dom); those + the×stay unicode bare-glyph affordances. - Keyboard model = browser tab bar (ARIA
tablist+ roving tabindex): the whole strip is one tab stop. The tab carrying the rovingtabindex=0is the active one (or the first if none active); every other tab is-1. ←/→ (and Home/End) move and select the adjacent tab — selection follows focus, wrapping at the ends (onStripKeyDownon therole=tabliststrip, guarded to fire only when a.stage-tabis the event target, not the+). The whole tab is the click/select target (no inner button); its.stage-tab-labelis a caption, so it'suser-select: none(like.stage-label) — a drag never selects the text. Clicking a tab focuses it (e.currentTarget.focus()) so ←/→ work straight from a click. The focus ring shows in keyboard mode only — the strip togglesstage-tabmenu-strip-show-ringwhen focus arrived by keyboard (tabbed in, or the first Tab/arrow after a click), never on a bare click (a deliberate per-tab deviation from the library-wide:focusrule — it reads better). Modality is tracked with atabFromPointerflag (set on a tabpointerdown, consumed infocusin); the gated rule is.stage-tabmenu-strip-show-ring .stage-tab:focuswithoutline-offset:-1pxso it doesn't bleed onto the butted neighbour. The active tab's top accent is a 2px inset box-shadow (not a border): thick enough to read through the 1px focus ring, and box-shadow means bumping it to 2px never nudges the label/× (the reserved 1px transparentborder-topkeeps layout identical to an inactive tab). The scrollable strip istabindex=-1so it never becomes a focusable-scroll-container phantom stop. - Controlled + uncontrolled mirrored: uncontrolled seeds internal signals
from
initialTabs(strings →{id:gen(),label}) +defaultActiveId(else first tab); controlled passestabs()(+ usuallyactiveId()) and the hooks become notification-only (the menu mutates nothing).onNewTabreturns a string label (else'New tab') orfalseto veto;onClosereturnsfalseto veto; after a non-vetoed uncontrolled close the active tab re-points next→prev→none. - Close-glyph reveal is VSCode-style (pure CSS, the button keeps its width so
the label never shifts):
.stage-tab-close-xis hidden on an inactive, unhovered tab; shows on the active tab and on:hover. A tab may setmodified(TabMenuTab): a dot (.stage-tab-dot) shows by default (active or not) and yields to the×on hover. The library only renders these; deciding when a tab is modified is the consumer's (controlledtabs). - Visual style is blocky VSCode folder tabs: square corners, butted together
(no gap), no bottom border so each tab's open bottom flows into the bar's
bottom border. The menu fills its slot height → it wants a full-height bar slot
(see the playground's
.playground-tabbar, a plain full-height div, not a centred Toolbar anchor). Inactive tabs brighten on hover; the active tab does not (marked by a 1px top accent line + lifted surface + brighter label). Drop in a bar slot (e.g.topSub); tabs size off control-height/font.
Toolbar #
One bar with left / center / right anchor cells (1fr auto 1fr grid;
center stays centered). The equal tracks exist only to protect that centering, so
an empty center switches the bar to flex (keyed off :empty, no prop) and left /
right share the whole width. Transparent layout only — the hosting region (sub-toolbar
stack / stage bar stack) supplies background + borders. Every bar is a
pre-composed Toolbar (or plain JSX), not a registry; stack by listing several
in a slot.
TitleBar #
A prominent bar — the same Toolbar anchors (it wraps one), but it paints
itself with the recessed --stage-color-bg + an edge separator (.stage-title-bar;
footer flips the border to the top edge). This is the deliberate inverse of Toolbar's
"region paints, bar is transparent" rule: TitleBar is chrome, so the prominent shade
travels with the component and can sit in any bar slot. It replaces a removed
positional accident — the top/bottom EditorPanels slots used to be hard-coded
darker (.stage-top-bar/.stage-bottom-bar painted --stage-color-bg) while every
other bar region was panel-bg, so "prominent" was a side effect of being top/bottom.
Those slots are now transparent cells; prominence is opt-in via TitleBar. (Migration:
a consumer that relied on a dark top/bottom swaps its Toolbar/bespoke div for a
TitleBar — no version bump, the slots still accept any JSX.)
CanvasStage #
Multi-layer canvas container with ResizeObserver. Vertical flex: topBars stack
/ .stage-viewport (1fr, holds the layers) / bottomBars stack. Bars inset
(reflow) the viewport rather than overlaying it; the ResizeObserver watches the
viewport so onResize excludes the bars. Source guards
typeof ResizeObserver !== 'undefined' (happy-dom).
6. Field framework (src/controls/) #
Ported once both consumers grew the same shape. A Field layout base +
FieldGroup, seven bare value controls (Text / Number / Textarea / Color /
Select / Boolean / Slider) each paired with a captioned field wrapper of the
same name, a display-only Label, a button family (IconButton / IconToggle /
ActionButton / ToggleButton / ButtonGroupControl / ButtonGroupField), and icon fields
(IconButtonField / IconToggleField).
Control vs Field #
The widgets split into two tiers, mirroring the industry hierarchy (Control = any interactive widget; Field = the captioned, value-bound subset):
- A
*Controlis the bare widget — a value (value/onChange), its widget-specific props (min/max/step,options/optionLabels,placeholder,alpha,variant, …),readonly, anaria-label, plus alevel?: 'error' | 'warn'(→data-stage-levelon the widget) and the three validation handlers (onFocusIn/onFocusOut/onPointerEnter) it forwards onto its root. It renders only the widget DOM (the.stage-input, the.stage-slidercluster, the select trigger + portal, the colour hex + swatch + popover, the bare switch/checkbox). No caption, noField, nocreateFieldValidation. The sharedControlValidationPropstype (controls/createFieldValidation.ts) carrieslevel+ the handlers. - A
*Fieldis the captioned row that composes the control: it runscreateFieldValidation, wraps<Field name … fieldType … info … description …>, and renders the bare control inside the render-prop, threadinglevel={v.borderLevel()}and{...v.controlHandlers}through. The control is the single source of truth for the widget; the field never re-implements it. Example —SliderFieldis just<Field …>{(value) => <SliderControl value={value} … level={v.borderLevel()} {...v.controlHandlers} />}</Field>.
Both halves are public (the bare control for a consumer composing their own
layout, e.g. a slider in a toolbar; the field for the captioned row). The bare
controls emit the same widget classes the fields always did, so the theme is
shared — only the field row (.stage-field-*) is absent from a bare control.
The Boolean exception. BooleanField does not compose BooleanControl.
Click-the-label-to-toggle needs the caption inside the Kobalte Switch/Checkbox
root for label association, so the field keeps its own bespoke caption wiring;
BooleanControl is a parallel bare widget (the switch/checkbox with no caption,
rooted on .stage-boolean-control). It is the one pair that doesn't collapse to
the generic compose-a-control contract.
Layout #
Every row is a .stage-field-control carrying a layout class
(stage-compact | stage-expanded) + a per-type class (stage-field-{type}).
Field renders the matching skeleton: compact = label | value grid; expanded =
label row (with trailing rule) above a full-width value. Default compact; a
control may pin one (BooleanField + ColorField compact-only, TextareaField
expanded-only).
The caption span is .stage-field-name (matching the name prop; the
expanded caption row is .stage-field-name-row) — renamed from
.stage-field-label so that segment is free for Label's per-type
discriminator.
Kobalte wrapping #
Most value controls wrap a Kobalte headless primitive (text-field /
number-field / select / switch / checkbox / slider / color) while keeping our
public accessor+event API; the matching field composes that control and adds the
Field layout (complete as of v0.5.0). The exceptions — the button/icon family
and SearchControl — are hand-rolled on purpose (see below).
- The button + icon fields stay ours deliberately, not a gap: they're
already native
<button>s, and Kobalte'sButtonprimitive only confers button semantics on non-button elements, so wrapping buys nothing. The rewrite targeted complexity/ARIA machinery (listbox nav, slideraria-valuenow, the colour area, spinbutton) — which buttons don't have. SearchControlis also deliberately ours, not Kobalte. Kobalte'sComboboxis value-list-bound and can't host the bespoke loading / recents / total / empty chrome the search dropdown needs, so the input is a plain<input>and the dropdown is a hand-rolledPortalpositioned by@floating-ui/dom(the same deptooltip.tsuses). It still honours the control contract (data-stage-level, theControlValidationPropsfocus/hover handlers).- The committed value has one render path —
valueRender(the slot) with plain text as its default, not two branches. The control always renders the<input>(the accessible value + sole tab stop) plus apointer-events:none/aria-hiddenoverlay that paints the committed value over it while at rest; at rest the input's own text goestransparentso the two don't double (the::placeholdercolour is pinned, else Firefox derives it from the now-transparent text colour and an empty field would show nothing). OmitvalueRenderand the overlay paints the plain label — so "plain text" is just the slot's default, no separate code path. The slot is consumer-driven (() => JSX.Element, reads the consumer's own signals) rather than handed the committedSearchItem, because a value seeded from persistence has no picked item — the consumer still derives the rich display from the name. MirrorsSelectField'srenderOption(rich trigger), closing the select-vs-search asymmetry. - "At rest" is gated on
!isEditing(), notcommitted()—committed()is only true after a pick/step, but the overlay must also paint the unfocused boot/externally-set value (no pick made).isEditing()isisFocused() && !committed();isFocusedwas promoted from a plainletto a signal so the overlay (and the resting-only × clear) re-render on focus change. The one pre-existing reader (the external-value→draft sync effect) readsisEditing()underuntrackso it still fires onvalue()change only — the focus/commit transitions set the draft in their own handlers. The inline × clear is also gated to rest (hidden while editing): an × in an actively-typed field is confusing, and resting-only means it never fights the overlay. - Attached input buttons are one primitive —
InputActions. The "buttons seamed to the right edge of a text input" pattern (the search ◀▶ stepper, and nowTextControl.actions— copy / apply / reset) lives in a single internal presentational component (controls/InputActions.tsx) so the joined-seam look (squared input edge via.stage-input-grouped, shared border, the focus-ring- over-seam trick, icon-only vs labelled sizing) is defined once.InputActionmirrorsSectionHeaderAction(icon orlabel,title,onClick, reactivedisabled); the buttons are real<button>s so they're tab-reachable by default (the established stepper behaviour).SearchControlfeeds its stepper through it while keeping the step logic (stepThrough, committed state) local — the primitive is purely the button cluster + CSS, NOT a base the controls extend (SearchControl's combobox<input>can't be a plainTextControl). The type is public (InputAction); the component is internal (reached via the controls). - The search pipeline is factored out and exported —
controls/search.tsowns theSearchItem/SearchResult/SearchContext/SearchFntypes,createSearchSource(the debounce + monotonic-token +AbortControllerpipeline exposing{ results, loading, schedule, reset, cancel }), andstepThrough(the ◀▶ wrap math).SearchControlis built on it, and it's public so a consumer can compose their own search-style control (e.g. a live-query "filter" model that keeps the query in the input and shows the selection elsewhere) without re-deriving the machinery — paired with a siblingSelectControlfor a category filter (its value passed viaschedule(query, filter)→SearchContext.filter) andcreateFieldValidationfor the field glue. We evaluated shipping that filter model as a first-classFilterControland pulled it: it was one-consumer, fully composable from these primitives, and its only non-composable trait — query-in-input + selection-below — fights the compact-field thesis. The reusable extraction (search.ts) is the part that earned its keep.SearchControl.tsxre-exports the search types so./SearchControlimport sites stay stable. - Kobalte ships zero visuals — style its parts with
stage-*classes keyed on itsdata-*state attrs (data-invalid/data-disabled/data-readonly/data-checked/data-expanded/data-highlighted/data-selected); no Kobalte stylesheet is imported. - Slider/Color thumbs are positioned horizontally only by Kobalte (
left%+translateX(-50%),topunset) — centre them withbox-sizing: border-box+top: calc((track − thumb) / 2).
What field and option rendering guarantees #
onChangemeans a user edited the value.NumberControldrops Kobalte's reports of the value it was just given, on mount and on outside changes, so consumers don't need guards.ButtonGroupControl,IconButtonFieldandIconToggleFieldrender options by position, withIndex. Inline arrays commonly read signals, which builds new objects on every change. A keyedForwould remount each button and lose keyboard focus. A static slot still re-renders when itsrenderchanges, because reusing it could show stale content.ReadoutFieldis separate fromreadonly.readonlymeans this editable field is locked for now, so it keeps the input's shape and drops pointer events. A readout was never editable, so it's plain selectable text with no focus stop.
The button family & BaseButton #
The family shares an internal BaseButton (NOT exported) so skins can't
drift. It owns the native <button> (so disabled removes it from the tab
order + blocks click-focus for free), the debounce click wrapper,
title/aria-label/aria-pressed, and the shared stage-button class (focus
ring + the disabled cursor default, matching every other inert control — not
the OS not-allowed). It forces focus() on click (for the cross-browser focus
ring, §4). IconButton/ActionButton/ToggleButton are thin skins adding only their
visual classes + children.
-
Naming:
ButtonGroupControl(notButtonGroup) — the one family member with a*Fieldpartner follows the*Control/*Fieldsymmetry. Buttons are actions, not values, so the standalone members keep plain action names (IconButton,ActionButton,ToggleButton— noControlsuffix, no field wrapper). ButButtonGroupControldoes have a captionedButtonGroupFieldwrapper, and a consumer who has internalised "XControlpairs withXField" would otherwise hunt for aButtonGroupControlthat didn't exist. Discoverability of the pair beat the "buttons aren't values, so noControlsuffix" purity. The bare control still emits.stage-button-group(class unchanged). -
ButtonGroupControlself-alignment:justify?: 'start'|'center'|'end'|'fill'. Opt-in (omit = natural shrink-wrap width, so aToolbaranchor / field value cell keeps positioning it). When set, the group spans its container (width:100%):start/center/endsetjustify-content;fillspans + gives the option buttonsflex:1to share the width (replaces the oldfillboolean — folded intojustifyfor one knob;kind:'static'slots aren't stretched, since the group can't force flex onto arbitrary JSX). Meaningful in block /1frcontexts; awidth:100%group in anauto-sized host (aToolbarcentercell) has nothing to fill.ButtonGroupFieldforwardsjustify(buttons within the value cell) and keeps its ownalign(aFieldAlignthat places the cell) — two layers, two props, deliberately not the same name. -
primaryis the variant default everywhere — a standaloneActionButtonand aButtonGroupControl's label options both default to it.primaryis simply "how a button looks normally" (filled input-bg + strong border), and aButtonGroupControlis just multiple buttons behaving normally — so the group must not silently restyle them. An earlier build defaulted the group toghost(reasoning: a segmented group reads lighter, and the active option already carries the accent fill). That was rejected: the library shouldn't impose a de-emphasis the consumer didn't ask for, and the standalone-vs-grouped discrepancy actively confused a consumer (dsts-viewer).ghoststays an opt-in (buttonVariant="ghost") for when a consumer does want to soften a group. -
tabbable?: boolean(default true):tabbable={false}setstabindex=-1, skips click-focus, adds.stage-button-untabbable(:focus { box-shadow: none }) — a mouse-only button (exposed on IconButton). -
subtlevariant on IconButton (.stage-icon-button-subtle) for secondary/meta actions: muted glyph, shape brightens on hover (no bg),:activeflashes a faint bg,subtle.activestays bright-shape with NO accent fill. Its rules sit AFTER the default.stage-icon-button*rules so they win on the equal-specificity:hoverpairs. -
a11y is just two ARIA attrs:
aria-pressedon the toggles (and on IconButton/ActionButton wheneveractiveis supplied — omitted otherwise so a plain action button stays plain) andaria-label(fromtitle) on the icon-only IconButton. -
No click "press" animation — the focus ring (now shown on click too) already marks the clicked control, so the old scale-punch was removed as a redundant holdover.
-
There is no close button control. A close is an
IconButtonwith anX, styled by where it sits. ASection'sonCloseappends one to itsheaderActionsrow, so every button there behaves alike. A toast's dismiss has its own skin intoast.css. TabMenu's tab close stays bespoke: it carries the modified-dot + hover-reveal behaviours and sits in a glyph-button family with the+/ scroll arrows.
Per-field notes #
- Label — static, display-only descriptive text (section blurb, status
line, bar app title). No value, no
onChange, untouched bybind(); the consumer drives it via a reactivetext(Solid props are reactive — no setter API needed).textis aLabelContent=string | (() => JSX.Element): a thunk carries light inline rich content (chiefly a hyperlink) or a live status line. The thunk is called inside the Label's JSX ({fn()}), not rendered via<Dynamic component={fn}>—Dynamictreats it as a component invoked once, so a signal read inside a string-returning thunk silently went stale (the string-form was reactive but a thunk returning the same string was not — a quiet footgun). Calling it inline wraps it in Solid'sinserttracking computation, so both the string form and any thunk (string- or JSX- returning) track inner signals and re-render. Links inside read in the accent (.stage-label a). Kept deliberately to inline accents — a Label is a caption, not a rich-text surface. No caption/value split — a single full-width.stage-labeltext element — so it renders a bespoke.stage-field-controlskeleton (fieldType="label") instead of going throughField. Three layouts:compact(one line, ellipsis;nowrapcollapses explicit newlines so they truncate too),expanded(pre-wrap: wraps and keeps newlines with natural proseline-height: 1.4— multiline reads compactly, not one control-height per line; a single line still reserves one control-height viamin-heightso a short blurb aligns with its neighbours),bar(one inline content-sized line, no truncation;alignis a no-op inline). Props:text,align(compact+expanded only →.stage-label-{center,right}),layout,size,class(the style hook — appended to.stage-labelfor consumer italic/bold/colour). Replaces all free text in bars. - BooleanField —
variant'toggle'(default, Kobalte switch:.stage-switch+.stage-switch-thumb) |'checkbox'(Kobalte checkbox:.stage-checkbox+ CSS-tick.stage-checkbox-indicator). Its row (.stage-field-boolean) overrides the compact grid tominmax(0,1fr) autoso the label expands to meet a right-anchored control. - NumberField — Kobalte
number-field: increment/decrement steppers (a bordered up/down column inset on the input's right edge) replace the OS spinner; they light on hover/press and dim viadata-disabledat min/max.steppers={false}hides them; always hidden whenreadonly. Public API unchanged (value: () => number+onChange(next)); the wrapper adapts to Kobalte'srawValue/onRawValueChangeinternally. - TextareaField — puts the caret at the start on keyboard focus
(tabbing in otherwise gives no visible caret and silently appends at the end).
Pointer focus keeps native placement (caret where you clicked) — distinguished
by a
fromPointerflag set ononPointerDown(fires beforefocus) and cleared each focus. - SelectField — keeps its full surface (
optionLabels, trailingactions,align,disabled); trigger is.stage-select-trigger(focusable button), Kobalte root is.stage-select, caret/items style offdata-expanded/data-highlighted/data-selected. - SearchField — combobox over an async
search(query, ctx) => Promise< SearchResult>the caller supplies; the primitive owns all chrome. Selection model:value()is the committed label string; picking a result firesonChange(item)(the fullSearchItem, incl. the caller's rawpayload) and commits itslabel. Focus is one simple rule (tab or click alike): select-all the term, always open recents, and wait for typing — no pointer-vs-keyboard split. Adirtyflag stays false until the user types, so focusing never fires a search for the committed value; the search kicks off on input (≥minQueryLength, default 1). Keyboard nav: ↑/↓ move the highlight, Enter / click commits viapick()(the single handoff). ←/→ are not intercepted (native text cursor) — prev/next is the tabbable ◀▶ stepper instead (real buttons, Space/Enter to stepstepItems; on:focusthey take a box-shadow ring +z-index— the button convention — so the ring shows on all four sides incl. the shared left seam, painted over the neighbour's border, never the browser default ring). On a committed rest, Enter re-engages (re-opens recents + select-all, same as clicking back in) —preventDefaultalso swallows Enter'sinsertLineBreakbeforeinput (nulldata), which the committed-rest branch would otherwise treat as a new empty query and clear the field (also guarded inonBeforeInput). Dropdown is hand-rolled, NOT Kobalte — full control of the chrome — portalled into the nearest.stage-rootand positioned with@floating-ui/dom. It grows to fit content (thesizemiddleware floorsmin-widthat the input; CSSwidth: max-content+--stage-search-max-widthcaps it);autoUpdateruns while open (deferred one microtask in the panel ref so the Portal node is connected before floating-ui walks its ancestors — elsegetComputedStyleon a detached parent throws and aborts the dropdown render), torn down on close/empty. One structure across modes — header + list + footer — so recents and results read the same: recents → "Recent searches" / rows / "{n} recent searches"; results → "Search results" / rows / "Showing {n} of {m} results.". Each label is a default the consumer overrides as text or JSX (recentsHeader,resultsHeader,recentsEmptyLabel,emptyLabel,recentsFooter,resultsFooter; footers also accept a count fn). Footers + empty states are italic. Request hygiene: a monotonic token discards stale resolutions and a per-requestAbortController(handed tosearchviactx.signal) cancels the in-flight call; input is debounced (debounceMs, default 150). Empty value: by default blurring an emptied field reverts to the committed label;allowEmptyflips that — empty/whitespace on blur stays empty and firesonClear(the consumer drops the value), and an inline clear (×) button shows while non-empty (.stage-search-clear, mouse-only). Committed rest: after a pick (or step) the input rests on the selection — still focused (so Tab works) but the caret is hidden (.stage-search-input-committed→caret-color: transparent) so it doesn't read as "ready to type". It reads as both "showing your pick" and "ready for new input": a printable keystroke (or paste) restarts the query from empty with that text (beforeinputblocks the in-place edit and re-seeds the draft) — no click/tab needed; a click or tab-in instead select-alls for replacement. When a stepper is wired, the resting field's ◀▶ arrow keys drive it (like ↑↓ in a dropdown); otherwise the resting keyboard is inert (Tab still moves focus). Clicking a ◀▶ button steps and lands inert focus on the input (so the arrow keys flow straight after) and closes any open dropdown. Stepper: passstepItems(an ordered list accessor — consumer-chosen: current results or whole catalog) to get a ◀▶ pair attached to the input that cycles the committed selection through that list (wrapping), committing each neighbour viaonChangewithout opening the dropdown. Position is anchored by the last-committedid(label-match fallback for externally-set values); stepping never records a recent. Recents are library-managed, opt-in viarecentsKey(persisted throughloadPersisted/savePersisted, version 1, MRU deduped byid, capped atmaxRecents). By default they're the verbatim snapshot;reconcileRecentopts into validating each stored recent against the live source right before display — return it (optionally refreshed) to keep,nullto drop a deleted/renamed entry. It's non-destructive (filters/refreshes the displayed recents; the snapshot is untouched, so a temporarily-missing entry returns when it resolves) and reactive (re-runs as a live catalog changes). Rows pick ononMouseDown+preventDefaultso the input keeps focus through the click; the default row rendersbadge/label/description(itemRenderoverrides).SearchItem/SearchResult/SearchFn/SearchContextare public types. (A filter-style variant — query-in-input, selection shown separately, sibling category filter — is not a built-in; it's composed consumer-side fromcreateSearchSource+SelectControl+createFieldValidation, see Kobalte-wrapping above.) - ColorField (compact-only) — value is a hex string without
#, UPPERCASE ('8A8A8A'/'8AF'; 8-digit whenalpha). A single editable hex box (left) + a swatch button (right) anchor right (auto value cell). The swatch opens a portalled popover (ColorArea + hue slider + optional alpha slider) — no second hex box: the picker and the field box edit the same value, so the box stays usable while the popover is open. The box'shexDraftmodel shows#+ uppercase hex (the#is a sticky presentation prefix, stripped back out of the stored value); entering it (tab or a focusing click) selects the whole value. Typing filters to hex digits (#and any non-hex stripped), caps to channel width, and live-applies the moment the draft is complete (3/6 digits, or 4/8 withalpha); an incomplete draft reverts on blur. The hex box is the field's single tab stop — the swatch is mouse-only (tabindex=-1). Picker dims (not removed) whenreadonly. The swatch tile is square (a definitewidth: var(--stage-control-height), notaspect-ratio— a derived width gives the shrink-to-fit trigger button no intrinsic size, collapsing it); the hex box hasmin-width: 0and the whole fieldmax-width: 100%so the box (not the tile) shrinks in place and the full#RRGGBB[AA]never overflows.- Popover behaviour. The popover anchors to the whole field (via
Popover.Anchor), not just the swatch — so opening right clears the swatch and a left flip clears the hex box instead of covering it. Placement followsFieldLayoutContext:'right'(flips left) in a panel/module,'bottom'in a'bar'; both centred so the arrow points at the field. It's controlled (opensignal) and stays open until an interaction lands outside both the field and the popover —onInteractOutsidecallspreventDefault()when the target is inside the field, so typing in the hex box never dismisses it; Enter/ Escape in the box closes it. Kobalte'sPopover.Arrow(size 8, matching the tooltip) auto-matches the popover fill/border;gutteris the anchor→arrow-tip gap (Kobalte offsets the content past the arrow), set slightly negative so the tip laps the field edge.
- Popover behaviour. The popover anchors to the whole field (via
- Icon fields — compact set right-anchors in a content-width column
(
grid-template-columns: minmax(0,1fr) auto, like boolean/colour) so the label keeps max room and a full compact set never wraps at any tier; overflow is the field's>4 → expandedswitch (COMPACT_MAX), not a squeezed row, and expanded wraps purely by width (no hard count).
Readonly #
Every value field takes readonly?: () => boolean. It maps to the native
readonly attribute where one exists (text/number/textarea, ColorField's hex
input) and to disabled otherwise (select/boolean/slider, ColorField's swatch).
- A readonly text-like input is inert (
.stage-input[readonly]: no caret,pointer-events/user-select: none) and visually distinct via SOLID colours — lighter fill (--stage-color-input-readonly-bg), muted text, softer border. Dim readonly with colour, neveropacity/alpha (a translucent slider knob shows the rail through it). - Dropped from the tab order: text-likes keep the native
readonlyattr (still tabbable) so they get explicittabindex={-1}; thedisabled-mapped ones are auto-skipped; NumberField's steppers are already-1(Kobalte).
Field focus (id + focusField) #
Every value field takes an id?: string — a focus/scroll deep-link handle (wire
a link, a toast action, a "jump to setting" button to it). focusField(id)
focuses the field's primary control and returns whether it found one.
- For
Field-based fields theidlands on the row (.stage-field-control);focusFieldfinds the first real focusable inside it (skipstabindex=-1chrome — swatch / steppers / clear ×), so it lands on the input / select trigger / etc. BooleanField (bespoke) puts theidon its Kobalte.Inputdirectly (via a conditional{...(id ? { id } : {})}spread — Kobalte typesidas non-optional, which tripsexactOptionalPropertyTypes).focusFieldhandles both: if the id'd element is itself focusable it's focused, else a descendant is. - Browsers scroll a focused control into view, so deep-linking to a field in another (scrollable) panel scrolls that pane to it for free.
- Decoupled by design:
focusField('x')works from anywhere (a toast handler, a different panel) — no ref threading, since the id is a global DOM handle. - Collapsed sections expand first. A collapsed
Sectionkeeps its body mounted but height-collapsed +inert(see §5), so the field is always in the DOM —focusFieldfinds it, walks up expanding any collapsedSectionancestors (clicking their toggles — synchronous render clearsinert, persists like a manual expand), then focuses. No registry needed, and it works even for a section never opened since load (its fields are mounted all along).
7. Overlay singletons (src/overlay/) #
stageTooltip (overlay/tooltip.ts) #
The library's first global singleton. One self-mounting "speech-bubble" popout.
- A single host element is lazily built on first
show()and re-parented (never re-created) into the triggering anchor's nearest.stage-rootso that root's tokens cascade (fallbackdocument.body). Positioned by@floating-ui/dom(computePosition+autoUpdate+arrow,strategy:'fixed'so it escapes paneloverflow).@floating-ui/domis a direct dep (auto-externalized by vite.config; already transitive via Kobalte). - Four levels (
info/success/warn/error) →stage-tooltip-{level}skins from--stage-tooltip-{level}-{bg,text,border}(color-mix tokens). Fade viastage-tooltip-open+transitionend, with a ~300ms fallback timer (reduced-motion drops the transition, so teardown can't depend on it). owner-gated:hide(owner)/owns(owner)/requestHide(owner)no-op unless that owner holds the bubble (owner = the control element), so a field only touches its own.- The controller owns auto-hide, uniformly for fields / info icons /
imperative callers, via a document
pointermove: it hides the moment the pointer leaves the union bounding box of anchor + bubble (padded ~6px), which spans the gap between them — no grace timer, no flicker (Nuffle's improvement over the original 120ms grace). The open bubble ispointer-events:auto(inert otherwise) so hovering it reads as the bubble, not the field beneath. A capturepointerdownhides on an outside click but never when the pointerdown is inside the anchor or bubble — clicking the element a tooltip points at never hides it. A degenerate (zero-size) rect is treated as "inside" so hover-hide just doesn't engage (happy-dom / pre-layout). - A removed anchor hides the bubble. A disconnected anchor measures as that same
zero rect, so the hover check can't catch it. The
autoUpdatecallback checksisConnectedinstead, since its observers fire on removal even with no pointer movement (touch). - Per-session options are written on every
show(), so none leak into the next session.placementsets the side floating-ui prefers.interactive: falsesets.stage-tooltip-non-interactive(pointer-events: none) and shrinks the hover area to the anchor, since the bubble can't be hovered.onHidefires when hiding starts, or whenshow()replaces the session, before any new state is set. It is cleared before it's called, so re-entrantshow()/hide()can't double-fire it, andhidecalls it last so a re-entrantshow()finds the exit ready to cancel. - No crossfade between anchors. An outside
pointerdownstarts the fade before aclickcan callshow(), and the library can't tell that the press belongs to another anchor. Consumers who want a seamless move callshow()inpointerdown(same dispatch, so no frame paints between). A staletransitionendor exit timer can't end the new session, because teardown only acts whilestate === 'leaving'. keepOpen?: () => boolean(a field passes() => focused) suppresses pointermove-hide so a focused field survives a stray mouse-out; blur routes throughrequestHide(immediate, gated bykeepOpen).- The arrow is a rotated square bordered on only its two outer edges (a triangle,
not a diamond), side chosen from the resolved placement. One element ever —
hidereleases content but leaves the host in the DOM. SSR-guarded.
createFieldValidation (controls/createFieldValidation.ts) #
The one factory the 7 value wrappers share (no 7× focus/hover duplication).
Computes a reactive result (helpers FIRST, first failure wins, then validate
in a try/catch → console.error + treat-as-valid; never trap the user,
mirrors persist.ts). Exposes borderLevel (error/warn only — info/success
don't recolour) and controlHandlers (spread on the control:
onPointerEnter/onFocusIn show with keepOpen: () => focused, onFocusOut →
requestHide; the controller owns hover-out hide, so no onPointerLeave).
textHelpers is the shared minLength/maxLength/pattern check.
Validation REPLACED the old error?: () => string prop (breaking): the
inline .stage-field-error line + .stage-input-invalid CSS are gone; the cue
is now the border recolour (a data-stage-level attr the hook sets on each
control, keyed in fields.css after the focus rules so it out-specifies the focus
white) plus the tooltip. validationState stays on the Kobalte primitives for
ARIA only.
Info bubble (controls/InfoIcon.tsx) #
info?: string | (()=>string) renders the bundled Phosphor info glyph
(InfoIcon, from the library's own icon set; override via IconComponent) beside the caption
(only when info set AND name!==''). The icon is mouse-only
(tabindex=-1 — power users tabbing fields skip it). Hover opens after a 500ms
delay; click opens immediately (chiefly touch, no hover) and never hides.
Hiding is the controller's unified auto-hide; createInfoTrigger only schedules
the open. Shares the one tooltip — last show() wins; info-close does NOT
restore a validation tooltip (re-shows on next focus/hover). NB BooleanField's
icon sits inside the Kobalte .Label, so its onClick must
preventDefault+stopPropagation or the click also toggles the boolean.
Mixins (FieldValidationProps / TextValidationProps / FieldInfoProps) are
exported and composed onto each value field's props.
modal (overlay/modal.tsx) #
The library's second global singleton — one app-level modal for settings-style
flows. Unlike the self-mounting tooltip it needs a one-time <StageModalHost>
mounted inside the consumer's .stage-root; the stageModal controller (open
/ close / isOpen) pokes section-level signals the host reads.
opentakes a thunk() => JSX.Element, NOT pre-built JSX — it's called from event handlers (no Solid owner), so eager JSX would orphan its effects; the host instantiates the thunk under its own owner via<Dynamic>.- The host renders a
fixed inset-0backdrop (dims + swallows clicks: they neither close nor pass through — no backdrop close handler, and being topmost it blocks pass-through) wrapping a centred.stage-modal-container. - The container is a portal mount target the field popovers + tooltip
recognise alongside
.stage-root— the three.closest('.stage-root')lookups became.closest('.stage-root, .stage-modal-container'), so fields opened inside the modal portal into the container (the closer match) and stack above the dim. The backdrop's--stage-z-modal(1050) opens a stacking context lifting the whole modal above page-level popovers while keeping a modal field's tooltip on top. - The container is deliberately NOT a nested
.stage-root— that would re-assert the:where(.stage-root)default tokens and drop consumer overrides (a switch's accent went grey inside the modal); as a plain descendant it inherits every token cleanly. Itsmax-widthis1.5×the expanded panel width (token-coupled). - The host owns a11y: focus trap (hard Tab wrap within
focusables(container)— the Panel tab-stop rule but wrap, not hold), Escape closes, focus restored to the opener on close (captured inopen(), restored in acreateEffect(on(content))), plusrole=dialog+aria-modal+ optionalopen(..., { label })→aria-label. - It hosts a single
Sectionin itsonClose(dismissable) mode (§5).
toast (overlay/toast.tsx) #
The library's third global singleton — a corner stack of transient notifications.
Like the modal it needs a one-time <StageToastHost> inside .stage-root; the
stageToast controller (push / dismiss / clear) pokes a module-level
toasts signal the host renders via <For>.
push(opts)returns an id;dismiss(id)/clear()remove. Levels reuse the tooltip/validation vocabulary (info/success/warn/error) → left-border accent off--stage-color-{level}. Default levelinfo.messageisstring | (() => JSX.Element)— a thunk (not eager JSX) for rich content like a link/button, rendered via<Dynamic>under the bubble's owner so any effects are scoped (mirrorsstageModal.open's thunk rule). The JSX's ownonClickwires the toast into the app: callstageModal.open(...)to deep-link to a dialog, or focus a control. (Focusing a library field is a DOMfocus()on itsaria-labeled input — controls don't expose aref/idyet.)- Auto-dismiss is owned by each bubble, not the controller: a
ToastBubbleruns its own auto-dismiss timer (default 5000ms;autoDismissMs: nullpins it) and clears it inonCleanup, so removing a toast (any path) tears its timer down. Keeps the module-level controller timer-free. Hover pauses the whole stack: a modulehoverCount(per-bubbleonMouseEnter/Leave, guarded to count once and to release on unmount-while-hovered) drives apausedaccessor; acreateEffectper bubble banks the unspent time and stops the clock whilepaused, then resumes with what's left on un-hover — so reading one toast never lets its neighbours slip away under the pointer. Hovering any toast pauses every toast (count, not bool, so sliding between bubbles stays paused). - Exit is two-phase so the fade-out can play — except the ×. Each entry carries
a reactive
leavingsignal.dismiss(id)(the API + the auto-dismiss path) sets it (the mounted bubble adds.stage-toast-leaving→ slides+fades) then schedules the realremove(id)afterTOAST_EXIT_MS(220ms — the JS mirror of the CSS exit duration; a much slower--stage-toast-motiononly loses the fade tail, the data still clears). The entry object is kept identity-stable (a per-entry signal, not a replaced object) so the keyed<For>doesn't remount the bubble mid-fade.dismissis idempotent (a second call while leaving is a no-op). Three paths remove outright (no animation): the dismiss × button (demands immediate feedback — a user clicking close wants it gone),clear()(a reset), and the overflowclearHidden(hidden toasts have no mounted bubble to animate). Enter plays inversely: the bubble mounts in.stage-toast-enter(edge-offset + transparent) and a double-rAF flips it to the resting state so the slide-in transition runs. Tests drive the exit delay (and the hover-pause clock) with fake timers. - The host is
pointer-events: none(empty space passes clicks through; bubbles opt back in), anaria-live="polite"region, and takes aplacementprop (bottom-rightdefault; bottom corners grow upward viacolumn-reverse, top corners downward).--stage-z-toast(1060) sits above the modal (a toast fired from a modal action shows over the dim) but below the tooltip.containedswapsposition: fixed(viewport) forposition: absolute, so mounting the host inside a positioned region (e.g. aCanvasStageviewport, which isposition: relative; overflow: hidden) scopes + clips toasts to that region rather than the screen corner. - The dismiss × is an
IconButtonwith its own skin intoast.css(see §6). - Overflow cap:
limit(default 5) caps the real toasts shown; the excess collapses into one pinned "+N more…" indicator whose × clears all the hidden ones at once.hide('oldest'default /'newest') picks which end collapses — visible bubbles keep the live array order, and the indicator sits at the end the hidden ones came from. Hidden toasts aren't rendered, so their (bubble-owned) auto-dismiss timers are effectively paused until they resurface — fine, since the indicator is the explicit handle for them. - Mirrors the modal's split (singleton + host) rather than the tooltip's
self-mount: a stack of N live bubbles with per-item timers wants a real Solid
<For>under an owner, not a hand-rolled single element.
8. State container (src/state/) #
createEditorStore<T> #
A Solid createStore-backed reactive source of truth with optional debounced
localStorage persistence ({ v, d } wrapper, discard-on-version-mismatch,
deep-merge-over-defaults load) and a typed bind(...path) field binder (depth
1–4 overloads). It is a store + helper, not a schema/registry: fields stay
hand-composed and the consumer owns T's shape; bind only removes wiring
boilerplate (spread {...bind(...)} into the unchanged value field). App
notification is the consumer's createEffect — no subscribe API.
createPersistedToggle(key, default) #
A createSignal-shaped [value, setValue] whose writes mirror to localStorage
(same readFlag/writeFlag kernel as Section/panel collapse). For chrome/
layout state the library doesn't render itself — chiefly split-view stage
visibility, where the app owns the <Show>/mount decision (a canvas tears down
its GL context on unmount, so only the app can own that) but the state belongs
with the other stage-* flags, not the versioned editor-content store. The
returned pair can also feed a controlled collapsed/onToggle slot directly.
The persist kernel (state/persist.ts) #
One shared readFlag/writeFlag pair underpins Section collapse, panel collapse
(EditorPanels.persistKey), and createPersistedToggle. It never traps the user
(read/parse failures are swallowed, mirrored by createFieldValidation).
9. Multi-stage model (sketch) #
The recursive-stage layout (§5 EditorPanels) is still a sketch (playground
demo only). What's proven: recursion via embedded, one shared
createEditorStore, per-stage panel-collapse namespacing via persistKey, and
the library/app split for visibility (createPersistedToggle).
The deliberate asymmetry: a panel collapse is library-owned (stays mounted, shrinks to a strip); a stage's visibility is app-owned structure with library-owned state (the GL-context-teardown reason above).
Open questions before this leaves "sketch":
- Stage naming — no naming/identity scheme yet.
Focus/Tab across stages— resolved (§4): one native traversal spans the whole stage and a nested stage nests in place; the outermost stage canwrapFocusto loop.- Per-stage section persist-key namespacing — panels are namespaced by
persistKey; sections still key globally bytitle.
10. Hard rules — what NOT to do #
- Don't add a section registry.
EditorPanelstakes children (thesis §1). - Don't hard-
importa third-party icon set (orclsx, or any styling helper) in library source. Chrome renders the bundled Phosphor set (@dolphinspired/solid-stage/icons); consumer-supplied icons come through theIconComponentprop. Styling is CSS variables (thesis §4). - Don't add SSR shims or
typeof windowdances beyond theResizeObserverguard. Browser-only (thesis §5). - Don't write
!importantanywhere. Token defaults use:where()(specificity 0) so consumer overrides win without it (thesis §3). - Don't re-export
test-utils.tsfromindex.ts. Internal only. - Don't broaden the public API surface without updating this doc and
README.md.