parent
f62ede4a23
commit
a0a1485b9f
@ -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.**
|
||||
@ -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"/)
|
||||
})
|
||||
})
|
||||
@ -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,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,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,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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue