You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md

441 lines
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:
```svelte
<div data-uix-canvas-inner>…</div>
```
## 2. Required imports
```ts
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:
```ts
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
```svelte
<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 `▶ play` buttons can fire on a real instance.
- The trace strip stays visible across tab switches.
```svelte
<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:
```ts
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:
```svelte
<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.
```svelte
<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
```ts
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 write `someProp && ' someProp'`
inline — `false` entries 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 `onChange` must use initially valid defaults unless it
explicitly documents that it is demonstrating an initially invalid form.
- `Form.AutoFields` examples must keep SIUM metadata, fields and defaults in
sync with the manual `Form + Field` example.
## 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:
```svelte
<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:
```ts
{@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:
1. **Header table**: name / kebab / scope / apg / parts.length / events.length
2. **Parts overview table**: kebab / marker / element / role / archetype / states / optional
3. **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>`
4. **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:
```svelte
{@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 or `eidos`-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 `&lt;Toggle&gt;` instead.
### 12.2 Curly braces inside literal text
Svelte interprets `{ … }` in template text as a JavaScript
expression. Wrap in template literal expression:
```svelte
<!-- bad -->
<td class="type">Snippet&lt;[{ checked: boolean }]&gt;</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:
1. `npm run check` reports 0 errors for that file.
2. Walk every tab in dev mode — every tab renders.
3. Every prop control changes something visible on the live preview.
4. Every Sema `▶ play` button fires (look for `data-event` in the
trace strip).
5. 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) |

Powered by TurnKey Linux.