Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs architecture.md
143 kB · 2,007 lines
Markdown
at main

Architecture — @dolphinspired/solid-stage #

This is the canonical record of the library's architectural decisions: the load-bearing principles, the cross-cutting systems, every primitive's design, and the gotchas worth not relearning. It is both reference and roadmap.

Read this before adding a feature; update it whenever an architectural decision is made. When a change touches the public API, update README.md too.

@dolphinspired/solid-stage is a small library of Solid + Three editor-chrome primitives, extracted from osrs-engine-study and 3d-model-exporter after both independently grew the same shape.


1. Architecture thesis #

The load-bearing principles. Everything else is downstream of these; a new feature that violates one is almost certainly wrong.

  1. Pre-composed children, not registries. EditorPanels takes leftPanel?: JSX.Element, not leftSections: SectionId[]; Section takes children, not a field schema; every bar is a pre-composed Toolbar (or plain JSX), not a bar config object. Naming the collapsible container Section does not imply a section registry / id-keyed lookup — that mapping is app-specific. Composition stays child-based throughout.

  2. Controlled + uncontrolled, mirrored everywhere. Anything with state (collapse, active tab) accepts optional open?: () => boolean + onToggle? with an internalOpen() signal fallback. Canonical accessor: isOpen = () => props.open?.() ?? internalOpen(). In uncontrolled mode the internal signal seeds from / writes back to localStorage where a primitive persists (Section collapse, panel collapse); controlled mode leaves persistence to the parent (never caches).

  3. CSS classes + variables, no inline styles. Every classname is stage-*. Every dimension and colour is a CSS custom property on :where(.stage-root). Consumers override by setting variables on any container. The library never writes !important — :where() (specificity 0) lets consumer overrides win without it. The library ships no inline-style escape hatch; the style hook is a class prop (e.g. Label).

  4. Bundled curated icon set + consumer-supplied icons. The library ships a small, curated subset of Phosphor icons as a tree-shakeable subpath export (@dolphinspired/solid-stage/icons), generated from the @phosphor-icons/core dev dependency into src/icons/generated/ (committed; codegen in scripts/gen-icons.mjs driven by the scripts/icons.json manifest). Phosphor glyphs are filled vector shapes (not strokes, viewBox 256), so they anti-alias cleanly at small chrome sizes — the reason we moved off lucide, whose thin strokes softened at 14–16px. The codegen's default weight is bold (a single caret reads thinner than a double at the lighter weight; bold evens the chrome out); a manifest entry overrides per icon via @weight (circle@fill). The set spans library chrome glyphs plus common editor-app icons (copy, lock, eye, trash, …) so consumers — and the playground — reach for icons without a separate package. Each is a Component<{ size?; class? }> carrying a stage-icon stage-icon-<name> class, so it sizes off --stage-icon-size (the .stage-icon base rule) and is addressable in CSS/tests. Library chrome renders these (collapse/disclosure carets, close X, Plus, the modified-tab dot as a fill-weight CircleFill, scroll/step carets) — no hand-rolled SVGs or unicode glyph affordances, and no house icons (the fill weight gives a real solid disc). The artwork is vendored under Phosphor's MIT licence; the notice is retained in NOTICE (shipped in the package). Icon-button components take an IconComponent prop so consumers can pass their own icons (any Component<{ size? }>) — never hard-import an icon set or a styling helper (incl. clsx) in library source. Scope boundary (the line for "add this icon?"): a glyph is bundled only when a library component renders it, plus the small band of generic editor-app icons already shipped. Domain icons stay consumer-supplied — media-transport (play/pause/skip), app-specific marks, rotation-axis arrows, etc. There's no principled stopping point once we bundle for one consumer's feature, and the IconComponent / thunk contract already lets a consumer pass any icon at zero maintenance cost; a curated Phosphor subset we hand-maintain for every use case would be strictly worse than the consumer importing lucide-solid directly. A domain glyph earns a slot only if/when a library component starts rendering it. (Surfaced by the dsts-viewer media-transport request, 2026-06-18 — declined for bundling on this basis.)

  5. Browser-only. ResizeObserver + getBoundingClientRect are not SSR-safe. Documented in README.md. Do not add SSR shims or typeof window dances beyond the one typeof ResizeObserver !== 'undefined' guard.

  6. The chrome is centre-agnostic; the world is a canvas. EditorPanels takes an arbitrary center: JSX.Element — the chrome could host any middle (DOM page-builder preview, code buffer, node graph). Do not narrow EditorPanels to canvases. But CanvasStage and the focus model that treats "the world" as a single non-tabbable boundary are canvas-specific: the multi-layer world/HUD/pointer z-stack + viewport ResizeObserver describe a viewport onto a continuous spatial scene. The tell: the moment a centre is editable DOM it stops being "a world" (an opaque region you never tab into) and becomes the primary tabbable content — which inverts the focus model. So wherever the focus/Tab design needs an answer for the centre, assume a canvas; everywhere structural, keep the centre open.

Scope discipline #

A feature that lives in only one consumer stays consumer-side until a second consumer asks for it (the field framework cleared that bar — both apps had grown it). Everything else is consumer-side or deferred; see docs/feature-ideas.md for the deferred list. Resist scope creep.


