# 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. Just the machine-readable contract. --- ## Why morfo exists Without morfo, a component's structural information lives in many places: - Part names in `createAttrs({ parts: [...] })` inside the provider. - Data-attr enums in `registerContract({ parts: {...} })` also in 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, and their enum values are authored once. `createAttrs` and `registerContract` 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 `data-{kebab}` and `createAttrs({component})`. - **`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. - **`parts`** — the part tree (recursive). Each part declares: - `name`, `kebab`, `kind` (`public` / `virtual`). - `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`](./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 | | Translations | `{component}/langs.ts` (idlangref) | Separate registry, consumed by the provider | | 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 | --- ## 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 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 }, 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-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. All 66 morfos 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`](./types.ts). - **`role`**: always-emitted ARIA role. Declare this even when the element has an implicit role (e.g. `