AGENTS.md — @dolphinspired/solid-stage #
Compact briefing for an AI agent using this library in a consumer app. Full
human docs are in docs/; this is the fast path plus the
assumptions you can safely make. (If you are developing this library itself,
read docs/architecture.md and CLAUDE.md instead.)
What it is #
Editor-chrome primitives for SolidJS (peer: solid-js ^1.9): collapsible side panels, collapsible sections, a canvas stage, a full set of compact form controls, and three app-level overlays (tooltip / modal / toast). Browser-only (no SSR). Targets property-heavy editor UIs (a canvas in the middle, dense settings on the sides).
Non-negotiable setup #
import '@dolphinspired/solid-stage/styles.css'once.- Wrap the app in an element with
class="stage-root". All tokens are scoped to it and all portalled UI mounts into the nearest one. Nothing themes correctly outside a.stage-root. - Mount overlay hosts once, inside the
.stage-root, for any overlay you use:<StageModalHost />,<StageToastHost />. (The tooltip self-mounts.) The dock model adds<SectionLayoutProvider>+<StageFloatHost />. - On Vite, set
optimizeDeps: { exclude: ['@kobalte/core'] }, or a Kobalte popup such as theColorFieldpicker throwsReact is not definedin dev. Seedocs/quickstart.md.
Read this before composing UI #
docs/guides.md explains how to put the pieces together. The mistakes
that come up most in real apps:
- Stacked fields go in a
FieldGrouporFieldset. Rows have no margin, and a section body adds no spacing, so bare rows touch with no error. Bare custom content is fine. - A value only the app changes is a
ReadoutField, not areadonlyinput with a no-oponChange. - A sentence in a
Labelneedslayout="expanded", or it truncates to one line. - Custom CSS reads tokens. Don't copy their values, and don't add
var()fallbacks, which only hide misspelled names. TextFieldfiresonChangeper keystroke. Keep a draft if the handler does real work.- Show state with
active, not by swappingvariant. - A dock section's
renderreruns on every move, so state that must survive goes outside it. - A panel that opens on demand is an app panel (
appPanelsoption; its sections name it asdock): open it withlayout.openPanel(id)and close it withlayout.closePanel(id). Its sections never leave it.closable: falsedrops its ×;framed: falsedrops its header strip (the × moves to the top section, and the panel stays at its declaredx/y). Don't park sections in a dock that nothing renders. - Global shortcuts must step aside for library controls.
SelectFieldis abuttonwith a portaled list, so checking forINPUTandSELECTisn't enough.
The one distinction that matters: Control vs Field #
Every form widget exists twice:
XField— captioned form row (label + control + description/info/ validation). Put these inside aSection. This is the default choice.XControl— the bare widget only. Use when placing a widget yourself (toolbar, custom layout).
Pairs: TextField/TextControl, NumberField/NumberControl,
TextareaField/TextareaControl, ColorField/ColorControl,
BooleanField/BooleanControl, SliderField/SliderControl,
SelectField/SelectControl, SearchField/SearchControl,
ButtonGroupField/ButtonGroupControl.
Buttons are actions, not values — no Field/Control split: IconButton,
IconToggle, ActionButton, ToggleButton (plus the icon-set fields
IconButtonField / IconToggleField). Label is display-only text, and
ReadoutField is a caption plus a value only the app changes.
Grouping containers (no value of their own): FieldGroup stacks rows;
Fieldset adds an inline caption+rule heading over the stack (the rung between
FieldGroup and a full Section); FieldBar lays fields out horizontally for a
toolbar (grow makes the cluster fluid).
TextField/TextControl take actions — a row of buttons (icon or label,
InputAction[]) seamed to the input's right edge for copy / apply / reset
(the same primitive as the SearchField ◀▶ stepper).
Assumptions you can rely on #
Reactivity convention — values are accessors. A widget's value is a getter
function () => V, not a value. Likewise readonly/disabled are
() => boolean. onChange is a plain (next: V) => void.
const [n, setN] = createSignal(0);
<NumberField name="N" value={n} onChange={setN} min={0} /> // value={n}, NOT value={n()}
Array/record props are passed by value, not as accessors. For SelectField
options/optionLabels, keep them stable — wrap a store/signal-derived array in
createMemo, never a bare getter that re-reads reactive state on every access (that
footgun once recursed Kobalte's Select into a stack overflow; the library now memoizes
defensively, but it's still the right habit). Button option arrays can read signals
inline, since they render by position and keep keyboard focus.
Every *Field accepts: name (caption; "" = no label), value, onChange,
and optionally readonly, layout ('compact'|'expanded'), description,
info (shows an (i) bubble), id (deep-link handle), validate.
Every *Control accepts: value, onChange, readonly, aria-label,
level ('error'|'warn'), and the validation handlers onFocusIn/onFocusOut/
onPointerEnter.
Controlled + uncontrolled, mirrored everywhere. Stateful things (panel/section
collapse, tabs) take optional state-accessor + change-handler; omit them to let
the component self-manage and persist. A controlled side panel can be asked to
close by the library: on a stage too narrow for both panels, EditorPanels calls
onLeftToggle(true) / onRightToggle(true) (exclusiveWhenNarrow, default on).
Theming is CSS variables. No inline-style colour/size props. Override
--stage-* tokens on .stage-root. Defaults are zero-specificity, so overrides
win without !important. Three size tiers via size="small|normal|large" or a
.stage-size-* class; they cascade and scale font + control height together.
Readonly: pass readonly={() => true}. Maps to the native readonly attr or
disabled; shown via colour, drops out of tab order.
State helper: createEditorStore<T>({ defaults, persist? }) returns
[state, setState, bind]. Spread {...bind('path', 'to', 'field')} into any field
to fill value + onChange. React to changes with createEffect. Don't expect a
subscribe API — it's Solid.
Color values are hex without #, UPPERCASE ('8AF' / '8A8A8A', 8 digits
with alpha). The input boxes display a leading # — presentation only, never
part of the stored value.
Icons: import { Copy, Lock, X, ... } from '@dolphinspired/solid-stage/icons'. Every
icon prop is an IconComponent (= Component, i.e. any component renderable
with no props) — pass the component itself (icon={Copy}), bundled or your own;
never call it. Sizing/colour is CSS (.stage-icon / --stage-icon-size /
--stage-color-icon), not a prop. For "any icon by name", the stageIcons()
preset (@dolphinspired/solid-stage/vite) wires unplugin-icons so
import X from '~icons/<pack>/<name>' works tree-shaken + auto-.stage-icon.
Common shapes (copy these) #
// Minimal stage with a store-bound field
const [state, setState, bind] = createEditorStore<S>({ defaults, persist: { key: 'app', version: 1 } });
<div class="stage-root">
<EditorPanels
center={<Canvas />}
leftPanel={<Section title="Settings"><FieldGroup>
<TextField name="Name" {...bind('name')} />
<SliderField name="Zoom" {...bind('zoom')} min={0} max={100} />
</FieldGroup></Section>}
/>
<StageModalHost />
<StageToastHost />
</div>
// Imperative overlays (call from anywhere)
stageToast.push({ message: 'Saved', level: 'success' });
stageModal.open(() => <Dialog />); // a THUNK, not <Dialog/>
Gotchas #
value={n}notvalue={n()}— pass the accessor.stageModal.openandstageToast.push({ message })take thunks (() => JSX), not eager JSX (they're called outside a Solid owner).- Overlays need their host mounted once, or nothing appears.
- The dock model's
SectionDef.renderis a thunk, andDockZone/useSectionLayoutmust run underSectionLayoutProvider(not in an eager top-levelconst). BooleanFielddoes not wrapBooleanControl(label-association reasons) — both exist, just not by composition.
Full docs #
docs/README.md indexes everything: quick start, concepts,
the full control/layout/state/overlay/dock reference, theming, icons, guides
(custom control, validation, custom search, deep-linking), and consumer testing
(the Kobalte .jsx Vitest inline recipe).