You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/morfo/README.md

20 KiB

Morfo

The cross-layer contract of a component's public DOM surface.

Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, focus policy, and public event contract. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables).

One file per component, at src/uix/morfo/components/{kebab}.ts. No prose — that's the component's README. No props — those live in types.ts with JSDoc. Just the machine-readable contract.


Why morfo exists

Without morfo, a component's structural information lives in many places:

  • Part names in createAttrs({ parts: [...] }) inside the provider.
  • Data-attr enums in registerContract({ parts: {...} }) also in the provider.
  • ARIA emission hardcoded in the provider's $derived.by(...) props.
  • Keyboard handlers scattered across the provider.
  • Public event names and their transport split across provider code, docs, and consumers.
  • Prose descriptions in the README.
  • Selector strings duplicated in eidos CSS, sema .csem, docs tables.

Renaming a part (content → panel) used to mean touching 6+ locations with zero automatic verification. Cross-layer drift (soma emits data-dialog-content, eidos styles data-dialog-panel) was silent.

With morfo, every location reads from the same declaration. Parts, data-attrs, and their enum values are authored once. createAttrs and registerContract consume the morfo directly. A smoke test validates the real DOM against the declaration on every CI run.


What morfo contains

A Morfo is a plain TypeScript constant that describes:

  • name — PascalCase display name ("Dialog").
  • kebab — kebab-case identifier ("dialog"), matches data-{kebab} and createAttrs({component}).
  • scope — which layers implement this component: ['soma'], ['soma', 'eidos'], etc.
  • apg — optional URL to the WAI-ARIA APG pattern when the component implements a formal one.
  • focus — optional focus policy for overlays / composites.
  • events — the component's public event surface: which semantic occurrences it may emit and expose to cross-layer consumers.
  • parts — the part tree (recursive). Each part declares:
    • name, kebab, kind (public / virtual).
    • defaultElement (advisory), role (always-emitted).
    • optional, supportsNesting.
    • states — the state names this part can be in.
    • data — data-attributes emitted, with enum values when applicable and optional runtime source metadata when the contract wants to declare where the attr comes from.
    • aria — ARIA attribute contract (attr + value source + condition).
    • keyboard — keyboard shortcuts relevant when the part has focus.
    • parts — nested sub-parts (recursive).

See types.ts for the full TypeScript shape.

What morfo does NOT contain

Not in morfo Lives in Reason
Summary, overview prose {component}/README.md Editorial, not contract
Comparison vs Radix / Base UI / Bits {component}/README.md Third-party drift shouldn't pollute the contract
Usage examples {component}/README.md Narrative
Props (names, types, defaults) {component}/types.ts with JSDoc Canonical source is TS + JSDoc
Translations {component}/langs.ts (idlangref) Separate registry, consumed by the provider
Event handlers / runtime wiring, state machines {component}-provider.svelte.ts Execution logic, not contract data
Visual variants / recipes src/uix/eidos/ (future) Layer-specific, not shared

Anatomy of a morfo file

Minimal template:

// src/uix/morfo/components/dialog.ts
import type { Morfo } from '../types';
import { v } from '../types';                     // value builders: v.literal, v.stateRef, v.partRef, v.propRef, v.translationRef

export const dialogMorfo = {
  name: 'Dialog',
  kebab: 'dialog',
  scope: ['soma'],
  apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',

  focus: {
    initial: 'first-focusable',
    trap: true,
    return: 'trigger',
    restore: true
  },

  parts: [
    {
      name: 'Provider',
      kebab: 'provider',          // special: 'provider' emits data-dialog (no suffix)
      kind: 'virtual',            // context-only, no DOM
      defaultElement: 'none',
      optional: false,
      data: [],
      aria: []
    },
    {
      name: 'Trigger',
      kebab: 'trigger',
      kind: 'public',
      defaultElement: 'button',
      role: 'button',
      optional: false,
      states: ['open', 'closed'],
      data: [{ attr: 'data-state', values: ['open', 'closed'] }],
      aria: [
        { attr: 'aria-haspopup', value: v.literal('dialog') },
        { attr: 'aria-expanded', value: v.stateRef('open') },
        { attr: 'aria-controls', value: v.partRef('content') }
      ]
    },
    {
      name: 'Content',
      kebab: 'content',
      kind: 'public',
      defaultElement: 'div',
      role: 'dialog',
      optional: false,
      states: ['open', 'closed'],
      data: [
        { attr: 'data-state', values: ['open', 'closed'] },
        { attr: 'data-last-action',
          values: ['saved', 'cancelled', 'dismissed', 'failed'],
          severity: 'optional' }
      ],
      aria: [
        {
          attr: 'aria-labelledby',
          value: v.partRef('title'),
          condition: { when: 'part-present', part: 'title' },
          severity: 'recommended'
        }
      ],
      keyboard: [{ key: 'Escape', action: 'close' }]
    },
    {
      name: 'Title',
      kebab: 'title',
      kind: 'public',
      defaultElement: 'div',
      role: 'heading',
      optional: true,
      data: [],
      aria: [{ attr: 'aria-level', value: v.propRef('level'), severity: 'recommended' }]
    }
  ]
} as const satisfies Morfo;

