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/docs/architecture/morfo.md

54 KiB

title type audience authority status source
Morfo — the declarative contract reference human + agent E1 architecture — the cross-layer contract layer (DNA) current 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):

    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 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:

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:

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

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 and the sium schema in 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.

{
  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 — 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 (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:

{
	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:

// 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 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', 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:

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:

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:

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

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; the shape is:

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

  • semantic.allowedTargets — optional list of partRefs the provider may redirect the stamp to at trigger time (the targetOverride trigger option wins over the registered target ref). 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 — repeated parts like the pressed day of a Calendar or the clicked item of a Pagination. 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).

    Since 2026-08-10 the runtime asserts it too: assertTargetOverride reads the overriding element's own part marker and warns when it is neither the declared target nor one of these. Runtime rather than static because the override is an expression (e.currentTarget) and only the live element can answer which part it is.

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

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

Step 6 — Wire the provider

In src/uix/soma/components/{kebab}/{kebab}-provider.svelte.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 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:

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


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 instead of hand-writing strings:

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.


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.


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:

// ❌ 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 and the literal(...) list of the sium validator in 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, and the reason nobody caught it is that npm run translations:check was crashing (SyntaxError in evalCatalogFile) before checking anything. Fix that script and sweep the 166 morfos before trusting this guard again.

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 — the TypeScript interfaces (authoritative reference).
  • PERMUTATION_RUNNER.md — CI tool that cycles components through their state space.
  • component-guide.md — soma component authoring (morfo-specific discipline: translation-namespace grep, DOM-topology audit, smoke validation).
  • sema/README.md — semantic layer (morfo provides everything Sema needs via events[].semantic).
  • guia-semantica-historica.md — the original API conventions (historical seed).

Powered by TurnKey Linux.