Refactor sema and add shared dom runtime

morfo-driven-soma
dev 6 months ago
parent f62ede4a23
commit a0a1485b9f

@ -1,6 +1,8 @@
import { Context } from 'runed';
import { createActiveDom } from '$uix/adom';
import type {
AppOptions,
AppDom,
AppLangs,
AppNums,
AppMoney,
@ -12,7 +14,7 @@ import type {
DateOrder,
HourCycle
} from './types';
import { fallbackLangs, fallbackPresentation, consoleLogger } from './defaults';
import { fallbackDom, fallbackLangs, fallbackPresentation, consoleLogger } from './defaults';
/**
* App — root service compositor.
@ -27,6 +29,7 @@ import { fallbackLangs, fallbackPresentation, consoleLogger } from './defaults';
*/
export class App {
// ── Services ────────────────────────────────────────────────────────
readonly dom: AppDom;
readonly langs: AppLangs;
readonly nums: AppNums | undefined;
readonly money: AppMoney | undefined;
@ -36,6 +39,7 @@ export class App {
readonly logger: AppLogger;
constructor(opts: AppOptions) {
this.dom = opts.dom ?? createActiveDom();
this.langs = opts.langs;
this.nums = opts.nums;
this.money = opts.money;
@ -122,6 +126,7 @@ const _ctx = new Context<App>('App');
* components must handle their absence gracefully.
*/
const _fallback = new App({
dom: fallbackDom,
langs: fallbackLangs,
presentation: fallbackPresentation,
logger: consoleLogger

@ -1,3 +1,4 @@
import { createActiveDom } from '$uix/adom';
import type { AppLangs, AppPresentation, AppLogger } from './types';
/** No-op langs: returns path as-is, no locale switching */
@ -35,3 +36,6 @@ export const fallbackPresentation: AppPresentation = {
setDensity: () => {},
onPreferenceChange: () => () => {}
};
/** Minimal dom runtime: default breakpoints + responsive helpers. */
export const fallbackDom = createActiveDom({});

@ -1,9 +1,10 @@
export { App } from './app.svelte';
export { fallbackLangs, fallbackPresentation, consoleLogger } from './defaults';
export { fallbackDom, fallbackLangs, fallbackPresentation, consoleLogger } from './defaults';
export type {
AppServices,
AppOptions,
AppDom,
AppLangs,
AppNums,
AppMoney,

@ -167,6 +167,13 @@ describe('App', () => {
expect(app.logger).toBe(consoleLogger);
});
it('provides a dom runtime even when none is injected', () => {
const langs = mockLangs('es');
const app = new App({ langs, presentation: fallbackPresentation });
expect(app.dom.currentBreakpoint.current).toBe('base');
expect(app.dom.resolve({ base: 'stack', lg: 'inline' })).toBe('stack');
});
it('uses provided logger', () => {
const langs = mockLangs('es');
const customLogger = {

@ -1,3 +1,5 @@
import type { ActiveDom } from '$uix/adom';
// ── Direction ────────────────────────────────────────────────────────────────
export type Direction = 'ltr' | 'rtl';
@ -159,6 +161,11 @@ export interface AppPresentation {
onPreferenceChange(fn: () => void): () => void;
}
// ── AppDom ───────────────────────────────────────────────────────────────────
/** DOM runtime contract. Implements viewport + breakpoints + responsive helpers. */
export type AppDom = ActiveDom;
// ── AppLogger ────────────────────────────────────────────────────────────────
/** Logging contract. logr implements this directly. */
@ -176,6 +183,8 @@ export interface AppLogger {
// ── AppServices ──────────────────────────────────────────────────────────────
export interface AppServices {
/** DOM runtime — viewport, breakpoints, responsive helpers */
readonly dom: AppDom;
/** i18n — locale, translation, interpolation, module extension */
readonly langs: AppLangs;
/** Numbers — formatting, parsing, separator preferences */
@ -197,6 +206,7 @@ export interface AppServices {
// ── AppOptions (constructor input) ───────────────────────────────────────────
export interface AppOptions {
dom?: AppDom;
langs: AppLangs;
nums?: AppNums;
money?: AppMoney;

@ -11,29 +11,51 @@
toaster.create({ title: `Toast #${count}`, description: 'This is a default toast.' });
}
function addSuccess() {
toaster.success({ title: 'Saved', description: 'Your changes have been saved.' });
function addAffirm() {
toaster.create({
title: 'Affirmed',
description: 'The system acknowledged the action.',
intent: 'affirm'
});
}
function addError() {
toaster.error({ title: 'Error', description: 'Something went wrong. Please try again.' });
function addFulfill() {
toaster.create({
title: 'Saved',
description: 'Your changes have been saved.',
intent: 'fulfill'
});
}
function addWarning() {
toaster.warning({ title: 'Warning', description: 'This action cannot be undone.' });
function addRisk() {
toaster.create({
title: 'Warning',
description: 'This action cannot be undone.',
intent: 'risk'
});
}
function addInfo() {
toaster.info({ title: 'Info', description: 'A new version is available.' });
function addThreat() {
toaster.create({
title: 'Error',
description: 'Something went wrong. Please try again.',
intent: 'threat'
});
}
function addWithAction() {
toaster.create({
title: 'File deleted',
description: 'report.pdf was moved to trash.',
intent: 'risk',
action: {
label: 'Undo',
onClick: () => toaster.info({ title: 'Restored', description: 'File restored.' })
onClick: () =>
toaster.create({
title: 'Restored',
description: 'File restored.',
intent: 'affirm'
})
}
});
}
@ -47,9 +69,10 @@
}
function addLoading() {
toaster.loading({
toaster.create({
title: 'Uploading...',
description: 'Please wait while we process your file.'
description: 'Please wait while we process your file.',
loading: true
});
}
@ -59,8 +82,11 @@
);
toaster.promise(fakeAsync, {
loading: { title: 'Uploading...', description: 'Processing file...' },
success: (name) => ({ title: 'Uploaded', description: `${name} uploaded successfully.` }),
error: () => ({ title: 'Upload failed', description: 'Please try again.' })
fulfill: (name: string) => ({
title: 'Uploaded',
description: `${name} uploaded successfully.`
}),
threat: () => ({ title: 'Upload failed', description: 'Please try again.' })
});
}
</script>
@ -81,10 +107,10 @@
<h2>Create Toasts</h2>
<div class="buttons">
<button onclick={addDefault}>Default</button>
<button onclick={addSuccess}>Success</button>
<button onclick={addError}>Error</button>
<button onclick={addWarning}>Warning</button>
<button onclick={addInfo}>Info</button>
<button onclick={addAffirm}>Affirm</button>
<button onclick={addFulfill}>Fulfill</button>
<button onclick={addRisk}>Risk</button>
<button onclick={addThreat}>Threat</button>
<button onclick={addWithAction}>With Action</button>
<button onclick={addPersistent}>Persistent</button>
<button onclick={addLoading}>Loading</button>
@ -196,19 +222,22 @@
transform: translateX(0);
transition: transform 200ms ease;
}
:global([data-toast-item][data-type='loading']) {
:global([data-toast-item][data-loading]) {
border-left: 4px solid #94a3b8;
}
:global([data-toast-item][data-type='success']) {
:global([data-toast-item][data-intent='affirm']) {
border-left: 4px solid #14b8a6;
}
:global([data-toast-item][data-intent='fulfill']) {
border-left: 4px solid #22c55e;
}
:global([data-toast-item][data-type='error']) {
:global([data-toast-item][data-intent='threat']) {
border-left: 4px solid #ef4444;
}
:global([data-toast-item][data-type='warning']) {
:global([data-toast-item][data-intent='risk']) {
border-left: 4px solid #f59e0b;
}
:global([data-toast-item][data-type='info']) {
:global([data-toast-item][data-intent='neutral']) {
border-left: 4px solid #3b82f6;
}

@ -0,0 +1,341 @@
# UIX Continuity — 2026-04-24
Documento de continuidad para retomar mañana sin reconstruir contexto.
Branch actual: `morfo-driven-soma`
## 1. Decisiones cerradas hoy
### Nomenclatura
- `ActiveXXX` = pieza principal con estado reactivo público + funcionalidad
- `EngineXXX` = pieza funcional; puede tener estado interno, pero no se presenta
como fuente reactiva pública
### Superficie de `App`
La forma objetivo de `App` queda así:
```ts
app.dom
app.presentation
app.semantic
app.langs
app.xxx
```
Importante:
- el nombre público de la capa usa el dominio (`app.dom`, `app.presentation`)
- la implementación interna puede llamarse `ActiveDom`, `EngineTheme`,
`SemanticEngine`, etc.
### Theme
- `theme` no pertenece a `dom`
- `theme` pertenece a `app.presentation`
- si no publica estado reactivo, el nombre interno correcto es
`EngineTheme`
### Relación entre `air` / `terra` y la línea nueva
- `air` y `terra` **no** consumen `uix/lib/dom` ni `uix/adom`
- `air` y `terra` se usan como **fuente de extracción / referencia**
- no deben tocarse como parte de la línea nueva salvo petición explícita
### Frontera de capas
- `uix/lib/dom` = primitives DOM puras o casi puras
- `uix/adom` = helpers/runtime DOM con estado o scope real
- `Soma` no debe ser la fuente de verdad de esas piezas; como mucho,
ofrece wrappers finos cuando necesita enganchar lifecycle
### Morfo / Sema / Soma / Eidos
- `Morfo` define el contrato público del componente:
partes, `data-*`, ARIA, foco, teclado, eventos y semántica del componente
- `Soma` emite estado y eventos, pero no contrato visual
- `Eidos` es la capa visual
- `Sema` ya no debe volver a ser un runtime multimodal
## 2. Estado de la parte semántica
### Qué ha cambiado
Se ha hecho una poda fuerte de la línea antigua de `sema`.
La nueva idea es:
- `Morfo` declara la semántica aplicada del componente
- `Sema` define el vocabulario canónico del framework
- `SemanticEngine` publica ocurrencias semánticas pequeñas
- el runtime multimodal viejo ya no es la referencia
### Estado actual del código
Archivos canónicos:
- [src/uix/sema/types.ts](/G:/dev/svelte/vicen/src/uix/sema/types.ts)
- [src/uix/sema/engine.ts](/G:/dev/svelte/vicen/src/uix/sema/engine.ts)
- [src/uix/sema/validation.ts](/G:/dev/svelte/vicen/src/uix/sema/validation.ts)
- [src/uix/sema/README.md](/G:/dev/svelte/vicen/src/uix/sema/README.md)
El dominio actual de `Sema` ya está reducido a:
- familias canónicas:
`contact | commit | alert | handle | emerge | sustain`
- intents canónicos:
`neutral | affirm | fulfill | risk | threat`
- shape estructurado de evento
- label canónico derivado
- `SemanticEngine` como broker pequeño
### Qué vive ya en `Morfo`
`Morfo` ya soporta:
- `events`
- semántica estructurada por evento
- `intent` fijo o configurable desde prop
- `prewrite`
- `commits`
- `mapRef` para resolver `aria` o `data-*` desde props/estados
Archivos clave:
- [src/uix/morfo/types.ts](/G:/dev/svelte/vicen/src/uix/morfo/types.ts)
- [src/uix/morfo/schema.ts](/G:/dev/svelte/vicen/src/uix/morfo/schema.ts)
### Ejemplos reales ya migrados
- [src/uix/morfo/components/dialog.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/dialog.ts)
- [src/uix/morfo/components/toast.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/toast.ts)
`toast` es el ejemplo más claro:
- familia fija `alert`
- `intent` configurable desde prop pública
- `supported` intents cerrados en `Morfo`
- `data-intent` y `aria-live`/`role` resueltos desde contrato
### Decisión semántica importante
Se cerró esta regla:
- la **semántica** del evento es fija
- el **intent** puede ser configurable en componentes donde aplique
- el `intent` se expone como prop pública de `Soma`
- pero el vocabulario de `intent` pertenece a `Sema`
Ejemplo conceptual:
```svelte
<Toast intent="risk" />
```
Eso no cambia qué es `Toast`; solo cambia su matiz semántico dentro del rango
permitido.
### Lo que todavía no está cerrado
Todavía **no** está terminado el enganche completo:
- los providers todavía no publican de forma uniforme a `SemanticEngine`
- la API común tipo `emitSemantic(...)` no está cerrada en la base `Provider`
- `ActiveDom` todavía no refleja eventos semánticos al DOM
### Siguiente paso semántico razonable
El siguiente paso bueno en semántica es:
1. dar a `Provider` una API mínima común para publicar a `SemanticEngine`
2. usar `morfo.events` como fuente de verdad
3. probar el patrón en `accordion`, `dialog` y `toast`
## 3. Estado de `dom` y `adom`
### `uix/lib/dom`
La nueva base canónica ya existe y está bastante cerrada.
Archivos actuales:
- [src/uix/lib/dom/core.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/core.ts)
- [src/uix/lib/dom/elements.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/elements.ts)
- [src/uix/lib/dom/focus.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/focus.ts)
- [src/uix/lib/dom/locale.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/locale.ts)
- [src/uix/lib/dom/resize-observer.svelte.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/resize-observer.svelte.ts)
- [src/uix/lib/dom/responsive.svelte.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/responsive.svelte.ts)
- [src/uix/lib/dom/tabbable.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/tabbable.ts)
- [src/uix/lib/dom/index.ts](/G:/dev/svelte/vicen/src/uix/lib/dom/index.ts)
Lo que ya vive ahí:
- guards DOM
- traversal/shadow DOM
- `contains`, `getDocument`, `getWindow`, `getActiveElement`, `getParentNode`
- helpers de foco
- tabbable helpers
- helpers de dirección
- `ResizeObserver` reutilizable
- responsive helpers puros
### Decisión de diseño importante en `lib/dom`
`uix/lib/dom` no debe contener piezas que dependan de lifecycle implícito
de componentes o de política global de aplicación.
Por eso:
- `ResizeObserver` se refactorizó a helper imperativo (`refresh()`, `destroy()`)
- `BodyScrollLock` **no** entró en `lib/dom`
- `DOMContext` **no** entró en `lib/dom`
### `uix/adom`
`adom` ya no es el bus semántico que se imaginó en una fase anterior.
Hoy `uix/adom` contiene:
- `ActiveDom`
- `BodyScrollLock`
- `DOMContext`
- `RovingFocusGroup`
Archivos:
- [src/uix/adom/active-dom.svelte.ts](/G:/dev/svelte/vicen/src/uix/adom/active-dom.svelte.ts)
- [src/uix/adom/body-scroll-lock.svelte.ts](/G:/dev/svelte/vicen/src/uix/adom/body-scroll-lock.svelte.ts)
- [src/uix/adom/dom-context.svelte.ts](/G:/dev/svelte/vicen/src/uix/adom/dom-context.svelte.ts)
- [src/uix/adom/roving-focus-group.svelte.ts](/G:/dev/svelte/vicen/src/uix/adom/roving-focus-group.svelte.ts)
- [src/uix/adom/index.ts](/G:/dev/svelte/vicen/src/uix/adom/index.ts)
- [src/uix/adom/README.md](/G:/dev/svelte/vicen/src/uix/adom/README.md)
### `ActiveDom`
`ActiveDom` actual se limita a:
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- `resolve(...)`
- `isAtLeast(...)`
- `matches(...)`
Y está cableado ya en:
- [src/lib/ext/app/app.svelte.ts](/G:/dev/svelte/vicen/src/lib/ext/app/app.svelte.ts)
- [src/uix/soma/core/soma.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/core/soma.svelte.ts)
### Decisiones cerradas en `ActiveDom`
- `breakpoints` se definen en la creación del `dom`
- `createActiveDom()` funciona con o sin args
- `app.dom` crea una instancia propia por `App`
- el tracking de `resize` se activa al crear `ActiveDom`
- el tracking quedó en modo V1 honesto:
inicialización única por módulo, sin falsa multiconsumición
### `BodyScrollLock`
Se subió a `uix/adom` como pieza de dominio DOM global.
Punto importante:
- `BodyScrollLock` vive en `adom`
- `Soma` conserva un wrapper fino en
[src/uix/soma/layers/scroll-lock.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/layers/scroll-lock.svelte.ts)
solo para enganchar cleanup por lifecycle
### `DOMContext`
Se subió a `uix/adom` como helper scoped para `Document` / `ShadowRoot`.
No forma parte de `app.dom`; es un helper reutilizable para providers/layers:
- `getDocument()`
- `getWindow()`
- `getActiveElement()`
- queries scopiadas
- timers scopiados al root
### `RovingFocusGroup`
También se subió a `uix/adom` como helper runtime de foco compuesto.
Razón:
- tiene estado propio (`currentTabStopId`)
- depende de `Active/State`
- reutiliza `uix/lib/dom`
- ya no es una utility pura
## 4. Qué se ha dejado fuera a propósito
Para mañana no perder tiempo reabriendo debates ya cerrados:
- `air` y `terra` no se tocan
- `theme` no entra en `dom`
- `BodyScrollLock` no va a `lib/dom`
- `DOMContext` no va a `lib/dom`
- `Sema` no vuelve a ser runtime multimodal
- `Soma` no debe contener contrato visual
## 5. Tests verdes relevantes de hoy
Comandos útiles ya verificados hoy:
```bash
npx vitest run src/uix/lib/dom/core.test.ts src/uix/lib/dom/focus.test.ts src/uix/lib/dom/locale.test.ts src/uix/lib/dom/resize-observer.test.ts src/uix/lib/dom/responsive.test.ts
npx vitest run src/uix/adom/active-dom.test.ts src/uix/adom/body-scroll-lock.test.ts src/uix/adom/dom-context.test.ts src/uix/adom/roving-focus-group.test.ts
npx vitest run src/lib/ext/app/test/app.test.ts
```
En las pasadas focalizadas del día:
- `lib/dom` quedó verde
- `adom` quedó verde
- `app.dom` / `soma.dom` no introducen errores nuevos
El `npm run check` global del repo todavía tiene rojo viejo ajeno a esta línea,
pero al filtrar por `uix/lib/dom`, `uix/adom`, `lib/ext/app` y `soma/core`
no salieron errores nuevos.
## 6. Orden recomendado para mañana
Orden de ataque recomendado:
1. **Cerrar el enganche semántico en `Provider`**
- API común de publicación hacia `SemanticEngine`
- consumo de `morfo.events`
2. **Aplicar ese patrón a 2-3 componentes**
- `accordion`
- `dialog`
- `toast`
3. **Decidir si `ActiveDom` empieza a consumir `SemanticEngine`**
- solo cuando el modelo de publicación desde providers esté claro
- no antes
4. **Seguir ampliando `adom` solo si hace falta**
- probable siguiente candidato: `useArrowNavigation`
- no meter piezas nuevas por volumen; solo por frontera arquitectónica clara
## 7. Frase resumen del estado actual
La línea nueva ya tiene esta forma:
```text
uix/lib/dom -> primitives DOM puras
uix/adom -> runtime/helpers DOM con estado o scope real
app.dom -> ActiveDom a nivel de aplicación
Sema -> vocabulario + SemanticEngine pequeño
Morfo -> contrato estructural + semántico del componente
Soma -> comportamiento + emisión futura al SemanticEngine
```
La siguiente gran pieza no es ya `dom`, sino **cerrar cómo los providers de
`Soma` publican semántica usando `Morfo` + `SemanticEngine`**.

@ -0,0 +1,370 @@
# UIX
Documento corto de posicionamiento arquitectonico para `src/uix`.
Nota de continuidad más reciente:
[src/uix/CONTINUITY_2026-04-24.md](/G:/dev/svelte/vicen/src/uix/CONTINUITY_2026-04-24.md)
UIX no intenta ser "otra libreria de componentes". La apuesta es mas ambiciosa y
mas estructural: **separar capas que casi todos los frameworks actuales mantienen
mezcladas**.
En la mayoria de sistemas de UI, estas cosas viven pegadas:
- contrato publico del DOM
- comportamiento headless
- accesibilidad
- semantica del evento
- capa visual
- motores modales (sound, vibra, motion)
- integracion con servicios de app
UIX intenta partir ese bloque en piezas con fronteras fuertes.
---
## 1. La idea central
UIX modela la interfaz como varias capas cooperando, no como un unico componente
gigante que hace todo a la vez.
```text
App
├─ servicios transversales
│ └─ ADom
└─ componentes
└─ capa estructural / semantica / comportamental / visual
```
La intuicion es esta:
- la estructura publica del componente no es lo mismo que su comportamiento
- la semantica de un evento no es lo mismo que su materializacion
- el DOM activo no es lo mismo que utilidades DOM puras
- la app no deberia acoplar motores modales entre si
UIX pone nombres y contratos explicitos a esas separaciones.
---
## 2. Las capas de UIX
### `Morfo`
Contrato estructural cross-layer del componente.
Define:
- partes
- `data-*`
- ARIA
- foco
- teclado
- eventos
No es prose ni runtime. Es la forma canonica publica del componente.
Ver: [src/uix/morfo/README.md](/G:/dev/svelte/vicen/src/uix/morfo/README.md)
### `Sema`
Vocabulario y contrato semantico.
No ejecuta sound, vibra ni CSS. Su trabajo es decir:
- que acciones existen
- que ocurrencias/eventos canónicos nombra el sistema
- como se relacionan esos nombres con el componente
En su version madura, `Sema` debe ser **vocabulario y validacion**, no runtime.
### `Soma`
Capa headless de comportamiento.
Gestiona:
- estado
- contexto
- a11y
- keyboard / pointer / focus
- emision de eventos hacia `ADom`
`Soma` no deberia conocer la implementacion concreta de los engines modales. Su
trabajo es emitir hechos del componente, no materializarlos.
Ver: [src/uix/soma/SOMA_ARCHITECTURE.md](/G:/dev/svelte/vicen/src/uix/soma/SOMA_ARCHITECTURE.md)
### `Eidos`
Capa visual.
Reacciona a contratos DOM y a senales reflejadas, pero no implementa la logica
headless del componente. Su responsabilidad es apariencia, no comportamiento.
### `ADom`
Runtime observable del DOM activo.
No es un helper DOM puro ni un semantic engine. Es el broker infrastructural de
senales DOM activas:
- recibe emisiones de `Soma`
- publica a listeners tipados
- refleja `data-event*` en el DOM
- evita que cada engine monte su propio observer para el mismo hecho
Ver: [src/uix/adom/README.md](/G:/dev/svelte/vicen/src/uix/adom/README.md)
### `uix/lib/dom`
Utilidades DOM puras o casi puras.
Aqui viven:
- `contains`
- `getDocument`
- `getWindow`
- foco
- traversal
- wrappers base de observers
No contiene el runtime activo. Ese papel pertenece a `ADom`.
---
## 3. Que hace distinto a UIX
### 3.1 El contrato estructural es una capa propia
En la mayoria de librerias, la estructura publica del componente esta dispersa:
- atributos en el provider
- roles en el render
- partes en CSS
- selector names en docs
- contratos en tests
UIX intenta concentrar eso en `Morfo`.
Eso no es una comodidad menor; cambia el tipo de sistema que puedes construir:
- docs derivadas del contrato
- validacion cross-layer
- menos drift entre headless y visual
- tooling mas fiable
### 3.2 La semantica no se mezcla con la ejecucion
UIX separa el **nombre de la ocurrencia** de su **materializacion modal**.
Eso permite que:
- sonido
- vibracion
- CSS
- motores futuros
usen el mismo vocabulario sin quedar pegados entre si.
### 3.3 El comportamiento headless no carga con toda la modalidad
`Soma` no deberia ser un mega-engine que sabe de todo:
- no sabe reproducir WAVs
- no sabe vibrar
- no sabe decidir la fisica perceptiva de cada canal
`Soma` emite. Los engines ejecutan.
### 3.4 El DOM activo es infraestructura de app, no detalle incidental
Muchos sistemas tratan el DOM como detalle local del componente.
UIX da un paso mas: reconoce que hay hechos transversales del DOM que varios
consumidores quieren escuchar, y por eso introduce `ADom`.
Eso permite:
- un solo punto de publicacion
- listeners tipados
- reflection uniforme en atributos
- menos `MutationObserver` duplicados
- mejor tooling y debug
### 3.5 La app compone servicios, no "super componentes"
UIX se apoya en un modelo donde la app compone servicios transversales y los
componentes los consumen. `langs`, `presentation`, `logger` y `ADom` viven mejor
como servicios de app que como dependencias ocultas dentro de cada componente.
---
## 4. Lo que UIX no es
UIX no es:
- una coleccion plana de componentes visuales
- un simple wrapper opinionated sobre primitives existentes
- un design system clasico donde visual, comportamiento y contratos viven juntos
- un semantic engine centralizado que ejecuta todas las modalidades
- un `EventEmitter` global disfrazado de arquitectura
Tampoco busca novedad gratuita.
La originalidad de UIX no esta en inventar nombres exoticos, sino en **separar
problemas reales** que otros sistemas suelen aceptar como un unico bloque.
---
## 5. Comparacion honesta con otros enfoques
### Frente a headless libraries clasicas
Librerias como Radix, Ariakit o React Aria resuelven muy bien comportamiento y
accesibilidad. Pero normalmente no separan:
- contrato estructural declarativo
- vocabulario semantico independiente
- runtime transversal de DOM activo
UIX quiere cubrir ese espacio.
### Frente a design systems clasicos
Muchos design systems tienen tokens, componentes y guidelines, pero la frontera
entre:
- estructura
- comportamiento
- visualidad
- semantica
queda difusa.
UIX intenta que cada una tenga una capa reconocible.
### Frente a engines modales aislados
Es relativamente comun encontrar sistemas de motion o sound por separado.
Lo raro es tener:
- headless primitives
- contrato estructural machine-readable
- vocabulario comun
- servicio de DOM activo
- engines modales desacoplados
trabajando juntos sin colapsar en un runtime monolitico.
---
## 6. Por que esto puede ser valioso
Si sale bien, UIX ofrece algo poco comun:
- mejor explicabilidad arquitectonica
- menos drift entre capas
- mas capacidad de validacion automatica
- mejor testabilidad
- mas libertad para introducir nuevos engines
- mas honestidad sobre que pertenece al framework y que pertenece al integrador
Especialmente importante:
**la coherencia cross-modal puede tratarse como responsabilidad del integrador, no
como una falsa promesa de un runtime centralizado que pretende saberlo todo.**
El framework puede proveer:
- vocabulario
- contratos
- transporte
- puntos de extension
Pero no debe fingir que puede decidir por todas las modalidades de todas las apps.
---
## 7. Los riesgos reales
UIX tambien tiene riesgos claros, y conviene decirlos sin adornos.
### 7.1 Exceso de capas
Si las fronteras no estan clarisimas, el sistema puede sentirse mas complejo de lo
que realmente resuelve.
### 7.2 Nombres sin disciplina
Si `Morfo`, `Sema`, `Soma`, `Eidos`, `ADom` no mantienen contratos nitidos, los
nombres se convierten en decoracion y no en arquitectura.
### 7.3 Invasion de responsabilidades
El peligro constante es que una capa intente hacer el trabajo de otra:
- `Sema` convirtiendose en runtime
- `Soma` convirtiendose en engine modal
- `ADom` convirtiendose en semantic engine
- `Eidos` acoplandose a detalles incidentales
UIX solo funciona si cada capa acepta sus limites.
### 7.4 Falta de precedentes
No hay demasiados sistemas con esta composicion exacta. Eso significa mas libertad,
pero tambien menos patrones externos que copiar. Hay que inventar con disciplina.
---
## 8. Reglas de dependencia
UIX debe preservar una direccion clara de acoplamiento.
Version simplificada:
```text
Morfo -> describe
Sema -> nombra y valida sobre Morfo
Soma -> implementa comportamiento y emite a ADom
ADom -> transporta y publica
Eidos -> materializa visualmente
App -> compone servicios y engines
```
Y, como regla general:
- `Morfo` no conoce `Soma`
- `Sema` no ejecuta engines
- `ADom` no conoce sonido ni vibracion
- `Soma` no conoce implementaciones modales concretas
- `Eidos` no duplica behavior headless
---
## 9. La diferencia en una frase
Si hubiera que resumir UIX en una sola idea, seria esta:
> UIX trata la interfaz no como un componente monolitico, sino como un sistema de
> capas con contratos explicitos entre estructura, semantica, comportamiento,
> visualidad y transporte de eventos activos.
Esa es la apuesta.
---
## 10. Orden de lectura sugerido
Para entender el sistema en su estado actual:
1. [src/uix/morfo/README.md](/G:/dev/svelte/vicen/src/uix/morfo/README.md)
2. [src/uix/soma/SOMA_ARCHITECTURE.md](/G:/dev/svelte/vicen/src/uix/soma/SOMA_ARCHITECTURE.md)
3. [src/uix/adom/README.md](/G:/dev/svelte/vicen/src/uix/adom/README.md)
4. [src/uix/terra/README.md](/G:/dev/svelte/vicen/src/uix/terra/README.md)
5. [src/uix/air/README.md](/G:/dev/svelte/vicen/src/uix/air/README.md)
La arquitectura final seguira cambiando, pero esta es la idea fundacional que
explica por que UIX no se parece demasiado a otros frameworks de UI.

@ -0,0 +1,167 @@
# ActiveDom
`uix/adom` contiene `ActiveDom`: el servicio DOM reactivo de aplicación.
## Qué es hoy
Ahora mismo `ActiveDom` no es un bus de eventos semánticos ni un reflector de
`data-event*`.
Su responsabilidad actual es más pequeña y más concreta:
- exponer el ancho de viewport de forma reactiva
- resolver el breakpoint actual
- mantener la definición de breakpoints de la app
- resolver valores responsive
- ofrecer helpers de consulta (`isAtLeast`, `matches`)
En otras palabras:
```text
uix/lib/dom -> uix/adom -> app.dom / soma.dom
puro reactivo consumo de app
```
## Qué pertenece a cada capa
### `uix/lib/dom`
Primitives DOM puras o casi puras:
- guards y traversal DOM
- focus helpers
- tabbable helpers
- responsive helpers puros
No mantiene estado de aplicación.
### `uix/adom`
Runtime reactivo de DOM:
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- `resolve(...)`
- `isAtLeast(...)`
- `matches(...)`
- `BodyScrollLock` como helper global de body scroll lock
- `DOMContext` como helper scoped para `Document` / `ShadowRoot`
- `RovingFocusGroup` como helper runtime para navegación compuesta por teclado
`ActiveDom` sí mantiene estado reactivo y por eso vive aquí, no en `uix/lib/dom`.
## Posición en App
`ActiveDom` vive a nivel de aplicación:
```ts
app.dom
soma.dom
```
La implementación actual se conecta desde:
- [app.svelte.ts](/G:/dev/svelte/vicen/src/lib/ext/app/app.svelte.ts)
- [soma.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/core/soma.svelte.ts)
## API actual
La API pública real de `ActiveDom` hoy es esta:
```ts
export type ActiveDom = {
breakpoints: Active<Breakpoints>
viewport: { width: number }
currentBreakpoint: Active<Breakpoint>
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined
isAtLeast(breakpoint: Breakpoint): boolean
matches(breakpoint: Breakpoint): boolean
}
```
Creación:
```ts
const dom = createActiveDom({
breakpoints: readableActive(() => ({
lg: 1100
}))
})
const domWithDefaults = createActiveDom()
```
Reglas:
- los breakpoints se definen en la creación del `dom`
- `breakpoints` es opcional; si no se pasa, usa `BREAKPOINTS_DEFAULT`
- no hay herencia de `dom` padre
- `ActiveDom` es servicio de app, no scope anidado
- `ActiveDom` activa el tracking de `resize` al crearse
## Qué no es
`ActiveDom` hoy no es:
- `SemanticEngine`
- broker de eventos
- reflector de `data-event*`
- hub de `MutationObserver`
- sistema de theme
- reemplazo de `uix/lib/dom`
Además, `uix/adom` puede alojar helpers DOM con estado global real, como
`BodyScrollLock`, o helpers scoped de runtime como `DOMContext`, cuando ya no
son primitives puras de `uix/lib/dom` pero tampoco pertenecen a `Soma`.
También caben aquí helpers runtime de foco con estado propio, como
`RovingFocusGroup`, que reutilizan `uix/lib/dom` por debajo pero ya no son
solo utilidades puras.
## Relación con otras piezas
### Semántica
La semántica pertenece a `Sema` y a `SemanticEngine`, no a `ActiveDom`.
Si mañana `ActiveDom` refleja eventos al DOM, será como consumidor de
`SemanticEngine`, no como autoridad semántica.
### Theme
El theme no pertenece a `dom`.
Va en `app.presentation`, porque es estado de presentación de aplicación, no una
primitive DOM.
### Air y Terra
`air` y `terra` no consumen esta capa nueva.
Su código actual sirve como referencia histórica para extraer utilidades hacia
`uix/lib/dom`, pero no forman parte del runtime nuevo.
## Estado del diseño
`ActiveDom` está en fase fundacional.
Lo que ya está cerrado:
- `app.dom`
- `soma.dom`
- `viewport`
- `breakpoints`
- `currentBreakpoint`
- resolución responsive
Lo que queda para fases posteriores, si de verdad hace falta:
- reflexión de eventos semánticos al DOM
- observers compartidos
- APIs por `Document` o `ShadowRoot`
- introspección/diagnóstico de runtime más rica
La regla importante por ahora es simple:
> `ActiveDom` es el servicio reactivo de DOM de la app; `uix/lib/dom` es su base pura.

@ -0,0 +1,52 @@
import { readableActive, type Active } from '$reactive'
import {
BREAKPOINTS_DEFAULT,
getCurrentBreakpoint,
initViewportTracking,
resolveResponsiveProp,
type Breakpoint,
type Breakpoints,
type ResponsiveProp,
viewport
} from '$uix/lib/dom/responsive.svelte.js'
export type ActiveDomProps = {
breakpoints?: Active<Partial<Breakpoints> | undefined>
}
export type ActiveDom = {
breakpoints: Active<Breakpoints>
viewport: typeof viewport
currentBreakpoint: Active<Breakpoint>
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined
isAtLeast(breakpoint: Breakpoint): boolean
matches(breakpoint: Breakpoint): boolean
}
export function createActiveDom(props: ActiveDomProps = {}): ActiveDom {
initViewportTracking()
const breakpoints = readableActive(() => ({
...BREAKPOINTS_DEFAULT,
...props.breakpoints?.current
}))
const currentBreakpoint = readableActive(() =>
getCurrentBreakpoint(viewport.width, breakpoints.current)
)
return {
breakpoints,
viewport,
currentBreakpoint,
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined {
return resolveResponsiveProp(value, viewport.width, breakpoints.current)
},
isAtLeast(breakpoint: Breakpoint): boolean {
return viewport.width >= breakpoints.current[breakpoint]
},
matches(breakpoint: Breakpoint): boolean {
return currentBreakpoint.current === breakpoint
}
}
}

@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { readableActive } from '$reactive'
import { createActiveDom } from './active-dom.svelte'
describe('ActiveDom', () => {
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 1024
})
document.body.innerHTML = ''
window.dispatchEvent(new Event('resize'))
})
it('uses default breakpoints when no overrides are provided', () => {
const dom = createActiveDom()
expect(dom.currentBreakpoint.current).toBe('lg')
expect(dom.isAtLeast('md')).toBe(true)
expect(dom.matches('lg')).toBe(true)
})
it('merges partial breakpoint overrides', () => {
const dom = createActiveDom({
breakpoints: readableActive(() => ({ lg: 1200 }))
})
expect(dom.breakpoints.current.lg).toBe(1200)
expect(dom.currentBreakpoint.current).toBe('md')
})
it('tracks window resize and updates viewport-derived state', () => {
const dom = createActiveDom()
expect(dom.viewport.width).toBe(1024)
expect(dom.currentBreakpoint.current).toBe('lg')
window.innerWidth = 460
window.dispatchEvent(new Event('resize'))
expect(dom.viewport.width).toBe(460)
expect(dom.currentBreakpoint.current).toBe('base')
expect(dom.isAtLeast('sm')).toBe(false)
expect(dom.resolve({ base: 'stack', sm: 'inline' })).toBe('stack')
})
})

@ -0,0 +1,201 @@
import { SvelteMap } from 'svelte/reactivity'
import { writableActive, type State } from '$reactive'
import { isIOS } from '$uix/lib/dom'
export interface BodyScrollLockOption {
padding?: boolean | number
margin?: boolean | number
}
const lockMap = new SvelteMap<string, boolean>()
let initialBodyStyle: string | null = $state<string | null>(null)
let stopTouchMoveListener: (() => void) | null = null
let cleanupTimeoutId: number | null = null
let isInCleanupTransition = false
let cleanupScheduledAt: number | null = null
let bodyEffectToken = 0
let idCounter = 0
function canUseDom(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function nextId(): string {
idCounter += 1
return `body-scroll-lock-${idCounter}`
}
function isAnyLocked(map: Map<string, boolean>): boolean {
for (const [, value] of map) {
if (value) return true
}
return false
}
function getLockedCount(map: Map<string, boolean>): number {
let count = 0
for (const [, value] of map) {
if (value) count += 1
}
return count
}
function cancelPendingCleanup() {
if (cleanupTimeoutId === null || !canUseDom()) return
window.clearTimeout(cleanupTimeoutId)
cleanupTimeoutId = null
}
function ensureInitialStyleCaptured() {
if (!canUseDom()) return
if (initialBodyStyle === null && getLockedCount(lockMap) === 1 && !isInCleanupTransition) {
initialBodyStyle = document.body.getAttribute('style')
}
}
function detachTouchMoveListener() {
stopTouchMoveListener?.()
stopTouchMoveListener = null
}
function attachTouchMoveListener() {
if (!canUseDom() || !isIOS || stopTouchMoveListener) return
const listener = (event: TouchEvent) => {
if (event.target !== document.documentElement) return
if (event.touches.length > 1) return
event.preventDefault()
}
document.addEventListener('touchmove', listener, { passive: false })
stopTouchMoveListener = () => {
document.removeEventListener('touchmove', listener)
stopTouchMoveListener = null
}
}
function resetBodyStyle() {
if (!canUseDom()) return
bodyEffectToken += 1
document.body.setAttribute('style', initialBodyStyle ?? '')
document.body.style.removeProperty('--scrollbar-width')
detachTouchMoveListener()
initialBodyStyle = null
}
function schedulePostLockBodySync() {
if (!canUseDom()) return
const token = ++bodyEffectToken
Promise.resolve().then(() => {
if (token !== bodyEffectToken || !isAnyLocked(lockMap)) return
document.body.style.pointerEvents = 'none'
document.body.style.overflow = 'hidden'
})
}
function applyBodyLock() {
if (!canUseDom()) return
cancelPendingCleanup()
ensureInitialStyleCaptured()
isInCleanupTransition = false
const htmlStyle = getComputedStyle(document.documentElement)
const bodyStyle = getComputedStyle(document.body)
const hasStableGutter =
htmlStyle.scrollbarGutter?.includes('stable') || bodyStyle.scrollbarGutter?.includes('stable')
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth
const paddingRight = Number.parseInt(bodyStyle.paddingRight ?? '0', 10)
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
document.body.style.paddingRight = `${paddingRight + verticalScrollbarWidth}px`
document.body.style.setProperty('--scrollbar-width', `${verticalScrollbarWidth}px`)
}
document.body.style.overflow = 'hidden'
attachTouchMoveListener()
schedulePostLockBodySync()
}
function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void) {
if (!canUseDom()) return
cancelPendingCleanup()
isInCleanupTransition = true
cleanupScheduledAt = Date.now()
const currentCleanupId = cleanupScheduledAt
const cleanupFn = () => {
cleanupTimeoutId = null
if (cleanupScheduledAt !== currentCleanupId) return
if (!isAnyLocked(lockMap)) {
isInCleanupTransition = false
callback()
} else {
isInCleanupTransition = false
}
}
cleanupTimeoutId = window.setTimeout(cleanupFn, delay ?? 24)
}
export class BodyScrollLock {
readonly id = nextId()
readonly locked: State<boolean>
constructor(
initialState?: boolean,
private readonly restoreScrollDelay: () => number | null = () => null
) {
lockMap.set(this.id, initialState ?? false)
this.locked = writableActive(
() => lockMap.get(this.id) ?? false,
(value: boolean) => {
lockMap.set(this.id, value)
if (value || isAnyLocked(lockMap)) {
applyBodyLock()
return
}
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), resetBodyStyle)
}
)
if (initialState) {
applyBodyLock()
}
}
destroy() {
const wasLocked = lockMap.get(this.id) ?? false
lockMap.delete(this.id)
if (isAnyLocked(lockMap)) {
applyBodyLock()
return
}
if (wasLocked) {
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), resetBodyStyle)
}
}
static reset() {
lockMap.clear()
cancelPendingCleanup()
resetBodyStyle()
initialBodyStyle = null
isInCleanupTransition = false
cleanupScheduledAt = null
bodyEffectToken = 0
idCounter = 0
}
}

@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BodyScrollLock } from './body-scroll-lock.svelte'
describe('BodyScrollLock', () => {
beforeEach(() => {
vi.useFakeTimers()
BodyScrollLock.reset()
document.body.setAttribute('style', 'background: red;')
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 1200
})
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1180
})
})
afterEach(() => {
BodyScrollLock.reset()
vi.useRealTimers()
})
it('locks body scroll and restores the initial body style when unlocked', async () => {
const lock = new BodyScrollLock(true)
await Promise.resolve()
expect(document.body.style.overflow).toBe('hidden')
expect(document.body.style.getPropertyValue('--scrollbar-width')).toBe('20px')
lock.locked.current = false
vi.runAllTimers()
await Promise.resolve()
expect(document.body.getAttribute('style')).toBe('background: red;')
})
it('keeps the body locked until the last lock is released', async () => {
const first = new BodyScrollLock(true)
const second = new BodyScrollLock(true)
await Promise.resolve()
first.locked.current = false
vi.runAllTimers()
await Promise.resolve()
expect(document.body.style.overflow).toBe('hidden')
second.destroy()
vi.runAllTimers()
await Promise.resolve()
expect(document.body.getAttribute('style')).toBe('background: red;')
})
it('allows creating unlocked instances without mutating the body', () => {
new BodyScrollLock(false)
expect(document.body.getAttribute('style')).toBe('background: red;')
})
})

@ -0,0 +1,83 @@
import { readableActive, type Active, type State } from '$reactive'
import {
getActiveElement,
getDocument,
getWindow,
isDocument,
isShadowRoot
} from '$uix/lib/dom'
type ElementGetter = () => HTMLElement | null
type ContextElement = Active<HTMLElement | null> | State<HTMLElement | null> | ElementGetter
function canUseDom(): boolean {
return typeof document !== 'undefined'
}
function getDefaultProvider(): Document | null {
return canUseDom() ? document : null
}
export class DOMContext {
readonly element: Active<HTMLElement | null>
readonly provider = readableActive<Document | ShadowRoot | null>(() => {
const element = this.element.current
if (!element) return getDefaultProvider()
const providerNode = element.getRootNode?.() ?? getDefaultProvider()
if (isDocument(providerNode) || isShadowRoot(providerNode)) {
return providerNode
}
return getDocument(element)
})
constructor(element: ContextElement) {
this.element =
typeof element === 'function' ? readableActive(element) : (element as Active<HTMLElement | null>)
}
getDocument = (): Document => {
return getDocument(this.provider.current ?? undefined)
}
getWindow = (): Window => {
return getWindow(this.provider.current ?? undefined)
}
getActiveElement = (): Element | null => {
const provider = this.provider.current
if (!provider && !canUseDom()) return null
return getActiveElement(provider ?? undefined)
}
isActiveElement = (node: HTMLElement | null): boolean => {
return node === this.getActiveElement()
}
getElementById<T extends Element = HTMLElement>(id: string): T | null {
const provider = this.provider.current
if (!provider || !('getElementById' in provider)) return null
return provider.getElementById(id) as T | null
}
querySelector = <T extends Element = Element>(selector: string): T | null => {
const provider = this.provider.current
if (!provider) return null
return provider.querySelector(selector) as T | null
}
querySelectorAll = <T extends Element = Element>(selector: string): NodeListOf<T> => {
const provider = this.provider.current
if (!provider) return [] as unknown as NodeListOf<T>
return provider.querySelectorAll(selector) as NodeListOf<T>
}
setTimeout = (callback: () => void, delay: number): ReturnType<typeof window.setTimeout> => {
return this.getWindow().setTimeout(callback, delay)
}
clearTimeout = (timeoutId: ReturnType<typeof window.setTimeout>) => {
this.getWindow().clearTimeout(timeoutId)
}
}

@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DOMContext } from './dom-context.svelte'
describe('DOMContext', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('falls back to the global document when no element is present', () => {
const node = document.createElement('div')
node.id = 'global-node'
document.body.appendChild(node)
const context = new DOMContext(() => null)
expect(context.getDocument()).toBe(document)
expect(context.getWindow()).toBe(window)
expect(context.getElementById('global-node')).toBe(node)
expect(context.querySelector('#global-node')).toBe(node)
})
it('scopes queries and active element to the element root', () => {
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'open' })
const input = document.createElement('input')
input.id = 'shadow-input'
shadow.appendChild(input)
document.body.appendChild(host)
const context = new DOMContext(() => input)
input.focus()
expect(context.provider.current).toBe(shadow)
expect(context.querySelector('#shadow-input')).toBe(input)
expect(context.getActiveElement()).toBe(input)
expect(context.isActiveElement(input)).toBe(true)
})
it('proxies timers through the provider window', () => {
vi.useFakeTimers()
const context = new DOMContext(() => null)
const callback = vi.fn()
const timeoutId = context.setTimeout(callback, 10)
vi.advanceTimersByTime(10)
expect(callback).toHaveBeenCalledTimes(1)
context.clearTimeout(timeoutId)
vi.useRealTimers()
})
})

@ -0,0 +1,4 @@
export * from './active-dom.svelte.js'
export * from './body-scroll-lock.svelte.js'
export * from './dom-context.svelte.js'
export * from './roving-focus-group.svelte.js'

@ -0,0 +1,161 @@
import { state, type Active, type State } from '$reactive'
import { isHTMLElement, getElementDirection } from '$uix/lib/dom'
export type RovingFocusOrientation = 'horizontal' | 'vertical'
type DirectionalKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'
const kbd = {
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
ARROW_UP: 'ArrowUp',
ARROW_DOWN: 'ArrowDown',
HOME: 'Home',
END: 'End'
} as const
type RovingFocusGroupOptions = (
| {
candidateAttr: string
candidateSelector?: undefined
}
| {
candidateSelector: string
candidateAttr?: undefined
}
) & {
providerNode: Active<HTMLElement | null> | State<HTMLElement | null>
loop: Active<boolean>
orientation: Active<RovingFocusOrientation>
onCandidateFocus?: (node: HTMLElement) => void
}
function canUseDom(): boolean {
return typeof document !== 'undefined'
}
function getDirectionalKeys(
dir: 'ltr' | 'rtl',
orientation: RovingFocusOrientation
): { nextKey: DirectionalKey; prevKey: DirectionalKey } {
if (orientation === 'vertical') {
return {
nextKey: kbd.ARROW_DOWN,
prevKey: kbd.ARROW_UP
}
}
return {
nextKey: dir === 'rtl' ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT,
prevKey: dir === 'rtl' ? kbd.ARROW_RIGHT : kbd.ARROW_LEFT
}
}
export class RovingFocusGroup {
readonly opts: RovingFocusGroupOptions
readonly currentTabStopId = state<string | null>(null)
constructor(opts: RovingFocusGroupOptions) {
this.opts = opts
}
getCandidateNodes(): HTMLElement[] {
if (!canUseDom() || !this.opts.providerNode.current) return []
if (this.opts.candidateSelector) {
return Array.from(
this.opts.providerNode.current.querySelectorAll<HTMLElement>(this.opts.candidateSelector)
)
}
if (this.opts.candidateAttr) {
return Array.from(
this.opts.providerNode.current.querySelectorAll<HTMLElement>(
`[${this.opts.candidateAttr}]:not([data-disabled])`
)
)
}
return []
}
focusFirstCandidate() {
const items = this.getCandidateNodes()
if (!items.length) return
items[0]?.focus()
}
handleKeydown(node: HTMLElement | null | undefined, event: KeyboardEvent, both = false) {
const providerNode = this.opts.providerNode.current
if (!providerNode || !node) return
const items = this.getCandidateNodes()
if (!items.length) return
const currentIndex = items.indexOf(node)
const dir = getElementDirection(providerNode)
const { nextKey, prevKey } = getDirectionalKeys(dir, this.opts.orientation.current)
const loop = this.opts.loop.current
const keyToIndex: Partial<Record<string, number>> = {
[nextKey]: currentIndex + 1,
[prevKey]: currentIndex - 1,
[kbd.HOME]: 0,
[kbd.END]: items.length - 1
}
if (both) {
const altNextKey = nextKey === kbd.ARROW_DOWN ? kbd.ARROW_RIGHT : kbd.ARROW_DOWN
const altPrevKey = prevKey === kbd.ARROW_UP ? kbd.ARROW_LEFT : kbd.ARROW_UP
keyToIndex[altNextKey] = currentIndex + 1
keyToIndex[altPrevKey] = currentIndex - 1
}
let itemIndex = keyToIndex[event.key]
if (itemIndex === undefined) return
event.preventDefault()
if (itemIndex < 0 && loop) {
itemIndex = items.length - 1
} else if (itemIndex === items.length && loop) {
itemIndex = 0
}
const itemToFocus = items[itemIndex]
if (!itemToFocus) return
itemToFocus.focus()
this.currentTabStopId.current = itemToFocus.id
this.opts.onCandidateFocus?.(itemToFocus)
return itemToFocus
}
getTabIndex(node: HTMLElement | null | undefined): 0 | -1 {
const items = this.getCandidateNodes()
const anyActive = this.currentTabStopId.current !== null
if (node && !anyActive && items[0] === node) {
this.currentTabStopId.current = node.id
return 0
}
if (node?.id === this.currentTabStopId.current) {
return 0
}
return -1
}
setCurrentTabStopId(id: string) {
this.currentTabStopId.current = id
}
focusCurrentTabStop() {
const currentTabStopId = this.currentTabStopId.current
if (!currentTabStopId) return
const currentTabStop = this.opts.providerNode.current?.querySelector(`#${currentTabStopId}`)
if (!currentTabStop || !isHTMLElement(currentTabStop)) return
currentTabStop.focus()
}
}

@ -0,0 +1,119 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readableActive } from '$reactive'
import { RovingFocusGroup } from './roving-focus-group.svelte'
function createGroup(
root: HTMLElement,
orientation: 'horizontal' | 'vertical' = 'horizontal',
loop = true
) {
return new RovingFocusGroup({
candidateAttr: 'data-roving-item',
providerNode: readableActive(() => root),
loop: readableActive(() => loop),
orientation: readableActive(() => orientation)
})
}
function createKeydownEvent(key: string): KeyboardEvent {
return new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
}
describe('RovingFocusGroup', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('assigns the first candidate as the default tab stop', () => {
const root = document.createElement('div')
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const group = createGroup(root)
expect(group.getTabIndex(first)).toBe(0)
expect(group.getTabIndex(second)).toBe(-1)
expect(group.currentTabStopId.current).toBe('first')
})
it('moves focus to the next candidate and loops when configured', () => {
const root = document.createElement('div')
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const group = createGroup(root)
const firstEvent = createKeydownEvent('ArrowRight')
const secondEvent = createKeydownEvent('ArrowRight')
group.handleKeydown(first, firstEvent)
expect(document.activeElement).toBe(second)
expect(group.currentTabStopId.current).toBe('second')
group.handleKeydown(second, secondEvent)
expect(document.activeElement).toBe(first)
expect(group.currentTabStopId.current).toBe('first')
})
it('respects rtl horizontal navigation', () => {
const root = document.createElement('div')
root.style.direction = 'rtl'
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const group = createGroup(root)
const event = createKeydownEvent('ArrowLeft')
group.handleKeydown(first, event)
expect(document.activeElement).toBe(second)
expect(group.currentTabStopId.current).toBe('second')
})
it('calls onCandidateFocus and can refocus the current tab stop', () => {
const root = document.createElement('div')
const first = document.createElement('button')
const second = document.createElement('button')
first.id = 'first'
second.id = 'second'
first.setAttribute('data-roving-item', '')
second.setAttribute('data-roving-item', '')
root.append(first, second)
document.body.appendChild(root)
const onCandidateFocus = vi.fn()
const group = new RovingFocusGroup({
candidateAttr: 'data-roving-item',
providerNode: readableActive(() => root),
loop: readableActive(() => true),
orientation: readableActive(() => 'horizontal'),
onCandidateFocus
})
group.handleKeydown(first, createKeydownEvent('ArrowRight'))
group.focusCurrentTabStop()
expect(onCandidateFocus).toHaveBeenCalledWith(second)
expect(document.activeElement).toBe(second)
})
})

