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

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

12.6 Visible controls and Morfo-owned attrs

Do not expose a control that has no visible effect in the live preview. If a demo has segments, paged navigation, modal, clear, min, max, minDays or maxDays, the stage must make the effect observable immediately. Otherwise remove the control or mark it as a separate non-preview API note.

Demos must not hand-stamp required data-* attrs to simulate missing component parts. If a selector is needed for Calendar, RangeCalendar, DatePicker or DateRangePicker styling, declare the part/data attr in Morfo/Soma first and let the component emit it. Manual attrs in the demo are only acceptable for the UIX docs shell itself (data-uix-*).

12.7 Chip parity — every theme value is selectable

Norm implanted 2026-05-21 (CHECKLIST §D-7.4).

Every chip-group control (size, variant, color, intent, …) must enumerate the full union declared in the component's types.ts. If ControlVariant is 'surface' | 'outline' | 'ghost' and the component's Variant type does not narrow it, the demo exposes 3 chips. Truncated arrays — showing 2 of 3 — are a contract bug; the demo lies about the component's surface.

If the component narrows the union deliberately (Extract<…>), the demo matches the narrowed set exactly. Narrowings must be justified in the component README; arbitrary narrowings just to "show fewer chips" are removed (see field, toolbar — both widened to full ControlVariant on 2026-05-21).

12.8 Size category cheatsheet

The shared Size scale is xxs · xs · sm · md · lg · xl · xxl · full. Each component's recipe declares which size primitives it maps, the *Size type narrows the union, and the demo chips reflect that narrowing 1:1.

Canonical category mapping (2026-05-21):

Category Sizes exposed Components
Form controls (toggle-like, single tactile target) xs · sm · md · lg · xl checkbox, switch, toggle, radio-group, rating-group, slider
Text inputs (control with input field) xs · sm · md · lg · xl search-field, number-field, date-field, editable, tags-input, combobox, select
Progress / meter (scalable bar) xs · sm · md · lg · xl progress, meter
Layout containers (field + form) xs · sm · md · lg · xl field, form
Nav controls (inline navigation strips) xs · sm · md · lg breadcrumb, pagination, tag-group, toolbar
Composite panels (no smaller-than-sm usable) sm · md · lg calendar, date-picker, date-range-picker, file-upload, stepper, tooltip
Pre-existing wide scales (kept) xs..xl or xxs..xxl avatar, dialog, drawer, popover, tabs, icon

When adding a new size to a component:

  1. Add 'size-{X}-{prop}': '…' entries to the component's recipe in src/uix/eidos/lib/recipes/base.ts.
  2. Add [data-{kebab}][data-size='{X}'] selector(s) to the component's *.css.
  3. Widen Size = Extract<Size, …> in the component's types.ts.
  4. Regenerate: npm run generate:eidos-css.
  5. Update the demo's chip array.

If the recipe has no per-size entries (component reads --control-height-X, --space-X, --font-size-X directly — e.g. combobox), step 1 is just adding the [data-size='X'] selector with the right primitive var() references.

12.9 Composition over visibility props

Norm implanted 2026-05-21 (PENDIENTES.md N-7).

Optional parts (Footer, Clear, Cancel, Close, Header sub-items, etc.) must not be controlled by *Button boolean props on the component root. Visibility is owned by composition: include the part to render it, omit it to hide it.

Wrong (the previous shape we removed from <DatePicker> / <DateRangePicker>):

<!-- DON'T: visibility decided by root prop, parts conditionally render -->
<DatePicker clearButton cancelButton closeButton>
  <DatePicker.Footer>
    <DatePicker.Clear />   <!-- decides internally if it renders -->
    <DatePicker.Cancel />
    <DatePicker.Close />
  </DatePicker.Footer>
</DatePicker>

Right:

<!-- DO: parts render unconditionally; consumer composes what they want -->
<DatePicker>
  <DatePicker.Footer>
    <DatePicker.Clear />   <!-- always renders when mounted -->
    <DatePicker.Cancel />
    <DatePicker.Close />
  </DatePicker.Footer>
</DatePicker>

For demos: wrap the parts in {#if showX} with local state so the user can toggle via switches, but the parts themselves don't read that state. The demo's switches decide whether the part is INCLUDED in the markup.

Modal mode (mode='modal') does NOT force the Close button to show; the consumer must include <X.Close/> if they need an exit affordance. Documented in the Close part's source.

12.10 Chakra-style kind for picker variants

Norm implanted 2026-05-21 (PENDIENTES.md N-6).

For DatePicker and DateRangePicker, the kind: 'date' | 'month' | 'year' prop is the single source for granularity. Drives:

  1. Input segments: filtered at the DateFieldProvider (soma). Consumers iterate segments from the snippet without filtering.
  2. Popover view: the consumer branches structurally on kind to render <Picker.YearView> / <Picker.MonthView> / <Picker.Calendar>.

Demo skeleton:

<!-- Input: no filter — soma emits the right segments per kind -->
<DatePicker.Input>
  {#snippet children({ segments })}
    {#each segments as { part, value }}
      <DatePicker.Segment {part}>{value}</DatePicker.Segment>
    {/each}
  {/snippet}
</DatePicker.Input>

<!-- Popover: branch on kind to pick the right view -->
<DatePicker.Content>
  {#if kind === 'year'}<DatePicker.YearView />
  {:else if kind === 'month'}<DatePicker.MonthView />
  {:else}<DatePicker.Calendar>…</DatePicker.Calendar>{/if}
  <DatePicker.Footer>…</DatePicker.Footer>
</DatePicker.Content>

No separate components — MonthPicker, YearPicker, MonthRangePicker, YearRangePicker are achieved via <DatePicker kind='X'> / <DateRangePicker kind='X'>.

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.
  6. Composite components list the inherited event surface; avoid claiming "0 events" when Popover, Field, Calendar or RangeCalendar actions are visible.
  7. Any data-* selector used by the preview is backed by Morfo/Soma, not by a demo-only attr that hides a missing contract.
  8. Date/calendar demos are visually checked in one-month, two-month, modal/no-modal, min/max and clear/deselect states before being called done.

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.