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 {
|
import { SemanticEngine } from './engine'
|
||||||
_resetEngineForTesting,
|
import { dialogMorfo } from '../morfo/components/dialog'
|
||||||
configureSema,
|
import { toastMorfo } from '../morfo/components/toast'
|
||||||
destroySema,
|
|
||||||
getSemaEngine,
|
|
||||||
SemaEngine,
|
|
||||||
type ColorChannelDriver,
|
|
||||||
type ColorSignature,
|
|
||||||
type MotionChannelDriver,
|
|
||||||
type MotionSignature,
|
|
||||||
type PresenceChannelDriver,
|
|
||||||
type SemaChannelDrivers,
|
|
||||||
type SemaEventDetail,
|
|
||||||
type SoundChannelDriver
|
|
||||||
} from './engine'
|
|
||||||
import type { ResolvedSemaAction, ResolvedSemaSustain, SemaContext } from './port'
|
|
||||||
|
|
||||||
class FakeElement extends EventTarget {
|
function createElement(initial: Record<string, string> = {}): HTMLElement {
|
||||||
isConnected = true
|
const attrs = new Map<string, string>(Object.entries(initial))
|
||||||
ownerDocument!: { documentElement: FakeElement }
|
|
||||||
private readonly attrs = new Map<string, string>()
|
|
||||||
|
|
||||||
getAttribute(name: string): string | null {
|
|
||||||
return this.attrs.has(name) ? this.attrs.get(name)! : null
|
|
||||||
}
|
|
||||||
|
|
||||||
setAttribute(name: string, value: string): void {
|
|
||||||
this.attrs.set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAttribute(name: string): void {
|
|
||||||
this.attrs.delete(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
matches(): boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
closest(): Element | null {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
this.isConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDom() {
|
|
||||||
const documentElement = new FakeElement()
|
|
||||||
const ownerDocument = { documentElement }
|
|
||||||
documentElement.ownerDocument = ownerDocument
|
|
||||||
|
|
||||||
const target = new FakeElement()
|
|
||||||
target.ownerDocument = ownerDocument
|
|
||||||
|
|
||||||
const root = new FakeElement()
|
|
||||||
root.ownerDocument = ownerDocument
|
|
||||||
|
|
||||||
return { documentElement, target, root }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAction(
|
|
||||||
overrides: Partial<ResolvedSemaAction> = {}
|
|
||||||
): ResolvedSemaAction {
|
|
||||||
return {
|
|
||||||
name: 'close-save',
|
|
||||||
component: 'dialog',
|
|
||||||
event: 'commit-fulfill',
|
|
||||||
mode: 'blocking',
|
|
||||||
regime: 'replace',
|
|
||||||
scope: 'part',
|
|
||||||
target: 'content',
|
|
||||||
prewritten: [],
|
|
||||||
...overrides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSustain(
|
|
||||||
overrides: Partial<ResolvedSemaSustain> = {}
|
|
||||||
): ResolvedSemaSustain {
|
|
||||||
return {
|
return {
|
||||||
name: 'open',
|
getAttribute(name: string) {
|
||||||
component: 'dialog',
|
return attrs.has(name) ? attrs.get(name)! : null
|
||||||
target: 'content',
|
},
|
||||||
scope: 'part',
|
setAttribute(name: string, value: string) {
|
||||||
...overrides
|
attrs.set(name, value)
|
||||||
}
|
},
|
||||||
}
|
removeAttribute(name: string) {
|
||||||
|
attrs.delete(name)
|
||||||
function createContext(targetEl: FakeElement, rootEl?: FakeElement): SemaContext {
|
}
|
||||||
return {
|
} as unknown as HTMLElement
|
||||||
targetEl: targetEl as unknown as HTMLElement,
|
|
||||||
rootEl: rootEl as unknown as HTMLElement | undefined,
|
|
||||||
snapshot: {},
|
|
||||||
partEls: {},
|
|
||||||
cause: 'programmatic'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDelayedMotionDriver(ms: number): MotionChannelDriver & { apply: ReturnType<typeof vi.fn> } {
|
|
||||||
return {
|
|
||||||
apply: vi.fn(async (_target: HTMLElement, _signature: MotionSignature, abortSignal: AbortSignal) => {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(() => resolve(), ms)
|
|
||||||
abortSignal.addEventListener(
|
|
||||||
'abort',
|
|
||||||
() => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
resolve()
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
applySustained: () => () => {},
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createImmediateDrivers(overrides: Partial<SemaChannelDrivers> = {}) {
|
|
||||||
const motion: MotionChannelDriver = {
|
|
||||||
async apply() {},
|
|
||||||
applySustained: () => () => {},
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
const sound: SoundChannelDriver = {
|
|
||||||
async apply() {},
|
|
||||||
applySustained: () => () => {},
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
const color: ColorChannelDriver = {
|
|
||||||
async apply() {},
|
|
||||||
applySustained: () => () => {},
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
const presence: PresenceChannelDriver = {
|
|
||||||
async apply() {},
|
|
||||||
applySustained: () => () => {},
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
motion,
|
|
||||||
sound,
|
|
||||||
color,
|
|
||||||
presence,
|
|
||||||
...overrides
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
describe('SemanticEngine', () => {
|
||||||
vi.useRealTimers()
|
it('publishes a dialog event with normalized canonical semantics', () => {
|
||||||
_resetEngineForTesting()
|
const engine = new SemanticEngine()
|
||||||
})
|
const contentEl = createElement({ 'data-state': 'open' })
|
||||||
|
|
||||||
describe('SemaEngine', () => {
|
const published = engine.publish(dialogMorfo, 'close-save', {
|
||||||
it('emits sema:event and reflects attrs around a blocking choreography', async () => {
|
targetEl: contentEl,
|
||||||
const { target, root } = createDom()
|
partEls: { content: contentEl },
|
||||||
const phases: SemaEventDetail[] = []
|
cause: 'pointer'
|
||||||
target.addEventListener('sema:event', (event) => {
|
|
||||||
phases.push((event as CustomEvent<SemaEventDetail>).detail)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const engine = new SemaEngine({
|
expect(contentEl.getAttribute('data-last-action')).toBe('saved')
|
||||||
channels: createImmediateDrivers()
|
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 () => {
|
it('resolves prop-driven intent from morfo events', () => {
|
||||||
vi.useFakeTimers()
|
const engine = new SemanticEngine()
|
||||||
const { target } = createDom()
|
const itemEl = createElement()
|
||||||
const motion = createDelayedMotionDriver(1000)
|
|
||||||
const engine = new SemaEngine({
|
|
||||||
channels: createImmediateDrivers({ motion })
|
|
||||||
})
|
|
||||||
engine.configure({
|
|
||||||
sound: { enabled: false },
|
|
||||||
color: { enabled: false },
|
|
||||||
presence: { enabled: false },
|
|
||||||
capBlockingMs: 20
|
|
||||||
})
|
|
||||||
const action = createAction({ regime: 'collapse' })
|
|
||||||
|
|
||||||
const first = engine.before(action, createContext(target))
|
|
||||||
const second = engine.before(action, createContext(target))
|
|
||||||
|
|
||||||
expect(motion.apply).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(20)
|
const published = engine.publish(toastMorfo, 'announce', {
|
||||||
await first
|
targetEl: itemEl,
|
||||||
await second
|
partEls: { item: itemEl },
|
||||||
})
|
props: { intent: 'risk' }
|
||||||
|
|
||||||
it('locks equivalent actions while one is active', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const { target } = createDom()
|
|
||||||
const motion = createDelayedMotionDriver(1000)
|
|
||||||
const engine = new SemaEngine({
|
|
||||||
channels: createImmediateDrivers({ motion })
|
|
||||||
})
|
})
|
||||||
engine.configure({
|
|
||||||
sound: { enabled: false },
|
|
||||||
color: { enabled: false },
|
|
||||||
presence: { enabled: false },
|
|
||||||
capBlockingMs: 20
|
|
||||||
})
|
|
||||||
const action = createAction({ regime: 'lock' })
|
|
||||||
|
|
||||||
const first = engine.before(action, createContext(target))
|
|
||||||
await engine.before(action, createContext(target))
|
|
||||||
|
|
||||||
expect(motion.apply).toHaveBeenCalledTimes(1)
|
expect(published).toMatchObject({
|
||||||
|
component: 'toast',
|
||||||
await vi.advanceTimersByTimeAsync(20)
|
name: 'announce',
|
||||||
await first
|
family: 'alert',
|
||||||
})
|
intent: 'risk',
|
||||||
|
label: 'alert-risk'
|
||||||
it('queues equivalent actions sequentially after the blocking cap releases', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
const { target } = createDom()
|
|
||||||
const motion = createDelayedMotionDriver(1000)
|
|
||||||
const engine = new SemaEngine({
|
|
||||||
channels: createImmediateDrivers({ motion })
|
|
||||||
})
|
|
||||||
engine.configure({
|
|
||||||
sound: { enabled: false },
|
|
||||||
color: { enabled: false },
|
|
||||||
presence: { enabled: false },
|
|
||||||
capBlockingMs: 20
|
|
||||||
})
|
})
|
||||||
const action = createAction({ regime: 'queue' })
|
|
||||||
|
|
||||||
const first = engine.before(action, createContext(target))
|
|
||||||
const second = engine.before(action, createContext(target))
|
|
||||||
|
|
||||||
expect(motion.apply).toHaveBeenCalledTimes(1)
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(20)
|
|
||||||
expect(motion.apply).toHaveBeenCalledTimes(2)
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(20)
|
|
||||||
await first
|
|
||||||
await second
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replaces an active choreography and marks the first one as cancelled', async () => {
|
it('falls back to the declared default intent when the prop is missing', () => {
|
||||||
vi.useFakeTimers()
|
const engine = new SemanticEngine()
|
||||||
const { target } = createDom()
|
const itemEl = createElement()
|
||||||
const motion = createDelayedMotionDriver(1000)
|
|
||||||
const phases: Array<SemaEventDetail['phase']> = []
|
|
||||||
target.addEventListener('sema:event', (event) => {
|
|
||||||
phases.push((event as CustomEvent<SemaEventDetail>).detail.phase)
|
|
||||||
})
|
|
||||||
const engine = new SemaEngine({
|
|
||||||
channels: createImmediateDrivers({ motion })
|
|
||||||
})
|
|
||||||
engine.configure({
|
|
||||||
sound: { enabled: false },
|
|
||||||
color: { enabled: false },
|
|
||||||
presence: { enabled: false },
|
|
||||||
capBlockingMs: 20
|
|
||||||
})
|
|
||||||
const action = createAction({ regime: 'replace' })
|
|
||||||
|
|
||||||
const first = engine.before(action, createContext(target))
|
|
||||||
const second = engine.before(action, createContext(target))
|
|
||||||
|
|
||||||
expect(motion.apply).toHaveBeenCalledTimes(2)
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(20)
|
|
||||||
await first
|
|
||||||
await second
|
|
||||||
|
|
||||||
expect(phases.filter((phase) => phase === 'start')).toHaveLength(2)
|
|
||||||
expect(phases).toContain('cancelled')
|
|
||||||
expect(phases).toContain('end')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('starts sustains and runs cleanups on stop()', () => {
|
const published = engine.publish(toastMorfo, 'announce', {
|
||||||
const { target } = createDom()
|
targetEl: itemEl,
|
||||||
const motionCleanup = vi.fn()
|
partEls: { item: itemEl }
|
||||||
const presenceCleanup = vi.fn()
|
|
||||||
const engine = new SemaEngine({
|
|
||||||
channels: createImmediateDrivers({
|
|
||||||
motion: {
|
|
||||||
async apply() {},
|
|
||||||
applySustained: () => motionCleanup,
|
|
||||||
destroy() {}
|
|
||||||
},
|
|
||||||
presence: {
|
|
||||||
async apply() {},
|
|
||||||
applySustained: () => presenceCleanup,
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = engine.startSustain(createSustain(), createContext(target))
|
expect(published.label).toBe('alert-neutral')
|
||||||
expect(session.active).toBe(true)
|
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)
|
const unsubscribe = engine.onEvent((event) => {
|
||||||
expect(motionCleanup).toHaveBeenCalledTimes(0)
|
seen.push(event.label)
|
||||||
expect(presenceCleanup).toHaveBeenCalledTimes(1)
|
}, { component: 'toast', family: 'alert' })
|
||||||
})
|
|
||||||
|
|
||||||
it('re-resolves signatures after map override reconfiguration', async () => {
|
engine.publish(toastMorfo, 'announce', {
|
||||||
const { target } = createDom()
|
targetEl: itemEl,
|
||||||
const seen: ColorSignature[] = []
|
partEls: { item: itemEl },
|
||||||
const color: ColorChannelDriver = {
|
props: { intent: 'threat' }
|
||||||
async apply(_target, signature) {
|
|
||||||
seen.push(signature)
|
|
||||||
},
|
|
||||||
applySustained: () => () => {},
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
const engine = new SemaEngine({
|
|
||||||
channels: createImmediateDrivers({ color })
|
|
||||||
})
|
})
|
||||||
engine.configure({
|
unsubscribe()
|
||||||
sound: { enabled: false },
|
engine.publish(toastMorfo, 'announce', {
|
||||||
motion: { enabled: false },
|
targetEl: itemEl,
|
||||||
presence: { enabled: false },
|
partEls: { item: itemEl },
|
||||||
mapOverrides: {
|
props: { intent: 'affirm' }
|
||||||
'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 }
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
await engine.before(createAction(), createContext(target))
|
expect(seen).toEqual(['alert-threat'])
|
||||||
|
|
||||||
expect(seen).toHaveLength(1)
|
|
||||||
expect(seen[0].hue).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes a singleton facade and resets it on destroy', () => {
|
it('throws when publishing an undeclared event', () => {
|
||||||
const first = getSemaEngine()
|
const engine = new SemanticEngine()
|
||||||
configureSema({ reflectEvents: true })
|
|
||||||
const second = getSemaEngine()
|
|
||||||
|
|
||||||
expect(first).toBe(second)
|
|
||||||
expect(second.currentConfig.reflectEvents).toBe(true)
|
|
||||||
|
|
||||||
destroySema()
|
expect(() =>
|
||||||
|
engine.publish(toastMorfo, 'missing', {
|
||||||
const third = getSemaEngine()
|
targetEl: createElement()
|
||||||
expect(third).not.toBe(first)
|
})
|
||||||
|
).toThrow(/event "missing" not declared/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,551 +1,193 @@
|
|||||||
import { DEV } from 'esm-env'
|
import { DEV } from 'esm-env'
|
||||||
|
|
||||||
import {
|
import { normalizeSemaEvent } from './event'
|
||||||
A11yMonitor,
|
|
||||||
DEFAULT_SEMA_RUNTIME_CONFIG,
|
|
||||||
type SemaRuntimeConfig
|
|
||||||
} from './a11y'
|
|
||||||
import { ColorChannel } from './channels/color'
|
|
||||||
import { MotionChannel } from './channels/motion'
|
|
||||||
import { PresenceChannel } from './channels/presence'
|
|
||||||
import { SoundChannel } from './channels/sound'
|
|
||||||
import type {
|
import type {
|
||||||
ColorSignature,
|
SemaAttrWrite,
|
||||||
EffectiveSignature,
|
SemaCause,
|
||||||
MotionSignature,
|
SemaCommit,
|
||||||
PresenceSignature,
|
SemaEvent,
|
||||||
RuntimeOverrides,
|
SemaEventLabel,
|
||||||
SoundSignature
|
SemaFamily,
|
||||||
} from './resolver'
|
SemaIntent,
|
||||||
import { Resolver } from './resolver'
|
SemaMode,
|
||||||
import type {
|
SemaRegime,
|
||||||
ResolvedSemaAction,
|
SemaScope
|
||||||
ResolvedSemaSustain,
|
} from './types'
|
||||||
SemaContext,
|
|
||||||
SemaPort,
|
import type { PartRef } from '../lib/types'
|
||||||
SemaSession
|
|
||||||
} from './port'
|
export interface SemanticEventDecl {
|
||||||
|
name: string
|
||||||
type SemaPhase = 'start' | 'end' | 'cancelled'
|
target: PartRef
|
||||||
|
semantic: SemaEvent
|
||||||
export interface SemaEventDetail {
|
mode?: SemaMode
|
||||||
event: ResolvedSemaAction['event']
|
regime?: SemaRegime
|
||||||
action: string
|
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
|
component: string
|
||||||
phase: SemaPhase
|
target: string
|
||||||
channels: EffectiveSignature['activeChannels']
|
targetEl: HTMLElement
|
||||||
duration: number
|
rootEl?: HTMLElement
|
||||||
}
|
family: SemaFamily
|
||||||
|
intent: SemaIntent | null
|
||||||
export interface MotionChannelDriver {
|
label: SemaEventLabel
|
||||||
apply(target: HTMLElement, signature: MotionSignature, abortSignal: AbortSignal): Promise<void>
|
mode: SemaMode
|
||||||
applySustained?(target: HTMLElement, signature: MotionSignature): () => void
|
regime: SemaRegime
|
||||||
destroy?(): void
|
scope: SemaScope
|
||||||
}
|
prewritten: readonly { part: string; attr: string; value: string }[]
|
||||||
|
commits?: SemaCommit
|
||||||
export interface SoundChannelDriver {
|
cause?: SemaCause
|
||||||
apply(signature: SoundSignature, gain: number, abortSignal: AbortSignal): Promise<void>
|
detail?: Record<string, unknown>
|
||||||
applySustained?(signature: SoundSignature, gain: number): () => void
|
timestamp: number
|
||||||
destroy?(): void
|
}
|
||||||
}
|
|
||||||
|
export interface SemanticEventFilter {
|
||||||
export interface ColorChannelDriver {
|
component?: string
|
||||||
apply(target: HTMLElement, signature: ColorSignature, abortSignal: AbortSignal): Promise<void>
|
name?: string
|
||||||
applySustained?(target: HTMLElement, signature: ColorSignature): () => void
|
family?: SemaFamily
|
||||||
destroy?(): void
|
intent?: SemaIntent | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenceChannelDriver {
|
type SemanticListener = (event: PublishedSemanticEvent) => void
|
||||||
apply(target: HTMLElement, signature: PresenceSignature, abortSignal: AbortSignal): Promise<void>
|
|
||||||
applySustained?(target: HTMLElement, signature: PresenceSignature): () => void
|
interface SemanticSubscriber {
|
||||||
destroy?(): void
|
filter?: SemanticEventFilter
|
||||||
}
|
listener: SemanticListener
|
||||||
|
}
|
||||||
export interface SemaChannelDrivers {
|
|
||||||
motion: MotionChannelDriver
|
function matchesFilter(event: PublishedSemanticEvent, filter?: SemanticEventFilter): boolean {
|
||||||
sound: SoundChannelDriver
|
if (!filter) return true
|
||||||
color: ColorChannelDriver
|
if (filter.component && filter.component !== event.component) return false
|
||||||
presence: PresenceChannelDriver
|
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
|
||||||
export interface EngineDependencies {
|
return true
|
||||||
resolver?: Resolver
|
}
|
||||||
a11y?: A11yMonitor
|
|
||||||
channels?: Partial<SemaChannelDrivers>
|
function findEvent(contract: SemanticComponentContract, name: string): SemanticEventDecl | undefined {
|
||||||
}
|
return contract.events?.find((event) => event.name === name)
|
||||||
|
}
|
||||||
export interface EngineConfig extends SemaRuntimeConfig {
|
|
||||||
mapOverrides?: RuntimeOverrides
|
function resolvePartElement(
|
||||||
}
|
ctx: SemanticPublishContext,
|
||||||
|
targetPart: string,
|
||||||
export interface EngineConfigPatch
|
fallback: HTMLElement
|
||||||
extends Partial<Omit<EngineConfig, 'sound' | 'motion' | 'color' | 'presence'>> {
|
): HTMLElement | undefined {
|
||||||
sound?: Partial<EngineConfig['sound']>
|
return ctx.partEls?.[targetPart] ?? fallback
|
||||||
motion?: Partial<EngineConfig['motion']>
|
}
|
||||||
color?: Partial<EngineConfig['color']>
|
|
||||||
presence?: Partial<EngineConfig['presence']>
|
export class SemanticEngine {
|
||||||
}
|
private readonly subscribers = new Set<SemanticSubscriber>()
|
||||||
|
private nextId = 0
|
||||||
const noopCleanup = () => {}
|
|
||||||
|
publish(
|
||||||
const noopChannels: SemaChannelDrivers = {
|
contract: SemanticComponentContract,
|
||||||
motion: {
|
name: string,
|
||||||
async apply() {},
|
ctx: SemanticPublishContext
|
||||||
applySustained() {
|
): PublishedSemanticEvent {
|
||||||
return noopCleanup
|
const decl = findEvent(contract, name)
|
||||||
},
|
if (!decl) {
|
||||||
destroy() {}
|
throw new Error(
|
||||||
},
|
`[semantic] event "${name}" not declared in "${contract.kebab}". Declared events: ${contract.events?.map((event) => event.name).join(', ') || '∅'}`
|
||||||
sound: {
|
)
|
||||||
async apply() {},
|
|
||||||
applySustained() {
|
|
||||||
return noopCleanup
|
|
||||||
},
|
|
||||||
destroy() {}
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
async apply() {},
|
|
||||||
applySustained() {
|
|
||||||
return noopCleanup
|
|
||||||
},
|
|
||||||
destroy() {}
|
|
||||||
},
|
|
||||||
presence: {
|
|
||||||
async apply() {},
|
|
||||||
applySustained() {
|
|
||||||
return noopCleanup
|
|
||||||
},
|
|
||||||
destroy() {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeConfig(current: EngineConfig, patch: EngineConfigPatch): EngineConfig {
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
...patch,
|
|
||||||
sound: patch.sound ? { ...current.sound, ...patch.sound } : current.sound,
|
|
||||||
motion: patch.motion ? { ...current.motion, ...patch.motion } : current.motion,
|
|
||||||
color: patch.color ? { ...current.color, ...patch.color } : current.color,
|
|
||||||
presence: patch.presence ? { ...current.presence, ...patch.presence } : current.presence,
|
|
||||||
mapOverrides:
|
|
||||||
'mapOverrides' in patch ? structuredClone(patch.mapOverrides ?? {}) : current.mapOverrides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSemaEvent(detail: SemaEventDetail): Event {
|
|
||||||
if (typeof CustomEvent === 'function') {
|
|
||||||
return new CustomEvent<SemaEventDetail>('sema:event', {
|
|
||||||
bubbles: true,
|
|
||||||
detail
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const event = new Event('sema:event', { bubbles: true }) as Event & { detail?: SemaEventDetail }
|
|
||||||
event.detail = detail
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConnected(el: HTMLElement | undefined): boolean {
|
|
||||||
if (!el) return false
|
|
||||||
return el.isConnected !== false
|
|
||||||
}
|
|
||||||
|
|
||||||
function swallowAbortable(work: Promise<void>): Promise<void> {
|
|
||||||
return work.catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SemaEngine implements SemaPort {
|
|
||||||
private config: EngineConfig = {
|
|
||||||
...DEFAULT_SEMA_RUNTIME_CONFIG
|
|
||||||
}
|
|
||||||
private readonly resolver: Resolver
|
|
||||||
private readonly a11y: A11yMonitor
|
|
||||||
private readonly drivers: SemaChannelDrivers
|
|
||||||
private readonly activeChoreographies = new Map<string, Choreography>()
|
|
||||||
private readonly sustainSessions = new Set<SustainRunner>()
|
|
||||||
private readonly elementIds = new WeakMap<HTMLElement, string>()
|
|
||||||
private nextElementId = 0
|
|
||||||
|
|
||||||
constructor(deps: EngineDependencies = {}) {
|
|
||||||
this.resolver = deps.resolver ?? new Resolver()
|
|
||||||
this.a11y = deps.a11y ?? new A11yMonitor()
|
|
||||||
this.drivers = {
|
|
||||||
motion: deps.channels?.motion ?? new MotionChannel(),
|
|
||||||
sound: deps.channels?.sound ?? new SoundChannel(),
|
|
||||||
color: deps.channels?.color ?? new ColorChannel(),
|
|
||||||
presence: deps.channels?.presence ?? new PresenceChannel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
configure(patch: EngineConfigPatch): void {
|
|
||||||
this.config = mergeConfig(this.config, patch)
|
|
||||||
if ('mapOverrides' in patch) {
|
|
||||||
this.resolver.setRuntimeOverrides(patch.mapOverrides ?? {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(): void {
|
|
||||||
for (const choreography of [...this.activeChoreographies.values()]) {
|
|
||||||
choreography.cancel()
|
|
||||||
}
|
|
||||||
this.activeChoreographies.clear()
|
|
||||||
|
|
||||||
for (const sustain of [...this.sustainSessions]) {
|
|
||||||
sustain.stop()
|
|
||||||
}
|
|
||||||
this.sustainSessions.clear()
|
|
||||||
|
|
||||||
this.drivers.motion.destroy?.()
|
|
||||||
this.drivers.sound.destroy?.()
|
|
||||||
this.drivers.color.destroy?.()
|
|
||||||
this.drivers.presence.destroy?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
async before(action: ResolvedSemaAction, ctx: SemaContext): Promise<void> {
|
|
||||||
const key = this.choreographyKey(action, ctx)
|
|
||||||
const existing = this.activeChoreographies.get(key)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
switch (action.regime) {
|
|
||||||
case 'replace':
|
|
||||||
existing.cancel()
|
|
||||||
this.activeChoreographies.delete(key)
|
|
||||||
break
|
|
||||||
case 'collapse':
|
|
||||||
existing.markRepeated()
|
|
||||||
return existing.promise
|
|
||||||
case 'lock':
|
|
||||||
return
|
|
||||||
case 'queue':
|
|
||||||
await existing.promise
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let choreography: Choreography | null = null
|
const targetEl = resolvePartElement(ctx, decl.target.target, ctx.targetEl)
|
||||||
let signature: EffectiveSignature | null = null
|
if (!targetEl) {
|
||||||
|
throw new Error(
|
||||||
try {
|
`[semantic] target part "${decl.target.target}" has no runtime element in "${contract.kebab}.${name}".`
|
||||||
signature = this.a11y.reduceSignature(this.resolver.resolve(action.event, ctx.targetEl), this.config)
|
|
||||||
choreography = new Choreography(action, ctx, signature, this.a11y.getBlockingCapMs(this.config), this)
|
|
||||||
this.activeChoreographies.set(key, choreography)
|
|
||||||
|
|
||||||
this.emitCustomEvent(ctx.targetEl, action, 'start', signature)
|
|
||||||
if (this.config.reflectEvents) {
|
|
||||||
ctx.targetEl.setAttribute('data-sema-active', action.event)
|
|
||||||
ctx.targetEl.setAttribute('data-sema-phase', 'active')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await choreography.run()
|
|
||||||
this.emitCustomEvent(
|
|
||||||
ctx.targetEl,
|
|
||||||
action,
|
|
||||||
result === 'cancelled' ? 'cancelled' : 'end',
|
|
||||||
signature
|
|
||||||
)
|
)
|
||||||
} catch (error) {
|
|
||||||
this.warn(`[sema] engine.before("${action.name}") degraded: ${String(error)}`)
|
|
||||||
} finally {
|
|
||||||
if (this.config.reflectEvents) {
|
|
||||||
ctx.targetEl.removeAttribute('data-sema-active')
|
|
||||||
ctx.targetEl.removeAttribute('data-sema-phase')
|
|
||||||
}
|
|
||||||
if (choreography) {
|
|
||||||
this.activeChoreographies.delete(key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fire(action: ResolvedSemaAction, ctx: SemaContext): void {
|
const prewritten = this.applyPrewrites(contract, decl, ctx, targetEl)
|
||||||
this.before(action, ctx).catch(() => {})
|
const normalized = normalizeSemaEvent(decl.semantic, ctx.props)
|
||||||
}
|
|
||||||
|
|
||||||
startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession {
|
const published: PublishedSemanticEvent = {
|
||||||
try {
|
id: `sem-${this.nextId++}`,
|
||||||
const runner = new SustainRunner(sustain, ctx, this)
|
name: decl.name,
|
||||||
this.sustainSessions.add(runner)
|
component: contract.kebab,
|
||||||
runner.start()
|
target: decl.target.target,
|
||||||
return {
|
targetEl,
|
||||||
stop: () => {
|
rootEl: ctx.rootEl,
|
||||||
runner.stop()
|
family: normalized.family,
|
||||||
this.sustainSessions.delete(runner)
|
intent: normalized.intent,
|
||||||
},
|
label: normalized.label,
|
||||||
get active() {
|
mode: decl.mode ?? 'blocking',
|
||||||
return runner.active
|
regime: decl.regime ?? 'replace',
|
||||||
}
|
scope: decl.scope ?? 'part',
|
||||||
}
|
prewritten,
|
||||||
} catch (error) {
|
commits: decl.commits,
|
||||||
this.warn(`[sema] engine.startSustain("${sustain.name}") degraded: ${String(error)}`)
|
cause: ctx.cause,
|
||||||
return {
|
detail: ctx.detail,
|
||||||
stop() {},
|
timestamp: Date.now()
|
||||||
get active() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
get channels(): Readonly<SemaChannelDrivers> {
|
|
||||||
return this.drivers
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentConfig(): Readonly<EngineConfig> {
|
for (const subscriber of this.subscribers) {
|
||||||
return this.config
|
if (!matchesFilter(published, subscriber.filter)) continue
|
||||||
}
|
subscriber.listener(published)
|
||||||
|
|
||||||
resolveReducedSignature(event: string, targetEl?: HTMLElement): EffectiveSignature {
|
|
||||||
return this.a11y.reduceSignature(this.resolver.resolve(event, targetEl), this.config)
|
|
||||||
}
|
|
||||||
|
|
||||||
private choreographyKey(action: ResolvedSemaAction, ctx: SemaContext): string {
|
|
||||||
return `${action.component}:${action.name}:${this.elementId(this.scopeAnchor(action.scope, ctx))}`
|
|
||||||
}
|
|
||||||
|
|
||||||
private scopeAnchor(scope: ResolvedSemaAction['scope'], ctx: SemaContext): HTMLElement {
|
|
||||||
if (scope === 'component') return ctx.rootEl ?? ctx.targetEl
|
|
||||||
if (scope === 'scene') {
|
|
||||||
return (ctx.rootEl?.ownerDocument?.documentElement as HTMLElement | undefined) ??
|
|
||||||
(ctx.targetEl.ownerDocument?.documentElement as HTMLElement | undefined) ??
|
|
||||||
ctx.rootEl ??
|
|
||||||
ctx.targetEl
|
|
||||||
}
|
}
|
||||||
return ctx.targetEl
|
|
||||||
}
|
|
||||||
|
|
||||||
private elementId(el: HTMLElement): string {
|
|
||||||
const existing = this.elementIds.get(el)
|
|
||||||
if (existing) return existing
|
|
||||||
const next = `el-${this.nextElementId++}`
|
|
||||||
this.elementIds.set(el, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitCustomEvent(
|
return published
|
||||||
target: HTMLElement,
|
|
||||||
action: ResolvedSemaAction,
|
|
||||||
phase: SemaPhase,
|
|
||||||
signature: EffectiveSignature
|
|
||||||
): void {
|
|
||||||
target.dispatchEvent(
|
|
||||||
createSemaEvent({
|
|
||||||
event: action.event,
|
|
||||||
action: action.name,
|
|
||||||
component: action.component,
|
|
||||||
phase,
|
|
||||||
channels: signature.activeChannels,
|
|
||||||
duration: this.durationOfSignature(signature)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private durationOfSignature(signature: EffectiveSignature): number {
|
onEvent(listener: SemanticListener, filter?: SemanticEventFilter): () => void {
|
||||||
const durations = [
|
const subscriber: SemanticSubscriber = { listener, filter }
|
||||||
signature.motion?.duration,
|
this.subscribers.add(subscriber)
|
||||||
signature.sound?.duration,
|
return () => {
|
||||||
signature.color?.duration,
|
this.subscribers.delete(subscriber)
|
||||||
signature.presence?.duration
|
|
||||||
].filter((value): value is number => value !== undefined)
|
|
||||||
|
|
||||||
return durations.length > 0 ? Math.max(...durations) : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private warn(message: string): void {
|
|
||||||
if (DEV) console.warn(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Choreography {
|
|
||||||
readonly promise: Promise<void>
|
|
||||||
|
|
||||||
private cancelled = false
|
|
||||||
private repeatedCount = 0
|
|
||||||
private settled = false
|
|
||||||
private readonly abortController = new AbortController()
|
|
||||||
private readonly settlePromise: () => void
|
|
||||||
private readonly detachExternalAbort?: () => void
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly action: ResolvedSemaAction,
|
|
||||||
private readonly ctx: SemaContext,
|
|
||||||
private readonly signature: EffectiveSignature,
|
|
||||||
private readonly capMs: number,
|
|
||||||
private readonly engine: SemaEngine
|
|
||||||
) {
|
|
||||||
let resolvePromise = () => {}
|
|
||||||
this.promise = new Promise<void>((resolve) => {
|
|
||||||
resolvePromise = resolve
|
|
||||||
})
|
|
||||||
this.settlePromise = resolvePromise
|
|
||||||
|
|
||||||
if (ctx.abortSignal) {
|
|
||||||
const onAbort = () => this.cancel()
|
|
||||||
if (ctx.abortSignal.aborted) {
|
|
||||||
this.cancel()
|
|
||||||
} else {
|
|
||||||
ctx.abortSignal.addEventListener('abort', onAbort, { once: true })
|
|
||||||
this.detachExternalAbort = () => {
|
|
||||||
ctx.abortSignal?.removeEventListener('abort', onAbort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
markRepeated(): void {
|
destroy(): void {
|
||||||
this.repeatedCount++
|
this.subscribers.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
private applyPrewrites(
|
||||||
if (this.cancelled) return
|
contract: SemanticComponentContract,
|
||||||
this.cancelled = true
|
decl: SemanticEventDecl,
|
||||||
this.abortController.abort()
|
ctx: SemanticPublishContext,
|
||||||
this.settle()
|
targetEl: HTMLElement
|
||||||
}
|
): readonly { part: string; attr: string; value: string }[] {
|
||||||
|
const applied: Array<{ part: string; attr: string; value: string }> = []
|
||||||
async run(): Promise<'completed' | 'cancelled'> {
|
|
||||||
if (this.action.scope !== 'scene' && !isConnected(this.ctx.targetEl)) {
|
for (const write of decl.prewrite ?? []) {
|
||||||
this.settle()
|
const el = resolvePartElement(ctx, write.part.target, write.part.target === decl.target.target ? targetEl : ctx.targetEl)
|
||||||
return 'cancelled'
|
if (!el) {
|
||||||
}
|
if (DEV) {
|
||||||
|
console.warn(
|
||||||
const channelPromises: Promise<void>[] = []
|
`[semantic] prewrite target "${write.part.target}" missing for "${contract.kebab}.${decl.name}".`
|
||||||
const config = this.engine.currentConfig
|
)
|
||||||
const channels = this.engine.channels
|
|
||||||
|
|
||||||
if (this.signature.activeChannels.includes('motion') && config.motion.enabled && this.signature.motion) {
|
|
||||||
channelPromises.push(
|
|
||||||
swallowAbortable(
|
|
||||||
channels.motion.apply(this.ctx.targetEl, this.signature.motion, this.abortController.signal)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (this.signature.activeChannels.includes('sound') && config.sound.enabled && this.signature.sound) {
|
|
||||||
channelPromises.push(
|
|
||||||
swallowAbortable(
|
|
||||||
channels.sound.apply(this.signature.sound, config.sound.gain, this.abortController.signal)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (this.signature.activeChannels.includes('color') && config.color.enabled && this.signature.color) {
|
|
||||||
channelPromises.push(
|
|
||||||
swallowAbortable(
|
|
||||||
channels.color.apply(this.ctx.targetEl, this.signature.color, this.abortController.signal)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
this.signature.activeChannels.includes('presence') &&
|
|
||||||
config.presence.enabled &&
|
|
||||||
this.signature.presence
|
|
||||||
) {
|
|
||||||
channelPromises.push(
|
|
||||||
swallowAbortable(
|
|
||||||
channels.presence.apply(this.ctx.targetEl, this.signature.presence, this.abortController.signal)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channelPromises.length === 0) {
|
|
||||||
this.settle()
|
|
||||||
return this.cancelled ? 'cancelled' : 'completed'
|
|
||||||
}
|
|
||||||
|
|
||||||
const outcome = await Promise.race([
|
|
||||||
Promise.all(channelPromises).then(() => 'completed' as const),
|
|
||||||
delay(this.capMs).then(() => 'completed' as const),
|
|
||||||
new Promise<'cancelled'>((resolve) => {
|
|
||||||
if (this.abortController.signal.aborted) {
|
|
||||||
resolve('cancelled')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
this.abortController.signal.addEventListener(
|
continue
|
||||||
'abort',
|
}
|
||||||
() => resolve('cancelled'),
|
el.setAttribute(write.attr, write.value)
|
||||||
{ once: true }
|
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 {
|
return applied
|
||||||
if (!engineInstance) {
|
|
||||||
engineInstance = new SemaEngine()
|
|
||||||
}
|
}
|
||||||
return engineInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSemaEngine(): SemaEngine {
|
|
||||||
return getEngine()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function configureSema(config: EngineConfigPatch): void {
|
|
||||||
getEngine().configure(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function destroySema(): void {
|
|
||||||
if (!engineInstance) return
|
|
||||||
engineInstance.destroy()
|
|
||||||
engineInstance = null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function _resetEngineForTesting(): void {
|
|
||||||
destroySema()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,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 {
|
export type {
|
||||||
|
SemaValencedFamily,
|
||||||
|
SemaTransitionalFamily,
|
||||||
|
SemaFamily,
|
||||||
|
SemaIntent,
|
||||||
|
SemaMode,
|
||||||
|
SemaRegime,
|
||||||
|
SemaScope,
|
||||||
|
SemaCause,
|
||||||
SemaEventLabel,
|
SemaEventLabel,
|
||||||
|
SemaIntentBinding,
|
||||||
|
SemaEvent,
|
||||||
|
SemaActionEvent,
|
||||||
SemaAttrWrite,
|
SemaAttrWrite,
|
||||||
SemaCommit,
|
SemaCommit
|
||||||
SemaAction,
|
} from './types'
|
||||||
SemaSustainDecl,
|
|
||||||
SemaSpec
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export { validateSema, SemaInvariantError } from './validation';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
SemaFamilyName,
|
|
||||||
SemaIntentName,
|
|
||||||
SemaActiveChannel,
|
|
||||||
MotionSignature,
|
|
||||||
SoundSignature,
|
|
||||||
ColorSignature,
|
|
||||||
PresenceSignature,
|
|
||||||
EffectiveSignature,
|
|
||||||
SemaMap,
|
|
||||||
RuntimeOverrides,
|
|
||||||
CSEMSelectorOverride,
|
|
||||||
CSEMOverrides,
|
|
||||||
ResolverOptions
|
|
||||||
} from './resolver';
|
|
||||||
|
|
||||||
export { Resolver, defaultSemaMap } from './resolver';
|
|
||||||
|
|
||||||
export type { PartialSemaContext, ActionName, SustainName, SemaBinding } from './binding';
|
|
||||||
|
|
||||||
export { createSemaBinding } from './binding';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
SemaRuntimeConfig,
|
|
||||||
MediaQueryListLike,
|
|
||||||
A11ySnapshot,
|
|
||||||
A11yMonitorOptions
|
|
||||||
} from './a11y';
|
|
||||||
|
|
||||||
export { A11yMonitor, DEFAULT_SEMA_RUNTIME_CONFIG } from './a11y';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
SemaEventDetail,
|
|
||||||
MotionChannelDriver,
|
|
||||||
SoundChannelDriver,
|
|
||||||
ColorChannelDriver,
|
|
||||||
PresenceChannelDriver,
|
|
||||||
SemaChannelDrivers,
|
|
||||||
EngineDependencies,
|
|
||||||
EngineConfig,
|
|
||||||
EngineConfigPatch
|
|
||||||
} from './engine';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SemaEngine,
|
SEMA_VALENCED_FAMILIES,
|
||||||
getSemaEngine,
|
SEMA_TRANSITIONAL_FAMILIES,
|
||||||
configureSema,
|
SEMA_INTENTS,
|
||||||
destroySema,
|
SEMA_EVENT_LABELS,
|
||||||
_resetEngineForTesting
|
isSemaFamily,
|
||||||
} from './engine';
|
isSemaValencedFamily,
|
||||||
|
isSemaTransitionalFamily,
|
||||||
export { MotionChannel } from './channels/motion';
|
isSemaIntent,
|
||||||
export { ColorChannel } from './channels/color';
|
isSemaEventLabel,
|
||||||
export { PresenceChannel } from './channels/presence';
|
isSemaIntentBinding,
|
||||||
export { SoundChannel } from './channels/sound';
|
isSemaEvent,
|
||||||
|
parseSemaEventLabel,
|
||||||
|
resolveSemaIntent,
|
||||||
|
normalizeSemaEvent,
|
||||||
|
toSemaEventLabel
|
||||||
|
} from './event'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ResolvedSemaAction,
|
SemanticEventDecl,
|
||||||
ResolvedSemaSustain,
|
SemanticComponentContract,
|
||||||
SemaContext,
|
SemanticPublishContext,
|
||||||
SemaSession,
|
PublishedSemanticEvent,
|
||||||
SemaPort,
|
SemanticEventFilter
|
||||||
TestSemaPortOptions,
|
} from './engine'
|
||||||
TestSemaCall,
|
|
||||||
TestSemaSession,
|
export { SemanticEngine } from './engine'
|
||||||
TestSemaPortHandle
|
|
||||||
} from './port';
|
|
||||||
|
|
||||||
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 @@
|
|||||||
/**
|
import { isSemaEvent, isSemaEventLabel, isSemaIntent, isSemaIntentBinding } from './event'
|
||||||
* Sema invariants validator.
|
import type { SemaActionEvent, SemaIntentBinding } from './types'
|
||||||
*
|
|
||||||
* Sema es autónoma. Los invariantes internos (nombres únicos, eventos
|
|
||||||
* canónicos) se validan sin morfo. Cuando se pasa morfo como contexto
|
|
||||||
* opcional, se añaden los cross-checks (parts, data[], states[],
|
|
||||||
* data-last-action.values[]).
|
|
||||||
*
|
|
||||||
* El validador recibe el morfo por su **forma estructural** (el tipo
|
|
||||||
* `MorfoContext` de abajo), no por su tipo `Morfo`. Así sema sigue sin
|
|
||||||
* depender del módulo morfo a nivel de tipos: cualquier valor que tenga
|
|
||||||
* `kebab` + `parts` servirá. En la práctica el llamador pasa un `Morfo`
|
|
||||||
* y TypeScript lo acepta por compatibilidad estructural.
|
|
||||||
*
|
|
||||||
* Importante: `validateSema()` asume que el `spec` ya está tipado por
|
|
||||||
* TypeScript (`as const satisfies SemaSpec`). A diferencia de
|
|
||||||
* `validateMorfo()`, no hace decode completo del shape runtime; valida
|
|
||||||
* invariantes semánticos y referencias cruzadas sobre entrada tipada.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SemaSpec, SemaEventLabel } from './types';
|
|
||||||
|
|
||||||
// Estructura mínima que el validador necesita del morfo para hacer los
|
|
||||||
// cross-checks. Redeclarada aquí (no importada de morfo) para que sema
|
|
||||||
// no tenga dependencia de tipos con morfo.
|
|
||||||
interface MorfoPartLike {
|
|
||||||
kebab: string;
|
|
||||||
states?: readonly string[];
|
|
||||||
data: readonly { attr: string; values?: readonly string[] }[];
|
|
||||||
parts?: readonly MorfoPartLike[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MorfoContext {
|
|
||||||
kebab: string;
|
|
||||||
parts: readonly MorfoPartLike[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Thrown when a sema invariant fails. */
|
|
||||||
export class SemaInvariantError extends Error {
|
export class SemaInvariantError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message)
|
||||||
this.name = 'SemaInvariantError';
|
this.name = 'SemaInvariantError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Los 22 eventos canónicos. Debe mantenerse en sync con `SemaEventLabel`. */
|
export function validateSemaIntentBinding(binding: SemaIntentBinding, ctx = 'sema.intent'): void {
|
||||||
const SEMA_EVENT_LABELS = new Set<SemaEventLabel>([
|
if (!isSemaIntentBinding(binding)) {
|
||||||
'contact-neutral',
|
throw new SemaInvariantError(`${ctx} is not a valid SemaIntentBinding`)
|
||||||
'contact-threat',
|
|
||||||
'contact-risk',
|
|
||||||
'contact-affirm',
|
|
||||||
'contact-fulfill',
|
|
||||||
'commit-neutral',
|
|
||||||
'commit-threat',
|
|
||||||
'commit-risk',
|
|
||||||
'commit-affirm',
|
|
||||||
'commit-fulfill',
|
|
||||||
'alert-neutral',
|
|
||||||
'alert-threat',
|
|
||||||
'alert-risk',
|
|
||||||
'alert-affirm',
|
|
||||||
'alert-fulfill',
|
|
||||||
'handle-neutral',
|
|
||||||
'handle-threat',
|
|
||||||
'handle-risk',
|
|
||||||
'handle-affirm',
|
|
||||||
'handle-fulfill',
|
|
||||||
'emerge',
|
|
||||||
'sustain'
|
|
||||||
]);
|
|
||||||
|
|
||||||
function flattenParts(parts: readonly MorfoPartLike[]): MorfoPartLike[] {
|
|
||||||
const out: MorfoPartLike[] = [];
|
|
||||||
for (const p of parts) {
|
|
||||||
out.push(p);
|
|
||||||
if (p.parts && p.parts.length > 0) out.push(...flattenParts(p.parts));
|
|
||||||
}
|
}
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const supported = binding.supported ?? []
|
||||||
* Valida los invariantes de un `SemaSpec`.
|
if (supported.length === 0) return
|
||||||
*
|
|
||||||
* **Invariantes internos** (siempre):
|
|
||||||
* 1. `name` único dentro del spec.
|
|
||||||
* 2. `event` pertenece al vocabulario canónico.
|
|
||||||
*
|
|
||||||
* **Invariantes cross-morfo** (sólo si se pasa `morfo`):
|
|
||||||
* 3. `spec.kebab === morfo.kebab`.
|
|
||||||
* 4. `action.target.target` resuelve a una parte del morfo.
|
|
||||||
* 5. `prewrite[].part.target` resuelve.
|
|
||||||
* 6. `prewrite[].attr` existe en `data[]` del part destino.
|
|
||||||
* 7. `prewrite[].value` ∈ `values[]` si el attr es enumerable.
|
|
||||||
* 8. `commits.part.target` resuelve.
|
|
||||||
* 9. `commits.value` ∈ `states[]` si `commits.attr === 'data-state'`.
|
|
||||||
* 10. `data-last-action.values[]` == unión de prewrites que escriben a ese
|
|
||||||
* attr (ambas direcciones).
|
|
||||||
* 11. `sustains[].target.target` y `sustains[].activeWhen.part.target` resuelven.
|
|
||||||
*/
|
|
||||||
export function validateSema(spec: SemaSpec, morfo?: MorfoContext): void {
|
|
||||||
// 1. Nombres únicos (siempre).
|
|
||||||
const actionNames = new Set<string>();
|
|
||||||
for (const action of spec.actions) {
|
|
||||||
if (actionNames.has(action.name)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`sema: duplicate action name "${action.name}" in "${spec.kebab}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
actionNames.add(action.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Eventos canónicos (siempre).
|
for (const intent of supported) {
|
||||||
for (const action of spec.actions) {
|
if (!isSemaIntent(intent)) {
|
||||||
if (!SEMA_EVENT_LABELS.has(action.event)) {
|
throw new SemaInvariantError(`${ctx}: intent "${String(intent)}" is not a valid SemaIntent`)
|
||||||
throw new SemaInvariantError(
|
|
||||||
`sema.actions["${action.name}"]: event "${action.event}" is not a valid SemaEventLabel`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resto depende de tener contexto de morfo.
|
if (!supported.includes(binding.default)) {
|
||||||
if (!morfo) return;
|
|
||||||
|
|
||||||
// 3. kebab coincide.
|
|
||||||
if (morfo.kebab !== spec.kebab) {
|
|
||||||
throw new SemaInvariantError(
|
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);
|
export function validateSemaEvent(event: SemaActionEvent, ctx = 'sema.event'): void {
|
||||||
const kebabs = new Set<string>();
|
if (isSemaEventLabel(event)) return
|
||||||
const partByKebab = new Map<string, MorfoPartLike>();
|
|
||||||
for (const part of flat) {
|
|
||||||
kebabs.add(part.kebab);
|
|
||||||
partByKebab.set(part.kebab, part);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prewriteDLAByPart = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
for (const action of spec.actions) {
|
|
||||||
const ctx = `sema.actions["${action.name}"]`;
|
|
||||||
|
|
||||||
// 4. target.
|
|
||||||
if (!kebabs.has(action.target.target)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`${ctx}: target "${action.target.target}" does not match any part in "${morfo.kebab}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5-7. prewrites.
|
|
||||||
for (const pw of action.prewrite ?? []) {
|
|
||||||
const pwCtx = `${ctx}.prewrite[${pw.attr}]`;
|
|
||||||
if (!kebabs.has(pw.part.target)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`${pwCtx}: part "${pw.part.target}" does not match any part in "${morfo.kebab}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const targetPart = partByKebab.get(pw.part.target);
|
|
||||||
if (!targetPart) continue;
|
|
||||||
const dataEntry = targetPart.data.find((d) => d.attr === pw.attr);
|
|
||||||
if (!dataEntry) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`${pwCtx}: attr "${pw.attr}" not declared in part "${pw.part.target}"'s data[] (declare it before referencing)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dataEntry.values && !dataEntry.values.includes(pw.value)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`${pwCtx}: value "${pw.value}" not in declared values [${dataEntry.values.join(', ')}]`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (pw.attr === 'data-last-action') {
|
|
||||||
const s = prewriteDLAByPart.get(pw.part.target) ?? new Set<string>();
|
|
||||||
s.add(pw.value);
|
|
||||||
prewriteDLAByPart.set(pw.part.target, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8-9. commits.
|
if (!isSemaEvent(event)) {
|
||||||
if (action.commits) {
|
throw new SemaInvariantError(`${ctx} is not a valid canonical semantic event`)
|
||||||
const cCtx = `${ctx}.commits`;
|
|
||||||
if (!kebabs.has(action.commits.part.target)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`${cCtx}: part "${action.commits.part.target}" does not match any part in "${morfo.kebab}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (action.commits.attr === 'data-state') {
|
|
||||||
const targetPart = partByKebab.get(action.commits.part.target);
|
|
||||||
const states = targetPart?.states ?? [];
|
|
||||||
if (!states.includes(action.commits.value)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`${cCtx}: value "${action.commits.value}" not in states of "${action.commits.part.target}" (declared: ${states.join(', ') || '∅'})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. data-last-action.values[] == unión de prewrites que lo escriben.
|
if (!('intent' in event) || typeof event.intent === 'string') return
|
||||||
for (const part of flat) {
|
|
||||||
const partKebab = part.kebab;
|
|
||||||
const dla = part.data.find((d) => d.attr === 'data-last-action');
|
|
||||||
if (!dla?.values) continue;
|
|
||||||
const written = prewriteDLAByPart.get(partKebab) ?? new Set<string>();
|
|
||||||
const declared = new Set(dla.values);
|
|
||||||
for (const v of written) {
|
|
||||||
if (!declared.has(v)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`sema: prewrite writes "${v}" to data-last-action on "${partKebab}", but the part's values[] does not include it (declared: ${[...declared].join(', ')})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const v of declared) {
|
|
||||||
if (!written.has(v)) {
|
|
||||||
throw new SemaInvariantError(
|
|
||||||
`sema: part "${partKebab}" declares data-last-action value "${v}" but no sema action prewrites it — values[] must equal the union of prewrites`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. sustains.
|
validateSemaIntentBinding(event.intent, `${ctx}.intent`)
|
||||||
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`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
* Keeps the current `ScrollLock` API for providers while delegating the
|
||||||
* When all instances unlock, body style is restored after a configurable delay.
|
* actual body-lock implementation to the higher DOM runtime layer.
|
||||||
*
|
|
||||||
* ## Usage
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* readonly scrollLock = new ScrollLock();
|
|
||||||
* // later: this.scrollLock.locked.current = true;
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { BodyScrollLock, type BodyScrollLockOption } from '$uix/adom'
|
||||||
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);
|
|
||||||
|
|
||||||
const hasStableGutter =
|
export type ScrollLockOption = BodyScrollLockOption
|
||||||
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 class ScrollLock extends BodyScrollLock {
|
||||||
constructor(
|
constructor(
|
||||||
initialState?: boolean,
|
initialState?: boolean,
|
||||||
private readonly restoreScrollDelay: () => number | null = () => null
|
restoreScrollDelay: () => number | null = () => null
|
||||||
) {
|
) {
|
||||||
ensureGlobalWatch();
|
super(initialState, restoreScrollDelay)
|
||||||
cancelPendingCleanup();
|
|
||||||
ensureInitialStyleCaptured();
|
|
||||||
lockMap.set(this.id, initialState ?? false);
|
|
||||||
|
|
||||||
this.locked = writableActive(
|
|
||||||
() => lockMap.get(this.id) ?? false,
|
|
||||||
(v: boolean) => lockMap.set(this.id, v)
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => () => {
|
$effect(() => () => {
|
||||||
lockMap.delete(this.id);
|
this.destroy()
|
||||||
if (isAnyLocked(lockMap)) return;
|
})
|
||||||
scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), resetBodyStyle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static reset() {
|
|
||||||
lockMap.clear();
|
|
||||||
cancelPendingCleanup();
|
|
||||||
resetBodyStyle();
|
|
||||||
initialBodyStyle = null;
|
|
||||||
isInCleanupTransition = false;
|
|
||||||
cleanupScheduledAt = null;
|
|
||||||
watchInitialized = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in new issue