11 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| Demo authoring guide — v2 layout | guide | human + agent | canonical — the locked template for component demo pages | current | migrated from web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md (2026-07-02, docs-book F7.5) |
Demo authoring guide — v2 layout
This is the canonical reference for authoring component demo pages under
web/routes/uix/components/{name}/+page.svelte.
The v1 template (6 tabs: Live · API · Morfo · Sema · Recipe · A11y) only
exposed variant/size/color-role. It hid most of the system: the
31-scale palette, border width/style, shape families, depth
planes, the motion catalog, and the perceptual sema firma + sound. The
v2 layout exposes the whole surface through a shared harness, so every demo
teaches the same axes and no demo re-implements (or omits) them.
The canary is
web/routes/uix/components/button/+page.svelte. It is the worked reference for every tab. When in doubt, copy from Button.
1. The shared harness — web/routes/uix/lib/
| Module | What it is |
|---|---|
harness.svelte.ts |
Constants (palette · border · shape · depth · density · motion · durations · easings) + DemoTrace (the data-event MutationObserver) + signatureFor() (wraps resolveSignature). |
PalettePicker.svelte |
The color control — intent axis + color axis (hierarchy roles and the 31 palette scales), gated by the intent↔color doctrine. |
SystemAxes.svelte |
The foundation axes applied to the stage: density · scaling · mode (local) · dir · border-width. |
MotionPanel.svelte |
The motion catalog — preset chips + a live sample + duration/easing tokens + reduced-motion preview. |
SemaPanel.svelte |
The sema firma — per event the resolved sound (pitch/gain/contour/envelope) + haptic (kind/pattern) + active channels + ▶ play (audible). |
Per-component creativity is still forbidden: a demo declares the component's data and composes the harness; it does not invent its own panels.
2. File frame + imports
Every demo lives at web/routes/uix/components/{kebab}/+page.svelte, wrapped by
web/routes/uix/+layout@.svelte (the @ resets layouts; it provides the
chrome + boots ActiveUix + imports generated/palette.css so the full
palette resolves). The outermost element is <div data-uix-canvas-inner>.
import { X, type XVariant, type XSize } from '$uix/eidos/components/{name}';
import { SHAPE_FAMILIES, type ComponentColor } from '$uix/eidos/lib/types';
import { compileMorfo } from '$uix/morfo';
import { xMorfo } from '@/uix/morfo/components/{name}';
import { getActiveUix } from '$active-uix';
import PalettePicker from '../../lib/PalettePicker.svelte';
import SystemAxes from '../../lib/SystemAxes.svelte';
import MotionPanel from '../../lib/MotionPanel.svelte';
import SemaPanel from '../../lib/SemaPanel.svelte';
import { DemoTrace } from '../../lib/harness.svelte';
const uix = getActiveUix();
3. Tabs
Nine tabs, in this order. System · Motion are unconditional for any visual component; Services is conditional (see §9).
Live · System · Motion · Sema · Services · API · Morfo · Recipe · A11y
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
A tab is never silently dropped for laziness. Omit Services only when the component consumes no app service. Omit Motion/System only for a pure data/logic component with no visual surface (rare) — and say why in the README.
4. The always-on stage carries the System axes
The stage sits between the header and the tablist (so Sema ▶ play fires on a real instance and the trace stays visible across tabs). In v2 the stage wrapper also applies the System axes, so changing density/mode/border in the System tab is visible immediately on the live preview:
<div data-uix-stage>
<div
data-uix-stage-area
bind:this={stageRef}
data-density={density}
data-theme={mode === 'inherit' ? undefined : mode}
data-mode={mode === 'inherit' ? undefined : mode}
dir={dir}
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px;`}
>
<X …>…</X>
</div>
<div data-uix-stage-trace>…</div>
</div>
The trace uses DemoTrace:
const trace = new DemoTrace();
let stageRef = $state<HTMLElement | null>(null);
$effect(() => { if (stageRef) return trace.observe(stageRef); });
5. Live tab — the component API plane
Controls grouped by the layer that owns them (soma / eidos badges), then the
reactive snippet(s). Color is part of the API plane (it's a real prop), so
it lives here via PalettePicker, not in System:
<div data-uix-subsection-head><span data-uix-layer-badge="eidos">eidos</span> color · intent ↔ palette</div>
<div data-uix-controls>
<PalettePicker bind:intent bind:color />
</div>
Then variant / size / rounded (radius magnitude) / shape
(SHAPE_FAMILIES), the layout flags, and the snippet. Snippet parity is
mandatory (the snippet shows the active prop values; see §12).
6. Color — the full palette, not just roles (replaces v1 §12.7/§12.8)
The v1 norm "every chip enumerates the role/variant/size union" was too narrow:
it hid the palette. The component color prop accepts the full surface
(theming/reference.md §4 / §25.5):
- intent (
neutral · affirm · fulfill · risk · threat · loss) — evaluative. - color — under
intent='neutral': a hierarchy role (primary · secondary · tertiary · neutral) or any of the 31 palette scales (teal,plum,gold, …). This is the Radixcolor="grass"per-instance override.
PalettePicker renders all of this and enforces the doctrine: when intent is
evaluative it dims the color section (intent wins). The picker drives the
real prop — <X color="teal"> — not a theme override.
Wiring a new component's palette. Today only components whose recipe has the
palette-{slot}cascade resolvecolor="teal"visually (Button is the pilot). When you add a new color-bearing component, extend its recipe'spalette-*declarations with the 31-scale cascade (thepaletteScaleDeclshelper inlib/recipes/base.ts), regenerate (npm run generate:eidos-css), and the picker just works.
size/variant chip parity rules still hold: enumerate the component's full
declared union, narrowings justified in the README.
7. System tab — the foundation plane
<SystemAxes> binds the foundation knobs that are not component props and
applies them to the stage (§4):
<SystemAxes bind:density bind:scaling bind:mode bind:dir bind:borderWidth />
- density (
data-density) · scaling (global zoom) · mode (light/dark, local to the stage so you can preview dark while the docs stay light) · dir (RTL) · border-width (--border-width).
Radius / shape / depth that a component exposes as a prop belong in Live,
not here. Only surface a System axis with a visible effect (the §12.6 rule):
e.g. border-style is omitted for components that hardcode solid.
8. Motion tab
<MotionPanel note="…"> renders the whole preset catalog applied to a live
sample, with retunable duration/easing tokens and a reduced-motion preview. The
selected preset name is the value a component's motion prop takes where it has
one. For components without a motion prop (most), the panel still teaches the
system + the component notes its own motion (press squeeze, the event firma).
9. Sema tab — firma + sound (first-class, not a flat table)
<SemaPanel> shows, per declared event, the resolved firma via
resolveSignature — sound (pitch / gain / contour / envelope / centroid /
roughness) + haptic (kind / intensity / pattern) + active channels + hold — and
a ▶ play that emits the real signal on the live target (audible with
Semantics → Sound on in the topbar):
<SemaPanel
{actions}
{uix}
boundIntent={intent}
getTarget={() => stageRef?.querySelector('[data-{component}]') ?? stageRef}
/>
actions = [...compileMorfo(xMorfo).actions.byName.values()]. When the morfo
declares 0 events, SemaPanel renders the justified empty state — the absence
must be defended in the component README, never assumed.
10. Services tab (conditional)
Render it when the component consumes an app service:
- langs (i18n) — translatable
textsin the morfo (every component with user-facing copy). Switch language in the topbar to see them follow. - format (
FormatNumber/FormatDate/RelativeTime, date/time fields) — locale / currency / unit controls. - announce (live region) — components that fire
requiresLiveRegion.
Button's Services tab is minimal (just langs for label/loading). A date or
number component expands it with locale/currency/unit pickers + their own
demoLocale state (see the service-component demos).
11. API · Morfo · Recipe · A11y
- API — every public prop: name / type / default / description; eidos additions tagged.
- Morfo — iterate the raw morfo (
xMorfo.parts,xMorfo.events) + the compiled contract (compiled.contracts.dataAttrsByPart). Parts table + per-part data/aria/keyboard + events table. - Recipe — selectors tagged morfo-backed / eidos-only. Note the palette cascade for color-bearing components.
- A11y — keyboard + ARIA contract; for overlays, the intent → role / aria-live mapping.
12. Snippet parity (unchanged from v1)
The snippet must match the live preview's real API surface: every rendered
field/part/prop appears, and active control values are reflected. Two parser
rules: split '</' + 'script>', and a <Foo> element name inside a <p> must
be written <Foo> (and { … } literal text wrapped in {…}).
13. Verification checklist
Before declaring a demo done:
npm run checkreports 0 errors for that file (the repo has unrelated pre-existing errors; filter to your paths).- Every tab renders; every control changes something visible on the live preview.
- Color: the palette picker drives the real
colorprop (a swatch click →data-color="{scale}"→ the right scale renders). Verify a couple of scales in light + dark. - Sema: every ▶ play stamps
data-event(watch the trace) and is audible with sound on; the firma values matchSEMA_MAPfor the family/intent. - Motion: presets apply to the sample; reduced-motion preview neutralises them.
- Snippets reflect the active control values.
Screenshots in this preview.
preview_screenshothangs on the docs shell (backdrop-filter + the large DOM). Verify rendered values withpreview_eval+getComputedStyle(decisive for color/size/border), and for a real pixel check use the Chrome MCP on your own tab. A frozen CSStransitionin the headless renderer can report a stalebackground-colormid-flight — read--_{component}-bg(the target) or settransition:noneto confirm the resolved value.