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

552 lines
21 KiB

/**
* 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<EngineSemantic, 'emit'>;
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;
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<string, EventHandler>;
/**
* 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<string, KeyboardActionHandler>;
/** 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<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>;
/**
* 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<string, unknown>;
/** 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;
}
export interface SomaRuntime {
part<Owner = unknown>(part: string, opts: SomaRuntimePartOpts<Owner>): SomaRuntimePart;
partProps(part: string): Record<string, unknown>;
/**
* 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<void>;
}
/**
* 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<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(morfo: Morfo, sources: SomaRuntimeSources): SomaRuntime {
const compiled = registerMorfo(morfo);
const registrations = new Map<string, PartRegistration>();
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<string, unknown> = { ...compiledPart.staticAttrs };
for (const plan of compiledPart.dynamicAttrs) {
resolved[plan.attr] = evalAttrPlan(plan, bindings);
}
sources.dom.apply({ target, attrs: resolved as Record<string, DomAttrValue> });
});
}
function part<Owner = unknown>(
partName: string,
opts: SomaRuntimePartOpts<Owner>
): 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<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;
}
};
}
function partProps(part: string): Record<string, unknown> {
const reg = registrations.get(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);
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<string, unknown> {
if (!rootPropsScratch || !sources.props) return EMPTY_ROOT_PROPS;
clearBindingsBucket(rootPropsScratch);
readSourcesInto(rootPropsScratch, sources.props);
return rootPropsScratch;
}
async function trigger(eventName: string, opts: TriggerOptions = {}): Promise<void> {
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 };
}

Powered by TurnKey Linux.