sema: add runtime foundation and dialog integration

semantuix
dev 6 months ago
parent 51dba711e9
commit f62ede4a23

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

@ -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"/
);
});
});

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

@ -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,

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

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

@ -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<Record<string, boolean>> = {}) {
const listeners = new Map<string, Set<() => 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)
})
})

@ -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<keyof A11ySnapshot, MediaQueryListLike>
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)
}
}

@ -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<string, string> = {}): HTMLElement {
const attrs = new Map<string, string>(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()
})
})

@ -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<Record<string, HTMLElement>>
cause?: SemaContext['cause']
abortSignal?: AbortSignal
}
export type ActionName<S extends SemaSpec> = S['actions'][number]['name']
export type SustainName<S extends SemaSpec> = S['sustains'] extends readonly SemaSustainDecl[]
? S['sustains'][number]['name']
: never
export interface SemaBinding<S extends SemaSpec> {
before(name: ActionName<S>, ctx: PartialSemaContext): Promise<void>
fire(name: ActionName<S>, ctx: PartialSemaContext): void
start(name: SustainName<S>, ctx: PartialSemaContext): SemaSession
action(name: ActionName<S>): 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<S extends SemaSpec>(spec: S, port: SemaPort): SemaBinding<S> {
const actionsByName = new Map<string, SemaAction>()
for (const action of spec.actions) {
actionsByName.set(action.name, action)
}
const sustainsByName = new Map<string, SemaSustainDecl>()
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<string, string | null> = {}
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)
}
}
}

@ -0,0 +1,218 @@
import { describe, expect, it, vi } from 'vitest'
import { ColorChannel } from './color'
import type { ColorSignature } from '../resolver'
interface MockAnimation extends Partial<Animation> {
cancel: ReturnType<typeof vi.fn>
finished: Promise<void>
resolveFinished(): void
rejectFinished(reason?: unknown): void
}
function createMockAnimation(): MockAnimation {
let resolveFinished = () => {}
let rejectFinished = (_reason?: unknown) => {}
const finished = new Promise<void>((resolve, reject) => {
resolveFinished = resolve
rejectFinished = reject
})
return {
cancel: vi.fn(),
finished,
resolveFinished,
rejectFinished
}
}
function createStyle(seed: Record<string, string> = {}): 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<typeof vi.fn> }> = [{ 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)
})
})

@ -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<Document, 'createElement' | 'querySelectorAll' | 'defaultView'>
type OverlayElement = HTMLElement & {
animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation
remove(): void
style: CSSStyleDeclaration
}
function hasAnimate(target: unknown): target is { animate: NonNullable<ColorTarget['animate']> } {
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<HTMLElement, OverlayElement[]>()
async apply(
target: HTMLElement,
signature: ColorSignature,
abortSignal: AbortSignal
): Promise<void> {
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<void> {
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<void> {
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<void> {
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)
}
}
}

@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import { MotionChannel } from './motion'
import type { MotionSignature } from '../resolver'
interface MockAnimation extends Partial<Animation> {
cancel: ReturnType<typeof vi.fn>
finished: Promise<void>
resolveFinished(): void
rejectFinished(reason?: unknown): void
}
function createMockAnimation(): MockAnimation {
let resolveFinished = () => {}
let rejectFinished = (_reason?: unknown) => {}
const finished = new Promise<void>((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()
})
})

@ -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<HTMLElement, Animation[]>()
async apply(
target: HTMLElement,
signature: MotionSignature,
abortSignal: AbortSignal
): Promise<void> {
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)
}
}
}

@ -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<Animation> {
cancel: ReturnType<typeof vi.fn>
finished: Promise<void>
resolveFinished(): void
rejectFinished(reason?: unknown): void
}
function createMockAnimation(): MockAnimation {
let resolveFinished = () => {}
let rejectFinished = (_reason?: unknown) => {}
const finished = new Promise<void>((resolve, reject) => {
resolveFinished = resolve
rejectFinished = reject
})
return {
cancel: vi.fn(),
finished,
resolveFinished,
rejectFinished
}
}
function createStyle(seed: Record<string, string> = {}): 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<typeof vi.fn> }> = [{ 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)
})
})

@ -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<Document, 'createElement' | 'querySelectorAll' | 'body' | 'defaultView'>
function hasAnimate(target: unknown): target is { animate: NonNullable<PresenceTarget['animate']> } {
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<void> {
if (!target.isConnected) return
const promises: Promise<void>[] = []
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<void> {
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<void> {
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<void> {
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<void>((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<void> {
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
}
}

@ -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<typeof createGainNode>[] = []
const oscillators: ReturnType<typeof createOscillatorNode>[] = []
const filters: ReturnType<typeof createBiquadFilterNode>[] = []
const sources: ReturnType<typeof createBufferSourceNode>[] = []
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)
})
})

@ -0,0 +1,322 @@
import type { SoundSignature } from '../resolver'
type AudioContextCtor = new () => AudioContext
export interface SoundChannelOptions {
audioContextFactory?: () => AudioContext | null
fetchFn?: typeof fetch
doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>
}
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<string, AudioBuffer>()
private readonly fetchFn?: typeof fetch
private readonly audioContextFactory?: () => AudioContext | null
private readonly doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>
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<void> {
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<void> {
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<AudioContext | null> {
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<void> {
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<void>((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<void> {
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<void>((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 })
})
}
}

@ -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<string, string>()
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> = {}
): 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> = {}
): 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<typeof vi.fn> } {
return {
apply: vi.fn(async (_target: HTMLElement, _signature: MotionSignature, abortSignal: AbortSignal) => {
await new Promise<void>((resolve) => {
const timer = setTimeout(() => resolve(), ms)
abortSignal.addEventListener(
'abort',
() => {
clearTimeout(timer)
resolve()
},
{ once: true }
)
})
}),
applySustained: () => () => {},
destroy() {}
}
}
function createImmediateDrivers(overrides: Partial<SemaChannelDrivers> = {}) {
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<SemaEventDetail>).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<SemaEventDetail['phase']> = []
target.addEventListener('sema:event', (event) => {
phases.push((event as CustomEvent<SemaEventDetail>).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)
})
})