@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { contains, getActiveElement, getDocument, getParentNode, getWindow } from './core'
import { getOwnerDocument, isOrContainsTarget } from './elements'
describe('uix/lib/dom core', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('resolves document and window from regular nodes', () => {
const node = document.createElement('div')
document.body.appendChild(node)
expect(getDocument(node)).toBe(document)
expect(getWindow(node)).toBe(window)
})
it('contains handles shadow DOM ancestry', () => {
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'open' })
const child = document.createElement('button')
shadow.appendChild(child)
document.body.appendChild(host)
expect(contains(host, child)).toBe(true)
expect(contains(document.body, child)).toBe(true)
expect(contains(child, host)).toBe(false)
})
it('getActiveElement descends into nested shadow roots', () => {
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'open' })
const nestedHost = document.createElement('div')
const nestedShadow = nestedHost.attachShadow({ mode: 'open' })
const input = document.createElement('input')
nestedShadow.appendChild(input)
shadow.appendChild(nestedHost)
document.body.appendChild(host)
input.focus()
expect(getActiveElement(document)).toBe(input)
expect(getActiveElement(shadow)).toBe(input)
})
it('getParentNode returns the shadow host when crossing a shadow boundary', () => {
const host = document.createElement('div')
const shadow = host.attachShadow({ mode: 'open' })
const child = document.createElement('span')
shadow.appendChild(child)
expect(getParentNode(child)).toBe(host)
})
it('exposes element convenience helpers on top of the core DOM API', () => {
const parent = document.createElement('div')
const child = document.createElement('button')
parent.appendChild(child)
document.body.appendChild(parent)
expect(getOwnerDocument(child)).toBe(document)
expect(isOrContainsTarget(parent, child)).toBe(true)
expect(isOrContainsTarget(child, parent)).toBe(false)
})
})

@ -0,0 +1,146 @@
const ELEMENT_NODE = 1
const DOCUMENT_NODE = 9
const DOCUMENT_FRAGMENT_NODE = 11
function isObject(value: unknown): value is Record<PropertyKey, unknown> {
return value !== null && typeof value === 'object'
}
function getGlobalDocument(): Document {
if (typeof document !== 'undefined') return document
throw new Error('[uix/lib/dom] getDocument() requires a browser document')
}
function getGlobalWindow(): Window {
if (typeof window !== 'undefined') return window
throw new Error('[uix/lib/dom] getWindow() requires a browser window')
}
export const isBrowser = typeof document !== 'undefined'
export const isIOS =
isBrowser &&
typeof navigator !== 'undefined' &&
(/iP(ad|hone|od)/.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 2 && /iPad|Macintosh/.test(navigator.userAgent)))
export function isTouch(event: PointerEvent): boolean {
return event.pointerType === 'touch'
}
export function isNode(node: unknown): node is Node {
return isObject(node) && typeof (node as Node).nodeType === 'number'
}
export function isDocument(node: unknown): node is Document {
return isNode(node) && node.nodeType === DOCUMENT_NODE
}
export function isWindow(node: unknown): node is Window {
return isObject(node) && 'window' in node && (node as Window).window === node
}
export function isShadowRoot(node: unknown): node is ShadowRoot {
return isNode(node) && node.nodeType === DOCUMENT_FRAGMENT_NODE && 'host' in node
}
export function isHTMLElement(node: unknown): node is HTMLElement {
return isNode(node) && node.nodeType === ELEMENT_NODE && typeof (node as Element).tagName === 'string'
}
export function isElement(node: unknown): node is Element {
return isNode(node) && node.nodeType === ELEMENT_NODE
}
export function isElementOrSVGElement(node: unknown): node is Element | SVGElement {
return isElement(node)
}
export function isFocusVisible(element: Element): boolean {
try {
return element.matches(':focus-visible')
} catch {
return false
}
}
export function isSelectableInput(
element: unknown
): element is HTMLInputElement & { select: () => void } {
if (typeof HTMLInputElement === 'undefined' || !(element instanceof HTMLInputElement)) return false
const nonSelectable = new Set(['button', 'checkbox', 'file', 'image', 'radio', 'reset', 'submit'])
return !nonSelectable.has(element.type)
}
export function isElementHidden(node: HTMLElement, stopAt?: HTMLElement): boolean {
if (getComputedStyle(node).visibility === 'hidden') return true
while (node) {
if (stopAt && node === stopAt) return false
if (getComputedStyle(node).display === 'none') return true
node = node.parentElement as HTMLElement
}
return false
}
export function getNodeName(node: Node | Window): string {
if (isHTMLElement(node)) return node.localName ?? ''
return '#document'
}
export function getDocument(node?: Element | Window | Node | Document | null): Document {
if (isDocument(node)) return node
if (isWindow(node)) return node.document
return node?.ownerDocument ?? getGlobalDocument()
}
export function getDocumentElement(node?: Element | Window | Node | Document | null): HTMLElement {
return getDocument(node).documentElement
}
export function getWindow(node?: Node | ShadowRoot | Document | Window | null): Window {
if (isWindow(node)) return node
if (isShadowRoot(node)) return getWindow(node.host)
if (isDocument(node)) return node.defaultView ?? getGlobalWindow()
if (isNode(node)) return node.ownerDocument?.defaultView ?? getGlobalWindow()
return getGlobalWindow()
}
export function getActiveElement(root?: Document | ShadowRoot | Node | null): Element | null {
const provider = isShadowRoot(root) ? root : getDocument(root)
let active = provider.activeElement
while (active?.shadowRoot?.activeElement) {
const nested = active.shadowRoot.activeElement
if (!nested || nested === active) break
active = nested
}
return active
}
export function getParentNode(node: Node): Node {
if (getNodeName(node) === 'html') return node
const next =
(node as Node & { assignedSlot?: HTMLSlotElement | null }).assignedSlot ||
node.parentNode ||
(isShadowRoot(node) ? node.host : null) ||
getDocumentElement(node)
return isShadowRoot(next) ? next.host : next
}
export function contains(
parent: Node | null | undefined,
child: Node | null | undefined
): boolean {
if (!parent || !child) return false
if (parent === child) return true
if (parent.contains(child)) return true
let current: Node | null = child
while (current) {
if (current === parent) return true
const next = getParentNode(current)
if (!next || next === current) break
current = next
}
return false
}

@ -0,0 +1,9 @@
import { contains, getDocument } from './core'
export function isOrContainsTarget(node: HTMLElement, target: Element): boolean {
return node === target || contains(node, target)
}
export function getOwnerDocument(element: Element | null | undefined): Document {
return getDocument(element ?? undefined)
}

@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { focusFirst, getTabbableCandidates, getTabbableEdges } from './focus'
import { getTabbableFrom } from './tabbable'
describe('uix/lib/dom focus', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('collects tabbable candidates in DOM order', () => {
const container = document.createElement('div')
const first = document.createElement('button')
const hidden = document.createElement('input')
const second = document.createElement('a')
hidden.type = 'hidden'
second.href = '#'
container.append(first, hidden, second)
document.body.appendChild(container)
expect(getTabbableCandidates(container)).toEqual([first, second])
})
it('returns the visible tabbable edges', () => {
const container = document.createElement('div')
const first = document.createElement('button')
const hidden = document.createElement('button')
const last = document.createElement('button')
hidden.style.display = 'none'
container.append(first, hidden, last)
document.body.appendChild(container)
expect(getTabbableEdges(container)).toEqual([first, last])
})
it('focuses the first candidate that can receive focus', () => {
const first = document.createElement('button')
const second = document.createElement('button')
document.body.append(first, second)
expect(focusFirst([first, second])).toBe(true)
expect(document.activeElement).toBe(first)
})
it('finds the next tabbable element with tabbable()', () => {
const first = document.createElement('button')
const second = document.createElement('button')
const third = document.createElement('button')
document.body.append(first, second, third)
expect(getTabbableFrom(first, 'next')).toBe(second)
expect(getTabbableFrom(third, 'prev')).toBe(second)
})
})

