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

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

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

Powered by TurnKey Linux.