15 KiB
Demo authoring guide
This document is the canonical reference for authoring component demo
pages under web/routes/uix/components/{name}/+page.svelte. The
template is locked — every demo must follow it. Per-component
creativity is forbidden; the only thing that varies is the data the
component declares.
The canary template is the drawer demo —
web/routes/uix/components/drawer/+page.svelte. When in doubt, copy
from drawer and adapt.
1. File location + frame
Every demo lives at web/routes/uix/components/{kebab}/+page.svelte.
The file is wrapped by web/routes/uix/+layout@.svelte (the @
resets intermediate layouts) so the chrome — top bar, sidebar rail,
canvas frame — is provided. The demo only renders the canvas inner.
The outermost element of every demo is:
<div data-uix-canvas-inner>…</div>
2. Required imports
import {
X,
type XSize,
type XVariant
// …other public types
} from '$uix/eidos/components/{name}';
import { compileMorfo } from '$uix/morfo';
import { xMorfo } from '@/uix/morfo/components/{name}';
import { getActiveUix } from '$active-uix';
const uix = getActiveUix();
Multi-instance components (toast) also import createToaster (or the
analogous instance constructor). Components with 0 semantic events still
render the Sema tab; the tab states that Morfo declares no events. A
0-event demo is only valid after the component README explicitly justifies
why the component is passive or why its actions do not belong to Sema. Those
pages do not need getActiveUix unless they offer interactive playback.
3. Tabs
Six tabs in this order:
Live · API · [morfo badge] Np·Me · [sema badge] N · Recipe · A11y
The Sema tab is never hidden. When the morfo declares 0 events, render
an explicit empty state instead of making the absence invisible. Do not
assume 0 events is correct because the current morfo has no events array:
the component must first pass the Morfo/Sema audit in
src/uix/eidos/components/{name}/README.md. The badge counters use
partsList.length and events.length.
Tab union:
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
let tab = $state<Tab>('live');
There is no Translates tab and no Anatomy tab — both were
inventions that have been removed.
4. Header
<header>
<div data-uix-eyebrow>{Category} · {Name}</div>
<h1 data-uix-page-title>{Name}</h1>
<p data-uix-page-lede>
{one-paragraph summary — what the component does, how many parts
and events, anything notable about its semantic shape}
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<!-- additional pills: variants, sizes, intents, apg link, scope -->
</div>
</header>
5. Live preview is always rendered
The stage sits between the header and the tablist, not inside the Live tab. This is critical so that:
- The Sema tab's
▶ playbuttons can fire on a real instance. - The trace strip stays visible across tab switches.
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<X bind:open …>
…
</X>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>{instructional copy — "open the X to see events"}</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span><span data-uix-stage-trace-event>{entry.event}</span> · {entry.family}{entry.intent ? ' · ' + entry.intent : ''}</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>{state-key}</span> {String(stateValue)}
</span>
</div>
</div>
6. MutationObserver trace
Watch data-event on the stage subtree. The runtime stamps it via
the sema engine before each event fires:
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
For multi-instance components (toast Item) the observer still works: each Item gets its own stamps because the runtime is per-instance.
7. Live tab — per-architectural-layer subsections
The Live tab groups controls by the layer that owns them. There is
no [sema] subsection in Live — sema events live in their dedicated
Sema tab.
Controls
── intro paragraph: layer-grouped, links to Sema tab for events
── [soma] props · headless behavior (subsection)
── [eidos] props · visual treatment (subsection)
── (additional groups: Group / Item / Demo content / Header etc.)
── [soma] somaSnippet pre/code (reactive $derived)
── [eidos] eidosSnippet pre/code (reactive $derived)
Subsection headings carry layer badges:
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · headless behavior
</div>
Both code snippets are at the bottom of the Live tab, in this order:
soma first, eidos second. Each in its own data-uix-code block with
data-uix-code-head showing the layer badge + a one-line caption +
language tag.
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · ARIA + behavior only</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
8. Snippet derivation pattern
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as X from '$soma/components/{name}';",
' let value = $state(...);',
'</' + 'script>',
'',
'<X.Provider …>',
' …',
'</X.Provider>'
]
.filter(Boolean)
.join('\n')
);
Two non-obvious rules:
'</' + 'script>'is split to prevent Svelte's parser from closing the demo's own<script>block..filter(Boolean)lets you writesomeProp && ' someProp'inline —falseentries drop out cleanly so the rendered snippet only shows the props the consumer actually passed.
8.1 Snippet parity is mandatory
The snippets are part of the demo contract. They must match the live preview's real API surface and data contract:
- If the live preview renders a field/part, the snippet must render it too.
- If the live preview uses a schema, the snippet must declare the same visible fields, imports, validators and defaults. Do not show a reduced schema while the preview validates extra fields.
- If a control changes a prop (
size,variant,validationBehaviour,disabled, etc.), the snippet must reflect the active value. - If a snippet is intentionally minimal, the live preview must also be minimal or the snippet caption must say it is a separate minimal example. Component demos should prefer parity over abbreviated examples.
For form demos specifically:
- Real-time validation is
validationBehaviour: 'onChange'. - A demo that starts in
onChangemust use initially valid defaults unless it explicitly documents that it is demonstrating an initially invalid form. Form.AutoFieldsexamples must keep SIUM metadata, fields and defaults in sync with the manualForm + Fieldexample.
9. Sema tab — events + interactive playback
Header with <span data-uix-layer-badge="sema">sema</span>. One table:
name / family / verb / sequence / intent / play. The play button
emits via the active uix's EngineSemantic onto the real DOM target:
<button
data-uix-play
onclick={() => {
const target = (
stageRef?.querySelector('[data-{component}-{eventTargetPart}]')
?? stageRef?.querySelector('[data-{component}-{fallbackPart}]')
?? stageRef
) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}
>▶ play</button>
effectiveIntent resolves the morfo's intent declaration:
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent = typeof intentDecl === 'string'
? intentDecl
: (intentDecl ? intentBoundProp : undefined)}
(Static literal → use it; fromProp binding → use the demo's local prop value; absent → don't pass intent.)
10. Morfo tab — declarative contract
Sections in this order:
- Header table: name / kebab / scope / apg / parts.length / events.length
- Parts overview table: kebab / marker / element / role / archetype / states / optional
- Per-part subsection (only when the part declares any data / aria / keyboard)
- data-attrs table: attr / values / source kind
- aria-attrs table: attr / source kind / condition / severity
- keyboard table: key / action — key wrapped in
<span data-uix-kbd>
- Events declaration table: name / family / verb / sequence / intent / target / prewrite / commit
Iterate raw morfo (xMorfo.parts, xMorfo.events) for declaration
data — NOT compiled everywhere. The compiler exposes
compiled.contracts.dataAttrsByPart.get(kebab) for data-attrs only.
ARIA + keyboard come from raw morfo. Cast for type narrowing inside
the each block:
{@const partAny = rawPart as unknown as {
kebab: string;
aria?: ReadonlyArray<{
attr: string;
value: { kind: string };
condition?: { when: string; prop?: string; part?: string };
severity?: string;
}>;
keyboard?: ReadonlyArray<{ key: string; action: string }>;
}}
When the morfo has no events (icon, tooltip), hardcode <td>0</td>
for events.length — xMorfo.events?.length TypeScript-errors
because the property doesn't exist on the const-narrowed type when
omitted.
11. Recipe + A11y tabs
Brief tables.
- Recipe = list of selectors with morfo / eidos source tag —
every selector is classified as
morfo-backed oreidos-only. - A11y = keyboard table + ARIA contract table. For overlay components also include intent → role / aria-live mapping when the morfo derives them.
12. Critical pitfalls
12.1 <Foo> element name in <p> parses as HTML
Writing Toggle is single-part — `<Toggle>` IS the button. inside
a <p> produces a Svelte parse error — it tries to close the <p>
because <Toggle> looks like an element. Use <Toggle> instead.
12.2 Curly braces inside literal text
Svelte interprets { … } in template text as a JavaScript
expression. Wrap in template literal expression:
<!-- bad -->
<td class="type">Snippet<[{ checked: boolean }]></td>
<!-- good -->
<td class="type">{`Snippet<[{ checked: boolean }]>`}</td>
12.3 Object.assign on Svelte component constructors
Bulk Object.assign(Root, { Trigger, Content, … }) causes a Svelte 5
hydration glitch where children re-mount on hydration (button appears
then disappears). The disciplined fix in eidos/components/*/index.ts
is explicit per-property assignment — see
src/uix/eidos/components/README.md rule #7 and the drawer
index.ts for the canonical pattern. Until every eidos package
adopts it, watch for this when adding new compound parts.
12.4 Children snippet self-shadow
When a wrapper renders <Provider>{@render children?.()}</Provider>,
the children prop must NOT be re-declared as {#snippet children}
in the same scope — it shadows the prop and recurses. The drawer
wrapper uses let { children, ...rest } = $props() and renders
directly.
12.5 Live state vs at-construction reads
Constructors that capture local state at mount time (e.g.
createToaster({ duration, max, … })) snapshot the initial value
of those $state() props. Subsequent slider changes do not re-create
the toaster. Either:
- accept the snapshot semantics and document it, or
- recreate the instance via a
$derived(forces a rebuild on every change — usually not desirable for stateful instances), or - expose runtime config setters on the instance.
Toast demo accepts the snapshot semantics (the warning is benign).
13. Verification checklist
Before declaring a demo done:
npm run checkreports 0 errors for that file.- Walk every tab in dev mode — every tab renders.
- Every prop control changes something visible on the live preview.
- Every Sema
▶ playbutton fires (look fordata-eventin the trace strip). - Snippets reflect the current control values without stale placeholders.
14. Status of demos under web/routes/uix/components/
All public Eidos components currently exposed in the UIX docs have
new-layout demos. The legacy web/routes/{name}/ set is removed; this
is the only canonical demo home. svg is an internal rendering helper
and does not get a standalone component demo unless it becomes a public
component.
| Component | Demo |
|---|---|
| accordion | ✓ |
| avatar | ✓ (0 events, pending audit) |
| breadcrumb | ✓ (0 events, structural) |
| calendar | ✓ (3 events) |
| checkbox | ✓ |
| collapsible | ✓ |
| dialog | ✓ |
| drawer | ✓ (canary template) |
| field | ✓ (0 events, pending audit) |
| form | ✓ (3 events, SIUM demo) |
| icon | ✓ (0 events, structural) |
| meter | ✓ (0 events, passive) |
| number-field | ✓ (2 events) |
| pagination | ✓ (1 event) |
| popover | ✓ |
| progress | ✓ (0 events, passive) |
| radio-group | ✓ |
| rating-group | ✓ (1 event) |
| search-field | ✓ (2 events) |
| slider | ✓ (2 events) |
| switch | ✓ |
| tabs | ✓ |
| toast | ✓ |
| toggle | ✓ |
| toolbar | ✓ (1 event) |
| tooltip | ✓ (0 events, pending audit) |