@ -0,0 +1,114 @@
import {
getActiveElement,
getDocument,
getWindow,
isElementHidden,
isSelectableInput
} from './core'
export type FocusableTarget =
| HTMLElement
| SVGElement
| {
focus: (options?: FocusOptions) => void
select?: () => void
}
| null
| undefined
export function focusWithoutScroll(element: HTMLElement | null | undefined): void {
if (!element) return
const doc = getDocument(element)
const win = getWindow(element)
const scrollPosition = {
x: win.pageXOffset || doc.documentElement.scrollLeft,
y: win.pageYOffset || doc.documentElement.scrollTop
}
try {
element.focus({ preventScroll: true })
} catch {
element.focus()
}
win.scrollTo(scrollPosition.x, scrollPosition.y)
}
export function focus(
element: FocusableTarget,
{ select = false }: { select?: boolean } = {}
): void {
if (!element || typeof element.focus !== 'function') return
const doc = getDocument(element as HTMLElement)
if (doc.activeElement === element) return
const previous = doc.activeElement
try {
element.focus({ preventScroll: true })
} catch {
element.focus()
}
if (element !== previous && isSelectableInput(element) && select) {
element.select()
}
}
export function focusFirst(
candidates: HTMLElement[],
{ select = false }: { select?: boolean } = {},
currentActive?: () => Element | null
): boolean {
const getCurrent =
currentActive ??
(() => {
const first = candidates[0]
return first ? getActiveElement(first) : null
})
const previous = getCurrent()
for (const candidate of candidates) {
focus(candidate, { select })
if (getCurrent() !== previous) return true
}
return false
}
export function findVisible(
elements: HTMLElement[],
container: HTMLElement
): HTMLElement | undefined {
for (const element of elements) {
if (!isElementHidden(element, container)) return element
}
}
export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
const nodes: HTMLElement[] = []
const doc = getDocument(container)
const walker = doc.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode(node) {
const element = node as HTMLElement
const isHiddenInput = element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'hidden'
if (element.disabled || element.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP
return element.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
}
})
while (walker.nextNode()) {
nodes.push(walker.currentNode as HTMLElement)
}
return nodes
}
export function getTabbableEdges(
container: HTMLElement
): readonly [HTMLElement | undefined, HTMLElement | undefined] {
const candidates = getTabbableCandidates(container)
const first = findVisible(candidates, container)
const last = findVisible([...candidates].reverse(), container)
return [first, last] as const
}

@ -0,0 +1,7 @@
export * from './core'
export * from './elements'
export * from './focus'
export * from './locale'
export * from './resize-observer.svelte.js'
export * from './responsive.svelte.js'
export * from './tabbable'

@ -0,0 +1,27 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it } from 'vitest'
import { getElemDirection, getElementDirection } from './locale'
describe('uix/lib/dom locale', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('reads rtl direction from computed styles', () => {
const node = document.createElement('div')
node.style.direction = 'rtl'
document.body.appendChild(node)
expect(getElementDirection(node)).toBe('rtl')
expect(getElemDirection(node)).toBe('rtl')
})
it('falls back to ltr when direction is not rtl', () => {
const node = document.createElement('div')
document.body.appendChild(node)
expect(getElementDirection(node)).toBe('ltr')
})
})

@ -0,0 +1,8 @@
export type DomDirection = 'ltr' | 'rtl'
export function getElementDirection(element: HTMLElement): DomDirection {
const direction = getComputedStyle(element).getPropertyValue('direction').trim()
return direction === 'rtl' ? 'rtl' : 'ltr'
}
export const getElemDirection = getElementDirection

@ -0,0 +1,58 @@
export type ResizeObservedNode = HTMLElement | null | undefined
export type ResizeObservedNodeGetter = () => ResizeObservedNode
function canObserveResize(): boolean {
return typeof window !== 'undefined' && typeof ResizeObserver !== 'undefined'
}
export class SvelteResizeObserver {
readonly node: ResizeObservedNodeGetter
readonly onResize: () => void
private observer: ResizeObserver | null = null
private observedNode: HTMLElement | null = null
private rAF = 0
constructor(node: ResizeObservedNodeGetter, onResize: () => void) {
this.node = node
this.onResize = onResize
this.refresh()
}
refresh = () => {
if (!canObserveResize()) return
const nextNode = this.node() ?? null
if (nextNode === this.observedNode) return
this.disconnect()
if (!nextNode) return
this.observedNode = nextNode
this.observer = new ResizeObserver(() => {
if (typeof window.requestAnimationFrame === 'function') {
window.cancelAnimationFrame(this.rAF)
this.rAF = window.requestAnimationFrame(this.onResize)
return
}
this.onResize()
})
this.observer.observe(nextNode)
}
destroy = () => {
this.disconnect()
}
private disconnect() {
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(this.rAF)
}
this.rAF = 0
this.observer?.disconnect()
this.observer = null
this.observedNode = null
}
}

@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SvelteResizeObserver } from './resize-observer.svelte'
type ResizeObserverCallbackLike = ConstructorParameters<typeof ResizeObserver>[0]
class MockResizeObserver {
static instances: MockResizeObserver[] = []
readonly observed: Element[] = []
disconnected = false
readonly callback: ResizeObserverCallbackLike
constructor(callback: ResizeObserverCallbackLike) {
this.callback = callback
MockResizeObserver.instances.push(this)
}
observe = (element: Element) => {
this.observed.push(element)
}
disconnect = () => {
this.disconnected = true
}
trigger() {
this.callback([] as ResizeObserverEntry[], this as unknown as ResizeObserver)
}
}
describe('uix/lib/dom resize observer', () => {
const originalResizeObserver = globalThis.ResizeObserver
const originalRequestAnimationFrame = window.requestAnimationFrame
const originalCancelAnimationFrame = window.cancelAnimationFrame
beforeEach(() => {
MockResizeObserver.instances = []
;(globalThis as typeof globalThis & { ResizeObserver: typeof ResizeObserver }).ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0)
return 1
}) as typeof window.requestAnimationFrame
window.cancelAnimationFrame = vi.fn() as typeof window.cancelAnimationFrame
})
afterEach(() => {
if (originalResizeObserver) {
globalThis.ResizeObserver = originalResizeObserver
} else {
delete (globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserver })
.ResizeObserver
}
window.requestAnimationFrame = originalRequestAnimationFrame
window.cancelAnimationFrame = originalCancelAnimationFrame
})
it('observes the provided node and invokes the callback on resize', async () => {
const node = document.createElement('div')
const onResize = vi.fn()
new SvelteResizeObserver(() => node, onResize)
expect(MockResizeObserver.instances).toHaveLength(1)
expect(MockResizeObserver.instances[0]?.observed).toEqual([node])
MockResizeObserver.instances[0]?.trigger()
expect(onResize).toHaveBeenCalledTimes(1)
})
it('does not create an observer when the target is null', async () => {
new SvelteResizeObserver(() => null, vi.fn())
expect(MockResizeObserver.instances).toHaveLength(0)
})
it('can rebind to a new node and disconnect on destroy', () => {
const first = document.createElement('div')
const second = document.createElement('div')
let current = first
const resizeObserver = new SvelteResizeObserver(() => current, vi.fn())
expect(MockResizeObserver.instances).toHaveLength(1)
expect(MockResizeObserver.instances[0]?.observed).toEqual([first])
current = second
resizeObserver.refresh()
expect(MockResizeObserver.instances).toHaveLength(2)
expect(MockResizeObserver.instances[0]?.disconnected).toBe(true)
expect(MockResizeObserver.instances[1]?.observed).toEqual([second])
resizeObserver.destroy()
expect(MockResizeObserver.instances[1]?.disconnected).toBe(true)
})
})

@ -0,0 +1,80 @@
export type Breakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
export type ResponsiveProp<T> = T | Partial<Record<Breakpoint, T>>
export const BREAKPOINTS_DEFAULT: Record<Breakpoint, number> = {
base: 0,
sm: 480,
md: 768,
lg: 1024,
xl: 1280,
xxl: 1536
}
export type Breakpoints = Record<Breakpoint, number>
export const BREAKPOINT_ORDER: readonly Breakpoint[] = ['base', 'sm', 'md', 'lg', 'xl', 'xxl']
const canUseDom = typeof window !== 'undefined'
export const viewport = $state({ width: canUseDom ? window.innerWidth : 0 })
export const viewportWidth = {
get current() {
return viewport.width
}
}
let trackingInitialized = false
export function initViewportTracking(): void {
if (!canUseDom) return
if (trackingInitialized) return
trackingInitialized = true
const update = () => {
viewport.width = window.innerWidth
}
update()
window.addEventListener('resize', update, { passive: true })
}
export function isResponsivePropObject<T>(
value: ResponsiveProp<T> | undefined
): value is Partial<Record<Breakpoint, T>> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function getCurrentBreakpoint(
width = viewport.width,
breakpoints: Breakpoints = BREAKPOINTS_DEFAULT
): Breakpoint {
let current: Breakpoint = 'base'
for (const breakpoint of BREAKPOINT_ORDER) {
if (width >= breakpoints[breakpoint]) current = breakpoint
}
return current
}
export function resolveResponsiveProp<T>(
value: ResponsiveProp<T> | undefined,
width = viewport.width,
breakpoints: Breakpoints = BREAKPOINTS_DEFAULT
): T | undefined {
if (value === undefined) return undefined
if (!isResponsivePropObject(value)) return value
let resolved = value.base
if (width >= breakpoints.sm && value.sm !== undefined) resolved = value.sm
if (width >= breakpoints.md && value.md !== undefined) resolved = value.md
if (width >= breakpoints.lg && value.lg !== undefined) resolved = value.lg
if (width >= breakpoints.xl && value.xl !== undefined) resolved = value.xl
if (width >= breakpoints.xxl && value.xxl !== undefined) resolved = value.xxl
return resolved
}

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
BREAKPOINTS_DEFAULT,
getCurrentBreakpoint,
resolveResponsiveProp
} from './responsive.svelte'
describe('uix/lib/dom responsive', () => {
it('resolves the current breakpoint from viewport width', () => {
expect(getCurrentBreakpoint(0)).toBe('base')
expect(getCurrentBreakpoint(BREAKPOINTS_DEFAULT.sm)).toBe('sm')
expect(getCurrentBreakpoint(BREAKPOINTS_DEFAULT.md + 12)).toBe('md')
expect(getCurrentBreakpoint(BREAKPOINTS_DEFAULT.xxl + 1)).toBe('xxl')
})
it('resolves responsive values against the active breakpoint', () => {
const value = {
base: 'xs',
sm: 'sm',
lg: 'lg',
xxl: 'xxl'
} as const
expect(resolveResponsiveProp(value, 320)).toBe('xs')
expect(resolveResponsiveProp(value, 640)).toBe('sm')
expect(resolveResponsiveProp(value, 1280)).toBe('lg')
expect(resolveResponsiveProp(value, 1800)).toBe('xxl')
})
it('returns non-responsive values untouched', () => {
expect(resolveResponsiveProp('md', 900)).toBe('md')
expect(resolveResponsiveProp(undefined, 900)).toBeUndefined()
})
})

@ -0,0 +1,49 @@
import { focusable, isFocusable, isTabbable, tabbable } from 'tabbable'
import { getDocument } from './core'
function getTabbableOptions() {
return {
getShadowRoot: true,
displayCheck:
typeof ResizeObserver === 'function' && ResizeObserver.toString().includes('[native code]')
? 'full'
: 'none'
} as const
}
export function getTabbableFrom(
currentNode: HTMLElement,
direction: 'next' | 'prev'
): HTMLElement {
if (!isTabbable(currentNode, getTabbableOptions())) {
return getTabbableFromFocusable(currentNode, direction)
}
const doc = getDocument(currentNode)
const allTabbable = tabbable(doc.body, getTabbableOptions())
if (direction === 'prev') allTabbable.reverse()
const activeIndex = allTabbable.indexOf(currentNode)
if (activeIndex === -1) return doc.body
return allTabbable.slice(activeIndex + 1)[0] ?? doc.body
}
export function getTabbableFromFocusable(
currentNode: HTMLElement,
direction: 'next' | 'prev'
): HTMLElement {
const doc = getDocument(currentNode)
if (!isFocusable(currentNode, getTabbableOptions())) return doc.body
const allFocusable = focusable(doc.body, getTabbableOptions())
if (direction === 'prev') allFocusable.reverse()
const activeIndex = allFocusable.indexOf(currentNode)
if (activeIndex === -1) return doc.body
return (
allFocusable.slice(activeIndex + 1).find((node) => isTabbable(node, getTabbableOptions())) ??
doc.body
)
}

@ -0,0 +1,506 @@
# Morfo vs Provider Study
Fecha: `2026-04-23`
Objetivo de esta pasada: responder una pregunta arquitectónica concreta.
> ¿`Morfo` está gobernando realmente el contrato público de los componentes, o los
> providers siguen siendo la fuente de verdad efectiva?
La muestra se ha tomado sobre ocho componentes representativos:
- `accordion`
- `collapsible`
- `dialog`
- `drawer`
- `toast`
- `tabs`
- `combobox`
- `calendar`
Esto cubre disclosure, overlays, composiciones con colección, transient UI y
componentes de fecha con alta densidad ARIA.
---
## 1. Conclusión corta
Hoy `Morfo` **no gobierna todavía** el contrato público de forma suficiente.
Sí aporta valor real como:
- vocabulario de parts
- naming de `data-{component}-{part}`
- inventario de `data-*`
- inventario de `aria-*`
- keyboard contract declarativo
- focus policy declarativa
- validación estructural e invariantes
Pero el provider sigue siendo, en la práctica, la autoridad efectiva sobre:
- emisión concreta de `role`
- emisión concreta de `aria-*`
- emisión concreta de muchos `data-*`
- attrs derivados/contextuales
- CSS vars públicas
- traducciones runtime en ARIA labels
- políticas de focus/dismissal/gesture
Dicho sin rodeos:
**`Morfo` hoy es una capa útil, pero todavía no es ejecutiva.**
No es ridícula, pero sí está en un estado intermedio: describe mucho más de lo
que el runtime realmente consume.
---
## 2. Qué sí resuelve hoy Morfo
En todos los componentes muestreados, `Morfo` ya resuelve al menos estas cosas:
- nombres de parts
- attrs de part vía `createAttrs(morfo)`
- validación de enums de `data-*` vía `registerContract(morfo)`
- cross-checks de `partRef` / `stateRef`
- surface declarativa para docs y futuras capas (`sema`, `eidos`)
Esto evita drift de naming, pero **no evita todavía drift de ejecución**.
---
## 3. Hallazgo principal
La separación real hoy es esta:
- `Morfo` declara el contrato
- `Provider` sigue implementando y reautorando gran parte del mismo contrato
Ese segundo punto es el problema.
La pasada hecha hoy mejora esto parcialmente:
- `Provider` ya puede resolver desde `Morfo` parte de `role`, `aria-*` y `data-*`
- `accordion` y `dialog` ya usan esa vía
Pero el estudio transversal deja claro que aún quedan categorías enteras fuera
de ese modelo.
---
## 4. Matriz de la muestra
## `accordion`
Estado:
- buen candidato para contrato `Morfo`-driven
- ya migrado parcialmente a `Provider.resolveMorfoProps(...)`
Todavía hardcodeado en provider:
- agregación manual de `data-disabled`
- keyboard execution (`onkeydown`)
- `Presence`
- CSS vars públicas:
- `--soma-accordion-content-height`
- `--soma-accordion-content-width`
Lectura:
- `accordion` confirma que el modelo sirve para `role`, `aria-*` y `data-*`
simples
- también confirma que las CSS vars públicas siguen fuera del contrato
Archivos:
- [src/uix/soma/components/accordion/accordion-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/accordion/accordion-provider.svelte.ts)
- [src/uix/morfo/components/accordion.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/accordion.ts)
## `collapsible`
Estado:
- caso simple donde casi todo el contrato público es declarable
Hardcodeado en provider:
- `aria-expanded`
- `aria-controls`
- `role: 'region'`
- `aria-labelledby`
- `data-state`
- `data-disabled`
- `onclick`
Lectura:
- es el mejor ejemplo de que el modelo `Provider <-> Morfo` debería cubrir mucho
más de lo que cubre hoy
- si ni `collapsible` está plenamente gobernado por `Morfo`, el problema no es
de edge cases sino de arquitectura base
Archivos:
- [src/uix/soma/components/collapsible/collapsible-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/collapsible/collapsible-provider.svelte.ts)
- [src/uix/morfo/components/collapsible.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/collapsible.ts)
## `dialog`
Estado:
- overlay complejo con layers y nesting
- ya migrado parcialmente a `Provider.resolveMorfoProps(...)`
Todavía hardcodeado en provider:
- override dinámico de `role` por `variant`
- `data-nested`
- `data-nested-open`
- CSS vars públicas:
- `--soma-dialog-depth`
- `--soma-dialog-nested-count`
- `FocusScope`
- `Dismissal`
- `ScrollLock`
- transición y presence
Lectura:
- `dialog` demuestra que `Morfo` puede cubrir el contrato estructural
- también demuestra que hay una segunda familia de contrato público no modelada:
attrs y vars contextuales derivados del runtime de overlay
Archivos:
- [src/uix/soma/components/dialog/dialog-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/dialog/dialog-provider.svelte.ts)
- [src/uix/morfo/components/dialog.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/dialog.ts)
## `drawer`
Estado:
- overlay + gesture + translated ARIA labels + side/dismissal semantics
Hardcodeado en provider:
- `aria-haspopup`
- `aria-expanded`
- `aria-controls`
- `aria-label` traducido del trigger/close
- `role: 'dialog'`
- `aria-modal`
- `aria-describedby`
- `aria-labelledby`
- `data-side`
- `data-dragging`
- `data-nested`
- `data-nested-open`
- CSS vars públicas:
- `--drawer-progress`
- `--drawer-offset-x`
- `--drawer-offset-y`
- gesture props / physics
- focus/dismissal/scroll policy
Lectura:
- `drawer` confirma que `Morfo` actual no modela todavía suficiente surface
pública para overlays gestuales
- aquí hay mucho contrato visible que hoy sigue viviendo solo en provider
Archivos:
- [src/uix/soma/components/drawer/drawer-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/drawer/drawer-provider.svelte.ts)
- [src/uix/morfo/components/drawer.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/drawer.ts)
## `toast`
Estado:
- caso de mayor divergencia entre provider y morfo de toda la muestra
Hardcodeado en provider:
- `role` dinámico (`alert` / `status`)
- `aria-live` dinámico (`assertive` / `polite`)
- `aria-atomic`
- `aria-labelledby`
- `aria-describedby`
- `data-type`
- `data-swipe`
- `data-swipe-direction`
- CSS vars públicas:
- `--soma-toast-swipe-move-x`
- `--soma-toast-swipe-move-y`
- `--soma-toast-swipe-end-x`
- `--soma-toast-swipe-end-y`
Lectura:
- `toast` no está preparado todavía para un provider realmente `Morfo`-driven
- aquí el morfo actual se queda corto respecto al contrato real emitido
Archivos:
- [src/uix/soma/components/toast/toast-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/toast/toast-provider.svelte.ts)
- [src/uix/morfo/components/toast.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/toast.ts)
## `tabs`
Estado:
- colección compuesta con states clásicos y `Presence`
Hardcodeado en provider:
- `role: 'tablist'`
- `role: 'tab'`
- `role: 'tabpanel'`
- `aria-selected`
- `aria-controls`
- `aria-labelledby`
- `aria-hidden`
- `data-state`
- `data-value`
- keyboard execution
- `Presence`
Lectura:
- muy buen candidato para pasar a modo `Morfo`-driven casi completo
- el gap aquí es más de integración que de expresividad del contrato
Archivos:
- [src/uix/soma/components/tabs/tabs-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/tabs/tabs-provider.svelte.ts)
- [src/uix/morfo/components/tabs.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/tabs.ts)
## `combobox`
Estado:
- componente compuesto con input, popup, option list y dismissal propio
Hardcodeado en provider:
- `role: 'combobox'`
- `aria-haspopup`
- `aria-expanded`
- `aria-controls`
- `aria-activedescendant`
- `aria-required`
- `aria-autocomplete`
- `role: 'listbox'`
- `aria-multiselectable`
- `role: 'option'`
- `aria-selected`
- `data-highlighted`
- `data-label`
- translated aria label del trigger
- keyboard execution
- dismissal policy
Lectura:
- `combobox` confirma que `Morfo` sí expresa bastante del contrato, pero
sigue faltando la ejecución desde la base `Provider`
- también deja ver attrs públicos adicionales no bien modelados (`data-label`,
`data-highlighted`)
Archivos:
- [src/uix/soma/components/combobox/combobox-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/combobox/combobox-provider.svelte.ts)
- [src/uix/morfo/components/combobox.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/combobox.ts)
## `calendar`
Estado:
- el caso más rico en ARIA declarativa
- también el más grande y con mayor densidad de attrs calculados
Hardcodeado en provider:
- `role: 'application'`
- `role: 'grid'`, `row`, `gridcell`, `button`
- `aria-label`
- `aria-disabled`
- `aria-readonly`
- `aria-selected`
- gran cantidad de `data-*` flags:
- `data-selected`
- `data-unavailable`
- `data-today`
- `data-weekend`
- `data-holiday`
- `data-outside-month`
- `data-focused`
- `data-value`
- labels traducidos de navegación
- keyboard routing
Lectura:
- aquí `Morfo` tiene potencial enorme, pero el provider actual sigue
implementando casi toda la surface pública de manera manual
- si `Morfo` llega a gobernar calendarios, probablemente gobernará casi todo lo
demás
Archivos:
- [src/uix/soma/components/calendar/calendar-provider.svelte.ts](/G:/dev/svelte/vicen/src/uix/soma/components/calendar/calendar-provider.svelte.ts)
- [src/uix/morfo/components/calendar.ts](/G:/dev/svelte/vicen/src/uix/morfo/components/calendar.ts)
---
## 5. Patrones transversales detectados
### A. Contrato estructural ya declarable
Esta familia sí merece estar en `Morfo` y debería resolverse desde `Provider`
base siempre que sea posible:
- `role`
- `aria-*` con `literal`, `stateRef`, `partRef`, `propRef`, `translationRef`
- `data-*` simples
- presence flags
- attrs de orientation / disabled / open / checked / active
### B. Contrato público aún no modelado
Esta familia también es cross-layer, pero hoy sigue fuera de `Morfo`:
- CSS vars públicas
- attrs contextuales como `data-nested`, `data-side`, `data-swipe`
- attrs causales como `data-last-action`
- markers de transición
- attrs derivados de selection/highlight/focus runtime
Esta es la mayor brecha del modelo actual.
### C. Runtime que debe seguir en Provider
Estas cosas no deberían subir enteras a `Morfo`; pertenecen al runtime:
- handlers concretos `onclick` / `onkeydown`
- lógica de estado
- DOM queries
- mediciones (`scrollHeight`, `scrollWidth`)
- `Presence`
- `FocusScope`
- `Dismissal`
- `ScrollLock`
- `Gesture`
- arbitraje y policies
La clave es: `Morfo` debe declarar el contrato, no ejecutar la mecánica.
---
## 6. Evaluación honesta de la situación actual
La hipótesis inicial era:
> "Todos los componentes heredan de `Provider`, así que el contacto con `Morfo`
> debería hacerse ahí."
El estudio confirma que esa idea era correcta.
Pero también confirma algo importante:
> meter el contacto en `Provider` no basta si `Morfo` solo modela parts, `aria`
> y `data-*` de forma parcial.
Hoy el problema no es solo de integración; también es de cobertura del contrato.
`Morfo` necesita modelar mejor al menos una familia más:
- CSS vars públicas / style contract
Y probablemente otra:
- attrs públicos derivados/contextuales no reducibles a `stateRef` trivial
---
## 7. Juicio sobre MorfoComponente
Pregunta de fondo:
> "si sigo encontrando hardcoded contrato público en provider, ¿no es casi
> ridículo tener MorfoComponente?"
Respuesta honesta:
- **No es ridículo**, porque ya resuelve naming, validación e inventario cross-layer.
- **Sí es insuficiente** como arquitectura ejecutiva.
En su estado actual, `MorfoComponente` es más parecido a:
- schema
- vocabulario
- documentación machine-readable
- base para validación
que a:
- contrato verdaderamente gobernante del runtime
La dirección correcta no es eliminarlo, sino completar dos movimientos:
1. `Provider` debe resolver mucho más contrato desde `Morfo`.
2. `Morfo` debe ampliar la porción de contrato público que hoy no modela.
---
## 8. Recomendación
No seguir migrando componente por componente a ciegas.
Primero cerrar estas dos extensiones del modelo:
1. `Morfo.data.value` ya introducido en esta rama.
Sirve para:
- `data-state`
- `data-orientation`
- `data-disabled`
- `aria-*` paralelos
2. Añadir `cssVars` o `styleVars` a `Morfo`.
Sirve para:
- `--accordion-content-height`
- `--accordion-content-width`
- `--drawer-progress`
- `--drawer-offset-x/y`
- `--dialog-depth`
- `--toast-swipe-*`
Después sí:
3. seguir con una segunda oleada de migración sobre:
- `collapsible`
- `tabs`
- `drawer`
- `calendar`
Ese orden te dará una lectura mucho más fiel de si el modelo escala.
---
## 9. Veredicto
El estudio confirma tres cosas:
1. La intuición original era correcta: el punto de contacto debe estar en
`Provider`.
2. `Morfo` hoy todavía no gobierna suficiente runtime como para cumplir su
promesa arquitectónica.
3. Aun así, la capa merece existir; lo que necesita no es borrarse, sino
volverse más ejecutiva y más completa como contrato.
La frase final sería:
**`Morfo` no sobra; lo que sobra es que el provider siga reescribiendo el
contrato que `Morfo` ya conoce.**

@ -2,7 +2,7 @@
**The cross-layer contract of a component's public DOM surface.**
Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, and focus policy. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables).
Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, focus policy, and public event contract. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables).
**One file per component**, at `src/uix/morfo/components/{kebab}.ts`. No prose — that's the component's README. No props — those live in `types.ts` with JSDoc. Just the machine-readable contract.
@ -16,6 +16,7 @@ Without morfo, a component's structural information lives in many places:
- Data-attr enums in `registerContract({ parts: {...} })` also in the provider.
- ARIA emission hardcoded in the provider's `$derived.by(...)` props.
- Keyboard handlers scattered across the provider.
- Public event names and their transport split across provider code, docs, and consumers.
- Prose descriptions in the README.
- Selector strings duplicated in eidos CSS, sema `.csem`, docs tables.
@ -34,12 +35,13 @@ A `Morfo` is a plain TypeScript constant that describes:
- **`scope`** — which layers implement this component: `['soma']`, `['soma', 'eidos']`, etc.
- **`apg`** — optional URL to the WAI-ARIA APG pattern when the component implements a formal one.
- **`focus`** — optional focus policy for overlays / composites.
- **`events`** — the component's public event surface: which semantic occurrences it may emit and expose to cross-layer consumers.
- **`parts`** — the part tree (recursive). Each part declares:
- `name`, `kebab`, `kind` (`public` / `virtual`).
- `defaultElement` (advisory), `role` (always-emitted).
- `optional`, `supportsNesting`.
- `states` — the state names this part can be in.
- `data` — data-attributes emitted, with enum values when applicable.
- `data` — data-attributes emitted, with enum values when applicable and optional runtime source metadata when the contract wants to declare where the attr comes from.
- `aria` — ARIA attribute contract (attr + value source + condition).
- `keyboard` — keyboard shortcuts relevant when the part has focus.
- `parts` — nested sub-parts (recursive).
@ -55,7 +57,7 @@ See [`types.ts`](./types.ts) for the full TypeScript shape.
| Usage examples | `{component}/README.md` | Narrative |
| Props (names, types, defaults) | `{component}/types.ts` with JSDoc | Canonical source is TS + JSDoc |
| Translations | `{component}/langs.ts` (idlangref) | Separate registry, consumed by the provider |
| Event handlers, state machines | `{component}-provider.svelte.ts` | Code, not data |
| Event handlers / runtime wiring, state machines | `{component}-provider.svelte.ts` | Execution logic, not contract data |
| Visual variants / recipes | `src/uix/eidos/` (future) | Layer-specific, not shared |
---
@ -188,6 +190,9 @@ data: [
// Enum-valued: the complete set.
{ attr: 'data-state', values: ['open', 'closed'] },
// Same attr, but now declaring its runtime source too.
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
// Presence-only flag: emitted only when true, absent otherwise.
{ attr: 'data-disabled', severity: 'optional' },

@ -14,8 +14,12 @@ export const accordionMorfo = {
defaultElement: 'div',
optional: false,
data: [
{ attr: 'data-orientation', values: ['horizontal', 'vertical'] },
{ attr: 'data-disabled', severity: 'optional' }
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
},
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: []
},
@ -27,9 +31,13 @@ export const accordionMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-orientation', values: ['horizontal', 'vertical'] }
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
}
],
aria: []
},
@ -42,9 +50,13 @@ export const accordionMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-orientation', values: ['horizontal', 'vertical'] }
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
}
],
aria: [{ attr: 'aria-level', value: v.propRef('level') }]
},
@ -57,9 +69,13 @@ export const accordionMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-orientation', values: ['horizontal', 'vertical'] }
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
}
],
aria: [
{ attr: 'type', value: v.literal('button') },
@ -89,9 +105,13 @@ export const accordionMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', severity: 'optional' },
{ attr: 'data-orientation', values: ['horizontal', 'vertical'] },
{
attr: 'data-orientation',
values: ['horizontal', 'vertical'],
value: v.propRef('orientation')
},
{ attr: 'data-starting-style', severity: 'optional' },
{ attr: 'data-ending-style', severity: 'optional' }
],

