feat(uix): persistence + a11ySemantic + polymorphic core (book §5.3, §6, §9)

Three book canon items codified in code:

**Persistence (libro §6.1)** — separa `hold` (perceptual min) de
`persistence` (lifecycle real):
- New `SignalPersistence = 'transient' | 'untilAction' | 'untilFix' | 'stateBound'`
- New `SEMA_HOLDS_BY_INTENT` canonical lookup table (`src/uix/sema/holds.ts`)
- `EngineSemantic.emit()` returns the resolved signal id; keeps projection
  alive past hold for non-transient. Exposes `clear(id)`, `clearTarget(target)`,
  `hasActive(id)`.
- `SomaRuntime.trigger()` returns `TriggerResult { id?, persistence? }`.
  Exposes `clearSignal(id)`, `clearTarget(target)`, `partRef(part)`.

**a11ySemantic (libro §9.1)**:
- New `MorfoA11ySemantic` (requiresPersistentTrace, requiresLiveRegion,
  requiresFocusMove, keyboardEquivalent, reducedMotionFallback) on MorfoEvent.
- `ActiveDom.prefersReducedMotion`: reactive tracker via media query
  (`src/arts/adom/reduced-motion.svelte.ts`).
- `ActiveUix.announce(msg, priority?, timeout?)`: lazy-created live region
  via `dom.writeNode`.
- `Soma.runtime()` auto-wires `sources.announce`.
- `SomaRuntime.trigger()` honors a11ySemantic after emit (live region, focus,
  reduced-motion fallback including `channels: []` for 'state').

**Polymorphic events (libro §5.3)** — ADITIVO sobre shape concreto:
- `allowedFamilies?: readonly SemaFamily[]` opcional en MorfoEventSemantic.
- Provider override via `runtime.trigger(name, { semantic })`.
- Default family of the morfo is implicitly allowed.
- New `SomaRuntimePolymorphicError`.
- New `isPolymorphicSemantic` type guard.

Synthetic `prewriteFixtureMorfo` (src/uix/morfo/test-fixtures.ts) decouples
compile + runtime tests from the production morfo catalogue.

671 tests pass in sema + morfo + soma + adom scopes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent e6eee766ee
commit 70f6f3bf0d

@ -18,6 +18,11 @@ import {
getSharedViewport,
type ViewportTracker
} from './viewport.svelte.js';
import {
createReducedMotionTracker,
getSharedReducedMotion,
type ReducedMotionTracker
} from './reduced-motion.svelte.js';
export type ActiveDomProps = {
breakpoints?: Active<Partial<Breakpoints>>;
@ -73,6 +78,17 @@ export interface ActiveDom {
breakpoints: Active<Breakpoints>;
viewport: { readonly width: number };
currentBreakpoint: Active<Breakpoint>;
/**
* Live read of the user's `(prefers-reduced-motion: reduce)` system
* preference. Reactive — consumers reading inside `$derived` /
* `$effect` automatically update when the user toggles the OS
* preference mid-session.
*
* In SSR / Node tests where `matchMedia` is unavailable, this is
* always `false`. SomaRuntime consults this when an event declares
* `a11ySemantic.reducedMotionFallback` (book §9).
*/
prefersReducedMotion: { readonly matches: boolean };
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
isAtLeast(breakpoint: Breakpoint): boolean;
matches(breakpoint: Breakpoint): boolean;
@ -182,6 +198,10 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
? getSharedViewport()
: createViewportTracker(props.targetWindow);
const motion: ReducedMotionTracker = props.shareViewport
? getSharedReducedMotion()
: createReducedMotionTracker(props.targetWindow);
const breakpoints = readableActive(() => ({
...BREAKPOINTS_DEFAULT,
...props.breakpoints?.current
@ -197,6 +217,12 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
}
};
const reducedMotionReadonly: { readonly matches: boolean } = {
get matches() {
return motion.matches;
}
};
let disposed = false;
function resolveStyleHost(host?: ActiveDomStyleHost): HTMLElement | null {
@ -284,6 +310,7 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
breakpoints,
viewport: viewportReadonly,
currentBreakpoint,
prefersReducedMotion: reducedMotionReadonly,
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, tracker.width, breakpoints.current);
},
@ -460,6 +487,7 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
if (disposed) return;
disposed = true;
tracker.dispose();
motion.dispose();
}
};
}

@ -0,0 +1,67 @@
import { isBrowser } from '$libs/dom';
/**
* Tracks the `(prefers-reduced-motion: reduce)` media query as a reactive
* boolean. Mirrors the `ViewportTracker` pattern: a per-instance tracker
* scoped to a target window, plus a process-wide singleton for callers
* that share state across the whole app.
*
* Reactive store: the tracked `matches` field is a Svelte `$state` cell,
* so consumers reading it inside a `$derived` / `$effect` react to OS
* changes (e.g. the user toggling the macOS "Reduce motion" preference
* mid-session) without polling.
*
* SSR / no-matchMedia safe: when `window.matchMedia` is absent (Node
* tests, SSR, ancient browsers), `matches` stays `false` forever and
* `dispose()` is a no-op.
*/
export interface ReducedMotionTracker {
readonly matches: boolean;
dispose: () => void;
}
// ── Shared singleton ────────────────────────────────────────────────────────
let shared: ReducedMotionTracker | undefined;
export function getSharedReducedMotion(): ReducedMotionTracker {
if (shared !== undefined) return shared;
shared = createReducedMotionTracker();
// Override dispose — singleton is process-lifetime.
const baseDispose = shared.dispose;
shared.dispose = () => {
void baseDispose;
// no-op
};
return shared;
}
// ── Per-instance tracker ────────────────────────────────────────────────────
const QUERY = '(prefers-reduced-motion: reduce)';
export function createReducedMotionTracker(targetWindow?: Window): ReducedMotionTracker {
const win = targetWindow ?? (isBrowser ? window : undefined);
const local = $state({ matches: false });
let detach: (() => void) | undefined;
if (win && typeof win.matchMedia === 'function') {
const mql = win.matchMedia(QUERY);
local.matches = mql.matches;
const onChange = (e: MediaQueryListEvent): void => {
local.matches = e.matches;
};
// addEventListener is the modern API. Older Safari shipped only
// addListener; ActiveDom no longer claims to support that vintage,
// but the .addListener fallback would go here if it ever did.
mql.addEventListener('change', onChange);
detach = () => mql.removeEventListener('change', onChange);
}
return {
get matches() {
return local.matches;
},
dispose: () => detach?.()
};
}

