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/docs/guides/component-guide.md

112 KiB

title type audience authority status source
Component Implementation Guide guide human + agent canonical — the ordered build process (steps 1–41 + rules A1–A37) current migrated from src/uix/soma/COMPONENT_GUIDE.md (2026-07-02, docs-book F7.5)

Component Implementation Guide

Step-by-step guide for building soma headless components.

⚠️ Build contract — read before building. The canon table of WHAT every component must consume to avoid drift lives below, in this guide (§ Build contract). Historical origin: the 2026-06-19 archetype-coherence audit (its §13 seeded this table; the audit is history now, not the source — DOC-1, 2026-07-11).

Build contract (the canon table)

This is what EVERY component consumes to stay faithful to the eidos design. All axes are LIVE — the phased rollout the 2026-06-19 audit planned (A3–A5) landed during 2026-06/07; each axis names the guard that defends it today.

Axis Canon — WHAT to consume NOT this (drift) Guard
Surface / elevation data-depth='overlay'|'modal'|… → the full bundle (surface·shadow·halo·border·blur·z) hand-picked surface-raised/-default; own --{c}-overlay-z; arbitrary frost elevation-plane.test.ts · THEME-SYS-1
Radius --radius-default / global factor + [data-shape-nest] concentric calc(--radius-md − space) by hand; fixed px R-2.x + shape engine
State (hover/active) --state-{hover,press,selected} layer (neutral tier; per-variant accent stays in the recipe) ad-hoc color-mix; per-component --x-hover-bg R-4.3
Focus outline + --focus-ring-* (§32 — ONE model, HCM-safe; the foundation fallback is :where()-wrapped so recipes win) own focus tokens; box-shadow rings (die in HCM) R-1.5 + forced-colors floor
Field label the canonical label role (size-relative, one step below the input; unified weight/color) redefining --{c}-label-* Field doctrine 2026-07-05
Size (controls) the --size-{k}-* bundle (height·font·padding·gap·radius·icon) re-deriving size→font; consuming none of the bundle size-bundle test (recipe-css-contract)
Touch hit-area §37: --touch-target (44px) under pointer: coarse only — AREA ≠ VISUAL (::before slop for bare markers) targets <44 on touch without slop; growing the visual archetypes.css coarse rules
Portal typography anchor font-family+line-height+color on the portaled content root inheriting (falls to serif in the portal) rule (LIVE)
RTL logical properties (inline/block, inset-inline) for flow; physical left/right ONLY where the geometry itself is physical (compass handles, polar arcs, a JS-measured offset) or as a placement grid that must NOT mirror — and that is now a named choice, not an exception: Position (physical) vs LogicalPosition (start/end, mirrors), both consts in eidos/lib/types.ts, both in canon/vocabularies.md §Placement grids. Ask: must it flip for a right-to-left reader? A strip pinned to bottom-end belongs on the trailing edge in both directions; a panel that opens to the physical right because that is where the space is does not. Narrow with Extract<>, never re-declare a grid (the logical one was hand-written five times until 2026-08-15). This closes EID-3, which recorded the physical exception in July 2026 and left its doctrine pending; branch on direction with :dir(rtl) — and then the provider MUST stamp the raw dir, or :dir() only ever sees the inherited direction (direction contract) physical padding-left/… in content flow; a logical anchor paired with a physical translateX — the anchor flips, the transform does not; [dir='rtl'] …, which misses the common no-attribute case and ignores any nearer re-declaration; accepting the prop, running the chain and never stamping — the maths moves, the paint stays behind; a :dir() rule that turns one logical face off and repaints the other — the property had ALREADY mirrored, so that cancels it RTL-1 · RTL-2 · npm run rtl:check
RTL · SVG a graphic with a READING axis mirrors (invert the scale's pixel range); a RADIAL one does not. text-anchor is LOGICAL: leave it alone when the composition mirrors, force the physical one when it does not — see eidos/components/chart/README.md §Direction mirroring and flipping the anchor (they cancel); flipping the anchor on a gutter that never moves (the label walks across the graphic); mirroring y values eye, in RTL — RTL-1 reads CSS text and cannot see SVG attrs or JS-written inline geometry
i18n eidos.langs.ts('#?key|fallback') + key in the catalog hardcoded strings / aria-labels rule (LIVE)
Color (values) role tokens --color-* / recipe tokens raw hex/rgb/hsl/oklch R-2.1/2.6 · R-4.6
Density / spacing --space-* · --control-height-* fixed px (bypasses density/scaling) R-2.x
Composition compose the existing Button/Field/Icon/Select re-implementing primitives inline §4 + review

Update rule (so the guide can never reference a nonexistent token): a new axis enters this table WITH its guard in the same pass — the table, the how-to-consume section and the lint advance coupled to the implementation, never ahead of it.

Before You Start

1. Compare with reference libraries

This step is mandatory. Do not skip it.

Search ark-ui, bits-ui, and radix-ui for the same component. Create a feature table:

Feature Radix Ark Bits Soma Decision
(each prop) ... ... ... ✓/✗ justification

Document what soma includes and what it skips (with reason).

2. Audit Morfo/Sema events

This step is mandatory for every component, including existing morfos.

Do not treat an empty events array as correct by default. Classify the component first:

Shape Sema expectation
Passive 0 events is valid when the component only projects external state.
Interactive User decisions usually need discrete events.
Continuous Do not emit every frame/pixel; model start/confirmed drag/commit boundaries.
Mixed Passive display may stay silent, but user actions still need events.

For every real user action decide:

  • family and verb from the canonical Sema vocabulary.
  • sequence (pre, post, coincident) based on whether the perceptual event must precede, follow, or accompany the state change.
  • intent only when the occurrence is evaluative. Neutral UI mechanics can be non-evaluative or default to neutral.
  • target part. Prefer the part the user perceives as acting; use provider only when the event is component-wide.
  • prewrite / commit only when the DOM must expose state before/after the semantic occurrence.

The provider must route semantic actions through runtime.trigger(...). Local callbacks such as onValueChange/onValueCommit are not a substitute for Sema when the action is perceptual.

3. Verify membership criteria

The component must meet ALL of these:

  • WAI-ARIA pattern or semantic role — the component implements a pattern from the ARIA Authoring Practices Guide (Dialog, Combobox, Treegrid, Feed, Tabs, Toolbar, …) OR a canonical ARIA role (role="status", role="meter", role="progressbar", role="searchbox", …). If the browser's native HTML gives you the right role + keyboard model with no extra behavior required (e.g. <a> for Link, <hr> for Separator, <img> for Image), the primitive belongs in Eidos, not Soma.
  • Composition of parts — 2+ sub-components communicating via context (Provider + Trigger + Content, Provider + Row + Cell, etc.). A single-DOM wrapper is Eidos-level styling, not headless behavior.
  • Complex behavior — keyboard navigation, focus management, floating, ARIA relationships, state machines, drag, form integration, or live-region coordination. Adding role="…" + aria-label to a single element is not enough.

If it fails any of these → it's Eidos-native, not Soma.

Accepted exceptions (composition criterion waived when WAI-ARIA defines a tight contract):

  • Announce — a live-region primitive per WAI-ARIA 1.2 live regions; meets complex-behavior via dual-region A/B dispatch + auto-clear + priority routing, even though its surface is a single region per priority.
  • Progress / Meter — canonical single-element roles with computed ARIA values and CSS custom properties for the decorative indicator; shipped with an Indicator part so consumers have two slots (the role host and the fill), crossing the composition threshold.

4. Compose existing components; flag gaps

Dogfood the framework. When a new component — or its demo, or any UI you build — needs a building block the framework already provides (Button, Field, Popover, Dialog, Icon, Calendar, Select, …), compose the existing soma/eidos component. Never re-implement a primitive inline or hand-roll a one-off. The picker family is the canonical example: pickers compose Popover + Field + Calendar/Slider with shared state instead of reinventing any of them (A27).

If a needed building block does not exist as a framework component, do not silently inline a bespoke version. Flag the gap — report that component X is missing — so it can be built as a proper, reusable component (its own morfo + soma + eidos) and then composed. A missing component is a signal to create it (or record the need), never an excuse for an ad-hoc reinvention that drifts from the system.

5. Classify the piece: component · shared layer · passive atom (2026-07-07)

Formal classes, canonized at the component-audit checkpoint (verdict S7, docs/audit/components/_veredictos.md). Every piece declares which one it is — the audit machine classifies by marker, never by guessing:

  • Full component — public compound with its own morfo (scope per the 2-of-3 rule), soma provider(s) and/or eidos recipe. The default.
  • Shared layer — infrastructure several components consume; no public component of its own (spin-field, list-surface, picker-shell, field-segment-state). Requires a layer README documenting the contract its consumers rely on. Layers carry no demo and no pack; their tests live with their consumers unless behavior is layer-owned.
  • Passive atom / composition — display-only piece or thin composition over existing components. STILL morfo-first: a minimal morfo with scope: ['eidos'] and 0 events, plus the ## Passive justification section in its README (machine rule F-1.5). The reference exemplars: color-swatch (minimal eidos-scope morfo) and radio-cards (composition whose morfo header explains the delegation: "declaring them here would duplicate the contract").

A composite that delegates behavior to embedded components documents that delegation in its morfo header and, when sema-scoped, declares expression: 'delegated' (see the participation doctrine in architecture/sema.md).

File Structure

components/{name}/
├── {name}-provider.svelte.ts   ← ALL concrete provider/state classes
├── types.ts                     ← ALL prop types with JSDoc + canonical field shapes
├── langs.ts                     ← optional idlangref constants for imperative strings
├── exports.ts                   ← barrel (Provider, Trigger, Content, etc.)
├── index.ts                     ← re-exports from exports.ts
└── components/
    ├── {name}.svelte            ← root wrapper
    ├── {name}-trigger.svelte    ← trigger wrapper
    ├── {name}-content.svelte    ← content wrapper
    └── ...

File naming rules

  • State class file: {name}-provider.svelte.ts (NOT {name}.svelte.ts)
    • Why: avoids Vite module resolution ambiguity with {name}.svelte wrapper
    • Contains ALL provider/state classes for the component
  • Wrapper files: {name}.svelte, {name}-trigger.svelte, etc.
  • Types file: types.ts — public props + canonical field shapes for Opts
  • Langs file: optional langs.ts — idlangref constants only when provider code needs imperative refs (see A3)
  • Barrel: exports.ts + index.ts

State Class Pattern ({name}-provider.svelte.ts)

import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { Soma } from '../../core/soma.svelte';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import { createAttrs } from '$uix/morfo';

// Parts + data contract live in the component's morfo (see src/uix/morfo/README.md):
import { {name}Morfo } from '../../../morfo/components/{name}';
// Selector helper only. Registration and DOM writes happen through SomaRuntime.part().
const attrs = createAttrs({name}Morfo);

// Root provider (with or without DOM)
interface {Name}Opts extends ProviderOpts, ... {}  // ProviderOpts if no DOM
interface {Name}Opts extends WithRefOpts, ... {}    // WithRefOpts if renders element

export class {Name}Provider {
    static readonly ctx = context<{Name}Provider>('{Name}');
    static get() { return this.ctx.getOr(undefined) as {Name}Provider | undefined; }
    static require() { return this.ctx.get(); }

    readonly opts: {Name}Opts;
    readonly runtime: SomaRuntime;
    readonly runtimePart: SomaRuntimePart;

    static create(opts: {Name}Opts) {
        return new {Name}Provider(opts);
    }

    private constructor(opts: {Name}Opts) {
        this.opts = opts;
        this.runtime = Soma.require().runtime({name}Morfo, {});
        this.runtimePart = this.runtime.part('provider', {
            id: opts.id,
            ref: opts.ref,
            owner: this,
            context: {Name}Provider.ctx,
            syncAttrs: true
        });
    }

    readonly props = $derived.by(() =>
        this.runtimePart.assert({
            ...this.runtimePart.props,
            // component-specific props
        } as const),
    );
}

// Sub-parts read parent context
export class {Name}TriggerProvider {
    static create(opts) { return new {Name}TriggerProvider(opts); }

    readonly opts: {Name}TriggerOpts;
    readonly runtimePart: SomaRuntimePart;
    readonly provider: {Name}Provider;

    private constructor(opts) {
        this.opts = opts;
        this.provider = {Name}Provider.require();
        this.runtimePart = this.provider.runtime.part('trigger', {
            id: opts.id,
            ref: opts.ref,
            owner: this,
            syncAttrs: true
        });
    }
}

Part props: read the morfo, don't re-declare it

"Morfo declares, soma executes" — a part's role / aria-* / data-* live in the morfo. A provider must NEVER re-declare them as literals in its props getter (that is duplication: the same attr in two sources, which drift). Two sanctioned ways to apply them:

  • No soma-specific extras → syncAttrs: true (the runtime writes the morfo attrs via dom.apply). The props getter is identity-only (...this.runtimePart.props) plus event handlers.

  • Needs soma-specific extras (event handlers, a locale-formatted value, a native form attr the morfo doesn't model) → spread ...this.runtimePart.renderProps() (static identity + every morfo attr, resolved against this part's registered props/states sources), then add ONLY the extras. Register the value sources at the runtime.part(...) call:

    this.runtimePart = provider.runtime.part('input', {
        id, ref, owner: this,
        props: { value: () => provider.value, min: () => provider.min }
    });
    
    readonly props = $derived.by(() => this.runtimePart.assert({
        ...this.runtimePart.renderProps(),     // role, aria-valuenow/min, data-*
        oninput: this.oninput,                  // handler (morfo can't model)
        'aria-valuetext': this.formatValue(...) // formatted (overrides raw morfo)
    }));
    

    Override a morfo attr only when soma genuinely owns the value (formatting, stringifying an ARIA boolean). Never override it just to repeat it.

Anti-pattern: { ...this.runtimePart.props, role: 'spinbutton', 'aria-disabled': ... } — role/aria-disabled are morfo-declared; spreading renderProps() supplies them. Most existing providers still do this (a documented migration backlog); NumberField's Input is the reference for the corrected shape.

Static Method Convention

All classes that use Svelte context follow the same 3-method pattern:

Method Behavior When to use
create(opts) Factory + context set Root wrapper creates the provider
get() Returns instance or undefined Optional parent (e.g., Checkbox inside optional Group)
require() Throws if not found Required parent (e.g., Trigger must be inside Provider)

This applies consistently to:

  • Provider classes: XProvider.create(), XProvider.get(), XProvider.require()
  • Soma: Soma.create(), Soma.get(), Soma.require()
  • App: App.create(), App.get(), App.require()

No standalone functions (createApp, getApp, useApp). No from(). The ctx field is readonly on the class but not exposed as public API — consumers use the static methods.

Rules

  • getContext() only works during component initialization (constructor called from script block). NEVER in event handlers, timeouts, or callbacks.
  • If a handler needs a context reference, capture it in the constructor.
  • Event handlers (onclick, onkeydown) must be included in props. Defining them as class methods without spreading them into props means they won't reach the DOM.
  • Layers (Presence, FocusScope, Dismissal, Gesture, etc.) are instantiated in the constructor and their .props are spread into the component's props.
  • For DropdownMenu / ContextMenu: interactOutsideBehavior defaults to 'close'. Menus are intentionally non-modal — for blocking semantics use Dialog/Drawer/AlertDialog.

Types: define once, reference in Opts

Canonical field shapes are defined once in types.ts and referenced by provider Opts via StateProps<> / ActiveProps<>:

// types.ts
export type DrawerStateFields = { open: boolean; activeSnapPoint: SnapPoint | null; };
export type DrawerActiveFields = { disabled: boolean; modal: boolean; direction: Direction; ... };

// provider
interface DrawerOpts extends ProviderOpts, StateProps<DrawerStateFields>, ActiveProps<DrawerActiveFields> {}

Do NOT redeclare field types in both types.ts and the Opts interface.

Alternatively, derive the whole Opts from the public Props — OptsFromProps<Props, Managed, StateKey, Preserve> (soma/provider/opts.ts). Preserve lists the keys whose undefined is meaningful (no wrapper default, or dir): those keep T | undefined; the rest are stripped because the wrapper's destructure default resolves them. Either way, EXPORT the Opts type — the wrapper targets it via bindProps<XOpts>.

Wrapper Pattern (components/{name}.svelte)

The wrapper builds the provider's reactive bag with the TARGET-TYPED bridge: bindProps<{Name}Opts>. The provider EXPORTS its Opts type, which computes the expected config shape — the literal is fully checked (keys, getter types, setter bodies) and the return IS the opts, so there is never a cast.

<script lang="ts">
    import { bindProps } from '../../../provider';
    import { mergeProps } from '../../../props';
    import { createId } from '$active-uix/id';
    import { activeDir } from '../../../direction';
    import { {Name}Provider, type {Name}Opts } from '../{name}-provider.svelte';
    import type { {Name}Props } from '../types';
    const uid = $props.id();

    let {
        ref = $bindable(null),
        id = createId(uid, '{name}'),
        open = $bindable(false),
        onOpenChange = () => {},
        // ... props with defaults ...
        dir,
        children,
        child,
        ...restProps
    }: {Name}Props = $props();

    const state = {Name}Provider.create(
        bindProps<{Name}Opts>({
            id: () => id,
            ref: { get: () => ref, set: (v) => (ref = v) },
            // Bindable-write callback rides the setter (see Callback conventions).
            open: {
                get: () => open,
                set: (v) => {
                    open = v;
                    onOpenChange(v);
                }
            },
            // ... a bare getter per remaining prop: disabled: () => disabled, ...
            // Active pass-through — the activeDir CALL stays visible in the init.
            dir: activeDir(() => dir)
        })
    );

    const mergedProps = $derived(mergeProps(restProps, state.props));
</script>

{#if child}
	{@render child({ props: mergedProps })}
{:else}
	<div {...mergedProps}>
		{@render children?.()}
	</div>
{/if}

For the trivial part — a bag of exactly { id, ref }, which is half the catalogue's parts — use partOpts instead of a hand-built literal:

<script lang="ts">
    import { partOpts } from '../../../provider';
    // ...
    const state = {Name}TitleProvider.create(
        partOpts(
            () => id,
            () => ref,
            (v) => (ref = v)
        )
    );
</script>

partOpts<E> narrows for element-specific refs (HTMLInputElement…): the getter fixes E, the setter's parameter is contextually typed from it, and the ONE narrowing cast lives inside the helper — call sites neither cast nor annotate.

Rules

  • Wrappers are thin: props → bindProps / partOpts → Provider.create() → mergeProps → render
  • No logic in wrappers. If you're writing more than prop conversion, the logic belongs in the Provider.
  • IDs: createId(uid, '{component}-{part}') — descriptive and inspectable
  • Callbacks default to () => {} inline — no noop import, soma does not depend on $lib
  • Direction: the wrapper CALLS activeDir(() => dir) in its init (prop → prefs, and it publishes DirectionContext) and passes the resulting box through the bag as an Active pass-through; the provider defaults once in resolvedDir, and stamps the raw opts.dir.current as dir whenever the recipe branches with :dir() — canon/direction-contract.md

Callback conventions (two concepts, one form each)

  • Bindable-write callback — a callback that reports writes to a bindable (onOpenChange beside open): it fires INSIDE the { get, set } setter, right after the local write. The setter is the ONLY write path of the State box, so no provider write can skip the notification — by construction, not by discipline. The provider just writes opts.open.current = v and never invokes the callback itself.
  • Pure event callback — no write to couple to (onValueCommit, onPress, onPlaced): it travels as its own Active entry (a bare getter) and the provider invokes it at the event site.
  • Debounced change callback — the one named exception. The write stays immediate (the binding always tells the truth) but the NOTIFICATION is coalesced by a debounceMs prop. It is never lost: clear / submit / unmount flush the pending one. A component in this shape emits from a SINGLE private method (search-field's emitValueChange) so the guarantee lives at one point rather than at every call site.

Catalogue invariant — no silent internal write. Every internal write to a bindable notifies, by construction, with exactly one named exception: the coalesced (debounced) notification above. A path that writes without notifying is a defect: the bind: consumer and the callback consumer would see different histories of the same component. When a reset feels like it needs to write silently (drawer's snap point once did), the real question is whether it should write at all — drawer's answer was that the snap survives the close, like a scroll position.

Exports Pattern (exports.ts)

export { default as Provider } from './components/{name}.svelte';    // ALWAYS "Provider", never "Root"
export { default as Trigger } from './components/{name}-trigger.svelte';
export { default as Content } from './components/{name}-content.svelte';

export type {
    {Name}Props as ProviderProps,       // ALWAYS "ProviderProps", never "RootProps"
    {Name}TriggerProps as TriggerProps,
    {Name}ContentProps as ContentProps,
} from './types';

Types Pattern (types.ts)

ALL props documented with JSDoc. No exceptions.

export type {Name}Props = WithChild<{
    /** Unique identifier. Auto-generated if omitted. */
    id?: string;
    /** Whether open. Bindable. @default false */
    open?: boolean;
    /** Callback on open change. */
    onOpenChange?: OnChangeFn<boolean>;
    /**
     * Multi-line for complex behavior.
     * Inherits from X when not set.
     * @default 'close'
     */
    escapeKeydownBehavior?: DismissalBehavior;
}> & Without<PrimitiveDivAttributes, {}>;

Rules

  • One-line JSDoc for simple props
  • Multi-line when there's conditional behavior or prop relationships
  • @default on every prop that has a default in the wrapper
  • Callbacks: document what e.preventDefault() does if applicable
  • value: string for required props (no ?)

Primitive HTML attributes

Soma prop types use primitive HTML aliases from src/uix/soma/types/html.ts instead of raw svelte/elements attributes. Those aliases omit fields managed by WithChild: id, style and children.

Use the primitive matching the rendered element:

import type { PrimitiveFormAttributes } from '../../types';

export type FormProviderProps = WithChild<
	{
		id?: string;
		// state/config props…
	},
	FormProviderSnippetProps
> &
	Without<Omit<PrimitiveFormAttributes, 'onsubmit'>, {}>;

Do not intersect WithChild<..., SnippetProps> with raw HTMLFormAttributes, HTMLAttributes, etc. Raw Svelte HTML types carry their own children?: Snippet<[]>; that collides with argumented snippets like children(snippetProps) and breaks Eidos wrappers that forward provider state.

ID Generation

createId(uid, 'dialog'); // → "soma-dialog-c12"
createId(uid, 'dialog-trigger'); // → "soma-dialog-trigger-c13"
createId(uid, 'dialog-content'); // → "soma-dialog-content-c14"

Pattern: soma-{component}-{part}-{uid}. Always descriptive.

Data Attributes

  • Provider: data-{component} (no -provider suffix)
  • Parts: data-{component}-{part}
  • State: data-state="open|closed", data-state="checked|unchecked|indeterminate"
  • Flags: data-disabled, data-readonly, data-checked
  • Floating: data-side, data-align
  • Animation: data-starting-style, data-ending-style
  • Nesting: data-nested, data-nested-open
  • Drag: data-dragging (present during active gesture)

Enum'd data attributes declare their closed set in the morfo ({ attr: 'data-type', values: ['single', 'multiple'] }) whenever soma holds a closed union — the compiler validates values and eidos can select per value. Do not emit aggregate state attrs nobody consumes: one representation per concept (checkpoint verdict S10 pruned field's dead 5-value data-state in favor of its consumed flags).

Documentation — the two READMEs (2026-07-07)

Canonized at the component-audit checkpoint (verdict S4). A full component documents itself at BOTH levels, each with its own audience — date-field is the reference pair:

  • eidos/components/{name}/README.md — the consumer's door. Visual API: variants, sizes, the recipe's public tokens (--{name}-*), composition examples. This is the level the machine requires (rule E-2.3).
  • soma/components/{name}/README.md — the headless contract. Provider API, snippet props, keyboard, the ## Sema events section, Field/Form participation.

Shared layers document their contract in a layer README; passive atoms carry the ## Passive justification section (see "Classify the piece" above).

API naming conventions (2026-07-07)

The prop style guide, ratified at the component-audit checkpoint (verdicts N1–N10; census and evidence in docs/audit/components/_naming.md):

  1. value + onValueChange: OnChangeFn<T> is the primary-value pair — except where a universal domain word exists (page/onPageChange, files/onFilesChange), which then follows the same on{Word}Change shape.
  2. Binaries speak their ARIA: checked/onCheckedChange, pressed/onPressedChange, indeterminate — never value: boolean.
  3. Overlays: open/onOpenChange/onOpenChangeComplete (post-animation)
    • side/align/forceMount/modal + onInteractOutside/ onFocusOutside. Hover timing: openDelay/closeDelay (+ groupSkipDelay for tooltip groups).
  4. Capability booleans: plain positive adjective first (deselectable, dismissible, loop); allowX only when no natural adjective exists (allowHalf, allowCustomValue); never allowsX.
  5. Callbacks: on{Noun}Change for state; on{Verb} for gestures and diagnostics (onPress, onResize); a raw () => void only for payload-less signals (onValueRevert). The terminal-commit callback is onValueCommit: OnChangeFn<T> — the single name catalog-wide.
  6. is* is forbidden in props — reserved for derived snippet props (isFocused, isPlaying). pending is the form-transaction word (derived); loading is the consumer-set busy prop — two concepts, both legitimate.
  7. Multi-axis values suffix the axis (selectedValue, expandedValue) — only when ≥2 value axes coexist; single-axis components use plain value.
  8. Selection multiplicity is selectionMode: 'single' | 'multiple' (native-attribute mirrors like file-upload's multiple stay, documented as such). No defaultValue — Svelte 5's $bindable(initial) covers the uncontrolled-initial case. Validation is validate returning a typed reason + onInvalid(reason). Numbers with units carry the unit in the name (debounceMs).

Checklist

This is the build checklist — the ordered authoring steps to take a component from nothing to shipped. For the acceptance criteria (the machine-audited rules that decide when a component counts as done across all four layers + recipe CSS + demo), see completion-checklist.md. The two are a complementary pair — build process vs done-criteria — not duplicate checklists. architecture/soma.md §9 only points at both; it keeps no copy of either.

[ ] 1. Compare with ark-ui, bits-ui, radix-ui — feature table with decisions
[ ] 2. Verify membership criteria (composition + complex behavior)
[ ] 3. Define parts: Provider + sub-parts (root part uses 'provider', not 'root' — A2)
[ ] 4. Create {name}-provider.svelte.ts with concrete provider/state classes
[ ] 5. Use `soma.runtime()` inside providers (or `createSomaRuntime()` in tests/tools) so `registerMorfo()` runs — A1
[ ] 6. Create types.ts with JSDoc on ALL props + canonical field shapes
[ ] 7. Add `morfo.texts` for component-owned text (catalog in `langs/components/{kebab}.ts`); create langs.ts only for imperative constants — A3
[ ] 8. Create wrapper .svelte files (thin: props → Active → Provider → mergeProps → render — A11)
[ ] 9. Verify: event handlers included in props (not just class methods)
[ ] 10. Verify: context captured in constructor, not in handlers
[ ] 11. Verify: ARIA relationships complete (aria-controls, aria-labelledby, aria-expanded — A4)
[ ] 12. Verify: accessible name via morfo `translationRef` / `commonRef` / idlangref constant, not hardcoded string — A3
[ ] 13. Verify: keyboard navigation respects RTL via getDirectionalKeys() — A12
[ ] 41. Verify: direction contract — `dir?: Direction` declared with the alias,
        the wrapper runs `activeDir(() => dir, soma)`, and the raw value is
        stamped as `dir` whenever the recipe branches with `:dir()`.
        See `docs/canon/direction-contract.md`.
[ ] 14. Verify: timers/listeners cleaned up in $effect return — A6
[ ] 15. Verify: gesture cleanup on unmount if using Gesture layer — A6, A15
[ ] 16. Verify: .get() for optional parents, .require() for required — A7
[ ] 17. Verify: no visual styles in provider (A8), static methods follow create/get/require pattern
[ ] 18. Verify: roving tabindex has exactly one tabindex=0 item — A14
[ ] 19. Create exports.ts (compound public parts; Provider only for the root headless entry)
[ ] 20. Add to components/index.ts barrel
[ ] 21. Create demo page in web/routes/uix/components/{name} as an INTERACTIVE TESTBED (A29)
        — every public prop wired to a live control, Field integration section,
        state readout. Not a gallery of canned snippets.
[ ] 22. Add link to web/routes/uix/+layout@.svelte sidebar nav
[ ] 23. svelte-check: 0 errors
[ ] 24. Run `npm run smoke` — all routes pass, including the new one.
        Smoke script catches runtime errors that svelte-check + HTTP 200 miss:
        `pageerror` (uncaught throws during hydration), `console.error`,
        translation-key-not-found, and `Context "X" not found`. HTTP 200 alone
        is SSR — it does NOT exercise client hydration. Interactively exercise
        every control in DevTools afterwards.
[ ] 25. Document gaps vs reference libraries
[ ] 26. Create README.md in the component folder — anatomy, props, data-attrs,
        keyboard, ARIA, and at least one composition example. Follow the
        format used by dialog/README.md and accordion/README.md.
[ ] 27. Verify data-attr naming is consistent across code, CSS, and docs.
        The morfo compiler emits `data-{component}` for root and
        `data-{component}-{part}` for sub-parts — NEVER `data-soma-*`.
        `createAttrs(morfo)` is only the typed selector helper for those
        names; it does not register the contract or write to the DOM.
        Grep the component folder for `data-soma-` and any other
        prefix: if any querySelector, CSS selector, README, or inline
        string uses a name that does not match the morfo-generated
        attrs, the reference is broken (selectors return null, CSS
        matches nothing) and the contract registration lies about
        what is on the DOM.

# Date / time specific
[ ] 28. Import date types/utilities from `$libs/days` directly — never from
        `$lib/util/dates` (legacy) and never from `@internationalized/date`.
        Extend `$libs/days` when a reusable helper is missing; never
        re-implement inside soma (A23). `soma/datetime/` only holds UI-level
        helpers.
[ ] 29. Segmented inputs emit `onbeforeinput: e => e.preventDefault()` on the
        contenteditable segment (A26). `keydown.preventDefault` does not
        stop IME / paste / drop.
[ ] 30. `readonlySegments` in a single-value component warns via
        `soma?.logger.warn` when `value` is undefined (A24). Range components
        split into `startReadonlySegments` / `endReadonlySegments` (A25).
[ ] 31. Pickers follow the shared-state composition pattern (A27): root
        wrapper creates the picker Provider + PopoverProvider + underlying
        Field/Calendar/Slider providers pointing at the same `writableActive`
        refs. Unique parts only for `Provider` / `Trigger` / calendar-or-slider
        bridge; everything else re-exports from the composed components.

# Reactivity hazards — mandatory
[ ] 32. Registering a child id with a parent provider's state is a **direct
        assignment in the constructor** (A30). Never use `$effect` for this.
        `$effect(() => parent.inputId.current = opts.id.current)` creates a
        reactive edge child → parent that can loop when any downstream
        consumer feeds back. Symptom: the page "freezes" / "blocks" on
        mount.
[ ] 33. Per-entity `$derived` MUST NOT call a provider method that reads
        global state (value array, items list, version counter) (A31). Lift
        the computation to a single `$derived` on the provider; per-entity
        derivations compare against the lifted result with O(1) operations.
        Symptom: works with 1–5 items, hangs with 30+.
[ ] 36. Reactive collections: use **`SvelteMap` / `SvelteSet`** from
        `svelte/reactivity` whenever readers index per-entry (`.get(k)`,
        `.has(k)`, iteration, `.size`) inside `$derived` / `$effect` /
        templates (A33). `$state(new Map())` only tracks field reassignment;
        `.set(k, v)` on the existing Map silently fails to notify readers.
        Symptom: cache updates but derivations that read it never re-run.
[ ] 38. Per-item `$effect` MUST NOT read `opts.ref.current` / tracked inputs
        AND write provider state that per-item `props` $derived read back
        (A35). The attachment reapply loop triggers
        `effect_update_depth_exceeded`. Register in the constructor; put
        only the cleanup in `$effect`. If a per-item method walks the full
        DOM / item set, wrap the walk in `untrack(...)` so the caller's
        `$derived` depends on one reactive field, not every sibling's ref.
        Symptom: demo page throws `effect_update_depth_exceeded` on mount;
        `morfo:check` reports "Execution context was destroyed" for that
        route.
[ ] 39. An `$effect` that kicks off an async side-effect (`.then` /
        microtask / `setTimeout`) which eventually WRITES a reactive var
        MUST NOT read that same var back — directly or via any helper it
        calls — without `untrack` (A36). The write will re-trigger the
        effect via the tracked read, spawn another async side-effect, and
        keep looping through the microtask queue. Svelte's synchronous
        effect-depth guard does not fire; the browser tab simply freezes.
        Symptom: `npm run smoke` passes (500 ms settle doesn't catch the
        build-up), component demo hangs on mount when reading a derived
        whose body triggers the effect. Wrap the fallback read in
        `untrack(() => ({ errors, issues }))` or similar.
[ ] 40. Instrument the component's demo page with `data-perm-step="N"`
        annotations on every interactive control that drives a distinct
        state transition (A37). Run `npm run perm:check` before shipping
        and confirm every permutation passes — this is the validation
        layer that catches reactivity loops (A35 / A36) and transition-
        time morfo drift that `morfo:check` misses. At minimum cover:
        open / dismiss for overlays, toggle for toggleables, first-to-
        second-item for composite roving, empty→invalid→valid for forms.
        See `src/uix/morfo/PERMUTATION_RUNNER.md` for the full authoring
        convention and the opt-in modifiers (`data-perm-mode`,
        `data-perm-settle`, `data-perm-skip-validate`).

# Scope approval — mandatory
[ ] 34. Before declaring the component done, **present the comparison table
        to the user in the conversation message** (A32). Not just in the
        README — in the reply. Every `❌` and `⚠️` row gets an explicit
        decision: (a) implement now, (b) defer to v2 with written
        justification and cost estimate in the README, or (c) drop because
        it's not a real gap. The user approves scope — the programmer
        does not.
[ ] 35. Deferred features land in an **"Out of scope (v2 roadmap)"** section
        in the component's README (A32). Each entry: what it is, the
        reference libraries that ship it, why it's deferred, and a cost
        estimate. This becomes the PR backlog — no feature dies in a
        footnote.

# Translation + topology audits — mandatory (A34)
[ ] 37. **Translation namespace grep.** After touching any lang-related
        code in a component or demo, grep the repo for `soma\.` inside
        quoted string literals outside `.md` files:

        grep -n "['\"]soma\.[a-z-]" src --include=!*.md

        soma's translation namespace is ALWAYS `components.{kebab-name}.*`
        (or `common.*` for shared strings). Any `langs.t('soma.…')` /
        `langs.ts('soma.…')` is a bug and will log `Translation key not
        found` at runtime. Component-owned paths should come from
        `morfo.texts` + `v.translationRef`; shared paths should use
        `v.commonRef` or an explicit idlangref constant (A3).

[ ] 38. **DOM topology vs `.require()` audit.** For every `X.require()`
        call in the provider file, answer: "is the required provider's
        component a DOM ancestor of the consumer of my component?". If
        the answer is NO, `.require()` WILL throw at runtime — context
        only flows to descendants. The classic trap is HTML constraints:
        `<tr>` cannot nest `<tr>`, so `Table.RowDetail` (a sibling `<tr>`)
        cannot `TableRowProvider.require()` even though it "belongs" to a
        row conceptually. Fix by: (a) receive the object via prop, (b) use
        `.get()` + fallback, or (c) restructure the DOM. Svelte-check
        never catches this — smoke does.

[ ] 39. **Smoke script is part of done.** A component is not done until
        `npm run smoke` (with `npm run dev` running) reports PASS for
        its new route AND all existing routes. Regressions in unrelated
        components caused by translation-table edits, lang-key typos, or
        core context changes must be caught here before declaring the
        work complete.

Common Mistakes

  1. Event handlers not in props — defining onclick as a class method but forgetting to include it in the props derived object. The handler exists but never reaches the DOM.

  2. getContext in event handler — calling ctx.get() inside onclick/onkeydown. getContext only works during component initialization. Capture the reference in the constructor.

  3. Naming Root instead of Provider — the export name is always Provider, never Root.

  4. State class file named same as wrapper — select.svelte.ts + components/select.svelte can cause Vite module resolution issues. Always use {name}-provider.svelte.ts.

  5. Missing readonly prop on form components — Switch, Checkbox, RadioGroup should have readonly alongside disabled. readonly prevents interaction but keeps the element focusable.

  6. Comments in Spanish — all code comments must be in English.

  7. Skipping reference library comparison — mandatory before implementation. No exceptions.

  8. Redeclaring field types — defining prop types in both types.ts and the provider Opts interface. Define canonical shapes once in types.ts, reference with StateProps<> / ActiveProps<>.

  9. Hardcoding aria strings — declare component-owned text slots in morfo.texts and reference them with v.translationRef; use v.commonRef / idlangref constants for shared imperative labels. Never inline strings in providers.

  10. Gesture capturing child clicks — setPointerCapture must be deferred until moveBuffer is exceeded. Immediate capture on pointerdown steals click events from buttons inside the draggable area.

  11. data-soma-* prefix — the framework never emits data-soma-{component}-*. The morfo compiler produces data-{component} for the root part and data-{component}-{part} for children; createAttrs(morfo) only exposes those names as typed strings for selectors. Writing a querySelector like [data-soma-calendar-day] returns null silently and the contract validator does NOT catch it (it only checks enum values, not attribute presence). Always grep the component folder for any data-soma- reference before completing the work — checklist item 27.

  12. Date types from the wrong module — DateValue, CalendarDate, CalendarDateTime, Time, ZonedDateTime, DateRange, Month, etc. come from $libs/days. Never import from $lib/util/dates (the legacy vendored copy) or directly from @internationalized/date.

  13. Using HourCycle as '12h' \| '24h' — the canonical form is numeric 12 \| 24, matching Intl.DateTimeFormat's hour12 resolved option. The App-layer ext/dates service, ext/app/types, and the dias library all share this form. String forms are legacy.

Audit-Derived Rules (mandatory for all components)

These rules were extracted from a full audit of the soma catalog (25 components at the time, 2026-05; they held through the 2026-07 re-audit of the full ≈140-component matrix). Every issue below was found in multiple components. Follow these to avoid repeating them.

A1. Register the morfo through the runtime

Every component MUST register its morfo before it relies on assertContract. The canonical path is to create a runtime:

const runtime = this.soma.runtime({name}Morfo, { states, props, parts, events });

For tests or a tool that does not have a Soma scope, use the lower-level factory:

const runtime = createSomaRuntime({name}Morfo, {
	dom,
	eventEngine,
	states,
	props,
	parts,
	events
});

Both paths call registerMorfo(morfo) internally. That compiles the morfo and registers its data-* contract (component text catalogs are registered separately by ActiveUix from src/uix/langs/components/*). Only call registerMorfo(morfo) manually for a legacy provider or tool that needs the registry side effect without creating a runtime.

A2. Root part must use 'provider', not 'root'

The orchestrator / context-creator part uses kebab: 'provider' in the morfo. The morfo compiler special-cases 'provider' to strip the suffix, so the emitted attribute is data-{component} (bare, no suffix). Using any other name produces data-{component}-{name}.

Naming coherence: morfo's name: 'Provider' field (the consumer-facing export) and kebab: 'provider' field (the DOM role) match — one name for the same part across both axes.

// Correct — in the morfo file:
{ name: 'Provider', kebab: 'provider', /* ... */ }
// The provider registers the same kebab through the runtime:
this.runtimePart = this.runtime.part('provider', {
	id: opts.id,
	ref: opts.ref,
	owner: this,
	context: DialogProvider.ctx,
	syncAttrs: true
});
// runtimePart.props includes data-dialog

// Wrong
{ name: 'Provider', kebab: 'root' }
// runtimePart.props would include data-dialog-root instead of data-dialog

A3. Soma access, translations, and imports

Soma access: Declare readonly soma = Soma.get() in the root provider when the component needs prefs-derived services or imperative translations. Sub-parts access soma via this.provider.soma.

Texts: The morfo declares component-owned text slots as idlangrefs; the multilingual catalog lives in src/uix/langs/components/{kebab}.ts:

export const drawerMorfo = {
	name: 'Drawer',
	kebab: 'drawer',
	texts: {
		trigger: '#?components.drawer.trigger|Open drawer'
	},
	parts: [
		{
			name: 'Trigger',
			kebab: 'trigger',
			aria: [{ attr: 'aria-label', value: v.translationRef('trigger', 'Open drawer') }]
		}
	]
} as const satisfies Morfo;

Shared strings use common refs:

value: v.commonRef('buttons.close', 'Close');

langs.ts is still allowed, but only as a small constants file when provider code needs an imperative idlangref:

// drawer/langs.ts
export const DRAWER_LANGS = {
	CLOSE: '#?common.buttons.close|Close'
} as const;

// provider
'aria-label': this.provider.soma?.langs.ts(DRAWER_LANGS.CLOSE)

Translation namespace structure:

common.buttons.close           ← project-wide, shared by soma + eidos + app
common.buttons.open
common.labels.*
components.drawer.trigger      ← component-specific
components.dialog.trigger
  • Common keys live under common.* at the lang root — not under soma
  • Component keys live under components.{name}.*
  • ActiveUix registers commonLangs defaults without overwriting user-provided leaves
  • ActiveUix registers the per-component catalogs from src/uix/langs/components/* under components.{kebab}.*
  • The morfo only declares slots (morfo.texts, idlangrefs); the multilingual records live in langs/components/{kebab}.ts. Shared strings live in common.*.
  • langs.ts() with idlangref for simple strings. langs.t() only for interpolated templates (e.g., Page {{value}})

Do NOT:

  • Create translate() helper methods in providers
  • Use ?? 'fallback' — the fallback belongs inside the langref (#?path|fallback)
  • Hardcode aria strings — use morfo.texts + v.translationRef, v.commonRef, or an explicit idlangref constant
  • Put common keys (close, open, cancel) under component namespaces — they belong in common.*

Imports within soma: Use relative paths, not $soma/ aliases. Relative paths make the library portable without requiring alias configuration in the consumer's build. soma does not import from $lib — trivial utilities (like empty callbacks) are inline (() => {}).

// Inside soma — relative
import { DRAWER_LANGS } from './langs';
import type { DrawerSide } from './types';
import { Presence } from '../../layers/presence.svelte';

// Wrong — alias
import { DRAWER_LANGS } from '$soma/components/drawer/langs';
// Wrong — app-layer dependency: soma depends on NOBODY above it
// (the `$lib` alias this example used no longer exists — it was removed in the
//  cleanup phase, and code that resurfaces it is a regression on its own)
import { noop } from '$app/util/funcs';

Consumer code (layouts, test pages, app) uses $soma/ alias — that's their build config, not soma's concern.

A4. ARIA relationships are mandatory

Every component with trigger→content pattern MUST emit:

  • Trigger: aria-controls={contentId}, aria-expanded
  • Content: aria-labelledby={triggerId} (for dialogs, popovers, selects, drawers)
  • Form controls: aria-labelledby={labelId} when a Label part exists
  • Groups: aria-label or aria-labelledby on role="group", role="tablist", role="toolbar", role="radiogroup", role="tree"

Missing ARIA relationships = the component is broken for screen readers.

A5. Feature flags are opt-in (=== true)

Per-item feature flags (Table columns, tree nodes) default to false. A feature only activates when the consumer explicitly sets it to true.

// Correct — opt-in
return col?.def.enableSorting === true;

// Wrong — opt-out (active by default)
return col?.def.enableSorting !== false;

Applies to Table: enableSorting, enableFiltering, enableResizing, enablePinning, enableHiding (exception: enableHiding defaults to true — documented in ColumnDef JSDoc).

A6. Clean up timers, listeners, and observers

Every setTimeout, setInterval, listener, ResizeObserver, or MutationObserver created in a provider MUST have cleanup in $effect return or explicit dispose. Uncleaned resources cause memory leaks.

Global or transversal listeners use this.soma.dom.listen(...) / this.provider.soma.dom.listen(...). Local Svelte handlers stay in props (onclick, onkeydown, etc.).

// Correct
$effect(() => {
    const timer = setTimeout(fn, delay);
    return () => clearTimeout(timer);
});

// Wrong — leak
constructor() {
    setTimeout(fn, delay); // never cleared
    window.addEventListener('keydown', fn); // bypasses ActiveDom and is never removed
}

A7. Use .get() for optional parents, .require() for required

Method Returns Use when
X.create(opts) instance Creating + registering in context
X.get() instance or undefined Parent is optional
X.require() instance (throws if missing) Parent is required
// Tooltip can work without Group — use get()
this.group = TooltipGroupProvider.get();

// Accordion Item MUST be inside Accordion — use require()
this.provider = AccordionProvider.require();

This convention applies to App, Soma, and all Provider classes. No standalone functions (createApp, getSoma). No from().

A8. No visual styles in headless providers

Headless providers MUST NOT emit visual CSS properties (border-radius, background, color, overflow: auto). Only functional CSS is allowed:

  • touch-action: none — required for gesture drag
  • pointer-events: auto — required for overlays and fixed-position content
  • transition: none — required during active drag to disable CSS transitions
  • transform: translate3d(...) — required during active drag for visual feedback
  • CSS custom properties (--drawer-progress, --drawer-offset-*) — data for the visual layer

The visual layer (Eidos) owns appearance. The provider owns behavior.

A9. Dismissal behavior for drawers vs dialogs

A drawer is NOT a popover. interactOutsideBehavior should be 'ignore' for drawers:

  • Modal drawer: overlay onclick handles close. Dismissal layer only handles Escape.
  • Non-modal drawer: background is interactive by definition. Dismissal layer disabled entirely. Escape handled via onkeydown on the content element.
  • Non-dismissible: Dismissal layer fully disabled. Close button uses forceClose() (bypasses the dismissible guard).

A10. DOM queries in providers must handle dynamism

getItems() patterns using querySelectorAll are fragile — they capture a snapshot, not a live reference. If items are added/removed dynamically, the query must re-run. For nested components (e.g., nested Accordion), filter results to only include elements whose closest root is the current root:

getTriggers(): HTMLButtonElement[] {
    const root = this.opts.ref?.current;
    if (!root) return [];
    const all = Array.from(root.querySelectorAll<HTMLButtonElement>(selector));
    return all.filter((el) => el.closest(`[${attrs.provider}]`) === root);
}

A11. Wrapper must be thin

Complex logic (effects, DOM manipulation, state machines, event coordination) belongs in the Provider, not the wrapper. The wrapper's job is: destructure props → wrap in Active/State → create Provider → mergeProps → render. If a wrapper has $effect blocks, onMount, or branching logic, the code belongs in the Provider.

A12. Keyboard navigation must respect RTL

Components with arrow-key navigation MUST check dir and swap left/right keys. That dir is the provider's resolvedDir — the resolved end of the chain, never a DOM read (canon/direction-contract.md). Use getDirectionalKeys(dir, orientation).

const { nextKey, prevKey } = getDirectionalKeys(dir, orientation);

Do NOT hardcode KEYS.ARROW_LEFT / KEYS.ARROW_RIGHT for directional navigation.

A13. Form components need hidden inputs

Components that participate in forms (Checkbox, RadioGroup, Switch, Select, TagsInput, NumberField) should render a hidden <input> with the current value and name prop. The name from a parent Group must propagate to children.

A14. Roving tabindex: exactly one item gets tabindex=0

In roving tabindex patterns (RadioGroup, Toolbar, Tabs, ToggleGroup), exactly one item must have tabindex=0 at all times — either the focused/selected item, or the first item when nothing is selected. Never all -1 (group unreachable) and never multiple 0 (breaks single-tab-stop pattern).

A15. Gesture layer integration

Components with drag behavior (Drawer, Slider, Splitter, ScrollArea, Toast) use the Gesture layer from layers/gesture/. Three specializations:

  • Gesture.base() — pointer tracking + axis lock + velocity (Slider, ScrollArea)
  • Gesture.drag() — base + progress + snap points + dismiss (Drawer, Toast)
  • Gesture.resize() — base + delta + min/max constraints (Splitter)

Rules:

  • setPointerCapture deferred until moveBuffer exceeded for containers with child buttons (Drawer, Slider). Exception: pure drag handles (Splitter resize trigger) capture immediately — the handle IS the drag target, no children to protect
  • Gesture .props (only onpointerdown) must be spread into the component's props
  • Gesture cleanup on unmount: $effect(() => { return () => { this.gesture.cancel(); }; })
  • CSS vars (--drawer-progress, --drawer-offset-x/y) set by provider for visual layer
  • During active drag: transition: none + inline transform for immediate feedback
  • Scroll-drag guard: disable gesture via enabled, not by suppressing callback
  • The gesture layer measures — the component decides what it means (dismiss, value, resize)

A16. Non-modal overlay components

Components with modal prop (Dialog, Drawer) must adjust behavior when modal=false:

  • FocusScope: trap=false, enabled=false — background must stay interactive
  • ScrollLock: disabled — background scrolling must work
  • Dismissal: disabled — no interactOutside, no focusOutside
  • Escape: handled via onkeydown on the content element, not via Dismissal layer
  • Auto-focus: prevented (e.preventDefault() in onOpenAutoFocus)
  • Content: needs tabindex=-1 to receive keyboard events without focus trap
  • Overlay: not rendered (consumer should not include <Overlay> for non-modal)
  • Focus return: non-modal close must return focus to trigger manually (FocusScope doesn't handle it)

A17. Focus strategy: virtual vs DOM

Two focus strategies exist. Choose based on component type:

  • aria-activedescendant (virtual focus): focus stays on trigger/input, items highlighted via CSS [data-highlighted]. Used for Select, Combobox — the trigger owns keyboard, items are options.
  • Roving tabindex (DOM focus): items receive real DOM focus. Used for DropdownMenu, RadioGroup, Toolbar, Tabs — items are independent interactive elements.

Never mix both in the same component. If the trigger has aria-activedescendant, items must NOT call .focus().

A18. Registry pattern over DOM queries

Prefer registering sub-parts in a Map on mount/unmount over querySelectorAll for keyboard navigation:

// In root provider:
private triggerRegistry = new Map<number, HTMLElement>();
registerTrigger(index: number, el: HTMLElement) { this.triggerRegistry.set(index, el); }
unregisterTrigger(index: number) { this.triggerRegistry.delete(index); }

getRegisteredTriggers(): HTMLElement[] {
    return [...this.triggerRegistry.entries()]
        .sort((a, b) => a[0] - b[0])
        .map(([, el]) => el)
        .filter(el => el.getAttribute('aria-disabled') !== 'true');
}

// In sub-part constructor:
$effect(() => {
    const el = opts.ref.current;
    if (el) this.provider.registerTrigger(this.index, el);
    return () => this.provider.unregisterTrigger(this.index);
});

Benefits: no DOM queries, works with portaled/lazy-mounted elements, O(1) lookup.

Same pattern for value-to-label registries (Select, Combobox):

private labelRegistry = new Map<string, string>();
registerLabel(value: string, label: string) { this.labelRegistry.set(value, label); }

A19. SafePolygon for hover-gap components

Components where pointer must traverse a gap between trigger and content (DropdownMenu submenus, Tooltip) integrate SafePolygon from layers/floating/safe-polygon:

import { SafePolygon } from '../../layers/floating/safe-polygon';

// In the provider that owns both trigger and content refs:
new SafePolygon({
	enabled: () => opts.open.current,
	triggerNode: () => this.triggerRef.current,
	contentNode: () => this.contentRef.current,
	onPointerExit: () => this.handleClose(),
	buffer: 2,
	transitIntentTimeout: 300
});

SafePolygon calculates a corridor polygon between trigger and content. The pointer can traverse the gap without closing. onPointerExit fires only when the pointer leaves the safe zone.

A20. Exit animation via Presence

Components that dismiss/remove elements (Toast, Drawer) must integrate Presence for exit animations:

  1. dismiss() marks the element as dismissing (state change, not removal)
  2. data-state transitions from 'open' to 'closed'
  3. Presence emits data-ending-style for CSS exit animation
  4. Animation completes → Presence fires onComplete(false) → element removed from array
// In Toaster:
dismiss(id) { this.toasts = this.toasts.map(t => t.id === id ? { ...t, dismissing: true } : t); }
remove(id) { this.toasts = this.toasts.filter(t => t.id !== id); /* + onDismiss callback */ }

// In provider:
this.presence = new Presence({
    open: readableActive(() => this.isOpen),
    ref: opts.ref,
    onComplete: (open) => { if (!open) this.provider.toaster.remove(id); }
});

A21. Contract case normalization

registerMorfo() and assertContract() normalize names to lowercase. Provider code should use morfo kebabs ('provider', 'trigger', 'content') when calling runtime.part(...); component names in contracts remain normalized internally. No manual case matching needed.

A22. Dismissal isValidEvent for complex widgets

Components with multiple interactive zones (Combobox, Select) must exclude their own elements from interact-outside detection. The isValidEvent callback should return false for clicks on trigger, input, and content:

isValidEvent: readableActive(() => (e: PointerEvent | FocusEvent) => {
	const target = e.target;
	if (!(target instanceof Node)) return true;
	if (inputEl?.contains(target)) return false;
	if (triggerEl?.contains(target)) return false;
	if (contentEl?.contains(target)) return false;
	return true;
});

Without this, clicking scrollbars inside the content, or clicking the trigger to close, triggers interact-outside and causes race conditions.

A23. Date/time utilities — never re-implement, extend dias

The canonical date library is $libs/days. Soma consumes it directly via the alias — no Soma façade. Before porting any date helper or writing a new one:

  1. Read $libs/days/*.ts (types, queries, operations, parse, format, segments) fully.
  2. If the helper already exists in days → import it via $libs/days. Never duplicate.
  3. If it is missing and reusable outside soma (pure, no DOM, no KEYS/Svelte deps) → add it to days. Don't proxy.
  4. Only when the helper is UI-specific (DOM navigation, KEYS-based predicates, screen-reader announcer, segment UI-state shapes with hasLeftFocus/lastKeyZero) does it live in soma/datetime/.

Never create a soma module whose only job is to re-export days symbols — consumers import from $libs/days directly. Dead re-export façades hide the real dependency.

A24. Readonly segments without a concrete value must log a warning

readonlySegments (or startReadonlySegments/endReadonlySegments in range components) fixes specific segments so the user cannot change them. The lock needs a concrete anchor:

  • Valid: value is set → locked segments preserve their values from value.
  • Invalid: value is undefined → the lock falls back to placeholder (empty-state display), which is not a commitment. Log a warning:
this.soma?.logger.warn(
	'{Name}Field',
	'`readonlySegments` is set but `value` is undefined — lock has no concrete anchor; falling back to placeholder. Supply an initial `value` so the locked segment has a defined meaning.',
	{ readonlySegments: [...segs] }
);

Guard against spam: track the last warned segment set and only re-warn when it changes, reset when the config becomes valid.

A25. Range components split readonly per-endpoint

DateRangeField, DateRangePicker (and future TimeRangeField) expose two lists:

  • startReadonlySegments?: EditableTimeSegmentPart[] | EditableSegmentPart[] — locks segments on the start input.
  • endReadonlySegments?: ... — locks segments on the end input.

A single readonlySegments applied symmetrically is wrong because the user may legitimately want one endpoint fixed (e.g., start's year) while the other remains editable.

For range pickers whose calendar is shared between endpoints: the calendar's navigation for a segment is blocked only when both endpoints have that segment in their readonly list. Blocking when only one side is locked would prevent navigating to pick the other endpoint's value. Per-cell selection constraints are not applied from outside because RangeCalendar picks the endpoint based on its own anchor state.

A26. Block direct contenteditable mutations with onbeforeinput

Segmented inputs (DateField, TimeField) use contenteditable="true" to get role="spinbutton" keyboard behaviour. The contenteditable surface must never accept direct mutations — all content is driven by the provider's segmentValues:

readonly sharedSegmentAttrs = {
    // …
    onbeforeinput: (e: Event) => e.preventDefault()
};

keydown.preventDefault() alone is not enough: IME/composition paths, paste, drag-and-drop, and mobile autocomplete bypass keydown. beforeinput fires before the browser mutates the element and blocks every insertion path in one line.

A27. Picker composition pattern (shared state)

Pickers (DatePicker, DateRangePicker, TimePicker, future TimeRangePicker) compose Popover + one of (DateField/DateRangeField/TimeField) + one of (Calendar/RangeCalendar/slider group). The picker's own Provider owns the shared reactive state, and the root wrapper creates three providers pointing at the same writableActive refs:

// Root wrapper script
const sharedValue = writableActive(/* getter */, /* setter */);
const sharedPlaceholder = writableActive(…);
const sharedOpen = writableActive(…);

{Name}PickerProvider.create({ value: sharedValue, placeholder: sharedPlaceholder, open: sharedOpen, …config });
PopoverProvider.create({ open: sharedOpen, … });
{Base}FieldProvider.create({ value: sharedValue, placeholder: sharedPlaceholder, …config });
// Calendar/RangeCalendar/slider providers are created inside their own wrapper
// (DatePicker.Calendar, TimePicker.HourSlider, …) reading from the picker context.

Parts: unique wrappers for Provider, Trigger, and the calendar/slider bridge. Everything else re-exports from the composed components — their native data-attrs (data-popover-*, data-date-field-*, data-calendar-*, data-slider-*) remain authoritative for styling. The picker only adds identity attrs (data-{picker}-trigger, data-{picker}-calendar) on the unique wrappers.

Auto-close / auto-anchor: the picker's Provider exposes a handleSelect() method that the calendar wrapper calls when a selection completes. Range pickers also re-anchor the placeholder so the end month lands in the rightmost visible slot — the user sees their selection, not the month they last scrolled past.

A28. Time placeholders are hh/mm/ss, not --

Dias' getPlaceholder('hour'|'minute'|'second', …) returns '––' (two en-dashes). Unreadable in most fonts and not self-describing. dias/segments.ts exposes createSegmentContent / createTimeSegmentContent which use a local getSegmentPlaceholder that returns 'hh' / 'mm' / 'ss' for time parts (while delegating to dias for date parts). Time-segmented components automatically benefit — no extra code needed.

A29. Demo pages are interactive testbeds

Every soma component demo at web/routes/uix/components/{name} must expose every public prop of the Provider as a live control plus a Field-integration section when applicable. Not six static code snippets. Required coverage:

  1. Each boolean → switch/checkbox. Each enum → radio or chip group. Each number → input. Arrays (e.g. readonlySegments) → one toggle per valid value.
  2. All format/locale/direction variants switchable (granularity, hourCycle, locale, dateOrder, dir).
  3. Field integration section with toggles for parent Field's disabled/readonly/required/invalid to verify inheritance.
  4. Live state readout — bindable value + placeholder + last onInvalid message visible.
  5. Edge cases — empty value, readonly-without-value (triggers A24 warning), disabled, required, form submission.

The demo page is how a consumer evaluates the component; a gallery of canned examples does not satisfy that.

A30. Register child ids with direct assignment — never $effect

A child provider that publishes its id to a parent provider's state (typical for Field.inputId, Dialog.triggerId, group labelId, etc.) assigns directly in the constructor. Wrapping the write in $effect creates a reactive edge child → parent that can loop when any downstream consumer feeds back into the child's derivations — the page appears "frozen" / "bloqueada" on mount.

// Wrong — $effect tracks opts.id + writes parent state; any downstream
// chain that reads back into this child loops through Svelte's scheduler.
$effect(() => {
	if (this.field) this.field.inputId.current = opts.id.current;
});

// Right — one-shot assignment at construction, matching Dialog, Combobox,
// Command, NumberField, DateField, TimeField, ColorField.
if (this.field) this.field.inputId.current = opts.id.current;

The rule is specifically for boilerplate identity writes (id, labelId, triggerId, contentId, descriptionId). Genuine side effects that must react to dep changes — DOM observers, timers, external subscriptions — still use $effect. ids almost never change after mount; there's nothing to react to.

How to recognise a violation: grep the provider file for $effect blocks whose body writes to a parent's Id.current / labelId.current / similar bookkeeping. Replace with a direct assignment after Provider.require() or after the parent reference is captured.

Incident: Listbox and PinInput both shipped with $effect wrappers for id registration. Listbox froze the page on mount.

A31. Per-entity $derived must NOT read global state through the provider

When each item / row / cell owns a $derived that calls a provider method which reads a shared $state (selection array, items registry, version counter, expanded map), every mutation of that shared state invalidates every entity's derivation — and each re-runs the provider method. Classic O(N²) cascade. Works with 1–5 items; hangs at 30+.

// Wrong — O(N²): value change invalidates N isRovingTarget derivations,
// each re-runs a full DOM query + Set construction.
readonly isRovingTarget = $derived.by(() => {
    return this.provider.rovingTarget() === this.opts.ref.current;
});

rovingTarget(): HTMLElement | undefined {
    const selected = new Set(this.opts.value.current);
    return this.getItems().find((el) => selected.has(el.dataset.value)) ?? this.getItems()[0];
}

// Right — O(N): lift the expensive computation to a single $derived on
// the provider. Per-entity derivations only pointer-compare.
// Provider:
readonly rovingTargetEl = $derived.by(() => {
    const items = this.getItems();
    if (items.length === 0) return undefined;
    const selected = new Set(this.opts.value.current);
    return items.find((el) => selected.has(el.dataset.value)) ?? items[0];
});
// Item:
readonly isRovingTarget = $derived.by(() => {
    return this.opts.ref.current === this.provider.rovingTargetEl;
});

Patterns that commonly hit this:

  • provider.isVisible(value) / provider.isSelected(value) / provider.isExpanded(id) called from N per-item derivations → lift a visibleSet: Set<string> / selectedSet / expandedSet on the provider.
  • provider.getItems() (DOM query or registry read) called from N per-item derivations → lift rovingTargetEl / firstVisibleIndex / whatever the real answer is to a single provider derived.

Recognise it: works with a handful of entities, freezes with a larger list. isX method called from N derivations is the signature. Fix before shipping — do not mask with untrack, microtask batching, or version-counter reads.

Incidents: Command component (2026-04-17, external diagnosis required), Listbox rovingTarget (2026-04-18).

A32. Explicit gap sign-off — the user approves scope, the programmer doesn't

The comparison table (## Comparison in every component README) is a contract, not a footnote. Before saying "component done":

  1. Fill the table — every feature that at least one of Radix / Ark / bits / React Aria implements is a row. Mark each cell ✅ / ⚠️ / ❌ — don't omit rows to hide a gap.
  2. Present the table in the conversation — paste the rows where at least one ⚠️ or ❌ exists (or the full table) into the reply that finalises the component. The user sees the gaps before approving.
  3. Decide each ❌ / ⚠️ explicitly — for every non-✅, the user approves one of:
    • Implement now — the gap is strategic or blocks a WAI-ARIA / reference expectation. Bring it into scope and finish the component with the feature.
    • Defer to v2 — the gap exists but isn't blocking. Add it to the component's ## Out of scope (v2 roadmap) section with: what it is, reference libraries that ship it, why deferred, cost estimate in lines. This becomes the PR backlog.
    • Drop — the feature isn't a real gap for Soma (e.g. a competitor's framework-specific quirk, or something Eidos should own). Document the reasoning and remove the row from the table.
  4. No silent gaps — if a feature appears only as a footnote and nowhere else, that's a failure mode. The reader of the README should see ❌ and know it's a deliberate decision.

Why this exists: during the 2026-04-19 session, AlertDialog / Listbox / Carousel / NavigationMenu all shipped with strategic gaps (Escape default, range-select, multi-slide, Viewport, Sub, data-motion, skipDelayDuration) hidden inside comparison tables the user never saw in conversation. AlertDialog in particular inherited Dialog's escapeKeydownBehavior='ignore' default — a WAI-ARIA regression disguised as a ⚠️ row. The rule is: if the gap isn't argued explicitly, it doesn't get to ship.

How to apply: the checklist items 34–35 are the mechanism. Item 34 says "present the table in the conversation and get sign-off"; item 35 says "deferred features become their own README section, not a table footnote".

A33. Reactive collections: SvelteMap / SvelteSet, not $state(new Map())

In Svelte 5 runes, plain Map / Set are not deeply reactive. $state(new Map()) only tracks reassignment of the field — writing to the map via .set(k, v) / .delete(k) does not notify readers of .get(k), .has(k), .size, or iteration.

Use SvelteMap / SvelteSet from 'svelte/reactivity' when:

  • Readers index per-entry (.get(k), .has(k), iteration, .size) inside a $derived, $effect, or template expression.
  • Mutations happen via .set(k, v) / .delete(k) on the existing collection (the common ergonomic case).
  • You want per-entry invalidation — changing key A shouldn't invalidate readers of key B.
// ❌ Wrong — .set() updates don't propagate to readers of .get() in $derived.
import { ... } from '...';
class ExampleProvider {
    private cache = $state(new Map<string, number>());

    measure(k: string, v: number) {
        this.cache.set(k, v);  // silently non-reactive
    }

    readonly computed = $derived.by(() => this.cache.get('x') ?? 0);
    //                                     ^^^^^ never re-runs after .set()
}

// ✅ Right — per-entry reactive, clean .set().
import { SvelteMap } from 'svelte/reactivity';
class ExampleProvider {
    private cache = new SvelteMap<string, number>();

    measure(k: string, v: number) {
        this.cache.set(k, v);  // notifies readers of .get(k) etc.
    }

    readonly computed = $derived.by(() => this.cache.get('x') ?? 0);
    //                                     re-runs when .set('x', ...) fires
}

The "reassign-the-whole-Map" workaround — some code does this to force reactivity with plain $state(Map):

// Works but fragile:
registerLabel(value: string, label: string) {
    const next = new Map(this.labels);
    next.set(value, label);
    this.labels = next;  // field reassignment triggers tracking
}

This is O(N) per mutation (copies the whole Map), looks like a bug to future readers ("why clone?"), and breaks silently if anyone refactors to this.labels.set(...) direct. SvelteMap removes both problems — .set is reactive and O(1).

Detection: grep for $state(new Map / $state(new Set in the providers folder. For each hit, audit: are readers using .get / .has / .size / iteration inside $derived? If yes, migrate to SvelteMap/SvelteSet. The reassignment-clone workaround should be rewritten too.

Incidents:

  • VirtualList dynamic heights (2026-04-19) — ResizeObserver wrote sizes to $state(Map) cache, offsets derived read .get(key) and never re-ran. Every row stayed at the 60 px estimate.
  • Form touched + registry Maps — setFieldTouched / registerField use .set direct, but isTouched / isDirty / firstInvalidField derivations read .values() / .keys() / .has(). Same bug, harder to notice because values + errors state cover most user-visible flows.
  • Combobox labelRegistry — used the "clone-and-reassign" workaround. Works today but fragile.

A34. Verification before "done": translation namespace + DOM topology + smoke

Three classes of bug cannot be caught by svelte-check or HTTP 200 — they all require either a runtime grep or a real browser. They must be run every time a component, demo, or lang entry is touched.

  1. Translation namespace grep. soma's namespace is components.{kebab-name}.* (or common.* for shared strings). Any soma.… or other prefix inside a quoted translation path is a bug that logs [langs] Translation key not found at runtime. Check with:

    grep -rn "['\"]soma\.[a-z-]" src --include=!*.md
    

    Fixes: route component-owned text through morfo.texts + v.translationRef; route shared text through v.commonRef or an explicit idlangref constant. Don't build translation paths via template strings in demos or providers. If a demo needs a dynamic path helper (like tt('columns', 'Columns')), hard-code the namespace prefix components.{name}. correctly.

  2. DOM topology vs .require() audit. Svelte's context (via getContext) flows only to descendants. Every X.require() call must be reachable from a descendant of the component that set the context. The trap is HTML: <tr> cannot nest <tr>, so Table.RowDetail (rendered as a sibling <tr> of Table.Row) cannot TableRowProvider.require(). Use one of:

    • Receive the object via a prop (consumer passes {row} or similar explicitly). This is consistent with <Table.Row {row}> / <Table.Cell {cell}> — Table already requires explicit objects.
    • Use .get() + a fallback for truly optional context (e.g. FeedProvider.get() inside Feed.Sentinel, which can live outside a Feed).
    • Restructure so the child actually lives inside the parent's subtree.

    Svelte-check never catches this — the error is thrown on mount. Smoke catches it.

  3. npm run smoke. The smoke script (scripts/smoke-check.mjs) walks every concrete +page.svelte route under web/routes with Playwright and surfaces:

    • pageerror (uncaught throw during hydration — e.g. Context "X" not found)
    • console.error (runtime exceptions caught by the framework)
    • same-origin request failures
    • Translation key missing warnings
    • [soma] context-not-found warnings
    • rendered __uix_lang_missing__ fallback markers

    It waits for domcontentloaded plus a short settle instead of networkidle, because icon/gallery-heavy docs pages can keep network work alive without being broken. Run it before declaring a component done. Regressions in unrelated components caused by lang-table edits or core changes surface here too. npm run smoke requires npm run dev running in another terminal and auto-detects the port on 5173–5180. Use SMOKE_SCOPE=/uix npm run smoke when you only need the UIX shell.

Incidents:

  • Table demo (2026-04-19) — tt('columns') built soma.table.columns instead of components.table.columns. Dozens of Translation key not found logs, silent in svelte-check.
  • Pagination item aria-label (pre-existing) — provider called langs.t('soma.pagination.page') directly. Same class of bug; fixed by migrating to an idlangref constant (PAGINATION_LANGS.PAGE).
  • Table.RowDetail (2026-04-19) — first version called TableRowProvider.require(). Threw Context "TableRow" not found because <tr> cannot nest and the Detail is a DOM sibling, not descendant. Fixed by taking {row} as prop + deriving the aria-controls id deterministically from row.id.

A35. $effect reading ref.current + writing provider state is a loop trap

A30 forbids using $effect for id registration. A35 extends the ban to any per-item $effect that reads opts.ref.current (or similar reactive input) and writes to provider state that the per-item props $derived reads back through the attachment system.

Recognise the shape:

// ❌ Wrong — mounts the component and immediately loops.
$effect(() => {
    void opts.ref.current;       // tracked
    void opts.disabled.current;  // tracked
    this.provider.notifyItemsChanged();  // writes itemsVersion
    return () => this.provider.notifyItemsChanged();
});

// Provider:
readonly firstTabStop = $derived.by(() => {
    void this.itemsVersion;      // tracks the counter
    return this.getItems()[0];
});

// Item props:
tabindex: this.provider.isTabStop(this.opts.ref.current) ? 0 : -1
// isTabStop reads firstTabStop → tabindex depends on itemsVersion

Why it loops: the item $effect writes itemsVersion → invalidates firstTabStop → invalidates every item's props $derived → Svelte re-spreads {...mergedProps} including the ref attachment → attachment re-runs → ref.current = node (same node, but the internal write still notifies tracked subscribers) → item $effectre-runs → back to step 1. Svelte terminates witheffect_update_depth_exceeded.

Fix:

  • Don't use $effect with reactive deps to notify the provider. Register in the constructor (A30 pattern) or via explicit method calls from handlers. $effect is for the cleanup function only: $effect(() => () => provider.unregister(...)).
  • When a per-item $derived needs to consult the full item set (e.g. "am I the first tab stop?"), wrap the set-walking read in untrack(...) so the derivation depends only on the single reactive field it actually cares about (lastFocusedElement), not on every sibling's ref or a shared counter.
// ✅ Right — no counter, no feedback edge.
isTabStop(el: HTMLElement | null): boolean {
    if (!el) return false;
    if (this.lastFocusedElement) return this.lastFocusedElement === el;
    return untrack(() => this.getItems()[0] === el);
}

Recognise it: demo page freezes or logs effect_update_depth_exceeded on mount. The stacktrace names the per-item $effect and the provider setter it calls (e.g. set itemsVersion). npm run smoke passes (HTTP 200) but scripts/morfo-check.ts fails with page.$$eval: Execution context was destroyed, most likely because of a navigation — Playwright sees the page's error handler trip and the document effectively dies mid-query.

Incident: Toolbar (2026-04-19) — Button / Link / GroupItem each carried a mount $effect that called notifyItemsChanged(); firstTabStop $derived read itemsVersion; per-item props read firstTabStop via isTabStop. Loop tripped on every page load, hiding behind an ERROR toolbar ... Execution context destroyed in morfo:check (not obviously a reactivity bug until probed in the browser console). Fix: removed the counter + three effects; isTabStop uses untrack.

A36. Async side-effect + reactive read-back = microtask-mediated loop

A35 covers synchronous $effect feedback cycles. A36 covers the asynchronous variant — the one Svelte's effect-depth guard does NOT catch because each re-entry happens in a separate scheduler tick, mediated by the microtask queue.

Shape:

// ❌ Wrong — microtask loop.
$effect(() => {
	JSON.stringify(values); // tracks values
	const res = schema['~standard'].validate(values);
	if (isPromiseLike(res)) {
		// Fires later in a microtask:
		res.then((r) => {
			errors = groupIssues(r.issues); // writes errors
			issues = groupIssues(r.issues); // writes issues
		});
		// Synchronous fallback value — reads errors/issues REACTIVELY:
		return { errors, issues }; // ← tracks errors + issues
	}
	return res.issues ? groupIssues(res.issues) : { errors: {}, issues: {} };
});

Why it loops:

  1. Effect runs. JSON.stringify(values) tracks values. The fallback return { errors, issues } tracks errors and issues as deps.
  2. .then(...) is scheduled as a microtask.
  3. Effect returns.
  4. Microtask fires: writes errors = … and issues = ….
  5. Those writes invalidate the effect (it depends on errors/issues).
  6. Effect re-runs. New .then scheduled. Goto 4.

Each iteration enqueues another microtask. The microtask queue starves the event loop — the tab freezes. No effect_update_depth_exceeded fires because the guard only counts depth inside a single synchronous tick.

Fix: wrap the reactive read in untrack so the effect doesn't subscribe to the state the async callback writes.

// ✅ Right — `untrack` breaks the feedback edge.
if (isPromiseLike(res)) {
	res.then((r) => {
		errors = groupIssues(r.issues);
		issues = groupIssues(r.issues);
	});
	return untrack(() => ({ errors, issues }));
}

Recognise it:

  • Browser tab freezes on mount of a specific component variant. No Svelte error in the console.
  • npm run smoke passes because its 500 ms post-load settle is shorter than the microtask storm's ramp-up.
  • npm run morfo:check may pass too — the DOM exists, validation just never reaches a steady state.
  • Bisect by stripping the effect body to read + empty-write → add validate alone → add the real writes back. The combination where the async helper reads state the effect writes is the trigger.

Why this is especially sneaky with Standard Schema v1 adapters: some libraries (sium included) declare their adapter's validate as async (...) unconditionally, so the Promise branch fires even for schemas whose underlying validation is synchronous. The soma Form has to live with that until the adapter exposes a sync path — untrack around the fallback is the durable fix.

Incident: Form onChange / onBlur hang (2026-04-21) — kitchen-sink at /test/sium/kitchen-sink froze on mount with a 12-field nested schema because runValidate's fallback read errors/issues reactively inside the validation $effect. Fixed in form-core.svelte.ts by wrapping the fallback return in untrack. Regression locked by two new tests in form-auto-fields.svelte.test.ts with 5 s vitest timeouts. See src/uix/soma/components/form/BUG-onchange-onblur-hang.md for the full diagnostic transcript.

A37. Instrument demos with data-perm-step for the permutation runner

Single-state validation (morfo:check) passes even when a state transition would loop or emit an undeclared attr. The permutation runner (scripts/permutation-check.ts) cycles components through their declared state space and re-validates morfo after every transition. This is the layer that would have caught the toolbar A35 loop, the form A36 microtask loop, and the slider RTL transform bug the same day they shipped — each of them passed morfo:check but failed the moment state changed.

Demos opt in by tagging interactive controls with data-perm-step="N":

<!-- Open → close → re-open cycle -->
<Dialog.Trigger data-perm-step="0" data-perm-label="open via trigger">Open</Dialog.Trigger>

{#if open}
	<Dialog.Content>
		<Dialog.Close data-perm-step="1" data-perm-label="close via Close button">Close</Dialog.Close>
	</Dialog.Content>
{/if}

Extra modifiers:

  • data-perm-mode='key="Escape"' — dispatch a keydown instead of clicking.
  • data-perm-mode='type="ada@example.com"' — type a string.
  • data-perm-settle="800" — longer wait before re-validation (for animations or async validation).
  • data-perm-skip-validate — click but don't re-validate (intermediate action).
  • data-perm-label="..." — override the log label.

What to exercise:

  • Overlays (Dialog, Popover, Drawer, Tooltip, NavigationMenu, DropdownMenu, ContextMenu, Menubar) — open / dismiss via every declared path (trigger click, Escape, outside click when applicable).
  • Toggleable items (Checkbox, Switch, Toggle, ToggleGroup.Item, Tabs.Trigger, RadioGroup.Item, Accordion.Trigger) — click to flip state; for multi-value pickers, advance through at least three values.
  • Composite roving (Listbox, Menu, Tree, Toolbar) — focus first item, arrow-key to next, arrow-key past the loop boundary.
  • Forms — empty → invalid input → valid input, to catch any validation-effect loops under change / blur modes.
  • RTL — if the component has dir semantics, include a data-perm-step that swaps dir="rtl" on the root and validates arrow keys flip.

What to skip:

  • Alerts / confirmations / anything that triggers window.alert() or window.confirm() — Playwright hangs on those by default. If the demo has them, use a non-alert callback for the perm-step path.
  • File uploads — native file picker is browser-modal and not scriptable from Playwright without setInputFiles.

Pragmatic coverage target: every component with a non-trivial state machine (roughly a third of the catalog's morfos — ≈40 of the then-66 at the 2026-06 count; see src/uix/morfo/components/ for today's) should have ≥2 permutation steps. Plain leaf components (Progress, Meter, Announce) don't need any. Run npm run perm:check before shipping a new component; the runner SKIPs annotations-less demos without failing, so onboarding is incremental.

Reference: src/uix/morfo/PERMUTATION_RUNNER.md has the full authoring convention and roadmap (v2 URL-driven states, v3 morfo-inferred cycles, v4 MutationObserver ordering for Sema).

Powered by TurnKey Linux.