@ -1,81 +1,28 @@
import { describe, it, expect } from 'vitest';
import { validateMorfo, MorfoInvariantError } from '../schema';
import type { Morfo } from '../types';
import { validateSema, SemaInvariantError } from '../../sema/validation';
import type { SemaSpec, SemaAction, SemaEventLabel } from '../../sema/types';
import { dialogMorfo, dialogSema } from './dialog';
// Authored as `as const satisfies Morfo` / `satisfies SemaSpec`. Tests below
// clone into mutable shapes to deliberately corrupt fields.
function cloneMorfo(m: typeof dialogMorfo): Morfo {
return structuredClone(m as Morfo) as Morfo;
}
import { describe, expect, it } from 'vitest'
import { validateMorfo, MorfoInvariantError } from '../schema'
import type { Morfo, MorfoEvent } from '../types'
import { dialogMorfo } from './dialog'
function cloneSema(s: typeof dialogSema): SemaSpec {
return structuredClone(s as SemaSpec) as SemaSpec;
function cloneMorfo(m: typeof dialogMorfo): Morfo {
return structuredClone(m as Morfo) as Morfo
}
describe('dialogMorfo', () => {
it('passes shape + invariant validation', () => {
expect(() => validateMorfo(dialogMorfo)).not.toThrow();
});
expect(() => validateMorfo(dialogMorfo)).not.toThrow()
})
it('declares the 7 parts the provider emits', () => {
const kebabs = dialogMorfo.parts.map((p) => p.kebab).sort();
const kebabs = dialogMorfo.parts.map((p) => p.kebab).sort()
expect(kebabs).toEqual(
['close', 'content', 'description', 'overlay', 'provider', 'title', 'trigger'].sort()
);
});
it('declares data-last-action on Content for Sema causal exits', () => {
const content = dialogMorfo.parts.find((p) => p.kebab === 'content')!;
const causal = content.data.find((d) => d.attr === 'data-last-action');
expect(causal).toBeDefined();
expect(causal!.values).toContain('saved');
expect(causal!.values).toContain('cancelled');
});
it('fails validation when a partRef targets a non-existent kebab', () => {
const broken = cloneMorfo(dialogMorfo);
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!;
const controls = trigger.aria.find((a) => a.attr === 'aria-controls')!;
(controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part';
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
it('fails validation when stateRef refers to a state not declared in the part', () => {
const broken = cloneMorfo(dialogMorfo);
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!;
const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')!;
(expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state';
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
it('fails validation when two parts share the same kebab', () => {
const broken = cloneMorfo(dialogMorfo);
(broken.parts as unknown as { kebab: string }[])[1].kebab = 'content';
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
)
})
it('fails validation with empty scope', () => {
const broken = cloneMorfo(dialogMorfo);
(broken as unknown as { scope: [] }).scope = [];
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError);
});
});
describe('dialogSema', () => {
it('passes sema invariants standalone', () => {
expect(() => validateSema(dialogSema)).not.toThrow();
});
it('passes sema invariants with morfo context (cross-ref)', () => {
expect(() => validateSema(dialogSema, dialogMorfo)).not.toThrow();
});
it('declares six canonical semantic actions', () => {
const actions = dialogSema.actions.map((a) => a.name).sort();
expect(actions).toEqual(
it('declares six semantic events directly in morfo', () => {
const events = dialogMorfo.events?.map((event) => event.name).sort()
expect(events).toEqual(
[
'open',
'close-save',
@ -84,76 +31,98 @@ describe('dialogSema', () => {
'close-dismiss-outside',
'close-after-fail'
].sort()
);
});
it('fails when an action targets a non-existent part (with morfo ctx)', () => {
const broken = cloneSema(dialogSema);
(broken.actions as SemaAction[])[0].target.target = 'no-such-part';
expect(() => validateSema(broken, dialogMorfo)).toThrow(SemaInvariantError);
expect(() => validateSema(broken, dialogMorfo)).toThrow(/target "no-such-part"/);
});
it('fails when an action uses a non-canonical event label (no morfo needed)', () => {
const broken = cloneSema(dialogSema);
(broken.actions as SemaAction[])[0].event = 'commit-fulfil' as unknown as SemaEventLabel;
expect(() => validateSema(broken)).toThrow(/not a valid SemaEventLabel/);
});
it('fails when a prewrite attr is not declared in the target part data[]', () => {
const broken = cloneSema(dialogSema);
const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!;
action.prewrite![0].attr = 'data-bogus';
expect(() => validateSema(broken, dialogMorfo)).toThrow(/data-bogus.*not declared/);
});
)
})
it('declares data-last-action on Content for causal exits', () => {
const content = dialogMorfo.parts.find((p) => p.kebab === 'content')!
const causal = content.data.find((d) => d.attr === 'data-last-action')
expect(causal).toBeDefined()
expect(causal!.values).toContain('saved')
expect(causal!.values).toContain('cancelled')
})
it('can declare runtime sources for data-* attrs', () => {
const trigger = dialogMorfo.parts.find((p) => p.kebab === 'trigger')!
const state = trigger.data.find((d) => d.attr === 'data-state')
expect(state?.value).toEqual({ kind: 'stateRef', state: 'open' })
})
it('fails validation when a partRef targets a non-existent kebab', () => {
const broken = cloneMorfo(dialogMorfo)
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!
const controls = trigger.aria.find((a) => a.attr === 'aria-controls')!
;(controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part'
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError)
})
it('fails validation when stateRef refers to a state not declared in the part', () => {
const broken = cloneMorfo(dialogMorfo)
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!
const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')!
;(expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state'
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError)
})
it('fails validation when data.value.stateRef refers to an undeclared state', () => {
const broken = cloneMorfo(dialogMorfo)
const trigger = broken.parts.find((p) => p.kebab === 'trigger')!
const state = trigger.data.find((d) => d.attr === 'data-state')!
;(state.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state'
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError)
})
it('fails validation when two parts share the same kebab', () => {
const broken = cloneMorfo(dialogMorfo)
;(broken.parts as unknown as { kebab: string }[])[1].kebab = 'content'
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError)
})
it('fails validation with empty scope', () => {
const broken = cloneMorfo(dialogMorfo)
;(broken as unknown as { scope: [] }).scope = []
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError)
})
it('fails when an event targets a non-existent part', () => {
const broken = cloneMorfo(dialogMorfo)
;(broken.events as MorfoEvent[])[0].target.target = 'no-such-part'
expect(() => validateMorfo(broken)).toThrow(/targets unknown part/)
})
it('fails when a prewrite attr is not declared on the target part', () => {
const broken = cloneMorfo(dialogMorfo)
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
action.prewrite![0].attr = 'data-bogus'
expect(() => validateMorfo(broken)).toThrow(/data-bogus.*is not declared/)
})
it('fails when a prewrite writes a value outside the declared enum', () => {
const broken = cloneSema(dialogSema);
const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!;
action.prewrite![0].value = 'not-in-enum';
expect(() => validateSema(broken, dialogMorfo)).toThrow(/not-in-enum.*not in declared values/);
});
it('fails when commits targets a non-existent state', () => {
const broken = cloneSema(dialogSema);
const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!;
action.commits!.value = 'zombied';
expect(() => validateSema(broken, dialogMorfo)).toThrow(/zombied.*not in states/);
});
it('fails when two actions share the same name (no morfo needed)', () => {
const broken = cloneSema(dialogSema);
(broken.actions as SemaAction[])[1].name = 'open';
expect(() => validateSema(broken)).toThrow(/duplicate action name "open"/);
});
it('fails when data-last-action has a declared value no action prewrites', () => {
const brokenMorfo = cloneMorfo(dialogMorfo);
const content = brokenMorfo.parts.find((p) => p.kebab === 'content')!;
const dla = content.data.find((d) => d.attr === 'data-last-action')!;
(dla.values as string[]) = [...dla.values!, 'orphan-value'];
expect(() => validateSema(dialogSema, brokenMorfo)).toThrow(
/orphan-value.*no sema action prewrites/
);
});
it('fails when another part declares data-last-action values but no action ever prewrites it', () => {
const brokenMorfo = cloneMorfo(dialogMorfo);
const trigger = brokenMorfo.parts.find((p) => p.kebab === 'trigger')!;
(trigger.data as { attr: string; values?: readonly string[] }[]).push({
attr: 'data-last-action',
values: ['ghost-action']
});
expect(() => validateSema(dialogSema, brokenMorfo)).toThrow(
/ghost-action.*no sema action prewrites/
);
});
it('fails when spec.kebab disagrees with morfo.kebab', () => {
const broken = cloneSema(dialogSema);
broken.kebab = 'not-dialog';
expect(() => validateSema(broken, dialogMorfo)).toThrow(
/spec.kebab "not-dialog" does not match morfo.kebab "dialog"/
);
});
});
const broken = cloneMorfo(dialogMorfo)
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
action.prewrite![0].value = 'not-in-enum'
expect(() => validateMorfo(broken)).toThrow(/not-in-enum.*is not declared/)
})
it('fails when an event commits a non-existent state', () => {
const broken = cloneMorfo(dialogMorfo)
const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')!
if (!action.commits) throw new Error('close-save commits missing')
action.commits.value = 'zombied'
expect(() => validateMorfo(broken)).toThrow(/zombied.*not declared/)
})
it('fails when two events share the same name', () => {
const broken = cloneMorfo(dialogMorfo)
;(broken.events as MorfoEvent[])[1].name = 'open'
expect(() => validateMorfo(broken)).toThrow(/duplicate event name "open"/)
})
it('fails when data-last-action declares a value that no event prewrites', () => {
const broken = cloneMorfo(dialogMorfo)
const content = broken.parts.find((part) => part.kebab === 'content')!
const dataLastAction = content.data.find((entry) => entry.attr === 'data-last-action')!
;(dataLastAction.values as string[]) = [...(dataLastAction.values ?? []), 'orphan-value']
expect(() => validateMorfo(broken)).toThrow(/orphan-value.*no event prewrites/)
})
})

@ -11,13 +11,92 @@
import type { Morfo } from '../types';
import { v } from '../types';
import type { SemaSpec } from '../../sema/types';
export const dialogMorfo = {
name: 'Dialog',
kebab: 'dialog',
scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/',
events: [
{
name: 'open',
target: v.partRef('content'),
semantic: { family: 'emerge' },
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'open'
}
},
{
name: 'close-save',
target: v.partRef('content'),
semantic: {
family: 'commit',
intent: 'fulfill'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-cancel',
target: v.partRef('content'),
semantic: { family: 'emerge' },
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss',
target: v.partRef('content'),
semantic: { family: 'emerge' },
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss-outside',
target: v.partRef('content'),
semantic: { family: 'emerge' },
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-after-fail',
target: v.partRef('content'),
semantic: {
family: 'alert',
intent: 'threat'
},
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
}
],
focus: {
initial: 'first-focusable',
@ -35,8 +114,8 @@ export const dialogMorfo = {
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-disabled', severity: 'optional' }
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: []
},
@ -48,7 +127,7 @@ export const dialogMorfo = {
role: 'button',
optional: false,
states: ['open', 'closed'],
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-haspopup', value: v.literal('dialog') },
@ -66,7 +145,7 @@ export const dialogMorfo = {
supportsNesting: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{
/**
* Sema alignment (sema_pre.md §9): causal exit reason. Updated
@ -137,7 +216,7 @@ export const dialogMorfo = {
supportsNesting: true,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-nested', severity: 'optional' },
{ attr: 'data-nested-open', severity: 'optional' },
{
@ -205,90 +284,3 @@ export const dialogMorfo = {
}
]
} as const satisfies Morfo;
/**
* Dialog sema declaration.
*
* Seis acciones: un `open` y cinco variantes de cierre. Cada cierre
* prewrites `data-last-action` antes del commit de `data-state`, de modo
* que la capa visual pueda tintar la salida según la razón causal. Todos
* los cierres usan `lock` — un diálogo en cierre no debe re-entrarse a
* mitad de coreografía.
*/
export const dialogSema = {
kebab: 'dialog',
actions: [
{
name: 'open',
target: v.partRef('content'),
event: 'emerge',
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'open'
}
},
{
name: 'close-save',
target: v.partRef('content'),
event: 'commit-fulfill',
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-cancel',
target: v.partRef('content'),
event: 'emerge',
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss',
target: v.partRef('content'),
event: 'emerge',
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-dismiss-outside',
target: v.partRef('content'),
event: 'emerge',
regime: 'lock',
prewrite: [
{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' }
],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
},
{
name: 'close-after-fail',
target: v.partRef('content'),
event: 'alert-threat',
regime: 'lock',
prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }],
commits: {
part: v.partRef('content'),
attr: 'data-state',
value: 'closed'
}
}
]
} as const satisfies SemaSpec;

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { validateMorfo, MorfoInvariantError } from '../schema'
import type { Morfo } from '../types'
import { toastMorfo } from './toast'
function cloneMorfo(m: typeof toastMorfo): Morfo {
return structuredClone(m as Morfo) as Morfo
}
describe('toastMorfo semantic contract', () => {
it('passes shape + invariant validation', () => {
expect(() => validateMorfo(toastMorfo)).not.toThrow()
})
it('declares announce semantics and supported intents in morfo', () => {
const announce = toastMorfo.events?.find((event) => event.name === 'announce')
expect(announce).toBeDefined()
expect(announce?.semantic).toMatchObject({
family: 'alert',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat']
}
})
})
it('declares intent-driven role and aria-live on the item contract', () => {
const item = toastMorfo.parts.find((part) => part.kebab === 'item')
const role = item?.aria.find((entry) => entry.attr === 'role')
const live = item?.aria.find((entry) => entry.attr === 'aria-live')
expect(role).toMatchObject({
attr: 'role',
value: {
kind: 'mapRef',
source: { kind: 'propRef', prop: 'intent' },
map: {
neutral: 'status',
affirm: 'status',
fulfill: 'status',
risk: 'alert',
threat: 'alert'
}
}
})
expect(live).toMatchObject({
attr: 'aria-live',
value: {
kind: 'mapRef',
source: { kind: 'propRef', prop: 'intent' },
map: {
neutral: 'polite',
affirm: 'polite',
fulfill: 'polite',
risk: 'assertive',
threat: 'assertive'
}
}
})
})
it('fails when the default intent is not in the supported list', () => {
const broken = cloneMorfo(toastMorfo)
const announce = broken.events?.find((event) => event.name === 'announce')
if (!announce || !('intent' in announce.semantic) || typeof announce.semantic.intent === 'string') {
throw new Error('announce semantic intent binding missing in fixture')
}
announce.semantic.intent.default = 'neutral'
announce.semantic.intent.supported = ['affirm', 'risk']
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError)
expect(() => validateMorfo(broken)).toThrow(/default intent "neutral" must be included/)
})
it('fails when an intent map references an unknown local state', () => {
const broken = cloneMorfo(toastMorfo)
const item = broken.parts.find((part) => part.kebab === 'item')
if (!item) throw new Error('item part missing in fixture')
item.aria[0] = {
attr: 'role',
value: {
kind: 'mapRef',
source: { kind: 'stateRef', state: 'missing' },
map: { true: 'alert', false: 'status' }
}
}
expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError)
expect(() => validateMorfo(broken)).toThrow(/stateRef "missing"/)
})
})