2. Theming & class conventions #

  • .stage-root is the token scope. All stage-* custom properties are declared on :where(.stage-root). Anything that must read those tokens has to live inside a .stage-root subtree.
  • Portalled content mounts into the nearest .stage-root, not document.body — otherwise tokens don't resolve and consumer overrides don't cascade. It also escapes panel overflow clipping and sits above chrome via --stage-z-popover. The modal extends this: lookups are .closest('.stage-root, .stage-modal-container') so fields opened inside a modal portal into the container (see §7).
  • Resolve the portal mount at open time, not once at mount. SelectControl, ColorControl, and SearchControl each .closest('.stage-root, …') off their trigger/input to find the mount target. Caching that once in onMount is a trap: on mobile WebKit onMount can fire before the trigger is spliced under .stage-root (first seen when Panel still detached collapsed bodies), so .closest returns null and it falls back to document.body. Cached, the dropdown then portals outside the token scope forever (transparent bg, UA-black text, no overflow scroll) until a remount. The fix: keep the onMount resolve to prime the common case, but re-resolve on every open (onOpenChange for Kobalte Select/Popover; the panelOpen effect for the hand-rolled Search panel) — the trigger is always connected and laid out by then. Desktop hid this (insertion beats onMount there). Guarded by a mount-outside-then-move-under-root test.
  • The surface ramp derives from one root, parent-by-parent. --stage-color-bg is the single root background the whole grayscale ground keys off. Each surface/border token mixes off its logical parent, not flat off the root: panel lifts off bg, popover = panel, header darkens off panel, header-hover lifts off header, border lifts off panel, border-strong lifts off border, border-hover lifts off strong, the input well darkens off panel, control-hover (the shared hover fill) lifts off the well, readonly lifts off the well. Same color-mix pattern as --stage-color-accent-readonly and the tooltip skins; each token stays individually overridable, so retinting one parent flows to its children while pinning any single token stops the chain there. The accent family (--stage-color-accent + -readonly/-on-accent/-text-on-accent/ -icon-on-accent) is the separate "primary" axis and does not derive from bg.
  • Surface colours are opaque — no alpha. --stage-color-modal-backdrop is the one intentionally translucent token (a scrim must let the dimmed page show through). Every other background is a solid, legible colour; don't reintroduce alpha into the ramp.
  • Foreground text derives from one base, like the surfaces. Three tiers (--stage-color-text / -text-label / -text-muted); label + muted mix off --stage-color-text (darkened toward black), so retinting the base text colour carries the whole type-colour scheme.
  • Content on an accent fill has its own colours, defaulting to the text colour. --stage-color-text-on-accent (the "on"/active button label, the highlighted dropdown option, the active search row) and --stage-color-icon-on-accent (icons in those spots, defaulting to -text-on-accent) both default to --stage-color-text — so the default mid-dark accent is unchanged, but a theme with a bright accent + light text overrides them to a dark colour without touching every rule (the legibility gap that surfaced in a bright-accent consumer). Distinct from --stage-color-on-accent, which is not part of the text group: it's the bright fill for the decorative switch thumb / checkbox tick (a knob/check that reads best at full contrast regardless of theme), pinned to contrast the accent, not the text.
  • Icons carry a colour token too. --stage-color-icon defaults to currentColor so a bundled .stage-icon matches its surrounding text and any context that sets its own colour (the accent info bubble, the select indicator) still wins — .stage-icon's rule loads in base.css, before every consumer of it. Set the token to recolour all icons at once; -icon-on-accent handles the accent-fill case. No glyph-colour hover shift anywhere by design — section headers are flat (their former hover-brighten was dropped when the chevron became the collapse control and the rest a drag grip), so don't add a --stage-color-text-hover nothing consumes. (--stage-color-section-header-hover-bg survives only as the close button's press-flash, not a header hover.)
  • --stage-colorpicker-* is dimensions, not colour. The ColorField popover's width/area/track/thumb sizes use the colorpicker stem (no hyphen after color) deliberately, so a --stage-color-* grep for themeable colours skips them. Don't reintroduce a --stage-color-picker-* form.
  • Corner rounding is one derived knob. --stage-border-radius (default 2px) is the base control radius (inputs, the select trigger, accents — badges, checkbox, slider track/fill, colorpicker area/track — and focus outlines); --stage-border-radius-lg (calc(× 2) → 4px) covers the button family, the tooltip, and the select/search dropdowns; --stage-border-radius-modal (calc(× 4) → 8px) the modal. Set --stage-border-radius: 0 for a fully-square aesthetic, or override -lg/-modal directly to decouple. Pills/circles (slider/switch/colorpicker thumbs, the switch track) are intentional shapes (50% / 9999px), not corner-rounding, and don't derive. Flush-joined controls (ColorField hex+swatch, SearchControl input+stepper) square their inner seam and round only the unit's outer corners so the radius reads as one pill.
  • Class taxonomy: .stage-field-* is the shared row/layout vocabulary; .stage-{type}-* is one control's own internals; a trailing segment is a state/variant modifier (-active, -on, -center).
  • Z-index tokens: --stage-z-popover (1000) < --stage-z-modal (1050) < tooltip (1100). The modal backdrop's --stage-z-modal opens a stacking context lifting the whole modal above page-level popovers while keeping a modal field's tooltip on top.
  • Motion derives from one base, like the surfaces. --stage-motion-duration (0.18s) + --stage-motion-ease (ease) are the base knobs; each animated interaction has its own --stage-*-motion token — a <duration> <easing> pair fed straight into transition — that derives from the base (--stage-panel-motion, --stage-section-motion, --stage-toast-motion). A consumer retunes all motion by overriding the base, or one interaction by pinning its own token; setting any duration to 0s disables that motion. Animations are on by default; @media (prefers-reduced-motion: reduce) zeroes --stage-motion-duration (in tokens.css), collapsing every derived transition to instant. The tooltip predates this and keeps its own --stage-transition-fade + reduced-motion block (§7) — don't fold it in. The three animated interactions: Panel collapse transitions grid-template-columns on .stage-panels (the strip↔expanded column track slides; only columns animate, rows snap) — the panel box fills its track (width:100%, overflow:hidden) while .stage-panel-inner holds the true width pinned to the pane's outer edge, so the body slides in and out clipped rather than reflowing. Section collapse uses the grid-template-rows: 1fr↔0fr height trick on .stage-section-collapse (the .stage-section-body grid item carries overflow:hidden/min-height:0, padding lives one level in on .stage-section-body-content so the row reaches a true zero); the body is also inert when closed — that, not display:none, is now what drops collapsed content from the tab order + a11y tree (compatible with the height animation, and focusField clears it on expand). Toast slides+fades via opacity/transform between a resting state and an edge-offset .stage-toast-enter/.stage-toast-leaving state (offset direction keys off the host placement); the exit is two-phase (§7).

3. Sizing — the type-scale tier system #

The library owns the type scale: row height is a deliberate, font-size-coupled primitive, not the emergent "whichever control is tallest" it once was.

The two coupled tokens #

  • --stage-font-size — base field font-size. The library sets its own; it no longer font: 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-input text/number/colour-hex + .stage-select-trigger): pin height: var(--stage-control-height).
  • Short controls scale via tokens derived from control-height (matching today's normal values): --stage-checkbox-size + --stage-slider-thumb-size use a constant control-height − 6px inset (14px at normal; an even inset off the even control-height so they integer-centre in the row); the switch derives width/thumb/travel from --stage-switch-height (control-height − 5px); the slider's rail thickness is control-height / 3.5, its label allowance is in ch (tracks font-size) and its thumb-clearance gap is thumb-derived.
    • Exception — booleans don't shrink at small. The switch / checkbox value is a single glyph, so shrinking it past normal costs legibility for no density gain. .stage-size-small pins --stage-checkbox-size / --stage-switch-height back to their normal-tier px (so the switch width + thumb re-derive); the label still shrinks off --stage-font-size, so a small boolean row is a smaller label beside a normal-sized control. normal / large are unchanged.
  • The whole button family joins the grid: shared .stage-button base is box-sizing: border-box, each skin sets height: var(--stage-control-height) with vertical padding 0 (horizontal kept). The base owns the button font — font: inherit (family) + font-size: var(--stage-font-size) (tier) — so button text and a text button's content-driven width scale with the tier (no width set on text buttons; fill / width: 100% opts into stretch).
  • Icon buttons are square at control-height (width = height; the old sm/md width split is gone — classes kept for API compat, now equivalent). The glyph is sized by --stage-icon-size = control-height − 4px → 12 / 16 / 20px at small / normal / large. The control-heights are all even (16/20/24), so an even-inset icon always integer-centres in the square button (content box ch − 2px border, icon ch − 4px → exactly 1px each side, no sub-pixel lean — the reason the tiers are even). Applied via .stage-button svg (CSS overrides the icon's own attrs).
  • Section header is one control-height (min-height, horizontal-only padding) so it lines up with its field rows. Styled as a field label rendered bold — --stage-color-text-muted, --stage-font-size (tier), font-weight: 700. Header height + font read the inherited (panel) tier, so a section re-tiering its own body never resizes its header.
  • One bar height derives from control-height: --stage-bar-height: calc(control-height + 8px) is the single height EVERY bar snaps to — toolbars, the title bar, the tab menu, sub-bars. It's border-inclusive: each bar is box-sizing: border-box so its 1px separator is absorbed (no off-by-one between a TitleBar and a toolbar-stack row). The bar component carries the height now, not the slot: .stage-toolbar and .stage-title-bar each set min-height: var(--stage-bar-height). The full-width .stage-top-bar/.stage-bottom-bar slots are transparent cells that only stretch their child to fill width (see TitleBar below) — they no longer enforce height or paint a background. A custom bar host (e.g. a TabMenu wrapper that isn't a Toolbar) opts in with min-height: var(--stage-bar-height) + box-sizing: border-box. (Replaced the old --stage-toolbar-height; the unused --stage-top-bar-height / -bottom-bar-height / -sub-pane-height fixed tokens were removed.)
  • Expanded fields ≈ 2 control-heights + gap (the .stage-field-name-row pins one, the value control below pins another).
  • Wrapping value controls tile to whole control-heights: .stage-icon-row (expanded IconButton/IconToggle) wraps with row-gap: 0, so K buttons over M lines occupy exactly M × control-height (horizontal column-gap unchanged).
  • TextareaField is exempt — multi-row by design, sized by its own --stage-textarea-{min,max}-height.

The cascade & the size prop #

The three tier tokens inherit, so setting a tier (a .stage-size-* class or the raw token) on any element resizes everything inside it; a nested element can re-tier to override its ancestor. This is the whole cascade — stage → panel → section → field, plus bars — for free.

size?: ControlSize ('small' | 'normal' | 'large') exposes it at three levels:

  • EditorPanels.size (whole stage, the .stage-panels root); leftSize / rightSize forward into the internal Panels (consumer composes panels via leftPanel/rightPanel, not a Panel directly).
  • Panel.size (header + sections + fields).
  • Section.size — applied to .stage-section-body, not the section root — so it resizes the section's fields while the header keeps the inherited panel size (an info-dense section can go small without its header falling out of line). This body-scoping is why no structural change was needed.

Helper layout/size.ts sizeClassList(size) returns a 3-key classList fragment (all keys present so Solid's diff swaps tiers reactively); spread into a classList. .stage-size-normal exists as a reset because the default is unclassed, so a subtree under a small/large ancestor needs an explicit class to return to normal.

The derived-token cascade gotcha #

The control-derived tokens (switch/checkbox/slider/icon) are declared on a selector list (:where(.stage-root), .stage-size-{small,normal,large}, mirrored in the touch media), not just the root. A custom property's var() is substituted at the element where it's declared, then the resolved value inherits. Declaring --stage-switch-height: calc(var(--stage-control-height) − 5px) only on the root would freeze the inner control-height at the root's value, so a subtree size override would resize the row + direct-use controls (text/number/select height, label font — these read the tokens directly at the use-site) but not the derived-token controls (switch/checkbox/slider/icon) — the label would shrink while the toggle stayed put. Re-declaring per tier re-resolves each at its own control-height. (So a raw --stage-control-height override on a non-.stage-size-* element only resizes the direct-use controls; use a .stage-size-* class / size prop to move everything.)

Root font size #

.stage-root and the tier classes set font-size: var(--stage-font-size), so custom markup reads at the tier's size rather than the page's. The library's rows set it themselves anyway. The playground must not set a body font size, because that hides any gap here.

Touch-device input bump (iOS zoom) #

iOS Safari auto-zooms (and won't cleanly zoom back) on focus of a text input whose computed font-size is under 16px — it reads the real computed size, so there's no faking it smaller. fields.css @media (pointer: coarse) bumps just .stage-input's font-size to --stage-font-size-touch (16px) so focusing one never zooms the viewport.

Deliberately narrow — three earlier-rejected over-corrections inform the shape:

  • Font only, not the whole tier. Only the focusable text inputs zoom (not buttons / select trigger / slider / checkbox), so only they need the bump. The row, label, bar height, icons, and panel width stay dense.
  • No row growth. A bare 16px font would clip in the 20px control-height box (content = 20 − 2px border − 4px padding = 14px). Single-line inputs also pin line-height: 1 and padding-block: 0, so 16px seats inside the unchanged row (18px content) — no reflow, no clip. (Textarea is excluded from that rule: it's multi-row and grows.)
  • pointer: coarse, not max-width. The zoom is a touch-device problem, not a narrow-window one — a desktop window dragged narrow shouldn't fatten its inputs. (An earlier max-width: 640px version also wrongly set --stage-bar-height on the field row, an ancestor-cascade dead end — the bar never saw it.)

The small tier opts out: a 16px line can't fit a 16px-tall bordered row, so .stage-size-small .stage-input reverts to its dense size and accepts the zoom — a conscious tradeoff for a UI that chooses small inputs on touch (the revert rules sit after the bumps and win on source order at equal specificity). Per control, touchSizing={false} adds .stage-touch-sizing-off, which the bump selectors exclude (:not(.stage-touch-sizing-off)); the shared TouchSizingProps

  • touchSizingClassList (controls/touchSizing.ts) thread it through the five input-bearing controls (text / number / textarea / color / search) and wrappers.

Narrow stage: one side panel at a time #

EditorPanels closes one panel rather than letting two open panels clip off a narrow stage (a phone). On by default (exclusiveWhenNarrow), since a clipped panel is never wanted; codified from dsts-viewer, which built the same rule by hand.

  • Fit test: left expanded width + right expanded width + minCenterWidth (320) ≤ stage width, the same in either PanelMode. Widths are measured off each panel's .stage-panel-inner, which keeps its expanded width while collapsed, so tier tokens and dragged widths count without a px number in props. An unmeasured stage (0 width, no ResizeObserver) counts as roomy, so headless tests see no change.
  • Opening closes the other side first, inside one batch. Solid runs effects on each write outside a batch, so opening first would let the crowding effect see both open and close the right panel, possibly the one just opened.
  • Both open anyway → the right one closes. This is re-checked on mount, on a ResizeObserver over the root and both inners, and when either collapse state changes.
  • Exclusivity only, no auto-overlay. dsts-viewer also switches panels to overlay when cramped; the library leaves the mode to the consumer.

Text assistance off; Enter is "done" on single-line inputs #

  • No spellcheck / autocorrect / autocapitalize / autofill on any text input. Editor values are identifiers, not prose. Number gets these from Kobalte's defaults (plus a numeric keyboard, where capitalization is moot); text / textarea / color / search set them by hand. Search also needs it because its input text is transparent at rest, but the spellcheck underline still paints through.
  • Single-line inputs label the Enter key (enterkeyhint): done on text / number / color, go on search (Enter picks the highlighted result). Without a hint, the on-screen key is a grey "return", which suggests a newline. Textarea is the one place Enter is a newline.
  • A touch-started session releases focus on Enter (and, for search, on a pick by tap). Desktop keeps focus so the Tab flow and search's committed rest survive, but a focused input keeps the on-screen keyboard open. One shared helper, createTouchKeyboard() (controls/touchKeyboard.ts), owns the rule. Arming is per session, not per device: a touch/pen pointerdown arms it and blur disarms it, so a keyboard tab-in on a touch device (an iPad with a keyboard) still keeps focus. Check === 'touch' || 'pen', never !== 'mouse': synthetic and some real pointer events carry an empty pointerType. Enter during IME composition is ignored. Color keeps its own Enter handler, which blurs on every device because Enter also closes its picker.

4. Focus & keyboard model #

  • Focus indicator is white, not the accent (--stage-color-focus, default #ffffff, consumer-overridable). A pink (accent) ring blends into accent-filled controls, so focus is intentionally accent-independent.

  • The white focus border is the TOP of an explicit border-state hierarchy, enforced with CSS cascade layers — for value controls AND buttons. A control's border shows several competing colours — resting, a hover lift, a checked/active accent, a readonly dim, an error/warn validation flag, and the white focus border. Rather than juggle selector specificity (fragile, and it caused the old hover-erases-focus bug), the precedence is declared once in fields.css as an ordered layer list — a later layer wins over an earlier one regardless of specificity:

    @layer stage-cb-rest, stage-cb-hover, stage-cb-selected, stage-cb-readonly,
           stage-cb-validation, stage-cb-button, stage-cb-focus;
    

    So for value controls, focus ▸ validation ▸ readonly ▸ selected ▸ hover ▸ rest. The white active-control border overrides every other border state while focused; because each state's selector (:focus, [data-stage-level], [data-checked], …) only matches while that state holds, the cascade falls back to the next-highest matching layer the instant a state clears — tabbing out drops :focus, so a still-invalid field restores its error border with zero extra code. readonly sits above selected so a checked+readonly toggle dims instead of staying bright; a disabled control isn't focusable/validated, so its position below those never bites.

    • Value controls use the fine-grained tiers; buttons fold in via one stage-cb-button layer. A value control's state set is small and semantic, so it tiers cleanly. A button's border cascade is skin-driven and mature (subtle overrides active, tone variants, ghost overrides the action hover, on-toggle overrides toggle hover), already correct by specificity + source order — so it's kept verbatim in one bucket between validation and focus. Folding it into a layer changes exactly one thing: the focus layer now overrides every button state too, so a focused button shows the white border regardless of hover/active and restores its skin border on blur — the same rule every control follows. A borderless button's transparent rest border simply recolours white; the mouse-only untabbable buttons are excluded (:not(.stage-button-untabbable)) and show no cue.
    • The layers own only border-color. Each control sets its border width/style in its own (unlayered) rule and its resting colour in a layer — never a border: shorthand or an unlayered border-color, either of which would beat every layer (unlayered always wins over layered) and break the hierarchy. Hover for the value controls is fill-only (never touches the border); only the select trigger lifts its border on hover, via the hover layer.
    • The colour field folds in as one box. It reads as a single input (a hex box + a swatch tile), so its hex↔swatch seam is dropped (the hex loses its right border, the swatch its left) and the hex's three borders + the swatch's three form one continuous perimeter. Both segments' colours join the layers (the hex is a .stage-input, already there; the swatch's rest/readonly/validation and the coordinated :has() focus highlight are added to the matching layers), so the whole unit recolours together — border-strong at rest, error/warn when the hex is invalid, white when the hex is focused or the picker is open.
    • Joined controls (input + attached action buttons) coordinate the seam. An input-action button shares each seam with the previous element (border-left: none, so the seam is the previous element's right border). On focus it whites its own three drawn borders, and the seam it borrows is whited on the previous element by two coordinated focus-layer rules: the button↔button seam via a direct-sibling .stage-input-action:has(+ .stage-input-action:focus), and the input↔first-action seam via a wrapper scope (:is(.stage-input-group, .stage-search):has(.stage-input-action:first-child:focus) .stage-input-grouped — the search input nests inside .stage-search-field, so scope, not a sibling combinator). The input's own focus already whites its right seam edge. So a focused segment shows a complete white outline with real border recolour — no box-shadow ring anywhere. (The same pattern would extend to any future joined group: a focused segment claims the seam it borrows.)
    • Not in the border system at all: the glyph micro-affordances (info icon, scroll hint, search-clear) and tabs keep their own lighter-weight cue (an offset outline / accent bar), by design — they're not bordered controls.
  • Every interactive spot resolves to exactly one affordance: it either lights up on hover (preferred) OR takes the hand cursor (pointer) — never both, never neither. The rule is keyed off what a click does: if clicking there produces a stage change, the spot must advertise it one way. Lighting up is the default (a desktop editor's pointer stays a plain arrow); the hand cursor is the fallback for a spot where a hover lift reads unclearly.

    • Lights up (arrow cursor + :hover brighten): the button family, popover triggers (select / colour-swatch), menu option rows, and tabs — each a :hover lift off the one shared fill --stage-color-control-hover-bg (plus --stage-color-border-hover on bordered ones), so the "lit" state is uniform and reads clearly against the panel. Also the boolean/range value controls: switch + checkbox brighten on hover (off → the neutral hover tone; on → the accent lifted toward white via --stage-color-accent-hover) including while checked, because a click there turns them OFF — a real change; and the slider lights its thumb whenever the bar is hovered (the grabbable knob is the affordance), to a tone a step below white. These three hover-brighten the fill only, never the border, on purpose: the border carries the control's state (a checked/accent fill, an error/warn validation colour, the focus border), so leaving it to hover would fight those — the fill lights and whichever border the state hierarchy picks stays. That hierarchy (the cascade layers above) is exactly what keeps hover and the focus/validation borders from clobbering each other. The slider fill stays below pure white so that white focus border reads against the lit knob. An active toggle that untoggles — IconToggle / IconToggleField, both of which turn a pressed button OFF on click — likewise stays lit while active (marked with the stage-icon-button-untoggles class → an active-:hover brightens the fill to --stage-color-accent-hover, per tone).
    • Hand cursor (pointer, no hover lift): the collapse affordances — the static section-header toggle, the section chevron button, and the panel collapse bars (header / edge / collapsed-strip) — and a module (section) header's action icons (the subtle glyph-brighten alone read as no affordance, so these take the pointer instead and drop the hover styling; their active-fill + press-flash still show as state). A pointer is the strong native convention for "click to collapse", so these stay flat + pointered. The colour swatch joins this group: it opens the picker popover on click and doesn't hover-brighten (it lights only while the picker is open), so cursor: pointer is what signals "click to open". The info icon is the one control on cursor: help — it isn't a stage change at all, it reveals a tooltip, so the help cursor is its honest hint.
    • Neither (no change on hover): a radio-style active option — a ButtonGroupControl option matching value() — because re-clicking it does nothing; only its inactive siblings light up. This is the one case that reads as "already selected, no-op", so it deliberately advertises nothing. (Contrast the untoggling toggles above, which DO change on re-click.)
    • Menu rows keep their accent [data-highlighted]/aria-selected bar; the glyph-only micro-affordances (scroll hint, search-clear ×, info icon) stay on their lighter-weight brighten by design. Disabled/data-disabled states resolve to default. Grab/resize/move affordances keep their grab / col-resize / ew-resize cursors — including the section drag-handle title, now a pure grab move handle (it no longer doubles as a collapse target; the chevron is the sole collapse control).
  • :focus, not :focus-visible, on every value control — the cue shows on click and tab, deliberately. In dense editor chrome a persistent "you are here" marker makes the next Tab predictable and doubles as "last thing I touched". (macOS Safari/Firefox don't focus <button>s on click, which would make the button ring keyboard-only there, so BaseButton forces focus() on click; value fields focus on click natively everywhere.)

  • Section header is the deliberate :focus-visible exception — 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). BooleanField sits on a native checkbox (Space toggles, Enter doesn't), so it adds an Enter→toggle onKeyDown (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=-1 on 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-body is itself tabindex=-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 onFocusIn on the body calls scrollIntoView({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 onKeyDown on .stage-panel-scroll-content handles 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 title attribute anywhere — the library emits zero OS tooltips. The browser's title hover bubble is unstyleable, slow to appear, and inconsistent across platforms, so it's banned. Every control's accessible name is an aria-label (icon-only buttons via their title prop, which maps to aria-label) or the visible label text. BaseButton renders only aria-label; the labelled skins (ActionButton / ToggleButton) have no title prop at all — their visible label is the name. A styled, deliberate hover tooltip goes through stageTooltip (the floating-UI singleton) instead — see §7. IconButton's title does 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 EditorPanels is position: fixed; inset: 0 (owns the window). embedded makes it position: relative to fill a parent slot — the recursion point, since center is JSX.Element and already accepts another EditorPanels.
  • A root stage turns off page overscroll. html and body get overscroll-behavior: none through :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-action on 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 off props directly. 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} />} where titleLeft is a JSX const), 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 a const. 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 center holds two embedded stages (e.g. left-panel-only primary + right-panel-only secondary), all sharing one createEditorStore. See §8 for the open questions.
  • persistKey caches each panel's collapse state (uncontrolled only) under stage-panel:<persistKey>:left / :right, via the shared readFlag/writeFlag kernel in state/persist.ts. Seed precedence: cached → defaultLeftCollapsed → expanded; a controlled panel never caches. persistKey is the per-stage namespace — give each stage in a split a distinct one.
  • Forwards leftSize/rightSize and leftToggleVariant/rightToggleVariant into 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 — a large stage gets wider panels. (--stage-modal-max-width keys 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's width, 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-host and .stage-panels are 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 (see docs/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's panelsLocked flag, the same lock that freezes floating panels, and layout.reset() restores it. It lives in stage-panel:<persistKey>:<side>:tab rather 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 internal resetCount signal, 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 with trackSavedKey, stored under stage-layout:<persistKey>:saved-keys to survive a reload, and reset clears 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 — Panel renders a wide transparent gutter on the inner edge and thickens that edge's divider to 3px as the affordance (no colour hint; the col-resize cursor 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. EditorPanels owns 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 + persistKey persists the dragged px under stage-panel:<persistKey>:left:width / :width (via readNumber/writeNumber/removeStored), written on release only; pass leftWidth/onLeftResize (+right*) to control it, and the parent owns persistence. A undefined internal width means "use the token" (the default state when no defaultLeftWidth/defaultRightWidth is set). The dragged px also overrides --stage-expanded-width on the panel's cell so the fixed-width .stage-panel-inner follows the track (not just the cell). During a drag the root carries .stage-panels-resizing to suppress text selection.

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 inert and visibility: hidden when collapsed. The visibility transition 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, so focus() 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. closeWhenInert closes them from the DOM state rather than a collapse signal, so it covers Section, Panel and any consumer-applied inert alike, with no context to thread through EditorPanels' slots. stageTooltip watches its anchor the same way.
  • .stage-panel-strip-button is absolutely positioned over the inner, and its fill fades in with the collapse. .stage-panel-strip is position: relative to anchor it, which is safe because the resize gutter (which must anchor to the cell) only exists while expanded.
  • .stage-panel uses overflow: clip, not hidden. A hidden box is still a scroll container, so focusing a field while the panel opens scrolls it sideways for good.
  • .stage-panel-strip has contain: 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 hitTestDock skips [inert] zones. Any new rect-based hit testing needs the same check.
  • focusField expands collapsed panels as well as sections.
  • Effects in a collapsed body keep running, and its content width doesn't change, so a ResizeObserver inside 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↔0fr trick 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 (focusField can expand + focus it; §6). The collapsed body is also inert — that drops it from the tab order + a11y tree (the role display:none used 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 onClose mode: header static (no chevron/toggle/hover), body always open, persistence skipped, a × button top-right calls onClose. This is the shape the global modal hosts; the prop lives on Section generally.
  • headerActions (SectionHeaderAction[]: { icon, title*, onClick, active?, disabled?, tone? }) puts icon buttons on the title bar — each a borderless IconButton (border is never exposed; the header skin is always borderless), with the subtle variant + 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). title is required (icon-only → the accessible name, rendered as aria-label). In a dismissable section they sit left of the ×.
  • The header is a <div> row with three label shapes + a .stage-section-header-actions cluster 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 (dragHandle prop, set by DockZone): 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-handle grip (grab cursor, user-select:none, touch-action:none, aria-hidden since 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 what DockZone'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).
  • 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-focus ring (: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? }. headerActions is forwarded to the underlying Section whether the section is docked or floating, so a movable section keeps full Section capability (no per-state loss). render is a thunk (not eager JSX) so a section's body instantiates under the rendering DockZone's owner — a section that moves docks remounts cleanly, effects never orphaned. dock is 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 underlying Section. 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 whose dock names 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 share floatingPanels: 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 (dock omitted, open/close per 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), via dockAllowed): 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 (setDockOrder skips refused ids, move/dockPanel return before their force-expand so a refused drop can't collapse an accordion's siblings, floatOut refuses app-panel sections) and in the gestures (hitTestDock's accepts option: 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). loadSeed resets 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.
      • openPanel on 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 (StageFloatHost hands the controller its floatRegion via setFloatHost); the panel's mount-time clamp covers a window that shrank since.
      • 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 when sortable !== false and 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: isPanelOpen reads it, openPanel ignores the panel (else it would seat an entry for a hidden panel), it never enters openPanels, and an effect on [open, resetCount] seats it (position + top z) each time it turns true and after a reset clears its entry while showing. reset() writes inside a batch so that effect sees the finished reset rather than re-seating before the entries are cleared. Its × calls onClose (none without one).
      • Declared geometry. Opening position: args → last entry (skipped when movable: false or framed: false, so a fixed panel can't drift from its declared spot after a code change; appPanelMovable is the one rule both the model and the host read) → x/y → float region top-left + 16. width is the panel's authored width (authoredDockWidth returns it), i.e. the width its sections are designed for: panel width and minimum, not w, which stays "the user resized it". Seeding w instead was wrong: a declared width below the library default lost to the designed-width minimum.
      • Frame and close are explicit (framed, closable, both default true). The header strip is the frame: shown iff framed, 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 from appPanelClose (none when closable: false, or controlled without onClose); framed, it's on the strip, frameless, DockZone appends 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).
    • SectionDef.collapsible: false → Section's collapsible={false}: always open, no chevron; with a grip, the grip alone (.stage-section-drag-handle-bare restores the title inset the chevron used to provide). It joins alwaysOpen with the modal and floating-card modes, so nothing is cached. In an accordion dock it's open beside the one open collapsible section: openInDraft doesn't collapse siblings for it.
    • Two locks (sectionsLocked / panelsLocked, each controlled or uncontrolled with a default… seed). The section lock hides grips; the panel lock freezes floating-panel move/resize/dock and the side-panel tab drag. Why split: one locked flag 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's movable: false freezes 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 for reset() 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. dockOrder keeps returning every id, since the model ops index into it.
      • Index mapping. hitTestDock counts 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. So DockHit.index indexes dockOrder (hidden included, ignored section excluded) and move / a reorder / dockPanel take 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 (keeping w), then setDockOrder(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.
    • placements: Record<id, Placement> where Placement = { 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; its panelId keys both this map and the dock of every section it holds. x/y are host-local, z a dense paint rank, w the dragged width (omitted → default CSS width).
    • open: Record<id, boolean> — every section's open state, owned by the layout (not each Section). Every MovableSection renders controlled, so collapse is plain reactive model state — a drop force-expands via one write the section reflects with no remount and no per-Section localStorage 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 via move, defaults to the home dock), reset(). Floating-panel methods: floatingPanelIds() (panel ids sorted by z ascending — paint order), floatOut(id, x, y, w?) (lift id into a fresh panel at x/y — new panel id + move(id, panelId, 0); w seeds 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 if dockId is a floating panel), dockPanel(panelId, dockId, index) (relocate all of a panel's sections into dockId at index in 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). Panel z is re-normalized to a dense 0..n-1 on each floatOut/raisePanel so it never drifts. Panel ids are float-<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 pointerdown and a focusin anywhere in a panel call raisePanel, capture so nothing inside can stop it. Raising only changes z: the host renders panels in creation order, not paint order, because moving a node would drop its focus and reshuffle the Tab order. z only orders panels within one float host: position: fixed makes the host and .stage-panels separate stacking contexts, so nothing in a stage can sit between two floating panels.
    • Floating width comes from the home dock. SectionDef has no width, because a section is designed for its home panel. EditorPanels provides defaultLeftWidth / defaultRightWidth through context around each panel slot, since it doesn't know which docks a slot holds, and each DockZone registers 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. FloatingPanel derives 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 CSS max() so a library-default section follows the size tier. w only ever means an explicit choice (a resize drag, or floatOut'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 inside setDockOrder's produce (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 all MovableSections are controlled now (not just accordion ones), accordion is purely a setter constraint — setExpanded/move collapse 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 (persistKey set): one versioned blob under stage-layout:<persistKey> via savePersisted + a local read/version-gate (loadSeed). The placement map and the open map each seed via seedById(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 a float-N panel id; kept as-is). Replace, not deepMerge, 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 in sections is dropped. Storage is an override: while the arrangement equals the declared defaults the key is removed — so the default layout persists nothing, a reset() 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; floatingPanels restored within the same version, then pruneEmptyPanels drops any container left with no sections). Bump opts.version on 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 (accordion map → open map) each bumped it — a blob holding the old floating-section variant or accordion map is invalid (the arrangement is throwaway, discarded cleanly; the playground is 4).
  • SectionLayoutProvider / useSectionLayout() (layout/SectionLayoutContext.tsx). Context exposing the controller; wrap the whole app, above every stage, so all DockZones 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)}> of MovableSections, and (b) starts a drag on a press of a section's grip. It drops into any leftPanel/rightPanel slot (those already take arbitrary JSX) — EditorPanels needs no change. Each section renders through one stable component per id (MovableSection, a Section wrapper carrying data-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), pointercancel and 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. The lostpointercapture listener must check target === 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-active on <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 z first. Side docks only overlap when an overlay panel covers a nested stage; they have no rank in the model, so elementsFromPoint settles 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. ignoreSectionId leaves the dragged section out of the count, which is what move and a reorder expect (counting it was an off-by-one).
  • Ownership. A module WeakMap<zoneEl, layout> that DockZone fills. 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.body in 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 floating placement variant + floatingIds / setFloating / setFloatingWidth / raise. Phase 4 removed all of that: a floated section is now an ordinary MovableSection inside a floating panel (a dock that floats). The panel-level drop area and floatRegion carry 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-root for the token scope — not inside a stage. Its fixed box is the global coordinate origin for every float's x/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's z-index is calc(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 Section in dragHandle mode, but with leadingAction set — a generic Section capability 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. move writes the section's open[id] to true (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 — no Section localStorage cache, no remount timing to race. Scoped to move (the drop path, incl. magnet redock + cross-dock transfer); a within-dock reorder goes through setDockOrder, which leaves open state alone.
  • Float move + resize + redock.
    • Move + resize + redock is ours. The header grip is a pointer handler (pointerdown on .stage-section-drag-handle → raise + setPointerCapture; pointermove → reposition via setFloating, 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 via move(id, dock, index) (overwrites the floating placement — redock-by-drag reuses move, no new path); otherwise the card floats at its current (already-clamped) position. The two .stage-float-card-resize side-edge grips resize the width via setFloatingWidth (left edge holds the right fixed by shifting x; floored at MIN_FLOAT_WIDTH, ceiled at the viewport; clampIntoView on release). The magnet leading button is the keyboard/click redock.
    • Panel-level drop area. The dock hit-test (hitTestDock/overDock, both via dockDropRect) targets the enclosing .stage-panel-body, not the dock's short content box — so a panel that contains a DockZone accepts 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.

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-zone document-wide (targeting the enclosing panel body, see Phase-2 panel-level drop), so a release over a panel on a different stage redocks there via move(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?: () => DOMRect prop on StageFloatHost is the rest region (a viewport-space rect; default = the host box). The drag live-clamps the preview into it (clampToRegion, shared with the release/resize clampIntoView), translating it into the host-local frame x/y live in. Dock targets are the panel-body rects, hit-tested independently each move and checked in onPointerUp before 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:

  • Placement became uniform { dock; order } (the floating variant 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. StageFloatHost renders <For each={layout.floatingPanelIds()}> of positioned .stage-float-panels, each wrapping a real <DockZone id={panelId}> — so the sections inside are ordinary MovableSections and chevron-collapse + grip-reorder return for free. A panel's z-index is still calc(var(--stage-z-float) + var(--stage-float-z)).
  • The magnet (the redock affordance — rendered as an arrows-in-simple glyph) is just a right-side headerActions entry merged on in MovableSection when layout.isFloatingDock(currentDock) — not a leadingAction, so the disclosure chevron stays and each section collapses on its own. (Phase 2's always-open card used leadingAction to 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 (pointerdown not on its dock-all button → raisePanel + setPointerCapture; pointermove → setFloatingPanel, live-clamped to floatRegion via the shared clampToRegion). On each move it also hit-tests the docks (hitTestDock with excludeDockId, so never the panel's own zone): over a dock it arms a drop
    • previews the shared insertion line (createInsertLine, on document.body at --stage-z-popover 1000 > the float z, so it shows above the panel); over none it clears both. On pointerup: an armed drop calls dockPanel(panelId, dockId, index) — every section relocates into that dock (the source panel then prunes, unmounting the FloatingPanel); otherwise the panel comes to a free rest (clampIntoView). Escape and pointercancel clear the line + drop and put the panel back at its starting x/y. hitTestDock finds 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-resize side edges resize via setFloatingPanelWidth (left edge holds the right fixed by shifting x; floored at MIN_FLOAT_WIDTH, ceiled at the host width). Dock-all iterates dockOrder(panelId) and redocks each — emptying + pruning the panel.

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-tab has a mouse-only × close — a <span role="button"> out of the tab order (middle-click closes too via onAuxClick button===1). An optional + new-tab button renders inside the strip at the end (scrolls with the tabs; only when onNewTab is set) as a real IconButton (a local scalable PlusIcon SVG — no icon pack — subtle variant) so it gets the shared button chrome + focus ring; it stays a tab stop. Overflow surfaces ‹/› scroll arrows (a ResizeObserver over 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 roving tabindex=0 is 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 (onStripKeyDown on the role=tablist strip, guarded to fire only when a .stage-tab is the event target, not the +). The whole tab is the click/select target (no inner button); its .stage-tab-label is a caption, so it's user-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 toggles stage-tabmenu-strip-show-ring when 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 :focus rule — it reads better). Modality is tracked with a tabFromPointer flag (set on a tab pointerdown, consumed in focusin); the gated rule is .stage-tabmenu-strip-show-ring .stage-tab:focus with outline-offset:-1px so 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 transparent border-top keeps layout identical to an inactive tab). The scrollable strip is tabindex=-1 so 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 passes tabs() (+ usually activeId()) and the hooks become notification-only (the menu mutates nothing). onNewTab returns a string label (else 'New tab') or false to veto; onClose returns false to 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-x is hidden on an inactive, unhovered tab; shows on the active tab and on :hover. A tab may set modified (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 (controlled tabs).
  • 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 *Control is the bare widget — a value (value / onChange), its widget-specific props (min/max/step, options/optionLabels, placeholder, alpha, variant, …), readonly, an aria-label, plus a level?: 'error' | 'warn' (→ data-stage-level on 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-slider cluster, the select trigger + portal, the colour hex + swatch + popover, the bare switch/checkbox). No caption, no Field, no createFieldValidation. The shared ControlValidationProps type (controls/createFieldValidation.ts) carries level + the handlers.
  • A *Field is the captioned row that composes the control: it runs createFieldValidation, wraps <Field name … fieldType … info … description …>, and renders the bare control inside the render-prop, threading level={v.borderLevel()} and {...v.controlHandlers} through. The control is the single source of truth for the widget; the field never re-implements it. Example — SliderField is 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's Button primitive only confers button semantics on non-button elements, so wrapping buys nothing. The rewrite targeted complexity/ARIA machinery (listbox nav, slider aria-valuenow, the colour area, spinbutton) — which buttons don't have.
  • SearchControl is also deliberately ours, not Kobalte. Kobalte's Combobox is 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-rolled Portal positioned by @floating-ui/dom (the same dep tooltip.ts uses). It still honours the control contract (data-stage-level, the ControlValidationProps focus/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 a pointer-events:none / aria-hidden overlay that paints the committed value over it while at rest; at rest the input's own text goes transparent so the two don't double (the ::placeholder colour is pinned, else Firefox derives it from the now-transparent text colour and an empty field would show nothing). Omit valueRender and 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 committed SearchItem, because a value seeded from persistence has no picked item — the consumer still derives the rich display from the name. Mirrors SelectField's renderOption (rich trigger), closing the select-vs-search asymmetry.
  • "At rest" is gated on !isEditing(), not committed() — committed() is only true after a pick/step, but the overlay must also paint the unfocused boot/externally-set value (no pick made). isEditing() is isFocused() && !committed(); isFocused was promoted from a plain let to 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) reads isEditing() under untrack so it still fires on value() 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 now TextControl.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. InputAction mirrors SectionHeaderAction (icon or label, title, onClick, reactive disabled); the buttons are real <button>s so they're tab-reachable by default (the established stepper behaviour). SearchControl feeds 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 plain TextControl). The type is public (InputAction); the component is internal (reached via the controls).
  • The search pipeline is factored out and exported — controls/search.ts owns the SearchItem / SearchResult / SearchContext / SearchFn types, createSearchSource (the debounce + monotonic-token + AbortController pipeline exposing { results, loading, schedule, reset, cancel }), and stepThrough (the ◀▶ wrap math). SearchControl is 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 sibling SelectControl for a category filter (its value passed via schedule(query, filter) → SearchContext.filter) and createFieldValidation for the field glue. We evaluated shipping that filter model as a first-class FilterControl and 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.tsx re-exports the search types so ./SearchControl import sites stay stable.
  • Kobalte ships zero visuals — style its parts with stage-* classes keyed on its data-* 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%), top unset) — centre them with box-sizing: border-box + top: calc((track − thumb) / 2).

What field and option rendering guarantees #

  • onChange means a user edited the value. NumberControl drops Kobalte's reports of the value it was just given, on mount and on outside changes, so consumers don't need guards.
  • ButtonGroupControl, IconButtonField and IconToggleField render options by position, with Index. Inline arrays commonly read signals, which builds new objects on every change. A keyed For would remount each button and lose keyboard focus. A static slot still re-renders when its render changes, because reusing it could show stale content.
  • ReadoutField is separate from readonly. readonly means 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 (not ButtonGroup) — the one family member with a *Field partner follows the *Control/*Field symmetry. Buttons are actions, not values, so the standalone members keep plain action names (IconButton, ActionButton, ToggleButton — no Control suffix, no field wrapper). But ButtonGroupControl does have a captioned ButtonGroupField wrapper, and a consumer who has internalised "XControl pairs with XField" would otherwise hunt for a ButtonGroupControl that didn't exist. Discoverability of the pair beat the "buttons aren't values, so no Control suffix" purity. The bare control still emits .stage-button-group (class unchanged).

  • ButtonGroupControl self-alignment: justify?: 'start'|'center'|'end'|'fill'. Opt-in (omit = natural shrink-wrap width, so a Toolbar anchor / field value cell keeps positioning it). When set, the group spans its container (width:100%): start/center/end set justify-content; fill spans + gives the option buttons flex:1 to share the width (replaces the old fill boolean — folded into justify for one knob; kind:'static' slots aren't stretched, since the group can't force flex onto arbitrary JSX). Meaningful in block / 1fr contexts; a width:100% group in an auto-sized host (a Toolbar center cell) has nothing to fill. ButtonGroupField forwards justify (buttons within the value cell) and keeps its own align (a FieldAlign that places the cell) — two layers, two props, deliberately not the same name.

  • primary is the variant default everywhere — a standalone ActionButton and a ButtonGroupControl's label options both default to it. primary is simply "how a button looks normally" (filled input-bg + strong border), and a ButtonGroupControl is just multiple buttons behaving normally — so the group must not silently restyle them. An earlier build defaulted the group to ghost (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). ghost stays an opt-in (buttonVariant="ghost") for when a consumer does want to soften a group.

  • tabbable?: boolean (default true): tabbable={false} sets tabindex=-1, skips click-focus, adds .stage-button-untabbable (:focus { box-shadow: none }) — a mouse-only button (exposed on IconButton).

  • subtle variant on IconButton (.stage-icon-button-subtle) for secondary/meta actions: muted glyph, shape brightens on hover (no bg), :active flashes a faint bg, subtle.active stays bright-shape with NO accent fill. Its rules sit AFTER the default .stage-icon-button* rules so they win on the equal-specificity :hover pairs.

  • a11y is just two ARIA attrs: aria-pressed on the toggles (and on IconButton/ActionButton whenever active is supplied — omitted otherwise so a plain action button stays plain) and aria-label (from title) 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 IconButton with an X, styled by where it sits. A Section's onClose appends one to its headerActions row, so every button there behaves alike. A toast's dismiss has its own skin in toast.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 by bind(); the consumer drives it via a reactive text (Solid props are reactive — no setter API needed). text is a LabelContent = 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}> — Dynamic treats 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's insert tracking 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-label text element — so it renders a bespoke .stage-field-control skeleton (fieldType="label") instead of going through Field. Three layouts: compact (one line, ellipsis; nowrap collapses explicit newlines so they truncate too), expanded (pre-wrap: wraps and keeps newlines with natural prose line-height: 1.4 — multiline reads compactly, not one control-height per line; a single line still reserves one control-height via min-height so a short blurb aligns with its neighbours), bar (one inline content-sized line, no truncation; align is a no-op inline). Props: text, align (compact+expanded only → .stage-label-{center,right}), layout, size, class (the style hook — appended to .stage-label for 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 to minmax(0,1fr) auto so 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 via data-disabled at min/max. steppers={false} hides them; always hidden when readonly. Public API unchanged (value: () => number + onChange(next)); the wrapper adapts to Kobalte's rawValue/onRawValueChange internally.
  • 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 fromPointer flag set on onPointerDown (fires before focus) and cleared each focus.
  • SelectField — keeps its full surface (optionLabels, trailing actions, align, disabled); trigger is .stage-select-trigger (focusable button), Kobalte root is .stage-select, caret/items style off data-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 fires onChange(item) (the full SearchItem, incl. the caller's raw payload) and commits its label. 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. A dirty flag 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 via pick() (the single handoff). ←/→ are not intercepted (native text cursor) — prev/next is the tabbable ◀▶ stepper instead (real buttons, Space/Enter to step stepItems; on :focus they 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) — preventDefault also swallows Enter's insertLineBreak beforeinput (null data), which the committed-rest branch would otherwise treat as a new empty query and clear the field (also guarded in onBeforeInput). Dropdown is hand-rolled, NOT Kobalte — full control of the chrome — portalled into the nearest .stage-root and positioned with @floating-ui/dom. It grows to fit content (the size middleware floors min-width at the input; CSS width: max-content + --stage-search-max-width caps it); autoUpdate runs while open (deferred one microtask in the panel ref so the Portal node is connected before floating-ui walks its ancestors — else getComputedStyle on 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-request AbortController (handed to search via ctx.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; allowEmpty flips that — empty/whitespace on blur stays empty and fires onClear (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 (beforeinput blocks 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: pass stepItems (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 via onChange without opening the dropdown. Position is anchored by the last-committed id (label-match fallback for externally-set values); stepping never records a recent. Recents are library-managed, opt-in via recentsKey (persisted through loadPersisted/ savePersisted, version 1, MRU deduped by id, capped at maxRecents). By default they're the verbatim snapshot; reconcileRecent opts into validating each stored recent against the live source right before display — return it (optionally refreshed) to keep, null to 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 on onMouseDown + preventDefault so the input keeps focus through the click; the default row renders badge/label/description (itemRender overrides). SearchItem / SearchResult / SearchFn / SearchContext are 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 from createSearchSource + SelectControl + createFieldValidation, see Kobalte-wrapping above.)
  • ColorField (compact-only) — value is a hex string without #, UPPERCASE ('8A8A8A'/'8AF'; 8-digit when alpha). 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's hexDraft model 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 with alpha); 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) when readonly. The swatch tile is square (a definite width: var(--stage-control-height), not aspect-ratio — a derived width gives the shrink-to-fit trigger button no intrinsic size, collapsing it); the hex box has min-width: 0 and the whole field max-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 follows FieldLayoutContext: 'right' (flips left) in a panel/module, 'bottom' in a 'bar'; both centred so the arrow points at the field. It's controlled (open signal) and stays open until an interaction lands outside both the field and the popover — onInteractOutside calls preventDefault() when the target is inside the field, so typing in the hex box never dismisses it; Enter/ Escape in the box closes it. Kobalte's Popover.Arrow (size 8, matching the tooltip) auto-matches the popover fill/border; gutter is the anchor→arrow-tip gap (Kobalte offsets the content past the arrow), set slightly negative so the tip laps the field edge.
  • 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 → expanded switch (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, never opacity/alpha (a translucent slider knob shows the rail through it).
  • Dropped from the tab order: text-likes keep the native readonly attr (still tabbable) so they get explicit tabindex={-1}; the disabled-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 the id lands on the row (.stage-field-control); focusField finds the first real focusable inside it (skips tabindex=-1 chrome — swatch / steppers / clear ×), so it lands on the input / select trigger / etc. BooleanField (bespoke) puts the id on its Kobalte .Input directly (via a conditional {...(id ? { id } : {})} spread — Kobalte types id as non-optional, which trips exactOptionalPropertyTypes). focusField handles 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 Section keeps its body mounted but height-collapsed + inert (see §5), so the field is always in the DOM — focusField finds it, walks up expanding any collapsed Section ancestors (clicking their toggles — synchronous render clears inert, 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-root so that root's tokens cascade (fallback document.body). Positioned by @floating-ui/dom (computePosition + autoUpdate + arrow, strategy:'fixed' so it escapes panel overflow). @floating-ui/dom is 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 via stage-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 is pointer-events:auto (inert otherwise) so hovering it reads as the bubble, not the field beneath. A capture pointerdown hides 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 autoUpdate callback checks isConnected instead, 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. placement sets the side floating-ui prefers. interactive: false sets .stage-tooltip-non-interactive (pointer-events: none) and shrinks the hover area to the anchor, since the bubble can't be hovered. onHide fires when hiding starts, or when show() replaces the session, before any new state is set. It is cleared before it's called, so re-entrant show()/hide() can't double-fire it, and hide calls it last so a re-entrant show() finds the exit ready to cancel.
  • No crossfade between anchors. An outside pointerdown starts the fade before a click can call show(), and the library can't tell that the press belongs to another anchor. Consumers who want a seamless move call show() in pointerdown (same dispatch, so no frame paints between). A stale transitionend or exit timer can't end the new session, because teardown only acts while state === 'leaving'.
  • keepOpen?: () => boolean (a field passes () => focused) suppresses pointermove-hide so a focused field survives a stray mouse-out; blur routes through requestHide (immediate, gated by keepOpen).
  • 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 — hide releases 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.

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.

  • open takes 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-0 backdrop (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. Its max-width is 1.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 in open(), restored in a createEffect(on(content))), plus role=dialog + aria-modal + optional open(..., { label }) → aria-label.
  • It hosts a single Section in its onClose (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 level info.
  • message is string | (() => 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 (mirrors stageModal.open's thunk rule). The JSX's own onClick wires the toast into the app: call stageModal.open(...) to deep-link to a dialog, or focus a control. (Focusing a library field is a DOM focus() on its aria-labeled input — controls don't expose a ref/id yet.)
  • Auto-dismiss is owned by each bubble, not the controller: a ToastBubble runs its own auto-dismiss timer (default 5000ms; autoDismissMs: null pins it) and clears it in onCleanup, so removing a toast (any path) tears its timer down. Keeps the module-level controller timer-free. Hover pauses the whole stack: a module hoverCount (per-bubble onMouseEnter/Leave, guarded to count once and to release on unmount-while-hovered) drives a paused accessor; a createEffect per bubble banks the unspent time and stops the clock while paused, 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 leaving signal. dismiss(id) (the API + the auto-dismiss path) sets it (the mounted bubble adds .stage-toast-leaving → slides+fades) then schedules the real remove(id) after TOAST_EXIT_MS (220ms — the JS mirror of the CSS exit duration; a much slower --stage-toast-motion only 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. dismiss is 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 overflow clearHidden (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), an aria-live="polite" region, and takes a placement prop (bottom-right default; bottom corners grow upward via column-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. contained swaps position: fixed (viewport) for position: absolute, so mounting the host inside a positioned region (e.g. a CanvasStage viewport, which is position: relative; overflow: hidden) scopes + clips toasts to that region rather than the screen corner.
  • The dismiss × is an IconButton with its own skin in toast.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 can wrapFocus to loop.
  • Per-stage section persist-key namespacing — panels are namespaced by persistKey; sections still key globally by title.

10. Hard rules — what NOT to do #

  • Don't add a section registry. EditorPanels takes children (thesis §1).
  • Don't hard-import a third-party icon set (or clsx, or any styling helper) in library source. Chrome renders the bundled Phosphor set (@dolphinspired/solid-stage/icons); consumer-supplied icons come through the IconComponent prop. Styling is CSS variables (thesis §4).
  • Don't add SSR shims or typeof window dances beyond the ResizeObserver guard. Browser-only (thesis §5).
  • Don't write !important anywhere. Token defaults use :where() (specificity 0) so consumer overrides win without it (thesis §3).
  • Don't re-export test-utils.ts from index.ts. Internal only.
  • Don't broaden the public API surface without updating this doc and README.md.