@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { createReducedMotionTracker } from '../reduced-motion.svelte';
describe('ReducedMotionTracker', () => {
it('initializes from the current matchMedia value', () => {
const tracker = createReducedMotionTracker();
// jsdom matchMedia defaults to matches=false for unknown queries.
expect(tracker.matches).toBe(false);
tracker.dispose();
});
it('dispose detaches the listener (idempotent)', () => {
const tracker = createReducedMotionTracker();
expect(() => tracker.dispose()).not.toThrow();
// second dispose is a no-op (no throw)
expect(() => tracker.dispose()).not.toThrow();
});
it('falls back to false when window.matchMedia is missing', () => {
const fakeWin = {} as unknown as Window;
const tracker = createReducedMotionTracker(fakeWin);
expect(tracker.matches).toBe(false);
// dispose without listeners is a no-op (no throw)
tracker.dispose();
});
it('reads from a custom target window', () => {
let attachedListeners = 0;
const fakeWin = {
matchMedia: (q: string) => {
expect(q).toBe('(prefers-reduced-motion: reduce)');
return {
matches: true,
media: q,
onchange: null,
addEventListener: () => {
attachedListeners++;
},
removeEventListener: () => {
attachedListeners--;
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false
} as MediaQueryList;
}
} as unknown as Window;
const tracker = createReducedMotionTracker(fakeWin);
expect(tracker.matches).toBe(true);
expect(attachedListeners).toBe(1);
tracker.dispose();
expect(attachedListeners).toBe(0);
});
});

@ -224,6 +224,11 @@ function createDisabledActiveDom(): ActiveDom {
}
},
currentBreakpoint,
prefersReducedMotion: {
get matches() {
return false;
}
},
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, 0, BREAKPOINTS_DEFAULT);
},
@ -372,9 +377,82 @@ class ActiveUixImpl implements ActiveUix {
return this.init.portal;
}
private readonly liveRegionIds = {
polite: 'uix-announce-polite',
assertive: 'uix-announce-assertive'
} as const;
private readonly liveRegionElements = new Map<'polite' | 'assertive', HTMLElement>();
announce(
message: string,
priority: 'polite' | 'assertive' = 'polite',
timeout: number = 5000
): void {
let dom: ActiveDom;
try {
dom = this.dom;
} catch {
return;
}
// Defensive: if the disabled DOM stub got through, dom.writeNode is
// a no-op that returns undefined — handled below.
let region = this.liveRegionElements.get(priority);
if (!region) {
const id = this.liveRegionIds[priority];
const node = dom.writeNode(id, {
tag: 'div',
attrs: {
id,
role: priority === 'assertive' ? 'alert' : 'status',
'aria-live': priority,
'aria-atomic': 'true',
// WAI-cookbook sr-only style — keeps the node off-screen
// without `display:none` (which suppresses AT announcement).
style:
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;'
}
});
if (!node) return;
region = node;
this.liveRegionElements.set(priority, region);
}
// Some AT engines won't re-announce when the same string lands in
// the same region. Tiny zero-width-space toggle works around it.
const zwsp = '​';
region.textContent =
region.textContent === message ? message + zwsp : message;
if (timeout > 0 && message.length > 0) {
this.timers.schedule(
`uix:announce:${priority}`,
timeout,
() => {
if (region) region.textContent = '';
},
{ replace: true, meta: { component: 'active-uix', action: 'clear-announce' } }
);
}
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// Clear any lazily-created announce regions before the dom service
// goes away. Each region was added via `dom.writeNode(id, ...)` so
// the dom owns the cleanup; calling `removeNode(id)` is the
// symmetric undo.
if (this.liveRegionElements.size > 0) {
try {
const dom = this.dom;
for (const priority of this.liveRegionElements.keys()) {
dom.removeNode(this.liveRegionIds[priority]);
}
} catch {
// dom disabled or already torn down — nothing to clean up.
}
this.liveRegionElements.clear();
}
this.init.detachLangsPrefs?.();
// Standalone: tear down every service we instantiated. Reverse
// dependency order: format → events/sema → dom → langs → prefs →

@ -115,6 +115,30 @@ export interface ActiveUix {
readonly prefs: ActivePrefs;
readonly portal: string | HTMLElement | undefined;
/**
* Push a text message to a shared aria-live region for screen reader
* announcement (book §9.1 — `a11ySemantic.requiresLiveRegion`).
*
* Lazily creates a visually-hidden `<div role="status" aria-live="...">`
* inside the document body the first time the corresponding priority
* is used. Each priority has its own region; consecutive messages
* replace the previous text.
*
* Lower-level than `<Announce>` soma — `Announce` exposes a Svelte
* component with snippet props and per-instance customization. This is
* the implicit live region that SomaRuntime can fire when a morfo
* event declares `a11ySemantic.requiresLiveRegion: true`, without
* forcing the app to mount an explicit Announce in its tree.
*
* When `dom` is unavailable (SSR / disabled-dom mode), this is a no-op.
*
* @param message — text to announce. Empty string clears the region.
* @param priority — 'polite' (default) or 'assertive'.
* @param timeout — clear the region after N ms. Default 5000.
* Pass 0 to disable auto-clear.
*/
announce(message: string, priority?: 'polite' | 'assertive', timeout?: number): void;
/**
* Underlying `ActiveApp` — present ONLY when `ActiveUix` was
* obtained via `attachActiveUix(app)`. In standalone mode

@ -8,6 +8,7 @@ import { toggleMorfo } from './components/toggle'
import { switchMorfo } from './components/switch'
import { toastMorfo } from './components/toast'
import { accordionMorfo } from './components/accordion'
import { prewriteFixtureMorfo } from './test-fixtures'
describe('compileMorfo — parts', () => {
it('walks every part into byKebab + order, including nested ones', () => {
@ -212,18 +213,27 @@ describe('compileMorfo — actions', () => {
it('compiles morfo events into actions.byName indexed by name', () => {
const compiled = compileMorfo(dialogMorfo)
expect(compiled.actions.byName.has('open')).toBe(true)
expect(compiled.actions.byName.has('close-cancel')).toBe(true)
expect(compiled.actions.byName.has('close-after-fail')).toBe(true)
// Dialog uses the polymorphic close (book §5.3) — one event named
// 'close' replaces the prior five close-* events. Provider's
// dismissWith concretes the cause via opts.semantic + imperative
// data-last-action.
expect(compiled.actions.byName.has('close')).toBe(true)
})
it('extracts target part kebab from the partRef', () => {
const compiled = compileMorfo(dialogMorfo)
const close = compiled.actions.byName.get('close-cancel')!
const close = compiled.actions.byName.get('close')!
expect(close.target).toBe('content')
})
it('preserves prewrite array for transient markers (data-last-action)', () => {
const compiled = compileMorfo(dialogMorfo)
// Synthetic fixture (`prewriteFixtureMorfo`) — decouples this test
// from the production morfo catalogue. Dialog / Drawer / Popover
// and the picker family migrated to polymorphic close, so they no
// longer carry `prewrite`. The fixture validates the compiler's
// contract for the prewrite shape independently of whichever
// morfos happen to use it today.
const compiled = compileMorfo(prewriteFixtureMorfo)
const close = compiled.actions.byName.get('close-cancel')!
expect(close.prewrite.length).toBeGreaterThan(0)
expect(close.prewrite[0].attr).toBe('data-last-action')
@ -239,7 +249,7 @@ describe('compileMorfo — actions', () => {
expect(compiled.actions.byName.size).toBe(1)
expect(compiled.actions.byName.has('commit-toggle')).toBe(true)
const action = compiled.actions.byName.get('commit-toggle')!
expect(action.semantic.family).toBe('commit')
expect('family' in action.semantic ? action.semantic.family : null).toBe('commit')
const intent = 'intent' in action.semantic ? action.semantic.intent : null
expect(intent).toMatchObject({
fromProp: 'intent',

@ -28,6 +28,7 @@
import type {
Morfo,
MorfoA11ySemantic,
MorfoAriaEntry,
MorfoArchetype,
MorfoCondition,
@ -178,6 +179,7 @@ export interface ActionPlan {
readonly name: string
readonly target: string // kebab
readonly semantic: MorfoEventSemantic
readonly a11ySemantic: MorfoA11ySemantic | undefined
readonly hold: SemaDurationSpec | undefined
readonly mode: SemaMode | undefined
readonly regime: SemaRegime | undefined
@ -611,6 +613,7 @@ function compileEvent(event: MorfoEvent): ActionPlan {
name: event.name,
target: event.semantic.target.target,
semantic: event.semantic,
a11ySemantic: event.a11ySemantic,
hold: event.hold,
mode: event.mode,
regime: event.regime,

@ -23,12 +23,13 @@ export type {
MorfoFocus,
MorfoSemanticIntent,
MorfoEventSemantic,
MorfoA11ySemantic,
MorfoEvent,
MorfoPart,
Morfo
} from './types';
export { v } from './types';
export { v, isPolymorphicSemantic } from './types';
// Pure morfo→attrs resolver — walks `data` / `aria` declarations and
// returns a flat attribute map. No reactivity, no DOM. Used by the

@ -209,20 +209,40 @@ const partRefSchema = object({
const sequenceSchema = union(literal('pre'), literal('coincident'), literal('post'));
const persistenceSchema = union(
literal('transient'),
literal('untilAction'),
literal('untilFix'),
literal('stateBound')
);
const semaFamilySchema = union(
semaIntentOptionalFamilySchema,
semaIntentExpectedFamilySchema
);
// Polymorphism (book §5.3) is ADDITIVE on the canonical event shape:
// the morfo declares its default `family` + `intent` + `verb` as usual
// and may add `allowedFamilies` to authorize providers to override the
// family at trigger time.
const eventSemanticSchema = union(
object({
family: semaIntentOptionalFamilySchema,
target: partRefSchema,
intent: optional(union(semaIntentSchema, semanticIntentSchema)),
verb: optional(string()),
sequence: optional(sequenceSchema)
sequence: optional(sequenceSchema),
persistence: optional(persistenceSchema),
allowedFamilies: optional(array(semaFamilySchema))
}),
object({
family: semaIntentExpectedFamilySchema,
target: partRefSchema,
intent: union(semaIntentSchema, semanticIntentSchema),
verb: optional(string()),
sequence: optional(sequenceSchema)
sequence: optional(sequenceSchema),
persistence: optional(persistenceSchema),
allowedFamilies: optional(array(semaFamilySchema))
})
);
@ -244,9 +264,25 @@ const commitSchema = object({
value: string()
});
const reducedMotionFallbackSchema = union(
literal('state'),
literal('text'),
literal('focus'),
literal('none')
);
const a11ySemanticSchema = object({
requiresPersistentTrace: optional(boolean()),
requiresLiveRegion: optional(boolean()),
requiresFocusMove: optional(boolean()),
keyboardEquivalent: optional(boolean()),
reducedMotionFallback: optional(reducedMotionFallbackSchema)
});
const eventSchema = object({
name: string(),
semantic: eventSemanticSchema,
a11ySemantic: optional(a11ySemanticSchema),
mode: optional(union(literal('blocking'), literal('advisory'))),
regime: optional(
union(literal('replace'), literal('collapse'), literal('lock'), literal('queue'))
@ -630,6 +666,8 @@ function validateInvariants(morfo: Morfo): void {
const declared = new Set(dataLastAction.values);
const written = prewriteDLAByPart.get(part.kebab) ?? new Set<string>();
// Direction 1 (kept strict): any value an event prewrites must be
// declared in values[]. Prevents typos and orphan prewrites.
for (const value of written) {
if (!declared.has(value)) {
throw new MorfoInvariantError(
@ -638,13 +676,14 @@ function validateInvariants(morfo: Morfo): void {
}
}
for (const value of declared) {
if (!written.has(value)) {
throw new MorfoInvariantError(
`part "${part.kebab}" declares data-last-action value "${value}" but no event prewrites it`
);
}
}
// Direction 2 (loosened — book §5.3 polymorphism): values declared in
// `values[]` may also be set IMPERATIVELY by the provider (e.g.
// `runtime.partRef(part)` + `dom.apply`) when a polymorphic event
// can't bind a single `prewrite` per call. Requiring every value to
// have a matching event prewrite breaks the polymorphic close
// pattern (one `close` event, multiple data-last-action values set
// by the provider). `data-last-action.values[]` remains the closed
// enum of valid values — eidos and lint still consume it.
}
}

@ -0,0 +1,86 @@
/**
* Synthetic morfos for tests.
*
* These fixtures exist so the morfo / runtime test suites don't depend on
* production component morfos. When a production morfo's shape evolves
* (e.g. Dialog / Drawer / Popover / picker family migrated to polymorphic
* close in 2026-05-27), the tests should NOT have to migrate too — they
* validate the compiler / runtime contract, not the component catalogue.
*
* Each fixture is named after the surface it exercises.
*
* SCOPE: test-only. Never imported by production code. The presence of
* this file under `src/uix/morfo/` is intentional — keeps the fixtures
* next to the contracts they exercise without polluting the morfo
* package's public exports (this file is not re-exported from
* `index.ts`).
*/
import type { Morfo } from './types';
import { v } from './types';
/**
* Minimal morfo exercising `prewrite` writing to a `data-last-action`
* attr with a declared `values[]` enum. Use this fixture in compile +
* runtime tests that check prewrite behavior. Production morfos may or
* may not have prewrite at any given moment; the synthetic fixture
* ensures the test contract is stable.
*
* Single event `close-cancel` — chosen to mirror the historical Dialog
* shape so test assertions read naturally against a "cancel" cause.
*/
export const prewriteFixtureMorfo = {
name: 'PrewriteFixture',
kebab: 'prewrite-fixture',
scope: ['soma'],
events: [
{
name: 'close-cancel',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('content'),
sequence: 'pre'
},
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'virtual',
defaultElement: 'none',
optional: false,
data: [],
aria: []
},
{
name: 'Content',
kebab: 'content',
archetype: 'content',
kind: 'public',
defaultElement: 'div',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
attr: 'data-last-action',
values: ['cancelled'],
severity: 'optional'
}
],
aria: []
}
]
} as const satisfies Morfo;