@ -5,6 +5,30 @@ export const toastMorfo = {
name: 'Toast',
kebab: 'toast',
scope: ['soma'],
events: [
{
name: 'present',
target: v.partRef('item'),
semantic: { family: 'emerge' }
},
{
name: 'announce',
target: v.partRef('item'),
semantic: {
family: 'alert',
intent: {
fromProp: 'intent',
default: 'neutral',
supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat']
}
}
},
{
name: 'dismiss',
target: v.partRef('item'),
semantic: { family: 'emerge' }
}
],
parts: [
{
name: 'Provider',
@ -26,7 +50,7 @@ export const toastMorfo = {
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.toast.viewport|Notifications'),
value: v.propRef('label'),
severity: 'recommended'
},
{ attr: 'aria-live', value: v.literal('polite') }
@ -37,18 +61,51 @@ export const toastMorfo = {
kebab: 'item',
kind: 'public',
defaultElement: 'div',
role: 'status',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
{ attr: 'data-starting-style', severity: 'optional' },
{ attr: 'data-ending-style', severity: 'optional' },
{ attr: 'data-type', values: ['default', 'info', 'success', 'warning', 'error', 'loading'] },
{ attr: 'data-swipe', values: ['start', 'move', 'cancel', 'end'] }
{
attr: 'data-intent',
values: ['neutral', 'affirm', 'fulfill', 'risk', 'threat'],
value: v.propRef('intent')
},
{ attr: 'data-loading', severity: 'optional', value: v.propRef('loading') },
{
attr: 'data-swipe',
values: ['start', 'move', 'cancel', 'end'],
value: v.propRef('swipeState')
},
{
attr: 'data-swipe-direction',
values: ['up', 'down', 'left', 'right'],
value: v.propRef('swipeDirection')
}
],
aria: [
{ attr: 'aria-live', value: v.propRef('priority'), severity: 'recommended' },
{
attr: 'role',
value: v.mapRef(v.propRef('intent'), {
neutral: 'status',
affirm: 'status',
fulfill: 'status',
risk: 'alert',
threat: 'alert'
})
},
{
attr: 'aria-live',
value: v.mapRef(v.propRef('intent'), {
neutral: 'polite',
affirm: 'polite',
fulfill: 'polite',
risk: 'assertive',
threat: 'assertive'
}),
severity: 'recommended'
},
{ attr: 'aria-atomic', value: v.literal('true') },
{
attr: 'aria-labelledby',
@ -91,7 +148,10 @@ export const toastMorfo = {
role: 'button',
optional: true,
data: [],
aria: [{ attr: 'type', value: v.literal('button') }]
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-label', value: v.propRef('altText'), severity: 'recommended' }
]
},
{
name: 'Close',

@ -10,7 +10,9 @@
// Consumers import them directly from there — no re-export here.
export type {
MorfoElement,
MorfoValueSource,
MorfoAriaValue,
MorfoDataValue,
MorfoCondition,
MorfoSeverity,
MorfoPartKind,
@ -18,6 +20,9 @@ export type {
MorfoAriaEntry,
MorfoKeyboard,
MorfoFocus,
MorfoSemanticIntent,
MorfoEventSemantic,
MorfoEvent,
MorfoPart,
Morfo
} from './types';

@ -21,10 +21,9 @@
* handled by a manual walker that calls `morfoPartSchema.decode()` for
* every sub-part. Swap to `lazy()` when sium ships it.
*
* This module is **layer-agnostic**: it knows about parts, ARIA, data,
* focus, keyboard — nothing about sema / eidos. Downstream layers ship
* their own deep validators (`sema/validation.ts`, …) and are called
* independently of `validateMorfo`.
* This module validates Morfo as the component contract source of truth:
* parts, ARIA, data, focus, keyboard, and component-authored semantic
* events. It still knows nothing about eidos recipes or runtime engines.
*/
import {
@ -42,7 +41,8 @@ import { SiumValidationError } from '$lib/sium/core';
import type {
Morfo,
MorfoPart,
MorfoAriaValue,
MorfoPrimitiveValueSource,
MorfoValueSource,
MorfoCondition,
MorfoElement
} from './types';
@ -101,15 +101,34 @@ const severitySchema = union(
const partKindSchema = union(literal('public'), literal('virtual'));
// ── MorfoAriaValue (tagged union) ─────────────────────────────────────────
// ── MorfoValueSource (tagged union) ───────────────────────────────────────
const ariaValueSchema = discriminated('kind', [
const primitiveValueSourceSchema = discriminated('kind', [
object({ kind: literal('literal'), value: string() }),
object({ kind: literal('stateRef'), state: string() }),
object({ kind: literal('partRef'), target: string() }),
object({ kind: literal('propRef'), prop: string() }),
object({ kind: literal('translationRef'), key: string() })
]) as Schema<MorfoAriaValue, MorfoAriaValue>;
]) as Schema<MorfoPrimitiveValueSource, MorfoPrimitiveValueSource>;
const stringMapSchema = object({}, { unknownKeys: 'passthrough' }) as unknown as Schema<
Record<string, string>,
Record<string, string>
>;
const valueSourceSchema = discriminated('kind', [
object({ kind: literal('literal'), value: string() }),
object({ kind: literal('stateRef'), state: string() }),
object({ kind: literal('partRef'), target: string() }),
object({ kind: literal('propRef'), prop: string() }),
object({ kind: literal('translationRef'), key: string() }),
object({
kind: literal('mapRef'),
source: primitiveValueSourceSchema,
map: stringMapSchema,
fallback: optional(string())
})
]) as Schema<MorfoValueSource, MorfoValueSource>;
// ── MorfoCondition (tagged union with 'always' literal + objects) ─────────
@ -134,13 +153,14 @@ const conditionSchema = union(literal('always'), conditionObjectSchema) as Schem
const dataSchema = object({
attr: string(),
values: optional(array(string())),
value: optional(valueSourceSchema),
condition: optional(conditionSchema),
severity: optional(severitySchema)
});
const ariaEntrySchema = object({
attr: string(),
value: ariaValueSchema,
value: valueSourceSchema,
condition: optional(conditionSchema),
severity: optional(severitySchema)
});
@ -151,6 +171,72 @@ const keyboardSchema = object({
condition: optional(conditionSchema)
});
// ── Semantic events ───────────────────────────────────────────────────────
const semaIntentSchema = union(
literal('threat'),
literal('risk'),
literal('neutral'),
literal('affirm'),
literal('fulfill')
)
const semaTransitionalFamilySchema = union(literal('emerge'), literal('sustain'))
const semaValencedFamilySchema = union(
literal('contact'),
literal('commit'),
literal('alert'),
literal('handle')
)
const semanticIntentSchema = object({
fromProp: optional(string()),
default: semaIntentSchema,
supported: array(semaIntentSchema)
})
const eventSemanticSchema = union(
object({
family: semaTransitionalFamilySchema
}),
object({
family: semaValencedFamilySchema,
intent: union(semaIntentSchema, semanticIntentSchema)
})
)
const attrWriteSchema = object({
part: object({
kind: literal('partRef'),
target: string()
}),
attr: string(),
value: string()
})
const commitSchema = object({
part: object({
kind: literal('partRef'),
target: string()
}),
attr: string(),
value: string()
})
const eventSchema = object({
name: string(),
target: object({
kind: literal('partRef'),
target: string()
}),
semantic: eventSemanticSchema,
mode: optional(union(literal('blocking'), literal('advisory'))),
regime: optional(union(literal('replace'), literal('collapse'), literal('lock'), literal('queue'))),
scope: optional(union(literal('part'), literal('component'), literal('scene'))),
prewrite: optional(array(attrWriteSchema)),
commits: optional(commitSchema)
})
// ── MorfoFocus ────────────────────────────────────────────────────────────
const focusTargetSchema = union(
@ -197,6 +283,7 @@ const morfoShallowSchema = object(
scope: array(layerSchema),
apg: optional(string()),
focus: optional(focusSchema),
events: optional(array(eventSchema)),
parts: array(object({}, { unknownKeys: 'passthrough' }))
// ^ parts are opaque here; walker recurses with `partShallowSchema`
},
@ -304,24 +391,57 @@ function validateInvariants(morfo: Morfo): void {
kebabs.add(part.kebab);
}
for (const { part, path } of flat) {
for (const ariaEntry of part.aria) {
const v = ariaEntry.value;
if (v.kind === 'partRef' && !kebabs.has(v.target)) {
throw new MorfoInvariantError(
`aria[${ariaEntry.attr}].partRef "${v.target}" does not match any part in "${morfo.kebab}"`,
path
);
const validateValueSource = (
value: MorfoValueSource,
part: MorfoPart,
path: ReadonlyArray<string>,
context: string
) => {
if (value.kind === 'mapRef') {
if (Object.keys(value.map).length === 0) {
throw new MorfoInvariantError(`${context}.mapRef must declare at least one mapping`, path);
}
if (v.kind === 'stateRef') {
const states = part.states ?? [];
if (!states.includes(v.state)) {
for (const [sourceValue, mappedValue] of Object.entries(value.map)) {
if (typeof mappedValue !== 'string') {
throw new MorfoInvariantError(
`aria[${ariaEntry.attr}].stateRef "${v.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`,
`${context}.mapRef["${sourceValue}"] must resolve to a string`,
path
);
}
}
validateValueSource(value.source, part, path, `${context}.mapRef.source`);
return;
}
if (value.kind === 'partRef' && !kebabs.has(value.target)) {
throw new MorfoInvariantError(
`${context}.partRef "${value.target}" does not match any part in "${morfo.kebab}"`,
path
);
}
if (value.kind === 'stateRef') {
const states = part.states ?? [];
if (!states.includes(value.state)) {
throw new MorfoInvariantError(
`${context}.stateRef "${value.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`,
path
);
}
}
};
for (const { part, path } of flat) {
for (const dataEntry of part.data) {
const v = dataEntry.value;
if (!v) continue;
validateValueSource(v, part, path, `data[${dataEntry.attr}]`);
}
for (const ariaEntry of part.aria) {
validateValueSource(ariaEntry.value, part, path, `aria[${ariaEntry.attr}]`);
}
const checkCondition = (c: MorfoCondition | undefined, context: string) => {
@ -367,6 +487,105 @@ function validateInvariants(morfo: Morfo): void {
);
}
}
const events = morfo.events ?? []
const eventNames = new Set<string>()
const partByKebab = new Map(flat.map(({ part }) => [part.kebab, part] as const))
const prewriteDLAByPart = new Map<string, Set<string>>()
for (const event of events) {
if (eventNames.has(event.name)) {
throw new MorfoInvariantError(
`duplicate event name "${event.name}" in morfo "${morfo.kebab}"`
)
}
eventNames.add(event.name)
if (!kebabs.has(event.target.target)) {
throw new MorfoInvariantError(
`event "${event.name}" targets unknown part "${event.target.target}"`
)
}
if ('intent' in event.semantic && typeof event.semantic.intent === 'object') {
if (event.semantic.intent.supported.length === 0) {
throw new MorfoInvariantError(
`event "${event.name}" must declare at least one supported intent`
)
}
if (!event.semantic.intent.supported.includes(event.semantic.intent.default)) {
throw new MorfoInvariantError(
`event "${event.name}" default intent "${event.semantic.intent.default}" must be included in supported intents`
)
}
}
for (const write of event.prewrite ?? []) {
if (!kebabs.has(write.part.target)) {
throw new MorfoInvariantError(
`event "${event.name}" prewrite targets unknown part "${write.part.target}"`
)
}
const targetPart = partByKebab.get(write.part.target)
const dataEntry = targetPart?.data.find((d) => d.attr === write.attr)
if (!dataEntry) {
throw new MorfoInvariantError(
`event "${event.name}" prewrite attr "${write.attr}" is not declared on part "${write.part.target}"`
)
}
if (dataEntry.values && !dataEntry.values.includes(write.value)) {
throw new MorfoInvariantError(
`event "${event.name}" prewrite value "${write.value}" is not declared for "${write.attr}"`
)
}
if (write.attr === 'data-last-action') {
const values = prewriteDLAByPart.get(write.part.target) ?? new Set<string>()
values.add(write.value)
prewriteDLAByPart.set(write.part.target, values)
}
}
if (event.commits) {
if (!kebabs.has(event.commits.part.target)) {
throw new MorfoInvariantError(
`event "${event.name}" commits unknown part "${event.commits.part.target}"`
)
}
if (event.commits.attr === 'data-state') {
const targetPart = partByKebab.get(event.commits.part.target)
const states = targetPart?.states ?? []
if (!states.includes(event.commits.value)) {
throw new MorfoInvariantError(
`event "${event.name}" commits state "${event.commits.value}" not declared on part "${event.commits.part.target}"`
)
}
}
}
}
for (const part of flat.map(({ part }) => part)) {
const dataLastAction = part.data.find((entry) => entry.attr === 'data-last-action')
if (!dataLastAction?.values) continue
const declared = new Set(dataLastAction.values)
const written = prewriteDLAByPart.get(part.kebab) ?? new Set<string>()
for (const value of written) {
if (!declared.has(value)) {
throw new MorfoInvariantError(
`part "${part.kebab}" is prewritten with data-last-action="${value}" but the attr does not declare it in values[]`
)
}
}
for (const value of declared) {
if (!written.has(value)) {
throw new MorfoInvariantError(
`part "${part.kebab}" declares data-last-action value "${value}" but no event prewrites it`
)
}
}
}
}
// ── Public API ────────────────────────────────────────────────────────────
@ -378,9 +597,7 @@ function validateInvariants(morfo: Morfo): void {
* Intended for build-time / dev-time. Run once per morfo on first load;
* results are cacheable.
*
* This validator knows nothing about Sema. Components that declare a
* `sema` extension must call `validateSema(morfo)` from `../sema/validation`
* in addition.
* This validator also checks `morfo.events` when present.
*/
export function validateMorfo(morfo: unknown): Morfo {
morfoShallowSchema.decodeSync(morfo as never);

@ -2,8 +2,9 @@
* Morfo — cross-layer component contract.
*
* A morfo is the single machine-readable source of truth for a component's
* **public DOM surface**: the parts it exposes, the data-attrs it emits, the
* ARIA contract it honours, the keyboard contract, and the focus policy.
* **public component contract**: the parts it exposes, the data-attrs it emits,
* the ARIA contract it honours, the keyboard contract, the focus policy, and
* the semantic events it declares.
*
* Consumers:
* - soma providers consume morfo via `createAttrs(morfo)` + `registerContract(morfo)`
@ -11,7 +12,7 @@
* - sema reads the DOM surface that morfo declares (see sema_pre.md)
* - docs render morfo as part / data / aria / keyboard tables
*
* Morfo is the DOM-surface contract. It does NOT contain prose (README),
* Morfo is the structural + semantic component contract. It does NOT contain prose (README),
* props (types.ts + JSDoc), provider behaviour (*-provider.svelte.ts), state
* machines, translations (langs.ts idlangref), or eidos recipes.
*
@ -21,6 +22,16 @@
*/
import type { Layer, PartRef } from '../lib/types';
import type {
SemaCommit,
SemaAttrWrite,
SemaIntent,
SemaMode,
SemaRegime,
SemaScope,
SemaTransitionalFamily,
SemaValencedFamily
} from '../sema/types'
// ── HTML element ──────────────────────────────────────────────────────────
@ -66,10 +77,10 @@ export type MorfoElement =
| 'textarea'
| 'none';
// ── ARIA value taxonomy ────────────────────────────────────────────────────
// ── Value source taxonomy ──────────────────────────────────────────────────
/**
* Source semantics of an ARIA attribute's value. Tagged union so the
* Reusable source semantics for declarative attr values. Tagged union so the
* validator can enforce cross-references:
*
* - `literal` → static string ("dialog", "true")
@ -77,17 +88,45 @@ export type MorfoElement =
* - `partRef` → must match another part's `kebab` in the same morfo
* - `propRef` → consumer-controlled via component prop
* - `translationRef` → must exist as idlangref key in the component's `langs.ts`
* - `mapRef` → declarative mapping from another source (`intent -> alert`)
*
* No `computed` escape hatch — if a value doesn't fit these five shapes,
* No `computed` escape hatch — if a value doesn't fit these declarative shapes,
* the declaration is modelling the wrong thing. Revisit the semantics.
*/
export type MorfoAriaValue =
export type MorfoPrimitiveValueSource =
| { kind: 'literal'; value: string }
| { kind: 'stateRef'; state: string }
| PartRef
| { kind: 'propRef'; prop: string }
| { kind: 'translationRef'; key: string };
export interface MorfoMapValueSource {
kind: 'mapRef';
source: MorfoPrimitiveValueSource;
map: Record<string, string>;
fallback?: string;
}
export type MorfoValueSource = MorfoPrimitiveValueSource | MorfoMapValueSource;
/**
* Source semantic of an ARIA attribute's value.
*
* Alias kept for readability at call sites and backwards compatibility with
* existing morfo consumers.
*/
export type MorfoAriaValue = MorfoValueSource;
/**
* Source semantic of a `data-*` attribute's value.
*
* When omitted, the attr remains declarative-only and the provider keeps
* manual responsibility for emitting it. This allows incremental adoption:
* morfo can progressively become executable without forcing every existing
* contract to model its runtime origin on day one.
*/
export type MorfoDataValue = MorfoValueSource;
// ── Condition taxonomy ────────────────────────────────────────────────────
/**
@ -150,6 +189,20 @@ export interface MorfoData {
attr: string;
/** Complete set of valid values. Omit for presence flags. */
values?: string[];
/**
* Optional declarative source of the attr's runtime value.
*
* This is the hook that allows `Provider` to become morfo-driven:
* `role`, `aria-*` and `data-*` can eventually be resolved from the same
* contract instead of being hardcoded in every component provider.
*
* For enum-valued attrs, the source resolves the raw semantic value.
* For presence flags, truthy resolves to `''` and falsy to `undefined`.
*
* When omitted, the morfo still declares the public contract but the
* provider must emit the attr manually.
*/
value?: MorfoDataValue;
/** When this attr is emitted. Defaults to `'always'`. */
condition?: MorfoCondition;
/** Validator severity. Defaults to `'required'`. */
@ -193,6 +246,60 @@ export interface MorfoKeyboard {
condition?: MorfoCondition;
}
// ── Semantic events ───────────────────────────────────────────────────────
/**
* Configurable semantic intent exposed as part of a component's public API.
*
* Example:
* - `Toast` is semantically an `alert`
* - the consumer can tune its tone through `intent`
*/
export interface MorfoSemanticIntent {
/**
* Prop name on the soma component that controls the semantic intent.
* Example: `intent`.
*/
fromProp?: string;
/** Fallback semantic tone when the prop is omitted. */
default: SemaIntent;
/** Closed set of intents the component supports for this semantic event. */
supported: readonly SemaIntent[];
}
/**
* Semantic classification of a component event.
*
* Transitional families carry no intent. Valenced families can either use a
* fixed intent or bind intent to a public component prop.
*/
export type MorfoEventSemantic =
| {
family: SemaTransitionalFamily;
}
| {
family: SemaValencedFamily;
intent: SemaIntent | MorfoSemanticIntent;
};
/**
* Runtime event contract authored in Morfo.
*
* This is the bridge point between structural contract and semantic contract:
* a component can declare not only that it emits public state, but also what
* semantic event that state transition means.
*/
export interface MorfoEvent {
name: string;
target: PartRef;
semantic: MorfoEventSemantic;
mode?: SemaMode;
regime?: SemaRegime;
scope?: SemaScope;
prewrite?: readonly SemaAttrWrite[];
commits?: SemaCommit;
}
// ── Focus policy ──────────────────────────────────────────────────────────
/**
@ -308,6 +415,13 @@ export interface Morfo {
* component has no special focus coordination (plain controls).
*/
focus?: MorfoFocus;
/**
* Semantic events the component can emit.
*
* Morfo is the source of truth for component-authored semantics: what
* events exist, what family they belong to, and which intents are valid.
*/
events?: readonly MorfoEvent[];
/** The component's part tree. */
parts: readonly MorfoPart[];
}
@ -324,15 +438,25 @@ export interface Morfo {
* { attr: 'aria-labelledby', value: v.partRef('title') },
* { attr: 'aria-haspopup', value: v.literal('dialog') }
* ]
*
* data: [
* { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') },
* { attr: 'data-orientation', values: ['horizontal', 'vertical'], value: v.propRef('orientation') }
* ]
* ```
*/
export const v = {
literal: (value: string): MorfoAriaValue => ({ kind: 'literal', value }),
stateRef: (state: string): MorfoAriaValue => ({ kind: 'stateRef', state }),
literal: (value: string): MorfoPrimitiveValueSource => ({ kind: 'literal', value }),
stateRef: (state: string): MorfoPrimitiveValueSource => ({ kind: 'stateRef', state }),
// Returns the narrow `PartRef` — still assignable to `MorfoAriaValue`
// because PartRef is one of its cases, and reusable by any layer that
// needs to reference a part (e.g. Sema action targets).
partRef: (target: string): PartRef => ({ kind: 'partRef', target }),
propRef: (prop: string): MorfoAriaValue => ({ kind: 'propRef', prop }),
translationRef: (key: string): MorfoAriaValue => ({ kind: 'translationRef', key })
propRef: (prop: string): MorfoPrimitiveValueSource => ({ kind: 'propRef', prop }),
translationRef: (key: string): MorfoPrimitiveValueSource => ({ kind: 'translationRef', key }),
mapRef: (
source: MorfoPrimitiveValueSource,
map: Record<string, string>,
fallback?: string
): MorfoValueSource => ({ kind: 'mapRef', source, map, fallback })
} as const;

@ -0,0 +1,43 @@
# Sema
`Sema` define el dominio semántico canónico de UIX.
## Qué es
- familias canónicas: `contact`, `commit`, `alert`, `handle`, `emerge`, `sustain`
- intents canónicos: `neutral`, `affirm`, `fulfill`, `risk`, `threat`
- normalización entre shape estructurado y label canónico
- validación mínima del dominio
- `SemanticEngine` como broker semántico pequeño
## Qué ya no es
`Sema` ya no es un runtime multimodal.
No contiene:
- resolver de canales
- sound/motion/color/presence engines
- mapa perceptivo por canal
- política global de accesibilidad por canal
- runtime DOM
Eso pertenece a capas futuras y separadas:
- `SemanticEngine` publica ocurrencias semánticas
- `ActiveDom` reflejará esas ocurrencias al DOM
- `SoundEngine`, `VibraEngine` y otros engines modales se suscribirán al engine
## Relación con Morfo y Soma
- `Morfo` declara los eventos semánticos del componente en `morfo.events`
- `Soma` decide cuándo ocurren y llama a `SemanticEngine.publish(...)`
- `Sema` aporta el vocabulario, la normalización y la validación de ese dominio
## Regla de arquitectura
`Morfo` autoriza la semántica del componente.
`Sema` define el vocabulario canónico.
`SemanticEngine` publica ocurrencias.

@ -1,199 +0,0 @@
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)
})
})

@ -1,278 +0,0 @@
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)
}
}

@ -1,236 +0,0 @@
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()
})
})

@ -1,203 +0,0 @@
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)
}
}
}

@ -1,218 +0,0 @@
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)
})
})

@ -1,261 +0,0 @@
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)
}
}
}

@ -1,129 +0,0 @@
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()
})
})

@ -1,125 +0,0 @@
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)
}
}
}

@ -1,199 +0,0 @@
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)
})
})

@ -1,256 +0,0 @@
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
}
}

@ -1,215 +0,0 @@
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)
})
})

@ -1,322 +0,0 @@
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 })
})
}
}

@ -1,364 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } 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'
import { SemanticEngine } from './engine'
import { dialogMorfo } from '../morfo/components/dialog'
import { toastMorfo } from '../morfo/components/toast'
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 {
function createElement(initial: Record<string, string> = {}): HTMLElement {
const attrs = new Map<string, string>(Object.entries(initial))
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
}
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
}
afterEach(() => {
vi.useRealTimers()
_resetEngineForTesting()
})
describe('SemanticEngine', () => {
it('publishes a dialog event with normalized canonical semantics', () => {
const engine = new SemanticEngine()
const contentEl = createElement({ 'data-state': 'open' })
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 published = engine.publish(dialogMorfo, 'close-save', {
targetEl: contentEl,
partEls: { content: contentEl },
cause: 'pointer'
})
const engine = new SemaEngine({
channels: createImmediateDrivers()
expect(contentEl.getAttribute('data-last-action')).toBe('saved')
expect(published).toMatchObject({
name: 'close-save',
component: 'dialog',
target: 'content',
family: 'commit',
intent: 'fulfill',
label: 'commit-fulfill',
mode: 'blocking',
regime: 'lock',
scope: 'part',
cause: 'pointer',
prewritten: [{ part: 'content', attr: 'data-last-action', value: 'saved' }]
})
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)
it('resolves prop-driven intent from morfo events', () => {
const engine = new SemanticEngine()
const itemEl = createElement()
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 })
const published = engine.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl },
props: { intent: 'risk' }
})
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
expect(published).toMatchObject({
component: 'toast',
name: 'announce',
family: 'alert',
intent: 'risk',
label: 'alert-risk'
})
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('falls back to the declared default intent when the prop is missing', () => {
const engine = new SemanticEngine()
const itemEl = createElement()
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 published = engine.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl }
})
const session = engine.startSustain(createSustain(), createContext(target))
expect(session.active).toBe(true)
expect(published.label).toBe('alert-neutral')
expect(published.intent).toBe('neutral')
})
session.stop()
it('notifies subscribers with optional filtering', () => {
const engine = new SemanticEngine()
const itemEl = createElement()
const seen: string[] = []
expect(session.active).toBe(false)
expect(motionCleanup).toHaveBeenCalledTimes(0)
expect(presenceCleanup).toHaveBeenCalledTimes(1)
})
const unsubscribe = engine.onEvent((event) => {
seen.push(event.label)
}, { component: 'toast', family: 'alert' })
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.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl },
props: { intent: 'threat' }
})
engine.configure({
sound: { enabled: false },
motion: { enabled: false },
presence: { enabled: false },
mapOverrides: {
'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 }
}
unsubscribe()
engine.publish(toastMorfo, 'announce', {
targetEl: itemEl,
partEls: { item: itemEl },
props: { intent: 'affirm' }
})
await engine.before(createAction(), createContext(target))
expect(seen).toHaveLength(1)
expect(seen[0].hue).toBe(0)
expect(seen).toEqual(['alert-threat'])
})
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)
it('throws when publishing an undeclared event', () => {
const engine = new SemanticEngine()
destroySema()
const third = getSemaEngine()
expect(third).not.toBe(first)
expect(() =>
engine.publish(toastMorfo, 'missing', {
targetEl: createElement()
})
).toThrow(/event "missing" not declared/)
})
})

@ -1,551 +1,193 @@
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 { normalizeSemaEvent } from './event'
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
SemaAttrWrite,
SemaCause,
SemaCommit,
SemaEvent,
SemaEventLabel,
SemaFamily,
SemaIntent,
SemaMode,
SemaRegime,
SemaScope
} from './types'
import type { PartRef } from '../lib/types'
export interface SemanticEventDecl {
name: string
target: PartRef
semantic: SemaEvent
mode?: SemaMode
regime?: SemaRegime
scope?: SemaScope
prewrite?: readonly SemaAttrWrite[]
commits?: SemaCommit
}
export interface SemanticComponentContract {
kebab: string
events?: readonly SemanticEventDecl[]
}
export interface SemanticPublishContext {
targetEl: HTMLElement
rootEl?: HTMLElement
partEls?: Partial<Record<string, HTMLElement>>
props?: Record<string, unknown>
cause?: SemaCause
detail?: Record<string, unknown>
}
export interface PublishedSemanticEvent {
id: string
name: 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
}
target: string
targetEl: HTMLElement
rootEl?: HTMLElement
family: SemaFamily
intent: SemaIntent | null
label: SemaEventLabel
mode: SemaMode
regime: SemaRegime
scope: SemaScope
prewritten: readonly { part: string; attr: string; value: string }[]
commits?: SemaCommit
cause?: SemaCause
detail?: Record<string, unknown>
timestamp: number
}
export interface SemanticEventFilter {
component?: string
name?: string
family?: SemaFamily
intent?: SemaIntent | null
}
type SemanticListener = (event: PublishedSemanticEvent) => void
interface SemanticSubscriber {
filter?: SemanticEventFilter
listener: SemanticListener
}
function matchesFilter(event: PublishedSemanticEvent, filter?: SemanticEventFilter): boolean {
if (!filter) return true
if (filter.component && filter.component !== event.component) return false
if (filter.name && filter.name !== event.name) return false
if (filter.family && filter.family !== event.family) return false
if ('intent' in filter && filter.intent !== event.intent) return false
return true
}
function findEvent(contract: SemanticComponentContract, name: string): SemanticEventDecl | undefined {
return contract.events?.find((event) => event.name === name)
}
function resolvePartElement(
ctx: SemanticPublishContext,
targetPart: string,
fallback: HTMLElement
): HTMLElement | undefined {
return ctx.partEls?.[targetPart] ?? fallback
}
export class SemanticEngine {
private readonly subscribers = new Set<SemanticSubscriber>()
private nextId = 0
publish(
contract: SemanticComponentContract,
name: string,
ctx: SemanticPublishContext
): PublishedSemanticEvent {
const decl = findEvent(contract, name)
if (!decl) {
throw new Error(
`[semantic] event "${name}" not declared in "${contract.kebab}". Declared events: ${contract.events?.map((event) => event.name).join(', ') || '∅'}`
)
}
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
const targetEl = resolvePartElement(ctx, decl.target.target, ctx.targetEl)
if (!targetEl) {
throw new Error(
`[semantic] target part "${decl.target.target}" has no runtime element in "${contract.kebab}.${name}".`
)
} 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(() => {})
}
const prewritten = this.applyPrewrites(contract, decl, ctx, targetEl)
const normalized = normalizeSemaEvent(decl.semantic, ctx.props)
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
}
}
const published: PublishedSemanticEvent = {
id: `sem-${this.nextId++}`,
name: decl.name,
component: contract.kebab,
target: decl.target.target,
targetEl,
rootEl: ctx.rootEl,
family: normalized.family,
intent: normalized.intent,
label: normalized.label,
mode: decl.mode ?? 'blocking',
regime: decl.regime ?? 'replace',
scope: decl.scope ?? 'part',
prewritten,
commits: decl.commits,
cause: ctx.cause,
detail: ctx.detail,
timestamp: Date.now()
}
}
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
for (const subscriber of this.subscribers) {
if (!matchesFilter(published, subscriber.filter)) continue
subscriber.listener(published)
}
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)
})
)
return published
}
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)
}
}
onEvent(listener: SemanticListener, filter?: SemanticEventFilter): () => void {
const subscriber: SemanticSubscriber = { listener, filter }
this.subscribers.add(subscriber)
return () => {
this.subscribers.delete(subscriber)
}
}
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
destroy(): void {
this.subscribers.clear()
}
private applyPrewrites(
contract: SemanticComponentContract,
decl: SemanticEventDecl,
ctx: SemanticPublishContext,
targetEl: HTMLElement
): readonly { part: string; attr: string; value: string }[] {
const applied: Array<{ part: string; attr: string; value: string }> = []
for (const write of decl.prewrite ?? []) {
const el = resolvePartElement(ctx, write.part.target, write.part.target === decl.target.target ? targetEl : ctx.targetEl)
if (!el) {
if (DEV) {
console.warn(
`[semantic] prewrite target "${write.part.target}" missing for "${contract.kebab}.${decl.name}".`
)
}
this.abortController.signal.addEventListener(
'abort',
() => resolve('cancelled'),
{ once: true }
)
continue
}
el.setAttribute(write.attr, write.value)
applied.push({
part: write.part.target,
attr: write.attr,
value: write.value
})
])
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 applied
}
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,174 @@
import type {
SemaActionEvent,
SemaEvent,
SemaEventLabel,
SemaFamily,
SemaIntent,
SemaIntentBinding,
SemaTransitionalFamily,
SemaValencedFamily
} from './types'
export const SEMA_VALENCED_FAMILIES = [
'contact',
'commit',
'alert',
'handle'
] as const satisfies readonly SemaValencedFamily[]
export const SEMA_TRANSITIONAL_FAMILIES = [
'emerge',
'sustain'
] as const satisfies readonly SemaTransitionalFamily[]
export const SEMA_INTENTS = [
'threat',
'risk',
'neutral',
'affirm',
'fulfill'
] as const satisfies readonly SemaIntent[]
export const SEMA_EVENT_LABELS = [
'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'
] as const satisfies readonly SemaEventLabel[]
const FAMILY_SET = new Set<SemaFamily>([...SEMA_VALENCED_FAMILIES, ...SEMA_TRANSITIONAL_FAMILIES])
const VALENCED_FAMILY_SET = new Set<SemaValencedFamily>(SEMA_VALENCED_FAMILIES)
const TRANSITIONAL_FAMILY_SET = new Set<SemaTransitionalFamily>(SEMA_TRANSITIONAL_FAMILIES)
const INTENT_SET = new Set<SemaIntent>(SEMA_INTENTS)
const LABEL_SET = new Set<SemaEventLabel>(SEMA_EVENT_LABELS)
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
export function isSemaFamily(value: unknown): value is SemaFamily {
return typeof value === 'string' && FAMILY_SET.has(value as SemaFamily)
}
export function isSemaValencedFamily(value: unknown): value is SemaValencedFamily {
return typeof value === 'string' && VALENCED_FAMILY_SET.has(value as SemaValencedFamily)
}
export function isSemaTransitionalFamily(value: unknown): value is SemaTransitionalFamily {
return typeof value === 'string' && TRANSITIONAL_FAMILY_SET.has(value as SemaTransitionalFamily)
}
export function isSemaIntent(value: unknown): value is SemaIntent {
return typeof value === 'string' && INTENT_SET.has(value as SemaIntent)
}
export function isSemaEventLabel(value: unknown): value is SemaEventLabel {
return typeof value === 'string' && LABEL_SET.has(value as SemaEventLabel)
}
export function isSemaIntentBinding(value: unknown): value is SemaIntentBinding {
if (!isRecord(value)) return false
if (!isSemaIntent(value.default)) return false
if ('fromProp' in value && value.fromProp !== undefined && typeof value.fromProp !== 'string') return false
if ('supported' in value && value.supported !== undefined) {
if (!Array.isArray(value.supported)) return false
if (!value.supported.every(isSemaIntent)) return false
}
return true
}
export function isSemaEvent(value: unknown): value is SemaEvent {
if (!isRecord(value) || !isSemaFamily(value.family)) return false
if (isSemaTransitionalFamily(value.family)) return !('intent' in value)
return 'intent' in value && (isSemaIntent(value.intent) || isSemaIntentBinding(value.intent))
}
export function parseSemaEventLabel(label: SemaEventLabel): SemaEvent {
const [family, rawIntent] = label.split('-')
if (isSemaTransitionalFamily(family)) {
return { family }
}
return {
family: family as SemaValencedFamily,
intent: rawIntent as SemaIntent
}
}
export function resolveSemaIntent(
intent: SemaIntent | SemaIntentBinding,
props?: Record<string, unknown>
): SemaIntent {
if (isSemaIntent(intent)) return intent
const supported = intent.supported?.filter(isSemaIntent) ?? [...SEMA_INTENTS]
const fallback = supported[0] ?? intent.default
const candidate = intent.fromProp ? props?.[intent.fromProp] : undefined
if (isSemaIntent(candidate) && supported.includes(candidate)) {
return candidate
}
return supported.includes(intent.default) ? intent.default : fallback
}
export function normalizeSemaEvent(
event: SemaActionEvent,
props?: Record<string, unknown>
): {
family: SemaFamily
intent: SemaIntent | null
label: SemaEventLabel
} {
if (typeof event === 'string') {
const parsed = parseSemaEventLabel(event)
if ('intent' in parsed) {
return {
family: parsed.family,
intent: parsed.intent,
label: event
}
}
return {
family: parsed.family,
intent: null,
label: event
}
}
if (isSemaTransitionalFamily(event.family)) {
return {
family: event.family,
intent: null,
label: event.family
}
}
const intent = resolveSemaIntent(event.intent, props)
return {
family: event.family,
intent,
label: `${event.family}-${intent}` as SemaEventLabel
}
}
export function toSemaEventLabel(event: SemaActionEvent, props?: Record<string, unknown>): SemaEventLabel {
return normalizeSemaEvent(event, props).label
}

@ -1,92 +1,46 @@
/**
* 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 {
SemaValencedFamily,
SemaTransitionalFamily,
SemaFamily,
SemaIntent,
SemaMode,
SemaRegime,
SemaScope,
SemaCause,
SemaEventLabel,
SemaIntentBinding,
SemaEvent,
SemaActionEvent,
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';
SemaCommit
} from './types'
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';
SEMA_VALENCED_FAMILIES,
SEMA_TRANSITIONAL_FAMILIES,
SEMA_INTENTS,
SEMA_EVENT_LABELS,
isSemaFamily,
isSemaValencedFamily,
isSemaTransitionalFamily,
isSemaIntent,
isSemaEventLabel,
isSemaIntentBinding,
isSemaEvent,
parseSemaEventLabel,
resolveSemaIntent,
normalizeSemaEvent,
toSemaEventLabel
} from './event'
export type {
ResolvedSemaAction,
ResolvedSemaSustain,
SemaContext,
SemaSession,
SemaPort,
TestSemaPortOptions,
TestSemaCall,
TestSemaSession,
TestSemaPortHandle
} from './port';
SemanticEventDecl,
SemanticComponentContract,
SemanticPublishContext,
PublishedSemanticEvent,
SemanticEventFilter
} from './engine'
export { SemanticEngine } from './engine'
export { noopSemaPort, createTestSemaPort } from './port';
export { SemaInvariantError, validateSemaEvent, validateSemaIntentBinding } from './validation'

@ -1,129 +0,0 @@
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)
})
})

@ -1,181 +0,0 @@
/**
* 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
}
}
}

@ -1,258 +0,0 @@
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/)
})
})

@ -1,323 +0,0 @@
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)
}
}

@ -1,210 +0,0 @@
{
"version": "0.4.0",
"families": {
"contact": {
"base": {
"motion": {
"duration": 80,
"easing": "ease-out",
"scale": { "from": 1, "to": 0.96 },
"translate": { "x": 0, "y": 0 }
},
"sound": {
"pitch": 800,
"centroid": 2000,
"roughness": 0.1,
"attack": 4,
"decay": 40,
"duration": 60,
"contour": "flat",
"gain": 0.25
},
"color": null,
"presence": null
},
"activeChannels": ["motion", "sound"]
},
"commit": {
"base": {
"motion": {
"duration": 180,
"easing": "ease-out",
"scale": { "from": 1, "to": 1.02 }
},
"sound": {
"pitch": 700,
"centroid": 1800,
"roughness": 0.1,
"attack": 8,
"decay": 120,
"duration": 100,
"contour": "flat",
"gain": 0.3
},
"color": {
"hue": 210,
"saturation": 0.3,
"lightness": 0.5,
"duration": 200,
"intensity": 0.3
},
"presence": null
},
"activeChannels": ["motion", "sound", "color"]
},
"alert": {
"base": {
"motion": {
"duration": 220,
"easing": "ease-in-out",
"scale": { "from": 1, "to": 1.03 },
"translate": { "x": 0, "y": 0 }
},
"sound": {
"pitch": 900,
"centroid": 2400,
"roughness": 0.3,
"attack": 3,
"decay": 150,
"duration": 180,
"contour": "arc",
"gain": 0.4
},
"color": {
"hue": 40,
"saturation": 0.7,
"lightness": 0.55,
"duration": 220,
"intensity": 0.5
},
"presence": null
},
"activeChannels": ["motion", "sound", "color"]
},
"emerge": {
"base": {
"motion": {
"duration": 240,
"easing": "ease-out",
"scale": { "from": 0.96, "to": 1 }
},
"sound": {
"pitch": 600,
"centroid": 1500,
"roughness": 0.05,
"attack": 12,
"decay": 200,
"duration": 150,
"contour": "ascending",
"gain": 0.2
},
"color": null,
"presence": {
"opacity": { "from": 0, "to": 1 },
"shadow": { "blur": 24, "y": 8, "opacity": 0.15 },
"backdrop": 0.35,
"duration": 280,
"easing": "ease-out"
}
},
"activeChannels": ["motion", "presence", "sound"]
},
"handle": {
"base": {
"motion": {
"duration": 40,
"easing": "linear",
"scale": { "from": 1, "to": 1 }
},
"sound": null,
"color": null,
"presence": null
},
"activeChannels": ["motion"]
},
"sustain": {
"base": {
"motion": null,
"sound": null,
"color": null,
"presence": {
"opacity": { "from": 1, "to": 1 },
"duration": 0,
"easing": "linear"
}
},
"activeChannels": ["presence"]
}
},
"intents": {
"threat": {
"deltas": {
"motion": {
"duration": { "op": "multiply", "factor": 1.1 },
"easing": "ease-in-out",
"scale": { "to": 0.01 }
},
"sound": {
"pitch": -200,
"roughness": 0.4,
"contour": "descending",
"gain": 0.1
},
"color": {
"hue": { "op": "replace", "value": 0 },
"saturation": { "op": "add", "value": 0.2 },
"intensity": 0.2
},
"presence": {
"backdrop": 0.1,
"shadow": { "blur": 2 }
}
}
},
"risk": {
"deltas": {
"sound": {
"pitch": -100,
"roughness": 0.2
},
"color": {
"hue": { "op": "replace", "value": 30 },
"saturation": 0.1
}
}
},
"neutral": {
"deltas": {}
},
"affirm": {
"deltas": {
"sound": { "pitch": 100 },
"color": {
"hue": { "op": "replace", "value": 145 }
}
}
},
"fulfill": {
"deltas": {
"motion": {
"duration": { "op": "multiply", "factor": 1.15 },
"scale": { "to": 0.02 }
},
"sound": {
"pitch": 300,
"contour": "ascending",
"gain": 0.05
},
"color": {
"hue": { "op": "replace", "value": 155 },
"saturation": { "op": "add", "value": 0.1 },
"intensity": 0.15
},
"presence": {
"shadow": { "blur": 1 }
}
}
}
},
"soundPack": {}
}

File diff suppressed because it is too large Load Diff

@ -1,973 +0,0 @@
# Sema — Especificación v0.4
**Capa semántico-perceptiva para interfaces de usuario**
> Especificación de Sema como capa conceptual. Independiente de framework, stack y artefactos de implementación concretos. Esta versión separa lo que es Sema (contenido de este documento) de cómo se implementa en un framework específico (ver documentos de implementación separados, por ejemplo `semauix-sema-impl.md`).
>
> **Estado:** v0.4 es v0.3.1 purificada de dependencias con SemaUIX. Mismo contenido doctrinal, alcance redefinido.
>
> **Cambios respecto a v0.3.1:**
> - Eliminadas referencias específicas a morfo como contrato obligatorio
> - `SemaAction` descrita conceptualmente, sin sintaxis TypeScript concreta de SemaUIX
> - Eliminadas menciones a `v.partRef`, `as const satisfies`, sium, `createSemaBinding`
> - Ejemplo Dialog movido a la documentación de implementación de referencia
> - Se añade §13 que define el contrato mínimo que una implementación debe proveer
>
> Lista para que implementaciones concretas (SemaUIX o cualquier otra) la materialicen.
---
## 1. Introducción
### 1.1. Qué resuelve Sema
Los frameworks de interfaz modernos separan componentes en dos planos: el comportamiento (qué hace el componente) y la presentación visual (cómo se ve en reposo). Esa separación está bien, pero deja sin modelar una tercera dimensión: **cómo el usuario percibe el cambio cuando algo ocurre**.
Un input que pasa de válido a inválido no es solo "un estado que cambia" — es un evento con una ventana temporal durante la cual el sistema debe expresar perceptivamente el significado de ese cambio (amenaza, advertencia, éxito, pérdida). Esa expresión atraviesa varios canales (movimiento, sonido, color, aparición) y tiene reglas cognitivas específicas (causalidad perceptiva, correspondencias crossmodales, valencia afectiva).
Sema es la capa que declara y gestiona esa dimensión perceptivo-temporal.
### 1.2. Arquitectura de tres capas asumida
Sema asume un contexto arquitectónico de **tres capas independientes** que se coordinan exclusivamente a través del DOM estándar y un contrato común:
| Capa | Rol | Contribución |
|---|---|---|
| Capa headless | Comportamiento, estado, ARIA, keyboard | Expone estado del componente como atributos DOM estables; orquesta el flujo de eventos |
| Capa visual | Presentación en reposo del componente | Consume los atributos de estado para estilar cómo se ve el elemento **mientras dura cada estado** |
| Sema | Expresión perceptivo-afectiva de los eventos | Reacciona a las acciones declaradas por la capa headless y ejecuta coreografías perceptivas acotadas |
Las tres capas se coordinan a través de un **contrato cross-layer**: un artefacto declarativo por componente que define la superficie pública del componente — sus partes, los atributos que emite, su contrato ARIA, sus acciones semánticas. Cada capa consume el contrato desde su ángulo.
Esta spec no dicta cómo se implementa ese contrato cross-layer. Una implementación puede usar un artefacto TypeScript con validación runtime (como hace la implementación de referencia SemaUIX con `morfo`), decoradores en clases, registros runtime, hooks, o cualquier otro mecanismo que exponga la información que Sema necesita.
### 1.3. Alcance de esta spec
Sema especifica:
- La taxonomía semántica (6 familias de eventos, 5 intents afectivos)
- Los canales perceptivos (4 ejes: motion, sound, color, presence)
- La noción conceptual de acción semántica (§5)
- El protocolo de coordinación entre la capa headless y Sema (§6)
- La gramática del artefacto `.csem` (donde el integrador resuelve la expresión perceptiva)
- La estructura del artefacto `sema-map.json` (vocabulario canónico de eventos y firmas)
- Los principios de operación (secuencialidad coordinada, regímenes de arbitraje)
- Los requisitos mínimos para que una implementación sea Sema-compliant (§13)
Sema NO especifica:
- Cómo se implementa la capa headless ni la visual
- Qué framework se usa (Svelte, React, Vue, Web Components)
- Qué mecanismo concreto declara las acciones semánticas (TypeScript const, decoradores, registros runtime, otro)
- El pipeline de build concreto
- La API JavaScript exacta del engine
- Los valores numéricos finales del mapa (se proveen rangos defendibles; la calibración final es responsabilidad de cada implementación)
---
## 2. Principios fundamentales
### 2.1. Estado vs evento
Sema distingue dos planos temporales:
**Estado del componente.** Propiedad persistente del elemento. Un input es `invalid` hasta que el usuario corrija; un dialog está `open` o `closed`. El estado lo gestiona la capa headless y lo consume la capa visual para estilar el elemento **mientras dura cada estado**. Es continuo.
**Ciclo de vida del evento.** Ventana temporal acotada durante la cual ocurre la transición hacia o desde un estado. Tiene principio y fin (típicamente 60-300ms). Durante ese intervalo, Sema expresa perceptivamente "acaba de ocurrir este cambio". Al terminar, el estado persiste pero el evento ya no existe. Es puntual.
La capa visual estiliza estados. Sema expresa eventos. No son el mismo objeto ni deben tratarse con el mismo mecanismo.
### 2.2. Secuencialidad coordinada
**En el camino canónico (acciones blocking), evento y estado ocurren en secuencia, nunca en paralelo.** Sema actúa primero (ventana del evento); después se aplica el cambio de estado y la capa visual reacciona a ese nuevo estado con sus transiciones CSS habituales.
La secuencialidad estricta aplica al modo `blocking`, que es el canónico para la mayoría de acciones con cambio de estado. Las acciones `advisory` (fase `after-state`) permiten que Sema corra en paralelo al estado ya cambiado, y las coreografías largas pueden extenderse más allá del cap temporal del provider como "tails post-state" — en esos casos, el tramo secuencial garantizado termina cuando el provider libera el `await`.
Esta secuencialidad la garantiza la capa headless. La capa headless sabe qué acciones puede realizar un componente (porque el contrato cross-layer las declara), sabe qué evento Sema precede a cada acción, y orquesta el orden:
1. La capa headless decide que va a ejecutar una acción
2. Aplica los prewrites necesarios al DOM (atributos contextuales antes del evento)
3. Invoca Sema y espera (si la acción es bloqueante)
4. Sema ejecuta la coreografía perceptiva
5. Al terminar, la capa headless aplica el cambio de estado
6. La capa visual reacciona al nuevo estado con sus transiciones
En el camino canónico ya no hay superposición de capas sobre el mismo elemento al mismo tiempo; hay turnos de autoridad claros, garantizados por el protocolo. Los casos que permiten paralelismo (advisory, tails post-cap) quedan explícitamente marcados como post-state, no como violaciones de la doctrina.
### 2.3. El contrato cross-layer como fuente única
La clave de que la secuencialidad funcione es que las tres capas comparten un contrato declarativo único por componente. Ese contrato:
- Declara la superficie pública del componente (partes, atributos, ARIA, keyboard) — usado por las tres capas
- Declara las acciones semánticas del componente (qué eventos Sema existen, cuándo ocurren) — usado por la capa headless para orquestar y por Sema para ejecutar
El contrato **no** declara cómo se estilan los estados (eso es responsabilidad de la capa visual) ni cómo se expresan perceptivamente los eventos (eso es responsabilidad de Sema via `.csem` y `sema-map.json`). El contrato es estructural, no implementacional.
Qué mecanismo concreto realiza ese contrato (const declarativo, decoradores, registros, hooks) es decisión de la implementación del framework.
### 2.4. Separación de qué y cómo
El contrato cross-layer declara qué existe; cada capa declara cómo lo hace:
| Capa | Qué declara el contrato | Dónde vive el cómo |
|---|---|---|
| Headless | Partes, atributos emitidos, ARIA, keyboard, acciones semánticas | Código del provider |
| Visual | (lee del contrato) | CSS en reposo del componente |
| Sema | (lee del contrato) | `.csem` del integrador + `sema-map.json` |
Cuando el contrato declara que un Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`, no está diciendo cómo se expresa `commit-fulfill`. Eso lo resuelve Sema con `.csem` (si el integrador lo sobreescribe) o con el `sema-map.json` por defecto.
### 2.5. Declaratividad y escape hatches
Sema es declarativa por diseño: el 95% de los casos se expresan en el contrato cross-layer y `.csem` sin código imperativo. Para casos que no caben en el modelo declarativo (condiciones no expresables como atributos DOM, orquestación temporal compleja, estímulos calculados en runtime), el engine debe proveer una API imperativa como escape hatch. Es excepción, no regla.
---
## 3. Taxonomía semántica
### 3.1. Las 6 familias
Cada familia corresponde a un tipo de evento perceptivo distinto. Las diferencias finas dentro de una familia se expresan como parámetros secundarios, no como familias separadas.
| Familia | Qué expresa | Perfil temporal | Acepta intent |
|---|---|---|---|
| `contact` | Respuesta perceptiva inmediata a un acto del usuario | Puntual (<100ms) | Sí |
| `commit` | El sistema registra un cambio de estado discreto | Discreto (100-300ms) | Sí |
| `alert` | El sistema reclama atención sobre algo no atendido | Repetible (100-400ms) | Sí |
| `emerge` | Algo aparece o desaparece en el espacio visual | De aparición (150-400ms) | No (transicional) |
| `handle` | El usuario interactúa con un objeto de forma continua | Continuo | Sí |
| `sustain` | Estado sostenido sin evento puntual | Largo (segundos o más) | No (transicional) |
Las familias valenciales (`contact`, `commit`, `alert`, `handle`) aceptan modulación por intent. Las transicionales (`emerge`, `sustain`) expresan cambios de régimen puros y derivan su direccionalidad del contexto, no del intent.
### 3.2. Los 5 intents
Aplicables solo a familias valenciales. Derivados del modelo circumplejo del afecto (Russell 1980):
| Intent | Valencia | Arousal | Cuándo se aplica |
|---|---|---|---|
| `threat` | Muy negativa | Alto | Peligro, irreversible, consecuencia grave |
| `risk` | Negativa | Medio-bajo | Precaución, subóptimo, fricción |
| `neutral` | Neutra | Bajo | Operación rutinaria, sin evaluación |
| `affirm` | Leve positiva | Bajo | Correcto, adecuado, aprobado |
| `fulfill` | Positiva | Medio-alto | Éxito, logro, encaje |
Los cinco intents son cinco puntos anclados en el espacio bidimensional valencia × arousal, no cinco categorías ortogonales. La diferencia entre `affirm` y `fulfill` es principalmente de arousal.
### 3.3. El vocabulario canónico de eventos
Sema define un conjunto finito y enumerable de eventos semánticos que resultan de combinar familias × intents:
```
SemaEventLabel ∈ {
// Contact × intents
contact-neutral, contact-threat, contact-risk,
contact-affirm, contact-fulfill,
// Commit × intents
commit-neutral, commit-threat, commit-risk,
commit-affirm, commit-fulfill,
// Alert × intents
alert-neutral, alert-threat, alert-risk,
alert-affirm, alert-fulfill,
// Handle × intents
handle-neutral, handle-threat, handle-risk,
handle-affirm, handle-fulfill,
// Transicionales (sin intent)
emerge, sustain
}
```
22 eventos en total (20 valenciales + 2 transicionales). Este vocabulario es finito y cerrado; implementaciones no deben extenderlo arbitrariamente. Si una necesidad perceptiva real no encaja, es señal de revisar la taxonomía, no de añadir un evento ad-hoc.
### 3.4. La moral la da el intent, no la familia
Un principio importante: ninguna familia tiene valencia moral intrínseca. `destruction` no existe como familia porque borrar algo no siempre es negativo (borrar spam es `commit` con intent `fulfill` — un cambio de estado hacia adelante con valencia positiva). La familia describe la forma del evento; el intent describe cómo se siente.
---
## 4. Canales perceptivos
Cada evento semántico produce una firma en cuatro canales. Los parámetros y rangos viven en `sema-map.json`.
### 4.1. motion
Expresa dinámicas espaciales: movimiento, escala, rotación.
```
motion: {
duration: <ms> // 30-500, default 200
easing: <curve> // linear | ease-out | ease-in | ease-in-out
scale: { from, to } // 0.9-1.1 típico
translate: { x, y } // px o %
rotate: <deg> // raro excepto en sustain
}
```
**Respeta:** `prefers-reduced-motion` (degrada a omisión o transiciones instantáneas).
### 4.2. sound
Expresa eventos auditivos breves. Parametrizado **psicoacústicamente**, no por tipo de oscilador. El engine elige la síntesis que produce la firma solicitada.
```
sound: {
pitch: <Hz> // 200-3000
centroid: <Hz> // centroide espectral (brillo)
roughness: <0..1> // modulación banda 30-150 Hz
attack: <ms> // <5 urgente, 10-30 orgánico
decay: <ms> // 20-80 puntual, 80-200 con cola
duration: <ms> // 40-200, óptimo 60-120
contour: <shape> // flat | ascending | descending | arc | bell
gain: <0..1> // 0-0.4 típico
}
```
**Política por defecto:** el canal `sound` está **desactivado por defecto**. La implementación debe proveer configuración opt-in explícita. Razón: el sonido introduce una modalidad sensorial que muchos contextos no aceptan culturalmente (apps de productividad, entornos de oficina).
**Nota sobre `handle.carry`:** sound debe ser `null` por defecto durante la fase carry de manipulación. Sonido continuo modulado por velocidad del puntero requiere AudioWorklets para no bloquear el main thread, y queda como opt-in explícito.
**Asimetría reconocida:** sound es el único canal que no es modulación de propiedades visuales; es un canal sensorial independiente. Por eso no interactúa con la capa visual — no hay posibilidad de conflicto.
### 4.3. color
Expresa pulso cromático temporal. El color en reposo es responsabilidad de la capa visual; Sema controla solo el pulso durante el evento.
```
color: {
hue: <deg> // 0-360
saturation: <0..1> // palanca fuerte de arousal
lightness: <0..1> // 0.4-0.7 típico
duration: <ms> // 80-300
intensity: <0..1> // desviación del estado base
}
```
**Nota cultural:** los hues concretos por intent son convención occidental. Sobreescribibles a nivel global para contextos culturales distintos.
**Respeta:** `prefers-contrast: more` (re-resuelve hacia variantes de mayor legibilidad), `prefers-reduced-transparency` (sustituye transparencias por equivalentes opacos), `forced-colors: active` (color ornamental se desactiva salvo variante segura).
### 4.4. presence
Expresa segregación figura-fondo: cómo algo se hace visible o se retira de la atención.
```
presence: {
opacity: { from, to } // típico 0↔1 o 0.7↔1
shadow: { blur, y, opacity } // elevación
backdrop: <0..1> // scrim + sombra de fondo (0-0.6)
outline: { width, style } // contorno emergente
duration: <ms> // 150-350
easing: <curve>
}
```
**`backdrop`** es un sub-parámetro compuesto que agrupa scrim + sombra de fondo, dado que casi siempre se usan juntos en modales y drawers.
**Respeta:** `prefers-reduced-transparency` (backdrop sólido, sin blur), `prefers-reduced-motion` (sin fades).
### 4.5. Canales activos por evento
No todos los eventos activan los cuatro canales. Qué canales activa cada evento está definido en `sema-map.json`. Por ejemplo, `emerge` activa principalmente motion + presence (apariciones visuales); `alert-threat` activa motion + sound + color (reclamo máximo de atención); `contact-neutral` activa solo motion + sound (feedback inmediato).
---
## 5. Acciones semánticas: el concepto
### 5.1. Qué es una acción semántica
Una **acción semántica** es cualquier cosa que un componente hace que tiene significado perceptivo: abrir, cerrar-con-éxito, cerrar-con-error, invalidar, confirmar, etc.
Las acciones semánticas son declaradas por cada componente en su contrato cross-layer. La capa headless las invoca durante el flujo del componente. Sema las ejecuta aplicando las firmas perceptivas correspondientes.
La acción es la unidad de integración entre la capa headless y Sema. No los eventos DOM crudos (un click puede ser una acción u otra dependiendo del contexto), ni los cambios de estado (un cambio puede ser consecuencia de varias acciones distintas). La acción declara explícitamente qué está ocurriendo semánticamente.
### 5.2. Campos conceptuales de una acción
Una acción declara:
- **Nombre** — identificador único dentro del componente. Referenciable desde el código del provider y desde declaraciones de teclado.
- **Target** — referencia a la parte del componente afectada por la acción (dónde se aplica la coreografía perceptiva).
- **Evento** — etiqueta del vocabulario canónico (`SemaEventLabel`). Define qué firma perceptiva se dispara.
- **Modo** (opcional, default `blocking`) — si la capa headless espera a que Sema termine antes de aplicar el cambio de estado, o dispara y sigue.
- **Régimen** (opcional, default `replace`) — qué hace Sema si llega otra acción equivalente durante la ventana.
- **Scope** (opcional, default `part`) — el alcance de la coreografía: solo el target, el componente completo, o persiste como escena tras desmontaje.
- **Prewrites** (opcional) — atributos DOM que se reflejan antes de invocar Sema (contexto causal que las capas posteriores podrán leer).
- **Commits** (opcional) — si existe, describe el cambio de estado que la capa headless aplicará **después** de que Sema termine.
Estos ocho campos son suficientes para que la capa headless orqueste la secuencia y Sema resuelva la firma. Nada más debe ir en la acción: los parámetros perceptivos (pitch, hue, duraciones concretas) viven en `.csem` y `sema-map.json`.
### 5.3. Cómo se declara una acción
Esta spec no prescribe la sintaxis concreta. La implementación decide el mecanismo — un TypeScript const declarativo, decoradores, un registro runtime, un archivo JSON o YAML, un hook — siempre que el contenido semántico declarado contenga los campos del §5.2.
Ejemplo conceptual (pseudocódigo neutro):
```
action "close-save" on Dialog.Content {
event: commit-fulfill
regime: lock
prewrite: [ data-last-action = "saved" on Content ]
commits: [ data-state = "closed" on Content ]
}
```
Cada implementación materializa esta declaración en su sintaxis. La doc de implementación de referencia (`semauix-sema-impl.md`) muestra cómo SemaUIX lo hace con morfo y TypeScript.
### 5.4. Los regímenes
El régimen define el comportamiento cuando una acción se dispara mientras ya hay otra de la misma identidad en curso.
**`replace` (default):** la acción entrante cancela la anterior y arranca una nueva desde cero. Para coreografías puntuales donde interesa el evento más reciente.
**`collapse`:** *single-flight coalescing*. La primera ocurrencia abre una ventana; toda ocurrencia equivalente que llegue mientras la ventana sigue abierta no reinicia, no extiende y no crea una segunda coreografía. Se registra como "hubo repetición" pero el evento visible sigue siendo uno. Útil para acciones de alta frecuencia (typing, scroll) donde no se quiere ni silencio perpetuo ni pulsos continuos.
**`lock`:** mientras la ventana está abierta, acciones equivalentes son rechazadas. Útil para acciones de cierre (dialog close) que no deben reentrar.
**`queue`:** las acciones entrantes se encolan y se ejecutan secuencialmente tras la actual. Útil para secuencias de confirmación múltiple.
**Equivalencia:** dos ocurrencias son equivalentes si comparten el mismo nombre de acción sobre el mismo target.
### 5.5. Modo y fase
`mode` define si la capa headless espera a que Sema termine:
- **`blocking` (default):** la capa headless hace `await` antes de aplicar el commit. La secuencia Sema → commit de estado es estricta.
- **`advisory`:** la capa headless dispara Sema y sigue inmediatamente. Útil para acentos no críticos posteriores a un cambio de estado.
La fase temporal (antes del cambio de estado, después, o independiente) se deduce de la combinación de `commits` y `mode`:
- Con `commits` presente + `mode: blocking` → fase **before-state** (canónica)
- Con `commits` presente + `mode: advisory` → fase **after-state** (el estado cambia, luego Sema corre en paralelo)
- Sin `commits` → fase **independent** (no hay cambio de estado, la acción es puro feedback)
### 5.6. Matriz de combinaciones válidas
| mode | commits | Fase | Legitimidad | Caso de uso |
|---|---|---|---|---|
| blocking | presente | before-state | Canónico | Dialog close-save, input invalidate |
| advisory | presente | after-state | Legítimo | Toast al completar una acción |
| blocking | ausente | independent | Legítimo | Submit-failed, close-denied |
| advisory | ausente | independent | Legítimo | Tick breve no bloqueante |
Las combinaciones no listadas son inválidas; la implementación debe rechazarlas en validación.
### 5.7. Alcance estructural
Las acciones declaran **efecto estructural**, no precondiciones de aplicabilidad. El contrato no modela desde qué estado una acción es válida, ni distingue una acción que produce transición real de una que sería no-op. La validez contextual de una acción (si puede o no ejecutarse en un momento dado) es responsabilidad de la capa headless. Las implementaciones pueden añadir validaciones que comprueben coherencia declarativa (target resuelve, event existe en el vocabulario, prewrites referencian atributos declarados) pero no deben pretender modelar la máquina de estados completa del componente.
---
## 6. El protocolo de coordinación
### 6.1. El puerto neutral
La capa headless no importa la implementación de Sema. Importa un **puerto neutral**: una interfaz abstracta con tres operaciones.
```
interface SemaPort {
// Invoca antes del commit y espera (blocking mode)
before(action, context): Promise<void>
// Invoca sin esperar (advisory mode)
fire(action, context): void
// Inicia un sustain con lifecycle explícito
startSustain(sustain, context): SemaSession
}
interface SemaSession {
stop(): void
active: boolean
}
```
El puerto puede ser:
- Un runtime real de Sema (producción)
- Un no-op port (desarrollo sin Sema cargado, o contexto donde Sema se desactiva globalmente)
- Un test port (testing)
La capa headless solo conoce la interfaz. No conoce síntesis, mapas ni resolución perceptiva.
### 6.2. El contexto de invocación
En cada invocación, la capa headless pasa un contexto que identifica:
- El componente y la acción ejecutada
- El elemento DOM del target
- Opcionalmente, el elemento raíz y otras partes relevantes
- Un snapshot de atributos DOM relevantes en ese momento
- La causa originadora (teclado, puntero, programática, validación)
- Opcionalmente, un AbortSignal para cancelación externa
La forma concreta de este contexto es decisión de la implementación; el contenido semántico está dictado por esta spec.
### 6.3. Secuencia canónica (blocking + commits)
Para una acción con `mode: blocking` y `commits` presente:
1. La capa headless resuelve la acción abstracta (ej. `close-save`)
2. Aplica los prewrites declarados al DOM
3. Hace flush — los atributos del prewrite ya están reflejados
4. Llama `await port.before(action, context)`
5. Sema resuelve la firma perceptiva (consultando `.csem` + `sema-map.json`)
6. Sema ejecuta los canales activos
7. La promesa resuelve cuando la coreografía termina
8. La capa headless aplica el commit de estado
9. La capa visual reacciona al nuevo estado con sus transiciones
10. Si hay exit CSS o desmontaje diferido, sigue el pipeline de la capa headless
Durante toda la ventana del paso 6, el estado del componente sigue siendo el anterior. La capa visual ve el estado saliente todavía. No hay competencia visual.
### 6.4. Secuencia para independent (sin commits)
Para una acción sin commits (ej. `submit-failed` sobre un input ya invalid):
1. La capa headless detecta que debe disparar la acción
2. Aplica prewrites si los hay (pero no afectan a `data-state`)
3. `await port.before(action, context)` o `port.fire(action, context)` según modo
4. No hay commit posterior
5. El flujo de la capa headless continúa
### 6.5. Sustain como sesión
`sustain` no es episódico. Se declara aparte del resto de acciones, con un predicado de activación:
```
sustain "loading" on Spinner {
activeWhen: data-state = "loading" on Spinner
event: sustain
scope: part
}
```
Protocolo:
1. La capa headless cambia el estado que satisface el predicado de activación
2. Inmediatamente después, llama `session = port.startSustain(sustain, context)`
3. La sesión corre mientras el predicado siga siendo verdadero
4. Cuando la capa headless sale del estado, llama `session.stop()`
Es una excepción documentada al patrón episódico.
### 6.6. Garantías del puerto
El puerto debe garantizar:
- `before()` nunca lanza si falta runtime; cae a no-op con promesa resuelta inmediatamente
- `before()` siempre resuelve (nunca cuelga indefinidamente)
- Si `AbortSignal` aborta, la coreografía se cancela y la promesa resuelve
- Si el target desaparece del DOM, resuelve tempranamente
- Respeta preferencias de accesibilidad del usuario (ver §9)
- `before()` nunca bloquea más del cap global (ver §9.3)
---
## 7. El artefacto `.csem`
### 7.1. Propósito
El `.csem` es el archivo donde el integrador (quien usa el componente en su app) especifica **cómo se expresan perceptivamente** los eventos semánticos que el contrato cross-layer declara.
El contrato dice: "Dialog tiene una acción `close-save` que dispara `commit-fulfill`".
`.csem` dice: "En esta app, `commit-fulfill` se expresa con estos canales, estos parámetros, estos targets".
Si el integrador no provee `.csem`, Sema usa los valores por defecto de `sema-map.json`. `.csem` es capa de override, no obligatoria.
### 7.2. Sintaxis CSS con custom properties
`.csem` usa sintaxis CSS válida procesada en build time. Declara overrides por selector:
```css
/* Override global: commit-fulfill en esta app es más brillante */
:root {
--sema-commit-fulfill-sound-pitch: 1200;
--sema-commit-fulfill-sound-contour: ascending;
--sema-commit-fulfill-color-hue: 155;
}
/* Override por componente: el dialog close-save es especialmente celebratorio */
[data-dialog][data-last-action="saved"] {
--sema-event-override: commit-fulfill;
--sema-sound-duration: 180;
--sema-color-intensity: 0.6;
}
/* Context override: en la zona silenciosa, alert-threat es más suave */
.quiet-zone {
--sema-alert-threat-sound-gain: 0.15;
--sema-alert-threat-color-intensity: 0.25;
}
```
La sintaxis aprovecha la cascada CSS natural: el `.csem` más específico (por componente, por contexto) sobrescribe al más genérico (global).
### 7.3. Pipeline de build
El `.csem` se procesa en build time (PostCSS plugin u equivalente) que:
1. Parsea las reglas
2. Valida que cada `--sema-*` referencie un evento/canal/parámetro existente
3. Compila a una estructura JSON optimizada
4. El runtime consume el JSON — no parsea CSS en cliente
### 7.4. Política ante canal inactivo
Si `.csem` define parámetros para un canal que el integrador ha desactivado globalmente (vía `engine.configure`), el runtime **ignora los parámetros y emite warning en dev**. El `.csem` no activa canales por sí mismo; la activación es decisión explícita del integrador a nivel configuración. Esto preserva la política "sound disabled by default": asignar un pitch a sound en `.csem` no activa sound — hay que activarlo explícitamente en la configuración.
### 7.5. Resolución de firmas en runtime
Cuando Sema ejecuta una acción, la firma se resuelve:
1. Mira el contrato cross-layer → obtiene `event` (ej. `commit-fulfill`)
2. Mira `.csem` (compilado) → busca overrides aplicables al target y contexto
3. Si no hay overrides, cae a `sema-map.json` (base)
4. Combina: base + overrides → firma efectiva
5. Ejecuta los canales activos con los parámetros resueltos
Tres capas de resolución: contrato (qué evento), `.csem` (cómo lo expresa la app), `sema-map` (cómo lo expresa por defecto).
---
## 8. El artefacto `sema-map.json`
### 8.1. Propósito
Contiene los valores concretos por canal para cada evento del vocabulario canónico (22 eventos). Es el "vocabulario perceptivo de referencia" — lo que `commit-fulfill` significa por defecto en una implementación Sema-compliant.
### 8.2. Estructura factorizada
Para evitar duplicar valores similares entre eventos relacionados, el mapa usa factorización base + delta:
```json
{
"version": "0.4.0",
"families": {
"commit": {
"base": {
"motion": { /* parámetros base */ },
"sound": { /* parámetros base */ },
"color": { /* parámetros base */ },
"presence": null
},
"activeChannels": ["motion", "sound", "color"]
}
},
"intents": {
"fulfill": {
"deltas": {
"motion": { "duration": { "op": "multiply", "factor": 1.2 } },
"sound": { "pitch": 400, "contour": "ascending" },
"color": { "hue": 155, "saturation": 0.1 }
}
}
}
}
```
La firma de `commit-fulfill` se resuelve: `signature = base(commit) + delta(fulfill)`.
### 8.3. Operaciones de delta
- **Valor numérico:** suma al base (`"pitch": 400` → base + 400)
- **String:** override (`"contour": "ascending"` → reemplaza al base)
- **Objeto con `op`:** operación explícita (`{"op": "multiply", "factor": 1.2}`)
### 8.4. Sound pack (opcional)
El integrador puede proveer samples WAV que reemplazan la síntesis para combinaciones específicas:
```json
{
"soundPack": {
"alert-threat": "/sounds/alarm.wav",
"commit-fulfill": "/sounds/success.wav"
}
}
```
Las entradas presentes en el pack son autoritativas; las ausentes usan síntesis algorítmica. Permite despliegue gradual del pack.
### 8.5. Fuera del mapa
`sema-map.json` contiene vocabulario perceptivo canónico. **No contiene scope** (operativo, vive en la acción), **no contiene información de cuándo disparar eventos** (eso es responsabilidad del contrato cross-layer), **no contiene reglas culturales específicas de apps** (eso es `.csem`). Es solo el vocabulario sensorial base.
---
## 9. Accesibilidad
### 9.1. Política por preferencia y canal
Las preferencias del usuario afectan a canales específicos, no globalmente:
| Preferencia | motion | sound | color | presence |
|---|---|---|---|---|
| `prefers-reduced-motion: reduce` | **omitido** | intacto | discreto | reducido a no cinético |
| `prefers-reduced-transparency` | intacto | intacto | intacto | **backdrop sólido, sin blur** |
| `prefers-contrast: more` | intacto | intacto | **re-resuelto alto contraste** | **re-resuelto con contorno** |
| `forced-colors: active` | intacto | intacto | **ornamental desactivado** | **degradado a contornos** |
### 9.2. Interacción con el modo blocking
En `mode: blocking`, `before()` espera solo por los canales activos tras aplicar las preferencias:
- Si tras la reducción no queda ningún canal activo → resuelve inmediatamente (0ms)
- Si quedan canales → espera el máximo entre sus duraciones
- Respeta siempre el cap temporal global
### 9.3. Caps temporales globales
La spec define caps para evitar que una coreografía larga convierta `blocking` en un problema de responsividad:
- **Camino normal:** 200ms máximo de bloqueo del provider
- **Con reducción por accesibilidad:** 80ms máximo
- El integrador puede **bajar** estos caps pero no subirlos
Una firma que declare duración superior al cap es truncada en ejecución, no rechazada. La coreografía puede continuar más allá del cap, pero el provider ya no espera.
### 9.4. Scope de la acción
El scope define el alcance temporal y espacial de la coreografía:
- **`part`** (default): la coreografía afecta solo al target y se cancela si el target se desmonta
- **`component`**: la coreografía afecta al árbol del componente completo
- **`scene`**: la coreografía sobrevive al componente (útil para advisory+independent que deben continuar tras desmontaje)
El scope se declara por acción, no globalmente — el mismo evento (`alert-threat`) puede tener scope distinto en componentes distintos (toast: scene; input: part).
---
## 10. Personalización
El integrador puede personalizar Sema en cinco niveles, ordenados de más global a más específico:
### 10.1. Nivel 1 — Activación y volumen global
```
engine.configure({
sound: { enabled: true, gain: 0.8 },
motion: { enabled: true },
color: { enabled: true },
presence: { enabled: true },
reflectEvents: false, // modo debug
capBlockingMs: 200 // override del cap global (solo bajar)
});
```
`sound` desactivado por defecto. Los demás canales activos, respetando preferencias de accesibilidad.
### 10.2. Nivel 2 — Sound pack
Como se describe en §8.4.
### 10.3. Nivel 3 — Override global del mapa
```
engine.configure({
mapOverrides: {
'alert-threat.sound.gain': 0.15,
'contact-neutral.sound.pitch': 800
}
});
```
### 10.4. Nivel 4 — Override local en `.csem`
Como se describe en §7.
### 10.5. Nivel 5 — API imperativa (escape hatch)
```
engine.trigger(node, {
event: 'alert-threat',
overrides: { sound: { pitch: 500 } }
});
```
Para casos que no caben declarativamente.
---
## 11. Requisitos del engine
### 11.1. Responsabilidades
Una implementación del engine Sema debe:
1. Consumir las acciones declaradas en el contrato cross-layer
2. Implementar el puerto `SemaPort` con `before()`, `fire()`, `startSustain()`
3. Consumir `sema-map.json` y el compilado de `.csem`
4. Resolver firmas según la jerarquía: acción → `.csem` → `sema-map`
5. Aplicar los canales activos respetando las garantías temporales y de accesibilidad
6. Emitir `CustomEvent('sema:event')` en el nodo target (contrato canónico de observabilidad)
7. Gestionar los cuatro regímenes (`replace | collapse | lock | queue`)
8. Cancelar coreografías por `AbortSignal` o por desmontaje del target
9. Respetar preferencias de accesibilidad del usuario
### 11.2. Protocolo de eventos
**Canónico — CustomEvent:**
```
node.dispatchEvent(new CustomEvent('sema:event', {
bubbles: true,
detail: {
event: 'alert-threat',
action: 'close-after-fail',
component: 'Dialog',
phase: 'start', // 'start' | 'end' | 'cancelled'
channels: ['motion', 'color', 'sound'],
duration: 180
}
}));
```
Este es el mecanismo fiable para tests y observabilidad en producción.
**Opt-in — Reflejo DOM:**
Cuando el engine está configurado con `reflectEvents: true`, durante la ventana del evento añade:
```html
<element
data-sema-active="alert-threat"
data-sema-phase="active">
```
Y los retira al terminar. **No es el contrato canónico** — solo herramienta de desarrollo.
### 11.3. Aplicación por canal
Esta sección describe técnicas de implementación, no arquitectura. La doctrina es la secuencialidad coordinada de §2.2; las técnicas siguientes son vías válidas para ejecutar los canales dentro de esa doctrina.
- **motion**: Web Animations API o mecanismo equivalente capaz de aplicar transformaciones temporales sobre el target
- **sound**: Web Audio API con síntesis parametrizada, o sample playback si hay pack
- **color**: implementación libre, respetando que durante la ventana del evento la capa visual no esté estilando activamente la misma propiedad
- **presence**: overlays, pseudoelementos, o cualquier mecanismo que exprese segregación figura-fondo sin interferir con el layout
**Recomendaciones de implementación (no normativas):**
En contextos donde el provider permite algún paralelismo con la capa visual (modo advisory, tails post-cap), o donde la capa visual gestiona transiciones CSS que podrían solaparse con el evento Sema, conviene aplicar técnicas aditivas para minimizar conflictos:
- `motion` con WAAPI y `composite: 'add'` se suma al transform existente en lugar de reemplazarlo
- `color` con `box-shadow` adicional evita modificar el `border-color` que la capa visual controla
- `presence` con pseudoelementos (`::after`, `::before`) como overlay mantiene la autoría de la capa visual intacta en el nodo principal
Estas técnicas no son obligatorias en el camino `blocking` (donde la secuencialidad evita el conflicto por diseño), pero son recomendables para implementaciones robustas.
### 11.4. Compatibilidad con frameworks reactivos
El engine debe tolerar:
- Re-renders del framework a mitad de ejecución
- Desmontaje del target durante la ventana del evento
- Cambios en el DOM observado por terceros
Esto implica que la implementación debe:
- Verificar que el nodo target sigue conectado antes de aplicar cambios
- Cancelar limpiamente si el target desaparece
- No retener referencias a elementos desmontados
---
## 12. Qué Sema NO debe hacer
Protección explícita contra deriva conceptual:
### 12.1. No carga narrativa de producto
Sema describe **cómo el usuario percibe un cambio**, no **cómo el producto quiere que se sienta**. No hay familias `celebrate`, `reassure`, `onboard`, `encourage`. Esas son categorías narrativas. Si una nueva familia no tiene firma perceptiva distinguible por timing + canales, no es familia.
### 12.2. No reemplaza presentación visual
Sema no gestiona colores en reposo, tipografía, espaciado, layout. Todo eso es responsabilidad de la capa visual. Sema expresa transiciones perceptivas, no presentación.
### 12.3. No gestiona estado de aplicación
Sema no almacena estado, no gestiona formularios, no valida. La capa headless gestiona el estado; Sema reacciona a cambios que otros gestionan.
### 12.4. No es una librería de animación
Sema usa animaciones como uno de sus cuatro canales. No sustituye a Framer Motion, GSAP. No expone API para "animar cualquier cosa" — solo anima lo que el modelo semántico predica.
### 12.5. No expone parámetros sin firma perceptiva distinguible
Si dos configuraciones producen resultados perceptivamente indistinguibles para un usuario normal, una sobra. El sistema no expone sliders para ajustes que nadie percibe.
### 12.6. El contrato cross-layer no contiene implementación perceptiva
El contrato declara **qué eventos existen y cuándo**, no **cómo se expresan**. Parámetros sensoriales (pitch, roughness, curvas, hues concretos, duraciones exactas, samples) viven en `sema-map.json` y `.csem`, nunca en el contrato.
---
## 13. Contrato mínimo de implementación
Una implementación Sema-compliant debe proveer al menos:
### 13.1. Mecanismo de declaración de acciones
Un mecanismo declarativo para que cada componente exponga:
- Sus acciones semánticas con los ocho campos conceptuales del §5.2
- Sus sustains (con predicado de activación)
- Su relación con las partes del componente (referenciables)
Forma concreta: libre. Puede ser TypeScript const, decoradores, JSON, YAML, registros runtime, hooks. El único requisito es que exponga la información que Sema necesita.
### 13.2. Implementación del puerto
Una realización concreta de `SemaPort` con:
- `before(action, context): Promise<void>` — blocking
- `fire(action, context): void` — advisory
- `startSustain(sustain, context): SemaSession` — sustain con lifecycle
Con las garantías del §6.6.
### 13.3. Runtime que consume artefactos
Un runtime que:
- Lea las acciones declaradas
- Procese `.csem` en build time
- Cargue `sema-map.json`
- Resuelva firmas según la jerarquía del §7.5
- Aplique canales con las técnicas del §11.3
### 13.4. Soporte obligatorio
- Las 6 familias y los 5 intents (vocabulario de 22 eventos)
- Los 4 canales con los parámetros del §4
- Los 4 regímenes de arbitraje (§5.4)
- La matriz de 4 combinaciones válidas de mode × commits (§5.6)
- La política de accesibilidad por canal (§9.1)
- Los caps temporales (§9.3)
- `sound` desactivado por defecto (§4.2)
- `CustomEvent('sema:event')` como protocolo de observabilidad (§11.2)
### 13.5. Soporte recomendado
- Compilación de `.csem` en build time
- Modo debug con reflejo DOM (`reflectEvents: true`)
- Los cinco niveles de personalización (§10)
- Soporte de sound packs (§8.4)
- Documentación explícita de limitaciones conocidas
### 13.6. Soporte opcional
- API imperativa avanzada
- Herramientas de debug especializadas
- Validador de sound packs contra la spec
---
## 14. Limitaciones conocidas
### 14.1. Rendimiento
- WAAPI con `composite: 'add'` tiene soporte universal en navegadores 2023+, pero verificar edge cases en Safari < 16
- Web Audio API tiene latencia variable (10-50ms típico). En Safari móvil puede ser mayor. Para eventos críticos, precargar AudioContext
- MutationObservers en alta frecuencia pueden impactar rendimiento; el engine debe throttle/debounce donde corresponda
### 14.2. Cobertura perceptiva
- Las 6 familias no son categorías naturales del cerebro — son taxonomía útil. Casos de borde pueden requerir decisión editorial
- Los 5 intents pueden ser insuficiente resolución fina para casos muy sutiles. Aceptar la pérdida de detalle, no forzar más granularidad
- Los valores del mapa son extrapolaciones razonables, no ciencia dura. Requieren calibración empírica con usuarios
### 14.3. Accesibilidad
- Usuarios con condiciones específicas (vestibulares severos, fotosensibilidad compleja) pueden requerir desactivación completa
- `prefers-reduced-sound` no está estandarizado; mientras tanto, la implementación provee su propio toggle
### 14.4. Contexto cultural
- Los hues por intent son convención occidental. Contextos distintos deben sobreescribir defaults
- Los contornos melódicos tienen asociaciones consistentes pero no universales. Validar con usuarios del contexto
### 14.5. Frameworks reactivos
- La spec asume que la capa headless puede orquestar la secuencialidad. Frameworks que re-rendericen agresivamente componentes pueden complicar este contrato
- El desmontaje de componentes durante eventos es caso límite que el engine debe tolerar
---
## Apéndice A — Implementaciones conocidas
**SemaUIX** es la implementación de referencia de esta especificación. Se construye sobre Svelte 5 y usa un artefacto llamado `morfo` como contrato cross-layer. El documento `semauix-sema-impl.md` describe cómo SemaUIX materializa cada parte de esta spec: cómo morfo extiende para declarar acciones, qué shape TypeScript tiene, cómo se valida, cómo los providers consumen el puerto.
Cualquier framework puede producir su propia implementación. La spec no exige ningún mecanismo concreto para el contrato cross-layer; solo que el contenido informativo esté disponible para las tres capas.
---
## Apéndice B — Historial de decisiones clave
1. **Sema es agnóstica de framework.** Opera sobre el DOM con Web APIs estándar.
2. **Contrato cross-layer como fuente única.** Parts, atributos, ARIA, keyboard, acciones semánticas — todo declarado una vez. El mecanismo concreto es decisión de cada implementación.
3. **Reducción de 14 semánticas a 6 familias.** Tras auditoría neurocientífica, las originales se solapaban.
4. **Reducción de 6 canales a 4.** `depth` fusionado con `presence` (misma vía magnocelular). `form` decompuesto.
5. **Parametrización psicoacústica del sonido.** Sin nombres de osciladores; el engine elige la síntesis.
6. **Factorización base + delta en el mapa.** 22 eventos con valores derivados.
7. **Secuencialidad coordinada en vez de principio aditivo.** En el camino canónico `blocking`, evento y estado ocurren en secuencia; los casos `advisory` y los tails post-cap quedan marcados como post-state y no violan la doctrina.
8. **Sound desactivado por defecto.** Los demás canales activos con preferencias.
9. **Estado vs evento como distinción fundamental.**
10. **CustomEvent canónico + atributos DOM opt-in en debug.**
11. **Acciones semánticas como unidad de integración.** Ni eventos DOM ni cambios de estado — acciones declaradas.
12. **`.csem` es capa de override del integrador.** No obligatoria; cae a `sema-map.json` por defecto.
13. **Cuatro regímenes de arbitraje.** `replace | collapse | lock | queue` cubren los casos de encadenado.
14. **Collapse como single-flight coalescing.** Ni silencio ni pulsos continuos ni ventana elástica.
15. **Política de accesibilidad por canal, no global.** Cada preferencia afecta canales específicos.
16. **Caps temporales de 200ms (normal) / 80ms (con reducción).**
17. **Commits declara efecto estructural, no precondiciones.** La validez contextual de una acción es responsabilidad de la capa headless, no del contrato declarativo.
18. **Keyboard y acciones son espacios separables.** La capa headless puede tener acciones de teclado sin contrapartida Sema; solo adquieren semántica perceptiva las que coinciden con acciones declaradas.
19. **Scope por acción, no por familia.** El mismo evento puede tener scope distinto en componentes distintos.
---
## Apéndice C — Glosario
- **Acción semántica**: unidad declarada en el contrato cross-layer que describe qué hace un componente con carga semántica (ej. `close-save`). La unidad de integración entre la capa headless y Sema.
- **Canal perceptivo**: eje sensorial por el que Sema expresa información (motion, sound, color, presence).
- **Capa headless**: capa del framework que gestiona comportamiento, estado, accesibilidad.
- **Capa visual**: capa del framework que gestiona presentación en reposo.
- **Commits**: campo de una acción que declara qué cambio de estado se aplicará tras la ventana Sema.
- **Contrato cross-layer**: artefacto declarativo por componente compartido por las tres capas. Mecanismo concreto decisión de implementación.
- **Evento Sema**: una de las 22 combinaciones del vocabulario canónico (`alert-threat`, `commit-fulfill`, etc.).
- **Firma efectiva**: conjunto de valores por canal resultante de resolver un evento contra `.csem` + `sema-map.json`.
- **Intent**: modulador afectivo de una familia valencial.
- **Prewrite**: atributos DOM que se reflejan antes de invocar Sema.
- **Régimen**: política de arbitraje para acciones repetidas (`replace | collapse | lock | queue`).
- **Sema-compliant**: implementación que cumple los requisitos mínimos del §13.
- **SemaEventLabel**: vocabulario de los 22 eventos canónicos.
- **SemaPort**: interfaz neutral que la capa headless consume para invocar Sema.
- **Secuencialidad coordinada**: principio operativo según el cual evento y estado ocurren en secuencia en el camino blocking.
---
**Fin de la especificación Sema v0.4.**
*Agnóstica de framework. Las implementaciones concretas documentan por separado cómo materializan la spec (ver por ejemplo `semauix-sema-impl.md`).*

@ -1,308 +0,0 @@
# 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.

@ -1,201 +1,79 @@
/**
* Sema — tipos públicos de la capa perceptiva.
* Sema — canonical semantic domain.
*
* La capa sema es **autónoma**. No importa nada de morfo. Los tipos
* compartidos entre capas (como `PartRef`) viven en `$uix/lib/types` y
* cada capa los importa desde allí de forma independiente.
*
* Un componente puede declarar `sema` aunque no declare `morfo` (ni al
* contrario). Cuando ambas capas están presentes, se relacionan por el
* `kebab` del componente — no por intersección ni extensión de tipos.
* Sema no autoriza componentes ni ejecuta canales. Define el vocabulario
* fijo del framework, normaliza labels canónicos y tipa la semántica que
* `Morfo` declara y que `SemanticEngine` publica.
*/
import type { PartRef } from '../lib/types';
import type { PartRef } from '../lib/types'
// ── Vocabulario canónico de eventos (sema-spec-v0.3.1 §3.3) ────────────────
// ── Core domain ────────────────────────────────────────────────────────────
export type SemaValencedFamily = 'contact' | 'commit' | 'alert' | 'handle'
export type SemaTransitionalFamily = 'emerge' | 'sustain'
export type SemaFamily = SemaValencedFamily | SemaTransitionalFamily
export type SemaIntent = 'threat' | 'risk' | 'neutral' | 'affirm' | 'fulfill'
export type SemaMode = 'blocking' | 'advisory'
export type SemaRegime = 'replace' | 'collapse' | 'lock' | 'queue'
export type SemaScope = 'part' | 'component' | 'scene'
export type SemaCause = 'keyboard' | 'pointer' | 'programmatic' | 'validation'
/**
* Los 22 eventos canónicos: 6 familias × 5 intents en las familias
* valenciales (contact, commit, alert, handle) + 2 transicionales (emerge,
* sustain). Closed set — cualquier `event` en un `SemaAction` debe ser uno
* de estos, y el validador lo enforce.
*
* La resolución perceptiva de cada evento (canales activos, pitches,
* durations, hues) vive en `sema-map.json` (defaults) y `.csem`
* (overrides del integrador). Sema-spec §7 y §8.
*/
export type SemaEventLabel =
// contact (feedback inmediato a acto del usuario)
| 'contact-neutral'
| 'contact-threat'
| 'contact-risk'
| 'contact-affirm'
| 'contact-fulfill'
// commit (cambio de estado discreto por el sistema)
| 'commit-neutral'
| 'commit-threat'
| 'commit-risk'
| 'commit-affirm'
| 'commit-fulfill'
// alert (reclamo de atención sobre estado no atendido)
| 'alert-neutral'
| 'alert-threat'
| 'alert-risk'
| 'alert-affirm'
| 'alert-fulfill'
// handle (manipulación continua del usuario)
| 'handle-neutral'
| 'handle-threat'
| 'handle-risk'
| 'handle-affirm'
| 'handle-fulfill'
// transicionales (sin intent)
| 'emerge'
| 'sustain';
| 'sustain'
// ── Escrituras al DOM (sema-spec-v0.3.1 §5.2) ──────────────────────────────
// ── Structured semantics ───────────────────────────────────────────────────
/**
* Escritura a un data-attribute que debe reflejarse en DOM antes de que
* Sema abra su ventana perceptiva. Su uso canónico es reflejar el motivo
* causal de un commit (`data-last-action="saved"`) para que `.csem` y la
* capa visual puedan tintar la ejecución del evento.
*
* Validación:
* - `part.target` resuelve a un `kebab` del morfo.
* - `attr` existe en el `data[]` de ese part.
* - `value` pertenece a `values[]` si el attr es enumerable.
*/
export interface SemaAttrWrite {
part: PartRef;
attr: string;
value: string;
}
/**
* Efecto estructural que una acción comitea tras cerrar la ventana Sema
* (modo `blocking`) o en paralelo (modo `advisory`).
*
* Alcance: §5.7 — `commits` declara **efecto**, no precondiciones de
* aplicabilidad. La validez contextual sigue siendo responsabilidad del
* provider headless; el contrato sólo cataloga qué cambia cuando la
* acción se ejecuta.
*/
export interface SemaCommit {
part: PartRef;
attr: string;
value: string;
export interface SemaIntentBinding {
fromProp?: string
default: SemaIntent
supported?: readonly SemaIntent[]
}
// ── Acciones (sema-spec-v0.3.1 §5.2) ───────────────────────────────────────
export type SemaEvent =
| {
family: SemaTransitionalFamily
}
| {
family: SemaValencedFamily
intent: SemaIntent | SemaIntentBinding
}
/**
* Una acción semántica del componente. Siete campos, cinco opcionales con
* defaults — lo mínimo para que Sema sepa cuándo ejecutar, qué firma
* aplicar, cómo comportarse ante interrupciones, y qué contexto DOM
* escribir antes.
*/
export interface SemaAction {
/**
* Identificador único dentro del morfo. Referenciado desde
* `keyboard.action` (cuando procede) y desde el provider al invocar
* `sema.before(name, ctx)`.
*/
name: string;
/** Part primario afectado por la acción. */
target: PartRef;
/** Evento canónico que esta acción dispara. */
event: SemaEventLabel;
/**
* Relación del provider con la ventana Sema.
* - `blocking` (default): el provider hace `await sema.before()` antes del commit.
* - `advisory`: el provider dispara Sema y continúa inmediatamente.
*/
mode?: 'blocking' | 'advisory';
/**
* Arbitraje cuando una segunda ocurrencia equivalente llega durante la
* ventana. Default `'replace'`.
* - `replace`: cancela la actual y arranca una nueva.
* - `collapse`: single-flight coalescing — no reinicia ni extiende.
* - `lock`: rechaza equivalentes mientras la ventana está abierta.
* - `queue`: las entrantes se encolan y se ejecutan secuencialmente.
*
* Dos ocurrencias son equivalentes si comparten `name` sobre el mismo target.
*/
regime?: 'replace' | 'collapse' | 'lock' | 'queue';
/**
* Alcance perceptivo de la acción. Default `'part'`.
* - `'part'`: la coreografía vive atada al target; se cancela si se desmonta.
* - `'component'`: la coreografía cubre el árbol del componente.
* - `'scene'`: la coreografía sobrevive al desmontaje del componente
* (toasts, notificaciones que deben terminar de ejecutarse).
*
* Se declara por acción (no por familia en sema-map) porque el scope
* correcto depende del componente-más-evento, no del evento abstracto:
* un `emerge` en Toast requiere `'scene'`, el mismo `emerge` en Dialog
* requiere `'part'`. Ver Apéndice B de sema-spec-v0.3.1.
*/
scope?: 'part' | 'component' | 'scene';
/**
* Atributos que se reflejan en DOM **antes** de invocar Sema.
* Típicamente `data-last-action` para comunicar el motivo causal del
* commit inminente.
*/
prewrite?: readonly SemaAttrWrite[];
/**
* Cambio de estado estructural que la acción comitea. Ausente para
* acciones de feedback puro sin transición de estado (p. ej.
* `submit-failed` no mueve al formulario de `idle` a ningún otro
* estado — sólo dispara un `alert-threat`).
*/
commits?: SemaCommit;
}
export type SemaActionEvent = SemaEvent | SemaEventLabel
// ── Sustains (sema-spec-v0.3.1 §6.6) ───────────────────────────────────────
// ── DOM-oriented writes used by Morfo events ──────────────────────────────
/**
* Una presencia perceptiva de larga duración (segundos, minutos, horas)
* cuya existencia depende de que un predicado DOM siga cumpliéndose.
* Distinto de `SemaAction` porque su ciclo de vida no es episódico —
* no tiene "fin natural" por duration, sino que termina cuando el
* provider llama `session.stop()`.
*/
export interface SemaSustainDecl {
name: string;
target: PartRef;
/** El predicado DOM que mantiene el sustain activo. */
activeWhen: { part: PartRef; attr: string; value: string };
/** Siempre `'sustain'`. Tipado por simetría con `SemaAction`. */
event: 'sustain';
/** Default `'part'`. Igual que en acciones, pero aplicado a la sesión sustain. */
scope?: 'part' | 'component' | 'scene';
export interface SemaAttrWrite {
part: PartRef
attr: string
value: string
}
// ── Declaración sema del componente ───────────────────────────────────────
/**
* Contrato sema completo de un componente. Standalone: no menciona morfo.
*
* `kebab` identifica al componente y sirve de puente con otras capas
* (morfo, eidos) cuando existen — sin tipado intersectado. Si el
* componente también declara morfo, los dos kebabs deben coincidir; lo
* enforce el validador cuando se le pasa el morfo como contexto.
*
* Autoría recomendada:
*
* ```ts
* export const dialogSema = {
* kebab: 'dialog',
* actions: [ ... ]
* } as const satisfies SemaSpec;
* ```
*/
export interface SemaSpec {
/**
* kebab-case del componente. Debe coincidir con el `kebab` del morfo
* correspondiente cuando el componente también tiene morfo.
*/
kebab: string;
actions: readonly SemaAction[];
sustains?: readonly SemaSustainDecl[];
export interface SemaCommit {
part: PartRef
attr: string
value: string
}

@ -1,236 +1,42 @@
/**
* 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 { isSemaEvent, isSemaEventLabel, isSemaIntent, isSemaIntentBinding } from './event'
import type { SemaActionEvent, SemaIntentBinding } from './types'
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';
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));
export function validateSemaIntentBinding(binding: SemaIntentBinding, ctx = 'sema.intent'): void {
if (!isSemaIntentBinding(binding)) {
throw new SemaInvariantError(`${ctx} is not a valid SemaIntentBinding`)
}
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);
}
const supported = binding.supported ?? []
if (supported.length === 0) return
// 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`
);
for (const intent of supported) {
if (!isSemaIntent(intent)) {
throw new SemaInvariantError(`${ctx}: intent "${String(intent)}" is not a valid SemaIntent`)
}
}
// Resto depende de tener contexto de morfo.
if (!morfo) return;
// 3. kebab coincide.
if (morfo.kebab !== spec.kebab) {
if (!supported.includes(binding.default)) {
throw new SemaInvariantError(
`sema: spec.kebab "${spec.kebab}" does not match morfo.kebab "${morfo.kebab}"`
);
`${ctx}: default intent "${binding.default}" must be included in supported intents`
)
}
}
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);
}
}
export function validateSemaEvent(event: SemaActionEvent, ctx = 'sema.event'): void {
if (isSemaEventLabel(event)) return
// 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(', ') || '∅'})`
);
}
}
}
if (!isSemaEvent(event)) {
throw new SemaInvariantError(`${ctx} is not a valid canonical semantic event`)
}
// 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`
);
}
}
}
if (!('intent' in event) || typeof event.intent === 'string') return
// 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`
);
}
}
validateSemaIntentBinding(event.intent, `${ctx}.intent`)
}

@ -1,7 +1,6 @@
import { Provider, context, type WithRefOpts } from '../../provider';
import {
createAttrs,
registerContract,
boolToEmptyStrOrUndef,
getDataOpenClosed
} from '../../attrs';
@ -19,7 +18,6 @@ import { ResizeObserver$ } from '../../layers/resize-observer.svelte';
import { accordionMorfo } from '../../../morfo/components/accordion';
const attrs = createAttrs(accordionMorfo);
registerContract(accordionMorfo);
export type AccordionType = 'single' | 'multiple';
@ -50,7 +48,7 @@ export class AccordionProvider extends Provider<AccordionOpts> {
private registeredValues = new Set<string>();
private constructor(opts: AccordionOpts) {
super(opts, 'Accordion', 'provider', attrs.provider, AccordionProvider.ctx);
super(opts, { morfo: accordionMorfo, part: 'provider' }, AccordionProvider.ctx);
}
isItemOpen(value: string): boolean {
@ -95,9 +93,13 @@ export class AccordionProvider extends Provider<AccordionOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
dir: this.opts.dir.current,
'data-orientation': this.opts.orientation.current,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
...this.resolveMorfoProps({
props: {
orientation: this.opts.orientation.current,
disabled: this.opts.disabled.current
}
}),
dir: this.opts.dir.current
} as const)
);
}
@ -132,7 +134,7 @@ export class AccordionItemProvider extends Provider<AccordionItemOpts> {
private contentRef = state<HTMLElement | null>(null);
private constructor(opts: AccordionItemOpts) {
super(opts, 'Accordion', 'item', attrs.item, AccordionItemProvider.ctx);
super(opts, { morfo: accordionMorfo, part: 'item' }, AccordionItemProvider.ctx);
this.provider = AccordionProvider.require();
this.contentPresence = new Presence({
@ -169,9 +171,16 @@ export class AccordionItemProvider extends Provider<AccordionItemOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': getDataOpenClosed(this.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-orientation': this.provider.opts.orientation.current
...this.resolveMorfoProps({
states: {
open: this.isOpen
},
props: {
disabled: this.isDisabled,
orientation: this.provider.opts.orientation.current
}
}),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled)
} as const)
);
}
@ -188,18 +197,24 @@ export class AccordionHeaderProvider extends Provider<AccordionHeaderOpts> {
readonly item: AccordionItemProvider;
private constructor(opts: AccordionHeaderOpts) {
super(opts, 'Accordion', 'header', attrs.header);
super(opts, { morfo: accordionMorfo, part: 'header' });
this.item = AccordionItemProvider.require();
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'heading' as const,
'aria-level': Math.max(1, Math.min(6, this.opts.level.current)),
'data-state': getDataOpenClosed(this.item.isOpen),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
'data-orientation': this.item.provider.opts.orientation.current
...this.resolveMorfoProps({
states: {
open: this.item.isOpen
},
props: {
level: Math.max(1, Math.min(6, this.opts.level.current)),
disabled: this.item.isDisabled,
orientation: this.item.provider.opts.orientation.current
}
}),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled)
} as const)
);
}
@ -216,7 +231,7 @@ export class AccordionTriggerProvider extends Provider<AccordionTriggerOpts> {
readonly item: AccordionItemProvider;
private constructor(opts: AccordionTriggerOpts) {
super(opts, 'Accordion', 'trigger', attrs.trigger);
super(opts, { morfo: accordionMorfo, part: 'trigger' });
this.item = AccordionItemProvider.require();
this.item.triggerId.current = opts.id.current;
}
@ -266,12 +281,19 @@ export class AccordionTriggerProvider extends Provider<AccordionTriggerOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-expanded': this.item.isOpen,
'aria-controls': this.item.contentId.current || undefined,
'data-state': getDataOpenClosed(this.item.isOpen),
...this.resolveMorfoProps({
states: {
open: this.item.isOpen
},
props: {
disabled: this.item.isDisabled,
orientation: this.item.provider.opts.orientation.current
},
parts: {
content: this.item.contentId.current
}
}),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
'data-orientation': this.item.provider.opts.orientation.current,
disabled: this.item.isDisabled || undefined,
onclick: this.onclick,
onkeydown: this.onkeydown
@ -291,7 +313,7 @@ export class AccordionContentProvider extends Provider<AccordionContentOpts> {
readonly item: AccordionItemProvider;
private constructor(opts: AccordionContentOpts) {
super(opts, 'Accordion', 'content', attrs.content, undefined, (el) => {
super(opts, { morfo: accordionMorfo, part: 'content' }, undefined, (el) => {
this.item.setContentRef(el);
});
this.item = AccordionItemProvider.require();
@ -303,11 +325,19 @@ export class AccordionContentProvider extends Provider<AccordionContentOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'region' as const,
'aria-labelledby': this.item.triggerId.current || undefined,
'data-state': getDataOpenClosed(this.item.isOpen),
...this.resolveMorfoProps({
states: {
open: this.item.isOpen
},
props: {
disabled: this.item.isDisabled,
orientation: this.item.provider.opts.orientation.current
},
parts: {
trigger: this.item.triggerId.current
}
}),
'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled),
'data-orientation': this.item.provider.opts.orientation.current,
style: {
'--soma-accordion-content-height': `${this.item.contentHeight.current}px`,
'--soma-accordion-content-width': `${this.item.contentWidth.current}px`

@ -1,10 +1,5 @@
import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider';
import {
createAttrs,
registerContract,
boolToEmptyStrOrUndef,
getDataOpenClosed
} from '../../attrs';
import { boolToEmptyStrOrUndef, getDataOpenClosed } from '../../attrs';
import {
readableActive,
state,
@ -25,15 +20,6 @@ import { TextSelection } from '../../layers/text-selection.svelte';
import { dialogMorfo } from '../../../morfo/components/dialog';
// Parts, data-attrs, and their valid enums are sourced from `dialogMorfo` —
// the single cross-layer contract. Changing a part name or a data-attr
// value here requires editing the morfo, never this file.
const attrs = createAttrs(dialogMorfo);
// Data-attr contract comes from the morfo. Any change in valid values
// or attr names is done in `morfo/components/dialog.ts`.
registerContract(dialogMorfo);
// ── Provider (root) ─────────────────────────────────────────────────────────
export type DialogVariant = 'dialog' | 'alertdialog';
@ -84,7 +70,7 @@ export class DialogProvider extends Provider<DialogOpts> {
private constructor(opts: DialogOpts) {
// Read parent BEFORE registering in context (ctx.set happens in super)
const parent = DialogProvider.get() as DialogProvider | undefined;
super(opts, 'Dialog', 'provider', attrs.provider, DialogProvider.ctx);
super(opts, { morfo: dialogMorfo, part: 'provider' }, DialogProvider.ctx);
this.parent = parent;
this.depth = parent ? parent.depth + 1 : 0;
@ -139,8 +125,14 @@ export class DialogProvider extends Provider<DialogOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': getDataOpenClosed(this.opts.open.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current)
...this.resolveMorfoProps({
states: {
open: this.opts.open.current
},
props: {
disabled: this.opts.disabled.current
}
})
} as const)
);
}
@ -157,7 +149,7 @@ export class DialogTriggerProvider extends Provider<DialogTriggerOpts> {
readonly provider: DialogProvider;
private constructor(opts: DialogTriggerOpts) {
super(opts, 'Dialog', 'trigger', attrs.trigger);
super(opts, { morfo: dialogMorfo, part: 'trigger' });
this.provider = DialogProvider.require();
this.provider.triggerId.current = opts.id.current;
}
@ -169,11 +161,14 @@ export class DialogTriggerProvider extends Provider<DialogTriggerOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-haspopup': 'dialog' as const,
'aria-expanded': this.provider.opts.open.current,
'aria-controls': this.provider.contentId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
...this.resolveMorfoProps({
states: {
open: this.provider.opts.open.current
},
parts: {
content: this.provider.contentId.current
}
}),
disabled: this.provider.opts.disabled.current || undefined,
onclick: this.onclick,
...this.attachment
@ -214,7 +209,7 @@ export class DialogContentProvider extends Provider<DialogContentOpts> {
readonly textSelection: TextSelection;
private constructor(opts: DialogContentOpts) {
super(opts, 'Dialog', 'content', attrs.content, undefined, (el) => {
super(opts, { morfo: dialogMorfo, part: 'content' }, undefined, (el) => {
this.provider.setContentRef(el);
});
this.provider = DialogProvider.require();
@ -277,12 +272,20 @@ export class DialogContentProvider extends Provider<DialogContentOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
...this.resolveMorfoProps({
states: {
open: this.provider.opts.open.current
},
props: {
modal: this.provider.opts.modal.current
},
parts: {
title: this.provider.titleId.current,
description: this.provider.descriptionId.current
}
}),
role: this.provider.opts.variant.current,
'aria-modal': this.provider.opts.modal.current ? true : undefined,
'aria-roledescription': undefined,
'aria-describedby': this.provider.descriptionId.current || undefined,
'aria-labelledby': this.provider.titleId.current || undefined,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'data-nested': this.provider.isNested ? '' : undefined,
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
@ -309,7 +312,7 @@ export class DialogOverlayProvider extends Provider<DialogOverlayOpts> {
readonly provider: DialogProvider;
private constructor(opts: DialogOverlayOpts) {
super(opts, 'Dialog', 'overlay', attrs.overlay, undefined, (el) => {
super(opts, { morfo: dialogMorfo, part: 'overlay' }, undefined, (el) => {
this.provider.setOverlayRef(el);
});
this.provider = DialogProvider.require();
@ -320,8 +323,11 @@ export class DialogOverlayProvider extends Provider<DialogOverlayOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
'data-state': getDataOpenClosed(this.provider.opts.open.current),
'aria-hidden': true as const,
...this.resolveMorfoProps({
states: {
open: this.provider.opts.open.current
}
}),
'data-nested': this.provider.isNested ? '' : undefined,
'data-nested-open': this.provider.hasNestedOpen ? '' : undefined,
style: {
@ -346,7 +352,7 @@ export class DialogTitleProvider extends Provider<DialogTitleOpts> {
readonly provider: DialogProvider;
private constructor(opts: DialogTitleOpts) {
super(opts, 'Dialog', 'title', attrs.title);
super(opts, { morfo: dialogMorfo, part: 'title' });
this.provider = DialogProvider.require();
this.provider.titleId.current = opts.id.current;
}
@ -354,8 +360,11 @@ export class DialogTitleProvider extends Provider<DialogTitleOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'heading' as const,
'aria-level': this.opts.level.current
...this.resolveMorfoProps({
props: {
level: this.opts.level.current
}
})
} as const)
);
}
@ -372,7 +381,7 @@ export class DialogDescriptionProvider extends Provider<DialogDescriptionOpts> {
readonly provider: DialogProvider;
private constructor(opts: DialogDescriptionOpts) {
super(opts, 'Dialog', 'description', attrs.description);
super(opts, { morfo: dialogMorfo, part: 'description' });
this.provider = DialogProvider.require();
this.provider.descriptionId.current = opts.id.current;
}
@ -396,7 +405,7 @@ export class DialogCloseProvider extends Provider<DialogCloseOpts> {
readonly provider: DialogProvider;
private constructor(opts: DialogCloseOpts) {
super(opts, 'Dialog', 'close', attrs.close);
super(opts, { morfo: dialogMorfo, part: 'close' });
this.provider = DialogProvider.require();
}
@ -407,7 +416,7 @@ export class DialogCloseProvider extends Provider<DialogCloseOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
...this.resolveMorfoProps(),
onclick: this.onclick
} as const)
);

@ -52,13 +52,12 @@ const toaster = createToaster({
hotkey: ['F8'] // keyboard shortcut to focus viewport
});
// Create toasts by type
// Create toasts by semantic intent
const id = toaster.create({ title: 'Hello' });
toaster.success({ title: 'Saved', description: 'Changes saved.' });
toaster.error({ title: 'Error', description: 'Something went wrong.' });
toaster.warning({ title: 'Warning' });
toaster.info({ title: 'Info' });
toaster.loading({ title: 'Uploading...' }); // persistent by default (duration: 0)
toaster.create({ title: 'Saved', description: 'Changes saved.', intent: 'fulfill' });
toaster.create({ title: 'Something needs attention', intent: 'risk' });
toaster.create({ title: 'Something went wrong', intent: 'threat' });
toaster.create({ title: 'Uploading...', loading: true }); // persistent by default (duration: 0)
// Manage toasts
toaster.dismiss(id); // dismiss one
@ -75,11 +74,11 @@ toaster.create({
toaster.create({ title: 'Quick', duration: 2000 });
toaster.create({ title: 'Persistent', duration: 0 }); // no auto-dismiss
// Promise tracking — loading → success/error
// Promise tracking — loading → fulfill/threat
toaster.promise(fetchData(), {
loading: { title: 'Loading...', description: 'Fetching data.' },
success: (data) => ({ title: 'Done', description: `Loaded ${data.count} items.` }),
error: (err) => ({ title: 'Failed', description: String(err) })
fulfill: (data) => ({ title: 'Done', description: `Loaded ${data.count} items.` }),
threat: (err) => ({ title: 'Failed', description: String(err) })
});
```
@ -89,15 +88,15 @@ toaster.promise(fetchData(), {
| -------- | ------------------ | ------------------------------------------------- |
| Viewport | `role` | `region` |
| Viewport | `aria-label` | Configurable (default "Notifications") |
| Item | `role` | `status` (default) \| `alert` (error/warning) |
| Item | `aria-live` | `polite` (default) \| `assertive` (error/warning) |
| Item | `role` | `status` (neutral/affirm/fulfill) \| `alert` (risk/threat) |
| Item | `aria-live` | `polite` (neutral/affirm/fulfill) \| `assertive` (risk/threat) |
| Item | `aria-atomic` | `true` |
| Item | `aria-labelledby` | ID of Title |
| Item | `aria-describedby` | ID of Description |
| Action | `aria-label` | `altText` prop value |
| Close | `aria-label` | Translated "Close" |
Error and warning toasts use `role="alert"` with `aria-live="assertive"` for immediate screen reader announcement. Other types use `role="status"` with `aria-live="polite"`.
Risk and threat toasts use `role="alert"` with `aria-live="assertive"` for immediate screen reader announcement. Other intents use `role="status"` with `aria-live="polite"`.
## Data Attributes
@ -108,7 +107,8 @@ Error and warning toasts use `role="alert"` with `aria-live="assertive"` for imm
| Viewport | `data-toast-viewport` | Always present |
| Item | `data-toast-item` | Always present |
| Item | `data-state` | `open` \| `closed` |
| Item | `data-type` | `default` \| `success` \| `error` \| `warning` \| `info` \| `loading` |
| Item | `data-intent` | `neutral` \| `affirm` \| `fulfill` \| `risk` \| `threat` |
| Item | `data-loading` | Present while the toast is in loading/pending phase |
| Item | `data-swipe` | `start` \| `move` \| `cancel` \| `end` (during swipe gesture) |
| Item | `data-swipe-direction` | `left` \| `right` \| `up` \| `down` |
| Item | `data-starting-style` | Present during open animation |
@ -142,7 +142,7 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`.
- Default duration: 5000ms (configurable per toaster and per toast)
- Set `duration: 0` on a toast to disable auto-dismiss
- `loading` type defaults to `duration: 0` (persistent until dismissed)
- `loading: true` defaults to `duration: 0` (persistent until dismissed)
- Timer pauses on hover (`pointerenter`) and focus
- Timer resumes on `pointerleave` and `blur`
@ -161,10 +161,10 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`.
### Promise tracking
- `toaster.promise(promise, { loading, success, error })` creates a loading toast
- When the promise resolves, the toast updates to success type
- When the promise rejects, the toast updates to error type
- Success/error options can be functions receiving the resolved value or error
- `toaster.promise(promise, { loading, fulfill, threat })` creates a loading toast
- When the promise resolves, the toast updates to `intent='fulfill'`
- When the promise rejects, the toast updates to `intent='threat'`
- `fulfill`/`threat` options can be functions receiving the resolved value or error
## Usage
@ -206,7 +206,7 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`.
const toaster = getContext('toaster');
</script>
<button onclick={() => toaster.success({ title: 'Saved!' })}> Save </button>
<button onclick={() => toaster.create({ title: 'Saved!', intent: 'fulfill' })}> Save </button>
```
### Promise tracking
@ -216,8 +216,8 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`.
onclick={() => {
toaster.promise(saveData(), {
loading: { title: 'Saving...' },
success: () => ({ title: 'Saved!' }),
error: (e) => ({ title: 'Failed', description: e.message })
fulfill: () => ({ title: 'Saved!' }),
threat: (e) => ({ title: 'Failed', description: e.message })
});
}}
>
@ -245,7 +245,7 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`.
| Feature | Radix | Ark | Sonner | Soma |
| ---------------- | --------------------- | -------------------------- | ----------------- | ------------------------------------------ |
| API style | Declarative | Imperative | Imperative | Imperative |
| Type variants | foreground/background | success/error/warning/info | success/error/etc | default/success/error/warning/info/loading |
| Semantic variants | foreground/background | success/error/warning/info | success/error/etc | intent + loading state |
| Auto-dismiss | Yes | Yes | Yes | Yes |
| Pause on hover | Yes | Yes | Yes | Yes |
| Swipe to dismiss | Yes | Yes | Yes | Yes |
@ -253,6 +253,6 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`.
| Max toasts | Manual | Built-in | Built-in | Built-in |
| Hotkey | F8 | No | Alt+T | F8 (configurable) |
| Promise tracking | No | Yes | Yes | Yes |
| Loading type | No | No | Yes | Yes |
| ARIA urgency | Manual | Auto by type | Auto | Auto by type |
| Loading state | No | No | Yes | Yes |
| ARIA urgency | Manual | Auto by type | Auto | Auto by intent |
| Queue management | Manual | Built-in | Built-in | Built-in |

@ -12,7 +12,7 @@ export type {
CreateToastOpts,
PromiseToastOpts,
ToasterConfig,
ToastType,
ToastIntent,
SwipeDirection
} from './toaster.svelte';

@ -1,18 +1,13 @@
import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs';
import { readableActive, state, type Active, type ActiveProps } from '../../reactive';
import type { SomaMouseEvent } from '../../types';
import { Soma } from '../../core/soma.svelte';
import { TOAST_LANGS } from './langs';
import { isBrowser } from '../../dom';
import { Presence } from '../../layers/presence.svelte';
import type { Toaster, ToastData, SwipeDirection } from './toaster.svelte';
import { toastMorfo } from '../../../morfo/components/toast';
const attrs = createAttrs(toastMorfo);
registerContract(toastMorfo);
// ── Provider (root, no DOM) ─────────────────────────────────────────────────
interface ToastProviderOpts
@ -35,7 +30,7 @@ export class ToastProvider extends Provider<ToastProviderOpts> {
readonly soma = Soma.get();
private constructor(opts: ToastProviderOpts) {
super(opts, 'Toast', 'provider', attrs.provider, ToastProvider.ctx);
super(opts, { morfo: toastMorfo, part: 'provider' }, ToastProvider.ctx);
}
get toaster(): Toaster {
@ -56,7 +51,7 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
private cleanupHotkey: (() => void) | undefined;
private constructor(opts: ToastViewportOpts) {
super(opts, 'Toast', 'viewport', attrs.viewport);
super(opts, { morfo: toastMorfo, part: 'viewport' });
this.provider = ToastProvider.require();
// Register global hotkey to focus viewport, with cleanup. Supports
@ -123,9 +118,11 @@ export class ToastViewportProvider extends Provider<ToastViewportOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'region' as const,
'aria-live': 'polite' as const,
'aria-label': this.provider.opts.label.current,
...this.resolveMorfoProps({
props: {
label: this.provider.opts.label.current
}
}),
tabindex: -1
} as const)
);
@ -174,7 +171,7 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
readonly isOpen = $derived.by(() => !this.opts.toast.current.dismissing);
private constructor(opts: ToastItemOpts) {
super(opts, 'Toast', 'item', attrs.item, ToastItemProvider.ctx);
super(opts, { morfo: toastMorfo, part: 'item' }, ToastItemProvider.ctx);
this.provider = ToastProvider.require();
// Presence for exit animation
@ -352,25 +349,24 @@ export class ToastItemProvider extends Provider<ToastItemOpts> {
}
};
// ── ARIA ─────────────────────────────────────────────────────────────────
private get isUrgent(): boolean {
const type = this.opts.toast.current.type;
return type === 'error' || type === 'warning';
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: this.isUrgent ? ('alert' as const) : ('status' as const),
'aria-live': this.isUrgent ? ('assertive' as const) : ('polite' as const),
'aria-atomic': true,
'aria-labelledby': this.titleId.current || undefined,
'aria-describedby': this.descriptionId.current || undefined,
'data-state': this.isOpen ? 'open' : 'closed',
'data-type': this.opts.toast.current.type,
'data-swipe': this.swipeState === 'idle' ? undefined : this.swipeState,
'data-swipe-direction': this.provider.toaster.swipeDirection,
...this.resolveMorfoProps({
states: {
open: this.isOpen
},
props: {
intent: this.opts.toast.current.intent,
loading: this.opts.toast.current.loading,
swipeState: this.swipeState === 'idle' ? undefined : this.swipeState,
swipeDirection: this.provider.toaster.swipeDirection
},
parts: {
title: this.titleId.current || undefined,
description: this.descriptionId.current || undefined
}
}),
tabindex: 0,
style: {
'--soma-toast-swipe-move-x': `${this.swipeDeltaX}px`,
@ -408,7 +404,7 @@ export class ToastTitleProvider extends Provider<ToastTitleOpts> {
}
private constructor(opts: ToastTitleOpts) {
super(opts, 'Toast', 'title', attrs.title);
super(opts, { morfo: toastMorfo, part: 'title' });
const toastItem = ToastItemProvider.require();
toastItem.titleId.current = opts.id.current;
}
@ -430,7 +426,7 @@ export class ToastDescriptionProvider extends Provider<ToastDescriptionOpts> {
}
private constructor(opts: ToastDescriptionOpts) {
super(opts, 'Toast', 'description', attrs.description);
super(opts, { morfo: toastMorfo, part: 'description' });
const toastItem = ToastItemProvider.require();
toastItem.descriptionId.current = opts.id.current;
}
@ -457,14 +453,17 @@ export class ToastActionProvider extends Provider<ToastActionOpts> {
}
private constructor(opts: ToastActionOpts) {
super(opts, 'Toast', 'action', attrs.action);
super(opts, { morfo: toastMorfo, part: 'action' });
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-label': this.opts.altText.current
...this.resolveMorfoProps({
props: {
altText: this.opts.altText.current
}
})
} as const)
);
}
@ -481,7 +480,7 @@ export class ToastCloseProvider extends Provider<ToastCloseOpts> {
readonly toastItem: ToastItemProvider;
private constructor(opts: ToastCloseOpts) {
super(opts, 'Toast', 'close', attrs.close);
super(opts, { morfo: toastMorfo, part: 'close' });
this.toastItem = ToastItemProvider.require();
}
@ -492,8 +491,9 @@ export class ToastCloseProvider extends Provider<ToastCloseOpts> {
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-label': this.toastItem.provider.soma?.langs.ts(TOAST_LANGS.CLOSE),
...this.resolveMorfoProps({
translations: (key) => this.toastItem.provider.soma?.langs.ts(key)
}),
onclick: this.onclick
} as const)
);

@ -1,8 +1,9 @@
import { useId } from '../../id';
import type { SemaIntent } from '../../../sema';
// ── Types ────────────────────────────────────────────────────────────────────
export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading';
export type ToastIntent = SemaIntent;
export type SwipeDirection = 'left' | 'right' | 'up' | 'down';
@ -15,8 +16,10 @@ export interface ToastData {
description?: string;
/** Whether the toast is in the process of being dismissed (exit animation). */
dismissing?: boolean;
/** Toast type. Affects ARIA urgency (error/warning use assertive). */
type: ToastType;
/** Semantic intent. Drives ARIA urgency and public DOM surface. */
intent: ToastIntent;
/** Whether the toast is in a loading/pending phase. */
loading?: boolean;
/** Auto-dismiss duration in ms. Overrides toaster default. */
duration?: number;
/** Action button config. */
@ -32,8 +35,10 @@ export interface CreateToastOpts {
title?: string;
/** Toast description. */
description?: string;
/** Toast type. @default 'default' */
type?: ToastType;
/** Semantic intent. @default 'neutral' */
intent?: ToastIntent;
/** Whether the toast is in a loading/pending phase. */
loading?: boolean;
/** Auto-dismiss duration in ms. Overrides toaster default. Set 0 for no auto-dismiss. */
duration?: number;
/** Action button config. */
@ -46,9 +51,9 @@ export interface PromiseToastOpts<T> {
/** Shown while the promise is pending. */
loading: CreateToastOpts;
/** Shown when the promise resolves. Receives the resolved value. */
success: CreateToastOpts | ((value: T) => CreateToastOpts);
fulfill: CreateToastOpts | ((value: T) => CreateToastOpts);
/** Shown when the promise rejects. Receives the error. */
error: CreateToastOpts | ((err: unknown) => CreateToastOpts);
threat: CreateToastOpts | ((err: unknown) => CreateToastOpts);
}
export interface ToasterConfig {
@ -76,8 +81,7 @@ export interface ToasterConfig {
* Usage:
* ```ts
* const toaster = createToaster({ duration: 5000, max: 5 });
* toaster.create({ title: 'Saved!' });
* toaster.success({ title: 'Done', description: 'All changes saved.' });
* toaster.create({ title: 'Saved!', intent: 'fulfill' });
* toaster.dismiss(id);
* ```
*/
@ -105,8 +109,9 @@ export class Toaster {
id,
title: opts.title,
description: opts.description,
type: opts.type ?? 'default',
duration: opts.duration,
intent: opts.intent ?? 'neutral',
loading: opts.loading,
duration: opts.duration ?? (opts.loading ? 0 : undefined),
action: opts.action,
onDismiss: opts.onDismiss,
createdAt: Date.now()
@ -124,48 +129,22 @@ export class Toaster {
return id;
}
/** Add a success toast. */
success(opts: CreateToastOpts): string {
return this.create({ ...opts, type: 'success' });
}
/** Add an error toast. */
error(opts: CreateToastOpts): string {
return this.create({ ...opts, type: 'error' });
}
/** Add a warning toast. */
warning(opts: CreateToastOpts): string {
return this.create({ ...opts, type: 'warning' });
}
/** Add an info toast. */
info(opts: CreateToastOpts): string {
return this.create({ ...opts, type: 'info' });
}
/** Add a loading toast. Duration defaults to 0 (persistent until dismissed). */
loading(opts: CreateToastOpts): string {
return this.create({ duration: 0, ...opts, type: 'loading' });
}
/**
* Track a promise. Shows loading toast while pending, then updates to
* success or error based on the result.
* Track a promise. Shows a loading toast while pending, then updates it
* to a fulfill/threat semantic outcome.
*/
promise<T>(promise: Promise<T>, opts: PromiseToastOpts<T>): string {
const id = this.loading(opts.loading);
const id = this.create({ duration: 0, ...opts.loading, loading: true });
promise
.then((value) => {
const successOpts = typeof opts.success === 'function' ? opts.success(value) : opts.success;
this.update(id, { ...successOpts, type: 'success', duration: undefined });
// Restart auto-dismiss for success toast
const fulfillOpts = typeof opts.fulfill === 'function' ? opts.fulfill(value) : opts.fulfill;
this.update(id, { ...fulfillOpts, intent: 'fulfill', loading: false, duration: undefined });
this.restartTimer(id);
})
.catch((err) => {
const errorOpts = typeof opts.error === 'function' ? opts.error(err) : opts.error;
this.update(id, { ...errorOpts, type: 'error', duration: undefined });
const threatOpts = typeof opts.threat === 'function' ? opts.threat(err) : opts.threat;
this.update(id, { ...threatOpts, intent: 'threat', loading: false, duration: undefined });
this.restartTimer(id);
});
@ -199,7 +178,14 @@ export class Toaster {
/** Update an existing toast's data. */
update(id: string, opts: Partial<CreateToastOpts>): void {
this.toasts = this.toasts.map((t) =>
t.id === id ? { ...t, ...opts, type: opts.type ?? t.type } : t
t.id === id
? {
...t,
...opts,
intent: opts.intent ?? t.intent,
loading: opts.loading ?? t.loading
}
: t
);
}
}

@ -1,6 +1,7 @@
import { Context } from 'runed';
import { App } from '$lib/ext/app';
import type {
AppDom,
AppLangs,
AppNums,
AppMoney,
@ -41,6 +42,9 @@ export class Soma {
get langs(): AppLangs {
return this.app.langs;
}
get dom(): AppDom {
return this.app.dom;
}
get nums(): AppNums | undefined {
return this.app.nums;
}

@ -1,179 +1,23 @@
/**
* # ScrollLock — body scroll lock with refcounting
* Soma wrapper over `uix/adom` body scroll lock.
*
* Multiple instances share the same global lock state.
* When all instances unlock, body style is restored after a configurable delay.
*
* ## Usage
*
* ```ts
* readonly scrollLock = new ScrollLock();
* // later: this.scrollLock.locked.current = true;
* ```
* Keeps the current `ScrollLock` API for providers while delegating the
* actual body-lock implementation to the higher DOM runtime layer.
*/
import { SvelteMap } from 'svelte/reactivity';
import { on } from 'svelte/events';
import { tick } from 'svelte';
import { watch } from 'runed';
import { readableActive, writableActive, type State } from '$soma/reactive';
import { isIOS } from '$soma/dom';
import { useId } from '$soma/id';
export interface ScrollLockOption {
padding?: boolean | number;
margin?: boolean | number;
}
const lockMap = new SvelteMap<string, boolean>();
let initialBodyStyle: string | null = $state<string | null>(null);
let stopTouchMoveListener: (() => void) | null = null;
let cleanupTimeoutId: number | null = null;
let isInCleanupTransition = false;
let cleanupScheduledAt: number | null = null;
const anyLocked = readableActive(() => {
for (const value of lockMap.values()) {
if (value) return true;
}
return false;
});
function isAnyLocked(map: Map<string, boolean>) {
for (const [, value] of map) {
if (value) return true;
}
return false;
}
function resetBodyStyle() {
if (typeof document === 'undefined') return;
document.body.setAttribute('style', initialBodyStyle ?? '');
document.body.style.removeProperty('--scrollbar-width');
if (isIOS) stopTouchMoveListener?.();
initialBodyStyle = null;
}
function cancelPendingCleanup() {
if (cleanupTimeoutId === null) return;
window.clearTimeout(cleanupTimeoutId);
cleanupTimeoutId = null;
}
function ensureInitialStyleCaptured() {
if (initialBodyStyle === null && lockMap.size === 0 && !isInCleanupTransition) {
initialBodyStyle = document.body.getAttribute('style');
}
}
function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void) {
cancelPendingCleanup();
isInCleanupTransition = true;
cleanupScheduledAt = Date.now();
const currentCleanupId = cleanupScheduledAt;
const cleanupFn = () => {
cleanupTimeoutId = null;
if (cleanupScheduledAt !== currentCleanupId) return;
if (!isAnyLocked(lockMap)) {
isInCleanupTransition = false;
callback();
} else {
isInCleanupTransition = false;
}
};
cleanupTimeoutId = window.setTimeout(cleanupFn, delay ?? 24);
}
// Global watcher — applies/removes scroll lock when any lock changes
let watchInitialized = false;
function ensureGlobalWatch() {
if (watchInitialized) return;
watchInitialized = true;
watch(
() => anyLocked.current,
() => {
if (!anyLocked.current) {
scheduleCleanupIfNoNewLocks(null, resetBodyStyle);
return;
}
ensureInitialStyleCaptured();
isInCleanupTransition = false;
const htmlStyle = getComputedStyle(document.documentElement);
const bodyStyle = getComputedStyle(document.body);
import { BodyScrollLock, type BodyScrollLockOption } from '$uix/adom'
const hasStableGutter =
htmlStyle.scrollbarGutter?.includes('stable') ||
bodyStyle.scrollbarGutter?.includes('stable');
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
const paddingRight = Number.parseInt(bodyStyle.paddingRight ?? '0', 10);
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
document.body.style.paddingRight = `${paddingRight + verticalScrollbarWidth}px`;
document.body.style.setProperty('--scrollbar-width', `${verticalScrollbarWidth}px`);
}
document.body.style.overflow = 'hidden';
if (isIOS) {
stopTouchMoveListener = on(
document,
'touchmove',
(e: TouchEvent) => {
if (e.target !== document.documentElement) return;
if (e.touches.length > 1) return;
e.preventDefault();
},
{ passive: false }
);
}
tick().then(() => {
document.body.style.pointerEvents = 'none';
document.body.style.overflow = 'hidden';
});
}
);
}
export class ScrollLock {
readonly id = useId();
readonly locked: State<boolean>;
export type ScrollLockOption = BodyScrollLockOption
export class ScrollLock extends BodyScrollLock {
constructor(
initialState?: boolean,
private readonly restoreScrollDelay: () => number | null = () => null
restoreScrollDelay: () => number | null = () => null
) {
ensureGlobalWatch();
cancelPendingCleanup();
ensureInitialStyleCaptured();
lockMap.set(this.id, initialState ?? false);
this.locked = writableActive(
() => lockMap.get(this.id) ?? false,
(v: boolean) => lockMap.set(this.id, v)
);
super(initialState, restoreScrollDelay)
$effect(() => () => {
lockMap.delete(this.id);
if (isAnyLocked(lockMap)) return;
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), resetBodyStyle);
});
}
static reset() {
lockMap.clear();
cancelPendingCleanup();
resetBodyStyle();
initialBodyStyle = null;
isInCleanupTransition = false;
cleanupScheduledAt = null;
watchInitialized = false;
this.destroy()
})
}
}

@ -73,9 +73,18 @@
import { untrack } from 'svelte';
import { createAttachmentKey } from 'svelte/attachments';
import { assertContract } from '../attrs';
import { assertContract, createAttrs, registerContract } from '../attrs';
import type { SomaContext } from './context';
import { isState, type Active, type State } from '../reactive';
import type {
Morfo,
MorfoPart,
MorfoCondition,
MorfoPrimitiveValueSource,
MorfoValueSource,
MorfoData,
MorfoAriaEntry
} from '../../morfo/types';
// ---- Ref attachment ----
@ -122,6 +131,94 @@ export interface WithRefOpts extends ProviderOpts {
ref: State<HTMLElement | null>;
}
export interface ProviderMorfoBindings {
props?: Record<string, unknown>;
states?: Record<string, unknown>;
parts?: Record<string, unknown>;
translations?: (key: string) => string | undefined;
}
export interface ProviderMorfoSpec<M extends Morfo = Morfo> {
morfo: M;
part: string;
}
function findMorfoPart(parts: readonly MorfoPart[], target: string): MorfoPart | undefined {
for (const part of parts) {
if (part.kebab === target) return part;
if (part.parts) {
const nested = findMorfoPart(part.parts, target);
if (nested) return nested;
}
}
return undefined;
}
function shouldEmitMorfoEntry(
condition: MorfoCondition | undefined,
bindings: ProviderMorfoBindings
): boolean {
if (!condition || condition === 'always') return true;
if (condition.when === 'part-present') return Boolean(bindings.parts?.[condition.part]);
if (condition.when === 'state-equals') return bindings.states?.[condition.state] === condition.value;
if (condition.when === 'prop-truthy') return Boolean(bindings.props?.[condition.prop]);
if (condition.when === 'prop-falsy') return !bindings.props?.[condition.prop];
return true;
}
function resolveMorfoPrimitiveSource(
source: MorfoPrimitiveValueSource,
bindings: ProviderMorfoBindings
): unknown {
if (source.kind === 'literal') return source.value;
if (source.kind === 'stateRef') return bindings.states?.[source.state];
if (source.kind === 'partRef') return bindings.parts?.[source.target];
if (source.kind === 'propRef') return bindings.props?.[source.prop];
if (source.kind === 'translationRef') return bindings.translations?.(source.key);
return undefined;
}
function resolveMorfoSource(source: MorfoValueSource, bindings: ProviderMorfoBindings): unknown {
if (source.kind !== 'mapRef') {
return resolveMorfoPrimitiveSource(source, bindings);
}
const raw = resolveMorfoPrimitiveSource(source.source, bindings);
if (raw === undefined || raw === null) {
return source.fallback;
}
const mapped = source.map[String(raw)];
return mapped ?? source.fallback;
}
function resolveMorfoDataValue(data: MorfoData, bindings: ProviderMorfoBindings): unknown {
const source = data.value;
if (!source) return undefined;
const raw = resolveMorfoSource(source, bindings);
if (!data.values || data.values.length === 0) {
return raw ? '' : undefined;
}
if (source.kind === 'stateRef' && typeof raw === 'boolean') {
if (raw) return source.state;
return data.values.find((value) => value !== source.state);
}
return raw;
}
function resolveMorfoAriaValue(entry: MorfoAriaEntry, bindings: ProviderMorfoBindings): unknown {
const raw = resolveMorfoSource(entry.value, bindings);
if (entry.value.kind === 'stateRef') {
return Boolean(raw);
}
return raw;
}
export abstract class Provider<S extends ProviderOpts> {
readonly opts: S;
readonly attachment: RefAttachment | undefined;
@ -129,7 +226,15 @@ export abstract class Provider<S extends ProviderOpts> {
protected readonly _component: string;
protected readonly _part: string;
protected readonly _partAttr: string;
protected readonly _morfo: Morfo | undefined;
protected readonly _morfoPartMeta: MorfoPart | undefined;
protected constructor(
opts: S,
spec: ProviderMorfoSpec,
ctx?: SomaContext<any>,
onRefChange?: (v: HTMLElement | null) => void
);
protected constructor(
opts: S,
component: string,
@ -137,11 +242,36 @@ export abstract class Provider<S extends ProviderOpts> {
partAttr: string,
ctx?: SomaContext<any>,
onRefChange?: (v: HTMLElement | null) => void
);
protected constructor(
opts: S,
componentOrSpec: string | ProviderMorfoSpec,
partOrCtx?: string | SomaContext<any>,
partAttrOrOnRefChange?: string | ((v: HTMLElement | null) => void),
ctx?: SomaContext<any>,
onRefChange?: (v: HTMLElement | null) => void
) {
this.opts = opts;
this._component = component;
this._part = part;
this._partAttr = partAttr;
if (typeof componentOrSpec === 'string') {
this._component = componentOrSpec;
this._part = partOrCtx as string;
this._partAttr = partAttrOrOnRefChange as string;
this._morfo = undefined;
this._morfoPartMeta = undefined;
} else {
const spec = componentOrSpec;
const attrs = createAttrs(spec.morfo);
registerContract(spec.morfo);
this._component = spec.morfo.name;
this._part = spec.part;
this._partAttr = attrs[spec.part] ?? `data-${spec.morfo.kebab}-${spec.part}`;
this._morfo = spec.morfo;
this._morfoPartMeta = findMorfoPart(spec.morfo.parts as readonly MorfoPart[], spec.part);
ctx = partOrCtx as SomaContext<any> | undefined;
onRefChange = partAttrOrOnRefChange as ((v: HTMLElement | null) => void) | undefined;
}
this.attachment = opts.ref ? attachRef(opts.ref, onRefChange) : undefined;
if (ctx) ctx.set(this);
}
@ -155,6 +285,35 @@ export abstract class Provider<S extends ProviderOpts> {
};
}
protected resolveMorfoProps(bindings: ProviderMorfoBindings = {}): Record<string, unknown> {
if (!this._morfoPartMeta) return {};
const props: Record<string, unknown> = {};
if (this._morfoPartMeta.role) {
props.role = this._morfoPartMeta.role;
}
for (const data of this._morfoPartMeta.data) {
if (!data.value) continue;
if (!shouldEmitMorfoEntry(data.condition, bindings)) continue;
const value = resolveMorfoDataValue(data, bindings);
if (value !== undefined) {
props[data.attr] = value;
}
}
for (const aria of this._morfoPartMeta.aria) {
if (!shouldEmitMorfoEntry(aria.condition, bindings)) continue;
const value = resolveMorfoAriaValue(aria, bindings);
if (value !== undefined) {
props[aria.attr] = value;
}
}
return props;
}
/** Assert data contract + return props. */
protected assertProps<P extends Record<string, unknown>>(props: P): P {
assertContract(this._component, this._part, props);

Loading…
Cancel
Save

Powered by TurnKey Linux.