--- title: Morfo — the declarative contract type: reference audience: human + agent authority: E1 architecture — the cross-layer contract layer (DNA) status: current source: migrated from src/uix/morfo/README.md (2026-07-02, docs-book F7.2) --- # Morfo **The cross-layer contract of a component's public DOM surface.** Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, focus policy, and public event contract. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables). **One file per component**, at `src/uix/morfo/components/{kebab}.ts`. No prose — that's the component's README. No props — those live in `types.ts` with JSDoc. Component-owned text slots may live in `texts` (idlangrefs) when they are part of ARIA labels, live-region text, or internal functional labels — the multilingual catalog itself lives in `src/uix/langs/components/{kebab}.ts`. Everything else is the machine-readable contract. ## Why morfo exists Without morfo, a component's structural information used to live in many places: - Part names in manual attr maps inside the provider. - Data-attr enums in manual contract registration inside the provider. - ARIA emission hardcoded in the provider's `$derived.by(...)` props. - Keyboard handlers scattered across the provider. - Public event names and their transport split across provider code, docs, and consumers. - Prose descriptions in the README. - Selector strings duplicated in eidos CSS, sema `.csem`, docs tables. Renaming a part (`content` → `panel`) used to mean touching 6+ locations with zero automatic verification. Cross-layer drift (soma emits `data-dialog-content`, eidos styles `data-dialog-panel`) was silent. With morfo, **every location reads from the same declaration**. Parts, data-attrs, enum values, and component-owned translation keys are authored once. `compileMorfo`, `registerMorfo`, `SomaRuntime`, and the selector helper `createAttrs` consume the morfo directly. A smoke test validates the real DOM against the declaration on every CI run. --- ## What morfo contains A `Morfo` is a plain TypeScript constant that describes: - **`name`** — PascalCase display name (`"Dialog"`). - **`kebab`** — kebab-case identifier (`"dialog"`), matches the public `data-{kebab}` marker. - **`scope`** — which layers implement this component: `['soma']`, `['soma', 'eidos']`, etc. - **`apg`** — optional URL to the WAI-ARIA APG pattern when the component implements a formal one. - **`focus`** — optional focus policy for overlays / composites. - **`events`** — the component's public event surface: which semantic occurrences it may emit and expose to cross-layer consumers. - **`texts`** — optional component-owned text slots, declared as idlangrefs (`'#?components.{kebab}.{key}|Fallback'`). The multilingual catalog itself lives in `src/uix/langs/components/{kebab}.ts` and is registered by `ActiveUix` under `components.{kebab}.*`. Its shape (flat keys, per-language leaves, named export `{camelKebab}Langs` registered in `langs/components/index.ts`): ```ts import type { LangNode } from '$libs/langs'; export const knobLangs = { label: { es: 'Dial', en: 'Knob' } } satisfies LangNode; ``` - **`parts`** — the part tree (recursive). Each part declares: - `name`, `kebab`, `kind` (`public` — consumer-composed · `private` — rendered only by soma/eidos defaults but still styled + contract-checked · `virtual` — DOM-less coordinator, ignored by the contract validator; the source is `MorfoPartKind` in `morfo/types.ts`). - `archetype` — optional cross-component classification (see "Archetypes" below). - `defaultElement` (advisory), `role` (always-emitted). - `optional`, `supportsNesting`. - `states` — the state names this part can be in. - `data` — data-attributes emitted, with enum values when applicable and optional runtime source metadata when the contract wants to declare where the attr comes from. - `aria` — ARIA attribute contract (attr + value source + condition). - `keyboard` — keyboard shortcuts relevant when the part has focus. - `parts` — nested sub-parts (recursive). See [`types.ts`](../../src/uix/morfo/types.ts) for the full TypeScript shape. ## What morfo does NOT contain | Not in morfo | Lives in | Reason | | ----------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------- | | Summary, overview prose | `{component}/README.md` | Editorial, not contract | | Comparison vs Radix / Base UI / Bits | `{component}/README.md` | Third-party drift shouldn't pollute the contract | | Usage examples | `{component}/README.md` | Narrative | | Props (names, types, defaults) | `{component}/types.ts` with JSDoc | Canonical source is TS + JSDoc | | Shared/common translations | app/langs catalog under `common.*` | Shared vocabulary should not be duplicated per component | | Provider-only id constants | optional `{component}/langs.ts` | Constants are code ergonomics; the catalog lives in `langs/components/{kebab}.ts` or app langs | | Event handlers / runtime wiring, state machines | `{component}-provider.svelte.ts` | Execution logic, not contract data | | Visual variants / recipes | `src/uix/eidos/` (future) | Layer-specific, not shared | --- ## How morfo gets executed (architecture) Morfo is **declarative**. By itself it doesn't render, doesn't bind events, doesn't write to the DOM. The piece that does is `SomaRuntime`, which lives in `soma/` and consumes a morfo together with the provider's reactive sources. The closed architecture (post-2026-04-25) has six pieces with disjoint responsibilities: ``` Morfo declares SomaRuntime transcribes Provider supplies sources, targets, handlers Effects sync attrs from state EngineSemantic dispatches signals to perceptual channels VisualChannel materializes the signal in the DOM (data-event*, hold, cleanup) ADom applies DOM mutations (structural commit) ``` ### What each morfo field maps to at runtime | Morfo field | Runtime executor | Purpose | | ----------------------------- | ------------------------------------ | ----------------- | | `parts[].data` (with `value`) | Effect of attrs | Reactive `data-*` | | `parts[].aria` | Effect of attrs — except NAMING attrs (`aria-label`), which ship in the part's render bag so the consumer's attr can win (see Step 4) | Reactive `aria-*` | | `parts[].role` | Effect of attrs | Stable role | | `parts[].keyboard` | `runtime.keydown(part, event)` | Key dispatch | | `events[].prewrite` | `trigger()` step 1 | Transient markers | | `events[].semantic` | `trigger()` step 2 (emit payload) | Perceptual signal | | `events[].commits` | **Nobody executes**; smoke validates | Documentation | | `focus` | Configures FocusScope layer | Layer bootstrap | `commits` is **descriptive**, not prescriptive. The actual causal chain is `handler -> state mutation -> effect -> dom.apply`. The `commits` declaration documents what an external observer will see and is checked by the smoke suite. ### The `trigger(eventName)` sequence ``` 1. prewrite imperative (data-last-action, etc.) 2. await semantic.emit(event) 3. provider's synchronous handler mutates state 4. effects derive and apply structural attrs (data-state, aria-*) ``` State is the only source of truth. The DOM is derivative. ### Provider responsibilities The provider supplies what morfo cannot infer: ```ts const runtime = createSomaRuntime(morfo, { dom: this.soma.dom, eventEngine: this.soma.events, states: { open: () => this.opts.open.current }, props: { disabled: () => this.opts.disabled.current }, parts: { content: () => this.contentId.current }, events: { 'emerge-open': () => { this.opts.open.current = true; }, 'emerge-close-cancel': () => { this.opts.open.current = false; } } }); ``` Each part-provider then renders only the static identity: ```ts readonly props = $derived.by(() => runtime.partProps('trigger')); // returns: { id, ref attachment, 'data-{component}-trigger': '' } ``` Everything mutable (`role` derived from prop, `aria-*`, `data-state`, `data-intent`) is written by the runtime's effects via `dom.apply`. Svelte does not render those attrs. ### Operational rules - `partProps(part)` returns only static identity (id, ref, marker). - `dom.apply` is the only writer of mutable attrs. - Event handlers are synchronous. Async work happens before `trigger()` is called. - Guards (`if (disabled) return`) live at the call-site, not inside the handler — if they enter the handler, the perceptual signal already fired. - `Semantic` may use `Dom` (downward dependency); `Dom` does not know `Semantic`. See [overview.md](./overview.md) §2.bis for the cross-layer view. --- ## `renderAttrs` — the attrs that are not parts Some components write `data-{component}-*` onto elements that **are not parts and never could be**: - a document engine marks the user's OWN tree — `palabras` stamps block kind, heading level, syntax tokens and i18n state on `
` / `` / `
` nodes whose shape depends on what the user wrote; - a component marks its internal render nodes — `waveform`'s two ``s, which its own source already calls "eidos-only hooks". They cannot be `parts[]` (the morfo could never enumerate them) and they cannot be `data-_*` (the private prefix is outside morfo by design, and eidos STYLES these — `palabras.css` alone has rules for 23). That left a real cross-layer contract, soma writing and eidos reading, that **nothing declared** — which is what morfo exists to prevent. ```ts renderAttrs: [ { attr: 'data-palabras-block' }, { attr: 'data-palabras-heading-level' }, { attr: 'data-palabras-untranslated' } ] ``` **Where the line is drawn, and it is sharp: if a consumer can compose it or address it, it is a PART.** `renderAttrs` is for what the component renders internally and the consumer never composes. It buys no runtime writer — the component emits them itself, as it always did; what it buys is that the contract is declared, greppable and guarded (`contracts.test.ts` reads this file, so an attr that is neither a part nor declared here fails). Added 2026-08-10, when lifting `palabras`' catalogue exemption exposed 23 undeclared ones. Like `MorfoElement`, the vocabulary lives in TWO places — the TypeScript shape in [`types.ts`](../../src/uix/morfo/types.ts) and the sium schema in [`schema.ts`](../../src/uix/morfo/schema.ts). Touch both in one edit. --- ## Archetypes — cross-component part classification Beyond the component-specific `kebab`, a part can declare an `archetype` that tags it as part of a cross-component category. This is what lets **eidos** style "all triggers" or "all overlays" with a single transversal selector instead of enumerating every component. ```ts { name: 'Trigger', kebab: 'trigger', archetype: 'trigger', // ← cross-component category role: 'button', // ... } ``` The runtime emits `data-archetype="..."` on the part's DOM element via `partProps`. Static identity (never mutates), so it ships through `partProps`, not through `dom.apply`. ### Vocabulary The canonical inventory is the `ARCHETYPE_VOCABULARY` const in [`types.ts`](../../src/uix/morfo/types.ts) — the single source; do not copy the list into prose (a copied list here survived at 24 entries while the code grew to 26). The **generated list with a one-line role for each** archetype lives at [`canon/vocabularies.md`](../canon/vocabularies.md) (from `ARCHETYPE_DESCRIPTIONS`) — read it to pick a part's archetype. Validated by the sium schema. Optional field — omit when a part is genuinely unique to its component (`Slider.Range`, `PinInput.Segment` internals). ### Provider as trigger vs container When `Provider` IS the interactive element (Toggle, Switch, Checkbox — where `defaultElement: 'button'` and the user clicks the Provider itself), its archetype is `'trigger'`. When `Provider` is just a root container (`defaultElement: 'div'/'section'/'nav'`), archetype is `'provider'`. Decide per-morfo. ### Adding a new archetype Only add when at least two existing components share the role with the same conceptual meaning. The vocabulary stays small on purpose. If only one component has it, leave the part without an archetype. --- ## The 2-of-3 rule for extending morfo Morfo is the cross-layer contract between **soma**, **sema**, and **eidos** — not a convenience repository for soma. An extension to morfo is justified only when **at least two of the three layers** consume it. | What | Soma | Sema | Eidos | In morfo? | | --------------------------------------------- | --------------------------------- | ------------------ | -------------------------- | ---------------------- | | `parts[].data` + `aria` + `role` | ✅ | — | ✅ | ✅ | | `parts[].archetype` | ✅ (emit) | ✅ (verbs by role) | ✅ (transversal selectors) | ✅ | | `parts[].keyboard` | ✅ (dispatch) | — | — | ✅ (was already there) | | `events[].semantic` family/intent | ✅ (signal payload) | ✅ (vocabulary) | ✅ (selector tinting) | ✅ | | `events[].prewrite` (e.g. `data-last-action`) | ✅ (apply) | ✅ (sequence) | ✅ (tint exit anim) | ✅ | | `data-starting-style` / `data-ending-style` | ✅ (Presence) | — | ✅ (animations) | ✅ | | Computed state from N props | — (provider exposes virtual prop) | — | — | ❌ | | `firstOf` value source (priority chain) | ✅ only | — | — | ❌ | | `prop-not-nullish` condition | ✅ only | — | — | ❌ | | Field-context OR'ing | — (provider, virtual prop) | — | — | ❌ | Soma-only conveniences live in the provider — typically as a "virtual prop" that the provider exposes via runtime `props` sources, then morfo reads with `propRef`. The line stays clean: morfo declares structure + contracts; provider decides logic. ### Disclosure events Disclosure-style components (`Collapsible`, accordion item panels, row-detail reveals) are non-evaluative. They should not declare `intent`, color subset or size semantics just because they animate. The canonical morfo shape is two directional `emerge` events: ```ts { name: 'emerge-expand', semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' } }, { name: 'emerge-collapse', semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'post' } } ``` `emerge-expand` is `post` so Eidos reacts after content exists. `emerge-collapse` is `post` too (collapsible-NEW-001), so the conceal runs against a content the flip has not yet hidden. Any visual color, motion or density response belongs to Eidos recipes, not to the morfo event. --- ## Anatomy of a morfo file Minimal template: ```ts // src/uix/morfo/components/dialog.ts import type { Morfo } from '../types'; import { v } from '../types'; // value builders: v.literal, v.stateRef, v.partRef, v.propRef, v.translationRef, v.commonRef, v.langRef export const dialogMorfo = { name: 'Dialog', kebab: 'dialog', scope: ['soma'], apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/', focus: { initial: 'first-focusable', trap: true, return: 'trigger', restore: true }, texts: { 'content.roledescription': '#?components.dialog.content.roledescription|dialog window' }, parts: [ { name: 'Provider', kebab: 'provider', // special: 'provider' emits data-dialog (no suffix) kind: 'virtual', // context-only, no DOM defaultElement: 'none', optional: false, data: [], aria: [] }, { name: 'Trigger', kebab: 'trigger', kind: 'public', defaultElement: 'button', role: 'button', optional: false, states: ['open', 'closed'], data: [{ attr: 'data-state', values: ['open', 'closed'] }], aria: [ { attr: 'aria-haspopup', value: v.literal('dialog') }, { attr: 'aria-expanded', value: v.stateRef('open') }, { attr: 'aria-controls', value: v.partRef('content') } ] }, { name: 'Content', kebab: 'content', kind: 'public', defaultElement: 'div', role: 'dialog', optional: false, states: ['open', 'closed'], data: [ { attr: 'data-state', values: ['open', 'closed'] }, { attr: 'data-last-action', values: ['saved', 'cancelled', 'dismissed', 'failed'], severity: 'optional' } ], aria: [ { attr: 'aria-roledescription', value: v.translationRef('content.roledescription', 'dialog window') }, { attr: 'aria-labelledby', value: v.partRef('title'), condition: { when: 'part-present', part: 'title' }, severity: 'recommended' } ], keyboard: [{ key: 'Escape', action: 'close' }] }, { name: 'Title', kebab: 'title', kind: 'public', defaultElement: 'div', role: 'heading', optional: true, data: [], aria: [{ attr: 'aria-level', value: v.propRef('level'), severity: 'recommended' }] } ] } as const satisfies Morfo; ``` The `as const satisfies Morfo` pattern is **mandatory**, not cosmetic. It does two things at once: - **`as const`** preserves the literal types (`kebab: 'dialog'`, not `string`). This is what lets `createAttrs(dialogMorfo)` return `{ provider: 'data-dialog'; trigger: 'data-dialog-trigger'; ... }` with autocomplete and typo detection in every provider that consumes the morfo. - **`satisfies Morfo`** validates that the object conforms to the `Morfo` interface without widening it. If a field is missing or mistyped, TypeScript reports it at the declaration — same safety as `: Morfo =` annotation, without the type widening. A morfo annotated `: Morfo =` still works at runtime but yields `createAttrs(...): Record` — no autocomplete, `attrs.trigerr` compiles. Every morfo in the codebase use `as const satisfies Morfo`; new morfos must do the same. --- ## Authoring a new morfo ### Step 1 — Create the file Write `src/uix/morfo/components/{kebab}.ts` exporting a `{camelName}Morfo` const. **Naming rules**: - `name`: PascalCase. `"Dialog"`, `"DateRangePicker"`, `"ColorField"`. - `kebab`: kebab-case. `"dialog"`, `"date-range-picker"`, `"color-field"`. Must match `data-{kebab}` and `createAttrs({component})` in the provider. - Part `kebab`s must be **unique across the whole morfo** — no nested path namespacing. If a conflict arises, rename (e.g. `item-trigger` instead of `trigger`). - The orchestrator part uses `kebab: 'provider'` — special-cased to emit `data-{component}` with no suffix. Matches `name: 'Provider'` for naming coherence. ### Step 2 — Declare parts For each part, decide: - **`kind`**: - `'public'` when the consumer composes the part (e.g. ``). - `'virtual'` for internal coordinators that have no DOM of their own (context-only providers, focus guards). Use with `defaultElement: 'none'`. - **`defaultElement`**: the HTML element the wrapper renders by default. Advisory — the consumer can override via `child` snippet. See the `MorfoElement` union in [`types.ts`](../../src/uix/morfo/types.ts). - **`role`**: always-emitted ARIA role. Declare this even when the element has an implicit role (e.g. `