--- 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. - `attrs` — generic HTML attributes (`type`, `tabindex`, `for`, `contenteditable`, …), same shape as `aria`, optional. - `aria` — ARIA attribute contract (attr + value source + condition), CLOSED to the ARIA vocabulary. - `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 Render bag re-derives attrs from state (Svelte renders them) 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`) | Render bag (`partProps`) — Svelte renders and re-derives it | Reactive `data-*` | | `parts[].aria` | Render bag — NAMING attrs (`aria-label`) are `consumerWins`: `mergeProps` resolves them consumer-first (see Step 4) | Reactive `aria-*` | | `parts[].role` | Render bag (`staticAttrs`) | Stable role | | `parts[].keyboard` | The DECLARATION is the single contract (grammar-validated `kind: 'spec'` rows; `'display'` rows are docs prose) — verified by `keyboard-census.test.ts`. EXECUTION converges on SPECIES PRIMITIVES (`soma/keyboard/`: `gridWalk` · `segmentWalk` · `highlightWalk` · `valueStep`, C+ verdict 2026-08-27) with the provider as the thin delegate; `runtime.keydown(part, event, anchor?)` + per-registration `actions` is the opt-in leaf convenience (6 adopters); genuinely unique machinery (palabras) stays hand-rolled with its acta | Key contract + species execution | | `events[].prewrite` | `trigger()` step 1 | Transient markers | | `events[].semantic` | `trigger()` step 2 (emit payload) | Perceptual signal | | `events[].commits` | **Nobody executes**; smoke validates | Documentation | | `focus` | `FocusScope.use({ policy })` for `kind: 'trap'` · `RovingFocusGroup` (`$adom`) or a SIGNED custom executor for `kind: 'roving'` — declaration↔wiring pinned by `focus-census.test.ts` | Species focus default | `commits` is **descriptive**, not prescriptive. The actual causal chain is `handler -> state mutation -> render bag re-derives -> Svelte renders` (P0 fase C, audit 2026-08-26 — the bag is the single attr pipeline). 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. the render bag re-derives structural attrs (data-state, aria-*) and Svelte renders them ``` 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': '', // role, aria-*, data-state, ... } — the FULL resolved contract ``` Everything the morfo declares (`role`, `aria-*`, `data-state`, `data-intent`) resolves into the render bag and Svelte renders it — server-rendered included (P0 fase C, audit 2026-08-26). There is no per-part imperative attr writer; the one imperative write left is `trigger`'s declared prewrite. ### Operational rules - `partProps(part)` returns the full render bag: static identity (id, ref, marker, dir) plus every resolved morfo plan. - `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) | | `focus` | ✅ (FocusScope policy + RovingFocusGroup) | — | — | ✅ (author-signed axis 2026-08-26: executor + `focus-census` + docs render — a stronger guard than the grandfather class) | | `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: { kind: 'trap', trap: true, initial: 'first-focusable', 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. `