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

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 ▶ play buttons 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 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:

<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:

  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:

{@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:

<!-- 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.