|
|
|
|
@ -48,29 +48,45 @@ import {
|
|
|
|
|
resolveIntent,
|
|
|
|
|
type EngineSemantic,
|
|
|
|
|
type SemaChannelId,
|
|
|
|
|
type SemaSignatureOverride
|
|
|
|
|
type SemaSignatureOverride,
|
|
|
|
|
type SignalPersistence
|
|
|
|
|
} from '$uix/sema';
|
|
|
|
|
|
|
|
|
|
import type { Morfo } from '$uix/morfo';
|
|
|
|
|
import type { Morfo, MorfoSemanticIntent } 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, SomaRuntimeTargetError } from './errors';
|
|
|
|
|
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'>;
|
|
|
|
|
export type EventEngineEmitter = Pick<EngineSemantic, 'emit'> &
|
|
|
|
|
Partial<Pick<EngineSemantic, 'clear' | 'clearTarget'>>;
|
|
|
|
|
|
|
|
|
|
export type SourceMap = Record<string, () => unknown>;
|
|
|
|
|
|
|
|
|
|
@ -116,6 +132,13 @@ export interface SomaRuntimeSources {
|
|
|
|
|
actions?: Record<string, KeyboardActionHandler>;
|
|
|
|
|
/** 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;
|
|
|
|
|
}
|
|
|
|
|
@ -196,8 +219,45 @@ export interface SomaRuntime {
|
|
|
|
|
*
|
|
|
|
|
* 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: string, 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: string): 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.
|
|
|
|
|
*/
|
|
|
|
|
trigger(eventName: string, opts?: TriggerOptions): Promise<void>;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@ -233,6 +293,42 @@ export interface TriggerOptions {
|
|
|
|
|
* silences the signal entirely on this call.
|
|
|
|
|
*/
|
|
|
|
|
channels?: readonly SemaChannelId[];
|
|
|
|
|
/**
|
|
|
|
|
* 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` + `defaultSemantic`, the caller can
|
|
|
|
|
* commit to a concrete shape here. Validated at runtime against
|
|
|
|
|
* `allowedFamilies` — passing a family outside the allowlist raises
|
|
|
|
|
* `SomaRuntimePolymorphicError`.
|
|
|
|
|
*
|
|
|
|
|
* Ignored for non-polymorphic events (those that declare a concrete
|
|
|
|
|
* `family`); pass-through warns via logger if present.
|
|
|
|
|
*/
|
|
|
|
|
semantic?: {
|
|
|
|
|
family: SemaFamily;
|
|
|
|
|
intent?: Intent | MorfoSemanticIntent;
|
|
|
|
|
verb?: string;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PartRegistration {
|
|
|
|
|
@ -449,7 +545,7 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
|
|
|
|
|
return rootPropsScratch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function trigger(eventName: string, opts: TriggerOptions = {}): Promise<void> {
|
|
|
|
|
async function trigger(eventName: string, opts: TriggerOptions = {}): Promise<TriggerResult> {
|
|
|
|
|
const action = compiled.actions.byName.get(eventName);
|
|
|
|
|
if (!action) {
|
|
|
|
|
throw new SomaRuntimeEventError(morfo.kebab, eventName);
|
|
|
|
|
@ -485,11 +581,59 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
void resolvedVerb; // currently unused at trigger-time; reserved for tooling/logging.
|
|
|
|
|
|
|
|
|
|
// `MorfoEventSequence` doc (morfo/types.ts §280): "default 'pre'
|
|
|
|
|
// preserves current runtime semantics".
|
|
|
|
|
const sequence = ('sequence' in action.semantic ? action.semantic.sequence : 'pre') ?? 'pre';
|
|
|
|
|
// preserves current runtime semantics". Polymorphic events use the
|
|
|
|
|
// resolved sequence from above; concrete events read it directly.
|
|
|
|
|
const sequence = resolvedSequence ?? 'pre';
|
|
|
|
|
const handler = sources.events?.[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.
|
|
|
|
|
const a11yChannelsOverride =
|
|
|
|
|
prefersReducedMotion && reducedFallback === 'state' ? ([] as const) : undefined;
|
|
|
|
|
|
|
|
|
|
let emittedId: string | undefined;
|
|
|
|
|
|
|
|
|
|
const runEmit = async () => {
|
|
|
|
|
if (!sources.eventEngine) return;
|
|
|
|
|
const targetReg = registrations.get(action.target);
|
|
|
|
|
@ -498,37 +642,74 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
|
|
|
|
|
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 family = resolvedFamily;
|
|
|
|
|
// Intent comes from the resolved morfo declaration (concrete
|
|
|
|
|
// `intent` field for non-polymorphic, or the polymorphic
|
|
|
|
|
// override / defaultSemantic 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 =
|
|
|
|
|
'intent' in action.semantic && action.semantic.intent !== undefined
|
|
|
|
|
? resolveIntent(action.semantic.intent, props)
|
|
|
|
|
: undefined;
|
|
|
|
|
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).
|
|
|
|
|
const channels = opts.channels ?? action.semantic.channels;
|
|
|
|
|
// (per-call channel slices win wholesale on conflict). a11y
|
|
|
|
|
// reduced-motion override has the LOWEST precedence so authors
|
|
|
|
|
// can still force motion when they know the context warrants it.
|
|
|
|
|
const channels =
|
|
|
|
|
opts.channels ?? action.semantic.channels ?? a11yChannelsOverride;
|
|
|
|
|
const morfoOverrides = action.semantic.overrides;
|
|
|
|
|
const overrides = opts.overrides
|
|
|
|
|
? ({ ...(morfoOverrides ?? {}), ...opts.overrides } as SemaSignatureOverride)
|
|
|
|
|
: morfoOverrides;
|
|
|
|
|
await sources.eventEngine.emit({
|
|
|
|
|
emittedId = await sources.eventEngine.emit({
|
|
|
|
|
target,
|
|
|
|
|
name: action.name,
|
|
|
|
|
family,
|
|
|
|
|
...(intent ? { intent } : {}),
|
|
|
|
|
...(hold !== undefined ? { hold } : {}),
|
|
|
|
|
...(channels !== undefined ? { channels } : {}),
|
|
|
|
|
...(overrides ? { overrides } : {})
|
|
|
|
|
...(overrides ? { overrides } : {}),
|
|
|
|
|
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) {
|
|
|
|
|
// Map family.signal/intent.threat → 'assertive', else polite.
|
|
|
|
|
// Uses the RESOLVED family/intent so polymorphic events get
|
|
|
|
|
// the right priority too.
|
|
|
|
|
const intent =
|
|
|
|
|
resolvedIntent !== undefined
|
|
|
|
|
? resolveIntent(resolvedIntent, snapshotRootProps())
|
|
|
|
|
: undefined;
|
|
|
|
|
const priority: 'polite' | 'assertive' =
|
|
|
|
|
resolvedFamily === 'signal' && (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 targetReg = registrations.get(action.target);
|
|
|
|
|
const target = opts.fallbackTarget ?? targetReg?.ref?.current ?? null;
|
|
|
|
|
if (target) sources.dom.focus(target);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (sequence === 'post') {
|
|
|
|
|
if (handler) await handler();
|
|
|
|
|
// `post` means the perceptual signal should see the resolved
|
|
|
|
|
@ -543,10 +724,33 @@ export function createSomaRuntime(morfo: Morfo, sources: SomaRuntimeSources): So
|
|
|
|
|
if (handler) await handler();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Effects on the affected parts re-derive structural attrs from the
|
|
|
|
|
// 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. 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 {
|
|
|
|
|
...(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 = registrations.get(part);
|
|
|
|
|
return reg?.ref?.current ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { part, partProps, keydown, trigger };
|
|
|
|
|
return { part, partProps, keydown, trigger, clearSignal, clearTarget, partRef };
|
|
|
|
|
}
|
|
|
|
|
|