The as const satisfies Morfo pattern is mandatory, not cosmetic. It does two things at once:

  • as const preserves the literal types (kebab: 'dialog', not string). This is what lets createAttrs(dialogMorfo) return { provider: 'data-dialog'; trigger: 'data-dialog-trigger'; ... } with autocomplete and typo detection in every provider that consumes the morfo.
  • satisfies Morfo validates that the object conforms to the Morfo interface without widening it. If a field is missing or mistyped, TypeScript reports it at the declaration — same safety as : Morfo = annotation, without the type widening.

A morfo annotated : Morfo = still works at runtime but yields createAttrs(...): Record<string, string> — no autocomplete, attrs.trigerr compiles. All 66 morfos in the codebase use as const satisfies Morfo; new morfos must do the same.


Authoring a new morfo

Step 1 — Create the file

Write src/uix/morfo/components/{kebab}.ts exporting a {camelName}Morfo const.

Naming rules:

  • name: PascalCase. "Dialog", "DateRangePicker", "ColorField".
  • kebab: kebab-case. "dialog", "date-range-picker", "color-field". Must match data-{kebab} and createAttrs({component}) in the provider.
  • Part kebabs 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.
  • 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:

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', severity: 'optional' },

  // Conditionally-emitted attr.
  {
    attr: 'data-starting-style',
    severity: 'optional',
    condition: { when: 'state-equals', state: 'open', value: 'starting' }
  }
]

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:

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-label', value: v.translationRef('common.buttons.close') }  // refs a langs.ts idlangref
]

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) — the attribute value resolves to a translation.

Add condition when the ARIA is emitted only in some cases:

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 5 — Optional: keyboard and focus

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 6 — Wire the provider

In src/uix/soma/components/{kebab}/{kebab}-provider.svelte.ts:

import { createAttrs, registerContract } from '../../attrs';
import { dialogMorfo } from '../../../morfo/components/dialog';

const attrs = createAttrs(dialogMorfo);
registerContract(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.

That's it. No more inline createAttrs({component, parts}), no more registerContract({name, version, parts: {...}}). 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 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 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 morfos against the real DOM:

  • npm run smoke — Playwright walks all /test/soma/* routes. Catches pageerror, console.error, translation-key-not-found, context-not-found. Not morfo-specific but catches common regressions.

  • npm run morfo:check — For each morfo, navigates to /test/soma/{kebab} and validates:

    • 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).
  • 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 data-_* prefix (convention only — no enforcement), which is skipped by strict-mode validation.


Handling polymorphism (consumer renders a different element)

The child snippet pattern allows consumers to swap the default element:

<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 is the future 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.


Files in this package

src/uix/morfo/
├── README.md          ← (this file) developer guide
├── DESIGN.md          ← design proposal shared with external reviewers
├── study.md           ← record of the iterative AI peer-review process
├── types.ts           ← Morfo interface + v.* builders
├── schema.ts          ← sium-based validator + CANONICAL_VOCABULARIES
├── index.ts           ← package barrel
└── components/
    ├── dialog.ts      ← one morfo per component (66 files)
    ├── accordion.ts
    ├── ...
    └── dialog.test.ts ← reference test pattern

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 all /test/soma/* routes (requires dev server).
npm run morfo:check Validate emitted DOM vs morfo per-component (requires dev server).
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:

// ❌ 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'

All 66 morfos 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.

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.

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).


See also

  • DESIGN.md — the original proposal (for context on why the shape is what it is).
  • study.md — the three-round AI peer-review process that refined the shape.
  • types.ts — the TypeScript interfaces (authoritative reference).
  • COMPONENT_GUIDE.md — soma component authoring (includes items 37-39 on morfo-specific discipline: translation-namespace grep, DOM-topology audit, smoke validation).
  • sema_pre.md — semantic layer context (morfo provides everything Sema needs).

Powered by TurnKey Linux.