12 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). Eidos-only components (icon, avatar)
omit getActiveUix since they have no Sema tab.
3. Tabs
Six tabs in this order:
Live · API · [morfo badge] Np·Me · [sema badge] N · Recipe · A11y
The Sema tab is dropped when the morfo declares 0 events
(tooltip, icon, avatar). 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.
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 14 components have new-layout demos. The legacy
web/routes/{name}/ set is removed; this is the only canonical
demo home.
| Component | Demo |
|---|---|
| accordion | ✓ |
| avatar | ✓ (no Sema — 0 events) |
| checkbox | ✓ |
| collapsible | ✓ |
| dialog | ✓ |
| drawer | ✓ (canary template) |
| icon | ✓ (no Sema — 0 events) |
| popover | ✓ |
| radio-group | ✓ |
| switch | ✓ |
| tabs | ✓ |
| toast | ✓ |
| toggle | ✓ |
| tooltip | ✓ (no Sema — 0 events) |