docs(book): F7.2 (1/6) — morfo chapter migrated to docs/architecture/morfo.md

Already-English doc: moved verbatim with frontmatter + internal links
repointed (10 targets); thin stub at src/uix/morfo/README.md; corpus
links swept (docs map, glossary, comparison, building-a-component, the
uix thesis, active_architecture, soma docs, eidos README incl. the
semaSelector anchor). docs:check 0 errors (245 docs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent c4fe43abc6
commit 4b48f2cc74

@ -65,7 +65,7 @@ invented vocabulary (morfo, archetype, hold, TSC, …) one line each.
| Doc | Layer |
| --- | --- |
| [`src/uix/active_architecture.md`](../src/uix/active_architecture.md) | The whole system — start here for depth |
| [`morfo/README.md`](../src/uix/morfo/README.md) | The declarative contract (DNA) |
| [`architecture/morfo.md`](./architecture/morfo.md) | The declarative contract (DNA) |
| [`soma/README.md`](../src/uix/soma/README.md) · [`SOMA_ARCHITECTURE.md`](../src/uix/soma/SOMA_ARCHITECTURE.md) | Headless behavior — README onboards, ARCHITECTURE is the deep reference |
| [`sema/README.md`](../src/uix/sema/README.md) | Perceptual engine + channels (sound/haptic) + cascade |
| [`eidos/README.md`](../src/uix/eidos/README.md) | The visual layer |

@ -0,0 +1,874 @@
---
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}.*`.
- **`parts`** — the part tree (recursive). Each part declares:
- `name`, `kebab`, `kind` (`public` / `virtual`).
- `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.
- `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`](../../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
Effects sync attrs from state
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`) | Effect of attrs | Reactive `data-*` |
| `parts[].aria` | Effect of attrs | Reactive `aria-*` |
| `parts[].role` | Effect of attrs | Stable role |
| `parts[].keyboard` | `runtime.keydown(part, event)` | Key dispatch |
| `events[].prewrite` | `trigger()` step 1 | Transient markers |
| `events[].semantic` | `trigger()` step 2 (emit payload) | Perceptual signal |
| `events[].commits` | **Nobody executes**; smoke validates | Documentation |
| `focus` | Configures FocusScope layer | Layer bootstrap |
`commits` is **descriptive**, not prescriptive. The actual causal chain is
`handler -> state mutation -> effect -> dom.apply`. 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. effects derive and apply structural attrs (data-state, aria-*)
```
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: {
open: () => {
this.opts.open.current = true;
},
'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': '' }
```
Everything mutable (`role` derived from prop, `aria-*`, `data-state`, `data-intent`)
is written by the runtime's effects via `dom.apply`. Svelte does not render
those attrs.
### Operational rules
- `partProps(part)` returns only static identity (id, ref, marker).
- `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 [src/uix/README.md](../../src/uix/README.md) §2.bis for the cross-layer view.
---
## 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). 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) |
| `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: 'expand',
semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' }
},
{
name: 'collapse',
semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'pre' }
}
```
`expand` is `post` so Eidos reacts after content exists. `collapse` is `pre` so
the exit signal can play while content is still visible. 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: {
initial: 'first-focusable',
trap: true,
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<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.
**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. `<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.
### Step 3 — Declare `data` entries
For each data-attr the provider emits:
```ts
data: [
// Enum-valued: the complete set.
{ attr: 'data-state', values: ['open', 'closed'] },
// Same attr, but now declaring its runtime source too.
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
// Presence-only flag: emitted only when true, absent otherwise.
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
// Free value: writes the raw value, not an empty presence attr.
{ attr: 'data-value', value: v.propRef('value'), emit: 'value' },
// Free value written manually by the component, not by SomaRuntime.
{ attr: 'data-form-auto-fields-field', emit: 'value' },
// Conditionally-emitted attr.
{
attr: 'data-starting-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'open', value: 'starting' }
}
];
```
Non-enum `data-*` entries default to `emit: 'presence'`: truthy writes
`data-x=""`, falsy removes the attr. Use `emit: 'value'` only when the attr
must carry a real value, such as `data-value`, `data-min`, `data-max` or a
manual component attr like `data-form-auto-fields-field`. When `value` is
omitted, the attr remains declarative-only: Morfo documents the contract, but
the component/provider is still responsible for writing the value.
**Severity rules**:
- `'required'` (default) — the attr must always be emitted. Missing = error in strict mode.
- `'recommended'` — emitted in most cases. Missing = warning.
- `'optional'` — emitted only when its condition is met (e.g. `data-disabled` only when disabled). Missing ≠ error.
Use `'optional'` for all presence-only flags so morfo-check doesn't flag them as missing when they're legitimately absent.
### Step 4 — Declare `aria` entries
ARIA values use a tagged union via builders:
```ts
aria: [
{ attr: 'aria-haspopup', value: v.literal('dialog') }, // static literal
{ attr: 'aria-expanded', value: v.stateRef('open') }, // refs states[]
{ attr: 'aria-labelledby', value: v.partRef('title') }, // refs another part's kebab
{ attr: 'aria-level', value: v.propRef('level') }, // refs a consumer prop
{
attr: 'aria-roledescription',
value: v.translationRef('content.roledescription', 'dialog window')
},
{ attr: 'aria-label', value: v.commonRef('buttons.close', 'Close') },
{ attr: 'aria-label', value: v.langRef('app.shell.close', 'Close') }
];
```
**Value kinds**:
- `v.literal(value)` — static string invariant. `aria-haspopup="dialog"`, `aria-modal="true"`.
- `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:
```ts
condition: 'always'
condition: { when: 'part-present', part: 'title' }
condition: { when: 'state-equals', state: 'open', value: 'true' }
condition: { when: 'prop-truthy', prop: 'modal' }
condition: { when: 'prop-falsy', prop: 'disabled' }
```
### Step 4.5 — Declare component-owned `texts`
If a text slot belongs to the component contract, declare it on the morfo as
an idlangref. The morfo never embeds the literal multilingual record — that
lives in `src/uix/langs/components/{kebab}.ts` and is merged into the active
catalog by `ActiveUix`:
```ts
export const dialogMorfo = {
name: 'Dialog',
kebab: 'dialog',
// ...
texts: {
trigger: '#?components.dialog.trigger|Open dialog',
'content.roledescription': '#?components.dialog.content.roledescription|dialog window'
},
parts: [
{
name: 'Content',
kebab: 'content',
// ...
aria: [
{
attr: 'aria-roledescription',
value: v.translationRef('content.roledescription', 'dialog window')
}
]
}
]
} as const satisfies Morfo;
```
Use relative refs for text owned by this component. Use `v.commonRef` for
shared actions (`close`, `cancel`, `save`, `next`, `previous`) so the same
string is not duplicated across Drawer, Dialog, Popover, Toast, etc. Use
`v.langRef` for an app/system namespace that is deliberately not owned by
the component.
### Step 5 — Optional: keyboard and focus
```ts
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
focus: {
initial: 'first-focusable', // 'first-focusable' | 'trigger' | { partRef: 'content' }
trap: true,
return: 'trigger', // 'trigger' | 'previous' | { partRef: '...' }
restore: true
}
```
Focus is only required for overlay/composite components (Dialog, Drawer, Popover). Plain controls omit it.
### Step 5.5 — Optional: semantic events
Events declare what semantic occurrences the component emits. The semantic
vocabulary (families, intents, verbs) is canonical in
[`docs/CANON.md`](../CANON.md); the shape is:
```ts
events: [
{
name: 'commit-toggle',
semantic: {
family: 'commit', // one of 8 SEMA families
verb: 'toggle', // canonical verb (advisory, validated)
target: v.partRef('provider'), // which part receives data-event-*
sequence: 'post', // 'pre' | 'coincident' | 'post'
intent: {
// valenced families only
fromProp: 'intent', // bind to a public prop
default: 'neutral',
supported: ['neutral', 'affirm', 'risk', 'threat']
}
}
}
];
```
Field rules:
- **`name`** — the addressable id used by `runtime.trigger(name)`.
Convention: `{verb}-{variant}` (e.g. `dismiss-outside`,
`close-cancel`) or `{family}-{verb}` (e.g. `commit-toggle`,
`commit-save`). The validator accepts both shapes.
- **`semantic.family`** — one of the 8: `contact`, `commit`, `signal`,
`handle`, `emerge`, `shift`, `sustain`, `delegate` (per `SEMA_MAP`).
Whether `intent` is required is set per-family by `SEMA_FAMILY_POLICY`
(`src/uix/sema/types.ts`), not by the valenced/transitional split: only
`commit` and `signal` are `intentRequirement: 'required'`; every other
family makes `intent` optional (literal or fromProp binding).
- **`semantic.verb`** — optional, advisory. Must be in
`SEMA_VERBS[family]` when present.
- **`semantic.target`** — the `partRef` whose DOM element receives the
`data-event-*` attrs during the visual hold. Lives inside `semantic`
per the doctrinal shape (was at event-level pre-2026-05-08).
- **`semantic.sequence`** — when the perceptual signal happens
relative to the structural state change. Default `'pre'` preserves
the runtime semantics where the signal completes before the commit.
Use `'post'` when the celebration belongs after the new state lands
(commit pulses on completed actions). `'coincident'` is for
in-flight processes (sustain).
For a comprehensive worked example see the toggle and dialog morfos.
### Step 6 — Wire the provider
In `src/uix/soma/components/{kebab}/{kebab}-provider.svelte.ts`:
```ts
import { createAttrs } from '$uix/morfo';
import { dialogMorfo } from '../../../morfo/components/dialog';
const attrs = createAttrs(dialogMorfo);
// attrs is typed as
// {
// readonly provider: 'data-dialog';
// readonly trigger: 'data-dialog-trigger';
// readonly content: 'data-dialog-content';
// readonly overlay: 'data-dialog-overlay';
// readonly title: 'data-dialog-title';
// readonly description: 'data-dialog-description';
// readonly close: 'data-dialog-close';
// }
// — literal-typed from the morfo's `as const` shape.
```
Runtime-based providers call `createSomaRuntime(morfo, sources)` or
`soma.runtime(morfo, sources)`. That path calls `registerMorfo(morfo)` for
you, which compiles the morfo and registers the data contract. The strings
catalog (`componentLangs` + `commonLangs`) is registered globally by
`ActiveUix` at boot — morfo no longer publishes strings dynamically.
Only call `registerMorfo(morfo)` manually when a tool or legacy provider
needs the registry side effect without creating a runtime.
That's it. No more inline attr maps, no more manual contract objects. A typo like
`attrs.trigerr` or `attrs.content-wrong` is a compile error, not a runtime
silent-undef.
---
## Validation
Three layers catch three classes of drift:
### 1. Sium schema (build / dev)
`src/uix/morfo/schema.ts` exports `validateMorfo(morfo)` which:
- Checks shape: types, enum discriminators, literal unions.
- Checks cross-field invariants:
- Every part `kebab` is unique in the morfo.
- Every `partRef.target` resolves to an existing part.
- Every `stateRef.state` exists in the containing part's `states[]`.
- Every relative `translationRef('label')` is normalized at compile time
to `#?components.{kebab}.label`. Catalog presence is enforced by
`npm run translations:check`, not by the schema itself.
- Every `state-equals` condition's `state` exists in the containing part.
- `focus.initial.partRef` / `focus.return.partRef` resolve.
- `scope` is non-empty.
- `kebab` matches `/^[a-z][a-z0-9-]*$/`.
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
`web/routes`. Catches `pageerror`, `console.error`, same-origin request
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:
| Part kebab | Emitted attr |
| -------------- | --------------------------------------------- |
| `'provider'` | `data-{component}` — no suffix (orchestrator) |
| `'trigger'` | `data-{component}-trigger` |
| `'item-group'` | `data-{component}-item-group` |
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 })}
<a href="/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 alignment
[Sema](../../src/uix/sema/README.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';
// [data-dialog-content][data-event-family="commit"]
semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' });
// [data-dialog-content][data-event="close-after-fail"]
semaSelector(dialogMorfo, 'content', { eventName: 'close-after-fail' });
// [data-dialog-content][data-event^="close-"][data-event-family="emerge"]
semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-', eventFamily: 'emerge' });
```
### What it guarantees
- **`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:
| Consumer | Status |
| --------------------------------------------------------- | ------------------------------------------------------------------------ |
| `sema/components/*.ts` cascade rules | **MUST use** — hand-written strings are an architecture violation |
| `eidos/components/{x}/*.ts` runtime selector logic (rare) | **MUST use** when targeting morfo-backed attrs |
| Eidos plain `.css` recipes | N/A — CSS files; covered by `scripts/eidos-lint.ts` as opt-in safety net |
| Test assertions / smoke routes | Optional — strings fine, drift is caught by smoke |
The helper is exported from `$uix/morfo`. Implementation lives in [`selectors.ts`](../../src/uix/morfo/selectors.ts).
---
## Files in this package
```
src/uix/morfo/
├── README.md ← (this file) developer guide
├── types.ts ← Morfo interface + v.* builders (pure TypeScript)
├── schema.ts ← sium-based validator + CANONICAL_VOCABULARIES
├── compile.ts ← compileMorfo(morfo) → CompiledMorfo (cached by WeakMap)
├── resolver.ts ← attr resolver — pure, no DOM, no reactivity
├── create-attrs.ts ← derive data-* attr names from morfo.parts
├── contracts.ts ← runtime contract registry + assertContract
├── registry.ts ← registerMorfo + morfo-owned langs registration hooks
├── selectors.ts ← typed selector builder for sema cascade rules
├── PERMUTATION_RUNNER.md ← CI tool spec for state-space validation
├── index.ts ← package barrel
└── components/
├── dialog.ts ← one morfo per component
├── accordion.ts
├── ...
└── dialog.test.ts ← reference test pattern
```
Morfo is **pure declarative TypeScript**. No Svelte runes, no `.svelte.ts`
files, no imports of `$uix/sema` / `$adom` / `$libs/reactive`. The
runtime that interprets a compiled morfo lives in soma — see
[`../soma/runtime.svelte.ts`](../../src/uix/soma/runtime.svelte.ts).
---
## Commands
| Command | Purpose |
| ------------------------------ | ----------------------------------------------------------------------- |
| `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
// ✅ Right — literal-preserved shape.
export const dialogMorfo = { ... } as const satisfies Morfo;
const attrs = createAttrs(dialogMorfo);
attrs.trigerr; // compile error — no such part
attrs.trigger; // typed as 'data-dialog-trigger'
```
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.
- [COMPONENT_GUIDE.md](../../src/uix/soma/COMPONENT_GUIDE.md) — soma component authoring (morfo-specific discipline: translation-namespace grep, DOM-topology audit, smoke validation).
- [sema/README.md](../../src/uix/sema/README.md) — semantic layer (morfo provides everything Sema needs via `events[].semantic`).
- [`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md) — convenciones doctrinales del API.

@ -28,7 +28,7 @@ this doc wins on *order*; the phase doc wins on *content*.
| Phase | You produce | Read THIS | Verified by |
| --- | --- | --- | --- |
| **0 · Decide** | comparison table vs ark/bits/radix (+ react-aria), membership call (soma or eidos-native), compose-first check | [`soma/COMPONENT_GUIDE.md`](../src/uix/soma/COMPONENT_GUIDE.md) §Before You Start (1–4) · membership: [`soma/README.md`](../src/uix/soma/README.md) §2 | reviewer — the table goes in the component README (phase 7) |
| **1 · Morfo** | `src/uix/morfo/components/{kebab}.ts` — parts, data/aria, keyboard, events (family/verb/intent/sequence), `texts`, `expression` | [`morfo/README.md`](../src/uix/morfo/README.md) §Authoring a new morfo · vocabulary: [`CANON.md`](./CANON.md) | `validateMorfo` + `npm run morfo:vocabulary` + audit `A-*` |
| **1 · Morfo** | `src/uix/morfo/components/{kebab}.ts` — parts, data/aria, keyboard, events (family/verb/intent/sequence), `texts`, `expression` | [`morfo/README.md`](./architecture/morfo.md) §Authoring a new morfo · vocabulary: [`CANON.md`](./CANON.md) | `validateMorfo` + `npm run morfo:vocabulary` + audit `A-*` |
| **2 · Soma** | `{kebab}-provider.svelte.ts` + thin wrappers + `types.ts` + provider test | [`soma/COMPONENT_GUIDE.md`](../src/uix/soma/COMPONENT_GUIDE.md) (patterns + rules A1–A37) | provider test + `npm run check` |
| **3 · Sema** | pack `src/uix/sema/components/{kebab}.ts` (or explicit `expression: 'family-default'`) — selectors via `semaSelector` ONLY | [`sema/README.md`](../src/uix/sema/README.md) §packs + §typed builder · pack-vs-default criteria: [`LIBRO_VARIACIONES`](../src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md) D.4 | `npm run morfo:vocabulary` (expression coverage) |
| **4 · Eidos wrapper** | `eidos/components/{kebab}/` — root + attached parts (option C), visual props | [`eidos/components/README.md`](../src/uix/eidos/components/README.md) (the 7 hard rules) | `component-api-contract` test + audit `E-*` |
@ -39,7 +39,7 @@ this doc wins on *order*; the phase doc wins on *content*.
Cross-phase invariants you will hit in every phase: the layer boundaries
([`active_architecture.md`](../src/uix/active_architecture.md) §7 hard rules) and the
2-of-3 rule for extending morfo ([`morfo/README.md`](../src/uix/morfo/README.md)).
2-of-3 rule for extending morfo ([`morfo/README.md`](./architecture/morfo.md)).
---

@ -37,7 +37,7 @@ A component's public DOM surface — parts, `data-*`, ARIA, keyboard, events —
declared once in a typed object and consumed by every layer. Rename a part and
the consuming layers break at **compile time**. In the headless libraries that
structural information is spread across the provider, the ARIA derivations, the
CSS and the docs, and drifts silently. → [`morfo/README`](../src/uix/morfo/README.md)
CSS and the docs, and drifts silently. → [`architecture/morfo`](./architecture/morfo.md)
### 2. A perception layer (`sema`)

@ -19,7 +19,7 @@ New here? Start at [`docs/README.md`](./README.md).
| Term | Meaning |
| --- | --- |
| **morfo** | The declarative contract (a component's "DNA"): its public DOM surface — parts, `data-*`/ARIA, keyboard, events — declared once in a typed object. Every other layer reads it. → [`morfo/README`](../src/uix/morfo/README.md) |
| **morfo** | The declarative contract (a component's "DNA"): its public DOM surface — parts, `data-*`/ARIA, keyboard, events — declared once in a typed object. Every other layer reads it. → [`architecture/morfo`](./architecture/morfo.md) |
| **soma** | The headless behavior layer: keyboard, focus, ARIA wiring, state machines, composition. No visuals. → [`soma/README`](../src/uix/soma/README.md) |
| **sema** | The perceptual engine: turns a declared event into sound / haptic (runtime) and a `data-event-*` projection (for eidos), via a cascade. → [`sema/README`](../src/uix/sema/README.md) |
| **eidos** | The visual layer: CSS recipes, tokens, themes, sizes, variants — reacts to the DOM attrs morfo promises. → [`eidos/README`](../src/uix/eidos/README.md) |

@ -91,7 +91,7 @@ audits/fósiles ya gestionados.
| Tanda | Contenido | Volumen | Estado |
|---|---|---|---|
| **F7.1** | Plan + esqueleto + piloto `active-uix.md` (valida el patrón) | ~100 L | **HECHA 2026-07-02** |
| F7.2 | `architecture/` — overview, active-architecture, morfo, soma×2, sema, eidos | ~5.4k L (ES→EN el grueso) | pendiente |
| F7.2 | `architecture/` — overview, active-architecture, ~~morfo~~ (hecha), soma×2, sema, eidos | ~4.5k L restantes (ES→EN el grueso) | en curso |
| F7.3 | `theming/` + `canon/` — THEMING familia, TSC, RECIPE_CONTRACT, motion | ~3.1k L | pendiente |
| F7.4 | `rfcs/` — 7 RFCs (rename incluido; MOTION_SERVICE_RFC NO — foráneo) | ~3-4k L | pendiente |
| F7.5 | `guides/` — COMPONENT_GUIDE, checklist (tocar docs-check I5), demo guides | ~2.2k L | pendiente |

@ -63,7 +63,7 @@ ARIA, foco, teclado, eventos y, cuando el texto pertenece al contrato, los
slots `texts` del componente (idlangrefs). No es prose ni runtime — es la
forma canónica pública.
Ver: [morfo/README.md](./morfo/README.md)
Ver: [docs/architecture/morfo.md](../../docs/architecture/morfo.md)
### `Sema` / Events (`src/uix/sema/`)
@ -476,7 +476,7 @@ Siete piezas, siete responsabilidades, ninguna invade a la siguiente.
## 7. Orden de lectura sugerido
0. [`docs/CANON.md`](../../docs/CANON.md) — **canon semántico**: el vocabulario (familias, intents, verbs, composición, canales) anclado al libro + código. La fuente de verdad que el resto enlaza.
1. [morfo/README.md](./morfo/README.md) — declaración, archetypes, regla 2-de-3
1. [docs/architecture/morfo.md](../../docs/architecture/morfo.md) — declaración, archetypes, regla 2-de-3
2. [sema/README.md](./sema/README.md) — `emit` contract, verbs canónicos, canales
3. [eidos/README.md](./eidos/README.md) — qué consume eidos del DOM + wrappers
4. [soma/SOMA_ARCHITECTURE.md](./soma/SOMA_ARCHITECTURE.md) — runtime que transcribe morfo

@ -853,7 +853,7 @@ que evolucionar conscientemente.
## 14. Para profundizar
- [src/uix/README.md](./README.md) — posicionamiento general (más narrativo)
- [src/uix/morfo/README.md](./morfo/README.md) — declaración, archetypes, regla 2-de-3
- [docs/architecture/morfo.md](../../docs/architecture/morfo.md) — declaración, archetypes, regla 2-de-3
- [src/uix/sema/README.md](./sema/README.md) — `emit` contract, verbs canónicos
- [src/uix/soma/SOMA_ARCHITECTURE.md](./soma/SOMA_ARCHITECTURE.md) — runtime + componentes
- [src/uix/soma/COMPONENT_GUIDE.md](./soma/COMPONENT_GUIDE.md) — guía operativa para crear / migrar componentes

@ -817,7 +817,7 @@ semaSelector(toggleMorfo, 'provider', { eventName: 'commit-toggle' });
Renombrar una part o un evento en el morfo rompe el typecheck. Es
imposible que un selector TypeScript drifte silenciosamente. Ver
[`morfo/README.md#typed-selector-builder--semaselector`](../morfo/README.md#typed-selector-builder--semaselector).
[`docs/architecture/morfo.md#typed-selector-builder--semaselector`](../../../docs/architecture/morfo.md#typed-selector-builder--semaselector).
### Run-time (CSS recipes) — `eidos-lint` como red de seguridad opt-in

@ -1,865 +1,16 @@
# 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}.*`.
- **`parts`** — the part tree (recursive). Each part declares:
- `name`, `kebab`, `kind` (`public` / `virtual`).
- `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.
- `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 |
| 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
Effects sync attrs from state
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`) | Effect of attrs | Reactive `data-*` |
| `parts[].aria` | Effect of attrs | Reactive `aria-*` |
| `parts[].role` | Effect of attrs | Stable role |
| `parts[].keyboard` | `runtime.keydown(part, event)` | Key dispatch |
| `events[].prewrite` | `trigger()` step 1 | Transient markers |
| `events[].semantic` | `trigger()` step 2 (emit payload) | Perceptual signal |
| `events[].commits` | **Nobody executes**; smoke validates | Documentation |
| `focus` | Configures FocusScope layer | Layer bootstrap |
`commits` is **descriptive**, not prescriptive. The actual causal chain is
`handler -> state mutation -> effect -> dom.apply`. 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. effects derive and apply structural attrs (data-state, aria-*)
```
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: {
open: () => {
this.opts.open.current = true;
},
'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': '' }
```
Everything mutable (`role` derived from prop, `aria-*`, `data-state`, `data-intent`)
is written by the runtime's effects via `dom.apply`. Svelte does not render
those attrs.
### Operational rules
- `partProps(part)` returns only static identity (id, ref, marker).
- `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 [src/uix/README.md](../README.md) §2.bis for the cross-layer view.
---
## 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`](./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). 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) |
| `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: 'expand',
semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' }
},
{
name: 'collapse',
semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'pre' }
}
```
`expand` is `post` so Eidos reacts after content exists. `collapse` is `pre` so
the exit signal can play while content is still visible. 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: {
initial: 'first-focusable',
trap: true,
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<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.
**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. `<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`](./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.
### Step 3 — Declare `data` entries
For each data-attr the provider emits:
```ts
data: [
// Enum-valued: the complete set.
{ attr: 'data-state', values: ['open', 'closed'] },
// Same attr, but now declaring its runtime source too.
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
// Presence-only flag: emitted only when true, absent otherwise.
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
// Free value: writes the raw value, not an empty presence attr.
{ attr: 'data-value', value: v.propRef('value'), emit: 'value' },
// Free value written manually by the component, not by SomaRuntime.
{ attr: 'data-form-auto-fields-field', emit: 'value' },
// Conditionally-emitted attr.
{
attr: 'data-starting-style',
severity: 'optional',
condition: { when: 'state-equals', state: 'open', value: 'starting' }
}
];
```
Non-enum `data-*` entries default to `emit: 'presence'`: truthy writes
`data-x=""`, falsy removes the attr. Use `emit: 'value'` only when the attr
must carry a real value, such as `data-value`, `data-min`, `data-max` or a
manual component attr like `data-form-auto-fields-field`. When `value` is
omitted, the attr remains declarative-only: Morfo documents the contract, but
the component/provider is still responsible for writing the value.
**Severity rules**:
- `'required'` (default) — the attr must always be emitted. Missing = error in strict mode.
- `'recommended'` — emitted in most cases. Missing = warning.
- `'optional'` — emitted only when its condition is met (e.g. `data-disabled` only when disabled). Missing ≠ error.
Use `'optional'` for all presence-only flags so morfo-check doesn't flag them as missing when they're legitimately absent.
### Step 4 — Declare `aria` entries
ARIA values use a tagged union via builders:
```ts
aria: [
{ attr: 'aria-haspopup', value: v.literal('dialog') }, // static literal
{ attr: 'aria-expanded', value: v.stateRef('open') }, // refs states[]
{ attr: 'aria-labelledby', value: v.partRef('title') }, // refs another part's kebab
{ attr: 'aria-level', value: v.propRef('level') }, // refs a consumer prop
{
attr: 'aria-roledescription',
value: v.translationRef('content.roledescription', 'dialog window')
},
{ attr: 'aria-label', value: v.commonRef('buttons.close', 'Close') },
{ attr: 'aria-label', value: v.langRef('app.shell.close', 'Close') }
];
```
**Value kinds**:
- `v.literal(value)` — static string invariant. `aria-haspopup="dialog"`, `aria-modal="true"`.
- `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:
```ts
condition: 'always'
condition: { when: 'part-present', part: 'title' }
condition: { when: 'state-equals', state: 'open', value: 'true' }
condition: { when: 'prop-truthy', prop: 'modal' }
condition: { when: 'prop-falsy', prop: 'disabled' }
```
### Step 4.5 — Declare component-owned `texts`
If a text slot belongs to the component contract, declare it on the morfo as
an idlangref. The morfo never embeds the literal multilingual record — that
lives in `src/uix/langs/components/{kebab}.ts` and is merged into the active
catalog by `ActiveUix`:
```ts
export const dialogMorfo = {
name: 'Dialog',
kebab: 'dialog',
// ...
texts: {
trigger: '#?components.dialog.trigger|Open dialog',
'content.roledescription': '#?components.dialog.content.roledescription|dialog window'
},
parts: [
{
name: 'Content',
kebab: 'content',
// ...
aria: [
{
attr: 'aria-roledescription',
value: v.translationRef('content.roledescription', 'dialog window')
}
]
}
]
} as const satisfies Morfo;
```
Use relative refs for text owned by this component. Use `v.commonRef` for
shared actions (`close`, `cancel`, `save`, `next`, `previous`) so the same
string is not duplicated across Drawer, Dialog, Popover, Toast, etc. Use
`v.langRef` for an app/system namespace that is deliberately not owned by
the component.
### Step 5 — Optional: keyboard and focus
```ts
keyboard: [
{ key: 'Escape', action: 'close' },
{ key: 'Tab', action: 'focus-next' },
{ key: 'Shift+Tab', action: 'focus-prev' }
]
focus: {
initial: 'first-focusable', // 'first-focusable' | 'trigger' | { partRef: 'content' }
trap: true,
return: 'trigger', // 'trigger' | 'previous' | { partRef: '...' }
restore: true
}
```
Focus is only required for overlay/composite components (Dialog, Drawer, Popover). Plain controls omit it.
### Step 5.5 — Optional: semantic events
Events declare what semantic occurrences the component emits. The semantic
vocabulary (families, intents, verbs) is canonical in
[`docs/CANON.md`](../../../docs/CANON.md); the shape is:
```ts
events: [
{
name: 'commit-toggle',
semantic: {
family: 'commit', // one of 8 SEMA families
verb: 'toggle', // canonical verb (advisory, validated)
target: v.partRef('provider'), // which part receives data-event-*
sequence: 'post', // 'pre' | 'coincident' | 'post'
intent: {
// valenced families only
fromProp: 'intent', // bind to a public prop
default: 'neutral',
supported: ['neutral', 'affirm', 'risk', 'threat']
}
}
}
];
```
Field rules:
- **`name`** — the addressable id used by `runtime.trigger(name)`.
Convention: `{verb}-{variant}` (e.g. `dismiss-outside`,
`close-cancel`) or `{family}-{verb}` (e.g. `commit-toggle`,
`commit-save`). The validator accepts both shapes.
- **`semantic.family`** — one of the 8: `contact`, `commit`, `signal`,
`handle`, `emerge`, `shift`, `sustain`, `delegate` (per `SEMA_MAP`).
Whether `intent` is required is set per-family by `SEMA_FAMILY_POLICY`
(`src/uix/sema/types.ts`), not by the valenced/transitional split: only
`commit` and `signal` are `intentRequirement: 'required'`; every other
family makes `intent` optional (literal or fromProp binding).
- **`semantic.verb`** — optional, advisory. Must be in
`SEMA_VERBS[family]` when present.
- **`semantic.target`** — the `partRef` whose DOM element receives the
`data-event-*` attrs during the visual hold. Lives inside `semantic`
per the doctrinal shape (was at event-level pre-2026-05-08).
- **`semantic.sequence`** — when the perceptual signal happens
relative to the structural state change. Default `'pre'` preserves
the runtime semantics where the signal completes before the commit.
Use `'post'` when the celebration belongs after the new state lands
(commit pulses on completed actions). `'coincident'` is for
in-flight processes (sustain).
For a comprehensive worked example see the toggle and dialog morfos.
### Step 6 — Wire the provider
In `src/uix/soma/components/{kebab}/{kebab}-provider.svelte.ts`:
```ts
import { createAttrs } from '$uix/morfo';
import { dialogMorfo } from '../../../morfo/components/dialog';
const attrs = createAttrs(dialogMorfo);
// attrs is typed as
// {
// readonly provider: 'data-dialog';
// readonly trigger: 'data-dialog-trigger';
// readonly content: 'data-dialog-content';
// readonly overlay: 'data-dialog-overlay';
// readonly title: 'data-dialog-title';
// readonly description: 'data-dialog-description';
// readonly close: 'data-dialog-close';
// }
// — literal-typed from the morfo's `as const` shape.
```
Runtime-based providers call `createSomaRuntime(morfo, sources)` or
`soma.runtime(morfo, sources)`. That path calls `registerMorfo(morfo)` for
you, which compiles the morfo and registers the data contract. The strings
catalog (`componentLangs` + `commonLangs`) is registered globally by
`ActiveUix` at boot — morfo no longer publishes strings dynamically.
Only call `registerMorfo(morfo)` manually when a tool or legacy provider
needs the registry side effect without creating a runtime.
That's it. No more inline attr maps, no more manual contract objects. A typo like
`attrs.trigerr` or `attrs.content-wrong` is a compile error, not a runtime
silent-undef.
---
## Validation
Three layers catch three classes of drift:
### 1. Sium schema (build / dev)
`src/uix/morfo/schema.ts` exports `validateMorfo(morfo)` which:
- Checks shape: types, enum discriminators, literal unions.
- Checks cross-field invariants:
- Every part `kebab` is unique in the morfo.
- Every `partRef.target` resolves to an existing part.
- Every `stateRef.state` exists in the containing part's `states[]`.
- Every relative `translationRef('label')` is normalized at compile time
to `#?components.{kebab}.label`. Catalog presence is enforced by
`npm run translations:check`, not by the schema itself.
- Every `state-equals` condition's `state` exists in the containing part.
- `focus.initial.partRef` / `focus.return.partRef` resolve.
- `scope` is non-empty.
- `kebab` matches `/^[a-z][a-z0-9-]*$/`.
Run a morfo through this to catch authoring errors early. See [`components/dialog.test.ts`](./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
`web/routes`. Catches `pageerror`, `console.error`, same-origin request
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:
| Part kebab | Emitted attr |
| -------------- | --------------------------------------------- |
| `'provider'` | `data-{component}` — no suffix (orchestrator) |
| `'trigger'` | `data-{component}-trigger` |
| `'item-group'` | `data-{component}-item-group` |
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 })}
<a href="/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 alignment
[Sema](../sema/README.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`](./selectors.ts) instead of hand-writing strings:
```ts
import { semaSelector } from '$uix/morfo';
import { dialogMorfo } from '$uix/morfo/components/dialog';
// [data-dialog-content][data-event-family="commit"]
semaSelector(dialogMorfo, 'content', { eventFamily: 'commit' });
// [data-dialog-content][data-event="close-after-fail"]
semaSelector(dialogMorfo, 'content', { eventName: 'close-after-fail' });
// [data-dialog-content][data-event^="close-"][data-event-family="emerge"]
semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-', eventFamily: 'emerge' });
```
### What it guarantees
- **`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:
| Consumer | Status |
| --------------------------------------------------------- | ------------------------------------------------------------------------ |
| `sema/components/*.ts` cascade rules | **MUST use** — hand-written strings are an architecture violation |
| `eidos/components/{x}/*.ts` runtime selector logic (rare) | **MUST use** when targeting morfo-backed attrs |
| Eidos plain `.css` recipes | N/A — CSS files; covered by `scripts/eidos-lint.ts` as opt-in safety net |
| Test assertions / smoke routes | Optional — strings fine, drift is caught by smoke |
The helper is exported from `$uix/morfo`. Implementation lives in [`selectors.ts`](./selectors.ts).
---
## Files in this package
```
src/uix/morfo/
├── README.md ← (this file) developer guide
├── types.ts ← Morfo interface + v.* builders (pure TypeScript)
├── schema.ts ← sium-based validator + CANONICAL_VOCABULARIES
├── compile.ts ← compileMorfo(morfo) → CompiledMorfo (cached by WeakMap)
├── resolver.ts ← attr resolver — pure, no DOM, no reactivity
├── create-attrs.ts ← derive data-* attr names from morfo.parts
├── contracts.ts ← runtime contract registry + assertContract
├── registry.ts ← registerMorfo + morfo-owned langs registration hooks
├── selectors.ts ← typed selector builder for sema cascade rules
├── PERMUTATION_RUNNER.md ← CI tool spec for state-space validation
├── index.ts ← package barrel
└── components/
├── dialog.ts ← one morfo per component
├── accordion.ts
├── ...
└── dialog.test.ts ← reference test pattern
```
Morfo is **pure declarative TypeScript**. No Svelte runes, no `.svelte.ts`
files, no imports of `$uix/sema` / `$adom` / `$libs/reactive`. The
runtime that interprets a compiled morfo lives in soma — see
[`../soma/runtime.svelte.ts`](../soma/runtime.svelte.ts).
---
## Commands
| Command | Purpose |
| ------------------------------ | ----------------------------------------------------------------------- |
| `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
// ✅ Right — literal-preserved shape.
export const dialogMorfo = { ... } as const satisfies Morfo;
const attrs = createAttrs(dialogMorfo);
attrs.trigerr; // compile error — no such part
attrs.trigger; // typed as 'data-dialog-trigger'
```
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](./types.ts) — the TypeScript interfaces (authoritative reference).
- [PERMUTATION_RUNNER.md](./PERMUTATION_RUNNER.md) — CI tool that cycles components through their state space.
- [COMPONENT_GUIDE.md](../soma/COMPONENT_GUIDE.md) — soma component authoring (morfo-specific discipline: translation-namespace grep, DOM-topology audit, smoke validation).
- [sema/README.md](../sema/README.md) — semantic layer (morfo provides everything Sema needs via `events[].semantic`).
- [`src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md`](../../docs/GUIA_IMPLEMENTACION_SEMAUIX.md) — convenciones doctrinales del API.
**The cross-layer contract of a component's public DOM surface** — parts,
data-attrs, ARIA, keyboard, focus policy and the public event surface, declared
once and consumed by soma / sema / eidos / the docs site.
**The reference moved to the docs corpus:**
[`docs/architecture/morfo.md`](../../../docs/architecture/morfo.md)
— why morfo exists, what it contains (and what it does NOT), how it gets
executed, archetypes, the authoring steps (morfo-first workflow), the typed
selector builder `semaSelector`, validation, and the pitfalls.
Quick pointers: the TypeScript shape is [`types.ts`](./types.ts)
(`ARCHETYPE_VOCABULARY` included); compiler in [`compile.ts`](./compile.ts);
schema validation in [`schema.ts`](./schema.ts); component declarations in
[`components/`](./components/).

@ -142,7 +142,7 @@ Cada componente tiene un archivo en `src/uix/morfo/components/{kebab}.ts` que de
El morfo es la **única fuente de verdad** del contrato público: Soma, Eidos,
Sema y la docs auto-generada lo consumen todos. El dev guide completo —por qué
existe morfo, archetypes, la regla 2-de-3, validación y qué NO va en morfo—
vive en [`src/uix/morfo/README.md`](../morfo/README.md).
vive en [`docs/architecture/morfo.md`](../../../docs/architecture/morfo.md).
### Cómo soma consume un morfo

@ -293,7 +293,7 @@ vez que el estado cambia. ADom es el unico escritor de attrs mutables.
Ver tambien:
- [src/uix/morfo/README.md](../morfo/README.md) — declaracion, archetypes, regla 2-de-3
- [docs/architecture/morfo.md](../../../docs/architecture/morfo.md) — declaracion, archetypes, regla 2-de-3
- [src/uix/sema/README.md](../sema/README.md) — contrato de `emit`, vocabulario de verbs
- [src/arts/adom/README.md](../../arts/adom/README.md) — `dom.apply`
- [src/uix/eidos/README.md](../eidos/README.md) — qué consume eidos del DOM

Loading…
Cancel
Save

Powered by TurnKey Linux.