/** * SomaRuntime — per-instance interpreter of a component's morfo within * the headless layer. * * Lives in soma (not morfo) because: * 1. Morfo is pure declarative TypeScript (DNA). It cannot host runtime * that imports Svelte runes or reaches into other layers (sema, adom). * 2. The runtime is consumed exclusively by soma providers. Co-locating * consumer + tool keeps the layer surface minimal. * 3. Naming: this is the runtime OF soma (its execution arm), not OF * morfo (which is declaration only). Per the doctrine * "morfo declares, soma executes". * * Surface: * - `part(part, opts)` — high-level part handle for concrete provider * classes: registration, context publication, id/marker/ref props and * contract helpers. When `syncAttrs` is true, the runtime also owns the * part's morfo-derived DOM attrs through `dom.apply`. * - `partProps(part)` — returns ONLY the part's static identity (id, marker, * ref attachment). Mutable attrs (data-state, aria-*, etc.) are written to * the DOM by the runtime's effect, never via Svelte render. * - `keydown(part, event)` — dispatch from `morfo.keyboard`. * - `trigger(eventName)` — orchestrates prewrite + eventEngine.emit + handler. * * Operational rules: * - `partProps` must not include any state-derived attr — that would race * with `dom.apply`. The boundary is identity vs. derivation. * - `part(..., { syncAttrs: true })` must be called inside an effect root * (a Svelte component scope or a class constructor invoked during component * init), because the per-part effect uses `$effect`. * - Effects are best-effort idempotent: each tick reapplies the full attr * map for that part. Missing attrs are removed via `dom.apply`'s * `false/null/undefined` handling. * * Sema is ornamental: * - `sources.eventEngine` is OPTIONAL. If absent, `trigger` runs prewrite + * handler + effects but skips the perceptual emit. Components stay * functional (state, a11y, keyboard) without an `EngineSemantic`. * - `target` is only required when emit will actually run. With no engine, * trigger doesn't throw on missing DOM target. */ import { tick, untrack } from 'svelte'; import type { ActiveDom, DomAttrValue } from '$adom'; import type { Logger } from '$libs/logger'; import { resolveSemaDuration, resolveIntent, type EngineSemantic, type SemaChannelId, type SemaSignatureOverride } from '$uix/sema'; import type { Morfo } from '$uix/morfo'; import { assertContract, evalAttrPlan, registerMorfo, type CompiledPart, type ParsedKey } from '$uix/morfo'; import { attachRef, type RefAttachment, type Active, type State } from '$libs/reactive'; import { shouldEmitMorfoEntry, type MorfoBindings } from '$uix/morfo'; import { SomaRuntimeEventError, SomaRuntimePartError, SomaRuntimeTargetError } from './errors'; /** * Minimum perceptual event surface the runtime depends on. Real consumers pass an * `EngineSemantic`; tests pass a fake with the same shape. Keeps the * runtime independent of the full engine surface (channels registry, * dispose, etc.). */ export type EventEngineEmitter = Pick; export type SourceMap = Record unknown>; /** * Provider-supplied event handler. Synchronous by V1 contract — async work * happens in the call-site before invoking `runtime.trigger(eventName)`. * Returning a value is allowed; it is ignored by the runtime. */ export type EventHandler = () => unknown; /** * Provider-supplied keyboard action handler. Receives the original event * (so the action can `preventDefault()` selectively) and runs synchronously. */ export type KeyboardActionHandler = (event: KeyboardEvent) => void; export interface SomaRuntimeSources { /** DOM service. Required — the runtime applies attrs through it. */ dom: ActiveDom; /** * Perceptual event engine. Optional — sema is ornamental per the doctrine. * When present, `trigger()` emits the perceptual signal between * prewrite and handler. When absent, that step is skipped silently; * components remain functional without it. */ eventEngine?: EventEngineEmitter; /** Component-wide state sources. Read by `stateRef` declarations. */ states?: SourceMap; /** Component-wide prop sources. Read by `propRef` declarations. */ props?: SourceMap; /** Component-wide part-id sources. Read by `partRef` declarations. */ parts?: SourceMap; /** * Provider's per-event handlers. Each is called by `runtime.trigger` after * `eventEngine.emit` resolves and is responsible for mutating internal state. */ events?: Record; /** * Provider's per-action handlers, dispatched by `runtime.keydown` against * the part's `morfo.keyboard` declarations. Action names match the * `action` field on each `MorfoKeyboard` entry (e.g. `'toggle'`, `'close'`). */ actions?: Record; /** Translation lookup. Read by `translationRef` declarations. */ translate?: (key: string) => string | undefined; /** Optional diagnostics logger for runtime contract checks. */ logger?: Logger; } export interface SomaRuntimePartBaseOpts { /** Per-instance id of this part (from the part-provider's opts). */ id: Active; /** Per-instance ref. Required for parts with DOM. */ ref?: State; /** Optional callback when the ref attaches/detaches. */ onRefChange?: (el: HTMLElement | null) => void; /** Per-part state sources (override component-level for this part only). */ states?: SourceMap; /** Per-part prop sources (override component-level for this part only). */ props?: SourceMap; /** Per-part part-id sources (override component-level for this part only). */ parts?: SourceMap; } export interface SomaRuntimePartContext { set(value: T): unknown; } export interface SomaRuntimePartOpts extends SomaRuntimePartBaseOpts { /** Concrete provider instance to publish into context. */ owner?: Owner; /** Optional context where the provider instance should be registered. */ context?: SomaRuntimePartContext; /** * When true, this part's morfo attrs are synchronized to the DOM by the * runtime effect. Keep false for providers that still author their * state-derived attrs in render props. */ syncAttrs?: boolean; } export interface SomaRuntimePart { readonly attachment: RefAttachment | undefined; /** Static render identity: id, marker attr, archetype and ref attachment. */ readonly props: Record; /** Resolve morfo static/dynamic attrs for render-time legacy/manual props. */ resolveProps(bindings?: MorfoBindings): Record; /** Validate an authored prop bag against the morfo data contract. */ assert

>(props: P): P; } export interface SomaRuntime { part(part: string, opts: SomaRuntimePartOpts): SomaRuntimePart; partProps(part: string): Record; /** * Dispatch a keyboard event against a part's `morfo.keyboard` entries. * * Matches `event.key` plus modifier flags against each entry's `key` spec * (supports `"Shift+Tab"`, `"Ctrl+A"`, etc.). On the first match, looks up * the corresponding handler in `sources.actions[entry.action]` and calls * it with the original event. * * Returns `true` if a handler ran, `false` otherwise — the consumer can * use the result to decide whether to fall through to native behavior. * * The runtime never calls `preventDefault` on its own; the action handler * decides whether to. */ keydown(part: string, event: KeyboardEvent): boolean; /** * Trigger a morfo-declared event by name. * * Sequence: * 1. apply `prewrite` attrs imperatively (transient markers like * `data-last-action`) * 2. await `eventEngine.emit({ target, name, family, intent? })` * 3. invoke the provider's handler from `sources.events[name]` * 4. effects on the affected parts re-derive structural attrs from the * new state and write them via `dom.apply` (automatic) * * Step 2 is skipped when `sources.eventEngine` is absent. Step 3 is skipped * when no handler is registered for the event. * * Resolves only after `eventEngine.emit` has had its rAF and the handler has * returned. Effects run on the next reactive tick, not awaited here. */ trigger(eventName: string, opts?: TriggerOptions): Promise; } /** * Per-call overrides for `runtime.trigger`. */ export interface TriggerOptions { /** * Element to use as the visual signal target when the morfo's declared * target part isn't (yet) in the DOM. Typical use: the trigger element * for `'open'` events, where the overlay/content the morfo points at * doesn't exist until the `handleOpen` step flips state. */ fallbackTarget?: HTMLElement; /** * Per-call signal overrides. Each runtime channel slice (`sound`, * `haptic`, future channels, …) merges with whatever the morfo event * declared in `semantic.overrides`; on key conflict, the per-call value * wins (per-call > morfo-declared). * * Cascade rules (capa 6) STILL win on conflict — a cascade rule that * sets `sound.pitch` will override a per-call pitch. To make a per-emit * value the source of truth, the cascade rule should NOT set the same * primitive (only set `channels` to activate the channel). * * Used for dynamic per-emit signatures, e.g. drawer drag-progress * modulating sound by velocity / position / direction. */ overrides?: SemaSignatureOverride; /** * Per-call active-channels override. Replaces the morfo event's * declared `semantic.channels` for this single emit. Empty array * silences the signal entirely on this call. */ channels?: readonly SemaChannelId[]; } interface PartRegistration { id: Active; ref: State | undefined; attachment: RefAttachment | undefined; states: SourceMap | undefined; props: SourceMap | undefined; parts: SourceMap | undefined; bindings: RuntimeBindingsScratch; /** Compiled part metadata so `partProps` doesn't re-walk the morfo tree. */ meta: CompiledPart; } interface RuntimeBindingsScratch extends MorfoBindings { states?: Record; props?: Record; parts?: Record; } const EMPTY_ROOT_PROPS: Record = Object.freeze({}); const EMPTY_BINDINGS: MorfoBindings = Object.freeze({}); function hasSourceKeys(map: SourceMap | undefined): boolean { if (!map) return false; for (const _key in map) return true; return false; } function createBindingsScratch( opts: Pick, sources: SomaRuntimeSources ): RuntimeBindingsScratch { return { states: hasSourceKeys(sources.states) || hasSourceKeys(opts.states) ? {} : undefined, props: hasSourceKeys(sources.props) || hasSourceKeys(opts.props) ? {} : undefined, parts: hasSourceKeys(sources.parts) || hasSourceKeys(opts.parts) ? {} : undefined, translations: sources.translate }; } function clearBindingsBucket(bucket: Record | undefined): void { if (!bucket) return; for (const key in bucket) delete bucket[key]; } function readSourcesInto( bucket: Record | undefined, map: SourceMap | undefined ): void { if (!bucket || !map) return; for (const key in map) bucket[key] = map[key](); } function readBindings(reg: PartRegistration, sources: SomaRuntimeSources): MorfoBindings { const bindings = reg.bindings; clearBindingsBucket(bindings.states); clearBindingsBucket(bindings.props); clearBindingsBucket(bindings.parts); readSourcesInto(bindings.states, sources.states); readSourcesInto(bindings.props, sources.props); readSourcesInto(bindings.parts, sources.parts); readSourcesInto(bindings.states, reg.states); readSourcesInto(bindings.props, reg.props); readSourcesInto(bindings.parts, reg.parts); bindings.translations = sources.translate; return bindings; } export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): SomaRuntime { const compiled = registerMorfo(morfo); const registrations = new Map(); const rootPropsScratch = hasSourceKeys(sources.props) ? {} : undefined; function resolvePart(part: string): CompiledPart { const compiledPart = compiled.parts.byKebab.get(part); if (!compiledPart) { throw new SomaRuntimePartError(morfo.kebab, part); } return compiledPart; } function createRegistration(part: string, opts: SomaRuntimePartBaseOpts): PartRegistration { const compiledPart = resolvePart(part); const reg: PartRegistration = { id: opts.id, ref: opts.ref, attachment: undefined, states: opts.states, props: opts.props, parts: opts.parts, bindings: createBindingsScratch(opts, sources), meta: compiledPart }; if (opts.ref) { reg.attachment = attachRef(opts.ref, (el) => { untrack(() => opts.onRefChange?.(el)); }); } registrations.set(part, reg); return reg; } function syncPartAttrs(reg: PartRegistration, compiledPart: CompiledPart): void { // Effect: whenever the part's ref attaches OR any source changes, // recompute the morfo-derived attrs and write them via dom.apply. // Reading `reg.ref.current` is the reactive subscription point — the // `attachRef` setter mutates it when Svelte's attachment callback fires. // // `staticAttrs` are pre-resolved at compile time (literals + role) and // applied unconditionally; `dynamicAttrs` is the slice that requires // per-tick evaluation against the bindings. $effect(() => { const target = reg.ref ? reg.ref.current : null; if (!target) return; const bindings = readBindings(reg, sources); const resolved: Record = { ...compiledPart.staticAttrs }; for (const plan of compiledPart.dynamicAttrs) { resolved[plan.attr] = evalAttrPlan(plan, bindings); } sources.dom.apply({ target, attrs: resolved as Record }); }); } function part( partName: string, opts: SomaRuntimePartOpts ): SomaRuntimePart { const compiledPart = resolvePart(partName); const reg = createRegistration(partName, opts); if (opts.syncAttrs) syncPartAttrs(reg, compiledPart); if (opts.context && opts.owner !== undefined) opts.context.set(opts.owner); return { get attachment() { return reg.attachment; }, get props() { return partPropsForRegistration(partName, reg); }, resolveProps(bindings: MorfoBindings = {}) { const props: Record = { ...compiledPart.staticAttrs }; for (const plan of compiledPart.dynamicAttrs) { const value = evalAttrPlan(plan, bindings); if (value !== undefined) props[plan.attr] = value; } return props; }, assert

>(props: P): P { assertContract(compiled.kebab, partName, props, sources.logger); return props; } }; } function partProps(part: string): Record { const reg = registrations.get(part); if (!reg) return {}; return partPropsForRegistration(part, reg); } function partPropsForRegistration(part: string, reg: PartRegistration): Record { const marker = compiled.parts.attrs[part]; const props: Record = { id: reg.id.current }; if (marker) props[marker] = ''; // `data-archetype` is part of static identity (cross-component // classification, never mutates), so it ships through partProps — // not through dom.apply. Eidos reads it via `[data-archetype=trigger]`. if (reg.meta.archetype) props['data-archetype'] = reg.meta.archetype; if (reg.attachment) Object.assign(props, reg.attachment); return props; } function matchesParsedKey(parsed: ParsedKey, event: KeyboardEvent): boolean { if (event.key !== parsed.key) return false; if (event.shiftKey !== parsed.shift) return false; if (event.ctrlKey !== parsed.ctrl) return false; if (event.altKey !== parsed.alt) return false; if (event.metaKey !== parsed.meta) return false; return true; } function keydown(part: string, event: KeyboardEvent): boolean { const compiledPart = compiled.parts.byKebab.get(part); if (!compiledPart || compiledPart.keyboard.length === 0) return false; for (const plan of compiledPart.keyboard) { if (!matchesParsedKey(plan.key, event)) continue; // Skip entries whose condition is currently falsy. const reg = registrations.get(part); const bindings = reg ? readBindings(reg, sources) : EMPTY_BINDINGS; if (!shouldEmitMorfoEntry(plan.condition, bindings)) continue; const handler = sources.actions?.[plan.action]; if (!handler) return false; handler(event); return true; } return false; } function snapshotRootProps(): Record { if (!rootPropsScratch || !sources.props) return EMPTY_ROOT_PROPS; clearBindingsBucket(rootPropsScratch); readSourcesInto(rootPropsScratch, sources.props); return rootPropsScratch; } async function trigger(eventName: string, opts: TriggerOptions = {}): Promise { const action = compiled.actions.byName.get(eventName); if (!action) { throw new SomaRuntimeEventError(morfo.kebab, eventName); } // 1. prewrite — transient markers applied before the perceptual signal. // Each prewrite entry already carries a literal value; no source resolution. for (const write of action.prewrite) { const writeReg = registrations.get(write.part.target); const writeTarget = writeReg?.ref?.current ?? null; if (writeTarget) { sources.dom.apply({ target: writeTarget, attrs: { [write.attr]: write.value } }); } } // 2 & 3. eventEngine.emit ↔ handler — order depends on the morfo // event's declared `sequence`: // // 'pre' (default) — emit BEFORE the handler. The perceptual // signal precedes the structural commit; // useful for "announce, then change" cases // (open: visual+sound first, state flips to // 'open' after). // // 'coincident' — same as 'pre' (emit awaited; handler runs // immediately after). For events where the // two are temporally indivisible. // // 'post' — handler runs FIRST, then emit. The signal // celebrates the resolved state — toggle's // commit-toggle, dialog's close-save. The // pulse arrives AFTER the state has flipped, // so the user perceives the consequence of // their action, not its anticipation. // // `MorfoEventSequence` doc (morfo/types.ts §280): "default 'pre' // preserves current runtime semantics". const sequence = ('sequence' in action.semantic ? action.semantic.sequence : 'pre') ?? 'pre'; const handler = sources.events?.[eventName]; const runEmit = async () => { if (!sources.eventEngine) return; const targetReg = registrations.get(action.target); const target = targetReg?.ref?.current ?? opts.fallbackTarget ?? null; if (!target) { throw new SomaRuntimeTargetError(eventName, action.target); } const props = snapshotRootProps(); const family = action.semantic.family; // Intent comes ENTIRELY from the morfo declaration. Components // that want a consumer prop to flow through declare it // explicitly with `intent: { fromProp: 'intent', … }` on the // event. Transitional families (emerge / shift / sustain) MAY // declare intent now (canon update); when omitted, the event // has no intent and intent.deltas don't apply. const intent = 'intent' in action.semantic && action.semantic.intent !== undefined ? resolveIntent(action.semantic.intent, props) : undefined; const hold = resolveSemaDuration(action.hold); // Per-call overrides win over morfo-declared. Channels: per-call // fully replaces morfo's. Overrides: shallow per-channel merge // (per-call channel slices win wholesale on conflict). const channels = opts.channels ?? action.semantic.channels; const morfoOverrides = action.semantic.overrides; const overrides = opts.overrides ? ({ ...(morfoOverrides ?? {}), ...opts.overrides } as SemaSignatureOverride) : morfoOverrides; await sources.eventEngine.emit({ target, name: action.name, family, ...(intent ? { intent } : {}), ...(hold !== undefined ? { hold } : {}), ...(channels !== undefined ? { channels } : {}), ...(overrides ? { overrides } : {}) }); }; if (sequence === 'post') { if (handler) await handler(); // `post` means the perceptual signal should see the resolved // structural DOM, not just the mutated state object. Flush once // so attrs/props such as data-state or hidden are up to date // before Sema stamps data-event-*. await tick(); await runEmit(); } else { // 'pre' or 'coincident' — emit first, then handler. await runEmit(); if (handler) await handler(); } // 4. Effects on the affected parts re-derive structural attrs from the // new state and write them via `dom.apply` automatically — no explicit // step here. State is the source of truth; the DOM is its derivation. } return { part, partProps, keydown, trigger }; }