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.
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}.*`.
- **`parts`** — the part tree (recursive). Each part declares:
-`name`, `kebab`, `kind` (`public` / `virtual`).
-`archetype` — optional cross-component classification (see "Archetypes" below).
-`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.
| 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 |
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<string, string>` — 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.
-`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. `<Dialog.Trigger>`).
-`'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. `<button>` has `role=button`) — this makes the contract polymorphism-safe: if the consumer uses `<div>` via `child`, the role still applies.
- **`optional`**: `true` if the part may be absent from a valid composition (Title, Description, Close, Overlay, Indicator, Separator). `false` for required parts (root + core).
- **`states`**: only declare if the part carries a `data-state` enum. List the exact values (e.g. `['open', 'closed']`). Required for `stateRef` ARIA values to validate.
- **`supportsNesting`**: `true` if this part can nest inside itself (Dialog inside Dialog, Menu inside Menu). Informational — enables eidos to style nested instances with scoped selectors.
-`v.stateRef(name)` — the attribute value derives from a named state. Validator requires `name` to be in the containing part's `states[]`.
-`v.partRef(target)` — the attribute value is the id of another part. Validator requires `target` to be an existing kebab in the morfo.
-`v.propRef(prop)` — the attribute value comes from a consumer prop (override, passthrough).
-`v.translationRef(key, fallback?)` — component-relative by default. `v.translationRef('content.roledescription', 'dialog window')` compiles to `#?components.dialog.content.roledescription|dialog window`.
-`v.commonRef(key, fallback?)` — shared UI vocabulary under `common.*`. Use for repeated actions like `close`, `cancel`, `save`, `next`, `previous`. UIX ships default `commonLangs`; integrators can provide their own leaves and `ActiveUix` only fills what is missing.
-`v.langRef(key, fallback?)` — explicit absolute translation path outside the component namespace, e.g. `v.langRef('app.shell.close', 'Close')`.
`translationRef` can also receive a raw absolute idlangref starting with `#?`; the compiler leaves it absolute and only appends the fallback when needed.
Add `condition` when the ARIA is emitted only in some cases:
Run a morfo through this to catch authoring errors early. See [`components/dialog.test.ts`](../../src/uix/morfo/components/dialog.test.ts) for a reference test.
### 2. Strict mode in `assertContract` (dev runtime)
The provider's `assertProps` walks the emitted data-attrs and checks their values against the registered contract. In dev mode, a value not in the declared `values[]` logs a warning:
```
[soma] dialog.content: "data-state" has value "opening" but contract expects one of: open, closed
```
### 3. Smoke + morfo-check (CI)
Two npm scripts exercise the UI shell and, historically, morfos against the real DOM:
-`npm run smoke` — Playwright walks concrete `+page.svelte` routes under
failures, translation-key-not-found, context-not-found and rendered
`__uix_lang_missing__` fallbacks. Not morfo-specific but catches common
regressions. Set `SMOKE_SCOPE=/uix` to restrict the run to the UIX shell.
-`npm run morfo:check` — DOM validator for morfos that have a routed demo
under `/uix/components/{kebab}`. Morfos without a current routed demo are
reported as `SKIP`; they are not treated as failures. Override the prefix
with `MORFO_ROUTE_PREFIX=/some/path` if a local docs shell maps morfos
elsewhere. Its contract:
- Every declared data-attr with `severity: 'required'` is emitted.
- Every emitted data-attr value matches `values[]` if declared.
- No undeclared `data-{component}-*` attrs are emitted (except `data-_*` private
provider state, which is outside morfo).
-`npm run morfo:vocabulary` — Flags `data-state` enums that diverge from canonical vocabularies (`open|closed`, `active|inactive`, `checked|unchecked|indeterminate`, etc.). WARN-level; novel vocabularies may be legitimate but should be reviewed.
Both scripts require `npm run dev` running in another terminal.
---
## The data-attr convention
`createAttrs(morfo)` derives data-attr names from parts:
Never `data-soma-*`, never `data-eidos-*` — always `data-{component}[-{part}]`.
Private attrs for internal debug / state use the reserved `data-_*` prefix and are
intentionally **outside** morfo. `validateMorfo()` rejects `data-_*` in a morfo
declaration; strict-mode tooling skips provider-private attrs when scanning the
real DOM.
---
## Handling polymorphism (consumer renders a different element)
The `child` snippet pattern allows consumers to swap the default element:
```svelte
<Dialog.Trigger>
{#snippet child({ props })}
<ahref="/about"{...props}>About</a>
{/snippet}
</Dialog.Trigger>
```
Morfo's `defaultElement` is **advisory** — the provider doesn't enforce it. What IS guaranteed is `role`: the provider always emits the explicit role (e.g. `role="button"` on a Trigger even though `<button>` has it implicitly). When the consumer renders as `<a>`, the role stays correct.
Keyboard handlers should also be element-agnostic: emit `onkeydown` that handles both Enter and Space for "activate" regardless of the underlying element, since `<a>` only handles Enter natively and `<div>` handles neither.
[Sema](./sema.md) is the perceptual/semantic layer. It consumes the same DOM surface that morfo declares — no extra hooks needed. The morfo authoring rules that support Sema:
- **Transition markers**: components with enter/exit transitions declare `data-starting-style` and `data-ending-style` on the transitioning part.
- **Causal exit states**: components with multiple semantically distinct exit paths (Dialog: saved / cancelled / dismissed / failed; Toast: dismissed / auto-timeout / action) declare `data-last-action` with enumerable `values`. The provider is expected to update `data-last-action`**before**`data-state` changes, so Sema can tint the exit animation per-action. (Tracked by a dedicated MutationObserver timing test — future work.)
- **Cross-component vocabulary consistency**: the `morfo:vocabulary` script groups components by data-attr semantic (disclosure → `open|closed`, lifecycle → `loading|idle|success|error`) and flags divergent vocabularies for review.
These don't change the morfo shape — they're authoring conventions that enable Sema without requiring a Sema-aware provider.
---
## Typed selector builder — `semaSelector`
When a TypeScript consumer needs to construct a CSS selector that targets the morfo's emitted attrs (e.g. `sema/components/*.ts` cascade rules), it MUST use [`semaSelector`](../../src/uix/morfo/selectors.ts) instead of hand-writing strings:
```ts
import { semaSelector } from '$uix/morfo';
import { dialogMorfo } from '$uix/morfo/components/dialog';
- **`partKebab`** is typed against `morfo.parts[].kebab`. Renaming a part breaks every consumer at compile-time, not silently in production.
- **`eventName`** is typed against `morfo.events[].name`. Renaming an event has the same compile-time tripwire.
- **`eventFamily` / `eventIntent`** are typed against the canonical unions (`SemaFamily`, `Intent`).
- **Output is plain CSS** — `target.matches(selector)` consumes it unchanged. Zero runtime cost beyond string concatenation.
### What it accepts loose
Plain strings (no compile-time check yet) for:
- **`state` / `aria`** — the data-attr vocabulary is per-component and not yet derived from the morfo's data contract. A future iteration will tighten these too.
- **`ancestor`** — instance / context scoping (`'#delete-confirm-dialog'`, `'[data-form]'`). Ancestors live outside the morfo's contract by design.
- **`pseudo`** — escape hatch for `:hover`, `:focus-visible`, etc.
### When to use it
Any TypeScript / Svelte module that builds a selector pointing at the morfo's emitted attrs:
| `npm run check` | TypeScript type-check across the repo (catches shape errors in morfos). |
| `npx vitest run src/uix/morfo` | Run morfo unit tests (schema invariants). |
| `npm run smoke` | Playwright smoke over concrete `web/routes` pages (requires dev server). |
| `npm run morfo:check` | Validate routed `/uix/components/{kebab}` demos vs morfo; unrouted morfos skip. |
| `npm run morfo:vocabulary` | Flag data-state enums that diverge from canonical vocabularies. |
---
## Common pitfalls
**Using `: Morfo =` instead of `as const satisfies Morfo`.** The annotated form widens all literals to `string`, so `createAttrs(morfo)` degrades to `Record<string, string>` — no autocomplete, typos slip past the compiler:
```ts
// ❌ Wrong — works at runtime, but loses literal types.
export const dialogMorfo: Morfo = { ... };
const attrs = createAttrs(dialogMorfo);
attrs.trigerr; // compiles as `string`, runtime undefined
Every morfo in the codebase use the `as const satisfies Morfo` form. This is mandatory, not stylistic.
**Duplicate kebab in the tree.** `item` in one part and `item` in another = error. Rename one.
**`stateRef` without declaring `states[]`.** If a part emits `aria-expanded` via `v.stateRef('open')`, the part **must** declare `states: ['open', ...]`. Otherwise the validator throws.
**`partRef` to a non-existent kebab.** Common after renaming a part. The validator catches this — but the dev-time warning is silent if you skip `validateMorfo`.
**Using a component-relative `translationRef` for shared text.** `v.translationRef('close', 'Close')` compiles to `components.{component}.close`, which duplicates the same close label across many components. Use `v.commonRef('buttons.close', 'Close')` for shared actions.
**Declaring `v.translationRef('content.label')` without a matching catalog entry.** The relative form normalizes to `#?components.{kebab}.content.label` at compile time. That path must resolve in `src/uix/langs/components/{kebab}.ts`. Either add the leaf there (and reference it from `morfo.texts`) or use an absolute ref via `v.commonRef`, `v.langRef`, or a raw `#?...` idlangref. `npm run translations:check` verifies this for the whole UIX tree.
**Missing `severity: 'optional'` on presence flags.** If you declare `{ attr: 'data-disabled' }` without severity, strict mode treats it as required. Add `severity: 'optional'` so morfo-check doesn't flag it missing when the flag is legitimately absent.
**Closing a component with an incomplete morfo.** Incident 2026-05-20:
DateField, DatePicker, RangeCalendar and DateRangePicker exposed a gap between
runtime/demo DOM and declared Morfo. A component is not done if the provider or
demo emits required `data-*` that Morfo does not declare, if the README says
"0 events" while the public UX composes observable events, or if a demo hand
stamps attrs to make a recipe work. For composite components, document the
composed surface: DateField/DateRangeField, Popover, Calendar/RangeCalendar and
the picker wrapper. Ownership may remain in the child component, but the public
picker docs still need the event table, targets and `data-event` trace path.
**Provider emits a data-attr not in the morfo.** Strict mode logs a warning at runtime; morfo-check fails in CI. Either add the attr to the morfo or rename the provider's emission to `data-_*` (private, not declared in morfo).
---
## See also
- [types.ts](../../src/uix/morfo/types.ts) — the TypeScript interfaces (authoritative reference).
- [PERMUTATION_RUNNER.md](../../src/uix/morfo/PERMUTATION_RUNNER.md) — CI tool that cycles components through their state space.