|
|
---
|
|
|
title: Morfo — the declarative contract
|
|
|
type: reference
|
|
|
audience: human + agent
|
|
|
authority: E1 architecture — the cross-layer contract layer (DNA)
|
|
|
status: current
|
|
|
source: migrated from src/uix/morfo/README.md (2026-07-02, docs-book F7.2)
|
|
|
---
|
|
|
|
|
|
# Morfo
|
|
|
|
|
|
**The cross-layer contract of a component's public DOM surface.**
|
|
|
|
|
|
Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, focus policy, and public event contract. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables).
|
|
|
|
|
|
**One file per component**, at `src/uix/morfo/components/{kebab}.ts`. No prose — that's the component's README. No props — those live in `types.ts` with JSDoc. Component-owned text slots may live in `texts` (idlangrefs) when they are part of ARIA labels, live-region text, or internal functional labels — the multilingual catalog itself lives in `src/uix/langs/components/{kebab}.ts`. Everything else is the machine-readable contract.
|
|
|
|
|
|
## Why morfo exists
|
|
|
|
|
|
Without morfo, a component's structural information used to live in many places:
|
|
|
|
|
|
- Part names in manual attr maps inside the provider.
|
|
|
- Data-attr enums in manual contract registration inside the provider.
|
|
|
- ARIA emission hardcoded in the provider's `$derived.by(...)` props.
|
|
|
- Keyboard handlers scattered across the provider.
|
|
|
- Public event names and their transport split across provider code, docs, and consumers.
|
|
|
- Prose descriptions in the README.
|
|
|
- Selector strings duplicated in eidos CSS, sema `.csem`, docs tables.
|
|
|
|
|
|
Renaming a part (`content` → `panel`) used to mean touching 6+ locations with zero automatic verification. Cross-layer drift (soma emits `data-dialog-content`, eidos styles `data-dialog-panel`) was silent.
|
|
|
|
|
|
With morfo, **every location reads from the same declaration**. Parts, data-attrs, enum values, and component-owned translation keys are authored once. `compileMorfo`, `registerMorfo`, `SomaRuntime`, and the selector helper `createAttrs` consume the morfo directly. A smoke test validates the real DOM against the declaration on every CI run.
|
|
|
|
|
|
---
|
|
|
|
|
|
## What morfo contains
|
|
|
|
|
|
A `Morfo` is a plain TypeScript constant that describes:
|
|
|
|
|
|
- **`name`** — PascalCase display name (`"Dialog"`).
|
|
|
- **`kebab`** — kebab-case identifier (`"dialog"`), matches the public `data-{kebab}` marker.
|
|
|
- **`scope`** — which layers implement this component: `['soma']`, `['soma', 'eidos']`, etc.
|
|
|
- **`apg`** — optional URL to the WAI-ARIA APG pattern when the component implements a formal one.
|
|
|
- **`focus`** — optional focus policy for overlays / composites.
|
|
|
- **`events`** — the component's public event surface: which semantic occurrences it may emit and expose to cross-layer consumers.
|
|
|
- **`texts`** — optional component-owned text slots, declared as idlangrefs (`'#?components.{kebab}.{key}|Fallback'`). The multilingual catalog itself lives in `src/uix/langs/components/{kebab}.ts` and is registered by `ActiveUix` under `components.{kebab}.*`. Its shape (flat keys, per-language leaves, named export `{camelKebab}Langs` registered in `langs/components/index.ts`):
|
|
|
|
|
|
```ts
|
|
|
import type { LangNode } from '$libs/langs';
|
|
|
|
|
|
export const knobLangs = {
|
|
|
label: { es: 'Dial', en: 'Knob' }
|
|
|
} satisfies LangNode;
|
|
|
```
|
|
|
- **`parts`** — the part tree (recursive). Each part declares:
|
|
|
- `name`, `kebab`, `kind` (`public` — consumer-composed · `private` —
|
|
|
rendered only by soma/eidos defaults but still styled + contract-checked ·
|
|
|
`virtual` — DOM-less coordinator, ignored by the contract validator; the
|
|
|
source is `MorfoPartKind` in `morfo/types.ts`).
|
|
|
- `archetype` — optional cross-component classification (see "Archetypes" below).
|
|
|
- `defaultElement` (advisory), `role` (always-emitted).
|
|
|
- `optional`, `supportsNesting`.
|
|
|
- `states` — the state names this part can be in.
|
|
|
- `data` — data-attributes emitted, with enum values when applicable and optional runtime source metadata when the contract wants to declare where the attr comes from.
|
|
|
- `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 — except NAMING attrs (`aria-label`), which ship in the part's render bag so the consumer's attr can win (see Step 4) | 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: {
|
|
|
'emerge-open': () => {
|
|
|
this.opts.open.current = true;
|
|
|
},
|
|
|
'emerge-close-cancel': () => {
|
|
|
this.opts.open.current = false;
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
```
|
|
|
|
|
|
Each part-provider then renders only the static identity:
|
|
|
|
|
|
```ts
|
|
|
readonly props = $derived.by(() => runtime.partProps('trigger'));
|
|
|
// returns: { id, ref attachment, 'data-{component}-trigger': '' }
|
|
|
```
|
|
|
|
|
|
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 [overview.md](./overview.md) §2.bis for the cross-layer view.
|
|
|
|
|
|
---
|
|
|
|
|
|
## `renderAttrs` — the attrs that are not parts
|
|
|
|
|
|
Some components write `data-{component}-*` onto elements that **are not parts
|
|
|
and never could be**:
|
|
|
|
|
|
- a document engine marks the user's OWN tree — `palabras` stamps block kind,
|
|
|
heading level, syntax tokens and i18n state on `<blockquote>` / `<table>` /
|
|
|
`<figure>` nodes whose shape depends on what the user wrote;
|
|
|
- a component marks its internal render nodes — `waveform`'s two `<path>`s,
|
|
|
which its own source already calls "eidos-only hooks".
|
|
|
|
|
|
They cannot be `parts[]` (the morfo could never enumerate them) and they cannot
|
|
|
be `data-_*` (the private prefix is outside morfo by design, and eidos STYLES
|
|
|
these — `palabras.css` alone has rules for 23). That left a real cross-layer
|
|
|
contract, soma writing and eidos reading, that **nothing declared** — which is
|
|
|
what morfo exists to prevent.
|
|
|
|
|
|
```ts
|
|
|
renderAttrs: [
|
|
|
{ attr: 'data-palabras-block' },
|
|
|
{ attr: 'data-palabras-heading-level' },
|
|
|
{ attr: 'data-palabras-untranslated' }
|
|
|
]
|
|
|
```
|
|
|
|
|
|
**Where the line is drawn, and it is sharp: if a consumer can compose it or
|
|
|
address it, it is a PART.** `renderAttrs` is for what the component renders
|
|
|
internally and the consumer never composes. It buys no runtime writer — the
|
|
|
component emits them itself, as it always did; what it buys is that the
|
|
|
contract is declared, greppable and guarded (`contracts.test.ts` reads this
|
|
|
file, so an attr that is neither a part nor declared here fails).
|
|
|
|
|
|
Added 2026-08-10, when lifting `palabras`' catalogue exemption exposed 23
|
|
|
undeclared ones. Like `MorfoElement`, the vocabulary lives in TWO places —
|
|
|
the TypeScript shape in [`types.ts`](../../src/uix/morfo/types.ts) and the sium
|
|
|
schema in [`schema.ts`](../../src/uix/morfo/schema.ts). Touch both in one edit.
|
|
|
|
|
|
---
|
|
|
|
|
|
## Archetypes — cross-component part classification
|
|
|
|
|
|
Beyond the component-specific `kebab`, a part can declare an `archetype`
|
|
|
that tags it as part of a cross-component category. This is what lets
|
|
|
**eidos** style "all triggers" or "all overlays" with a single transversal
|
|
|
selector instead of enumerating every component.
|
|
|
|
|
|
```ts
|
|
|
{
|
|
|
name: 'Trigger',
|
|
|
kebab: 'trigger',
|
|
|
archetype: 'trigger', // ← cross-component category
|
|
|
role: 'button',
|
|
|
// ...
|
|
|
}
|
|
|
```
|
|
|
|
|
|
The runtime emits `data-archetype="..."` on the part's DOM element via
|
|
|
`partProps`. Static identity (never mutates), so it ships through
|
|
|
`partProps`, not through `dom.apply`.
|
|
|
|
|
|
### Vocabulary
|
|
|
|
|
|
The canonical inventory is the `ARCHETYPE_VOCABULARY` const in
|
|
|
[`types.ts`](../../src/uix/morfo/types.ts) — the single source; do not copy the list into
|
|
|
prose (a copied list here survived at 24 entries while the code grew to
|
|
|
26). The **generated list with a one-line role for each** archetype lives at
|
|
|
[`canon/vocabularies.md`](../canon/vocabularies.md) (from
|
|
|
`ARCHETYPE_DESCRIPTIONS`) — read it to pick a part's archetype. Validated by
|
|
|
the sium schema. Optional field — omit when a part is genuinely unique to its
|
|
|
component (`Slider.Range`, `PinInput.Segment` internals).
|
|
|
|
|
|
### Provider as trigger vs container
|
|
|
|
|
|
When `Provider` IS the interactive element (Toggle, Switch, Checkbox —
|
|
|
where `defaultElement: 'button'` and the user clicks the Provider itself),
|
|
|
its archetype is `'trigger'`. When `Provider` is just a root container
|
|
|
(`defaultElement: 'div'/'section'/'nav'`), archetype is `'provider'`.
|
|
|
Decide per-morfo.
|
|
|
|
|
|
### Adding a new archetype
|
|
|
|
|
|
Only add when at least two existing components share the role with the
|
|
|
same conceptual meaning. The vocabulary stays small on purpose. If only
|
|
|
one component has it, leave the part without an archetype.
|
|
|
|
|
|
---
|
|
|
|
|
|
## The 2-of-3 rule for extending morfo
|
|
|
|
|
|
Morfo is the cross-layer contract between **soma**, **sema**, and **eidos**
|
|
|
— not a convenience repository for soma. An extension to morfo is justified
|
|
|
only when **at least two of the three layers** consume it.
|
|
|
|
|
|
| What | Soma | Sema | Eidos | In morfo? |
|
|
|
| --------------------------------------------- | --------------------------------- | ------------------ | -------------------------- | ---------------------- |
|
|
|
| `parts[].data` + `aria` + `role` | ✅ | — | ✅ | ✅ |
|
|
|
| `parts[].archetype` | ✅ (emit) | ✅ (verbs by role) | ✅ (transversal selectors) | ✅ |
|
|
|
| `parts[].keyboard` | ✅ (dispatch) | — | — | ✅ (was already there) |
|
|
|
| `events[].semantic` family/intent | ✅ (signal payload) | ✅ (vocabulary) | ✅ (selector tinting) | ✅ |
|
|
|
| `events[].prewrite` (e.g. `data-last-action`) | ✅ (apply) | ✅ (sequence) | ✅ (tint exit anim) | ✅ |
|
|
|
| `data-starting-style` / `data-ending-style` | ✅ (Presence) | — | ✅ (animations) | ✅ |
|
|
|
| Computed state from N props | — (provider exposes virtual prop) | — | — | ❌ |
|
|
|
| `firstOf` value source (priority chain) | ✅ only | — | — | ❌ |
|
|
|
| `prop-not-nullish` condition | ✅ only | — | — | ❌ |
|
|
|
| Field-context OR'ing | — (provider, virtual prop) | — | — | ❌ |
|
|
|
|
|
|
Soma-only conveniences live in the provider — typically as a "virtual
|
|
|
prop" that the provider exposes via runtime `props` sources, then morfo
|
|
|
reads with `propRef`. The line stays clean: morfo declares structure +
|
|
|
contracts; provider decides logic.
|
|
|
|
|
|
### Disclosure events
|
|
|
|
|
|
Disclosure-style components (`Collapsible`, accordion item panels, row-detail
|
|
|
reveals) are non-evaluative. They should not declare `intent`, color subset or
|
|
|
size semantics just because they animate. The canonical morfo shape is two
|
|
|
directional `emerge` events:
|
|
|
|
|
|
```ts
|
|
|
{
|
|
|
name: 'emerge-expand',
|
|
|
semantic: { family: 'emerge', verb: 'expand', target: v.partRef('content'), sequence: 'post' }
|
|
|
},
|
|
|
{
|
|
|
name: 'emerge-collapse',
|
|
|
semantic: { family: 'emerge', verb: 'collapse', target: v.partRef('content'), sequence: 'post' }
|
|
|
}
|
|
|
```
|
|
|
|
|
|
`emerge-expand` is `post` so Eidos reacts after content exists. `emerge-collapse` is `post`
|
|
|
too (collapsible-NEW-001), so the conceal runs against a content the flip has
|
|
|
not yet hidden. Any visual color,
|
|
|
motion or density response belongs to Eidos recipes, not to the morfo event.
|
|
|
|
|
|
---
|
|
|
|
|
|
## Anatomy of a morfo file
|
|
|
|
|
|
Minimal template:
|
|
|
|
|
|
```ts
|
|
|
// src/uix/morfo/components/dialog.ts
|
|
|
import type { Morfo } from '../types';
|
|
|
import { v } from '../types'; // value builders: v.literal, v.stateRef, v.partRef, v.propRef, v.translationRef, v.commonRef, v.langRef
|
|
|
|
|
|
export const dialogMorfo = {
|
|
|
name: 'Dialog',
|
|
|
kebab: 'dialog',
|
|
|
scope: ['soma'],
|
|
|
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',
|
|
|
|
|
|
focus: {
|
|
|
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.
|
|
|
|
|
|
**Two precedence classes (A-85, 2026-08-11).** Every attr the morfo declares is
|
|
|
one of two kinds, and the split is by ATTR NAME, not per-declaration:
|
|
|
|
|
|
- **Contract** — state, wiring and identity (`role`, `data-*`, `aria-expanded`,
|
|
|
`aria-controls`, `aria-roledescription`, …): the runtime always wins. A
|
|
|
consumer overriding these would make the component lie, and that guarantee is
|
|
|
something user-props-last libraries don't have.
|
|
|
- **Naming** (`ARIA_NAMING_ATTRS` in [`types.ts`](../../src/uix/morfo/types.ts)
|
|
|
— today `aria-label`): the morfo's value is a DEFAULT accessible name and the
|
|
|
consumer's explicit attr wins, mirroring the platform's accessible-name
|
|
|
precedence. Declare the default plainly — unconditional `translationRef` is
|
|
|
the canonical shape; no `prop-truthy` dance is needed. The compiler marks
|
|
|
these plans `consumerWins`: they ship in the part's render bag (SSR included)
|
|
|
and never through `dom.apply`; `mergeProps` resolves them consumer-first.
|
|
|
132 shipped declarations were clobbering the consumer's label before the
|
|
|
runtime learnt this split — the declarations were right, the precedence was
|
|
|
wrong. (`aria-labelledby` stays contract on purpose: it is `partRef` wiring,
|
|
|
and the platform's name computation already lets a consumer-supplied
|
|
|
`labelledby` beat any `label` without our arbitration.)
|
|
|
|
|
|
**The default belongs HERE, not in a provider resolver.** The anti-pattern the
|
|
|
two-class split retires is `value: v.propRef('ariaLabel')` paired with a
|
|
|
provider chain that supplies the real default:
|
|
|
|
|
|
```ts
|
|
|
// ❌ the routing dance: the morfo claims "whatever the consumer passed" while
|
|
|
// the emission is actually a framework label the contract never names.
|
|
|
{ attr: 'aria-label', value: v.propRef('ariaLabel'), condition: { when: 'prop-truthy', prop: 'ariaLabel' } }
|
|
|
// provider: opts.ariaLabel.current || soma.langs.ts(XXX_LANGS.LABEL)
|
|
|
// wrapper: destructures `aria-label` out of restProps to feed that chain
|
|
|
|
|
|
// ✅ morfo-first: the contract names the default; the consumer's attr stays in
|
|
|
// restProps and wins by merge policy. No wrapper wiring, no provider chain.
|
|
|
{ attr: 'aria-label', value: v.translationRef('#?components.meter.label|Meter'), severity: 'recommended' }
|
|
|
```
|
|
|
|
|
|
A label that must disappear when the consumer names the part by reference
|
|
|
declares it — `condition: { when: 'prop-falsy', prop: 'ariaLabelledby' }` —
|
|
|
which requires the provider to publish `ariaLabelledby` as a source. A part
|
|
|
with no framework default declares NO entry at all (Button): there is nothing
|
|
|
to default to, and the consumer's attr flows through untouched.
|
|
|
|
|
|
⚠️ **Four kinds of label CANNOT move here** — measured across the 2026-08-11
|
|
|
sweep of the whole catalogue. A provider chain is not automatically a dance;
|
|
|
check against these before deleting one:
|
|
|
|
|
|
| Cannot move | Why | Examples |
|
|
|
| --- | --- | --- |
|
|
|
| **state-dependent** | `mapRef` returns `map[String(raw)]` verbatim and nothing translates that output, so a mapped idlangref would ship as a literal `#?…` | clipboard copy/copied · a visibility toggle's show/hide · a row's expand/collapse |
|
|
|
| **interpolated** | `translationRef` names a key; it takes no params, so a name built from data has nowhere to put them | `Slide 3 of 8` · `Remove {tag}` · a tree node's per-node name |
|
|
|
| **cross-element** | the consumer's prop is declared on a ROOT wrapper that renders a role-less `<div>`, but the name belongs to a CHILD control. Letting it ride restProps would name the `generic` div — which ARIA forbids — and silently leave the real control on its default | textarea → its `<textarea>` · mask-field / search-field / password-field roots → their `<input>` · waveform → the composed Slider |
|
|
|
|
|
|
The first two are blocked by the value system: leave those chains alone, they
|
|
|
are doing real work.
|
|
|
|
|
|
**Cross-element is NOT blocked — it is declared on the child part.** The name
|
|
|
belongs to the control, so the morfo's naming default belongs there too; only
|
|
|
the consumer's ROOT convenience prop still needs forwarding, and the provider
|
|
|
forwards it with a CONDITIONAL spread:
|
|
|
|
|
|
```ts
|
|
|
// morfo: the default sits on the part that owns the name
|
|
|
{ attr: 'aria-label', value: v.translationRef('#?components.textarea.label|Text area'),
|
|
|
condition: { when: 'prop-falsy', prop: 'fieldLabelled' } }
|
|
|
|
|
|
// provider: forward the root's convenience prop — conditionally.
|
|
|
...(p.opts.ariaLabel.current ? { 'aria-label': p.opts.ariaLabel.current } : {})
|
|
|
```
|
|
|
|
|
|
⚠️ **The spread MUST be conditional.** Writing `'aria-label': p.opts.ariaLabel.current`
|
|
|
unconditionally sets the key to `undefined` when the prop is absent, and that
|
|
|
ERASES the morfo default the bag just supplied — an unconditional key beats an
|
|
|
absent one in a spread. Conditional, the default stands when the prop is absent
|
|
|
and the prop wins when present, which is exactly the A-85 precedence.
|
|
|
|
|
|
**A `Field.Label` is the other half of that class.** It names the control
|
|
|
through `for`/`id`, and `aria-label` WINS over `<label for>` in the
|
|
|
accessible-name computation — so a generic default emitted next to a real
|
|
|
visible label would silently override it. That suppression used to hide in a
|
|
|
provider resolver; it is now declared, reading a virtual prop the provider
|
|
|
publishes (`fieldLabelled: () => Boolean(this.field?.labelId.current)` — the
|
|
|
2-of-3 rule: computed state lives in the provider, the morfo only reads it).
|
|
|
Result: with a `Field.Label` present the attr is genuinely ABSENT rather than
|
|
|
present-and-undefined, so assert it with `not.toHaveProperty('aria-label')`.
|
|
|
|
|
|
**First ask whether the element is YOURS.** The default moves to the morfo only
|
|
|
when the naming element is a part of THIS component. When the name lands on a
|
|
|
*composed* component's element (Waveform's embedded `Slider`), there is no part
|
|
|
to declare it on — a `texts` slot resolved by the provider and handed to the
|
|
|
child is the sanctioned shape, and moving it would be wrong. What must still be
|
|
|
audited in that case is the OTHER half: that the consumer's `aria-label` is
|
|
|
stripped from the wrapper's passthrough (`Without<PrimitiveDivAttributes, {
|
|
|
'aria-label'?: string }>`) and forwarded to the child. Waveform shipped without
|
|
|
that strip — and with the prop misnamed `ariaLabel`, unique in the catalogue —
|
|
|
so a consumer's `aria-label` silently named the role-less root while the real
|
|
|
control kept its default.
|
|
|
|
|
|
Add `condition` when the ARIA is emitted only in some cases:
|
|
|
|
|
|
```ts
|
|
|
condition: 'always'
|
|
|
condition: { when: 'part-present', part: 'title' }
|
|
|
condition: { when: 'part-absent', part: 'label' } // e.g. aria-label only when no Label part
|
|
|
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)`. The name
|
|
|
**declares the family**: `{family}-{verb}[-{nuance}]` (e.g. `commit-toggle`,
|
|
|
`emerge-dismiss-outside`). `validateMorfo` rejects a name that does not
|
|
|
start with its own `semantic.family`.
|
|
|
- **`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). Which part
|
|
|
it should be follows one rule — the gesture is stamped where the hand
|
|
|
is, the terminal where the value lives: §_Where the stamp lands_ below.
|
|
|
- **`semantic.allowedTargets`** — optional list of `partRef`s an ANCHORED
|
|
|
emission may land on (`SomaRuntimePart.trigger` /
|
|
|
`runtime.partInstance(part, el).trigger` — identity against the registry;
|
|
|
the raw-element `targetOverride` option was DELETED 2026-08-13 once the F3
|
|
|
census hit zero). The exact counterpart of `allowedFamilies` on the target
|
|
|
axis: `target` stays the default the runtime resolves; this declares the
|
|
|
OTHER surfaces an emission may land on — the toolbar `command-button` a
|
|
|
format gesture names, the `dropzone` beside a file-upload's trigger.
|
|
|
Declaring it is what lets `pack-census.test.ts` tell a legitimate
|
|
|
redirection from drift: a sema rule may select the declared target or a
|
|
|
part listed here, and nothing else (the TextArea class of bug — rules
|
|
|
aimed at a part the stamp never visits — ran 6×/13× above its written gain
|
|
|
for months without any guard seeing it).
|
|
|
|
|
|
The runtime side is enforced twice: statically, the anchored trigger's name
|
|
|
union (`EventNameTargeting`) only admits events whose `target` or
|
|
|
`allowedTargets` include the anchoring part; and in dev, `assertAnchor`
|
|
|
warns JS callers whose anchor part is off-contract.
|
|
|
- **`semantic.targetFallback`** — ordered `partRef` chain the RUNTIME resolves
|
|
|
when the canonical `target` has **no live element** at emit time: the first
|
|
|
listed part with a registered instance takes the stamp (and the a11y focus
|
|
|
move, when the event declares one — `resolveEmitTarget` is the ONE
|
|
|
resolution both share, so they can never disagree). This is the second axis
|
|
|
of emission targeting, split from `allowedTargets` on the precedent of
|
|
|
`intentRequirement`/`intentGuidance`:
|
|
|
|
|
|
| Axis | Who decides | When | Example |
|
|
|
| --- | --- | --- | --- |
|
|
|
| `allowedTargets` | the CALLER, per trigger | normal operation, repeated parts | the pressed `day`, the clicked `item` |
|
|
|
| `targetFallback` | the RUNTIME, from mount state | the declared target is unmounted | drawer `emerge-close` → `trigger` once `content` is gone |
|
|
|
|
|
|
It replaces the hand-rolled `content ?? partRef('trigger')` every overlay
|
|
|
provider used to write around `targetOverride` (dialog / drawer / popover /
|
|
|
float-panel `close`; aura's terminals landing on `provider` when the
|
|
|
decorative ring was never composed; chronos' editor commits landing on
|
|
|
`provider` when no chip names them). Declared in the morfo so soma, sema and
|
|
|
eidos read the same truth: `pack-census.test.ts` counts these parts as
|
|
|
stampable, exactly like `allowedTargets` — but note the perceptual
|
|
|
difference: an `allowedTargets` part is stamped in routine use, a
|
|
|
`targetFallback` part only in the degraded mount, so a sound rule that
|
|
|
matches ONLY fallback parts almost never fires.
|
|
|
|
|
|
`validateMorfo` enforces three invariants (all tested): every entry is an
|
|
|
existing part; the canonical `target` may not list itself (it is always
|
|
|
resolved first); no duplicates (order is meaning — a duplicate reads as two
|
|
|
chances where there is one). Anchored emissions (`SomaRuntimePart.trigger` /
|
|
|
`partInstance(...).trigger`) do NOT fall back: an anchored emit stamps ITS
|
|
|
instance or raises a target error, never a silent redirection — and the
|
|
|
anchored name union (`EventNameTargeting`) excludes fallback-only events on
|
|
|
purpose, because anchoring to the degraded surface would force the poor
|
|
|
landing while the primary is mounted.
|
|
|
- **`regime`** — what this event does when it arrives and the target surface
|
|
|
already carries a live occurrence: `replace` (default) or `queue`. The
|
|
|
`data-event-*` projection is ONE SLOT per element. Declare it only for pairs
|
|
|
that genuinely share a node — when the collision comes from a redirection,
|
|
|
retire the redirection instead. Detail:
|
|
|
[`architecture/sema.md` §The surface is ONE SLOT](./sema.md).
|
|
|
- **`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).
|
|
|
|
|
|
**Not declarable here: `direction`.** The morfo cannot state the sense of a
|
|
|
traversal, because the same declared event goes backward on one press and
|
|
|
forward on the next — only the emitter knows which. It travels per-call as
|
|
|
`TriggerOptions.direction` (`forward` | `backward`, the `SemaDirection`
|
|
|
vocabulary) and lands as `data-event-direction`, which is what lets the `shift`
|
|
|
family's motion firma slide in the right sense. Omit it where the route has no
|
|
|
clear sense — a month picked from a select is a jump, not a step, and a sense
|
|
|
inferred from comparing dates is not a sense. Contrast with `intent`, the other
|
|
|
per-emission axis, which the morfo CAN declare a default for.
|
|
|
|
|
|
For a comprehensive worked example see the toggle and dialog morfos.
|
|
|
|
|
|
**`emission` — who fires the event (technical appendix; D.2, signed).** By
|
|
|
default a declared event is presumed emitted by this repo's providers
|
|
|
(`emission: 'runtime'`, the norm — the field is simply absent), and the D9
|
|
|
contract guard fails when none does. Three declared exceptions exist, and they
|
|
|
are metadata about the EMITTER, never a license to declare perception that
|
|
|
does not happen:
|
|
|
|
|
|
| Value | Who fires it | Guards |
|
|
|
| --- | --- | --- |
|
|
|
| `'runtime'` (default, absent) | a soma provider / eidos view via `runtime.trigger` | D9 requires a live emitter |
|
|
|
| `'host'` | the embedding application, through the runtime | D9 exempt; pack rules stay LIVE (the stamp happens) |
|
|
|
| `'external'` | an outside system consumes the declaration (analytics, external a11y) | D9 exempt; nothing stamps here |
|
|
|
| `'declared-only'` | nobody — contract surface for testing / docs | D9 exempt; a pack rule tuning it is DEAD and the pack census rejects it |
|
|
|
|
|
|
Doctrinal note (the reason this is an appendix and not book doctrine, per
|
|
|
D.2): presented wrong, this reads as "the system promises events that do not
|
|
|
exist". It is the opposite — the flag makes the emitter explicit so the
|
|
|
README event tables stop promising perception that never occurs, and the
|
|
|
guard can tell a declared surface from an abandoned one. Marking an inert
|
|
|
event `'declared-only'` to silence the guard is the same move as widening its
|
|
|
debt list, and the review treats it the same way.
|
|
|
|
|
|
### 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](./sema.md) is the perceptual/semantic layer. It consumes the same DOM surface that morfo declares — no extra hooks needed. The morfo authoring rules that support Sema:
|
|
|
|
|
|
- **Transition markers**: components with enter/exit transitions declare `data-starting-style` and `data-ending-style` on the transitioning part.
|
|
|
|
|
|
- **Causal exit states**: components with multiple semantically distinct exit paths (Dialog: saved / cancelled / dismissed / failed; Toast: dismissed / auto-timeout / action) declare `data-last-action` with enumerable `values`. The provider is expected to update `data-last-action` **before** `data-state` changes, so Sema can tint the exit animation per-action. (Tracked by a dedicated MutationObserver timing test — future work.)
|
|
|
|
|
|
- **Cross-component vocabulary consistency**: the `morfo:vocabulary` script groups components by data-attr semantic (disclosure → `open|closed`, lifecycle → `loading|idle|success|error`) and flags divergent vocabularies for review.
|
|
|
|
|
|
These don't change the morfo shape — they're authoring conventions that enable Sema without requiring a Sema-aware provider.
|
|
|
|
|
|
---
|
|
|
|
|
|
## Where the stamp lands — gesture vs terminal
|
|
|
|
|
|
`semantic.target` is a claim about **subject**, not about paint: the part it names is the one the occurrence is _about_. Read across the catalogue, that claim resolves into a single rule in two halves:
|
|
|
|
|
|
> **The gesture is stamped where the hand is. The terminal is stamped where the value lives.**
|
|
|
|
|
|
Seventeen components declare the `handle` family. Twelve put the two halves on different parts:
|
|
|
|
|
|
| Component | Grip (`handle-*`) | Terminal |
|
|
|
| ------------------------------- | ------------------------------------ | --------------------------------------- |
|
|
|
| `rotate-align` | `needle` | `dial` (`handle-drop`) |
|
|
|
| `path-trace` | `token` | `track` (`handle-drop`) |
|
|
|
| `drag-drop` | `draggable` | `droppable` (`handle-drop`) |
|
|
|
| `splitter` | `resize-trigger` | `provider` (`commit-set`) |
|
|
|
| `color-picker` | `area` | `provider` |
|
|
|
| `css-field` · `number-field` | `scrubber` | `provider` |
|
|
|
| `gradient-builder` | `track` | `provider` |
|
|
|
| `cropper` | `selection` · `handle` · `viewport` | `provider` (`commit-crop`) |
|
|
|
| `image-picker` | `preview` | `provider` |
|
|
|
| `virtual-list` · `virtual-grid` | `viewport` | `provider` (`commit-set-resize`) |
|
|
|
| `chronos` | `event-chip` · `event-resize-handle` | `event-chip` (falls back to `provider`) |
|
|
|
| `knob` | `control` | `control` |
|
|
|
| `drawer` · `float-panel` | `content` | `content` |
|
|
|
| `slider` | `provider` | `provider` |
|
|
|
|
|
|
The five that do not separate them are not exceptions — they are the components where grip and value are the **same node**: Chronos' chip _is_ the event, Knob's control _is_ the dial, Drawer's and FloatPanel's content _is_ the position. Slider is the borderline case: it declares a `thumb` part and the provider gates `handle-pick` on it (`isHandleTarget` in `slider-provider.svelte.ts`), but the pointer capture and the whole grabbable track belong to the provider, so the provider is the surface under the hand.
|
|
|
|
|
|
Two consequences worth naming:
|
|
|
|
|
|
- **The terminal is not a synonym for `commit`.** Rotate-align, path-trace and drag-drop terminate on a `handle-drop`. The family says what kind of occurrence it is; the target says whose.
|
|
|
- **A signature that must paint a node other than the stamped one is a descendant selector, never a reason to move the stamp.** The corollary and its worked example live in [`architecture/eidos.md`](./eidos.md) §From sema (DOM).
|
|
|
|
|
|
When a rule doesn't fire, the diagnostic question is always: **is the stamped node the subject of the event?** If it is, the recipe descends. If it isn't, the morfo is wrong.
|
|
|
|
|
|
### A repeated part cannot be the subject of one occurrence
|
|
|
|
|
|
`resolveEmitTarget` resolves an unanchored emit to the declared target's **newest live instance**. That is exactly right when the occurrence has one subject among many — one row selected, one day, one item — and the framework names it with an ANCHORED emit: the part's own handle (`runtimePart.trigger`, as `table` does for its row) or a registry-identity lookup (`runtime.partInstance('day', el).trigger`, as `calendar` does for its pressed day). Fifty of the catalogue's 252 partRef targets are repeatable parts, and every one of them works this way.
|
|
|
|
|
|
It breaks when one occurrence has **N subjects at once**. `calendar.shift-navigate` targeted `grid`; with `numberOfMonths=2` there are two grids and both cross, but only the newest was stamped — measured 2026-08-12: June changed its dates standing still while July crossed. Neither mechanism helps, because both pick one instance, and neither does emitting N times: that mints N occurrences for one gesture, N ids in the dominance arbiter, and a repeat in the frequency memory (C-2 exempts only `handle`).
|
|
|
|
|
|
The fix is not to administer the stamp. **It is to declare the part that is actually the subject** — here `months`, the paginated view, which crosses as a unit under every value of `numberOfMonths`. A target that is only correct for one parameter value is not a target.
|
|
|
|
|
|
So the diagnostic has a second half: **if the target is a repeated part, ask whether the occurrence has one subject or all of them.** One → anchor it. All → the subject is the container, and it needs to exist.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 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="signal-alert-close-fail"]
|
|
|
semaSelector(dialogMorfo, 'content', { eventName: 'signal-alert-close-fail' });
|
|
|
|
|
|
// [data-dialog-content][data-event^="emerge-close"][data-event-family="emerge"]
|
|
|
semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'emerge-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.
|
|
|
|
|
|
**Adding a `defaultElement` to only ONE of its two lists.** The `MorfoElement` vocabulary is declared **twice**: the TypeScript union in [`types.ts`](../../src/uix/morfo/types.ts) and the `literal(...)` list of the sium validator in [`schema.ts`](../../src/uix/morfo/schema.ts). Extending only the union **compiles clean** and then throws at runtime:
|
|
|
|
|
|
```
|
|
|
morfo::invariant: [morfo] Part at path {kebab} failed shape validation:
|
|
|
[sium] validation failed with 1 issue(s)
|
|
|
```
|
|
|
|
|
|
`npm run check` does not catch it — the validation is runtime. `npm run morfo:check` (or simply mounting the component) does. Touch both lists in the same edit. Incident 2026-07-29: `'text'` was added for Barcode's human-readable interpretation and the demo threw on mount with a green typecheck.
|
|
|
|
|
|
**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.
|
|
|
|
|
|
> ⚠️ **`texts` is a DECLARATION, never a lookup table — and this failure is silent.**
|
|
|
> `normalizeTranslationRef` takes the key **verbatim**; it does not consult `morfo.texts`
|
|
|
> to translate an alias into a path. So `texts: { prevMonth: '#?components.x.prev-month|…' }`
|
|
|
> plus `v.translationRef('prevMonth')` resolves `#?components.x.prevMonth`, misses, and ships
|
|
|
> the English **fallback** — no error, no warning, just the wrong language.
|
|
|
> The same applies to `v.commonRef('action.undo')` when `common.action` does not exist
|
|
|
> (the groups are `buttons`, `calendar`, `month-grid`, `year-grid`, `date`, `time`, `field`).
|
|
|
> **Keep the `texts` keys identical to the catalog keys** so the mismatch is visible at a glance.
|
|
|
> Measured 2026-08-10/11 in chronos: five labels shipping English on an `es` page. Since
|
|
|
> 2026-08-11 `npm run translations:check` guards this class **at the compiled contract**: it
|
|
|
> imports every morfo, compiles it with the real `compileMorfo`, and resolves each normalized
|
|
|
> `translationRef`/`commonRef`/`langRef` against the catalogs — an alias key is a check ERROR,
|
|
|
> not a silent fallback. (The previous script regex-evaluated catalog sources — a `https://`
|
|
|
> inside a string crashed it — and never scanned call sites at all: a `v.translationRef('key')`
|
|
|
> contains no `#?` literal until the compiler normalizes it.) The 166-morfo sweep of 2026-08-11
|
|
|
> fixed 172 findings: six morfos declared `texts` with **no catalog file** (card-group,
|
|
|
> css-field, picker, qr-code, radio-cards, waveform), media-player's catalog had drifted six
|
|
|
> keys behind its morfo, and palabras' editor chrome referenced ~130 keys its catalog lacked —
|
|
|
> including two **leaf↔branch collisions** (`status`, `placeholder`): a catalog node cannot be
|
|
|
> both a label leaf and a group (`isLangRecord` rejects mixed nodes), so the label moves to a
|
|
|
> child (`status.label`, `placeholder.default`) and sibling keys stay flat (`slash.heading-1-d`,
|
|
|
> never `slash.heading-1.d`).
|
|
|
|
|
|
**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](../guides/component-guide.md) — soma component authoring (morfo-specific discipline: translation-namespace grep, DOM-topology audit, smoke validation).
|
|
|
- [sema/README.md](./sema.md) — semantic layer (morfo provides everything Sema needs via `events[].semantic`).
|
|
|
- [`guia-semantica-historica.md`](../decisions/guia-semantica-historica.md) — the original API conventions (historical seed).
|
|
|
|
|
|
## Backlog / Evolution decisions
|
|
|
|
|
|
### 2026-08-13 — `semaSelector`: the `state` matcher speaks the morfo's data contract
|
|
|
|
|
|
Extends §_Typed selector builder — `semaSelector`_ above (M5, `b53c93e42`).
|
|
|
Two slots, deliberately asymmetric:
|
|
|
|
|
|
- **`state?: DataPairOf<M>`** — an `{ attr, value }` pair the part DECLARES in
|
|
|
its `data`. Typed against the morfo, so renaming or dropping the attr breaks
|
|
|
at compile time instead of drifting into a selector that matches nothing.
|
|
|
- **`undeclaredState?: { attr: string; value: string }`** — the escape hatch
|
|
|
for attrs OUTSIDE the contract (a visual wrapper's `data-size`, a
|
|
|
presentation flag like `data-sheet`). It THROWS when the attr turns out to
|
|
|
be declared, which is what keeps the typed slot mandatory: one open slot
|
|
|
with no guard makes the typed one optional in practice.
|
|
|
|
|
|
The pair replaces the free-form state string a cascade rule used to write by
|
|
|
hand — the same drift class M6 closed on names and D.2 closed on emitters.
|