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 };
|
||||
@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
A11yMonitor,
|
||||
DEFAULT_SEMA_RUNTIME_CONFIG,
|
||||
type MediaQueryListLike,
|
||||
type SemaRuntimeConfig
|
||||
} from './a11y'
|
||||
import type { EffectiveSignature } from './resolver'
|
||||
|
||||
function createMatchMedia(state: Partial<Record<string, boolean>> = {}) {
|
||||
const listeners = new Map<string, Set<() => void>>()
|
||||
|
||||
return (query: string): MediaQueryListLike => ({
|
||||
get matches() {
|
||||
return state[query] ?? false
|
||||
},
|
||||
addEventListener(_type: 'change', listener: () => void) {
|
||||
if (!listeners.has(query)) listeners.set(query, new Set())
|
||||
listeners.get(query)!.add(listener)
|
||||
},
|
||||
removeEventListener(_type: 'change', listener: () => void) {
|
||||
listeners.get(query)?.delete(listener)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const baseSignature: EffectiveSignature = {
|
||||
event: 'alert-threat',
|
||||
activeChannels: ['motion', 'sound', 'color', 'presence'],
|
||||
motion: {
|
||||
duration: 180,
|
||||
easing: 'ease-out',
|
||||
scale: { from: 1, to: 1.06 }
|
||||
},
|
||||
sound: {
|
||||
pitch: 1100,
|
||||
centroid: 1800,
|
||||
roughness: 0.6,
|
||||
attack: 8,
|
||||
decay: 120,
|
||||
duration: 180,
|
||||
contour: 'descending',
|
||||
gain: 0.7
|
||||
},
|
||||
color: {
|
||||
hue: 10,
|
||||
saturation: 0.7,
|
||||
lightness: 0.45,
|
||||
duration: 180,
|
||||
intensity: 0.5
|
||||
},
|
||||
presence: {
|
||||
opacity: { from: 0.6, to: 1 },
|
||||
shadow: { blur: 18, y: 6, opacity: 0.4 },
|
||||
backdrop: 0.9,
|
||||
duration: 180,
|
||||
easing: 'ease-out'
|
||||
}
|
||||
}
|
||||
|
||||
const allChannelsEnabledConfig: SemaRuntimeConfig = {
|
||||
...DEFAULT_SEMA_RUNTIME_CONFIG,
|
||||
sound: { enabled: true, gain: 0.8 }
|
||||
}
|
||||
|
||||
describe('A11yMonitor', () => {
|
||||
it('is SSR-safe and reports no reduction by default', () => {
|
||||
const monitor = new A11yMonitor()
|
||||
|
||||
expect(monitor.snapshot()).toEqual({
|
||||
reducedMotion: false,
|
||||
reducedTransparency: false,
|
||||
highContrast: false,
|
||||
forcedColors: false
|
||||
})
|
||||
expect(monitor.hasActiveReduction()).toBe(false)
|
||||
expect(monitor.getBlockingCapMs(DEFAULT_SEMA_RUNTIME_CONFIG)).toBe(200)
|
||||
})
|
||||
|
||||
it('removes motion and discretizes color/presence under reduced motion', () => {
|
||||
const monitor = new A11yMonitor({
|
||||
matchMedia: createMatchMedia({
|
||||
'(prefers-reduced-motion: reduce)': true
|
||||
})
|
||||
})
|
||||
|
||||
const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig)
|
||||
|
||||
expect(reduced.activeChannels).toEqual(['sound', 'color', 'presence'])
|
||||
expect(reduced.motion).toBeUndefined()
|
||||
expect(reduced.color?.duration).toBe(50)
|
||||
expect(reduced.color?.intensity).toBe(0.2)
|
||||
expect(reduced.presence?.duration).toBe(50)
|
||||
expect(monitor.getBlockingCapMs(DEFAULT_SEMA_RUNTIME_CONFIG)).toBe(80)
|
||||
})
|
||||
|
||||
it('reduces transparency by clamping backdrop only', () => {
|
||||
const monitor = new A11yMonitor({
|
||||
matchMedia: createMatchMedia({
|
||||
'(prefers-reduced-transparency: reduce)': true
|
||||
})
|
||||
})
|
||||
|
||||
const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig)
|
||||
|
||||
expect(reduced.activeChannels).toEqual(baseSignature.activeChannels)
|
||||
expect(reduced.presence?.backdrop).toBe(0.7)
|
||||
expect(reduced.color).toEqual(baseSignature.color)
|
||||
})
|
||||
|
||||
it('boosts contrast and adds outline under high contrast', () => {
|
||||
const monitor = new A11yMonitor({
|
||||
matchMedia: createMatchMedia({
|
||||
'(prefers-contrast: more)': true
|
||||
})
|
||||
})
|
||||
|
||||
const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig)
|
||||
|
||||
expect(reduced.color?.saturation).toBeCloseTo(0.9)
|
||||
expect(reduced.color?.lightness).toBe(0.2)
|
||||
expect(reduced.color?.intensity).toBe(0.8)
|
||||
expect(reduced.presence?.outline).toEqual({ width: 2, style: 'solid' })
|
||||
})
|
||||
|
||||
it('disables ornamental color and degrades presence to contour in forced colors', () => {
|
||||
const monitor = new A11yMonitor({
|
||||
matchMedia: createMatchMedia({
|
||||
'(forced-colors: active)': true
|
||||
})
|
||||
})
|
||||
|
||||
const reduced = monitor.reduceSignature(baseSignature, allChannelsEnabledConfig)
|
||||
|
||||
expect(reduced.activeChannels).toEqual(['motion', 'sound', 'presence'])
|
||||
expect(reduced.color).toBeUndefined()
|
||||
expect(reduced.presence?.outline).toEqual({ width: 3, style: 'solid' })
|
||||
expect(reduced.presence?.backdrop).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters globally disabled channels after applying reductions', () => {
|
||||
const monitor = new A11yMonitor({
|
||||
matchMedia: createMatchMedia({
|
||||
'(prefers-contrast: more)': true
|
||||
})
|
||||
})
|
||||
const config: SemaRuntimeConfig = {
|
||||
...DEFAULT_SEMA_RUNTIME_CONFIG,
|
||||
sound: { enabled: true, gain: 0.8 },
|
||||
color: { enabled: false },
|
||||
presence: { enabled: false }
|
||||
}
|
||||
|
||||
const reduced = monitor.reduceSignature(baseSignature, config)
|
||||
|
||||
expect(reduced.activeChannels).toEqual(['motion', 'sound'])
|
||||
expect(reduced.color).toBeUndefined()
|
||||
expect(reduced.presence).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps sound disabled by default in the runtime config', () => {
|
||||
const monitor = new A11yMonitor({
|
||||
matchMedia: createMatchMedia()
|
||||
})
|
||||
|
||||
const reduced = monitor.reduceSignature(baseSignature)
|
||||
|
||||
expect(reduced.activeChannels).toEqual(['motion', 'color', 'presence'])
|
||||
expect(reduced.sound).toBeUndefined()
|
||||
})
|
||||
|
||||
it('notifies preference changes and unsubscribes cleanly', () => {
|
||||
const query = '(prefers-reduced-motion: reduce)'
|
||||
const listeners = new Set<() => void>()
|
||||
const monitor = new A11yMonitor({
|
||||
matchMedia: (requestedQuery) => ({
|
||||
get matches() {
|
||||
return false
|
||||
},
|
||||
addEventListener(_type: 'change', listener: () => void) {
|
||||
if (requestedQuery === query) listeners.add(listener)
|
||||
},
|
||||
removeEventListener(_type: 'change', listener: () => void) {
|
||||
if (requestedQuery === query) listeners.delete(listener)
|
||||
}
|
||||
})
|
||||
})
|
||||
const callback = vi.fn()
|
||||
|
||||
const dispose = monitor.onPreferenceChange(callback)
|
||||
for (const listener of listeners) listener()
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
|
||||
dispose()
|
||||
expect(listeners.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,278 @@
|
||||
import type { EffectiveSignature, PresenceSignature, SemaActiveChannel } from './resolver'
|
||||
|
||||
export interface SemaRuntimeConfig {
|
||||
sound: { enabled: boolean; gain: number }
|
||||
motion: { enabled: boolean }
|
||||
color: { enabled: boolean }
|
||||
presence: { enabled: boolean }
|
||||
reflectEvents: boolean
|
||||
capBlockingMs: number
|
||||
capBlockingReducedMs: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SEMA_RUNTIME_CONFIG: SemaRuntimeConfig = {
|
||||
sound: { enabled: false, gain: 0.8 },
|
||||
motion: { enabled: true },
|
||||
color: { enabled: true },
|
||||
presence: { enabled: true },
|
||||
reflectEvents: false,
|
||||
capBlockingMs: 200,
|
||||
capBlockingReducedMs: 80
|
||||
}
|
||||
|
||||
export interface MediaQueryListLike {
|
||||
readonly matches: boolean
|
||||
addEventListener?(type: 'change', listener: () => void): void
|
||||
removeEventListener?(type: 'change', listener: () => void): void
|
||||
addListener?(listener: () => void): void
|
||||
removeListener?(listener: () => void): void
|
||||
}
|
||||
|
||||
export interface A11ySnapshot {
|
||||
reducedMotion: boolean
|
||||
reducedTransparency: boolean
|
||||
highContrast: boolean
|
||||
forcedColors: boolean
|
||||
}
|
||||
|
||||
export interface A11yMonitorOptions {
|
||||
matchMedia?: (query: string) => MediaQueryListLike
|
||||
}
|
||||
|
||||
type A11yMediaQueries = Record<keyof A11ySnapshot, MediaQueryListLike>
|
||||
|
||||
const MEDIA_QUERIES = {
|
||||
reducedMotion: '(prefers-reduced-motion: reduce)',
|
||||
reducedTransparency: '(prefers-reduced-transparency: reduce)',
|
||||
highContrast: '(prefers-contrast: more)',
|
||||
forcedColors: '(forced-colors: active)'
|
||||
} as const
|
||||
|
||||
function createInactiveMediaQueryList(): MediaQueryListLike {
|
||||
return {
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneSignature(signature: EffectiveSignature): EffectiveSignature {
|
||||
return structuredClone(signature)
|
||||
}
|
||||
|
||||
function stripInactiveChannels(signature: EffectiveSignature): EffectiveSignature {
|
||||
const active = new Set(signature.activeChannels)
|
||||
return {
|
||||
...signature,
|
||||
motion: active.has('motion') ? signature.motion : undefined,
|
||||
sound: active.has('sound') ? signature.sound : undefined,
|
||||
color: active.has('color') ? signature.color : undefined,
|
||||
presence: active.has('presence') ? signature.presence : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function withActiveChannels(
|
||||
signature: EffectiveSignature,
|
||||
activeChannels: SemaActiveChannel[]
|
||||
): EffectiveSignature {
|
||||
return stripInactiveChannels({
|
||||
...signature,
|
||||
activeChannels
|
||||
})
|
||||
}
|
||||
|
||||
function mergePresenceOutline(
|
||||
presence: PresenceSignature | undefined,
|
||||
outline: { width: number; style: string }
|
||||
): PresenceSignature | undefined {
|
||||
if (!presence) return undefined
|
||||
return {
|
||||
...presence,
|
||||
outline: {
|
||||
width: Math.max(presence.outline?.width ?? 0, outline.width),
|
||||
style: presence.outline?.style ?? outline.style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class A11yMonitor {
|
||||
private readonly mq: A11yMediaQueries
|
||||
|
||||
constructor(opts: A11yMonitorOptions = {}) {
|
||||
const matchMedia =
|
||||
opts.matchMedia ??
|
||||
(typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia.bind(window)
|
||||
: undefined)
|
||||
|
||||
this.mq = {
|
||||
reducedMotion: matchMedia
|
||||
? matchMedia(MEDIA_QUERIES.reducedMotion)
|
||||
: createInactiveMediaQueryList(),
|
||||
reducedTransparency: matchMedia
|
||||
? matchMedia(MEDIA_QUERIES.reducedTransparency)
|
||||
: createInactiveMediaQueryList(),
|
||||
highContrast: matchMedia
|
||||
? matchMedia(MEDIA_QUERIES.highContrast)
|
||||
: createInactiveMediaQueryList(),
|
||||
forcedColors: matchMedia
|
||||
? matchMedia(MEDIA_QUERIES.forcedColors)
|
||||
: createInactiveMediaQueryList()
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(): A11ySnapshot {
|
||||
return {
|
||||
reducedMotion: this.mq.reducedMotion.matches,
|
||||
reducedTransparency: this.mq.reducedTransparency.matches,
|
||||
highContrast: this.mq.highContrast.matches,
|
||||
forcedColors: this.mq.forcedColors.matches
|
||||
}
|
||||
}
|
||||
|
||||
hasActiveReduction(): boolean {
|
||||
const state = this.snapshot()
|
||||
return (
|
||||
state.reducedMotion ||
|
||||
state.reducedTransparency ||
|
||||
state.highContrast ||
|
||||
state.forcedColors
|
||||
)
|
||||
}
|
||||
|
||||
getBlockingCapMs(config: SemaRuntimeConfig): number {
|
||||
return this.hasActiveReduction() ? config.capBlockingReducedMs : config.capBlockingMs
|
||||
}
|
||||
|
||||
reduceSignature(
|
||||
signature: EffectiveSignature,
|
||||
config: SemaRuntimeConfig = DEFAULT_SEMA_RUNTIME_CONFIG
|
||||
): EffectiveSignature {
|
||||
let result = cloneSignature(signature)
|
||||
const state = this.snapshot()
|
||||
|
||||
if (state.reducedMotion) {
|
||||
result = this.applyReducedMotion(result)
|
||||
}
|
||||
if (state.reducedTransparency) {
|
||||
result = this.applyReducedTransparency(result)
|
||||
}
|
||||
if (state.highContrast) {
|
||||
result = this.applyHighContrast(result)
|
||||
}
|
||||
if (state.forcedColors) {
|
||||
result = this.applyForcedColors(result)
|
||||
}
|
||||
|
||||
result = this.applyDisabledChannels(result, config)
|
||||
return stripInactiveChannels(result)
|
||||
}
|
||||
|
||||
onPreferenceChange(callback: () => void): () => void {
|
||||
const listeners: Array<{ mq: MediaQueryListLike; listener: () => void }> = []
|
||||
for (const mq of Object.values(this.mq)) {
|
||||
const listener = () => callback()
|
||||
if (mq.addEventListener) {
|
||||
mq.addEventListener('change', listener)
|
||||
} else {
|
||||
mq.addListener?.(listener)
|
||||
}
|
||||
listeners.push({ mq, listener })
|
||||
}
|
||||
return () => {
|
||||
for (const { mq, listener } of listeners) {
|
||||
if (mq.removeEventListener) {
|
||||
mq.removeEventListener('change', listener)
|
||||
} else {
|
||||
mq.removeListener?.(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyReducedMotion(signature: EffectiveSignature): EffectiveSignature {
|
||||
const activeChannels = signature.activeChannels.filter((channel) => channel !== 'motion')
|
||||
return withActiveChannels(
|
||||
{
|
||||
...signature,
|
||||
color: signature.color
|
||||
? {
|
||||
...signature.color,
|
||||
duration: Math.min(signature.color.duration, 50),
|
||||
intensity: Math.min(signature.color.intensity, 0.2)
|
||||
}
|
||||
: undefined,
|
||||
presence: signature.presence
|
||||
? {
|
||||
...signature.presence,
|
||||
duration: Math.min(signature.presence.duration, 50)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
activeChannels
|
||||
)
|
||||
}
|
||||
|
||||
private applyReducedTransparency(signature: EffectiveSignature): EffectiveSignature {
|
||||
if (!signature.presence) return signature
|
||||
return {
|
||||
...signature,
|
||||
presence: {
|
||||
...signature.presence,
|
||||
backdrop:
|
||||
typeof signature.presence.backdrop === 'number'
|
||||
? Math.min(signature.presence.backdrop, 0.7)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyHighContrast(signature: EffectiveSignature): EffectiveSignature {
|
||||
return {
|
||||
...signature,
|
||||
color: signature.color
|
||||
? {
|
||||
...signature.color,
|
||||
saturation: Math.min(signature.color.saturation + 0.2, 1),
|
||||
lightness: signature.color.lightness < 0.5 ? 0.2 : 0.8,
|
||||
intensity: Math.min(signature.color.intensity + 0.3, 1)
|
||||
}
|
||||
: undefined,
|
||||
presence: mergePresenceOutline(signature.presence, { width: 2, style: 'solid' })
|
||||
}
|
||||
}
|
||||
|
||||
private applyForcedColors(signature: EffectiveSignature): EffectiveSignature {
|
||||
const activeChannels = signature.activeChannels.filter((channel) => channel !== 'color')
|
||||
return withActiveChannels(
|
||||
{
|
||||
...signature,
|
||||
presence: signature.presence
|
||||
? {
|
||||
...signature.presence,
|
||||
backdrop: undefined,
|
||||
outline: { width: 3, style: 'solid' }
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
activeChannels
|
||||
)
|
||||
}
|
||||
|
||||
private applyDisabledChannels(
|
||||
signature: EffectiveSignature,
|
||||
config: SemaRuntimeConfig
|
||||
): EffectiveSignature {
|
||||
const activeChannels = signature.activeChannels.filter((channel) => {
|
||||
if (channel === 'motion' && !config.motion.enabled) return false
|
||||
if (channel === 'sound' && !config.sound.enabled) return false
|
||||
if (channel === 'color' && !config.color.enabled) return false
|
||||
if (channel === 'presence' && !config.presence.enabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return withActiveChannels(signature, activeChannels)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { dialogSema } from '../morfo/components/dialog'
|
||||
import { createSemaBinding } from './binding'
|
||||
import { createTestSemaPort, noopSemaPort } from './port'
|
||||
import type { SemaSpec } from './types'
|
||||
|
||||
function createElement(initial: Record<string, string> = {}): HTMLElement {
|
||||
const attrs = new Map<string, string>(Object.entries(initial))
|
||||
return {
|
||||
getAttribute(name: string) {
|
||||
return attrs.has(name) ? attrs.get(name)! : null
|
||||
},
|
||||
setAttribute(name: string, value: string) {
|
||||
attrs.set(name, value)
|
||||
},
|
||||
removeAttribute(name: string) {
|
||||
attrs.delete(name)
|
||||
}
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
describe('createSemaBinding', () => {
|
||||
it('applies defaults, prewrites and delegates before()', async () => {
|
||||
const handle = createTestSemaPort()
|
||||
const binding = createSemaBinding(dialogSema, handle.port)
|
||||
const contentEl = createElement({
|
||||
'data-state': 'open'
|
||||
})
|
||||
|
||||
await binding.before('close-save', {
|
||||
targetEl: contentEl,
|
||||
partEls: { content: contentEl },
|
||||
cause: 'pointer'
|
||||
})
|
||||
|
||||
expect(contentEl.getAttribute('data-last-action')).toBe('saved')
|
||||
expect(handle.calls).toHaveLength(1)
|
||||
expect(handle.calls[0]).toMatchObject({
|
||||
kind: 'before',
|
||||
action: {
|
||||
name: 'close-save',
|
||||
component: 'dialog',
|
||||
event: 'commit-fulfill',
|
||||
mode: 'blocking',
|
||||
regime: 'lock',
|
||||
scope: 'part',
|
||||
target: 'content',
|
||||
prewritten: [{ part: 'content', attr: 'data-last-action', value: 'saved' }]
|
||||
},
|
||||
ctx: {
|
||||
cause: 'pointer',
|
||||
snapshot: {
|
||||
'data-state': 'open',
|
||||
'data-last-action': 'saved',
|
||||
'data-starting-style': null,
|
||||
'data-ending-style': null
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps prewrites active even with noopSemaPort', async () => {
|
||||
const binding = createSemaBinding(dialogSema, noopSemaPort)
|
||||
const contentEl = createElement({
|
||||
'data-state': 'open'
|
||||
})
|
||||
|
||||
await binding.before('close-after-fail', {
|
||||
targetEl: contentEl
|
||||
})
|
||||
|
||||
expect(contentEl.getAttribute('data-last-action')).toBe('failed')
|
||||
})
|
||||
|
||||
it('delegates fire() without waiting and preserves explicit mode/scope', () => {
|
||||
const spec = {
|
||||
kebab: 'toast',
|
||||
actions: [
|
||||
{
|
||||
name: 'announce',
|
||||
target: { kind: 'partRef', target: 'root' },
|
||||
event: 'alert-affirm',
|
||||
mode: 'advisory',
|
||||
scope: 'scene'
|
||||
}
|
||||
]
|
||||
} as const satisfies SemaSpec
|
||||
const handle = createTestSemaPort()
|
||||
const binding = createSemaBinding(spec, handle.port)
|
||||
const rootEl = createElement()
|
||||
|
||||
binding.fire('announce', {
|
||||
targetEl: rootEl
|
||||
})
|
||||
|
||||
expect(handle.calls).toHaveLength(1)
|
||||
expect(handle.calls[0]).toMatchObject({
|
||||
kind: 'fire',
|
||||
action: {
|
||||
name: 'announce',
|
||||
mode: 'advisory',
|
||||
scope: 'scene',
|
||||
regime: 'replace',
|
||||
target: 'root'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('starts sustains with default scope and built context', () => {
|
||||
const spec = {
|
||||
kebab: 'spinner',
|
||||
actions: [],
|
||||
sustains: [
|
||||
{
|
||||
name: 'loading',
|
||||
target: { kind: 'partRef', target: 'glyph' },
|
||||
activeWhen: {
|
||||
part: { kind: 'partRef', target: 'glyph' },
|
||||
attr: 'data-state',
|
||||
value: 'loading'
|
||||
},
|
||||
event: 'sustain'
|
||||
}
|
||||
]
|
||||
} as const satisfies SemaSpec
|
||||
const handle = createTestSemaPort()
|
||||
const binding = createSemaBinding(spec, handle.port)
|
||||
const glyphEl = createElement({
|
||||
'data-state': 'loading'
|
||||
})
|
||||
|
||||
const session = binding.start('loading', {
|
||||
targetEl: glyphEl,
|
||||
cause: 'programmatic'
|
||||
})
|
||||
|
||||
expect(session.active).toBe(true)
|
||||
expect(handle.sessions).toHaveLength(1)
|
||||
expect(handle.sessions[0]).toMatchObject({
|
||||
sustain: {
|
||||
name: 'loading',
|
||||
component: 'spinner',
|
||||
target: 'glyph',
|
||||
scope: 'part'
|
||||
},
|
||||
ctx: {
|
||||
cause: 'programmatic',
|
||||
snapshot: {
|
||||
'data-state': 'loading',
|
||||
'data-last-action': null,
|
||||
'data-starting-style': null,
|
||||
'data-ending-style': null
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('returns declared actions for introspection', () => {
|
||||
const binding = createSemaBinding(dialogSema, noopSemaPort)
|
||||
expect(binding.action('open')).toBe(dialogSema.actions[0])
|
||||
})
|
||||
|
||||
it('throws on unknown action names in dev', async () => {
|
||||
const binding = createSemaBinding(dialogSema, noopSemaPort)
|
||||
const contentEl = createElement()
|
||||
|
||||
await expect(
|
||||
binding.before('missing' as never, {
|
||||
targetEl: contentEl
|
||||
})
|
||||
).rejects.toThrow(/action "missing" not declared/)
|
||||
})
|
||||
|
||||
it('throws on unknown sustain names in dev', () => {
|
||||
const spec = {
|
||||
kebab: 'spinner',
|
||||
actions: [],
|
||||
sustains: [
|
||||
{
|
||||
name: 'loading',
|
||||
target: { kind: 'partRef', target: 'glyph' },
|
||||
activeWhen: {
|
||||
part: { kind: 'partRef', target: 'glyph' },
|
||||
attr: 'data-state',
|
||||
value: 'loading'
|
||||
},
|
||||
event: 'sustain'
|
||||
}
|
||||
]
|
||||
} as const satisfies SemaSpec
|
||||
const binding = createSemaBinding(spec, noopSemaPort)
|
||||
|
||||
expect(() =>
|
||||
binding.start('missing' as never, {
|
||||
targetEl: createElement()
|
||||
})
|
||||
).toThrow(/sustain "missing" not declared/)
|
||||
})
|
||||
|
||||
it('skips non-target prewrites when the runtime context is incomplete', async () => {
|
||||
const spec = {
|
||||
kebab: 'widget',
|
||||
actions: [
|
||||
{
|
||||
name: 'promote',
|
||||
target: { kind: 'partRef', target: 'content' },
|
||||
event: 'commit-affirm',
|
||||
prewrite: [
|
||||
{
|
||||
part: { kind: 'partRef', target: 'badge' },
|
||||
attr: 'data-tone',
|
||||
value: 'loud'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
} as const satisfies SemaSpec
|
||||
const handle = createTestSemaPort()
|
||||
const binding = createSemaBinding(spec, handle.port)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const contentEl = createElement({
|
||||
'data-state': 'idle'
|
||||
})
|
||||
|
||||
await binding.before('promote', {
|
||||
targetEl: contentEl
|
||||
})
|
||||
|
||||
expect(contentEl.getAttribute('data-tone')).toBe(null)
|
||||
expect(handle.calls[0].action.prewritten).toEqual([])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(warn.mock.calls[0][0]).toMatch(/prewrite target "badge" missing/)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,203 @@
|
||||
import { DEV } from 'esm-env'
|
||||
|
||||
import type {
|
||||
ResolvedSemaAction,
|
||||
ResolvedSemaSustain,
|
||||
SemaContext,
|
||||
SemaPort,
|
||||
SemaSession
|
||||
} from './port'
|
||||
import type { SemaAction, SemaSpec, SemaSustainDecl } from './types'
|
||||
|
||||
const SNAPSHOT_ATTRS = [
|
||||
'data-state',
|
||||
'data-last-action',
|
||||
'data-starting-style',
|
||||
'data-ending-style'
|
||||
] as const
|
||||
|
||||
const INACTIVE_SEMA_SESSION: SemaSession = {
|
||||
stop() {},
|
||||
get active() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface PartialSemaContext {
|
||||
targetEl: HTMLElement
|
||||
rootEl?: HTMLElement
|
||||
partEls?: Partial<Record<string, HTMLElement>>
|
||||
cause?: SemaContext['cause']
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
export type ActionName<S extends SemaSpec> = S['actions'][number]['name']
|
||||
export type SustainName<S extends SemaSpec> = S['sustains'] extends readonly SemaSustainDecl[]
|
||||
? S['sustains'][number]['name']
|
||||
: never
|
||||
|
||||
export interface SemaBinding<S extends SemaSpec> {
|
||||
before(name: ActionName<S>, ctx: PartialSemaContext): Promise<void>
|
||||
fire(name: ActionName<S>, ctx: PartialSemaContext): void
|
||||
start(name: SustainName<S>, ctx: PartialSemaContext): SemaSession
|
||||
action(name: ActionName<S>): SemaAction
|
||||
}
|
||||
|
||||
function createUnknownAction(name: string): SemaAction {
|
||||
return {
|
||||
name,
|
||||
target: { kind: 'partRef', target: '' },
|
||||
event: 'emerge'
|
||||
}
|
||||
}
|
||||
|
||||
function createUnknownSustain(name: string): SemaSustainDecl {
|
||||
return {
|
||||
name,
|
||||
target: { kind: 'partRef', target: '' },
|
||||
activeWhen: {
|
||||
part: { kind: 'partRef', target: '' },
|
||||
attr: 'data-state',
|
||||
value: ''
|
||||
},
|
||||
event: 'sustain'
|
||||
}
|
||||
}
|
||||
|
||||
export function createSemaBinding<S extends SemaSpec>(spec: S, port: SemaPort): SemaBinding<S> {
|
||||
const actionsByName = new Map<string, SemaAction>()
|
||||
for (const action of spec.actions) {
|
||||
actionsByName.set(action.name, action)
|
||||
}
|
||||
|
||||
const sustainsByName = new Map<string, SemaSustainDecl>()
|
||||
for (const sustain of spec.sustains ?? []) {
|
||||
sustainsByName.set(sustain.name, sustain)
|
||||
}
|
||||
|
||||
function failUnknown(kind: 'action' | 'sustain', name: string, declared: string[]): void {
|
||||
throw new Error(
|
||||
`[sema] ${kind} "${name}" not declared in "${spec.kebab}". Declared ${kind}s: ${declared.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
function warn(message: string): void {
|
||||
if (DEV) console.warn(message)
|
||||
}
|
||||
|
||||
function resolveAction(name: string): SemaAction | null {
|
||||
const action = actionsByName.get(name)
|
||||
if (action) return action
|
||||
if (DEV) failUnknown('action', name, [...actionsByName.keys()])
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveSustain(name: string): SemaSustainDecl | null {
|
||||
const sustain = sustainsByName.get(name)
|
||||
if (sustain) return sustain
|
||||
if (DEV) failUnknown('sustain', name, [...sustainsByName.keys()])
|
||||
return null
|
||||
}
|
||||
|
||||
function resolvePartElement(
|
||||
partial: PartialSemaContext,
|
||||
targetPart: string,
|
||||
primaryPart: string
|
||||
): HTMLElement | undefined {
|
||||
if (targetPart === primaryPart) return partial.targetEl
|
||||
return partial.partEls?.[targetPart]
|
||||
}
|
||||
|
||||
function applyPrewrites(
|
||||
action: SemaAction,
|
||||
partial: PartialSemaContext
|
||||
): ResolvedSemaAction['prewritten'] {
|
||||
const applied: ResolvedSemaAction['prewritten'] = []
|
||||
for (const pw of action.prewrite ?? []) {
|
||||
const part = pw.part.target
|
||||
const el = resolvePartElement(partial, part, action.target.target)
|
||||
if (!el) {
|
||||
warn(
|
||||
`[sema] prewrite target "${part}" missing in runtime context for "${spec.kebab}.${action.name}".`
|
||||
)
|
||||
continue
|
||||
}
|
||||
el.setAttribute(pw.attr, pw.value)
|
||||
applied.push({
|
||||
part,
|
||||
attr: pw.attr,
|
||||
value: pw.value
|
||||
})
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
function buildContext(partial: PartialSemaContext): SemaContext {
|
||||
const snapshot: Record<string, string | null> = {}
|
||||
for (const attr of SNAPSHOT_ATTRS) {
|
||||
snapshot[attr] = partial.targetEl.getAttribute(attr)
|
||||
}
|
||||
return {
|
||||
targetEl: partial.targetEl,
|
||||
rootEl: partial.rootEl,
|
||||
partEls: partial.partEls,
|
||||
snapshot,
|
||||
cause: partial.cause,
|
||||
abortSignal: partial.abortSignal
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActionToRuntime(
|
||||
action: SemaAction,
|
||||
prewritten: ResolvedSemaAction['prewritten']
|
||||
): ResolvedSemaAction {
|
||||
return {
|
||||
name: action.name,
|
||||
component: spec.kebab,
|
||||
event: action.event,
|
||||
mode: action.mode ?? 'blocking',
|
||||
regime: action.regime ?? 'replace',
|
||||
scope: action.scope ?? 'part',
|
||||
target: action.target.target,
|
||||
prewritten
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSustainToRuntime(sustain: SemaSustainDecl): ResolvedSemaSustain {
|
||||
return {
|
||||
name: sustain.name,
|
||||
component: spec.kebab,
|
||||
target: sustain.target.target,
|
||||
scope: sustain.scope ?? 'part'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action(name) {
|
||||
return resolveAction(name as string) ?? createUnknownAction(name as string)
|
||||
},
|
||||
async before(name, partial) {
|
||||
const action = resolveAction(name as string)
|
||||
if (!action) return
|
||||
const prewritten = applyPrewrites(action, partial)
|
||||
const resolved = resolveActionToRuntime(action, prewritten)
|
||||
const ctx = buildContext(partial)
|
||||
await port.before(resolved, ctx)
|
||||
},
|
||||
fire(name, partial) {
|
||||
const action = resolveAction(name as string)
|
||||
if (!action) return
|
||||
const prewritten = applyPrewrites(action, partial)
|
||||
const resolved = resolveActionToRuntime(action, prewritten)
|
||||
const ctx = buildContext(partial)
|
||||
port.fire(resolved, ctx)
|
||||
},
|
||||
start(name, partial) {
|
||||
const sustain = resolveSustain(name as string)
|
||||
if (!sustain) return INACTIVE_SEMA_SESSION
|
||||
const resolved = resolveSustainToRuntime(sustain)
|
||||
const ctx = buildContext(partial)
|
||||
return port.startSustain(resolved, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,218 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ColorChannel } from './color'
|
||||
import type { ColorSignature } from '../resolver'
|
||||
|
||||
interface MockAnimation extends Partial<Animation> {
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
finished: Promise<void>
|
||||
resolveFinished(): void
|
||||
rejectFinished(reason?: unknown): void
|
||||
}
|
||||
|
||||
function createMockAnimation(): MockAnimation {
|
||||
let resolveFinished = () => {}
|
||||
let rejectFinished = (_reason?: unknown) => {}
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
resolveFinished = resolve
|
||||
rejectFinished = reject
|
||||
})
|
||||
|
||||
return {
|
||||
cancel: vi.fn(),
|
||||
finished,
|
||||
resolveFinished,
|
||||
rejectFinished
|
||||
}
|
||||
}
|
||||
|
||||
function createStyle(seed: Record<string, string> = {}): CSSStyleDeclaration {
|
||||
return seed as unknown as CSSStyleDeclaration
|
||||
}
|
||||
|
||||
function createEnvironment(opts: {
|
||||
display?: string
|
||||
datasetTechnique?: 'overlay' | 'outline'
|
||||
withParent?: boolean
|
||||
} = {}) {
|
||||
const animations: MockAnimation[] = []
|
||||
const targetCalls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = []
|
||||
const overlayCalls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = []
|
||||
const removed: HTMLElement[] = []
|
||||
const queryNodes: Array<{ remove: ReturnType<typeof vi.fn> }> = [{ remove: vi.fn() }, { remove: vi.fn() }]
|
||||
|
||||
const defaultView = {
|
||||
getComputedStyle(node: HTMLElement) {
|
||||
if (node === parent) {
|
||||
return { position: 'static' } as CSSStyleDeclaration
|
||||
}
|
||||
return {
|
||||
display: opts.display ?? 'block',
|
||||
boxShadow: '0 0 0 1px rgb(0 0 0 / 0.3)'
|
||||
} as CSSStyleDeclaration
|
||||
}
|
||||
}
|
||||
|
||||
const overlayFactory = () => {
|
||||
const animation = createMockAnimation()
|
||||
animations.push(animation)
|
||||
const style = createStyle()
|
||||
const overlay = {
|
||||
style,
|
||||
setAttribute: vi.fn(),
|
||||
remove: vi.fn(() => {
|
||||
removed.push(overlay as unknown as HTMLElement)
|
||||
}),
|
||||
animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) {
|
||||
overlayCalls.push({ keyframes, options })
|
||||
return animation as Animation
|
||||
}
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
|
||||
const doc = {
|
||||
defaultView,
|
||||
createElement: vi.fn(() => overlayFactory()),
|
||||
querySelectorAll: vi.fn(() => queryNodes)
|
||||
}
|
||||
|
||||
const parent = {
|
||||
style: createStyle(),
|
||||
appendChild: vi.fn(),
|
||||
ownerDocument: doc
|
||||
} as unknown as HTMLElement
|
||||
|
||||
const target = {
|
||||
isConnected: true,
|
||||
ownerDocument: doc,
|
||||
parentElement: opts.withParent === false ? null : parent,
|
||||
dataset: opts.datasetTechnique ? { semaColorTechnique: opts.datasetTechnique } : {},
|
||||
style: createStyle(),
|
||||
animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) {
|
||||
targetCalls.push({ keyframes, options })
|
||||
const animation = createMockAnimation()
|
||||
animations.push(animation)
|
||||
return animation as Animation
|
||||
}
|
||||
} as unknown as HTMLElement
|
||||
|
||||
return {
|
||||
channel: new ColorChannel(),
|
||||
target,
|
||||
parent,
|
||||
doc,
|
||||
targetCalls,
|
||||
overlayCalls,
|
||||
animations,
|
||||
removed,
|
||||
queryNodes
|
||||
}
|
||||
}
|
||||
|
||||
const signature: ColorSignature = {
|
||||
hue: 30,
|
||||
saturation: 0.7,
|
||||
lightness: 0.45,
|
||||
duration: 180,
|
||||
intensity: 0.5
|
||||
}
|
||||
|
||||
describe('ColorChannel', () => {
|
||||
it('uses box-shadow as the default additive technique', async () => {
|
||||
const env = createEnvironment()
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(env.target, signature, controller.signal)
|
||||
|
||||
expect(env.targetCalls).toHaveLength(1)
|
||||
expect(env.targetCalls[0].keyframes[0]).toEqual({
|
||||
boxShadow: '0 0 0 1px rgb(0 0 0 / 0.3), 0 0 0 0px hsl(30, 70%, 45%)'
|
||||
})
|
||||
expect(env.targetCalls[0].options).toEqual({
|
||||
duration: 180,
|
||||
easing: 'ease-out',
|
||||
fill: 'none'
|
||||
})
|
||||
|
||||
env.animations[0].resolveFinished()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('switches to overlay for inline targets', async () => {
|
||||
const env = createEnvironment({ display: 'inline' })
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(env.target, signature, controller.signal)
|
||||
|
||||
expect(env.doc.createElement).toHaveBeenCalledWith('span')
|
||||
expect(env.parent.appendChild).toHaveBeenCalledTimes(1)
|
||||
expect(env.overlayCalls).toHaveLength(1)
|
||||
expect(env.overlayCalls[0].options).toEqual({
|
||||
duration: 180,
|
||||
easing: 'ease-out',
|
||||
fill: 'none'
|
||||
})
|
||||
|
||||
env.animations[0].resolveFinished()
|
||||
await pending
|
||||
expect(env.removed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses outline when requested explicitly', async () => {
|
||||
const env = createEnvironment({ datasetTechnique: 'outline' })
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(env.target, signature, controller.signal)
|
||||
|
||||
expect(env.targetCalls).toHaveLength(1)
|
||||
expect(env.targetCalls[0].keyframes[1]).toEqual({
|
||||
outline: '2px solid hsl(30, 70%, 45%)',
|
||||
offset: 0.3
|
||||
})
|
||||
|
||||
env.animations[0].resolveFinished()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('cancels and removes overlay on abort', async () => {
|
||||
const env = createEnvironment({ display: 'inline' })
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(env.target, signature, controller.signal)
|
||||
controller.abort()
|
||||
env.animations[0].rejectFinished(new Error('cancelled'))
|
||||
|
||||
await pending
|
||||
expect(env.animations[0].cancel).toHaveBeenCalledTimes(1)
|
||||
expect(env.removed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to outline when overlay has no parent', async () => {
|
||||
const env = createEnvironment({ display: 'inline', withParent: false })
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(env.target, signature, controller.signal)
|
||||
|
||||
expect(env.targetCalls).toHaveLength(1)
|
||||
expect(env.overlayCalls).toHaveLength(0)
|
||||
|
||||
env.animations[0].resolveFinished()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('destroy() removes temporary overlays from the document', () => {
|
||||
const env = createEnvironment()
|
||||
const previousDocument = globalThis.document
|
||||
|
||||
Object.assign(globalThis, { document: env.doc })
|
||||
try {
|
||||
env.channel.destroy()
|
||||
} finally {
|
||||
Object.assign(globalThis, { document: previousDocument })
|
||||
}
|
||||
|
||||
expect(env.queryNodes[0].remove).toHaveBeenCalledTimes(1)
|
||||
expect(env.queryNodes[1].remove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,261 @@
|
||||
import type { ColorSignature } from '../resolver'
|
||||
|
||||
type ColorTechnique = 'box-shadow' | 'overlay' | 'outline'
|
||||
|
||||
type ColorTarget = HTMLElement & {
|
||||
animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation
|
||||
parentElement?: HTMLElement | null
|
||||
dataset: DOMStringMap
|
||||
style: CSSStyleDeclaration
|
||||
}
|
||||
|
||||
type DocLike = Pick<Document, 'createElement' | 'querySelectorAll' | 'defaultView'>
|
||||
type OverlayElement = HTMLElement & {
|
||||
animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation
|
||||
remove(): void
|
||||
style: CSSStyleDeclaration
|
||||
}
|
||||
|
||||
function hasAnimate(target: unknown): target is { animate: NonNullable<ColorTarget['animate']> } {
|
||||
return typeof (target as { animate?: unknown })?.animate === 'function'
|
||||
}
|
||||
|
||||
function canUseDOM(target: HTMLElement): target is HTMLElement & { ownerDocument: Document } {
|
||||
return !!target.ownerDocument
|
||||
}
|
||||
|
||||
function removeOverlay(
|
||||
overlay: OverlayElement,
|
||||
target: HTMLElement,
|
||||
onRemove: () => void,
|
||||
state: { removed: boolean }
|
||||
): void {
|
||||
if (state.removed) return
|
||||
state.removed = true
|
||||
overlay.remove()
|
||||
onRemove()
|
||||
}
|
||||
|
||||
export class ColorChannel {
|
||||
private readonly activeOverlays = new WeakMap<HTMLElement, OverlayElement[]>()
|
||||
|
||||
async apply(
|
||||
target: HTMLElement,
|
||||
signature: ColorSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!target.isConnected) return
|
||||
|
||||
switch (this.selectTechnique(target)) {
|
||||
case 'box-shadow':
|
||||
await this.applyBoxShadow(target, signature, abortSignal)
|
||||
return
|
||||
case 'overlay':
|
||||
await this.applyOverlay(target, signature, abortSignal)
|
||||
return
|
||||
case 'outline':
|
||||
await this.applyOutline(target, signature, abortSignal)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (typeof document === 'undefined') return
|
||||
for (const node of document.querySelectorAll('[data-sema-temp]')) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private selectTechnique(target: HTMLElement): ColorTechnique {
|
||||
const explicit = (target as ColorTarget).dataset?.semaColorTechnique
|
||||
if (explicit === 'outline') return 'outline'
|
||||
if (explicit === 'overlay') return 'overlay'
|
||||
|
||||
if (!canUseDOM(target)) return 'outline'
|
||||
const computed = target.ownerDocument.defaultView?.getComputedStyle(target)
|
||||
if (computed?.display === 'inline') return 'overlay'
|
||||
|
||||
return hasAnimate(target) ? 'box-shadow' : 'outline'
|
||||
}
|
||||
|
||||
private async applyBoxShadow(
|
||||
target: HTMLElement,
|
||||
signature: ColorSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!hasAnimate(target) || !canUseDOM(target)) return
|
||||
|
||||
const computed = target.ownerDocument.defaultView?.getComputedStyle(target)
|
||||
const previousShadow = computed?.boxShadow && computed.boxShadow !== 'none' ? computed.boxShadow : ''
|
||||
const color = this.toColor(signature)
|
||||
const peakWidth = Math.max(1, Math.round(signature.intensity * 8))
|
||||
const animation = target.animate(
|
||||
[
|
||||
{ boxShadow: this.composeShadow(previousShadow, 0, color) },
|
||||
{ boxShadow: this.composeShadow(previousShadow, peakWidth, color), offset: 0.3 },
|
||||
{ boxShadow: this.composeShadow(previousShadow, 0, color) }
|
||||
],
|
||||
{
|
||||
duration: signature.duration,
|
||||
easing: 'ease-out',
|
||||
fill: 'none'
|
||||
}
|
||||
)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
animation.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => animation.cancel()
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
await animation.finished
|
||||
} catch {
|
||||
// degradación silenciosa
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
private async applyOverlay(
|
||||
target: HTMLElement,
|
||||
signature: ColorSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!canUseDOM(target)) return
|
||||
const doc = target.ownerDocument as DocLike
|
||||
const parent = (target as ColorTarget).parentElement
|
||||
if (!parent) {
|
||||
await this.applyOutline(target, signature, abortSignal)
|
||||
return
|
||||
}
|
||||
|
||||
const overlay = doc.createElement('span') as OverlayElement
|
||||
overlay.setAttribute('data-sema-temp', '')
|
||||
overlay.style.position = 'absolute'
|
||||
overlay.style.inset = '0'
|
||||
overlay.style.pointerEvents = 'none'
|
||||
overlay.style.borderRadius = 'inherit'
|
||||
overlay.style.boxShadow = `0 0 0 0 ${this.toColor(signature)}`
|
||||
overlay.style.opacity = '0'
|
||||
|
||||
const parentStyle = doc.defaultView?.getComputedStyle(parent)
|
||||
if (parentStyle?.position === 'static') {
|
||||
parent.style.position = 'relative'
|
||||
}
|
||||
|
||||
parent.appendChild(overlay)
|
||||
this.trackOverlay(target, overlay)
|
||||
const overlayState = { removed: false }
|
||||
|
||||
if (!hasAnimate(overlay)) {
|
||||
removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState)
|
||||
return
|
||||
}
|
||||
|
||||
const peakWidth = Math.max(1, Math.round(signature.intensity * 8))
|
||||
const animation = overlay.animate(
|
||||
[
|
||||
{ boxShadow: `0 0 0 0 ${this.toColor(signature)}`, opacity: 0 },
|
||||
{
|
||||
boxShadow: `0 0 0 ${peakWidth}px ${this.toColor(signature)}`,
|
||||
opacity: 1,
|
||||
offset: 0.3
|
||||
},
|
||||
{ boxShadow: `0 0 0 0 ${this.toColor(signature)}`, opacity: 0 }
|
||||
],
|
||||
{
|
||||
duration: signature.duration,
|
||||
easing: 'ease-out',
|
||||
fill: 'none'
|
||||
}
|
||||
)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
animation.cancel()
|
||||
removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState)
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
animation.cancel()
|
||||
removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState)
|
||||
}
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
await animation.finished
|
||||
} catch {
|
||||
// cancelación silenciosa
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
removeOverlay(overlay, target, () => this.untrackOverlay(target, overlay), overlayState)
|
||||
}
|
||||
}
|
||||
|
||||
private async applyOutline(
|
||||
target: HTMLElement,
|
||||
signature: ColorSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!hasAnimate(target)) return
|
||||
|
||||
const color = this.toColor(signature)
|
||||
const peakWidth = Math.max(2, Math.round(signature.intensity * 4))
|
||||
const animation = target.animate(
|
||||
[
|
||||
{ outline: `0px solid ${color}` },
|
||||
{ outline: `${peakWidth}px solid ${color}`, offset: 0.3 },
|
||||
{ outline: `0px solid ${color}` }
|
||||
],
|
||||
{
|
||||
duration: signature.duration,
|
||||
easing: 'ease-out',
|
||||
fill: 'none'
|
||||
}
|
||||
)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
animation.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => animation.cancel()
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
await animation.finished
|
||||
} catch {
|
||||
// degradación silenciosa
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
private composeShadow(previous: string, width: number, color: string): string {
|
||||
const pulse = `0 0 0 ${width}px ${color}`
|
||||
return previous ? `${previous}, ${pulse}` : pulse
|
||||
}
|
||||
|
||||
private toColor(signature: ColorSignature): string {
|
||||
return `hsl(${signature.hue}, ${signature.saturation * 100}%, ${signature.lightness * 100}%)`
|
||||
}
|
||||
|
||||
private trackOverlay(target: HTMLElement, overlay: OverlayElement): void {
|
||||
const existing = this.activeOverlays.get(target) ?? []
|
||||
existing.push(overlay)
|
||||
this.activeOverlays.set(target, existing)
|
||||
}
|
||||
|
||||
private untrackOverlay(target: HTMLElement, overlay: OverlayElement): void {
|
||||
const existing = this.activeOverlays.get(target)
|
||||
if (!existing) return
|
||||
const index = existing.indexOf(overlay)
|
||||
if (index >= 0) existing.splice(index, 1)
|
||||
if (existing.length === 0) {
|
||||
this.activeOverlays.delete(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MotionChannel } from './motion'
|
||||
import type { MotionSignature } from '../resolver'
|
||||
|
||||
interface MockAnimation extends Partial<Animation> {
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
finished: Promise<void>
|
||||
resolveFinished(): void
|
||||
rejectFinished(reason?: unknown): void
|
||||
}
|
||||
|
||||
function createMockAnimation(): MockAnimation {
|
||||
let resolveFinished = () => {}
|
||||
let rejectFinished = (_reason?: unknown) => {}
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
resolveFinished = resolve
|
||||
rejectFinished = reject
|
||||
})
|
||||
|
||||
return {
|
||||
cancel: vi.fn(),
|
||||
finished,
|
||||
resolveFinished,
|
||||
rejectFinished
|
||||
}
|
||||
}
|
||||
|
||||
function createTarget(opts: { connected?: boolean } = {}) {
|
||||
const animations: MockAnimation[] = []
|
||||
const calls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = []
|
||||
const target = {
|
||||
isConnected: opts.connected ?? true,
|
||||
animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) {
|
||||
calls.push({ keyframes, options })
|
||||
const animation = createMockAnimation()
|
||||
animations.push(animation)
|
||||
return animation as Animation
|
||||
}
|
||||
} as unknown as HTMLElement
|
||||
|
||||
return { target, calls, animations }
|
||||
}
|
||||
|
||||
const signature: MotionSignature = {
|
||||
duration: 180,
|
||||
easing: 'ease-out',
|
||||
scale: { from: 1, to: 1.06 },
|
||||
translate: { x: 4, y: -2 },
|
||||
rotate: 6
|
||||
}
|
||||
|
||||
describe('MotionChannel', () => {
|
||||
it('animates with additive WAAPI options and resolves on finished', async () => {
|
||||
const channel = new MotionChannel()
|
||||
const { target, calls, animations } = createTarget()
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = channel.apply(target, signature, controller.signal)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].keyframes).toEqual([
|
||||
{ transform: 'scale(1) translate(4px, -2px) rotate(6deg)' },
|
||||
{ transform: 'scale(1.06) translate(4px, -2px) rotate(6deg)' }
|
||||
])
|
||||
expect(calls[0].options).toEqual({
|
||||
duration: 180,
|
||||
easing: 'ease-out',
|
||||
fill: 'none',
|
||||
composite: 'add'
|
||||
})
|
||||
|
||||
animations[0].resolveFinished()
|
||||
await pending
|
||||
expect(animations[0].cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels the animation when the abort signal fires', async () => {
|
||||
const channel = new MotionChannel()
|
||||
const { target, animations } = createTarget()
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = channel.apply(target, signature, controller.signal)
|
||||
controller.abort()
|
||||
animations[0].rejectFinished(new Error('cancelled'))
|
||||
|
||||
await pending
|
||||
expect(animations[0].cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('degrades silently when the target is disconnected or animate is missing', async () => {
|
||||
const channel = new MotionChannel()
|
||||
const disconnected = { isConnected: false } as HTMLElement
|
||||
const missingAnimate = { isConnected: true } as HTMLElement
|
||||
|
||||
await expect(channel.apply(disconnected, signature, new AbortController().signal)).resolves.toBeUndefined()
|
||||
await expect(channel.apply(missingAnimate, signature, new AbortController().signal)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a cleanup for sustained animations', () => {
|
||||
const channel = new MotionChannel()
|
||||
const { target, calls, animations } = createTarget()
|
||||
|
||||
const cleanup = channel.applySustained(target, signature)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].options).toEqual({
|
||||
duration: 180,
|
||||
easing: 'ease-out',
|
||||
iterations: Infinity,
|
||||
fill: 'none',
|
||||
composite: 'add'
|
||||
})
|
||||
|
||||
cleanup()
|
||||
expect(animations[0].cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('swallows finished rejections from the browser', async () => {
|
||||
const channel = new MotionChannel()
|
||||
const { target, animations } = createTarget()
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = channel.apply(target, signature, controller.signal)
|
||||
animations[0].rejectFinished(new Error('browser oddity'))
|
||||
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,125 @@
|
||||
import type { MotionSignature } from '../resolver'
|
||||
|
||||
type MotionTarget = HTMLElement & {
|
||||
animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation
|
||||
}
|
||||
|
||||
function hasAnimate(target: HTMLElement): target is MotionTarget {
|
||||
return typeof (target as MotionTarget).animate === 'function'
|
||||
}
|
||||
|
||||
function noopCleanup(): void {}
|
||||
|
||||
export class MotionChannel {
|
||||
private readonly activeAnimations = new WeakMap<HTMLElement, Animation[]>()
|
||||
|
||||
async apply(
|
||||
target: HTMLElement,
|
||||
signature: MotionSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!target.isConnected || !hasAnimate(target)) return
|
||||
|
||||
const animation = target.animate(
|
||||
this.signatureToKeyframes(signature),
|
||||
this.signatureToOptions(signature)
|
||||
)
|
||||
this.trackAnimation(target, animation)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
animation.cancel()
|
||||
this.untrackAnimation(target, animation)
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => animation.cancel()
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
await animation.finished
|
||||
} catch {
|
||||
// Cancelada o rechazada por el navegador. Sema degrada silenciosamente.
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
this.untrackAnimation(target, animation)
|
||||
}
|
||||
}
|
||||
|
||||
applySustained(target: HTMLElement, signature: MotionSignature): () => void {
|
||||
if (!target.isConnected || !hasAnimate(target)) return noopCleanup
|
||||
|
||||
const animation = target.animate(this.signatureToKeyframes(signature), {
|
||||
duration: signature.duration || 1000,
|
||||
easing: signature.easing,
|
||||
iterations: Infinity,
|
||||
fill: 'none',
|
||||
composite: 'add'
|
||||
})
|
||||
this.trackAnimation(target, animation)
|
||||
|
||||
return () => {
|
||||
animation.cancel()
|
||||
this.untrackAnimation(target, animation)
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// No-op. El canal no mantiene estado global iterable; el DOM y WeakMap
|
||||
// permiten que las animaciones queden acotadas al lifecycle del target.
|
||||
}
|
||||
|
||||
private signatureToKeyframes(signature: MotionSignature): Keyframe[] {
|
||||
const fromTransforms: string[] = []
|
||||
const toTransforms: string[] = []
|
||||
|
||||
if (signature.scale) {
|
||||
fromTransforms.push(`scale(${signature.scale.from})`)
|
||||
toTransforms.push(`scale(${signature.scale.to})`)
|
||||
}
|
||||
|
||||
if (signature.translate) {
|
||||
fromTransforms.push(`translate(${signature.translate.x}px, ${signature.translate.y}px)`)
|
||||
toTransforms.push(`translate(${signature.translate.x}px, ${signature.translate.y}px)`)
|
||||
}
|
||||
|
||||
if (signature.rotate !== undefined) {
|
||||
fromTransforms.push(`rotate(${signature.rotate}deg)`)
|
||||
toTransforms.push(`rotate(${signature.rotate}deg)`)
|
||||
}
|
||||
|
||||
const from: Keyframe = {}
|
||||
const to: Keyframe = {}
|
||||
|
||||
if (fromTransforms.length > 0) {
|
||||
from.transform = fromTransforms.join(' ')
|
||||
to.transform = toTransforms.join(' ')
|
||||
}
|
||||
|
||||
return [from, to]
|
||||
}
|
||||
|
||||
private signatureToOptions(signature: MotionSignature): KeyframeAnimationOptions {
|
||||
return {
|
||||
duration: signature.duration,
|
||||
easing: signature.easing,
|
||||
fill: 'none',
|
||||
composite: 'add'
|
||||
}
|
||||
}
|
||||
|
||||
private trackAnimation(target: HTMLElement, animation: Animation): void {
|
||||
const existing = this.activeAnimations.get(target) ?? []
|
||||
existing.push(animation)
|
||||
this.activeAnimations.set(target, existing)
|
||||
}
|
||||
|
||||
private untrackAnimation(target: HTMLElement, animation: Animation): void {
|
||||
const existing = this.activeAnimations.get(target)
|
||||
if (!existing) return
|
||||
const index = existing.indexOf(animation)
|
||||
if (index >= 0) existing.splice(index, 1)
|
||||
if (existing.length === 0) {
|
||||
this.activeAnimations.delete(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PresenceChannel } from './presence'
|
||||
import type { PresenceSignature } from '../resolver'
|
||||
|
||||
interface MockAnimation extends Partial<Animation> {
|
||||
cancel: ReturnType<typeof vi.fn>
|
||||
finished: Promise<void>
|
||||
resolveFinished(): void
|
||||
rejectFinished(reason?: unknown): void
|
||||
}
|
||||
|
||||
function createMockAnimation(): MockAnimation {
|
||||
let resolveFinished = () => {}
|
||||
let rejectFinished = (_reason?: unknown) => {}
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
resolveFinished = resolve
|
||||
rejectFinished = reject
|
||||
})
|
||||
|
||||
return {
|
||||
cancel: vi.fn(),
|
||||
finished,
|
||||
resolveFinished,
|
||||
rejectFinished
|
||||
}
|
||||
}
|
||||
|
||||
function createStyle(seed: Record<string, string> = {}): CSSStyleDeclaration {
|
||||
return seed as unknown as CSSStyleDeclaration
|
||||
}
|
||||
|
||||
function createEnvironment() {
|
||||
const animations: MockAnimation[] = []
|
||||
const targetCalls: Array<{ keyframes: Keyframe[]; options?: KeyframeAnimationOptions }> = []
|
||||
const appended: HTMLElement[] = []
|
||||
const removed: HTMLElement[] = []
|
||||
const queryNodes: Array<{ remove: ReturnType<typeof vi.fn> }> = [{ remove: vi.fn() }, { remove: vi.fn() }]
|
||||
|
||||
const defaultView = {
|
||||
getComputedStyle() {
|
||||
return {
|
||||
boxShadow: '0 1px 2px rgb(0 0 0 / 0.2)'
|
||||
} as CSSStyleDeclaration
|
||||
}
|
||||
}
|
||||
|
||||
const doc = {
|
||||
defaultView,
|
||||
querySelectorAll: vi.fn(() => queryNodes),
|
||||
body: {
|
||||
appendChild: vi.fn((node: HTMLElement) => {
|
||||
appended.push(node)
|
||||
})
|
||||
},
|
||||
createElement: vi.fn(() => {
|
||||
const style = createStyle()
|
||||
return {
|
||||
style,
|
||||
setAttribute: vi.fn(),
|
||||
remove: vi.fn(function () {
|
||||
removed.push(this as unknown as HTMLElement)
|
||||
}),
|
||||
getBoundingClientRect: vi.fn(() => ({}) as DOMRect)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const target = {
|
||||
isConnected: true,
|
||||
ownerDocument: doc,
|
||||
animate(keyframes: Keyframe[], options?: KeyframeAnimationOptions) {
|
||||
targetCalls.push({ keyframes, options })
|
||||
const animation = createMockAnimation()
|
||||
animations.push(animation)
|
||||
return animation as Animation
|
||||
}
|
||||
} as unknown as HTMLElement
|
||||
|
||||
return {
|
||||
channel: new PresenceChannel(),
|
||||
target,
|
||||
doc,
|
||||
animations,
|
||||
targetCalls,
|
||||
appended,
|
||||
removed,
|
||||
queryNodes
|
||||
}
|
||||
}
|
||||
|
||||
const signature: PresenceSignature = {
|
||||
opacity: { from: 0.6, to: 1 },
|
||||
shadow: { blur: 18, y: 6, opacity: 0.4 },
|
||||
backdrop: 0.35,
|
||||
outline: { width: 2, style: 'solid' },
|
||||
duration: 180,
|
||||
easing: 'ease-out'
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('PresenceChannel', () => {
|
||||
it('applies opacity, shadow, outline and backdrop together', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = createEnvironment()
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(env.target, signature, controller.signal)
|
||||
|
||||
expect(env.targetCalls).toHaveLength(3)
|
||||
expect(env.targetCalls[0].keyframes).toEqual([{ opacity: 0.6 }, { opacity: 1 }])
|
||||
expect(env.targetCalls[0].options).toEqual({
|
||||
duration: 180,
|
||||
easing: 'ease-out',
|
||||
fill: 'forwards'
|
||||
})
|
||||
expect(env.targetCalls[1].keyframes).toEqual([
|
||||
{ boxShadow: '0 1px 2px rgb(0 0 0 / 0.2)' },
|
||||
{ boxShadow: '0 1px 2px rgb(0 0 0 / 0.2), 0 6px 18px rgba(0,0,0,0.4)' }
|
||||
])
|
||||
expect(env.targetCalls[2].keyframes[1]).toEqual({
|
||||
outline: '2px solid currentColor',
|
||||
offset: 0.3
|
||||
})
|
||||
expect(env.appended).toHaveLength(1)
|
||||
|
||||
env.animations[0].resolveFinished()
|
||||
env.animations[1].resolveFinished()
|
||||
env.animations[2].resolveFinished()
|
||||
await vi.advanceTimersByTimeAsync(235)
|
||||
await pending
|
||||
|
||||
expect(env.removed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancels target animations and removes backdrop on abort', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = createEnvironment()
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(env.target, signature, controller.signal)
|
||||
controller.abort()
|
||||
env.animations[0].rejectFinished(new Error('cancelled'))
|
||||
env.animations[1].rejectFinished(new Error('cancelled'))
|
||||
env.animations[2].rejectFinished(new Error('cancelled'))
|
||||
await vi.runAllTimersAsync()
|
||||
await pending
|
||||
|
||||
expect(env.animations[0].cancel).toHaveBeenCalledTimes(1)
|
||||
expect(env.animations[1].cancel).toHaveBeenCalledTimes(1)
|
||||
expect(env.animations[2].cancel).toHaveBeenCalledTimes(1)
|
||||
expect(env.removed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('creates a sustained backdrop and cleans it up on stop', () => {
|
||||
const env = createEnvironment()
|
||||
|
||||
const cleanup = env.channel.applySustained(env.target, signature)
|
||||
|
||||
expect(env.appended).toHaveLength(1)
|
||||
cleanup()
|
||||
expect(env.removed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('degrades silently when there is no animate support', async () => {
|
||||
vi.useFakeTimers()
|
||||
const env = createEnvironment()
|
||||
const target = {
|
||||
isConnected: true,
|
||||
ownerDocument: env.doc
|
||||
} as HTMLElement
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = env.channel.apply(target, signature, controller.signal)
|
||||
await vi.advanceTimersByTimeAsync(235)
|
||||
await pending
|
||||
|
||||
expect(env.appended).toHaveLength(1)
|
||||
expect(env.removed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('destroy() removes all persistent backdrops from the document', () => {
|
||||
const env = createEnvironment()
|
||||
const previousDocument = globalThis.document
|
||||
|
||||
Object.assign(globalThis, { document: env.doc })
|
||||
try {
|
||||
env.channel.destroy()
|
||||
} finally {
|
||||
Object.assign(globalThis, { document: previousDocument })
|
||||
}
|
||||
|
||||
expect(env.queryNodes[0].remove).toHaveBeenCalledTimes(1)
|
||||
expect(env.queryNodes[1].remove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,256 @@
|
||||
import type { PresenceSignature } from '../resolver'
|
||||
|
||||
type PresenceTarget = HTMLElement & {
|
||||
animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation
|
||||
ownerDocument: Document
|
||||
}
|
||||
|
||||
type BackdropElement = HTMLElement & {
|
||||
style: CSSStyleDeclaration
|
||||
remove(): void
|
||||
getBoundingClientRect(): DOMRect
|
||||
}
|
||||
|
||||
type DocLike = Pick<Document, 'createElement' | 'querySelectorAll' | 'body' | 'defaultView'>
|
||||
|
||||
function hasAnimate(target: unknown): target is { animate: NonNullable<PresenceTarget['animate']> } {
|
||||
return typeof (target as { animate?: unknown })?.animate === 'function'
|
||||
}
|
||||
|
||||
function canUseDOM(target: HTMLElement): target is PresenceTarget {
|
||||
return !!target.ownerDocument
|
||||
}
|
||||
|
||||
function removeBackdrop(backdrop: BackdropElement, state: { removed: boolean }): void {
|
||||
if (state.removed) return
|
||||
state.removed = true
|
||||
backdrop.remove()
|
||||
}
|
||||
|
||||
export class PresenceChannel {
|
||||
async apply(
|
||||
target: HTMLElement,
|
||||
signature: PresenceSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!target.isConnected) return
|
||||
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
if (signature.opacity && hasAnimate(target)) {
|
||||
promises.push(this.applyOpacity(target, signature, abortSignal))
|
||||
}
|
||||
if (signature.shadow && hasAnimate(target)) {
|
||||
promises.push(this.applyShadow(target, signature, abortSignal))
|
||||
}
|
||||
if (signature.backdrop !== undefined && signature.backdrop > 0) {
|
||||
promises.push(this.applyBackdrop(target, signature, abortSignal))
|
||||
}
|
||||
if (signature.outline && hasAnimate(target)) {
|
||||
promises.push(this.applyOutline(target, signature, abortSignal))
|
||||
}
|
||||
|
||||
if (promises.length === 0) return
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
applySustained(target: HTMLElement, signature: PresenceSignature): () => void {
|
||||
if (!target.isConnected || !canUseDOM(target)) return () => {}
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
if (signature.backdrop !== undefined && signature.backdrop > 0) {
|
||||
const backdrop = this.createBackdrop(target.ownerDocument as unknown as DocLike, signature)
|
||||
target.ownerDocument.body?.appendChild(backdrop)
|
||||
cleanups.push(() => backdrop.remove())
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (typeof document === 'undefined') return
|
||||
for (const node of document.querySelectorAll('[data-sema-backdrop]')) {
|
||||
node.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private async applyOpacity(
|
||||
target: HTMLElement,
|
||||
signature: PresenceSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!hasAnimate(target)) return
|
||||
const animation = target.animate(
|
||||
[
|
||||
{ opacity: signature.opacity.from },
|
||||
{ opacity: signature.opacity.to }
|
||||
],
|
||||
{
|
||||
duration: signature.duration,
|
||||
easing: signature.easing,
|
||||
fill: 'forwards'
|
||||
}
|
||||
)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
animation.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => animation.cancel()
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
await animation.finished
|
||||
} catch {
|
||||
// degradación silenciosa
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
private async applyShadow(
|
||||
target: HTMLElement,
|
||||
signature: PresenceSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signature.shadow || !hasAnimate(target) || !canUseDOM(target)) return
|
||||
|
||||
const computed = target.ownerDocument.defaultView?.getComputedStyle(target)
|
||||
const previousShadow = computed?.boxShadow && computed.boxShadow !== 'none' ? computed.boxShadow : ''
|
||||
const shadowEnd = `0 ${signature.shadow.y}px ${signature.shadow.blur}px rgba(0,0,0,${signature.shadow.opacity})`
|
||||
const animation = target.animate(
|
||||
[
|
||||
{ boxShadow: this.composeShadow(previousShadow, 'none') },
|
||||
{ boxShadow: this.composeShadow(previousShadow, shadowEnd) }
|
||||
],
|
||||
{
|
||||
duration: signature.duration,
|
||||
easing: signature.easing,
|
||||
fill: 'none'
|
||||
}
|
||||
)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
animation.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => animation.cancel()
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
await animation.finished
|
||||
} catch {
|
||||
// degradación silenciosa
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
private async applyBackdrop(
|
||||
target: HTMLElement,
|
||||
signature: PresenceSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!canUseDOM(target)) return
|
||||
const doc = target.ownerDocument as unknown as DocLike
|
||||
if (!doc.body) return
|
||||
|
||||
const backdrop = this.createBackdrop(doc, signature)
|
||||
const removalState = { removed: false }
|
||||
backdrop.style.opacity = '0'
|
||||
doc.body.appendChild(backdrop)
|
||||
backdrop.getBoundingClientRect()
|
||||
backdrop.style.opacity = '1'
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const fadeTimer = setTimeout(() => {
|
||||
backdrop.style.opacity = '0'
|
||||
const cleanupTimer = setTimeout(() => {
|
||||
removeBackdrop(backdrop, removalState)
|
||||
resolve()
|
||||
}, signature.duration)
|
||||
|
||||
const onAbortLate = () => {
|
||||
clearTimeout(cleanupTimer)
|
||||
removeBackdrop(backdrop, removalState)
|
||||
resolve()
|
||||
}
|
||||
abortSignal.addEventListener('abort', onAbortLate, { once: true })
|
||||
}, signature.duration * 0.3)
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(fadeTimer)
|
||||
removeBackdrop(backdrop, removalState)
|
||||
resolve()
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
private async applyOutline(
|
||||
target: HTMLElement,
|
||||
signature: PresenceSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signature.outline || !hasAnimate(target)) return
|
||||
|
||||
const animation = target.animate(
|
||||
[
|
||||
{ outline: `0px ${signature.outline.style} currentColor` },
|
||||
{ outline: `${signature.outline.width}px ${signature.outline.style} currentColor`, offset: 0.3 },
|
||||
{ outline: `0px ${signature.outline.style} currentColor` }
|
||||
],
|
||||
{
|
||||
duration: signature.duration,
|
||||
easing: signature.easing,
|
||||
fill: 'none'
|
||||
}
|
||||
)
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
animation.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
const onAbort = () => animation.cancel()
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
await animation.finished
|
||||
} catch {
|
||||
// degradación silenciosa
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
private composeShadow(previous: string, pulse: string): string {
|
||||
if (pulse === 'none') return previous || 'none'
|
||||
return previous ? `${previous}, ${pulse}` : pulse
|
||||
}
|
||||
|
||||
private createBackdrop(doc: DocLike, signature: PresenceSignature): BackdropElement {
|
||||
const backdrop = doc.createElement('div') as BackdropElement
|
||||
backdrop.setAttribute('data-sema-backdrop', '')
|
||||
backdrop.style.position = 'fixed'
|
||||
backdrop.style.inset = '0'
|
||||
backdrop.style.background = `rgba(0, 0, 0, ${signature.backdrop ?? 0})`
|
||||
backdrop.style.backdropFilter = 'blur(4px)'
|
||||
backdrop.style.pointerEvents = 'none'
|
||||
backdrop.style.zIndex = '9998'
|
||||
backdrop.style.transition = `opacity ${signature.duration}ms ${signature.easing}`
|
||||
return backdrop
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SoundChannel } from './sound'
|
||||
import type { SoundSignature } from '../resolver'
|
||||
|
||||
function createAudioParam() {
|
||||
return {
|
||||
value: 0,
|
||||
setValueAtTime: vi.fn(),
|
||||
linearRampToValueAtTime: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createGainNode() {
|
||||
return {
|
||||
gain: createAudioParam(),
|
||||
connect: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createOscillatorNode() {
|
||||
return {
|
||||
type: 'sine',
|
||||
frequency: { value: 0 },
|
||||
detune: createAudioParam(),
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createBiquadFilterNode() {
|
||||
return {
|
||||
type: 'lowpass',
|
||||
frequency: { value: 0 },
|
||||
Q: { value: 0 },
|
||||
connect: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createBufferSourceNode() {
|
||||
return {
|
||||
buffer: null as AudioBuffer | null,
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(function () {
|
||||
setTimeout(() => this.onended?.(), 0)
|
||||
}),
|
||||
stop: vi.fn(function () {
|
||||
this.onended?.()
|
||||
}),
|
||||
onended: null as null | (() => void)
|
||||
}
|
||||
}
|
||||
|
||||
function createAudioContext(state: AudioContextState = 'running') {
|
||||
const gains: ReturnType<typeof createGainNode>[] = []
|
||||
const oscillators: ReturnType<typeof createOscillatorNode>[] = []
|
||||
const filters: ReturnType<typeof createBiquadFilterNode>[] = []
|
||||
const sources: ReturnType<typeof createBufferSourceNode>[] = []
|
||||
|
||||
const ctx = {
|
||||
state,
|
||||
currentTime: 0,
|
||||
destination: {},
|
||||
createGain: vi.fn(() => {
|
||||
const node = createGainNode()
|
||||
gains.push(node)
|
||||
return node as unknown as GainNode
|
||||
}),
|
||||
createOscillator: vi.fn(() => {
|
||||
const node = createOscillatorNode()
|
||||
oscillators.push(node)
|
||||
return node as unknown as OscillatorNode
|
||||
}),
|
||||
createBiquadFilter: vi.fn(() => {
|
||||
const node = createBiquadFilterNode()
|
||||
filters.push(node)
|
||||
return node as unknown as BiquadFilterNode
|
||||
}),
|
||||
createBufferSource: vi.fn(() => {
|
||||
const node = createBufferSourceNode()
|
||||
sources.push(node)
|
||||
return node as unknown as AudioBufferSourceNode
|
||||
}),
|
||||
decodeAudioData: vi.fn(async (_buffer: ArrayBuffer) => ({}) as AudioBuffer),
|
||||
resume: vi.fn(async () => {
|
||||
ctx.state = 'running'
|
||||
}),
|
||||
close: vi.fn(async () => {})
|
||||
}
|
||||
|
||||
return {
|
||||
ctx: ctx as unknown as AudioContext,
|
||||
gains,
|
||||
oscillators,
|
||||
filters,
|
||||
sources
|
||||
}
|
||||
}
|
||||
|
||||
const baseSignature: SoundSignature = {
|
||||
pitch: 700,
|
||||
centroid: 1800,
|
||||
roughness: 0.4,
|
||||
attack: 8,
|
||||
decay: 120,
|
||||
duration: 120,
|
||||
contour: 'ascending',
|
||||
gain: 0.7
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('SoundChannel', () => {
|
||||
it('synthesizes an earcon with oscillators, filter and contour', async () => {
|
||||
vi.useFakeTimers()
|
||||
const audio = createAudioContext('running')
|
||||
const addEventListener = vi.fn()
|
||||
const removeEventListener = vi.fn()
|
||||
const channel = new SoundChannel({
|
||||
audioContextFactory: () => audio.ctx,
|
||||
doc: { addEventListener, removeEventListener }
|
||||
})
|
||||
|
||||
const pending = channel.apply(baseSignature, 0.8, new AbortController().signal)
|
||||
await vi.advanceTimersByTimeAsync(baseSignature.duration)
|
||||
await pending
|
||||
|
||||
expect(audio.gains).toHaveLength(5)
|
||||
expect(audio.oscillators).toHaveLength(3)
|
||||
expect(audio.filters).toHaveLength(1)
|
||||
expect(audio.gains[0].connect).toHaveBeenCalledWith((audio.ctx as any).destination)
|
||||
expect(audio.oscillators[0].frequency.value).toBe(700)
|
||||
expect(audio.oscillators[1].frequency.value).toBe(1050)
|
||||
expect(audio.filters[0].frequency.value).toBe(1800)
|
||||
expect(audio.oscillators[0].detune.setValueAtTime).toHaveBeenCalledWith(-50, 0)
|
||||
expect(audio.oscillators[0].detune.linearRampToValueAtTime).toHaveBeenCalledWith(50, 0.12)
|
||||
expect(addEventListener).toHaveBeenCalled()
|
||||
channel.destroy()
|
||||
expect(audio.ctx.close).toHaveBeenCalledTimes(1)
|
||||
expect(removeEventListener).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('degrades silently when the context stays suspended', async () => {
|
||||
const audio = createAudioContext('suspended')
|
||||
audio.ctx.resume = vi.fn(async () => {
|
||||
// keep suspended on purpose
|
||||
}) as unknown as AudioContext['resume']
|
||||
const channel = new SoundChannel({
|
||||
audioContextFactory: () => audio.ctx
|
||||
})
|
||||
|
||||
await expect(channel.apply(baseSignature, 0.8, new AbortController().signal)).resolves.toBeUndefined()
|
||||
expect(audio.ctx.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio.oscillators).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('plays and caches sample earcons', async () => {
|
||||
const audio = createAudioContext('running')
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
arrayBuffer: async () => new ArrayBuffer(8)
|
||||
}))
|
||||
const channel = new SoundChannel({
|
||||
audioContextFactory: () => audio.ctx,
|
||||
fetchFn
|
||||
})
|
||||
const signature: SoundSignature = {
|
||||
...baseSignature,
|
||||
sampleUrl: '/sounds/alarm.wav'
|
||||
}
|
||||
|
||||
await channel.apply(signature, 0.8, new AbortController().signal)
|
||||
await channel.apply(signature, 0.8, new AbortController().signal)
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
expect(audio.ctx.decodeAudioData).toHaveBeenCalledTimes(1)
|
||||
expect(audio.sources).toHaveLength(2)
|
||||
expect(audio.sources[0].start).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('aborts synthesis cleanly', async () => {
|
||||
vi.useFakeTimers()
|
||||
const audio = createAudioContext('running')
|
||||
const channel = new SoundChannel({
|
||||
audioContextFactory: () => audio.ctx
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = channel.apply(baseSignature, 0.8, controller.signal)
|
||||
controller.abort()
|
||||
await vi.runAllTimersAsync()
|
||||
await pending
|
||||
|
||||
expect(audio.oscillators[0].stop).toHaveBeenCalled()
|
||||
expect(audio.oscillators[1].stop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preloads sample buffers opportunistically', async () => {
|
||||
const audio = createAudioContext('running')
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
arrayBuffer: async () => new ArrayBuffer(8)
|
||||
}))
|
||||
const channel = new SoundChannel({
|
||||
audioContextFactory: () => audio.ctx,
|
||||
fetchFn
|
||||
})
|
||||
|
||||
await channel.preloadSamples(['/a.wav', '/a.wav', '/b.wav'])
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
expect(audio.ctx.decodeAudioData).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,322 @@
|
||||
import type { SoundSignature } from '../resolver'
|
||||
|
||||
type AudioContextCtor = new () => AudioContext
|
||||
|
||||
export interface SoundChannelOptions {
|
||||
audioContextFactory?: () => AudioContext | null
|
||||
fetchFn?: typeof fetch
|
||||
doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>
|
||||
}
|
||||
|
||||
function getGlobalAudioContextCtor(): AudioContextCtor | null {
|
||||
const maybeCtor = (
|
||||
globalThis as typeof globalThis & {
|
||||
AudioContext?: AudioContextCtor
|
||||
webkitAudioContext?: AudioContextCtor
|
||||
}
|
||||
).AudioContext ??
|
||||
(globalThis as typeof globalThis & {
|
||||
webkitAudioContext?: AudioContextCtor
|
||||
}).webkitAudioContext
|
||||
|
||||
return maybeCtor ?? null
|
||||
}
|
||||
|
||||
function safeStop(node: { stop(when?: number): void } | null | undefined, when?: number): void {
|
||||
if (!node) return
|
||||
try {
|
||||
node.stop(when)
|
||||
} catch {
|
||||
// already stopped or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function noopCleanup(): void {}
|
||||
|
||||
export class SoundChannel {
|
||||
private audioCtx: AudioContext | null = null
|
||||
private masterGain: GainNode | null = null
|
||||
private readonly sampleCache = new Map<string, AudioBuffer>()
|
||||
private readonly fetchFn?: typeof fetch
|
||||
private readonly audioContextFactory?: () => AudioContext | null
|
||||
private readonly doc?: Pick<Document, 'addEventListener' | 'removeEventListener'>
|
||||
private teardownUnlock?: () => void
|
||||
|
||||
constructor(opts: SoundChannelOptions = {}) {
|
||||
this.fetchFn = opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined)
|
||||
this.audioContextFactory = opts.audioContextFactory
|
||||
this.doc = opts.doc ?? (typeof document !== 'undefined' ? document : undefined)
|
||||
}
|
||||
|
||||
async apply(
|
||||
signature: SoundSignature,
|
||||
masterGainValue: number,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
const ctx = await this.getOrCreateContext()
|
||||
if (!ctx || ctx.state !== 'running' || !this.masterGain) return
|
||||
|
||||
this.masterGain.gain.value = masterGainValue
|
||||
|
||||
if (signature.sampleUrl) {
|
||||
await this.playSample(ctx, signature, abortSignal)
|
||||
return
|
||||
}
|
||||
|
||||
await this.synthesize(ctx, signature, abortSignal)
|
||||
}
|
||||
|
||||
applySustained(): () => void {
|
||||
return noopCleanup
|
||||
}
|
||||
|
||||
async preloadSamples(urls: string[]): Promise<void> {
|
||||
const ctx = await this.getOrCreateContext()
|
||||
if (!ctx || !this.fetchFn) return
|
||||
|
||||
const uniqueUrls = [...new Set(urls)]
|
||||
|
||||
await Promise.all(uniqueUrls.map(async (url) => {
|
||||
if (this.sampleCache.has(url)) return
|
||||
try {
|
||||
const response = await this.fetchFn!(url)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const buffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
this.sampleCache.set(url, buffer)
|
||||
} catch {
|
||||
// fail silently; preload is opportunistic
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.teardownUnlock?.()
|
||||
this.teardownUnlock = undefined
|
||||
if (this.audioCtx) {
|
||||
this.audioCtx.close().catch(() => {})
|
||||
this.audioCtx = null
|
||||
this.masterGain = null
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateContext(): Promise<AudioContext | null> {
|
||||
if (!this.audioCtx) {
|
||||
try {
|
||||
this.audioCtx = this.audioContextFactory?.() ?? this.createContextFromGlobals()
|
||||
if (!this.audioCtx) return null
|
||||
this.masterGain = this.audioCtx.createGain()
|
||||
this.masterGain.connect(this.audioCtx.destination)
|
||||
this.setupUnlockListener()
|
||||
} catch {
|
||||
this.audioCtx = null
|
||||
this.masterGain = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
try {
|
||||
await this.audioCtx.resume()
|
||||
} catch {
|
||||
return this.audioCtx
|
||||
}
|
||||
}
|
||||
|
||||
return this.audioCtx
|
||||
}
|
||||
|
||||
private createContextFromGlobals(): AudioContext | null {
|
||||
const Ctor = getGlobalAudioContextCtor()
|
||||
return Ctor ? new Ctor() : null
|
||||
}
|
||||
|
||||
private setupUnlockListener(): void {
|
||||
if (!this.doc || this.teardownUnlock) return
|
||||
|
||||
const events = ['click', 'touchstart', 'keydown'] as const
|
||||
const unlock = () => {
|
||||
this.audioCtx?.resume().catch(() => {})
|
||||
}
|
||||
|
||||
for (const eventName of events) {
|
||||
this.doc.addEventListener(eventName, unlock, true)
|
||||
}
|
||||
|
||||
this.teardownUnlock = () => {
|
||||
for (const eventName of events) {
|
||||
this.doc?.removeEventListener(eventName, unlock, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async synthesize(
|
||||
ctx: AudioContext,
|
||||
signature: SoundSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!this.masterGain) return
|
||||
|
||||
const now = ctx.currentTime
|
||||
const durationSec = signature.duration / 1000
|
||||
const attackSec = signature.attack / 1000
|
||||
const decaySec = signature.decay / 1000
|
||||
|
||||
const osc1 = ctx.createOscillator()
|
||||
osc1.type = 'sine'
|
||||
osc1.frequency.value = signature.pitch
|
||||
|
||||
const osc2 = ctx.createOscillator()
|
||||
osc2.type = 'sine'
|
||||
osc2.frequency.value = signature.pitch * 1.5
|
||||
|
||||
const mixer = ctx.createGain()
|
||||
mixer.gain.value = 1
|
||||
|
||||
const osc2Gain = ctx.createGain()
|
||||
osc2Gain.gain.value = 0.3
|
||||
|
||||
osc1.connect(mixer)
|
||||
osc2.connect(osc2Gain)
|
||||
osc2Gain.connect(mixer)
|
||||
|
||||
const filter = ctx.createBiquadFilter()
|
||||
filter.type = 'lowpass'
|
||||
filter.frequency.value = signature.centroid
|
||||
filter.Q.value = 1
|
||||
mixer.connect(filter)
|
||||
|
||||
const envelope = ctx.createGain()
|
||||
envelope.gain.setValueAtTime(0, now)
|
||||
envelope.gain.linearRampToValueAtTime(signature.gain, now + attackSec)
|
||||
envelope.gain.linearRampToValueAtTime(
|
||||
Math.max(signature.gain * 0.75, 0.0001),
|
||||
now + attackSec + decaySec
|
||||
)
|
||||
envelope.gain.linearRampToValueAtTime(0.0001, now + durationSec)
|
||||
filter.connect(envelope)
|
||||
envelope.connect(this.masterGain)
|
||||
|
||||
let modulator: OscillatorNode | null = null
|
||||
if (signature.roughness > 0.2) {
|
||||
modulator = ctx.createOscillator()
|
||||
modulator.type = 'sine'
|
||||
modulator.frequency.value = 30 + (signature.roughness - 0.2) * 150
|
||||
const modulatorGain = ctx.createGain()
|
||||
modulatorGain.gain.value = signature.roughness * 0.5
|
||||
modulator.connect(modulatorGain)
|
||||
modulatorGain.connect(envelope.gain)
|
||||
}
|
||||
|
||||
this.applyContour(osc1, signature.contour, now, durationSec)
|
||||
|
||||
osc1.start(now)
|
||||
osc2.start(now)
|
||||
modulator?.start(now)
|
||||
osc1.stop(now + durationSec)
|
||||
osc2.stop(now + durationSec)
|
||||
modulator?.stop(now + durationSec)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(), signature.duration)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
safeStop(osc1)
|
||||
safeStop(osc2)
|
||||
safeStop(modulator)
|
||||
resolve()
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
private applyContour(
|
||||
osc: OscillatorNode,
|
||||
contour: SoundSignature['contour'],
|
||||
startTime: number,
|
||||
durationSec: number
|
||||
): void {
|
||||
const endTime = startTime + durationSec
|
||||
switch (contour) {
|
||||
case 'flat':
|
||||
osc.detune.value = 0
|
||||
break
|
||||
case 'ascending':
|
||||
osc.detune.setValueAtTime(-50, startTime)
|
||||
osc.detune.linearRampToValueAtTime(50, endTime)
|
||||
break
|
||||
case 'descending':
|
||||
osc.detune.setValueAtTime(50, startTime)
|
||||
osc.detune.linearRampToValueAtTime(-50, endTime)
|
||||
break
|
||||
case 'arc':
|
||||
osc.detune.setValueAtTime(-25, startTime)
|
||||
osc.detune.linearRampToValueAtTime(50, startTime + durationSec * 0.5)
|
||||
osc.detune.linearRampToValueAtTime(-25, endTime)
|
||||
break
|
||||
case 'bell':
|
||||
osc.detune.setValueAtTime(25, startTime)
|
||||
osc.detune.linearRampToValueAtTime(-50, startTime + durationSec * 0.5)
|
||||
osc.detune.linearRampToValueAtTime(25, endTime)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private async playSample(
|
||||
ctx: AudioContext,
|
||||
signature: SoundSignature,
|
||||
abortSignal: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signature.sampleUrl || !this.fetchFn || !this.masterGain) return
|
||||
|
||||
let buffer = this.sampleCache.get(signature.sampleUrl)
|
||||
if (!buffer) {
|
||||
try {
|
||||
const response = await this.fetchFn(signature.sampleUrl)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
buffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
this.sampleCache.set(signature.sampleUrl, buffer)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!buffer || abortSignal.aborted) return
|
||||
|
||||
const source = ctx.createBufferSource()
|
||||
source.buffer = buffer
|
||||
const envelope = ctx.createGain()
|
||||
envelope.gain.value = signature.gain
|
||||
source.connect(envelope)
|
||||
envelope.connect(this.masterGain)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
source.onended = finish
|
||||
source.start()
|
||||
|
||||
const onAbort = () => {
|
||||
safeStop(source)
|
||||
finish()
|
||||
}
|
||||
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,364 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
_resetEngineForTesting,
|
||||
configureSema,
|
||||
destroySema,
|
||||
getSemaEngine,
|
||||
SemaEngine,
|
||||
type ColorChannelDriver,
|
||||
type ColorSignature,
|
||||
type MotionChannelDriver,
|
||||
type MotionSignature,
|
||||
type PresenceChannelDriver,
|
||||
type SemaChannelDrivers,
|
||||
type SemaEventDetail,
|
||||
type SoundChannelDriver
|
||||
} from './engine'
|
||||
import type { ResolvedSemaAction, ResolvedSemaSustain, SemaContext } from './port'
|
||||
|
||||
class FakeElement extends EventTarget {
|
||||
isConnected = true
|
||||
ownerDocument!: { documentElement: FakeElement }
|
||||
private readonly attrs = new Map<string, string>()
|
||||
|
||||
getAttribute(name: string): string | null {
|
||||
return this.attrs.has(name) ? this.attrs.get(name)! : null
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string): void {
|
||||
this.attrs.set(name, value)
|
||||
}
|
||||
|
||||
removeAttribute(name: string): void {
|
||||
this.attrs.delete(name)
|
||||
}
|
||||
|
||||
matches(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
closest(): Element | null {
|
||||
return null
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.isConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
function createDom() {
|
||||
const documentElement = new FakeElement()
|
||||
const ownerDocument = { documentElement }
|
||||
documentElement.ownerDocument = ownerDocument
|
||||
|
||||
const target = new FakeElement()
|
||||
target.ownerDocument = ownerDocument
|
||||
|
||||
const root = new FakeElement()
|
||||
root.ownerDocument = ownerDocument
|
||||
|
||||
return { documentElement, target, root }
|
||||
}
|
||||
|
||||
function createAction(
|
||||
overrides: Partial<ResolvedSemaAction> = {}
|
||||
): ResolvedSemaAction {
|
||||
return {
|
||||
name: 'close-save',
|
||||
component: 'dialog',
|
||||
event: 'commit-fulfill',
|
||||
mode: 'blocking',
|
||||
regime: 'replace',
|
||||
scope: 'part',
|
||||
target: 'content',
|
||||
prewritten: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createSustain(
|
||||
overrides: Partial<ResolvedSemaSustain> = {}
|
||||
): ResolvedSemaSustain {
|
||||
return {
|
||||
name: 'open',
|
||||
component: 'dialog',
|
||||
target: 'content',
|
||||
scope: 'part',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(targetEl: FakeElement, rootEl?: FakeElement): SemaContext {
|
||||
return {
|
||||
targetEl: targetEl as unknown as HTMLElement,
|
||||
rootEl: rootEl as unknown as HTMLElement | undefined,
|
||||
snapshot: {},
|
||||
partEls: {},
|
||||
cause: 'programmatic'
|
||||
}
|
||||
}
|
||||
|
||||
function createDelayedMotionDriver(ms: number): MotionChannelDriver & { apply: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
apply: vi.fn(async (_target: HTMLElement, _signature: MotionSignature, abortSignal: AbortSignal) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(), ms)
|
||||
abortSignal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}),
|
||||
applySustained: () => () => {},
|
||||
destroy() {}
|
||||
}
|
||||
}
|
||||
|
||||
function createImmediateDrivers(overrides: Partial<SemaChannelDrivers> = {}) {
|
||||
const motion: MotionChannelDriver = {
|
||||
async apply() {},
|
||||
applySustained: () => () => {},
|
||||
destroy() {}
|
||||
}
|
||||
const sound: SoundChannelDriver = {
|
||||
async apply() {},
|
||||
applySustained: () => () => {},
|
||||
destroy() {}
|
||||
}
|
||||
const color: ColorChannelDriver = {
|
||||
async apply() {},
|
||||
applySustained: () => () => {},
|
||||
destroy() {}
|
||||
}
|
||||
const presence: PresenceChannelDriver = {
|
||||
async apply() {},
|
||||
applySustained: () => () => {},
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
return {
|
||||
motion,
|
||||
sound,
|
||||
color,
|
||||
presence,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
_resetEngineForTesting()
|
||||
})
|
||||
|
||||
describe('SemaEngine', () => {
|
||||
it('emits sema:event and reflects attrs around a blocking choreography', async () => {
|
||||
const { target, root } = createDom()
|
||||
const phases: SemaEventDetail[] = []
|
||||
target.addEventListener('sema:event', (event) => {
|
||||
phases.push((event as CustomEvent<SemaEventDetail>).detail)
|
||||
})
|
||||
|
||||
const engine = new SemaEngine({
|
||||
channels: createImmediateDrivers()
|
||||
})
|
||||
engine.configure({ reflectEvents: true })
|
||||
|
||||
const pending = engine.before(createAction(), createContext(target, root))
|
||||
expect(target.getAttribute('data-sema-active')).toBe('commit-fulfill')
|
||||
expect(target.getAttribute('data-sema-phase')).toBe('active')
|
||||
|
||||
await pending
|
||||
|
||||
expect(target.getAttribute('data-sema-active')).toBe(null)
|
||||
expect(target.getAttribute('data-sema-phase')).toBe(null)
|
||||
expect(phases.map((entry) => entry.phase)).toEqual(['start', 'end'])
|
||||
expect(phases[0].channels).toContain('motion')
|
||||
expect(phases[0].duration).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('coalesces collapse actions into a single in-flight choreography', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { target } = createDom()
|
||||
const motion = createDelayedMotionDriver(1000)
|
||||
const engine = new SemaEngine({
|
||||
channels: createImmediateDrivers({ motion })
|
||||
})
|
||||
engine.configure({
|
||||
sound: { enabled: false },
|
||||
color: { enabled: false },
|
||||
presence: { enabled: false },
|
||||
capBlockingMs: 20
|
||||
})
|
||||
const action = createAction({ regime: 'collapse' })
|
||||
|
||||
const first = engine.before(action, createContext(target))
|
||||
const second = engine.before(action, createContext(target))
|
||||
|
||||
expect(motion.apply).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await first
|
||||
await second
|
||||
})
|
||||
|
||||
it('locks equivalent actions while one is active', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { target } = createDom()
|
||||
const motion = createDelayedMotionDriver(1000)
|
||||
const engine = new SemaEngine({
|
||||
channels: createImmediateDrivers({ motion })
|
||||
})
|
||||
engine.configure({
|
||||
sound: { enabled: false },
|
||||
color: { enabled: false },
|
||||
presence: { enabled: false },
|
||||
capBlockingMs: 20
|
||||
})
|
||||
const action = createAction({ regime: 'lock' })
|
||||
|
||||
const first = engine.before(action, createContext(target))
|
||||
await engine.before(action, createContext(target))
|
||||
|
||||
expect(motion.apply).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await first
|
||||
})
|
||||
|
||||
it('queues equivalent actions sequentially after the blocking cap releases', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { target } = createDom()
|
||||
const motion = createDelayedMotionDriver(1000)
|
||||
const engine = new SemaEngine({
|
||||
channels: createImmediateDrivers({ motion })
|
||||
})
|
||||
engine.configure({
|
||||
sound: { enabled: false },
|
||||
color: { enabled: false },
|
||||
presence: { enabled: false },
|
||||
capBlockingMs: 20
|
||||
})
|
||||
const action = createAction({ regime: 'queue' })
|
||||
|
||||
const first = engine.before(action, createContext(target))
|
||||
const second = engine.before(action, createContext(target))
|
||||
|
||||
expect(motion.apply).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(motion.apply).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await first
|
||||
await second
|
||||
})
|
||||
|
||||
it('replaces an active choreography and marks the first one as cancelled', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { target } = createDom()
|
||||
const motion = createDelayedMotionDriver(1000)
|
||||
const phases: Array<SemaEventDetail['phase']> = []
|
||||
target.addEventListener('sema:event', (event) => {
|
||||
phases.push((event as CustomEvent<SemaEventDetail>).detail.phase)
|
||||
})
|
||||
const engine = new SemaEngine({
|
||||
channels: createImmediateDrivers({ motion })
|
||||
})
|
||||
engine.configure({
|
||||
sound: { enabled: false },
|
||||
color: { enabled: false },
|
||||
presence: { enabled: false },
|
||||
capBlockingMs: 20
|
||||
})
|
||||
const action = createAction({ regime: 'replace' })
|
||||
|
||||
const first = engine.before(action, createContext(target))
|
||||
const second = engine.before(action, createContext(target))
|
||||
|
||||
expect(motion.apply).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await first
|
||||
await second
|
||||
|
||||
expect(phases.filter((phase) => phase === 'start')).toHaveLength(2)
|
||||
expect(phases).toContain('cancelled')
|
||||
expect(phases).toContain('end')
|
||||
})
|
||||
|
||||
it('starts sustains and runs cleanups on stop()', () => {
|
||||
const { target } = createDom()
|
||||
const motionCleanup = vi.fn()
|
||||
const presenceCleanup = vi.fn()
|
||||
const engine = new SemaEngine({
|
||||
channels: createImmediateDrivers({
|
||||
motion: {
|
||||
async apply() {},
|
||||
applySustained: () => motionCleanup,
|
||||
destroy() {}
|
||||
},
|
||||
presence: {
|
||||
async apply() {},
|
||||
applySustained: () => presenceCleanup,
|
||||
destroy() {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const session = engine.startSustain(createSustain(), createContext(target))
|
||||
expect(session.active).toBe(true)
|
||||
|
||||
session.stop()
|
||||
|
||||
expect(session.active).toBe(false)
|
||||
expect(motionCleanup).toHaveBeenCalledTimes(0)
|
||||
expect(presenceCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-resolves signatures after map override reconfiguration', async () => {
|
||||
const { target } = createDom()
|
||||
const seen: ColorSignature[] = []
|
||||
const color: ColorChannelDriver = {
|
||||
async apply(_target, signature) {
|
||||
seen.push(signature)
|
||||
},
|
||||
applySustained: () => () => {},
|
||||
destroy() {}
|
||||
}
|
||||
const engine = new SemaEngine({
|
||||
channels: createImmediateDrivers({ color })
|
||||
})
|
||||
engine.configure({
|
||||
sound: { enabled: false },
|
||||
motion: { enabled: false },
|
||||
presence: { enabled: false },
|
||||
mapOverrides: {
|
||||
'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
await engine.before(createAction(), createContext(target))
|
||||
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0].hue).toBe(0)
|
||||
})
|
||||
|
||||
it('exposes a singleton facade and resets it on destroy', () => {
|
||||
const first = getSemaEngine()
|
||||
configureSema({ reflectEvents: true })
|
||||
const second = getSemaEngine()
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect(second.currentConfig.reflectEvents).toBe(true)
|
||||
|
||||
destroySema()
|
||||
|
||||
const third = getSemaEngine()
|
||||
expect(third).not.toBe(first)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,551 @@
|
||||
import { DEV } from 'esm-env'
|
||||
|
||||
import {
|
||||
A11yMonitor,
|
||||
DEFAULT_SEMA_RUNTIME_CONFIG,
|
||||
type SemaRuntimeConfig
|
||||
} from './a11y'
|
||||
import { ColorChannel } from './channels/color'
|
||||
import { MotionChannel } from './channels/motion'
|
||||
import { PresenceChannel } from './channels/presence'
|
||||
import { SoundChannel } from './channels/sound'
|
||||
import type {
|
||||
ColorSignature,
|
||||
EffectiveSignature,
|
||||
MotionSignature,
|
||||
PresenceSignature,
|
||||
RuntimeOverrides,
|
||||
SoundSignature
|
||||
} from './resolver'
|
||||
import { Resolver } from './resolver'
|
||||
import type {
|
||||
ResolvedSemaAction,
|
||||
ResolvedSemaSustain,
|
||||
SemaContext,
|
||||
SemaPort,
|
||||
SemaSession
|
||||
} from './port'
|
||||
|
||||
type SemaPhase = 'start' | 'end' | 'cancelled'
|
||||
|
||||
export interface SemaEventDetail {
|
||||
event: ResolvedSemaAction['event']
|
||||
action: string
|
||||
component: string
|
||||
phase: SemaPhase
|
||||
channels: EffectiveSignature['activeChannels']
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface MotionChannelDriver {
|
||||
apply(target: HTMLElement, signature: MotionSignature, abortSignal: AbortSignal): Promise<void>
|
||||
applySustained?(target: HTMLElement, signature: MotionSignature): () => void
|
||||
destroy?(): void
|
||||
}
|
||||
|
||||
export interface SoundChannelDriver {
|
||||
apply(signature: SoundSignature, gain: number, abortSignal: AbortSignal): Promise<void>
|
||||
applySustained?(signature: SoundSignature, gain: number): () => void
|
||||
destroy?(): void
|
||||
}
|
||||
|
||||
export interface ColorChannelDriver {
|
||||
apply(target: HTMLElement, signature: ColorSignature, abortSignal: AbortSignal): Promise<void>
|
||||
applySustained?(target: HTMLElement, signature: ColorSignature): () => void
|
||||
destroy?(): void
|
||||
}
|
||||
|
||||
export interface PresenceChannelDriver {
|
||||
apply(target: HTMLElement, signature: PresenceSignature, abortSignal: AbortSignal): Promise<void>
|
||||
applySustained?(target: HTMLElement, signature: PresenceSignature): () => void
|
||||
destroy?(): void
|
||||
}
|
||||
|
||||
export interface SemaChannelDrivers {
|
||||
motion: MotionChannelDriver
|
||||
sound: SoundChannelDriver
|
||||
color: ColorChannelDriver
|
||||
presence: PresenceChannelDriver
|
||||
}
|
||||
|
||||
export interface EngineDependencies {
|
||||
resolver?: Resolver
|
||||
a11y?: A11yMonitor
|
||||
channels?: Partial<SemaChannelDrivers>
|
||||
}
|
||||
|
||||
export interface EngineConfig extends SemaRuntimeConfig {
|
||||
mapOverrides?: RuntimeOverrides
|
||||
}
|
||||
|
||||
export interface EngineConfigPatch
|
||||
extends Partial<Omit<EngineConfig, 'sound' | 'motion' | 'color' | 'presence'>> {
|
||||
sound?: Partial<EngineConfig['sound']>
|
||||
motion?: Partial<EngineConfig['motion']>
|
||||
color?: Partial<EngineConfig['color']>
|
||||
presence?: Partial<EngineConfig['presence']>
|
||||
}
|
||||
|
||||
const noopCleanup = () => {}
|
||||
|
||||
const noopChannels: SemaChannelDrivers = {
|
||||
motion: {
|
||||
async apply() {},
|
||||
applySustained() {
|
||||
return noopCleanup
|
||||
},
|
||||
destroy() {}
|
||||
},
|
||||
sound: {
|
||||
async apply() {},
|
||||
applySustained() {
|
||||
return noopCleanup
|
||||
},
|
||||
destroy() {}
|
||||
},
|
||||
color: {
|
||||
async apply() {},
|
||||
applySustained() {
|
||||
return noopCleanup
|
||||
},
|
||||
destroy() {}
|
||||
},
|
||||
presence: {
|
||||
async apply() {},
|
||||
applySustained() {
|
||||
return noopCleanup
|
||||
},
|
||||
destroy() {}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeConfig(current: EngineConfig, patch: EngineConfigPatch): EngineConfig {
|
||||
return {
|
||||
...current,
|
||||
...patch,
|
||||
sound: patch.sound ? { ...current.sound, ...patch.sound } : current.sound,
|
||||
motion: patch.motion ? { ...current.motion, ...patch.motion } : current.motion,
|
||||
color: patch.color ? { ...current.color, ...patch.color } : current.color,
|
||||
presence: patch.presence ? { ...current.presence, ...patch.presence } : current.presence,
|
||||
mapOverrides:
|
||||
'mapOverrides' in patch ? structuredClone(patch.mapOverrides ?? {}) : current.mapOverrides
|
||||
}
|
||||
}
|
||||
|
||||
function createSemaEvent(detail: SemaEventDetail): Event {
|
||||
if (typeof CustomEvent === 'function') {
|
||||
return new CustomEvent<SemaEventDetail>('sema:event', {
|
||||
bubbles: true,
|
||||
detail
|
||||
})
|
||||
}
|
||||
const event = new Event('sema:event', { bubbles: true }) as Event & { detail?: SemaEventDetail }
|
||||
event.detail = detail
|
||||
return event
|
||||
}
|
||||
|
||||
function isConnected(el: HTMLElement | undefined): boolean {
|
||||
if (!el) return false
|
||||
return el.isConnected !== false
|
||||
}
|
||||
|
||||
function swallowAbortable(work: Promise<void>): Promise<void> {
|
||||
return work.catch(() => {})
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export class SemaEngine implements SemaPort {
|
||||
private config: EngineConfig = {
|
||||
...DEFAULT_SEMA_RUNTIME_CONFIG
|
||||
}
|
||||
private readonly resolver: Resolver
|
||||
private readonly a11y: A11yMonitor
|
||||
private readonly drivers: SemaChannelDrivers
|
||||
private readonly activeChoreographies = new Map<string, Choreography>()
|
||||
private readonly sustainSessions = new Set<SustainRunner>()
|
||||
private readonly elementIds = new WeakMap<HTMLElement, string>()
|
||||
private nextElementId = 0
|
||||
|
||||
constructor(deps: EngineDependencies = {}) {
|
||||
this.resolver = deps.resolver ?? new Resolver()
|
||||
this.a11y = deps.a11y ?? new A11yMonitor()
|
||||
this.drivers = {
|
||||
motion: deps.channels?.motion ?? new MotionChannel(),
|
||||
sound: deps.channels?.sound ?? new SoundChannel(),
|
||||
color: deps.channels?.color ?? new ColorChannel(),
|
||||
presence: deps.channels?.presence ?? new PresenceChannel()
|
||||
}
|
||||
}
|
||||
|
||||
configure(patch: EngineConfigPatch): void {
|
||||
this.config = mergeConfig(this.config, patch)
|
||||
if ('mapOverrides' in patch) {
|
||||
this.resolver.setRuntimeOverrides(patch.mapOverrides ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const choreography of [...this.activeChoreographies.values()]) {
|
||||
choreography.cancel()
|
||||
}
|
||||
this.activeChoreographies.clear()
|
||||
|
||||
for (const sustain of [...this.sustainSessions]) {
|
||||
sustain.stop()
|
||||
}
|
||||
this.sustainSessions.clear()
|
||||
|
||||
this.drivers.motion.destroy?.()
|
||||
this.drivers.sound.destroy?.()
|
||||
this.drivers.color.destroy?.()
|
||||
this.drivers.presence.destroy?.()
|
||||
}
|
||||
|
||||
async before(action: ResolvedSemaAction, ctx: SemaContext): Promise<void> {
|
||||
const key = this.choreographyKey(action, ctx)
|
||||
const existing = this.activeChoreographies.get(key)
|
||||
|
||||
if (existing) {
|
||||
switch (action.regime) {
|
||||
case 'replace':
|
||||
existing.cancel()
|
||||
this.activeChoreographies.delete(key)
|
||||
break
|
||||
case 'collapse':
|
||||
existing.markRepeated()
|
||||
return existing.promise
|
||||
case 'lock':
|
||||
return
|
||||
case 'queue':
|
||||
await existing.promise
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let choreography: Choreography | null = null
|
||||
let signature: EffectiveSignature | null = null
|
||||
|
||||
try {
|
||||
signature = this.a11y.reduceSignature(this.resolver.resolve(action.event, ctx.targetEl), this.config)
|
||||
choreography = new Choreography(action, ctx, signature, this.a11y.getBlockingCapMs(this.config), this)
|
||||
this.activeChoreographies.set(key, choreography)
|
||||
|
||||
this.emitCustomEvent(ctx.targetEl, action, 'start', signature)
|
||||
if (this.config.reflectEvents) {
|
||||
ctx.targetEl.setAttribute('data-sema-active', action.event)
|
||||
ctx.targetEl.setAttribute('data-sema-phase', 'active')
|
||||
}
|
||||
|
||||
const result = await choreography.run()
|
||||
this.emitCustomEvent(
|
||||
ctx.targetEl,
|
||||
action,
|
||||
result === 'cancelled' ? 'cancelled' : 'end',
|
||||
signature
|
||||
)
|
||||
} catch (error) {
|
||||
this.warn(`[sema] engine.before("${action.name}") degraded: ${String(error)}`)
|
||||
} finally {
|
||||
if (this.config.reflectEvents) {
|
||||
ctx.targetEl.removeAttribute('data-sema-active')
|
||||
ctx.targetEl.removeAttribute('data-sema-phase')
|
||||
}
|
||||
if (choreography) {
|
||||
this.activeChoreographies.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fire(action: ResolvedSemaAction, ctx: SemaContext): void {
|
||||
this.before(action, ctx).catch(() => {})
|
||||
}
|
||||
|
||||
startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession {
|
||||
try {
|
||||
const runner = new SustainRunner(sustain, ctx, this)
|
||||
this.sustainSessions.add(runner)
|
||||
runner.start()
|
||||
return {
|
||||
stop: () => {
|
||||
runner.stop()
|
||||
this.sustainSessions.delete(runner)
|
||||
},
|
||||
get active() {
|
||||
return runner.active
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.warn(`[sema] engine.startSustain("${sustain.name}") degraded: ${String(error)}`)
|
||||
return {
|
||||
stop() {},
|
||||
get active() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get channels(): Readonly<SemaChannelDrivers> {
|
||||
return this.drivers
|
||||
}
|
||||
|
||||
get currentConfig(): Readonly<EngineConfig> {
|
||||
return this.config
|
||||
}
|
||||
|
||||
resolveReducedSignature(event: string, targetEl?: HTMLElement): EffectiveSignature {
|
||||
return this.a11y.reduceSignature(this.resolver.resolve(event, targetEl), this.config)
|
||||
}
|
||||
|
||||
private choreographyKey(action: ResolvedSemaAction, ctx: SemaContext): string {
|
||||
return `${action.component}:${action.name}:${this.elementId(this.scopeAnchor(action.scope, ctx))}`
|
||||
}
|
||||
|
||||
private scopeAnchor(scope: ResolvedSemaAction['scope'], ctx: SemaContext): HTMLElement {
|
||||
if (scope === 'component') return ctx.rootEl ?? ctx.targetEl
|
||||
if (scope === 'scene') {
|
||||
return (ctx.rootEl?.ownerDocument?.documentElement as HTMLElement | undefined) ??
|
||||
(ctx.targetEl.ownerDocument?.documentElement as HTMLElement | undefined) ??
|
||||
ctx.rootEl ??
|
||||
ctx.targetEl
|
||||
}
|
||||
return ctx.targetEl
|
||||
}
|
||||
|
||||
private elementId(el: HTMLElement): string {
|
||||
const existing = this.elementIds.get(el)
|
||||
if (existing) return existing
|
||||
const next = `el-${this.nextElementId++}`
|
||||
this.elementIds.set(el, next)
|
||||
return next
|
||||
}
|
||||
|
||||
private emitCustomEvent(
|
||||
target: HTMLElement,
|
||||
action: ResolvedSemaAction,
|
||||
phase: SemaPhase,
|
||||
signature: EffectiveSignature
|
||||
): void {
|
||||
target.dispatchEvent(
|
||||
createSemaEvent({
|
||||
event: action.event,
|
||||
action: action.name,
|
||||
component: action.component,
|
||||
phase,
|
||||
channels: signature.activeChannels,
|
||||
duration: this.durationOfSignature(signature)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private durationOfSignature(signature: EffectiveSignature): number {
|
||||
const durations = [
|
||||
signature.motion?.duration,
|
||||
signature.sound?.duration,
|
||||
signature.color?.duration,
|
||||
signature.presence?.duration
|
||||
].filter((value): value is number => value !== undefined)
|
||||
|
||||
return durations.length > 0 ? Math.max(...durations) : 0
|
||||
}
|
||||
|
||||
private warn(message: string): void {
|
||||
if (DEV) console.warn(message)
|
||||
}
|
||||
}
|
||||
|
||||
class Choreography {
|
||||
readonly promise: Promise<void>
|
||||
|
||||
private cancelled = false
|
||||
private repeatedCount = 0
|
||||
private settled = false
|
||||
private readonly abortController = new AbortController()
|
||||
private readonly settlePromise: () => void
|
||||
private readonly detachExternalAbort?: () => void
|
||||
|
||||
constructor(
|
||||
private readonly action: ResolvedSemaAction,
|
||||
private readonly ctx: SemaContext,
|
||||
private readonly signature: EffectiveSignature,
|
||||
private readonly capMs: number,
|
||||
private readonly engine: SemaEngine
|
||||
) {
|
||||
let resolvePromise = () => {}
|
||||
this.promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
this.settlePromise = resolvePromise
|
||||
|
||||
if (ctx.abortSignal) {
|
||||
const onAbort = () => this.cancel()
|
||||
if (ctx.abortSignal.aborted) {
|
||||
this.cancel()
|
||||
} else {
|
||||
ctx.abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
this.detachExternalAbort = () => {
|
||||
ctx.abortSignal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markRepeated(): void {
|
||||
this.repeatedCount++
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.cancelled) return
|
||||
this.cancelled = true
|
||||
this.abortController.abort()
|
||||
this.settle()
|
||||
}
|
||||
|
||||
async run(): Promise<'completed' | 'cancelled'> {
|
||||
if (this.action.scope !== 'scene' && !isConnected(this.ctx.targetEl)) {
|
||||
this.settle()
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
const channelPromises: Promise<void>[] = []
|
||||
const config = this.engine.currentConfig
|
||||
const channels = this.engine.channels
|
||||
|
||||
if (this.signature.activeChannels.includes('motion') && config.motion.enabled && this.signature.motion) {
|
||||
channelPromises.push(
|
||||
swallowAbortable(
|
||||
channels.motion.apply(this.ctx.targetEl, this.signature.motion, this.abortController.signal)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (this.signature.activeChannels.includes('sound') && config.sound.enabled && this.signature.sound) {
|
||||
channelPromises.push(
|
||||
swallowAbortable(
|
||||
channels.sound.apply(this.signature.sound, config.sound.gain, this.abortController.signal)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (this.signature.activeChannels.includes('color') && config.color.enabled && this.signature.color) {
|
||||
channelPromises.push(
|
||||
swallowAbortable(
|
||||
channels.color.apply(this.ctx.targetEl, this.signature.color, this.abortController.signal)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.signature.activeChannels.includes('presence') &&
|
||||
config.presence.enabled &&
|
||||
this.signature.presence
|
||||
) {
|
||||
channelPromises.push(
|
||||
swallowAbortable(
|
||||
channels.presence.apply(this.ctx.targetEl, this.signature.presence, this.abortController.signal)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (channelPromises.length === 0) {
|
||||
this.settle()
|
||||
return this.cancelled ? 'cancelled' : 'completed'
|
||||
}
|
||||
|
||||
const outcome = await Promise.race([
|
||||
Promise.all(channelPromises).then(() => 'completed' as const),
|
||||
delay(this.capMs).then(() => 'completed' as const),
|
||||
new Promise<'cancelled'>((resolve) => {
|
||||
if (this.abortController.signal.aborted) {
|
||||
resolve('cancelled')
|
||||
return
|
||||
}
|
||||
this.abortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => resolve('cancelled'),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
this.settle()
|
||||
return this.cancelled ? 'cancelled' : outcome
|
||||
}
|
||||
|
||||
private settle(): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
this.detachExternalAbort?.()
|
||||
this.settlePromise()
|
||||
}
|
||||
}
|
||||
|
||||
class SustainRunner {
|
||||
active = true
|
||||
|
||||
private cleanups: Array<() => void> = []
|
||||
|
||||
constructor(
|
||||
private readonly sustain: ResolvedSemaSustain,
|
||||
private readonly ctx: SemaContext,
|
||||
private readonly engine: SemaEngine
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
const signature = this.engine.resolveReducedSignature('sustain', this.ctx.targetEl)
|
||||
const config = this.engine.currentConfig
|
||||
const channels = this.engine.channels
|
||||
|
||||
if (signature.activeChannels.includes('motion') && config.motion.enabled && signature.motion) {
|
||||
this.cleanups.push(channels.motion.applySustained?.(this.ctx.targetEl, signature.motion) ?? noopCleanup)
|
||||
}
|
||||
if (signature.activeChannels.includes('sound') && config.sound.enabled && signature.sound) {
|
||||
this.cleanups.push(
|
||||
channels.sound.applySustained?.(signature.sound, config.sound.gain) ?? noopCleanup
|
||||
)
|
||||
}
|
||||
if (signature.activeChannels.includes('color') && config.color.enabled && signature.color) {
|
||||
this.cleanups.push(channels.color.applySustained?.(this.ctx.targetEl, signature.color) ?? noopCleanup)
|
||||
}
|
||||
if (signature.activeChannels.includes('presence') && config.presence.enabled && signature.presence) {
|
||||
this.cleanups.push(
|
||||
channels.presence.applySustained?.(this.ctx.targetEl, signature.presence) ?? noopCleanup
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.active) return
|
||||
this.active = false
|
||||
for (const cleanup of this.cleanups.splice(0)) {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let engineInstance: SemaEngine | null = null
|
||||
|
||||
function getEngine(): SemaEngine {
|
||||
if (!engineInstance) {
|
||||
engineInstance = new SemaEngine()
|
||||
}
|
||||
return engineInstance
|
||||
}
|
||||
|
||||
export function getSemaEngine(): SemaEngine {
|
||||
return getEngine()
|
||||
}
|
||||
|
||||
export function configureSema(config: EngineConfigPatch): void {
|
||||
getEngine().configure(config)
|
||||
}
|
||||
|
||||
export function destroySema(): void {
|
||||
if (!engineInstance) return
|
||||
engineInstance.destroy()
|
||||
engineInstance = null
|
||||
}
|
||||
|
||||
export function _resetEngineForTesting(): void {
|
||||
destroySema()
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Sema — public surface.
|
||||
*
|
||||
* Sema is a standalone layer. It owns its types and its validator. The
|
||||
* only import from outside is `PartRef`, a cross-layer primitive that
|
||||
* lives in `$uix/lib/types` — not in morfo. Sema has zero dependency on
|
||||
* the morfo module.
|
||||
*
|
||||
* Consumers (soma providers, demos, eventual runtime) import from here.
|
||||
* Inside `src/uix/sema/` prefer direct file imports.
|
||||
*/
|
||||
|
||||
export type {
|
||||
SemaEventLabel,
|
||||
SemaAttrWrite,
|
||||
SemaCommit,
|
||||
SemaAction,
|
||||
SemaSustainDecl,
|
||||
SemaSpec
|
||||
} from './types';
|
||||
|
||||
export { validateSema, SemaInvariantError } from './validation';
|
||||
|
||||
export type {
|
||||
SemaFamilyName,
|
||||
SemaIntentName,
|
||||
SemaActiveChannel,
|
||||
MotionSignature,
|
||||
SoundSignature,
|
||||
ColorSignature,
|
||||
PresenceSignature,
|
||||
EffectiveSignature,
|
||||
SemaMap,
|
||||
RuntimeOverrides,
|
||||
CSEMSelectorOverride,
|
||||
CSEMOverrides,
|
||||
ResolverOptions
|
||||
} from './resolver';
|
||||
|
||||
export { Resolver, defaultSemaMap } from './resolver';
|
||||
|
||||
export type { PartialSemaContext, ActionName, SustainName, SemaBinding } from './binding';
|
||||
|
||||
export { createSemaBinding } from './binding';
|
||||
|
||||
export type {
|
||||
SemaRuntimeConfig,
|
||||
MediaQueryListLike,
|
||||
A11ySnapshot,
|
||||
A11yMonitorOptions
|
||||
} from './a11y';
|
||||
|
||||
export { A11yMonitor, DEFAULT_SEMA_RUNTIME_CONFIG } from './a11y';
|
||||
|
||||
export type {
|
||||
SemaEventDetail,
|
||||
MotionChannelDriver,
|
||||
SoundChannelDriver,
|
||||
ColorChannelDriver,
|
||||
PresenceChannelDriver,
|
||||
SemaChannelDrivers,
|
||||
EngineDependencies,
|
||||
EngineConfig,
|
||||
EngineConfigPatch
|
||||
} from './engine';
|
||||
|
||||
export {
|
||||
SemaEngine,
|
||||
getSemaEngine,
|
||||
configureSema,
|
||||
destroySema,
|
||||
_resetEngineForTesting
|
||||
} from './engine';
|
||||
|
||||
export { MotionChannel } from './channels/motion';
|
||||
export { ColorChannel } from './channels/color';
|
||||
export { PresenceChannel } from './channels/presence';
|
||||
export { SoundChannel } from './channels/sound';
|
||||
|
||||
export type {
|
||||
ResolvedSemaAction,
|
||||
ResolvedSemaSustain,
|
||||
SemaContext,
|
||||
SemaSession,
|
||||
SemaPort,
|
||||
TestSemaPortOptions,
|
||||
TestSemaCall,
|
||||
TestSemaSession,
|
||||
TestSemaPortHandle
|
||||
} from './port';
|
||||
|
||||
export { noopSemaPort, createTestSemaPort } from './port';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports'
|
||||
@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
|
||||
import {
|
||||
createTestSemaPort,
|
||||
noopSemaPort,
|
||||
type ResolvedSemaAction,
|
||||
type ResolvedSemaSustain,
|
||||
type SemaContext
|
||||
} from './port'
|
||||
|
||||
const action: ResolvedSemaAction = {
|
||||
name: 'close-save',
|
||||
component: 'dialog',
|
||||
event: 'commit-fulfill',
|
||||
mode: 'blocking',
|
||||
regime: 'lock',
|
||||
scope: 'part',
|
||||
target: 'content',
|
||||
prewritten: [{ part: 'content', attr: 'data-last-action', value: 'saved' }]
|
||||
}
|
||||
|
||||
const sustain: ResolvedSemaSustain = {
|
||||
name: 'loading',
|
||||
component: 'spinner',
|
||||
target: 'spinner',
|
||||
scope: 'part'
|
||||
}
|
||||
|
||||
const ctx: SemaContext = {
|
||||
targetEl: {} as HTMLElement,
|
||||
snapshot: {
|
||||
'data-state': 'open'
|
||||
},
|
||||
cause: 'pointer'
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('noopSemaPort', () => {
|
||||
it('resolves before immediately and stays silent for fire', async () => {
|
||||
await expect(noopSemaPort.before(action, ctx)).resolves.toBeUndefined()
|
||||
expect(() => noopSemaPort.fire(action, ctx)).not.toThrow()
|
||||
})
|
||||
|
||||
it('returns an inactive sustain session', () => {
|
||||
const session = noopSemaPort.startSustain(sustain, ctx)
|
||||
expect(session.active).toBe(false)
|
||||
expect(() => session.stop()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTestSemaPort', () => {
|
||||
it('records before calls', async () => {
|
||||
const handle = createTestSemaPort()
|
||||
await handle.port.before(action, ctx)
|
||||
expect(handle.calls).toHaveLength(1)
|
||||
expect(handle.calls[0]).toMatchObject({
|
||||
kind: 'before',
|
||||
action,
|
||||
ctx
|
||||
})
|
||||
})
|
||||
|
||||
it('records fire calls synchronously', () => {
|
||||
const handle = createTestSemaPort()
|
||||
handle.port.fire(action, ctx)
|
||||
expect(handle.calls).toHaveLength(1)
|
||||
expect(handle.calls[0].kind).toBe('fire')
|
||||
})
|
||||
|
||||
it('creates active sustain sessions that can be stopped', () => {
|
||||
const handle = createTestSemaPort()
|
||||
const session = handle.port.startSustain(sustain, ctx)
|
||||
expect(handle.sessions).toHaveLength(1)
|
||||
expect(session.active).toBe(true)
|
||||
expect(handle.sessions[0].stopped).toBe(false)
|
||||
session.stop()
|
||||
expect(session.active).toBe(false)
|
||||
expect(handle.sessions[0].stopped).toBe(true)
|
||||
})
|
||||
|
||||
it('resets captured calls and sessions', async () => {
|
||||
const handle = createTestSemaPort()
|
||||
await handle.port.before(action, ctx)
|
||||
handle.port.startSustain(sustain, ctx)
|
||||
expect(handle.calls).toHaveLength(1)
|
||||
expect(handle.sessions).toHaveLength(1)
|
||||
handle.reset()
|
||||
expect(handle.calls).toHaveLength(0)
|
||||
expect(handle.sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('supports artificial before delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
const handle = createTestSemaPort({ beforeDelay: 50 })
|
||||
const promise = handle.port.before(action, ctx)
|
||||
let settled = false
|
||||
void promise.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(49)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await promise
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('can resolve before early when abort is respected', async () => {
|
||||
vi.useFakeTimers()
|
||||
const handle = createTestSemaPort({ beforeDelay: 50, respectAbort: true })
|
||||
const controller = new AbortController()
|
||||
const promise = handle.port.before(action, {
|
||||
...ctx,
|
||||
abortSignal: controller.signal
|
||||
})
|
||||
let settled = false
|
||||
void promise.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
controller.abort()
|
||||
await promise
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Sema runtime port.
|
||||
*
|
||||
* Boundary between sema callers (providers / future binding) and the runtime
|
||||
* implementation (real engine, no-op port, or test double).
|
||||
*/
|
||||
|
||||
import type { SemaAction, SemaEventLabel, SemaSustainDecl } from './types'
|
||||
|
||||
type ResolvedSemaMode = NonNullable<SemaAction['mode']>
|
||||
type ResolvedSemaRegime = NonNullable<SemaAction['regime']>
|
||||
type ResolvedSemaScope = NonNullable<SemaAction['scope']>
|
||||
|
||||
/**
|
||||
* Resolved action passed to the runtime. Defaults are already applied by the
|
||||
* caller before invoking the port.
|
||||
*/
|
||||
export interface ResolvedSemaAction {
|
||||
name: string
|
||||
component: string
|
||||
event: SemaEventLabel
|
||||
mode: ResolvedSemaMode
|
||||
regime: ResolvedSemaRegime
|
||||
scope: ResolvedSemaScope
|
||||
target: string
|
||||
prewritten: readonly { part: string; attr: string; value: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved sustain declaration passed to the runtime.
|
||||
*/
|
||||
export interface ResolvedSemaSustain {
|
||||
name: string
|
||||
component: string
|
||||
target: string
|
||||
scope: NonNullable<SemaSustainDecl['scope']>
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context built by the caller for an invocation.
|
||||
*/
|
||||
export interface SemaContext {
|
||||
targetEl: HTMLElement
|
||||
rootEl?: HTMLElement
|
||||
partEls?: Partial<Record<string, HTMLElement>>
|
||||
snapshot: Record<string, string | null>
|
||||
cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation'
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface SemaSession {
|
||||
stop(): void
|
||||
readonly active: boolean
|
||||
}
|
||||
|
||||
export interface SemaPort {
|
||||
before(action: ResolvedSemaAction, ctx: SemaContext): Promise<void>
|
||||
fire(action: ResolvedSemaAction, ctx: SemaContext): void
|
||||
startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op runtime boundary. Useful when the engine is not present yet or sema is
|
||||
* globally disabled. Calls never throw and promises resolve immediately.
|
||||
*/
|
||||
export const noopSemaPort: SemaPort = {
|
||||
before: async () => {},
|
||||
fire: () => {},
|
||||
startSustain: () => ({
|
||||
stop: () => {},
|
||||
active: false
|
||||
})
|
||||
}
|
||||
|
||||
export interface TestSemaPortOptions {
|
||||
/**
|
||||
* Optional artificial delay for `before()`, in milliseconds.
|
||||
*/
|
||||
beforeDelay?: number
|
||||
/**
|
||||
* When true, `before()` resolves early if `ctx.abortSignal` aborts.
|
||||
* Default false to keep the smallest possible test double surface.
|
||||
*/
|
||||
respectAbort?: boolean
|
||||
}
|
||||
|
||||
export interface TestSemaCall {
|
||||
kind: 'before' | 'fire'
|
||||
action: ResolvedSemaAction
|
||||
ctx: SemaContext
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface TestSemaSession extends SemaSession {
|
||||
sustain: ResolvedSemaSustain
|
||||
ctx: SemaContext
|
||||
readonly stopped: boolean
|
||||
}
|
||||
|
||||
export interface TestSemaPortHandle {
|
||||
port: SemaPort
|
||||
calls: TestSemaCall[]
|
||||
sessions: TestSemaSession[]
|
||||
reset(): void
|
||||
}
|
||||
|
||||
export function createTestSemaPort(opts: TestSemaPortOptions = {}): TestSemaPortHandle {
|
||||
const calls: TestSemaCall[] = []
|
||||
const sessions: TestSemaSession[] = []
|
||||
|
||||
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) return;
|
||||
if (!opts.respectAbort || !signal) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
return
|
||||
}
|
||||
if (signal.aborted) return
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
function onAbort() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
const port: SemaPort = {
|
||||
before: async (action, ctx) => {
|
||||
calls.push({
|
||||
kind: 'before',
|
||||
action,
|
||||
ctx,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
await delay(opts.beforeDelay ?? 0, ctx.abortSignal)
|
||||
},
|
||||
fire: (action, ctx) => {
|
||||
calls.push({
|
||||
kind: 'fire',
|
||||
action,
|
||||
ctx,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
},
|
||||
startSustain: (sustain, ctx) => {
|
||||
let active = true
|
||||
let stopped = false
|
||||
const session: TestSemaSession = {
|
||||
get active() {
|
||||
return active
|
||||
},
|
||||
get stopped() {
|
||||
return stopped
|
||||
},
|
||||
stop() {
|
||||
active = false
|
||||
stopped = true
|
||||
},
|
||||
sustain,
|
||||
ctx
|
||||
}
|
||||
sessions.push(session)
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
calls,
|
||||
sessions,
|
||||
reset() {
|
||||
calls.length = 0
|
||||
sessions.length = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Resolver, defaultSemaMap, type SemaMap } from './resolver'
|
||||
|
||||
function createContextEl(opts: {
|
||||
matches?: string[]
|
||||
closest?: string[]
|
||||
} = {}): HTMLElement {
|
||||
const matchSet = new Set(opts.matches ?? [])
|
||||
const closestSet = new Set(opts.closest ?? [])
|
||||
return {
|
||||
matches(selector: string) {
|
||||
if (selector === '!!invalid!!') throw new Error('invalid selector')
|
||||
return matchSet.has(selector)
|
||||
},
|
||||
closest(selector: string) {
|
||||
if (selector === '!!invalid!!') throw new Error('invalid selector')
|
||||
return closestSet.has(selector) ? ({} as Element) : null
|
||||
},
|
||||
ownerDocument: {
|
||||
documentElement: {
|
||||
matches(selector: string) {
|
||||
return selector === ':root'
|
||||
}
|
||||
}
|
||||
}
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
describe('Resolver', () => {
|
||||
it('resolves a transitional event from family base', () => {
|
||||
const resolver = new Resolver({ onWarn: () => {} })
|
||||
const sig = resolver.resolve('emerge')
|
||||
|
||||
expect(sig.event).toBe('emerge')
|
||||
expect(sig.activeChannels).toEqual(['motion', 'presence', 'sound'])
|
||||
expect(sig.motion?.duration).toBe(240)
|
||||
expect(sig.presence?.backdrop).toBe(0.35)
|
||||
expect(sig.sound?.contour).toBe('ascending')
|
||||
})
|
||||
|
||||
it('applies fulfill intent deltas over commit base', () => {
|
||||
const resolver = new Resolver({ onWarn: () => {} })
|
||||
const sig = resolver.resolve('commit-fulfill')
|
||||
|
||||
expect(sig.event).toBe('commit-fulfill')
|
||||
expect(sig.motion?.duration).toBeCloseTo(207)
|
||||
expect(sig.motion?.scale?.to).toBeCloseTo(1.04)
|
||||
expect(sig.sound?.pitch).toBe(1000)
|
||||
expect(sig.sound?.contour).toBe('ascending')
|
||||
expect(sig.color?.hue).toBe(155)
|
||||
expect(sig.color?.saturation).toBeCloseTo(0.4)
|
||||
expect(sig.color?.intensity).toBeCloseTo(0.45)
|
||||
})
|
||||
|
||||
it('falls back from invalid transitional+intent to the bare transitional event', () => {
|
||||
const warnings: string[] = []
|
||||
const resolver = new Resolver({ onWarn: (msg) => warnings.push(msg) })
|
||||
const sig = resolver.resolve('emerge-threat')
|
||||
|
||||
expect(sig.event).toBe('emerge')
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatch(/falling back to "emerge"/)
|
||||
})
|
||||
|
||||
it('falls back from invalid valential intent to family-neutral', () => {
|
||||
const warnings: string[] = []
|
||||
const resolver = new Resolver({ onWarn: (msg) => warnings.push(msg) })
|
||||
const sig = resolver.resolve('commit-happy')
|
||||
|
||||
expect(sig.event).toBe('commit-neutral')
|
||||
expect(sig.sound?.pitch).toBe(700)
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatch(/commit-neutral/)
|
||||
})
|
||||
|
||||
it('falls back to contact-neutral for unknown families', () => {
|
||||
const warnings: string[] = []
|
||||
const resolver = new Resolver({ onWarn: (msg) => warnings.push(msg) })
|
||||
const sig = resolver.resolve('comit-fulfill')
|
||||
|
||||
expect(sig.event).toBe('contact-neutral')
|
||||
expect(sig.sound?.pitch).toBe(800)
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatch(/contact-neutral/)
|
||||
})
|
||||
|
||||
it('uses family base when the intent is missing from the map', () => {
|
||||
const map = structuredClone(defaultSemaMap) as SemaMap
|
||||
delete map.intents.fulfill
|
||||
const warnings: string[] = []
|
||||
const resolver = new Resolver({
|
||||
map,
|
||||
onWarn: (msg) => warnings.push(msg)
|
||||
})
|
||||
const sig = resolver.resolve('commit-fulfill')
|
||||
|
||||
expect(sig.event).toBe('commit-fulfill')
|
||||
expect(sig.sound?.pitch).toBe(700)
|
||||
expect(sig.color?.hue).toBe(210)
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatch(/Intent "fulfill" missing/)
|
||||
})
|
||||
|
||||
it('throws when the map is corrupted and a family base is missing', () => {
|
||||
const map = structuredClone(defaultSemaMap) as SemaMap
|
||||
delete map.families.commit
|
||||
const resolver = new Resolver({
|
||||
map,
|
||||
onWarn: () => {}
|
||||
})
|
||||
|
||||
expect(() => resolver.resolve('commit-affirm')).toThrow(/Family "commit" not found/)
|
||||
})
|
||||
|
||||
it('applies runtime overrides before resolving', () => {
|
||||
const resolver = new Resolver({
|
||||
onWarn: () => {},
|
||||
runtimeOverrides: {
|
||||
'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 },
|
||||
'families.commit.base.sound.pitch': 900
|
||||
}
|
||||
})
|
||||
const sig = resolver.resolve('commit-fulfill')
|
||||
|
||||
expect(sig.sound?.pitch).toBe(1200)
|
||||
expect(sig.color?.hue).toBe(0)
|
||||
})
|
||||
|
||||
it('attaches sampleUrl from the sound pack when present', () => {
|
||||
const resolver = new Resolver({
|
||||
map: {
|
||||
...structuredClone(defaultSemaMap),
|
||||
soundPack: {
|
||||
'alert-threat': '/sounds/alarm.wav'
|
||||
}
|
||||
},
|
||||
onWarn: () => {}
|
||||
})
|
||||
|
||||
const sig = resolver.resolve('alert-threat')
|
||||
expect(sig.sound?.sampleUrl).toBe('/sounds/alarm.wav')
|
||||
})
|
||||
|
||||
it('applies CSEM overrides for a directly matching selector', () => {
|
||||
const resolver = new Resolver({
|
||||
onWarn: () => {},
|
||||
csemOverrides: {
|
||||
selectors: [
|
||||
{
|
||||
selector: '[data-dialog][data-last-action="saved"]',
|
||||
overrides: {
|
||||
'commit-fulfill': {
|
||||
color: {
|
||||
intensity: { op: 'replace', value: 0.6 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const sig = resolver.resolve(
|
||||
'commit-fulfill',
|
||||
createContextEl({ matches: ['[data-dialog][data-last-action="saved"]'] })
|
||||
)
|
||||
expect(sig.color?.intensity).toBe(0.6)
|
||||
})
|
||||
|
||||
it('applies CSEM overrides when an ancestor selector matches through closest()', () => {
|
||||
const resolver = new Resolver({
|
||||
onWarn: () => {},
|
||||
csemOverrides: {
|
||||
selectors: [
|
||||
{
|
||||
selector: '.quiet-zone',
|
||||
overrides: {
|
||||
'alert-threat': {
|
||||
sound: {
|
||||
gain: { op: 'replace', value: 0.15 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const sig = resolver.resolve('alert-threat', createContextEl({ closest: ['.quiet-zone'] }))
|
||||
expect(sig.sound?.gain).toBe(0.15)
|
||||
})
|
||||
|
||||
it('applies :root CSEM overrides globally', () => {
|
||||
const resolver = new Resolver({
|
||||
onWarn: () => {},
|
||||
csemOverrides: {
|
||||
selectors: [
|
||||
{
|
||||
selector: ':root',
|
||||
overrides: {
|
||||
'commit-fulfill': {
|
||||
sound: {
|
||||
pitch: { op: 'replace', value: 1200 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const sig = resolver.resolve('commit-fulfill', createContextEl())
|
||||
expect(sig.sound?.pitch).toBe(1200)
|
||||
})
|
||||
|
||||
it('can replace runtime overrides after construction', () => {
|
||||
const resolver = new Resolver({
|
||||
onWarn: () => {},
|
||||
runtimeOverrides: {
|
||||
'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
resolver.setRuntimeOverrides({
|
||||
'intents.fulfill.deltas.color.hue': { op: 'replace', value: 270 }
|
||||
})
|
||||
|
||||
const sig = resolver.resolve('commit-fulfill')
|
||||
expect(sig.color?.hue).toBe(270)
|
||||
})
|
||||
|
||||
it('ignores invalid CSEM selectors with a warning', () => {
|
||||
const warnings: string[] = []
|
||||
const resolver = new Resolver({
|
||||
onWarn: (msg) => warnings.push(msg),
|
||||
csemOverrides: {
|
||||
selectors: [
|
||||
{
|
||||
selector: '!!invalid!!',
|
||||
overrides: {
|
||||
'commit-fulfill': {
|
||||
color: {
|
||||
intensity: { op: 'replace', value: 0.9 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const sig = resolver.resolve('commit-fulfill', createContextEl())
|
||||
expect(sig.color?.intensity).toBeCloseTo(0.45)
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatch(/Invalid CSEM selector/)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,323 @@
|
||||
import { DEV } from 'esm-env'
|
||||
|
||||
import type { SemaEventLabel } from './types'
|
||||
import semaMapJson from './sema-map.json'
|
||||
|
||||
export type SemaFamilyName = 'contact' | 'commit' | 'alert' | 'handle' | 'emerge' | 'sustain'
|
||||
export type SemaIntentName = 'threat' | 'risk' | 'neutral' | 'affirm' | 'fulfill'
|
||||
export type SemaActiveChannel = 'motion' | 'sound' | 'color' | 'presence'
|
||||
|
||||
export interface MotionSignature {
|
||||
duration: number
|
||||
easing: string
|
||||
scale?: { from: number; to: number }
|
||||
translate?: { x: number; y: number }
|
||||
rotate?: number
|
||||
}
|
||||
|
||||
export interface SoundSignature {
|
||||
pitch: number
|
||||
centroid: number
|
||||
roughness: number
|
||||
attack: number
|
||||
decay: number
|
||||
duration: number
|
||||
contour: 'flat' | 'ascending' | 'descending' | 'arc' | 'bell'
|
||||
gain: number
|
||||
sampleUrl?: string
|
||||
}
|
||||
|
||||
export interface ColorSignature {
|
||||
hue: number
|
||||
saturation: number
|
||||
lightness: number
|
||||
duration: number
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export interface PresenceSignature {
|
||||
opacity: { from: number; to: number }
|
||||
shadow?: { blur: number; y: number; opacity: number }
|
||||
backdrop?: number
|
||||
outline?: { width: number; style: string }
|
||||
duration: number
|
||||
easing: string
|
||||
}
|
||||
|
||||
export interface EffectiveSignature {
|
||||
event: SemaEventLabel
|
||||
activeChannels: SemaActiveChannel[]
|
||||
motion?: MotionSignature
|
||||
sound?: SoundSignature
|
||||
color?: ColorSignature
|
||||
presence?: PresenceSignature
|
||||
}
|
||||
|
||||
type DeltaOp =
|
||||
| { op: 'multiply'; factor: number }
|
||||
| { op: 'replace'; value: number | string | boolean | null }
|
||||
| { op: 'add'; value: number }
|
||||
|
||||
type DeltaValue = number | string | boolean | null | DeltaOp | { [key: string]: DeltaValue }
|
||||
|
||||
interface FamilyMapEntry {
|
||||
base: {
|
||||
motion: MotionSignature | null
|
||||
sound: SoundSignature | null
|
||||
color: ColorSignature | null
|
||||
presence: PresenceSignature | null
|
||||
}
|
||||
activeChannels: SemaActiveChannel[]
|
||||
}
|
||||
|
||||
interface IntentMapEntry {
|
||||
deltas: Record<string, DeltaValue>
|
||||
}
|
||||
|
||||
export interface SemaMap {
|
||||
version: string
|
||||
families: Record<SemaFamilyName, FamilyMapEntry>
|
||||
intents: Record<SemaIntentName, IntentMapEntry>
|
||||
soundPack: Record<string, string>
|
||||
}
|
||||
|
||||
export type RuntimeOverrides = Record<string, DeltaValue>
|
||||
export interface CSEMSelectorOverride {
|
||||
selector: string
|
||||
overrides: Record<string, Record<string, DeltaValue>>
|
||||
}
|
||||
|
||||
export interface CSEMOverrides {
|
||||
selectors: CSEMSelectorOverride[]
|
||||
}
|
||||
|
||||
export interface ResolverOptions {
|
||||
map?: SemaMap
|
||||
runtimeOverrides?: RuntimeOverrides
|
||||
csemOverrides?: CSEMOverrides
|
||||
onWarn?: (message: string) => void
|
||||
}
|
||||
|
||||
const DEFAULT_EVENT: SemaEventLabel = 'contact-neutral'
|
||||
const VALENTIAL_FAMILIES: SemaFamilyName[] = ['contact', 'commit', 'alert', 'handle']
|
||||
const TRANSITIONAL_FAMILIES: SemaFamilyName[] = ['emerge', 'sustain']
|
||||
const KNOWN_INTENTS: SemaIntentName[] = ['threat', 'risk', 'neutral', 'affirm', 'fulfill']
|
||||
|
||||
export const defaultSemaMap = semaMapJson as SemaMap
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDeltaOp(value: unknown): value is DeltaOp {
|
||||
return isRecord(value) && typeof value.op === 'string'
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
}
|
||||
|
||||
function toCanonicalEvent(family: SemaFamilyName, intent: SemaIntentName | null): SemaEventLabel {
|
||||
if (!intent) return family as Extract<SemaEventLabel, 'emerge' | 'sustain'>
|
||||
return `${family}-${intent}` as SemaEventLabel
|
||||
}
|
||||
|
||||
function applyLeaf(base: unknown, delta: DeltaValue): unknown {
|
||||
if (typeof delta === 'number') {
|
||||
return typeof base === 'number' ? base + delta : delta
|
||||
}
|
||||
if (typeof delta === 'string' || typeof delta === 'boolean' || delta === null) {
|
||||
return delta
|
||||
}
|
||||
if (isDeltaOp(delta)) {
|
||||
if (delta.op === 'replace') return delta.value
|
||||
if (typeof base !== 'number') return base
|
||||
if (delta.op === 'multiply') return base * delta.factor
|
||||
return base + delta.value
|
||||
}
|
||||
if (!isRecord(delta)) return base
|
||||
if (!isRecord(base)) return base
|
||||
|
||||
const out: Record<string, unknown> = deepClone(base)
|
||||
for (const [key, nextDelta] of Object.entries(delta)) {
|
||||
out[key] = applyLeaf(out[key], nextDelta as DeltaValue)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function applyMapOverrides(baseMap: SemaMap, overrides: RuntimeOverrides = {}): SemaMap {
|
||||
const next = deepClone(baseMap)
|
||||
for (const [path, value] of Object.entries(overrides)) {
|
||||
const parts = path.split('.')
|
||||
let cursor: Record<string, unknown> = next as unknown as Record<string, unknown>
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]
|
||||
if (!isRecord(cursor[key])) cursor[key] = {}
|
||||
cursor = cursor[key] as Record<string, unknown>
|
||||
}
|
||||
cursor[parts[parts.length - 1]] = value
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export class Resolver {
|
||||
private readonly baseMap: SemaMap
|
||||
private map: SemaMap
|
||||
private runtimeOverrides: RuntimeOverrides
|
||||
private readonly csemOverrides?: CSEMOverrides
|
||||
private readonly onWarn?: (message: string) => void
|
||||
|
||||
constructor(opts: ResolverOptions = {}) {
|
||||
this.baseMap = deepClone(opts.map ?? defaultSemaMap)
|
||||
this.runtimeOverrides = deepClone(opts.runtimeOverrides ?? {})
|
||||
this.map = applyMapOverrides(this.baseMap, this.runtimeOverrides)
|
||||
this.csemOverrides = opts.csemOverrides
|
||||
this.onWarn = opts.onWarn
|
||||
}
|
||||
|
||||
setRuntimeOverrides(overrides: RuntimeOverrides = {}): void {
|
||||
this.runtimeOverrides = deepClone(overrides)
|
||||
this.map = applyMapOverrides(this.baseMap, this.runtimeOverrides)
|
||||
}
|
||||
|
||||
resolve(event: string, contextEl?: HTMLElement): EffectiveSignature {
|
||||
const normalized = this.normalizeEvent(event)
|
||||
const familyData = this.map.families[normalized.family]
|
||||
if (!familyData) {
|
||||
throw new Error(`[sema] Family "${normalized.family}" not found in sema-map`)
|
||||
}
|
||||
|
||||
let signature: EffectiveSignature = {
|
||||
event: normalized.event,
|
||||
activeChannels: [...familyData.activeChannels],
|
||||
motion: familyData.base.motion ? deepClone(familyData.base.motion) : undefined,
|
||||
sound: familyData.base.sound ? deepClone(familyData.base.sound) : undefined,
|
||||
color: familyData.base.color ? deepClone(familyData.base.color) : undefined,
|
||||
presence: familyData.base.presence ? deepClone(familyData.base.presence) : undefined
|
||||
}
|
||||
|
||||
if (normalized.intent) {
|
||||
const intentData = this.map.intents[normalized.intent]
|
||||
if (!intentData) {
|
||||
this.warn(
|
||||
`[sema] Intent "${normalized.intent}" missing in sema-map; using family base for "${normalized.event}".`
|
||||
)
|
||||
} else {
|
||||
signature = this.applyIntentDelta(signature, intentData.deltas)
|
||||
}
|
||||
}
|
||||
|
||||
if (contextEl && this.csemOverrides) {
|
||||
signature = this.applyCSEMOverrides(signature, contextEl)
|
||||
}
|
||||
|
||||
const sampleUrl = this.map.soundPack[normalized.event]
|
||||
if (sampleUrl && signature.sound) {
|
||||
signature.sound.sampleUrl = sampleUrl
|
||||
}
|
||||
|
||||
return signature
|
||||
}
|
||||
|
||||
private normalizeEvent(event: string): {
|
||||
event: SemaEventLabel
|
||||
family: SemaFamilyName
|
||||
intent: SemaIntentName | null
|
||||
} {
|
||||
const [first, ...rest] = event.split('-')
|
||||
const family = first as SemaFamilyName
|
||||
const intentText = rest.length > 0 ? rest.join('-') : null
|
||||
|
||||
if (TRANSITIONAL_FAMILIES.includes(family)) {
|
||||
if (intentText) {
|
||||
this.warn(
|
||||
`[sema] Event "${event}" is invalid for transitional family "${family}"; falling back to "${family}".`
|
||||
)
|
||||
}
|
||||
return {
|
||||
event: toCanonicalEvent(family, null),
|
||||
family,
|
||||
intent: null
|
||||
}
|
||||
}
|
||||
|
||||
if (VALENTIAL_FAMILIES.includes(family)) {
|
||||
if (intentText && KNOWN_INTENTS.includes(intentText as SemaIntentName)) {
|
||||
return {
|
||||
event: toCanonicalEvent(family, intentText as SemaIntentName),
|
||||
family,
|
||||
intent: intentText as SemaIntentName
|
||||
}
|
||||
}
|
||||
this.warn(
|
||||
`[sema] Event "${event}" has invalid or missing intent for family "${family}"; falling back to "${family}-neutral".`
|
||||
)
|
||||
return {
|
||||
event: toCanonicalEvent(family, 'neutral'),
|
||||
family,
|
||||
intent: 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
this.warn(
|
||||
`[sema] Event "${event}" is not canonical; falling back to "${DEFAULT_EVENT}".`
|
||||
)
|
||||
return {
|
||||
event: DEFAULT_EVENT,
|
||||
family: 'contact',
|
||||
intent: 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
private applyIntentDelta(
|
||||
signature: EffectiveSignature,
|
||||
deltas: Record<string, DeltaValue>
|
||||
): EffectiveSignature {
|
||||
const next = deepClone(signature)
|
||||
for (const channel of next.activeChannels) {
|
||||
const delta = deltas[channel]
|
||||
if (!delta) continue
|
||||
const current = next[channel]
|
||||
if (!current) continue
|
||||
next[channel] = applyLeaf(current, delta) as never
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private applyCSEMOverrides(signature: EffectiveSignature, contextEl: HTMLElement): EffectiveSignature {
|
||||
let next = deepClone(signature)
|
||||
for (const rule of this.csemOverrides?.selectors ?? []) {
|
||||
if (!this.matchesSelector(contextEl, rule.selector)) continue
|
||||
const eventOverrides = rule.overrides[next.event]
|
||||
if (!eventOverrides) continue
|
||||
for (const channel of next.activeChannels) {
|
||||
const delta = eventOverrides[channel]
|
||||
if (!delta) continue
|
||||
const current = next[channel]
|
||||
if (!current) continue
|
||||
next[channel] = applyLeaf(current, delta) as never
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private matchesSelector(contextEl: HTMLElement, selector: string): boolean {
|
||||
try {
|
||||
if (selector === ':root') {
|
||||
return contextEl.ownerDocument?.documentElement?.matches(':root') ?? false
|
||||
}
|
||||
return contextEl.matches(selector) || contextEl.closest(selector) !== null
|
||||
} catch {
|
||||
this.warn(`[sema] Invalid CSEM selector "${selector}" ignored.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private warn(message: string): void {
|
||||
if (this.onWarn) {
|
||||
this.onWarn(message)
|
||||
return
|
||||
}
|
||||
if (DEV) console.warn(message)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,210 @@
|
||||
{
|
||||
"version": "0.4.0",
|
||||
"families": {
|
||||
"contact": {
|
||||
"base": {
|
||||
"motion": {
|
||||
"duration": 80,
|
||||
"easing": "ease-out",
|
||||
"scale": { "from": 1, "to": 0.96 },
|
||||
"translate": { "x": 0, "y": 0 }
|
||||
},
|
||||
"sound": {
|
||||
"pitch": 800,
|
||||
"centroid": 2000,
|
||||
"roughness": 0.1,
|
||||
"attack": 4,
|
||||
"decay": 40,
|
||||
"duration": 60,
|
||||
"contour": "flat",
|
||||
"gain": 0.25
|
||||
},
|
||||
"color": null,
|
||||
"presence": null
|
||||
},
|
||||
"activeChannels": ["motion", "sound"]
|
||||
},
|
||||
"commit": {
|
||||
"base": {
|
||||
"motion": {
|
||||
"duration": 180,
|
||||
"easing": "ease-out",
|
||||
"scale": { "from": 1, "to": 1.02 }
|
||||
},
|
||||
"sound": {
|
||||
"pitch": 700,
|
||||
"centroid": 1800,
|
||||
"roughness": 0.1,
|
||||
"attack": 8,
|
||||
"decay": 120,
|
||||
"duration": 100,
|
||||
"contour": "flat",
|
||||
"gain": 0.3
|
||||
},
|
||||
"color": {
|
||||
"hue": 210,
|
||||
"saturation": 0.3,
|
||||
"lightness": 0.5,
|
||||
"duration": 200,
|
||||
"intensity": 0.3
|
||||
},
|
||||
"presence": null
|
||||
},
|
||||
"activeChannels": ["motion", "sound", "color"]
|
||||
},
|
||||
"alert": {
|
||||
"base": {
|
||||
"motion": {
|
||||
"duration": 220,
|
||||
"easing": "ease-in-out",
|
||||
"scale": { "from": 1, "to": 1.03 },
|
||||
"translate": { "x": 0, "y": 0 }
|
||||
},
|
||||
"sound": {
|
||||
"pitch": 900,
|
||||
"centroid": 2400,
|
||||
"roughness": 0.3,
|
||||
"attack": 3,
|
||||
"decay": 150,
|
||||
"duration": 180,
|
||||
"contour": "arc",
|
||||
"gain": 0.4
|
||||
},
|
||||
"color": {
|
||||
"hue": 40,
|
||||
"saturation": 0.7,
|
||||
"lightness": 0.55,
|
||||
"duration": 220,
|
||||
"intensity": 0.5
|
||||
},
|
||||
"presence": null
|
||||
},
|
||||
"activeChannels": ["motion", "sound", "color"]
|
||||
},
|
||||
"emerge": {
|
||||
"base": {
|
||||
"motion": {
|
||||
"duration": 240,
|
||||
"easing": "ease-out",
|
||||
"scale": { "from": 0.96, "to": 1 }
|
||||
},
|
||||
"sound": {
|
||||
"pitch": 600,
|
||||
"centroid": 1500,
|
||||
"roughness": 0.05,
|
||||
"attack": 12,
|
||||
"decay": 200,
|
||||
"duration": 150,
|
||||
"contour": "ascending",
|
||||
"gain": 0.2
|
||||
},
|
||||
"color": null,
|
||||
"presence": {
|
||||
"opacity": { "from": 0, "to": 1 },
|
||||
"shadow": { "blur": 24, "y": 8, "opacity": 0.15 },
|
||||
"backdrop": 0.35,
|
||||
"duration": 280,
|
||||
"easing": "ease-out"
|
||||
}
|
||||
},
|
||||
"activeChannels": ["motion", "presence", "sound"]
|
||||
},
|
||||
"handle": {
|
||||
"base": {
|
||||
"motion": {
|
||||
"duration": 40,
|
||||
"easing": "linear",
|
||||
"scale": { "from": 1, "to": 1 }
|
||||
},
|
||||
"sound": null,
|
||||
"color": null,
|
||||
"presence": null
|
||||
},
|
||||
"activeChannels": ["motion"]
|
||||
},
|
||||
"sustain": {
|
||||
"base": {
|
||||
"motion": null,
|
||||
"sound": null,
|
||||
"color": null,
|
||||
"presence": {
|
||||
"opacity": { "from": 1, "to": 1 },
|
||||
"duration": 0,
|
||||
"easing": "linear"
|
||||
}
|
||||
},
|
||||
"activeChannels": ["presence"]
|
||||
}
|
||||
},
|
||||
"intents": {
|
||||
"threat": {
|
||||
"deltas": {
|
||||
"motion": {
|
||||
"duration": { "op": "multiply", "factor": 1.1 },
|
||||
"easing": "ease-in-out",
|
||||
"scale": { "to": 0.01 }
|
||||
},
|
||||
"sound": {
|
||||
"pitch": -200,
|
||||
"roughness": 0.4,
|
||||
"contour": "descending",
|
||||
"gain": 0.1
|
||||
},
|
||||
"color": {
|
||||
"hue": { "op": "replace", "value": 0 },
|
||||
"saturation": { "op": "add", "value": 0.2 },
|
||||
"intensity": 0.2
|
||||
},
|
||||
"presence": {
|
||||
"backdrop": 0.1,
|
||||
"shadow": { "blur": 2 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"risk": {
|
||||
"deltas": {
|
||||
"sound": {
|
||||
"pitch": -100,
|
||||
"roughness": 0.2
|
||||
},
|
||||
"color": {
|
||||
"hue": { "op": "replace", "value": 30 },
|
||||
"saturation": 0.1
|
||||
}
|
||||
}
|
||||
},
|
||||
"neutral": {
|
||||
"deltas": {}
|
||||
},
|
||||
"affirm": {
|
||||
"deltas": {
|
||||
"sound": { "pitch": 100 },
|
||||
"color": {
|
||||
"hue": { "op": "replace", "value": 145 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"fulfill": {
|
||||
"deltas": {
|
||||
"motion": {
|
||||
"duration": { "op": "multiply", "factor": 1.15 },
|
||||
"scale": { "to": 0.02 }
|
||||
},
|
||||
"sound": {
|
||||
"pitch": 300,
|
||||
"contour": "ascending",
|
||||
"gain": 0.05
|
||||
},
|
||||
"color": {
|
||||
"hue": { "op": "replace", "value": 155 },
|
||||
"saturation": { "op": "add", "value": 0.1 },
|
||||
"intensity": 0.15
|
||||
},
|
||||
"presence": {
|
||||
"shadow": { "blur": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"soundPack": {}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,308 @@
|
||||
# SemaUIX — Implementación de Sema
|
||||
|
||||
> Este documento describe cómo `src/uix/sema/` materializa hoy parte de la spec Sema.
|
||||
> No reemplaza la spec: la asume leída y referenciada. Aquí se documenta el estado
|
||||
> real del repo y, cuando aplica, la dirección prevista.
|
||||
|
||||
## Estado de este documento
|
||||
|
||||
- **Implementado**: existe en el repo, compila, funciona y tiene tests.
|
||||
- **Planificado (diseñado)**: la firma y el comportamiento base están decididos,
|
||||
pero todavía no existe código.
|
||||
- **Sketch**: idea arquitectónica orientativa; la API puede cambiar de forma
|
||||
material al implementarse.
|
||||
|
||||
## 1. Mapa de estado actual
|
||||
|
||||
| Aspecto | Estado | Realidad actual en SemaUIX |
|
||||
|---|---|---|
|
||||
| Tipos sema (`SemaSpec`, `SemaAction`, `SemaSustainDecl`) | Implementado | Viven en `src/uix/sema/types.ts` |
|
||||
| Validador de invariantes (`validateSema`) | Implementado | Vive en `src/uix/sema/validation.ts` |
|
||||
| Contrato cross-layer con morfo | Implementado | `morfo` y `sema` son artefactos separados, relacionados por `kebab` y `PartRef` |
|
||||
| Validación cruzada `sema` + `morfo` | Implementado | Se invoca explícitamente con `validateSema(spec, morfo)` |
|
||||
| Hook automático desde `schema.ts` | No implementado | `src/uix/morfo/schema.ts` no conoce Sema |
|
||||
| `SemaPort` / `noopSemaPort` / `testSemaPort` | Planificado (diseñado) | Las firmas están pensadas, pero no existen en el repo |
|
||||
| `createSemaBinding()` | Sketch | La idea está clara, pero la API real puede cambiar al bajar a providers Svelte 5 |
|
||||
| Engine real + `.csem` + `sema-map.json` | Sketch | Fuera del estado actual del repo |
|
||||
|
||||
## 2. Contrato cross-layer hoy
|
||||
|
||||
### 2.1. Sema es una capa autónoma
|
||||
|
||||
**Implementado**
|
||||
|
||||
Sema no está embebida dentro de `morfo`. La forma actual en el repo es:
|
||||
|
||||
- `dialogMorfo` declara la superficie DOM pública del componente
|
||||
- `dialogSema` declara sus acciones y sustains semánticos
|
||||
- ambos artefactos se coordinan por `kebab` y por referencias a `PartRef`
|
||||
- el validador cruza ambos solo cuando se le pasa `morfo` como contexto
|
||||
|
||||
Esto preserva la autonomía entre capas:
|
||||
|
||||
- `morfo` puede existir sin `sema`
|
||||
- `sema` puede existir sin `morfo`
|
||||
- cuando ambas existen, se validan juntas por convención explícita, no por acoplamiento implícito
|
||||
|
||||
### 2.2. Superficie pública real de `src/uix/sema`
|
||||
|
||||
**Implementado**
|
||||
|
||||
La superficie pública actual es la exportada por [exports.ts](/G:/dev/svelte/vicen/src/uix/sema/exports.ts):
|
||||
|
||||
- tipos: `SemaEventLabel`, `SemaAttrWrite`, `SemaCommit`, `SemaAction`, `SemaSustainDecl`, `SemaSpec`
|
||||
- runtime: `validateSema()` y `SemaInvariantError`
|
||||
|
||||
No hay más runtime público hoy. En particular, **no** existen todavía:
|
||||
|
||||
- `SemaPort`
|
||||
- `noopSemaPort`
|
||||
- `testSemaPort`
|
||||
- `createSemaBinding`
|
||||
- `before()` / `fire()` / `start()`
|
||||
|
||||
### 2.3. Tipo actual de una declaración sema
|
||||
|
||||
**Implementado**
|
||||
|
||||
`src/uix/sema/types.ts` modela hoy:
|
||||
|
||||
- `SemaAction`
|
||||
- `name`
|
||||
- `target`
|
||||
- `event`
|
||||
- `mode?`
|
||||
- `regime?`
|
||||
- `scope?`
|
||||
- `prewrite?`
|
||||
- `commits?`
|
||||
- `SemaSustainDecl`
|
||||
- `name`
|
||||
- `target`
|
||||
- `activeWhen`
|
||||
- `event: 'sustain'`
|
||||
- `scope?`
|
||||
- `SemaSpec`
|
||||
- `kebab`
|
||||
- `actions`
|
||||
- `sustains?`
|
||||
|
||||
Los defaults conceptuales siguen siendo los de la spec:
|
||||
|
||||
- `mode` → `blocking`
|
||||
- `regime` → `replace`
|
||||
- `scope` → `part`
|
||||
|
||||
Hoy esos defaults son **convención semántica**; todavía no existe un binding/runtime que los materialice operativamente.
|
||||
|
||||
## 3. Validación actual
|
||||
|
||||
### 3.1. Qué valida `validateSema()`
|
||||
|
||||
**Implementado**
|
||||
|
||||
`validateSema(spec, morfo?)` valida dos grupos de reglas.
|
||||
|
||||
**Sin morfo**
|
||||
|
||||
1. `action.name` es único dentro del spec
|
||||
2. `action.event` pertenece al vocabulario canónico `SemaEventLabel`
|
||||
|
||||
**Con morfo**
|
||||
|
||||
3. `spec.kebab === morfo.kebab`
|
||||
4. `action.target` resuelve a un part existente
|
||||
5. `prewrite[].part` resuelve
|
||||
6. `prewrite[].attr` existe en `data[]` del part destino
|
||||
7. `prewrite[].value` pertenece a `values[]` si el attr es enumerable
|
||||
8. `commits.part` resuelve
|
||||
9. `commits.value` pertenece a `states[]` si `commits.attr === 'data-state'`
|
||||
10. `data-last-action.values[]` coincide exactamente con la unión de prewrites que escriben ese attr
|
||||
11. `sustains[].target` y `sustains[].activeWhen.part` resuelven
|
||||
|
||||
### 3.2. Qué **no** valida `validateSema()`
|
||||
|
||||
**Implementado**
|
||||
|
||||
`validateSema()` asume que el `spec` llega ya tipado con TypeScript, por ejemplo:
|
||||
|
||||
```ts
|
||||
export const dialogSema = {
|
||||
kebab: 'dialog',
|
||||
actions: [/* ... */]
|
||||
} as const satisfies SemaSpec;
|
||||
```
|
||||
|
||||
Por eso, a diferencia de `validateMorfo()`, **no** hace decode completo del shape runtime.
|
||||
No está pensado para aceptar JSON arbitrario o input no tipado; su responsabilidad actual es
|
||||
validar invariantes semánticos y referencias cruzadas sobre entrada ya tipada.
|
||||
|
||||
Si más adelante aparece una necesidad real de consumir specs no tipados, entonces tendría sentido
|
||||
plantear una segunda capa de decode. Hoy no existe.
|
||||
|
||||
### 3.3. No hay hook automático desde `schema.ts`
|
||||
|
||||
**Implementado**
|
||||
|
||||
A diferencia de una versión anterior de esta documentación, `src/uix/morfo/schema.ts` **no**
|
||||
inyecta validaciones Sema automáticamente.
|
||||
|
||||
La realidad hoy es esta:
|
||||
|
||||
- `validateMorfo(morfo)` valida solo morfo
|
||||
- `validateSema(spec, morfo)` valida solo sema + cross-checks con morfo
|
||||
- cada componente que declare ambos debe invocarlos explícitamente en tests o sanity-checks
|
||||
|
||||
Esto es deliberado: mantiene la autonomía entre capas y evita que `morfo` tenga que conocer el
|
||||
runtime o el validador de `sema`.
|
||||
|
||||
### 3.4. Patrón de test recomendado
|
||||
|
||||
**Implementado**
|
||||
|
||||
Patrón real hoy, tomando dialog como referencia:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateMorfo } from '$uix/morfo/schema';
|
||||
import { validateSema } from '$uix/sema/validation';
|
||||
import { dialogMorfo, dialogSema } from './dialog';
|
||||
|
||||
describe('dialog contracts', () => {
|
||||
it('passes morfo validation', () => {
|
||||
expect(() => validateMorfo(dialogMorfo)).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes sema validation with morfo cross-checks', () => {
|
||||
expect(() => validateSema(dialogSema, dialogMorfo)).not.toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Cada componente con declaración sema debería tener al menos:
|
||||
|
||||
- un test verde de `validateMorfo(morfo)`
|
||||
- un test verde de `validateSema(sema, morfo)`
|
||||
- varios tests rojos de invariantes rotos relevantes
|
||||
|
||||
## 4. Ejemplo actual: `dialog`
|
||||
|
||||
**Implementado**
|
||||
|
||||
El ejemplo real hoy vive en:
|
||||
|
||||
- [dialog.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/dialog.ts)
|
||||
- [dialog.test.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/dialog.test.ts)
|
||||
|
||||
`dialogMorfo` y `dialogSema` son dos artefactos separados:
|
||||
|
||||
- `dialogMorfo` declara parts, attrs, ARIA, keyboard y focus
|
||||
- `dialogSema` declara las acciones perceptivas (`open`, `close-save`, etc.)
|
||||
|
||||
El caso más característico hoy es `data-last-action`:
|
||||
|
||||
- cada cierre prewritea una razón causal (`saved`, `cancelled`, `dismissed`, ...)
|
||||
- el validador comprueba que los valores declarados en morfo coincidan exactamente con los valores prewriteados por sema
|
||||
|
||||
## 5. Puerto runtime
|
||||
|
||||
### 5.1. `SemaPort`
|
||||
|
||||
**Planificado (diseñado)**
|
||||
|
||||
La firma propuesta para desacoplar providers de un engine Sema real es:
|
||||
|
||||
```ts
|
||||
export interface SemaPort {
|
||||
before(action: ResolvedSemaAction, ctx: SemaContext): Promise<void>;
|
||||
fire(action: ResolvedSemaAction, ctx: SemaContext): void;
|
||||
startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession;
|
||||
}
|
||||
|
||||
export interface SemaSession {
|
||||
stop(): void;
|
||||
readonly active: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Estado actual:
|
||||
|
||||
- esta interfaz **no** existe aún en `src/uix/sema`
|
||||
- tampoco existen `noopSemaPort` ni `testSemaPort`
|
||||
- aun así, la firma base `before / fire / startSustain` se considera bastante estable
|
||||
|
||||
Por eso esta sección se clasifica como **Planificado (diseñado)** y no como sketch.
|
||||
|
||||
### 5.2. Alcance del puerto
|
||||
|
||||
**Planificado (diseñado)**
|
||||
|
||||
Cuando exista, el puerto debería permitir:
|
||||
|
||||
- invocar acciones `blocking` (`before`)
|
||||
- invocar acciones `advisory` (`fire`)
|
||||
- iniciar sustains con lifecycle explícito (`startSustain`)
|
||||
|
||||
Lo que **no** está decidido aquí es la implementación interna del engine, solo el contrato de llamada
|
||||
entre provider y runtime sema.
|
||||
|
||||
## 6. Binding para providers
|
||||
|
||||
### 6.1. `createSemaBinding()`
|
||||
|
||||
**Sketch**
|
||||
|
||||
La idea general es ofrecer algo así:
|
||||
|
||||
```ts
|
||||
export function createSemaBinding(morfoLike, port): SemaBinding;
|
||||
```
|
||||
|
||||
con una interfaz ergonómica tipo:
|
||||
|
||||
```ts
|
||||
interface SemaBinding {
|
||||
before(name, ctx): Promise<void>;
|
||||
fire(name, ctx): void;
|
||||
start(name, ctx): SemaSession;
|
||||
action(name): SemaAction;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2. Por qué sigue siendo sketch
|
||||
|
||||
**Sketch**
|
||||
|
||||
Aunque el concepto es claro, todavía hay decisiones abiertas que pueden alterar materialmente la API:
|
||||
|
||||
- si el binding consume `morfo + sema` o solo `sema`
|
||||
- cómo resuelve `targetEl`, `rootEl` y otros elementos en providers Svelte 5
|
||||
- si compila defaults en construcción o en cada invocación
|
||||
- dónde aplica prewrites sin pelearse con el ciclo reactivo del provider
|
||||
- cómo se expresa el contexto (`cause`, refs DOM, metadata de componente)
|
||||
|
||||
Por eso hoy conviene tratar `createSemaBinding()` como **dirección arquitectónica**, no como contrato congelado.
|
||||
|
||||
## 7. Qué queda fuera hoy
|
||||
|
||||
**Sketch**
|
||||
|
||||
Todavía no forman parte del estado implementado del repo:
|
||||
|
||||
- engine real que consuma `.csem`
|
||||
- parser / pipeline de `.csem`
|
||||
- `sema-map.json`
|
||||
- arbitraje runtime de `replace | collapse | lock | queue`
|
||||
- aplicación efectiva de canales (`motion`, `sound`, `color`, `presence`)
|
||||
- caps de accesibilidad / preferencias del usuario
|
||||
|
||||
Nada de eso invalida el valor actual de la capa: hoy `sema` ya aporta tipado y validación de
|
||||
invariantes cross-layer, que es el primer paso útil y verificable.
|
||||
|
||||
## 8. Resumen operativo
|
||||
|
||||
- Usa `SemaSpec` desde `src/uix/sema/types.ts` para declarar acciones y sustains.
|
||||
- Relaciona `morfo` y `sema` por `kebab` y `PartRef`, no por extensión de tipos.
|
||||
- Ejecuta `validateSema(spec, morfo)` explícitamente allí donde quieras sanity-check cross-layer.
|
||||
- No asumas que existen todavía `SemaPort` o `createSemaBinding()` en runtime.
|
||||
- Si documentas trabajo futuro, clasifícalo como **Planificado (diseñado)** o **Sketch**, no como implementado.
|
||||
@ -0,0 +1,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…
Reference in new issue