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

@ -1,27 +1,19 @@
# Sema — Especificación v0.3.1
# Sema — Especificación v0.4
**Capa semántico-perceptiva para interfaces de usuario**
> Working draft. Esta especificación describe Sema, una capa de expresión perceptivo-afectiva de los eventos de interfaz. Es independiente de framework y stack. La implementación de referencia es **SemaUIX**, donde Sema convive con dos capas adicionales llamadas Soma (headless) y Eidos (visual).
> Especificación de Sema como capa conceptual. Independiente de framework, stack y artefactos de implementación concretos. Esta versión separa lo que es Sema (contenido de este documento) de cómo se implementa en un framework específico (ver documentos de implementación separados, por ejemplo `semauix-sema-impl.md`).
>
> **Estado:** v0.3.1 es la versión v0.3 con correcciones de fricción interna detectadas en revisión final por ChatGPT. No introduce cambios arquitectónicos; solo precisa formulaciones y corrige ejemplos.
> **Estado:** v0.4 es v0.3.1 purificada de dependencias con SemaUIX. Mismo contenido doctrinal, alcance redefinido.
>
> **Cambios respecto a v0.3:**
> - §2.2 reformulado: la secuencialidad estricta aplica al camino `blocking`, no a toda acción
> - §11.3 neutralizado: las técnicas aditivas (composite: 'add', overlays) pasan a recomendación de implementación, no doctrina arquitectónica
> - §5.7 explicita: `commits` declara efecto estructural, no precondiciones de aplicabilidad
> - El ejemplo de Dialog renombra `close-failed` a `close-after-fail` para eliminar ambigüedad semántica
> - La regla de validación de `keyboard.action` se relaja: solo adquiere semántica Sema si además está declarada en `sema.actions`
> **Cambios respecto a v0.3.1:**
> - Eliminadas referencias específicas a morfo como contrato obligatorio
> - `SemaAction` descrita conceptualmente, sin sintaxis TypeScript concreta de SemaUIX
> - Eliminadas menciones a `v.partRef`, `as const satisfies`, sium, `createSemaBinding`
> - Ejemplo Dialog movido a la documentación de implementación de referencia
> - Se añade §13 que define el contrato mínimo que una implementación debe proveer
>
> **Cambios de v0.2 a v0.3 (heredados):**
> - El principio aditivo se reformula como principio de secuencialidad coordinada
> - Se introduce la extensión `MorfoSema` como contrato estructural del componente
> - Se introduce el puerto neutral `SemaPort` como protocolo Soma-Sema
> - Los regímenes de arbitraje (`replace | collapse | lock | queue`) quedan definidos
> - La política de accesibilidad pasa a ser por canal y por preferencia
> - La fundamentación bibliográfica se extrae a documento paralelo (`sema-research.md`)
>
> Lista para implementación prototipo.
> Lista para que implementaciones concretas (SemaUIX o cualquier otra) la materialicen.
---
@ -45,9 +37,9 @@ Sema asume un contexto arquitectónico de **tres capas independientes** que se c
| Capa visual | Presentación en reposo del componente | Consume los atributos de estado para estilar cómo se ve el elemento **mientras dura cada estado** |
| Sema | Expresión perceptivo-afectiva de los eventos | Reacciona a las acciones declaradas por la capa headless y ejecuta coreografías perceptivas acotadas |
Las tres capas se coordinan a través de un **contrato cross-layer**: un artefacto declarativo por componente (llamado `morfo` en SemaUIX) que define la superficie pública del componente — sus partes, los atributos que emite, su contrato ARIA, sus acciones semánticas. Cada capa consume el contrato desde su ángulo.
Las tres capas se coordinan a través de un **contrato cross-layer**: un artefacto declarativo por componente que define la superficie pública del componente — sus partes, los atributos que emite, su contrato ARIA, sus acciones semánticas. Cada capa consume el contrato desde su ángulo.
> En la implementación de referencia **SemaUIX**, la capa headless se llama **Soma**, la capa visual se llama **Eidos**, y el contrato cross-layer se llama **morfo**. Esta spec usa los términos genéricos "capa headless" y "capa visual" para mantenerse agnóstica, refiriéndose por nombre solo cuando aporte claridad operativa.
Esta spec no dicta cómo se implementa ese contrato cross-layer. Una implementación puede usar un artefacto TypeScript con validación runtime (como hace la implementación de referencia SemaUIX con `morfo`), decoradores en clases, registros runtime, hooks, o cualquier otro mecanismo que exponga la información que Sema necesita.
### 1.3. Alcance de esta spec
@ -55,17 +47,18 @@ Sema especifica:
- La taxonomía semántica (6 familias de eventos, 5 intents afectivos)
- Los canales perceptivos (4 ejes: motion, sound, color, presence)
- La extensión del contrato cross-layer para declarar acciones semánticas (morfo-sema)
- El protocolo de coordinación entre la capa headless y Sema (SemaPort)
- La noción conceptual de acción semántica (§5)
- El protocolo de coordinación entre la capa headless y Sema (§6)
- La gramática del artefacto `.csem` (donde el integrador resuelve la expresión perceptiva)
- La estructura del artefacto `sema-map.json` (vocabulario canónico de eventos y firmas)
- Los principios de operación (secuencialidad coordinada, regímenes de arbitraje)
- Los requisitos mínimos para que una implementación sea "Sema-compliant"
- Los requisitos mínimos para que una implementación sea Sema-compliant (§13)
Sema NO especifica:
- Cómo se implementa la capa headless ni la visual
- Qué framework se usa (Svelte, React, Vue, Web Components)
- Qué mecanismo concreto declara las acciones semánticas (TypeScript const, decoradores, registros runtime, otro)
- El pipeline de build concreto
- La API JavaScript exacta del engine
- Los valores numéricos finales del mapa (se proveen rangos defendibles; la calibración final es responsabilidad de cada implementación)
@ -90,7 +83,7 @@ La capa visual estiliza estados. Sema expresa eventos. No son el mismo objeto ni
La secuencialidad estricta aplica al modo `blocking`, que es el canónico para la mayoría de acciones con cambio de estado. Las acciones `advisory` (fase `after-state`) permiten que Sema corra en paralelo al estado ya cambiado, y las coreografías largas pueden extenderse más allá del cap temporal del provider como "tails post-state" — en esos casos, el tramo secuencial garantizado termina cuando el provider libera el `await`.
Esta secuencialidad la garantiza la capa headless, guiada por el contrato cross-layer. La capa headless sabe qué acciones puede realizar un componente (porque morfo las declara), sabe qué evento Sema precede a cada acción (porque morfo-sema lo declara), y orquesta el orden:
Esta secuencialidad la garantiza la capa headless. La capa headless sabe qué acciones puede realizar un componente (porque el contrato cross-layer las declara), sabe qué evento Sema precede a cada acción, y orquesta el orden:
1. La capa headless decide que va a ejecutar una acción
2. Aplica los prewrites necesarios al DOM (atributos contextuales antes del evento)
@ -99,7 +92,7 @@ Esta secuencialidad la garantiza la capa headless, guiada por el contrato cross-
5. Al terminar, la capa headless aplica el cambio de estado
6. La capa visual reacciona al nuevo estado con sus transiciones
Esto reemplaza el "principio aditivo" de versiones anteriores. En el camino canónico ya no hay superposición de capas sobre el mismo elemento al mismo tiempo; hay turnos de autoridad claros, garantizados por el protocolo. Los casos que permiten paralelismo (advisory, tails post-cap) quedan explícitamente marcados como post-state, no como violaciones de la doctrina.
En el camino canónico ya no hay superposición de capas sobre el mismo elemento al mismo tiempo; hay turnos de autoridad claros, garantizados por el protocolo. Los casos que permiten paralelismo (advisory, tails post-cap) quedan explícitamente marcados como post-state, no como violaciones de la doctrina.
### 2.3. El contrato cross-layer como fuente única
@ -110,21 +103,23 @@ La clave de que la secuencialidad funcione es que las tres capas comparten un co
El contrato **no** declara cómo se estilan los estados (eso es responsabilidad de la capa visual) ni cómo se expresan perceptivamente los eventos (eso es responsabilidad de Sema via `.csem` y `sema-map.json`). El contrato es estructural, no implementacional.
Qué mecanismo concreto realiza ese contrato (const declarativo, decoradores, registros, hooks) es decisión de la implementación del framework.
### 2.4. Separación de qué y cómo
Morfo declara qué existe; cada capa declara cómo lo hace:
El contrato cross-layer declara qué existe; cada capa declara cómo lo hace:
| Capa | Qué declara el contrato | Dónde vive el cómo |
|---|---|---|
| Headless | Parts, atributos emitidos, ARIA, keyboard, acciones semánticas | Código del provider |
| Headless | Partes, atributos emitidos, ARIA, keyboard, acciones semánticas | Código del provider |
| Visual | (lee del contrato) | CSS en reposo del componente |
| Sema | (lee del contrato) | `.csem` del integrador + `sema-map.json` |
Cuando morfo dice "Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`", no está diciendo cómo se expresa `commit-fulfill`. Eso lo resuelve Sema con `.csem` (si el integrador lo sobreescribe) o con el `sema-map.json` por defecto.
Cuando el contrato declara que un Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`, no está diciendo cómo se expresa `commit-fulfill`. Eso lo resuelve Sema con `.csem` (si el integrador lo sobreescribe) o con el `sema-map.json` por defecto.
### 2.5. Declaratividad y escape hatches
Sema es declarativa por diseño: el 95% de los casos se expresan en morfo-sema y `.csem` sin código imperativo. Para casos que no caben en el modelo declarativo (condiciones no expresables como atributos DOM, orquestación temporal compleja, estímulos calculados en runtime), el engine debe proveer una API imperativa como escape hatch. Es excepción, no regla.
Sema es declarativa por diseño: el 95% de los casos se expresan en el contrato cross-layer y `.csem` sin código imperativo. Para casos que no caben en el modelo declarativo (condiciones no expresables como atributos DOM, orquestación temporal compleja, estímulos calculados en runtime), el engine debe proveer una API imperativa como escape hatch. Es excepción, no regla.
---
@ -163,25 +158,30 @@ Los cinco intents son cinco puntos anclados en el espacio bidimensional valencia
Sema define un conjunto finito y enumerable de eventos semánticos que resultan de combinar familias × intents:
```ts
type SemaEventLabel =
// Contact (4 familia valencial × 5 intents)
| 'contact-neutral' | 'contact-threat' | 'contact-risk'
| 'contact-affirm' | 'contact-fulfill'
// Commit
| 'commit-neutral' | 'commit-threat' | 'commit-risk'
| 'commit-affirm' | 'commit-fulfill'
// Alert
| 'alert-neutral' | 'alert-threat' | 'alert-risk'
| 'alert-affirm' | 'alert-fulfill'
// Handle
| 'handle-neutral' | 'handle-threat' | 'handle-risk'
| 'handle-affirm' | 'handle-fulfill'
```
SemaEventLabel ∈ {
// Contact × intents
contact-neutral, contact-threat, contact-risk,
contact-affirm, contact-fulfill,
// Commit × intents
commit-neutral, commit-threat, commit-risk,
commit-affirm, commit-fulfill,
// Alert × intents
alert-neutral, alert-threat, alert-risk,
alert-affirm, alert-fulfill,
// Handle × intents
handle-neutral, handle-threat, handle-risk,
handle-affirm, handle-fulfill,
// Transicionales (sin intent)
| 'emerge' | 'sustain';
emerge, sustain
}
```
22 eventos en total (20 valenciales + 2 transicionales). Este tipo es exportado por el paquete Sema y consumido por morfo para validar que las acciones referencian eventos existentes.
22 eventos en total (20 valenciales + 2 transicionales). Este vocabulario es finito y cerrado; implementaciones no deben extenderlo arbitrariamente. Si una necesidad perceptiva real no encaja, es señal de revisar la taxonomía, no de añadir un evento ad-hoc.
### 3.4. La moral la da el intent, no la familia
@ -275,73 +275,51 @@ No todos los eventos activan los cuatro canales. Qué canales activa cada evento
---
## 5. Morfo-Sema: el contrato de acciones
### 5.1. Propósito
## 5. Acciones semánticas: el concepto
Morfo-sema es la extensión del contrato cross-layer que declara las **acciones semánticas** de un componente. Una acción es cualquier cosa que el componente hace que tiene significado perceptivo: abrir, cerrar-con-éxito, cerrar-con-error, invalidar, confirmar, etc.
### 5.1. Qué es una acción semántica
Morfo-sema declara la estructura de esas acciones (qué eventos Sema disparan, qué transiciones de estado comitean, qué prewrites necesitan) pero **no declara cómo se expresan perceptivamente** (eso es responsabilidad de `.csem` y `sema-map.json`).
Una **acción semántica** es cualquier cosa que un componente hace que tiene significado perceptivo: abrir, cerrar-con-éxito, cerrar-con-error, invalidar, confirmar, etc.
La analogía clave: morfo declara que un Dialog tiene un part `Content` con estados `['open', 'closed']`, pero no declara cómo se ve el componente en cada estado (eso es trabajo de la capa visual). De la misma forma, morfo-sema declara que Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`, pero no declara cómo suena o se anima ese evento.
Las acciones semánticas son declaradas por cada componente en su contrato cross-layer. La capa headless las invoca durante el flujo del componente. Sema las ejecuta aplicando las firmas perceptivas correspondientes.
### 5.2. Shape del contrato
La acción es la unidad de integración entre la capa headless y Sema. No los eventos DOM crudos (un click puede ser una acción u otra dependiendo del contexto), ni los cambios de estado (un cambio puede ser consecuencia de varias acciones distintas). La acción declara explícitamente qué está ocurriendo semánticamente.
```ts
type SemaAttrWrite = {
part: PartRef;
attr: string; // debe existir en data[] del part referenciado
value: string; // debe pertenecer a values[] si el attr es enumerable
};
### 5.2. Campos conceptuales de una acción
type SemaCommit = {
part: PartRef;
attr: string; // típicamente 'data-state'
value: string; // el valor al que se comitea
};
Una acción declara:
type SemaAction = {
/** Identificador único de la acción dentro del componente.
* Referenciado desde keyboard.action y desde el provider. */
name: string;
- **Nombre** — identificador único dentro del componente. Referenciable desde el código del provider y desde declaraciones de teclado.
- **Target** — referencia a la parte del componente afectada por la acción (dónde se aplica la coreografía perceptiva).
- **Evento** — etiqueta del vocabulario canónico (`SemaEventLabel`). Define qué firma perceptiva se dispara.
- **Modo** (opcional, default `blocking`) — si la capa headless espera a que Sema termine antes de aplicar el cambio de estado, o dispara y sigue.
- **Régimen** (opcional, default `replace`) — qué hace Sema si llega otra acción equivalente durante la ventana.
- **Scope** (opcional, default `part`) — el alcance de la coreografía: solo el target, el componente completo, o persiste como escena tras desmontaje.
- **Prewrites** (opcional) — atributos DOM que se reflejan antes de invocar Sema (contexto causal que las capas posteriores podrán leer).
- **Commits** (opcional) — si existe, describe el cambio de estado que la capa headless aplicará **después** de que Sema termine.
/** Part primario afectado por la acción. */
target: PartRef;
Estos ocho campos son suficientes para que la capa headless orqueste la secuencia y Sema resuelva la firma. Nada más debe ir en la acción: los parámetros perceptivos (pitch, hue, duraciones concretas) viven en `.csem` y `sema-map.json`.
/** Evento Sema que dispara esta acción. */
event: SemaEventLabel;
### 5.3. Cómo se declara una acción
/** Cómo se comporta el provider durante la ejecución de Sema.
* Default 'blocking'. */
mode?: 'blocking' | 'advisory';
Esta spec no prescribe la sintaxis concreta. La implementación decide el mecanismo — un TypeScript const declarativo, decoradores, un registro runtime, un archivo JSON o YAML, un hook — siempre que el contenido semántico declarado contenga los campos del §5.2.
/** Qué hace Sema si llega otra acción equivalente durante la ventana.
* Default 'replace'. */
regime?: 'replace' | 'collapse' | 'lock' | 'queue';
Ejemplo conceptual (pseudocódigo neutro):
/** Atributos que se deben reflejar en DOM antes de invocar Sema. */
prewrite?: readonly SemaAttrWrite[];
/** Si existe, transición de estado que se comitea tras la ventana Sema.
* Ausente para acciones que no comitean estado (submit-failed). */
commits?: SemaCommit;
};
type MorfoSema = {
actions: readonly SemaAction[];
};
type Morfo = {
// ... shape actual
sema?: MorfoSema;
};
```
action "close-save" on Dialog.Content {
event: commit-fulfill
regime: lock
prewrite: [ data-last-action = "saved" on Content ]
commits: [ data-state = "closed" on Content ]
}
```
Siete campos por acción, cinco opcionales con defaults. Lo mínimo para que Sema sepa cuándo ejecutar, qué firma aplicar, cómo comportarse ante interrupciones, y qué contexto escribir antes.
Cada implementación materializa esta declaración en su sintaxis. La doc de implementación de referencia (`semauix-sema-impl.md`) muestra cómo SemaUIX lo hace con morfo y TypeScript.
### 5.3. Los regímenes
### 5.4. Los regímenes
El campo `regime` define el comportamiento cuando una acción se dispara mientras ya hay otra de la misma identidad en curso.
El régimen define el comportamiento cuando una acción se dispara mientras ya hay otra de la misma identidad en curso.
**`replace` (default):** la acción entrante cancela la anterior y arranca una nueva desde cero. Para coreografías puntuales donde interesa el evento más reciente.
@ -351,22 +329,22 @@ El campo `regime` define el comportamiento cuando una acción se dispara mientra
**`queue`:** las acciones entrantes se encolan y se ejecutan secuencialmente tras la actual. Útil para secuencias de confirmación múltiple.
**Equivalencia:** dos ocurrencias son equivalentes si comparten el mismo `action.name` sobre el mismo target.
**Equivalencia:** dos ocurrencias son equivalentes si comparten el mismo nombre de acción sobre el mismo target.
### 5.4. Modo y fase
### 5.5. Modo y fase
`mode` define si la capa headless espera a que Sema termine:
- **`blocking` (default):** la capa headless hace `await sema.before(...)` antes de aplicar el commit. La secuencia Sema → commit de estado es estricta.
- **`blocking` (default):** la capa headless hace `await` antes de aplicar el commit. La secuencia Sema → commit de estado es estricta.
- **`advisory`:** la capa headless dispara Sema y sigue inmediatamente. Útil para acentos no críticos posteriores a un cambio de estado.
La fase temporal (antes del cambio de estado, después, o independiente) se deduce de la combinación de `commits` y `mode`:
- Con `commits` presente + `mode: 'blocking'` → fase **before-state** (canónica)
- Con `commits` presente + `mode: 'advisory'` → fase **after-state** (el estado cambia, luego Sema corre en paralelo)
- Con `commits` presente + `mode: blocking` → fase **before-state** (canónica)
- Con `commits` presente + `mode: advisory` → fase **after-state** (el estado cambia, luego Sema corre en paralelo)
- Sin `commits` → fase **independent** (no hay cambio de estado, la acción es puro feedback)
### 5.5. Matriz de combinaciones válidas
### 5.6. Matriz de combinaciones válidas
| mode | commits | Fase | Legitimidad | Caso de uso |
|---|---|---|---|---|
@ -375,265 +353,115 @@ La fase temporal (antes del cambio de estado, después, o independiente) se dedu
| blocking | ausente | independent | Legítimo | Submit-failed, close-denied |
| advisory | ausente | independent | Legítimo | Tick breve no bloqueante |
Las combinaciones no listadas son inválidas (el validador las rechaza).
### 5.6. Ejemplo: Dialog extendido
```ts
export const dialogMorfo = {
name: 'Dialog',
kebab: 'dialog',
scope: ['soma', 'sema'],
focus: { initial: 'first-focusable', trap: true, return: 'trigger', restore: true },
parts: [
// ... Provider, Trigger, etc.
{
name: 'Content',
kebab: 'content',
kind: 'public',
defaultElement: 'div',
role: 'dialog',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{
attr: 'data-last-action',
values: ['saved', 'cancelled', 'dismissed', 'failed'],
severity: 'optional'
}
],
aria: [/* ... */],
keyboard: [
{ key: 'Escape', action: 'close-dismiss' } // referencia a una acción del provider;
// adquiere semántica Sema al coincidir con sema.actions
]
}
],
sema: {
actions: [
{
name: 'open',
target: v.partRef('content'),
event: 'emerge',
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'open'
}
},
{
name: 'close-save',
target: v.partRef('content'),
event: 'commit-fulfill',
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-cancel',
target: v.partRef('content'),
event: 'emerge',
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss',
target: v.partRef('content'),
event: 'emerge',
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-after-fail',
target: v.partRef('content'),
event: 'alert-threat',
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
}
]
}
} as const satisfies Morfo;
```
Las cinco acciones del Dialog quedan declaradas con nombre, evento asociado, prewrite, commit. Cero redundancia — `data-last-action.values` se deriva de los prewrites; `keyboard.action` referencia una acción del provider (con semántica Sema cuando coincide con `sema.actions`).
**Nota sobre el nombre `close-after-fail`:** la acción describe el caso donde el dialog **sí se cierra** (commits a `closed`) pero el motivo causal fue el fallo de una operación contenida (guardado fallido, validación rechazada, red caída). El prewrite de `data-last-action: 'failed'` permite que la capa visual aplique un tratamiento distintivo al estado `closed` resultante si así lo decide. Si el caso que se quiere modelar es "el intento de cerrar falló y el dialog permanece abierto", esa sería otra acción distinta sin `commits` (acción independiente con `alert-threat` pero sin cambio de estado).
### 5.7. Validación
El validador del contrato (en SemaUIX, `schema.ts` con sium) añade estas validaciones cuando `scope` incluye `'sema'`:
Las combinaciones no listadas son inválidas; la implementación debe rechazarlas en validación.
- `target.part` debe resolver a un `part.kebab` existente
- `event` debe pertenecer al union type `SemaEventLabel`
- `prewrite[].attr` debe existir en `data[]` del `part` referenciado
- `prewrite[].value` debe pertenecer a `values[]` cuando el attr es enumerable
- `commits.part` debe resolver
- `commits.value` debe pertenecer a `states[]` del part si `commits.attr === 'data-state'`
- `data-last-action.values[]` (si existe) debe ser exactamente la unión de los valores de los prewrites que escriben a `data-last-action`
### 5.7. Alcance estructural
**Alcance estructural de `commits`.** El campo `commits` declara el **efecto estructural** de la acción — qué atributo cambia y a qué valor — pero **no sus precondiciones de aplicabilidad**. Morfo-sema no modela desde qué estado una acción es válida, ni distingue una acción que produce transición real de una que sería no-op. La validez contextual de una acción (si puede o no ejecutarse en un momento dado) sigue siendo responsabilidad del provider headless, que conoce el estado interno del componente. El validador solo comprueba que la declaración sea coherente, no que sea aplicable en todo contexto.
**Validación cruzada con `keyboard.action`.** El campo `keyboard.action` puede resolver a cualquier acción del provider (`focus-next`, `focus-prev`, `close`, etc.). Solo cuando esa acción además está declarada en `sema.actions[]` adquiere semántica perceptiva tipada: el validador comprueba que si el nombre coincide, las referencias sean consistentes, pero no exige que toda acción de teclado tenga contrapartida Sema. Esto preserva que la capa headless pueda tener acciones puramente operativas sin obligarlas a declarar firma perceptiva.
Las acciones declaran **efecto estructural**, no precondiciones de aplicabilidad. El contrato no modela desde qué estado una acción es válida, ni distingue una acción que produce transición real de una que sería no-op. La validez contextual de una acción (si puede o no ejecutarse en un momento dado) es responsabilidad de la capa headless. Las implementaciones pueden añadir validaciones que comprueben coherencia declarativa (target resuelve, event existe en el vocabulario, prewrites referencian atributos declarados) pero no deben pretender modelar la máquina de estados completa del componente.
---
## 6. El protocolo Soma-Sema
## 6. El protocolo de coordinación
### 6.1. El puerto neutral
La capa headless no importa la implementación de Sema. Importa un **puerto neutral**:
La capa headless no importa la implementación de Sema. Importa un **puerto neutral**: una interfaz abstracta con tres operaciones.
```ts
```
interface SemaPort {
/** Invoca antes del commit y espera (blocking mode). */
before(action: ResolvedSemaAction, ctx: SemaContext): Promise<void>;
// Invoca antes del commit y espera (blocking mode)
before(action, context): Promise<void>
/** Invoca sin esperar (advisory mode). */
fire(action: ResolvedSemaAction, ctx: SemaContext): void;
// Invoca sin esperar (advisory mode)
fire(action, context): void
/** Inicia un sustain con lifecycle explícito. */
startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession;
// Inicia un sustain con lifecycle explícito
startSustain(sustain, context): SemaSession
}
interface SemaSession {
stop(): void;
readonly active: boolean;
stop(): void
active: boolean
}
```
El puerto puede ser:
- Un runtime real de Sema (producción)
- Un no-op port (desarrollo sin Sema cargado)
- Un no-op port (desarrollo sin Sema cargado, o contexto donde Sema se desactiva globalmente)
- Un test port (testing)
La capa headless solo conoce la interfaz. No conoce síntesis, mapas ni resolución perceptiva.
### 6.2. La API que consume el provider
Paralelo a `createAttrs(morfo)` y `registerContract(morfo)`, el provider obtiene:
```ts
const sema = createSemaBinding(dialogMorfo, semaPort);
### 6.2. El contexto de invocación
// Uso:
await sema.before('close-save', ctx); // blocking
sema.fire('notification', ctx); // advisory
const session = sema.start('loading', ctx);
session.stop();
```
`createSemaBinding` compila el bloque `morfo.sema` del contrato, valida referencias en dev, resuelve defaults, y devuelve helpers tipados.
En cada invocación, la capa headless pasa un contexto que identifica:
### 6.3. SemaContext
- El componente y la acción ejecutada
- El elemento DOM del target
- Opcionalmente, el elemento raíz y otras partes relevantes
- Un snapshot de atributos DOM relevantes en ese momento
- La causa originadora (teclado, puntero, programática, validación)
- Opcionalmente, un AbortSignal para cancelación externa
El contexto que el provider pasa a Sema en cada invocación:
```ts
type SemaContext = {
component: string; // 'Dialog'
action: string; // 'close-save'
targetEl: HTMLElement; // el part primario
rootEl?: HTMLElement; // el provider si tiene DOM
partEls?: Partial<Record<string, HTMLElement>>; // otras partes
snapshot: Record<string, string | null>; // atributos relevantes ya en DOM
cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation';
abortSignal?: AbortSignal;
};
```
La forma concreta de este contexto es decisión de la implementación; el contenido semántico está dictado por esta spec.
### 6.4. Secuencia canónica (blocking + commits)
### 6.3. Secuencia canónica (blocking + commits)
Para una acción con `mode: 'blocking'` y `commits` presente:
Para una acción con `mode: blocking` y `commits` presente:
1. El provider resuelve la acción abstracta (ej. `close-save`)
2. Aplica en su propio estado los prewrites declarados
3. Hace flush al DOM — los atributos del prewrite ya están reflejados
4. Llama `await sema.before(action, ctx)`
1. La capa headless resuelve la acción abstracta (ej. `close-save`)
2. Aplica los prewrites declarados al DOM
3. Hace flush — los atributos del prewrite ya están reflejados
4. Llama `await port.before(action, context)`
5. Sema resuelve la firma perceptiva (consultando `.csem` + `sema-map.json`)
6. Sema ejecuta los canales activos
7. La promesa resuelve cuando la coreografía termina
8. El provider aplica el commit de estado (cambia `data-state`)
8. La capa headless aplica el commit de estado
9. La capa visual reacciona al nuevo estado con sus transiciones
10. Si hay exit CSS o desmontaje diferido, sigue el pipeline del provider
10. Si hay exit CSS o desmontaje diferido, sigue el pipeline de la capa headless
Durante toda la ventana del paso 6, el estado del componente sigue siendo el anterior. La capa visual ve `data-state="open"` todavía. No hay competencia visual.
Durante toda la ventana del paso 6, el estado del componente sigue siendo el anterior. La capa visual ve el estado saliente todavía. No hay competencia visual.
### 6.5. Secuencia para independent (sin commits)
### 6.4. Secuencia para independent (sin commits)
Para una acción como `submit-failed`:
Para una acción sin commits (ej. `submit-failed` sobre un input ya invalid):
1. El provider detecta que debe disparar la acción
2. No hay prewrite (o los hay pero no afectan a `data-state`)
3. `await sema.before(action, ctx)` o `sema.fire(action, ctx)` según mode
1. La capa headless detecta que debe disparar la acción
2. Aplica prewrites si los hay (pero no afectan a `data-state`)
3. `await port.before(action, context)` o `port.fire(action, context)` según modo
4. No hay commit posterior
5. El flujo del provider continúa
5. El flujo de la capa headless continúa
### 6.6. Sustain como sesión
### 6.5. Sustain como sesión
`sustain` no es episódico. Se declara en una sección aparte:
`sustain` no es episódico. Se declara aparte del resto de acciones, con un predicado de activación:
```ts
type SemaSustainDecl = {
name: string;
target: PartRef;
activeWhen: { part: PartRef; attr: string; value: string };
event: 'sustain';
};
type MorfoSema = {
actions: readonly SemaAction[];
sustains?: readonly SemaSustainDecl[];
};
```
sustain "loading" on Spinner {
activeWhen: data-state = "loading" on Spinner
event: sustain
scope: part
}
```
Protocolo:
1. El provider cambia el estado que satisface `activeWhen`
2. Inmediatamente después, llama `session = sema.start('loading', ctx)`
1. La capa headless cambia el estado que satisface el predicado de activación
2. Inmediatamente después, llama `session = port.startSustain(sustain, context)`
3. La sesión corre mientras el predicado siga siendo verdadero
4. Cuando el provider sale del estado, llama `session.stop()`
4. Cuando la capa headless sale del estado, llama `session.stop()`
Es una excepción documentada al patrón episódico.
### 6.7. Garantías de Sema
### 6.6. Garantías del puerto
El puerto debe garantizar:
- `before()` nunca lanza si falta runtime; cae a no-op con promesa resuelta inmediatamente
- `before()` siempre resuelve (nunca cuelga indefinidamente)
- Si `abortSignal` aborta, la coreografía se cancela y la promesa resuelve
- Si `targetEl` desaparece del DOM, resuelve tempranamente
- Si `AbortSignal` aborta, la coreografía se cancela y la promesa resuelve
- Si el target desaparece del DOM, resuelve tempranamente
- Respeta preferencias de accesibilidad del usuario (ver §9)
- `before()` nunca bloquea más del cap global (ver §9.3)
@ -643,9 +471,9 @@ El puerto debe garantizar:
### 7.1. Propósito
El `.csem` es el archivo donde el integrador (quien usa el componente en su app) especifica **cómo se expresan perceptivamente** los eventos semánticos que morfo-sema declara.
El `.csem` es el archivo donde el integrador (quien usa el componente en su app) especifica **cómo se expresan perceptivamente** los eventos semánticos que el contrato cross-layer declara.
Morfo-sema dice: "Dialog tiene una acción `close-save` que dispara `commit-fulfill`".
El contrato dice: "Dialog tiene una acción `close-save` que dispara `commit-fulfill`".
`.csem` dice: "En esta app, `commit-fulfill` se expresa con estos canales, estos parámetros, estos targets".
@ -653,7 +481,7 @@ Si el integrador no provee `.csem`, Sema usa los valores por defecto de `sema-ma
### 7.2. Sintaxis CSS con custom properties
`.csem` usa sintaxis CSS válida procesada en build time (PostCSS plugin). Declara overrides por selector:
`.csem` usa sintaxis CSS válida procesada en build time. Declara overrides por selector:
```css
/* Override global: commit-fulfill en esta app es más brillante */
@ -681,24 +509,28 @@ La sintaxis aprovecha la cascada CSS natural: el `.csem` más específico (por c
### 7.3. Pipeline de build
El `.csem` lo procesa un plugin PostCSS que:
El `.csem` se procesa en build time (PostCSS plugin u equivalente) que:
1. Parsea las reglas
2. Valida que cada `--sema-*` referencie un evento/canal/parámetro existente
3. Compila a una estructura JSON optimizada (`sema-overrides.json`)
3. Compila a una estructura JSON optimizada
4. El runtime consume el JSON — no parsea CSS en cliente
### 7.4. Resolución de firmas en runtime
### 7.4. Política ante canal inactivo
Si `.csem` define parámetros para un canal que el integrador ha desactivado globalmente (vía `engine.configure`), el runtime **ignora los parámetros y emite warning en dev**. El `.csem` no activa canales por sí mismo; la activación es decisión explícita del integrador a nivel configuración. Esto preserva la política "sound disabled by default": asignar un pitch a sound en `.csem` no activa sound — hay que activarlo explícitamente en la configuración.
### 7.5. Resolución de firmas en runtime
Cuando Sema ejecuta una acción, la firma se resuelve:
1. Mira morfo-sema → obtiene `event` (ej. `commit-fulfill`)
2. Mira `sema-overrides.json` (compilado de `.csem`) → busca overrides aplicables al target y contexto
1. Mira el contrato cross-layer → obtiene `event` (ej. `commit-fulfill`)
2. Mira `.csem` (compilado) → busca overrides aplicables al target y contexto
3. Si no hay overrides, cae a `sema-map.json` (base)
4. Combina: base + overrides → firma efectiva
5. Ejecuta los canales activos con los parámetros resueltos
Tres capas de resolución: morfo (qué evento), `.csem` (cómo lo expresa la app), `sema-map` (cómo lo expresa por defecto).
Tres capas de resolución: contrato (qué evento), `.csem` (cómo lo expresa la app), `sema-map` (cómo lo expresa por defecto).
---
@ -714,13 +546,13 @@ Para evitar duplicar valores similares entre eventos relacionados, el mapa usa f
```json
{
"version": "0.3.0",
"version": "0.4.0",
"families": {
"commit": {
"base": {
"motion": { ... },
"sound": { ... },
"color": { ... },
"motion": { /* parámetros base */ },
"sound": { /* parámetros base */ },
"color": { /* parámetros base */ },
"presence": null
},
"activeChannels": ["motion", "sound", "color"]
@ -761,6 +593,10 @@ El integrador puede proveer samples WAV que reemplazan la síntesis para combina
Las entradas presentes en el pack son autoritativas; las ausentes usan síntesis algorítmica. Permite despliegue gradual del pack.
### 8.5. Fuera del mapa
`sema-map.json` contiene vocabulario perceptivo canónico. **No contiene scope** (operativo, vive en la acción), **no contiene información de cuándo disparar eventos** (eso es responsabilidad del contrato cross-layer), **no contiene reglas culturales específicas de apps** (eso es `.csem`). Es solo el vocabulario sensorial base.
---
## 9. Accesibilidad
@ -778,7 +614,7 @@ Las preferencias del usuario afectan a canales específicos, no globalmente:
### 9.2. Interacción con el modo blocking
En `mode: 'blocking'`, `before()` espera solo por los canales activos tras aplicar las preferencias:
En `mode: blocking`, `before()` espera solo por los canales activos tras aplicar las preferencias:
- Si tras la reducción no queda ningún canal activo → resuelve inmediatamente (0ms)
- Si quedan canales → espera el máximo entre sus duraciones
@ -796,13 +632,13 @@ Una firma que declare duración superior al cap es truncada en ejecución, no re
### 9.4. Scope de la acción
El campo `scope` (declarado en sema-map por familia, no en morfo) define el alcance perceptivo:
El scope define el alcance temporal y espacial de la coreografía:
- **`part`** (default): la coreografía afecta solo al target
- **`part`** (default): la coreografía afecta solo al target y se cancela si el target se desmonta
- **`component`**: la coreografía afecta al árbol del componente completo
- **`scene`**: la coreografía sobrevive al componente (útil para advisory+independent que deben continuar tras desmontaje)
`scope: 'scene'` es promoción explícita. Por defecto, los eventos se atan al ciclo de vida de su target y se cancelan si el target se desmonta.
El scope se declara por acción, no globalmente — el mismo evento (`alert-threat`) puede tener scope distinto en componentes distintos (toast: scene; input: part).
---
@ -812,14 +648,14 @@ El integrador puede personalizar Sema en cinco niveles, ordenados de más global
### 10.1. Nivel 1 — Activación y volumen global
```javascript
```
engine.configure({
sound: { enabled: true, gain: 0.8 },
motion: { enabled: true },
color: { enabled: true },
presence: { enabled: true },
reflectEvents: false, // modo debug
capBlockingMs: 200 // override del cap global
capBlockingMs: 200 // override del cap global (solo bajar)
});
```
@ -831,7 +667,7 @@ Como se describe en §8.4.
### 10.3. Nivel 3 — Override global del mapa
```javascript
```
engine.configure({
mapOverrides: {
'alert-threat.sound.gain': 0.15,
@ -846,7 +682,7 @@ Como se describe en §7.
### 10.5. Nivel 5 — API imperativa (escape hatch)
```javascript
```
engine.trigger(node, {
event: 'alert-threat',
overrides: { sound: { pitch: 500 } }
@ -861,23 +697,23 @@ Para casos que no caben declarativamente.
### 11.1. Responsabilidades
Una implementación Sema-compliant debe:
Una implementación del engine Sema debe:
1. Consumir morfo-sema compilado como input declarativo
1. Consumir las acciones declaradas en el contrato cross-layer
2. Implementar el puerto `SemaPort` con `before()`, `fire()`, `startSustain()`
3. Consumir `sema-map.json` y `sema-overrides.json` (compilado de `.csem`)
4. Resolver firmas según la jerarquía: morfo → `.csem` → `sema-map`
3. Consumir `sema-map.json` y el compilado de `.csem`
4. Resolver firmas según la jerarquía: acción → `.csem` → `sema-map`
5. Aplicar los canales activos respetando las garantías temporales y de accesibilidad
6. Emitir `CustomEvent('sema:event')` en el nodo target (contrato canónico de observabilidad)
7. Gestionar los cuatro regímenes (`replace | collapse | lock | queue`)
8. Cancelar coreografías por `abortSignal` o por desmontaje del target
8. Cancelar coreografías por `AbortSignal` o por desmontaje del target
9. Respetar preferencias de accesibilidad del usuario
### 11.2. Protocolo de eventos
**Canónico — CustomEvent:**
```javascript
```
node.dispatchEvent(new CustomEvent('sema:event', {
bubbles: true,
detail: {
@ -916,7 +752,7 @@ Esta sección describe técnicas de implementación, no arquitectura. La doctrin
**Recomendaciones de implementación (no normativas):**
En contextos donde el provider sí permite algún paralelismo con la capa visual (modo advisory, tails post-cap), o donde la capa visual gestiona transiciones CSS que podrían solaparse con el evento Sema, conviene aplicar técnicas aditivas para minimizar conflictos:
En contextos donde el provider permite algún paralelismo con la capa visual (modo advisory, tails post-cap), o donde la capa visual gestiona transiciones CSS que podrían solaparse con el evento Sema, conviene aplicar técnicas aditivas para minimizar conflictos:
- `motion` con WAAPI y `composite: 'add'` se suma al transform existente en lugar de reemplazarlo
- `color` con `box-shadow` adicional evita modificar el `border-color` que la capa visual controla
@ -934,8 +770,8 @@ El engine debe tolerar:
Esto implica que la implementación debe:
- Verificar `node.isConnected` antes de aplicar cambios
- Cancelar cleanly si el target desaparece
- Verificar que el nodo target sigue conectado antes de aplicar cambios
- Cancelar limpiamente si el target desaparece
- No retener referencias a elementos desmontados
---
@ -964,42 +800,70 @@ Sema usa animaciones como uno de sus cuatro canales. No sustituye a Framer Motio
Si dos configuraciones producen resultados perceptivamente indistinguibles para un usuario normal, una sobra. El sistema no expone sliders para ajustes que nadie percibe.
### 12.6. Morfo-sema no contiene implementación perceptiva
### 12.6. El contrato cross-layer no contiene implementación perceptiva
Morfo-sema declara **qué eventos existen y cuándo**, no **cómo se expresan**. Parámetros sensoriales (pitch, roughness, curvas, hues concretos, duraciones exactas, samples) viven en `sema-map.json` y `.csem`, nunca en morfo.
El contrato declara **qué eventos existen y cuándo**, no **cómo se expresan**. Parámetros sensoriales (pitch, roughness, curvas, hues concretos, duraciones exactas, samples) viven en `sema-map.json` y `.csem`, nunca en el contrato.
---
## 13. Compliance
## 13. Contrato mínimo de implementación
Una implementación Sema-compliant debe proveer al menos:
### 13.1. Mecanismo de declaración de acciones
Un mecanismo declarativo para que cada componente exponga:
- Sus acciones semánticas con los ocho campos conceptuales del §5.2
- Sus sustains (con predicado de activación)
- Su relación con las partes del componente (referenciables)
Forma concreta: libre. Puede ser TypeScript const, decoradores, JSON, YAML, registros runtime, hooks. El único requisito es que exponga la información que Sema necesita.
### 13.2. Implementación del puerto
Para que una implementación se considere Sema-compliant debe:
Una realización concreta de `SemaPort` con:
**Obligatorio:**
- `before(action, context): Promise<void>` — blocking
- `fire(action, context): void` — advisory
- `startSustain(sustain, context): SemaSession` — sustain con lifecycle
1. Soportar las 6 familias y los 5 intents (vocabulario de 22 eventos)
2. Soportar los 4 canales con los parámetros descritos en §4
3. Implementar el contrato `MorfoSema` del §5 con todas sus validaciones
4. Implementar el protocolo `SemaPort` del §6
5. Respetar la secuencialidad coordinada (§2.2): los eventos `blocking` bloquean el commit
6. Emitir `CustomEvent('sema:event')` como contrato canónico (§11.2)
7. Respetar preferencias de accesibilidad por canal (§9)
8. Respetar caps temporales globales (§9.3)
9. Tener el canal `sound` desactivado por defecto
10. Implementar los cuatro regímenes (§5.3)
Con las garantías del §6.6.
**Recomendado:**
### 13.3. Runtime que consume artefactos
1. Compilar `.csem` en build time via PostCSS
2. Proveer modo debug con reflejo DOM
3. Proveer los cinco niveles de personalización
4. Implementar soporte de sound packs
5. Documentar limitaciones conocidas explícitamente
Un runtime que:
**Opcional:**
- Lea las acciones declaradas
- Procese `.csem` en build time
- Cargue `sema-map.json`
- Resuelva firmas según la jerarquía del §7.5
- Aplique canales con las técnicas del §11.3
1. API imperativa avanzada
2. Herramientas de debug especializadas
3. Validador de sound packs contra la spec
### 13.4. Soporte obligatorio
- Las 6 familias y los 5 intents (vocabulario de 22 eventos)
- Los 4 canales con los parámetros del §4
- Los 4 regímenes de arbitraje (§5.4)
- La matriz de 4 combinaciones válidas de mode × commits (§5.6)
- La política de accesibilidad por canal (§9.1)
- Los caps temporales (§9.3)
- `sound` desactivado por defecto (§4.2)
- `CustomEvent('sema:event')` como protocolo de observabilidad (§11.2)
### 13.5. Soporte recomendado
- Compilación de `.csem` en build time
- Modo debug con reflejo DOM (`reflectEvents: true`)
- Los cinco niveles de personalización (§10)
- Soporte de sound packs (§8.4)
- Documentación explícita de limitaciones conocidas
### 13.6. Soporte opcional
- API imperativa avanzada
- Herramientas de debug especializadas
- Validador de sound packs contra la spec
---
@ -1034,42 +898,19 @@ Para que una implementación se considere Sema-compliant debe:
---
## Apéndice A — Relación con SemaUIX
## Apéndice A — Implementaciones conocidas
SemaUIX es la implementación de referencia de esta especificación. En SemaUIX:
**SemaUIX** es la implementación de referencia de esta especificación. Se construye sobre Svelte 5 y usa un artefacto llamado `morfo` como contrato cross-layer. El documento `semauix-sema-impl.md` describe cómo SemaUIX materializa cada parte de esta spec: cómo morfo extiende para declarar acciones, qué shape TypeScript tiene, cómo se valida, cómo los providers consumen el puerto.
- La capa headless se llama **Soma** y está implementada en Svelte 5
- La capa visual se llama **Eidos** (CSS con tokens, variants, recipes)
- **Sema** es la implementación concreta de esta spec
- El contrato cross-layer se llama **morfo** — un TypeScript const declarativo por componente, validado con sium
Los archivos viven en:
```
src/uix/
├── morfo/
│ ├── components/{name}.ts ← contrato por componente (incluye sema)
│ ├── schema.ts ← validador sium
│ └── types.ts ← Morfo, MorfoSema, SemaAction, SemaEventLabel
├── soma/components/{name}/ ← provider que consume morfo
├── eidos/components/{name}.css ← CSS que consume morfo (selectores generados)
└── sema/
├── engine.ts ← runtime Sema
├── sema-map.json ← mapa base
└── sema-overrides/ ← compilado de .csem
```
El pipeline de build usa Vite + PostCSS con plugins propios para procesar `.csem` y generar el JSON compilado.
Cualquier framework puede producir su propia implementación. La spec no exige ningún mecanismo concreto para el contrato cross-layer; solo que el contenido informativo esté disponible para las tres capas.
---
## Apéndice B — Historial de decisiones clave
Documentado para futura referencia:
1. **Sema es agnóstica de framework.** Opera sobre el DOM con Web APIs estándar.
2. **Morfo como contrato cross-layer único.** Parts, atributos, ARIA, keyboard, acciones semánticas — todo declarado una vez.
2. **Contrato cross-layer como fuente única.** Parts, atributos, ARIA, keyboard, acciones semánticas — todo declarado una vez. El mecanismo concreto es decisión de cada implementación.
3. **Reducción de 14 semánticas a 6 familias.** Tras auditoría neurocientífica, las originales se solapaban.
@ -1087,7 +928,7 @@ Documentado para futura referencia:
10. **CustomEvent canónico + atributos DOM opt-in en debug.**
11. **Morfo-sema declara acciones, no implementación perceptiva.** Shape minimal: name, target, event, mode, regime, prewrite, commits.
11. **Acciones semánticas como unidad de integración.** Ni eventos DOM ni cambios de estado — acciones declaradas.
12. **`.csem` es capa de override del integrador.** No obligatoria; cae a `sema-map.json` por defecto.
@ -1099,47 +940,34 @@ Documentado para futura referencia:
16. **Caps temporales de 200ms (normal) / 80ms (con reducción).**
17. **Commits declara efecto estructural, no precondiciones.** La validez contextual de una acción es responsabilidad del provider, no del contrato declarativo. Morfo-sema no modela máquina de estados completa; solo declara qué cambia cuando la acción se ejecuta.
17. **Commits declara efecto estructural, no precondiciones.** La validez contextual de una acción es responsabilidad de la capa headless, no del contrato declarativo.
18. **Keyboard.action puede ser puramente operativa.** Solo adquiere semántica Sema tipada si coincide con un nombre declarado en `sema.actions[]`. La capa headless conserva acciones de teclado sin contrapartida perceptiva (`focus-next`, `focus-prev`, etc.).
18. **Keyboard y acciones son espacios separables.** La capa headless puede tener acciones de teclado sin contrapartida Sema; solo adquieren semántica perceptiva las que coinciden con acciones declaradas.
19. **Scope por acción, no por familia.** El mismo evento puede tener scope distinto en componentes distintos.
---
## Apéndice C — Glosario
- **Acción semántica**: entidad declarada en morfo-sema que describe qué hace un componente con carga semántica (ej. `close-save`). Diferente de "evento Sema".
- **Acción semántica**: unidad declarada en el contrato cross-layer que describe qué hace un componente con carga semántica (ej. `close-save`). La unidad de integración entre la capa headless y Sema.
- **Canal perceptivo**: eje sensorial por el que Sema expresa información (motion, sound, color, presence).
- **Capa headless**: capa del framework que gestiona comportamiento, estado, accesibilidad. En SemaUIX se llama Soma.
- **Capa visual**: capa del framework que gestiona presentación en reposo. En SemaUIX se llama Eidos.
- **Commits**: campo de una acción que declara qué cambio de estado comitea tras la ventana Sema.
- **Capa headless**: capa del framework que gestiona comportamiento, estado, accesibilidad.
- **Capa visual**: capa del framework que gestiona presentación en reposo.
- **Commits**: campo de una acción que declara qué cambio de estado se aplicará tras la ventana Sema.
- **Contrato cross-layer**: artefacto declarativo por componente compartido por las tres capas. Mecanismo concreto decisión de implementación.
- **Evento Sema**: una de las 22 combinaciones del vocabulario canónico (`alert-threat`, `commit-fulfill`, etc.).
- **Firma efectiva**: conjunto de valores por canal resultante de resolver un evento contra `.csem` + `sema-map.json`.
- **Intent**: modulador afectivo de una familia valencial.
- **Morfo**: contrato cross-layer por componente. En SemaUIX es TypeScript con validación sium.
- **Morfo-sema**: extensión de morfo que declara las acciones semánticas del componente.
- **Prewrite**: atributos DOM que se reflejan antes de invocar Sema.
- **Régimen**: política de arbitraje para acciones repetidas (`replace | collapse | lock | queue`).
- **Sema-compliant**: implementación que cumple los requisitos mínimos de §13.
- **SemaEventLabel**: union type de los 22 eventos canónicos.
- **Sema-compliant**: implementación que cumple los requisitos mínimos del §13.
- **SemaEventLabel**: vocabulario de los 22 eventos canónicos.
- **SemaPort**: interfaz neutral que la capa headless consume para invocar Sema.
- **Secuencialidad coordinada**: principio operativo según el cual evento y estado ocurren en secuencia, nunca en paralelo.
---
## Próximos pasos
Para pasar de v0.3 a v1.0:
1. Implementar prototipo del engine en SemaUIX con 4 componentes reales (Button, Input, Dialog, Toast)
2. Validar la secuencialidad coordinada en componentes con reactividad Svelte real
3. Calibrar valores del `sema-map.json` mediante testing perceptivo
4. Especificar el plugin PostCSS para `.csem`
5. Implementar el validador sium para `MorfoSema`
6. Desplegar la herramienta de consultoría de samples
7. Iterar sobre casos límite que emerjan de uso real
- **Secuencialidad coordinada**: principio operativo según el cual evento y estado ocurren en secuencia en el camino blocking.
---
**Fin del documento Sema v0.3.**
**Fin de la especificación Sema v0.4.**
*Este working draft consolida cuatro rondas de revisión externa con Gemini, ChatGPT y Grok. El modelo queda listo para implementación prototipo. Feedback sobre casos concretos que la spec no cubre adecuadamente es bienvenido.*
*Agnóstica de framework. Las implementaciones concretas documentan por separado cómo materializan la spec (ver por ejemplo `semauix-sema-impl.md`).*

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