|
|
---
|
|
|
title: Component Implementation Guide
|
|
|
type: guide
|
|
|
audience: human + agent
|
|
|
authority: canonical — the ordered build process (steps 1–40 + rules A1–A37)
|
|
|
status: current
|
|
|
source: 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](#build-contract-the-canon-table)). 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 as floating *placement* APIs (EID-3 exception) | physical `padding-left`/… in content flow | rule (LIVE) |
|
|
|
| **i18n** | `eidos.langs.ts('#?key\|fallback')` + key in the catalog | hardcoded strings / `aria-label`s | 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](https://www.w3.org/WAI/ARIA/apg/patterns/) (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`](../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`](../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)
|
|
|
|
|
|
```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:
|
|
|
|
|
|
```ts
|
|
|
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<>`:
|
|
|
|
|
|
```ts
|
|
|
// 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.
|
|
|
|
|
|
## Wrapper Pattern (components/{name}.svelte)
|
|
|
|
|
|
```svelte
|
|
|
<script lang="ts">
|
|
|
import { readableActive, writableActive } from '../../reactive';
|
|
|
import { mergeProps } from '../../props';
|
|
|
import { createId } from '$active-uix/id';
|
|
|
import { {Name}Provider } from '../{name}-provider.svelte';
|
|
|
import type { {Name}Props } from '../types';
|
|
|
const uid = $props.id();
|
|
|
|
|
|
let {
|
|
|
ref = $bindable(null),
|
|
|
id = createId(uid, '{name}'),
|
|
|
// ... props with defaults ...
|
|
|
onOpenChange = () => {},
|
|
|
children,
|
|
|
child,
|
|
|
...restProps
|
|
|
}: {Name}Props = $props();
|
|
|
|
|
|
const state = {Name}Provider.create({
|
|
|
id: readableActive(() => id),
|
|
|
ref: writableActive(() => ref, (v) => (ref = v)),
|
|
|
// ... wrap each prop ...
|
|
|
});
|
|
|
|
|
|
const mergedProps = $derived(mergeProps(restProps, state.props));
|
|
|
</script>
|
|
|
|
|
|
{#if child}
|
|
|
{@render child({ props: mergedProps })}
|
|
|
{:else}
|
|
|
<div {...mergedProps}>
|
|
|
{@render children?.()}
|
|
|
</div>
|
|
|
{/if}
|
|
|
```
|
|
|
|
|
|
### Rules
|
|
|
|
|
|
- Wrappers are thin: props → Active/State → 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: `soma?.prefs.getDir() ?? 'ltr'` where Soma is available;
|
|
|
this is backed by `uix.prefs.direction`
|
|
|
|
|
|
## Exports Pattern (exports.ts)
|
|
|
|
|
|
```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.
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
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
|
|
|
|
|
|
```ts
|
|
|
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`](../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`](./completion-checklist.md).
|
|
|
> The two are a complementary pair — build process vs done-criteria — not
|
|
|
> duplicate checklists. [`architecture/soma.md`](../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
|
|
|
[ ] 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:
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
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.
|
|
|
|
|
|
```ts
|
|
|
// 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`:
|
|
|
|
|
|
```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:
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
// 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 (`() => {}`).
|
|
|
|
|
|
```ts
|
|
|
// 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 — external dependency
|
|
|
import { noop } from '$lib/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`.
|
|
|
|
|
|
```ts
|
|
|
// 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.).
|
|
|
|
|
|
```ts
|
|
|
// 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 |
|
|
|
|
|
|
```ts
|
|
|
// 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:
|
|
|
|
|
|
```ts
|
|
|
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. Use `getDirectionalKeys(dir, orientation)`.
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
// 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):
|
|
|
|
|
|
```ts
|
|
|
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`:
|
|
|
|
|
|
```ts
|
|
|
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
|
|
|
|
|
|
```ts
|
|
|
// 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:
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
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`:
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
// 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.
|
|
|
|
|
|
```ts
|
|
|
// 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+.
|
|
|
|
|
|
```ts
|
|
|
// 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`.
|
|
|
|
|
|
```ts
|
|
|
// ❌ 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)`:
|
|
|
|
|
|
```ts
|
|
|
// 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:
|
|
|
|
|
|
```sh
|
|
|
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:**
|
|
|
|
|
|
```ts
|
|
|
// ❌ 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 `$effect`re-runs → back to step 1. Svelte terminates with`effect_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.
|
|
|
|
|
|
```ts
|
|
|
// ✅ 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:**
|
|
|
|
|
|
```ts
|
|
|
// ❌ 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.
|
|
|
|
|
|
```ts
|
|
|
// ✅ 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"`:
|
|
|
|
|
|
```svelte
|
|
|
<!-- 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).
|