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.
552 lines
21 KiB
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 };
|
|
}
|