@ -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<void>
applySustained?(target: HTMLElement, signature: MotionSignature): () => void
destroy?(): void
}
export interface SoundChannelDriver {
apply(signature: SoundSignature, gain: number, abortSignal: AbortSignal): Promise<void>
applySustained?(signature: SoundSignature, gain: number): () => void
destroy?(): void
}
export interface ColorChannelDriver {
apply(target: HTMLElement, signature: ColorSignature, abortSignal: AbortSignal): Promise<void>
applySustained?(target: HTMLElement, signature: ColorSignature): () => void
destroy?(): void
}
export interface PresenceChannelDriver {
apply(target: HTMLElement, signature: PresenceSignature, abortSignal: AbortSignal): Promise<void>
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<SemaChannelDrivers>
}
export interface EngineConfig extends SemaRuntimeConfig {
mapOverrides?: RuntimeOverrides
}
export interface EngineConfigPatch
extends Partial<Omit<EngineConfig, 'sound' | 'motion' | 'color' | 'presence'>> {
sound?: Partial<EngineConfig['sound']>
motion?: Partial<EngineConfig['motion']>
color?: Partial<EngineConfig['color']>
presence?: Partial<EngineConfig['presence']>
}
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<SemaEventDetail>('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<void>): Promise<void> {
return work.catch(() => {})
}
function delay(ms: number): Promise<void> {
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<string, Choreography>()
private readonly sustainSessions = new Set<SustainRunner>()
private readonly elementIds = new WeakMap<HTMLElement, string>()
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<void> {
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<SemaChannelDrivers> {
return this.drivers
}
get currentConfig(): Readonly<EngineConfig> {
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<void>
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<void>((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<void>[] = []
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()
}

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

@ -0,0 +1 @@
export * from './exports'

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

@ -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<SemaAction['mode']>
type ResolvedSemaRegime = NonNullable<SemaAction['regime']>
type ResolvedSemaScope = NonNullable<SemaAction['scope']>
/**
* 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<SemaSustainDecl['scope']>
}
/**
* Runtime context built by the caller for an invocation.
*/
export interface SemaContext {
targetEl: HTMLElement
rootEl?: HTMLElement
partEls?: Partial<Record<string, HTMLElement>>
snapshot: Record<string, string | null>
cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation'
abortSignal?: AbortSignal
}
export interface SemaSession {
stop(): void
readonly active: boolean
}
export interface SemaPort {
before(action: ResolvedSemaAction, ctx: SemaContext): Promise<void>
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<void> {
if (ms <= 0) return;
if (!opts.respectAbort || !signal) {
await new Promise<void>((resolve) => setTimeout(resolve, ms))
return
}
if (signal.aborted) return
await new Promise<void>((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
}
}
}

@ -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/)
})
})

@ -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<string, DeltaValue>
}
export interface SemaMap {
version: string
families: Record<SemaFamilyName, FamilyMapEntry>
intents: Record<SemaIntentName, IntentMapEntry>
soundPack: Record<string, string>
}
export type RuntimeOverrides = Record<string, DeltaValue>
export interface CSEMSelectorOverride {
selector: string
overrides: Record<string, Record<string, DeltaValue>>
}
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<string, unknown> {
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<T>(value: T): T {
return structuredClone(value)
}
function toCanonicalEvent(family: SemaFamilyName, intent: SemaIntentName | null): SemaEventLabel {
if (!intent) return family as Extract<SemaEventLabel, 'emerge' | 'sustain'>
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<string, unknown> = 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<string, unknown> = next as unknown as Record<string, unknown>
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]
if (!isRecord(cursor[key])) cursor[key] = {}
cursor = cursor[key] as Record<string, unknown>
}
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<string, DeltaValue>
): 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)
}
}

@ -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": {}
}

File diff suppressed because it is too large Load Diff

@ -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<void>;
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<void>;
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.

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

@ -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<SemaEventLabel>([
'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<string>();
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<string>();
const partByKebab = new Map<string, MorfoPartLike>();
for (const part of flat) {
kebabs.add(part.kebab);
partByKebab.set(part.kebab, part);
}
const prewriteDLAByPart = new Map<string, Set<string>>();
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<string>();
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<string>();
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`
);
}
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.