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/soma/runtime.svelte.ts

1152 lines
48 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

/**
* 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, the render bag and contract
* helpers.
* - `partProps(part)` — the FULL render bag (P0 fase C, audit 2026-08-26):
* static identity (id, marker, ref attachment, dir) plus the resolved morfo
* contract — `staticAttrs` (role, literals) and every dynamic plan. It is
* the SINGLE attr pipeline: the same values server-render and re-derive
* through Svelte's reactivity. Before the unification the bag carried
* identity only and the contract lived in a client-only `syncAttrs` effect
* (retired in fase C2c), so 42 providers server-rendered with no role /
* aria-* / data-state.
* - `keydown(part, event)` — dispatch from `morfo.keyboard`.
* - `trigger(eventName)` — orchestrates prewrite + eventEngine.emit + handler.
*
* Operational rules:
* - Nothing writes a part's contract attrs outside the render bag. The one
* sanctioned imperative writer left is `trigger`'s prewrite (a commit the
* morfo declares), which goes through `dom.apply` on its own step.
*
* 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 { ActorRef } from '$libs/actor';
import type { Logger } from '$libs/logger';
import {
resolveSemaDuration,
resolveIntent,
type EngineSemantic,
type SemaChannelId,
type SemaDirection,
type SemaSignatureOverride,
type SignalPersistence
} from '$uix/sema';
import type { Direction } from './types';
import type {
ActionNameOf,
EventNameOf,
EventNameTargeting,
Morfo,
MorfoDirection,
MorfoFocus,
MorfoSemanticIntent,
PartKebabOf
} from '$uix/morfo';
import {
assertContract,
evalAttrPlan,
isPolymorphicSemantic,
registerMorfo,
type CompiledPart,
type ParsedKey
} from '$uix/morfo';
import type { Intent } from '$uix/intent';
import type { SemaFamily } from '$uix/sema/types';
import { attachRef, type RefAttachment, type Active, type State } from '$libs/reactive';
import { shouldEmitMorfoEntry, type MorfoBindings } from '$uix/morfo';
import {
SomaRuntimeEventError,
SomaRuntimePartError,
SomaRuntimePolymorphicError,
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.).
*
* `clear` and `clearTarget` are optional so test fakes that only implement
* `emit` keep type-checking. When absent, `runtime.clearSignal` /
* `runtime.clearTarget` become no-ops (and the persistent signal projection
* just lingers — fine for tests, never seen in production where the real
* `EngineSemantic` always provides them).
*/
export type EventEngineEmitter = Pick<EngineSemantic, 'emit'> &
Partial<Pick<EngineSemantic, 'clear' | 'clearTarget'>>;
export type SourceMap = Record<string, () => 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;
/**
* The `dir` requirement, computed from the MORFO — the declaration owns the
* mechanism (direction contract §2):
*
* - morfo declares `direction` ⇒ the runtime REQUIRES `dir`, the raw
* assertion (`Active<Direction | undefined>`, i.e. `opts.dir` — never a
* resolved value). WHERE it stamps comes from the morfo's
* `direction.parts`, validated fail-closed at compile.
* - no `direction` ⇒ the source is FORBIDDEN, so a component with no reading
* direction can never gain a stray attribute.
*
* Both directions of the gate are type errors: a component that accepts a
* `dir` prop and forgets to wire it does not compile, and the ~75 runtimes
* with no direction concept stopped having to say `dir: null` about an axis
* that never concerned them.
*/
export type SomaRuntimeDirSource<M extends Morfo = Morfo> = M extends {
direction: MorfoDirection;
}
? { dir: Active<Direction | undefined> }
: { dir?: never };
export interface SomaRuntimeBaseSources {
/** 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;
/** Translation lookup. Read by `translationRef` declarations. */
translate?: (key: string) => string | undefined;
/**
* Live-region bridge. Called by `trigger()` when a morfo event declares
* `a11ySemantic.requiresLiveRegion: true` and the caller passes a
* `message` in `TriggerOptions`. Soma's standard runtime wires this to
* `ActiveUix.announce(...)`; tests can pass a fake or omit entirely.
*/
announce?: (message: string, priority?: 'polite' | 'assertive') => void;
/** Optional diagnostics logger for runtime contract checks. */
logger?: Logger;
}
/**
* The full sources contract, parameterised by the morfo so the `dir`
* requirement is computed from the declaration (see {@link SomaRuntimeDirSource})
* and the handler maps are keyed by DECLARED names. A handler for an event or
* action the morfo does not declare is dead code that reads as if it did
* something — the same drift class the pack census kills on the consumption
* side, closed here at the type level (2026-08-10).
*/
export type SomaRuntimeSources<M extends Morfo = Morfo> = SomaRuntimeBaseSources & {
/**
* Provider's per-event handlers. Each is called by `runtime.trigger` after
* `eventEngine.emit` resolves and is responsible for mutating internal
* state. Keys are the morfo's declared event names.
*/
events?: { [K in EventNameOf<M>]?: EventHandler };
/**
* Provider's per-action handlers, dispatched by `runtime.keydown` against
* the part's `morfo.keyboard` declarations. Keys are the `action` literals
* declared across the morfo's parts (e.g. `'toggle'`, `'close'`).
*/
actions?: { [K in ActionNameOf<M>]?: KeyboardActionHandler };
} & SomaRuntimeDirSource<M>;
export interface SomaRuntimePartBaseOpts {
/** Per-instance id of this part (from the part-provider's opts). */
id: Active<string>;
/** Per-instance ref. Required for parts with DOM. */
ref?: State<HTMLElement | null>;
/** 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<T = unknown> {
set(value: T): unknown;
}
export interface SomaRuntimePartOpts<Owner = unknown> extends SomaRuntimePartBaseOpts {
/** Concrete provider instance to publish into context. */
owner?: Owner;
/** Optional context where the provider instance should be registered. */
context?: SomaRuntimePartContext<Owner>;
}
/**
* Handle for ONE mounted instance of a part, parameterised by the morfo and
* the part kebab. No default type arguments — same doctrine as
* {@link SomaRuntime}: an untyped handle would anchor any event on any part.
*/
export interface SomaRuntimePart<M extends Morfo, K extends PartKebabOf<M>> {
readonly attachment: RefAttachment | undefined;
/**
* The render bag (P0 fase C, audit 2026-08-26): static identity (id,
* marker attr, archetype, ref attachment, dir) plus every morfo-declared
* attr resolved against THIS part's registered sources. Spread it in a
* part's `props` getter, then add only the soma-specific extras (handlers,
* formatted overrides, native attrs). The single attr pipeline — it
* absorbed the old `the render bag`.
*/
readonly props: Record<string, unknown> & { readonly dir?: Direction };
/** Resolve morfo static/dynamic attrs for render-time legacy/manual props. */
resolveProps(bindings?: MorfoBindings): Record<string, unknown>;
/** Validate an authored prop bag against the morfo data contract. */
assert<P extends Record<string, unknown>>(props: P): P;
/**
* Emit a morfo-declared event ANCHORED to this instance: the stamp (and
* the a11y focus move, when declared) lands on THIS part's element, never
* on the runtime's own resolution. The name is typed to the events whose
* `target` or `allowedTargets` include this part — an event that cannot
* land here cannot be emitted from here.
*
* This is the repeated-part emission surface (the pressed `day`, the
* clicked `item`): the instance the user touched holds the handle, so no
* element ever travels through an untyped option. Mount-state fallback
* (`targetFallback`) applies only to the UN-anchored `runtime.trigger`.
*/
trigger(eventName: EventNameTargeting<M, K>, opts?: TriggerOptions): Promise<TriggerResult>;
}
/**
* Anchored emitter for an instance located by ELEMENT rather than held as a
* handle — `runtime.partInstance(part, el)`. The identity is the registry's:
* `el` must be a registered live instance of `part`, or the lookup is `null`.
*/
export interface SomaPartAnchor<M extends Morfo, K extends PartKebabOf<M>> {
readonly el: HTMLElement;
trigger(eventName: EventNameTargeting<M, K>, opts?: TriggerOptions): Promise<TriggerResult>;
}
/**
* The runtime handle a provider works through, parameterised by ITS morfo.
*
* No default type argument ON PURPOSE: `SomaRuntime` without a morfo would
* accept any part kebab and any event name, which is exactly the anonymous
* surface the 2026-08-10 audit measured (S-12, S-08/S-15, A-36 — emissions
* aimed at names and parts the contract never declared, invisible to every
* consumer). A provider annotates `SomaRuntime<typeof xxxMorfo>`; a helper
* that genuinely abstracts over morfos is generic itself. Widening to
* `SomaRuntime<Morfo>` re-opens the anonymous surface — treat it as drift.
*/
export interface SomaRuntime<M extends Morfo> {
part<K extends PartKebabOf<M>, Owner = unknown>(
part: K,
opts: SomaRuntimePartOpts<Owner>
): SomaRuntimePart<M, K>;
partProps(part: PartKebabOf<M>): Record<string, unknown>;
/**
* The morfo's declared focus policy (eje focus-first, 2026-08-26) — the
* SPECIES DEFAULT the executors consume: `FocusScope.use` takes it as its
* `policy` fallback (`trapFocus ?? modal ?? policy.trap`), and roving
* providers wire `RovingFocusGroup` (or their signed custom executor)
* from it. Per-instance props always win. `undefined` = the component
* declares no focus policy.
*/
readonly focus: MorfoFocus | undefined;
/**
* Resolve an ELEMENT to the registered live instance of `part` it is, and
* return an anchored emitter for it — or `null` when the element is not a
* registered instance (identity by reference against the per-instance
* registry, never by attribute sniffing). For providers that locate a
* repeated part's instance by query (`querySelector`) instead of holding
* its handle.
*/
partInstance<K extends PartKebabOf<M>>(part: K, el: HTMLElement): SomaPartAnchor<M, K> | null;
/**
* 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: PartKebabOf<M>, 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. the render bag re-derives structural attrs from the new state and
* Svelte re-renders them (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.
*
* Returns the signal id emitted (undefined if `eventEngine` is absent or
* the event was silenced via `channels: []`). For non-transient signals
* the caller can hold this id and pass it to {@link clearSignal} when
* the underlying condition (user action, fix applied, state ended) is met.
*/
trigger(eventName: EventNameOf<M>, opts?: TriggerOptions): Promise<TriggerResult>;
/**
* Clear an active persistent signal by id. Returns `true` if cleared,
* `false` if the id wasn't active. No-op when `eventEngine` lacks
* `clear` (test fakes).
*/
clearSignal(id: string): boolean;
/**
* Clear all active persistent signals projected on the given target
* element. Returns the count of signals cleared. No-op when
* `eventEngine` lacks `clearTarget` (test fakes).
*/
clearTarget(target: HTMLElement): number;
/**
* Read the current DOM element of a registered part by its kebab name.
* Returns `null` when the part isn't registered, or when its ref hasn't
* been attached yet. Used by providers that need to call
* `clearTarget(...)` on a sub-part without holding the part's own
* reference directly. Cheaper than passing refs around or going
* through context lookups.
*/
partRef(part: PartKebabOf<M>): HTMLElement | null;
}
/**
* Result of a `trigger()` call. Always returned; the caller can ignore
* unless the event is non-transient and they need to clear it later.
*/
export interface TriggerResult {
/** Signal id emitted, when `eventEngine` was present and the signal wasn't silenced. */
readonly id?: string;
/** Resolved persistence policy applied to the emitted signal. */
readonly persistence?: SignalPersistence;
}
/**
* Per-call overrides for `runtime.trigger`.
*
* There is deliberately NO way to hand this API an element. The emission
* surface is resolved from the DECLARATION and the REGISTRY, nothing else:
* a singleton lands on its registered instance, a repeated part is anchored
* through its own typed handle (`SomaRuntimePart.trigger` /
* `partInstance(...).trigger`, gated by `EventNameTargeting`), and an
* unmounted target degrades through the morfo's `targetFallback` chain.
* The `targetOverride?: HTMLElement` escape hatch that used to live here —
* born `fallbackTarget`, renamed 2026-08-10, censused to zero and DELETED
* 2026-08-13 — existed to compensate for a registry without instance
* identity, and it is what let three audits (fable S1, sema S-17, blocks
* A-36/A-65) find the same anonymous-element drift without any of them being
* able to close it. An element option here would reopen that class at the
* type level; do not add one.
*/
export interface TriggerOptions {
/**
* 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 (capas 5a/5b) 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[];
/**
* Sense of the traversal this emit performs, forwarded verbatim to the
* signal (`data-event-direction`). Per-call ONLY — the morfo cannot declare
* it, because the same declared event (`shift-navigate`) goes backward on
* one press and forward on the next, and only the caller knows which.
*
* Omit when the route has no clear sense (a jump to a month picked from a
* select): the surface then carries no direction, which is the honest
* answer.
*/
direction?: SemaDirection;
/**
* Per-call persistence override (book cap. 24 §6). Replaces the morfo
* event's declared `semantic.persistence` for this single emit. Use
* sparingly — the morfo declaration is the source of truth for what
* a signal type means. Per-call overrides exist for genuine runtime
* branches (e.g. a warn signal that's transient when the user dismisses
* the field but untilFix when the form is submitted).
*/
persistence?: SignalPersistence;
/**
* Human-readable text the event represents. Consumed by the a11y
* pipeline:
* - pushed to `sources.announce(...)` when the morfo event declares
* `a11ySemantic.requiresLiveRegion`;
* - reused as the announce content when the user prefers reduced
* motion AND the event declares `reducedMotionFallback: 'text'`.
*
* Optional. When omitted, the live-region announcement is skipped
* (no point announcing an empty string).
*/
message?: string;
/**
* Polymorphic event concretion (book §5.3). When the morfo event
* declares `allowedFamilies` (its regular `family`/`verb` act as the
* default shape — additive design, LIBRO_VARIACIONES D.11), the caller
* can commit to a concrete shape here. Validated at runtime against
* `allowedFamilies` — passing a family outside the allowlist raises
* `SomaRuntimePolymorphicError`.
*
* Silently ignored for non-polymorphic events (those without
* `allowedFamilies`).
*/
semantic?: {
family: SemaFamily;
intent?: Intent | MorfoSemanticIntent;
verb?: string;
};
/**
* Causal actor of this trigger (agent axis, ⚖️2 —
* docs/architecture/agent.md §5). A provider whose public API was
* invoked from a delegated CapabilityCall threads the engine-minted
* token here (typically alongside `semantic: { family: 'delegate',
* verb: 'act' }` on polymorphic events); absent ⇒ user semantics.
* Copied verbatim onto the `SemanticSignal` — the runtime never
* constructs nor resolves tokens (minting authority: EngineAgent).
*/
actor?: ActorRef;
}
interface PartRegistration {
id: Active<string>;
ref: State<HTMLElement | null> | 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<string, unknown>;
props?: Record<string, unknown>;
parts?: Record<string, unknown>;
}
const EMPTY_ROOT_PROPS: Record<string, unknown> = 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<SomaRuntimePartBaseOpts, 'states' | 'props' | 'parts'>,
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<string, unknown> | undefined): void {
if (!bucket) return;
for (const key in bucket) delete bucket[key];
}
function readSourcesInto(
bucket: Record<string, unknown> | 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<M extends Morfo>(
morfo: M,
sources: SomaRuntimeSources<M>
): SomaRuntime<M> {
// Internal widening: the conditional type narrows `dir` per-morfo for the
// CALLER; in here we only need "an Active or nothing". Same for the handler
// maps: the mapped types gate the CALLER's keys against the declaration;
// in here lookups run on runtime strings.
const dirSource = (sources as { dir?: Active<Direction | undefined> }).dir;
const eventHandlers = sources.events as Partial<Record<string, EventHandler>> | undefined;
const actionHandlers = sources.actions as
| Partial<Record<string, KeyboardActionHandler>>
| undefined;
const compiled = registerMorfo(morfo);
/**
* Per-INSTANCE part registry (2026-08-10). A repeated part (`day`, `item`,
* `row`) mounts many instances, and the old `Map<part, registration>` kept
* only the last one — the defect that forced providers to smuggle elements
* through `targetOverride`, and that let a dead newest instance SHADOW a
* live older one (unmount order decided what the runtime could see).
*
* Each `part()` call appends its registration; for parts with a ref, the
* attachment stream maintains membership — detached instances leave the
* bucket, re-attached ones rejoin at the end. Ref-less parts stay put (no
* element, no liveness to track). Resolution is {@link liveReg}: the newest
* ATTACHED instance, falling back to the newest entry so a not-yet-attached
* part behaves exactly as before (null ref → the target checks throw/skip).
*/
const registrations = new Map<string, PartRegistration[]>();
const rootPropsScratch = hasSourceKeys(sources.props) ? {} : undefined;
// Which parts carry the `dir` stamp. Defaults to the provider root, which is
// where 44 of the 49 hand-written stamps sat; the five that paint elsewhere
// (a root that renders no element, a portalled content, a field's input)
// name their parts (see `SomaRuntimeDir`).
// WHERE the stamp lands comes from the morfo (normalised at compile:
// `['provider']` default applied there; unknown names threw at compile).
const dirParts = new Set<string>(compiled.direction?.parts ?? []);
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
};
const bucket = registrations.get(part) ?? [];
if (bucket.length === 0) registrations.set(part, bucket);
bucket.push(reg);
if (opts.ref) {
reg.attachment = attachRef(opts.ref, (el) => {
// Registry membership follows the attachment: a detached instance
// leaves (a virtualised repeated part would otherwise grow the
// bucket without bound), a re-attached one rejoins at the end,
// which also makes it the newest for {@link liveReg}.
if (el) {
if (!bucket.includes(reg)) bucket.push(reg);
} else {
const index = bucket.indexOf(reg);
if (index >= 0) bucket.splice(index, 1);
}
untrack(() => opts.onRefChange?.(el));
});
}
return reg;
}
/**
* The registration that answers for a part today: the newest ATTACHED
* instance, else the newest entry (a not-yet-attached part keeps its
* pre-2026-08-10 behaviour — a null ref the target checks reject). The
* per-instance handles returned by `part()` never go through here; this is
* the runtime's own resolution for morfo-declared targets and bindings.
*/
function liveReg(part: string): PartRegistration | undefined {
const bucket = registrations.get(part);
if (!bucket || bucket.length === 0) return undefined;
for (let i = bucket.length - 1; i >= 0; i--) {
if (bucket[i].ref?.current) return bucket[i];
}
return bucket[bucket.length - 1];
}
function part<Owner = unknown>(
partName: string,
opts: SomaRuntimePartOpts<Owner>
): SomaRuntimePart<M, PartKebabOf<M>> {
const compiledPart = resolvePart(partName);
const reg = createRegistration(partName, opts);
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<string, unknown> = { ...compiledPart.staticAttrs };
for (const plan of compiledPart.dynamicAttrs) {
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
return props;
},
assert<P extends Record<string, unknown>>(props: P): P {
assertContract(compiled.kebab, partName, props, sources.logger);
return props;
},
trigger(eventName: string, opts: TriggerOptions = {}) {
return trigger(eventName, opts, reg);
}
};
}
function partInstance(partName: string, el: HTMLElement) {
const bucket = registrations.get(partName);
const reg = bucket?.find((candidate) => candidate.ref?.current === el);
if (!reg) return null;
return {
el,
trigger(eventName: string, opts: TriggerOptions = {}) {
return trigger(eventName, opts, reg);
}
};
}
function partProps(part: string): Record<string, unknown> {
const reg = liveReg(part);
if (!reg) return {};
return partPropsForRegistration(part, reg);
}
function partPropsForRegistration(part: string, reg: PartRegistration): Record<string, unknown> {
const marker = compiled.parts.attrs[part];
const props: Record<string, unknown> = {
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);
// The `dir` stamp. Ships here — the one bag BOTH `props` and
// `the render bag` include — because it was the last thing every provider
// still wrote by hand: 49 copies of the same line, and 20 of 55
// components had simply forgotten it. The morfo DECLARES the direction
// (and which parts carry it); the type computed from that declaration
// REQUIRES the source — so both forgetting to wire it and wiring it onto
// a direction-less component are compile errors.
//
// The ASSERTION, raw (direction contract §2): `undefined` means nobody
// asserted, the attribute is omitted, and the element inherits — from
// the projected `<html dir>`, or from the nearest asserted ancestor.
if (dirSource && dirParts.has(part)) {
const value = dirSource.current;
if (value !== undefined) props.dir = value;
}
// The morfo contract itself (P0 fase C, audit 2026-08-26): `staticAttrs`
// (role + unconditional literals) and every non-`consumerWins` dynamic
// plan resolve into the render bag, so the contract reaches
// server-rendered HTML and re-derives through Svelte's own reactivity —
// `readBindings` reads rune-backed sources, so any template or $derived
// spreading this bag re-renders when a state/prop source changes. Before
// this, the whole slice lived only in the client `syncAttrs` effect and
// 42 providers server-rendered with no role / aria-* / data-state /
// type="button" (a Toggle in a form defaulted to type="submit").
Object.assign(props, reg.meta.staticAttrs);
let bindings: MorfoBindings | undefined;
for (const plan of reg.meta.dynamicAttrs) {
if (plan.consumerWins) continue;
bindings ??= readBindings(reg, sources);
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
// Naming defaults (`consumerWins` plans — `aria-label`) go LAST and are
// the only plans `mergeProps` resolves consumer-first. An imperative
// post-render writer would overwrite the consumer's attr on every pass —
// the A-85 class the retired syncAttrs effect had to tiptoe around.
// Reads are reactive: `translate` is `langs.ts()` (rune-backed locale),
// so the default re-renders on locale change.
for (const plan of reg.meta.dynamicAttrs) {
if (!plan.consumerWins) continue;
bindings ??= readBindings(reg, sources);
const value = evalAttrPlan(plan, bindings);
if (value !== undefined) props[plan.attr] = value;
}
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 = liveReg(part);
const bindings = reg ? readBindings(reg, sources) : EMPTY_BINDINGS;
if (!shouldEmitMorfoEntry(plan.condition, bindings)) continue;
const handler = actionHandlers?.[plan.action];
// No handler registered for THIS plan — keep scanning: another plan
// on the same key (different condition / action) may still handle it
// (SOM-2, clean-room 2026-07-10: `return false` here aborted the
// loop and swallowed the later plans).
if (!handler) continue;
handler(event);
return true;
}
return false;
}
function snapshotRootProps(): Record<string, unknown> {
if (!rootPropsScratch || !sources.props) return EMPTY_ROOT_PROPS;
clearBindingsBucket(rootPropsScratch);
readSourcesInto(rootPropsScratch, sources.props);
return rootPropsScratch;
}
/**
* The emit surface an occurrence lands on. One resolution for the three
* readers (pre-validation, the emit itself, the a11y focus move), so they
* can never disagree:
*
* anchor — a part-anchored emission (`SomaRuntimePart.trigger` /
* `partInstance(...).trigger`) stamps ITS instance, or nothing:
* an anchored emit from a detached instance is a target error,
* never a silent redirection elsewhere.
* else — the declared `target`'s newest live instance, then the
* morfo's `targetFallback` chain in order.
*/
function resolveEmitTarget(
action: { target: string; targetFallback: readonly string[] },
anchor: PartRegistration | undefined
): HTMLElement | null {
if (anchor) return anchor.ref?.current ?? null;
const primary = liveReg(action.target)?.ref?.current;
if (primary) return primary;
for (const part of action.targetFallback) {
const el = liveReg(part)?.ref?.current;
if (el) return el;
}
return null;
}
/**
* Dev guard for the anchored path — the ONLY door an emission can name an
* instance through, now that the `targetOverride` element option is gone
* (deleted 2026-08-13; census 0). The TYPE gate (`EventNameTargeting`)
* makes an off-contract anchor inexpressible in TS, so this warn exists
* for JS callers only. A warning, never a throw — sema is ornamental.
*/
function assertAnchor(
action: { name: string; target: string; semantic: unknown },
anchor: PartRegistration
): void {
if (!sources.logger) return;
const semantic = action.semantic as { allowedTargets?: readonly { target: string }[] };
const allowed = [action.target, ...(semantic.allowedTargets ?? []).map((ref) => ref.target)];
if (allowed.includes(anchor.meta.kebab)) return;
sources.logger.warn(
'soma',
`anchored trigger "${compiled.kebab}.${action.name}" from part "${anchor.meta.kebab}" is off-contract`,
{
context: {
event: action.name,
anchoredOn: anchor.meta.kebab,
declared: allowed,
fix: 'anchor on the declared target or an `allowedTargets` part'
}
}
);
}
function trigger(
eventName: string,
opts: TriggerOptions = {},
anchor?: PartRegistration
): Promise<TriggerResult> {
const result = runTrigger(eventName, opts, anchor);
// Pre-attach a logging catch so fire-and-forget dispatch (`void
// runtime.trigger(...)` — the dominant call shape in providers) can
// never surface as an UNHANDLED rejection when a race rejects the
// emit (e.g. SomaRuntimeTargetError on a part unmounted mid-flight).
// The ORIGINAL promise is returned, so awaiting callers still get the
// rejection intact (SEM-2, clean-room 2026-07-10).
result.catch((err) => {
sources.logger?.error('soma', `trigger "${eventName}" rejected`, { error: err });
});
return result;
}
async function runTrigger(
eventName: string,
opts: TriggerOptions = {},
anchor?: PartRegistration
): Promise<TriggerResult> {
const action = compiled.actions.byName.get(eventName);
if (!action) {
throw new SomaRuntimeEventError(morfo.kebab, eventName);
}
if (anchor) assertAnchor(action, anchor);
// 0. Validate the emit target BEFORE any DOM write (SO4, fable audit
// 2026-07-01). The prewrite used to run first, so a target that had
// unmounted mid-flight left `data-last-action` written on the DOM and
// then threw — a half-applied state whose only witness was an attribute
// nobody would clear. Nothing observable happens until we know the
// occurrence can complete.
//
// ONLY for `pre` / `coincident`, where the emit precedes the handler and
// the target must therefore already exist. A `post` event's target may
// legitimately MOUNT during the trigger (the overlay `open`s: the
// handler flips state, `tick()` mounts the content, the emit stamps it),
// so validating it here would reject the canonical open flow. `post`
// keeps its emit-time check inside `runEmit` — and if that one fails,
// the handler has already done the component's real work; only the
// ornament is lost, which is sema's contract.
if (sources.eventEngine && (action.semantic.sequence ?? 'pre') !== 'post') {
if (!resolveEmitTarget(action, anchor)) {
throw new SomaRuntimeTargetError(eventName, action.target);
}
}
// 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 = liveReg(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.
//
// Polymorphic event resolution (book §5.3). The morfo's regular
// `family` + `intent` + `verb` + `sequence` form the DEFAULT shape.
// When the morfo additionally declares `allowedFamilies`, providers
// MAY pass `semantic` in opts to concrete to one of those families
// instead. The override must be in `allowedFamilies` (the morfo's
// own family is implicitly always allowed).
let resolvedFamily: SemaFamily = action.semantic.family;
let resolvedIntent: Intent | MorfoSemanticIntent | undefined = action.semantic.intent;
let resolvedVerb: string | undefined = action.semantic.verb;
const resolvedSequence: 'pre' | 'coincident' | 'post' | undefined = action.semantic.sequence;
if (isPolymorphicSemantic(action.semantic) && opts.semantic) {
const allowed = action.semantic.allowedFamilies;
const provided = opts.semantic;
// The morfo's declared family is implicitly always allowed —
// authors don't have to repeat themselves in `allowedFamilies`.
const implicitlyAllowed = provided.family === action.semantic.family;
if (!implicitlyAllowed && !allowed.includes(provided.family)) {
throw new SomaRuntimePolymorphicError(eventName, provided.family, allowed);
}
resolvedFamily = provided.family;
resolvedIntent = provided.intent;
resolvedVerb = provided.verb;
} else if (opts.semantic) {
// SO2 (fable audit 2026-07-01): `TriggerOptions.semantic`'s own JSDoc
// promised a warn here and there was none, so a provider that passed a
// concretion to a NON-polymorphic event believed it had overridden the
// family and had not. Silence made the two cases — honoured and
// ignored — indistinguishable at the call site.
sources.logger?.warn(
'soma',
`trigger("${eventName}", { semantic }) ignored — the event is not polymorphic`,
{
context: {
event: eventName,
declaredFamily: action.semantic.family,
requestedFamily: opts.semantic.family,
fix: 'declare `allowedFamilies` on the morfo event, or drop the `semantic` option'
}
}
);
}
// The verb TRAVELS (fixed 2026-08-06, audit S-40 — it used to be computed
// and discarded here while the type promised it was emitted). It selects
// the family's default sound: an `emerge` + `close` finds `close` in the
// map's verb table, so no overlay pack has to name it.
// `MorfoEventSequence` doc (morfo/types.ts §280): "default 'pre'
// preserves current runtime semantics". Polymorphic events use the
// resolved sequence from above; concrete events read it directly.
const sequence = resolvedSequence ?? 'pre';
const handler = eventHandlers?.[eventName];
// Persistence: morfo declares; per-call opts override. Default
// `'transient'` (engine auto-cleans after hold). For non-transient
// values, caller owns the cleanup via `runtime.clearSignal(id)`
// or `runtime.clearTarget(target)`.
const persistence: SignalPersistence =
opts.persistence ?? action.semantic.persistence ?? 'transient';
// A11y semantics (book §9.1). Read once; honored after emit.
const a11y = action.a11ySemantic;
const prefersReducedMotion = sources.dom.prefersReducedMotion.matches;
const reducedFallback = a11y?.reducedMotionFallback;
// When the user prefers reduced motion AND the event declares a
// `'state'` fallback, the morfo asks us to silence the perceptual
// signal entirely (no motion, no haptic, no sound) and rely on the
// state attrs the runtime writes naturally. We do that by forcing
// `channels: []` which the engine short-circuits without dispatching.
//
// S5 (fable audit 2026-07-01) — this WINS over the morfo's and the
// call's `channels`. It used to be the last term of a `??` chain, so a
// morfo tuning its channels (`channels: ['sound']`, to keep a live
// region from competing with haptic) silently defeated a user
// ACCESSIBILITY PREFERENCE. The old comment defended it as deliberate
// ("authors can still force motion"), but it conflated two different
// intents: choosing which channels express an event is the author's;
// deciding whether motion reaches this user is not.
const a11yChannelsOverride =
prefersReducedMotion && reducedFallback === 'state' ? ([] as const) : undefined;
let emittedId: string | undefined;
const runEmit = async () => {
if (!sources.eventEngine) return;
const target = resolveEmitTarget(action, anchor);
if (!target) {
throw new SomaRuntimeTargetError(eventName, action.target);
}
const props = snapshotRootProps();
const family = resolvedFamily;
// Intent comes from the resolved morfo declaration (concrete
// `intent` field for non-polymorphic, or the polymorphic
// override / declared default for polymorphic). Components that
// want a consumer prop to flow through declare `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 =
resolvedIntent !== undefined ? resolveIntent(resolvedIntent, 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).
//
// The a11y reduced-motion silence is FIRST, not last (S5): a user
// preference is not a default for an author to fall back on.
const channels = a11yChannelsOverride ?? opts.channels ?? action.semantic.channels;
const morfoOverrides = action.semantic.overrides;
const overrides = opts.overrides
? ({ ...(morfoOverrides ?? {}), ...opts.overrides } as SemaSignatureOverride)
: morfoOverrides;
emittedId = await sources.eventEngine.emit({
target,
name: action.name,
family,
...(resolvedVerb ? { verb: resolvedVerb } : {}),
...(intent ? { intent } : {}),
// The sense of the traversal, when the caller knows it. No morfo
// term to fall back on: it is per-emit by nature.
...(opts.direction !== undefined ? { direction: opts.direction } : {}),
...(hold !== undefined ? { hold } : {}),
...(channels !== undefined ? { channels } : {}),
...(overrides ? { overrides } : {}),
...(opts.actor !== undefined ? { actor: opts.actor } : {}),
// Surface concurrency policy, declared by the morfo. Only the
// irreducible pairs set it (`SemaRegime`); everything else takes
// the default `replace`.
...(action.regime !== undefined ? { regime: action.regime } : {}),
persistence
});
};
const runA11y = () => {
if (!a11y) return;
// Live region — push the caller-provided message text to the
// shared screen-reader region. Triggers ALSO when
// reducedMotionFallback is 'text' so the user gets a textual
// substitute for the motion they don't see.
const wantsAnnounce =
a11y.requiresLiveRegion || (prefersReducedMotion && reducedFallback === 'text');
if (wantsAnnounce && opts.message && sources.announce) {
// CANONICAL live-region policy (SEM-1, 2026-07-11): urgency rides
// the evaluative INTENT alone — threat/loss interrupt, everything
// else waits its turn — matching sema's AnnounceChannel
// (`priorityForIntent`, chans/announce.ts) so BOTH announcement
// paths agree. The old `family === 'signal'` conjunct made a
// threat-tinted commit failure announce polite here and assertive
// via the channel. Uses the RESOLVED intent so polymorphic events
// get the right priority too.
const intent =
resolvedIntent !== undefined
? resolveIntent(resolvedIntent, snapshotRootProps())
: undefined;
const priority: 'polite' | 'assertive' =
intent === 'threat' || intent === 'loss' ? 'assertive' : 'polite';
sources.announce(opts.message, priority);
}
// Focus move — bring keyboard focus to the event's target. Also
// triggered by 'focus' fallback under reduced motion.
const wantsFocus =
a11y.requiresFocusMove || (prefersReducedMotion && reducedFallback === 'focus');
if (wantsFocus) {
const target = resolveEmitTarget(action, anchor);
if (target) sources.dom.focus(target);
}
};
if (sequence === 'post') {
// Handlers are synchronous by the V1 contract (`EventHandler` — a
// returned value, promises included, is IGNORED, never awaited nor
// settled; async work belongs in the call-site BEFORE trigger).
// The old `await handler()` contradicted that contract and delayed
// a11y + TriggerResult behind out-of-contract async handlers
// (SOM-1, clean-room 2026-07-10).
if (handler) 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) handler();
}
// 4. a11y commitments (book §9.1). Live region + focus move + reduced-
// motion fallback. Runs AFTER the perceptual emit so the message
// reflects the resolved outcome (handler may have flipped state).
runA11y();
// 5. The render bag re-derives structural attrs from the new state and
// Svelte re-renders them automatically — no explicit step here. State
// is the source of truth; the DOM is its derivation.
return {
...(emittedId !== undefined ? { id: emittedId } : {}),
persistence
};
}
function clearSignal(id: string): boolean {
return sources.eventEngine?.clear?.(id) ?? false;
}
function clearTarget(target: HTMLElement): number {
return sources.eventEngine?.clearTarget?.(target) ?? 0;
}
function partRef(part: string): HTMLElement | null {
const reg = liveReg(part);
return reg?.ref?.current ?? null;
}
return {
part,
partInstance,
partProps,
keydown,
trigger,
clearSignal,
clearTarget,
partRef,
focus: compiled.focus
};
}

Powered by TurnKey Linux.