---
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` | SIX species, one executor apiece: `trap` → `FocusScope.use({ policy })` (soma) · `roving` → `RovingFocusGroup` (`$adom`), or a SIGNED custom executor where the pattern exceeds the 1D primitive · `walk` → `getDirectionalKeys` (`$soma/keyboard/directional`) + `dom.focus` · `activedescendant` → `highlightWalk` (`$soma/keyboard/highlight-walk`) · `slider` → `valueStep` (`$soma/keyboard/value-step`) · `segments` → `segmentWalk` / `segmentAdvance` / `segmentRetreat` (`$soma/keyboard/segment-walk`) — declaration↔wiring pinned in BOTH directions 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,
// REQUIRED like `dom` (changelog §62 §7): the runtime honours the morfo's
// `a11ySemantic.reducedMotionFallback`, so it must be able to answer «is
// motion reduced?». `this.soma.runtime(...)` injects it from `uix.prefs`;
// through the low-level factory the caller passes a `MotionSource`.
motion,
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. `