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.
441 lines
15 KiB
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 `<Toggle>` 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<[{ 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:
|
|
|
|
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) |
|