diff --git a/src/uix/lib/types.ts b/src/uix/lib/types.ts new file mode 100644 index 000000000..605927d5a --- /dev/null +++ b/src/uix/lib/types.ts @@ -0,0 +1,29 @@ +/** + * UIX — cross-layer primitives. + * + * Tipos compartidos entre capas UIX (morfo, sema, eidos, soma). Viven + * aquí para que ninguna capa "posea" un primitivo cross-layer: cada capa + * importa desde `$uix/lib/types` de forma independiente, sin generar + * dependencias entre capas. + * + * Sin runtime, sin framework, sin reactividad. Sólo los mínimos contratos + * de forma que las capas deben compartir. + */ + +/** + * Identificadores de las capas UIX. Una declaración de componente puede + * participar en más de una (`scope: ['soma', 'sema']`, etc.). + */ +export type Layer = 'soma' | 'sema' | 'eidos'; + +/** + * Referencia a una parte de componente por su `kebab`. + * + * La produce el builder `v.partRef()` de morfo y la consume cualquier + * capa que necesite apuntar a "una parte concreta de un componente" — + * targets de acciones sema, selectores eidos, el union de `MorfoAriaValue`. + * + * El tag `kind: 'partRef'` permite que `MorfoAriaValue` incluya la forma + * directamente como caso del union discriminado. + */ +export type PartRef = { kind: 'partRef'; target: string }; diff --git a/src/uix/morfo/components/dialog.test.ts b/src/uix/morfo/components/dialog.test.ts index d217084af..65a098947 100644 --- a/src/uix/morfo/components/dialog.test.ts +++ b/src/uix/morfo/components/dialog.test.ts @@ -1,15 +1,20 @@ import { describe, it, expect } from 'vitest'; import { validateMorfo, MorfoInvariantError } from '../schema'; import type { Morfo } from '../types'; -import { dialogMorfo } from './dialog'; +import { validateSema, SemaInvariantError } from '../../sema/validation'; +import type { SemaSpec, SemaAction, SemaEventLabel } from '../../sema/types'; +import { dialogMorfo, dialogSema } from './dialog'; -// `dialogMorfo` is authored `as const satisfies Morfo` so runtime -// assertions on its literal shape stay precise. Invariant tests below -// clone it into a mutable `Morfo` to deliberately corrupt fields. -function cloneMutable(m: typeof dialogMorfo): Morfo { +// Authored as `as const satisfies Morfo` / `satisfies SemaSpec`. Tests below +// clone into mutable shapes to deliberately corrupt fields. +function cloneMorfo(m: typeof dialogMorfo): Morfo { return structuredClone(m as Morfo) as Morfo; } +function cloneSema(s: typeof dialogSema): SemaSpec { + return structuredClone(s as SemaSpec) as SemaSpec; +} + describe('dialogMorfo', () => { it('passes shape + invariant validation', () => { expect(() => validateMorfo(dialogMorfo)).not.toThrow(); @@ -31,16 +36,15 @@ describe('dialogMorfo', () => { }); it('fails validation when a partRef targets a non-existent kebab', () => { - const broken = cloneMutable(dialogMorfo); + const broken = cloneMorfo(dialogMorfo); const trigger = broken.parts.find((p) => p.kebab === 'trigger')!; const controls = trigger.aria.find((a) => a.attr === 'aria-controls')!; - // Force a partRef to a non-existent target. (controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part'; expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError); }); it('fails validation when stateRef refers to a state not declared in the part', () => { - const broken = cloneMutable(dialogMorfo); + const broken = cloneMorfo(dialogMorfo); const trigger = broken.parts.find((p) => p.kebab === 'trigger')!; const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')!; (expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state'; @@ -48,14 +52,108 @@ describe('dialogMorfo', () => { }); it('fails validation when two parts share the same kebab', () => { - const broken = cloneMutable(dialogMorfo); - broken.parts[1].kebab = 'content'; // trigger → content (duplicate) + const broken = cloneMorfo(dialogMorfo); + (broken.parts as unknown as { kebab: string }[])[1].kebab = 'content'; expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError); }); it('fails validation with empty scope', () => { - const broken = cloneMutable(dialogMorfo); - broken.scope = []; + const broken = cloneMorfo(dialogMorfo); + (broken as unknown as { scope: [] }).scope = []; expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError); }); }); + +describe('dialogSema', () => { + it('passes sema invariants standalone', () => { + expect(() => validateSema(dialogSema)).not.toThrow(); + }); + + it('passes sema invariants with morfo context (cross-ref)', () => { + expect(() => validateSema(dialogSema, dialogMorfo)).not.toThrow(); + }); + + it('declares six canonical semantic actions', () => { + const actions = dialogSema.actions.map((a) => a.name).sort(); + expect(actions).toEqual( + [ + 'open', + 'close-save', + 'close-cancel', + 'close-dismiss', + 'close-dismiss-outside', + 'close-after-fail' + ].sort() + ); + }); + + it('fails when an action targets a non-existent part (with morfo ctx)', () => { + const broken = cloneSema(dialogSema); + (broken.actions as SemaAction[])[0].target.target = 'no-such-part'; + expect(() => validateSema(broken, dialogMorfo)).toThrow(SemaInvariantError); + expect(() => validateSema(broken, dialogMorfo)).toThrow(/target "no-such-part"/); + }); + + it('fails when an action uses a non-canonical event label (no morfo needed)', () => { + const broken = cloneSema(dialogSema); + (broken.actions as SemaAction[])[0].event = 'commit-fulfil' as unknown as SemaEventLabel; + expect(() => validateSema(broken)).toThrow(/not a valid SemaEventLabel/); + }); + + it('fails when a prewrite attr is not declared in the target part data[]', () => { + const broken = cloneSema(dialogSema); + const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!; + action.prewrite![0].attr = 'data-bogus'; + expect(() => validateSema(broken, dialogMorfo)).toThrow(/data-bogus.*not declared/); + }); + + it('fails when a prewrite writes a value outside the declared enum', () => { + const broken = cloneSema(dialogSema); + const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!; + action.prewrite![0].value = 'not-in-enum'; + expect(() => validateSema(broken, dialogMorfo)).toThrow(/not-in-enum.*not in declared values/); + }); + + it('fails when commits targets a non-existent state', () => { + const broken = cloneSema(dialogSema); + const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!; + action.commits!.value = 'zombied'; + expect(() => validateSema(broken, dialogMorfo)).toThrow(/zombied.*not in states/); + }); + + it('fails when two actions share the same name (no morfo needed)', () => { + const broken = cloneSema(dialogSema); + (broken.actions as SemaAction[])[1].name = 'open'; + expect(() => validateSema(broken)).toThrow(/duplicate action name "open"/); + }); + + it('fails when data-last-action has a declared value no action prewrites', () => { + const brokenMorfo = cloneMorfo(dialogMorfo); + const content = brokenMorfo.parts.find((p) => p.kebab === 'content')!; + const dla = content.data.find((d) => d.attr === 'data-last-action')!; + (dla.values as string[]) = [...dla.values!, 'orphan-value']; + expect(() => validateSema(dialogSema, brokenMorfo)).toThrow( + /orphan-value.*no sema action prewrites/ + ); + }); + + it('fails when another part declares data-last-action values but no action ever prewrites it', () => { + const brokenMorfo = cloneMorfo(dialogMorfo); + const trigger = brokenMorfo.parts.find((p) => p.kebab === 'trigger')!; + (trigger.data as { attr: string; values?: readonly string[] }[]).push({ + attr: 'data-last-action', + values: ['ghost-action'] + }); + expect(() => validateSema(dialogSema, brokenMorfo)).toThrow( + /ghost-action.*no sema action prewrites/ + ); + }); + + it('fails when spec.kebab disagrees with morfo.kebab', () => { + const broken = cloneSema(dialogSema); + broken.kebab = 'not-dialog'; + expect(() => validateSema(broken, dialogMorfo)).toThrow( + /spec.kebab "not-dialog" does not match morfo.kebab "dialog"/ + ); + }); +}); diff --git a/src/uix/morfo/components/dialog.ts b/src/uix/morfo/components/dialog.ts index ce44f49d5..8849e06d8 100644 --- a/src/uix/morfo/components/dialog.ts +++ b/src/uix/morfo/components/dialog.ts @@ -11,11 +11,12 @@ import type { Morfo } from '../types'; import { v } from '../types'; +import type { SemaSpec } from '../../sema/types'; export const dialogMorfo = { name: 'Dialog', kebab: 'dialog', - scope: ['soma'], + scope: ['soma', 'sema'], apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/', focus: { @@ -204,3 +205,90 @@ export const dialogMorfo = { } ] } as const satisfies Morfo; + +/** + * Dialog sema declaration. + * + * Seis acciones: un `open` y cinco variantes de cierre. Cada cierre + * prewrites `data-last-action` antes del commit de `data-state`, de modo + * que la capa visual pueda tintar la salida según la razón causal. Todos + * los cierres usan `lock` — un diálogo en cierre no debe re-entrarse a + * mitad de coreografía. + */ +export const dialogSema = { + kebab: 'dialog', + actions: [ + { + name: 'open', + target: v.partRef('content'), + event: 'emerge', + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'open' + } + }, + { + name: 'close-save', + target: v.partRef('content'), + event: 'commit-fulfill', + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-cancel', + target: v.partRef('content'), + event: 'emerge', + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-dismiss', + target: v.partRef('content'), + event: 'emerge', + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-dismiss-outside', + target: v.partRef('content'), + event: 'emerge', + regime: 'lock', + prewrite: [ + { part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' } + ], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-after-fail', + target: v.partRef('content'), + event: 'alert-threat', + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + } + ] +} as const satisfies SemaSpec; diff --git a/src/uix/morfo/index.ts b/src/uix/morfo/index.ts index aa210c859..097c5b7c6 100644 --- a/src/uix/morfo/index.ts +++ b/src/uix/morfo/index.ts @@ -6,8 +6,9 @@ * `$uix/morfo/components/{name}` for individual component morfos. */ +// Cross-layer primitives (`Layer`, `PartRef`) live in `$uix/lib/types`. +// Consumers import them directly from there — no re-export here. export type { - Layer, MorfoElement, MorfoAriaValue, MorfoCondition, diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 90d689671..11ff886ab 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -20,6 +20,11 @@ * Sium has no `lazy()` today, so recursion in `MorfoPart.parts?` is * handled by a manual walker that calls `morfoPartSchema.decode()` for * every sub-part. Swap to `lazy()` when sium ships it. + * + * This module is **layer-agnostic**: it knows about parts, ARIA, data, + * focus, keyboard — nothing about sema / eidos. Downstream layers ship + * their own deep validators (`sema/validation.ts`, …) and are called + * independently of `validateMorfo`. */ import { @@ -185,15 +190,21 @@ const partShallowSchema = object({ // ── Morfo root ──────────────────────────────────────────────────────────── -const morfoShallowSchema = object({ - name: string(), - kebab: string(), - scope: array(layerSchema), - apg: optional(string()), - focus: optional(focusSchema), - parts: array(object({}, { unknownKeys: 'passthrough' })) - // ^ parts are opaque here; walker recurses with `partShallowSchema` -}); +const morfoShallowSchema = object( + { + name: string(), + kebab: string(), + scope: array(layerSchema), + apg: optional(string()), + focus: optional(focusSchema), + parts: array(object({}, { unknownKeys: 'passthrough' })) + // ^ parts are opaque here; walker recurses with `partShallowSchema` + }, + // Extension keys from downstream layers (e.g. `sema`) are tolerated so + // `satisfies MorfoWithSema`-style authoring still passes the root shape + // check. Each layer ships its own deep validator. + { unknownKeys: 'passthrough' } +); // ── Manual walker + invariants ──────────────────────────────────────────── @@ -366,16 +377,19 @@ function validateInvariants(morfo: Morfo): void { * * Intended for build-time / dev-time. Run once per morfo on first load; * results are cacheable. + * + * This validator knows nothing about Sema. Components that declare a + * `sema` extension must call `validateSema(morfo)` from `../sema/validation` + * in addition. */ export function validateMorfo(morfo: unknown): Morfo { - // 1. Shape: root shell (scope, apg, focus) + opaque parts. morfoShallowSchema.decodeSync(morfo as never); const m = morfo as Morfo; - // 2. Shape: every part (recursively) — sium has no lazy() today. + // Sium has no lazy() today, so parts are validated via an explicit walker. validatePartsRecursively(m.parts); - // 3. Invariants: kebab uniqueness, partRef/stateRef/conditions, focus. + // Cross-reference invariants: kebab uniqueness, partRef/stateRef, focus. validateInvariants(m); return m; diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index 72f51c696..d3a740bab 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -20,13 +20,7 @@ * ./DESIGN.md for the original proposal. */ -// ── Layer ────────────────────────────────────────────────────────────────── - -/** - * The framework layer that implements a component. A single component can - * live in multiple layers (soma + eidos + sema). - */ -export type Layer = 'soma' | 'sema' | 'eidos'; +import type { Layer, PartRef } from '../lib/types'; // ── HTML element ────────────────────────────────────────────────────────── @@ -90,7 +84,7 @@ export type MorfoElement = export type MorfoAriaValue = | { kind: 'literal'; value: string } | { kind: 'stateRef'; state: string } - | { kind: 'partRef'; target: string } + | PartRef | { kind: 'propRef'; prop: string } | { kind: 'translationRef'; key: string }; @@ -335,7 +329,10 @@ export interface Morfo { export const v = { literal: (value: string): MorfoAriaValue => ({ kind: 'literal', value }), stateRef: (state: string): MorfoAriaValue => ({ kind: 'stateRef', state }), - partRef: (target: string): MorfoAriaValue => ({ kind: 'partRef', target }), + // Returns the narrow `PartRef` — still assignable to `MorfoAriaValue` + // because PartRef is one of its cases, and reusable by any layer that + // needs to reference a part (e.g. Sema action targets). + partRef: (target: string): PartRef => ({ kind: 'partRef', target }), propRef: (prop: string): MorfoAriaValue => ({ kind: 'propRef', prop }), translationRef: (key: string): MorfoAriaValue => ({ kind: 'translationRef', key }) } as const; diff --git a/src/uix/sema/a11y.test.ts b/src/uix/sema/a11y.test.ts new file mode 100644 index 000000000..16f7a411f --- /dev/null +++ b/src/uix/sema/a11y.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + A11yMonitor, + DEFAULT_SEMA_RUNTIME_CONFIG, + type MediaQueryListLike, + type SemaRuntimeConfig +} from './a11y' +import type { EffectiveSignature } from './resolver' + +function createMatchMedia(state: Partial> = {}) { + const listeners = new Map void>>() + + return (query: string): MediaQueryListLike => ({ + get matches() { + return state[query] ?? false + }, + addEventListener(_type: 'change', listener: () => void) { + if (!listeners.has(query)) listeners.set(query, new Set()) + listeners.get(query)!.add(listener) + }, + removeEventListener(_type: 'change', listener: () => void) { + listeners.get(query)?.delete(listener) + } + }) +} + +const baseSignature: EffectiveSignature = { + event: 'alert-threat', + activeChannels: ['motion', 'sound', 'color', 'presence'], + motion: { + duration: 180, + easing: 'ease-out', + scale: { from: 1, to: 1.06 } + }, + sound: { + pitch: 1100, + centroid: 1800, + roughness: 0.6, + attack: 8, + decay: 120, + duration: 180, + contour: 'descending', + gain: 0.7 + }, + color: { + hue: 10, + saturation: 0.7, + lightness: 0.45, + duration: 180, + intensity: 0.5 + }, + presence: { + opacity: { from: 0.6, to: 1 }, + shadow: { blur: 18, y: 6, opacity: 0.4 }, + backdrop: 0.9, + duration: 180, + easing: 'ease-out' + } +} + +const allChannelsEnabledConfig: SemaRuntimeConfig = { + ...DEFAULT_SEMA_RUNTIME_CONFIG, + sound: { enabled: true, gain: 0.8 } +} + +describe('A11yMonitor', () => { + it('is SSR-safe and reports no reduction by default', () => { + const monitor = new A11yMonitor() + + expect(monitor.snapshot()).toEqual({ + reducedMotion: false, + reducedTransparency: false, + highContrast: false, + forcedColors: false + }) + expect(monitor.hasActiveReduction()).toBe(false) + expect(monitor.getBlockingCapMs(DEFAULT_SEMA_RUNTIME_CONFIG)).toBe(200) + }) + + it('removes motion and discretizes color/presence under reduced motion', () => { + const monitor = new A11yMonitor({ + matchMedia: createMatchMedia({ + '(prefers-reduced-motion: reduce)': true + }) + }) + + const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig) + + expect(reduced.activeChannels).toEqual(['sound', 'color', 'presence']) + expect(reduced.motion).toBeUndefined() + expect(reduced.color?.duration).toBe(50) + expect(reduced.color?.intensity).toBe(0.2) + expect(reduced.presence?.duration).toBe(50) + expect(monitor.getBlockingCapMs(DEFAULT_SEMA_RUNTIME_CONFIG)).toBe(80) + }) + + it('reduces transparency by clamping backdrop only', () => { + const monitor = new A11yMonitor({ + matchMedia: createMatchMedia({ + '(prefers-reduced-transparency: reduce)': true + }) + }) + + const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig) + + expect(reduced.activeChannels).toEqual(baseSignature.activeChannels) + expect(reduced.presence?.backdrop).toBe(0.7) + expect(reduced.color).toEqual(baseSignature.color) + }) + + it('boosts contrast and adds outline under high contrast', () => { + const monitor = new A11yMonitor({ + matchMedia: createMatchMedia({ + '(prefers-contrast: more)': true + }) + }) + + const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig) + + expect(reduced.color?.saturation).toBeCloseTo(0.9) + expect(reduced.color?.lightness).toBe(0.2) + expect(reduced.color?.intensity).toBe(0.8) + expect(reduced.presence?.outline).toEqual({ width: 2, style: 'solid' }) + }) + + it('disables ornamental color and degrades presence to contour in forced colors', () => { + const monitor = new A11yMonitor({ + matchMedia: createMatchMedia({ + '(forced-colors: active)': true + }) + }) + + const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig) + + expect(reduced.activeChannels).toEqual(['motion', 'sound', 'presence']) + expect(reduced.color).toBeUndefined() + expect(reduced.presence?.outline).toEqual({ width: 3, style: 'solid' }) + expect(reduced.presence?.backdrop).toBeUndefined() + }) + + it('filters globally disabled channels after applying reductions', () => { + const monitor = new A11yMonitor({ + matchMedia: createMatchMedia({ + '(prefers-contrast: more)': true + }) + }) + const config: SemaRuntimeConfig = { + ...DEFAULT_SEMA_RUNTIME_CONFIG, + sound: { enabled: true, gain: 0.8 }, + color: { enabled: false }, + presence: { enabled: false } + } + + const reduced = monitor.reduceSignature(baseSignature, config) + + expect(reduced.activeChannels).toEqual(['motion', 'sound']) + expect(reduced.color).toBeUndefined() + expect(reduced.presence).toBeUndefined() + }) + + it('keeps sound disabled by default in the runtime config', () => { + const monitor = new A11yMonitor({ + matchMedia: createMatchMedia() + }) + + const reduced = monitor.reduceSignature(baseSignature) + + expect(reduced.activeChannels).toEqual(['motion', 'color', 'presence']) + expect(reduced.sound).toBeUndefined() + }) + + it('notifies preference changes and unsubscribes cleanly', () => { + const query = '(prefers-reduced-motion: reduce)' + const listeners = new Set<() => void>() + const monitor = new A11yMonitor({ + matchMedia: (requestedQuery) => ({ + get matches() { + return false + }, + addEventListener(_type: 'change', listener: () => void) { + if (requestedQuery === query) listeners.add(listener) + }, + removeEventListener(_type: 'change', listener: () => void) { + if (requestedQuery === query) listeners.delete(listener) + } + }) + }) + const callback = vi.fn() + + const dispose = monitor.onPreferenceChange(callback) + for (const listener of listeners) listener() + + expect(callback).toHaveBeenCalledTimes(1) + + dispose() + expect(listeners.size).toBe(0) + }) +}) diff --git a/src/uix/sema/a11y.ts b/src/uix/sema/a11y.ts new file mode 100644 index 000000000..15b3daf6b --- /dev/null +++ b/src/uix/sema/a11y.ts @@ -0,0 +1,278 @@ +import type { EffectiveSignature, PresenceSignature, SemaActiveChannel } from './resolver' + +export interface SemaRuntimeConfig { + sound: { enabled: boolean; gain: number } + motion: { enabled: boolean } + color: { enabled: boolean } + presence: { enabled: boolean } + reflectEvents: boolean + capBlockingMs: number + capBlockingReducedMs: number +} + +export const DEFAULT_SEMA_RUNTIME_CONFIG: SemaRuntimeConfig = { + sound: { enabled: false, gain: 0.8 }, + motion: { enabled: true }, + color: { enabled: true }, + presence: { enabled: true }, + reflectEvents: false, + capBlockingMs: 200, + capBlockingReducedMs: 80 +} + +export interface MediaQueryListLike { + readonly matches: boolean + addEventListener?(type: 'change', listener: () => void): void + removeEventListener?(type: 'change', listener: () => void): void + addListener?(listener: () => void): void + removeListener?(listener: () => void): void +} + +export interface A11ySnapshot { + reducedMotion: boolean + reducedTransparency: boolean + highContrast: boolean + forcedColors: boolean +} + +export interface A11yMonitorOptions { + matchMedia?: (query: string) => MediaQueryListLike +} + +type A11yMediaQueries = Record + +const MEDIA_QUERIES = { + reducedMotion: '(prefers-reduced-motion: reduce)', + reducedTransparency: '(prefers-reduced-transparency: reduce)', + highContrast: '(prefers-contrast: more)', + forcedColors: '(forced-colors: active)' +} as const + +function createInactiveMediaQueryList(): MediaQueryListLike { + return { + matches: false, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {} + } +} + +function cloneSignature(signature: EffectiveSignature): EffectiveSignature { + return structuredClone(signature) +} + +function stripInactiveChannels(signature: EffectiveSignature): EffectiveSignature { + const active = new Set(signature.activeChannels) + return { + ...signature, + motion: active.has('motion') ? signature.motion : undefined, + sound: active.has('sound') ? signature.sound : undefined, + color: active.has('color') ? signature.color : undefined, + presence: active.has('presence') ? signature.presence : undefined + } +} + +function withActiveChannels( + signature: EffectiveSignature, + activeChannels: SemaActiveChannel[] +): EffectiveSignature { + return stripInactiveChannels({ + ...signature, + activeChannels + }) +} + +function mergePresenceOutline( + presence: PresenceSignature | undefined, + outline: { width: number; style: string } +): PresenceSignature | undefined { + if (!presence) return undefined + return { + ...presence, + outline: { + width: Math.max(presence.outline?.width ?? 0, outline.width), + style: presence.outline?.style ?? outline.style + } + } +} + +export class A11yMonitor { + private readonly mq: A11yMediaQueries + + constructor(opts: A11yMonitorOptions = {}) { + const matchMedia = + opts.matchMedia ?? + (typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia.bind(window) + : undefined) + + this.mq = { + reducedMotion: matchMedia + ? matchMedia(MEDIA_QUERIES.reducedMotion) + : createInactiveMediaQueryList(), + reducedTransparency: matchMedia + ? matchMedia(MEDIA_QUERIES.reducedTransparency) + : createInactiveMediaQueryList(), + highContrast: matchMedia + ? matchMedia(MEDIA_QUERIES.highContrast) + : createInactiveMediaQueryList(), + forcedColors: matchMedia + ? matchMedia(MEDIA_QUERIES.forcedColors) + : createInactiveMediaQueryList() + } + } + + snapshot(): A11ySnapshot { + return { + reducedMotion: this.mq.reducedMotion.matches, + reducedTransparency: this.mq.reducedTransparency.matches, + highContrast: this.mq.highContrast.matches, + forcedColors: this.mq.forcedColors.matches + } + } + + hasActiveReduction(): boolean { + const state = this.snapshot() + return ( + state.reducedMotion || + state.reducedTransparency || + state.highContrast || + state.forcedColors + ) + } + + getBlockingCapMs(config: SemaRuntimeConfig): number { + return this.hasActiveReduction() ? config.capBlockingReducedMs : config.capBlockingMs + } + + reduceSignature( + signature: EffectiveSignature, + config: SemaRuntimeConfig = DEFAULT_SEMA_RUNTIME_CONFIG + ): EffectiveSignature { + let result = cloneSignature(signature) + const state = this.snapshot() + + if (state.reducedMotion) { + result = this.applyReducedMotion(result) + } + if (state.reducedTransparency) { + result = this.applyReducedTransparency(result) + } + if (state.highContrast) { + result = this.applyHighContrast(result) + } + if (state.forcedColors) { + result = this.applyForcedColors(result) + } + + result = this.applyDisabledChannels(result, config) + return stripInactiveChannels(result) + } + + onPreferenceChange(callback: () => void): () => void { + const listeners: Array<{ mq: MediaQueryListLike; listener: () => void }> = [] + for (const mq of Object.values(this.mq)) { + const listener = () => callback() + if (mq.addEventListener) { + mq.addEventListener('change', listener) + } else { + mq.addListener?.(listener) + } + listeners.push({ mq, listener }) + } + return () => { + for (const { mq, listener } of listeners) { + if (mq.removeEventListener) { + mq.removeEventListener('change', listener) + } else { + mq.removeListener?.(listener) + } + } + } + } + + private applyReducedMotion(signature: EffectiveSignature): EffectiveSignature { + const activeChannels = signature.activeChannels.filter((channel) => channel !== 'motion') + return withActiveChannels( + { + ...signature, + color: signature.color + ? { + ...signature.color, + duration: Math.min(signature.color.duration, 50), + intensity: Math.min(signature.color.intensity, 0.2) + } + : undefined, + presence: signature.presence + ? { + ...signature.presence, + duration: Math.min(signature.presence.duration, 50) + } + : undefined + }, + activeChannels + ) + } + + private applyReducedTransparency(signature: EffectiveSignature): EffectiveSignature { + if (!signature.presence) return signature + return { + ...signature, + presence: { + ...signature.presence, + backdrop: + typeof signature.presence.backdrop === 'number' + ? Math.min(signature.presence.backdrop, 0.7) + : undefined + } + } + } + + private applyHighContrast(signature: EffectiveSignature): EffectiveSignature { + return { + ...signature, + color: signature.color + ? { + ...signature.color, + saturation: Math.min(signature.color.saturation + 0.2, 1), + lightness: signature.color.lightness < 0.5 ? 0.2 : 0.8, + intensity: Math.min(signature.color.intensity + 0.3, 1) + } + : undefined, + presence: mergePresenceOutline(signature.presence, { width: 2, style: 'solid' }) + } + } + + private applyForcedColors(signature: EffectiveSignature): EffectiveSignature { + const activeChannels = signature.activeChannels.filter((channel) => channel !== 'color') + return withActiveChannels( + { + ...signature, + presence: signature.presence + ? { + ...signature.presence, + backdrop: undefined, + outline: { width: 3, style: 'solid' } + } + : undefined + }, + activeChannels + ) + } + + private applyDisabledChannels( + signature: EffectiveSignature, + config: SemaRuntimeConfig + ): EffectiveSignature { + const activeChannels = signature.activeChannels.filter((channel) => { + if (channel === 'motion' && !config.motion.enabled) return false + if (channel === 'sound' && !config.sound.enabled) return false + if (channel === 'color' && !config.color.enabled) return false + if (channel === 'presence' && !config.presence.enabled) return false + return true + }) + + return withActiveChannels(signature, activeChannels) + } +} diff --git a/src/uix/sema/binding.test.ts b/src/uix/sema/binding.test.ts new file mode 100644 index 000000000..221b10a88 --- /dev/null +++ b/src/uix/sema/binding.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest' + +import { dialogSema } from '../morfo/components/dialog' +import { createSemaBinding } from './binding' +import { createTestSemaPort, noopSemaPort } from './port' +import type { SemaSpec } from './types' + +function createElement(initial: Record = {}): HTMLElement { + const attrs = new Map(Object.entries(initial)) + return { + getAttribute(name: string) { + return attrs.has(name) ? attrs.get(name)! : null + }, + setAttribute(name: string, value: string) { + attrs.set(name, value) + }, + removeAttribute(name: string) { + attrs.delete(name) + } + } as unknown as HTMLElement +} + +describe('createSemaBinding', () => { + it('applies defaults, prewrites and delegates before()', async () => { + const handle = createTestSemaPort() + const binding = createSemaBinding(dialogSema, handle.port) + const contentEl = createElement({ + 'data-state': 'open' + }) + + await binding.before('close-save', { + targetEl: contentEl, + partEls: { content: contentEl }, + cause: 'pointer' + }) + + expect(contentEl.getAttribute('data-last-action')).toBe('saved') + expect(handle.calls).toHaveLength(1) + expect(handle.calls[0]).toMatchObject({ + kind: 'before', + action: { + name: 'close-save', + component: 'dialog', + event: 'commit-fulfill', + mode: 'blocking', + regime: 'lock', + scope: 'part', + target: 'content', + prewritten: [{ part: 'content', attr: 'data-last-action', value: 'saved' }] + }, + ctx: { + cause: 'pointer', + snapshot: { + 'data-state': 'open', + 'data-last-action': 'saved', + 'data-starting-style': null, + 'data-ending-style': null + } + } + }) + }) + + it('keeps prewrites active even with noopSemaPort', async () => { + const binding = createSemaBinding(dialogSema, noopSemaPort) + const contentEl = createElement({ + 'data-state': 'open' + }) + + await binding.before('close-after-fail', { + targetEl: contentEl + }) + + expect(contentEl.getAttribute('data-last-action')).toBe('failed') + }) + + it('delegates fire() without waiting and preserves explicit mode/scope', () => { + const spec = { + kebab: 'toast', + actions: [ + { + name: 'announce', + target: { kind: 'partRef', target: 'root' }, + event: 'alert-affirm', + mode: 'advisory', + scope: 'scene' + } + ] + } as const satisfies SemaSpec + const handle = createTestSemaPort() + const binding = createSemaBinding(spec, handle.port) + const rootEl = createElement() + + binding.fire('announce', { + targetEl: rootEl + }) + + expect(handle.calls).toHaveLength(1) + expect(handle.calls[0]).toMatchObject({ + kind: 'fire', + action: { + name: 'announce', + mode: 'advisory', + scope: 'scene', + regime: 'replace', + target: 'root' + } + }) + }) + + it('starts sustains with default scope and built context', () => { + const spec = { + kebab: 'spinner', + actions: [], + sustains: [ + { + name: 'loading', + target: { kind: 'partRef', target: 'glyph' }, + activeWhen: { + part: { kind: 'partRef', target: 'glyph' }, + attr: 'data-state', + value: 'loading' + }, + event: 'sustain' + } + ] + } as const satisfies SemaSpec + const handle = createTestSemaPort() + const binding = createSemaBinding(spec, handle.port) + const glyphEl = createElement({ + 'data-state': 'loading' + }) + + const session = binding.start('loading', { + targetEl: glyphEl, + cause: 'programmatic' + }) + + expect(session.active).toBe(true) + expect(handle.sessions).toHaveLength(1) + expect(handle.sessions[0]).toMatchObject({ + sustain: { + name: 'loading', + component: 'spinner', + target: 'glyph', + scope: 'part' + }, + ctx: { + cause: 'programmatic', + snapshot: { + 'data-state': 'loading', + 'data-last-action': null, + 'data-starting-style': null, + 'data-ending-style': null + } + } + }) + }) + + it('returns declared actions for introspection', () => { + const binding = createSemaBinding(dialogSema, noopSemaPort) + expect(binding.action('open')).toBe(dialogSema.actions[0]) + }) + + it('throws on unknown action names in dev', async () => { + const binding = createSemaBinding(dialogSema, noopSemaPort) + const contentEl = createElement() + + await expect( + binding.before('missing' as never, { + targetEl: contentEl + }) + ).rejects.toThrow(/action "missing" not declared/) + }) + + it('throws on unknown sustain names in dev', () => { + const spec = { + kebab: 'spinner', + actions: [], + sustains: [ + { + name: 'loading', + target: { kind: 'partRef', target: 'glyph' }, + activeWhen: { + part: { kind: 'partRef', target: 'glyph' }, + attr: 'data-state', + value: 'loading' + }, + event: 'sustain' + } + ] + } as const satisfies SemaSpec + const binding = createSemaBinding(spec, noopSemaPort) + + expect(() => + binding.start('missing' as never, { + targetEl: createElement() + }) + ).toThrow(/sustain "missing" not declared/) + }) + + it('skips non-target prewrites when the runtime context is incomplete', async () => { + const spec = { + kebab: 'widget', + actions: [ + { + name: 'promote', + target: { kind: 'partRef', target: 'content' }, + event: 'commit-affirm', + prewrite: [ + { + part: { kind: 'partRef', target: 'badge' }, + attr: 'data-tone', + value: 'loud' + } + ] + } + ] + } as const satisfies SemaSpec + const handle = createTestSemaPort() + const binding = createSemaBinding(spec, handle.port) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const contentEl = createElement({ + 'data-state': 'idle' + }) + + await binding.before('promote', { + targetEl: contentEl + }) + + expect(contentEl.getAttribute('data-tone')).toBe(null) + expect(handle.calls[0].action.prewritten).toEqual([]) + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls[0][0]).toMatch(/prewrite target "badge" missing/) + warn.mockRestore() + }) +}) diff --git a/src/uix/sema/binding.ts b/src/uix/sema/binding.ts new file mode 100644 index 000000000..7b7309e58 --- /dev/null +++ b/src/uix/sema/binding.ts @@ -0,0 +1,203 @@ +import { DEV } from 'esm-env' + +import type { + ResolvedSemaAction, + ResolvedSemaSustain, + SemaContext, + SemaPort, + SemaSession +} from './port' +import type { SemaAction, SemaSpec, SemaSustainDecl } from './types' + +const SNAPSHOT_ATTRS = [ + 'data-state', + 'data-last-action', + 'data-starting-style', + 'data-ending-style' +] as const + +const INACTIVE_SEMA_SESSION: SemaSession = { + stop() {}, + get active() { + return false + } +} + +export interface PartialSemaContext { + targetEl: HTMLElement + rootEl?: HTMLElement + partEls?: Partial> + cause?: SemaContext['cause'] + abortSignal?: AbortSignal +} + +export type ActionName = S['actions'][number]['name'] +export type SustainName = S['sustains'] extends readonly SemaSustainDecl[] + ? S['sustains'][number]['name'] + : never + +export interface SemaBinding { + before(name: ActionName, ctx: PartialSemaContext): Promise + fire(name: ActionName, ctx: PartialSemaContext): void + start(name: SustainName, ctx: PartialSemaContext): SemaSession + action(name: ActionName): SemaAction +} + +function createUnknownAction(name: string): SemaAction { + return { + name, + target: { kind: 'partRef', target: '' }, + event: 'emerge' + } +} + +function createUnknownSustain(name: string): SemaSustainDecl { + return { + name, + target: { kind: 'partRef', target: '' }, + activeWhen: { + part: { kind: 'partRef', target: '' }, + attr: 'data-state', + value: '' + }, + event: 'sustain' + } +} + +export function createSemaBinding(spec: S, port: SemaPort): SemaBinding { + const actionsByName = new Map() + for (const action of spec.actions) { + actionsByName.set(action.name, action) + } + + const sustainsByName = new Map() + for (const sustain of spec.sustains ?? []) { + sustainsByName.set(sustain.name, sustain) + } + + function failUnknown(kind: 'action' | 'sustain', name: string, declared: string[]): void { + throw new Error( + `[sema] ${kind} "${name}" not declared in "${spec.kebab}". Declared ${kind}s: ${declared.join(', ')}` + ) + } + + function warn(message: string): void { + if (DEV) console.warn(message) + } + + function resolveAction(name: string): SemaAction | null { + const action = actionsByName.get(name) + if (action) return action + if (DEV) failUnknown('action', name, [...actionsByName.keys()]) + return null + } + + function resolveSustain(name: string): SemaSustainDecl | null { + const sustain = sustainsByName.get(name) + if (sustain) return sustain + if (DEV) failUnknown('sustain', name, [...sustainsByName.keys()]) + return null + } + + function resolvePartElement( + partial: PartialSemaContext, + targetPart: string, + primaryPart: string + ): HTMLElement | undefined { + if (targetPart === primaryPart) return partial.targetEl + return partial.partEls?.[targetPart] + } + + function applyPrewrites( + action: SemaAction, + partial: PartialSemaContext + ): ResolvedSemaAction['prewritten'] { + const applied: ResolvedSemaAction['prewritten'] = [] + for (const pw of action.prewrite ?? []) { + const part = pw.part.target + const el = resolvePartElement(partial, part, action.target.target) + if (!el) { + warn( + `[sema] prewrite target "${part}" missing in runtime context for "${spec.kebab}.${action.name}".` + ) + continue + } + el.setAttribute(pw.attr, pw.value) + applied.push({ + part, + attr: pw.attr, + value: pw.value + }) + } + return applied + } + + function buildContext(partial: PartialSemaContext): SemaContext { + const snapshot: Record = {} + for (const attr of SNAPSHOT_ATTRS) { + snapshot[attr] = partial.targetEl.getAttribute(attr) + } + return { + targetEl: partial.targetEl, + rootEl: partial.rootEl, + partEls: partial.partEls, + snapshot, + cause: partial.cause, + abortSignal: partial.abortSignal + } + } + + function resolveActionToRuntime( + action: SemaAction, + prewritten: ResolvedSemaAction['prewritten'] + ): ResolvedSemaAction { + return { + name: action.name, + component: spec.kebab, + event: action.event, + mode: action.mode ?? 'blocking', + regime: action.regime ?? 'replace', + scope: action.scope ?? 'part', + target: action.target.target, + prewritten + } + } + + function resolveSustainToRuntime(sustain: SemaSustainDecl): ResolvedSemaSustain { + return { + name: sustain.name, + component: spec.kebab, + target: sustain.target.target, + scope: sustain.scope ?? 'part' + } + } + + return { + action(name) { + return resolveAction(name as string) ?? createUnknownAction(name as string) + }, + async before(name, partial) { + const action = resolveAction(name as string) + if (!action) return + const prewritten = applyPrewrites(action, partial) + const resolved = resolveActionToRuntime(action, prewritten) + const ctx = buildContext(partial) + await port.before(resolved, ctx) + }, + fire(name, partial) { + const action = resolveAction(name as string) + if (!action) return + const prewritten = applyPrewrites(action, partial) + const resolved = resolveActionToRuntime(action, prewritten) + const ctx = buildContext(partial) + port.fire(resolved, ctx) + }, + start(name, partial) { + const sustain = resolveSustain(name as string) + if (!sustain) return INACTIVE_SEMA_SESSION + const resolved = resolveSustainToRuntime(sustain) + const ctx = buildContext(partial) + return port.startSustain(resolved, ctx) + } + } +} diff --git a/src/uix/sema/channels/color.test.ts b/src/uix/sema/channels/color.test.ts new file mode 100644 index 000000000..3d827d1d0 --- /dev/null +++ b/src/uix/sema/channels/color.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi } from 'vitest' + +import { ColorChannel } from './color' +import type { ColorSignature } from '../resolver' + +interface MockAnimation extends Partial { + cancel: ReturnType + finished: Promise + resolveFinished(): void + rejectFinished(reason?: unknown): void +} + +function createMockAnimation(): MockAnimation { + let resolveFinished = () => {} + let rejectFinished = (_reason?: unknown) => {} + const finished = new Promise((resolve, reject) => { + resolveFinished = resolve + rejectFinished = reject + }) + + return { + cancel: vi.fn(), + finished, + resolveFinished, + rejectFinished + } +} + +function createStyle(seed: Record = {}): CSSStyleDeclaration { + return seed as unknown as CSSStyleDeclaration +} + +function createEnvironment(opts: { + display?: string + datasetTechnique?: 'overlay' | 'outline' + withParent?: boolean +} = {}) { + const animations: MockAnimation[] = [] + const targetCalls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = [] + const overlayCalls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = [] + const removed: HTMLElement[] = [] + const queryNodes: Array<{ remove: ReturnType }> = [{ remove: vi.fn() }, { remove: vi.fn() }] + + const defaultView = { + getComputedStyle(node: HTMLElement) { + if (node === parent) { + return { position: 'static' } as CSSStyleDeclaration + } + return { + display: opts.display ?? 'block', + boxShadow: '0 0 0 1px rgb(0 0 0 / 0.3)' + } as CSSStyleDeclaration + } + } + + const overlayFactory = () => { + const animation = createMockAnimation() + animations.push(animation) + const style = createStyle() + const overlay = { + style, + setAttribute: vi.fn(), + remove: vi.fn(() => { + removed.push(overlay as unknown as HTMLElement) + }), + animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) { + overlayCalls.push({ keyframes, options }) + return animation as Animation + } + } + return overlay + } + + const doc = { + defaultView, + createElement: vi.fn(() => overlayFactory()), + querySelectorAll: vi.fn(() => queryNodes) + } + + const parent = { + style: createStyle(), + appendChild: vi.fn(), + ownerDocument: doc + } as unknown as HTMLElement + + const target = { + isConnected: true, + ownerDocument: doc, + parentElement: opts.withParent === false ? null : parent, + dataset: opts.datasetTechnique ? { semaColorTechnique: opts.datasetTechnique } : {}, + style: createStyle(), + animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) { + targetCalls.push({ keyframes, options }) + const animation = createMockAnimation() + animations.push(animation) + return animation as Animation + } + } as unknown as HTMLElement + + return { + channel: new ColorChannel(), + target, + parent, + doc, + targetCalls, + overlayCalls, + animations, + removed, + queryNodes + } +} + +const signature: ColorSignature = { + hue: 30, + saturation: 0.7, + lightness: 0.45, + duration: 180, + intensity: 0.5 +} + +describe('ColorChannel', () => { + it('uses box-shadow as the default additive technique', async () => { + const env = createEnvironment() + const controller = new AbortController() + + const pending = env.channel.apply(env.target, signature, controller.signal) + + expect(env.targetCalls).toHaveLength(1) + expect(env.targetCalls[0].keyframes[0]).toEqual({ + boxShadow: '0 0 0 1px rgb(0 0 0 / 0.3), 0 0 0 0px hsl(30, 70%, 45%)' + }) + expect(env.targetCalls[0].options).toEqual({ + duration: 180, + easing: 'ease-out', + fill: 'none' + }) + + env.animations[0].resolveFinished() + await pending + }) + + it('switches to overlay for inline targets', async () => { + const env = createEnvironment({ display: 'inline' }) + const controller = new AbortController() + + const pending = env.channel.apply(env.target, signature, controller.signal) + + expect(env.doc.createElement).toHaveBeenCalledWith('span') + expect(env.parent.appendChild).toHaveBeenCalledTimes(1) + expect(env.overlayCalls).toHaveLength(1) + expect(env.overlayCalls[0].options).toEqual({ + duration: 180, + easing: 'ease-out', + fill: 'none' + }) + + env.animations[0].resolveFinished() + await pending + expect(env.removed).toHaveLength(1) + }) + + it('uses outline when requested explicitly', async () => { + const env = createEnvironment({ datasetTechnique: 'outline' }) + const controller = new AbortController() + + const pending = env.channel.apply(env.target, signature, controller.signal) + + expect(env.targetCalls).toHaveLength(1) + expect(env.targetCalls[0].keyframes[1]).toEqual({ + outline: '2px solid hsl(30, 70%, 45%)', + offset: 0.3 + }) + + env.animations[0].resolveFinished() + await pending + }) + + it('cancels and removes overlay on abort', async () => { + const env = createEnvironment({ display: 'inline' }) + const controller = new AbortController() + + const pending = env.channel.apply(env.target, signature, controller.signal) + controller.abort() + env.animations[0].rejectFinished(new Error('cancelled')) + + await pending + expect(env.animations[0].cancel).toHaveBeenCalledTimes(1) + expect(env.removed).toHaveLength(1) + }) + + it('falls back to outline when overlay has no parent', async () => { + const env = createEnvironment({ display: 'inline', withParent: false }) + const controller = new AbortController() + + const pending = env.channel.apply(env.target, signature, controller.signal) + + expect(env.targetCalls).toHaveLength(1) + expect(env.overlayCalls).toHaveLength(0) + + env.animations[0].resolveFinished() + await pending + }) + + it('destroy() removes temporary overlays from the document', () => { + const env = createEnvironment() + const previousDocument = globalThis.document + + Object.assign(globalThis, { document: env.doc }) + try { + env.channel.destroy() + } finally { + Object.assign(globalThis, { document: previousDocument }) + } + + expect(env.queryNodes[0].remove).toHaveBeenCalledTimes(1) + expect(env.queryNodes[1].remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/uix/sema/channels/color.ts b/src/uix/sema/channels/color.ts new file mode 100644 index 000000000..ec40e71ae --- /dev/null +++ b/src/uix/sema/channels/color.ts @@ -0,0 +1,261 @@ +import type { ColorSignature } from '../resolver' + +type ColorTechnique = 'box-shadow' | 'overlay' | 'outline' + +type ColorTarget = HTMLElement & { + animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation + parentElement?: HTMLElement | null + dataset: DOMStringMap + style: CSSStyleDeclaration +} + +type DocLike = Pick +type OverlayElement = HTMLElement & { + animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation + remove(): void + style: CSSStyleDeclaration +} + +function hasAnimate(target: unknown): target is { animate: NonNullable } { + return typeof (target as { animate?: unknown })?.animate === 'function' +} + +function canUseDOM(target: HTMLElement): target is HTMLElement & { ownerDocument: Document } { + return !!target.ownerDocument +} + +function removeOverlay( + overlay: OverlayElement, + target: HTMLElement, + onRemove: () => void, + state: { removed: boolean } +): void { + if (state.removed) return + state.removed = true + overlay.remove() + onRemove() +} + +export class ColorChannel { + private readonly activeOverlays = new WeakMap() + + async apply( + target: HTMLElement, + signature: ColorSignature, + abortSignal: AbortSignal + ): Promise { + if (!target.isConnected) return + + switch (this.selectTechnique(target)) { + case 'box-shadow': + await this.applyBoxShadow(target, signature, abortSignal) + return + case 'overlay': + await this.applyOverlay(target, signature, abortSignal) + return + case 'outline': + await this.applyOutline(target, signature, abortSignal) + return + } + } + + destroy(): void { + if (typeof document === 'undefined') return + for (const node of document.querySelectorAll('[data-sema-temp]')) { + node.remove() + } + } + + private selectTechnique(target: HTMLElement): ColorTechnique { + const explicit = (target as ColorTarget).dataset?.semaColorTechnique + if (explicit === 'outline') return 'outline' + if (explicit === 'overlay') return 'overlay' + + if (!canUseDOM(target)) return 'outline' + const computed = target.ownerDocument.defaultView?.getComputedStyle(target) + if (computed?.display === 'inline') return 'overlay' + + return hasAnimate(target) ? 'box-shadow' : 'outline' + } + + private async applyBoxShadow( + target: HTMLElement, + signature: ColorSignature, + abortSignal: AbortSignal + ): Promise { + if (!hasAnimate(target) || !canUseDOM(target)) return + + const computed = target.ownerDocument.defaultView?.getComputedStyle(target) + const previousShadow = computed?.boxShadow && computed.boxShadow !== 'none' ? computed.boxShadow : '' + const color = this.toColor(signature) + const peakWidth = Math.max(1, Math.round(signature.intensity * 8)) + const animation = target.animate( + [ + { boxShadow: this.composeShadow(previousShadow, 0, color) }, + { boxShadow: this.composeShadow(previousShadow, peakWidth, color), offset: 0.3 }, + { boxShadow: this.composeShadow(previousShadow, 0, color) } + ], + { + duration: signature.duration, + easing: 'ease-out', + fill: 'none' + } + ) + + if (abortSignal.aborted) { + animation.cancel() + return + } + + const onAbort = () => animation.cancel() + abortSignal.addEventListener('abort', onAbort, { once: true }) + + try { + await animation.finished + } catch { + // degradación silenciosa + } finally { + abortSignal.removeEventListener('abort', onAbort) + } + } + + private async applyOverlay( + target: HTMLElement, + signature: ColorSignature, + abortSignal: AbortSignal + ): Promise { + if (!canUseDOM(target)) return + const doc = target.ownerDocument as DocLike + const parent = (target as ColorTarget).parentElement + if (!parent) { + await this.applyOutline(target, signature, abortSignal) + return + } + + const overlay = doc.createElement('span') as OverlayElement + overlay.setAttribute('data-sema-temp', '') + overlay.style.position = 'absolute' + overlay.style.inset = '0' + overlay.style.pointerEvents = 'none' + overlay.style.borderRadius = 'inherit' + overlay.style.boxShadow = `0 0 0 0 ${this.toColor(signature)}` + overlay.style.opacity = '0' + + const parentStyle = doc.defaultView?.getComputedStyle(parent) + if (parentStyle?.position === 'static') { + parent.style.position = 'relative' + } + + parent.appendChild(overlay) + this.trackOverlay(target, overlay) + const overlayState = { removed: false } + + if (!hasAnimate(overlay)) { + removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState) + return + } + + const peakWidth = Math.max(1, Math.round(signature.intensity * 8)) + const animation = overlay.animate( + [ + { boxShadow: `0 0 0 0 ${this.toColor(signature)}`, opacity: 0 }, + { + boxShadow: `0 0 0 ${peakWidth}px ${this.toColor(signature)}`, + opacity: 1, + offset: 0.3 + }, + { boxShadow: `0 0 0 0 ${this.toColor(signature)}`, opacity: 0 } + ], + { + duration: signature.duration, + easing: 'ease-out', + fill: 'none' + } + ) + + if (abortSignal.aborted) { + animation.cancel() + removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState) + return + } + + const onAbort = () => { + animation.cancel() + removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState) + } + abortSignal.addEventListener('abort', onAbort, { once: true }) + + try { + await animation.finished + } catch { + // cancelación silenciosa + } finally { + abortSignal.removeEventListener('abort', onAbort) + removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState) + } + } + + private async applyOutline( + target: HTMLElement, + signature: ColorSignature, + abortSignal: AbortSignal + ): Promise { + if (!hasAnimate(target)) return + + const color = this.toColor(signature) + const peakWidth = Math.max(2, Math.round(signature.intensity * 4)) + const animation = target.animate( + [ + { outline: `0px solid ${color}` }, + { outline: `${peakWidth}px solid ${color}`, offset: 0.3 }, + { outline: `0px solid ${color}` } + ], + { + duration: signature.duration, + easing: 'ease-out', + fill: 'none' + } + ) + + if (abortSignal.aborted) { + animation.cancel() + return + } + + const onAbort = () => animation.cancel() + abortSignal.addEventListener('abort', onAbort, { once: true }) + + try { + await animation.finished + } catch { + // degradación silenciosa + } finally { + abortSignal.removeEventListener('abort', onAbort) + } + } + + private composeShadow(previous: string, width: number, color: string): string { + const pulse = `0 0 0 ${width}px ${color}` + return previous ? `${previous}, ${pulse}` : pulse + } + + private toColor(signature: ColorSignature): string { + return `hsl(${signature.hue}, ${signature.saturation * 100}%, ${signature.lightness * 100}%)` + } + + private trackOverlay(target: HTMLElement, overlay: OverlayElement): void { + const existing = this.activeOverlays.get(target) ?? [] + existing.push(overlay) + this.activeOverlays.set(target, existing) + } + + private untrackOverlay(target: HTMLElement, overlay: OverlayElement): void { + const existing = this.activeOverlays.get(target) + if (!existing) return + const index = existing.indexOf(overlay) + if (index >= 0) existing.splice(index, 1) + if (existing.length === 0) { + this.activeOverlays.delete(target) + } + } +} diff --git a/src/uix/sema/channels/motion.test.ts b/src/uix/sema/channels/motion.test.ts new file mode 100644 index 000000000..04c82d388 --- /dev/null +++ b/src/uix/sema/channels/motion.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest' + +import { MotionChannel } from './motion' +import type { MotionSignature } from '../resolver' + +interface MockAnimation extends Partial { + cancel: ReturnType + finished: Promise + resolveFinished(): void + rejectFinished(reason?: unknown): void +} + +function createMockAnimation(): MockAnimation { + let resolveFinished = () => {} + let rejectFinished = (_reason?: unknown) => {} + const finished = new Promise((resolve, reject) => { + resolveFinished = resolve + rejectFinished = reject + }) + + return { + cancel: vi.fn(), + finished, + resolveFinished, + rejectFinished + } +} + +function createTarget(opts: { connected?: boolean } = {}) { + const animations: MockAnimation[] = [] + const calls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = [] + const target = { + isConnected: opts.connected ?? true, + animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) { + calls.push({ keyframes, options }) + const animation = createMockAnimation() + animations.push(animation) + return animation as Animation + } + } as unknown as HTMLElement + + return { target, calls, animations } +} + +const signature: MotionSignature = { + duration: 180, + easing: 'ease-out', + scale: { from: 1, to: 1.06 }, + translate: { x: 4, y: -2 }, + rotate: 6 +} + +describe('MotionChannel', () => { + it('animates with additive WAAPI options and resolves on finished', async () => { + const channel = new MotionChannel() + const { target, calls, animations } = createTarget() + const controller = new AbortController() + + const pending = channel.apply(target, signature, controller.signal) + + expect(calls).toHaveLength(1) + expect(calls[0].keyframes).toEqual([ + { transform: 'scale(1) translate(4px, -2px) rotate(6deg)' }, + { transform: 'scale(1.06) translate(4px, -2px) rotate(6deg)' } + ]) + expect(calls[0].options).toEqual({ + duration: 180, + easing: 'ease-out', + fill: 'none', + composite: 'add' + }) + + animations[0].resolveFinished() + await pending + expect(animations[0].cancel).not.toHaveBeenCalled() + }) + + it('cancels the animation when the abort signal fires', async () => { + const channel = new MotionChannel() + const { target, animations } = createTarget() + const controller = new AbortController() + + const pending = channel.apply(target, signature, controller.signal) + controller.abort() + animations[0].rejectFinished(new Error('cancelled')) + + await pending + expect(animations[0].cancel).toHaveBeenCalledTimes(1) + }) + + it('degrades silently when the target is disconnected or animate is missing', async () => { + const channel = new MotionChannel() + const disconnected = { isConnected: false } as HTMLElement + const missingAnimate = { isConnected: true } as HTMLElement + + await expect(channel.apply(disconnected, signature, new AbortController().signal)).resolves.toBeUndefined() + await expect(channel.apply(missingAnimate, signature, new AbortController().signal)).resolves.toBeUndefined() + }) + + it('returns a cleanup for sustained animations', () => { + const channel = new MotionChannel() + const { target, calls, animations } = createTarget() + + const cleanup = channel.applySustained(target, signature) + + expect(calls).toHaveLength(1) + expect(calls[0].options).toEqual({ + duration: 180, + easing: 'ease-out', + iterations: Infinity, + fill: 'none', + composite: 'add' + }) + + cleanup() + expect(animations[0].cancel).toHaveBeenCalledTimes(1) + }) + + it('swallows finished rejections from the browser', async () => { + const channel = new MotionChannel() + const { target, animations } = createTarget() + const controller = new AbortController() + + const pending = channel.apply(target, signature, controller.signal) + animations[0].rejectFinished(new Error('browser oddity')) + + await expect(pending).resolves.toBeUndefined() + }) +}) diff --git a/src/uix/sema/channels/motion.ts b/src/uix/sema/channels/motion.ts new file mode 100644 index 000000000..998b3f075 --- /dev/null +++ b/src/uix/sema/channels/motion.ts @@ -0,0 +1,125 @@ +import type { MotionSignature } from '../resolver' + +type MotionTarget = HTMLElement & { + animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation +} + +function hasAnimate(target: HTMLElement): target is MotionTarget { + return typeof (target as MotionTarget).animate === 'function' +} + +function noopCleanup(): void {} + +export class MotionChannel { + private readonly activeAnimations = new WeakMap() + + async apply( + target: HTMLElement, + signature: MotionSignature, + abortSignal: AbortSignal + ): Promise { + if (!target.isConnected || !hasAnimate(target)) return + + const animation = target.animate( + this.signatureToKeyframes(signature), + this.signatureToOptions(signature) + ) + this.trackAnimation(target, animation) + + if (abortSignal.aborted) { + animation.cancel() + this.untrackAnimation(target, animation) + return + } + + const onAbort = () => animation.cancel() + abortSignal.addEventListener('abort', onAbort, { once: true }) + + try { + await animation.finished + } catch { + // Cancelada o rechazada por el navegador. Sema degrada silenciosamente. + } finally { + abortSignal.removeEventListener('abort', onAbort) + this.untrackAnimation(target, animation) + } + } + + applySustained(target: HTMLElement, signature: MotionSignature): () => void { + if (!target.isConnected || !hasAnimate(target)) return noopCleanup + + const animation = target.animate(this.signatureToKeyframes(signature), { + duration: signature.duration || 1000, + easing: signature.easing, + iterations: Infinity, + fill: 'none', + composite: 'add' + }) + this.trackAnimation(target, animation) + + return () => { + animation.cancel() + this.untrackAnimation(target, animation) + } + } + + destroy(): void { + // No-op. El canal no mantiene estado global iterable; el DOM y WeakMap + // permiten que las animaciones queden acotadas al lifecycle del target. + } + + private signatureToKeyframes(signature: MotionSignature): Keyframe[] { + const fromTransforms: string[] = [] + const toTransforms: string[] = [] + + if (signature.scale) { + fromTransforms.push(`scale(${signature.scale.from})`) + toTransforms.push(`scale(${signature.scale.to})`) + } + + if (signature.translate) { + fromTransforms.push(`translate(${signature.translate.x}px, ${signature.translate.y}px)`) + toTransforms.push(`translate(${signature.translate.x}px, ${signature.translate.y}px)`) + } + + if (signature.rotate !== undefined) { + fromTransforms.push(`rotate(${signature.rotate}deg)`) + toTransforms.push(`rotate(${signature.rotate}deg)`) + } + + const from: Keyframe = {} + const to: Keyframe = {} + + if (fromTransforms.length > 0) { + from.transform = fromTransforms.join(' ') + to.transform = toTransforms.join(' ') + } + + return [from, to] + } + + private signatureToOptions(signature: MotionSignature): KeyframeAnimationOptions { + return { + duration: signature.duration, + easing: signature.easing, + fill: 'none', + composite: 'add' + } + } + + private trackAnimation(target: HTMLElement, animation: Animation): void { + const existing = this.activeAnimations.get(target) ?? [] + existing.push(animation) + this.activeAnimations.set(target, existing) + } + + private untrackAnimation(target: HTMLElement, animation: Animation): void { + const existing = this.activeAnimations.get(target) + if (!existing) return + const index = existing.indexOf(animation) + if (index >= 0) existing.splice(index, 1) + if (existing.length === 0) { + this.activeAnimations.delete(target) + } + } +} diff --git a/src/uix/sema/channels/presence.test.ts b/src/uix/sema/channels/presence.test.ts new file mode 100644 index 000000000..8a97e4637 --- /dev/null +++ b/src/uix/sema/channels/presence.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { PresenceChannel } from './presence' +import type { PresenceSignature } from '../resolver' + +interface MockAnimation extends Partial { + cancel: ReturnType + finished: Promise + resolveFinished(): void + rejectFinished(reason?: unknown): void +} + +function createMockAnimation(): MockAnimation { + let resolveFinished = () => {} + let rejectFinished = (_reason?: unknown) => {} + const finished = new Promise((resolve, reject) => { + resolveFinished = resolve + rejectFinished = reject + }) + + return { + cancel: vi.fn(), + finished, + resolveFinished, + rejectFinished + } +} + +function createStyle(seed: Record = {}): CSSStyleDeclaration { + return seed as unknown as CSSStyleDeclaration +} + +function createEnvironment() { + const animations: MockAnimation[] = [] + const targetCalls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = [] + const appended: HTMLElement[] = [] + const removed: HTMLElement[] = [] + const queryNodes: Array<{ remove: ReturnType }> = [{ remove: vi.fn() }, { remove: vi.fn() }] + + const defaultView = { + getComputedStyle() { + return { + boxShadow: '0 1px 2px rgb(0 0 0 / 0.2)' + } as CSSStyleDeclaration + } + } + + const doc = { + defaultView, + querySelectorAll: vi.fn(() => queryNodes), + body: { + appendChild: vi.fn((node: HTMLElement) => { + appended.push(node) + }) + }, + createElement: vi.fn(() => { + const style = createStyle() + return { + style, + setAttribute: vi.fn(), + remove: vi.fn(function () { + removed.push(this as unknown as HTMLElement) + }), + getBoundingClientRect: vi.fn(() => ({}) as DOMRect) + } + }) + } + + const target = { + isConnected: true, + ownerDocument: doc, + animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) { + targetCalls.push({ keyframes, options }) + const animation = createMockAnimation() + animations.push(animation) + return animation as Animation + } + } as unknown as HTMLElement + + return { + channel: new PresenceChannel(), + target, + doc, + animations, + targetCalls, + appended, + removed, + queryNodes + } +} + +const signature: PresenceSignature = { + opacity: { from: 0.6, to: 1 }, + shadow: { blur: 18, y: 6, opacity: 0.4 }, + backdrop: 0.35, + outline: { width: 2, style: 'solid' }, + duration: 180, + easing: 'ease-out' +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('PresenceChannel', () => { + it('applies opacity, shadow, outline and backdrop together', async () => { + vi.useFakeTimers() + const env = createEnvironment() + const controller = new AbortController() + + const pending = env.channel.apply(env.target, signature, controller.signal) + + expect(env.targetCalls).toHaveLength(3) + expect(env.targetCalls[0].keyframes).toEqual([{ opacity: 0.6 }, { opacity: 1 }]) + expect(env.targetCalls[0].options).toEqual({ + duration: 180, + easing: 'ease-out', + fill: 'forwards' + }) + expect(env.targetCalls[1].keyframes).toEqual([ + { boxShadow: '0 1px 2px rgb(0 0 0 / 0.2)' }, + { boxShadow: '0 1px 2px rgb(0 0 0 / 0.2), 0 6px 18px rgba(0,0,0,0.4)' } + ]) + expect(env.targetCalls[2].keyframes[1]).toEqual({ + outline: '2px solid currentColor', + offset: 0.3 + }) + expect(env.appended).toHaveLength(1) + + env.animations[0].resolveFinished() + env.animations[1].resolveFinished() + env.animations[2].resolveFinished() + await vi.advanceTimersByTimeAsync(235) + await pending + + expect(env.removed).toHaveLength(1) + }) + + it('cancels target animations and removes backdrop on abort', async () => { + vi.useFakeTimers() + const env = createEnvironment() + const controller = new AbortController() + + const pending = env.channel.apply(env.target, signature, controller.signal) + controller.abort() + env.animations[0].rejectFinished(new Error('cancelled')) + env.animations[1].rejectFinished(new Error('cancelled')) + env.animations[2].rejectFinished(new Error('cancelled')) + await vi.runAllTimersAsync() + await pending + + expect(env.animations[0].cancel).toHaveBeenCalledTimes(1) + expect(env.animations[1].cancel).toHaveBeenCalledTimes(1) + expect(env.animations[2].cancel).toHaveBeenCalledTimes(1) + expect(env.removed).toHaveLength(1) + }) + + it('creates a sustained backdrop and cleans it up on stop', () => { + const env = createEnvironment() + + const cleanup = env.channel.applySustained(env.target, signature) + + expect(env.appended).toHaveLength(1) + cleanup() + expect(env.removed).toHaveLength(1) + }) + + it('degrades silently when there is no animate support', async () => { + vi.useFakeTimers() + const env = createEnvironment() + const target = { + isConnected: true, + ownerDocument: env.doc + } as HTMLElement + const controller = new AbortController() + + const pending = env.channel.apply(target, signature, controller.signal) + await vi.advanceTimersByTimeAsync(235) + await pending + + expect(env.appended).toHaveLength(1) + expect(env.removed).toHaveLength(1) + }) + + it('destroy() removes all persistent backdrops from the document', () => { + const env = createEnvironment() + const previousDocument = globalThis.document + + Object.assign(globalThis, { document: env.doc }) + try { + env.channel.destroy() + } finally { + Object.assign(globalThis, { document: previousDocument }) + } + + expect(env.queryNodes[0].remove).toHaveBeenCalledTimes(1) + expect(env.queryNodes[1].remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/uix/sema/channels/presence.ts b/src/uix/sema/channels/presence.ts new file mode 100644 index 000000000..dedfcb81d --- /dev/null +++ b/src/uix/sema/channels/presence.ts @@ -0,0 +1,256 @@ +import type { PresenceSignature } from '../resolver' + +type PresenceTarget = HTMLElement & { + animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation + ownerDocument: Document +} + +type BackdropElement = HTMLElement & { + style: CSSStyleDeclaration + remove(): void + getBoundingClientRect(): DOMRect +} + +type DocLike = Pick + +function hasAnimate(target: unknown): target is { animate: NonNullable } { + return typeof (target as { animate?: unknown })?.animate === 'function' +} + +function canUseDOM(target: HTMLElement): target is PresenceTarget { + return !!target.ownerDocument +} + +function removeBackdrop(backdrop: BackdropElement, state: { removed: boolean }): void { + if (state.removed) return + state.removed = true + backdrop.remove() +} + +export class PresenceChannel { + async apply( + target: HTMLElement, + signature: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!target.isConnected) return + + const promises: Promise[] = [] + + if (signature.opacity && hasAnimate(target)) { + promises.push(this.applyOpacity(target, signature, abortSignal)) + } + if (signature.shadow && hasAnimate(target)) { + promises.push(this.applyShadow(target, signature, abortSignal)) + } + if (signature.backdrop !== undefined && signature.backdrop > 0) { + promises.push(this.applyBackdrop(target, signature, abortSignal)) + } + if (signature.outline && hasAnimate(target)) { + promises.push(this.applyOutline(target, signature, abortSignal)) + } + + if (promises.length === 0) return + await Promise.all(promises) + } + + applySustained(target: HTMLElement, signature: PresenceSignature): () => void { + if (!target.isConnected || !canUseDOM(target)) return () => {} + const cleanups: Array<() => void> = [] + + if (signature.backdrop !== undefined && signature.backdrop > 0) { + const backdrop = this.createBackdrop(target.ownerDocument as unknown as DocLike, signature) + target.ownerDocument.body?.appendChild(backdrop) + cleanups.push(() => backdrop.remove()) + } + + return () => { + for (const cleanup of cleanups.splice(0)) { + cleanup() + } + } + } + + destroy(): void { + if (typeof document === 'undefined') return + for (const node of document.querySelectorAll('[data-sema-backdrop]')) { + node.remove() + } + } + + private async applyOpacity( + target: HTMLElement, + signature: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!hasAnimate(target)) return + const animation = target.animate( + [ + { opacity: signature.opacity.from }, + { opacity: signature.opacity.to } + ], + { + duration: signature.duration, + easing: signature.easing, + fill: 'forwards' + } + ) + + if (abortSignal.aborted) { + animation.cancel() + return + } + + const onAbort = () => animation.cancel() + abortSignal.addEventListener('abort', onAbort, { once: true }) + + try { + await animation.finished + } catch { + // degradación silenciosa + } finally { + abortSignal.removeEventListener('abort', onAbort) + } + } + + private async applyShadow( + target: HTMLElement, + signature: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!signature.shadow || !hasAnimate(target) || !canUseDOM(target)) return + + const computed = target.ownerDocument.defaultView?.getComputedStyle(target) + const previousShadow = computed?.boxShadow && computed.boxShadow !== 'none' ? computed.boxShadow : '' + const shadowEnd = `0 ${signature.shadow.y}px ${signature.shadow.blur}px rgba(0,0,0,${signature.shadow.opacity})` + const animation = target.animate( + [ + { boxShadow: this.composeShadow(previousShadow, 'none') }, + { boxShadow: this.composeShadow(previousShadow, shadowEnd) } + ], + { + duration: signature.duration, + easing: signature.easing, + fill: 'none' + } + ) + + if (abortSignal.aborted) { + animation.cancel() + return + } + + const onAbort = () => animation.cancel() + abortSignal.addEventListener('abort', onAbort, { once: true }) + + try { + await animation.finished + } catch { + // degradación silenciosa + } finally { + abortSignal.removeEventListener('abort', onAbort) + } + } + + private async applyBackdrop( + target: HTMLElement, + signature: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!canUseDOM(target)) return + const doc = target.ownerDocument as unknown as DocLike + if (!doc.body) return + + const backdrop = this.createBackdrop(doc, signature) + const removalState = { removed: false } + backdrop.style.opacity = '0' + doc.body.appendChild(backdrop) + backdrop.getBoundingClientRect() + backdrop.style.opacity = '1' + + await new Promise((resolve) => { + const fadeTimer = setTimeout(() => { + backdrop.style.opacity = '0' + const cleanupTimer = setTimeout(() => { + removeBackdrop(backdrop, removalState) + resolve() + }, signature.duration) + + const onAbortLate = () => { + clearTimeout(cleanupTimer) + removeBackdrop(backdrop, removalState) + resolve() + } + abortSignal.addEventListener('abort', onAbortLate, { once: true }) + }, signature.duration * 0.3) + + const onAbort = () => { + clearTimeout(fadeTimer) + removeBackdrop(backdrop, removalState) + resolve() + } + + if (abortSignal.aborted) { + onAbort() + return + } + + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) + } + + private async applyOutline( + target: HTMLElement, + signature: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!signature.outline || !hasAnimate(target)) return + + const animation = target.animate( + [ + { outline: `0px ${signature.outline.style} currentColor` }, + { outline: `${signature.outline.width}px ${signature.outline.style} currentColor`, offset: 0.3 }, + { outline: `0px ${signature.outline.style} currentColor` } + ], + { + duration: signature.duration, + easing: signature.easing, + fill: 'none' + } + ) + + if (abortSignal.aborted) { + animation.cancel() + return + } + + const onAbort = () => animation.cancel() + abortSignal.addEventListener('abort', onAbort, { once: true }) + + try { + await animation.finished + } catch { + // degradación silenciosa + } finally { + abortSignal.removeEventListener('abort', onAbort) + } + } + + private composeShadow(previous: string, pulse: string): string { + if (pulse === 'none') return previous || 'none' + return previous ? `${previous}, ${pulse}` : pulse + } + + private createBackdrop(doc: DocLike, signature: PresenceSignature): BackdropElement { + const backdrop = doc.createElement('div') as BackdropElement + backdrop.setAttribute('data-sema-backdrop', '') + backdrop.style.position = 'fixed' + backdrop.style.inset = '0' + backdrop.style.background = `rgba(0, 0, 0, ${signature.backdrop ?? 0})` + backdrop.style.backdropFilter = 'blur(4px)' + backdrop.style.pointerEvents = 'none' + backdrop.style.zIndex = '9998' + backdrop.style.transition = `opacity ${signature.duration}ms ${signature.easing}` + return backdrop + } +} diff --git a/src/uix/sema/channels/sound.test.ts b/src/uix/sema/channels/sound.test.ts new file mode 100644 index 000000000..f367aeb9a --- /dev/null +++ b/src/uix/sema/channels/sound.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { SoundChannel } from './sound' +import type { SoundSignature } from '../resolver' + +function createAudioParam() { + return { + value: 0, + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn() + } +} + +function createGainNode() { + return { + gain: createAudioParam(), + connect: vi.fn() + } +} + +function createOscillatorNode() { + return { + type: 'sine', + frequency: { value: 0 }, + detune: createAudioParam(), + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn() + } +} + +function createBiquadFilterNode() { + return { + type: 'lowpass', + frequency: { value: 0 }, + Q: { value: 0 }, + connect: vi.fn() + } +} + +function createBufferSourceNode() { + return { + buffer: null as AudioBuffer | null, + connect: vi.fn(), + start: vi.fn(function () { + setTimeout(() => this.onended?.(), 0) + }), + stop: vi.fn(function () { + this.onended?.() + }), + onended: null as null | (() => void) + } +} + +function createAudioContext(state: AudioContextState = 'running') { + const gains: ReturnType[] = [] + const oscillators: ReturnType[] = [] + const filters: ReturnType[] = [] + const sources: ReturnType[] = [] + + const ctx = { + state, + currentTime: 0, + destination: {}, + createGain: vi.fn(() => { + const node = createGainNode() + gains.push(node) + return node as unknown as GainNode + }), + createOscillator: vi.fn(() => { + const node = createOscillatorNode() + oscillators.push(node) + return node as unknown as OscillatorNode + }), + createBiquadFilter: vi.fn(() => { + const node = createBiquadFilterNode() + filters.push(node) + return node as unknown as BiquadFilterNode + }), + createBufferSource: vi.fn(() => { + const node = createBufferSourceNode() + sources.push(node) + return node as unknown as AudioBufferSourceNode + }), + decodeAudioData: vi.fn(async (_buffer: ArrayBuffer) => ({}) as AudioBuffer), + resume: vi.fn(async () => { + ctx.state = 'running' + }), + close: vi.fn(async () => {}) + } + + return { + ctx: ctx as unknown as AudioContext, + gains, + oscillators, + filters, + sources + } +} + +const baseSignature: SoundSignature = { + pitch: 700, + centroid: 1800, + roughness: 0.4, + attack: 8, + decay: 120, + duration: 120, + contour: 'ascending', + gain: 0.7 +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('SoundChannel', () => { + it('synthesizes an earcon with oscillators, filter and contour', async () => { + vi.useFakeTimers() + const audio = createAudioContext('running') + const addEventListener = vi.fn() + const removeEventListener = vi.fn() + const channel = new SoundChannel({ + audioContextFactory: () => audio.ctx, + doc: { addEventListener, removeEventListener } + }) + + const pending = channel.apply(baseSignature, 0.8, new AbortController().signal) + await vi.advanceTimersByTimeAsync(baseSignature.duration) + await pending + + expect(audio.gains).toHaveLength(5) + expect(audio.oscillators).toHaveLength(3) + expect(audio.filters).toHaveLength(1) + expect(audio.gains[0].connect).toHaveBeenCalledWith((audio.ctx as any).destination) + expect(audio.oscillators[0].frequency.value).toBe(700) + expect(audio.oscillators[1].frequency.value).toBe(1050) + expect(audio.filters[0].frequency.value).toBe(1800) + expect(audio.oscillators[0].detune.setValueAtTime).toHaveBeenCalledWith(-50, 0) + expect(audio.oscillators[0].detune.linearRampToValueAtTime).toHaveBeenCalledWith(50, 0.12) + expect(addEventListener).toHaveBeenCalled() + channel.destroy() + expect(audio.ctx.close).toHaveBeenCalledTimes(1) + expect(removeEventListener).toHaveBeenCalled() + }) + + it('degrades silently when the context stays suspended', async () => { + const audio = createAudioContext('suspended') + audio.ctx.resume = vi.fn(async () => { + // keep suspended on purpose + }) as unknown as AudioContext['resume'] + const channel = new SoundChannel({ + audioContextFactory: () => audio.ctx + }) + + await expect(channel.apply(baseSignature, 0.8, new AbortController().signal)).resolves.toBeUndefined() + expect(audio.ctx.resume).toHaveBeenCalledTimes(1) + expect(audio.oscillators).toHaveLength(0) + }) + + it('plays and caches sample earcons', async () => { + const audio = createAudioContext('running') + const fetchFn = vi.fn(async () => ({ + arrayBuffer: async () => new ArrayBuffer(8) + })) + const channel = new SoundChannel({ + audioContextFactory: () => audio.ctx, + fetchFn + }) + const signature: SoundSignature = { + ...baseSignature, + sampleUrl: '/sounds/alarm.wav' + } + + await channel.apply(signature, 0.8, new AbortController().signal) + await channel.apply(signature, 0.8, new AbortController().signal) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(audio.ctx.decodeAudioData).toHaveBeenCalledTimes(1) + expect(audio.sources).toHaveLength(2) + expect(audio.sources[0].start).toHaveBeenCalledTimes(1) + }) + + it('aborts synthesis cleanly', async () => { + vi.useFakeTimers() + const audio = createAudioContext('running') + const channel = new SoundChannel({ + audioContextFactory: () => audio.ctx + }) + const controller = new AbortController() + + const pending = channel.apply(baseSignature, 0.8, controller.signal) + controller.abort() + await vi.runAllTimersAsync() + await pending + + expect(audio.oscillators[0].stop).toHaveBeenCalled() + expect(audio.oscillators[1].stop).toHaveBeenCalled() + }) + + it('preloads sample buffers opportunistically', async () => { + const audio = createAudioContext('running') + const fetchFn = vi.fn(async () => ({ + arrayBuffer: async () => new ArrayBuffer(8) + })) + const channel = new SoundChannel({ + audioContextFactory: () => audio.ctx, + fetchFn + }) + + await channel.preloadSamples(['/a.wav', '/a.wav', '/b.wav']) + + expect(fetchFn).toHaveBeenCalledTimes(2) + expect(audio.ctx.decodeAudioData).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/uix/sema/channels/sound.ts b/src/uix/sema/channels/sound.ts new file mode 100644 index 000000000..6edeb365e --- /dev/null +++ b/src/uix/sema/channels/sound.ts @@ -0,0 +1,322 @@ +import type { SoundSignature } from '../resolver' + +type AudioContextCtor = new () => AudioContext + +export interface SoundChannelOptions { + audioContextFactory?: () => AudioContext | null + fetchFn?: typeof fetch + doc?: Pick +} + +function getGlobalAudioContextCtor(): AudioContextCtor | null { + const maybeCtor = ( + globalThis as typeof globalThis & { + AudioContext?: AudioContextCtor + webkitAudioContext?: AudioContextCtor + } + ).AudioContext ?? + (globalThis as typeof globalThis & { + webkitAudioContext?: AudioContextCtor + }).webkitAudioContext + + return maybeCtor ?? null +} + +function safeStop(node: { stop(when?: number): void } | null | undefined, when?: number): void { + if (!node) return + try { + node.stop(when) + } catch { + // already stopped or unavailable + } +} + +function noopCleanup(): void {} + +export class SoundChannel { + private audioCtx: AudioContext | null = null + private masterGain: GainNode | null = null + private readonly sampleCache = new Map() + private readonly fetchFn?: typeof fetch + private readonly audioContextFactory?: () => AudioContext | null + private readonly doc?: Pick + private teardownUnlock?: () => void + + constructor(opts: SoundChannelOptions = {}) { + this.fetchFn = opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined) + this.audioContextFactory = opts.audioContextFactory + this.doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined) + } + + async apply( + signature: SoundSignature, + masterGainValue: number, + abortSignal: AbortSignal + ): Promise { + const ctx = await this.getOrCreateContext() + if (!ctx || ctx.state !== 'running' || !this.masterGain) return + + this.masterGain.gain.value = masterGainValue + + if (signature.sampleUrl) { + await this.playSample(ctx, signature, abortSignal) + return + } + + await this.synthesize(ctx, signature, abortSignal) + } + + applySustained(): () => void { + return noopCleanup + } + + async preloadSamples(urls: string[]): Promise { + const ctx = await this.getOrCreateContext() + if (!ctx || !this.fetchFn) return + + const uniqueUrls = [...new Set(urls)] + + await Promise.all(uniqueUrls.map(async (url) => { + if (this.sampleCache.has(url)) return + try { + const response = await this.fetchFn!(url) + const arrayBuffer = await response.arrayBuffer() + const buffer = await ctx.decodeAudioData(arrayBuffer) + this.sampleCache.set(url, buffer) + } catch { + // fail silently; preload is opportunistic + } + })) + } + + destroy(): void { + this.teardownUnlock?.() + this.teardownUnlock = undefined + if (this.audioCtx) { + this.audioCtx.close().catch(() => {}) + this.audioCtx = null + this.masterGain = null + } + } + + private async getOrCreateContext(): Promise { + if (!this.audioCtx) { + try { + this.audioCtx = this.audioContextFactory?.() ?? this.createContextFromGlobals() + if (!this.audioCtx) return null + this.masterGain = this.audioCtx.createGain() + this.masterGain.connect(this.audioCtx.destination) + this.setupUnlockListener() + } catch { + this.audioCtx = null + this.masterGain = null + return null + } + } + + if (this.audioCtx.state === 'suspended') { + try { + await this.audioCtx.resume() + } catch { + return this.audioCtx + } + } + + return this.audioCtx + } + + private createContextFromGlobals(): AudioContext | null { + const Ctor = getGlobalAudioContextCtor() + return Ctor ? new Ctor() : null + } + + private setupUnlockListener(): void { + if (!this.doc || this.teardownUnlock) return + + const events = ['click', 'touchstart', 'keydown'] as const + const unlock = () => { + this.audioCtx?.resume().catch(() => {}) + } + + for (const eventName of events) { + this.doc.addEventListener(eventName, unlock, true) + } + + this.teardownUnlock = () => { + for (const eventName of events) { + this.doc?.removeEventListener(eventName, unlock, true) + } + } + } + + private async synthesize( + ctx: AudioContext, + signature: SoundSignature, + abortSignal: AbortSignal + ): Promise { + if (!this.masterGain) return + + const now = ctx.currentTime + const durationSec = signature.duration / 1000 + const attackSec = signature.attack / 1000 + const decaySec = signature.decay / 1000 + + const osc1 = ctx.createOscillator() + osc1.type = 'sine' + osc1.frequency.value = signature.pitch + + const osc2 = ctx.createOscillator() + osc2.type = 'sine' + osc2.frequency.value = signature.pitch * 1.5 + + const mixer = ctx.createGain() + mixer.gain.value = 1 + + const osc2Gain = ctx.createGain() + osc2Gain.gain.value = 0.3 + + osc1.connect(mixer) + osc2.connect(osc2Gain) + osc2Gain.connect(mixer) + + const filter = ctx.createBiquadFilter() + filter.type = 'lowpass' + filter.frequency.value = signature.centroid + filter.Q.value = 1 + mixer.connect(filter) + + const envelope = ctx.createGain() + envelope.gain.setValueAtTime(0, now) + envelope.gain.linearRampToValueAtTime(signature.gain, now + attackSec) + envelope.gain.linearRampToValueAtTime( + Math.max(signature.gain * 0.75, 0.0001), + now + attackSec + decaySec + ) + envelope.gain.linearRampToValueAtTime(0.0001, now + durationSec) + filter.connect(envelope) + envelope.connect(this.masterGain) + + let modulator: OscillatorNode | null = null + if (signature.roughness > 0.2) { + modulator = ctx.createOscillator() + modulator.type = 'sine' + modulator.frequency.value = 30 + (signature.roughness - 0.2) * 150 + const modulatorGain = ctx.createGain() + modulatorGain.gain.value = signature.roughness * 0.5 + modulator.connect(modulatorGain) + modulatorGain.connect(envelope.gain) + } + + this.applyContour(osc1, signature.contour, now, durationSec) + + osc1.start(now) + osc2.start(now) + modulator?.start(now) + osc1.stop(now + durationSec) + osc2.stop(now + durationSec) + modulator?.stop(now + durationSec) + + await new Promise((resolve) => { + const timer = setTimeout(() => resolve(), signature.duration) + const onAbort = () => { + clearTimeout(timer) + safeStop(osc1) + safeStop(osc2) + safeStop(modulator) + resolve() + } + + if (abortSignal.aborted) { + onAbort() + return + } + + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) + } + + private applyContour( + osc: OscillatorNode, + contour: SoundSignature['contour'], + startTime: number, + durationSec: number + ): void { + const endTime = startTime + durationSec + switch (contour) { + case 'flat': + osc.detune.value = 0 + break + case 'ascending': + osc.detune.setValueAtTime(-50, startTime) + osc.detune.linearRampToValueAtTime(50, endTime) + break + case 'descending': + osc.detune.setValueAtTime(50, startTime) + osc.detune.linearRampToValueAtTime(-50, endTime) + break + case 'arc': + osc.detune.setValueAtTime(-25, startTime) + osc.detune.linearRampToValueAtTime(50, startTime + durationSec * 0.5) + osc.detune.linearRampToValueAtTime(-25, endTime) + break + case 'bell': + osc.detune.setValueAtTime(25, startTime) + osc.detune.linearRampToValueAtTime(-50, startTime + durationSec * 0.5) + osc.detune.linearRampToValueAtTime(25, endTime) + break + } + } + + private async playSample( + ctx: AudioContext, + signature: SoundSignature, + abortSignal: AbortSignal + ): Promise { + if (!signature.sampleUrl || !this.fetchFn || !this.masterGain) return + + let buffer = this.sampleCache.get(signature.sampleUrl) + if (!buffer) { + try { + const response = await this.fetchFn(signature.sampleUrl) + const arrayBuffer = await response.arrayBuffer() + buffer = await ctx.decodeAudioData(arrayBuffer) + this.sampleCache.set(signature.sampleUrl, buffer) + } catch { + return + } + } + + if (!buffer || abortSignal.aborted) return + + const source = ctx.createBufferSource() + source.buffer = buffer + const envelope = ctx.createGain() + envelope.gain.value = signature.gain + source.connect(envelope) + envelope.connect(this.masterGain) + + await new Promise((resolve) => { + let settled = false + const finish = () => { + if (settled) return + settled = true + resolve() + } + + source.onended = finish + source.start() + + const onAbort = () => { + safeStop(source) + finish() + } + + if (abortSignal.aborted) { + onAbort() + return + } + + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) + } +} diff --git a/src/uix/sema/engine.test.ts b/src/uix/sema/engine.test.ts new file mode 100644 index 000000000..910c9ed49 --- /dev/null +++ b/src/uix/sema/engine.test.ts @@ -0,0 +1,364 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + _resetEngineForTesting, + configureSema, + destroySema, + getSemaEngine, + SemaEngine, + type ColorChannelDriver, + type ColorSignature, + type MotionChannelDriver, + type MotionSignature, + type PresenceChannelDriver, + type SemaChannelDrivers, + type SemaEventDetail, + type SoundChannelDriver +} from './engine' +import type { ResolvedSemaAction, ResolvedSemaSustain, SemaContext } from './port' + +class FakeElement extends EventTarget { + isConnected = true + ownerDocument!: { documentElement: FakeElement } + private readonly attrs = new Map() + + getAttribute(name: string): string | null { + return this.attrs.has(name) ? this.attrs.get(name)! : null + } + + setAttribute(name: string, value: string): void { + this.attrs.set(name, value) + } + + removeAttribute(name: string): void { + this.attrs.delete(name) + } + + matches(): boolean { + return false + } + + closest(): Element | null { + return null + } + + disconnect(): void { + this.isConnected = false + } +} + +function createDom() { + const documentElement = new FakeElement() + const ownerDocument = { documentElement } + documentElement.ownerDocument = ownerDocument + + const target = new FakeElement() + target.ownerDocument = ownerDocument + + const root = new FakeElement() + root.ownerDocument = ownerDocument + + return { documentElement, target, root } +} + +function createAction( + overrides: Partial = {} +): ResolvedSemaAction { + return { + name: 'close-save', + component: 'dialog', + event: 'commit-fulfill', + mode: 'blocking', + regime: 'replace', + scope: 'part', + target: 'content', + prewritten: [], + ...overrides + } +} + +function createSustain( + overrides: Partial = {} +): ResolvedSemaSustain { + return { + name: 'open', + component: 'dialog', + target: 'content', + scope: 'part', + ...overrides + } +} + +function createContext(targetEl: FakeElement, rootEl?: FakeElement): SemaContext { + return { + targetEl: targetEl as unknown as HTMLElement, + rootEl: rootEl as unknown as HTMLElement | undefined, + snapshot: {}, + partEls: {}, + cause: 'programmatic' + } +} + +function createDelayedMotionDriver(ms: number): MotionChannelDriver & { apply: ReturnType } { + return { + apply: vi.fn(async (_target: HTMLElement, _signature: MotionSignature, abortSignal: AbortSignal) => { + await new Promise((resolve) => { + const timer = setTimeout(() => resolve(), ms) + abortSignal.addEventListener( + 'abort', + () => { + clearTimeout(timer) + resolve() + }, + { once: true } + ) + }) + }), + applySustained: () => () => {}, + destroy() {} + } +} + +function createImmediateDrivers(overrides: Partial = {}) { + const motion: MotionChannelDriver = { + async apply() {}, + applySustained: () => () => {}, + destroy() {} + } + const sound: SoundChannelDriver = { + async apply() {}, + applySustained: () => () => {}, + destroy() {} + } + const color: ColorChannelDriver = { + async apply() {}, + applySustained: () => () => {}, + destroy() {} + } + const presence: PresenceChannelDriver = { + async apply() {}, + applySustained: () => () => {}, + destroy() {} + } + + return { + motion, + sound, + color, + presence, + ...overrides + } +} + +afterEach(() => { + vi.useRealTimers() + _resetEngineForTesting() +}) + +describe('SemaEngine', () => { + it('emits sema:event and reflects attrs around a blocking choreography', async () => { + const { target, root } = createDom() + const phases: SemaEventDetail[] = [] + target.addEventListener('sema:event', (event) => { + phases.push((event as CustomEvent).detail) + }) + + const engine = new SemaEngine({ + channels: createImmediateDrivers() + }) + engine.configure({ reflectEvents: true }) + + const pending = engine.before(createAction(), createContext(target, root)) + expect(target.getAttribute('data-sema-active')).toBe('commit-fulfill') + expect(target.getAttribute('data-sema-phase')).toBe('active') + + await pending + + expect(target.getAttribute('data-sema-active')).toBe(null) + expect(target.getAttribute('data-sema-phase')).toBe(null) + expect(phases.map((entry) => entry.phase)).toEqual(['start', 'end']) + expect(phases[0].channels).toContain('motion') + expect(phases[0].duration).toBeGreaterThan(0) + }) + + it('coalesces collapse actions into a single in-flight choreography', async () => { + vi.useFakeTimers() + const { target } = createDom() + const motion = createDelayedMotionDriver(1000) + const engine = new SemaEngine({ + channels: createImmediateDrivers({ motion }) + }) + engine.configure({ + sound: { enabled: false }, + color: { enabled: false }, + presence: { enabled: false }, + capBlockingMs: 20 + }) + const action = createAction({ regime: 'collapse' }) + + const first = engine.before(action, createContext(target)) + const second = engine.before(action, createContext(target)) + + expect(motion.apply).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(20) + await first + await second + }) + + it('locks equivalent actions while one is active', async () => { + vi.useFakeTimers() + const { target } = createDom() + const motion = createDelayedMotionDriver(1000) + const engine = new SemaEngine({ + channels: createImmediateDrivers({ motion }) + }) + engine.configure({ + sound: { enabled: false }, + color: { enabled: false }, + presence: { enabled: false }, + capBlockingMs: 20 + }) + const action = createAction({ regime: 'lock' }) + + const first = engine.before(action, createContext(target)) + await engine.before(action, createContext(target)) + + expect(motion.apply).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(20) + await first + }) + + it('queues equivalent actions sequentially after the blocking cap releases', async () => { + vi.useFakeTimers() + const { target } = createDom() + const motion = createDelayedMotionDriver(1000) + const engine = new SemaEngine({ + channels: createImmediateDrivers({ motion }) + }) + engine.configure({ + sound: { enabled: false }, + color: { enabled: false }, + presence: { enabled: false }, + capBlockingMs: 20 + }) + const action = createAction({ regime: 'queue' }) + + const first = engine.before(action, createContext(target)) + const second = engine.before(action, createContext(target)) + + expect(motion.apply).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(20) + expect(motion.apply).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(20) + await first + await second + }) + + it('replaces an active choreography and marks the first one as cancelled', async () => { + vi.useFakeTimers() + const { target } = createDom() + const motion = createDelayedMotionDriver(1000) + const phases: Array = [] + target.addEventListener('sema:event', (event) => { + phases.push((event as CustomEvent).detail.phase) + }) + const engine = new SemaEngine({ + channels: createImmediateDrivers({ motion }) + }) + engine.configure({ + sound: { enabled: false }, + color: { enabled: false }, + presence: { enabled: false }, + capBlockingMs: 20 + }) + const action = createAction({ regime: 'replace' }) + + const first = engine.before(action, createContext(target)) + const second = engine.before(action, createContext(target)) + + expect(motion.apply).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(20) + await first + await second + + expect(phases.filter((phase) => phase === 'start')).toHaveLength(2) + expect(phases).toContain('cancelled') + expect(phases).toContain('end') + }) + + it('starts sustains and runs cleanups on stop()', () => { + const { target } = createDom() + const motionCleanup = vi.fn() + const presenceCleanup = vi.fn() + const engine = new SemaEngine({ + channels: createImmediateDrivers({ + motion: { + async apply() {}, + applySustained: () => motionCleanup, + destroy() {} + }, + presence: { + async apply() {}, + applySustained: () => presenceCleanup, + destroy() {} + } + }) + }) + + const session = engine.startSustain(createSustain(), createContext(target)) + expect(session.active).toBe(true) + + session.stop() + + expect(session.active).toBe(false) + expect(motionCleanup).toHaveBeenCalledTimes(0) + expect(presenceCleanup).toHaveBeenCalledTimes(1) + }) + + it('re-resolves signatures after map override reconfiguration', async () => { + const { target } = createDom() + const seen: ColorSignature[] = [] + const color: ColorChannelDriver = { + async apply(_target, signature) { + seen.push(signature) + }, + applySustained: () => () => {}, + destroy() {} + } + const engine = new SemaEngine({ + channels: createImmediateDrivers({ color }) + }) + engine.configure({ + sound: { enabled: false }, + motion: { enabled: false }, + presence: { enabled: false }, + mapOverrides: { + 'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 } + } + }) + + await engine.before(createAction(), createContext(target)) + + expect(seen).toHaveLength(1) + expect(seen[0].hue).toBe(0) + }) + + it('exposes a singleton facade and resets it on destroy', () => { + const first = getSemaEngine() + configureSema({ reflectEvents: true }) + const second = getSemaEngine() + + expect(first).toBe(second) + expect(second.currentConfig.reflectEvents).toBe(true) + + destroySema() + + const third = getSemaEngine() + expect(third).not.toBe(first) + }) +}) diff --git a/src/uix/sema/engine.ts b/src/uix/sema/engine.ts new file mode 100644 index 000000000..1ebf7c183 --- /dev/null +++ b/src/uix/sema/engine.ts @@ -0,0 +1,551 @@ +import { DEV } from 'esm-env' + +import { + A11yMonitor, + DEFAULT_SEMA_RUNTIME_CONFIG, + type SemaRuntimeConfig +} from './a11y' +import { ColorChannel } from './channels/color' +import { MotionChannel } from './channels/motion' +import { PresenceChannel } from './channels/presence' +import { SoundChannel } from './channels/sound' +import type { + ColorSignature, + EffectiveSignature, + MotionSignature, + PresenceSignature, + RuntimeOverrides, + SoundSignature +} from './resolver' +import { Resolver } from './resolver' +import type { + ResolvedSemaAction, + ResolvedSemaSustain, + SemaContext, + SemaPort, + SemaSession +} from './port' + +type SemaPhase = 'start' | 'end' | 'cancelled' + +export interface SemaEventDetail { + event: ResolvedSemaAction['event'] + action: string + component: string + phase: SemaPhase + channels: EffectiveSignature['activeChannels'] + duration: number +} + +export interface MotionChannelDriver { + apply(target: HTMLElement, signature: MotionSignature, abortSignal: AbortSignal): Promise + applySustained?(target: HTMLElement, signature: MotionSignature): () => void + destroy?(): void +} + +export interface SoundChannelDriver { + apply(signature: SoundSignature, gain: number, abortSignal: AbortSignal): Promise + applySustained?(signature: SoundSignature, gain: number): () => void + destroy?(): void +} + +export interface ColorChannelDriver { + apply(target: HTMLElement, signature: ColorSignature, abortSignal: AbortSignal): Promise + applySustained?(target: HTMLElement, signature: ColorSignature): () => void + destroy?(): void +} + +export interface PresenceChannelDriver { + apply(target: HTMLElement, signature: PresenceSignature, abortSignal: AbortSignal): Promise + applySustained?(target: HTMLElement, signature: PresenceSignature): () => void + destroy?(): void +} + +export interface SemaChannelDrivers { + motion: MotionChannelDriver + sound: SoundChannelDriver + color: ColorChannelDriver + presence: PresenceChannelDriver +} + +export interface EngineDependencies { + resolver?: Resolver + a11y?: A11yMonitor + channels?: Partial +} + +export interface EngineConfig extends SemaRuntimeConfig { + mapOverrides?: RuntimeOverrides +} + +export interface EngineConfigPatch + extends Partial> { + sound?: Partial + motion?: Partial + color?: Partial + presence?: Partial +} + +const noopCleanup = () => {} + +const noopChannels: SemaChannelDrivers = { + motion: { + async apply() {}, + applySustained() { + return noopCleanup + }, + destroy() {} + }, + sound: { + async apply() {}, + applySustained() { + return noopCleanup + }, + destroy() {} + }, + color: { + async apply() {}, + applySustained() { + return noopCleanup + }, + destroy() {} + }, + presence: { + async apply() {}, + applySustained() { + return noopCleanup + }, + destroy() {} + } +} + +function mergeConfig(current: EngineConfig, patch: EngineConfigPatch): EngineConfig { + return { + ...current, + ...patch, + sound: patch.sound ? { ...current.sound, ...patch.sound } : current.sound, + motion: patch.motion ? { ...current.motion, ...patch.motion } : current.motion, + color: patch.color ? { ...current.color, ...patch.color } : current.color, + presence: patch.presence ? { ...current.presence, ...patch.presence } : current.presence, + mapOverrides: + 'mapOverrides' in patch ? structuredClone(patch.mapOverrides ?? {}) : current.mapOverrides + } +} + +function createSemaEvent(detail: SemaEventDetail): Event { + if (typeof CustomEvent === 'function') { + return new CustomEvent('sema:event', { + bubbles: true, + detail + }) + } + const event = new Event('sema:event', { bubbles: true }) as Event & { detail?: SemaEventDetail } + event.detail = detail + return event +} + +function isConnected(el: HTMLElement | undefined): boolean { + if (!el) return false + return el.isConnected !== false +} + +function swallowAbortable(work: Promise): Promise { + return work.catch(() => {}) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export class SemaEngine implements SemaPort { + private config: EngineConfig = { + ...DEFAULT_SEMA_RUNTIME_CONFIG + } + private readonly resolver: Resolver + private readonly a11y: A11yMonitor + private readonly drivers: SemaChannelDrivers + private readonly activeChoreographies = new Map() + private readonly sustainSessions = new Set() + private readonly elementIds = new WeakMap() + private nextElementId = 0 + + constructor(deps: EngineDependencies = {}) { + this.resolver = deps.resolver ?? new Resolver() + this.a11y = deps.a11y ?? new A11yMonitor() + this.drivers = { + motion: deps.channels?.motion ?? new MotionChannel(), + sound: deps.channels?.sound ?? new SoundChannel(), + color: deps.channels?.color ?? new ColorChannel(), + presence: deps.channels?.presence ?? new PresenceChannel() + } + } + + configure(patch: EngineConfigPatch): void { + this.config = mergeConfig(this.config, patch) + if ('mapOverrides' in patch) { + this.resolver.setRuntimeOverrides(patch.mapOverrides ?? {}) + } + } + + destroy(): void { + for (const choreography of [...this.activeChoreographies.values()]) { + choreography.cancel() + } + this.activeChoreographies.clear() + + for (const sustain of [...this.sustainSessions]) { + sustain.stop() + } + this.sustainSessions.clear() + + this.drivers.motion.destroy?.() + this.drivers.sound.destroy?.() + this.drivers.color.destroy?.() + this.drivers.presence.destroy?.() + } + + async before(action: ResolvedSemaAction, ctx: SemaContext): Promise { + const key = this.choreographyKey(action, ctx) + const existing = this.activeChoreographies.get(key) + + if (existing) { + switch (action.regime) { + case 'replace': + existing.cancel() + this.activeChoreographies.delete(key) + break + case 'collapse': + existing.markRepeated() + return existing.promise + case 'lock': + return + case 'queue': + await existing.promise + break + } + } + + let choreography: Choreography | null = null + let signature: EffectiveSignature | null = null + + try { + signature = this.a11y.reduceSignature(this.resolver.resolve(action.event, ctx.targetEl), this.config) + choreography = new Choreography(action, ctx, signature, this.a11y.getBlockingCapMs(this.config), this) + this.activeChoreographies.set(key, choreography) + + this.emitCustomEvent(ctx.targetEl, action, 'start', signature) + if (this.config.reflectEvents) { + ctx.targetEl.setAttribute('data-sema-active', action.event) + ctx.targetEl.setAttribute('data-sema-phase', 'active') + } + + const result = await choreography.run() + this.emitCustomEvent( + ctx.targetEl, + action, + result === 'cancelled' ? 'cancelled' : 'end', + signature + ) + } catch (error) { + this.warn(`[sema] engine.before("${action.name}") degraded: ${String(error)}`) + } finally { + if (this.config.reflectEvents) { + ctx.targetEl.removeAttribute('data-sema-active') + ctx.targetEl.removeAttribute('data-sema-phase') + } + if (choreography) { + this.activeChoreographies.delete(key) + } + } + } + + fire(action: ResolvedSemaAction, ctx: SemaContext): void { + this.before(action, ctx).catch(() => {}) + } + + startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession { + try { + const runner = new SustainRunner(sustain, ctx, this) + this.sustainSessions.add(runner) + runner.start() + return { + stop: () => { + runner.stop() + this.sustainSessions.delete(runner) + }, + get active() { + return runner.active + } + } + } catch (error) { + this.warn(`[sema] engine.startSustain("${sustain.name}") degraded: ${String(error)}`) + return { + stop() {}, + get active() { + return false + } + } + } + } + + get channels(): Readonly { + return this.drivers + } + + get currentConfig(): Readonly { + return this.config + } + + resolveReducedSignature(event: string, targetEl?: HTMLElement): EffectiveSignature { + return this.a11y.reduceSignature(this.resolver.resolve(event, targetEl), this.config) + } + + private choreographyKey(action: ResolvedSemaAction, ctx: SemaContext): string { + return `${action.component}:${action.name}:${this.elementId(this.scopeAnchor(action.scope, ctx))}` + } + + private scopeAnchor(scope: ResolvedSemaAction['scope'], ctx: SemaContext): HTMLElement { + if (scope === 'component') return ctx.rootEl ?? ctx.targetEl + if (scope === 'scene') { + return (ctx.rootEl?.ownerDocument?.documentElement as HTMLElement | undefined) ?? + (ctx.targetEl.ownerDocument?.documentElement as HTMLElement | undefined) ?? + ctx.rootEl ?? + ctx.targetEl + } + return ctx.targetEl + } + + private elementId(el: HTMLElement): string { + const existing = this.elementIds.get(el) + if (existing) return existing + const next = `el-${this.nextElementId++}` + this.elementIds.set(el, next) + return next + } + + private emitCustomEvent( + target: HTMLElement, + action: ResolvedSemaAction, + phase: SemaPhase, + signature: EffectiveSignature + ): void { + target.dispatchEvent( + createSemaEvent({ + event: action.event, + action: action.name, + component: action.component, + phase, + channels: signature.activeChannels, + duration: this.durationOfSignature(signature) + }) + ) + } + + private durationOfSignature(signature: EffectiveSignature): number { + const durations = [ + signature.motion?.duration, + signature.sound?.duration, + signature.color?.duration, + signature.presence?.duration + ].filter((value): value is number => value !== undefined) + + return durations.length > 0 ? Math.max(...durations) : 0 + } + + private warn(message: string): void { + if (DEV) console.warn(message) + } +} + +class Choreography { + readonly promise: Promise + + private cancelled = false + private repeatedCount = 0 + private settled = false + private readonly abortController = new AbortController() + private readonly settlePromise: () => void + private readonly detachExternalAbort?: () => void + + constructor( + private readonly action: ResolvedSemaAction, + private readonly ctx: SemaContext, + private readonly signature: EffectiveSignature, + private readonly capMs: number, + private readonly engine: SemaEngine + ) { + let resolvePromise = () => {} + this.promise = new Promise((resolve) => { + resolvePromise = resolve + }) + this.settlePromise = resolvePromise + + if (ctx.abortSignal) { + const onAbort = () => this.cancel() + if (ctx.abortSignal.aborted) { + this.cancel() + } else { + ctx.abortSignal.addEventListener('abort', onAbort, { once: true }) + this.detachExternalAbort = () => { + ctx.abortSignal?.removeEventListener('abort', onAbort) + } + } + } + } + + markRepeated(): void { + this.repeatedCount++ + } + + cancel(): void { + if (this.cancelled) return + this.cancelled = true + this.abortController.abort() + this.settle() + } + + async run(): Promise<'completed' | 'cancelled'> { + if (this.action.scope !== 'scene' && !isConnected(this.ctx.targetEl)) { + this.settle() + return 'cancelled' + } + + const channelPromises: Promise[] = [] + const config = this.engine.currentConfig + const channels = this.engine.channels + + if (this.signature.activeChannels.includes('motion') && config.motion.enabled && this.signature.motion) { + channelPromises.push( + swallowAbortable( + channels.motion.apply(this.ctx.targetEl, this.signature.motion, this.abortController.signal) + ) + ) + } + if (this.signature.activeChannels.includes('sound') && config.sound.enabled && this.signature.sound) { + channelPromises.push( + swallowAbortable( + channels.sound.apply(this.signature.sound, config.sound.gain, this.abortController.signal) + ) + ) + } + if (this.signature.activeChannels.includes('color') && config.color.enabled && this.signature.color) { + channelPromises.push( + swallowAbortable( + channels.color.apply(this.ctx.targetEl, this.signature.color, this.abortController.signal) + ) + ) + } + if ( + this.signature.activeChannels.includes('presence') && + config.presence.enabled && + this.signature.presence + ) { + channelPromises.push( + swallowAbortable( + channels.presence.apply(this.ctx.targetEl, this.signature.presence, this.abortController.signal) + ) + ) + } + + if (channelPromises.length === 0) { + this.settle() + return this.cancelled ? 'cancelled' : 'completed' + } + + const outcome = await Promise.race([ + Promise.all(channelPromises).then(() => 'completed' as const), + delay(this.capMs).then(() => 'completed' as const), + new Promise<'cancelled'>((resolve) => { + if (this.abortController.signal.aborted) { + resolve('cancelled') + return + } + this.abortController.signal.addEventListener( + 'abort', + () => resolve('cancelled'), + { once: true } + ) + }) + ]) + + this.settle() + return this.cancelled ? 'cancelled' : outcome + } + + private settle(): void { + if (this.settled) return + this.settled = true + this.detachExternalAbort?.() + this.settlePromise() + } +} + +class SustainRunner { + active = true + + private cleanups: Array<() => void> = [] + + constructor( + private readonly sustain: ResolvedSemaSustain, + private readonly ctx: SemaContext, + private readonly engine: SemaEngine + ) {} + + start(): void { + const signature = this.engine.resolveReducedSignature('sustain', this.ctx.targetEl) + const config = this.engine.currentConfig + const channels = this.engine.channels + + if (signature.activeChannels.includes('motion') && config.motion.enabled && signature.motion) { + this.cleanups.push(channels.motion.applySustained?.(this.ctx.targetEl, signature.motion) ?? noopCleanup) + } + if (signature.activeChannels.includes('sound') && config.sound.enabled && signature.sound) { + this.cleanups.push( + channels.sound.applySustained?.(signature.sound, config.sound.gain) ?? noopCleanup + ) + } + if (signature.activeChannels.includes('color') && config.color.enabled && signature.color) { + this.cleanups.push(channels.color.applySustained?.(this.ctx.targetEl, signature.color) ?? noopCleanup) + } + if (signature.activeChannels.includes('presence') && config.presence.enabled && signature.presence) { + this.cleanups.push( + channels.presence.applySustained?.(this.ctx.targetEl, signature.presence) ?? noopCleanup + ) + } + } + + stop(): void { + if (!this.active) return + this.active = false + for (const cleanup of this.cleanups.splice(0)) { + cleanup() + } + } +} + +let engineInstance: SemaEngine | null = null + +function getEngine(): SemaEngine { + if (!engineInstance) { + engineInstance = new SemaEngine() + } + return engineInstance +} + +export function getSemaEngine(): SemaEngine { + return getEngine() +} + +export function configureSema(config: EngineConfigPatch): void { + getEngine().configure(config) +} + +export function destroySema(): void { + if (!engineInstance) return + engineInstance.destroy() + engineInstance = null +} + +export function _resetEngineForTesting(): void { + destroySema() +} diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts new file mode 100644 index 000000000..e38f58e5d --- /dev/null +++ b/src/uix/sema/exports.ts @@ -0,0 +1,92 @@ +/** + * Sema — public surface. + * + * Sema is a standalone layer. It owns its types and its validator. The + * only import from outside is `PartRef`, a cross-layer primitive that + * lives in `$uix/lib/types` — not in morfo. Sema has zero dependency on + * the morfo module. + * + * Consumers (soma providers, demos, eventual runtime) import from here. + * Inside `src/uix/sema/` prefer direct file imports. + */ + +export type { + SemaEventLabel, + SemaAttrWrite, + SemaCommit, + SemaAction, + SemaSustainDecl, + SemaSpec +} from './types'; + +export { validateSema, SemaInvariantError } from './validation'; + +export type { + SemaFamilyName, + SemaIntentName, + SemaActiveChannel, + MotionSignature, + SoundSignature, + ColorSignature, + PresenceSignature, + EffectiveSignature, + SemaMap, + RuntimeOverrides, + CSEMSelectorOverride, + CSEMOverrides, + ResolverOptions +} from './resolver'; + +export { Resolver, defaultSemaMap } from './resolver'; + +export type { PartialSemaContext, ActionName, SustainName, SemaBinding } from './binding'; + +export { createSemaBinding } from './binding'; + +export type { + SemaRuntimeConfig, + MediaQueryListLike, + A11ySnapshot, + A11yMonitorOptions +} from './a11y'; + +export { A11yMonitor, DEFAULT_SEMA_RUNTIME_CONFIG } from './a11y'; + +export type { + SemaEventDetail, + MotionChannelDriver, + SoundChannelDriver, + ColorChannelDriver, + PresenceChannelDriver, + SemaChannelDrivers, + EngineDependencies, + EngineConfig, + EngineConfigPatch +} from './engine'; + +export { + SemaEngine, + getSemaEngine, + configureSema, + destroySema, + _resetEngineForTesting +} from './engine'; + +export { MotionChannel } from './channels/motion'; +export { ColorChannel } from './channels/color'; +export { PresenceChannel } from './channels/presence'; +export { SoundChannel } from './channels/sound'; + +export type { + ResolvedSemaAction, + ResolvedSemaSustain, + SemaContext, + SemaSession, + SemaPort, + TestSemaPortOptions, + TestSemaCall, + TestSemaSession, + TestSemaPortHandle +} from './port'; + +export { noopSemaPort, createTestSemaPort } from './port'; diff --git a/src/uix/sema/index.ts b/src/uix/sema/index.ts new file mode 100644 index 000000000..b44737ae2 --- /dev/null +++ b/src/uix/sema/index.ts @@ -0,0 +1 @@ +export * from './exports' diff --git a/src/uix/sema/port.test.ts b/src/uix/sema/port.test.ts new file mode 100644 index 000000000..49e268cc1 --- /dev/null +++ b/src/uix/sema/port.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' + +import { + createTestSemaPort, + noopSemaPort, + type ResolvedSemaAction, + type ResolvedSemaSustain, + type SemaContext +} from './port' + +const action: ResolvedSemaAction = { + name: 'close-save', + component: 'dialog', + event: 'commit-fulfill', + mode: 'blocking', + regime: 'lock', + scope: 'part', + target: 'content', + prewritten: [{ part: 'content', attr: 'data-last-action', value: 'saved' }] +} + +const sustain: ResolvedSemaSustain = { + name: 'loading', + component: 'spinner', + target: 'spinner', + scope: 'part' +} + +const ctx: SemaContext = { + targetEl: {} as HTMLElement, + snapshot: { + 'data-state': 'open' + }, + cause: 'pointer' +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('noopSemaPort', () => { + it('resolves before immediately and stays silent for fire', async () => { + await expect(noopSemaPort.before(action, ctx)).resolves.toBeUndefined() + expect(() => noopSemaPort.fire(action, ctx)).not.toThrow() + }) + + it('returns an inactive sustain session', () => { + const session = noopSemaPort.startSustain(sustain, ctx) + expect(session.active).toBe(false) + expect(() => session.stop()).not.toThrow() + }) +}) + +describe('createTestSemaPort', () => { + it('records before calls', async () => { + const handle = createTestSemaPort() + await handle.port.before(action, ctx) + expect(handle.calls).toHaveLength(1) + expect(handle.calls[0]).toMatchObject({ + kind: 'before', + action, + ctx + }) + }) + + it('records fire calls synchronously', () => { + const handle = createTestSemaPort() + handle.port.fire(action, ctx) + expect(handle.calls).toHaveLength(1) + expect(handle.calls[0].kind).toBe('fire') + }) + + it('creates active sustain sessions that can be stopped', () => { + const handle = createTestSemaPort() + const session = handle.port.startSustain(sustain, ctx) + expect(handle.sessions).toHaveLength(1) + expect(session.active).toBe(true) + expect(handle.sessions[0].stopped).toBe(false) + session.stop() + expect(session.active).toBe(false) + expect(handle.sessions[0].stopped).toBe(true) + }) + + it('resets captured calls and sessions', async () => { + const handle = createTestSemaPort() + await handle.port.before(action, ctx) + handle.port.startSustain(sustain, ctx) + expect(handle.calls).toHaveLength(1) + expect(handle.sessions).toHaveLength(1) + handle.reset() + expect(handle.calls).toHaveLength(0) + expect(handle.sessions).toHaveLength(0) + }) + + it('supports artificial before delay', async () => { + vi.useFakeTimers() + const handle = createTestSemaPort({ beforeDelay: 50 }) + const promise = handle.port.before(action, ctx) + let settled = false + void promise.then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(49) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await promise + expect(settled).toBe(true) + }) + + it('can resolve before early when abort is respected', async () => { + vi.useFakeTimers() + const handle = createTestSemaPort({ beforeDelay: 50, respectAbort: true }) + const controller = new AbortController() + const promise = handle.port.before(action, { + ...ctx, + abortSignal: controller.signal + }) + let settled = false + void promise.then(() => { + settled = true + }) + + controller.abort() + await promise + expect(settled).toBe(true) + }) +}) diff --git a/src/uix/sema/port.ts b/src/uix/sema/port.ts new file mode 100644 index 000000000..9d47e498d --- /dev/null +++ b/src/uix/sema/port.ts @@ -0,0 +1,181 @@ +/** + * Sema runtime port. + * + * Boundary between sema callers (providers / future binding) and the runtime + * implementation (real engine, no-op port, or test double). + */ + +import type { SemaAction, SemaEventLabel, SemaSustainDecl } from './types' + +type ResolvedSemaMode = NonNullable +type ResolvedSemaRegime = NonNullable +type ResolvedSemaScope = NonNullable + +/** + * Resolved action passed to the runtime. Defaults are already applied by the + * caller before invoking the port. + */ +export interface ResolvedSemaAction { + name: string + component: string + event: SemaEventLabel + mode: ResolvedSemaMode + regime: ResolvedSemaRegime + scope: ResolvedSemaScope + target: string + prewritten: readonly { part: string; attr: string; value: string }[] +} + +/** + * Resolved sustain declaration passed to the runtime. + */ +export interface ResolvedSemaSustain { + name: string + component: string + target: string + scope: NonNullable +} + +/** + * Runtime context built by the caller for an invocation. + */ +export interface SemaContext { + targetEl: HTMLElement + rootEl?: HTMLElement + partEls?: Partial> + snapshot: Record + cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation' + abortSignal?: AbortSignal +} + +export interface SemaSession { + stop(): void + readonly active: boolean +} + +export interface SemaPort { + before(action: ResolvedSemaAction, ctx: SemaContext): Promise + fire(action: ResolvedSemaAction, ctx: SemaContext): void + startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession +} + +/** + * No-op runtime boundary. Useful when the engine is not present yet or sema is + * globally disabled. Calls never throw and promises resolve immediately. + */ +export const noopSemaPort: SemaPort = { + before: async () => {}, + fire: () => {}, + startSustain: () => ({ + stop: () => {}, + active: false + }) +} + +export interface TestSemaPortOptions { + /** + * Optional artificial delay for `before()`, in milliseconds. + */ + beforeDelay?: number + /** + * When true, `before()` resolves early if `ctx.abortSignal` aborts. + * Default false to keep the smallest possible test double surface. + */ + respectAbort?: boolean +} + +export interface TestSemaCall { + kind: 'before' | 'fire' + action: ResolvedSemaAction + ctx: SemaContext + timestamp: number +} + +export interface TestSemaSession extends SemaSession { + sustain: ResolvedSemaSustain + ctx: SemaContext + readonly stopped: boolean +} + +export interface TestSemaPortHandle { + port: SemaPort + calls: TestSemaCall[] + sessions: TestSemaSession[] + reset(): void +} + +export function createTestSemaPort(opts: TestSemaPortOptions = {}): TestSemaPortHandle { + const calls: TestSemaCall[] = [] + const sessions: TestSemaSession[] = [] + + async function delay(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return; + if (!opts.respectAbort || !signal) { + await new Promise((resolve) => setTimeout(resolve, ms)) + return + } + if (signal.aborted) return + await new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + function onAbort() { + clearTimeout(timer) + signal.removeEventListener('abort', onAbort) + resolve() + } + signal.addEventListener('abort', onAbort, { once: true }) + }) + } + + const port: SemaPort = { + before: async (action, ctx) => { + calls.push({ + kind: 'before', + action, + ctx, + timestamp: Date.now() + }) + await delay(opts.beforeDelay ?? 0, ctx.abortSignal) + }, + fire: (action, ctx) => { + calls.push({ + kind: 'fire', + action, + ctx, + timestamp: Date.now() + }) + }, + startSustain: (sustain, ctx) => { + let active = true + let stopped = false + const session: TestSemaSession = { + get active() { + return active + }, + get stopped() { + return stopped + }, + stop() { + active = false + stopped = true + }, + sustain, + ctx + } + sessions.push(session) + return session + } + } + + return { + port, + calls, + sessions, + reset() { + calls.length = 0 + sessions.length = 0 + } + } +} diff --git a/src/uix/sema/resolver.test.ts b/src/uix/sema/resolver.test.ts new file mode 100644 index 000000000..3777818ed --- /dev/null +++ b/src/uix/sema/resolver.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest' + +import { Resolver, defaultSemaMap, type SemaMap } from './resolver' + +function createContextEl(opts: { + matches?: string[] + closest?: string[] +} = {}): HTMLElement { + const matchSet = new Set(opts.matches ?? []) + const closestSet = new Set(opts.closest ?? []) + return { + matches(selector: string) { + if (selector === '!!invalid!!') throw new Error('invalid selector') + return matchSet.has(selector) + }, + closest(selector: string) { + if (selector === '!!invalid!!') throw new Error('invalid selector') + return closestSet.has(selector) ? ({} as Element) : null + }, + ownerDocument: { + documentElement: { + matches(selector: string) { + return selector === ':root' + } + } + } + } as unknown as HTMLElement +} + +describe('Resolver', () => { + it('resolves a transitional event from family base', () => { + const resolver = new Resolver({ onWarn: () => {} }) + const sig = resolver.resolve('emerge') + + expect(sig.event).toBe('emerge') + expect(sig.activeChannels).toEqual(['motion', 'presence', 'sound']) + expect(sig.motion?.duration).toBe(240) + expect(sig.presence?.backdrop).toBe(0.35) + expect(sig.sound?.contour).toBe('ascending') + }) + + it('applies fulfill intent deltas over commit base', () => { + const resolver = new Resolver({ onWarn: () => {} }) + const sig = resolver.resolve('commit-fulfill') + + expect(sig.event).toBe('commit-fulfill') + expect(sig.motion?.duration).toBeCloseTo(207) + expect(sig.motion?.scale?.to).toBeCloseTo(1.04) + expect(sig.sound?.pitch).toBe(1000) + expect(sig.sound?.contour).toBe('ascending') + expect(sig.color?.hue).toBe(155) + expect(sig.color?.saturation).toBeCloseTo(0.4) + expect(sig.color?.intensity).toBeCloseTo(0.45) + }) + + it('falls back from invalid transitional+intent to the bare transitional event', () => { + const warnings: string[] = [] + const resolver = new Resolver({ onWarn: (msg) => warnings.push(msg) }) + const sig = resolver.resolve('emerge-threat') + + expect(sig.event).toBe('emerge') + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatch(/falling back to "emerge"/) + }) + + it('falls back from invalid valential intent to family-neutral', () => { + const warnings: string[] = [] + const resolver = new Resolver({ onWarn: (msg) => warnings.push(msg) }) + const sig = resolver.resolve('commit-happy') + + expect(sig.event).toBe('commit-neutral') + expect(sig.sound?.pitch).toBe(700) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatch(/commit-neutral/) + }) + + it('falls back to contact-neutral for unknown families', () => { + const warnings: string[] = [] + const resolver = new Resolver({ onWarn: (msg) => warnings.push(msg) }) + const sig = resolver.resolve('comit-fulfill') + + expect(sig.event).toBe('contact-neutral') + expect(sig.sound?.pitch).toBe(800) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatch(/contact-neutral/) + }) + + it('uses family base when the intent is missing from the map', () => { + const map = structuredClone(defaultSemaMap) as SemaMap + delete map.intents.fulfill + const warnings: string[] = [] + const resolver = new Resolver({ + map, + onWarn: (msg) => warnings.push(msg) + }) + const sig = resolver.resolve('commit-fulfill') + + expect(sig.event).toBe('commit-fulfill') + expect(sig.sound?.pitch).toBe(700) + expect(sig.color?.hue).toBe(210) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatch(/Intent "fulfill" missing/) + }) + + it('throws when the map is corrupted and a family base is missing', () => { + const map = structuredClone(defaultSemaMap) as SemaMap + delete map.families.commit + const resolver = new Resolver({ + map, + onWarn: () => {} + }) + + expect(() => resolver.resolve('commit-affirm')).toThrow(/Family "commit" not found/) + }) + + it('applies runtime overrides before resolving', () => { + const resolver = new Resolver({ + onWarn: () => {}, + runtimeOverrides: { + 'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 }, + 'families.commit.base.sound.pitch': 900 + } + }) + const sig = resolver.resolve('commit-fulfill') + + expect(sig.sound?.pitch).toBe(1200) + expect(sig.color?.hue).toBe(0) + }) + + it('attaches sampleUrl from the sound pack when present', () => { + const resolver = new Resolver({ + map: { + ...structuredClone(defaultSemaMap), + soundPack: { + 'alert-threat': '/sounds/alarm.wav' + } + }, + onWarn: () => {} + }) + + const sig = resolver.resolve('alert-threat') + expect(sig.sound?.sampleUrl).toBe('/sounds/alarm.wav') + }) + + it('applies CSEM overrides for a directly matching selector', () => { + const resolver = new Resolver({ + onWarn: () => {}, + csemOverrides: { + selectors: [ + { + selector: '[data-dialog][data-last-action="saved"]', + overrides: { + 'commit-fulfill': { + color: { + intensity: { op: 'replace', value: 0.6 } + } + } + } + } + ] + } + }) + + const sig = resolver.resolve( + 'commit-fulfill', + createContextEl({ matches: ['[data-dialog][data-last-action="saved"]'] }) + ) + expect(sig.color?.intensity).toBe(0.6) + }) + + it('applies CSEM overrides when an ancestor selector matches through closest()', () => { + const resolver = new Resolver({ + onWarn: () => {}, + csemOverrides: { + selectors: [ + { + selector: '.quiet-zone', + overrides: { + 'alert-threat': { + sound: { + gain: { op: 'replace', value: 0.15 } + } + } + } + } + ] + } + }) + + const sig = resolver.resolve('alert-threat', createContextEl({ closest: ['.quiet-zone'] })) + expect(sig.sound?.gain).toBe(0.15) + }) + + it('applies :root CSEM overrides globally', () => { + const resolver = new Resolver({ + onWarn: () => {}, + csemOverrides: { + selectors: [ + { + selector: ':root', + overrides: { + 'commit-fulfill': { + sound: { + pitch: { op: 'replace', value: 1200 } + } + } + } + } + ] + } + }) + + const sig = resolver.resolve('commit-fulfill', createContextEl()) + expect(sig.sound?.pitch).toBe(1200) + }) + + it('can replace runtime overrides after construction', () => { + const resolver = new Resolver({ + onWarn: () => {}, + runtimeOverrides: { + 'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 } + } + }) + + resolver.setRuntimeOverrides({ + 'intents.fulfill.deltas.color.hue': { op: 'replace', value: 270 } + }) + + const sig = resolver.resolve('commit-fulfill') + expect(sig.color?.hue).toBe(270) + }) + + it('ignores invalid CSEM selectors with a warning', () => { + const warnings: string[] = [] + const resolver = new Resolver({ + onWarn: (msg) => warnings.push(msg), + csemOverrides: { + selectors: [ + { + selector: '!!invalid!!', + overrides: { + 'commit-fulfill': { + color: { + intensity: { op: 'replace', value: 0.9 } + } + } + } + } + ] + } + }) + + const sig = resolver.resolve('commit-fulfill', createContextEl()) + expect(sig.color?.intensity).toBeCloseTo(0.45) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatch(/Invalid CSEM selector/) + }) +}) diff --git a/src/uix/sema/resolver.ts b/src/uix/sema/resolver.ts new file mode 100644 index 000000000..55ec6fea1 --- /dev/null +++ b/src/uix/sema/resolver.ts @@ -0,0 +1,323 @@ +import { DEV } from 'esm-env' + +import type { SemaEventLabel } from './types' +import semaMapJson from './sema-map.json' + +export type SemaFamilyName = 'contact' | 'commit' | 'alert' | 'handle' | 'emerge' | 'sustain' +export type SemaIntentName = 'threat' | 'risk' | 'neutral' | 'affirm' | 'fulfill' +export type SemaActiveChannel = 'motion' | 'sound' | 'color' | 'presence' + +export interface MotionSignature { + duration: number + easing: string + scale?: { from: number; to: number } + translate?: { x: number; y: number } + rotate?: number +} + +export interface SoundSignature { + pitch: number + centroid: number + roughness: number + attack: number + decay: number + duration: number + contour: 'flat' | 'ascending' | 'descending' | 'arc' | 'bell' + gain: number + sampleUrl?: string +} + +export interface ColorSignature { + hue: number + saturation: number + lightness: number + duration: number + intensity: number +} + +export interface PresenceSignature { + opacity: { from: number; to: number } + shadow?: { blur: number; y: number; opacity: number } + backdrop?: number + outline?: { width: number; style: string } + duration: number + easing: string +} + +export interface EffectiveSignature { + event: SemaEventLabel + activeChannels: SemaActiveChannel[] + motion?: MotionSignature + sound?: SoundSignature + color?: ColorSignature + presence?: PresenceSignature +} + +type DeltaOp = + | { op: 'multiply'; factor: number } + | { op: 'replace'; value: number | string | boolean | null } + | { op: 'add'; value: number } + +type DeltaValue = number | string | boolean | null | DeltaOp | { [key: string]: DeltaValue } + +interface FamilyMapEntry { + base: { + motion: MotionSignature | null + sound: SoundSignature | null + color: ColorSignature | null + presence: PresenceSignature | null + } + activeChannels: SemaActiveChannel[] +} + +interface IntentMapEntry { + deltas: Record +} + +export interface SemaMap { + version: string + families: Record + intents: Record + soundPack: Record +} + +export type RuntimeOverrides = Record +export interface CSEMSelectorOverride { + selector: string + overrides: Record> +} + +export interface CSEMOverrides { + selectors: CSEMSelectorOverride[] +} + +export interface ResolverOptions { + map?: SemaMap + runtimeOverrides?: RuntimeOverrides + csemOverrides?: CSEMOverrides + onWarn?: (message: string) => void +} + +const DEFAULT_EVENT: SemaEventLabel = 'contact-neutral' +const VALENTIAL_FAMILIES: SemaFamilyName[] = ['contact', 'commit', 'alert', 'handle'] +const TRANSITIONAL_FAMILIES: SemaFamilyName[] = ['emerge', 'sustain'] +const KNOWN_INTENTS: SemaIntentName[] = ['threat', 'risk', 'neutral', 'affirm', 'fulfill'] + +export const defaultSemaMap = semaMapJson as SemaMap + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isDeltaOp(value: unknown): value is DeltaOp { + return isRecord(value) && typeof value.op === 'string' +} + +function deepClone(value: T): T { + return structuredClone(value) +} + +function toCanonicalEvent(family: SemaFamilyName, intent: SemaIntentName | null): SemaEventLabel { + if (!intent) return family as Extract + return `${family}-${intent}` as SemaEventLabel +} + +function applyLeaf(base: unknown, delta: DeltaValue): unknown { + if (typeof delta === 'number') { + return typeof base === 'number' ? base + delta : delta + } + if (typeof delta === 'string' || typeof delta === 'boolean' || delta === null) { + return delta + } + if (isDeltaOp(delta)) { + if (delta.op === 'replace') return delta.value + if (typeof base !== 'number') return base + if (delta.op === 'multiply') return base * delta.factor + return base + delta.value + } + if (!isRecord(delta)) return base + if (!isRecord(base)) return base + + const out: Record = deepClone(base) + for (const [key, nextDelta] of Object.entries(delta)) { + out[key] = applyLeaf(out[key], nextDelta as DeltaValue) + } + return out +} + +function applyMapOverrides(baseMap: SemaMap, overrides: RuntimeOverrides = {}): SemaMap { + const next = deepClone(baseMap) + for (const [path, value] of Object.entries(overrides)) { + const parts = path.split('.') + let cursor: Record = next as unknown as Record + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i] + if (!isRecord(cursor[key])) cursor[key] = {} + cursor = cursor[key] as Record + } + cursor[parts[parts.length - 1]] = value + } + return next +} + +export class Resolver { + private readonly baseMap: SemaMap + private map: SemaMap + private runtimeOverrides: RuntimeOverrides + private readonly csemOverrides?: CSEMOverrides + private readonly onWarn?: (message: string) => void + + constructor(opts: ResolverOptions = {}) { + this.baseMap = deepClone(opts.map ?? defaultSemaMap) + this.runtimeOverrides = deepClone(opts.runtimeOverrides ?? {}) + this.map = applyMapOverrides(this.baseMap, this.runtimeOverrides) + this.csemOverrides = opts.csemOverrides + this.onWarn = opts.onWarn + } + + setRuntimeOverrides(overrides: RuntimeOverrides = {}): void { + this.runtimeOverrides = deepClone(overrides) + this.map = applyMapOverrides(this.baseMap, this.runtimeOverrides) + } + + resolve(event: string, contextEl?: HTMLElement): EffectiveSignature { + const normalized = this.normalizeEvent(event) + const familyData = this.map.families[normalized.family] + if (!familyData) { + throw new Error(`[sema] Family "${normalized.family}" not found in sema-map`) + } + + let signature: EffectiveSignature = { + event: normalized.event, + activeChannels: [...familyData.activeChannels], + motion: familyData.base.motion ? deepClone(familyData.base.motion) : undefined, + sound: familyData.base.sound ? deepClone(familyData.base.sound) : undefined, + color: familyData.base.color ? deepClone(familyData.base.color) : undefined, + presence: familyData.base.presence ? deepClone(familyData.base.presence) : undefined + } + + if (normalized.intent) { + const intentData = this.map.intents[normalized.intent] + if (!intentData) { + this.warn( + `[sema] Intent "${normalized.intent}" missing in sema-map; using family base for "${normalized.event}".` + ) + } else { + signature = this.applyIntentDelta(signature, intentData.deltas) + } + } + + if (contextEl && this.csemOverrides) { + signature = this.applyCSEMOverrides(signature, contextEl) + } + + const sampleUrl = this.map.soundPack[normalized.event] + if (sampleUrl && signature.sound) { + signature.sound.sampleUrl = sampleUrl + } + + return signature + } + + private normalizeEvent(event: string): { + event: SemaEventLabel + family: SemaFamilyName + intent: SemaIntentName | null + } { + const [first, ...rest] = event.split('-') + const family = first as SemaFamilyName + const intentText = rest.length > 0 ? rest.join('-') : null + + if (TRANSITIONAL_FAMILIES.includes(family)) { + if (intentText) { + this.warn( + `[sema] Event "${event}" is invalid for transitional family "${family}"; falling back to "${family}".` + ) + } + return { + event: toCanonicalEvent(family, null), + family, + intent: null + } + } + + if (VALENTIAL_FAMILIES.includes(family)) { + if (intentText && KNOWN_INTENTS.includes(intentText as SemaIntentName)) { + return { + event: toCanonicalEvent(family, intentText as SemaIntentName), + family, + intent: intentText as SemaIntentName + } + } + this.warn( + `[sema] Event "${event}" has invalid or missing intent for family "${family}"; falling back to "${family}-neutral".` + ) + return { + event: toCanonicalEvent(family, 'neutral'), + family, + intent: 'neutral' + } + } + + this.warn( + `[sema] Event "${event}" is not canonical; falling back to "${DEFAULT_EVENT}".` + ) + return { + event: DEFAULT_EVENT, + family: 'contact', + intent: 'neutral' + } + } + + private applyIntentDelta( + signature: EffectiveSignature, + deltas: Record + ): EffectiveSignature { + const next = deepClone(signature) + for (const channel of next.activeChannels) { + const delta = deltas[channel] + if (!delta) continue + const current = next[channel] + if (!current) continue + next[channel] = applyLeaf(current, delta) as never + } + return next + } + + private applyCSEMOverrides(signature: EffectiveSignature, contextEl: HTMLElement): EffectiveSignature { + let next = deepClone(signature) + for (const rule of this.csemOverrides?.selectors ?? []) { + if (!this.matchesSelector(contextEl, rule.selector)) continue + const eventOverrides = rule.overrides[next.event] + if (!eventOverrides) continue + for (const channel of next.activeChannels) { + const delta = eventOverrides[channel] + if (!delta) continue + const current = next[channel] + if (!current) continue + next[channel] = applyLeaf(current, delta) as never + } + } + return next + } + + private matchesSelector(contextEl: HTMLElement, selector: string): boolean { + try { + if (selector === ':root') { + return contextEl.ownerDocument?.documentElement?.matches(':root') ?? false + } + return contextEl.matches(selector) || contextEl.closest(selector) !== null + } catch { + this.warn(`[sema] Invalid CSEM selector "${selector}" ignored.`) + return false + } + } + + private warn(message: string): void { + if (this.onWarn) { + this.onWarn(message) + return + } + if (DEV) console.warn(message) + } +} diff --git a/src/uix/sema/sema-map.json b/src/uix/sema/sema-map.json new file mode 100644 index 000000000..c4ce81527 --- /dev/null +++ b/src/uix/sema/sema-map.json @@ -0,0 +1,210 @@ +{ + "version": "0.4.0", + "families": { + "contact": { + "base": { + "motion": { + "duration": 80, + "easing": "ease-out", + "scale": { "from": 1, "to": 0.96 }, + "translate": { "x": 0, "y": 0 } + }, + "sound": { + "pitch": 800, + "centroid": 2000, + "roughness": 0.1, + "attack": 4, + "decay": 40, + "duration": 60, + "contour": "flat", + "gain": 0.25 + }, + "color": null, + "presence": null + }, + "activeChannels": ["motion", "sound"] + }, + "commit": { + "base": { + "motion": { + "duration": 180, + "easing": "ease-out", + "scale": { "from": 1, "to": 1.02 } + }, + "sound": { + "pitch": 700, + "centroid": 1800, + "roughness": 0.1, + "attack": 8, + "decay": 120, + "duration": 100, + "contour": "flat", + "gain": 0.3 + }, + "color": { + "hue": 210, + "saturation": 0.3, + "lightness": 0.5, + "duration": 200, + "intensity": 0.3 + }, + "presence": null + }, + "activeChannels": ["motion", "sound", "color"] + }, + "alert": { + "base": { + "motion": { + "duration": 220, + "easing": "ease-in-out", + "scale": { "from": 1, "to": 1.03 }, + "translate": { "x": 0, "y": 0 } + }, + "sound": { + "pitch": 900, + "centroid": 2400, + "roughness": 0.3, + "attack": 3, + "decay": 150, + "duration": 180, + "contour": "arc", + "gain": 0.4 + }, + "color": { + "hue": 40, + "saturation": 0.7, + "lightness": 0.55, + "duration": 220, + "intensity": 0.5 + }, + "presence": null + }, + "activeChannels": ["motion", "sound", "color"] + }, + "emerge": { + "base": { + "motion": { + "duration": 240, + "easing": "ease-out", + "scale": { "from": 0.96, "to": 1 } + }, + "sound": { + "pitch": 600, + "centroid": 1500, + "roughness": 0.05, + "attack": 12, + "decay": 200, + "duration": 150, + "contour": "ascending", + "gain": 0.2 + }, + "color": null, + "presence": { + "opacity": { "from": 0, "to": 1 }, + "shadow": { "blur": 24, "y": 8, "opacity": 0.15 }, + "backdrop": 0.35, + "duration": 280, + "easing": "ease-out" + } + }, + "activeChannels": ["motion", "presence", "sound"] + }, + "handle": { + "base": { + "motion": { + "duration": 40, + "easing": "linear", + "scale": { "from": 1, "to": 1 } + }, + "sound": null, + "color": null, + "presence": null + }, + "activeChannels": ["motion"] + }, + "sustain": { + "base": { + "motion": null, + "sound": null, + "color": null, + "presence": { + "opacity": { "from": 1, "to": 1 }, + "duration": 0, + "easing": "linear" + } + }, + "activeChannels": ["presence"] + } + }, + "intents": { + "threat": { + "deltas": { + "motion": { + "duration": { "op": "multiply", "factor": 1.1 }, + "easing": "ease-in-out", + "scale": { "to": 0.01 } + }, + "sound": { + "pitch": -200, + "roughness": 0.4, + "contour": "descending", + "gain": 0.1 + }, + "color": { + "hue": { "op": "replace", "value": 0 }, + "saturation": { "op": "add", "value": 0.2 }, + "intensity": 0.2 + }, + "presence": { + "backdrop": 0.1, + "shadow": { "blur": 2 } + } + } + }, + "risk": { + "deltas": { + "sound": { + "pitch": -100, + "roughness": 0.2 + }, + "color": { + "hue": { "op": "replace", "value": 30 }, + "saturation": 0.1 + } + } + }, + "neutral": { + "deltas": {} + }, + "affirm": { + "deltas": { + "sound": { "pitch": 100 }, + "color": { + "hue": { "op": "replace", "value": 145 } + } + } + }, + "fulfill": { + "deltas": { + "motion": { + "duration": { "op": "multiply", "factor": 1.15 }, + "scale": { "to": 0.02 } + }, + "sound": { + "pitch": 300, + "contour": "ascending", + "gain": 0.05 + }, + "color": { + "hue": { "op": "replace", "value": 155 }, + "saturation": { "op": "add", "value": 0.1 }, + "intensity": 0.15 + }, + "presence": { + "shadow": { "blur": 1 } + } + } + } + }, + "soundPack": {} +} diff --git a/src/uix/sema/sema-runtime-architecture _v01.md b/src/uix/sema/sema-runtime-architecture _v01.md new file mode 100644 index 000000000..95ba544f4 --- /dev/null +++ b/src/uix/sema/sema-runtime-architecture _v01.md @@ -0,0 +1,2979 @@ +# Sema Runtime — Arquitectura de Implementación + +> Documento de arquitectura técnica para implementar el runtime Sema. Cubre todas las partes del sistema: port, binding, resolver, engine, los cuatro canales (motion, sound, color, presence), pipeline de build para `.csem`, y estrategia de testing. +> +> **Asume leída:** `sema-spec-v0.4.md`. Este documento baja al nivel de implementación que la spec deja abierto deliberadamente. +> +> **Estado del código existente:** `$uix/sema/types.ts` y `$uix/sema/validation.ts` ya implementados y correctos. Los dos bugs pendientes (P1 data-last-action en validator, P1 scope en SemaSustainDecl) se arreglan aparte — este documento asume que ya están arreglados. +> +> **Decisiones arquitectónicas fijadas** (no reabrir en implementación): +> - Engine: **single-engine por document**, lazy-initialized +> - AudioContext: **lazy con lock visible** (sin cola de retry) +> - Síntesis: **substractiva con dos osciladores + ADSR + filter + ring mod opcional** +> - Sonido: **híbrido** (síntesis procedural base + sample packs como override) +> - Framework: **agnóstico** (solo DOM + Promises; adaptadores framework aparte) +> - API del engine: **promise-based**, sin event emitters custom más allá del `CustomEvent` canónico + +--- + +## Índice + +1. Visión general de la arquitectura +2. Estructura de archivos del paquete +3. Intents: caracterización perceptiva +4. El puerto: `port.ts` +5. El binding: `binding.ts` +6. El resolver: `resolver.ts` y `sema-map.json` +7. El engine: `engine.ts` +8. Canal motion: `channels/motion.ts` +9. Canal sound: `channels/sound.ts` +10. Canal color: `channels/color.ts` +11. Canal presence: `channels/presence.ts` +12. Gestión de accesibilidad: `a11y.ts` +13. Pipeline `.csem`: plugin PostCSS +14. API pública final +15. Estrategia de testing +16. Criterios de aceptación + +--- + +## 1. Visión general de la arquitectura + +### 1.1. Diagrama en prosa + +El runtime Sema se organiza en cuatro capas lógicas, cada una con una responsabilidad bien delimitada: + +**Capa de contrato.** Qué está declarado: `SemaSpec` por componente (ya existente en `types.ts`), `sema-map.json` (vocabulario perceptivo canónico), overrides compilados desde `.csem` (personalización del integrador). + +**Capa de protocolo.** Cómo se invoca: `SemaPort` (interfaz neutral entre la capa headless y el runtime), `SemaBinding` (helpers tipados derivados de un `SemaSpec` concreto). + +**Capa de resolución.** Qué firma se aplica: `Resolver` que combina contrato + mapa + overrides según el evento disparado. + +**Capa de ejecución.** Cómo se aplica: `Engine` que gestiona coreografías concurrentes, accesibilidad y caps, delegando en los cuatro canales la aplicación concreta (motion, sound, color, presence). + +### 1.2. Flujo de invocación típico + +Un provider de la capa headless invoca una acción semántica. El camino que sigue la invocación: + +1. Provider llama `binding.before('close-save', ctx)` +2. Binding resuelve el `SemaAction` desde el `SemaSpec` compilado +3. Binding aplica los prewrites declarados al DOM (escribe `data-last-action="saved"`) +4. Binding invoca `port.before(resolvedAction, ctx)` +5. Port (si es el real, no el no-op) delega en el Engine +6. Engine consulta el Resolver para obtener la firma efectiva +7. Engine chequea regímenes (¿hay otra coreografía activa en este target?) +8. Engine chequea accesibilidad (¿qué canales están permitidos?) +9. Engine chequea caps (¿cuánto tiempo máximo puede bloquear?) +10. Engine ejecuta los canales activos concurrentemente +11. Engine emite `CustomEvent('sema:event', { phase: 'start' })` +12. Los canales completan (o el cap temporal vence) +13. Engine emite `CustomEvent('sema:event', { phase: 'end' })` +14. La promesa del `port.before()` resuelve +15. El binding devuelve al provider +16. El provider aplica el commit de estado +17. La capa visual reacciona al nuevo estado con sus transiciones + +Cada paso es testeable por separado. + +### 1.3. Decisiones arquitectónicas + +**Single-engine por document.** Una sola instancia del engine gestiona todas las coreografías en la página. Esto garantiza un solo AudioContext, una sola cola de animaciones pendientes, una sola lectura de media queries de accesibilidad. La instancia se crea lazy la primera vez que un binding la solicita. En contextos multi-document (iframes), cada document tiene su propia instancia. + +**Framework-agnostic.** El runtime usa solo DOM estándar y Promises. No importa nada de Svelte/React/Vue. Si algún framework necesita adaptaciones específicas (ejemplo: integrar con el ciclo de runas de Svelte 5), vive en archivos separados como `$uix/sema/adapters/svelte.ts`. El core es limpio. + +**Promise-based, sin event emitters custom.** La única superficie de observabilidad pública es `CustomEvent('sema:event')` que emite el engine. El binding expone `before()` que devuelve `Promise`, `fire()` void, `start()` que devuelve `SemaSession`. No hay `engine.on('complete', ...)` ni callbacks registrables. Simplicidad sobre flexibilidad. + +**Lazy initialization.** El AudioContext no se crea hasta el primer evento con canal sound activo. El Engine no se instancia hasta el primer binding. Los canales no cargan sus dependencias hasta que se usan. Esto mantiene el coste de Sema en cero para páginas que no disparan eventos. + +**Degradación silenciosa.** Cuando el engine no puede ejecutar (port es no-op, AudioContext locked, runtime descargado), las Promises resuelven inmediatamente sin error. El provider nunca ve una excepción de Sema. Esto es deliberado: Sema es ornamental; no debe bloquear funcionalidad core. + +### 1.4. Lo que el runtime no hace + +Para evitar que el engine crezca incontroladamente: + +- No gestiona routing ni lifecycle de componentes (responsabilidad del framework) +- No emite telemetría a servidores (responsabilidad del integrador si la necesita) +- No persiste estado entre navegaciones (cada página empieza limpia) +- No hace preloading agresivo de samples (carga bajo demanda) +- No sintetiza voz ni sonidos complejos (solo earcons cortos) +- No implementa transiciones CSS (esas son de la capa visual) + +--- + +## 2. Estructura de archivos del paquete + +``` +src/uix/sema/ +├── index.ts # Barrel con exports públicos +├── exports.ts # (existente) exports principales +├── types.ts # (existente) tipos públicos +├── validation.ts # (existente) validador +├── port.ts # SemaPort + noopSemaPort + createTestSemaPort +├── binding.ts # createSemaBinding + SemaBinding +├── engine.ts # SemaEngine (runtime real) +├── resolver.ts # Resolver de firmas desde map + csem overrides +├── a11y.ts # Lectura de preferencias y reducción por canal +├── registers.ts # Registries internos (active choreographies, etc) +├── sema-map.json # Vocabulario perceptivo canónico +├── channels/ +│ ├── motion.ts # WAAPI wrapper +│ ├── sound.ts # Web Audio synth + sample playback +│ ├── color.ts # Overlay layer management +│ └── presence.ts # Backdrop + elevation +├── csem/ +│ ├── plugin.ts # PostCSS plugin +│ ├── parser.ts # Parser de .csem a AST interno +│ ├── compiler.ts # AST → JSON consumible +│ └── vocabulary.ts # Validación contra vocabulario canónico +├── adapters/ +│ └── (vacío por ahora; aquí irían adaptadores por framework) +└── __tests__/ + ├── port.test.ts + ├── binding.test.ts + ├── resolver.test.ts + ├── engine.test.ts + ├── channels/*.test.ts + └── fixtures/ +``` + +Los archivos `exports.ts`, `types.ts`, `validation.ts` ya existen. Todo lo demás se crea en esta implementación. + +--- + +## 3. Intents: caracterización perceptiva + +### 3.1. Por qué esta sección existe + +Los intents son el modulador afectivo que convierte una familia valencial (`contact`, `commit`, `alert`, `handle`) en un evento con significado concreto. Son cinco: `threat`, `risk`, `neutral`, `affirm`, `fulfill`. Las familias transicionales (`emerge`, `sustain`) no aceptan intent — su direccionalidad viene del contexto, no de una modulación afectiva. + +La spec Sema v0.4 describe los intents conceptualmente en §3.2 (valencia y arousal). Este documento baja al nivel de implementación: qué valor concreto toma cada intent en cada canal, cómo se combina con la familia base en el resolver, qué hace el runtime ante combinaciones malformadas, y cómo el integrador puede sobrescribir la caracterización canónica. + +Esta sección es referencia temprana porque los intents atraviesan resolver, `sema-map.json`, los cuatro canales, y la validación. Verla antes de bajar a implementación ahorra tener que saltar entre secciones. + +### 3.2. Recapitulación de qué son + +Cada intent es un punto en el espacio bidimensional valencia × arousal. Los cinco puntos no son categorías ortogonales sino anclas para cubrir el espacio con granularidad suficiente: + +| Intent | Valencia | Arousal | Significado pragmático | +|---|---|---|---| +| `threat` | Muy negativa | Alto | Peligro, irreversibilidad, consecuencia grave | +| `risk` | Negativa | Medio-bajo | Precaución, subóptimo, fricción moderada | +| `neutral` | Neutra | Bajo | Operación rutinaria sin evaluación afectiva | +| `affirm` | Leve positiva | Bajo | Correcto, adecuado, aprobado | +| `fulfill` | Positiva | Medio-alto | Éxito, logro, encaje celebratorio | + +La diferencia entre `affirm` y `fulfill` es principalmente de arousal: ambos son positivos, pero `fulfill` es más energético. La diferencia entre `threat` y `risk` es principalmente de valencia intensa + urgencia: `threat` demanda atención inmediata, `risk` es advertencia. + +### 3.3. Tabla de caracterización canónica + +Cada intent modula los cuatro canales con valores concretos. Esta tabla es la referencia canónica que el implementador usa para poblar `sema-map.json`. Los valores son deltas sobre la base de familia — se suman (o reemplazan, según operación) al valor base. + +#### 3.3.1. Canal motion + +| Intent | Duration modifier | Scale delta | Easing tendency | Notas | +|---|---|---|---|---| +| `threat` | × 1.1 | +0.01 amplitud | ease-in-out | Ligeramente más largo y enfático | +| `risk` | × 1.0 (sin cambio) | sin cambio | ease-out | Duración base | +| `neutral` | × 1.0 | sin cambio | ease-out | Referencia | +| `affirm` | × 1.0 | sin cambio | ease-out | Igual que neutral en motion | +| `fulfill` | × 1.15 | +0.02 amplitud | ease-out | Más largo y con más rebote | + +El intent afecta motion de forma sutil — el canal primario de diferenciación emocional es sound y color. Motion solo enfatiza. + +#### 3.3.2. Canal sound + +| Intent | Pitch delta | Contour | Roughness delta | Gain delta | Notas | +|---|---|---|---|---|---| +| `threat` | −200 Hz | descending | +0.4 | +0.1 | Grave, áspero, descendente — tono de alarma | +| `risk` | −100 Hz | (mantiene base) | +0.2 | sin cambio | Ligeramente más grave y rugoso | +| `neutral` | sin cambio | (mantiene base) | sin cambio | sin cambio | Tono base de familia | +| `affirm` | +100 Hz | (mantiene base) | sin cambio | sin cambio | Ligeramente agudo, limpio | +| `fulfill` | +300 Hz | ascending | sin cambio | +0.05 | Agudo, ascendente — celebración | + +El contour es la palanca más distintiva. `descending` comunica cierre/caída/pérdida; `ascending` comunica apertura/logro. La roughness es la palanca de negatividad: +0.4 en threat es la que produce la sensación "áspera" que el cerebro asocia con urgencia. + +#### 3.3.3. Canal color + +| Intent | Hue (override) | Saturation delta | Intensity delta | Notas | +|---|---|---|---|---| +| `threat` | 0° (rojo puro) | +0.2 | +0.2 | Rojo saturado, pulso fuerte | +| `risk` | 30° (ámbar) | +0.1 | sin cambio | Naranja/ámbar de advertencia | +| `neutral` | (mantiene base) | sin cambio | sin cambio | Color base de familia | +| `affirm` | 145° (verde suave) | sin cambio | sin cambio | Verde discreto | +| `fulfill` | 155° (verde vibrante) | +0.1 | +0.15 | Verde más saturado, pulso visible | + +Estos hues son la convención occidental estándar y deben ser sobrescribibles a nivel global (ver §3.6). En contextos culturales donde otros colores son apropiados (rojo como positivo en culturas de Asia oriental, por ejemplo), el integrador sobrescribe los deltas. + +#### 3.3.4. Canal presence + +Los intents afectan presence solo sutilmente — presence expresa aparición/retirada, que es más una cuestión de familia (`emerge`, `sustain`) que de intent. Los deltas son: + +| Intent | Backdrop delta | Shadow delta | Notas | +|---|---|---|---| +| `threat` | +0.1 | +2 blur | Scrim ligeramente más opaco, sombra más pronunciada | +| `risk` | sin cambio | sin cambio | Sin modulación | +| `neutral` | sin cambio | sin cambio | Referencia | +| `affirm` | sin cambio | sin cambio | Sin modulación | +| `fulfill` | sin cambio | +1 blur | Sombra ligeramente más suave (celebratoria) | + +En la mayoría de casos, presence con intent es idéntico a presence sin intent. Solo `threat` y `fulfill` aplican modulación perceptible. + +### 3.4. Algoritmo de combinación en el resolver + +El resolver combina familia base + delta de intent en este orden: + +``` +1. Parse event → (family, intent | null) +2. signature = deep_clone(sema-map.families[family].base) +3. activeChannels = sema-map.families[family].activeChannels +4. if intent is not null: + deltas = sema-map.intents[intent].deltas + for each channel in signature: + if deltas[channel] exists: + signature[channel] = apply_delta(signature[channel], deltas[channel]) +5. Apply runtime overrides (engine.configure) +6. Apply CSEM overrides (if contextEl provided) +7. Apply sound pack (if event has sampleUrl) +8. Return signature +``` + +**Operaciones de delta (recapitulación):** + +- **Número simple**: suma al base (`"pitch": 300` → `effective.pitch = base.pitch + 300`) +- **String**: reemplaza (`"contour": "ascending"` → sustituye `base.contour`) +- **`{op: "multiply", factor: N}`**: multiplica +- **`{op: "replace", value: N}`**: reemplaza +- **`{op: "add", value: N}`**: suma explícita (útil cuando el contexto no permite usar número simple sin ambigüedad) + +### 3.5. Validación de combinaciones malformadas + +El vocabulario canónico define 22 eventos válidos (20 valenciales + 2 transicionales). Combinaciones como `emerge-threat` o `sustain-fulfill` no existen y el runtime debe gestionarlas. + +**Política del resolver:** + +1. **Si el evento recibido no está en `SemaEventLabel`** (p. ej. `emerge-threat`, typo como `comit-fulfill`, o string arbitrario): el resolver registra un warning en dev y cae al evento canónico más cercano por familia. `emerge-threat` cae a `emerge` (ignora el intent sobrante). Un typo como `comit-fulfill` cae a `contact-neutral` como default seguro. + +2. **Si el evento es válido pero la familia no tiene base en `sema-map.json`**: error crítico, lanza excepción. Esto solo ocurre si el mapa está corrompido. + +3. **Si el evento es válido valencial pero el intent no tiene deltas en `sema-map.json`**: el resolver devuelve la firma base de familia sin modificación. Log en dev. + +En producción todas las degradaciones son silenciosas con logs. En dev lanzan warnings visibles. + +**Validación en build time:** el validador `validateSema(spec)` ya comprueba que los eventos declarados en `SemaAction.event` pertenecen a `SemaEventLabel`. Los combinaciones inválidas no deberían llegar al runtime si el spec se valida. + +### 3.6. Overrides culturales + +El integrador puede sobrescribir globalmente la caracterización de un intent via `engine.configure({ mapOverrides })`. Esto es particularmente útil para adaptar los hues a contextos culturales distintos. + +**Ejemplo — contexto asiático oriental donde rojo es positivo:** + +```ts +configureSema({ + mapOverrides: { + 'intents.threat.deltas.color.hue': { op: 'replace', value: 270 }, // violeta + 'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 } // rojo + } +}); +``` + +**Ejemplo — app silenciosa donde threat debe ser menos agresivo:** + +```ts +configureSema({ + mapOverrides: { + 'intents.threat.deltas.sound.gain': -0.15, + 'intents.threat.deltas.sound.roughness': { op: 'replace', value: 0.2 }, + 'intents.threat.deltas.color.intensity': 0.0 + } +}); +``` + +**Granularidad de overrides:** el path es `{section}.{key}.deltas.{channel}.{param}` donde section puede ser `families`, `intents`, o `soundPack`. El resolver aplica estos overrides en el paso 5 del algoritmo de §3.4, antes de los CSEM overrides (que son más específicos por selector). + +### 3.7. Cuándo intent no debe modificar + +Hay casos donde el intent existe formalmente pero no debe modificar apenas la firma base: + +- **`handle-*` en fase "carry"**: el intent es informativo, pero durante la manipulación continua el canal motion debe ser muy estable (baja frecuencia de actualización, transiciones cortas). El intent solo modula el feedback de inicio/fin de la manipulación, no la fase continua. + +- **`contact-*` en interacciones de alta frecuencia**: un botón pulsado rápidamente no debe tener pulsos cromáticos agresivos aunque el intent sea `threat`. El régimen `collapse` (configurable en el `SemaAction`) coalesce múltiples contactos, y el resolver debe poder leer que el evento está colapsando para aplicar menos intensidad. + +Esta sutileza no se implementa en v1 — el resolver aplica los deltas tal cual. Queda documentado como refinamiento futuro. + +--- + +## 4. El puerto: `port.ts` + +### 3.1. Responsabilidad + +El puerto es la interfaz abstracta entre quien invoca Sema (la capa headless, vía binding) y quien la ejecuta (el engine real, el no-op, o un mock de test). Es el punto de desacoplamiento más importante del sistema. + +### 3.2. Interfaz pública + +```ts +// src/uix/sema/port.ts + +import type { SemaAction, SemaSustainDecl } from './types'; + +/** + * Contrato para que la capa headless invoque Sema sin dependencia directa + * del runtime. Los providers llaman before() / fire() / startSustain() como + * parte de su flujo de acciones. Cuando el engine real no está cargado, el + * port recibido es noopSemaPort y las promesas resuelven inmediatamente — + * el flujo del provider es idéntico en producción con engine, en dev sin + * engine, y en tests con testSemaPort. + * + * NO eliminar las llamadas sema.before() aunque en este momento el port + * sea no-op. Son contrato. + */ +export interface SemaPort { + before(action: ResolvedSemaAction, ctx: SemaContext): Promise; + fire(action: ResolvedSemaAction, ctx: SemaContext): void; + startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession; +} + +export interface SemaSession { + stop(): void; + readonly active: boolean; +} + +/** + * Acción resuelta que el binding pasa al port. Contiene los campos del + * SemaAction original con los defaults aplicados. + */ +export interface ResolvedSemaAction { + name: string; + component: string; // kebab del componente + event: string; // SemaEventLabel + mode: 'blocking' | 'advisory'; + regime: 'replace' | 'collapse' | 'lock' | 'queue'; + scope: 'part' | 'component' | 'scene'; + target: string; // kebab del part primario + /** Atributos DOM ya aplicados por el binding antes de invocar al port. */ + prewritten: readonly { part: string; attr: string; value: string }[]; +} + +export interface ResolvedSemaSustain { + name: string; + component: string; + target: string; + scope: 'part' | 'component' | 'scene'; +} + +/** + * Contexto runtime que la capa headless construye para cada invocación. + * El binding enriquece este contexto con información derivada del SemaSpec. + */ +export interface SemaContext { + /** Elemento DOM del part primario de la acción. */ + targetEl: HTMLElement; + /** Elemento DOM raíz del componente (opcional; para coreografías scope:component). */ + rootEl?: HTMLElement; + /** Otros parts del componente indexados por kebab (opcional). */ + partEls?: Partial>; + /** Snapshot de atributos DOM relevantes en el momento de la invocación. */ + snapshot: Record; + /** Qué originó la invocación. */ + cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation'; + /** Señal externa de cancelación. */ + abortSignal?: AbortSignal; +} +``` + +### 3.3. Implementación no-op + +```ts +/** + * No-op port. Usado cuando el engine Sema no está cargado o cuando Sema + * está globalmente desactivada. Todas las promesas resuelven inmediatamente; + * las sesiones nacen inactivas. + */ +export const noopSemaPort: SemaPort = { + before: () => Promise.resolve(), + fire: () => { + // No-op deliberado. Llamadas fire() no tienen efecto cuando no hay engine. + }, + startSustain: () => ({ + stop: () => {}, + active: false + }) +}; +``` + +### 3.4. Port de test + +```ts +export interface TestPortOptions { + /** + * Si está definido, `before()` esperará este número de ms antes de resolver. + * Útil para testear secuencialidad en el provider. + */ + beforeDelay?: number; + /** + * Si está definido, `before()` lanzará AbortError si el signal se cancela. + * Default false (cumple las garantías de §6.6 de la spec). + */ + respectAbort?: boolean; +} + +export interface TestCall { + kind: 'before' | 'fire'; + action: ResolvedSemaAction; + ctx: SemaContext; + timestamp: number; +} + +export interface TestSession extends SemaSession { + sustain: ResolvedSemaSustain; + ctx: SemaContext; + stopped: boolean; +} + +export function createTestSemaPort(opts?: TestPortOptions): { + port: SemaPort; + calls: TestCall[]; + sessions: TestSession[]; + /** Resetea calls[] y sessions[]. */ + reset(): void; +} { + const calls: TestCall[] = []; + const sessions: TestSession[] = []; + + const port: SemaPort = { + before: async (action, ctx) => { + calls.push({ kind: 'before', action, ctx, timestamp: performance.now() }); + if (opts?.beforeDelay) { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, opts.beforeDelay); + if (opts?.respectAbort && ctx.abortSignal) { + ctx.abortSignal.addEventListener('abort', () => { + clearTimeout(timer); + resolve(); // spec: abortar NO lanza, solo cancela + }); + } + }); + } + }, + fire: (action, ctx) => { + calls.push({ kind: 'fire', action, ctx, timestamp: performance.now() }); + }, + startSustain: (sustain, ctx) => { + const session: TestSession = { + stop: () => { session.stopped = true; (session as any).active = false; }, + active: true, + sustain, + ctx, + stopped: false + }; + sessions.push(session); + return session; + } + }; + + return { + port, + calls, + sessions, + reset: () => { calls.length = 0; sessions.length = 0; } + }; +} +``` + +### 3.5. Garantías que el port debe cumplir + +Cualquier implementación de `SemaPort` debe cumplir: + +1. `before()` devuelve una Promise que siempre resuelve (nunca rechaza). +2. Si `ctx.abortSignal` aborta, `before()` resuelve inmediatamente (no rechaza — la cancelación no es error). +3. Si el `targetEl` se desmonta del DOM durante la ejecución, `before()` resuelve tempranamente. +4. `before()` nunca bloquea más del cap global (200ms normal, 80ms con accesibilidad reducida). +5. `fire()` es sincrónico y void; cualquier trabajo async es fire-and-forget. +6. `startSustain()` devuelve una sesión que puede inspeccionarse con `.active` y detenerse con `.stop()`. + +El port no-op cumple todas trivialmente. El engine real tiene que implementarlas explícitamente. + +--- + +## 5. El binding: `binding.ts` + +### 4.1. Responsabilidad + +`createSemaBinding(spec, port)` toma un `SemaSpec` (el contrato declarativo de un componente) y un `SemaPort`, y devuelve helpers tipados que el provider usa para invocar acciones. + +Las responsabilidades del binding son: + +1. Exponer helpers tipados con `ActionName` / `SustainName` derivados del spec +2. Resolver defaults (`mode: blocking`, `regime: replace`, `scope: part`) +3. Validar en dev que los nombres invocados existen en el spec +4. Aplicar los `prewrite` declarados al DOM antes de invocar el port +5. Construir el `SemaContext` completo a partir del context parcial que pasa el provider +6. Delegar al port y devolver la Promise + +### 4.2. API + +```ts +// src/uix/sema/binding.ts + +import type { SemaSpec, SemaAction, SemaSustainDecl } from './types'; +import type { SemaPort, SemaContext, SemaSession, ResolvedSemaAction, ResolvedSemaSustain } from './port'; + +/** + * Helpers derivados de un SemaSpec para invocar acciones semánticas. + */ +export interface SemaBinding { + /** Invocar una acción blocking. Espera a que el engine termine. */ + before(name: ActionName, ctx: PartialSemaContext): Promise; + /** Invocar una acción advisory. No espera. */ + fire(name: ActionName, ctx: PartialSemaContext): void; + /** Iniciar un sustain. Devuelve session para detener. */ + start(name: SustainName, ctx: PartialSemaContext): SemaSession; + /** Introspección: devuelve el SemaAction declarado con ese nombre. */ + action(name: ActionName): SemaAction; +} + +/** + * Contexto que el provider pasa (parcial — el binding rellena lo derivable). + * El provider siempre pasa targetEl; rootEl, partEls, cause y abortSignal + * son opcionales. + */ +export interface PartialSemaContext { + targetEl: HTMLElement; + rootEl?: HTMLElement; + partEls?: Partial>; + cause?: SemaContext['cause']; + abortSignal?: AbortSignal; +} + +// Helper types para derivar unions literales desde el spec. +type ActionName = S['actions'][number]['name']; +type SustainName = + NonNullable[number]['name']; + +export function createSemaBinding( + spec: S, + port: SemaPort +): SemaBinding { + // Indexar acciones y sustains por nombre una sola vez. + const actionsByName = new Map(); + for (const action of spec.actions) { + actionsByName.set(action.name, action); + } + const sustainsByName = new Map(); + for (const sustain of spec.sustains ?? []) { + sustainsByName.set(sustain.name, sustain); + } + + const DEV = process.env.NODE_ENV !== 'production'; + + function resolveAction(name: string): SemaAction { + const action = actionsByName.get(name); + if (!action) { + if (DEV) { + throw new Error( + `[sema] action "${name}" not declared in "${spec.kebab}". ` + + `Declared actions: ${[...actionsByName.keys()].join(', ')}` + ); + } + // En producción, degradar silenciosamente. + return { name, target: { target: '' } as any, event: 'emerge' }; + } + return action; + } + + function buildContext( + partial: PartialSemaContext, + action: SemaAction + ): SemaContext { + // Capturar snapshot de atributos DOM relevantes. + const snapshot: Record = {}; + if (partial.targetEl) { + // Captura data-state, data-last-action, data-starting-style, data-ending-style. + // (El set exacto es convención; ampliable si se necesita.) + const relevantAttrs = [ + 'data-state', + 'data-last-action', + 'data-starting-style', + 'data-ending-style' + ]; + for (const attr of relevantAttrs) { + snapshot[attr] = partial.targetEl.getAttribute(attr); + } + } + + return { + targetEl: partial.targetEl, + rootEl: partial.rootEl, + partEls: partial.partEls, + snapshot, + cause: partial.cause, + abortSignal: partial.abortSignal + }; + } + + function applyPrewrites( + action: SemaAction, + partial: PartialSemaContext + ): ResolvedSemaAction['prewritten'] { + const applied: { part: string; attr: string; value: string }[] = []; + for (const pw of action.prewrite ?? []) { + const partKebab = pw.part.target; + const el = partial.partEls?.[partKebab] ?? partial.targetEl; + if (el) { + el.setAttribute(pw.attr, pw.value); + applied.push({ part: partKebab, attr: pw.attr, value: pw.value }); + } + } + return applied; + } + + function resolveActionToResolved( + action: SemaAction, + prewritten: ResolvedSemaAction['prewritten'] + ): ResolvedSemaAction { + return { + name: action.name, + component: spec.kebab, + event: action.event, + mode: action.mode ?? 'blocking', + regime: action.regime ?? 'replace', + scope: action.scope ?? 'part', + target: action.target.target, + prewritten + }; + } + + function resolveSustain(name: string): SemaSustainDecl { + const sustain = sustainsByName.get(name); + if (!sustain) { + if (DEV) { + throw new Error( + `[sema] sustain "${name}" not declared in "${spec.kebab}". ` + + `Declared sustains: ${[...sustainsByName.keys()].join(', ')}` + ); + } + return { name, target: { target: '' } as any, activeWhen: { part: { target: '' } as any, attr: '', value: '' }, event: 'sustain' }; + } + return sustain; + } + + return { + before: async (name, partial) => { + const action = resolveAction(name as string); + const prewritten = applyPrewrites(action, partial); + const resolved = resolveActionToResolved(action, prewritten); + const ctx = buildContext(partial, action); + await port.before(resolved, ctx); + }, + + fire: (name, partial) => { + const action = resolveAction(name as string); + const prewritten = applyPrewrites(action, partial); + const resolved = resolveActionToResolved(action, prewritten); + const ctx = buildContext(partial, action); + port.fire(resolved, ctx); + }, + + start: (name, partial) => { + const sustain = resolveSustain(name as string); + const ctx = buildContext(partial, { name: sustain.name, target: sustain.target, event: 'sustain' } as any); + const resolved: ResolvedSemaSustain = { + name: sustain.name, + component: spec.kebab, + target: sustain.target.target, + scope: (sustain as any).scope ?? 'part' + }; + return port.startSustain(resolved, ctx); + }, + + action: (name) => { + return resolveAction(name as string); + } + }; +} +``` + +### 4.3. Comportamiento en modo no-op + +Cuando el port es `noopSemaPort`, las invocaciones del binding siguen: +- Aplicando prewrites al DOM (esto es responsabilidad del binding, no del engine) +- Construyendo contexto +- Delegando al port + +El port no-op simplemente resuelve inmediatamente sin aplicar coreografía. Pero los prewrites **sí se aplican**. Esto es importante: `data-last-action` queda reflejado en el DOM incluso sin engine, porque la capa visual puede necesitarlo para su propio estilado (ejemplo: dialog cerrado con `data-last-action="failed"` puede mostrar un tinte sutil aunque no haya evento Sema). + +### 4.4. Interacción con desmontaje + +El binding en sí no gestiona desmontaje. Si el provider invoca `binding.before()` y luego el componente se desmonta, el `AbortSignal` del contexto debe cancelarse — es responsabilidad del provider conectar el signal al ciclo de vida del componente. + +Patrón recomendado en frameworks reactivos: + +```ts +// Pseudo-código en provider +const abortController = new AbortController(); + +onMount(() => { /* ... */ }); +onDestroy(() => abortController.abort()); + +async function closeSave() { + await binding.before('close-save', { + targetEl: contentEl, + abortSignal: abortController.signal + }); + if (abortController.signal.aborted) return; + state.open = false; +} +``` + +### 4.5. Testing del binding + +Con `createTestSemaPort` se puede verificar: +- Que `binding.before('name', ctx)` llama a `port.before()` con los argumentos correctos +- Que los prewrites se aplican al DOM antes de la invocación +- Que los defaults se aplican correctamente +- Que acciones con nombre inválido lanzan en dev y degradan en prod + +--- + +## 6. El resolver: `resolver.ts` y `sema-map.json` + +### 5.1. Responsabilidad del Resolver + +Dado un evento canónico (p. ej. `commit-fulfill`) y un contexto de ejecución (element target, overrides aplicables), el Resolver produce una **firma efectiva**: un objeto con los valores por canal que el engine va a aplicar. + +La resolución combina tres fuentes en orden de especificidad: + +1. **sema-map.json** — vocabulario canónico base (factorizado en familia + intent) +2. **CSEM overrides compilados** — JSON generado por el plugin PostCSS desde archivos `.csem` del integrador +3. **Runtime overrides** — configuración global vía `engine.configure({ mapOverrides })` + +El resultado es una `EffectiveSignature` completa. + +### 5.2. Estructura de `sema-map.json` + +```json +{ + "version": "0.4.0", + "families": { + "contact": { + "base": { + "motion": { + "duration": 80, + "easing": "ease-out", + "scale": { "from": 1.0, "to": 0.96 }, + "translate": { "x": 0, "y": 0 } + }, + "sound": { + "pitch": 800, + "centroid": 2000, + "roughness": 0.1, + "attack": 4, + "decay": 40, + "duration": 60, + "contour": "flat", + "gain": 0.25 + }, + "color": null, + "presence": null + }, + "activeChannels": ["motion", "sound"] + }, + + "commit": { + "base": { + "motion": { + "duration": 180, + "easing": "ease-out", + "scale": { "from": 1.0, "to": 1.02 } + }, + "sound": { + "pitch": 700, + "centroid": 1800, + "roughness": 0.1, + "attack": 8, + "decay": 120, + "duration": 100, + "contour": "flat", + "gain": 0.3 + }, + "color": { + "hue": 210, + "saturation": 0.3, + "lightness": 0.5, + "duration": 200, + "intensity": 0.3 + }, + "presence": null + }, + "activeChannels": ["motion", "sound", "color"] + }, + + "alert": { + "base": { + "motion": { + "duration": 220, + "easing": "ease-in-out", + "scale": { "from": 1.0, "to": 1.03 }, + "translate": { "x": 0, "y": 0 } + }, + "sound": { + "pitch": 900, + "centroid": 2400, + "roughness": 0.3, + "attack": 3, + "decay": 150, + "duration": 180, + "contour": "arc", + "gain": 0.4 + }, + "color": { + "hue": 40, + "saturation": 0.7, + "lightness": 0.55, + "duration": 220, + "intensity": 0.5 + }, + "presence": null + }, + "activeChannels": ["motion", "sound", "color"] + }, + + "emerge": { + "base": { + "motion": { + "duration": 240, + "easing": "ease-out", + "scale": { "from": 0.96, "to": 1.0 } + }, + "sound": { + "pitch": 600, + "centroid": 1500, + "roughness": 0.05, + "attack": 12, + "decay": 200, + "duration": 150, + "contour": "ascending", + "gain": 0.2 + }, + "color": null, + "presence": { + "opacity": { "from": 0, "to": 1 }, + "shadow": { "blur": 24, "y": 8, "opacity": 0.15 }, + "backdrop": 0.35, + "duration": 280, + "easing": "ease-out" + } + }, + "activeChannels": ["motion", "presence", "sound"] + }, + + "handle": { + "base": { + "motion": { + "duration": 40, + "easing": "linear", + "scale": { "from": 1.0, "to": 1.0 } + }, + "sound": null, + "color": null, + "presence": null + }, + "activeChannels": ["motion"] + }, + + "sustain": { + "base": { + "motion": null, + "sound": null, + "color": null, + "presence": { + "opacity": { "from": 1, "to": 1 }, + "duration": 0 + } + }, + "activeChannels": ["presence"] + } + }, + + "intents": { + "threat": { + "deltas": { + "motion": { "duration": { "op": "multiply", "factor": 1.1 } }, + "sound": { + "pitch": -200, + "roughness": 0.4, + "contour": "descending", + "gain": 0.1 + }, + "color": { + "hue": { "op": "replace", "value": 0 }, + "saturation": { "op": "add", "value": 0.2 }, + "intensity": 0.2 + } + } + }, + "risk": { + "deltas": { + "sound": { "pitch": -100, "roughness": 0.2 }, + "color": { "hue": 30, "saturation": 0.1 } + } + }, + "neutral": { + "deltas": {} + }, + "affirm": { + "deltas": { + "sound": { "pitch": 100 }, + "color": { "hue": { "op": "replace", "value": 145 } } + } + }, + "fulfill": { + "deltas": { + "motion": { "duration": { "op": "multiply", "factor": 1.15 } }, + "sound": { + "pitch": 300, + "contour": "ascending", + "gain": 0.05 + }, + "color": { + "hue": { "op": "replace", "value": 155 }, + "saturation": { "op": "add", "value": 0.1 }, + "intensity": 0.15 + } + } + } + }, + + "soundPack": {} +} +``` + +Nota: los valores concretos arriba son **defaults razonables para primera implementación**. No son calibración final. Se ajustan empíricamente con usuarios reales. + +### 5.3. Operaciones de delta + +Los deltas se combinan con el base según reglas simples: + +- **Número simple** (ej. `"pitch": 300`): suma al base. `base.pitch = 700` + `delta.pitch = 300` → `effective.pitch = 1000`. +- **String**: reemplaza al base. `base.contour = "flat"` + `delta.contour = "ascending"` → `effective.contour = "ascending"`. +- **Objeto con `op: "multiply"`**: multiplica. `base.duration = 200` + `delta = { op: "multiply", factor: 1.2 }` → `effective.duration = 240`. +- **Objeto con `op: "replace"`**: reemplaza. Útil para valores numéricos donde el default se sustituye. `base.hue = 210` + `delta = { op: "replace", value: 0 }` → `effective.hue = 0`. +- **Objeto con `op: "add"`**: suma explícita (para casos donde el número simple no aplica por ambigüedad). `base.saturation = 0.3` + `delta = { op: "add", value: 0.2 }` → `effective.saturation = 0.5`. + +### 5.4. Tipos de la firma efectiva + +```ts +// src/uix/sema/resolver.ts + +export interface MotionSignature { + duration: number; + easing: 'linear' | 'ease-out' | 'ease-in' | 'ease-in-out' | string; + scale?: { from: number; to: number }; + translate?: { x: number; y: number }; + rotate?: number; +} + +export interface SoundSignature { + pitch: number; + centroid: number; + roughness: number; + attack: number; + decay: number; + duration: number; + contour: 'flat' | 'ascending' | 'descending' | 'arc' | 'bell'; + gain: number; + /** Si presente, sobreescribe síntesis con sample. */ + sampleUrl?: string; +} + +export interface ColorSignature { + hue: number; + saturation: number; + lightness: number; + duration: number; + intensity: number; +} + +export interface PresenceSignature { + opacity: { from: number; to: number }; + shadow?: { blur: number; y: number; opacity: number }; + backdrop?: number; + outline?: { width: number; style: string }; + duration: number; + easing: string; +} + +export interface EffectiveSignature { + event: string; + activeChannels: ('motion' | 'sound' | 'color' | 'presence')[]; + motion?: MotionSignature; + sound?: SoundSignature; + color?: ColorSignature; + presence?: PresenceSignature; +} +``` + +### 5.5. Interfaz del Resolver + +```ts +export interface ResolverOptions { + /** Contenido de sema-map.json. */ + map: SemaMap; + /** Overrides compilados desde .csem. */ + csemOverrides?: CSEMOverrides; + /** Overrides runtime pasados en engine.configure(). */ + runtimeOverrides?: RuntimeOverrides; +} + +export class Resolver { + constructor(opts: ResolverOptions) { /* ... */ } + + /** + * Resolución principal. Dado un evento canónico y un element de contexto, + * produce la firma efectiva combinando base + intent + overrides aplicables. + */ + resolve(event: string, contextEl?: HTMLElement): EffectiveSignature { + // 1. Parsear event en familia e intent. + const [family, intent] = this.parseEvent(event); + + // 2. Obtener base de la familia. + const familyData = this.opts.map.families[family]; + if (!familyData) throw new Error(`Unknown family: ${family}`); + + let signature: EffectiveSignature = { + event, + activeChannels: [...familyData.activeChannels], + motion: familyData.base.motion ?? undefined, + sound: familyData.base.sound ?? undefined, + color: familyData.base.color ?? undefined, + presence: familyData.base.presence ?? undefined + }; + + // 3. Aplicar delta de intent (si es familia valencial). + if (intent) { + const intentData = this.opts.map.intents[intent]; + if (intentData) { + signature = this.applyDelta(signature, intentData.deltas); + } + } + + // 4. Aplicar runtime overrides globales. + signature = this.applyRuntimeOverrides(signature, event); + + // 5. Aplicar CSEM overrides contextuales. + // Los overrides de .csem pueden targetearse por selector CSS; el resolver + // consulta el elemento contextual para ver qué selectores matchean. + if (contextEl) { + signature = this.applyCSEMOverrides(signature, event, contextEl); + } + + // 6. Aplicar sample pack si existe. + if (signature.activeChannels.includes('sound')) { + const sampleUrl = this.opts.map.soundPack?.[event]; + if (sampleUrl && signature.sound) { + signature.sound.sampleUrl = sampleUrl; + } + } + + return signature; + } + + private parseEvent(event: string): [string, string | null] { + // 'commit-fulfill' → ['commit', 'fulfill'] + // 'emerge' → ['emerge', null] + const parts = event.split('-'); + if (parts.length === 1) return [parts[0], null]; + return [parts[0], parts.slice(1).join('-')]; + } + + private applyDelta( + signature: EffectiveSignature, + deltas: Record + ): EffectiveSignature { + // Implementación de la combinación descrita en §5.3. + // (Detalle omitido por brevedad; straightforward.) + return signature; + } + + // applyRuntimeOverrides, applyCSEMOverrides: similar structure. +} +``` + +### 5.6. Caching + +El Resolver puede cachear firmas resueltas por `(event, contextElId)` para evitar re-resolver en cada invocación cuando nada cambia. La clave del cache debe incluir: +- El evento +- Un hash de los overrides de `.csem` aplicables (si el .csem cambia en HMR, se invalida) +- Los runtime overrides activos + +Política de cache: LRU con tamaño máximo 100 entradas. Invalidación total en `engine.configure()`. + +--- + +## 7. El engine: `engine.ts` + +### 6.1. Responsabilidad + +El engine es el runtime real que coordina todo. Sus responsabilidades: + +1. Ser implementación concreta de `SemaPort` +2. Mantener registro de coreografías activas por target +3. Implementar los 4 regímenes (`replace`, `collapse`, `lock`, `queue`) +4. Consultar el Resolver para obtener firmas +5. Consultar a11y para aplicar reducciones +6. Delegar a los canales la aplicación concreta +7. Gestionar caps temporales +8. Emitir `CustomEvent('sema:event')` +9. Gestionar lifecycle (cancelación, desmontaje) + +### 6.2. Arquitectura + +Una sola instancia por document, creada lazy: + +```ts +// src/uix/sema/engine.ts + +let _engineInstance: SemaEngine | null = null; + +export function getEngine(): SemaEngine { + if (!_engineInstance) { + _engineInstance = new SemaEngine(); + } + return _engineInstance; +} + +/** + * Solo para tests. Resetea la instancia. + */ +export function _resetEngineForTesting(): void { + if (_engineInstance) { + _engineInstance.destroy(); + _engineInstance = null; + } +} +``` + +### 6.3. Clase SemaEngine + +```ts +export interface EngineConfig { + sound: { enabled: boolean; gain: number }; + motion: { enabled: boolean }; + color: { enabled: boolean }; + presence: { enabled: boolean }; + reflectEvents: boolean; + capBlockingMs: number; // default 200 + capBlockingReducedMs: number; // default 80 + mapOverrides?: RuntimeOverrides; +} + +const DEFAULT_CONFIG: EngineConfig = { + sound: { enabled: false, gain: 0.8 }, // sound disabled by default + motion: { enabled: true }, + color: { enabled: true }, + presence: { enabled: true }, + reflectEvents: false, + capBlockingMs: 200, + capBlockingReducedMs: 80 +}; + +export class SemaEngine implements SemaPort { + private config: EngineConfig = { ...DEFAULT_CONFIG }; + private resolver: Resolver; + private motion: MotionChannel; + private sound: SoundChannel; + private color: ColorChannel; + private presence: PresenceChannel; + private a11y: A11yMonitor; + private activeChoreographies: Map; + private sustainSessions: Set; + + constructor() { + this.resolver = new Resolver({ map: defaultMap }); + this.motion = new MotionChannel(); + this.sound = new SoundChannel(); + this.color = new ColorChannel(); + this.presence = new PresenceChannel(); + this.a11y = new A11yMonitor(); + this.activeChoreographies = new Map(); + this.sustainSessions = new Set(); + } + + configure(config: Partial): void { + this.config = { ...this.config, ...config }; + if (config.mapOverrides) { + this.resolver.setRuntimeOverrides(config.mapOverrides); + } + } + + destroy(): void { + // Cancelar todas las coreografías activas. + for (const c of this.activeChoreographies.values()) { + c.cancel(); + } + this.activeChoreographies.clear(); + // Detener todos los sustains. + for (const s of this.sustainSessions) { + s.stop(); + } + this.sustainSessions.clear(); + // Canales. + this.sound.destroy(); + this.motion.destroy(); + this.color.destroy(); + this.presence.destroy(); + } + + // ── SemaPort implementation ───────────────────────────────────── + + async before(action: ResolvedSemaAction, ctx: SemaContext): Promise { + const key = this.choreographyKey(action, ctx); + + // Aplicar régimen. + const existing = this.activeChoreographies.get(key); + if (existing) { + switch (action.regime) { + case 'replace': + existing.cancel(); + this.activeChoreographies.delete(key); + break; + case 'collapse': + // Single-flight coalescing: marcar repetición y devolver la misma promise. + existing.markRepeated(); + return existing.promise; + case 'lock': + // Rechazar silenciosamente (la promise resuelve inmediatamente). + return; + case 'queue': + // Esperar a que termine la actual, luego ejecutar. + await existing.promise; + break; + } + } + + // Resolver firma. + const signature = this.resolver.resolve(action.event, ctx.targetEl); + + // Aplicar a11y. + const reducedSignature = this.a11y.reduceSignature(signature, this.config); + + // Calcular cap temporal efectivo. + const isReduced = this.a11y.hasActiveReduction(); + const cap = isReduced ? this.config.capBlockingReducedMs : this.config.capBlockingMs; + + // Crear y registrar coreografía. + const choreography = new Choreography( + action, + ctx, + reducedSignature, + cap, + this + ); + this.activeChoreographies.set(key, choreography); + + // Emitir start. + this.emitCustomEvent(ctx.targetEl, action, 'start', reducedSignature); + + // Reflejar en DOM si está habilitado. + if (this.config.reflectEvents) { + ctx.targetEl.setAttribute('data-sema-active', action.event); + ctx.targetEl.setAttribute('data-sema-phase', 'active'); + } + + try { + await choreography.run(); + // Emitir end. + this.emitCustomEvent(ctx.targetEl, action, 'end', reducedSignature); + } catch (err) { + // Cancelada por abort o desmontaje — no propagar. + this.emitCustomEvent(ctx.targetEl, action, 'cancelled', reducedSignature); + } finally { + if (this.config.reflectEvents) { + ctx.targetEl.removeAttribute('data-sema-active'); + ctx.targetEl.removeAttribute('data-sema-phase'); + } + this.activeChoreographies.delete(key); + } + } + + fire(action: ResolvedSemaAction, ctx: SemaContext): void { + // Fire-and-forget. No esperar, no gestionar régimen complejo + // (regime='replace' es el único sensato aquí). + this.before(action, ctx).catch(() => {}); + } + + startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession { + const runner = new SustainRunner(sustain, ctx, this); + this.sustainSessions.add(runner); + runner.start(); + return { + stop: () => { + runner.stop(); + this.sustainSessions.delete(runner); + }, + get active() { return runner.active; } + }; + } + + // ── Internal helpers ──────────────────────────────────────────── + + private choreographyKey(action: ResolvedSemaAction, ctx: SemaContext): string { + // Clave de equivalencia: misma acción sobre mismo target. + // Usamos referencia al element + nombre de acción. + const elId = this.elementId(ctx.targetEl); + return `${action.component}:${action.name}:${elId}`; + } + + private elementId(el: HTMLElement): string { + // WeakMap para asociar IDs únicos a elementos sin leak. + if (!this.elIds) this.elIds = new WeakMap(); + let id = this.elIds.get(el); + if (!id) { + id = `el-${this.nextElId++}`; + this.elIds.set(el, id); + } + return id; + } + private elIds?: WeakMap; + private nextElId = 0; + + private emitCustomEvent( + target: HTMLElement, + action: ResolvedSemaAction, + phase: 'start' | 'end' | 'cancelled', + signature: EffectiveSignature + ): void { + target.dispatchEvent(new CustomEvent('sema:event', { + bubbles: true, + detail: { + event: action.event, + action: action.name, + component: action.component, + phase, + channels: signature.activeChannels, + duration: this.durationOfSignature(signature) + } + })); + } + + private durationOfSignature(sig: EffectiveSignature): number { + const candidates = [ + sig.motion?.duration, + sig.sound?.duration, + sig.color?.duration, + sig.presence?.duration + ].filter((d): d is number => d !== undefined); + return candidates.length ? Math.max(...candidates) : 0; + } + + // Getters públicos para que la Choreography acceda a los canales. + get channels() { + return { + motion: this.motion, + sound: this.sound, + color: this.color, + presence: this.presence + }; + } + + get currentConfig(): EngineConfig { + return this.config; + } +} +``` + +### 6.4. Clase Choreography + +Encapsula una coreografía en ejecución. Gestiona el ciclo de vida (creación, run, cancelación), respeta caps, coordina canales. + +```ts +class Choreography { + readonly promise: Promise; + private resolvePromise!: () => void; + private cancelled = false; + private repeatedCount = 0; + private abortController: AbortController; + + constructor( + private action: ResolvedSemaAction, + private ctx: SemaContext, + private signature: EffectiveSignature, + private capMs: number, + private engine: SemaEngine + ) { + this.promise = new Promise((resolve) => { + this.resolvePromise = resolve; + }); + this.abortController = new AbortController(); + + // Si el ctx tiene abortSignal, propagarlo. + if (ctx.abortSignal) { + ctx.abortSignal.addEventListener('abort', () => this.cancel()); + } + } + + markRepeated(): void { + this.repeatedCount++; + } + + cancel(): void { + if (this.cancelled) return; + this.cancelled = true; + this.abortController.abort(); + this.resolvePromise(); + } + + async run(): Promise { + // Verificar que el target sigue conectado antes de empezar. + if (!this.ctx.targetEl.isConnected) { + this.resolvePromise(); + return; + } + + // Lanzar canales concurrentemente. + const channelPromises: Promise[] = []; + const config = this.engine.currentConfig; + const channels = this.engine.channels; + + if (this.signature.activeChannels.includes('motion') && config.motion.enabled && this.signature.motion) { + channelPromises.push( + channels.motion.apply(this.ctx.targetEl, this.signature.motion, this.abortController.signal) + ); + } + if (this.signature.activeChannels.includes('sound') && config.sound.enabled && this.signature.sound) { + channelPromises.push( + channels.sound.apply(this.signature.sound, config.sound.gain, this.abortController.signal) + ); + } + if (this.signature.activeChannels.includes('color') && config.color.enabled && this.signature.color) { + channelPromises.push( + channels.color.apply(this.ctx.targetEl, this.signature.color, this.abortController.signal) + ); + } + if (this.signature.activeChannels.includes('presence') && config.presence.enabled && this.signature.presence) { + channelPromises.push( + channels.presence.apply(this.ctx.targetEl, this.signature.presence, this.abortController.signal) + ); + } + + // Si no hay canales activos, resolver inmediatamente. + if (channelPromises.length === 0) { + this.resolvePromise(); + return; + } + + // Cap temporal: el engine libera el await al cap, pero los canales continúan. + const capPromise = new Promise((resolve) => { + setTimeout(() => resolve(), this.capMs); + }); + + // Esperar al primero entre: todos los canales terminan, o el cap vence. + await Promise.race([ + Promise.all(channelPromises).then(() => {}), + capPromise + ]); + + this.resolvePromise(); + + // Los canales que sigan corriendo después del cap son "tails post-state" + // y continúan hasta completar naturalmente o cancelación. + } +} +``` + +### 6.5. Clase SustainRunner + +Gestiona un sustain activo. Distinto de Choreography porque no tiene duración fija. + +```ts +class SustainRunner { + active = true; + private motionCleanup?: () => void; + private presenceCleanup?: () => void; + private soundCleanup?: () => void; + + constructor( + private sustain: ResolvedSemaSustain, + private ctx: SemaContext, + private engine: SemaEngine + ) {} + + start(): void { + const signature = this.engine.resolver.resolve('sustain', this.ctx.targetEl); + const reduced = this.engine.a11y.reduceSignature(signature, this.engine.currentConfig); + const config = this.engine.currentConfig; + + if (reduced.activeChannels.includes('motion') && config.motion.enabled && reduced.motion) { + this.motionCleanup = this.engine.channels.motion.applySustained( + this.ctx.targetEl, + reduced.motion + ); + } + if (reduced.activeChannels.includes('presence') && config.presence.enabled && reduced.presence) { + this.presenceCleanup = this.engine.channels.presence.applySustained( + this.ctx.targetEl, + reduced.presence + ); + } + // Sound no suele activarse en sustain por default; depende del mapa. + } + + stop(): void { + if (!this.active) return; + this.active = false; + this.motionCleanup?.(); + this.presenceCleanup?.(); + this.soundCleanup?.(); + } +} +``` + +### 6.6. API pública del engine + +```ts +// Exportado en src/uix/sema/index.ts + +export function getSemaEngine(): SemaEngine { + return getEngine(); +} + +export function configureSema(config: Partial): void { + getEngine().configure(config); +} + +export function destroySema(): void { + if (_engineInstance) { + _engineInstance.destroy(); + _engineInstance = null; + } +} + +// Para test-only: +export { _resetEngineForTesting }; +``` + +--- + +## 8. Canal motion: `channels/motion.ts` + +### 7.1. Responsabilidad + +Aplica dinámicas espaciales (scale, translate, rotate) usando Web Animations API. Coopera con transforms CSS existentes mediante `composite: 'add'` cuando la firma lo requiera. + +### 7.2. Interfaz + +```ts +// src/uix/sema/channels/motion.ts + +import type { MotionSignature } from '../resolver'; + +export class MotionChannel { + private activeAnimations = new WeakMap(); + + /** + * Aplica la firma motion al target. Devuelve promise que resuelve cuando + * la animación termina o se cancela. + */ + async apply( + target: HTMLElement, + signature: MotionSignature, + abortSignal: AbortSignal + ): Promise { + if (!target.isConnected) return; + + const keyframes = this.signatureToKeyframes(signature); + const options = this.signatureToOptions(signature); + + const animation = target.animate(keyframes, options); + this.trackAnimation(target, animation); + + if (abortSignal.aborted) { + animation.cancel(); + return; + } + abortSignal.addEventListener('abort', () => animation.cancel()); + + try { + await animation.finished; + } catch { + // Cancelada. + } finally { + this.untrackAnimation(target, animation); + } + } + + /** + * Variante sustained (sin promise — devuelve cleanup). + */ + applySustained(target: HTMLElement, signature: MotionSignature): () => void { + if (!target.isConnected) return () => {}; + + const keyframes = this.signatureToKeyframes(signature); + const options: KeyframeAnimationOptions = { + duration: signature.duration || 1000, + easing: signature.easing, + iterations: Infinity, + composite: 'add' + }; + + const animation = target.animate(keyframes, options); + this.trackAnimation(target, animation); + + return () => { + animation.cancel(); + this.untrackAnimation(target, animation); + }; + } + + destroy(): void { + // No necesario — las animations se limpian por el WeakMap + DOM lifecycle. + } + + private signatureToKeyframes(sig: MotionSignature): Keyframe[] { + const from: Keyframe = {}; + const to: Keyframe = {}; + + const transforms: string[] = []; + const transformsTo: string[] = []; + + if (sig.scale) { + transforms.push(`scale(${sig.scale.from})`); + transformsTo.push(`scale(${sig.scale.to})`); + } + if (sig.translate) { + transforms.push(`translate(${sig.translate.x}px, ${sig.translate.y}px)`); + transformsTo.push(`translate(${sig.translate.x}px, ${sig.translate.y}px)`); + } + if (sig.rotate !== undefined) { + transforms.push(`rotate(${sig.rotate}deg)`); + transformsTo.push(`rotate(${sig.rotate}deg)`); + } + + if (transforms.length > 0) { + from.transform = transforms.join(' '); + to.transform = transformsTo.join(' '); + } + + return [from, to]; + } + + private signatureToOptions(sig: MotionSignature): KeyframeAnimationOptions { + return { + duration: sig.duration, + easing: sig.easing, + fill: 'none', // No retener estado al terminar. + composite: 'add' // Sumar al transform existente. + }; + } + + private trackAnimation(target: HTMLElement, animation: Animation): void { + const existing = this.activeAnimations.get(target) ?? []; + existing.push(animation); + this.activeAnimations.set(target, existing); + } + + private untrackAnimation(target: HTMLElement, animation: Animation): void { + const existing = this.activeAnimations.get(target); + if (!existing) return; + const idx = existing.indexOf(animation); + if (idx >= 0) existing.splice(idx, 1); + } +} +``` + +### 7.3. Comportamiento aditivo + +El uso de `composite: 'add'` es clave: permite que el transform de Sema se sume al transform en reposo de la capa visual. Si un botón tiene `transform: scale(0.98)` por `:active`, y Sema aplica un scale pulse, el resultado final es la composición, no el reemplazo. + +### 7.4. Fallback + +Si `Animation.finished` lanza por razones raras (navegador muy antiguo), `apply()` debe resolver sin error — la coreografía simplemente no tuvo motion, pero el resto del sistema sigue. + +--- + +## 9. Canal sound: `channels/sound.ts` + +### 8.1. Responsabilidad + +Genera earcons cortos a partir de la firma psicoacústica usando Web Audio API. Híbrido: síntesis procedural base; si la firma incluye `sampleUrl`, lo reproduce como sample con ajustes de gain. + +### 8.2. Arquitectura de síntesis + +Para cada earcon se construye un grafo Web Audio con esta estructura: + +``` +[Oscillator1 (main)] ──┐ + ├──> [Mixer Gain] ──> [Filter (low-pass)] ──> [ADSR Envelope] ──> [Master Gain] ──> destination +[Oscillator2 (fifth)] ─┘ │ + │ + [Ring Modulator (opcional)] ────────── +``` + +- **Oscillator1**: sine base con pitch de la firma +- **Oscillator2**: sine una quinta por encima (pitch × 1.5), mezclado al 30% para añadir cuerpo +- **Mixer Gain**: combina ambos osciladores +- **Filter**: low-pass con frecuencia de corte = `signature.centroid`, Q bajo (~1) +- **ADSR Envelope**: applied via GainNode con `setValueCurveAtTime` +- **Ring Modulator**: si `roughness > 0.2`, añadir un modulador en banda 30-150 Hz con profundidad proporcional a roughness + +El contour melódico se aplica modulando el detune del oscilador principal: + +- `flat`: detune constante 0 +- `ascending`: detune linealmente de -50 a +50 cents a lo largo de la duración +- `descending`: detune linealmente de +50 a -50 cents +- `arc`: detune sigue curva bell (centro pico) +- `bell`: detune sigue curva bell invertida + +### 8.3. Gestión del AudioContext + +```ts +// src/uix/sema/channels/sound.ts + +export class SoundChannel { + private audioCtx: AudioContext | null = null; + private masterGain: GainNode | null = null; + private unlocked = false; + private pendingUnlockCallbacks: Array<() => void> = []; + + private getOrCreateContext(): AudioContext | null { + if (!this.audioCtx) { + try { + this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + this.masterGain = this.audioCtx.createGain(); + this.masterGain.connect(this.audioCtx.destination); + this.setupUnlockListener(); + } catch { + return null; + } + } + // Si el context está suspended (navegador) — intentar resume. + if (this.audioCtx.state === 'suspended') { + this.audioCtx.resume().catch(() => {}); + } + return this.audioCtx; + } + + private setupUnlockListener(): void { + // Safari / mobile: AudioContext locked hasta user gesture. + const unlock = () => { + if (!this.audioCtx) return; + if (this.audioCtx.state === 'suspended') { + this.audioCtx.resume(); + } + this.unlocked = true; + // Desregistrar listeners. + ['click', 'touchstart', 'keydown'].forEach(ev => + document.removeEventListener(ev, unlock, true) + ); + }; + ['click', 'touchstart', 'keydown'].forEach(ev => + document.addEventListener(ev, unlock, true) + ); + } + + async apply( + signature: SoundSignature, + masterGainValue: number, + abortSignal: AbortSignal + ): Promise { + const ctx = this.getOrCreateContext(); + if (!ctx || ctx.state !== 'running') { + // AudioContext no disponible o locked — no sonar silenciosamente. + return; + } + + if (this.masterGain) { + this.masterGain.gain.value = masterGainValue; + } + + if (signature.sampleUrl) { + return this.playSample(ctx, signature, abortSignal); + } else { + return this.synthesize(ctx, signature, abortSignal); + } + } + + // Implementación de synthesize y playSample en secciones siguientes. + + destroy(): void { + if (this.audioCtx) { + this.audioCtx.close(); + this.audioCtx = null; + this.masterGain = null; + } + } +} +``` + +### 8.4. Síntesis procedural + +```ts +private async synthesize( + ctx: AudioContext, + sig: SoundSignature, + abortSignal: AbortSignal +): Promise { + const now = ctx.currentTime; + const durationSec = sig.duration / 1000; + const attackSec = sig.attack / 1000; + const decaySec = sig.decay / 1000; + + // 1. Oscilador principal. + const osc1 = ctx.createOscillator(); + osc1.type = 'sine'; + osc1.frequency.value = sig.pitch; + + // 2. Oscilador secundario (quinta). + const osc2 = ctx.createOscillator(); + osc2.type = 'sine'; + osc2.frequency.value = sig.pitch * 1.5; + + // 3. Mixer. + const mixer = ctx.createGain(); + mixer.gain.value = 1.0; + const osc2Gain = ctx.createGain(); + osc2Gain.gain.value = 0.3; + osc1.connect(mixer); + osc2.connect(osc2Gain); + osc2Gain.connect(mixer); + + // 4. Filter low-pass. + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = sig.centroid; + filter.Q.value = 1; + mixer.connect(filter); + + // 5. ADSR envelope. + const envelope = ctx.createGain(); + envelope.gain.setValueAtTime(0, now); + envelope.gain.linearRampToValueAtTime(sig.gain, now + attackSec); + envelope.gain.linearRampToValueAtTime(0, now + durationSec); + filter.connect(envelope); + + // 6. Ring modulator si roughness > 0.2. + let lastNode: AudioNode = envelope; + if (sig.roughness > 0.2) { + const modFreq = 30 + (sig.roughness - 0.2) * 150; + const modulator = ctx.createOscillator(); + modulator.frequency.value = modFreq; + const modulatorGain = ctx.createGain(); + modulatorGain.gain.value = sig.roughness * 0.5; + modulator.connect(modulatorGain.gain); + modulatorGain.connect(envelope.gain); + modulator.start(now); + modulator.stop(now + durationSec); + } + + // 7. Contour (modulación de detune). + this.applyContour(osc1, sig.contour, now, durationSec); + + // 8. Master gain. + lastNode.connect(this.masterGain!); + + osc1.start(now); + osc2.start(now); + osc1.stop(now + durationSec); + osc2.stop(now + durationSec); + + // 9. Esperar a que termine o abortar. + return new Promise((resolve) => { + const timer = setTimeout(resolve, sig.duration); + abortSignal.addEventListener('abort', () => { + clearTimeout(timer); + osc1.stop(); + osc2.stop(); + resolve(); + }); + }); +} + +private applyContour( + osc: OscillatorNode, + contour: string, + startTime: number, + durationSec: number +): void { + const endTime = startTime + durationSec; + switch (contour) { + case 'flat': + osc.detune.value = 0; + break; + case 'ascending': + osc.detune.setValueAtTime(-50, startTime); + osc.detune.linearRampToValueAtTime(50, endTime); + break; + case 'descending': + osc.detune.setValueAtTime(50, startTime); + osc.detune.linearRampToValueAtTime(-50, endTime); + break; + case 'arc': + osc.detune.setValueAtTime(-25, startTime); + osc.detune.linearRampToValueAtTime(50, startTime + durationSec * 0.5); + osc.detune.linearRampToValueAtTime(-25, endTime); + break; + case 'bell': + osc.detune.setValueAtTime(25, startTime); + osc.detune.linearRampToValueAtTime(-50, startTime + durationSec * 0.5); + osc.detune.linearRampToValueAtTime(25, endTime); + break; + } +} +``` + +### 8.5. Sample playback + +```ts +private sampleCache = new Map(); + +private async playSample( + ctx: AudioContext, + sig: SoundSignature, + abortSignal: AbortSignal +): Promise { + if (!sig.sampleUrl) return; + + let buffer = this.sampleCache.get(sig.sampleUrl); + if (!buffer) { + try { + const response = await fetch(sig.sampleUrl); + const arrayBuffer = await response.arrayBuffer(); + buffer = await ctx.decodeAudioData(arrayBuffer); + this.sampleCache.set(sig.sampleUrl, buffer); + } catch { + return; // Fallar silenciosamente. + } + } + + if (abortSignal.aborted) return; + + const source = ctx.createBufferSource(); + source.buffer = buffer; + + const envelope = ctx.createGain(); + envelope.gain.value = sig.gain; + source.connect(envelope); + envelope.connect(this.masterGain!); + + source.start(); + + return new Promise((resolve) => { + source.onended = () => resolve(); + abortSignal.addEventListener('abort', () => { + source.stop(); + resolve(); + }); + }); +} +``` + +### 8.6. Preload opcional + +Para eventos críticos donde la latencia de fetch importa, el engine puede precargar samples: + +```ts +export class SoundChannel { + async preloadSamples(urls: string[]): Promise { + const ctx = this.getOrCreateContext(); + if (!ctx) return; + await Promise.all(urls.map(async url => { + if (this.sampleCache.has(url)) return; + try { + const response = await fetch(url); + const ab = await response.arrayBuffer(); + const buf = await ctx.decodeAudioData(ab); + this.sampleCache.set(url, buf); + } catch {} + })); + } +} +``` + +Exposado vía `configureSema({ preloadSamples: [...] })`. + +--- + +## 10. Canal color: `channels/color.ts` + +### 9.1. Responsabilidad + +Expresa pulso cromático temporal. El color en reposo es responsabilidad de la capa visual; este canal aplica un pulso superpuesto usando técnicas aditivas (box-shadow) que no modifican las propiedades CSS que la capa visual controla. + +### 9.2. Estrategia de aplicación + +Tres técnicas posibles, elegidas según disponibilidad y preferencia: + +**Técnica primaria — box-shadow ring:** + +```css +box-shadow: 0 0 0 var(--sema-color-ring-width) hsl(...); +``` + +Se anima `var(--sema-color-ring-width)` de 0 a un valor (p. ej. 3px) y de vuelta a 0. La capa visual puede tener su propio `box-shadow` para estados (focus, etc.); Sema compone con `box-shadow: previous, new`. + +**Técnica secundaria — overlay pseudoelement:** + +Si el target no soporta box-shadow limpio (ej. `display: inline`), crear un pseudoelemento vía inyección temporal: + +```html +... + +``` + +El overlay se posiciona absolute, mismo tamaño, con `border-radius` derivado, y anima su color. + +**Técnica terciaria — outline:** + +Fallback cuando las dos primeras no funcionan. `outline` es siempre aditivo por definición (no afecta layout). + +### 9.3. Implementación + +```ts +// src/uix/sema/channels/color.ts + +import type { ColorSignature } from '../resolver'; + +export class ColorChannel { + private activeOverlays = new WeakMap(); + private originalBoxShadows = new WeakMap(); + + async apply( + target: HTMLElement, + signature: ColorSignature, + abortSignal: AbortSignal + ): Promise { + if (!target.isConnected) return; + + // Decidir técnica. + const technique = this.selectTechnique(target); + + switch (technique) { + case 'box-shadow': + return this.applyBoxShadow(target, signature, abortSignal); + case 'overlay': + return this.applyOverlay(target, signature, abortSignal); + case 'outline': + return this.applyOutline(target, signature, abortSignal); + } + } + + private selectTechnique(target: HTMLElement): 'box-shadow' | 'overlay' | 'outline' { + const computed = window.getComputedStyle(target); + // display inline no soporta bien box-shadow/outline. + if (computed.display === 'inline') return 'overlay'; + // Algunos targets necesitan outline por diseño. + if (target.dataset.semaColorTechnique === 'outline') return 'outline'; + return 'box-shadow'; + } + + private async applyBoxShadow( + target: HTMLElement, + sig: ColorSignature, + abortSignal: AbortSignal + ): Promise { + const color = `hsl(${sig.hue}, ${sig.saturation * 100}%, ${sig.lightness * 100}%)`; + const peakWidth = Math.round(sig.intensity * 8); // hasta 8px + const duration = sig.duration; + + // Capturar box-shadow original si hay. + const original = window.getComputedStyle(target).boxShadow; + const previousShadow = original === 'none' ? '' : original; + + // Animar usando Web Animations API para no tocar style. + const keyframes: Keyframe[] = [ + { boxShadow: this.composeShadow(previousShadow, 0, color) }, + { boxShadow: this.composeShadow(previousShadow, peakWidth, color), offset: 0.3 }, + { boxShadow: this.composeShadow(previousShadow, 0, color) } + ]; + + const animation = target.animate(keyframes, { + duration, + easing: 'ease-out', + fill: 'none' + }); + + if (abortSignal.aborted) { + animation.cancel(); + return; + } + abortSignal.addEventListener('abort', () => animation.cancel()); + + try { + await animation.finished; + } catch {} + } + + private composeShadow(previous: string, width: number, color: string): string { + const pulse = `0 0 0 ${width}px ${color}`; + return previous ? `${previous}, ${pulse}` : pulse; + } + + private async applyOverlay( + target: HTMLElement, + sig: ColorSignature, + abortSignal: AbortSignal + ): Promise { + const overlay = document.createElement('span'); + overlay.setAttribute('data-sema-temp', ''); + overlay.style.cssText = ` + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + box-shadow: 0 0 0 0 hsl(${sig.hue}, ${sig.saturation * 100}%, ${sig.lightness * 100}%); + opacity: 0; + `; + + // Asegurar contenedor positioned. + const parent = target.parentElement; + if (!parent) return; + const parentStyle = window.getComputedStyle(parent); + if (parentStyle.position === 'static') { + parent.style.position = 'relative'; + } + parent.appendChild(overlay); + + this.trackOverlay(target, overlay); + + const animation = overlay.animate([ + { boxShadow: `0 0 0 0 currentColor`, opacity: 0 }, + { boxShadow: `0 0 0 ${Math.round(sig.intensity * 8)}px currentColor`, opacity: 1, offset: 0.3 }, + { boxShadow: `0 0 0 0 currentColor`, opacity: 0 } + ], { + duration: sig.duration, + easing: 'ease-out' + }); + + if (abortSignal.aborted) { + animation.cancel(); + overlay.remove(); + return; + } + abortSignal.addEventListener('abort', () => { + animation.cancel(); + overlay.remove(); + }); + + try { + await animation.finished; + } finally { + overlay.remove(); + this.untrackOverlay(target, overlay); + } + } + + private async applyOutline( + target: HTMLElement, + sig: ColorSignature, + abortSignal: AbortSignal + ): Promise { + const color = `hsl(${sig.hue}, ${sig.saturation * 100}%, ${sig.lightness * 100}%)`; + const peakWidth = Math.max(2, Math.round(sig.intensity * 4)); + + const keyframes: Keyframe[] = [ + { outline: `0px solid ${color}` }, + { outline: `${peakWidth}px solid ${color}`, offset: 0.3 }, + { outline: `0px solid ${color}` } + ]; + + const animation = target.animate(keyframes, { + duration: sig.duration, + easing: 'ease-out' + }); + if (abortSignal.aborted) { + animation.cancel(); + return; + } + abortSignal.addEventListener('abort', () => animation.cancel()); + + try { + await animation.finished; + } catch {} + } + + private trackOverlay(target: HTMLElement, overlay: HTMLElement): void { + const existing = this.activeOverlays.get(target) ?? []; + existing.push(overlay); + this.activeOverlays.set(target, existing); + } + private untrackOverlay(target: HTMLElement, overlay: HTMLElement): void { + const existing = this.activeOverlays.get(target); + if (!existing) return; + const idx = existing.indexOf(overlay); + if (idx >= 0) existing.splice(idx, 1); + } + + destroy(): void { + // Limpiar overlays pendientes — rastreo por WeakMap no permite iterar, + // pero los overlays con data-sema-temp se pueden barrer del DOM. + document.querySelectorAll('[data-sema-temp]').forEach(el => el.remove()); + } +} +``` + +--- + +## 11. Canal presence: `channels/presence.ts` + +### 10.1. Responsabilidad + +Gestiona segregación figura-fondo: opacity, shadow de elevación, backdrop scrim, outline emergente. Coordina con la estructura del componente para colocar backdrop al nivel correcto. + +### 10.2. Estrategia + +- **opacity**: se aplica directamente al target con WAAPI (composite: 'replace' — opacity no compone bien con add) +- **shadow**: box-shadow superpuesto con técnica aditiva similar a color +- **backdrop**: overlay full-viewport o contenedor padre, inyectado y removido +- **outline**: aditivo por definición, sin riesgo de colisión + +### 10.3. Implementación + +```ts +// src/uix/sema/channels/presence.ts + +import type { PresenceSignature } from '../resolver'; + +export class PresenceChannel { + private backdrops = new WeakMap(); + + async apply( + target: HTMLElement, + signature: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!target.isConnected) return; + + const promises: Promise[] = []; + + // Opacity — se aplica al target directamente. + if (signature.opacity) { + promises.push(this.applyOpacity(target, signature, abortSignal)); + } + + // Shadow — aditivo. + if (signature.shadow) { + promises.push(this.applyShadow(target, signature, abortSignal)); + } + + // Backdrop — overlay en viewport root. + if (signature.backdrop !== undefined && signature.backdrop > 0) { + promises.push(this.applyBackdrop(target, signature, abortSignal)); + } + + // Outline. + if (signature.outline) { + promises.push(this.applyOutline(target, signature, abortSignal)); + } + + await Promise.all(promises); + } + + applySustained(target: HTMLElement, signature: PresenceSignature): () => void { + if (!target.isConnected) return () => {}; + + const cleanups: Array<() => void> = []; + + // Backdrop sustained (dialog abierto). + if (signature.backdrop !== undefined && signature.backdrop > 0) { + const backdrop = this.createBackdrop(signature); + document.body.appendChild(backdrop); + cleanups.push(() => backdrop.remove()); + } + + return () => cleanups.forEach(c => c()); + } + + private async applyOpacity( + target: HTMLElement, + sig: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + const animation = target.animate([ + { opacity: sig.opacity.from }, + { opacity: sig.opacity.to } + ], { + duration: sig.duration, + easing: sig.easing, + fill: 'forwards' // mantener el valor final hasta que la capa visual tome control + }); + if (abortSignal.aborted) { animation.cancel(); return; } + abortSignal.addEventListener('abort', () => animation.cancel()); + try { await animation.finished; } catch {} + } + + private async applyShadow( + target: HTMLElement, + sig: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!sig.shadow) return; + const { blur, y, opacity } = sig.shadow; + const original = window.getComputedStyle(target).boxShadow; + const previousShadow = original === 'none' ? '' : original; + const shadowEnd = `0 ${y}px ${blur}px rgba(0,0,0,${opacity})`; + const shadowStart = previousShadow || 'none'; + + const animation = target.animate([ + { boxShadow: this.composeShadow(previousShadow, 'none') }, + { boxShadow: this.composeShadow(previousShadow, shadowEnd) } + ], { + duration: sig.duration, + easing: sig.easing + }); + if (abortSignal.aborted) { animation.cancel(); return; } + abortSignal.addEventListener('abort', () => animation.cancel()); + try { await animation.finished; } catch {} + } + + private composeShadow(previous: string, pulse: string): string { + if (pulse === 'none') return previous || 'none'; + return previous ? `${previous}, ${pulse}` : pulse; + } + + private createBackdrop(sig: PresenceSignature): HTMLElement { + const backdrop = document.createElement('div'); + backdrop.setAttribute('data-sema-backdrop', ''); + backdrop.style.cssText = ` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, ${sig.backdrop}); + backdrop-filter: blur(4px); + pointer-events: none; + z-index: 9998; + transition: opacity ${sig.duration}ms ${sig.easing}; + `; + return backdrop; + } + + private async applyBackdrop( + target: HTMLElement, + sig: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + // Para apply() (pulso, no sustained), el backdrop aparece y se desvanece. + const backdrop = this.createBackdrop(sig); + backdrop.style.opacity = '0'; + document.body.appendChild(backdrop); + + // Force reflow. + backdrop.getBoundingClientRect(); + + backdrop.style.opacity = '1'; + + await new Promise((resolve) => { + const timer = setTimeout(() => { + backdrop.style.opacity = '0'; + setTimeout(() => { backdrop.remove(); resolve(); }, sig.duration); + }, sig.duration * 0.3); + abortSignal.addEventListener('abort', () => { + clearTimeout(timer); + backdrop.remove(); + resolve(); + }); + }); + } + + private async applyOutline( + target: HTMLElement, + sig: PresenceSignature, + abortSignal: AbortSignal + ): Promise { + if (!sig.outline) return; + const { width, style } = sig.outline; + const animation = target.animate([ + { outline: `0px ${style} currentColor` }, + { outline: `${width}px ${style} currentColor`, offset: 0.3 }, + { outline: `0px ${style} currentColor` } + ], { duration: sig.duration, easing: sig.easing }); + if (abortSignal.aborted) { animation.cancel(); return; } + abortSignal.addEventListener('abort', () => animation.cancel()); + try { await animation.finished; } catch {} + } + + destroy(): void { + document.querySelectorAll('[data-sema-backdrop]').forEach(el => el.remove()); + } +} +``` + +--- + +## 12. Gestión de accesibilidad: `a11y.ts` + +### 11.1. Responsabilidad + +Lee preferencias del usuario (`prefers-reduced-motion`, `prefers-reduced-transparency`, `prefers-contrast`, `forced-colors`) y transforma firmas efectivas aplicando las reducciones por canal según la política de la spec. + +### 11.2. Implementación + +```ts +// src/uix/sema/a11y.ts + +export class A11yMonitor { + private mq = { + reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)'), + reducedTransparency: window.matchMedia('(prefers-reduced-transparency: reduce)'), + highContrast: window.matchMedia('(prefers-contrast: more)'), + forcedColors: window.matchMedia('(forced-colors: active)') + }; + + hasActiveReduction(): boolean { + return this.mq.reducedMotion.matches || + this.mq.reducedTransparency.matches || + this.mq.highContrast.matches || + this.mq.forcedColors.matches; + } + + reduceSignature( + signature: EffectiveSignature, + config: EngineConfig + ): EffectiveSignature { + let result = { ...signature }; + + // Aplicar reductions canal por canal. + if (this.mq.reducedMotion.matches) { + result = this.applyReducedMotion(result); + } + if (this.mq.reducedTransparency.matches) { + result = this.applyReducedTransparency(result); + } + if (this.mq.highContrast.matches) { + result = this.applyHighContrast(result); + } + if (this.mq.forcedColors.matches) { + result = this.applyForcedColors(result); + } + + // Filtrar canales desactivados globalmente. + result.activeChannels = result.activeChannels.filter(ch => { + if (ch === 'motion' && !config.motion.enabled) return false; + if (ch === 'sound' && !config.sound.enabled) return false; + if (ch === 'color' && !config.color.enabled) return false; + if (ch === 'presence' && !config.presence.enabled) return false; + return true; + }); + + return result; + } + + private applyReducedMotion(sig: EffectiveSignature): EffectiveSignature { + const activeChannels = sig.activeChannels.filter(c => c !== 'motion'); + return { + ...sig, + activeChannels, + motion: undefined, + // Color pasa a "discreto" (sin pulso animado). + color: sig.color ? { + ...sig.color, + duration: Math.min(sig.color.duration, 50), + intensity: Math.min(sig.color.intensity, 0.2) + } : undefined, + // Presence pasa a "no cinético" (sin fade suave). + presence: sig.presence ? { + ...sig.presence, + duration: Math.min(sig.presence.duration, 50) + } : undefined + }; + } + + private applyReducedTransparency(sig: EffectiveSignature): EffectiveSignature { + // Solo afecta a presence. + if (!sig.presence) return sig; + return { + ...sig, + presence: { + ...sig.presence, + backdrop: sig.presence.backdrop ? Math.min(sig.presence.backdrop, 0.7) : undefined + // blur se gestiona en el canal; sin blur aquí se logra removiendo `backdrop-filter` del CSS. + } + }; + } + + private applyHighContrast(sig: EffectiveSignature): EffectiveSignature { + // Re-resolver color hacia variantes de alto contraste. + return { + ...sig, + color: sig.color ? { + ...sig.color, + saturation: Math.min(sig.color.saturation + 0.2, 1), + lightness: sig.color.lightness < 0.5 ? 0.2 : 0.8, + intensity: Math.min(sig.color.intensity + 0.3, 1) + } : undefined + }; + } + + private applyForcedColors(sig: EffectiveSignature): EffectiveSignature { + // Color ornamental fuera; presence degrada a contorno. + const activeChannels = sig.activeChannels.filter(c => c !== 'color'); + return { + ...sig, + activeChannels, + color: undefined, + presence: sig.presence ? { + ...sig.presence, + outline: { width: 3, style: 'solid' }, + backdrop: undefined + } : undefined + }; + } + + onPreferenceChange(callback: () => void): () => void { + const listeners: Array<[MediaQueryList, () => void]> = []; + for (const mq of Object.values(this.mq)) { + const listener = () => callback(); + mq.addEventListener('change', listener); + listeners.push([mq, listener]); + } + return () => { + for (const [mq, listener] of listeners) { + mq.removeEventListener('change', listener); + } + }; + } +} +``` + +--- + +## 13. Pipeline `.csem`: plugin PostCSS + +### 12.1. Responsabilidad + +El plugin PostCSS procesa archivos `.csem` en tiempo de build. Valida que las custom properties `--sema-*` referencien eventos canónicos y parámetros válidos. Compila a un JSON consumible por el Resolver en runtime. + +### 12.2. Estructura + +``` +src/uix/sema/csem/ +├── plugin.ts # Entry point del plugin PostCSS +├── parser.ts # Extrae declaraciones --sema-* de reglas CSS +├── compiler.ts # Transforma declaraciones a CSEMOverrides JSON +└── vocabulary.ts # Whitelist de nombres de parámetros válidos +``` + +### 12.3. Formato de entrada (`.csem`) + +```css +/* app/styles/sema.csem */ + +:root { + --sema-commit-fulfill-sound-pitch: 1200; + --sema-commit-fulfill-sound-contour: ascending; + --sema-commit-fulfill-color-hue: 155; +} + +[data-dialog][data-last-action="saved"] { + --sema-event-override: commit-fulfill; + --sema-sound-duration: 180; + --sema-color-intensity: 0.6; +} + +.quiet-zone { + --sema-alert-threat-sound-gain: 0.15; + --sema-alert-threat-color-intensity: 0.25; +} +``` + +### 12.4. Formato de salida (`sema-overrides.json`) + +```json +{ + "version": "0.4.0", + "selectors": [ + { + "selector": ":root", + "overrides": { + "commit-fulfill": { + "sound": { "pitch": 1200, "contour": "ascending" }, + "color": { "hue": 155 } + } + } + }, + { + "selector": "[data-dialog][data-last-action=\"saved\"]", + "overrides": { + "commit-fulfill": { + "sound": { "duration": 180 }, + "color": { "intensity": 0.6 } + } + } + } + ] +} +``` + +### 12.5. Plugin PostCSS + +```ts +// src/uix/sema/csem/plugin.ts + +import type { Plugin } from 'postcss'; +import { parseCSEM } from './parser'; +import { compileCSEM } from './compiler'; +import fs from 'fs'; +import path from 'path'; + +export interface CSEMPluginOptions { + /** Dónde escribir el JSON compilado. */ + output: string; + /** Dónde buscar .csem. Default: todos los que PostCSS procese. */ + include?: string[]; +} + +export const csemPlugin = (opts: CSEMPluginOptions): Plugin => { + const collected: ParsedCSEM[] = []; + + return { + postcssPlugin: 'sema-csem', + Once(root) { + const filename = root.source?.input.from; + if (!filename?.endsWith('.csem')) return; + + const parsed = parseCSEM(root); + collected.push(parsed); + + // Eliminar del output CSS (el .csem no debe ir al navegador). + root.removeAll(); + }, + OnceExit() { + const compiled = compileCSEM(collected); + fs.writeFileSync(opts.output, JSON.stringify(compiled, null, 2)); + } + }; +}; + +csemPlugin.postcss = true; +``` + +### 12.6. Vocabulario válido + +```ts +// src/uix/sema/csem/vocabulary.ts + +// Todos los 22 eventos +export const VALID_EVENTS = new Set([...]); + +// Parámetros válidos por canal +export const VALID_SOUND_PARAMS = new Set([ + 'pitch', 'centroid', 'roughness', 'attack', 'decay', + 'duration', 'contour', 'gain' +]); +export const VALID_MOTION_PARAMS = new Set([ + 'duration', 'easing', 'scale-from', 'scale-to', + 'translate-x', 'translate-y', 'rotate' +]); +export const VALID_COLOR_PARAMS = new Set([ + 'hue', 'saturation', 'lightness', 'duration', 'intensity' +]); +export const VALID_PRESENCE_PARAMS = new Set([ + 'opacity-from', 'opacity-to', 'shadow-blur', 'shadow-y', + 'shadow-opacity', 'backdrop', 'outline-width', 'outline-style', + 'duration', 'easing' +]); + +export function validateCustomProperty(name: string): ValidationResult { + // Parsea "--sema---" y valida cada parte. +} +``` + +### 12.7. Integración con Vite + +```ts +// En vite.config.ts del proyecto consumidor +import { defineConfig } from 'vite'; +import { csemPlugin } from '$uix/sema/csem'; + +export default defineConfig({ + css: { + postcss: { + plugins: [ + csemPlugin({ output: 'src/generated/sema-overrides.json' }) + ] + } + } +}); +``` + +El runtime lee `sema-overrides.json` en initialization y lo pasa al Resolver. + +--- + +## 14. API pública final + +Lo que `src/uix/sema/index.ts` exporta cuando todo está implementado: + +```ts +// Tipos +export type { + SemaEventLabel, SemaAttrWrite, SemaCommit, + SemaAction, SemaSustainDecl, SemaSpec +} from './types'; + +export type { + SemaPort, SemaSession, SemaContext, + ResolvedSemaAction, ResolvedSemaSustain +} from './port'; + +export type { + SemaBinding, PartialSemaContext +} from './binding'; + +export type { + EffectiveSignature, + MotionSignature, SoundSignature, ColorSignature, PresenceSignature, + EngineConfig +} from './resolver'; + +// Validación +export { + validateSema, + SemaInvariantError +} from './validation'; + +// Port +export { + noopSemaPort, + createTestSemaPort +} from './port'; + +// Binding +export { createSemaBinding } from './binding'; + +// Engine +export { + getSemaEngine, + configureSema, + destroySema +} from './engine'; + +// CSEM (build-time only) +export { csemPlugin } from './csem/plugin'; +``` + +Uso típico en un provider: + +```ts +import { createSemaBinding, noopSemaPort, getSemaEngine } from '$uix/sema'; +import { dialogSema } from '$uix/sema/components/dialog'; + +// En producción, usar engine real. +const port = getSemaEngine(); +// En tests, usar noopSemaPort o createTestSemaPort. + +const sema = createSemaBinding(dialogSema, port); + +// En un método del provider: +async function closeSave() { + await sema.before('close-save', { + targetEl: contentEl, + partEls: { content: contentEl, trigger: triggerEl }, + cause: 'pointer', + abortSignal: this.abortController.signal + }); + this.state.open = false; +} +``` + +--- + +## 15. Estrategia de testing + +### 14.1. Niveles de test + +**Unit tests (por módulo).** Cada pieza testeada de forma aislada: +- `port.test.ts` — no-op, test port capture calls +- `binding.test.ts` — resolve defaults, apply prewrites, delegate to port +- `resolver.test.ts` — parse event, apply deltas, apply overrides, cache +- `engine.test.ts` — regime handling, cap enforcement, lifecycle +- `a11y.test.ts` — reductions per preference +- `channels/motion.test.ts` — WAAPI invocation, composite +- `channels/sound.test.ts` — graph construction, context unlock +- `channels/color.test.ts` — technique selection, overlay lifecycle +- `channels/presence.test.ts` — backdrop mounting, opacity + +**Integration tests.** Binding + engine + port real + canal falso (mocks DOM API): +- Flujo completo de una acción blocking +- Flujo de una acción advisory +- Flujo de un sustain +- Cancelación por abort signal +- Cancelación por desmontaje + +**E2E tests (Playwright).** Sobre componentes reales: +- Dialog con close-save: verificar `sema:event` CustomEvent +- Input invalidate: verificar que `data-state` cambia después del cap +- Toast con scope:scene: verificar supervivencia al desmontaje + +### 14.2. Test helpers + +```ts +// src/uix/sema/__tests__/helpers.ts + +export function createMockTarget(): HTMLElement { + const el = document.createElement('div'); + document.body.appendChild(el); + return el; +} + +export function mockMatchMedia(reducedMotion: boolean): void { + window.matchMedia = (query: string): MediaQueryList => { + const matches = query.includes('reduced-motion') ? reducedMotion : false; + return { + matches, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + // ... otros props + } as any; + }; +} + +export function waitForCustomEvent( + target: HTMLElement, + eventName: string, + filter?: (ev: CustomEvent) => boolean +): Promise { + return new Promise((resolve) => { + const listener = (ev: Event) => { + const ce = ev as CustomEvent; + if (!filter || filter(ce)) { + target.removeEventListener(eventName, listener); + resolve(ce); + } + }; + target.addEventListener(eventName, listener); + }); +} +``` + +### 14.3. Mocking de Web Audio + +Web Audio es difícil de testear en jsdom. Para los tests unitarios del canal sound, se usa un stub completo de `AudioContext` que registra las llamadas en lugar de hacer DSP: + +```ts +export function stubAudioContext(): any { + const calls: string[] = []; + return { + currentTime: 0, + state: 'running', + createOscillator: () => ({ + frequency: { value: 0 }, + detune: { + value: 0, + setValueAtTime: () => calls.push('detune.setValueAtTime'), + linearRampToValueAtTime: () => calls.push('detune.ramp') + }, + connect: () => calls.push('osc.connect'), + start: () => calls.push('osc.start'), + stop: () => calls.push('osc.stop'), + type: 'sine' + }), + // ... otros nodos + _calls: calls + }; +} +``` + +Tests verifican que la secuencia de llamadas es la esperada para cada firma. + +### 14.4. Mocking de WAAPI + +jsdom no implementa WAAPI. Polyfill o mock: + +```ts +// test-setup.ts +if (!Element.prototype.animate) { + Element.prototype.animate = function(keyframes: any, options: any) { + const animation: any = { + finished: Promise.resolve(), + cancel: () => {}, + playState: 'running' + }; + // Record para verificación en tests. + (this as any)._lastAnimation = { keyframes, options }; + return animation; + }; +} +``` + +--- + +## 16. Criterios de aceptación + +La implementación se considera completa cuando: + +1. **Port funcional.** `noopSemaPort` existe y cumple la interfaz. `createTestSemaPort` registra correctamente todas las invocaciones. Los tests de port pasan. + +2. **Binding funcional.** `createSemaBinding(spec, port)` produce helpers tipados. Los prewrites se aplican al DOM. Los defaults se resuelven. Acciones con nombre inválido lanzan en dev. Los tests de binding pasan. + +3. **Resolver funcional.** Dado un evento canónico y un mapa, produce la firma efectiva. Aplica deltas correctamente (add, multiply, replace). Aplica overrides de CSEM compilado. Los tests del resolver pasan. + +4. **Engine funcional.** Implementa `SemaPort`. Gestiona los 4 regímenes correctamente. Respeta caps temporales. Emite `CustomEvent('sema:event')` en start/end/cancelled. Se destruye limpiamente. Los tests del engine pasan. + +5. **Canales funcionales.** Cada canal (motion, sound, color, presence) aplica su firma al target DOM. Respetan AbortSignal. No dejan leaks de DOM ni Web Audio. Los tests de canales pasan. + +6. **A11y funcional.** Lee preferencias correctamente. Aplica reducciones por canal según la política. Los tests de a11y pasan. + +7. **CSEM pipeline funcional.** El plugin PostCSS procesa archivos `.csem`. Valida vocabulario. Genera JSON consumible. Los tests de csem pasan. + +8. **Integration tests.** Un flujo completo de acción blocking termina correctamente. Un sustain se activa y se detiene. La cancelación por abort funciona. + +9. **E2E tests.** Un componente real con morfo-sema emite los `CustomEvent` esperados cuando se dispara una acción. + +10. **Sin regresiones en el código existente.** `$uix/sema/types.ts` y `$uix/sema/validation.ts` siguen funcionando; los 66 morfos existentes siguen siendo válidos; el test de `dialog.test.ts` sigue pasando. + +--- + +## Apéndice A — Dependencias nuevas del paquete + +No deberían requerirse dependencias nuevas. El runtime usa solo APIs estándar del navegador (DOM, WAAPI, Web Audio, matchMedia). + +El plugin PostCSS sí requiere `postcss` como peer dependency (probablemente ya está en el proyecto). + +Test-only dependencies (probablemente ya instaladas): +- `vitest` +- `@vitest/browser` o jsdom +- `@playwright/test` para E2E + +--- + +## Apéndice B — Orden sugerido de implementación + +El orden reduce dependencias cíclicas y permite testear incrementalmente: + +1. **port.ts + tests** — sin dependencias; base del sistema +2. **resolver.ts + sema-map.json + tests** — consume solo tipos ya existentes +3. **a11y.ts + tests** — independiente +4. **channels/motion.ts + tests** — WAAPI wrapper simple +5. **channels/color.ts + tests** — más complejo (técnicas múltiples) +6. **channels/presence.ts + tests** — parecido a color +7. **channels/sound.ts + tests** — el más complejo; dejar para el final +8. **engine.ts + tests** — depende de todo lo anterior +9. **binding.ts + tests** — depende de port y types +10. **csem/plugin.ts + tests** — independiente, puede hacerse en paralelo + +Tiempo estimado para implementación completa con tests: 3-4 semanas de trabajo full-time. La parte más compleja por mucho es `channels/sound.ts` (psicoacústica a DSP real). + +--- + +## Apéndice C — Casos límite documentados + +Casos que la implementación debe manejar explícitamente: + +1. **Target desmontado durante ventana Sema.** El engine detecta vía `isConnected` check y resuelve la promise tempranamente. Los canales que hayan empezado cancelan sus animations. + +2. **Múltiples instancias del engine.** Si por error se crea más de una, los canales entran en conflicto (especialmente sound — dos AudioContexts). El getter `getEngine()` garantiza instancia única. + +3. **AudioContext locked en Safari.** El canal sound escucha gestos de usuario y llama `resume()`. Mientras tanto, los eventos con sonido simplemente no suenan. No se encolan. + +4. **Más de 100 eventos concurrentes.** Poco probable pero posible. El engine no limita; depende del navegador para throttle. Documentar como limitación. + +5. **Evento con sound activo pero runtime overrides deshabilitan sound.** El resolver produce una firma sin sound; el engine no ejecuta el canal; el CustomEvent refleja `channels: []` si solo había sound. + +6. **Acción cuyo prewrite escribe a un atributo que un MutationObserver observa.** El prewrite puede disparar lógica externa. Esto es diseño intencional (es lo que permite que la capa visual reaccione) pero el provider debe ser consciente de que los prewrites ocurren antes de que el engine devuelva control. + +7. **Two bindings para el mismo componente en la misma página.** Cada instancia de componente tiene su propio binding. Los bindings no compiten entre sí; el engine los ve como targets distintos. + +--- + +**Fin del documento de arquitectura.** + +*Este documento es la referencia autoritativa para la implementación del runtime Sema. Cualquier desviación en el código respecto a lo aquí descrito debe justificarse explícitamente y actualizar el documento.* diff --git a/src/uix/sema/sema-spec-v0.3.1.md b/src/uix/sema/sema-spec-v0.4.md similarity index 55% rename from src/uix/sema/sema-spec-v0.3.1.md rename to src/uix/sema/sema-spec-v0.4.md index 4118071b4..a1884547a 100644 --- a/src/uix/sema/sema-spec-v0.3.1.md +++ b/src/uix/sema/sema-spec-v0.4.md @@ -1,27 +1,19 @@ -# Sema — Especificación v0.3.1 +# Sema — Especificación v0.4 **Capa semántico-perceptiva para interfaces de usuario** -> Working draft. Esta especificación describe Sema, una capa de expresión perceptivo-afectiva de los eventos de interfaz. Es independiente de framework y stack. La implementación de referencia es **SemaUIX**, donde Sema convive con dos capas adicionales llamadas Soma (headless) y Eidos (visual). +> Especificación de Sema como capa conceptual. Independiente de framework, stack y artefactos de implementación concretos. Esta versión separa lo que es Sema (contenido de este documento) de cómo se implementa en un framework específico (ver documentos de implementación separados, por ejemplo `semauix-sema-impl.md`). > -> **Estado:** v0.3.1 es la versión v0.3 con correcciones de fricción interna detectadas en revisión final por ChatGPT. No introduce cambios arquitectónicos; solo precisa formulaciones y corrige ejemplos. +> **Estado:** v0.4 es v0.3.1 purificada de dependencias con SemaUIX. Mismo contenido doctrinal, alcance redefinido. > -> **Cambios respecto a v0.3:** -> - §2.2 reformulado: la secuencialidad estricta aplica al camino `blocking`, no a toda acción -> - §11.3 neutralizado: las técnicas aditivas (composite: 'add', overlays) pasan a recomendación de implementación, no doctrina arquitectónica -> - §5.7 explicita: `commits` declara efecto estructural, no precondiciones de aplicabilidad -> - El ejemplo de Dialog renombra `close-failed` a `close-after-fail` para eliminar ambigüedad semántica -> - La regla de validación de `keyboard.action` se relaja: solo adquiere semántica Sema si además está declarada en `sema.actions` +> **Cambios respecto a v0.3.1:** +> - Eliminadas referencias específicas a morfo como contrato obligatorio +> - `SemaAction` descrita conceptualmente, sin sintaxis TypeScript concreta de SemaUIX +> - Eliminadas menciones a `v.partRef`, `as const satisfies`, sium, `createSemaBinding` +> - Ejemplo Dialog movido a la documentación de implementación de referencia +> - Se añade §13 que define el contrato mínimo que una implementación debe proveer > -> **Cambios de v0.2 a v0.3 (heredados):** -> - El principio aditivo se reformula como principio de secuencialidad coordinada -> - Se introduce la extensión `MorfoSema` como contrato estructural del componente -> - Se introduce el puerto neutral `SemaPort` como protocolo Soma-Sema -> - Los regímenes de arbitraje (`replace | collapse | lock | queue`) quedan definidos -> - La política de accesibilidad pasa a ser por canal y por preferencia -> - La fundamentación bibliográfica se extrae a documento paralelo (`sema-research.md`) -> -> Lista para implementación prototipo. +> Lista para que implementaciones concretas (SemaUIX o cualquier otra) la materialicen. --- @@ -45,9 +37,9 @@ Sema asume un contexto arquitectónico de **tres capas independientes** que se c | Capa visual | Presentación en reposo del componente | Consume los atributos de estado para estilar cómo se ve el elemento **mientras dura cada estado** | | Sema | Expresión perceptivo-afectiva de los eventos | Reacciona a las acciones declaradas por la capa headless y ejecuta coreografías perceptivas acotadas | -Las tres capas se coordinan a través de un **contrato cross-layer**: un artefacto declarativo por componente (llamado `morfo` en SemaUIX) que define la superficie pública del componente — sus partes, los atributos que emite, su contrato ARIA, sus acciones semánticas. Cada capa consume el contrato desde su ángulo. +Las tres capas se coordinan a través de un **contrato cross-layer**: un artefacto declarativo por componente que define la superficie pública del componente — sus partes, los atributos que emite, su contrato ARIA, sus acciones semánticas. Cada capa consume el contrato desde su ángulo. -> En la implementación de referencia **SemaUIX**, la capa headless se llama **Soma**, la capa visual se llama **Eidos**, y el contrato cross-layer se llama **morfo**. Esta spec usa los términos genéricos "capa headless" y "capa visual" para mantenerse agnóstica, refiriéndose por nombre solo cuando aporte claridad operativa. +Esta spec no dicta cómo se implementa ese contrato cross-layer. Una implementación puede usar un artefacto TypeScript con validación runtime (como hace la implementación de referencia SemaUIX con `morfo`), decoradores en clases, registros runtime, hooks, o cualquier otro mecanismo que exponga la información que Sema necesita. ### 1.3. Alcance de esta spec @@ -55,17 +47,18 @@ Sema especifica: - La taxonomía semántica (6 familias de eventos, 5 intents afectivos) - Los canales perceptivos (4 ejes: motion, sound, color, presence) -- La extensión del contrato cross-layer para declarar acciones semánticas (morfo-sema) -- El protocolo de coordinación entre la capa headless y Sema (SemaPort) +- La noción conceptual de acción semántica (§5) +- El protocolo de coordinación entre la capa headless y Sema (§6) - La gramática del artefacto `.csem` (donde el integrador resuelve la expresión perceptiva) - La estructura del artefacto `sema-map.json` (vocabulario canónico de eventos y firmas) - Los principios de operación (secuencialidad coordinada, regímenes de arbitraje) -- Los requisitos mínimos para que una implementación sea "Sema-compliant" +- Los requisitos mínimos para que una implementación sea Sema-compliant (§13) Sema NO especifica: - Cómo se implementa la capa headless ni la visual - Qué framework se usa (Svelte, React, Vue, Web Components) +- Qué mecanismo concreto declara las acciones semánticas (TypeScript const, decoradores, registros runtime, otro) - El pipeline de build concreto - La API JavaScript exacta del engine - Los valores numéricos finales del mapa (se proveen rangos defendibles; la calibración final es responsabilidad de cada implementación) @@ -90,7 +83,7 @@ La capa visual estiliza estados. Sema expresa eventos. No son el mismo objeto ni La secuencialidad estricta aplica al modo `blocking`, que es el canónico para la mayoría de acciones con cambio de estado. Las acciones `advisory` (fase `after-state`) permiten que Sema corra en paralelo al estado ya cambiado, y las coreografías largas pueden extenderse más allá del cap temporal del provider como "tails post-state" — en esos casos, el tramo secuencial garantizado termina cuando el provider libera el `await`. -Esta secuencialidad la garantiza la capa headless, guiada por el contrato cross-layer. La capa headless sabe qué acciones puede realizar un componente (porque morfo las declara), sabe qué evento Sema precede a cada acción (porque morfo-sema lo declara), y orquesta el orden: +Esta secuencialidad la garantiza la capa headless. La capa headless sabe qué acciones puede realizar un componente (porque el contrato cross-layer las declara), sabe qué evento Sema precede a cada acción, y orquesta el orden: 1. La capa headless decide que va a ejecutar una acción 2. Aplica los prewrites necesarios al DOM (atributos contextuales antes del evento) @@ -99,7 +92,7 @@ Esta secuencialidad la garantiza la capa headless, guiada por el contrato cross- 5. Al terminar, la capa headless aplica el cambio de estado 6. La capa visual reacciona al nuevo estado con sus transiciones -Esto reemplaza el "principio aditivo" de versiones anteriores. En el camino canónico ya no hay superposición de capas sobre el mismo elemento al mismo tiempo; hay turnos de autoridad claros, garantizados por el protocolo. Los casos que permiten paralelismo (advisory, tails post-cap) quedan explícitamente marcados como post-state, no como violaciones de la doctrina. +En el camino canónico ya no hay superposición de capas sobre el mismo elemento al mismo tiempo; hay turnos de autoridad claros, garantizados por el protocolo. Los casos que permiten paralelismo (advisory, tails post-cap) quedan explícitamente marcados como post-state, no como violaciones de la doctrina. ### 2.3. El contrato cross-layer como fuente única @@ -110,21 +103,23 @@ La clave de que la secuencialidad funcione es que las tres capas comparten un co El contrato **no** declara cómo se estilan los estados (eso es responsabilidad de la capa visual) ni cómo se expresan perceptivamente los eventos (eso es responsabilidad de Sema via `.csem` y `sema-map.json`). El contrato es estructural, no implementacional. +Qué mecanismo concreto realiza ese contrato (const declarativo, decoradores, registros, hooks) es decisión de la implementación del framework. + ### 2.4. Separación de qué y cómo -Morfo declara qué existe; cada capa declara cómo lo hace: +El contrato cross-layer declara qué existe; cada capa declara cómo lo hace: | Capa | Qué declara el contrato | Dónde vive el cómo | |---|---|---| -| Headless | Parts, atributos emitidos, ARIA, keyboard, acciones semánticas | Código del provider | +| Headless | Partes, atributos emitidos, ARIA, keyboard, acciones semánticas | Código del provider | | Visual | (lee del contrato) | CSS en reposo del componente | | Sema | (lee del contrato) | `.csem` del integrador + `sema-map.json` | -Cuando morfo dice "Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`", no está diciendo cómo se expresa `commit-fulfill`. Eso lo resuelve Sema con `.csem` (si el integrador lo sobreescribe) o con el `sema-map.json` por defecto. +Cuando el contrato declara que un Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`, no está diciendo cómo se expresa `commit-fulfill`. Eso lo resuelve Sema con `.csem` (si el integrador lo sobreescribe) o con el `sema-map.json` por defecto. ### 2.5. Declaratividad y escape hatches -Sema es declarativa por diseño: el 95% de los casos se expresan en morfo-sema y `.csem` sin código imperativo. Para casos que no caben en el modelo declarativo (condiciones no expresables como atributos DOM, orquestación temporal compleja, estímulos calculados en runtime), el engine debe proveer una API imperativa como escape hatch. Es excepción, no regla. +Sema es declarativa por diseño: el 95% de los casos se expresan en el contrato cross-layer y `.csem` sin código imperativo. Para casos que no caben en el modelo declarativo (condiciones no expresables como atributos DOM, orquestación temporal compleja, estímulos calculados en runtime), el engine debe proveer una API imperativa como escape hatch. Es excepción, no regla. --- @@ -163,25 +158,30 @@ Los cinco intents son cinco puntos anclados en el espacio bidimensional valencia Sema define un conjunto finito y enumerable de eventos semánticos que resultan de combinar familias × intents: -```ts -type SemaEventLabel = - // Contact (4 familia valencial × 5 intents) - | 'contact-neutral' | 'contact-threat' | 'contact-risk' - | 'contact-affirm' | 'contact-fulfill' - // Commit - | 'commit-neutral' | 'commit-threat' | 'commit-risk' - | 'commit-affirm' | 'commit-fulfill' - // Alert - | 'alert-neutral' | 'alert-threat' | 'alert-risk' - | 'alert-affirm' | 'alert-fulfill' - // Handle - | 'handle-neutral' | 'handle-threat' | 'handle-risk' - | 'handle-affirm' | 'handle-fulfill' +``` +SemaEventLabel ∈ { + // Contact × intents + contact-neutral, contact-threat, contact-risk, + contact-affirm, contact-fulfill, + + // Commit × intents + commit-neutral, commit-threat, commit-risk, + commit-affirm, commit-fulfill, + + // Alert × intents + alert-neutral, alert-threat, alert-risk, + alert-affirm, alert-fulfill, + + // Handle × intents + handle-neutral, handle-threat, handle-risk, + handle-affirm, handle-fulfill, + // Transicionales (sin intent) - | 'emerge' | 'sustain'; + emerge, sustain +} ``` -22 eventos en total (20 valenciales + 2 transicionales). Este tipo es exportado por el paquete Sema y consumido por morfo para validar que las acciones referencian eventos existentes. +22 eventos en total (20 valenciales + 2 transicionales). Este vocabulario es finito y cerrado; implementaciones no deben extenderlo arbitrariamente. Si una necesidad perceptiva real no encaja, es señal de revisar la taxonomía, no de añadir un evento ad-hoc. ### 3.4. La moral la da el intent, no la familia @@ -275,73 +275,51 @@ No todos los eventos activan los cuatro canales. Qué canales activa cada evento --- -## 5. Morfo-Sema: el contrato de acciones +## 5. Acciones semánticas: el concepto -### 5.1. Propósito +### 5.1. Qué es una acción semántica -Morfo-sema es la extensión del contrato cross-layer que declara las **acciones semánticas** de un componente. Una acción es cualquier cosa que el componente hace que tiene significado perceptivo: abrir, cerrar-con-éxito, cerrar-con-error, invalidar, confirmar, etc. +Una **acción semántica** es cualquier cosa que un componente hace que tiene significado perceptivo: abrir, cerrar-con-éxito, cerrar-con-error, invalidar, confirmar, etc. -Morfo-sema declara la estructura de esas acciones (qué eventos Sema disparan, qué transiciones de estado comitean, qué prewrites necesitan) pero **no declara cómo se expresan perceptivamente** (eso es responsabilidad de `.csem` y `sema-map.json`). +Las acciones semánticas son declaradas por cada componente en su contrato cross-layer. La capa headless las invoca durante el flujo del componente. Sema las ejecuta aplicando las firmas perceptivas correspondientes. -La analogía clave: morfo declara que un Dialog tiene un part `Content` con estados `['open', 'closed']`, pero no declara cómo se ve el componente en cada estado (eso es trabajo de la capa visual). De la misma forma, morfo-sema declara que Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`, pero no declara cómo suena o se anima ese evento. +La acción es la unidad de integración entre la capa headless y Sema. No los eventos DOM crudos (un click puede ser una acción u otra dependiendo del contexto), ni los cambios de estado (un cambio puede ser consecuencia de varias acciones distintas). La acción declara explícitamente qué está ocurriendo semánticamente. -### 5.2. Shape del contrato +### 5.2. Campos conceptuales de una acción -```ts -type SemaAttrWrite = { - part: PartRef; - attr: string; // debe existir en data[] del part referenciado - value: string; // debe pertenecer a values[] si el attr es enumerable -}; +Una acción declara: -type SemaCommit = { - part: PartRef; - attr: string; // típicamente 'data-state' - value: string; // el valor al que se comitea -}; +- **Nombre** — identificador único dentro del componente. Referenciable desde el código del provider y desde declaraciones de teclado. +- **Target** — referencia a la parte del componente afectada por la acción (dónde se aplica la coreografía perceptiva). +- **Evento** — etiqueta del vocabulario canónico (`SemaEventLabel`). Define qué firma perceptiva se dispara. +- **Modo** (opcional, default `blocking`) — si la capa headless espera a que Sema termine antes de aplicar el cambio de estado, o dispara y sigue. +- **Régimen** (opcional, default `replace`) — qué hace Sema si llega otra acción equivalente durante la ventana. +- **Scope** (opcional, default `part`) — el alcance de la coreografía: solo el target, el componente completo, o persiste como escena tras desmontaje. +- **Prewrites** (opcional) — atributos DOM que se reflejan antes de invocar Sema (contexto causal que las capas posteriores podrán leer). +- **Commits** (opcional) — si existe, describe el cambio de estado que la capa headless aplicará **después** de que Sema termine. -type SemaAction = { - /** Identificador único de la acción dentro del componente. - * Referenciado desde keyboard.action y desde el provider. */ - name: string; - - /** Part primario afectado por la acción. */ - target: PartRef; - - /** Evento Sema que dispara esta acción. */ - event: SemaEventLabel; - - /** Cómo se comporta el provider durante la ejecución de Sema. - * Default 'blocking'. */ - mode?: 'blocking' | 'advisory'; - - /** Qué hace Sema si llega otra acción equivalente durante la ventana. - * Default 'replace'. */ - regime?: 'replace' | 'collapse' | 'lock' | 'queue'; - - /** Atributos que se deben reflejar en DOM antes de invocar Sema. */ - prewrite?: readonly SemaAttrWrite[]; - - /** Si existe, transición de estado que se comitea tras la ventana Sema. - * Ausente para acciones que no comitean estado (submit-failed). */ - commits?: SemaCommit; -}; - -type MorfoSema = { - actions: readonly SemaAction[]; -}; - -type Morfo = { - // ... shape actual - sema?: MorfoSema; -}; +Estos ocho campos son suficientes para que la capa headless orqueste la secuencia y Sema resuelva la firma. Nada más debe ir en la acción: los parámetros perceptivos (pitch, hue, duraciones concretas) viven en `.csem` y `sema-map.json`. + +### 5.3. Cómo se declara una acción + +Esta spec no prescribe la sintaxis concreta. La implementación decide el mecanismo — un TypeScript const declarativo, decoradores, un registro runtime, un archivo JSON o YAML, un hook — siempre que el contenido semántico declarado contenga los campos del §5.2. + +Ejemplo conceptual (pseudocódigo neutro): + +``` +action "close-save" on Dialog.Content { + event: commit-fulfill + regime: lock + prewrite: [ data-last-action = "saved" on Content ] + commits: [ data-state = "closed" on Content ] +} ``` -Siete campos por acción, cinco opcionales con defaults. Lo mínimo para que Sema sepa cuándo ejecutar, qué firma aplicar, cómo comportarse ante interrupciones, y qué contexto escribir antes. +Cada implementación materializa esta declaración en su sintaxis. La doc de implementación de referencia (`semauix-sema-impl.md`) muestra cómo SemaUIX lo hace con morfo y TypeScript. -### 5.3. Los regímenes +### 5.4. Los regímenes -El campo `regime` define el comportamiento cuando una acción se dispara mientras ya hay otra de la misma identidad en curso. +El régimen define el comportamiento cuando una acción se dispara mientras ya hay otra de la misma identidad en curso. **`replace` (default):** la acción entrante cancela la anterior y arranca una nueva desde cero. Para coreografías puntuales donde interesa el evento más reciente. @@ -351,22 +329,22 @@ El campo `regime` define el comportamiento cuando una acción se dispara mientra **`queue`:** las acciones entrantes se encolan y se ejecutan secuencialmente tras la actual. Útil para secuencias de confirmación múltiple. -**Equivalencia:** dos ocurrencias son equivalentes si comparten el mismo `action.name` sobre el mismo target. +**Equivalencia:** dos ocurrencias son equivalentes si comparten el mismo nombre de acción sobre el mismo target. -### 5.4. Modo y fase +### 5.5. Modo y fase `mode` define si la capa headless espera a que Sema termine: -- **`blocking` (default):** la capa headless hace `await sema.before(...)` antes de aplicar el commit. La secuencia Sema → commit de estado es estricta. +- **`blocking` (default):** la capa headless hace `await` antes de aplicar el commit. La secuencia Sema → commit de estado es estricta. - **`advisory`:** la capa headless dispara Sema y sigue inmediatamente. Útil para acentos no críticos posteriores a un cambio de estado. La fase temporal (antes del cambio de estado, después, o independiente) se deduce de la combinación de `commits` y `mode`: -- Con `commits` presente + `mode: 'blocking'` → fase **before-state** (canónica) -- Con `commits` presente + `mode: 'advisory'` → fase **after-state** (el estado cambia, luego Sema corre en paralelo) +- Con `commits` presente + `mode: blocking` → fase **before-state** (canónica) +- Con `commits` presente + `mode: advisory` → fase **after-state** (el estado cambia, luego Sema corre en paralelo) - Sin `commits` → fase **independent** (no hay cambio de estado, la acción es puro feedback) -### 5.5. Matriz de combinaciones válidas +### 5.6. Matriz de combinaciones válidas | mode | commits | Fase | Legitimidad | Caso de uso | |---|---|---|---|---| @@ -375,265 +353,115 @@ La fase temporal (antes del cambio de estado, después, o independiente) se dedu | blocking | ausente | independent | Legítimo | Submit-failed, close-denied | | advisory | ausente | independent | Legítimo | Tick breve no bloqueante | -Las combinaciones no listadas son inválidas (el validador las rechaza). +Las combinaciones no listadas son inválidas; la implementación debe rechazarlas en validación. -### 5.6. Ejemplo: Dialog extendido +### 5.7. Alcance estructural -```ts -export const dialogMorfo = { - name: 'Dialog', - kebab: 'dialog', - scope: ['soma', 'sema'], - focus: { initial: 'first-focusable', trap: true, return: 'trigger', restore: true }, - - parts: [ - // ... Provider, Trigger, etc. - { - name: 'Content', - kebab: 'content', - kind: 'public', - defaultElement: 'div', - role: 'dialog', - optional: false, - states: ['open', 'closed'], - data: [ - { attr: 'data-state', values: ['open', 'closed'] }, - { - attr: 'data-last-action', - values: ['saved', 'cancelled', 'dismissed', 'failed'], - severity: 'optional' - } - ], - aria: [/* ... */], - keyboard: [ - { key: 'Escape', action: 'close-dismiss' } // referencia a una acción del provider; - // adquiere semántica Sema al coincidir con sema.actions - ] - } - ], - - sema: { - actions: [ - { - name: 'open', - target: v.partRef('content'), - event: 'emerge', - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'open' - } - }, - { - name: 'close-save', - target: v.partRef('content'), - event: 'commit-fulfill', - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'saved' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-cancel', - target: v.partRef('content'), - event: 'emerge', - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-dismiss', - target: v.partRef('content'), - event: 'emerge', - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-after-fail', - target: v.partRef('content'), - event: 'alert-threat', - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'failed' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - } - ] - } -} as const satisfies Morfo; -``` - -Las cinco acciones del Dialog quedan declaradas con nombre, evento asociado, prewrite, commit. Cero redundancia — `data-last-action.values` se deriva de los prewrites; `keyboard.action` referencia una acción del provider (con semántica Sema cuando coincide con `sema.actions`). - -**Nota sobre el nombre `close-after-fail`:** la acción describe el caso donde el dialog **sí se cierra** (commits a `closed`) pero el motivo causal fue el fallo de una operación contenida (guardado fallido, validación rechazada, red caída). El prewrite de `data-last-action: 'failed'` permite que la capa visual aplique un tratamiento distintivo al estado `closed` resultante si así lo decide. Si el caso que se quiere modelar es "el intento de cerrar falló y el dialog permanece abierto", esa sería otra acción distinta sin `commits` (acción independiente con `alert-threat` pero sin cambio de estado). - -### 5.7. Validación - -El validador del contrato (en SemaUIX, `schema.ts` con sium) añade estas validaciones cuando `scope` incluye `'sema'`: - -- `target.part` debe resolver a un `part.kebab` existente -- `event` debe pertenecer al union type `SemaEventLabel` -- `prewrite[].attr` debe existir en `data[]` del `part` referenciado -- `prewrite[].value` debe pertenecer a `values[]` cuando el attr es enumerable -- `commits.part` debe resolver -- `commits.value` debe pertenecer a `states[]` del part si `commits.attr === 'data-state'` -- `data-last-action.values[]` (si existe) debe ser exactamente la unión de los valores de los prewrites que escriben a `data-last-action` - -**Alcance estructural de `commits`.** El campo `commits` declara el **efecto estructural** de la acción — qué atributo cambia y a qué valor — pero **no sus precondiciones de aplicabilidad**. Morfo-sema no modela desde qué estado una acción es válida, ni distingue una acción que produce transición real de una que sería no-op. La validez contextual de una acción (si puede o no ejecutarse en un momento dado) sigue siendo responsabilidad del provider headless, que conoce el estado interno del componente. El validador solo comprueba que la declaración sea coherente, no que sea aplicable en todo contexto. - -**Validación cruzada con `keyboard.action`.** El campo `keyboard.action` puede resolver a cualquier acción del provider (`focus-next`, `focus-prev`, `close`, etc.). Solo cuando esa acción además está declarada en `sema.actions[]` adquiere semántica perceptiva tipada: el validador comprueba que si el nombre coincide, las referencias sean consistentes, pero no exige que toda acción de teclado tenga contrapartida Sema. Esto preserva que la capa headless pueda tener acciones puramente operativas sin obligarlas a declarar firma perceptiva. +Las acciones declaran **efecto estructural**, no precondiciones de aplicabilidad. El contrato no modela desde qué estado una acción es válida, ni distingue una acción que produce transición real de una que sería no-op. La validez contextual de una acción (si puede o no ejecutarse en un momento dado) es responsabilidad de la capa headless. Las implementaciones pueden añadir validaciones que comprueben coherencia declarativa (target resuelve, event existe en el vocabulario, prewrites referencian atributos declarados) pero no deben pretender modelar la máquina de estados completa del componente. --- -## 6. El protocolo Soma-Sema +## 6. El protocolo de coordinación ### 6.1. El puerto neutral -La capa headless no importa la implementación de Sema. Importa un **puerto neutral**: +La capa headless no importa la implementación de Sema. Importa un **puerto neutral**: una interfaz abstracta con tres operaciones. -```ts +``` interface SemaPort { - /** Invoca antes del commit y espera (blocking mode). */ - before(action: ResolvedSemaAction, ctx: SemaContext): Promise; + // Invoca antes del commit y espera (blocking mode) + before(action, context): Promise - /** Invoca sin esperar (advisory mode). */ - fire(action: ResolvedSemaAction, ctx: SemaContext): void; + // Invoca sin esperar (advisory mode) + fire(action, context): void - /** Inicia un sustain con lifecycle explícito. */ - startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession; + // Inicia un sustain con lifecycle explícito + startSustain(sustain, context): SemaSession } interface SemaSession { - stop(): void; - readonly active: boolean; + stop(): void + active: boolean } ``` El puerto puede ser: - Un runtime real de Sema (producción) -- Un no-op port (desarrollo sin Sema cargado) +- Un no-op port (desarrollo sin Sema cargado, o contexto donde Sema se desactiva globalmente) - Un test port (testing) La capa headless solo conoce la interfaz. No conoce síntesis, mapas ni resolución perceptiva. -### 6.2. La API que consume el provider - -Paralelo a `createAttrs(morfo)` y `registerContract(morfo)`, el provider obtiene: +### 6.2. El contexto de invocación -```ts -const sema = createSemaBinding(dialogMorfo, semaPort); +En cada invocación, la capa headless pasa un contexto que identifica: -// Uso: -await sema.before('close-save', ctx); // blocking -sema.fire('notification', ctx); // advisory -const session = sema.start('loading', ctx); -session.stop(); -``` - -`createSemaBinding` compila el bloque `morfo.sema` del contrato, valida referencias en dev, resuelve defaults, y devuelve helpers tipados. - -### 6.3. SemaContext +- El componente y la acción ejecutada +- El elemento DOM del target +- Opcionalmente, el elemento raíz y otras partes relevantes +- Un snapshot de atributos DOM relevantes en ese momento +- La causa originadora (teclado, puntero, programática, validación) +- Opcionalmente, un AbortSignal para cancelación externa -El contexto que el provider pasa a Sema en cada invocación: - -```ts -type SemaContext = { - component: string; // 'Dialog' - action: string; // 'close-save' - targetEl: HTMLElement; // el part primario - rootEl?: HTMLElement; // el provider si tiene DOM - partEls?: Partial>; // otras partes - snapshot: Record; // atributos relevantes ya en DOM - cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation'; - abortSignal?: AbortSignal; -}; -``` +La forma concreta de este contexto es decisión de la implementación; el contenido semántico está dictado por esta spec. -### 6.4. Secuencia canónica (blocking + commits) +### 6.3. Secuencia canónica (blocking + commits) -Para una acción con `mode: 'blocking'` y `commits` presente: +Para una acción con `mode: blocking` y `commits` presente: -1. El provider resuelve la acción abstracta (ej. `close-save`) -2. Aplica en su propio estado los prewrites declarados -3. Hace flush al DOM — los atributos del prewrite ya están reflejados -4. Llama `await sema.before(action, ctx)` +1. La capa headless resuelve la acción abstracta (ej. `close-save`) +2. Aplica los prewrites declarados al DOM +3. Hace flush — los atributos del prewrite ya están reflejados +4. Llama `await port.before(action, context)` 5. Sema resuelve la firma perceptiva (consultando `.csem` + `sema-map.json`) 6. Sema ejecuta los canales activos 7. La promesa resuelve cuando la coreografía termina -8. El provider aplica el commit de estado (cambia `data-state`) +8. La capa headless aplica el commit de estado 9. La capa visual reacciona al nuevo estado con sus transiciones -10. Si hay exit CSS o desmontaje diferido, sigue el pipeline del provider +10. Si hay exit CSS o desmontaje diferido, sigue el pipeline de la capa headless -Durante toda la ventana del paso 6, el estado del componente sigue siendo el anterior. La capa visual ve `data-state="open"` todavía. No hay competencia visual. +Durante toda la ventana del paso 6, el estado del componente sigue siendo el anterior. La capa visual ve el estado saliente todavía. No hay competencia visual. -### 6.5. Secuencia para independent (sin commits) +### 6.4. Secuencia para independent (sin commits) -Para una acción como `submit-failed`: +Para una acción sin commits (ej. `submit-failed` sobre un input ya invalid): -1. El provider detecta que debe disparar la acción -2. No hay prewrite (o los hay pero no afectan a `data-state`) -3. `await sema.before(action, ctx)` o `sema.fire(action, ctx)` según mode +1. La capa headless detecta que debe disparar la acción +2. Aplica prewrites si los hay (pero no afectan a `data-state`) +3. `await port.before(action, context)` o `port.fire(action, context)` según modo 4. No hay commit posterior -5. El flujo del provider continúa +5. El flujo de la capa headless continúa -### 6.6. Sustain como sesión +### 6.5. Sustain como sesión -`sustain` no es episódico. Se declara en una sección aparte: +`sustain` no es episódico. Se declara aparte del resto de acciones, con un predicado de activación: -```ts -type SemaSustainDecl = { - name: string; - target: PartRef; - activeWhen: { part: PartRef; attr: string; value: string }; - event: 'sustain'; -}; - -type MorfoSema = { - actions: readonly SemaAction[]; - sustains?: readonly SemaSustainDecl[]; -}; +``` +sustain "loading" on Spinner { + activeWhen: data-state = "loading" on Spinner + event: sustain + scope: part +} ``` Protocolo: -1. El provider cambia el estado que satisface `activeWhen` -2. Inmediatamente después, llama `session = sema.start('loading', ctx)` +1. La capa headless cambia el estado que satisface el predicado de activación +2. Inmediatamente después, llama `session = port.startSustain(sustain, context)` 3. La sesión corre mientras el predicado siga siendo verdadero -4. Cuando el provider sale del estado, llama `session.stop()` +4. Cuando la capa headless sale del estado, llama `session.stop()` Es una excepción documentada al patrón episódico. -### 6.7. Garantías de Sema +### 6.6. Garantías del puerto El puerto debe garantizar: - `before()` nunca lanza si falta runtime; cae a no-op con promesa resuelta inmediatamente - `before()` siempre resuelve (nunca cuelga indefinidamente) -- Si `abortSignal` aborta, la coreografía se cancela y la promesa resuelve -- Si `targetEl` desaparece del DOM, resuelve tempranamente +- Si `AbortSignal` aborta, la coreografía se cancela y la promesa resuelve +- Si el target desaparece del DOM, resuelve tempranamente - Respeta preferencias de accesibilidad del usuario (ver §9) - `before()` nunca bloquea más del cap global (ver §9.3) @@ -643,9 +471,9 @@ El puerto debe garantizar: ### 7.1. Propósito -El `.csem` es el archivo donde el integrador (quien usa el componente en su app) especifica **cómo se expresan perceptivamente** los eventos semánticos que morfo-sema declara. +El `.csem` es el archivo donde el integrador (quien usa el componente en su app) especifica **cómo se expresan perceptivamente** los eventos semánticos que el contrato cross-layer declara. -Morfo-sema dice: "Dialog tiene una acción `close-save` que dispara `commit-fulfill`". +El contrato dice: "Dialog tiene una acción `close-save` que dispara `commit-fulfill`". `.csem` dice: "En esta app, `commit-fulfill` se expresa con estos canales, estos parámetros, estos targets". @@ -653,7 +481,7 @@ Si el integrador no provee `.csem`, Sema usa los valores por defecto de `sema-ma ### 7.2. Sintaxis CSS con custom properties -`.csem` usa sintaxis CSS válida procesada en build time (PostCSS plugin). Declara overrides por selector: +`.csem` usa sintaxis CSS válida procesada en build time. Declara overrides por selector: ```css /* Override global: commit-fulfill en esta app es más brillante */ @@ -681,24 +509,28 @@ La sintaxis aprovecha la cascada CSS natural: el `.csem` más específico (por c ### 7.3. Pipeline de build -El `.csem` lo procesa un plugin PostCSS que: +El `.csem` se procesa en build time (PostCSS plugin u equivalente) que: 1. Parsea las reglas 2. Valida que cada `--sema-*` referencie un evento/canal/parámetro existente -3. Compila a una estructura JSON optimizada (`sema-overrides.json`) +3. Compila a una estructura JSON optimizada 4. El runtime consume el JSON — no parsea CSS en cliente -### 7.4. Resolución de firmas en runtime +### 7.4. Política ante canal inactivo + +Si `.csem` define parámetros para un canal que el integrador ha desactivado globalmente (vía `engine.configure`), el runtime **ignora los parámetros y emite warning en dev**. El `.csem` no activa canales por sí mismo; la activación es decisión explícita del integrador a nivel configuración. Esto preserva la política "sound disabled by default": asignar un pitch a sound en `.csem` no activa sound — hay que activarlo explícitamente en la configuración. + +### 7.5. Resolución de firmas en runtime Cuando Sema ejecuta una acción, la firma se resuelve: -1. Mira morfo-sema → obtiene `event` (ej. `commit-fulfill`) -2. Mira `sema-overrides.json` (compilado de `.csem`) → busca overrides aplicables al target y contexto +1. Mira el contrato cross-layer → obtiene `event` (ej. `commit-fulfill`) +2. Mira `.csem` (compilado) → busca overrides aplicables al target y contexto 3. Si no hay overrides, cae a `sema-map.json` (base) 4. Combina: base + overrides → firma efectiva 5. Ejecuta los canales activos con los parámetros resueltos -Tres capas de resolución: morfo (qué evento), `.csem` (cómo lo expresa la app), `sema-map` (cómo lo expresa por defecto). +Tres capas de resolución: contrato (qué evento), `.csem` (cómo lo expresa la app), `sema-map` (cómo lo expresa por defecto). --- @@ -714,13 +546,13 @@ Para evitar duplicar valores similares entre eventos relacionados, el mapa usa f ```json { - "version": "0.3.0", + "version": "0.4.0", "families": { "commit": { "base": { - "motion": { ... }, - "sound": { ... }, - "color": { ... }, + "motion": { /* parámetros base */ }, + "sound": { /* parámetros base */ }, + "color": { /* parámetros base */ }, "presence": null }, "activeChannels": ["motion", "sound", "color"] @@ -761,6 +593,10 @@ El integrador puede proveer samples WAV que reemplazan la síntesis para combina Las entradas presentes en el pack son autoritativas; las ausentes usan síntesis algorítmica. Permite despliegue gradual del pack. +### 8.5. Fuera del mapa + +`sema-map.json` contiene vocabulario perceptivo canónico. **No contiene scope** (operativo, vive en la acción), **no contiene información de cuándo disparar eventos** (eso es responsabilidad del contrato cross-layer), **no contiene reglas culturales específicas de apps** (eso es `.csem`). Es solo el vocabulario sensorial base. + --- ## 9. Accesibilidad @@ -778,7 +614,7 @@ Las preferencias del usuario afectan a canales específicos, no globalmente: ### 9.2. Interacción con el modo blocking -En `mode: 'blocking'`, `before()` espera solo por los canales activos tras aplicar las preferencias: +En `mode: blocking`, `before()` espera solo por los canales activos tras aplicar las preferencias: - Si tras la reducción no queda ningún canal activo → resuelve inmediatamente (0ms) - Si quedan canales → espera el máximo entre sus duraciones @@ -796,13 +632,13 @@ Una firma que declare duración superior al cap es truncada en ejecución, no re ### 9.4. Scope de la acción -El campo `scope` (declarado en sema-map por familia, no en morfo) define el alcance perceptivo: +El scope define el alcance temporal y espacial de la coreografía: -- **`part`** (default): la coreografía afecta solo al target +- **`part`** (default): la coreografía afecta solo al target y se cancela si el target se desmonta - **`component`**: la coreografía afecta al árbol del componente completo - **`scene`**: la coreografía sobrevive al componente (útil para advisory+independent que deben continuar tras desmontaje) -`scope: 'scene'` es promoción explícita. Por defecto, los eventos se atan al ciclo de vida de su target y se cancelan si el target se desmonta. +El scope se declara por acción, no globalmente — el mismo evento (`alert-threat`) puede tener scope distinto en componentes distintos (toast: scene; input: part). --- @@ -812,14 +648,14 @@ El integrador puede personalizar Sema en cinco niveles, ordenados de más global ### 10.1. Nivel 1 — Activación y volumen global -```javascript +``` engine.configure({ sound: { enabled: true, gain: 0.8 }, motion: { enabled: true }, color: { enabled: true }, presence: { enabled: true }, reflectEvents: false, // modo debug - capBlockingMs: 200 // override del cap global + capBlockingMs: 200 // override del cap global (solo bajar) }); ``` @@ -831,7 +667,7 @@ Como se describe en §8.4. ### 10.3. Nivel 3 — Override global del mapa -```javascript +``` engine.configure({ mapOverrides: { 'alert-threat.sound.gain': 0.15, @@ -846,7 +682,7 @@ Como se describe en §7. ### 10.5. Nivel 5 — API imperativa (escape hatch) -```javascript +``` engine.trigger(node, { event: 'alert-threat', overrides: { sound: { pitch: 500 } } @@ -861,23 +697,23 @@ Para casos que no caben declarativamente. ### 11.1. Responsabilidades -Una implementación Sema-compliant debe: +Una implementación del engine Sema debe: -1. Consumir morfo-sema compilado como input declarativo +1. Consumir las acciones declaradas en el contrato cross-layer 2. Implementar el puerto `SemaPort` con `before()`, `fire()`, `startSustain()` -3. Consumir `sema-map.json` y `sema-overrides.json` (compilado de `.csem`) -4. Resolver firmas según la jerarquía: morfo → `.csem` → `sema-map` +3. Consumir `sema-map.json` y el compilado de `.csem` +4. Resolver firmas según la jerarquía: acción → `.csem` → `sema-map` 5. Aplicar los canales activos respetando las garantías temporales y de accesibilidad 6. Emitir `CustomEvent('sema:event')` en el nodo target (contrato canónico de observabilidad) 7. Gestionar los cuatro regímenes (`replace | collapse | lock | queue`) -8. Cancelar coreografías por `abortSignal` o por desmontaje del target +8. Cancelar coreografías por `AbortSignal` o por desmontaje del target 9. Respetar preferencias de accesibilidad del usuario ### 11.2. Protocolo de eventos **Canónico — CustomEvent:** -```javascript +``` node.dispatchEvent(new CustomEvent('sema:event', { bubbles: true, detail: { @@ -916,7 +752,7 @@ Esta sección describe técnicas de implementación, no arquitectura. La doctrin **Recomendaciones de implementación (no normativas):** -En contextos donde el provider sí permite algún paralelismo con la capa visual (modo advisory, tails post-cap), o donde la capa visual gestiona transiciones CSS que podrían solaparse con el evento Sema, conviene aplicar técnicas aditivas para minimizar conflictos: +En contextos donde el provider permite algún paralelismo con la capa visual (modo advisory, tails post-cap), o donde la capa visual gestiona transiciones CSS que podrían solaparse con el evento Sema, conviene aplicar técnicas aditivas para minimizar conflictos: - `motion` con WAAPI y `composite: 'add'` se suma al transform existente en lugar de reemplazarlo - `color` con `box-shadow` adicional evita modificar el `border-color` que la capa visual controla @@ -934,8 +770,8 @@ El engine debe tolerar: Esto implica que la implementación debe: -- Verificar `node.isConnected` antes de aplicar cambios -- Cancelar cleanly si el target desaparece +- Verificar que el nodo target sigue conectado antes de aplicar cambios +- Cancelar limpiamente si el target desaparece - No retener referencias a elementos desmontados --- @@ -964,42 +800,70 @@ Sema usa animaciones como uno de sus cuatro canales. No sustituye a Framer Motio Si dos configuraciones producen resultados perceptivamente indistinguibles para un usuario normal, una sobra. El sistema no expone sliders para ajustes que nadie percibe. -### 12.6. Morfo-sema no contiene implementación perceptiva +### 12.6. El contrato cross-layer no contiene implementación perceptiva -Morfo-sema declara **qué eventos existen y cuándo**, no **cómo se expresan**. Parámetros sensoriales (pitch, roughness, curvas, hues concretos, duraciones exactas, samples) viven en `sema-map.json` y `.csem`, nunca en morfo. +El contrato declara **qué eventos existen y cuándo**, no **cómo se expresan**. Parámetros sensoriales (pitch, roughness, curvas, hues concretos, duraciones exactas, samples) viven en `sema-map.json` y `.csem`, nunca en el contrato. --- -## 13. Compliance +## 13. Contrato mínimo de implementación + +Una implementación Sema-compliant debe proveer al menos: + +### 13.1. Mecanismo de declaración de acciones + +Un mecanismo declarativo para que cada componente exponga: + +- Sus acciones semánticas con los ocho campos conceptuales del §5.2 +- Sus sustains (con predicado de activación) +- Su relación con las partes del componente (referenciables) -Para que una implementación se considere Sema-compliant debe: +Forma concreta: libre. Puede ser TypeScript const, decoradores, JSON, YAML, registros runtime, hooks. El único requisito es que exponga la información que Sema necesita. -**Obligatorio:** +### 13.2. Implementación del puerto -1. Soportar las 6 familias y los 5 intents (vocabulario de 22 eventos) -2. Soportar los 4 canales con los parámetros descritos en §4 -3. Implementar el contrato `MorfoSema` del §5 con todas sus validaciones -4. Implementar el protocolo `SemaPort` del §6 -5. Respetar la secuencialidad coordinada (§2.2): los eventos `blocking` bloquean el commit -6. Emitir `CustomEvent('sema:event')` como contrato canónico (§11.2) -7. Respetar preferencias de accesibilidad por canal (§9) -8. Respetar caps temporales globales (§9.3) -9. Tener el canal `sound` desactivado por defecto -10. Implementar los cuatro regímenes (§5.3) +Una realización concreta de `SemaPort` con: -**Recomendado:** +- `before(action, context): Promise` — blocking +- `fire(action, context): void` — advisory +- `startSustain(sustain, context): SemaSession` — sustain con lifecycle -1. Compilar `.csem` en build time via PostCSS -2. Proveer modo debug con reflejo DOM -3. Proveer los cinco niveles de personalización -4. Implementar soporte de sound packs -5. Documentar limitaciones conocidas explícitamente +Con las garantías del §6.6. -**Opcional:** +### 13.3. Runtime que consume artefactos -1. API imperativa avanzada -2. Herramientas de debug especializadas -3. Validador de sound packs contra la spec +Un runtime que: + +- Lea las acciones declaradas +- Procese `.csem` en build time +- Cargue `sema-map.json` +- Resuelva firmas según la jerarquía del §7.5 +- Aplique canales con las técnicas del §11.3 + +### 13.4. Soporte obligatorio + +- Las 6 familias y los 5 intents (vocabulario de 22 eventos) +- Los 4 canales con los parámetros del §4 +- Los 4 regímenes de arbitraje (§5.4) +- La matriz de 4 combinaciones válidas de mode × commits (§5.6) +- La política de accesibilidad por canal (§9.1) +- Los caps temporales (§9.3) +- `sound` desactivado por defecto (§4.2) +- `CustomEvent('sema:event')` como protocolo de observabilidad (§11.2) + +### 13.5. Soporte recomendado + +- Compilación de `.csem` en build time +- Modo debug con reflejo DOM (`reflectEvents: true`) +- Los cinco niveles de personalización (§10) +- Soporte de sound packs (§8.4) +- Documentación explícita de limitaciones conocidas + +### 13.6. Soporte opcional + +- API imperativa avanzada +- Herramientas de debug especializadas +- Validador de sound packs contra la spec --- @@ -1034,42 +898,19 @@ Para que una implementación se considere Sema-compliant debe: --- -## Apéndice A — Relación con SemaUIX - -SemaUIX es la implementación de referencia de esta especificación. En SemaUIX: +## Apéndice A — Implementaciones conocidas -- La capa headless se llama **Soma** y está implementada en Svelte 5 -- La capa visual se llama **Eidos** (CSS con tokens, variants, recipes) -- **Sema** es la implementación concreta de esta spec -- El contrato cross-layer se llama **morfo** — un TypeScript const declarativo por componente, validado con sium +**SemaUIX** es la implementación de referencia de esta especificación. Se construye sobre Svelte 5 y usa un artefacto llamado `morfo` como contrato cross-layer. El documento `semauix-sema-impl.md` describe cómo SemaUIX materializa cada parte de esta spec: cómo morfo extiende para declarar acciones, qué shape TypeScript tiene, cómo se valida, cómo los providers consumen el puerto. -Los archivos viven en: - -``` -src/uix/ -├── morfo/ -│ ├── components/{name}.ts ← contrato por componente (incluye sema) -│ ├── schema.ts ← validador sium -│ └── types.ts ← Morfo, MorfoSema, SemaAction, SemaEventLabel -├── soma/components/{name}/ ← provider que consume morfo -├── eidos/components/{name}.css ← CSS que consume morfo (selectores generados) -└── sema/ - ├── engine.ts ← runtime Sema - ├── sema-map.json ← mapa base - └── sema-overrides/ ← compilado de .csem -``` - -El pipeline de build usa Vite + PostCSS con plugins propios para procesar `.csem` y generar el JSON compilado. +Cualquier framework puede producir su propia implementación. La spec no exige ningún mecanismo concreto para el contrato cross-layer; solo que el contenido informativo esté disponible para las tres capas. --- ## Apéndice B — Historial de decisiones clave -Documentado para futura referencia: - 1. **Sema es agnóstica de framework.** Opera sobre el DOM con Web APIs estándar. -2. **Morfo como contrato cross-layer único.** Parts, atributos, ARIA, keyboard, acciones semánticas — todo declarado una vez. +2. **Contrato cross-layer como fuente única.** Parts, atributos, ARIA, keyboard, acciones semánticas — todo declarado una vez. El mecanismo concreto es decisión de cada implementación. 3. **Reducción de 14 semánticas a 6 familias.** Tras auditoría neurocientífica, las originales se solapaban. @@ -1087,7 +928,7 @@ Documentado para futura referencia: 10. **CustomEvent canónico + atributos DOM opt-in en debug.** -11. **Morfo-sema declara acciones, no implementación perceptiva.** Shape minimal: name, target, event, mode, regime, prewrite, commits. +11. **Acciones semánticas como unidad de integración.** Ni eventos DOM ni cambios de estado — acciones declaradas. 12. **`.csem` es capa de override del integrador.** No obligatoria; cae a `sema-map.json` por defecto. @@ -1099,47 +940,34 @@ Documentado para futura referencia: 16. **Caps temporales de 200ms (normal) / 80ms (con reducción).** -17. **Commits declara efecto estructural, no precondiciones.** La validez contextual de una acción es responsabilidad del provider, no del contrato declarativo. Morfo-sema no modela máquina de estados completa; solo declara qué cambia cuando la acción se ejecuta. +17. **Commits declara efecto estructural, no precondiciones.** La validez contextual de una acción es responsabilidad de la capa headless, no del contrato declarativo. -18. **Keyboard.action puede ser puramente operativa.** Solo adquiere semántica Sema tipada si coincide con un nombre declarado en `sema.actions[]`. La capa headless conserva acciones de teclado sin contrapartida perceptiva (`focus-next`, `focus-prev`, etc.). +18. **Keyboard y acciones son espacios separables.** La capa headless puede tener acciones de teclado sin contrapartida Sema; solo adquieren semántica perceptiva las que coinciden con acciones declaradas. + +19. **Scope por acción, no por familia.** El mismo evento puede tener scope distinto en componentes distintos. --- ## Apéndice C — Glosario -- **Acción semántica**: entidad declarada en morfo-sema que describe qué hace un componente con carga semántica (ej. `close-save`). Diferente de "evento Sema". +- **Acción semántica**: unidad declarada en el contrato cross-layer que describe qué hace un componente con carga semántica (ej. `close-save`). La unidad de integración entre la capa headless y Sema. - **Canal perceptivo**: eje sensorial por el que Sema expresa información (motion, sound, color, presence). -- **Capa headless**: capa del framework que gestiona comportamiento, estado, accesibilidad. En SemaUIX se llama Soma. -- **Capa visual**: capa del framework que gestiona presentación en reposo. En SemaUIX se llama Eidos. -- **Commits**: campo de una acción que declara qué cambio de estado comitea tras la ventana Sema. +- **Capa headless**: capa del framework que gestiona comportamiento, estado, accesibilidad. +- **Capa visual**: capa del framework que gestiona presentación en reposo. +- **Commits**: campo de una acción que declara qué cambio de estado se aplicará tras la ventana Sema. +- **Contrato cross-layer**: artefacto declarativo por componente compartido por las tres capas. Mecanismo concreto decisión de implementación. - **Evento Sema**: una de las 22 combinaciones del vocabulario canónico (`alert-threat`, `commit-fulfill`, etc.). - **Firma efectiva**: conjunto de valores por canal resultante de resolver un evento contra `.csem` + `sema-map.json`. - **Intent**: modulador afectivo de una familia valencial. -- **Morfo**: contrato cross-layer por componente. En SemaUIX es TypeScript con validación sium. -- **Morfo-sema**: extensión de morfo que declara las acciones semánticas del componente. - **Prewrite**: atributos DOM que se reflejan antes de invocar Sema. - **Régimen**: política de arbitraje para acciones repetidas (`replace | collapse | lock | queue`). -- **Sema-compliant**: implementación que cumple los requisitos mínimos de §13. -- **SemaEventLabel**: union type de los 22 eventos canónicos. +- **Sema-compliant**: implementación que cumple los requisitos mínimos del §13. +- **SemaEventLabel**: vocabulario de los 22 eventos canónicos. - **SemaPort**: interfaz neutral que la capa headless consume para invocar Sema. -- **Secuencialidad coordinada**: principio operativo según el cual evento y estado ocurren en secuencia, nunca en paralelo. - ---- - -## Próximos pasos - -Para pasar de v0.3 a v1.0: - -1. Implementar prototipo del engine en SemaUIX con 4 componentes reales (Button, Input, Dialog, Toast) -2. Validar la secuencialidad coordinada en componentes con reactividad Svelte real -3. Calibrar valores del `sema-map.json` mediante testing perceptivo -4. Especificar el plugin PostCSS para `.csem` -5. Implementar el validador sium para `MorfoSema` -6. Desplegar la herramienta de consultoría de samples -7. Iterar sobre casos límite que emerjan de uso real +- **Secuencialidad coordinada**: principio operativo según el cual evento y estado ocurren en secuencia en el camino blocking. --- -**Fin del documento Sema v0.3.** +**Fin de la especificación Sema v0.4.** -*Este working draft consolida cuatro rondas de revisión externa con Gemini, ChatGPT y Grok. El modelo queda listo para implementación prototipo. Feedback sobre casos concretos que la spec no cubre adecuadamente es bienvenido.* +*Agnóstica de framework. Las implementaciones concretas documentan por separado cómo materializan la spec (ver por ejemplo `semauix-sema-impl.md`).* diff --git a/src/uix/sema/semauix-sema-impl.md b/src/uix/sema/semauix-sema-impl.md new file mode 100644 index 000000000..a11ccb92a --- /dev/null +++ b/src/uix/sema/semauix-sema-impl.md @@ -0,0 +1,308 @@ +# SemaUIX — Implementación de Sema + +> Este documento describe cómo `src/uix/sema/` materializa hoy parte de la spec Sema. +> No reemplaza la spec: la asume leída y referenciada. Aquí se documenta el estado +> real del repo y, cuando aplica, la dirección prevista. + +## Estado de este documento + +- **Implementado**: existe en el repo, compila, funciona y tiene tests. +- **Planificado (diseñado)**: la firma y el comportamiento base están decididos, + pero todavía no existe código. +- **Sketch**: idea arquitectónica orientativa; la API puede cambiar de forma + material al implementarse. + +## 1. Mapa de estado actual + +| Aspecto | Estado | Realidad actual en SemaUIX | +|---|---|---| +| Tipos sema (`SemaSpec`, `SemaAction`, `SemaSustainDecl`) | Implementado | Viven en `src/uix/sema/types.ts` | +| Validador de invariantes (`validateSema`) | Implementado | Vive en `src/uix/sema/validation.ts` | +| Contrato cross-layer con morfo | Implementado | `morfo` y `sema` son artefactos separados, relacionados por `kebab` y `PartRef` | +| Validación cruzada `sema` + `morfo` | Implementado | Se invoca explícitamente con `validateSema(spec, morfo)` | +| Hook automático desde `schema.ts` | No implementado | `src/uix/morfo/schema.ts` no conoce Sema | +| `SemaPort` / `noopSemaPort` / `testSemaPort` | Planificado (diseñado) | Las firmas están pensadas, pero no existen en el repo | +| `createSemaBinding()` | Sketch | La idea está clara, pero la API real puede cambiar al bajar a providers Svelte 5 | +| Engine real + `.csem` + `sema-map.json` | Sketch | Fuera del estado actual del repo | + +## 2. Contrato cross-layer hoy + +### 2.1. Sema es una capa autónoma + +**Implementado** + +Sema no está embebida dentro de `morfo`. La forma actual en el repo es: + +- `dialogMorfo` declara la superficie DOM pública del componente +- `dialogSema` declara sus acciones y sustains semánticos +- ambos artefactos se coordinan por `kebab` y por referencias a `PartRef` +- el validador cruza ambos solo cuando se le pasa `morfo` como contexto + +Esto preserva la autonomía entre capas: + +- `morfo` puede existir sin `sema` +- `sema` puede existir sin `morfo` +- cuando ambas existen, se validan juntas por convención explícita, no por acoplamiento implícito + +### 2.2. Superficie pública real de `src/uix/sema` + +**Implementado** + +La superficie pública actual es la exportada por [exports.ts](/G:/dev/svelte/vicen/src/uix/sema/exports.ts): + +- tipos: `SemaEventLabel`, `SemaAttrWrite`, `SemaCommit`, `SemaAction`, `SemaSustainDecl`, `SemaSpec` +- runtime: `validateSema()` y `SemaInvariantError` + +No hay más runtime público hoy. En particular, **no** existen todavía: + +- `SemaPort` +- `noopSemaPort` +- `testSemaPort` +- `createSemaBinding` +- `before()` / `fire()` / `start()` + +### 2.3. Tipo actual de una declaración sema + +**Implementado** + +`src/uix/sema/types.ts` modela hoy: + +- `SemaAction` + - `name` + - `target` + - `event` + - `mode?` + - `regime?` + - `scope?` + - `prewrite?` + - `commits?` +- `SemaSustainDecl` + - `name` + - `target` + - `activeWhen` + - `event: 'sustain'` + - `scope?` +- `SemaSpec` + - `kebab` + - `actions` + - `sustains?` + +Los defaults conceptuales siguen siendo los de la spec: + +- `mode` → `blocking` +- `regime` → `replace` +- `scope` → `part` + +Hoy esos defaults son **convención semántica**; todavía no existe un binding/runtime que los materialice operativamente. + +## 3. Validación actual + +### 3.1. Qué valida `validateSema()` + +**Implementado** + +`validateSema(spec, morfo?)` valida dos grupos de reglas. + +**Sin morfo** + +1. `action.name` es único dentro del spec +2. `action.event` pertenece al vocabulario canónico `SemaEventLabel` + +**Con morfo** + +3. `spec.kebab === morfo.kebab` +4. `action.target` resuelve a un part existente +5. `prewrite[].part` resuelve +6. `prewrite[].attr` existe en `data[]` del part destino +7. `prewrite[].value` pertenece a `values[]` si el attr es enumerable +8. `commits.part` resuelve +9. `commits.value` pertenece a `states[]` si `commits.attr === 'data-state'` +10. `data-last-action.values[]` coincide exactamente con la unión de prewrites que escriben ese attr +11. `sustains[].target` y `sustains[].activeWhen.part` resuelven + +### 3.2. Qué **no** valida `validateSema()` + +**Implementado** + +`validateSema()` asume que el `spec` llega ya tipado con TypeScript, por ejemplo: + +```ts +export const dialogSema = { + kebab: 'dialog', + actions: [/* ... */] +} as const satisfies SemaSpec; +``` + +Por eso, a diferencia de `validateMorfo()`, **no** hace decode completo del shape runtime. +No está pensado para aceptar JSON arbitrario o input no tipado; su responsabilidad actual es +validar invariantes semánticos y referencias cruzadas sobre entrada ya tipada. + +Si más adelante aparece una necesidad real de consumir specs no tipados, entonces tendría sentido +plantear una segunda capa de decode. Hoy no existe. + +### 3.3. No hay hook automático desde `schema.ts` + +**Implementado** + +A diferencia de una versión anterior de esta documentación, `src/uix/morfo/schema.ts` **no** +inyecta validaciones Sema automáticamente. + +La realidad hoy es esta: + +- `validateMorfo(morfo)` valida solo morfo +- `validateSema(spec, morfo)` valida solo sema + cross-checks con morfo +- cada componente que declare ambos debe invocarlos explícitamente en tests o sanity-checks + +Esto es deliberado: mantiene la autonomía entre capas y evita que `morfo` tenga que conocer el +runtime o el validador de `sema`. + +### 3.4. Patrón de test recomendado + +**Implementado** + +Patrón real hoy, tomando dialog como referencia: + +```ts +import { describe, it, expect } from 'vitest'; +import { validateMorfo } from '$uix/morfo/schema'; +import { validateSema } from '$uix/sema/validation'; +import { dialogMorfo, dialogSema } from './dialog'; + +describe('dialog contracts', () => { + it('passes morfo validation', () => { + expect(() => validateMorfo(dialogMorfo)).not.toThrow(); + }); + + it('passes sema validation with morfo cross-checks', () => { + expect(() => validateSema(dialogSema, dialogMorfo)).not.toThrow(); + }); +}); +``` + +Cada componente con declaración sema debería tener al menos: + +- un test verde de `validateMorfo(morfo)` +- un test verde de `validateSema(sema, morfo)` +- varios tests rojos de invariantes rotos relevantes + +## 4. Ejemplo actual: `dialog` + +**Implementado** + +El ejemplo real hoy vive en: + +- [dialog.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/dialog.ts) +- [dialog.test.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/dialog.test.ts) + +`dialogMorfo` y `dialogSema` son dos artefactos separados: + +- `dialogMorfo` declara parts, attrs, ARIA, keyboard y focus +- `dialogSema` declara las acciones perceptivas (`open`, `close-save`, etc.) + +El caso más característico hoy es `data-last-action`: + +- cada cierre prewritea una razón causal (`saved`, `cancelled`, `dismissed`, ...) +- el validador comprueba que los valores declarados en morfo coincidan exactamente con los valores prewriteados por sema + +## 5. Puerto runtime + +### 5.1. `SemaPort` + +**Planificado (diseñado)** + +La firma propuesta para desacoplar providers de un engine Sema real es: + +```ts +export interface SemaPort { + before(action: ResolvedSemaAction, ctx: SemaContext): Promise; + fire(action: ResolvedSemaAction, ctx: SemaContext): void; + startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession; +} + +export interface SemaSession { + stop(): void; + readonly active: boolean; +} +``` + +Estado actual: + +- esta interfaz **no** existe aún en `src/uix/sema` +- tampoco existen `noopSemaPort` ni `testSemaPort` +- aun así, la firma base `before / fire / startSustain` se considera bastante estable + +Por eso esta sección se clasifica como **Planificado (diseñado)** y no como sketch. + +### 5.2. Alcance del puerto + +**Planificado (diseñado)** + +Cuando exista, el puerto debería permitir: + +- invocar acciones `blocking` (`before`) +- invocar acciones `advisory` (`fire`) +- iniciar sustains con lifecycle explícito (`startSustain`) + +Lo que **no** está decidido aquí es la implementación interna del engine, solo el contrato de llamada +entre provider y runtime sema. + +## 6. Binding para providers + +### 6.1. `createSemaBinding()` + +**Sketch** + +La idea general es ofrecer algo así: + +```ts +export function createSemaBinding(morfoLike, port): SemaBinding; +``` + +con una interfaz ergonómica tipo: + +```ts +interface SemaBinding { + before(name, ctx): Promise; + fire(name, ctx): void; + start(name, ctx): SemaSession; + action(name): SemaAction; +} +``` + +### 6.2. Por qué sigue siendo sketch + +**Sketch** + +Aunque el concepto es claro, todavía hay decisiones abiertas que pueden alterar materialmente la API: + +- si el binding consume `morfo + sema` o solo `sema` +- cómo resuelve `targetEl`, `rootEl` y otros elementos en providers Svelte 5 +- si compila defaults en construcción o en cada invocación +- dónde aplica prewrites sin pelearse con el ciclo reactivo del provider +- cómo se expresa el contexto (`cause`, refs DOM, metadata de componente) + +Por eso hoy conviene tratar `createSemaBinding()` como **dirección arquitectónica**, no como contrato congelado. + +## 7. Qué queda fuera hoy + +**Sketch** + +Todavía no forman parte del estado implementado del repo: + +- engine real que consuma `.csem` +- parser / pipeline de `.csem` +- `sema-map.json` +- arbitraje runtime de `replace | collapse | lock | queue` +- aplicación efectiva de canales (`motion`, `sound`, `color`, `presence`) +- caps de accesibilidad / preferencias del usuario + +Nada de eso invalida el valor actual de la capa: hoy `sema` ya aporta tipado y validación de +invariantes cross-layer, que es el primer paso útil y verificable. + +## 8. Resumen operativo + +- Usa `SemaSpec` desde `src/uix/sema/types.ts` para declarar acciones y sustains. +- Relaciona `morfo` y `sema` por `kebab` y `PartRef`, no por extensión de tipos. +- Ejecuta `validateSema(spec, morfo)` explícitamente allí donde quieras sanity-check cross-layer. +- No asumas que existen todavía `SemaPort` o `createSemaBinding()` en runtime. +- Si documentas trabajo futuro, clasifícalo como **Planificado (diseñado)** o **Sketch**, no como implementado. diff --git a/src/uix/sema/types.ts b/src/uix/sema/types.ts new file mode 100644 index 000000000..f5281fa9f --- /dev/null +++ b/src/uix/sema/types.ts @@ -0,0 +1,201 @@ +/** + * Sema — tipos públicos de la capa perceptiva. + * + * La capa sema es **autónoma**. No importa nada de morfo. Los tipos + * compartidos entre capas (como `PartRef`) viven en `$uix/lib/types` y + * cada capa los importa desde allí de forma independiente. + * + * Un componente puede declarar `sema` aunque no declare `morfo` (ni al + * contrario). Cuando ambas capas están presentes, se relacionan por el + * `kebab` del componente — no por intersección ni extensión de tipos. + */ + +import type { PartRef } from '../lib/types'; + +// ── Vocabulario canónico de eventos (sema-spec-v0.3.1 §3.3) ──────────────── + +/** + * Los 22 eventos canónicos: 6 familias × 5 intents en las familias + * valenciales (contact, commit, alert, handle) + 2 transicionales (emerge, + * sustain). Closed set — cualquier `event` en un `SemaAction` debe ser uno + * de estos, y el validador lo enforce. + * + * La resolución perceptiva de cada evento (canales activos, pitches, + * durations, hues) vive en `sema-map.json` (defaults) y `.csem` + * (overrides del integrador). Sema-spec §7 y §8. + */ +export type SemaEventLabel = + // contact (feedback inmediato a acto del usuario) + | 'contact-neutral' + | 'contact-threat' + | 'contact-risk' + | 'contact-affirm' + | 'contact-fulfill' + // commit (cambio de estado discreto por el sistema) + | 'commit-neutral' + | 'commit-threat' + | 'commit-risk' + | 'commit-affirm' + | 'commit-fulfill' + // alert (reclamo de atención sobre estado no atendido) + | 'alert-neutral' + | 'alert-threat' + | 'alert-risk' + | 'alert-affirm' + | 'alert-fulfill' + // handle (manipulación continua del usuario) + | 'handle-neutral' + | 'handle-threat' + | 'handle-risk' + | 'handle-affirm' + | 'handle-fulfill' + // transicionales (sin intent) + | 'emerge' + | 'sustain'; + +// ── Escrituras al DOM (sema-spec-v0.3.1 §5.2) ────────────────────────────── + +/** + * Escritura a un data-attribute que debe reflejarse en DOM antes de que + * Sema abra su ventana perceptiva. Su uso canónico es reflejar el motivo + * causal de un commit (`data-last-action="saved"`) para que `.csem` y la + * capa visual puedan tintar la ejecución del evento. + * + * Validación: + * - `part.target` resuelve a un `kebab` del morfo. + * - `attr` existe en el `data[]` de ese part. + * - `value` pertenece a `values[]` si el attr es enumerable. + */ +export interface SemaAttrWrite { + part: PartRef; + attr: string; + value: string; +} + +/** + * Efecto estructural que una acción comitea tras cerrar la ventana Sema + * (modo `blocking`) o en paralelo (modo `advisory`). + * + * Alcance: §5.7 — `commits` declara **efecto**, no precondiciones de + * aplicabilidad. La validez contextual sigue siendo responsabilidad del + * provider headless; el contrato sólo cataloga qué cambia cuando la + * acción se ejecuta. + */ +export interface SemaCommit { + part: PartRef; + attr: string; + value: string; +} + +// ── Acciones (sema-spec-v0.3.1 §5.2) ─────────────────────────────────────── + +/** + * Una acción semántica del componente. Siete campos, cinco opcionales con + * defaults — lo mínimo para que Sema sepa cuándo ejecutar, qué firma + * aplicar, cómo comportarse ante interrupciones, y qué contexto DOM + * escribir antes. + */ +export interface SemaAction { + /** + * Identificador único dentro del morfo. Referenciado desde + * `keyboard.action` (cuando procede) y desde el provider al invocar + * `sema.before(name, ctx)`. + */ + name: string; + /** Part primario afectado por la acción. */ + target: PartRef; + /** Evento canónico que esta acción dispara. */ + event: SemaEventLabel; + /** + * Relación del provider con la ventana Sema. + * - `blocking` (default): el provider hace `await sema.before()` antes del commit. + * - `advisory`: el provider dispara Sema y continúa inmediatamente. + */ + mode?: 'blocking' | 'advisory'; + /** + * Arbitraje cuando una segunda ocurrencia equivalente llega durante la + * ventana. Default `'replace'`. + * - `replace`: cancela la actual y arranca una nueva. + * - `collapse`: single-flight coalescing — no reinicia ni extiende. + * - `lock`: rechaza equivalentes mientras la ventana está abierta. + * - `queue`: las entrantes se encolan y se ejecutan secuencialmente. + * + * Dos ocurrencias son equivalentes si comparten `name` sobre el mismo target. + */ + regime?: 'replace' | 'collapse' | 'lock' | 'queue'; + /** + * Alcance perceptivo de la acción. Default `'part'`. + * - `'part'`: la coreografía vive atada al target; se cancela si se desmonta. + * - `'component'`: la coreografía cubre el árbol del componente. + * - `'scene'`: la coreografía sobrevive al desmontaje del componente + * (toasts, notificaciones que deben terminar de ejecutarse). + * + * Se declara por acción (no por familia en sema-map) porque el scope + * correcto depende del componente-más-evento, no del evento abstracto: + * un `emerge` en Toast requiere `'scene'`, el mismo `emerge` en Dialog + * requiere `'part'`. Ver Apéndice B de sema-spec-v0.3.1. + */ + scope?: 'part' | 'component' | 'scene'; + /** + * Atributos que se reflejan en DOM **antes** de invocar Sema. + * Típicamente `data-last-action` para comunicar el motivo causal del + * commit inminente. + */ + prewrite?: readonly SemaAttrWrite[]; + /** + * Cambio de estado estructural que la acción comitea. Ausente para + * acciones de feedback puro sin transición de estado (p. ej. + * `submit-failed` no mueve al formulario de `idle` a ningún otro + * estado — sólo dispara un `alert-threat`). + */ + commits?: SemaCommit; +} + +// ── Sustains (sema-spec-v0.3.1 §6.6) ─────────────────────────────────────── + +/** + * Una presencia perceptiva de larga duración (segundos, minutos, horas) + * cuya existencia depende de que un predicado DOM siga cumpliéndose. + * Distinto de `SemaAction` porque su ciclo de vida no es episódico — + * no tiene "fin natural" por duration, sino que termina cuando el + * provider llama `session.stop()`. + */ +export interface SemaSustainDecl { + name: string; + target: PartRef; + /** El predicado DOM que mantiene el sustain activo. */ + activeWhen: { part: PartRef; attr: string; value: string }; + /** Siempre `'sustain'`. Tipado por simetría con `SemaAction`. */ + event: 'sustain'; + /** Default `'part'`. Igual que en acciones, pero aplicado a la sesión sustain. */ + scope?: 'part' | 'component' | 'scene'; +} + +// ── Declaración sema del componente ─────────────────────────────────────── + +/** + * Contrato sema completo de un componente. Standalone: no menciona morfo. + * + * `kebab` identifica al componente y sirve de puente con otras capas + * (morfo, eidos) cuando existen — sin tipado intersectado. Si el + * componente también declara morfo, los dos kebabs deben coincidir; lo + * enforce el validador cuando se le pasa el morfo como contexto. + * + * Autoría recomendada: + * + * ```ts + * export const dialogSema = { + * kebab: 'dialog', + * actions: [ ... ] + * } as const satisfies SemaSpec; + * ``` + */ +export interface SemaSpec { + /** + * kebab-case del componente. Debe coincidir con el `kebab` del morfo + * correspondiente cuando el componente también tiene morfo. + */ + kebab: string; + actions: readonly SemaAction[]; + sustains?: readonly SemaSustainDecl[]; +} diff --git a/src/uix/sema/validation.ts b/src/uix/sema/validation.ts new file mode 100644 index 000000000..613174e91 --- /dev/null +++ b/src/uix/sema/validation.ts @@ -0,0 +1,236 @@ +/** + * Sema invariants validator. + * + * Sema es autónoma. Los invariantes internos (nombres únicos, eventos + * canónicos) se validan sin morfo. Cuando se pasa morfo como contexto + * opcional, se añaden los cross-checks (parts, data[], states[], + * data-last-action.values[]). + * + * El validador recibe el morfo por su **forma estructural** (el tipo + * `MorfoContext` de abajo), no por su tipo `Morfo`. Así sema sigue sin + * depender del módulo morfo a nivel de tipos: cualquier valor que tenga + * `kebab` + `parts` servirá. En la práctica el llamador pasa un `Morfo` + * y TypeScript lo acepta por compatibilidad estructural. + * + * Importante: `validateSema()` asume que el `spec` ya está tipado por + * TypeScript (`as const satisfies SemaSpec`). A diferencia de + * `validateMorfo()`, no hace decode completo del shape runtime; valida + * invariantes semánticos y referencias cruzadas sobre entrada tipada. + */ + +import type { SemaSpec, SemaEventLabel } from './types'; + +// Estructura mínima que el validador necesita del morfo para hacer los +// cross-checks. Redeclarada aquí (no importada de morfo) para que sema +// no tenga dependencia de tipos con morfo. +interface MorfoPartLike { + kebab: string; + states?: readonly string[]; + data: readonly { attr: string; values?: readonly string[] }[]; + parts?: readonly MorfoPartLike[]; +} + +interface MorfoContext { + kebab: string; + parts: readonly MorfoPartLike[]; +} + +/** Thrown when a sema invariant fails. */ +export class SemaInvariantError extends Error { + constructor(message: string) { + super(message); + this.name = 'SemaInvariantError'; + } +} + +/** Los 22 eventos canónicos. Debe mantenerse en sync con `SemaEventLabel`. */ +const SEMA_EVENT_LABELS = new Set([ + 'contact-neutral', + 'contact-threat', + 'contact-risk', + 'contact-affirm', + 'contact-fulfill', + 'commit-neutral', + 'commit-threat', + 'commit-risk', + 'commit-affirm', + 'commit-fulfill', + 'alert-neutral', + 'alert-threat', + 'alert-risk', + 'alert-affirm', + 'alert-fulfill', + 'handle-neutral', + 'handle-threat', + 'handle-risk', + 'handle-affirm', + 'handle-fulfill', + 'emerge', + 'sustain' +]); + +function flattenParts(parts: readonly MorfoPartLike[]): MorfoPartLike[] { + const out: MorfoPartLike[] = []; + for (const p of parts) { + out.push(p); + if (p.parts && p.parts.length > 0) out.push(...flattenParts(p.parts)); + } + return out; +} + +/** + * Valida los invariantes de un `SemaSpec`. + * + * **Invariantes internos** (siempre): + * 1. `name` único dentro del spec. + * 2. `event` pertenece al vocabulario canónico. + * + * **Invariantes cross-morfo** (sólo si se pasa `morfo`): + * 3. `spec.kebab === morfo.kebab`. + * 4. `action.target.target` resuelve a una parte del morfo. + * 5. `prewrite[].part.target` resuelve. + * 6. `prewrite[].attr` existe en `data[]` del part destino. + * 7. `prewrite[].value` ∈ `values[]` si el attr es enumerable. + * 8. `commits.part.target` resuelve. + * 9. `commits.value` ∈ `states[]` si `commits.attr === 'data-state'`. + * 10. `data-last-action.values[]` == unión de prewrites que escriben a ese + * attr (ambas direcciones). + * 11. `sustains[].target.target` y `sustains[].activeWhen.part.target` resuelven. + */ +export function validateSema(spec: SemaSpec, morfo?: MorfoContext): void { + // 1. Nombres únicos (siempre). + const actionNames = new Set(); + for (const action of spec.actions) { + if (actionNames.has(action.name)) { + throw new SemaInvariantError( + `sema: duplicate action name "${action.name}" in "${spec.kebab}"` + ); + } + actionNames.add(action.name); + } + + // 2. Eventos canónicos (siempre). + for (const action of spec.actions) { + if (!SEMA_EVENT_LABELS.has(action.event)) { + throw new SemaInvariantError( + `sema.actions["${action.name}"]: event "${action.event}" is not a valid SemaEventLabel` + ); + } + } + + // Resto depende de tener contexto de morfo. + if (!morfo) return; + + // 3. kebab coincide. + if (morfo.kebab !== spec.kebab) { + throw new SemaInvariantError( + `sema: spec.kebab "${spec.kebab}" does not match morfo.kebab "${morfo.kebab}"` + ); + } + + const flat = flattenParts(morfo.parts); + const kebabs = new Set(); + const partByKebab = new Map(); + for (const part of flat) { + kebabs.add(part.kebab); + partByKebab.set(part.kebab, part); + } + + const prewriteDLAByPart = new Map>(); + + for (const action of spec.actions) { + const ctx = `sema.actions["${action.name}"]`; + + // 4. target. + if (!kebabs.has(action.target.target)) { + throw new SemaInvariantError( + `${ctx}: target "${action.target.target}" does not match any part in "${morfo.kebab}"` + ); + } + + // 5-7. prewrites. + for (const pw of action.prewrite ?? []) { + const pwCtx = `${ctx}.prewrite[${pw.attr}]`; + if (!kebabs.has(pw.part.target)) { + throw new SemaInvariantError( + `${pwCtx}: part "${pw.part.target}" does not match any part in "${morfo.kebab}"` + ); + } + const targetPart = partByKebab.get(pw.part.target); + if (!targetPart) continue; + const dataEntry = targetPart.data.find((d) => d.attr === pw.attr); + if (!dataEntry) { + throw new SemaInvariantError( + `${pwCtx}: attr "${pw.attr}" not declared in part "${pw.part.target}"'s data[] (declare it before referencing)` + ); + } + if (dataEntry.values && !dataEntry.values.includes(pw.value)) { + throw new SemaInvariantError( + `${pwCtx}: value "${pw.value}" not in declared values [${dataEntry.values.join(', ')}]` + ); + } + if (pw.attr === 'data-last-action') { + const s = prewriteDLAByPart.get(pw.part.target) ?? new Set(); + s.add(pw.value); + prewriteDLAByPart.set(pw.part.target, s); + } + } + + // 8-9. commits. + if (action.commits) { + const cCtx = `${ctx}.commits`; + if (!kebabs.has(action.commits.part.target)) { + throw new SemaInvariantError( + `${cCtx}: part "${action.commits.part.target}" does not match any part in "${morfo.kebab}"` + ); + } + if (action.commits.attr === 'data-state') { + const targetPart = partByKebab.get(action.commits.part.target); + const states = targetPart?.states ?? []; + if (!states.includes(action.commits.value)) { + throw new SemaInvariantError( + `${cCtx}: value "${action.commits.value}" not in states of "${action.commits.part.target}" (declared: ${states.join(', ') || '∅'})` + ); + } + } + } + } + + // 10. data-last-action.values[] == unión de prewrites que lo escriben. + for (const part of flat) { + const partKebab = part.kebab; + const dla = part.data.find((d) => d.attr === 'data-last-action'); + if (!dla?.values) continue; + const written = prewriteDLAByPart.get(partKebab) ?? new Set(); + const declared = new Set(dla.values); + for (const v of written) { + if (!declared.has(v)) { + throw new SemaInvariantError( + `sema: prewrite writes "${v}" to data-last-action on "${partKebab}", but the part's values[] does not include it (declared: ${[...declared].join(', ')})` + ); + } + } + for (const v of declared) { + if (!written.has(v)) { + throw new SemaInvariantError( + `sema: part "${partKebab}" declares data-last-action value "${v}" but no sema action prewrites it — values[] must equal the union of prewrites` + ); + } + } + } + + // 11. sustains. + for (const sustain of spec.sustains ?? []) { + const ctx = `sema.sustains["${sustain.name}"]`; + if (!kebabs.has(sustain.target.target)) { + throw new SemaInvariantError( + `${ctx}: target "${sustain.target.target}" does not match any part` + ); + } + if (!kebabs.has(sustain.activeWhen.part.target)) { + throw new SemaInvariantError( + `${ctx}: activeWhen.part "${sustain.activeWhen.part.target}" does not match any part` + ); + } + } +}