Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage AGENTS.md
9.0 kB · 177 lines
Markdown
at main

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 #

  1. import '@dolphinspired/solid-stage/styles.css' once.
  2. 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.
  3. 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 />.
  4. On Vite, set optimizeDeps: { exclude: ['@kobalte/core'] }, or a Kobalte popup such as the ColorField picker throws React is not defined in dev. See docs/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 FieldGroup or Fieldset. 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 a readonly input with a no-op onChange.
  • A sentence in a Label needs layout="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.
  • TextField fires onChange per keystroke. Keep a draft if the handler does real work.
  • Show state with active, not by swapping variant.
  • A dock section's render reruns on every move, so state that must survive goes outside it.
  • A panel that opens on demand is an app panel (appPanels option; its sections name it as dock): open it with layout.openPanel(id) and close it with layout.closePanel(id). Its sections never leave it. closable: false drops its ×; framed: false drops its header strip (the × moves to the top section, and the panel stays at its declared x/y). Don't park sections in a dock that nothing renders.
  • Global shortcuts must step aside for library controls. SelectField is a button with a portaled list, so checking for INPUT and SELECT isn'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 a Section. 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} not value={n()} — pass the accessor.
  • stageModal.open and stageToast.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.render is a thunk, and DockZone/useSectionLayout must run under SectionLayoutProvider (not in an eager top-level const).
  • BooleanField does not wrap BooleanControl (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).