diff --git a/src/uix/morfo/README.md b/src/uix/morfo/README.md new file mode 100644 index 000000000..a1ef98b64 --- /dev/null +++ b/src/uix/morfo/README.md @@ -0,0 +1,425 @@ +# 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, and focus policy. 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. +- 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. +- **`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. + - `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, state machines | `{component}-provider.svelte.ts` | Code, not 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: Morfo = { + 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: 'root', // special: 'root' 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' }] + } + ] +}; +``` + +--- + +## 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 root part uses `kebab: 'root'` — special-cased to emit `data-{component}` with no suffix. + +### 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. `