| **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 <44ontouchwithoutslop;growingthevisual|archetypes.csscoarserules|
| **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) or where the geometry itself is physical (compass handles, polar arcs, a JS-measured offset); 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](../canon/direction-contract.md)) | 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 |
| 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
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<>`:
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.
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.
// 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`:
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';
- **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
- **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));
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.
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`](../canon/direction-contract.md)). Use `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.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`
- 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>();
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
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:
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.
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:
// 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.
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.
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.
-`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.
### 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.
**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)
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.
// 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`.
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.Triggerdata-perm-step="0"data-perm-label="open via trigger">Open</Dialog.Trigger>
{#if open}
<Dialog.Content>
<Dialog.Closedata-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