@ -32,8 +32,10 @@ import type {
SemaMode,
SemaRegime,
SemaScope,
SemaFamily,
IntentExpectedFamily,
IntentOptionalFamily
IntentOptionalFamily,
SignalPersistence
} from '../sema/types';
import type { SemaChannelId, SemaSignatureOverride } from '../sema/channels';
import type { SemaDurationSpec } from '../sema/durations';
@ -320,6 +322,21 @@ export interface MorfoSemanticIntent {
*/
export type MorfoEventSequence = 'pre' | 'coincident' | 'post';
/**
* Type guard — true when a morfo event's semantic is polymorphic (book
* §5.3): it declares `allowedFamilies` on top of the canonical
* `family` + `intent` shape, allowing providers to concrete to an
* alternative family at trigger time.
*/
export function isPolymorphicSemantic(
semantic: MorfoEventSemantic
): semantic is MorfoEventSemantic & { allowedFamilies: readonly SemaFamily[] } {
return (
'allowedFamilies' in semantic &&
Array.isArray((semantic as { allowedFamilies?: unknown }).allowedFamilies)
);
}
/**
* Semantic classification of a component event.
*
@ -339,6 +356,39 @@ export type MorfoEventSequence = 'pre' | 'coincident' | 'post';
*
* Edit `SEMA_INTENT_POLICY` to change classification — the type updates.
*/
/**
* Polymorphism (book §5.3). A morfo event may declare CAPACITY for a set
* of alternative families on top of its canonical declaration:
*
* events: [{
* name: 'close',
* semantic: {
* family: 'shift', // default family
* verb: 'exit-mode',
* target: v.partRef('content'),
* allowedFamilies: ['shift', 'commit', 'emerge'] // polymorphic capacity
* }
* }]
*
* The morfo's `family` + `intent` + `verb` + `sequence` ARE the default
* concretion. `allowedFamilies` opens the door to alternatives. The
* provider concretes at trigger time:
*
* provider (default close):
* runtime.trigger('close') // → emits as { family: 'shift', verb: 'exit-mode' }
*
* provider (close with unsaved changes):
* runtime.trigger('close', {
* semantic: { family: 'commit', verb: 'discard', intent: 'loss' }
* }) // → emits as that concrete shape
*
* The override is validated against `allowedFamilies` at runtime —
* passing a family not in the allowlist raises `SomaRuntimePolymorphicError`.
*
* Backwards compatible: existing morfos that don't declare
* `allowedFamilies` work unchanged. Their event family is fixed at
* declaration time.
*/
export type MorfoEventSemantic = (
| {
family: IntentOptionalFamily;
@ -346,6 +396,14 @@ export type MorfoEventSemantic = (
intent?: Intent | MorfoSemanticIntent;
verb?: string;
sequence?: MorfoEventSequence;
/**
* Alternative families the provider may concrete to at trigger
* time (book §5.3). Optional — most events are not polymorphic.
* The morfo's own `family` is treated as the default and
* implicitly part of the allowed set; redeclaring it here is
* fine but redundant.
*/
allowedFamilies?: readonly SemaFamily[];
}
| {
family: IntentExpectedFamily;
@ -353,6 +411,7 @@ export type MorfoEventSemantic = (
intent: Intent | MorfoSemanticIntent;
verb?: string;
sequence?: MorfoEventSequence;
allowedFamilies?: readonly SemaFamily[];
}
) & {
/**
@ -377,8 +436,92 @@ export type MorfoEventSemantic = (
* overrides: { sound: { sampleUrl: '/sounds/dialog-fail.wav' } }
*/
overrides?: SemaSignatureOverride;
/**
* Signal lifecycle policy (book cap. 24 §6). Default `'transient'` —
* engine auto-clears `data-event-*` attrs after the hold elapses.
*
* | Value | Use when |
* |---------------|-------------------------------------------------------|
* | `transient` | One-shot pulse (most events). Default. |
* | `untilAction` | signal.alert + threat — stays until user acknowledges.|
* | `untilFix` | signal.warn + risk — stays until problem corrected. |
* | `stateBound` | sustain / caps-lock indicator — lifecycle = state. |
*
* For non-transient values, the soma provider OWNS the cleanup: it
* must call `runtime.clearSignal(id)` or `runtime.clearTarget(target)`
* when the relevant condition is met.
*
* See `src/uix/sema/holds.ts` for the canonical book §6.2 table that
* maps family + intent to the recommended persistence.
*/
persistence?: SignalPersistence;
};
/**
* Accessibility semantics for a morfo event (book §9.1). Declares the
* cross-modal commitments the event must honor so users on assistive
* technologies receive the same perceptual content as sighted users.
*
* The SomaRuntime reads this contract at trigger time and:
* - pushes a text alternative through `ActiveUix.announce(...)` when
* `requiresLiveRegion` is set;
* - moves focus to the event's target when `requiresFocusMove` is set;
* - applies `reducedMotionFallback` when the user prefers reduced
* motion (see `prefersReducedMotion` on ActiveDom).
*
* Fields are independent flags so authors can opt into the right axes
* without enabling unrelated behavior. Per book §9.2 examples:
*
* - signal.warn + risk → requiresPersistentTrace + reducedMotionFallback='text'
* - signal.alert + threat → requiresPersistentTrace + requiresLiveRegion + requiresFocusMove
* - handle (drag) → keyboardEquivalent
* - commit.delete + loss → requiresPersistentTrace
*/
export interface MorfoA11ySemantic {
/**
* Whether the signal must leave a trace the user can return to after
* the perceptual hold elapses. Implies the consumer surfaces a
* persistent UI affordance (a banner, an inline error, an undo
* toast). Lint / docs check this at the morfo level; the runtime
* doesn't enforce it directly because the trace lives in app code,
* not in the perceptual signal itself.
*/
requiresPersistentTrace?: boolean;
/**
* Whether the event's text content must be sent to a polite/assertive
* live region so screen readers announce it. The SomaRuntime calls
* `ActiveUix.announce(...)` when this is true.
*/
requiresLiveRegion?: boolean;
/**
* Whether keyboard focus must move to the event's target as part of
* the perceptual signal. SomaRuntime calls `dom.focus(target)` after
* the emit.
*/
requiresFocusMove?: boolean;
/**
* Whether the underlying interaction needs a keyboard equivalent.
* Mostly applies to handle.* (drag interactions) per book §9.2. Not
* enforced by the runtime; advisory contract checked by lint/docs
* and by smoke tests that walk the morfo.
*/
keyboardEquivalent?: boolean;
/**
* What to substitute for motion when the user prefers reduced motion.
*
* - `'state'` — emit only the state attrs (data-state, etc.); skip
* motion-tied attrs (`data-event-phase`).
* - `'text'` — push the event's text content via live region.
* - `'focus'` — move focus to convey the event happened.
* - `'none'` — no alternative; the motion is incidental.
*
* When omitted, the runtime treats the event as motion-incidental
* (no fallback). Components whose motion is load-bearing (overlay
* entrances, alert pulses) MUST declare a fallback.
*/
reducedMotionFallback?: 'state' | 'text' | 'focus' | 'none';
}
/**
* Runtime event contract authored in Morfo.
*
@ -389,6 +532,12 @@ export type MorfoEventSemantic = (
export interface MorfoEvent {
name: string;
semantic: MorfoEventSemantic;
/**
* Accessibility commitments the event must honor (book §9.1). When
* omitted, the runtime applies no extra a11y behavior beyond the
* structural contract. See {@link MorfoA11ySemantic}.
*/
a11ySemantic?: MorfoA11ySemantic;
/**
* Per-component override for the visual channel's signal hold (how long
* `data-event-*` attrs live in the DOM). Either a label from the

@ -317,4 +317,157 @@ describe('EngineSemantic', () => {
// Pack's pitch isn't touched by the app rule — survives.
expect(captured?.sound?.pitch).toBe(720)
})
// ── Persistence (book §6.1) ──────────────────────────────────────────────
describe('persistence', () => {
it('emit returns the resolved signal id', async () => {
const engine = new EngineSemantic({ visual: false })
engine.register(makeChannel('visual'))
const id = await engine.emit({ target: makeTarget(), name: 'announce' })
expect(id).toMatch(/^sig-\d+$/)
})
it('emit returns caller-provided id verbatim', async () => {
const engine = new EngineSemantic({ visual: false })
engine.register(makeChannel('visual'))
const id = await engine.emit({
target: makeTarget(),
name: 'announce',
id: 'caller-supplied'
})
expect(id).toBe('caller-supplied')
})
it('transient signal auto-cleans the projection in finally', async () => {
const cleanup = vi.fn()
const ch: Channel = {
id: 'visual',
prepare: () => ({ cleanup }),
handle: async () => {}
}
const engine = new EngineSemantic({ visual: false })
engine.register(ch)
const id = await engine.emit({
target: makeTarget(),
name: 'announce',
persistence: 'transient'
})
expect(cleanup).toHaveBeenCalledTimes(1)
expect(engine.hasActive(id)).toBe(false)
})
it('untilAction signal DOES NOT auto-clean — caller owns cleanup', async () => {
const cleanup = vi.fn()
const ch: Channel = {
id: 'visual',
prepare: () => ({ cleanup }),
handle: async () => {}
}
const engine = new EngineSemantic({ visual: false })
engine.register(ch)
const id = await engine.emit({
target: makeTarget(),
name: 'signal-alert',
family: 'signal',
intent: 'threat',
persistence: 'untilAction'
})
expect(cleanup).not.toHaveBeenCalled()
expect(engine.hasActive(id)).toBe(true)
})
it('untilFix signal stays active until clear(id)', async () => {
const cleanup = vi.fn()
const ch: Channel = {
id: 'visual',
prepare: () => ({ cleanup }),
handle: async () => {}
}
const engine = new EngineSemantic({ visual: false })
engine.register(ch)
const id = await engine.emit({
target: makeTarget(),
name: 'signal-warn-invalid',
family: 'signal',
intent: 'risk',
persistence: 'untilFix'
})
expect(engine.hasActive(id)).toBe(true)
const ok = engine.clear(id)
expect(ok).toBe(true)
expect(cleanup).toHaveBeenCalledTimes(1)
expect(engine.hasActive(id)).toBe(false)
})
it('clear returns false for unknown id', () => {
const engine = new EngineSemantic({ visual: false })
expect(engine.clear('does-not-exist')).toBe(false)
})
it('stateBound signal stays active until clearTarget(element)', async () => {
const cleanup1 = vi.fn()
const cleanup2 = vi.fn()
const target1 = makeTarget()
const target2 = makeTarget()
const ch: Channel = {
id: 'visual',
prepare: (signal) => ({
cleanup: signal.target === target1 ? cleanup1 : cleanup2
}),
handle: async () => {}
}
const engine = new EngineSemantic({ visual: false })
engine.register(ch)
await engine.emit({
target: target1,
name: 'signal-notify-caps-state',
family: 'signal',
intent: 'neutral',
persistence: 'stateBound'
})
await engine.emit({
target: target2,
name: 'signal-notify-caps-state',
family: 'signal',
intent: 'neutral',
persistence: 'stateBound'
})
const cleared = engine.clearTarget(target1)
expect(cleared).toBe(1)
expect(cleanup1).toHaveBeenCalledTimes(1)
expect(cleanup2).not.toHaveBeenCalled()
})
it('clearTarget returns 0 when no active signals match', () => {
const engine = new EngineSemantic({ visual: false })
expect(engine.clearTarget(makeTarget())).toBe(0)
})
it('dispose cleans up lingering persistent signals', async () => {
const cleanup = vi.fn()
const ch: Channel = {
id: 'visual',
prepare: () => ({ cleanup }),
handle: async () => {}
}
const engine = new EngineSemantic({ visual: false })
engine.register(ch)
await engine.emit({
target: makeTarget(),
name: 'signal-alert',
family: 'signal',
intent: 'threat',
persistence: 'untilAction'
})
expect(cleanup).not.toHaveBeenCalled()
engine.dispose()
expect(cleanup).toHaveBeenCalledTimes(1)
})
})
})

@ -84,6 +84,11 @@ export interface EngineSemanticOptions {
dom?: DomApplier | (DomApplier & SoundChannelDom);
}
interface PersistentSignalEntry {
readonly preparations: readonly ChannelPreparation[];
readonly target: HTMLElement | undefined;
}
export class EngineSemantic {
private readonly channels = new Map<string, Channel>();
private nextSignalId = 0;
@ -92,6 +97,14 @@ export class EngineSemantic {
private readonly cascade: readonly SemaCascadeRule[];
private readonly logger: Logger | undefined;
/**
* Active non-transient signals — projection handles kept alive past the
* hold for `untilAction` / `untilFix` / `stateBound`. Cleared via
* `clear(id)` or `clearTarget(target)`. Per book §6.1: persistence is
* caller-managed; the engine only holds the cleanup handle.
*/
private readonly active = new Map<string, PersistentSignalEntry>();
constructor(opts: EngineSemanticOptions = {}) {
this.logger = opts.logger;
@ -175,11 +188,9 @@ export class EngineSemantic {
* Sequential strict: the caller's structural commit happens AFTER
* cleanup. State change is strictly ordered after the perceptual window.
*/
async emit(signal: SemanticSignal): Promise<void> {
const enriched: SemanticSignal = {
...signal,
id: signal.id ?? `sig-${this.nextSignalId++}`
};
async emit(signal: SemanticSignal): Promise<string> {
const id = signal.id ?? `sig-${this.nextSignalId++}`;
const enriched: SemanticSignal = { ...signal, id };
// Explicit silence (capa 4 morfo override): `signal.channels: []`
// skips EVERYTHING — no projection, no dispatch, no hold. The morfo
@ -188,7 +199,7 @@ export class EngineSemantic {
// degenerate case) still go through dispatch so individual channels
// can self-skip via their own activeChannels check.
if (signal.channels !== undefined && signal.channels.length === 0) {
return;
return id;
}
const preparations: ChannelPreparation[] = [];
@ -197,6 +208,10 @@ export class EngineSemantic {
if (handle) preparations.push(handle);
}
const persistence = signal.persistence ?? 'transient';
const shouldAutoCleanup = persistence === 'transient';
let cleanedUp = false;
try {
const effective = resolveSignature(enriched, {
map: this.map,
@ -228,10 +243,73 @@ export class EngineSemantic {
await visualChannel.handle(enriched, effective);
}
} finally {
for (const handle of preparations.reverse()) {
if (shouldAutoCleanup) {
for (const handle of [...preparations].reverse()) {
handle.cleanup();
}
cleanedUp = true;
}
}
// Persistent signals: keep projection alive past the hold. Caller
// (typically a soma provider) clears via `clear(id)` or
// `clearTarget(target)` when the relevant condition is met. If the
// caller never clears, the projection lingers until dispose() — by
// design: persistence is caller-managed per book §6.1.
if (!cleanedUp) {
this.active.set(id, { preparations, target: signal.target });
}
return id;
}
/**
* Clear an active persistent signal by id. Returns `true` if the signal
* was found and cleared, `false` if it didn't exist (already cleared,
* was transient, or never emitted).
*
* Call this from the caller that originally emitted the signal when
* the underlying condition is met:
* - `untilAction` — user acknowledged the alert.
* - `untilFix` — the validation error was corrected.
* - `stateBound` — the state ended.
*/
clear(id: string): boolean {
const entry = this.active.get(id);
if (!entry) return false;
this.active.delete(id);
for (const handle of [...entry.preparations].reverse()) {
handle.cleanup();
}
return true;
}
/**
* Clear all active persistent signals whose `target` matches the given
* element. Returns the count of signals cleared. Useful when a single
* gesture invalidates multiple persistent signals on the same surface
* (e.g. a form with three field warnings: one form-valid clears all).
*/
clearTarget(target: HTMLElement): number {
let count = 0;
for (const [id, entry] of this.active) {
if (entry.target === target) {
this.active.delete(id);
for (const handle of [...entry.preparations].reverse()) {
handle.cleanup();
}
count++;
}
}
return count;
}
/**
* Whether a persistent signal id is currently active. Read-only helper
* for diagnostics and tests; production code should not branch on this.
*/
hasActive(id: string): boolean {
return this.active.has(id);
}
getChannel(id: string): Channel | undefined {
@ -239,6 +317,15 @@ export class EngineSemantic {
}
dispose(): void {
// Clean up any lingering persistent signal projections before tearing
// down channels, so DOM doesn't keep stale data-event-* attrs.
for (const entry of this.active.values()) {
for (const handle of [...entry.preparations].reverse()) {
handle.cleanup();
}
}
this.active.clear();
for (const channel of this.channels.values()) {
channel.dispose?.();
}

@ -16,9 +16,16 @@ export type {
SemaEventExtensions,
SemaActionEvent,
SemaAttrWrite,
SemaCommit
SemaCommit,
SignalPersistence
} from './types';
export {
SEMA_HOLDS_BY_INTENT,
resolveHoldsByIntent,
type HoldsPolicy
} from './holds';
export { SEMA_FAMILY_POLICY } from './types';
export {

@ -0,0 +1,80 @@
/**
* Tests for SEMA_HOLDS_BY_INTENT and resolveHoldsByIntent.
*
* The map encodes the book Cap. 24 §6.2 lookup: for a given (family,
* intent), what is the canonical perceptual hold + lifecycle policy?
*/
import { describe, expect, it } from 'vitest';
import { SEMA_HOLDS_BY_INTENT, resolveHoldsByIntent } from './holds';
describe('SEMA_HOLDS_BY_INTENT', () => {
it('covers all 8 canonical families', () => {
const families = Object.keys(SEMA_HOLDS_BY_INTENT);
expect(families).toEqual(
expect.arrayContaining([
'contact',
'commit',
'signal',
'handle',
'emerge',
'shift',
'sustain',
'delegate'
])
);
expect(families).toHaveLength(8);
});
it('every family has a _default policy', () => {
for (const [family, entry] of Object.entries(SEMA_HOLDS_BY_INTENT)) {
expect(entry._default, `${family}._default`).toBeDefined();
expect(entry._default.hold, `${family}._default.hold`).toBeDefined();
expect(entry._default.persistence, `${family}._default.persistence`).toBeDefined();
}
});
it('signal + risk = untilFix per book §6.2', () => {
const policy = resolveHoldsByIntent('signal', 'risk');
expect(policy?.persistence).toBe('untilFix');
});
it('signal + threat = untilAction per book §6.2', () => {
const policy = resolveHoldsByIntent('signal', 'threat');
expect(policy?.persistence).toBe('untilAction');
});
it('signal + neutral = transient per book §6.2', () => {
const policy = resolveHoldsByIntent('signal', 'neutral');
expect(policy?.persistence).toBe('transient');
});
it('sustain = stateBound regardless of intent', () => {
expect(resolveHoldsByIntent('sustain')?.persistence).toBe('stateBound');
expect(resolveHoldsByIntent('sustain', 'neutral')?.persistence).toBe('stateBound');
});
});
describe('resolveHoldsByIntent', () => {
it('falls back to _default when intent is undefined', () => {
const policy = resolveHoldsByIntent('commit');
expect(policy).toEqual(SEMA_HOLDS_BY_INTENT.commit._default);
});
it('falls back to _default when intent has no per-intent entry', () => {
// commit only declares per-intent for `fulfill`. Other intents
// resolve to `_default`.
const policy = resolveHoldsByIntent('commit', 'affirm');
expect(policy).toEqual(SEMA_HOLDS_BY_INTENT.commit._default);
});
it('returns the per-intent override when present', () => {
const policy = resolveHoldsByIntent('commit', 'fulfill');
expect(policy).toEqual(SEMA_HOLDS_BY_INTENT.commit.fulfill);
});
it('returns undefined for an unknown family', () => {
const policy = resolveHoldsByIntent(undefined);
expect(policy).toBeUndefined();
});
});

@ -0,0 +1,136 @@
/**
* SEMA_HOLDS_BY_INTENT — canonical lookup of perceptual hold + persistence
* by family + intent, derived from book *Diseñando lo que ocurre* cap. 24
* §6.2 (Holds por familia e intent).
*
* Two orthogonal concepts per the canon §6.1:
*
* - **hold** — perceptual MINIMUM display time. Independent of motion or
* animation timing; this is the duration the signal MUST be visible to
* register as a signal.
* - **persistence** — lifecycle policy. `transient` = auto-cleared after
* hold; `untilAction` / `untilFix` / `stateBound` = caller-managed
* lifecycle.
*
* The book's §6.2 table mixes the two — it gives a number when the
* persistence is transient (the number IS the hold), and a string when
* the persistence is non-transient (the hold is irrelevant beyond the
* minimum display). This file separates them so types are honest.
*
* Used by:
* - documentation tooling (lookup canonical defaults per family+intent)
* - authors as a reference when declaring `MorfoEvent.persistence`
*
* NOT auto-applied by the runtime — `SomaRuntime.trigger` defaults to
* `'transient'` when the morfo doesn't declare persistence. This avoids
* silent behavior changes for components that don't yet opt in. Authors
* who want the canonical policy must declare it explicitly on the morfo
* event; `resolveHoldsByIntent(family, intent)` is provided so they don't
* have to guess.
*/
import type { Intent } from '../intent';
import type { SemaDurationSpec } from './durations';
import type { SemaFamily, SignalPersistence } from './types';
/**
* Canonical hold + persistence policy per family + intent.
*
* Encoded as a sparse map: each family's `_default` is the policy for
* absent / neutral intent; per-intent entries override. Intents NOT
* present fall back to `_default`.
*
* Reading: `SEMA_HOLDS_BY_INTENT.signal.threat = { hold: 'brief', persistence: 'untilAction' }`
* — a `signal.alert + threat` signal must be perceptible for at LEAST
* `brief` (240 ms) and persists in the DOM until the user acknowledges
* (caller clears it).
*
* Tables match the book §6.2 verbatim where possible. Where the book
* specifies a number (e.g. `commit.fulfill: 280`) we map to the closest
* label on the perceptual scale (`noticed` = 600 ms; `brief` = 240 ms).
* Numbers expressible as a label are preferred so any future scale
* adjustment propagates automatically.
*/
export const SEMA_HOLDS_BY_INTENT = {
contact: {
_default: { hold: 'glimpse', persistence: 'transient' }
},
emerge: {
_default: { hold: 'brief', persistence: 'transient' }
},
shift: {
_default: { hold: 'noticed', persistence: 'transient' }
},
commit: {
_default: { hold: 'brief', persistence: 'transient' },
fulfill: { hold: 'noticed', persistence: 'transient' }
},
signal: {
// §6.2: signal.neutral → 240 ms, transient.
_default: { hold: 'brief', persistence: 'transient' },
// §6.2: signal.risk → untilFix. Hold = minimum display so a
// quickly-fixed warning still flashes for perceptibility.
risk: { hold: 'brief', persistence: 'untilFix' },
// §6.2: signal.threat → untilAction.
threat: { hold: 'brief', persistence: 'untilAction' },
// §6.2: signal.loss → 400 ms, transient (loss is consumed grief —
// it registers and goes; the trace lives in undo, not in the
// signal itself).
loss: { hold: 'noticed', persistence: 'transient' }
},
handle: {
_default: { hold: 'brief', persistence: 'transient' }
},
sustain: {
// §6.3 verbatim: sustain has no hold. State-bound — lasts while
// the process is active. The `hold` here is the minimum display
// for the perceptual transition into sustain mode.
_default: { hold: 'noticed', persistence: 'stateBound' }
},
delegate: {
// Cap. 29 — delegate is structural, signal weight similar to
// sustain. Transient by default; specific compositions
// (delegate + signal.warn, etc.) get their persistence from the
// signal partner.
_default: { hold: 'noticed', persistence: 'transient' }
}
} as const satisfies Record<
SemaFamily,
{ _default: HoldsPolicy } & Partial<Record<Intent, HoldsPolicy>>
>;
/**
* Hold + persistence pair resolved for a given family + intent. Authors
* declare this verbatim on `MorfoEvent` to opt into the canonical book
* §6.2 policy; otherwise `SomaRuntime` defaults to `'transient'`.
*/
export interface HoldsPolicy {
hold: SemaDurationSpec;
persistence: SignalPersistence;
}
/**
* Lookup the canonical hold + persistence for a given family + intent.
*
* - Family + specific intent declared → uses the intent's override.
* - Family + neutral / unspecified intent → uses the family's `_default`.
* - Family unknown → returns `undefined` (caller should fall back to the
* engine's default behavior).
*
* Pure function — no caching needed, lookup is constant time.
*/
export function resolveHoldsByIntent(
family: SemaFamily | undefined,
intent?: Intent | undefined
): HoldsPolicy | undefined {
if (!family) return undefined;
const entry = SEMA_HOLDS_BY_INTENT[family] as
| ({ _default: HoldsPolicy } & Partial<Record<Intent, HoldsPolicy>>)
| undefined;
if (!entry) return undefined;
if (intent && intent in entry) {
const perIntent = entry[intent];
if (perIntent) return perIntent;
}
return entry._default;
}

@ -10,7 +10,7 @@
import type { SemaChannelId, SemaSignatureOverride } from './channels'
import type { Intent } from '../intent'
import type { SemaFamily } from './types'
import type { SemaFamily, SignalPersistence } from './types'
export interface SemanticSignal {
/** DOM target donde se proyecta la señal en el canal visual. */
@ -63,4 +63,17 @@ export interface SemanticSignal {
* signals con eventos externos.
*/
id?: string
/**
* Signal lifecycle policy (book cap. 24 §6). Default `'transient'`.
*
* - `'transient'` — engine auto-clears `data-event-*` after `hold` ms.
* - `'untilAction'` / `'untilFix'` / `'stateBound'` — projection
* survives past `hold`; caller must invoke `engine.clear(id)` or
* `engine.clearTarget(target)` when the underlying condition is met.
*
* SomaRuntime copies this from `morfo.event.semantic.persistence`.
* See {@link SignalPersistence} for the per-value lifecycle.
*/
persistence?: SignalPersistence
}

@ -113,6 +113,37 @@ export type SemaRegime = 'replace' | 'collapse' | 'lock' | 'queue';
export type SemaScope = 'part' | 'component' | 'scene';
export type SemaCause = 'keyboard' | 'pointer' | 'programmatic' | 'validation';
// ── Signal persistence (book cap. 24 §6) ───────────────────────────────────
/**
* Lifecycle policy of a perceptual signal — distinct from its `hold` (the
* minimum display duration). Per the book canon §6.1, `hold` and
* `persistence` are orthogonal: `hold` is how long the signal MUST be
* perceptible to register; `persistence` is when it gets cleared.
*
* | Value | Lifecycle |
* |-----------------|----------------------------------------------------------|
* | `transient` | Auto-cleared after `hold` elapses. Default. |
* | `untilAction` | Stays until the user acts on it. signal.alert + threat. |
* | `untilFix` | Stays until the underlying problem is corrected. |
* | | signal.warn + risk (a form validation warning). |
* | `stateBound` | Lifecycle tracks an external state. |
* | | sustain.progress / password-field caps-lock indicator. |
*
* Non-transient signals are NOT auto-cleared by the engine. The caller
* (typically a soma provider) must invoke `engine.clear(signalId)` or
* `engine.clearTarget(target)` when the relevant condition is met.
*
* Default applied by the runtime when a morfo event doesn't declare
* persistence is `'transient'` — a deliberate conservative choice to
* preserve backward-compatible behavior. Components that need
* `untilFix` / `untilAction` / `stateBound` MUST declare it explicitly.
*
* See `src/uix/sema/holds.ts` for the canonical family+intent → persistence
* table from the book §6.2.
*/
export type SignalPersistence = 'transient' | 'untilAction' | 'untilFix' | 'stateBound';
/**
* Canonical event identity composed of family ± optional intent.
*
@ -165,6 +196,18 @@ export interface SemaEventExtensions {
* family.base + intent.deltas signature.
*/
overrides?: SemaSignatureOverride;
/**
* Signal lifecycle policy (book cap. 24 §6). Default `'transient'` —
* the engine auto-clears `data-event-*` attrs after the hold elapses.
*
* Non-transient values keep the projection alive past the hold and
* require the caller to invoke `engine.clear(signalId)` or
* `engine.clearTarget(target)` when the relevant condition is met
* (user acts, problem is fixed, state ends).
*
* See {@link SignalPersistence} for the per-value lifecycle.
*/
persistence?: SignalPersistence;
}
/**

@ -64,7 +64,7 @@ function fakeSemantic(): { engine: EventEngineEmitter; calls: SemanticSignal[] }
engine: {
emit(signal) {
calls.push(signal);
return Promise.resolve();
return Promise.resolve(signal.id ?? 'sig-test');
}
}
};

@ -86,6 +86,10 @@ export class Soma {
dom: this.dom,
eventEngine: this.events,
translate: (key) => this.langs.ts(key),
// Wire the shared aria-live region to morfo events that declare
// `a11ySemantic.requiresLiveRegion`. The SomaRuntime calls this
// only when the caller passes a `message` via TriggerOptions.
announce: (message, priority) => this.uix.announce(message, priority),
...sources
});
}

@ -7,6 +7,7 @@ export const SOMA_ERR_RUNTIME: ErrCode = errCode(SOMA_ERR, 'runtime');
export const SOMA_ERR_RUNTIME_PART: ErrCode = errCode(SOMA_ERR_RUNTIME, 'part');
export const SOMA_ERR_RUNTIME_EVENT: ErrCode = errCode(SOMA_ERR_RUNTIME, 'event');
export const SOMA_ERR_RUNTIME_TARGET: ErrCode = errCode(SOMA_ERR_RUNTIME, 'target');
export const SOMA_ERR_RUNTIME_POLYMORPHIC: ErrCode = errCode(SOMA_ERR_RUNTIME, 'polymorphic');
export class SomaNoContextError extends CodeError {
constructor() {
@ -70,3 +71,20 @@ export class SomaRuntimeTargetError extends CodeError {
}
}
export class SomaRuntimePolymorphicError extends CodeError {
readonly eventName: string;
readonly attemptedFamily: string;
readonly allowedFamilies: readonly string[];
constructor(eventName: string, attemptedFamily: string, allowedFamilies: readonly string[]) {
super(SOMA_ERR_RUNTIME_POLYMORPHIC, {
message:
`[soma-runtime] Polymorphic event "${eventName}" was triggered with family ` +
`"${attemptedFamily}", which is not in allowedFamilies [${allowedFamilies.join(', ')}].`
});
this.eventName = eventName;
this.attemptedFamily = attemptedFamily;
this.allowedFamilies = allowedFamilies;
}
}

@ -10,6 +10,7 @@ import { state } from '$libs/reactive';
import { toggleMorfo } from '@/uix/morfo/components/toggle';
import { toastMorfo } from '@/uix/morfo/components/toast';
import { dialogMorfo } from '@/uix/morfo/components/dialog';
import { prewriteFixtureMorfo } from '@/uix/morfo/test-fixtures';
import { switchMorfo } from '@/uix/morfo/components/switch';
import { progressMorfo } from '@/uix/morfo/components/progress';
import { createSomaRuntime, type EventEngineEmitter } from './runtime.svelte';
@ -403,8 +404,8 @@ function fakeSemantic(): {
const engine: EventEngineEmitter = {
emit(signal: SemanticSignal) {
calls.push(signal);
return new Promise<void>((res) => {
pendingResolve = res;
return new Promise<string>((res) => {
pendingResolve = () => res(signal.id ?? 'fake-id');
});
}
};
@ -626,16 +627,20 @@ describe('runtime.trigger', () => {
});
it('applies prewrite imperatively before semantic.emit', async () => {
// Synthetic fixture (`prewriteFixtureMorfo`) — decouples this
// test from the production morfo catalogue. Production morfos
// no longer carry per-event prewrite once they migrate to the
// polymorphic close shape (book §5.3); the fixture exercises
// the runtime's prewrite path independently.
const sem = fakeSemantic();
const contentRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(dialogMorfo, {
const r = createSomaRuntime(prewriteFixtureMorfo, {
dom,
eventEngine: sem.engine,
states: { open: () => true },
props: { disabled: () => false, modal: () => false }
states: { open: () => true }
});
r.part('content', { id: state('dlg-1'), ref: contentRef, syncAttrs: true });
r.part('content', { id: state('fx-1'), ref: contentRef, syncAttrs: true });
contentRef.current = content;
void r.trigger('close-cancel');
});
@ -693,6 +698,202 @@ describe('runtime.trigger', () => {
expect(handler).toHaveBeenCalledTimes(1);
cleanup();
});
// ── Persistence (book §6.1) — SomaRuntime forwarding ───────────────────
it('forwards persistence from the morfo event semantic to the signal', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? { ...e, semantic: { ...e.semantic, persistence: 'untilAction' as const } }
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
return r.trigger('announce');
});
expect(sem.calls[0].persistence).toBe('untilAction');
sem.resolve();
const triggerResult = await result;
expect(triggerResult.persistence).toBe('untilAction');
expect(triggerResult.id).toBeDefined();
cleanup();
});
it('defaults persistence to transient when the morfo doesn’t declare it', async () => {
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(toastMorfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
return r.trigger('announce');
});
expect(sem.calls[0].persistence).toBe('transient');
sem.resolve();
const triggerResult = await result;
expect(triggerResult.persistence).toBe('transient');
cleanup();
});
it('per-call persistence override beats the morfo declaration', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? { ...e, semantic: { ...e.semantic, persistence: 'transient' as const } }
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce', { persistence: 'untilFix' });
});
expect(sem.calls[0].persistence).toBe('untilFix');
sem.resolve();
await Promise.resolve();
cleanup();
});
// ── Polymorphic events (book §5.3) ─────────────────────────────────────
it('polymorphic event uses the morfo default family when no override is passed', async () => {
// toast `announce` is signal/neutral; we layer `allowedFamilies` on
// top — the morfo's own family/intent ARE the default.
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['signal', 'commit']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce');
});
expect(sem.calls[0]).toMatchObject({ family: 'signal', name: 'announce' });
sem.resolve();
await Promise.resolve();
cleanup();
});
it('polymorphic event concretes the semantic from opts.semantic', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['signal', 'commit', 'shift']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce', {
semantic: { family: 'commit', verb: 'discard', intent: 'loss' }
});
});
expect(sem.calls[0]).toMatchObject({ family: 'commit', intent: 'loss' });
sem.resolve();
await Promise.resolve();
cleanup();
});
it('polymorphic event throws when family is not in allowedFamilies', async () => {
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['signal', 'commit']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { result, cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
return r.trigger('announce', {
// `shift` is NOT in allowedFamilies and isn't the default — should raise.
semantic: { family: 'shift', verb: 'enter-mode' }
});
});
try {
await expect(result).rejects.toThrow(/not in allowedFamilies/);
} finally {
cleanup();
}
});
it('polymorphic event allows the morfo default family without listing it explicitly', async () => {
// `signal` is the morfo's declared family. It's implicitly always
// allowed even when allowedFamilies enumerates only the alternatives.
const morfo = {
...toastMorfo,
events: toastMorfo.events.map((e) =>
e.name === 'announce'
? {
...e,
semantic: {
...e.semantic,
allowedFamilies: ['commit', 'shift']
}
}
: e
)
} as unknown as typeof toastMorfo;
const sem = fakeSemantic();
const itemRef = state<HTMLElement | null>(null);
const { cleanup } = withEffectRoot(() => {
const r = createSomaRuntime(morfo, { dom, eventEngine: sem.engine });
r.part('item', { id: state('toast-1'), ref: itemRef, syncAttrs: true });
itemRef.current = item;
void r.trigger('announce', {
semantic: { family: 'signal', verb: 'announce', intent: 'risk' }
});
});
expect(sem.calls[0]).toMatchObject({ family: 'signal', intent: 'risk' });
sem.resolve();
await Promise.resolve();
cleanup();
});
});
// ── runtime.keydown ─────────────────────────────────────────────────────────

@ -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 };
}

Loading…
Cancel
Save

Powered by TurnKey Linux.