diff --git a/src/arts/adom/active-dom.svelte.ts b/src/arts/adom/active-dom.svelte.ts index 2e0520367..05eb8b39e 100644 --- a/src/arts/adom/active-dom.svelte.ts +++ b/src/arts/adom/active-dom.svelte.ts @@ -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>; @@ -73,6 +78,17 @@ export interface ActiveDom { breakpoints: Active; viewport: { readonly width: number }; currentBreakpoint: Active; + /** + * 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(value: ResponsiveProp | 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(value: ResponsiveProp | 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(); } }; } diff --git a/src/arts/adom/reduced-motion.svelte.ts b/src/arts/adom/reduced-motion.svelte.ts new file mode 100644 index 000000000..fa239f6a4 --- /dev/null +++ b/src/arts/adom/reduced-motion.svelte.ts @@ -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?.() + }; +} diff --git a/src/arts/adom/test/reduced-motion.test.ts b/src/arts/adom/test/reduced-motion.test.ts new file mode 100644 index 000000000..97ef2c7ea --- /dev/null +++ b/src/arts/adom/test/reduced-motion.test.ts @@ -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); + }); +}); diff --git a/src/uix/active-uix/active-uix.svelte.ts b/src/uix/active-uix/active-uix.svelte.ts index f54255546..b3811b425 100644 --- a/src/uix/active-uix/active-uix.svelte.ts +++ b/src/uix/active-uix/active-uix.svelte.ts @@ -224,6 +224,11 @@ function createDisabledActiveDom(): ActiveDom { } }, currentBreakpoint, + prefersReducedMotion: { + get matches() { + return false; + } + }, resolve(value: ResponsiveProp | 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 → diff --git a/src/uix/active-uix/types.ts b/src/uix/active-uix/types.ts index 2af17c83f..6cf6ecdfa 100644 --- a/src/uix/active-uix/types.ts +++ b/src/uix/active-uix/types.ts @@ -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 `
` + * 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 `` 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 diff --git a/src/uix/morfo/compile.test.ts b/src/uix/morfo/compile.test.ts index 3cbaf1546..bb2738701 100644 --- a/src/uix/morfo/compile.test.ts +++ b/src/uix/morfo/compile.test.ts @@ -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', diff --git a/src/uix/morfo/compile.ts b/src/uix/morfo/compile.ts index 7da6449b3..b6a4c325a 100644 --- a/src/uix/morfo/compile.ts +++ b/src/uix/morfo/compile.ts @@ -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, diff --git a/src/uix/morfo/index.ts b/src/uix/morfo/index.ts index 8261f7d6a..2d0bf2446 100644 --- a/src/uix/morfo/index.ts +++ b/src/uix/morfo/index.ts @@ -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 diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 865a728d2..90fd14fa5 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -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(); + // 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. } } diff --git a/src/uix/morfo/test-fixtures.ts b/src/uix/morfo/test-fixtures.ts new file mode 100644 index 000000000..18283214c --- /dev/null +++ b/src/uix/morfo/test-fixtures.ts @@ -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; diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index 87d2b220d..67e612f83 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -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 diff --git a/src/uix/sema/engine.test.ts b/src/uix/sema/engine.test.ts index a999af22b..0ad082236 100644 --- a/src/uix/sema/engine.test.ts +++ b/src/uix/sema/engine.test.ts @@ -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) + }) + }) }) diff --git a/src/uix/sema/engine.ts b/src/uix/sema/engine.ts index ebb89ce89..ee9b63c10 100644 --- a/src/uix/sema/engine.ts +++ b/src/uix/sema/engine.ts @@ -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(); 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(); + 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 { - const enriched: SemanticSignal = { - ...signal, - id: signal.id ?? `sig-${this.nextSignalId++}` - }; + async emit(signal: SemanticSignal): Promise { + 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()) { - handle.cleanup(); + 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?.(); } diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts index b7a1e6ae7..2969d97a6 100644 --- a/src/uix/sema/exports.ts +++ b/src/uix/sema/exports.ts @@ -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 { diff --git a/src/uix/sema/holds.test.ts b/src/uix/sema/holds.test.ts new file mode 100644 index 000000000..a8d1ed6a8 --- /dev/null +++ b/src/uix/sema/holds.test.ts @@ -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(); + }); +}); diff --git a/src/uix/sema/holds.ts b/src/uix/sema/holds.ts new file mode 100644 index 000000000..1a4b209c2 --- /dev/null +++ b/src/uix/sema/holds.ts @@ -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> +>; + +/** + * 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>) + | undefined; + if (!entry) return undefined; + if (intent && intent in entry) { + const perIntent = entry[intent]; + if (perIntent) return perIntent; + } + return entry._default; +} diff --git a/src/uix/sema/signal.ts b/src/uix/sema/signal.ts index 2d90a8812..73064edca 100644 --- a/src/uix/sema/signal.ts +++ b/src/uix/sema/signal.ts @@ -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 } diff --git a/src/uix/sema/types.ts b/src/uix/sema/types.ts index e74c53d6b..c1c95d66c 100644 --- a/src/uix/sema/types.ts +++ b/src/uix/sema/types.ts @@ -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; } /** diff --git a/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts index f0ee4a0b7..aef83b5c4 100644 --- a/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts +++ b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts @@ -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'); } } }; diff --git a/src/uix/soma/core/soma.svelte.ts b/src/uix/soma/core/soma.svelte.ts index cd5d5f632..849922384 100644 --- a/src/uix/soma/core/soma.svelte.ts +++ b/src/uix/soma/core/soma.svelte.ts @@ -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 }); } diff --git a/src/uix/soma/errors.ts b/src/uix/soma/errors.ts index b515f71fc..c44b37b51 100644 --- a/src/uix/soma/errors.ts +++ b/src/uix/soma/errors.ts @@ -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; + } +} + diff --git a/src/uix/soma/runtime.svelte.test.ts b/src/uix/soma/runtime.svelte.test.ts index 9eb5b571d..7066e18f4 100644 --- a/src/uix/soma/runtime.svelte.test.ts +++ b/src/uix/soma/runtime.svelte.test.ts @@ -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((res) => { - pendingResolve = res; + return new Promise((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(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(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(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(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(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(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(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(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 ───────────────────────────────────────────────────────── diff --git a/src/uix/soma/runtime.svelte.ts b/src/uix/soma/runtime.svelte.ts index 140675d77..6fc4a7f24 100644 --- a/src/uix/soma/runtime.svelte.ts +++ b/src/uix/soma/runtime.svelte.ts @@ -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; +export type EventEngineEmitter = Pick & + Partial>; export type SourceMap = Record unknown>; @@ -116,6 +132,13 @@ export interface SomaRuntimeSources { actions?: Record; /** 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; + /** + * 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). */ - trigger(eventName: string, opts?: TriggerOptions): Promise; + 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. + */ +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 { + async function trigger(eventName: string, opts: TriggerOptions = {}): Promise { 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 }; }