From a0a1485b9f1d3eb3ddfd94b2a689710ab26ee400 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 24 Apr 2026 03:56:29 +0200 Subject: [PATCH] Refactor sema and add shared dom runtime --- src/lib/ext/app/app.svelte.ts | 7 +- src/lib/ext/app/defaults.ts | 4 + src/lib/ext/app/index.ts | 3 +- src/lib/ext/app/test/app.test.ts | 7 + src/lib/ext/app/types.ts | 10 + src/routes/test/soma/toast/+page.svelte | 73 +- src/uix/CONTINUITY_2026-04-24.md | 341 ++ src/uix/README.md | 370 ++ src/uix/adom/README.md | 167 + src/uix/adom/active-dom.svelte.ts | 52 + src/uix/adom/active-dom.test.ts | 50 + src/uix/adom/body-scroll-lock.svelte.ts | 201 ++ src/uix/adom/body-scroll-lock.test.ts | 65 + src/uix/adom/dom-context.svelte.ts | 83 + src/uix/adom/dom-context.test.ts | 56 + src/uix/adom/index.ts | 4 + src/uix/adom/roving-focus-group.svelte.ts | 161 + src/uix/adom/roving-focus-group.test.ts | 119 + src/uix/lib/dom/core.test.ts | 71 + src/uix/lib/dom/core.ts | 146 + src/uix/lib/dom/elements.ts | 9 + src/uix/lib/dom/focus.test.ts | 59 + src/uix/lib/dom/focus.ts | 114 + src/uix/lib/dom/index.ts | 7 + src/uix/lib/dom/locale.test.ts | 27 + src/uix/lib/dom/locale.ts | 8 + src/uix/lib/dom/resize-observer.svelte.ts | 58 + src/uix/lib/dom/resize-observer.test.ts | 102 + src/uix/lib/dom/responsive.svelte.ts | 80 + src/uix/lib/dom/responsive.test.ts | 35 + src/uix/lib/dom/tabbable.ts | 49 + src/uix/morfo/PROVIDER_STUDY_2026-04-23.md | 506 +++ src/uix/morfo/README.md | 11 +- src/uix/morfo/components/accordion.ts | 40 +- src/uix/morfo/components/dialog.test.ts | 247 +- src/uix/morfo/components/dialog.ts | 178 +- src/uix/morfo/components/toast.test.ts | 96 + src/uix/morfo/components/toast.ts | 74 +- src/uix/morfo/index.ts | 5 + src/uix/morfo/schema.ts | 265 +- src/uix/morfo/types.ts | 146 +- src/uix/sema/README.md | 43 + src/uix/sema/a11y.test.ts | 199 -- src/uix/sema/a11y.ts | 278 -- src/uix/sema/binding.test.ts | 236 -- src/uix/sema/binding.ts | 203 -- src/uix/sema/channels/color.test.ts | 218 -- src/uix/sema/channels/color.ts | 261 -- src/uix/sema/channels/motion.test.ts | 129 - src/uix/sema/channels/motion.ts | 125 - src/uix/sema/channels/presence.test.ts | 199 -- src/uix/sema/channels/presence.ts | 256 -- src/uix/sema/channels/sound.test.ts | 215 -- src/uix/sema/channels/sound.ts | 322 -- src/uix/sema/engine.test.ts | 416 +-- src/uix/sema/engine.ts | 694 +--- src/uix/sema/event.ts | 174 + src/uix/sema/exports.ts | 122 +- src/uix/sema/port.test.ts | 129 - src/uix/sema/port.ts | 181 - src/uix/sema/resolver.test.ts | 258 -- src/uix/sema/resolver.ts | 323 -- src/uix/sema/sema-map.json | 210 -- .../sema/sema-runtime-architecture _v01.md | 2979 ----------------- src/uix/sema/sema-spec-v0.4.md | 973 ------ src/uix/sema/semauix-sema-impl.md | 308 -- src/uix/sema/types.ts | 204 +- src/uix/sema/validation.ts | 238 +- .../accordion/accordion-provider.svelte.ts | 84 +- .../dialog/dialog-provider.svelte.ts | 85 +- src/uix/soma/components/toast/README.md | 48 +- src/uix/soma/components/toast/exports.ts | 2 +- .../components/toast/toast-provider.svelte.ts | 70 +- .../soma/components/toast/toaster.svelte.ts | 76 +- src/uix/soma/core/soma.svelte.ts | 4 + src/uix/soma/layers/scroll-lock.svelte.ts | 176 +- src/uix/soma/provider/provider.svelte.ts | 167 +- 77 files changed, 4736 insertions(+), 9975 deletions(-) create mode 100644 src/uix/CONTINUITY_2026-04-24.md create mode 100644 src/uix/README.md create mode 100644 src/uix/adom/README.md create mode 100644 src/uix/adom/active-dom.svelte.ts create mode 100644 src/uix/adom/active-dom.test.ts create mode 100644 src/uix/adom/body-scroll-lock.svelte.ts create mode 100644 src/uix/adom/body-scroll-lock.test.ts create mode 100644 src/uix/adom/dom-context.svelte.ts create mode 100644 src/uix/adom/dom-context.test.ts create mode 100644 src/uix/adom/index.ts create mode 100644 src/uix/adom/roving-focus-group.svelte.ts create mode 100644 src/uix/adom/roving-focus-group.test.ts create mode 100644 src/uix/lib/dom/core.test.ts create mode 100644 src/uix/lib/dom/core.ts create mode 100644 src/uix/lib/dom/elements.ts create mode 100644 src/uix/lib/dom/focus.test.ts create mode 100644 src/uix/lib/dom/focus.ts create mode 100644 src/uix/lib/dom/index.ts create mode 100644 src/uix/lib/dom/locale.test.ts create mode 100644 src/uix/lib/dom/locale.ts create mode 100644 src/uix/lib/dom/resize-observer.svelte.ts create mode 100644 src/uix/lib/dom/resize-observer.test.ts create mode 100644 src/uix/lib/dom/responsive.svelte.ts create mode 100644 src/uix/lib/dom/responsive.test.ts create mode 100644 src/uix/lib/dom/tabbable.ts create mode 100644 src/uix/morfo/PROVIDER_STUDY_2026-04-23.md create mode 100644 src/uix/morfo/components/toast.test.ts create mode 100644 src/uix/sema/README.md delete mode 100644 src/uix/sema/a11y.test.ts delete mode 100644 src/uix/sema/a11y.ts delete mode 100644 src/uix/sema/binding.test.ts delete mode 100644 src/uix/sema/binding.ts delete mode 100644 src/uix/sema/channels/color.test.ts delete mode 100644 src/uix/sema/channels/color.ts delete mode 100644 src/uix/sema/channels/motion.test.ts delete mode 100644 src/uix/sema/channels/motion.ts delete mode 100644 src/uix/sema/channels/presence.test.ts delete mode 100644 src/uix/sema/channels/presence.ts delete mode 100644 src/uix/sema/channels/sound.test.ts delete mode 100644 src/uix/sema/channels/sound.ts create mode 100644 src/uix/sema/event.ts delete mode 100644 src/uix/sema/port.test.ts delete mode 100644 src/uix/sema/port.ts delete mode 100644 src/uix/sema/resolver.test.ts delete mode 100644 src/uix/sema/resolver.ts delete mode 100644 src/uix/sema/sema-map.json delete mode 100644 src/uix/sema/sema-runtime-architecture _v01.md delete mode 100644 src/uix/sema/sema-spec-v0.4.md delete mode 100644 src/uix/sema/semauix-sema-impl.md diff --git a/src/lib/ext/app/app.svelte.ts b/src/lib/ext/app/app.svelte.ts index f114e4fc9..f9e38db9c 100644 --- a/src/lib/ext/app/app.svelte.ts +++ b/src/lib/ext/app/app.svelte.ts @@ -1,6 +1,8 @@ import { Context } from 'runed'; +import { createActiveDom } from '$uix/adom'; import type { AppOptions, + AppDom, AppLangs, AppNums, AppMoney, @@ -12,7 +14,7 @@ import type { DateOrder, HourCycle } from './types'; -import { fallbackLangs, fallbackPresentation, consoleLogger } from './defaults'; +import { fallbackDom, fallbackLangs, fallbackPresentation, consoleLogger } from './defaults'; /** * App — root service compositor. @@ -27,6 +29,7 @@ import { fallbackLangs, fallbackPresentation, consoleLogger } from './defaults'; */ export class App { // ── Services ──────────────────────────────────────────────────────── + readonly dom: AppDom; readonly langs: AppLangs; readonly nums: AppNums | undefined; readonly money: AppMoney | undefined; @@ -36,6 +39,7 @@ export class App { readonly logger: AppLogger; constructor(opts: AppOptions) { + this.dom = opts.dom ?? createActiveDom(); this.langs = opts.langs; this.nums = opts.nums; this.money = opts.money; @@ -122,6 +126,7 @@ const _ctx = new Context('App'); * components must handle their absence gracefully. */ const _fallback = new App({ + dom: fallbackDom, langs: fallbackLangs, presentation: fallbackPresentation, logger: consoleLogger diff --git a/src/lib/ext/app/defaults.ts b/src/lib/ext/app/defaults.ts index a43df93c4..fe5aaeef3 100644 --- a/src/lib/ext/app/defaults.ts +++ b/src/lib/ext/app/defaults.ts @@ -1,3 +1,4 @@ +import { createActiveDom } from '$uix/adom'; import type { AppLangs, AppPresentation, AppLogger } from './types'; /** No-op langs: returns path as-is, no locale switching */ @@ -35,3 +36,6 @@ export const fallbackPresentation: AppPresentation = { setDensity: () => {}, onPreferenceChange: () => () => {} }; + +/** Minimal dom runtime: default breakpoints + responsive helpers. */ +export const fallbackDom = createActiveDom({}); diff --git a/src/lib/ext/app/index.ts b/src/lib/ext/app/index.ts index 33b4f5855..e99a78bea 100644 --- a/src/lib/ext/app/index.ts +++ b/src/lib/ext/app/index.ts @@ -1,9 +1,10 @@ export { App } from './app.svelte'; -export { fallbackLangs, fallbackPresentation, consoleLogger } from './defaults'; +export { fallbackDom, fallbackLangs, fallbackPresentation, consoleLogger } from './defaults'; export type { AppServices, AppOptions, + AppDom, AppLangs, AppNums, AppMoney, diff --git a/src/lib/ext/app/test/app.test.ts b/src/lib/ext/app/test/app.test.ts index bdb1f6161..f1c63f202 100644 --- a/src/lib/ext/app/test/app.test.ts +++ b/src/lib/ext/app/test/app.test.ts @@ -167,6 +167,13 @@ describe('App', () => { expect(app.logger).toBe(consoleLogger); }); + it('provides a dom runtime even when none is injected', () => { + const langs = mockLangs('es'); + const app = new App({ langs, presentation: fallbackPresentation }); + expect(app.dom.currentBreakpoint.current).toBe('base'); + expect(app.dom.resolve({ base: 'stack', lg: 'inline' })).toBe('stack'); + }); + it('uses provided logger', () => { const langs = mockLangs('es'); const customLogger = { diff --git a/src/lib/ext/app/types.ts b/src/lib/ext/app/types.ts index 35885ffac..9bbbab31c 100644 --- a/src/lib/ext/app/types.ts +++ b/src/lib/ext/app/types.ts @@ -1,3 +1,5 @@ +import type { ActiveDom } from '$uix/adom'; + // ── Direction ──────────────────────────────────────────────────────────────── export type Direction = 'ltr' | 'rtl'; @@ -159,6 +161,11 @@ export interface AppPresentation { onPreferenceChange(fn: () => void): () => void; } +// ── AppDom ─────────────────────────────────────────────────────────────────── + +/** DOM runtime contract. Implements viewport + breakpoints + responsive helpers. */ +export type AppDom = ActiveDom; + // ── AppLogger ──────────────────────────────────────────────────────────────── /** Logging contract. logr implements this directly. */ @@ -176,6 +183,8 @@ export interface AppLogger { // ── AppServices ────────────────────────────────────────────────────────────── export interface AppServices { + /** DOM runtime — viewport, breakpoints, responsive helpers */ + readonly dom: AppDom; /** i18n — locale, translation, interpolation, module extension */ readonly langs: AppLangs; /** Numbers — formatting, parsing, separator preferences */ @@ -197,6 +206,7 @@ export interface AppServices { // ── AppOptions (constructor input) ─────────────────────────────────────────── export interface AppOptions { + dom?: AppDom; langs: AppLangs; nums?: AppNums; money?: AppMoney; diff --git a/src/routes/test/soma/toast/+page.svelte b/src/routes/test/soma/toast/+page.svelte index 5cf244ce6..21b4e0efd 100644 --- a/src/routes/test/soma/toast/+page.svelte +++ b/src/routes/test/soma/toast/+page.svelte @@ -11,29 +11,51 @@ toaster.create({ title: `Toast #${count}`, description: 'This is a default toast.' }); } - function addSuccess() { - toaster.success({ title: 'Saved', description: 'Your changes have been saved.' }); + function addAffirm() { + toaster.create({ + title: 'Affirmed', + description: 'The system acknowledged the action.', + intent: 'affirm' + }); } - function addError() { - toaster.error({ title: 'Error', description: 'Something went wrong. Please try again.' }); + function addFulfill() { + toaster.create({ + title: 'Saved', + description: 'Your changes have been saved.', + intent: 'fulfill' + }); } - function addWarning() { - toaster.warning({ title: 'Warning', description: 'This action cannot be undone.' }); + function addRisk() { + toaster.create({ + title: 'Warning', + description: 'This action cannot be undone.', + intent: 'risk' + }); } - function addInfo() { - toaster.info({ title: 'Info', description: 'A new version is available.' }); + function addThreat() { + toaster.create({ + title: 'Error', + description: 'Something went wrong. Please try again.', + intent: 'threat' + }); } function addWithAction() { toaster.create({ title: 'File deleted', description: 'report.pdf was moved to trash.', + intent: 'risk', action: { label: 'Undo', - onClick: () => toaster.info({ title: 'Restored', description: 'File restored.' }) + onClick: () => + toaster.create({ + title: 'Restored', + description: 'File restored.', + intent: 'affirm' + }) } }); } @@ -47,9 +69,10 @@ } function addLoading() { - toaster.loading({ + toaster.create({ title: 'Uploading...', - description: 'Please wait while we process your file.' + description: 'Please wait while we process your file.', + loading: true }); } @@ -59,8 +82,11 @@ ); toaster.promise(fakeAsync, { loading: { title: 'Uploading...', description: 'Processing file...' }, - success: (name) => ({ title: 'Uploaded', description: `${name} uploaded successfully.` }), - error: () => ({ title: 'Upload failed', description: 'Please try again.' }) + fulfill: (name: string) => ({ + title: 'Uploaded', + description: `${name} uploaded successfully.` + }), + threat: () => ({ title: 'Upload failed', description: 'Please try again.' }) }); } @@ -81,10 +107,10 @@

Create Toasts

- - - - + + + + @@ -196,19 +222,22 @@ transform: translateX(0); transition: transform 200ms ease; } - :global([data-toast-item][data-type='loading']) { + :global([data-toast-item][data-loading]) { border-left: 4px solid #94a3b8; } - :global([data-toast-item][data-type='success']) { + :global([data-toast-item][data-intent='affirm']) { + border-left: 4px solid #14b8a6; + } + :global([data-toast-item][data-intent='fulfill']) { border-left: 4px solid #22c55e; } - :global([data-toast-item][data-type='error']) { + :global([data-toast-item][data-intent='threat']) { border-left: 4px solid #ef4444; } - :global([data-toast-item][data-type='warning']) { + :global([data-toast-item][data-intent='risk']) { border-left: 4px solid #f59e0b; } - :global([data-toast-item][data-type='info']) { + :global([data-toast-item][data-intent='neutral']) { border-left: 4px solid #3b82f6; } diff --git a/src/uix/CONTINUITY_2026-04-24.md b/src/uix/CONTINUITY_2026-04-24.md new file mode 100644 index 000000000..d42c61500 --- /dev/null +++ b/src/uix/CONTINUITY_2026-04-24.md @@ -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 + +``` + +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`**. diff --git a/src/uix/README.md b/src/uix/README.md new file mode 100644 index 000000000..0a66ff6db --- /dev/null +++ b/src/uix/README.md @@ -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. diff --git a/src/uix/adom/README.md b/src/uix/adom/README.md new file mode 100644 index 000000000..22603f542 --- /dev/null +++ b/src/uix/adom/README.md @@ -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 + viewport: { width: number } + currentBreakpoint: Active + resolve(value: ResponsiveProp | 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. diff --git a/src/uix/adom/active-dom.svelte.ts b/src/uix/adom/active-dom.svelte.ts new file mode 100644 index 000000000..c541516d2 --- /dev/null +++ b/src/uix/adom/active-dom.svelte.ts @@ -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 | undefined> +} + +export type ActiveDom = { + breakpoints: Active + viewport: typeof viewport + currentBreakpoint: Active + resolve(value: ResponsiveProp | 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(value: ResponsiveProp | 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 + } + } +} diff --git a/src/uix/adom/active-dom.test.ts b/src/uix/adom/active-dom.test.ts new file mode 100644 index 000000000..e33ce7468 --- /dev/null +++ b/src/uix/adom/active-dom.test.ts @@ -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') + }) +}) diff --git a/src/uix/adom/body-scroll-lock.svelte.ts b/src/uix/adom/body-scroll-lock.svelte.ts new file mode 100644 index 000000000..60f7dec36 --- /dev/null +++ b/src/uix/adom/body-scroll-lock.svelte.ts @@ -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() + +let initialBodyStyle: string | null = $state(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): boolean { + for (const [, value] of map) { + if (value) return true + } + return false +} + +function getLockedCount(map: Map): 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 + + 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 + } +} diff --git a/src/uix/adom/body-scroll-lock.test.ts b/src/uix/adom/body-scroll-lock.test.ts new file mode 100644 index 000000000..df2d201ad --- /dev/null +++ b/src/uix/adom/body-scroll-lock.test.ts @@ -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;') + }) +}) diff --git a/src/uix/adom/dom-context.svelte.ts b/src/uix/adom/dom-context.svelte.ts new file mode 100644 index 000000000..7ccde7133 --- /dev/null +++ b/src/uix/adom/dom-context.svelte.ts @@ -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 | State | ElementGetter + +function canUseDom(): boolean { + return typeof document !== 'undefined' +} + +function getDefaultProvider(): Document | null { + return canUseDom() ? document : null +} + +export class DOMContext { + readonly element: Active + readonly provider = readableActive(() => { + 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) + } + + 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(id: string): T | null { + const provider = this.provider.current + if (!provider || !('getElementById' in provider)) return null + return provider.getElementById(id) as T | null + } + + querySelector = (selector: string): T | null => { + const provider = this.provider.current + if (!provider) return null + return provider.querySelector(selector) as T | null + } + + querySelectorAll = (selector: string): NodeListOf => { + const provider = this.provider.current + if (!provider) return [] as unknown as NodeListOf + return provider.querySelectorAll(selector) as NodeListOf + } + + setTimeout = (callback: () => void, delay: number): ReturnType => { + return this.getWindow().setTimeout(callback, delay) + } + + clearTimeout = (timeoutId: ReturnType) => { + this.getWindow().clearTimeout(timeoutId) + } +} diff --git a/src/uix/adom/dom-context.test.ts b/src/uix/adom/dom-context.test.ts new file mode 100644 index 000000000..98b045fcb --- /dev/null +++ b/src/uix/adom/dom-context.test.ts @@ -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() + }) +}) diff --git a/src/uix/adom/index.ts b/src/uix/adom/index.ts new file mode 100644 index 000000000..58881bc83 --- /dev/null +++ b/src/uix/adom/index.ts @@ -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' diff --git a/src/uix/adom/roving-focus-group.svelte.ts b/src/uix/adom/roving-focus-group.svelte.ts new file mode 100644 index 000000000..23712e76b --- /dev/null +++ b/src/uix/adom/roving-focus-group.svelte.ts @@ -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 | State + loop: Active + orientation: Active + 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(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(this.opts.candidateSelector) + ) + } + + if (this.opts.candidateAttr) { + return Array.from( + this.opts.providerNode.current.querySelectorAll( + `[${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> = { + [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() + } +} diff --git a/src/uix/adom/roving-focus-group.test.ts b/src/uix/adom/roving-focus-group.test.ts new file mode 100644 index 000000000..2ea796735 --- /dev/null +++ b/src/uix/adom/roving-focus-group.test.ts @@ -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) + }) +}) diff --git a/src/uix/lib/dom/core.test.ts b/src/uix/lib/dom/core.test.ts new file mode 100644 index 000000000..ad495a957 --- /dev/null +++ b/src/uix/lib/dom/core.test.ts @@ -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) + }) +}) diff --git a/src/uix/lib/dom/core.ts b/src/uix/lib/dom/core.ts new file mode 100644 index 000000000..7a615ac8a --- /dev/null +++ b/src/uix/lib/dom/core.ts @@ -0,0 +1,146 @@ +const ELEMENT_NODE = 1 +const DOCUMENT_NODE = 9 +const DOCUMENT_FRAGMENT_NODE = 11 + +function isObject(value: unknown): value is Record { + 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 +} diff --git a/src/uix/lib/dom/elements.ts b/src/uix/lib/dom/elements.ts new file mode 100644 index 000000000..8e76db63e --- /dev/null +++ b/src/uix/lib/dom/elements.ts @@ -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) +} diff --git a/src/uix/lib/dom/focus.test.ts b/src/uix/lib/dom/focus.test.ts new file mode 100644 index 000000000..351cc7d1b --- /dev/null +++ b/src/uix/lib/dom/focus.test.ts @@ -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) + }) +}) diff --git a/src/uix/lib/dom/focus.ts b/src/uix/lib/dom/focus.ts new file mode 100644 index 000000000..8add34e47 --- /dev/null +++ b/src/uix/lib/dom/focus.ts @@ -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 +} diff --git a/src/uix/lib/dom/index.ts b/src/uix/lib/dom/index.ts new file mode 100644 index 000000000..bcba8bec8 --- /dev/null +++ b/src/uix/lib/dom/index.ts @@ -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' diff --git a/src/uix/lib/dom/locale.test.ts b/src/uix/lib/dom/locale.test.ts new file mode 100644 index 000000000..c3f3d63c1 --- /dev/null +++ b/src/uix/lib/dom/locale.test.ts @@ -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') + }) +}) diff --git a/src/uix/lib/dom/locale.ts b/src/uix/lib/dom/locale.ts new file mode 100644 index 000000000..a258274b0 --- /dev/null +++ b/src/uix/lib/dom/locale.ts @@ -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 diff --git a/src/uix/lib/dom/resize-observer.svelte.ts b/src/uix/lib/dom/resize-observer.svelte.ts new file mode 100644 index 000000000..9bb72843a --- /dev/null +++ b/src/uix/lib/dom/resize-observer.svelte.ts @@ -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 + } +} diff --git a/src/uix/lib/dom/resize-observer.test.ts b/src/uix/lib/dom/resize-observer.test.ts new file mode 100644 index 000000000..cf6a8d31d --- /dev/null +++ b/src/uix/lib/dom/resize-observer.test.ts @@ -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[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) + }) +}) diff --git a/src/uix/lib/dom/responsive.svelte.ts b/src/uix/lib/dom/responsive.svelte.ts new file mode 100644 index 000000000..892d2c474 --- /dev/null +++ b/src/uix/lib/dom/responsive.svelte.ts @@ -0,0 +1,80 @@ +export type Breakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + +export type ResponsiveProp = T | Partial> + +export const BREAKPOINTS_DEFAULT: Record = { + base: 0, + sm: 480, + md: 768, + lg: 1024, + xl: 1280, + xxl: 1536 +} + +export type Breakpoints = Record + +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( + value: ResponsiveProp | undefined +): value is Partial> { + 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( + value: ResponsiveProp | 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 +} diff --git a/src/uix/lib/dom/responsive.test.ts b/src/uix/lib/dom/responsive.test.ts new file mode 100644 index 000000000..3af2cf0fb --- /dev/null +++ b/src/uix/lib/dom/responsive.test.ts @@ -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() + }) +}) diff --git a/src/uix/lib/dom/tabbable.ts b/src/uix/lib/dom/tabbable.ts new file mode 100644 index 000000000..b11e9d82f --- /dev/null +++ b/src/uix/lib/dom/tabbable.ts @@ -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 + ) +} diff --git a/src/uix/morfo/PROVIDER_STUDY_2026-04-23.md b/src/uix/morfo/PROVIDER_STUDY_2026-04-23.md new file mode 100644 index 000000000..59f4b718e --- /dev/null +++ b/src/uix/morfo/PROVIDER_STUDY_2026-04-23.md @@ -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.** diff --git a/src/uix/morfo/README.md b/src/uix/morfo/README.md index 24c296612..4e13fe1fb 100644 --- a/src/uix/morfo/README.md +++ b/src/uix/morfo/README.md @@ -2,7 +2,7 @@ **The cross-layer contract of a component's public DOM surface.** -Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, and focus policy. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables). +Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, focus policy, and public event contract. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables). **One file per component**, at `src/uix/morfo/components/{kebab}.ts`. No prose — that's the component's README. No props — those live in `types.ts` with JSDoc. Just the machine-readable contract. @@ -16,6 +16,7 @@ Without morfo, a component's structural information lives in many places: - Data-attr enums in `registerContract({ parts: {...} })` also in the provider. - ARIA emission hardcoded in the provider's `$derived.by(...)` props. - Keyboard handlers scattered across the provider. +- Public event names and their transport split across provider code, docs, and consumers. - Prose descriptions in the README. - Selector strings duplicated in eidos CSS, sema `.csem`, docs tables. @@ -34,12 +35,13 @@ A `Morfo` is a plain TypeScript constant that describes: - **`scope`** — which layers implement this component: `['soma']`, `['soma', 'eidos']`, etc. - **`apg`** — optional URL to the WAI-ARIA APG pattern when the component implements a formal one. - **`focus`** — optional focus policy for overlays / composites. +- **`events`** — the component's public event surface: which semantic occurrences it may emit and expose to cross-layer consumers. - **`parts`** — the part tree (recursive). Each part declares: - `name`, `kebab`, `kind` (`public` / `virtual`). - `defaultElement` (advisory), `role` (always-emitted). - `optional`, `supportsNesting`. - `states` — the state names this part can be in. - - `data` — data-attributes emitted, with enum values when applicable. + - `data` — data-attributes emitted, with enum values when applicable and optional runtime source metadata when the contract wants to declare where the attr comes from. - `aria` — ARIA attribute contract (attr + value source + condition). - `keyboard` — keyboard shortcuts relevant when the part has focus. - `parts` — nested sub-parts (recursive). @@ -55,7 +57,7 @@ See [`types.ts`](./types.ts) for the full TypeScript shape. | Usage examples | `{component}/README.md` | Narrative | | Props (names, types, defaults) | `{component}/types.ts` with JSDoc | Canonical source is TS + JSDoc | | Translations | `{component}/langs.ts` (idlangref) | Separate registry, consumed by the provider | -| Event handlers, state machines | `{component}-provider.svelte.ts` | Code, not data | +| Event handlers / runtime wiring, state machines | `{component}-provider.svelte.ts` | Execution logic, not contract data | | Visual variants / recipes | `src/uix/eidos/` (future) | Layer-specific, not shared | --- @@ -188,6 +190,9 @@ data: [ // Enum-valued: the complete set. { attr: 'data-state', values: ['open', 'closed'] }, + // Same attr, but now declaring its runtime source too. + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, + // Presence-only flag: emitted only when true, absent otherwise. { attr: 'data-disabled', severity: 'optional' }, diff --git a/src/uix/morfo/components/accordion.ts b/src/uix/morfo/components/accordion.ts index 5d8d79100..3adf6db16 100644 --- a/src/uix/morfo/components/accordion.ts +++ b/src/uix/morfo/components/accordion.ts @@ -14,8 +14,12 @@ export const accordionMorfo = { defaultElement: 'div', optional: false, data: [ - { attr: 'data-orientation', values: ['horizontal', 'vertical'] }, - { attr: 'data-disabled', severity: 'optional' } + { + attr: 'data-orientation', + values: ['horizontal', 'vertical'], + value: v.propRef('orientation') + }, + { attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' } ], aria: [] }, @@ -27,9 +31,13 @@ export const accordionMorfo = { optional: false, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-disabled', severity: 'optional' }, - { attr: 'data-orientation', values: ['horizontal', 'vertical'] } + { + attr: 'data-orientation', + values: ['horizontal', 'vertical'], + value: v.propRef('orientation') + } ], aria: [] }, @@ -42,9 +50,13 @@ export const accordionMorfo = { optional: false, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-disabled', severity: 'optional' }, - { attr: 'data-orientation', values: ['horizontal', 'vertical'] } + { + attr: 'data-orientation', + values: ['horizontal', 'vertical'], + value: v.propRef('orientation') + } ], aria: [{ attr: 'aria-level', value: v.propRef('level') }] }, @@ -57,9 +69,13 @@ export const accordionMorfo = { optional: false, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-disabled', severity: 'optional' }, - { attr: 'data-orientation', values: ['horizontal', 'vertical'] } + { + attr: 'data-orientation', + values: ['horizontal', 'vertical'], + value: v.propRef('orientation') + } ], aria: [ { attr: 'type', value: v.literal('button') }, @@ -89,9 +105,13 @@ export const accordionMorfo = { optional: false, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-disabled', severity: 'optional' }, - { attr: 'data-orientation', values: ['horizontal', 'vertical'] }, + { + attr: 'data-orientation', + values: ['horizontal', 'vertical'], + value: v.propRef('orientation') + }, { attr: 'data-starting-style', severity: 'optional' }, { attr: 'data-ending-style', severity: 'optional' } ], diff --git a/src/uix/morfo/components/dialog.test.ts b/src/uix/morfo/components/dialog.test.ts index 65a098947..aa03c289a 100644 --- a/src/uix/morfo/components/dialog.test.ts +++ b/src/uix/morfo/components/dialog.test.ts @@ -1,81 +1,28 @@ -import { describe, it, expect } from 'vitest'; -import { validateMorfo, MorfoInvariantError } from '../schema'; -import type { Morfo } from '../types'; -import { validateSema, SemaInvariantError } from '../../sema/validation'; -import type { SemaSpec, SemaAction, SemaEventLabel } from '../../sema/types'; -import { dialogMorfo, dialogSema } from './dialog'; - -// Authored as `as const satisfies Morfo` / `satisfies SemaSpec`. Tests below -// clone into mutable shapes to deliberately corrupt fields. -function cloneMorfo(m: typeof dialogMorfo): Morfo { - return structuredClone(m as Morfo) as Morfo; -} +import { describe, expect, it } from 'vitest' + +import { validateMorfo, MorfoInvariantError } from '../schema' +import type { Morfo, MorfoEvent } from '../types' +import { dialogMorfo } from './dialog' -function cloneSema(s: typeof dialogSema): SemaSpec { - return structuredClone(s as SemaSpec) as SemaSpec; +function cloneMorfo(m: typeof dialogMorfo): Morfo { + return structuredClone(m as Morfo) as Morfo } describe('dialogMorfo', () => { it('passes shape + invariant validation', () => { - expect(() => validateMorfo(dialogMorfo)).not.toThrow(); - }); + expect(() => validateMorfo(dialogMorfo)).not.toThrow() + }) it('declares the 7 parts the provider emits', () => { - const kebabs = dialogMorfo.parts.map((p) => p.kebab).sort(); + const kebabs = dialogMorfo.parts.map((p) => p.kebab).sort() expect(kebabs).toEqual( ['close', 'content', 'description', 'overlay', 'provider', 'title', 'trigger'].sort() - ); - }); - - it('declares data-last-action on Content for Sema causal exits', () => { - const content = dialogMorfo.parts.find((p) => p.kebab === 'content')!; - const causal = content.data.find((d) => d.attr === 'data-last-action'); - expect(causal).toBeDefined(); - expect(causal!.values).toContain('saved'); - expect(causal!.values).toContain('cancelled'); - }); - - it('fails validation when a partRef targets a non-existent kebab', () => { - const broken = cloneMorfo(dialogMorfo); - const trigger = broken.parts.find((p) => p.kebab === 'trigger')!; - const controls = trigger.aria.find((a) => a.attr === 'aria-controls')!; - (controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part'; - expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError); - }); - - it('fails validation when stateRef refers to a state not declared in the part', () => { - const broken = cloneMorfo(dialogMorfo); - const trigger = broken.parts.find((p) => p.kebab === 'trigger')!; - const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')!; - (expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state'; - expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError); - }); - - it('fails validation when two parts share the same kebab', () => { - const broken = cloneMorfo(dialogMorfo); - (broken.parts as unknown as { kebab: string }[])[1].kebab = 'content'; - expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError); - }); + ) + }) - it('fails validation with empty scope', () => { - const broken = cloneMorfo(dialogMorfo); - (broken as unknown as { scope: [] }).scope = []; - expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError); - }); -}); - -describe('dialogSema', () => { - it('passes sema invariants standalone', () => { - expect(() => validateSema(dialogSema)).not.toThrow(); - }); - - it('passes sema invariants with morfo context (cross-ref)', () => { - expect(() => validateSema(dialogSema, dialogMorfo)).not.toThrow(); - }); - - it('declares six canonical semantic actions', () => { - const actions = dialogSema.actions.map((a) => a.name).sort(); - expect(actions).toEqual( + it('declares six semantic events directly in morfo', () => { + const events = dialogMorfo.events?.map((event) => event.name).sort() + expect(events).toEqual( [ 'open', 'close-save', @@ -84,76 +31,98 @@ describe('dialogSema', () => { 'close-dismiss-outside', 'close-after-fail' ].sort() - ); - }); - - it('fails when an action targets a non-existent part (with morfo ctx)', () => { - const broken = cloneSema(dialogSema); - (broken.actions as SemaAction[])[0].target.target = 'no-such-part'; - expect(() => validateSema(broken, dialogMorfo)).toThrow(SemaInvariantError); - expect(() => validateSema(broken, dialogMorfo)).toThrow(/target "no-such-part"/); - }); - - it('fails when an action uses a non-canonical event label (no morfo needed)', () => { - const broken = cloneSema(dialogSema); - (broken.actions as SemaAction[])[0].event = 'commit-fulfil' as unknown as SemaEventLabel; - expect(() => validateSema(broken)).toThrow(/not a valid SemaEventLabel/); - }); - - it('fails when a prewrite attr is not declared in the target part data[]', () => { - const broken = cloneSema(dialogSema); - const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!; - action.prewrite![0].attr = 'data-bogus'; - expect(() => validateSema(broken, dialogMorfo)).toThrow(/data-bogus.*not declared/); - }); + ) + }) + + it('declares data-last-action on Content for causal exits', () => { + const content = dialogMorfo.parts.find((p) => p.kebab === 'content')! + const causal = content.data.find((d) => d.attr === 'data-last-action') + expect(causal).toBeDefined() + expect(causal!.values).toContain('saved') + expect(causal!.values).toContain('cancelled') + }) + + it('can declare runtime sources for data-* attrs', () => { + const trigger = dialogMorfo.parts.find((p) => p.kebab === 'trigger')! + const state = trigger.data.find((d) => d.attr === 'data-state') + expect(state?.value).toEqual({ kind: 'stateRef', state: 'open' }) + }) + + it('fails validation when a partRef targets a non-existent kebab', () => { + const broken = cloneMorfo(dialogMorfo) + const trigger = broken.parts.find((p) => p.kebab === 'trigger')! + const controls = trigger.aria.find((a) => a.attr === 'aria-controls')! + ;(controls.value as { kind: 'partRef'; target: string }).target = 'nonexistent-part' + expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) + }) + + it('fails validation when stateRef refers to a state not declared in the part', () => { + const broken = cloneMorfo(dialogMorfo) + const trigger = broken.parts.find((p) => p.kebab === 'trigger')! + const expanded = trigger.aria.find((a) => a.attr === 'aria-expanded')! + ;(expanded.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state' + expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) + }) + + it('fails validation when data.value.stateRef refers to an undeclared state', () => { + const broken = cloneMorfo(dialogMorfo) + const trigger = broken.parts.find((p) => p.kebab === 'trigger')! + const state = trigger.data.find((d) => d.attr === 'data-state')! + ;(state.value as { kind: 'stateRef'; state: string }).state = 'nonexistent-state' + expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) + }) + + it('fails validation when two parts share the same kebab', () => { + const broken = cloneMorfo(dialogMorfo) + ;(broken.parts as unknown as { kebab: string }[])[1].kebab = 'content' + expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) + }) + + it('fails validation with empty scope', () => { + const broken = cloneMorfo(dialogMorfo) + ;(broken as unknown as { scope: [] }).scope = [] + expect(() => validateMorfo(broken)).toThrow(MorfoInvariantError) + }) + + it('fails when an event targets a non-existent part', () => { + const broken = cloneMorfo(dialogMorfo) + ;(broken.events as MorfoEvent[])[0].target.target = 'no-such-part' + expect(() => validateMorfo(broken)).toThrow(/targets unknown part/) + }) + + it('fails when a prewrite attr is not declared on the target part', () => { + const broken = cloneMorfo(dialogMorfo) + const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')! + action.prewrite![0].attr = 'data-bogus' + expect(() => validateMorfo(broken)).toThrow(/data-bogus.*is not declared/) + }) it('fails when a prewrite writes a value outside the declared enum', () => { - const broken = cloneSema(dialogSema); - const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!; - action.prewrite![0].value = 'not-in-enum'; - expect(() => validateSema(broken, dialogMorfo)).toThrow(/not-in-enum.*not in declared values/); - }); - - it('fails when commits targets a non-existent state', () => { - const broken = cloneSema(dialogSema); - const action = (broken.actions as SemaAction[]).find((a) => a.name === 'close-save')!; - action.commits!.value = 'zombied'; - expect(() => validateSema(broken, dialogMorfo)).toThrow(/zombied.*not in states/); - }); - - it('fails when two actions share the same name (no morfo needed)', () => { - const broken = cloneSema(dialogSema); - (broken.actions as SemaAction[])[1].name = 'open'; - expect(() => validateSema(broken)).toThrow(/duplicate action name "open"/); - }); - - it('fails when data-last-action has a declared value no action prewrites', () => { - const brokenMorfo = cloneMorfo(dialogMorfo); - const content = brokenMorfo.parts.find((p) => p.kebab === 'content')!; - const dla = content.data.find((d) => d.attr === 'data-last-action')!; - (dla.values as string[]) = [...dla.values!, 'orphan-value']; - expect(() => validateSema(dialogSema, brokenMorfo)).toThrow( - /orphan-value.*no sema action prewrites/ - ); - }); - - it('fails when another part declares data-last-action values but no action ever prewrites it', () => { - const brokenMorfo = cloneMorfo(dialogMorfo); - const trigger = brokenMorfo.parts.find((p) => p.kebab === 'trigger')!; - (trigger.data as { attr: string; values?: readonly string[] }[]).push({ - attr: 'data-last-action', - values: ['ghost-action'] - }); - expect(() => validateSema(dialogSema, brokenMorfo)).toThrow( - /ghost-action.*no sema action prewrites/ - ); - }); - - it('fails when spec.kebab disagrees with morfo.kebab', () => { - const broken = cloneSema(dialogSema); - broken.kebab = 'not-dialog'; - expect(() => validateSema(broken, dialogMorfo)).toThrow( - /spec.kebab "not-dialog" does not match morfo.kebab "dialog"/ - ); - }); -}); + const broken = cloneMorfo(dialogMorfo) + const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')! + action.prewrite![0].value = 'not-in-enum' + expect(() => validateMorfo(broken)).toThrow(/not-in-enum.*is not declared/) + }) + + it('fails when an event commits a non-existent state', () => { + const broken = cloneMorfo(dialogMorfo) + const action = (broken.events as MorfoEvent[]).find((event) => event.name === 'close-save')! + if (!action.commits) throw new Error('close-save commits missing') + action.commits.value = 'zombied' + expect(() => validateMorfo(broken)).toThrow(/zombied.*not declared/) + }) + + it('fails when two events share the same name', () => { + const broken = cloneMorfo(dialogMorfo) + ;(broken.events as MorfoEvent[])[1].name = 'open' + expect(() => validateMorfo(broken)).toThrow(/duplicate event name "open"/) + }) + + it('fails when data-last-action declares a value that no event prewrites', () => { + const broken = cloneMorfo(dialogMorfo) + const content = broken.parts.find((part) => part.kebab === 'content')! + const dataLastAction = content.data.find((entry) => entry.attr === 'data-last-action')! + ;(dataLastAction.values as string[]) = [...(dataLastAction.values ?? []), 'orphan-value'] + expect(() => validateMorfo(broken)).toThrow(/orphan-value.*no event prewrites/) + }) +}) diff --git a/src/uix/morfo/components/dialog.ts b/src/uix/morfo/components/dialog.ts index 8849e06d8..d92fadf16 100644 --- a/src/uix/morfo/components/dialog.ts +++ b/src/uix/morfo/components/dialog.ts @@ -11,13 +11,92 @@ import type { Morfo } from '../types'; import { v } from '../types'; -import type { SemaSpec } from '../../sema/types'; export const dialogMorfo = { name: 'Dialog', kebab: 'dialog', scope: ['soma', 'sema'], apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/', + events: [ + { + name: 'open', + target: v.partRef('content'), + semantic: { family: 'emerge' }, + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'open' + } + }, + { + name: 'close-save', + target: v.partRef('content'), + semantic: { + family: 'commit', + intent: 'fulfill' + }, + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-cancel', + target: v.partRef('content'), + semantic: { family: 'emerge' }, + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-dismiss', + target: v.partRef('content'), + semantic: { family: 'emerge' }, + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-dismiss-outside', + target: v.partRef('content'), + semantic: { family: 'emerge' }, + regime: 'lock', + prewrite: [ + { part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' } + ], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + }, + { + name: 'close-after-fail', + target: v.partRef('content'), + semantic: { + family: 'alert', + intent: 'threat' + }, + regime: 'lock', + prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }], + commits: { + part: v.partRef('content'), + attr: 'data-state', + value: 'closed' + } + } + ], focus: { initial: 'first-focusable', @@ -35,8 +114,8 @@ export const dialogMorfo = { optional: false, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, - { attr: 'data-disabled', severity: 'optional' } + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, + { attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' } ], aria: [] }, @@ -48,7 +127,7 @@ export const dialogMorfo = { role: 'button', optional: false, states: ['open', 'closed'], - data: [{ attr: 'data-state', values: ['open', 'closed'] }], + data: [{ attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }], aria: [ { attr: 'type', value: v.literal('button') }, { attr: 'aria-haspopup', value: v.literal('dialog') }, @@ -66,7 +145,7 @@ export const dialogMorfo = { supportsNesting: true, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { /** * Sema alignment (sema_pre.md §9): causal exit reason. Updated @@ -137,7 +216,7 @@ export const dialogMorfo = { supportsNesting: true, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-nested', severity: 'optional' }, { attr: 'data-nested-open', severity: 'optional' }, { @@ -205,90 +284,3 @@ export const dialogMorfo = { } ] } as const satisfies Morfo; - -/** - * Dialog sema declaration. - * - * Seis acciones: un `open` y cinco variantes de cierre. Cada cierre - * prewrites `data-last-action` antes del commit de `data-state`, de modo - * que la capa visual pueda tintar la salida según la razón causal. Todos - * los cierres usan `lock` — un diálogo en cierre no debe re-entrarse a - * mitad de coreografía. - */ -export const dialogSema = { - kebab: 'dialog', - actions: [ - { - name: 'open', - target: v.partRef('content'), - event: 'emerge', - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'open' - } - }, - { - name: 'close-save', - target: v.partRef('content'), - event: 'commit-fulfill', - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'saved' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-cancel', - target: v.partRef('content'), - event: 'emerge', - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'cancelled' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-dismiss', - target: v.partRef('content'), - event: 'emerge', - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-dismiss-outside', - target: v.partRef('content'), - event: 'emerge', - regime: 'lock', - prewrite: [ - { part: v.partRef('content'), attr: 'data-last-action', value: 'dismissed-outside' } - ], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - }, - { - name: 'close-after-fail', - target: v.partRef('content'), - event: 'alert-threat', - regime: 'lock', - prewrite: [{ part: v.partRef('content'), attr: 'data-last-action', value: 'failed' }], - commits: { - part: v.partRef('content'), - attr: 'data-state', - value: 'closed' - } - } - ] -} as const satisfies SemaSpec; diff --git a/src/uix/morfo/components/toast.test.ts b/src/uix/morfo/components/toast.test.ts new file mode 100644 index 000000000..1b23d0bcd --- /dev/null +++ b/src/uix/morfo/components/toast.test.ts @@ -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"/) + }) +}) diff --git a/src/uix/morfo/components/toast.ts b/src/uix/morfo/components/toast.ts index 5226c2c7e..b4682dda5 100644 --- a/src/uix/morfo/components/toast.ts +++ b/src/uix/morfo/components/toast.ts @@ -5,6 +5,30 @@ export const toastMorfo = { name: 'Toast', kebab: 'toast', scope: ['soma'], + events: [ + { + name: 'present', + target: v.partRef('item'), + semantic: { family: 'emerge' } + }, + { + name: 'announce', + target: v.partRef('item'), + semantic: { + family: 'alert', + intent: { + fromProp: 'intent', + default: 'neutral', + supported: ['neutral', 'affirm', 'fulfill', 'risk', 'threat'] + } + } + }, + { + name: 'dismiss', + target: v.partRef('item'), + semantic: { family: 'emerge' } + } + ], parts: [ { name: 'Provider', @@ -26,7 +50,7 @@ export const toastMorfo = { aria: [ { attr: 'aria-label', - value: v.translationRef('#?components.toast.viewport|Notifications'), + value: v.propRef('label'), severity: 'recommended' }, { attr: 'aria-live', value: v.literal('polite') } @@ -37,18 +61,51 @@ export const toastMorfo = { kebab: 'item', kind: 'public', defaultElement: 'div', - role: 'status', optional: false, states: ['open', 'closed'], data: [ - { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, { attr: 'data-starting-style', severity: 'optional' }, { attr: 'data-ending-style', severity: 'optional' }, - { attr: 'data-type', values: ['default', 'info', 'success', 'warning', 'error', 'loading'] }, - { attr: 'data-swipe', values: ['start', 'move', 'cancel', 'end'] } + { + attr: 'data-intent', + values: ['neutral', 'affirm', 'fulfill', 'risk', 'threat'], + value: v.propRef('intent') + }, + { attr: 'data-loading', severity: 'optional', value: v.propRef('loading') }, + { + attr: 'data-swipe', + values: ['start', 'move', 'cancel', 'end'], + value: v.propRef('swipeState') + }, + { + attr: 'data-swipe-direction', + values: ['up', 'down', 'left', 'right'], + value: v.propRef('swipeDirection') + } ], aria: [ - { attr: 'aria-live', value: v.propRef('priority'), severity: 'recommended' }, + { + attr: 'role', + value: v.mapRef(v.propRef('intent'), { + neutral: 'status', + affirm: 'status', + fulfill: 'status', + risk: 'alert', + threat: 'alert' + }) + }, + { + attr: 'aria-live', + value: v.mapRef(v.propRef('intent'), { + neutral: 'polite', + affirm: 'polite', + fulfill: 'polite', + risk: 'assertive', + threat: 'assertive' + }), + severity: 'recommended' + }, { attr: 'aria-atomic', value: v.literal('true') }, { attr: 'aria-labelledby', @@ -91,7 +148,10 @@ export const toastMorfo = { role: 'button', optional: true, data: [], - aria: [{ attr: 'type', value: v.literal('button') }] + aria: [ + { attr: 'type', value: v.literal('button') }, + { attr: 'aria-label', value: v.propRef('altText'), severity: 'recommended' } + ] }, { name: 'Close', diff --git a/src/uix/morfo/index.ts b/src/uix/morfo/index.ts index 097c5b7c6..cc5581305 100644 --- a/src/uix/morfo/index.ts +++ b/src/uix/morfo/index.ts @@ -10,7 +10,9 @@ // Consumers import them directly from there — no re-export here. export type { MorfoElement, + MorfoValueSource, MorfoAriaValue, + MorfoDataValue, MorfoCondition, MorfoSeverity, MorfoPartKind, @@ -18,6 +20,9 @@ export type { MorfoAriaEntry, MorfoKeyboard, MorfoFocus, + MorfoSemanticIntent, + MorfoEventSemantic, + MorfoEvent, MorfoPart, Morfo } from './types'; diff --git a/src/uix/morfo/schema.ts b/src/uix/morfo/schema.ts index 11ff886ab..3b420c339 100644 --- a/src/uix/morfo/schema.ts +++ b/src/uix/morfo/schema.ts @@ -21,10 +21,9 @@ * handled by a manual walker that calls `morfoPartSchema.decode()` for * every sub-part. Swap to `lazy()` when sium ships it. * - * This module is **layer-agnostic**: it knows about parts, ARIA, data, - * focus, keyboard — nothing about sema / eidos. Downstream layers ship - * their own deep validators (`sema/validation.ts`, …) and are called - * independently of `validateMorfo`. + * This module validates Morfo as the component contract source of truth: + * parts, ARIA, data, focus, keyboard, and component-authored semantic + * events. It still knows nothing about eidos recipes or runtime engines. */ import { @@ -42,7 +41,8 @@ import { SiumValidationError } from '$lib/sium/core'; import type { Morfo, MorfoPart, - MorfoAriaValue, + MorfoPrimitiveValueSource, + MorfoValueSource, MorfoCondition, MorfoElement } from './types'; @@ -101,15 +101,34 @@ const severitySchema = union( const partKindSchema = union(literal('public'), literal('virtual')); -// ── MorfoAriaValue (tagged union) ───────────────────────────────────────── +// ── MorfoValueSource (tagged union) ─────────────────────────────────────── -const ariaValueSchema = discriminated('kind', [ +const primitiveValueSourceSchema = discriminated('kind', [ object({ kind: literal('literal'), value: string() }), object({ kind: literal('stateRef'), state: string() }), object({ kind: literal('partRef'), target: string() }), object({ kind: literal('propRef'), prop: string() }), object({ kind: literal('translationRef'), key: string() }) -]) as Schema; +]) as Schema; + +const stringMapSchema = object({}, { unknownKeys: 'passthrough' }) as unknown as Schema< + Record, + Record +>; + +const valueSourceSchema = discriminated('kind', [ + object({ kind: literal('literal'), value: string() }), + object({ kind: literal('stateRef'), state: string() }), + object({ kind: literal('partRef'), target: string() }), + object({ kind: literal('propRef'), prop: string() }), + object({ kind: literal('translationRef'), key: string() }), + object({ + kind: literal('mapRef'), + source: primitiveValueSourceSchema, + map: stringMapSchema, + fallback: optional(string()) + }) +]) as Schema; // ── MorfoCondition (tagged union with 'always' literal + objects) ───────── @@ -134,13 +153,14 @@ const conditionSchema = union(literal('always'), conditionObjectSchema) as Schem const dataSchema = object({ attr: string(), values: optional(array(string())), + value: optional(valueSourceSchema), condition: optional(conditionSchema), severity: optional(severitySchema) }); const ariaEntrySchema = object({ attr: string(), - value: ariaValueSchema, + value: valueSourceSchema, condition: optional(conditionSchema), severity: optional(severitySchema) }); @@ -151,6 +171,72 @@ const keyboardSchema = object({ condition: optional(conditionSchema) }); +// ── Semantic events ─────────────────────────────────────────────────────── + +const semaIntentSchema = union( + literal('threat'), + literal('risk'), + literal('neutral'), + literal('affirm'), + literal('fulfill') +) + +const semaTransitionalFamilySchema = union(literal('emerge'), literal('sustain')) +const semaValencedFamilySchema = union( + literal('contact'), + literal('commit'), + literal('alert'), + literal('handle') +) + +const semanticIntentSchema = object({ + fromProp: optional(string()), + default: semaIntentSchema, + supported: array(semaIntentSchema) +}) + +const eventSemanticSchema = union( + object({ + family: semaTransitionalFamilySchema + }), + object({ + family: semaValencedFamilySchema, + intent: union(semaIntentSchema, semanticIntentSchema) + }) +) + +const attrWriteSchema = object({ + part: object({ + kind: literal('partRef'), + target: string() + }), + attr: string(), + value: string() +}) + +const commitSchema = object({ + part: object({ + kind: literal('partRef'), + target: string() + }), + attr: string(), + value: string() +}) + +const eventSchema = object({ + name: string(), + target: object({ + kind: literal('partRef'), + target: string() + }), + semantic: eventSemanticSchema, + mode: optional(union(literal('blocking'), literal('advisory'))), + regime: optional(union(literal('replace'), literal('collapse'), literal('lock'), literal('queue'))), + scope: optional(union(literal('part'), literal('component'), literal('scene'))), + prewrite: optional(array(attrWriteSchema)), + commits: optional(commitSchema) +}) + // ── MorfoFocus ──────────────────────────────────────────────────────────── const focusTargetSchema = union( @@ -197,6 +283,7 @@ const morfoShallowSchema = object( scope: array(layerSchema), apg: optional(string()), focus: optional(focusSchema), + events: optional(array(eventSchema)), parts: array(object({}, { unknownKeys: 'passthrough' })) // ^ parts are opaque here; walker recurses with `partShallowSchema` }, @@ -304,24 +391,57 @@ function validateInvariants(morfo: Morfo): void { kebabs.add(part.kebab); } - for (const { part, path } of flat) { - for (const ariaEntry of part.aria) { - const v = ariaEntry.value; - if (v.kind === 'partRef' && !kebabs.has(v.target)) { - throw new MorfoInvariantError( - `aria[${ariaEntry.attr}].partRef "${v.target}" does not match any part in "${morfo.kebab}"`, - path - ); + const validateValueSource = ( + value: MorfoValueSource, + part: MorfoPart, + path: ReadonlyArray, + context: string + ) => { + if (value.kind === 'mapRef') { + if (Object.keys(value.map).length === 0) { + throw new MorfoInvariantError(`${context}.mapRef must declare at least one mapping`, path); } - if (v.kind === 'stateRef') { - const states = part.states ?? []; - if (!states.includes(v.state)) { + + for (const [sourceValue, mappedValue] of Object.entries(value.map)) { + if (typeof mappedValue !== 'string') { throw new MorfoInvariantError( - `aria[${ariaEntry.attr}].stateRef "${v.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`, + `${context}.mapRef["${sourceValue}"] must resolve to a string`, path ); } } + + validateValueSource(value.source, part, path, `${context}.mapRef.source`); + return; + } + + if (value.kind === 'partRef' && !kebabs.has(value.target)) { + throw new MorfoInvariantError( + `${context}.partRef "${value.target}" does not match any part in "${morfo.kebab}"`, + path + ); + } + + if (value.kind === 'stateRef') { + const states = part.states ?? []; + if (!states.includes(value.state)) { + throw new MorfoInvariantError( + `${context}.stateRef "${value.state}" is not in part.states of "${part.kebab}" (declared: ${states.join(', ') || '∅'})`, + path + ); + } + } + }; + + for (const { part, path } of flat) { + for (const dataEntry of part.data) { + const v = dataEntry.value; + if (!v) continue; + validateValueSource(v, part, path, `data[${dataEntry.attr}]`); + } + + for (const ariaEntry of part.aria) { + validateValueSource(ariaEntry.value, part, path, `aria[${ariaEntry.attr}]`); } const checkCondition = (c: MorfoCondition | undefined, context: string) => { @@ -367,6 +487,105 @@ function validateInvariants(morfo: Morfo): void { ); } } + + const events = morfo.events ?? [] + const eventNames = new Set() + const partByKebab = new Map(flat.map(({ part }) => [part.kebab, part] as const)) + const prewriteDLAByPart = new Map>() + + for (const event of events) { + if (eventNames.has(event.name)) { + throw new MorfoInvariantError( + `duplicate event name "${event.name}" in morfo "${morfo.kebab}"` + ) + } + eventNames.add(event.name) + + if (!kebabs.has(event.target.target)) { + throw new MorfoInvariantError( + `event "${event.name}" targets unknown part "${event.target.target}"` + ) + } + + if ('intent' in event.semantic && typeof event.semantic.intent === 'object') { + if (event.semantic.intent.supported.length === 0) { + throw new MorfoInvariantError( + `event "${event.name}" must declare at least one supported intent` + ) + } + if (!event.semantic.intent.supported.includes(event.semantic.intent.default)) { + throw new MorfoInvariantError( + `event "${event.name}" default intent "${event.semantic.intent.default}" must be included in supported intents` + ) + } + } + + for (const write of event.prewrite ?? []) { + if (!kebabs.has(write.part.target)) { + throw new MorfoInvariantError( + `event "${event.name}" prewrite targets unknown part "${write.part.target}"` + ) + } + const targetPart = partByKebab.get(write.part.target) + const dataEntry = targetPart?.data.find((d) => d.attr === write.attr) + if (!dataEntry) { + throw new MorfoInvariantError( + `event "${event.name}" prewrite attr "${write.attr}" is not declared on part "${write.part.target}"` + ) + } + if (dataEntry.values && !dataEntry.values.includes(write.value)) { + throw new MorfoInvariantError( + `event "${event.name}" prewrite value "${write.value}" is not declared for "${write.attr}"` + ) + } + if (write.attr === 'data-last-action') { + const values = prewriteDLAByPart.get(write.part.target) ?? new Set() + values.add(write.value) + prewriteDLAByPart.set(write.part.target, values) + } + } + + if (event.commits) { + if (!kebabs.has(event.commits.part.target)) { + throw new MorfoInvariantError( + `event "${event.name}" commits unknown part "${event.commits.part.target}"` + ) + } + if (event.commits.attr === 'data-state') { + const targetPart = partByKebab.get(event.commits.part.target) + const states = targetPart?.states ?? [] + if (!states.includes(event.commits.value)) { + throw new MorfoInvariantError( + `event "${event.name}" commits state "${event.commits.value}" not declared on part "${event.commits.part.target}"` + ) + } + } + } + } + + for (const part of flat.map(({ part }) => part)) { + const dataLastAction = part.data.find((entry) => entry.attr === 'data-last-action') + if (!dataLastAction?.values) continue + + const declared = new Set(dataLastAction.values) + const written = prewriteDLAByPart.get(part.kebab) ?? new Set() + + for (const value of written) { + if (!declared.has(value)) { + throw new MorfoInvariantError( + `part "${part.kebab}" is prewritten with data-last-action="${value}" but the attr does not declare it in values[]` + ) + } + } + + for (const value of declared) { + if (!written.has(value)) { + throw new MorfoInvariantError( + `part "${part.kebab}" declares data-last-action value "${value}" but no event prewrites it` + ) + } + } + } } // ── Public API ──────────────────────────────────────────────────────────── @@ -378,9 +597,7 @@ function validateInvariants(morfo: Morfo): void { * Intended for build-time / dev-time. Run once per morfo on first load; * results are cacheable. * - * This validator knows nothing about Sema. Components that declare a - * `sema` extension must call `validateSema(morfo)` from `../sema/validation` - * in addition. + * This validator also checks `morfo.events` when present. */ export function validateMorfo(morfo: unknown): Morfo { morfoShallowSchema.decodeSync(morfo as never); diff --git a/src/uix/morfo/types.ts b/src/uix/morfo/types.ts index d3a740bab..4a97cc81f 100644 --- a/src/uix/morfo/types.ts +++ b/src/uix/morfo/types.ts @@ -2,8 +2,9 @@ * Morfo — cross-layer component contract. * * A morfo is the single machine-readable source of truth for a component's - * **public DOM surface**: the parts it exposes, the data-attrs it emits, the - * ARIA contract it honours, the keyboard contract, and the focus policy. + * **public component contract**: the parts it exposes, the data-attrs it emits, + * the ARIA contract it honours, the keyboard contract, the focus policy, and + * the semantic events it declares. * * Consumers: * - soma providers consume morfo via `createAttrs(morfo)` + `registerContract(morfo)` @@ -11,7 +12,7 @@ * - sema reads the DOM surface that morfo declares (see sema_pre.md) * - docs render morfo as part / data / aria / keyboard tables * - * Morfo is the DOM-surface contract. It does NOT contain prose (README), + * Morfo is the structural + semantic component contract. It does NOT contain prose (README), * props (types.ts + JSDoc), provider behaviour (*-provider.svelte.ts), state * machines, translations (langs.ts idlangref), or eidos recipes. * @@ -21,6 +22,16 @@ */ import type { Layer, PartRef } from '../lib/types'; +import type { + SemaCommit, + SemaAttrWrite, + SemaIntent, + SemaMode, + SemaRegime, + SemaScope, + SemaTransitionalFamily, + SemaValencedFamily +} from '../sema/types' // ── HTML element ────────────────────────────────────────────────────────── @@ -66,10 +77,10 @@ export type MorfoElement = | 'textarea' | 'none'; -// ── ARIA value taxonomy ──────────────────────────────────────────────────── +// ── Value source taxonomy ────────────────────────────────────────────────── /** - * Source semantics of an ARIA attribute's value. Tagged union so the + * Reusable source semantics for declarative attr values. Tagged union so the * validator can enforce cross-references: * * - `literal` → static string ("dialog", "true") @@ -77,17 +88,45 @@ export type MorfoElement = * - `partRef` → must match another part's `kebab` in the same morfo * - `propRef` → consumer-controlled via component prop * - `translationRef` → must exist as idlangref key in the component's `langs.ts` + * - `mapRef` → declarative mapping from another source (`intent -> alert`) * - * No `computed` escape hatch — if a value doesn't fit these five shapes, + * No `computed` escape hatch — if a value doesn't fit these declarative shapes, * the declaration is modelling the wrong thing. Revisit the semantics. */ -export type MorfoAriaValue = +export type MorfoPrimitiveValueSource = | { kind: 'literal'; value: string } | { kind: 'stateRef'; state: string } | PartRef | { kind: 'propRef'; prop: string } | { kind: 'translationRef'; key: string }; +export interface MorfoMapValueSource { + kind: 'mapRef'; + source: MorfoPrimitiveValueSource; + map: Record; + fallback?: string; +} + +export type MorfoValueSource = MorfoPrimitiveValueSource | MorfoMapValueSource; + +/** + * Source semantic of an ARIA attribute's value. + * + * Alias kept for readability at call sites and backwards compatibility with + * existing morfo consumers. + */ +export type MorfoAriaValue = MorfoValueSource; + +/** + * Source semantic of a `data-*` attribute's value. + * + * When omitted, the attr remains declarative-only and the provider keeps + * manual responsibility for emitting it. This allows incremental adoption: + * morfo can progressively become executable without forcing every existing + * contract to model its runtime origin on day one. + */ +export type MorfoDataValue = MorfoValueSource; + // ── Condition taxonomy ──────────────────────────────────────────────────── /** @@ -150,6 +189,20 @@ export interface MorfoData { attr: string; /** Complete set of valid values. Omit for presence flags. */ values?: string[]; + /** + * Optional declarative source of the attr's runtime value. + * + * This is the hook that allows `Provider` to become morfo-driven: + * `role`, `aria-*` and `data-*` can eventually be resolved from the same + * contract instead of being hardcoded in every component provider. + * + * For enum-valued attrs, the source resolves the raw semantic value. + * For presence flags, truthy resolves to `''` and falsy to `undefined`. + * + * When omitted, the morfo still declares the public contract but the + * provider must emit the attr manually. + */ + value?: MorfoDataValue; /** When this attr is emitted. Defaults to `'always'`. */ condition?: MorfoCondition; /** Validator severity. Defaults to `'required'`. */ @@ -193,6 +246,60 @@ export interface MorfoKeyboard { condition?: MorfoCondition; } +// ── Semantic events ─────────────────────────────────────────────────────── + +/** + * Configurable semantic intent exposed as part of a component's public API. + * + * Example: + * - `Toast` is semantically an `alert` + * - the consumer can tune its tone through `intent` + */ +export interface MorfoSemanticIntent { + /** + * Prop name on the soma component that controls the semantic intent. + * Example: `intent`. + */ + fromProp?: string; + /** Fallback semantic tone when the prop is omitted. */ + default: SemaIntent; + /** Closed set of intents the component supports for this semantic event. */ + supported: readonly SemaIntent[]; +} + +/** + * Semantic classification of a component event. + * + * Transitional families carry no intent. Valenced families can either use a + * fixed intent or bind intent to a public component prop. + */ +export type MorfoEventSemantic = + | { + family: SemaTransitionalFamily; + } + | { + family: SemaValencedFamily; + intent: SemaIntent | MorfoSemanticIntent; + }; + +/** + * Runtime event contract authored in Morfo. + * + * This is the bridge point between structural contract and semantic contract: + * a component can declare not only that it emits public state, but also what + * semantic event that state transition means. + */ +export interface MorfoEvent { + name: string; + target: PartRef; + semantic: MorfoEventSemantic; + mode?: SemaMode; + regime?: SemaRegime; + scope?: SemaScope; + prewrite?: readonly SemaAttrWrite[]; + commits?: SemaCommit; +} + // ── Focus policy ────────────────────────────────────────────────────────── /** @@ -308,6 +415,13 @@ export interface Morfo { * component has no special focus coordination (plain controls). */ focus?: MorfoFocus; + /** + * Semantic events the component can emit. + * + * Morfo is the source of truth for component-authored semantics: what + * events exist, what family they belong to, and which intents are valid. + */ + events?: readonly MorfoEvent[]; /** The component's part tree. */ parts: readonly MorfoPart[]; } @@ -324,15 +438,25 @@ export interface Morfo { * { attr: 'aria-labelledby', value: v.partRef('title') }, * { attr: 'aria-haspopup', value: v.literal('dialog') } * ] + * + * data: [ + * { attr: 'data-state', values: ['open', 'closed'], value: v.stateRef('open') }, + * { attr: 'data-orientation', values: ['horizontal', 'vertical'], value: v.propRef('orientation') } + * ] * ``` */ export const v = { - literal: (value: string): MorfoAriaValue => ({ kind: 'literal', value }), - stateRef: (state: string): MorfoAriaValue => ({ kind: 'stateRef', state }), + literal: (value: string): MorfoPrimitiveValueSource => ({ kind: 'literal', value }), + stateRef: (state: string): MorfoPrimitiveValueSource => ({ kind: 'stateRef', state }), // Returns the narrow `PartRef` — still assignable to `MorfoAriaValue` // because PartRef is one of its cases, and reusable by any layer that // needs to reference a part (e.g. Sema action targets). partRef: (target: string): PartRef => ({ kind: 'partRef', target }), - propRef: (prop: string): MorfoAriaValue => ({ kind: 'propRef', prop }), - translationRef: (key: string): MorfoAriaValue => ({ kind: 'translationRef', key }) + propRef: (prop: string): MorfoPrimitiveValueSource => ({ kind: 'propRef', prop }), + translationRef: (key: string): MorfoPrimitiveValueSource => ({ kind: 'translationRef', key }), + mapRef: ( + source: MorfoPrimitiveValueSource, + map: Record, + fallback?: string + ): MorfoValueSource => ({ kind: 'mapRef', source, map, fallback }) } as const; diff --git a/src/uix/sema/README.md b/src/uix/sema/README.md new file mode 100644 index 000000000..b5b2ab038 --- /dev/null +++ b/src/uix/sema/README.md @@ -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. diff --git a/src/uix/sema/a11y.test.ts b/src/uix/sema/a11y.test.ts deleted file mode 100644 index 16f7a411f..000000000 --- a/src/uix/sema/a11y.test.ts +++ /dev/null @@ -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> = {}) { - const listeners = new Map 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) - }) -}) diff --git a/src/uix/sema/a11y.ts b/src/uix/sema/a11y.ts deleted file mode 100644 index 15b3daf6b..000000000 --- a/src/uix/sema/a11y.ts +++ /dev/null @@ -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 - -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) - } -} diff --git a/src/uix/sema/binding.test.ts b/src/uix/sema/binding.test.ts deleted file mode 100644 index 221b10a88..000000000 --- a/src/uix/sema/binding.test.ts +++ /dev/null @@ -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 = {}): HTMLElement { - const attrs = new Map(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() - }) -}) diff --git a/src/uix/sema/binding.ts b/src/uix/sema/binding.ts deleted file mode 100644 index 7b7309e58..000000000 --- a/src/uix/sema/binding.ts +++ /dev/null @@ -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> - cause?: SemaContext['cause'] - abortSignal?: AbortSignal -} - -export type ActionName = S['actions'][number]['name'] -export type SustainName = S['sustains'] extends readonly SemaSustainDecl[] - ? S['sustains'][number]['name'] - : never - -export interface SemaBinding { - before(name: ActionName, ctx: PartialSemaContext): Promise - fire(name: ActionName, ctx: PartialSemaContext): void - start(name: SustainName, ctx: PartialSemaContext): SemaSession - action(name: ActionName): 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(spec: S, port: SemaPort): SemaBinding { - const actionsByName = new Map() - for (const action of spec.actions) { - actionsByName.set(action.name, action) - } - - const sustainsByName = new Map() - 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 = {} - 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) - } - } -} diff --git a/src/uix/sema/channels/color.test.ts b/src/uix/sema/channels/color.test.ts deleted file mode 100644 index 3d827d1d0..000000000 --- a/src/uix/sema/channels/color.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { ColorChannel } from './color' -import type { ColorSignature } from '../resolver' - -interface MockAnimation extends Partial { - cancel: ReturnType - finished: Promise - resolveFinished(): void - rejectFinished(reason?: unknown): void -} - -function createMockAnimation(): MockAnimation { - let resolveFinished = () => {} - let rejectFinished = (_reason?: unknown) => {} - const finished = new Promise((resolve, reject) => { - resolveFinished = resolve - rejectFinished = reject - }) - - return { - cancel: vi.fn(), - finished, - resolveFinished, - rejectFinished - } -} - -function createStyle(seed: Record = {}): 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 }> = [{ 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) - }) -}) diff --git a/src/uix/sema/channels/color.ts b/src/uix/sema/channels/color.ts deleted file mode 100644 index ec40e71ae..000000000 --- a/src/uix/sema/channels/color.ts +++ /dev/null @@ -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 -type OverlayElement = HTMLElement & { - animate?: (keyframes: Keyframe[], options?: KeyframeAnimationOptions) => Animation - remove(): void - style: CSSStyleDeclaration -} - -function hasAnimate(target: unknown): target is { animate: NonNullable } { - 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() - - async apply( - target: HTMLElement, - signature: ColorSignature, - abortSignal: AbortSignal - ): Promise { - 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 { - 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 { - 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 { - 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) - } - } -} diff --git a/src/uix/sema/channels/motion.test.ts b/src/uix/sema/channels/motion.test.ts deleted file mode 100644 index 04c82d388..000000000 --- a/src/uix/sema/channels/motion.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { MotionChannel } from './motion' -import type { MotionSignature } from '../resolver' - -interface MockAnimation extends Partial { - cancel: ReturnType - finished: Promise - resolveFinished(): void - rejectFinished(reason?: unknown): void -} - -function createMockAnimation(): MockAnimation { - let resolveFinished = () => {} - let rejectFinished = (_reason?: unknown) => {} - const finished = new Promise((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() - }) -}) diff --git a/src/uix/sema/channels/motion.ts b/src/uix/sema/channels/motion.ts deleted file mode 100644 index 998b3f075..000000000 --- a/src/uix/sema/channels/motion.ts +++ /dev/null @@ -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() - - async apply( - target: HTMLElement, - signature: MotionSignature, - abortSignal: AbortSignal - ): Promise { - 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) - } - } -} diff --git a/src/uix/sema/channels/presence.test.ts b/src/uix/sema/channels/presence.test.ts deleted file mode 100644 index 8a97e4637..000000000 --- a/src/uix/sema/channels/presence.test.ts +++ /dev/null @@ -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 { - cancel: ReturnType - finished: Promise - resolveFinished(): void - rejectFinished(reason?: unknown): void -} - -function createMockAnimation(): MockAnimation { - let resolveFinished = () => {} - let rejectFinished = (_reason?: unknown) => {} - const finished = new Promise((resolve, reject) => { - resolveFinished = resolve - rejectFinished = reject - }) - - return { - cancel: vi.fn(), - finished, - resolveFinished, - rejectFinished - } -} - -function createStyle(seed: Record = {}): 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 }> = [{ 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) - }) -}) diff --git a/src/uix/sema/channels/presence.ts b/src/uix/sema/channels/presence.ts deleted file mode 100644 index dedfcb81d..000000000 --- a/src/uix/sema/channels/presence.ts +++ /dev/null @@ -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 - -function hasAnimate(target: unknown): target is { animate: NonNullable } { - 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 { - if (!target.isConnected) return - - const promises: Promise[] = [] - - 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 { - 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 { - 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 { - 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((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 { - 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 - } -} diff --git a/src/uix/sema/channels/sound.test.ts b/src/uix/sema/channels/sound.test.ts deleted file mode 100644 index f367aeb9a..000000000 --- a/src/uix/sema/channels/sound.test.ts +++ /dev/null @@ -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[] = [] - const oscillators: ReturnType[] = [] - const filters: ReturnType[] = [] - const sources: ReturnType[] = [] - - 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) - }) -}) diff --git a/src/uix/sema/channels/sound.ts b/src/uix/sema/channels/sound.ts deleted file mode 100644 index 6edeb365e..000000000 --- a/src/uix/sema/channels/sound.ts +++ /dev/null @@ -1,322 +0,0 @@ -import type { SoundSignature } from '../resolver' - -type AudioContextCtor = new () => AudioContext - -export interface SoundChannelOptions { - audioContextFactory?: () => AudioContext | null - fetchFn?: typeof fetch - doc?: Pick -} - -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() - private readonly fetchFn?: typeof fetch - private readonly audioContextFactory?: () => AudioContext | null - private readonly doc?: Pick - 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 { - 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 { - 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 { - 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 { - 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((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 { - 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((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 }) - }) - } -} diff --git a/src/uix/sema/engine.test.ts b/src/uix/sema/engine.test.ts index 910c9ed49..269d19980 100644 --- a/src/uix/sema/engine.test.ts +++ b/src/uix/sema/engine.test.ts @@ -1,364 +1,114 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' -import { - _resetEngineForTesting, - configureSema, - destroySema, - getSemaEngine, - SemaEngine, - type ColorChannelDriver, - type ColorSignature, - type MotionChannelDriver, - type MotionSignature, - type PresenceChannelDriver, - type SemaChannelDrivers, - type SemaEventDetail, - type SoundChannelDriver -} from './engine' -import type { ResolvedSemaAction, ResolvedSemaSustain, SemaContext } from './port' +import { SemanticEngine } from './engine' +import { dialogMorfo } from '../morfo/components/dialog' +import { toastMorfo } from '../morfo/components/toast' -class FakeElement extends EventTarget { - isConnected = true - ownerDocument!: { documentElement: FakeElement } - private readonly attrs = new Map() - - 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 { - return { - name: 'close-save', - component: 'dialog', - event: 'commit-fulfill', - mode: 'blocking', - regime: 'replace', - scope: 'part', - target: 'content', - prewritten: [], - ...overrides - } -} - -function createSustain( - overrides: Partial = {} -): ResolvedSemaSustain { +function createElement(initial: Record = {}): HTMLElement { + const attrs = new Map(Object.entries(initial)) return { - name: 'open', - component: 'dialog', - target: 'content', - scope: 'part', - ...overrides - } -} - -function createContext(targetEl: FakeElement, rootEl?: FakeElement): SemaContext { - return { - targetEl: targetEl as unknown as HTMLElement, - rootEl: rootEl as unknown as HTMLElement | undefined, - snapshot: {}, - partEls: {}, - cause: 'programmatic' - } -} - -function createDelayedMotionDriver(ms: number): MotionChannelDriver & { apply: ReturnType } { - return { - apply: vi.fn(async (_target: HTMLElement, _signature: MotionSignature, abortSignal: AbortSignal) => { - await new Promise((resolve) => { - const timer = setTimeout(() => resolve(), ms) - abortSignal.addEventListener( - 'abort', - () => { - clearTimeout(timer) - resolve() - }, - { once: true } - ) - }) - }), - applySustained: () => () => {}, - destroy() {} - } -} - -function createImmediateDrivers(overrides: Partial = {}) { - const motion: MotionChannelDriver = { - async apply() {}, - applySustained: () => () => {}, - destroy() {} - } - const sound: SoundChannelDriver = { - async apply() {}, - applySustained: () => () => {}, - destroy() {} - } - const color: ColorChannelDriver = { - async apply() {}, - applySustained: () => () => {}, - destroy() {} - } - const presence: PresenceChannelDriver = { - async apply() {}, - applySustained: () => () => {}, - destroy() {} - } - - return { - motion, - sound, - color, - presence, - ...overrides - } + getAttribute(name: string) { + return attrs.has(name) ? attrs.get(name)! : null + }, + setAttribute(name: string, value: string) { + attrs.set(name, value) + }, + removeAttribute(name: string) { + attrs.delete(name) + } + } as unknown as HTMLElement } -afterEach(() => { - vi.useRealTimers() - _resetEngineForTesting() -}) +describe('SemanticEngine', () => { + it('publishes a dialog event with normalized canonical semantics', () => { + const engine = new SemanticEngine() + const contentEl = createElement({ 'data-state': 'open' }) -describe('SemaEngine', () => { - it('emits sema:event and reflects attrs around a blocking choreography', async () => { - const { target, root } = createDom() - const phases: SemaEventDetail[] = [] - target.addEventListener('sema:event', (event) => { - phases.push((event as CustomEvent).detail) + const published = engine.publish(dialogMorfo, 'close-save', { + targetEl: contentEl, + partEls: { content: contentEl }, + cause: 'pointer' }) - const engine = new SemaEngine({ - channels: createImmediateDrivers() + expect(contentEl.getAttribute('data-last-action')).toBe('saved') + expect(published).toMatchObject({ + name: 'close-save', + component: 'dialog', + target: 'content', + family: 'commit', + intent: 'fulfill', + label: 'commit-fulfill', + mode: 'blocking', + regime: 'lock', + scope: 'part', + cause: 'pointer', + prewritten: [{ part: 'content', attr: 'data-last-action', value: 'saved' }] }) - engine.configure({ reflectEvents: true }) - - const pending = engine.before(createAction(), createContext(target, root)) - expect(target.getAttribute('data-sema-active')).toBe('commit-fulfill') - expect(target.getAttribute('data-sema-phase')).toBe('active') - - await pending - - expect(target.getAttribute('data-sema-active')).toBe(null) - expect(target.getAttribute('data-sema-phase')).toBe(null) - expect(phases.map((entry) => entry.phase)).toEqual(['start', 'end']) - expect(phases[0].channels).toContain('motion') - expect(phases[0].duration).toBeGreaterThan(0) }) - it('coalesces collapse actions into a single in-flight choreography', async () => { - vi.useFakeTimers() - const { target } = createDom() - const motion = createDelayedMotionDriver(1000) - const engine = new SemaEngine({ - channels: createImmediateDrivers({ motion }) - }) - engine.configure({ - sound: { enabled: false }, - color: { enabled: false }, - presence: { enabled: false }, - capBlockingMs: 20 - }) - const action = createAction({ regime: 'collapse' }) - - const first = engine.before(action, createContext(target)) - const second = engine.before(action, createContext(target)) - - expect(motion.apply).toHaveBeenCalledTimes(1) + it('resolves prop-driven intent from morfo events', () => { + const engine = new SemanticEngine() + const itemEl = createElement() - await vi.advanceTimersByTimeAsync(20) - await first - await second - }) - - it('locks equivalent actions while one is active', async () => { - vi.useFakeTimers() - const { target } = createDom() - const motion = createDelayedMotionDriver(1000) - const engine = new SemaEngine({ - channels: createImmediateDrivers({ motion }) + const published = engine.publish(toastMorfo, 'announce', { + targetEl: itemEl, + partEls: { item: itemEl }, + props: { intent: 'risk' } }) - engine.configure({ - sound: { enabled: false }, - color: { enabled: false }, - presence: { enabled: false }, - capBlockingMs: 20 - }) - const action = createAction({ regime: 'lock' }) - - const first = engine.before(action, createContext(target)) - await engine.before(action, createContext(target)) - expect(motion.apply).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(20) - await first - }) - - it('queues equivalent actions sequentially after the blocking cap releases', async () => { - vi.useFakeTimers() - const { target } = createDom() - const motion = createDelayedMotionDriver(1000) - const engine = new SemaEngine({ - channels: createImmediateDrivers({ motion }) - }) - engine.configure({ - sound: { enabled: false }, - color: { enabled: false }, - presence: { enabled: false }, - capBlockingMs: 20 + expect(published).toMatchObject({ + component: 'toast', + name: 'announce', + family: 'alert', + intent: 'risk', + label: 'alert-risk' }) - const action = createAction({ regime: 'queue' }) - - const first = engine.before(action, createContext(target)) - const second = engine.before(action, createContext(target)) - - expect(motion.apply).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(20) - expect(motion.apply).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(20) - await first - await second }) - it('replaces an active choreography and marks the first one as cancelled', async () => { - vi.useFakeTimers() - const { target } = createDom() - const motion = createDelayedMotionDriver(1000) - const phases: Array = [] - target.addEventListener('sema:event', (event) => { - phases.push((event as CustomEvent).detail.phase) - }) - const engine = new SemaEngine({ - channels: createImmediateDrivers({ motion }) - }) - engine.configure({ - sound: { enabled: false }, - color: { enabled: false }, - presence: { enabled: false }, - capBlockingMs: 20 - }) - const action = createAction({ regime: 'replace' }) - - const first = engine.before(action, createContext(target)) - const second = engine.before(action, createContext(target)) - - expect(motion.apply).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(20) - await first - await second - - expect(phases.filter((phase) => phase === 'start')).toHaveLength(2) - expect(phases).toContain('cancelled') - expect(phases).toContain('end') - }) + it('falls back to the declared default intent when the prop is missing', () => { + const engine = new SemanticEngine() + const itemEl = createElement() - it('starts sustains and runs cleanups on stop()', () => { - const { target } = createDom() - const motionCleanup = vi.fn() - const presenceCleanup = vi.fn() - const engine = new SemaEngine({ - channels: createImmediateDrivers({ - motion: { - async apply() {}, - applySustained: () => motionCleanup, - destroy() {} - }, - presence: { - async apply() {}, - applySustained: () => presenceCleanup, - destroy() {} - } - }) + const published = engine.publish(toastMorfo, 'announce', { + targetEl: itemEl, + partEls: { item: itemEl } }) - const session = engine.startSustain(createSustain(), createContext(target)) - expect(session.active).toBe(true) + expect(published.label).toBe('alert-neutral') + expect(published.intent).toBe('neutral') + }) - session.stop() + it('notifies subscribers with optional filtering', () => { + const engine = new SemanticEngine() + const itemEl = createElement() + const seen: string[] = [] - expect(session.active).toBe(false) - expect(motionCleanup).toHaveBeenCalledTimes(0) - expect(presenceCleanup).toHaveBeenCalledTimes(1) - }) + const unsubscribe = engine.onEvent((event) => { + seen.push(event.label) + }, { component: 'toast', family: 'alert' }) - it('re-resolves signatures after map override reconfiguration', async () => { - const { target } = createDom() - const seen: ColorSignature[] = [] - const color: ColorChannelDriver = { - async apply(_target, signature) { - seen.push(signature) - }, - applySustained: () => () => {}, - destroy() {} - } - const engine = new SemaEngine({ - channels: createImmediateDrivers({ color }) + engine.publish(toastMorfo, 'announce', { + targetEl: itemEl, + partEls: { item: itemEl }, + props: { intent: 'threat' } }) - engine.configure({ - sound: { enabled: false }, - motion: { enabled: false }, - presence: { enabled: false }, - mapOverrides: { - 'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 } - } + unsubscribe() + engine.publish(toastMorfo, 'announce', { + targetEl: itemEl, + partEls: { item: itemEl }, + props: { intent: 'affirm' } }) - await engine.before(createAction(), createContext(target)) - - expect(seen).toHaveLength(1) - expect(seen[0].hue).toBe(0) + expect(seen).toEqual(['alert-threat']) }) - it('exposes a singleton facade and resets it on destroy', () => { - const first = getSemaEngine() - configureSema({ reflectEvents: true }) - const second = getSemaEngine() - - expect(first).toBe(second) - expect(second.currentConfig.reflectEvents).toBe(true) + it('throws when publishing an undeclared event', () => { + const engine = new SemanticEngine() - destroySema() - - const third = getSemaEngine() - expect(third).not.toBe(first) + expect(() => + engine.publish(toastMorfo, 'missing', { + targetEl: createElement() + }) + ).toThrow(/event "missing" not declared/) }) }) diff --git a/src/uix/sema/engine.ts b/src/uix/sema/engine.ts index 1ebf7c183..d5c7aa966 100644 --- a/src/uix/sema/engine.ts +++ b/src/uix/sema/engine.ts @@ -1,551 +1,193 @@ import { DEV } from 'esm-env' -import { - A11yMonitor, - DEFAULT_SEMA_RUNTIME_CONFIG, - type SemaRuntimeConfig -} from './a11y' -import { ColorChannel } from './channels/color' -import { MotionChannel } from './channels/motion' -import { PresenceChannel } from './channels/presence' -import { SoundChannel } from './channels/sound' +import { normalizeSemaEvent } from './event' import type { - ColorSignature, - EffectiveSignature, - MotionSignature, - PresenceSignature, - RuntimeOverrides, - SoundSignature -} from './resolver' -import { Resolver } from './resolver' -import type { - ResolvedSemaAction, - ResolvedSemaSustain, - SemaContext, - SemaPort, - SemaSession -} from './port' - -type SemaPhase = 'start' | 'end' | 'cancelled' - -export interface SemaEventDetail { - event: ResolvedSemaAction['event'] - action: string + SemaAttrWrite, + SemaCause, + SemaCommit, + SemaEvent, + SemaEventLabel, + SemaFamily, + SemaIntent, + SemaMode, + SemaRegime, + SemaScope +} from './types' + +import type { PartRef } from '../lib/types' + +export interface SemanticEventDecl { + name: string + target: PartRef + semantic: SemaEvent + mode?: SemaMode + regime?: SemaRegime + scope?: SemaScope + prewrite?: readonly SemaAttrWrite[] + commits?: SemaCommit +} + +export interface SemanticComponentContract { + kebab: string + events?: readonly SemanticEventDecl[] +} + +export interface SemanticPublishContext { + targetEl: HTMLElement + rootEl?: HTMLElement + partEls?: Partial> + props?: Record + cause?: SemaCause + detail?: Record +} + +export interface PublishedSemanticEvent { + id: string + name: string component: string - phase: SemaPhase - channels: EffectiveSignature['activeChannels'] - duration: number -} - -export interface MotionChannelDriver { - apply(target: HTMLElement, signature: MotionSignature, abortSignal: AbortSignal): Promise - applySustained?(target: HTMLElement, signature: MotionSignature): () => void - destroy?(): void -} - -export interface SoundChannelDriver { - apply(signature: SoundSignature, gain: number, abortSignal: AbortSignal): Promise - applySustained?(signature: SoundSignature, gain: number): () => void - destroy?(): void -} - -export interface ColorChannelDriver { - apply(target: HTMLElement, signature: ColorSignature, abortSignal: AbortSignal): Promise - applySustained?(target: HTMLElement, signature: ColorSignature): () => void - destroy?(): void -} - -export interface PresenceChannelDriver { - apply(target: HTMLElement, signature: PresenceSignature, abortSignal: AbortSignal): Promise - applySustained?(target: HTMLElement, signature: PresenceSignature): () => void - destroy?(): void -} - -export interface SemaChannelDrivers { - motion: MotionChannelDriver - sound: SoundChannelDriver - color: ColorChannelDriver - presence: PresenceChannelDriver -} - -export interface EngineDependencies { - resolver?: Resolver - a11y?: A11yMonitor - channels?: Partial -} - -export interface EngineConfig extends SemaRuntimeConfig { - mapOverrides?: RuntimeOverrides -} - -export interface EngineConfigPatch - extends Partial> { - sound?: Partial - motion?: Partial - color?: Partial - presence?: Partial -} - -const noopCleanup = () => {} - -const noopChannels: SemaChannelDrivers = { - motion: { - async apply() {}, - applySustained() { - return noopCleanup - }, - destroy() {} - }, - sound: { - async apply() {}, - applySustained() { - return noopCleanup - }, - destroy() {} - }, - color: { - async apply() {}, - applySustained() { - return noopCleanup - }, - destroy() {} - }, - presence: { - async apply() {}, - applySustained() { - return noopCleanup - }, - destroy() {} - } -} - -function mergeConfig(current: EngineConfig, patch: EngineConfigPatch): EngineConfig { - return { - ...current, - ...patch, - sound: patch.sound ? { ...current.sound, ...patch.sound } : current.sound, - motion: patch.motion ? { ...current.motion, ...patch.motion } : current.motion, - color: patch.color ? { ...current.color, ...patch.color } : current.color, - presence: patch.presence ? { ...current.presence, ...patch.presence } : current.presence, - mapOverrides: - 'mapOverrides' in patch ? structuredClone(patch.mapOverrides ?? {}) : current.mapOverrides - } -} - -function createSemaEvent(detail: SemaEventDetail): Event { - if (typeof CustomEvent === 'function') { - return new CustomEvent('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): Promise { - return work.catch(() => {}) -} - -function delay(ms: number): Promise { - 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() - private readonly sustainSessions = new Set() - private readonly elementIds = new WeakMap() - 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 { - const key = this.choreographyKey(action, ctx) - const existing = this.activeChoreographies.get(key) - - if (existing) { - switch (action.regime) { - case 'replace': - existing.cancel() - this.activeChoreographies.delete(key) - break - case 'collapse': - existing.markRepeated() - return existing.promise - case 'lock': - return - case 'queue': - await existing.promise - break - } + target: string + targetEl: HTMLElement + rootEl?: HTMLElement + family: SemaFamily + intent: SemaIntent | null + label: SemaEventLabel + mode: SemaMode + regime: SemaRegime + scope: SemaScope + prewritten: readonly { part: string; attr: string; value: string }[] + commits?: SemaCommit + cause?: SemaCause + detail?: Record + timestamp: number +} + +export interface SemanticEventFilter { + component?: string + name?: string + family?: SemaFamily + intent?: SemaIntent | null +} + +type SemanticListener = (event: PublishedSemanticEvent) => void + +interface SemanticSubscriber { + filter?: SemanticEventFilter + listener: SemanticListener +} + +function matchesFilter(event: PublishedSemanticEvent, filter?: SemanticEventFilter): boolean { + if (!filter) return true + if (filter.component && filter.component !== event.component) return false + if (filter.name && filter.name !== event.name) return false + if (filter.family && filter.family !== event.family) return false + if ('intent' in filter && filter.intent !== event.intent) return false + return true +} + +function findEvent(contract: SemanticComponentContract, name: string): SemanticEventDecl | undefined { + return contract.events?.find((event) => event.name === name) +} + +function resolvePartElement( + ctx: SemanticPublishContext, + targetPart: string, + fallback: HTMLElement +): HTMLElement | undefined { + return ctx.partEls?.[targetPart] ?? fallback +} + +export class SemanticEngine { + private readonly subscribers = new Set() + private nextId = 0 + + publish( + contract: SemanticComponentContract, + name: string, + ctx: SemanticPublishContext + ): PublishedSemanticEvent { + const decl = findEvent(contract, name) + if (!decl) { + throw new Error( + `[semantic] event "${name}" not declared in "${contract.kebab}". Declared events: ${contract.events?.map((event) => event.name).join(', ') || '∅'}` + ) } - let choreography: Choreography | null = null - let signature: EffectiveSignature | null = null - - try { - signature = this.a11y.reduceSignature(this.resolver.resolve(action.event, ctx.targetEl), this.config) - choreography = new Choreography(action, ctx, signature, this.a11y.getBlockingCapMs(this.config), this) - this.activeChoreographies.set(key, choreography) - - this.emitCustomEvent(ctx.targetEl, action, 'start', signature) - if (this.config.reflectEvents) { - ctx.targetEl.setAttribute('data-sema-active', action.event) - ctx.targetEl.setAttribute('data-sema-phase', 'active') - } - - const result = await choreography.run() - this.emitCustomEvent( - ctx.targetEl, - action, - result === 'cancelled' ? 'cancelled' : 'end', - signature + const targetEl = resolvePartElement(ctx, decl.target.target, ctx.targetEl) + if (!targetEl) { + throw new Error( + `[semantic] target part "${decl.target.target}" has no runtime element in "${contract.kebab}.${name}".` ) - } catch (error) { - this.warn(`[sema] engine.before("${action.name}") degraded: ${String(error)}`) - } finally { - if (this.config.reflectEvents) { - ctx.targetEl.removeAttribute('data-sema-active') - ctx.targetEl.removeAttribute('data-sema-phase') - } - if (choreography) { - this.activeChoreographies.delete(key) - } } - } - fire(action: ResolvedSemaAction, ctx: SemaContext): void { - this.before(action, ctx).catch(() => {}) - } + const prewritten = this.applyPrewrites(contract, decl, ctx, targetEl) + const normalized = normalizeSemaEvent(decl.semantic, ctx.props) - startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession { - try { - const runner = new SustainRunner(sustain, ctx, this) - this.sustainSessions.add(runner) - runner.start() - return { - stop: () => { - runner.stop() - this.sustainSessions.delete(runner) - }, - get active() { - return runner.active - } - } - } catch (error) { - this.warn(`[sema] engine.startSustain("${sustain.name}") degraded: ${String(error)}`) - return { - stop() {}, - get active() { - return false - } - } + const published: PublishedSemanticEvent = { + id: `sem-${this.nextId++}`, + name: decl.name, + component: contract.kebab, + target: decl.target.target, + targetEl, + rootEl: ctx.rootEl, + family: normalized.family, + intent: normalized.intent, + label: normalized.label, + mode: decl.mode ?? 'blocking', + regime: decl.regime ?? 'replace', + scope: decl.scope ?? 'part', + prewritten, + commits: decl.commits, + cause: ctx.cause, + detail: ctx.detail, + timestamp: Date.now() } - } - - get channels(): Readonly { - return this.drivers - } - get currentConfig(): Readonly { - return this.config - } - - resolveReducedSignature(event: string, targetEl?: HTMLElement): EffectiveSignature { - return this.a11y.reduceSignature(this.resolver.resolve(event, targetEl), this.config) - } - - private choreographyKey(action: ResolvedSemaAction, ctx: SemaContext): string { - return `${action.component}:${action.name}:${this.elementId(this.scopeAnchor(action.scope, ctx))}` - } - - private scopeAnchor(scope: ResolvedSemaAction['scope'], ctx: SemaContext): HTMLElement { - if (scope === 'component') return ctx.rootEl ?? ctx.targetEl - if (scope === 'scene') { - return (ctx.rootEl?.ownerDocument?.documentElement as HTMLElement | undefined) ?? - (ctx.targetEl.ownerDocument?.documentElement as HTMLElement | undefined) ?? - ctx.rootEl ?? - ctx.targetEl + for (const subscriber of this.subscribers) { + if (!matchesFilter(published, subscriber.filter)) continue + subscriber.listener(published) } - return ctx.targetEl - } - - private elementId(el: HTMLElement): string { - const existing = this.elementIds.get(el) - if (existing) return existing - const next = `el-${this.nextElementId++}` - this.elementIds.set(el, next) - return next - } - private emitCustomEvent( - target: HTMLElement, - action: ResolvedSemaAction, - phase: SemaPhase, - signature: EffectiveSignature - ): void { - target.dispatchEvent( - createSemaEvent({ - event: action.event, - action: action.name, - component: action.component, - phase, - channels: signature.activeChannels, - duration: this.durationOfSignature(signature) - }) - ) + return published } - private durationOfSignature(signature: EffectiveSignature): number { - const durations = [ - signature.motion?.duration, - signature.sound?.duration, - signature.color?.duration, - signature.presence?.duration - ].filter((value): value is number => value !== undefined) - - return durations.length > 0 ? Math.max(...durations) : 0 - } - - private warn(message: string): void { - if (DEV) console.warn(message) - } -} - -class Choreography { - readonly promise: Promise - - 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((resolve) => { - resolvePromise = resolve - }) - this.settlePromise = resolvePromise - - if (ctx.abortSignal) { - const onAbort = () => this.cancel() - if (ctx.abortSignal.aborted) { - this.cancel() - } else { - ctx.abortSignal.addEventListener('abort', onAbort, { once: true }) - this.detachExternalAbort = () => { - ctx.abortSignal?.removeEventListener('abort', onAbort) - } - } + onEvent(listener: SemanticListener, filter?: SemanticEventFilter): () => void { + const subscriber: SemanticSubscriber = { listener, filter } + this.subscribers.add(subscriber) + return () => { + this.subscribers.delete(subscriber) } } - markRepeated(): void { - this.repeatedCount++ - } - - cancel(): void { - if (this.cancelled) return - this.cancelled = true - this.abortController.abort() - this.settle() - } - - async run(): Promise<'completed' | 'cancelled'> { - if (this.action.scope !== 'scene' && !isConnected(this.ctx.targetEl)) { - this.settle() - return 'cancelled' - } - - const channelPromises: Promise[] = [] - const config = this.engine.currentConfig - const channels = this.engine.channels - - if (this.signature.activeChannels.includes('motion') && config.motion.enabled && this.signature.motion) { - channelPromises.push( - swallowAbortable( - channels.motion.apply(this.ctx.targetEl, this.signature.motion, this.abortController.signal) - ) - ) - } - if (this.signature.activeChannels.includes('sound') && config.sound.enabled && this.signature.sound) { - channelPromises.push( - swallowAbortable( - channels.sound.apply(this.signature.sound, config.sound.gain, this.abortController.signal) - ) - ) - } - if (this.signature.activeChannels.includes('color') && config.color.enabled && this.signature.color) { - channelPromises.push( - swallowAbortable( - channels.color.apply(this.ctx.targetEl, this.signature.color, this.abortController.signal) - ) - ) - } - if ( - this.signature.activeChannels.includes('presence') && - config.presence.enabled && - this.signature.presence - ) { - channelPromises.push( - swallowAbortable( - channels.presence.apply(this.ctx.targetEl, this.signature.presence, this.abortController.signal) - ) - ) - } - - if (channelPromises.length === 0) { - this.settle() - return this.cancelled ? 'cancelled' : 'completed' - } - - const outcome = await Promise.race([ - Promise.all(channelPromises).then(() => 'completed' as const), - delay(this.capMs).then(() => 'completed' as const), - new Promise<'cancelled'>((resolve) => { - if (this.abortController.signal.aborted) { - resolve('cancelled') - return + destroy(): void { + this.subscribers.clear() + } + + private applyPrewrites( + contract: SemanticComponentContract, + decl: SemanticEventDecl, + ctx: SemanticPublishContext, + targetEl: HTMLElement + ): readonly { part: string; attr: string; value: string }[] { + const applied: Array<{ part: string; attr: string; value: string }> = [] + + for (const write of decl.prewrite ?? []) { + const el = resolvePartElement(ctx, write.part.target, write.part.target === decl.target.target ? targetEl : ctx.targetEl) + if (!el) { + if (DEV) { + console.warn( + `[semantic] prewrite target "${write.part.target}" missing for "${contract.kebab}.${decl.name}".` + ) } - this.abortController.signal.addEventListener( - 'abort', - () => resolve('cancelled'), - { once: true } - ) + continue + } + el.setAttribute(write.attr, write.value) + applied.push({ + part: write.part.target, + attr: write.attr, + value: write.value }) - ]) - - this.settle() - return this.cancelled ? 'cancelled' : outcome - } - - private settle(): void { - if (this.settled) return - this.settled = true - this.detachExternalAbort?.() - this.settlePromise() - } -} - -class SustainRunner { - active = true - - private cleanups: Array<() => void> = [] - - constructor( - private readonly sustain: ResolvedSemaSustain, - private readonly ctx: SemaContext, - private readonly engine: SemaEngine - ) {} - - start(): void { - const signature = this.engine.resolveReducedSignature('sustain', this.ctx.targetEl) - const config = this.engine.currentConfig - const channels = this.engine.channels - - if (signature.activeChannels.includes('motion') && config.motion.enabled && signature.motion) { - this.cleanups.push(channels.motion.applySustained?.(this.ctx.targetEl, signature.motion) ?? noopCleanup) - } - if (signature.activeChannels.includes('sound') && config.sound.enabled && signature.sound) { - this.cleanups.push( - channels.sound.applySustained?.(signature.sound, config.sound.gain) ?? noopCleanup - ) } - if (signature.activeChannels.includes('color') && config.color.enabled && signature.color) { - this.cleanups.push(channels.color.applySustained?.(this.ctx.targetEl, signature.color) ?? noopCleanup) - } - if (signature.activeChannels.includes('presence') && config.presence.enabled && signature.presence) { - this.cleanups.push( - channels.presence.applySustained?.(this.ctx.targetEl, signature.presence) ?? noopCleanup - ) - } - } - - stop(): void { - if (!this.active) return - this.active = false - for (const cleanup of this.cleanups.splice(0)) { - cleanup() - } - } -} - -let engineInstance: SemaEngine | null = null -function getEngine(): SemaEngine { - if (!engineInstance) { - engineInstance = new SemaEngine() + return applied } - return engineInstance -} - -export function getSemaEngine(): SemaEngine { - return getEngine() -} - -export function configureSema(config: EngineConfigPatch): void { - getEngine().configure(config) -} - -export function destroySema(): void { - if (!engineInstance) return - engineInstance.destroy() - engineInstance = null -} - -export function _resetEngineForTesting(): void { - destroySema() } diff --git a/src/uix/sema/event.ts b/src/uix/sema/event.ts new file mode 100644 index 000000000..ed7cd3e05 --- /dev/null +++ b/src/uix/sema/event.ts @@ -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([...SEMA_VALENCED_FAMILIES, ...SEMA_TRANSITIONAL_FAMILIES]) +const VALENCED_FAMILY_SET = new Set(SEMA_VALENCED_FAMILIES) +const TRANSITIONAL_FAMILY_SET = new Set(SEMA_TRANSITIONAL_FAMILIES) +const INTENT_SET = new Set(SEMA_INTENTS) +const LABEL_SET = new Set(SEMA_EVENT_LABELS) + +function isRecord(value: unknown): value is Record { + 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 +): 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 +): { + 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): SemaEventLabel { + return normalizeSemaEvent(event, props).label +} diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts index e38f58e5d..8b9eb6574 100644 --- a/src/uix/sema/exports.ts +++ b/src/uix/sema/exports.ts @@ -1,92 +1,46 @@ -/** - * Sema — public surface. - * - * Sema is a standalone layer. It owns its types and its validator. The - * only import from outside is `PartRef`, a cross-layer primitive that - * lives in `$uix/lib/types` — not in morfo. Sema has zero dependency on - * the morfo module. - * - * Consumers (soma providers, demos, eventual runtime) import from here. - * Inside `src/uix/sema/` prefer direct file imports. - */ - export type { + SemaValencedFamily, + SemaTransitionalFamily, + SemaFamily, + SemaIntent, + SemaMode, + SemaRegime, + SemaScope, + SemaCause, SemaEventLabel, + SemaIntentBinding, + SemaEvent, + SemaActionEvent, SemaAttrWrite, - SemaCommit, - SemaAction, - SemaSustainDecl, - SemaSpec -} from './types'; - -export { validateSema, SemaInvariantError } from './validation'; - -export type { - SemaFamilyName, - SemaIntentName, - SemaActiveChannel, - MotionSignature, - SoundSignature, - ColorSignature, - PresenceSignature, - EffectiveSignature, - SemaMap, - RuntimeOverrides, - CSEMSelectorOverride, - CSEMOverrides, - ResolverOptions -} from './resolver'; - -export { Resolver, defaultSemaMap } from './resolver'; - -export type { PartialSemaContext, ActionName, SustainName, SemaBinding } from './binding'; - -export { createSemaBinding } from './binding'; - -export type { - SemaRuntimeConfig, - MediaQueryListLike, - A11ySnapshot, - A11yMonitorOptions -} from './a11y'; - -export { A11yMonitor, DEFAULT_SEMA_RUNTIME_CONFIG } from './a11y'; - -export type { - SemaEventDetail, - MotionChannelDriver, - SoundChannelDriver, - ColorChannelDriver, - PresenceChannelDriver, - SemaChannelDrivers, - EngineDependencies, - EngineConfig, - EngineConfigPatch -} from './engine'; + SemaCommit +} from './types' export { - SemaEngine, - getSemaEngine, - configureSema, - destroySema, - _resetEngineForTesting -} from './engine'; - -export { MotionChannel } from './channels/motion'; -export { ColorChannel } from './channels/color'; -export { PresenceChannel } from './channels/presence'; -export { SoundChannel } from './channels/sound'; + SEMA_VALENCED_FAMILIES, + SEMA_TRANSITIONAL_FAMILIES, + SEMA_INTENTS, + SEMA_EVENT_LABELS, + isSemaFamily, + isSemaValencedFamily, + isSemaTransitionalFamily, + isSemaIntent, + isSemaEventLabel, + isSemaIntentBinding, + isSemaEvent, + parseSemaEventLabel, + resolveSemaIntent, + normalizeSemaEvent, + toSemaEventLabel +} from './event' export type { - ResolvedSemaAction, - ResolvedSemaSustain, - SemaContext, - SemaSession, - SemaPort, - TestSemaPortOptions, - TestSemaCall, - TestSemaSession, - TestSemaPortHandle -} from './port'; + SemanticEventDecl, + SemanticComponentContract, + SemanticPublishContext, + PublishedSemanticEvent, + SemanticEventFilter +} from './engine' + +export { SemanticEngine } from './engine' -export { noopSemaPort, createTestSemaPort } from './port'; +export { SemaInvariantError, validateSemaEvent, validateSemaIntentBinding } from './validation' diff --git a/src/uix/sema/port.test.ts b/src/uix/sema/port.test.ts deleted file mode 100644 index 49e268cc1..000000000 --- a/src/uix/sema/port.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/uix/sema/port.ts b/src/uix/sema/port.ts deleted file mode 100644 index 9d47e498d..000000000 --- a/src/uix/sema/port.ts +++ /dev/null @@ -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 -type ResolvedSemaRegime = NonNullable -type ResolvedSemaScope = NonNullable - -/** - * 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 -} - -/** - * Runtime context built by the caller for an invocation. - */ -export interface SemaContext { - targetEl: HTMLElement - rootEl?: HTMLElement - partEls?: Partial> - snapshot: Record - cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation' - abortSignal?: AbortSignal -} - -export interface SemaSession { - stop(): void - readonly active: boolean -} - -export interface SemaPort { - before(action: ResolvedSemaAction, ctx: SemaContext): Promise - 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 { - if (ms <= 0) return; - if (!opts.respectAbort || !signal) { - await new Promise((resolve) => setTimeout(resolve, ms)) - return - } - if (signal.aborted) return - await new Promise((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 - } - } -} diff --git a/src/uix/sema/resolver.test.ts b/src/uix/sema/resolver.test.ts deleted file mode 100644 index 3777818ed..000000000 --- a/src/uix/sema/resolver.test.ts +++ /dev/null @@ -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/) - }) -}) diff --git a/src/uix/sema/resolver.ts b/src/uix/sema/resolver.ts deleted file mode 100644 index 55ec6fea1..000000000 --- a/src/uix/sema/resolver.ts +++ /dev/null @@ -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 -} - -export interface SemaMap { - version: string - families: Record - intents: Record - soundPack: Record -} - -export type RuntimeOverrides = Record -export interface CSEMSelectorOverride { - selector: string - overrides: Record> -} - -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 { - 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(value: T): T { - return structuredClone(value) -} - -function toCanonicalEvent(family: SemaFamilyName, intent: SemaIntentName | null): SemaEventLabel { - if (!intent) return family as Extract - 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 = 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 = next as unknown as Record - for (let i = 0; i < parts.length - 1; i++) { - const key = parts[i] - if (!isRecord(cursor[key])) cursor[key] = {} - cursor = cursor[key] as Record - } - 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 - ): 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) - } -} diff --git a/src/uix/sema/sema-map.json b/src/uix/sema/sema-map.json deleted file mode 100644 index c4ce81527..000000000 --- a/src/uix/sema/sema-map.json +++ /dev/null @@ -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": {} -} diff --git a/src/uix/sema/sema-runtime-architecture _v01.md b/src/uix/sema/sema-runtime-architecture _v01.md deleted file mode 100644 index 95ba544f4..000000000 --- a/src/uix/sema/sema-runtime-architecture _v01.md +++ /dev/null @@ -1,2979 +0,0 @@ -# Sema Runtime — Arquitectura de Implementación - -> Documento de arquitectura técnica para implementar el runtime Sema. Cubre todas las partes del sistema: port, binding, resolver, engine, los cuatro canales (motion, sound, color, presence), pipeline de build para `.csem`, y estrategia de testing. -> -> **Asume leída:** `sema-spec-v0.4.md`. Este documento baja al nivel de implementación que la spec deja abierto deliberadamente. -> -> **Estado del código existente:** `$uix/sema/types.ts` y `$uix/sema/validation.ts` ya implementados y correctos. Los dos bugs pendientes (P1 data-last-action en validator, P1 scope en SemaSustainDecl) se arreglan aparte — este documento asume que ya están arreglados. -> -> **Decisiones arquitectónicas fijadas** (no reabrir en implementación): -> - Engine: **single-engine por document**, lazy-initialized -> - AudioContext: **lazy con lock visible** (sin cola de retry) -> - Síntesis: **substractiva con dos osciladores + ADSR + filter + ring mod opcional** -> - Sonido: **híbrido** (síntesis procedural base + sample packs como override) -> - Framework: **agnóstico** (solo DOM + Promises; adaptadores framework aparte) -> - API del engine: **promise-based**, sin event emitters custom más allá del `CustomEvent` canónico - ---- - -## Índice - -1. Visión general de la arquitectura -2. Estructura de archivos del paquete -3. Intents: caracterización perceptiva -4. El puerto: `port.ts` -5. El binding: `binding.ts` -6. El resolver: `resolver.ts` y `sema-map.json` -7. El engine: `engine.ts` -8. Canal motion: `channels/motion.ts` -9. Canal sound: `channels/sound.ts` -10. Canal color: `channels/color.ts` -11. Canal presence: `channels/presence.ts` -12. Gestión de accesibilidad: `a11y.ts` -13. Pipeline `.csem`: plugin PostCSS -14. API pública final -15. Estrategia de testing -16. Criterios de aceptación - ---- - -## 1. Visión general de la arquitectura - -### 1.1. Diagrama en prosa - -El runtime Sema se organiza en cuatro capas lógicas, cada una con una responsabilidad bien delimitada: - -**Capa de contrato.** Qué está declarado: `SemaSpec` por componente (ya existente en `types.ts`), `sema-map.json` (vocabulario perceptivo canónico), overrides compilados desde `.csem` (personalización del integrador). - -**Capa de protocolo.** Cómo se invoca: `SemaPort` (interfaz neutral entre la capa headless y el runtime), `SemaBinding` (helpers tipados derivados de un `SemaSpec` concreto). - -**Capa de resolución.** Qué firma se aplica: `Resolver` que combina contrato + mapa + overrides según el evento disparado. - -**Capa de ejecución.** Cómo se aplica: `Engine` que gestiona coreografías concurrentes, accesibilidad y caps, delegando en los cuatro canales la aplicación concreta (motion, sound, color, presence). - -### 1.2. Flujo de invocación típico - -Un provider de la capa headless invoca una acción semántica. El camino que sigue la invocación: - -1. Provider llama `binding.before('close-save', ctx)` -2. Binding resuelve el `SemaAction` desde el `SemaSpec` compilado -3. Binding aplica los prewrites declarados al DOM (escribe `data-last-action="saved"`) -4. Binding invoca `port.before(resolvedAction, ctx)` -5. Port (si es el real, no el no-op) delega en el Engine -6. Engine consulta el Resolver para obtener la firma efectiva -7. Engine chequea regímenes (¿hay otra coreografía activa en este target?) -8. Engine chequea accesibilidad (¿qué canales están permitidos?) -9. Engine chequea caps (¿cuánto tiempo máximo puede bloquear?) -10. Engine ejecuta los canales activos concurrentemente -11. Engine emite `CustomEvent('sema:event', { phase: 'start' })` -12. Los canales completan (o el cap temporal vence) -13. Engine emite `CustomEvent('sema:event', { phase: 'end' })` -14. La promesa del `port.before()` resuelve -15. El binding devuelve al provider -16. El provider aplica el commit de estado -17. La capa visual reacciona al nuevo estado con sus transiciones - -Cada paso es testeable por separado. - -### 1.3. Decisiones arquitectónicas - -**Single-engine por document.** Una sola instancia del engine gestiona todas las coreografías en la página. Esto garantiza un solo AudioContext, una sola cola de animaciones pendientes, una sola lectura de media queries de accesibilidad. La instancia se crea lazy la primera vez que un binding la solicita. En contextos multi-document (iframes), cada document tiene su propia instancia. - -**Framework-agnostic.** El runtime usa solo DOM estándar y Promises. No importa nada de Svelte/React/Vue. Si algún framework necesita adaptaciones específicas (ejemplo: integrar con el ciclo de runas de Svelte 5), vive en archivos separados como `$uix/sema/adapters/svelte.ts`. El core es limpio. - -**Promise-based, sin event emitters custom.** La única superficie de observabilidad pública es `CustomEvent('sema:event')` que emite el engine. El binding expone `before()` que devuelve `Promise`, `fire()` void, `start()` que devuelve `SemaSession`. No hay `engine.on('complete', ...)` ni callbacks registrables. Simplicidad sobre flexibilidad. - -**Lazy initialization.** El AudioContext no se crea hasta el primer evento con canal sound activo. El Engine no se instancia hasta el primer binding. Los canales no cargan sus dependencias hasta que se usan. Esto mantiene el coste de Sema en cero para páginas que no disparan eventos. - -**Degradación silenciosa.** Cuando el engine no puede ejecutar (port es no-op, AudioContext locked, runtime descargado), las Promises resuelven inmediatamente sin error. El provider nunca ve una excepción de Sema. Esto es deliberado: Sema es ornamental; no debe bloquear funcionalidad core. - -### 1.4. Lo que el runtime no hace - -Para evitar que el engine crezca incontroladamente: - -- No gestiona routing ni lifecycle de componentes (responsabilidad del framework) -- No emite telemetría a servidores (responsabilidad del integrador si la necesita) -- No persiste estado entre navegaciones (cada página empieza limpia) -- No hace preloading agresivo de samples (carga bajo demanda) -- No sintetiza voz ni sonidos complejos (solo earcons cortos) -- No implementa transiciones CSS (esas son de la capa visual) - ---- - -## 2. Estructura de archivos del paquete - -``` -src/uix/sema/ -├── index.ts # Barrel con exports públicos -├── exports.ts # (existente) exports principales -├── types.ts # (existente) tipos públicos -├── validation.ts # (existente) validador -├── port.ts # SemaPort + noopSemaPort + createTestSemaPort -├── binding.ts # createSemaBinding + SemaBinding -├── engine.ts # SemaEngine (runtime real) -├── resolver.ts # Resolver de firmas desde map + csem overrides -├── a11y.ts # Lectura de preferencias y reducción por canal -├── registers.ts # Registries internos (active choreographies, etc) -├── sema-map.json # Vocabulario perceptivo canónico -├── channels/ -│ ├── motion.ts # WAAPI wrapper -│ ├── sound.ts # Web Audio synth + sample playback -│ ├── color.ts # Overlay layer management -│ └── presence.ts # Backdrop + elevation -├── csem/ -│ ├── plugin.ts # PostCSS plugin -│ ├── parser.ts # Parser de .csem a AST interno -│ ├── compiler.ts # AST → JSON consumible -│ └── vocabulary.ts # Validación contra vocabulario canónico -├── adapters/ -│ └── (vacío por ahora; aquí irían adaptadores por framework) -└── __tests__/ - ├── port.test.ts - ├── binding.test.ts - ├── resolver.test.ts - ├── engine.test.ts - ├── channels/*.test.ts - └── fixtures/ -``` - -Los archivos `exports.ts`, `types.ts`, `validation.ts` ya existen. Todo lo demás se crea en esta implementación. - ---- - -## 3. Intents: caracterización perceptiva - -### 3.1. Por qué esta sección existe - -Los intents son el modulador afectivo que convierte una familia valencial (`contact`, `commit`, `alert`, `handle`) en un evento con significado concreto. Son cinco: `threat`, `risk`, `neutral`, `affirm`, `fulfill`. Las familias transicionales (`emerge`, `sustain`) no aceptan intent — su direccionalidad viene del contexto, no de una modulación afectiva. - -La spec Sema v0.4 describe los intents conceptualmente en §3.2 (valencia y arousal). Este documento baja al nivel de implementación: qué valor concreto toma cada intent en cada canal, cómo se combina con la familia base en el resolver, qué hace el runtime ante combinaciones malformadas, y cómo el integrador puede sobrescribir la caracterización canónica. - -Esta sección es referencia temprana porque los intents atraviesan resolver, `sema-map.json`, los cuatro canales, y la validación. Verla antes de bajar a implementación ahorra tener que saltar entre secciones. - -### 3.2. Recapitulación de qué son - -Cada intent es un punto en el espacio bidimensional valencia × arousal. Los cinco puntos no son categorías ortogonales sino anclas para cubrir el espacio con granularidad suficiente: - -| Intent | Valencia | Arousal | Significado pragmático | -|---|---|---|---| -| `threat` | Muy negativa | Alto | Peligro, irreversibilidad, consecuencia grave | -| `risk` | Negativa | Medio-bajo | Precaución, subóptimo, fricción moderada | -| `neutral` | Neutra | Bajo | Operación rutinaria sin evaluación afectiva | -| `affirm` | Leve positiva | Bajo | Correcto, adecuado, aprobado | -| `fulfill` | Positiva | Medio-alto | Éxito, logro, encaje celebratorio | - -La diferencia entre `affirm` y `fulfill` es principalmente de arousal: ambos son positivos, pero `fulfill` es más energético. La diferencia entre `threat` y `risk` es principalmente de valencia intensa + urgencia: `threat` demanda atención inmediata, `risk` es advertencia. - -### 3.3. Tabla de caracterización canónica - -Cada intent modula los cuatro canales con valores concretos. Esta tabla es la referencia canónica que el implementador usa para poblar `sema-map.json`. Los valores son deltas sobre la base de familia — se suman (o reemplazan, según operación) al valor base. - -#### 3.3.1. Canal motion - -| Intent | Duration modifier | Scale delta | Easing tendency | Notas | -|---|---|---|---|---| -| `threat` | × 1.1 | +0.01 amplitud | ease-in-out | Ligeramente más largo y enfático | -| `risk` | × 1.0 (sin cambio) | sin cambio | ease-out | Duración base | -| `neutral` | × 1.0 | sin cambio | ease-out | Referencia | -| `affirm` | × 1.0 | sin cambio | ease-out | Igual que neutral en motion | -| `fulfill` | × 1.15 | +0.02 amplitud | ease-out | Más largo y con más rebote | - -El intent afecta motion de forma sutil — el canal primario de diferenciación emocional es sound y color. Motion solo enfatiza. - -#### 3.3.2. Canal sound - -| Intent | Pitch delta | Contour | Roughness delta | Gain delta | Notas | -|---|---|---|---|---|---| -| `threat` | −200 Hz | descending | +0.4 | +0.1 | Grave, áspero, descendente — tono de alarma | -| `risk` | −100 Hz | (mantiene base) | +0.2 | sin cambio | Ligeramente más grave y rugoso | -| `neutral` | sin cambio | (mantiene base) | sin cambio | sin cambio | Tono base de familia | -| `affirm` | +100 Hz | (mantiene base) | sin cambio | sin cambio | Ligeramente agudo, limpio | -| `fulfill` | +300 Hz | ascending | sin cambio | +0.05 | Agudo, ascendente — celebración | - -El contour es la palanca más distintiva. `descending` comunica cierre/caída/pérdida; `ascending` comunica apertura/logro. La roughness es la palanca de negatividad: +0.4 en threat es la que produce la sensación "áspera" que el cerebro asocia con urgencia. - -#### 3.3.3. Canal color - -| Intent | Hue (override) | Saturation delta | Intensity delta | Notas | -|---|---|---|---|---| -| `threat` | 0° (rojo puro) | +0.2 | +0.2 | Rojo saturado, pulso fuerte | -| `risk` | 30° (ámbar) | +0.1 | sin cambio | Naranja/ámbar de advertencia | -| `neutral` | (mantiene base) | sin cambio | sin cambio | Color base de familia | -| `affirm` | 145° (verde suave) | sin cambio | sin cambio | Verde discreto | -| `fulfill` | 155° (verde vibrante) | +0.1 | +0.15 | Verde más saturado, pulso visible | - -Estos hues son la convención occidental estándar y deben ser sobrescribibles a nivel global (ver §3.6). En contextos culturales donde otros colores son apropiados (rojo como positivo en culturas de Asia oriental, por ejemplo), el integrador sobrescribe los deltas. - -#### 3.3.4. Canal presence - -Los intents afectan presence solo sutilmente — presence expresa aparición/retirada, que es más una cuestión de familia (`emerge`, `sustain`) que de intent. Los deltas son: - -| Intent | Backdrop delta | Shadow delta | Notas | -|---|---|---|---| -| `threat` | +0.1 | +2 blur | Scrim ligeramente más opaco, sombra más pronunciada | -| `risk` | sin cambio | sin cambio | Sin modulación | -| `neutral` | sin cambio | sin cambio | Referencia | -| `affirm` | sin cambio | sin cambio | Sin modulación | -| `fulfill` | sin cambio | +1 blur | Sombra ligeramente más suave (celebratoria) | - -En la mayoría de casos, presence con intent es idéntico a presence sin intent. Solo `threat` y `fulfill` aplican modulación perceptible. - -### 3.4. Algoritmo de combinación en el resolver - -El resolver combina familia base + delta de intent en este orden: - -``` -1. Parse event → (family, intent | null) -2. signature = deep_clone(sema-map.families[family].base) -3. activeChannels = sema-map.families[family].activeChannels -4. if intent is not null: - deltas = sema-map.intents[intent].deltas - for each channel in signature: - if deltas[channel] exists: - signature[channel] = apply_delta(signature[channel], deltas[channel]) -5. Apply runtime overrides (engine.configure) -6. Apply CSEM overrides (if contextEl provided) -7. Apply sound pack (if event has sampleUrl) -8. Return signature -``` - -**Operaciones de delta (recapitulación):** - -- **Número simple**: suma al base (`"pitch": 300` → `effective.pitch = base.pitch + 300`) -- **String**: reemplaza (`"contour": "ascending"` → sustituye `base.contour`) -- **`{op: "multiply", factor: N}`**: multiplica -- **`{op: "replace", value: N}`**: reemplaza -- **`{op: "add", value: N}`**: suma explícita (útil cuando el contexto no permite usar número simple sin ambigüedad) - -### 3.5. Validación de combinaciones malformadas - -El vocabulario canónico define 22 eventos válidos (20 valenciales + 2 transicionales). Combinaciones como `emerge-threat` o `sustain-fulfill` no existen y el runtime debe gestionarlas. - -**Política del resolver:** - -1. **Si el evento recibido no está en `SemaEventLabel`** (p. ej. `emerge-threat`, typo como `comit-fulfill`, o string arbitrario): el resolver registra un warning en dev y cae al evento canónico más cercano por familia. `emerge-threat` cae a `emerge` (ignora el intent sobrante). Un typo como `comit-fulfill` cae a `contact-neutral` como default seguro. - -2. **Si el evento es válido pero la familia no tiene base en `sema-map.json`**: error crítico, lanza excepción. Esto solo ocurre si el mapa está corrompido. - -3. **Si el evento es válido valencial pero el intent no tiene deltas en `sema-map.json`**: el resolver devuelve la firma base de familia sin modificación. Log en dev. - -En producción todas las degradaciones son silenciosas con logs. En dev lanzan warnings visibles. - -**Validación en build time:** el validador `validateSema(spec)` ya comprueba que los eventos declarados en `SemaAction.event` pertenecen a `SemaEventLabel`. Los combinaciones inválidas no deberían llegar al runtime si el spec se valida. - -### 3.6. Overrides culturales - -El integrador puede sobrescribir globalmente la caracterización de un intent via `engine.configure({ mapOverrides })`. Esto es particularmente útil para adaptar los hues a contextos culturales distintos. - -**Ejemplo — contexto asiático oriental donde rojo es positivo:** - -```ts -configureSema({ - mapOverrides: { - 'intents.threat.deltas.color.hue': { op: 'replace', value: 270 }, // violeta - 'intents.fulfill.deltas.color.hue': { op: 'replace', value: 0 } // rojo - } -}); -``` - -**Ejemplo — app silenciosa donde threat debe ser menos agresivo:** - -```ts -configureSema({ - mapOverrides: { - 'intents.threat.deltas.sound.gain': -0.15, - 'intents.threat.deltas.sound.roughness': { op: 'replace', value: 0.2 }, - 'intents.threat.deltas.color.intensity': 0.0 - } -}); -``` - -**Granularidad de overrides:** el path es `{section}.{key}.deltas.{channel}.{param}` donde section puede ser `families`, `intents`, o `soundPack`. El resolver aplica estos overrides en el paso 5 del algoritmo de §3.4, antes de los CSEM overrides (que son más específicos por selector). - -### 3.7. Cuándo intent no debe modificar - -Hay casos donde el intent existe formalmente pero no debe modificar apenas la firma base: - -- **`handle-*` en fase "carry"**: el intent es informativo, pero durante la manipulación continua el canal motion debe ser muy estable (baja frecuencia de actualización, transiciones cortas). El intent solo modula el feedback de inicio/fin de la manipulación, no la fase continua. - -- **`contact-*` en interacciones de alta frecuencia**: un botón pulsado rápidamente no debe tener pulsos cromáticos agresivos aunque el intent sea `threat`. El régimen `collapse` (configurable en el `SemaAction`) coalesce múltiples contactos, y el resolver debe poder leer que el evento está colapsando para aplicar menos intensidad. - -Esta sutileza no se implementa en v1 — el resolver aplica los deltas tal cual. Queda documentado como refinamiento futuro. - ---- - -## 4. El puerto: `port.ts` - -### 3.1. Responsabilidad - -El puerto es la interfaz abstracta entre quien invoca Sema (la capa headless, vía binding) y quien la ejecuta (el engine real, el no-op, o un mock de test). Es el punto de desacoplamiento más importante del sistema. - -### 3.2. Interfaz pública - -```ts -// src/uix/sema/port.ts - -import type { SemaAction, SemaSustainDecl } from './types'; - -/** - * Contrato para que la capa headless invoque Sema sin dependencia directa - * del runtime. Los providers llaman before() / fire() / startSustain() como - * parte de su flujo de acciones. Cuando el engine real no está cargado, el - * port recibido es noopSemaPort y las promesas resuelven inmediatamente — - * el flujo del provider es idéntico en producción con engine, en dev sin - * engine, y en tests con testSemaPort. - * - * NO eliminar las llamadas sema.before() aunque en este momento el port - * sea no-op. Son contrato. - */ -export interface SemaPort { - before(action: ResolvedSemaAction, ctx: SemaContext): Promise; - fire(action: ResolvedSemaAction, ctx: SemaContext): void; - startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession; -} - -export interface SemaSession { - stop(): void; - readonly active: boolean; -} - -/** - * Acción resuelta que el binding pasa al port. Contiene los campos del - * SemaAction original con los defaults aplicados. - */ -export interface ResolvedSemaAction { - name: string; - component: string; // kebab del componente - event: string; // SemaEventLabel - mode: 'blocking' | 'advisory'; - regime: 'replace' | 'collapse' | 'lock' | 'queue'; - scope: 'part' | 'component' | 'scene'; - target: string; // kebab del part primario - /** Atributos DOM ya aplicados por el binding antes de invocar al port. */ - prewritten: readonly { part: string; attr: string; value: string }[]; -} - -export interface ResolvedSemaSustain { - name: string; - component: string; - target: string; - scope: 'part' | 'component' | 'scene'; -} - -/** - * Contexto runtime que la capa headless construye para cada invocación. - * El binding enriquece este contexto con información derivada del SemaSpec. - */ -export interface SemaContext { - /** Elemento DOM del part primario de la acción. */ - targetEl: HTMLElement; - /** Elemento DOM raíz del componente (opcional; para coreografías scope:component). */ - rootEl?: HTMLElement; - /** Otros parts del componente indexados por kebab (opcional). */ - partEls?: Partial>; - /** Snapshot de atributos DOM relevantes en el momento de la invocación. */ - snapshot: Record; - /** Qué originó la invocación. */ - cause?: 'keyboard' | 'pointer' | 'programmatic' | 'validation'; - /** Señal externa de cancelación. */ - abortSignal?: AbortSignal; -} -``` - -### 3.3. Implementación no-op - -```ts -/** - * No-op port. Usado cuando el engine Sema no está cargado o cuando Sema - * está globalmente desactivada. Todas las promesas resuelven inmediatamente; - * las sesiones nacen inactivas. - */ -export const noopSemaPort: SemaPort = { - before: () => Promise.resolve(), - fire: () => { - // No-op deliberado. Llamadas fire() no tienen efecto cuando no hay engine. - }, - startSustain: () => ({ - stop: () => {}, - active: false - }) -}; -``` - -### 3.4. Port de test - -```ts -export interface TestPortOptions { - /** - * Si está definido, `before()` esperará este número de ms antes de resolver. - * Útil para testear secuencialidad en el provider. - */ - beforeDelay?: number; - /** - * Si está definido, `before()` lanzará AbortError si el signal se cancela. - * Default false (cumple las garantías de §6.6 de la spec). - */ - respectAbort?: boolean; -} - -export interface TestCall { - kind: 'before' | 'fire'; - action: ResolvedSemaAction; - ctx: SemaContext; - timestamp: number; -} - -export interface TestSession extends SemaSession { - sustain: ResolvedSemaSustain; - ctx: SemaContext; - stopped: boolean; -} - -export function createTestSemaPort(opts?: TestPortOptions): { - port: SemaPort; - calls: TestCall[]; - sessions: TestSession[]; - /** Resetea calls[] y sessions[]. */ - reset(): void; -} { - const calls: TestCall[] = []; - const sessions: TestSession[] = []; - - const port: SemaPort = { - before: async (action, ctx) => { - calls.push({ kind: 'before', action, ctx, timestamp: performance.now() }); - if (opts?.beforeDelay) { - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, opts.beforeDelay); - if (opts?.respectAbort && ctx.abortSignal) { - ctx.abortSignal.addEventListener('abort', () => { - clearTimeout(timer); - resolve(); // spec: abortar NO lanza, solo cancela - }); - } - }); - } - }, - fire: (action, ctx) => { - calls.push({ kind: 'fire', action, ctx, timestamp: performance.now() }); - }, - startSustain: (sustain, ctx) => { - const session: TestSession = { - stop: () => { session.stopped = true; (session as any).active = false; }, - active: true, - sustain, - ctx, - stopped: false - }; - sessions.push(session); - return session; - } - }; - - return { - port, - calls, - sessions, - reset: () => { calls.length = 0; sessions.length = 0; } - }; -} -``` - -### 3.5. Garantías que el port debe cumplir - -Cualquier implementación de `SemaPort` debe cumplir: - -1. `before()` devuelve una Promise que siempre resuelve (nunca rechaza). -2. Si `ctx.abortSignal` aborta, `before()` resuelve inmediatamente (no rechaza — la cancelación no es error). -3. Si el `targetEl` se desmonta del DOM durante la ejecución, `before()` resuelve tempranamente. -4. `before()` nunca bloquea más del cap global (200ms normal, 80ms con accesibilidad reducida). -5. `fire()` es sincrónico y void; cualquier trabajo async es fire-and-forget. -6. `startSustain()` devuelve una sesión que puede inspeccionarse con `.active` y detenerse con `.stop()`. - -El port no-op cumple todas trivialmente. El engine real tiene que implementarlas explícitamente. - ---- - -## 5. El binding: `binding.ts` - -### 4.1. Responsabilidad - -`createSemaBinding(spec, port)` toma un `SemaSpec` (el contrato declarativo de un componente) y un `SemaPort`, y devuelve helpers tipados que el provider usa para invocar acciones. - -Las responsabilidades del binding son: - -1. Exponer helpers tipados con `ActionName` / `SustainName` derivados del spec -2. Resolver defaults (`mode: blocking`, `regime: replace`, `scope: part`) -3. Validar en dev que los nombres invocados existen en el spec -4. Aplicar los `prewrite` declarados al DOM antes de invocar el port -5. Construir el `SemaContext` completo a partir del context parcial que pasa el provider -6. Delegar al port y devolver la Promise - -### 4.2. API - -```ts -// src/uix/sema/binding.ts - -import type { SemaSpec, SemaAction, SemaSustainDecl } from './types'; -import type { SemaPort, SemaContext, SemaSession, ResolvedSemaAction, ResolvedSemaSustain } from './port'; - -/** - * Helpers derivados de un SemaSpec para invocar acciones semánticas. - */ -export interface SemaBinding { - /** Invocar una acción blocking. Espera a que el engine termine. */ - before(name: ActionName, ctx: PartialSemaContext): Promise; - /** Invocar una acción advisory. No espera. */ - fire(name: ActionName, ctx: PartialSemaContext): void; - /** Iniciar un sustain. Devuelve session para detener. */ - start(name: SustainName, ctx: PartialSemaContext): SemaSession; - /** Introspección: devuelve el SemaAction declarado con ese nombre. */ - action(name: ActionName): SemaAction; -} - -/** - * Contexto que el provider pasa (parcial — el binding rellena lo derivable). - * El provider siempre pasa targetEl; rootEl, partEls, cause y abortSignal - * son opcionales. - */ -export interface PartialSemaContext { - targetEl: HTMLElement; - rootEl?: HTMLElement; - partEls?: Partial>; - cause?: SemaContext['cause']; - abortSignal?: AbortSignal; -} - -// Helper types para derivar unions literales desde el spec. -type ActionName = S['actions'][number]['name']; -type SustainName = - NonNullable[number]['name']; - -export function createSemaBinding( - spec: S, - port: SemaPort -): SemaBinding { - // Indexar acciones y sustains por nombre una sola vez. - const actionsByName = new Map(); - for (const action of spec.actions) { - actionsByName.set(action.name, action); - } - const sustainsByName = new Map(); - for (const sustain of spec.sustains ?? []) { - sustainsByName.set(sustain.name, sustain); - } - - const DEV = process.env.NODE_ENV !== 'production'; - - function resolveAction(name: string): SemaAction { - const action = actionsByName.get(name); - if (!action) { - if (DEV) { - throw new Error( - `[sema] action "${name}" not declared in "${spec.kebab}". ` + - `Declared actions: ${[...actionsByName.keys()].join(', ')}` - ); - } - // En producción, degradar silenciosamente. - return { name, target: { target: '' } as any, event: 'emerge' }; - } - return action; - } - - function buildContext( - partial: PartialSemaContext, - action: SemaAction - ): SemaContext { - // Capturar snapshot de atributos DOM relevantes. - const snapshot: Record = {}; - if (partial.targetEl) { - // Captura data-state, data-last-action, data-starting-style, data-ending-style. - // (El set exacto es convención; ampliable si se necesita.) - const relevantAttrs = [ - 'data-state', - 'data-last-action', - 'data-starting-style', - 'data-ending-style' - ]; - for (const attr of relevantAttrs) { - snapshot[attr] = partial.targetEl.getAttribute(attr); - } - } - - return { - targetEl: partial.targetEl, - rootEl: partial.rootEl, - partEls: partial.partEls, - snapshot, - cause: partial.cause, - abortSignal: partial.abortSignal - }; - } - - function applyPrewrites( - action: SemaAction, - partial: PartialSemaContext - ): ResolvedSemaAction['prewritten'] { - const applied: { part: string; attr: string; value: string }[] = []; - for (const pw of action.prewrite ?? []) { - const partKebab = pw.part.target; - const el = partial.partEls?.[partKebab] ?? partial.targetEl; - if (el) { - el.setAttribute(pw.attr, pw.value); - applied.push({ part: partKebab, attr: pw.attr, value: pw.value }); - } - } - return applied; - } - - function resolveActionToResolved( - 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 resolveSustain(name: string): SemaSustainDecl { - const sustain = sustainsByName.get(name); - if (!sustain) { - if (DEV) { - throw new Error( - `[sema] sustain "${name}" not declared in "${spec.kebab}". ` + - `Declared sustains: ${[...sustainsByName.keys()].join(', ')}` - ); - } - return { name, target: { target: '' } as any, activeWhen: { part: { target: '' } as any, attr: '', value: '' }, event: 'sustain' }; - } - return sustain; - } - - return { - before: async (name, partial) => { - const action = resolveAction(name as string); - const prewritten = applyPrewrites(action, partial); - const resolved = resolveActionToResolved(action, prewritten); - const ctx = buildContext(partial, action); - await port.before(resolved, ctx); - }, - - fire: (name, partial) => { - const action = resolveAction(name as string); - const prewritten = applyPrewrites(action, partial); - const resolved = resolveActionToResolved(action, prewritten); - const ctx = buildContext(partial, action); - port.fire(resolved, ctx); - }, - - start: (name, partial) => { - const sustain = resolveSustain(name as string); - const ctx = buildContext(partial, { name: sustain.name, target: sustain.target, event: 'sustain' } as any); - const resolved: ResolvedSemaSustain = { - name: sustain.name, - component: spec.kebab, - target: sustain.target.target, - scope: (sustain as any).scope ?? 'part' - }; - return port.startSustain(resolved, ctx); - }, - - action: (name) => { - return resolveAction(name as string); - } - }; -} -``` - -### 4.3. Comportamiento en modo no-op - -Cuando el port es `noopSemaPort`, las invocaciones del binding siguen: -- Aplicando prewrites al DOM (esto es responsabilidad del binding, no del engine) -- Construyendo contexto -- Delegando al port - -El port no-op simplemente resuelve inmediatamente sin aplicar coreografía. Pero los prewrites **sí se aplican**. Esto es importante: `data-last-action` queda reflejado en el DOM incluso sin engine, porque la capa visual puede necesitarlo para su propio estilado (ejemplo: dialog cerrado con `data-last-action="failed"` puede mostrar un tinte sutil aunque no haya evento Sema). - -### 4.4. Interacción con desmontaje - -El binding en sí no gestiona desmontaje. Si el provider invoca `binding.before()` y luego el componente se desmonta, el `AbortSignal` del contexto debe cancelarse — es responsabilidad del provider conectar el signal al ciclo de vida del componente. - -Patrón recomendado en frameworks reactivos: - -```ts -// Pseudo-código en provider -const abortController = new AbortController(); - -onMount(() => { /* ... */ }); -onDestroy(() => abortController.abort()); - -async function closeSave() { - await binding.before('close-save', { - targetEl: contentEl, - abortSignal: abortController.signal - }); - if (abortController.signal.aborted) return; - state.open = false; -} -``` - -### 4.5. Testing del binding - -Con `createTestSemaPort` se puede verificar: -- Que `binding.before('name', ctx)` llama a `port.before()` con los argumentos correctos -- Que los prewrites se aplican al DOM antes de la invocación -- Que los defaults se aplican correctamente -- Que acciones con nombre inválido lanzan en dev y degradan en prod - ---- - -## 6. El resolver: `resolver.ts` y `sema-map.json` - -### 5.1. Responsabilidad del Resolver - -Dado un evento canónico (p. ej. `commit-fulfill`) y un contexto de ejecución (element target, overrides aplicables), el Resolver produce una **firma efectiva**: un objeto con los valores por canal que el engine va a aplicar. - -La resolución combina tres fuentes en orden de especificidad: - -1. **sema-map.json** — vocabulario canónico base (factorizado en familia + intent) -2. **CSEM overrides compilados** — JSON generado por el plugin PostCSS desde archivos `.csem` del integrador -3. **Runtime overrides** — configuración global vía `engine.configure({ mapOverrides })` - -El resultado es una `EffectiveSignature` completa. - -### 5.2. Estructura de `sema-map.json` - -```json -{ - "version": "0.4.0", - "families": { - "contact": { - "base": { - "motion": { - "duration": 80, - "easing": "ease-out", - "scale": { "from": 1.0, "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.0, "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.0, "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.0 } - }, - "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.0, "to": 1.0 } - }, - "sound": null, - "color": null, - "presence": null - }, - "activeChannels": ["motion"] - }, - - "sustain": { - "base": { - "motion": null, - "sound": null, - "color": null, - "presence": { - "opacity": { "from": 1, "to": 1 }, - "duration": 0 - } - }, - "activeChannels": ["presence"] - } - }, - - "intents": { - "threat": { - "deltas": { - "motion": { "duration": { "op": "multiply", "factor": 1.1 } }, - "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 - } - } - }, - "risk": { - "deltas": { - "sound": { "pitch": -100, "roughness": 0.2 }, - "color": { "hue": 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 } }, - "sound": { - "pitch": 300, - "contour": "ascending", - "gain": 0.05 - }, - "color": { - "hue": { "op": "replace", "value": 155 }, - "saturation": { "op": "add", "value": 0.1 }, - "intensity": 0.15 - } - } - } - }, - - "soundPack": {} -} -``` - -Nota: los valores concretos arriba son **defaults razonables para primera implementación**. No son calibración final. Se ajustan empíricamente con usuarios reales. - -### 5.3. Operaciones de delta - -Los deltas se combinan con el base según reglas simples: - -- **Número simple** (ej. `"pitch": 300`): suma al base. `base.pitch = 700` + `delta.pitch = 300` → `effective.pitch = 1000`. -- **String**: reemplaza al base. `base.contour = "flat"` + `delta.contour = "ascending"` → `effective.contour = "ascending"`. -- **Objeto con `op: "multiply"`**: multiplica. `base.duration = 200` + `delta = { op: "multiply", factor: 1.2 }` → `effective.duration = 240`. -- **Objeto con `op: "replace"`**: reemplaza. Útil para valores numéricos donde el default se sustituye. `base.hue = 210` + `delta = { op: "replace", value: 0 }` → `effective.hue = 0`. -- **Objeto con `op: "add"`**: suma explícita (para casos donde el número simple no aplica por ambigüedad). `base.saturation = 0.3` + `delta = { op: "add", value: 0.2 }` → `effective.saturation = 0.5`. - -### 5.4. Tipos de la firma efectiva - -```ts -// src/uix/sema/resolver.ts - -export interface MotionSignature { - duration: number; - easing: 'linear' | 'ease-out' | 'ease-in' | 'ease-in-out' | 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; - /** Si presente, sobreescribe síntesis con sample. */ - 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: string; - activeChannels: ('motion' | 'sound' | 'color' | 'presence')[]; - motion?: MotionSignature; - sound?: SoundSignature; - color?: ColorSignature; - presence?: PresenceSignature; -} -``` - -### 5.5. Interfaz del Resolver - -```ts -export interface ResolverOptions { - /** Contenido de sema-map.json. */ - map: SemaMap; - /** Overrides compilados desde .csem. */ - csemOverrides?: CSEMOverrides; - /** Overrides runtime pasados en engine.configure(). */ - runtimeOverrides?: RuntimeOverrides; -} - -export class Resolver { - constructor(opts: ResolverOptions) { /* ... */ } - - /** - * Resolución principal. Dado un evento canónico y un element de contexto, - * produce la firma efectiva combinando base + intent + overrides aplicables. - */ - resolve(event: string, contextEl?: HTMLElement): EffectiveSignature { - // 1. Parsear event en familia e intent. - const [family, intent] = this.parseEvent(event); - - // 2. Obtener base de la familia. - const familyData = this.opts.map.families[family]; - if (!familyData) throw new Error(`Unknown family: ${family}`); - - let signature: EffectiveSignature = { - event, - activeChannels: [...familyData.activeChannels], - motion: familyData.base.motion ?? undefined, - sound: familyData.base.sound ?? undefined, - color: familyData.base.color ?? undefined, - presence: familyData.base.presence ?? undefined - }; - - // 3. Aplicar delta de intent (si es familia valencial). - if (intent) { - const intentData = this.opts.map.intents[intent]; - if (intentData) { - signature = this.applyDelta(signature, intentData.deltas); - } - } - - // 4. Aplicar runtime overrides globales. - signature = this.applyRuntimeOverrides(signature, event); - - // 5. Aplicar CSEM overrides contextuales. - // Los overrides de .csem pueden targetearse por selector CSS; el resolver - // consulta el elemento contextual para ver qué selectores matchean. - if (contextEl) { - signature = this.applyCSEMOverrides(signature, event, contextEl); - } - - // 6. Aplicar sample pack si existe. - if (signature.activeChannels.includes('sound')) { - const sampleUrl = this.opts.map.soundPack?.[event]; - if (sampleUrl && signature.sound) { - signature.sound.sampleUrl = sampleUrl; - } - } - - return signature; - } - - private parseEvent(event: string): [string, string | null] { - // 'commit-fulfill' → ['commit', 'fulfill'] - // 'emerge' → ['emerge', null] - const parts = event.split('-'); - if (parts.length === 1) return [parts[0], null]; - return [parts[0], parts.slice(1).join('-')]; - } - - private applyDelta( - signature: EffectiveSignature, - deltas: Record - ): EffectiveSignature { - // Implementación de la combinación descrita en §5.3. - // (Detalle omitido por brevedad; straightforward.) - return signature; - } - - // applyRuntimeOverrides, applyCSEMOverrides: similar structure. -} -``` - -### 5.6. Caching - -El Resolver puede cachear firmas resueltas por `(event, contextElId)` para evitar re-resolver en cada invocación cuando nada cambia. La clave del cache debe incluir: -- El evento -- Un hash de los overrides de `.csem` aplicables (si el .csem cambia en HMR, se invalida) -- Los runtime overrides activos - -Política de cache: LRU con tamaño máximo 100 entradas. Invalidación total en `engine.configure()`. - ---- - -## 7. El engine: `engine.ts` - -### 6.1. Responsabilidad - -El engine es el runtime real que coordina todo. Sus responsabilidades: - -1. Ser implementación concreta de `SemaPort` -2. Mantener registro de coreografías activas por target -3. Implementar los 4 regímenes (`replace`, `collapse`, `lock`, `queue`) -4. Consultar el Resolver para obtener firmas -5. Consultar a11y para aplicar reducciones -6. Delegar a los canales la aplicación concreta -7. Gestionar caps temporales -8. Emitir `CustomEvent('sema:event')` -9. Gestionar lifecycle (cancelación, desmontaje) - -### 6.2. Arquitectura - -Una sola instancia por document, creada lazy: - -```ts -// src/uix/sema/engine.ts - -let _engineInstance: SemaEngine | null = null; - -export function getEngine(): SemaEngine { - if (!_engineInstance) { - _engineInstance = new SemaEngine(); - } - return _engineInstance; -} - -/** - * Solo para tests. Resetea la instancia. - */ -export function _resetEngineForTesting(): void { - if (_engineInstance) { - _engineInstance.destroy(); - _engineInstance = null; - } -} -``` - -### 6.3. Clase SemaEngine - -```ts -export interface EngineConfig { - sound: { enabled: boolean; gain: number }; - motion: { enabled: boolean }; - color: { enabled: boolean }; - presence: { enabled: boolean }; - reflectEvents: boolean; - capBlockingMs: number; // default 200 - capBlockingReducedMs: number; // default 80 - mapOverrides?: RuntimeOverrides; -} - -const DEFAULT_CONFIG: EngineConfig = { - sound: { enabled: false, gain: 0.8 }, // sound disabled by default - motion: { enabled: true }, - color: { enabled: true }, - presence: { enabled: true }, - reflectEvents: false, - capBlockingMs: 200, - capBlockingReducedMs: 80 -}; - -export class SemaEngine implements SemaPort { - private config: EngineConfig = { ...DEFAULT_CONFIG }; - private resolver: Resolver; - private motion: MotionChannel; - private sound: SoundChannel; - private color: ColorChannel; - private presence: PresenceChannel; - private a11y: A11yMonitor; - private activeChoreographies: Map; - private sustainSessions: Set; - - constructor() { - this.resolver = new Resolver({ map: defaultMap }); - this.motion = new MotionChannel(); - this.sound = new SoundChannel(); - this.color = new ColorChannel(); - this.presence = new PresenceChannel(); - this.a11y = new A11yMonitor(); - this.activeChoreographies = new Map(); - this.sustainSessions = new Set(); - } - - configure(config: Partial): void { - this.config = { ...this.config, ...config }; - if (config.mapOverrides) { - this.resolver.setRuntimeOverrides(config.mapOverrides); - } - } - - destroy(): void { - // Cancelar todas las coreografías activas. - for (const c of this.activeChoreographies.values()) { - c.cancel(); - } - this.activeChoreographies.clear(); - // Detener todos los sustains. - for (const s of this.sustainSessions) { - s.stop(); - } - this.sustainSessions.clear(); - // Canales. - this.sound.destroy(); - this.motion.destroy(); - this.color.destroy(); - this.presence.destroy(); - } - - // ── SemaPort implementation ───────────────────────────────────── - - async before(action: ResolvedSemaAction, ctx: SemaContext): Promise { - const key = this.choreographyKey(action, ctx); - - // Aplicar régimen. - const existing = this.activeChoreographies.get(key); - if (existing) { - switch (action.regime) { - case 'replace': - existing.cancel(); - this.activeChoreographies.delete(key); - break; - case 'collapse': - // Single-flight coalescing: marcar repetición y devolver la misma promise. - existing.markRepeated(); - return existing.promise; - case 'lock': - // Rechazar silenciosamente (la promise resuelve inmediatamente). - return; - case 'queue': - // Esperar a que termine la actual, luego ejecutar. - await existing.promise; - break; - } - } - - // Resolver firma. - const signature = this.resolver.resolve(action.event, ctx.targetEl); - - // Aplicar a11y. - const reducedSignature = this.a11y.reduceSignature(signature, this.config); - - // Calcular cap temporal efectivo. - const isReduced = this.a11y.hasActiveReduction(); - const cap = isReduced ? this.config.capBlockingReducedMs : this.config.capBlockingMs; - - // Crear y registrar coreografía. - const choreography = new Choreography( - action, - ctx, - reducedSignature, - cap, - this - ); - this.activeChoreographies.set(key, choreography); - - // Emitir start. - this.emitCustomEvent(ctx.targetEl, action, 'start', reducedSignature); - - // Reflejar en DOM si está habilitado. - if (this.config.reflectEvents) { - ctx.targetEl.setAttribute('data-sema-active', action.event); - ctx.targetEl.setAttribute('data-sema-phase', 'active'); - } - - try { - await choreography.run(); - // Emitir end. - this.emitCustomEvent(ctx.targetEl, action, 'end', reducedSignature); - } catch (err) { - // Cancelada por abort o desmontaje — no propagar. - this.emitCustomEvent(ctx.targetEl, action, 'cancelled', reducedSignature); - } finally { - if (this.config.reflectEvents) { - ctx.targetEl.removeAttribute('data-sema-active'); - ctx.targetEl.removeAttribute('data-sema-phase'); - } - this.activeChoreographies.delete(key); - } - } - - fire(action: ResolvedSemaAction, ctx: SemaContext): void { - // Fire-and-forget. No esperar, no gestionar régimen complejo - // (regime='replace' es el único sensato aquí). - this.before(action, ctx).catch(() => {}); - } - - startSustain(sustain: ResolvedSemaSustain, ctx: SemaContext): SemaSession { - const runner = new SustainRunner(sustain, ctx, this); - this.sustainSessions.add(runner); - runner.start(); - return { - stop: () => { - runner.stop(); - this.sustainSessions.delete(runner); - }, - get active() { return runner.active; } - }; - } - - // ── Internal helpers ──────────────────────────────────────────── - - private choreographyKey(action: ResolvedSemaAction, ctx: SemaContext): string { - // Clave de equivalencia: misma acción sobre mismo target. - // Usamos referencia al element + nombre de acción. - const elId = this.elementId(ctx.targetEl); - return `${action.component}:${action.name}:${elId}`; - } - - private elementId(el: HTMLElement): string { - // WeakMap para asociar IDs únicos a elementos sin leak. - if (!this.elIds) this.elIds = new WeakMap(); - let id = this.elIds.get(el); - if (!id) { - id = `el-${this.nextElId++}`; - this.elIds.set(el, id); - } - return id; - } - private elIds?: WeakMap; - private nextElId = 0; - - private emitCustomEvent( - target: HTMLElement, - action: ResolvedSemaAction, - phase: 'start' | 'end' | 'cancelled', - signature: EffectiveSignature - ): void { - target.dispatchEvent(new CustomEvent('sema:event', { - bubbles: true, - detail: { - event: action.event, - action: action.name, - component: action.component, - phase, - channels: signature.activeChannels, - duration: this.durationOfSignature(signature) - } - })); - } - - private durationOfSignature(sig: EffectiveSignature): number { - const candidates = [ - sig.motion?.duration, - sig.sound?.duration, - sig.color?.duration, - sig.presence?.duration - ].filter((d): d is number => d !== undefined); - return candidates.length ? Math.max(...candidates) : 0; - } - - // Getters públicos para que la Choreography acceda a los canales. - get channels() { - return { - motion: this.motion, - sound: this.sound, - color: this.color, - presence: this.presence - }; - } - - get currentConfig(): EngineConfig { - return this.config; - } -} -``` - -### 6.4. Clase Choreography - -Encapsula una coreografía en ejecución. Gestiona el ciclo de vida (creación, run, cancelación), respeta caps, coordina canales. - -```ts -class Choreography { - readonly promise: Promise; - private resolvePromise!: () => void; - private cancelled = false; - private repeatedCount = 0; - private abortController: AbortController; - - constructor( - private action: ResolvedSemaAction, - private ctx: SemaContext, - private signature: EffectiveSignature, - private capMs: number, - private engine: SemaEngine - ) { - this.promise = new Promise((resolve) => { - this.resolvePromise = resolve; - }); - this.abortController = new AbortController(); - - // Si el ctx tiene abortSignal, propagarlo. - if (ctx.abortSignal) { - ctx.abortSignal.addEventListener('abort', () => this.cancel()); - } - } - - markRepeated(): void { - this.repeatedCount++; - } - - cancel(): void { - if (this.cancelled) return; - this.cancelled = true; - this.abortController.abort(); - this.resolvePromise(); - } - - async run(): Promise { - // Verificar que el target sigue conectado antes de empezar. - if (!this.ctx.targetEl.isConnected) { - this.resolvePromise(); - return; - } - - // Lanzar canales concurrentemente. - const channelPromises: Promise[] = []; - const config = this.engine.currentConfig; - const channels = this.engine.channels; - - if (this.signature.activeChannels.includes('motion') && config.motion.enabled && this.signature.motion) { - channelPromises.push( - 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( - 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( - 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( - channels.presence.apply(this.ctx.targetEl, this.signature.presence, this.abortController.signal) - ); - } - - // Si no hay canales activos, resolver inmediatamente. - if (channelPromises.length === 0) { - this.resolvePromise(); - return; - } - - // Cap temporal: el engine libera el await al cap, pero los canales continúan. - const capPromise = new Promise((resolve) => { - setTimeout(() => resolve(), this.capMs); - }); - - // Esperar al primero entre: todos los canales terminan, o el cap vence. - await Promise.race([ - Promise.all(channelPromises).then(() => {}), - capPromise - ]); - - this.resolvePromise(); - - // Los canales que sigan corriendo después del cap son "tails post-state" - // y continúan hasta completar naturalmente o cancelación. - } -} -``` - -### 6.5. Clase SustainRunner - -Gestiona un sustain activo. Distinto de Choreography porque no tiene duración fija. - -```ts -class SustainRunner { - active = true; - private motionCleanup?: () => void; - private presenceCleanup?: () => void; - private soundCleanup?: () => void; - - constructor( - private sustain: ResolvedSemaSustain, - private ctx: SemaContext, - private engine: SemaEngine - ) {} - - start(): void { - const signature = this.engine.resolver.resolve('sustain', this.ctx.targetEl); - const reduced = this.engine.a11y.reduceSignature(signature, this.engine.currentConfig); - const config = this.engine.currentConfig; - - if (reduced.activeChannels.includes('motion') && config.motion.enabled && reduced.motion) { - this.motionCleanup = this.engine.channels.motion.applySustained( - this.ctx.targetEl, - reduced.motion - ); - } - if (reduced.activeChannels.includes('presence') && config.presence.enabled && reduced.presence) { - this.presenceCleanup = this.engine.channels.presence.applySustained( - this.ctx.targetEl, - reduced.presence - ); - } - // Sound no suele activarse en sustain por default; depende del mapa. - } - - stop(): void { - if (!this.active) return; - this.active = false; - this.motionCleanup?.(); - this.presenceCleanup?.(); - this.soundCleanup?.(); - } -} -``` - -### 6.6. API pública del engine - -```ts -// Exportado en src/uix/sema/index.ts - -export function getSemaEngine(): SemaEngine { - return getEngine(); -} - -export function configureSema(config: Partial): void { - getEngine().configure(config); -} - -export function destroySema(): void { - if (_engineInstance) { - _engineInstance.destroy(); - _engineInstance = null; - } -} - -// Para test-only: -export { _resetEngineForTesting }; -``` - ---- - -## 8. Canal motion: `channels/motion.ts` - -### 7.1. Responsabilidad - -Aplica dinámicas espaciales (scale, translate, rotate) usando Web Animations API. Coopera con transforms CSS existentes mediante `composite: 'add'` cuando la firma lo requiera. - -### 7.2. Interfaz - -```ts -// src/uix/sema/channels/motion.ts - -import type { MotionSignature } from '../resolver'; - -export class MotionChannel { - private activeAnimations = new WeakMap(); - - /** - * Aplica la firma motion al target. Devuelve promise que resuelve cuando - * la animación termina o se cancela. - */ - async apply( - target: HTMLElement, - signature: MotionSignature, - abortSignal: AbortSignal - ): Promise { - if (!target.isConnected) return; - - const keyframes = this.signatureToKeyframes(signature); - const options = this.signatureToOptions(signature); - - const animation = target.animate(keyframes, options); - this.trackAnimation(target, animation); - - if (abortSignal.aborted) { - animation.cancel(); - return; - } - abortSignal.addEventListener('abort', () => animation.cancel()); - - try { - await animation.finished; - } catch { - // Cancelada. - } finally { - this.untrackAnimation(target, animation); - } - } - - /** - * Variante sustained (sin promise — devuelve cleanup). - */ - applySustained(target: HTMLElement, signature: MotionSignature): () => void { - if (!target.isConnected) return () => {}; - - const keyframes = this.signatureToKeyframes(signature); - const options: KeyframeAnimationOptions = { - duration: signature.duration || 1000, - easing: signature.easing, - iterations: Infinity, - composite: 'add' - }; - - const animation = target.animate(keyframes, options); - this.trackAnimation(target, animation); - - return () => { - animation.cancel(); - this.untrackAnimation(target, animation); - }; - } - - destroy(): void { - // No necesario — las animations se limpian por el WeakMap + DOM lifecycle. - } - - private signatureToKeyframes(sig: MotionSignature): Keyframe[] { - const from: Keyframe = {}; - const to: Keyframe = {}; - - const transforms: string[] = []; - const transformsTo: string[] = []; - - if (sig.scale) { - transforms.push(`scale(${sig.scale.from})`); - transformsTo.push(`scale(${sig.scale.to})`); - } - if (sig.translate) { - transforms.push(`translate(${sig.translate.x}px, ${sig.translate.y}px)`); - transformsTo.push(`translate(${sig.translate.x}px, ${sig.translate.y}px)`); - } - if (sig.rotate !== undefined) { - transforms.push(`rotate(${sig.rotate}deg)`); - transformsTo.push(`rotate(${sig.rotate}deg)`); - } - - if (transforms.length > 0) { - from.transform = transforms.join(' '); - to.transform = transformsTo.join(' '); - } - - return [from, to]; - } - - private signatureToOptions(sig: MotionSignature): KeyframeAnimationOptions { - return { - duration: sig.duration, - easing: sig.easing, - fill: 'none', // No retener estado al terminar. - composite: 'add' // Sumar al transform existente. - }; - } - - 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 idx = existing.indexOf(animation); - if (idx >= 0) existing.splice(idx, 1); - } -} -``` - -### 7.3. Comportamiento aditivo - -El uso de `composite: 'add'` es clave: permite que el transform de Sema se sume al transform en reposo de la capa visual. Si un botón tiene `transform: scale(0.98)` por `:active`, y Sema aplica un scale pulse, el resultado final es la composición, no el reemplazo. - -### 7.4. Fallback - -Si `Animation.finished` lanza por razones raras (navegador muy antiguo), `apply()` debe resolver sin error — la coreografía simplemente no tuvo motion, pero el resto del sistema sigue. - ---- - -## 9. Canal sound: `channels/sound.ts` - -### 8.1. Responsabilidad - -Genera earcons cortos a partir de la firma psicoacústica usando Web Audio API. Híbrido: síntesis procedural base; si la firma incluye `sampleUrl`, lo reproduce como sample con ajustes de gain. - -### 8.2. Arquitectura de síntesis - -Para cada earcon se construye un grafo Web Audio con esta estructura: - -``` -[Oscillator1 (main)] ──┐ - ├──> [Mixer Gain] ──> [Filter (low-pass)] ──> [ADSR Envelope] ──> [Master Gain] ──> destination -[Oscillator2 (fifth)] ─┘ │ - │ - [Ring Modulator (opcional)] ────────── -``` - -- **Oscillator1**: sine base con pitch de la firma -- **Oscillator2**: sine una quinta por encima (pitch × 1.5), mezclado al 30% para añadir cuerpo -- **Mixer Gain**: combina ambos osciladores -- **Filter**: low-pass con frecuencia de corte = `signature.centroid`, Q bajo (~1) -- **ADSR Envelope**: applied via GainNode con `setValueCurveAtTime` -- **Ring Modulator**: si `roughness > 0.2`, añadir un modulador en banda 30-150 Hz con profundidad proporcional a roughness - -El contour melódico se aplica modulando el detune del oscilador principal: - -- `flat`: detune constante 0 -- `ascending`: detune linealmente de -50 a +50 cents a lo largo de la duración -- `descending`: detune linealmente de +50 a -50 cents -- `arc`: detune sigue curva bell (centro pico) -- `bell`: detune sigue curva bell invertida - -### 8.3. Gestión del AudioContext - -```ts -// src/uix/sema/channels/sound.ts - -export class SoundChannel { - private audioCtx: AudioContext | null = null; - private masterGain: GainNode | null = null; - private unlocked = false; - private pendingUnlockCallbacks: Array<() => void> = []; - - private getOrCreateContext(): AudioContext | null { - if (!this.audioCtx) { - try { - this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); - this.masterGain = this.audioCtx.createGain(); - this.masterGain.connect(this.audioCtx.destination); - this.setupUnlockListener(); - } catch { - return null; - } - } - // Si el context está suspended (navegador) — intentar resume. - if (this.audioCtx.state === 'suspended') { - this.audioCtx.resume().catch(() => {}); - } - return this.audioCtx; - } - - private setupUnlockListener(): void { - // Safari / mobile: AudioContext locked hasta user gesture. - const unlock = () => { - if (!this.audioCtx) return; - if (this.audioCtx.state === 'suspended') { - this.audioCtx.resume(); - } - this.unlocked = true; - // Desregistrar listeners. - ['click', 'touchstart', 'keydown'].forEach(ev => - document.removeEventListener(ev, unlock, true) - ); - }; - ['click', 'touchstart', 'keydown'].forEach(ev => - document.addEventListener(ev, unlock, true) - ); - } - - async apply( - signature: SoundSignature, - masterGainValue: number, - abortSignal: AbortSignal - ): Promise { - const ctx = this.getOrCreateContext(); - if (!ctx || ctx.state !== 'running') { - // AudioContext no disponible o locked — no sonar silenciosamente. - return; - } - - if (this.masterGain) { - this.masterGain.gain.value = masterGainValue; - } - - if (signature.sampleUrl) { - return this.playSample(ctx, signature, abortSignal); - } else { - return this.synthesize(ctx, signature, abortSignal); - } - } - - // Implementación de synthesize y playSample en secciones siguientes. - - destroy(): void { - if (this.audioCtx) { - this.audioCtx.close(); - this.audioCtx = null; - this.masterGain = null; - } - } -} -``` - -### 8.4. Síntesis procedural - -```ts -private async synthesize( - ctx: AudioContext, - sig: SoundSignature, - abortSignal: AbortSignal -): Promise { - const now = ctx.currentTime; - const durationSec = sig.duration / 1000; - const attackSec = sig.attack / 1000; - const decaySec = sig.decay / 1000; - - // 1. Oscilador principal. - const osc1 = ctx.createOscillator(); - osc1.type = 'sine'; - osc1.frequency.value = sig.pitch; - - // 2. Oscilador secundario (quinta). - const osc2 = ctx.createOscillator(); - osc2.type = 'sine'; - osc2.frequency.value = sig.pitch * 1.5; - - // 3. Mixer. - const mixer = ctx.createGain(); - mixer.gain.value = 1.0; - const osc2Gain = ctx.createGain(); - osc2Gain.gain.value = 0.3; - osc1.connect(mixer); - osc2.connect(osc2Gain); - osc2Gain.connect(mixer); - - // 4. Filter low-pass. - const filter = ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.value = sig.centroid; - filter.Q.value = 1; - mixer.connect(filter); - - // 5. ADSR envelope. - const envelope = ctx.createGain(); - envelope.gain.setValueAtTime(0, now); - envelope.gain.linearRampToValueAtTime(sig.gain, now + attackSec); - envelope.gain.linearRampToValueAtTime(0, now + durationSec); - filter.connect(envelope); - - // 6. Ring modulator si roughness > 0.2. - let lastNode: AudioNode = envelope; - if (sig.roughness > 0.2) { - const modFreq = 30 + (sig.roughness - 0.2) * 150; - const modulator = ctx.createOscillator(); - modulator.frequency.value = modFreq; - const modulatorGain = ctx.createGain(); - modulatorGain.gain.value = sig.roughness * 0.5; - modulator.connect(modulatorGain.gain); - modulatorGain.connect(envelope.gain); - modulator.start(now); - modulator.stop(now + durationSec); - } - - // 7. Contour (modulación de detune). - this.applyContour(osc1, sig.contour, now, durationSec); - - // 8. Master gain. - lastNode.connect(this.masterGain!); - - osc1.start(now); - osc2.start(now); - osc1.stop(now + durationSec); - osc2.stop(now + durationSec); - - // 9. Esperar a que termine o abortar. - return new Promise((resolve) => { - const timer = setTimeout(resolve, sig.duration); - abortSignal.addEventListener('abort', () => { - clearTimeout(timer); - osc1.stop(); - osc2.stop(); - resolve(); - }); - }); -} - -private applyContour( - osc: OscillatorNode, - contour: string, - 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; - } -} -``` - -### 8.5. Sample playback - -```ts -private sampleCache = new Map(); - -private async playSample( - ctx: AudioContext, - sig: SoundSignature, - abortSignal: AbortSignal -): Promise { - if (!sig.sampleUrl) return; - - let buffer = this.sampleCache.get(sig.sampleUrl); - if (!buffer) { - try { - const response = await fetch(sig.sampleUrl); - const arrayBuffer = await response.arrayBuffer(); - buffer = await ctx.decodeAudioData(arrayBuffer); - this.sampleCache.set(sig.sampleUrl, buffer); - } catch { - return; // Fallar silenciosamente. - } - } - - if (abortSignal.aborted) return; - - const source = ctx.createBufferSource(); - source.buffer = buffer; - - const envelope = ctx.createGain(); - envelope.gain.value = sig.gain; - source.connect(envelope); - envelope.connect(this.masterGain!); - - source.start(); - - return new Promise((resolve) => { - source.onended = () => resolve(); - abortSignal.addEventListener('abort', () => { - source.stop(); - resolve(); - }); - }); -} -``` - -### 8.6. Preload opcional - -Para eventos críticos donde la latencia de fetch importa, el engine puede precargar samples: - -```ts -export class SoundChannel { - async preloadSamples(urls: string[]): Promise { - const ctx = this.getOrCreateContext(); - if (!ctx) return; - await Promise.all(urls.map(async url => { - if (this.sampleCache.has(url)) return; - try { - const response = await fetch(url); - const ab = await response.arrayBuffer(); - const buf = await ctx.decodeAudioData(ab); - this.sampleCache.set(url, buf); - } catch {} - })); - } -} -``` - -Exposado vía `configureSema({ preloadSamples: [...] })`. - ---- - -## 10. Canal color: `channels/color.ts` - -### 9.1. Responsabilidad - -Expresa pulso cromático temporal. El color en reposo es responsabilidad de la capa visual; este canal aplica un pulso superpuesto usando técnicas aditivas (box-shadow) que no modifican las propiedades CSS que la capa visual controla. - -### 9.2. Estrategia de aplicación - -Tres técnicas posibles, elegidas según disponibilidad y preferencia: - -**Técnica primaria — box-shadow ring:** - -```css -box-shadow: 0 0 0 var(--sema-color-ring-width) hsl(...); -``` - -Se anima `var(--sema-color-ring-width)` de 0 a un valor (p. ej. 3px) y de vuelta a 0. La capa visual puede tener su propio `box-shadow` para estados (focus, etc.); Sema compone con `box-shadow: previous, new`. - -**Técnica secundaria — overlay pseudoelement:** - -Si el target no soporta box-shadow limpio (ej. `display: inline`), crear un pseudoelemento vía inyección temporal: - -```html -... - -``` - -El overlay se posiciona absolute, mismo tamaño, con `border-radius` derivado, y anima su color. - -**Técnica terciaria — outline:** - -Fallback cuando las dos primeras no funcionan. `outline` es siempre aditivo por definición (no afecta layout). - -### 9.3. Implementación - -```ts -// src/uix/sema/channels/color.ts - -import type { ColorSignature } from '../resolver'; - -export class ColorChannel { - private activeOverlays = new WeakMap(); - private originalBoxShadows = new WeakMap(); - - async apply( - target: HTMLElement, - signature: ColorSignature, - abortSignal: AbortSignal - ): Promise { - if (!target.isConnected) return; - - // Decidir técnica. - const technique = this.selectTechnique(target); - - switch (technique) { - case 'box-shadow': - return this.applyBoxShadow(target, signature, abortSignal); - case 'overlay': - return this.applyOverlay(target, signature, abortSignal); - case 'outline': - return this.applyOutline(target, signature, abortSignal); - } - } - - private selectTechnique(target: HTMLElement): 'box-shadow' | 'overlay' | 'outline' { - const computed = window.getComputedStyle(target); - // display inline no soporta bien box-shadow/outline. - if (computed.display === 'inline') return 'overlay'; - // Algunos targets necesitan outline por diseño. - if (target.dataset.semaColorTechnique === 'outline') return 'outline'; - return 'box-shadow'; - } - - private async applyBoxShadow( - target: HTMLElement, - sig: ColorSignature, - abortSignal: AbortSignal - ): Promise { - const color = `hsl(${sig.hue}, ${sig.saturation * 100}%, ${sig.lightness * 100}%)`; - const peakWidth = Math.round(sig.intensity * 8); // hasta 8px - const duration = sig.duration; - - // Capturar box-shadow original si hay. - const original = window.getComputedStyle(target).boxShadow; - const previousShadow = original === 'none' ? '' : original; - - // Animar usando Web Animations API para no tocar style. - const keyframes: Keyframe[] = [ - { boxShadow: this.composeShadow(previousShadow, 0, color) }, - { boxShadow: this.composeShadow(previousShadow, peakWidth, color), offset: 0.3 }, - { boxShadow: this.composeShadow(previousShadow, 0, color) } - ]; - - const animation = target.animate(keyframes, { - duration, - easing: 'ease-out', - fill: 'none' - }); - - if (abortSignal.aborted) { - animation.cancel(); - return; - } - abortSignal.addEventListener('abort', () => animation.cancel()); - - try { - await animation.finished; - } catch {} - } - - private composeShadow(previous: string, width: number, color: string): string { - const pulse = `0 0 0 ${width}px ${color}`; - return previous ? `${previous}, ${pulse}` : pulse; - } - - private async applyOverlay( - target: HTMLElement, - sig: ColorSignature, - abortSignal: AbortSignal - ): Promise { - const overlay = document.createElement('span'); - overlay.setAttribute('data-sema-temp', ''); - overlay.style.cssText = ` - position: absolute; - inset: 0; - pointer-events: none; - border-radius: inherit; - box-shadow: 0 0 0 0 hsl(${sig.hue}, ${sig.saturation * 100}%, ${sig.lightness * 100}%); - opacity: 0; - `; - - // Asegurar contenedor positioned. - const parent = target.parentElement; - if (!parent) return; - const parentStyle = window.getComputedStyle(parent); - if (parentStyle.position === 'static') { - parent.style.position = 'relative'; - } - parent.appendChild(overlay); - - this.trackOverlay(target, overlay); - - const animation = overlay.animate([ - { boxShadow: `0 0 0 0 currentColor`, opacity: 0 }, - { boxShadow: `0 0 0 ${Math.round(sig.intensity * 8)}px currentColor`, opacity: 1, offset: 0.3 }, - { boxShadow: `0 0 0 0 currentColor`, opacity: 0 } - ], { - duration: sig.duration, - easing: 'ease-out' - }); - - if (abortSignal.aborted) { - animation.cancel(); - overlay.remove(); - return; - } - abortSignal.addEventListener('abort', () => { - animation.cancel(); - overlay.remove(); - }); - - try { - await animation.finished; - } finally { - overlay.remove(); - this.untrackOverlay(target, overlay); - } - } - - private async applyOutline( - target: HTMLElement, - sig: ColorSignature, - abortSignal: AbortSignal - ): Promise { - const color = `hsl(${sig.hue}, ${sig.saturation * 100}%, ${sig.lightness * 100}%)`; - const peakWidth = Math.max(2, Math.round(sig.intensity * 4)); - - const keyframes: Keyframe[] = [ - { outline: `0px solid ${color}` }, - { outline: `${peakWidth}px solid ${color}`, offset: 0.3 }, - { outline: `0px solid ${color}` } - ]; - - const animation = target.animate(keyframes, { - duration: sig.duration, - easing: 'ease-out' - }); - if (abortSignal.aborted) { - animation.cancel(); - return; - } - abortSignal.addEventListener('abort', () => animation.cancel()); - - try { - await animation.finished; - } catch {} - } - - private trackOverlay(target: HTMLElement, overlay: HTMLElement): void { - const existing = this.activeOverlays.get(target) ?? []; - existing.push(overlay); - this.activeOverlays.set(target, existing); - } - private untrackOverlay(target: HTMLElement, overlay: HTMLElement): void { - const existing = this.activeOverlays.get(target); - if (!existing) return; - const idx = existing.indexOf(overlay); - if (idx >= 0) existing.splice(idx, 1); - } - - destroy(): void { - // Limpiar overlays pendientes — rastreo por WeakMap no permite iterar, - // pero los overlays con data-sema-temp se pueden barrer del DOM. - document.querySelectorAll('[data-sema-temp]').forEach(el => el.remove()); - } -} -``` - ---- - -## 11. Canal presence: `channels/presence.ts` - -### 10.1. Responsabilidad - -Gestiona segregación figura-fondo: opacity, shadow de elevación, backdrop scrim, outline emergente. Coordina con la estructura del componente para colocar backdrop al nivel correcto. - -### 10.2. Estrategia - -- **opacity**: se aplica directamente al target con WAAPI (composite: 'replace' — opacity no compone bien con add) -- **shadow**: box-shadow superpuesto con técnica aditiva similar a color -- **backdrop**: overlay full-viewport o contenedor padre, inyectado y removido -- **outline**: aditivo por definición, sin riesgo de colisión - -### 10.3. Implementación - -```ts -// src/uix/sema/channels/presence.ts - -import type { PresenceSignature } from '../resolver'; - -export class PresenceChannel { - private backdrops = new WeakMap(); - - async apply( - target: HTMLElement, - signature: PresenceSignature, - abortSignal: AbortSignal - ): Promise { - if (!target.isConnected) return; - - const promises: Promise[] = []; - - // Opacity — se aplica al target directamente. - if (signature.opacity) { - promises.push(this.applyOpacity(target, signature, abortSignal)); - } - - // Shadow — aditivo. - if (signature.shadow) { - promises.push(this.applyShadow(target, signature, abortSignal)); - } - - // Backdrop — overlay en viewport root. - if (signature.backdrop !== undefined && signature.backdrop > 0) { - promises.push(this.applyBackdrop(target, signature, abortSignal)); - } - - // Outline. - if (signature.outline) { - promises.push(this.applyOutline(target, signature, abortSignal)); - } - - await Promise.all(promises); - } - - applySustained(target: HTMLElement, signature: PresenceSignature): () => void { - if (!target.isConnected) return () => {}; - - const cleanups: Array<() => void> = []; - - // Backdrop sustained (dialog abierto). - if (signature.backdrop !== undefined && signature.backdrop > 0) { - const backdrop = this.createBackdrop(signature); - document.body.appendChild(backdrop); - cleanups.push(() => backdrop.remove()); - } - - return () => cleanups.forEach(c => c()); - } - - private async applyOpacity( - target: HTMLElement, - sig: PresenceSignature, - abortSignal: AbortSignal - ): Promise { - const animation = target.animate([ - { opacity: sig.opacity.from }, - { opacity: sig.opacity.to } - ], { - duration: sig.duration, - easing: sig.easing, - fill: 'forwards' // mantener el valor final hasta que la capa visual tome control - }); - if (abortSignal.aborted) { animation.cancel(); return; } - abortSignal.addEventListener('abort', () => animation.cancel()); - try { await animation.finished; } catch {} - } - - private async applyShadow( - target: HTMLElement, - sig: PresenceSignature, - abortSignal: AbortSignal - ): Promise { - if (!sig.shadow) return; - const { blur, y, opacity } = sig.shadow; - const original = window.getComputedStyle(target).boxShadow; - const previousShadow = original === 'none' ? '' : original; - const shadowEnd = `0 ${y}px ${blur}px rgba(0,0,0,${opacity})`; - const shadowStart = previousShadow || 'none'; - - const animation = target.animate([ - { boxShadow: this.composeShadow(previousShadow, 'none') }, - { boxShadow: this.composeShadow(previousShadow, shadowEnd) } - ], { - duration: sig.duration, - easing: sig.easing - }); - if (abortSignal.aborted) { animation.cancel(); return; } - abortSignal.addEventListener('abort', () => animation.cancel()); - try { await animation.finished; } catch {} - } - - private composeShadow(previous: string, pulse: string): string { - if (pulse === 'none') return previous || 'none'; - return previous ? `${previous}, ${pulse}` : pulse; - } - - private createBackdrop(sig: PresenceSignature): HTMLElement { - const backdrop = document.createElement('div'); - backdrop.setAttribute('data-sema-backdrop', ''); - backdrop.style.cssText = ` - position: fixed; - inset: 0; - background: rgba(0, 0, 0, ${sig.backdrop}); - backdrop-filter: blur(4px); - pointer-events: none; - z-index: 9998; - transition: opacity ${sig.duration}ms ${sig.easing}; - `; - return backdrop; - } - - private async applyBackdrop( - target: HTMLElement, - sig: PresenceSignature, - abortSignal: AbortSignal - ): Promise { - // Para apply() (pulso, no sustained), el backdrop aparece y se desvanece. - const backdrop = this.createBackdrop(sig); - backdrop.style.opacity = '0'; - document.body.appendChild(backdrop); - - // Force reflow. - backdrop.getBoundingClientRect(); - - backdrop.style.opacity = '1'; - - await new Promise((resolve) => { - const timer = setTimeout(() => { - backdrop.style.opacity = '0'; - setTimeout(() => { backdrop.remove(); resolve(); }, sig.duration); - }, sig.duration * 0.3); - abortSignal.addEventListener('abort', () => { - clearTimeout(timer); - backdrop.remove(); - resolve(); - }); - }); - } - - private async applyOutline( - target: HTMLElement, - sig: PresenceSignature, - abortSignal: AbortSignal - ): Promise { - if (!sig.outline) return; - const { width, style } = sig.outline; - const animation = target.animate([ - { outline: `0px ${style} currentColor` }, - { outline: `${width}px ${style} currentColor`, offset: 0.3 }, - { outline: `0px ${style} currentColor` } - ], { duration: sig.duration, easing: sig.easing }); - if (abortSignal.aborted) { animation.cancel(); return; } - abortSignal.addEventListener('abort', () => animation.cancel()); - try { await animation.finished; } catch {} - } - - destroy(): void { - document.querySelectorAll('[data-sema-backdrop]').forEach(el => el.remove()); - } -} -``` - ---- - -## 12. Gestión de accesibilidad: `a11y.ts` - -### 11.1. Responsabilidad - -Lee preferencias del usuario (`prefers-reduced-motion`, `prefers-reduced-transparency`, `prefers-contrast`, `forced-colors`) y transforma firmas efectivas aplicando las reducciones por canal según la política de la spec. - -### 11.2. Implementación - -```ts -// src/uix/sema/a11y.ts - -export class A11yMonitor { - private mq = { - reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)'), - reducedTransparency: window.matchMedia('(prefers-reduced-transparency: reduce)'), - highContrast: window.matchMedia('(prefers-contrast: more)'), - forcedColors: window.matchMedia('(forced-colors: active)') - }; - - hasActiveReduction(): boolean { - return this.mq.reducedMotion.matches || - this.mq.reducedTransparency.matches || - this.mq.highContrast.matches || - this.mq.forcedColors.matches; - } - - reduceSignature( - signature: EffectiveSignature, - config: EngineConfig - ): EffectiveSignature { - let result = { ...signature }; - - // Aplicar reductions canal por canal. - if (this.mq.reducedMotion.matches) { - result = this.applyReducedMotion(result); - } - if (this.mq.reducedTransparency.matches) { - result = this.applyReducedTransparency(result); - } - if (this.mq.highContrast.matches) { - result = this.applyHighContrast(result); - } - if (this.mq.forcedColors.matches) { - result = this.applyForcedColors(result); - } - - // Filtrar canales desactivados globalmente. - result.activeChannels = result.activeChannels.filter(ch => { - if (ch === 'motion' && !config.motion.enabled) return false; - if (ch === 'sound' && !config.sound.enabled) return false; - if (ch === 'color' && !config.color.enabled) return false; - if (ch === 'presence' && !config.presence.enabled) return false; - return true; - }); - - return result; - } - - private applyReducedMotion(sig: EffectiveSignature): EffectiveSignature { - const activeChannels = sig.activeChannels.filter(c => c !== 'motion'); - return { - ...sig, - activeChannels, - motion: undefined, - // Color pasa a "discreto" (sin pulso animado). - color: sig.color ? { - ...sig.color, - duration: Math.min(sig.color.duration, 50), - intensity: Math.min(sig.color.intensity, 0.2) - } : undefined, - // Presence pasa a "no cinético" (sin fade suave). - presence: sig.presence ? { - ...sig.presence, - duration: Math.min(sig.presence.duration, 50) - } : undefined - }; - } - - private applyReducedTransparency(sig: EffectiveSignature): EffectiveSignature { - // Solo afecta a presence. - if (!sig.presence) return sig; - return { - ...sig, - presence: { - ...sig.presence, - backdrop: sig.presence.backdrop ? Math.min(sig.presence.backdrop, 0.7) : undefined - // blur se gestiona en el canal; sin blur aquí se logra removiendo `backdrop-filter` del CSS. - } - }; - } - - private applyHighContrast(sig: EffectiveSignature): EffectiveSignature { - // Re-resolver color hacia variantes de alto contraste. - return { - ...sig, - color: sig.color ? { - ...sig.color, - saturation: Math.min(sig.color.saturation + 0.2, 1), - lightness: sig.color.lightness < 0.5 ? 0.2 : 0.8, - intensity: Math.min(sig.color.intensity + 0.3, 1) - } : undefined - }; - } - - private applyForcedColors(sig: EffectiveSignature): EffectiveSignature { - // Color ornamental fuera; presence degrada a contorno. - const activeChannels = sig.activeChannels.filter(c => c !== 'color'); - return { - ...sig, - activeChannels, - color: undefined, - presence: sig.presence ? { - ...sig.presence, - outline: { width: 3, style: 'solid' }, - backdrop: undefined - } : undefined - }; - } - - onPreferenceChange(callback: () => void): () => void { - const listeners: Array<[MediaQueryList, () => void]> = []; - for (const mq of Object.values(this.mq)) { - const listener = () => callback(); - mq.addEventListener('change', listener); - listeners.push([mq, listener]); - } - return () => { - for (const [mq, listener] of listeners) { - mq.removeEventListener('change', listener); - } - }; - } -} -``` - ---- - -## 13. Pipeline `.csem`: plugin PostCSS - -### 12.1. Responsabilidad - -El plugin PostCSS procesa archivos `.csem` en tiempo de build. Valida que las custom properties `--sema-*` referencien eventos canónicos y parámetros válidos. Compila a un JSON consumible por el Resolver en runtime. - -### 12.2. Estructura - -``` -src/uix/sema/csem/ -├── plugin.ts # Entry point del plugin PostCSS -├── parser.ts # Extrae declaraciones --sema-* de reglas CSS -├── compiler.ts # Transforma declaraciones a CSEMOverrides JSON -└── vocabulary.ts # Whitelist de nombres de parámetros válidos -``` - -### 12.3. Formato de entrada (`.csem`) - -```css -/* app/styles/sema.csem */ - -:root { - --sema-commit-fulfill-sound-pitch: 1200; - --sema-commit-fulfill-sound-contour: ascending; - --sema-commit-fulfill-color-hue: 155; -} - -[data-dialog][data-last-action="saved"] { - --sema-event-override: commit-fulfill; - --sema-sound-duration: 180; - --sema-color-intensity: 0.6; -} - -.quiet-zone { - --sema-alert-threat-sound-gain: 0.15; - --sema-alert-threat-color-intensity: 0.25; -} -``` - -### 12.4. Formato de salida (`sema-overrides.json`) - -```json -{ - "version": "0.4.0", - "selectors": [ - { - "selector": ":root", - "overrides": { - "commit-fulfill": { - "sound": { "pitch": 1200, "contour": "ascending" }, - "color": { "hue": 155 } - } - } - }, - { - "selector": "[data-dialog][data-last-action=\"saved\"]", - "overrides": { - "commit-fulfill": { - "sound": { "duration": 180 }, - "color": { "intensity": 0.6 } - } - } - } - ] -} -``` - -### 12.5. Plugin PostCSS - -```ts -// src/uix/sema/csem/plugin.ts - -import type { Plugin } from 'postcss'; -import { parseCSEM } from './parser'; -import { compileCSEM } from './compiler'; -import fs from 'fs'; -import path from 'path'; - -export interface CSEMPluginOptions { - /** Dónde escribir el JSON compilado. */ - output: string; - /** Dónde buscar .csem. Default: todos los que PostCSS procese. */ - include?: string[]; -} - -export const csemPlugin = (opts: CSEMPluginOptions): Plugin => { - const collected: ParsedCSEM[] = []; - - return { - postcssPlugin: 'sema-csem', - Once(root) { - const filename = root.source?.input.from; - if (!filename?.endsWith('.csem')) return; - - const parsed = parseCSEM(root); - collected.push(parsed); - - // Eliminar del output CSS (el .csem no debe ir al navegador). - root.removeAll(); - }, - OnceExit() { - const compiled = compileCSEM(collected); - fs.writeFileSync(opts.output, JSON.stringify(compiled, null, 2)); - } - }; -}; - -csemPlugin.postcss = true; -``` - -### 12.6. Vocabulario válido - -```ts -// src/uix/sema/csem/vocabulary.ts - -// Todos los 22 eventos -export const VALID_EVENTS = new Set([...]); - -// Parámetros válidos por canal -export const VALID_SOUND_PARAMS = new Set([ - 'pitch', 'centroid', 'roughness', 'attack', 'decay', - 'duration', 'contour', 'gain' -]); -export const VALID_MOTION_PARAMS = new Set([ - 'duration', 'easing', 'scale-from', 'scale-to', - 'translate-x', 'translate-y', 'rotate' -]); -export const VALID_COLOR_PARAMS = new Set([ - 'hue', 'saturation', 'lightness', 'duration', 'intensity' -]); -export const VALID_PRESENCE_PARAMS = new Set([ - 'opacity-from', 'opacity-to', 'shadow-blur', 'shadow-y', - 'shadow-opacity', 'backdrop', 'outline-width', 'outline-style', - 'duration', 'easing' -]); - -export function validateCustomProperty(name: string): ValidationResult { - // Parsea "--sema---" y valida cada parte. -} -``` - -### 12.7. Integración con Vite - -```ts -// En vite.config.ts del proyecto consumidor -import { defineConfig } from 'vite'; -import { csemPlugin } from '$uix/sema/csem'; - -export default defineConfig({ - css: { - postcss: { - plugins: [ - csemPlugin({ output: 'src/generated/sema-overrides.json' }) - ] - } - } -}); -``` - -El runtime lee `sema-overrides.json` en initialization y lo pasa al Resolver. - ---- - -## 14. API pública final - -Lo que `src/uix/sema/index.ts` exporta cuando todo está implementado: - -```ts -// Tipos -export type { - SemaEventLabel, SemaAttrWrite, SemaCommit, - SemaAction, SemaSustainDecl, SemaSpec -} from './types'; - -export type { - SemaPort, SemaSession, SemaContext, - ResolvedSemaAction, ResolvedSemaSustain -} from './port'; - -export type { - SemaBinding, PartialSemaContext -} from './binding'; - -export type { - EffectiveSignature, - MotionSignature, SoundSignature, ColorSignature, PresenceSignature, - EngineConfig -} from './resolver'; - -// Validación -export { - validateSema, - SemaInvariantError -} from './validation'; - -// Port -export { - noopSemaPort, - createTestSemaPort -} from './port'; - -// Binding -export { createSemaBinding } from './binding'; - -// Engine -export { - getSemaEngine, - configureSema, - destroySema -} from './engine'; - -// CSEM (build-time only) -export { csemPlugin } from './csem/plugin'; -``` - -Uso típico en un provider: - -```ts -import { createSemaBinding, noopSemaPort, getSemaEngine } from '$uix/sema'; -import { dialogSema } from '$uix/sema/components/dialog'; - -// En producción, usar engine real. -const port = getSemaEngine(); -// En tests, usar noopSemaPort o createTestSemaPort. - -const sema = createSemaBinding(dialogSema, port); - -// En un método del provider: -async function closeSave() { - await sema.before('close-save', { - targetEl: contentEl, - partEls: { content: contentEl, trigger: triggerEl }, - cause: 'pointer', - abortSignal: this.abortController.signal - }); - this.state.open = false; -} -``` - ---- - -## 15. Estrategia de testing - -### 14.1. Niveles de test - -**Unit tests (por módulo).** Cada pieza testeada de forma aislada: -- `port.test.ts` — no-op, test port capture calls -- `binding.test.ts` — resolve defaults, apply prewrites, delegate to port -- `resolver.test.ts` — parse event, apply deltas, apply overrides, cache -- `engine.test.ts` — regime handling, cap enforcement, lifecycle -- `a11y.test.ts` — reductions per preference -- `channels/motion.test.ts` — WAAPI invocation, composite -- `channels/sound.test.ts` — graph construction, context unlock -- `channels/color.test.ts` — technique selection, overlay lifecycle -- `channels/presence.test.ts` — backdrop mounting, opacity - -**Integration tests.** Binding + engine + port real + canal falso (mocks DOM API): -- Flujo completo de una acción blocking -- Flujo de una acción advisory -- Flujo de un sustain -- Cancelación por abort signal -- Cancelación por desmontaje - -**E2E tests (Playwright).** Sobre componentes reales: -- Dialog con close-save: verificar `sema:event` CustomEvent -- Input invalidate: verificar que `data-state` cambia después del cap -- Toast con scope:scene: verificar supervivencia al desmontaje - -### 14.2. Test helpers - -```ts -// src/uix/sema/__tests__/helpers.ts - -export function createMockTarget(): HTMLElement { - const el = document.createElement('div'); - document.body.appendChild(el); - return el; -} - -export function mockMatchMedia(reducedMotion: boolean): void { - window.matchMedia = (query: string): MediaQueryList => { - const matches = query.includes('reduced-motion') ? reducedMotion : false; - return { - matches, - media: query, - addEventListener: () => {}, - removeEventListener: () => {}, - // ... otros props - } as any; - }; -} - -export function waitForCustomEvent( - target: HTMLElement, - eventName: string, - filter?: (ev: CustomEvent) => boolean -): Promise { - return new Promise((resolve) => { - const listener = (ev: Event) => { - const ce = ev as CustomEvent; - if (!filter || filter(ce)) { - target.removeEventListener(eventName, listener); - resolve(ce); - } - }; - target.addEventListener(eventName, listener); - }); -} -``` - -### 14.3. Mocking de Web Audio - -Web Audio es difícil de testear en jsdom. Para los tests unitarios del canal sound, se usa un stub completo de `AudioContext` que registra las llamadas en lugar de hacer DSP: - -```ts -export function stubAudioContext(): any { - const calls: string[] = []; - return { - currentTime: 0, - state: 'running', - createOscillator: () => ({ - frequency: { value: 0 }, - detune: { - value: 0, - setValueAtTime: () => calls.push('detune.setValueAtTime'), - linearRampToValueAtTime: () => calls.push('detune.ramp') - }, - connect: () => calls.push('osc.connect'), - start: () => calls.push('osc.start'), - stop: () => calls.push('osc.stop'), - type: 'sine' - }), - // ... otros nodos - _calls: calls - }; -} -``` - -Tests verifican que la secuencia de llamadas es la esperada para cada firma. - -### 14.4. Mocking de WAAPI - -jsdom no implementa WAAPI. Polyfill o mock: - -```ts -// test-setup.ts -if (!Element.prototype.animate) { - Element.prototype.animate = function(keyframes: any, options: any) { - const animation: any = { - finished: Promise.resolve(), - cancel: () => {}, - playState: 'running' - }; - // Record para verificación en tests. - (this as any)._lastAnimation = { keyframes, options }; - return animation; - }; -} -``` - ---- - -## 16. Criterios de aceptación - -La implementación se considera completa cuando: - -1. **Port funcional.** `noopSemaPort` existe y cumple la interfaz. `createTestSemaPort` registra correctamente todas las invocaciones. Los tests de port pasan. - -2. **Binding funcional.** `createSemaBinding(spec, port)` produce helpers tipados. Los prewrites se aplican al DOM. Los defaults se resuelven. Acciones con nombre inválido lanzan en dev. Los tests de binding pasan. - -3. **Resolver funcional.** Dado un evento canónico y un mapa, produce la firma efectiva. Aplica deltas correctamente (add, multiply, replace). Aplica overrides de CSEM compilado. Los tests del resolver pasan. - -4. **Engine funcional.** Implementa `SemaPort`. Gestiona los 4 regímenes correctamente. Respeta caps temporales. Emite `CustomEvent('sema:event')` en start/end/cancelled. Se destruye limpiamente. Los tests del engine pasan. - -5. **Canales funcionales.** Cada canal (motion, sound, color, presence) aplica su firma al target DOM. Respetan AbortSignal. No dejan leaks de DOM ni Web Audio. Los tests de canales pasan. - -6. **A11y funcional.** Lee preferencias correctamente. Aplica reducciones por canal según la política. Los tests de a11y pasan. - -7. **CSEM pipeline funcional.** El plugin PostCSS procesa archivos `.csem`. Valida vocabulario. Genera JSON consumible. Los tests de csem pasan. - -8. **Integration tests.** Un flujo completo de acción blocking termina correctamente. Un sustain se activa y se detiene. La cancelación por abort funciona. - -9. **E2E tests.** Un componente real con morfo-sema emite los `CustomEvent` esperados cuando se dispara una acción. - -10. **Sin regresiones en el código existente.** `$uix/sema/types.ts` y `$uix/sema/validation.ts` siguen funcionando; los 66 morfos existentes siguen siendo válidos; el test de `dialog.test.ts` sigue pasando. - ---- - -## Apéndice A — Dependencias nuevas del paquete - -No deberían requerirse dependencias nuevas. El runtime usa solo APIs estándar del navegador (DOM, WAAPI, Web Audio, matchMedia). - -El plugin PostCSS sí requiere `postcss` como peer dependency (probablemente ya está en el proyecto). - -Test-only dependencies (probablemente ya instaladas): -- `vitest` -- `@vitest/browser` o jsdom -- `@playwright/test` para E2E - ---- - -## Apéndice B — Orden sugerido de implementación - -El orden reduce dependencias cíclicas y permite testear incrementalmente: - -1. **port.ts + tests** — sin dependencias; base del sistema -2. **resolver.ts + sema-map.json + tests** — consume solo tipos ya existentes -3. **a11y.ts + tests** — independiente -4. **channels/motion.ts + tests** — WAAPI wrapper simple -5. **channels/color.ts + tests** — más complejo (técnicas múltiples) -6. **channels/presence.ts + tests** — parecido a color -7. **channels/sound.ts + tests** — el más complejo; dejar para el final -8. **engine.ts + tests** — depende de todo lo anterior -9. **binding.ts + tests** — depende de port y types -10. **csem/plugin.ts + tests** — independiente, puede hacerse en paralelo - -Tiempo estimado para implementación completa con tests: 3-4 semanas de trabajo full-time. La parte más compleja por mucho es `channels/sound.ts` (psicoacústica a DSP real). - ---- - -## Apéndice C — Casos límite documentados - -Casos que la implementación debe manejar explícitamente: - -1. **Target desmontado durante ventana Sema.** El engine detecta vía `isConnected` check y resuelve la promise tempranamente. Los canales que hayan empezado cancelan sus animations. - -2. **Múltiples instancias del engine.** Si por error se crea más de una, los canales entran en conflicto (especialmente sound — dos AudioContexts). El getter `getEngine()` garantiza instancia única. - -3. **AudioContext locked en Safari.** El canal sound escucha gestos de usuario y llama `resume()`. Mientras tanto, los eventos con sonido simplemente no suenan. No se encolan. - -4. **Más de 100 eventos concurrentes.** Poco probable pero posible. El engine no limita; depende del navegador para throttle. Documentar como limitación. - -5. **Evento con sound activo pero runtime overrides deshabilitan sound.** El resolver produce una firma sin sound; el engine no ejecuta el canal; el CustomEvent refleja `channels: []` si solo había sound. - -6. **Acción cuyo prewrite escribe a un atributo que un MutationObserver observa.** El prewrite puede disparar lógica externa. Esto es diseño intencional (es lo que permite que la capa visual reaccione) pero el provider debe ser consciente de que los prewrites ocurren antes de que el engine devuelva control. - -7. **Two bindings para el mismo componente en la misma página.** Cada instancia de componente tiene su propio binding. Los bindings no compiten entre sí; el engine los ve como targets distintos. - ---- - -**Fin del documento de arquitectura.** - -*Este documento es la referencia autoritativa para la implementación del runtime Sema. Cualquier desviación en el código respecto a lo aquí descrito debe justificarse explícitamente y actualizar el documento.* diff --git a/src/uix/sema/sema-spec-v0.4.md b/src/uix/sema/sema-spec-v0.4.md deleted file mode 100644 index a1884547a..000000000 --- a/src/uix/sema/sema-spec-v0.4.md +++ /dev/null @@ -1,973 +0,0 @@ -# Sema — Especificación v0.4 - -**Capa semántico-perceptiva para interfaces de usuario** - -> Especificación de Sema como capa conceptual. Independiente de framework, stack y artefactos de implementación concretos. Esta versión separa lo que es Sema (contenido de este documento) de cómo se implementa en un framework específico (ver documentos de implementación separados, por ejemplo `semauix-sema-impl.md`). -> -> **Estado:** v0.4 es v0.3.1 purificada de dependencias con SemaUIX. Mismo contenido doctrinal, alcance redefinido. -> -> **Cambios respecto a v0.3.1:** -> - Eliminadas referencias específicas a morfo como contrato obligatorio -> - `SemaAction` descrita conceptualmente, sin sintaxis TypeScript concreta de SemaUIX -> - Eliminadas menciones a `v.partRef`, `as const satisfies`, sium, `createSemaBinding` -> - Ejemplo Dialog movido a la documentación de implementación de referencia -> - Se añade §13 que define el contrato mínimo que una implementación debe proveer -> -> Lista para que implementaciones concretas (SemaUIX o cualquier otra) la materialicen. - ---- - -## 1. Introducción - -### 1.1. Qué resuelve Sema - -Los frameworks de interfaz modernos separan componentes en dos planos: el comportamiento (qué hace el componente) y la presentación visual (cómo se ve en reposo). Esa separación está bien, pero deja sin modelar una tercera dimensión: **cómo el usuario percibe el cambio cuando algo ocurre**. - -Un input que pasa de válido a inválido no es solo "un estado que cambia" — es un evento con una ventana temporal durante la cual el sistema debe expresar perceptivamente el significado de ese cambio (amenaza, advertencia, éxito, pérdida). Esa expresión atraviesa varios canales (movimiento, sonido, color, aparición) y tiene reglas cognitivas específicas (causalidad perceptiva, correspondencias crossmodales, valencia afectiva). - -Sema es la capa que declara y gestiona esa dimensión perceptivo-temporal. - -### 1.2. Arquitectura de tres capas asumida - -Sema asume un contexto arquitectónico de **tres capas independientes** que se coordinan exclusivamente a través del DOM estándar y un contrato común: - -| Capa | Rol | Contribución | -|---|---|---| -| Capa headless | Comportamiento, estado, ARIA, keyboard | Expone estado del componente como atributos DOM estables; orquesta el flujo de eventos | -| Capa visual | Presentación en reposo del componente | Consume los atributos de estado para estilar cómo se ve el elemento **mientras dura cada estado** | -| Sema | Expresión perceptivo-afectiva de los eventos | Reacciona a las acciones declaradas por la capa headless y ejecuta coreografías perceptivas acotadas | - -Las tres capas se coordinan a través de un **contrato cross-layer**: un artefacto declarativo por componente que define la superficie pública del componente — sus partes, los atributos que emite, su contrato ARIA, sus acciones semánticas. Cada capa consume el contrato desde su ángulo. - -Esta spec no dicta cómo se implementa ese contrato cross-layer. Una implementación puede usar un artefacto TypeScript con validación runtime (como hace la implementación de referencia SemaUIX con `morfo`), decoradores en clases, registros runtime, hooks, o cualquier otro mecanismo que exponga la información que Sema necesita. - -### 1.3. Alcance de esta spec - -Sema especifica: - -- La taxonomía semántica (6 familias de eventos, 5 intents afectivos) -- Los canales perceptivos (4 ejes: motion, sound, color, presence) -- La noción conceptual de acción semántica (§5) -- El protocolo de coordinación entre la capa headless y Sema (§6) -- La gramática del artefacto `.csem` (donde el integrador resuelve la expresión perceptiva) -- La estructura del artefacto `sema-map.json` (vocabulario canónico de eventos y firmas) -- Los principios de operación (secuencialidad coordinada, regímenes de arbitraje) -- Los requisitos mínimos para que una implementación sea Sema-compliant (§13) - -Sema NO especifica: - -- Cómo se implementa la capa headless ni la visual -- Qué framework se usa (Svelte, React, Vue, Web Components) -- Qué mecanismo concreto declara las acciones semánticas (TypeScript const, decoradores, registros runtime, otro) -- El pipeline de build concreto -- La API JavaScript exacta del engine -- Los valores numéricos finales del mapa (se proveen rangos defendibles; la calibración final es responsabilidad de cada implementación) - ---- - -## 2. Principios fundamentales - -### 2.1. Estado vs evento - -Sema distingue dos planos temporales: - -**Estado del componente.** Propiedad persistente del elemento. Un input es `invalid` hasta que el usuario corrija; un dialog está `open` o `closed`. El estado lo gestiona la capa headless y lo consume la capa visual para estilar el elemento **mientras dura cada estado**. Es continuo. - -**Ciclo de vida del evento.** Ventana temporal acotada durante la cual ocurre la transición hacia o desde un estado. Tiene principio y fin (típicamente 60-300ms). Durante ese intervalo, Sema expresa perceptivamente "acaba de ocurrir este cambio". Al terminar, el estado persiste pero el evento ya no existe. Es puntual. - -La capa visual estiliza estados. Sema expresa eventos. No son el mismo objeto ni deben tratarse con el mismo mecanismo. - -### 2.2. Secuencialidad coordinada - -**En el camino canónico (acciones blocking), evento y estado ocurren en secuencia, nunca en paralelo.** Sema actúa primero (ventana del evento); después se aplica el cambio de estado y la capa visual reacciona a ese nuevo estado con sus transiciones CSS habituales. - -La secuencialidad estricta aplica al modo `blocking`, que es el canónico para la mayoría de acciones con cambio de estado. Las acciones `advisory` (fase `after-state`) permiten que Sema corra en paralelo al estado ya cambiado, y las coreografías largas pueden extenderse más allá del cap temporal del provider como "tails post-state" — en esos casos, el tramo secuencial garantizado termina cuando el provider libera el `await`. - -Esta secuencialidad la garantiza la capa headless. La capa headless sabe qué acciones puede realizar un componente (porque el contrato cross-layer las declara), sabe qué evento Sema precede a cada acción, y orquesta el orden: - -1. La capa headless decide que va a ejecutar una acción -2. Aplica los prewrites necesarios al DOM (atributos contextuales antes del evento) -3. Invoca Sema y espera (si la acción es bloqueante) -4. Sema ejecuta la coreografía perceptiva -5. Al terminar, la capa headless aplica el cambio de estado -6. La capa visual reacciona al nuevo estado con sus transiciones - -En el camino canónico ya no hay superposición de capas sobre el mismo elemento al mismo tiempo; hay turnos de autoridad claros, garantizados por el protocolo. Los casos que permiten paralelismo (advisory, tails post-cap) quedan explícitamente marcados como post-state, no como violaciones de la doctrina. - -### 2.3. El contrato cross-layer como fuente única - -La clave de que la secuencialidad funcione es que las tres capas comparten un contrato declarativo único por componente. Ese contrato: - -- Declara la superficie pública del componente (partes, atributos, ARIA, keyboard) — usado por las tres capas -- Declara las acciones semánticas del componente (qué eventos Sema existen, cuándo ocurren) — usado por la capa headless para orquestar y por Sema para ejecutar - -El contrato **no** declara cómo se estilan los estados (eso es responsabilidad de la capa visual) ni cómo se expresan perceptivamente los eventos (eso es responsabilidad de Sema via `.csem` y `sema-map.json`). El contrato es estructural, no implementacional. - -Qué mecanismo concreto realiza ese contrato (const declarativo, decoradores, registros, hooks) es decisión de la implementación del framework. - -### 2.4. Separación de qué y cómo - -El contrato cross-layer declara qué existe; cada capa declara cómo lo hace: - -| Capa | Qué declara el contrato | Dónde vive el cómo | -|---|---|---| -| Headless | Partes, atributos emitidos, ARIA, keyboard, acciones semánticas | Código del provider | -| Visual | (lee del contrato) | CSS en reposo del componente | -| Sema | (lee del contrato) | `.csem` del integrador + `sema-map.json` | - -Cuando el contrato declara que un Dialog tiene una acción `close-save` que dispara el evento `commit-fulfill`, no está diciendo cómo se expresa `commit-fulfill`. Eso lo resuelve Sema con `.csem` (si el integrador lo sobreescribe) o con el `sema-map.json` por defecto. - -### 2.5. Declaratividad y escape hatches - -Sema es declarativa por diseño: el 95% de los casos se expresan en el contrato cross-layer y `.csem` sin código imperativo. Para casos que no caben en el modelo declarativo (condiciones no expresables como atributos DOM, orquestación temporal compleja, estímulos calculados en runtime), el engine debe proveer una API imperativa como escape hatch. Es excepción, no regla. - ---- - -## 3. Taxonomía semántica - -### 3.1. Las 6 familias - -Cada familia corresponde a un tipo de evento perceptivo distinto. Las diferencias finas dentro de una familia se expresan como parámetros secundarios, no como familias separadas. - -| Familia | Qué expresa | Perfil temporal | Acepta intent | -|---|---|---|---| -| `contact` | Respuesta perceptiva inmediata a un acto del usuario | Puntual (<100ms) | Sí | -| `commit` | El sistema registra un cambio de estado discreto | Discreto (100-300ms) | Sí | -| `alert` | El sistema reclama atención sobre algo no atendido | Repetible (100-400ms) | Sí | -| `emerge` | Algo aparece o desaparece en el espacio visual | De aparición (150-400ms) | No (transicional) | -| `handle` | El usuario interactúa con un objeto de forma continua | Continuo | Sí | -| `sustain` | Estado sostenido sin evento puntual | Largo (segundos o más) | No (transicional) | - -Las familias valenciales (`contact`, `commit`, `alert`, `handle`) aceptan modulación por intent. Las transicionales (`emerge`, `sustain`) expresan cambios de régimen puros y derivan su direccionalidad del contexto, no del intent. - -### 3.2. Los 5 intents - -Aplicables solo a familias valenciales. Derivados del modelo circumplejo del afecto (Russell 1980): - -| Intent | Valencia | Arousal | Cuándo se aplica | -|---|---|---|---| -| `threat` | Muy negativa | Alto | Peligro, irreversible, consecuencia grave | -| `risk` | Negativa | Medio-bajo | Precaución, subóptimo, fricción | -| `neutral` | Neutra | Bajo | Operación rutinaria, sin evaluación | -| `affirm` | Leve positiva | Bajo | Correcto, adecuado, aprobado | -| `fulfill` | Positiva | Medio-alto | Éxito, logro, encaje | - -Los cinco intents son cinco puntos anclados en el espacio bidimensional valencia × arousal, no cinco categorías ortogonales. La diferencia entre `affirm` y `fulfill` es principalmente de arousal. - -### 3.3. El vocabulario canónico de eventos - -Sema define un conjunto finito y enumerable de eventos semánticos que resultan de combinar familias × intents: - -``` -SemaEventLabel ∈ { - // Contact × intents - contact-neutral, contact-threat, contact-risk, - contact-affirm, contact-fulfill, - - // Commit × intents - commit-neutral, commit-threat, commit-risk, - commit-affirm, commit-fulfill, - - // Alert × intents - alert-neutral, alert-threat, alert-risk, - alert-affirm, alert-fulfill, - - // Handle × intents - handle-neutral, handle-threat, handle-risk, - handle-affirm, handle-fulfill, - - // Transicionales (sin intent) - emerge, sustain -} -``` - -22 eventos en total (20 valenciales + 2 transicionales). Este vocabulario es finito y cerrado; implementaciones no deben extenderlo arbitrariamente. Si una necesidad perceptiva real no encaja, es señal de revisar la taxonomía, no de añadir un evento ad-hoc. - -### 3.4. La moral la da el intent, no la familia - -Un principio importante: ninguna familia tiene valencia moral intrínseca. `destruction` no existe como familia porque borrar algo no siempre es negativo (borrar spam es `commit` con intent `fulfill` — un cambio de estado hacia adelante con valencia positiva). La familia describe la forma del evento; el intent describe cómo se siente. - ---- - -## 4. Canales perceptivos - -Cada evento semántico produce una firma en cuatro canales. Los parámetros y rangos viven en `sema-map.json`. - -### 4.1. motion - -Expresa dinámicas espaciales: movimiento, escala, rotación. - -``` -motion: { - duration: // 30-500, default 200 - easing: // linear | ease-out | ease-in | ease-in-out - scale: { from, to } // 0.9-1.1 típico - translate: { x, y } // px o % - rotate: // raro excepto en sustain -} -``` - -**Respeta:** `prefers-reduced-motion` (degrada a omisión o transiciones instantáneas). - -### 4.2. sound - -Expresa eventos auditivos breves. Parametrizado **psicoacústicamente**, no por tipo de oscilador. El engine elige la síntesis que produce la firma solicitada. - -``` -sound: { - pitch: // 200-3000 - centroid: // centroide espectral (brillo) - roughness: <0..1> // modulación banda 30-150 Hz - attack: // <5 urgente, 10-30 orgánico - decay: // 20-80 puntual, 80-200 con cola - duration: // 40-200, óptimo 60-120 - contour: // flat | ascending | descending | arc | bell - gain: <0..1> // 0-0.4 típico -} -``` - -**Política por defecto:** el canal `sound` está **desactivado por defecto**. La implementación debe proveer configuración opt-in explícita. Razón: el sonido introduce una modalidad sensorial que muchos contextos no aceptan culturalmente (apps de productividad, entornos de oficina). - -**Nota sobre `handle.carry`:** sound debe ser `null` por defecto durante la fase carry de manipulación. Sonido continuo modulado por velocidad del puntero requiere AudioWorklets para no bloquear el main thread, y queda como opt-in explícito. - -**Asimetría reconocida:** sound es el único canal que no es modulación de propiedades visuales; es un canal sensorial independiente. Por eso no interactúa con la capa visual — no hay posibilidad de conflicto. - -### 4.3. color - -Expresa pulso cromático temporal. El color en reposo es responsabilidad de la capa visual; Sema controla solo el pulso durante el evento. - -``` -color: { - hue: // 0-360 - saturation: <0..1> // palanca fuerte de arousal - lightness: <0..1> // 0.4-0.7 típico - duration: // 80-300 - intensity: <0..1> // desviación del estado base -} -``` - -**Nota cultural:** los hues concretos por intent son convención occidental. Sobreescribibles a nivel global para contextos culturales distintos. - -**Respeta:** `prefers-contrast: more` (re-resuelve hacia variantes de mayor legibilidad), `prefers-reduced-transparency` (sustituye transparencias por equivalentes opacos), `forced-colors: active` (color ornamental se desactiva salvo variante segura). - -### 4.4. presence - -Expresa segregación figura-fondo: cómo algo se hace visible o se retira de la atención. - -``` -presence: { - opacity: { from, to } // típico 0↔1 o 0.7↔1 - shadow: { blur, y, opacity } // elevación - backdrop: <0..1> // scrim + sombra de fondo (0-0.6) - outline: { width, style } // contorno emergente - duration: // 150-350 - easing: -} -``` - -**`backdrop`** es un sub-parámetro compuesto que agrupa scrim + sombra de fondo, dado que casi siempre se usan juntos en modales y drawers. - -**Respeta:** `prefers-reduced-transparency` (backdrop sólido, sin blur), `prefers-reduced-motion` (sin fades). - -### 4.5. Canales activos por evento - -No todos los eventos activan los cuatro canales. Qué canales activa cada evento está definido en `sema-map.json`. Por ejemplo, `emerge` activa principalmente motion + presence (apariciones visuales); `alert-threat` activa motion + sound + color (reclamo máximo de atención); `contact-neutral` activa solo motion + sound (feedback inmediato). - ---- - -## 5. Acciones semánticas: el concepto - -### 5.1. Qué es una acción semántica - -Una **acción semántica** es cualquier cosa que un componente hace que tiene significado perceptivo: abrir, cerrar-con-éxito, cerrar-con-error, invalidar, confirmar, etc. - -Las acciones semánticas son declaradas por cada componente en su contrato cross-layer. La capa headless las invoca durante el flujo del componente. Sema las ejecuta aplicando las firmas perceptivas correspondientes. - -La acción es la unidad de integración entre la capa headless y Sema. No los eventos DOM crudos (un click puede ser una acción u otra dependiendo del contexto), ni los cambios de estado (un cambio puede ser consecuencia de varias acciones distintas). La acción declara explícitamente qué está ocurriendo semánticamente. - -### 5.2. Campos conceptuales de una acción - -Una acción declara: - -- **Nombre** — identificador único dentro del componente. Referenciable desde el código del provider y desde declaraciones de teclado. -- **Target** — referencia a la parte del componente afectada por la acción (dónde se aplica la coreografía perceptiva). -- **Evento** — etiqueta del vocabulario canónico (`SemaEventLabel`). Define qué firma perceptiva se dispara. -- **Modo** (opcional, default `blocking`) — si la capa headless espera a que Sema termine antes de aplicar el cambio de estado, o dispara y sigue. -- **Régimen** (opcional, default `replace`) — qué hace Sema si llega otra acción equivalente durante la ventana. -- **Scope** (opcional, default `part`) — el alcance de la coreografía: solo el target, el componente completo, o persiste como escena tras desmontaje. -- **Prewrites** (opcional) — atributos DOM que se reflejan antes de invocar Sema (contexto causal que las capas posteriores podrán leer). -- **Commits** (opcional) — si existe, describe el cambio de estado que la capa headless aplicará **después** de que Sema termine. - -Estos ocho campos son suficientes para que la capa headless orqueste la secuencia y Sema resuelva la firma. Nada más debe ir en la acción: los parámetros perceptivos (pitch, hue, duraciones concretas) viven en `.csem` y `sema-map.json`. - -### 5.3. Cómo se declara una acción - -Esta spec no prescribe la sintaxis concreta. La implementación decide el mecanismo — un TypeScript const declarativo, decoradores, un registro runtime, un archivo JSON o YAML, un hook — siempre que el contenido semántico declarado contenga los campos del §5.2. - -Ejemplo conceptual (pseudocódigo neutro): - -``` -action "close-save" on Dialog.Content { - event: commit-fulfill - regime: lock - prewrite: [ data-last-action = "saved" on Content ] - commits: [ data-state = "closed" on Content ] -} -``` - -Cada implementación materializa esta declaración en su sintaxis. La doc de implementación de referencia (`semauix-sema-impl.md`) muestra cómo SemaUIX lo hace con morfo y TypeScript. - -### 5.4. Los regímenes - -El régimen define el comportamiento cuando una acción se dispara mientras ya hay otra de la misma identidad en curso. - -**`replace` (default):** la acción entrante cancela la anterior y arranca una nueva desde cero. Para coreografías puntuales donde interesa el evento más reciente. - -**`collapse`:** *single-flight coalescing*. La primera ocurrencia abre una ventana; toda ocurrencia equivalente que llegue mientras la ventana sigue abierta no reinicia, no extiende y no crea una segunda coreografía. Se registra como "hubo repetición" pero el evento visible sigue siendo uno. Útil para acciones de alta frecuencia (typing, scroll) donde no se quiere ni silencio perpetuo ni pulsos continuos. - -**`lock`:** mientras la ventana está abierta, acciones equivalentes son rechazadas. Útil para acciones de cierre (dialog close) que no deben reentrar. - -**`queue`:** las acciones entrantes se encolan y se ejecutan secuencialmente tras la actual. Útil para secuencias de confirmación múltiple. - -**Equivalencia:** dos ocurrencias son equivalentes si comparten el mismo nombre de acción sobre el mismo target. - -### 5.5. Modo y fase - -`mode` define si la capa headless espera a que Sema termine: - -- **`blocking` (default):** la capa headless hace `await` antes de aplicar el commit. La secuencia Sema → commit de estado es estricta. -- **`advisory`:** la capa headless dispara Sema y sigue inmediatamente. Útil para acentos no críticos posteriores a un cambio de estado. - -La fase temporal (antes del cambio de estado, después, o independiente) se deduce de la combinación de `commits` y `mode`: - -- Con `commits` presente + `mode: blocking` → fase **before-state** (canónica) -- Con `commits` presente + `mode: advisory` → fase **after-state** (el estado cambia, luego Sema corre en paralelo) -- Sin `commits` → fase **independent** (no hay cambio de estado, la acción es puro feedback) - -### 5.6. Matriz de combinaciones válidas - -| mode | commits | Fase | Legitimidad | Caso de uso | -|---|---|---|---|---| -| blocking | presente | before-state | Canónico | Dialog close-save, input invalidate | -| advisory | presente | after-state | Legítimo | Toast al completar una acción | -| blocking | ausente | independent | Legítimo | Submit-failed, close-denied | -| advisory | ausente | independent | Legítimo | Tick breve no bloqueante | - -Las combinaciones no listadas son inválidas; la implementación debe rechazarlas en validación. - -### 5.7. Alcance estructural - -Las acciones declaran **efecto estructural**, no precondiciones de aplicabilidad. El contrato no modela desde qué estado una acción es válida, ni distingue una acción que produce transición real de una que sería no-op. La validez contextual de una acción (si puede o no ejecutarse en un momento dado) es responsabilidad de la capa headless. Las implementaciones pueden añadir validaciones que comprueben coherencia declarativa (target resuelve, event existe en el vocabulario, prewrites referencian atributos declarados) pero no deben pretender modelar la máquina de estados completa del componente. - ---- - -## 6. El protocolo de coordinación - -### 6.1. El puerto neutral - -La capa headless no importa la implementación de Sema. Importa un **puerto neutral**: una interfaz abstracta con tres operaciones. - -``` -interface SemaPort { - // Invoca antes del commit y espera (blocking mode) - before(action, context): Promise - - // Invoca sin esperar (advisory mode) - fire(action, context): void - - // Inicia un sustain con lifecycle explícito - startSustain(sustain, context): SemaSession -} - -interface SemaSession { - stop(): void - active: boolean -} -``` - -El puerto puede ser: - -- Un runtime real de Sema (producción) -- Un no-op port (desarrollo sin Sema cargado, o contexto donde Sema se desactiva globalmente) -- Un test port (testing) - -La capa headless solo conoce la interfaz. No conoce síntesis, mapas ni resolución perceptiva. - -### 6.2. El contexto de invocación - -En cada invocación, la capa headless pasa un contexto que identifica: - -- El componente y la acción ejecutada -- El elemento DOM del target -- Opcionalmente, el elemento raíz y otras partes relevantes -- Un snapshot de atributos DOM relevantes en ese momento -- La causa originadora (teclado, puntero, programática, validación) -- Opcionalmente, un AbortSignal para cancelación externa - -La forma concreta de este contexto es decisión de la implementación; el contenido semántico está dictado por esta spec. - -### 6.3. Secuencia canónica (blocking + commits) - -Para una acción con `mode: blocking` y `commits` presente: - -1. La capa headless resuelve la acción abstracta (ej. `close-save`) -2. Aplica los prewrites declarados al DOM -3. Hace flush — los atributos del prewrite ya están reflejados -4. Llama `await port.before(action, context)` -5. Sema resuelve la firma perceptiva (consultando `.csem` + `sema-map.json`) -6. Sema ejecuta los canales activos -7. La promesa resuelve cuando la coreografía termina -8. La capa headless aplica el commit de estado -9. La capa visual reacciona al nuevo estado con sus transiciones -10. Si hay exit CSS o desmontaje diferido, sigue el pipeline de la capa headless - -Durante toda la ventana del paso 6, el estado del componente sigue siendo el anterior. La capa visual ve el estado saliente todavía. No hay competencia visual. - -### 6.4. Secuencia para independent (sin commits) - -Para una acción sin commits (ej. `submit-failed` sobre un input ya invalid): - -1. La capa headless detecta que debe disparar la acción -2. Aplica prewrites si los hay (pero no afectan a `data-state`) -3. `await port.before(action, context)` o `port.fire(action, context)` según modo -4. No hay commit posterior -5. El flujo de la capa headless continúa - -### 6.5. Sustain como sesión - -`sustain` no es episódico. Se declara aparte del resto de acciones, con un predicado de activación: - -``` -sustain "loading" on Spinner { - activeWhen: data-state = "loading" on Spinner - event: sustain - scope: part -} -``` - -Protocolo: - -1. La capa headless cambia el estado que satisface el predicado de activación -2. Inmediatamente después, llama `session = port.startSustain(sustain, context)` -3. La sesión corre mientras el predicado siga siendo verdadero -4. Cuando la capa headless sale del estado, llama `session.stop()` - -Es una excepción documentada al patrón episódico. - -### 6.6. Garantías del puerto - -El puerto debe garantizar: - -- `before()` nunca lanza si falta runtime; cae a no-op con promesa resuelta inmediatamente -- `before()` siempre resuelve (nunca cuelga indefinidamente) -- Si `AbortSignal` aborta, la coreografía se cancela y la promesa resuelve -- Si el target desaparece del DOM, resuelve tempranamente -- Respeta preferencias de accesibilidad del usuario (ver §9) -- `before()` nunca bloquea más del cap global (ver §9.3) - ---- - -## 7. El artefacto `.csem` - -### 7.1. Propósito - -El `.csem` es el archivo donde el integrador (quien usa el componente en su app) especifica **cómo se expresan perceptivamente** los eventos semánticos que el contrato cross-layer declara. - -El contrato dice: "Dialog tiene una acción `close-save` que dispara `commit-fulfill`". - -`.csem` dice: "En esta app, `commit-fulfill` se expresa con estos canales, estos parámetros, estos targets". - -Si el integrador no provee `.csem`, Sema usa los valores por defecto de `sema-map.json`. `.csem` es capa de override, no obligatoria. - -### 7.2. Sintaxis CSS con custom properties - -`.csem` usa sintaxis CSS válida procesada en build time. Declara overrides por selector: - -```css -/* Override global: commit-fulfill en esta app es más brillante */ -:root { - --sema-commit-fulfill-sound-pitch: 1200; - --sema-commit-fulfill-sound-contour: ascending; - --sema-commit-fulfill-color-hue: 155; -} - -/* Override por componente: el dialog close-save es especialmente celebratorio */ -[data-dialog][data-last-action="saved"] { - --sema-event-override: commit-fulfill; - --sema-sound-duration: 180; - --sema-color-intensity: 0.6; -} - -/* Context override: en la zona silenciosa, alert-threat es más suave */ -.quiet-zone { - --sema-alert-threat-sound-gain: 0.15; - --sema-alert-threat-color-intensity: 0.25; -} -``` - -La sintaxis aprovecha la cascada CSS natural: el `.csem` más específico (por componente, por contexto) sobrescribe al más genérico (global). - -### 7.3. Pipeline de build - -El `.csem` se procesa en build time (PostCSS plugin u equivalente) que: - -1. Parsea las reglas -2. Valida que cada `--sema-*` referencie un evento/canal/parámetro existente -3. Compila a una estructura JSON optimizada -4. El runtime consume el JSON — no parsea CSS en cliente - -### 7.4. Política ante canal inactivo - -Si `.csem` define parámetros para un canal que el integrador ha desactivado globalmente (vía `engine.configure`), el runtime **ignora los parámetros y emite warning en dev**. El `.csem` no activa canales por sí mismo; la activación es decisión explícita del integrador a nivel configuración. Esto preserva la política "sound disabled by default": asignar un pitch a sound en `.csem` no activa sound — hay que activarlo explícitamente en la configuración. - -### 7.5. Resolución de firmas en runtime - -Cuando Sema ejecuta una acción, la firma se resuelve: - -1. Mira el contrato cross-layer → obtiene `event` (ej. `commit-fulfill`) -2. Mira `.csem` (compilado) → busca overrides aplicables al target y contexto -3. Si no hay overrides, cae a `sema-map.json` (base) -4. Combina: base + overrides → firma efectiva -5. Ejecuta los canales activos con los parámetros resueltos - -Tres capas de resolución: contrato (qué evento), `.csem` (cómo lo expresa la app), `sema-map` (cómo lo expresa por defecto). - ---- - -## 8. El artefacto `sema-map.json` - -### 8.1. Propósito - -Contiene los valores concretos por canal para cada evento del vocabulario canónico (22 eventos). Es el "vocabulario perceptivo de referencia" — lo que `commit-fulfill` significa por defecto en una implementación Sema-compliant. - -### 8.2. Estructura factorizada - -Para evitar duplicar valores similares entre eventos relacionados, el mapa usa factorización base + delta: - -```json -{ - "version": "0.4.0", - "families": { - "commit": { - "base": { - "motion": { /* parámetros base */ }, - "sound": { /* parámetros base */ }, - "color": { /* parámetros base */ }, - "presence": null - }, - "activeChannels": ["motion", "sound", "color"] - } - }, - "intents": { - "fulfill": { - "deltas": { - "motion": { "duration": { "op": "multiply", "factor": 1.2 } }, - "sound": { "pitch": 400, "contour": "ascending" }, - "color": { "hue": 155, "saturation": 0.1 } - } - } - } -} -``` - -La firma de `commit-fulfill` se resuelve: `signature = base(commit) + delta(fulfill)`. - -### 8.3. Operaciones de delta - -- **Valor numérico:** suma al base (`"pitch": 400` → base + 400) -- **String:** override (`"contour": "ascending"` → reemplaza al base) -- **Objeto con `op`:** operación explícita (`{"op": "multiply", "factor": 1.2}`) - -### 8.4. Sound pack (opcional) - -El integrador puede proveer samples WAV que reemplazan la síntesis para combinaciones específicas: - -```json -{ - "soundPack": { - "alert-threat": "/sounds/alarm.wav", - "commit-fulfill": "/sounds/success.wav" - } -} -``` - -Las entradas presentes en el pack son autoritativas; las ausentes usan síntesis algorítmica. Permite despliegue gradual del pack. - -### 8.5. Fuera del mapa - -`sema-map.json` contiene vocabulario perceptivo canónico. **No contiene scope** (operativo, vive en la acción), **no contiene información de cuándo disparar eventos** (eso es responsabilidad del contrato cross-layer), **no contiene reglas culturales específicas de apps** (eso es `.csem`). Es solo el vocabulario sensorial base. - ---- - -## 9. Accesibilidad - -### 9.1. Política por preferencia y canal - -Las preferencias del usuario afectan a canales específicos, no globalmente: - -| Preferencia | motion | sound | color | presence | -|---|---|---|---|---| -| `prefers-reduced-motion: reduce` | **omitido** | intacto | discreto | reducido a no cinético | -| `prefers-reduced-transparency` | intacto | intacto | intacto | **backdrop sólido, sin blur** | -| `prefers-contrast: more` | intacto | intacto | **re-resuelto alto contraste** | **re-resuelto con contorno** | -| `forced-colors: active` | intacto | intacto | **ornamental desactivado** | **degradado a contornos** | - -### 9.2. Interacción con el modo blocking - -En `mode: blocking`, `before()` espera solo por los canales activos tras aplicar las preferencias: - -- Si tras la reducción no queda ningún canal activo → resuelve inmediatamente (0ms) -- Si quedan canales → espera el máximo entre sus duraciones -- Respeta siempre el cap temporal global - -### 9.3. Caps temporales globales - -La spec define caps para evitar que una coreografía larga convierta `blocking` en un problema de responsividad: - -- **Camino normal:** 200ms máximo de bloqueo del provider -- **Con reducción por accesibilidad:** 80ms máximo -- El integrador puede **bajar** estos caps pero no subirlos - -Una firma que declare duración superior al cap es truncada en ejecución, no rechazada. La coreografía puede continuar más allá del cap, pero el provider ya no espera. - -### 9.4. Scope de la acción - -El scope define el alcance temporal y espacial de la coreografía: - -- **`part`** (default): la coreografía afecta solo al target y se cancela si el target se desmonta -- **`component`**: la coreografía afecta al árbol del componente completo -- **`scene`**: la coreografía sobrevive al componente (útil para advisory+independent que deben continuar tras desmontaje) - -El scope se declara por acción, no globalmente — el mismo evento (`alert-threat`) puede tener scope distinto en componentes distintos (toast: scene; input: part). - ---- - -## 10. Personalización - -El integrador puede personalizar Sema en cinco niveles, ordenados de más global a más específico: - -### 10.1. Nivel 1 — Activación y volumen global - -``` -engine.configure({ - sound: { enabled: true, gain: 0.8 }, - motion: { enabled: true }, - color: { enabled: true }, - presence: { enabled: true }, - reflectEvents: false, // modo debug - capBlockingMs: 200 // override del cap global (solo bajar) -}); -``` - -`sound` desactivado por defecto. Los demás canales activos, respetando preferencias de accesibilidad. - -### 10.2. Nivel 2 — Sound pack - -Como se describe en §8.4. - -### 10.3. Nivel 3 — Override global del mapa - -``` -engine.configure({ - mapOverrides: { - 'alert-threat.sound.gain': 0.15, - 'contact-neutral.sound.pitch': 800 - } -}); -``` - -### 10.4. Nivel 4 — Override local en `.csem` - -Como se describe en §7. - -### 10.5. Nivel 5 — API imperativa (escape hatch) - -``` -engine.trigger(node, { - event: 'alert-threat', - overrides: { sound: { pitch: 500 } } -}); -``` - -Para casos que no caben declarativamente. - ---- - -## 11. Requisitos del engine - -### 11.1. Responsabilidades - -Una implementación del engine Sema debe: - -1. Consumir las acciones declaradas en el contrato cross-layer -2. Implementar el puerto `SemaPort` con `before()`, `fire()`, `startSustain()` -3. Consumir `sema-map.json` y el compilado de `.csem` -4. Resolver firmas según la jerarquía: acción → `.csem` → `sema-map` -5. Aplicar los canales activos respetando las garantías temporales y de accesibilidad -6. Emitir `CustomEvent('sema:event')` en el nodo target (contrato canónico de observabilidad) -7. Gestionar los cuatro regímenes (`replace | collapse | lock | queue`) -8. Cancelar coreografías por `AbortSignal` o por desmontaje del target -9. Respetar preferencias de accesibilidad del usuario - -### 11.2. Protocolo de eventos - -**Canónico — CustomEvent:** - -``` -node.dispatchEvent(new CustomEvent('sema:event', { - bubbles: true, - detail: { - event: 'alert-threat', - action: 'close-after-fail', - component: 'Dialog', - phase: 'start', // 'start' | 'end' | 'cancelled' - channels: ['motion', 'color', 'sound'], - duration: 180 - } -})); -``` - -Este es el mecanismo fiable para tests y observabilidad en producción. - -**Opt-in — Reflejo DOM:** - -Cuando el engine está configurado con `reflectEvents: true`, durante la ventana del evento añade: - -```html - -``` - -Y los retira al terminar. **No es el contrato canónico** — solo herramienta de desarrollo. - -### 11.3. Aplicación por canal - -Esta sección describe técnicas de implementación, no arquitectura. La doctrina es la secuencialidad coordinada de §2.2; las técnicas siguientes son vías válidas para ejecutar los canales dentro de esa doctrina. - -- **motion**: Web Animations API o mecanismo equivalente capaz de aplicar transformaciones temporales sobre el target -- **sound**: Web Audio API con síntesis parametrizada, o sample playback si hay pack -- **color**: implementación libre, respetando que durante la ventana del evento la capa visual no esté estilando activamente la misma propiedad -- **presence**: overlays, pseudoelementos, o cualquier mecanismo que exprese segregación figura-fondo sin interferir con el layout - -**Recomendaciones de implementación (no normativas):** - -En contextos donde el provider permite algún paralelismo con la capa visual (modo advisory, tails post-cap), o donde la capa visual gestiona transiciones CSS que podrían solaparse con el evento Sema, conviene aplicar técnicas aditivas para minimizar conflictos: - -- `motion` con WAAPI y `composite: 'add'` se suma al transform existente en lugar de reemplazarlo -- `color` con `box-shadow` adicional evita modificar el `border-color` que la capa visual controla -- `presence` con pseudoelementos (`::after`, `::before`) como overlay mantiene la autoría de la capa visual intacta en el nodo principal - -Estas técnicas no son obligatorias en el camino `blocking` (donde la secuencialidad evita el conflicto por diseño), pero son recomendables para implementaciones robustas. - -### 11.4. Compatibilidad con frameworks reactivos - -El engine debe tolerar: - -- Re-renders del framework a mitad de ejecución -- Desmontaje del target durante la ventana del evento -- Cambios en el DOM observado por terceros - -Esto implica que la implementación debe: - -- Verificar que el nodo target sigue conectado antes de aplicar cambios -- Cancelar limpiamente si el target desaparece -- No retener referencias a elementos desmontados - ---- - -## 12. Qué Sema NO debe hacer - -Protección explícita contra deriva conceptual: - -### 12.1. No carga narrativa de producto - -Sema describe **cómo el usuario percibe un cambio**, no **cómo el producto quiere que se sienta**. No hay familias `celebrate`, `reassure`, `onboard`, `encourage`. Esas son categorías narrativas. Si una nueva familia no tiene firma perceptiva distinguible por timing + canales, no es familia. - -### 12.2. No reemplaza presentación visual - -Sema no gestiona colores en reposo, tipografía, espaciado, layout. Todo eso es responsabilidad de la capa visual. Sema expresa transiciones perceptivas, no presentación. - -### 12.3. No gestiona estado de aplicación - -Sema no almacena estado, no gestiona formularios, no valida. La capa headless gestiona el estado; Sema reacciona a cambios que otros gestionan. - -### 12.4. No es una librería de animación - -Sema usa animaciones como uno de sus cuatro canales. No sustituye a Framer Motion, GSAP. No expone API para "animar cualquier cosa" — solo anima lo que el modelo semántico predica. - -### 12.5. No expone parámetros sin firma perceptiva distinguible - -Si dos configuraciones producen resultados perceptivamente indistinguibles para un usuario normal, una sobra. El sistema no expone sliders para ajustes que nadie percibe. - -### 12.6. El contrato cross-layer no contiene implementación perceptiva - -El contrato declara **qué eventos existen y cuándo**, no **cómo se expresan**. Parámetros sensoriales (pitch, roughness, curvas, hues concretos, duraciones exactas, samples) viven en `sema-map.json` y `.csem`, nunca en el contrato. - ---- - -## 13. Contrato mínimo de implementación - -Una implementación Sema-compliant debe proveer al menos: - -### 13.1. Mecanismo de declaración de acciones - -Un mecanismo declarativo para que cada componente exponga: - -- Sus acciones semánticas con los ocho campos conceptuales del §5.2 -- Sus sustains (con predicado de activación) -- Su relación con las partes del componente (referenciables) - -Forma concreta: libre. Puede ser TypeScript const, decoradores, JSON, YAML, registros runtime, hooks. El único requisito es que exponga la información que Sema necesita. - -### 13.2. Implementación del puerto - -Una realización concreta de `SemaPort` con: - -- `before(action, context): Promise` — blocking -- `fire(action, context): void` — advisory -- `startSustain(sustain, context): SemaSession` — sustain con lifecycle - -Con las garantías del §6.6. - -### 13.3. Runtime que consume artefactos - -Un runtime que: - -- Lea las acciones declaradas -- Procese `.csem` en build time -- Cargue `sema-map.json` -- Resuelva firmas según la jerarquía del §7.5 -- Aplique canales con las técnicas del §11.3 - -### 13.4. Soporte obligatorio - -- Las 6 familias y los 5 intents (vocabulario de 22 eventos) -- Los 4 canales con los parámetros del §4 -- Los 4 regímenes de arbitraje (§5.4) -- La matriz de 4 combinaciones válidas de mode × commits (§5.6) -- La política de accesibilidad por canal (§9.1) -- Los caps temporales (§9.3) -- `sound` desactivado por defecto (§4.2) -- `CustomEvent('sema:event')` como protocolo de observabilidad (§11.2) - -### 13.5. Soporte recomendado - -- Compilación de `.csem` en build time -- Modo debug con reflejo DOM (`reflectEvents: true`) -- Los cinco niveles de personalización (§10) -- Soporte de sound packs (§8.4) -- Documentación explícita de limitaciones conocidas - -### 13.6. Soporte opcional - -- API imperativa avanzada -- Herramientas de debug especializadas -- Validador de sound packs contra la spec - ---- - -## 14. Limitaciones conocidas - -### 14.1. Rendimiento - -- WAAPI con `composite: 'add'` tiene soporte universal en navegadores 2023+, pero verificar edge cases en Safari < 16 -- Web Audio API tiene latencia variable (10-50ms típico). En Safari móvil puede ser mayor. Para eventos críticos, precargar AudioContext -- MutationObservers en alta frecuencia pueden impactar rendimiento; el engine debe throttle/debounce donde corresponda - -### 14.2. Cobertura perceptiva - -- Las 6 familias no son categorías naturales del cerebro — son taxonomía útil. Casos de borde pueden requerir decisión editorial -- Los 5 intents pueden ser insuficiente resolución fina para casos muy sutiles. Aceptar la pérdida de detalle, no forzar más granularidad -- Los valores del mapa son extrapolaciones razonables, no ciencia dura. Requieren calibración empírica con usuarios - -### 14.3. Accesibilidad - -- Usuarios con condiciones específicas (vestibulares severos, fotosensibilidad compleja) pueden requerir desactivación completa -- `prefers-reduced-sound` no está estandarizado; mientras tanto, la implementación provee su propio toggle - -### 14.4. Contexto cultural - -- Los hues por intent son convención occidental. Contextos distintos deben sobreescribir defaults -- Los contornos melódicos tienen asociaciones consistentes pero no universales. Validar con usuarios del contexto - -### 14.5. Frameworks reactivos - -- La spec asume que la capa headless puede orquestar la secuencialidad. Frameworks que re-rendericen agresivamente componentes pueden complicar este contrato -- El desmontaje de componentes durante eventos es caso límite que el engine debe tolerar - ---- - -## Apéndice A — Implementaciones conocidas - -**SemaUIX** es la implementación de referencia de esta especificación. Se construye sobre Svelte 5 y usa un artefacto llamado `morfo` como contrato cross-layer. El documento `semauix-sema-impl.md` describe cómo SemaUIX materializa cada parte de esta spec: cómo morfo extiende para declarar acciones, qué shape TypeScript tiene, cómo se valida, cómo los providers consumen el puerto. - -Cualquier framework puede producir su propia implementación. La spec no exige ningún mecanismo concreto para el contrato cross-layer; solo que el contenido informativo esté disponible para las tres capas. - ---- - -## Apéndice B — Historial de decisiones clave - -1. **Sema es agnóstica de framework.** Opera sobre el DOM con Web APIs estándar. - -2. **Contrato cross-layer como fuente única.** Parts, atributos, ARIA, keyboard, acciones semánticas — todo declarado una vez. El mecanismo concreto es decisión de cada implementación. - -3. **Reducción de 14 semánticas a 6 familias.** Tras auditoría neurocientífica, las originales se solapaban. - -4. **Reducción de 6 canales a 4.** `depth` fusionado con `presence` (misma vía magnocelular). `form` decompuesto. - -5. **Parametrización psicoacústica del sonido.** Sin nombres de osciladores; el engine elige la síntesis. - -6. **Factorización base + delta en el mapa.** 22 eventos con valores derivados. - -7. **Secuencialidad coordinada en vez de principio aditivo.** En el camino canónico `blocking`, evento y estado ocurren en secuencia; los casos `advisory` y los tails post-cap quedan marcados como post-state y no violan la doctrina. - -8. **Sound desactivado por defecto.** Los demás canales activos con preferencias. - -9. **Estado vs evento como distinción fundamental.** - -10. **CustomEvent canónico + atributos DOM opt-in en debug.** - -11. **Acciones semánticas como unidad de integración.** Ni eventos DOM ni cambios de estado — acciones declaradas. - -12. **`.csem` es capa de override del integrador.** No obligatoria; cae a `sema-map.json` por defecto. - -13. **Cuatro regímenes de arbitraje.** `replace | collapse | lock | queue` cubren los casos de encadenado. - -14. **Collapse como single-flight coalescing.** Ni silencio ni pulsos continuos ni ventana elástica. - -15. **Política de accesibilidad por canal, no global.** Cada preferencia afecta canales específicos. - -16. **Caps temporales de 200ms (normal) / 80ms (con reducción).** - -17. **Commits declara efecto estructural, no precondiciones.** La validez contextual de una acción es responsabilidad de la capa headless, no del contrato declarativo. - -18. **Keyboard y acciones son espacios separables.** La capa headless puede tener acciones de teclado sin contrapartida Sema; solo adquieren semántica perceptiva las que coinciden con acciones declaradas. - -19. **Scope por acción, no por familia.** El mismo evento puede tener scope distinto en componentes distintos. - ---- - -## Apéndice C — Glosario - -- **Acción semántica**: unidad declarada en el contrato cross-layer que describe qué hace un componente con carga semántica (ej. `close-save`). La unidad de integración entre la capa headless y Sema. -- **Canal perceptivo**: eje sensorial por el que Sema expresa información (motion, sound, color, presence). -- **Capa headless**: capa del framework que gestiona comportamiento, estado, accesibilidad. -- **Capa visual**: capa del framework que gestiona presentación en reposo. -- **Commits**: campo de una acción que declara qué cambio de estado se aplicará tras la ventana Sema. -- **Contrato cross-layer**: artefacto declarativo por componente compartido por las tres capas. Mecanismo concreto decisión de implementación. -- **Evento Sema**: una de las 22 combinaciones del vocabulario canónico (`alert-threat`, `commit-fulfill`, etc.). -- **Firma efectiva**: conjunto de valores por canal resultante de resolver un evento contra `.csem` + `sema-map.json`. -- **Intent**: modulador afectivo de una familia valencial. -- **Prewrite**: atributos DOM que se reflejan antes de invocar Sema. -- **Régimen**: política de arbitraje para acciones repetidas (`replace | collapse | lock | queue`). -- **Sema-compliant**: implementación que cumple los requisitos mínimos del §13. -- **SemaEventLabel**: vocabulario de los 22 eventos canónicos. -- **SemaPort**: interfaz neutral que la capa headless consume para invocar Sema. -- **Secuencialidad coordinada**: principio operativo según el cual evento y estado ocurren en secuencia en el camino blocking. - ---- - -**Fin de la especificación Sema v0.4.** - -*Agnóstica de framework. Las implementaciones concretas documentan por separado cómo materializan la spec (ver por ejemplo `semauix-sema-impl.md`).* diff --git a/src/uix/sema/semauix-sema-impl.md b/src/uix/sema/semauix-sema-impl.md deleted file mode 100644 index a11ccb92a..000000000 --- a/src/uix/sema/semauix-sema-impl.md +++ /dev/null @@ -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; - 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; - 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. diff --git a/src/uix/sema/types.ts b/src/uix/sema/types.ts index f5281fa9f..a3a24f892 100644 --- a/src/uix/sema/types.ts +++ b/src/uix/sema/types.ts @@ -1,201 +1,79 @@ /** - * Sema — tipos públicos de la capa perceptiva. + * Sema — canonical semantic domain. * - * La capa sema es **autónoma**. No importa nada de morfo. Los tipos - * compartidos entre capas (como `PartRef`) viven en `$uix/lib/types` y - * cada capa los importa desde allí de forma independiente. - * - * Un componente puede declarar `sema` aunque no declare `morfo` (ni al - * contrario). Cuando ambas capas están presentes, se relacionan por el - * `kebab` del componente — no por intersección ni extensión de tipos. + * Sema no autoriza componentes ni ejecuta canales. Define el vocabulario + * fijo del framework, normaliza labels canónicos y tipa la semántica que + * `Morfo` declara y que `SemanticEngine` publica. */ -import type { PartRef } from '../lib/types'; +import type { PartRef } from '../lib/types' -// ── Vocabulario canónico de eventos (sema-spec-v0.3.1 §3.3) ──────────────── +// ── Core domain ──────────────────────────────────────────────────────────── + +export type SemaValencedFamily = 'contact' | 'commit' | 'alert' | 'handle' +export type SemaTransitionalFamily = 'emerge' | 'sustain' +export type SemaFamily = SemaValencedFamily | SemaTransitionalFamily + +export type SemaIntent = 'threat' | 'risk' | 'neutral' | 'affirm' | 'fulfill' + +export type SemaMode = 'blocking' | 'advisory' +export type SemaRegime = 'replace' | 'collapse' | 'lock' | 'queue' +export type SemaScope = 'part' | 'component' | 'scene' +export type SemaCause = 'keyboard' | 'pointer' | 'programmatic' | 'validation' -/** - * Los 22 eventos canónicos: 6 familias × 5 intents en las familias - * valenciales (contact, commit, alert, handle) + 2 transicionales (emerge, - * sustain). Closed set — cualquier `event` en un `SemaAction` debe ser uno - * de estos, y el validador lo enforce. - * - * La resolución perceptiva de cada evento (canales activos, pitches, - * durations, hues) vive en `sema-map.json` (defaults) y `.csem` - * (overrides del integrador). Sema-spec §7 y §8. - */ export type SemaEventLabel = - // contact (feedback inmediato a acto del usuario) | 'contact-neutral' | 'contact-threat' | 'contact-risk' | 'contact-affirm' | 'contact-fulfill' - // commit (cambio de estado discreto por el sistema) | 'commit-neutral' | 'commit-threat' | 'commit-risk' | 'commit-affirm' | 'commit-fulfill' - // alert (reclamo de atención sobre estado no atendido) | 'alert-neutral' | 'alert-threat' | 'alert-risk' | 'alert-affirm' | 'alert-fulfill' - // handle (manipulación continua del usuario) | 'handle-neutral' | 'handle-threat' | 'handle-risk' | 'handle-affirm' | 'handle-fulfill' - // transicionales (sin intent) | 'emerge' - | 'sustain'; + | 'sustain' -// ── Escrituras al DOM (sema-spec-v0.3.1 §5.2) ────────────────────────────── +// ── Structured semantics ─────────────────────────────────────────────────── -/** - * Escritura a un data-attribute que debe reflejarse en DOM antes de que - * Sema abra su ventana perceptiva. Su uso canónico es reflejar el motivo - * causal de un commit (`data-last-action="saved"`) para que `.csem` y la - * capa visual puedan tintar la ejecución del evento. - * - * Validación: - * - `part.target` resuelve a un `kebab` del morfo. - * - `attr` existe en el `data[]` de ese part. - * - `value` pertenece a `values[]` si el attr es enumerable. - */ -export interface SemaAttrWrite { - part: PartRef; - attr: string; - value: string; -} - -/** - * Efecto estructural que una acción comitea tras cerrar la ventana Sema - * (modo `blocking`) o en paralelo (modo `advisory`). - * - * Alcance: §5.7 — `commits` declara **efecto**, no precondiciones de - * aplicabilidad. La validez contextual sigue siendo responsabilidad del - * provider headless; el contrato sólo cataloga qué cambia cuando la - * acción se ejecuta. - */ -export interface SemaCommit { - part: PartRef; - attr: string; - value: string; +export interface SemaIntentBinding { + fromProp?: string + default: SemaIntent + supported?: readonly SemaIntent[] } -// ── Acciones (sema-spec-v0.3.1 §5.2) ─────────────────────────────────────── +export type SemaEvent = + | { + family: SemaTransitionalFamily + } + | { + family: SemaValencedFamily + intent: SemaIntent | SemaIntentBinding + } -/** - * Una acción semántica del componente. Siete campos, cinco opcionales con - * defaults — lo mínimo para que Sema sepa cuándo ejecutar, qué firma - * aplicar, cómo comportarse ante interrupciones, y qué contexto DOM - * escribir antes. - */ -export interface SemaAction { - /** - * Identificador único dentro del morfo. Referenciado desde - * `keyboard.action` (cuando procede) y desde el provider al invocar - * `sema.before(name, ctx)`. - */ - name: string; - /** Part primario afectado por la acción. */ - target: PartRef; - /** Evento canónico que esta acción dispara. */ - event: SemaEventLabel; - /** - * Relación del provider con la ventana Sema. - * - `blocking` (default): el provider hace `await sema.before()` antes del commit. - * - `advisory`: el provider dispara Sema y continúa inmediatamente. - */ - mode?: 'blocking' | 'advisory'; - /** - * Arbitraje cuando una segunda ocurrencia equivalente llega durante la - * ventana. Default `'replace'`. - * - `replace`: cancela la actual y arranca una nueva. - * - `collapse`: single-flight coalescing — no reinicia ni extiende. - * - `lock`: rechaza equivalentes mientras la ventana está abierta. - * - `queue`: las entrantes se encolan y se ejecutan secuencialmente. - * - * Dos ocurrencias son equivalentes si comparten `name` sobre el mismo target. - */ - regime?: 'replace' | 'collapse' | 'lock' | 'queue'; - /** - * Alcance perceptivo de la acción. Default `'part'`. - * - `'part'`: la coreografía vive atada al target; se cancela si se desmonta. - * - `'component'`: la coreografía cubre el árbol del componente. - * - `'scene'`: la coreografía sobrevive al desmontaje del componente - * (toasts, notificaciones que deben terminar de ejecutarse). - * - * Se declara por acción (no por familia en sema-map) porque el scope - * correcto depende del componente-más-evento, no del evento abstracto: - * un `emerge` en Toast requiere `'scene'`, el mismo `emerge` en Dialog - * requiere `'part'`. Ver Apéndice B de sema-spec-v0.3.1. - */ - scope?: 'part' | 'component' | 'scene'; - /** - * Atributos que se reflejan en DOM **antes** de invocar Sema. - * Típicamente `data-last-action` para comunicar el motivo causal del - * commit inminente. - */ - prewrite?: readonly SemaAttrWrite[]; - /** - * Cambio de estado estructural que la acción comitea. Ausente para - * acciones de feedback puro sin transición de estado (p. ej. - * `submit-failed` no mueve al formulario de `idle` a ningún otro - * estado — sólo dispara un `alert-threat`). - */ - commits?: SemaCommit; -} +export type SemaActionEvent = SemaEvent | SemaEventLabel -// ── Sustains (sema-spec-v0.3.1 §6.6) ─────────────────────────────────────── +// ── DOM-oriented writes used by Morfo events ────────────────────────────── -/** - * Una presencia perceptiva de larga duración (segundos, minutos, horas) - * cuya existencia depende de que un predicado DOM siga cumpliéndose. - * Distinto de `SemaAction` porque su ciclo de vida no es episódico — - * no tiene "fin natural" por duration, sino que termina cuando el - * provider llama `session.stop()`. - */ -export interface SemaSustainDecl { - name: string; - target: PartRef; - /** El predicado DOM que mantiene el sustain activo. */ - activeWhen: { part: PartRef; attr: string; value: string }; - /** Siempre `'sustain'`. Tipado por simetría con `SemaAction`. */ - event: 'sustain'; - /** Default `'part'`. Igual que en acciones, pero aplicado a la sesión sustain. */ - scope?: 'part' | 'component' | 'scene'; +export interface SemaAttrWrite { + part: PartRef + attr: string + value: string } -// ── Declaración sema del componente ─────────────────────────────────────── - -/** - * Contrato sema completo de un componente. Standalone: no menciona morfo. - * - * `kebab` identifica al componente y sirve de puente con otras capas - * (morfo, eidos) cuando existen — sin tipado intersectado. Si el - * componente también declara morfo, los dos kebabs deben coincidir; lo - * enforce el validador cuando se le pasa el morfo como contexto. - * - * Autoría recomendada: - * - * ```ts - * export const dialogSema = { - * kebab: 'dialog', - * actions: [ ... ] - * } as const satisfies SemaSpec; - * ``` - */ -export interface SemaSpec { - /** - * kebab-case del componente. Debe coincidir con el `kebab` del morfo - * correspondiente cuando el componente también tiene morfo. - */ - kebab: string; - actions: readonly SemaAction[]; - sustains?: readonly SemaSustainDecl[]; +export interface SemaCommit { + part: PartRef + attr: string + value: string } diff --git a/src/uix/sema/validation.ts b/src/uix/sema/validation.ts index 613174e91..5a0a4340f 100644 --- a/src/uix/sema/validation.ts +++ b/src/uix/sema/validation.ts @@ -1,236 +1,42 @@ -/** - * Sema invariants validator. - * - * Sema es autónoma. Los invariantes internos (nombres únicos, eventos - * canónicos) se validan sin morfo. Cuando se pasa morfo como contexto - * opcional, se añaden los cross-checks (parts, data[], states[], - * data-last-action.values[]). - * - * El validador recibe el morfo por su **forma estructural** (el tipo - * `MorfoContext` de abajo), no por su tipo `Morfo`. Así sema sigue sin - * depender del módulo morfo a nivel de tipos: cualquier valor que tenga - * `kebab` + `parts` servirá. En la práctica el llamador pasa un `Morfo` - * y TypeScript lo acepta por compatibilidad estructural. - * - * Importante: `validateSema()` asume que el `spec` ya está tipado por - * TypeScript (`as const satisfies SemaSpec`). A diferencia de - * `validateMorfo()`, no hace decode completo del shape runtime; valida - * invariantes semánticos y referencias cruzadas sobre entrada tipada. - */ +import { isSemaEvent, isSemaEventLabel, isSemaIntent, isSemaIntentBinding } from './event' +import type { SemaActionEvent, SemaIntentBinding } from './types' -import type { SemaSpec, SemaEventLabel } from './types'; - -// Estructura mínima que el validador necesita del morfo para hacer los -// cross-checks. Redeclarada aquí (no importada de morfo) para que sema -// no tenga dependencia de tipos con morfo. -interface MorfoPartLike { - kebab: string; - states?: readonly string[]; - data: readonly { attr: string; values?: readonly string[] }[]; - parts?: readonly MorfoPartLike[]; -} - -interface MorfoContext { - kebab: string; - parts: readonly MorfoPartLike[]; -} - -/** Thrown when a sema invariant fails. */ export class SemaInvariantError extends Error { constructor(message: string) { - super(message); - this.name = 'SemaInvariantError'; + super(message) + this.name = 'SemaInvariantError' } } -/** Los 22 eventos canónicos. Debe mantenerse en sync con `SemaEventLabel`. */ -const SEMA_EVENT_LABELS = new Set([ - 'contact-neutral', - 'contact-threat', - 'contact-risk', - 'contact-affirm', - 'contact-fulfill', - 'commit-neutral', - 'commit-threat', - 'commit-risk', - 'commit-affirm', - 'commit-fulfill', - 'alert-neutral', - 'alert-threat', - 'alert-risk', - 'alert-affirm', - 'alert-fulfill', - 'handle-neutral', - 'handle-threat', - 'handle-risk', - 'handle-affirm', - 'handle-fulfill', - 'emerge', - 'sustain' -]); - -function flattenParts(parts: readonly MorfoPartLike[]): MorfoPartLike[] { - const out: MorfoPartLike[] = []; - for (const p of parts) { - out.push(p); - if (p.parts && p.parts.length > 0) out.push(...flattenParts(p.parts)); +export function validateSemaIntentBinding(binding: SemaIntentBinding, ctx = 'sema.intent'): void { + if (!isSemaIntentBinding(binding)) { + throw new SemaInvariantError(`${ctx} is not a valid SemaIntentBinding`) } - return out; -} -/** - * Valida los invariantes de un `SemaSpec`. - * - * **Invariantes internos** (siempre): - * 1. `name` único dentro del spec. - * 2. `event` pertenece al vocabulario canónico. - * - * **Invariantes cross-morfo** (sólo si se pasa `morfo`): - * 3. `spec.kebab === morfo.kebab`. - * 4. `action.target.target` resuelve a una parte del morfo. - * 5. `prewrite[].part.target` resuelve. - * 6. `prewrite[].attr` existe en `data[]` del part destino. - * 7. `prewrite[].value` ∈ `values[]` si el attr es enumerable. - * 8. `commits.part.target` resuelve. - * 9. `commits.value` ∈ `states[]` si `commits.attr === 'data-state'`. - * 10. `data-last-action.values[]` == unión de prewrites que escriben a ese - * attr (ambas direcciones). - * 11. `sustains[].target.target` y `sustains[].activeWhen.part.target` resuelven. - */ -export function validateSema(spec: SemaSpec, morfo?: MorfoContext): void { - // 1. Nombres únicos (siempre). - const actionNames = new Set(); - for (const action of spec.actions) { - if (actionNames.has(action.name)) { - throw new SemaInvariantError( - `sema: duplicate action name "${action.name}" in "${spec.kebab}"` - ); - } - actionNames.add(action.name); - } + const supported = binding.supported ?? [] + if (supported.length === 0) return - // 2. Eventos canónicos (siempre). - for (const action of spec.actions) { - if (!SEMA_EVENT_LABELS.has(action.event)) { - throw new SemaInvariantError( - `sema.actions["${action.name}"]: event "${action.event}" is not a valid SemaEventLabel` - ); + for (const intent of supported) { + if (!isSemaIntent(intent)) { + throw new SemaInvariantError(`${ctx}: intent "${String(intent)}" is not a valid SemaIntent`) } } - // Resto depende de tener contexto de morfo. - if (!morfo) return; - - // 3. kebab coincide. - if (morfo.kebab !== spec.kebab) { + if (!supported.includes(binding.default)) { throw new SemaInvariantError( - `sema: spec.kebab "${spec.kebab}" does not match morfo.kebab "${morfo.kebab}"` - ); + `${ctx}: default intent "${binding.default}" must be included in supported intents` + ) } +} - const flat = flattenParts(morfo.parts); - const kebabs = new Set(); - const partByKebab = new Map(); - for (const part of flat) { - kebabs.add(part.kebab); - partByKebab.set(part.kebab, part); - } - - const prewriteDLAByPart = new Map>(); - - 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(); - s.add(pw.value); - prewriteDLAByPart.set(pw.part.target, s); - } - } +export function validateSemaEvent(event: SemaActionEvent, ctx = 'sema.event'): void { + if (isSemaEventLabel(event)) return - // 8-9. commits. - if (action.commits) { - const cCtx = `${ctx}.commits`; - if (!kebabs.has(action.commits.part.target)) { - throw new SemaInvariantError( - `${cCtx}: part "${action.commits.part.target}" does not match any part in "${morfo.kebab}"` - ); - } - if (action.commits.attr === 'data-state') { - const targetPart = partByKebab.get(action.commits.part.target); - const states = targetPart?.states ?? []; - if (!states.includes(action.commits.value)) { - throw new SemaInvariantError( - `${cCtx}: value "${action.commits.value}" not in states of "${action.commits.part.target}" (declared: ${states.join(', ') || '∅'})` - ); - } - } - } + if (!isSemaEvent(event)) { + throw new SemaInvariantError(`${ctx} is not a valid canonical semantic event`) } - // 10. data-last-action.values[] == unión de prewrites que lo escriben. - for (const part of flat) { - const partKebab = part.kebab; - const dla = part.data.find((d) => d.attr === 'data-last-action'); - if (!dla?.values) continue; - const written = prewriteDLAByPart.get(partKebab) ?? new Set(); - const declared = new Set(dla.values); - for (const v of written) { - if (!declared.has(v)) { - throw new SemaInvariantError( - `sema: prewrite writes "${v}" to data-last-action on "${partKebab}", but the part's values[] does not include it (declared: ${[...declared].join(', ')})` - ); - } - } - for (const v of declared) { - if (!written.has(v)) { - throw new SemaInvariantError( - `sema: part "${partKebab}" declares data-last-action value "${v}" but no sema action prewrites it — values[] must equal the union of prewrites` - ); - } - } - } + if (!('intent' in event) || typeof event.intent === 'string') return - // 11. sustains. - for (const sustain of spec.sustains ?? []) { - const ctx = `sema.sustains["${sustain.name}"]`; - if (!kebabs.has(sustain.target.target)) { - throw new SemaInvariantError( - `${ctx}: target "${sustain.target.target}" does not match any part` - ); - } - if (!kebabs.has(sustain.activeWhen.part.target)) { - throw new SemaInvariantError( - `${ctx}: activeWhen.part "${sustain.activeWhen.part.target}" does not match any part` - ); - } - } + validateSemaIntentBinding(event.intent, `${ctx}.intent`) } diff --git a/src/uix/soma/components/accordion/accordion-provider.svelte.ts b/src/uix/soma/components/accordion/accordion-provider.svelte.ts index 06989bd5b..ff5ef03d6 100644 --- a/src/uix/soma/components/accordion/accordion-provider.svelte.ts +++ b/src/uix/soma/components/accordion/accordion-provider.svelte.ts @@ -1,7 +1,6 @@ import { Provider, context, type WithRefOpts } from '../../provider'; import { createAttrs, - registerContract, boolToEmptyStrOrUndef, getDataOpenClosed } from '../../attrs'; @@ -19,7 +18,6 @@ import { ResizeObserver$ } from '../../layers/resize-observer.svelte'; import { accordionMorfo } from '../../../morfo/components/accordion'; const attrs = createAttrs(accordionMorfo); -registerContract(accordionMorfo); export type AccordionType = 'single' | 'multiple'; @@ -50,7 +48,7 @@ export class AccordionProvider extends Provider { private registeredValues = new Set(); private constructor(opts: AccordionOpts) { - super(opts, 'Accordion', 'provider', attrs.provider, AccordionProvider.ctx); + super(opts, { morfo: accordionMorfo, part: 'provider' }, AccordionProvider.ctx); } isItemOpen(value: string): boolean { @@ -95,9 +93,13 @@ export class AccordionProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - dir: this.opts.dir.current, - 'data-orientation': this.opts.orientation.current, - 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current) + ...this.resolveMorfoProps({ + props: { + orientation: this.opts.orientation.current, + disabled: this.opts.disabled.current + } + }), + dir: this.opts.dir.current } as const) ); } @@ -132,7 +134,7 @@ export class AccordionItemProvider extends Provider { private contentRef = state(null); private constructor(opts: AccordionItemOpts) { - super(opts, 'Accordion', 'item', attrs.item, AccordionItemProvider.ctx); + super(opts, { morfo: accordionMorfo, part: 'item' }, AccordionItemProvider.ctx); this.provider = AccordionProvider.require(); this.contentPresence = new Presence({ @@ -169,9 +171,16 @@ export class AccordionItemProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - 'data-state': getDataOpenClosed(this.isOpen), - 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), - 'data-orientation': this.provider.opts.orientation.current + ...this.resolveMorfoProps({ + states: { + open: this.isOpen + }, + props: { + disabled: this.isDisabled, + orientation: this.provider.opts.orientation.current + } + }), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled) } as const) ); } @@ -188,18 +197,24 @@ export class AccordionHeaderProvider extends Provider { readonly item: AccordionItemProvider; private constructor(opts: AccordionHeaderOpts) { - super(opts, 'Accordion', 'header', attrs.header); + super(opts, { morfo: accordionMorfo, part: 'header' }); this.item = AccordionItemProvider.require(); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - role: 'heading' as const, - 'aria-level': Math.max(1, Math.min(6, this.opts.level.current)), - 'data-state': getDataOpenClosed(this.item.isOpen), - 'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled), - 'data-orientation': this.item.provider.opts.orientation.current + ...this.resolveMorfoProps({ + states: { + open: this.item.isOpen + }, + props: { + level: Math.max(1, Math.min(6, this.opts.level.current)), + disabled: this.item.isDisabled, + orientation: this.item.provider.opts.orientation.current + } + }), + 'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled) } as const) ); } @@ -216,7 +231,7 @@ export class AccordionTriggerProvider extends Provider { readonly item: AccordionItemProvider; private constructor(opts: AccordionTriggerOpts) { - super(opts, 'Accordion', 'trigger', attrs.trigger); + super(opts, { morfo: accordionMorfo, part: 'trigger' }); this.item = AccordionItemProvider.require(); this.item.triggerId.current = opts.id.current; } @@ -266,12 +281,19 @@ export class AccordionTriggerProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - type: 'button' as const, - 'aria-expanded': this.item.isOpen, - 'aria-controls': this.item.contentId.current || undefined, - 'data-state': getDataOpenClosed(this.item.isOpen), + ...this.resolveMorfoProps({ + states: { + open: this.item.isOpen + }, + props: { + disabled: this.item.isDisabled, + orientation: this.item.provider.opts.orientation.current + }, + parts: { + content: this.item.contentId.current + } + }), 'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled), - 'data-orientation': this.item.provider.opts.orientation.current, disabled: this.item.isDisabled || undefined, onclick: this.onclick, onkeydown: this.onkeydown @@ -291,7 +313,7 @@ export class AccordionContentProvider extends Provider { readonly item: AccordionItemProvider; private constructor(opts: AccordionContentOpts) { - super(opts, 'Accordion', 'content', attrs.content, undefined, (el) => { + super(opts, { morfo: accordionMorfo, part: 'content' }, undefined, (el) => { this.item.setContentRef(el); }); this.item = AccordionItemProvider.require(); @@ -303,11 +325,19 @@ export class AccordionContentProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - role: 'region' as const, - 'aria-labelledby': this.item.triggerId.current || undefined, - 'data-state': getDataOpenClosed(this.item.isOpen), + ...this.resolveMorfoProps({ + states: { + open: this.item.isOpen + }, + props: { + disabled: this.item.isDisabled, + orientation: this.item.provider.opts.orientation.current + }, + parts: { + trigger: this.item.triggerId.current + } + }), 'data-disabled': boolToEmptyStrOrUndef(this.item.isDisabled), - 'data-orientation': this.item.provider.opts.orientation.current, style: { '--soma-accordion-content-height': `${this.item.contentHeight.current}px`, '--soma-accordion-content-width': `${this.item.contentWidth.current}px` diff --git a/src/uix/soma/components/dialog/dialog-provider.svelte.ts b/src/uix/soma/components/dialog/dialog-provider.svelte.ts index 83984721c..eb12eaaf6 100644 --- a/src/uix/soma/components/dialog/dialog-provider.svelte.ts +++ b/src/uix/soma/components/dialog/dialog-provider.svelte.ts @@ -1,10 +1,5 @@ import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider'; -import { - createAttrs, - registerContract, - boolToEmptyStrOrUndef, - getDataOpenClosed -} from '../../attrs'; +import { boolToEmptyStrOrUndef, getDataOpenClosed } from '../../attrs'; import { readableActive, state, @@ -25,15 +20,6 @@ import { TextSelection } from '../../layers/text-selection.svelte'; import { dialogMorfo } from '../../../morfo/components/dialog'; -// Parts, data-attrs, and their valid enums are sourced from `dialogMorfo` — -// the single cross-layer contract. Changing a part name or a data-attr -// value here requires editing the morfo, never this file. -const attrs = createAttrs(dialogMorfo); - -// Data-attr contract comes from the morfo. Any change in valid values -// or attr names is done in `morfo/components/dialog.ts`. -registerContract(dialogMorfo); - // ── Provider (root) ───────────────────────────────────────────────────────── export type DialogVariant = 'dialog' | 'alertdialog'; @@ -84,7 +70,7 @@ export class DialogProvider extends Provider { private constructor(opts: DialogOpts) { // Read parent BEFORE registering in context (ctx.set happens in super) const parent = DialogProvider.get() as DialogProvider | undefined; - super(opts, 'Dialog', 'provider', attrs.provider, DialogProvider.ctx); + super(opts, { morfo: dialogMorfo, part: 'provider' }, DialogProvider.ctx); this.parent = parent; this.depth = parent ? parent.depth + 1 : 0; @@ -139,8 +125,14 @@ export class DialogProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - 'data-state': getDataOpenClosed(this.opts.open.current), - 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current) + ...this.resolveMorfoProps({ + states: { + open: this.opts.open.current + }, + props: { + disabled: this.opts.disabled.current + } + }) } as const) ); } @@ -157,7 +149,7 @@ export class DialogTriggerProvider extends Provider { readonly provider: DialogProvider; private constructor(opts: DialogTriggerOpts) { - super(opts, 'Dialog', 'trigger', attrs.trigger); + super(opts, { morfo: dialogMorfo, part: 'trigger' }); this.provider = DialogProvider.require(); this.provider.triggerId.current = opts.id.current; } @@ -169,11 +161,14 @@ export class DialogTriggerProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - type: 'button' as const, - 'aria-haspopup': 'dialog' as const, - 'aria-expanded': this.provider.opts.open.current, - 'aria-controls': this.provider.contentId.current || undefined, - 'data-state': getDataOpenClosed(this.provider.opts.open.current), + ...this.resolveMorfoProps({ + states: { + open: this.provider.opts.open.current + }, + parts: { + content: this.provider.contentId.current + } + }), disabled: this.provider.opts.disabled.current || undefined, onclick: this.onclick, ...this.attachment @@ -214,7 +209,7 @@ export class DialogContentProvider extends Provider { readonly textSelection: TextSelection; private constructor(opts: DialogContentOpts) { - super(opts, 'Dialog', 'content', attrs.content, undefined, (el) => { + super(opts, { morfo: dialogMorfo, part: 'content' }, undefined, (el) => { this.provider.setContentRef(el); }); this.provider = DialogProvider.require(); @@ -277,12 +272,20 @@ export class DialogContentProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, + ...this.resolveMorfoProps({ + states: { + open: this.provider.opts.open.current + }, + props: { + modal: this.provider.opts.modal.current + }, + parts: { + title: this.provider.titleId.current, + description: this.provider.descriptionId.current + } + }), role: this.provider.opts.variant.current, - 'aria-modal': this.provider.opts.modal.current ? true : undefined, 'aria-roledescription': undefined, - 'aria-describedby': this.provider.descriptionId.current || undefined, - 'aria-labelledby': this.provider.titleId.current || undefined, - 'data-state': getDataOpenClosed(this.provider.opts.open.current), 'data-nested': this.provider.isNested ? '' : undefined, 'data-nested-open': this.provider.hasNestedOpen ? '' : undefined, style: { @@ -309,7 +312,7 @@ export class DialogOverlayProvider extends Provider { readonly provider: DialogProvider; private constructor(opts: DialogOverlayOpts) { - super(opts, 'Dialog', 'overlay', attrs.overlay, undefined, (el) => { + super(opts, { morfo: dialogMorfo, part: 'overlay' }, undefined, (el) => { this.provider.setOverlayRef(el); }); this.provider = DialogProvider.require(); @@ -320,8 +323,11 @@ export class DialogOverlayProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - 'data-state': getDataOpenClosed(this.provider.opts.open.current), - 'aria-hidden': true as const, + ...this.resolveMorfoProps({ + states: { + open: this.provider.opts.open.current + } + }), 'data-nested': this.provider.isNested ? '' : undefined, 'data-nested-open': this.provider.hasNestedOpen ? '' : undefined, style: { @@ -346,7 +352,7 @@ export class DialogTitleProvider extends Provider { readonly provider: DialogProvider; private constructor(opts: DialogTitleOpts) { - super(opts, 'Dialog', 'title', attrs.title); + super(opts, { morfo: dialogMorfo, part: 'title' }); this.provider = DialogProvider.require(); this.provider.titleId.current = opts.id.current; } @@ -354,8 +360,11 @@ export class DialogTitleProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - role: 'heading' as const, - 'aria-level': this.opts.level.current + ...this.resolveMorfoProps({ + props: { + level: this.opts.level.current + } + }) } as const) ); } @@ -372,7 +381,7 @@ export class DialogDescriptionProvider extends Provider { readonly provider: DialogProvider; private constructor(opts: DialogDescriptionOpts) { - super(opts, 'Dialog', 'description', attrs.description); + super(opts, { morfo: dialogMorfo, part: 'description' }); this.provider = DialogProvider.require(); this.provider.descriptionId.current = opts.id.current; } @@ -396,7 +405,7 @@ export class DialogCloseProvider extends Provider { readonly provider: DialogProvider; private constructor(opts: DialogCloseOpts) { - super(opts, 'Dialog', 'close', attrs.close); + super(opts, { morfo: dialogMorfo, part: 'close' }); this.provider = DialogProvider.require(); } @@ -407,7 +416,7 @@ export class DialogCloseProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - type: 'button' as const, + ...this.resolveMorfoProps(), onclick: this.onclick } as const) ); diff --git a/src/uix/soma/components/toast/README.md b/src/uix/soma/components/toast/README.md index 53f32d469..ea04e3ffc 100644 --- a/src/uix/soma/components/toast/README.md +++ b/src/uix/soma/components/toast/README.md @@ -52,13 +52,12 @@ const toaster = createToaster({ hotkey: ['F8'] // keyboard shortcut to focus viewport }); -// Create toasts by type +// Create toasts by semantic intent const id = toaster.create({ title: 'Hello' }); -toaster.success({ title: 'Saved', description: 'Changes saved.' }); -toaster.error({ title: 'Error', description: 'Something went wrong.' }); -toaster.warning({ title: 'Warning' }); -toaster.info({ title: 'Info' }); -toaster.loading({ title: 'Uploading...' }); // persistent by default (duration: 0) +toaster.create({ title: 'Saved', description: 'Changes saved.', intent: 'fulfill' }); +toaster.create({ title: 'Something needs attention', intent: 'risk' }); +toaster.create({ title: 'Something went wrong', intent: 'threat' }); +toaster.create({ title: 'Uploading...', loading: true }); // persistent by default (duration: 0) // Manage toasts toaster.dismiss(id); // dismiss one @@ -75,11 +74,11 @@ toaster.create({ toaster.create({ title: 'Quick', duration: 2000 }); toaster.create({ title: 'Persistent', duration: 0 }); // no auto-dismiss -// Promise tracking — loading → success/error +// Promise tracking — loading → fulfill/threat toaster.promise(fetchData(), { loading: { title: 'Loading...', description: 'Fetching data.' }, - success: (data) => ({ title: 'Done', description: `Loaded ${data.count} items.` }), - error: (err) => ({ title: 'Failed', description: String(err) }) + fulfill: (data) => ({ title: 'Done', description: `Loaded ${data.count} items.` }), + threat: (err) => ({ title: 'Failed', description: String(err) }) }); ``` @@ -89,15 +88,15 @@ toaster.promise(fetchData(), { | -------- | ------------------ | ------------------------------------------------- | | Viewport | `role` | `region` | | Viewport | `aria-label` | Configurable (default "Notifications") | -| Item | `role` | `status` (default) \| `alert` (error/warning) | -| Item | `aria-live` | `polite` (default) \| `assertive` (error/warning) | +| Item | `role` | `status` (neutral/affirm/fulfill) \| `alert` (risk/threat) | +| Item | `aria-live` | `polite` (neutral/affirm/fulfill) \| `assertive` (risk/threat) | | Item | `aria-atomic` | `true` | | Item | `aria-labelledby` | ID of Title | | Item | `aria-describedby` | ID of Description | | Action | `aria-label` | `altText` prop value | | Close | `aria-label` | Translated "Close" | -Error and warning toasts use `role="alert"` with `aria-live="assertive"` for immediate screen reader announcement. Other types use `role="status"` with `aria-live="polite"`. +Risk and threat toasts use `role="alert"` with `aria-live="assertive"` for immediate screen reader announcement. Other intents use `role="status"` with `aria-live="polite"`. ## Data Attributes @@ -108,7 +107,8 @@ Error and warning toasts use `role="alert"` with `aria-live="assertive"` for imm | Viewport | `data-toast-viewport` | Always present | | Item | `data-toast-item` | Always present | | Item | `data-state` | `open` \| `closed` | -| Item | `data-type` | `default` \| `success` \| `error` \| `warning` \| `info` \| `loading` | +| Item | `data-intent` | `neutral` \| `affirm` \| `fulfill` \| `risk` \| `threat` | +| Item | `data-loading` | Present while the toast is in loading/pending phase | | Item | `data-swipe` | `start` \| `move` \| `cancel` \| `end` (during swipe gesture) | | Item | `data-swipe-direction` | `left` \| `right` \| `up` \| `down` | | Item | `data-starting-style` | Present during open animation | @@ -142,7 +142,7 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`. - Default duration: 5000ms (configurable per toaster and per toast) - Set `duration: 0` on a toast to disable auto-dismiss -- `loading` type defaults to `duration: 0` (persistent until dismissed) +- `loading: true` defaults to `duration: 0` (persistent until dismissed) - Timer pauses on hover (`pointerenter`) and focus - Timer resumes on `pointerleave` and `blur` @@ -161,10 +161,10 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`. ### Promise tracking -- `toaster.promise(promise, { loading, success, error })` creates a loading toast -- When the promise resolves, the toast updates to success type -- When the promise rejects, the toast updates to error type -- Success/error options can be functions receiving the resolved value or error +- `toaster.promise(promise, { loading, fulfill, threat })` creates a loading toast +- When the promise resolves, the toast updates to `intent='fulfill'` +- When the promise rejects, the toast updates to `intent='threat'` +- `fulfill`/`threat` options can be functions receiving the resolved value or error ## Usage @@ -206,7 +206,7 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`. const toaster = getContext('toaster'); - + ``` ### Promise tracking @@ -216,8 +216,8 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`. onclick={() => { toaster.promise(saveData(), { loading: { title: 'Saving...' }, - success: () => ({ title: 'Saved!' }), - error: (e) => ({ title: 'Failed', description: e.message }) + fulfill: () => ({ title: 'Saved!' }), + threat: (e) => ({ title: 'Failed', description: e.message }) }); }} > @@ -245,7 +245,7 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`. | Feature | Radix | Ark | Sonner | Soma | | ---------------- | --------------------- | -------------------------- | ----------------- | ------------------------------------------ | | API style | Declarative | Imperative | Imperative | Imperative | -| Type variants | foreground/background | success/error/warning/info | success/error/etc | default/success/error/warning/info/loading | +| Semantic variants | foreground/background | success/error/warning/info | success/error/etc | intent + loading state | | Auto-dismiss | Yes | Yes | Yes | Yes | | Pause on hover | Yes | Yes | Yes | Yes | | Swipe to dismiss | Yes | Yes | Yes | Yes | @@ -253,6 +253,6 @@ The hotkey is configurable via `createToaster({ hotkey: ['F8'] })`. | Max toasts | Manual | Built-in | Built-in | Built-in | | Hotkey | F8 | No | Alt+T | F8 (configurable) | | Promise tracking | No | Yes | Yes | Yes | -| Loading type | No | No | Yes | Yes | -| ARIA urgency | Manual | Auto by type | Auto | Auto by type | +| Loading state | No | No | Yes | Yes | +| ARIA urgency | Manual | Auto by type | Auto | Auto by intent | | Queue management | Manual | Built-in | Built-in | Built-in | diff --git a/src/uix/soma/components/toast/exports.ts b/src/uix/soma/components/toast/exports.ts index 78fe1ee6e..4a4fbe871 100644 --- a/src/uix/soma/components/toast/exports.ts +++ b/src/uix/soma/components/toast/exports.ts @@ -12,7 +12,7 @@ export type { CreateToastOpts, PromiseToastOpts, ToasterConfig, - ToastType, + ToastIntent, SwipeDirection } from './toaster.svelte'; diff --git a/src/uix/soma/components/toast/toast-provider.svelte.ts b/src/uix/soma/components/toast/toast-provider.svelte.ts index 0dee0fca5..c17601b6d 100644 --- a/src/uix/soma/components/toast/toast-provider.svelte.ts +++ b/src/uix/soma/components/toast/toast-provider.svelte.ts @@ -1,18 +1,13 @@ import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider'; -import { createAttrs, registerContract, boolToEmptyStrOrUndef } from '../../attrs'; import { readableActive, state, type Active, type ActiveProps } from '../../reactive'; import type { SomaMouseEvent } from '../../types'; import { Soma } from '../../core/soma.svelte'; -import { TOAST_LANGS } from './langs'; import { isBrowser } from '../../dom'; import { Presence } from '../../layers/presence.svelte'; import type { Toaster, ToastData, SwipeDirection } from './toaster.svelte'; import { toastMorfo } from '../../../morfo/components/toast'; -const attrs = createAttrs(toastMorfo); -registerContract(toastMorfo); - // ── Provider (root, no DOM) ───────────────────────────────────────────────── interface ToastProviderOpts @@ -35,7 +30,7 @@ export class ToastProvider extends Provider { readonly soma = Soma.get(); private constructor(opts: ToastProviderOpts) { - super(opts, 'Toast', 'provider', attrs.provider, ToastProvider.ctx); + super(opts, { morfo: toastMorfo, part: 'provider' }, ToastProvider.ctx); } get toaster(): Toaster { @@ -56,7 +51,7 @@ export class ToastViewportProvider extends Provider { private cleanupHotkey: (() => void) | undefined; private constructor(opts: ToastViewportOpts) { - super(opts, 'Toast', 'viewport', attrs.viewport); + super(opts, { morfo: toastMorfo, part: 'viewport' }); this.provider = ToastProvider.require(); // Register global hotkey to focus viewport, with cleanup. Supports @@ -123,9 +118,11 @@ export class ToastViewportProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - role: 'region' as const, - 'aria-live': 'polite' as const, - 'aria-label': this.provider.opts.label.current, + ...this.resolveMorfoProps({ + props: { + label: this.provider.opts.label.current + } + }), tabindex: -1 } as const) ); @@ -174,7 +171,7 @@ export class ToastItemProvider extends Provider { readonly isOpen = $derived.by(() => !this.opts.toast.current.dismissing); private constructor(opts: ToastItemOpts) { - super(opts, 'Toast', 'item', attrs.item, ToastItemProvider.ctx); + super(opts, { morfo: toastMorfo, part: 'item' }, ToastItemProvider.ctx); this.provider = ToastProvider.require(); // Presence for exit animation @@ -352,25 +349,24 @@ export class ToastItemProvider extends Provider { } }; - // ── ARIA ───────────────────────────────────────────────────────────────── - - private get isUrgent(): boolean { - const type = this.opts.toast.current.type; - return type === 'error' || type === 'warning'; - } - readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - role: this.isUrgent ? ('alert' as const) : ('status' as const), - 'aria-live': this.isUrgent ? ('assertive' as const) : ('polite' as const), - 'aria-atomic': true, - 'aria-labelledby': this.titleId.current || undefined, - 'aria-describedby': this.descriptionId.current || undefined, - 'data-state': this.isOpen ? 'open' : 'closed', - 'data-type': this.opts.toast.current.type, - 'data-swipe': this.swipeState === 'idle' ? undefined : this.swipeState, - 'data-swipe-direction': this.provider.toaster.swipeDirection, + ...this.resolveMorfoProps({ + states: { + open: this.isOpen + }, + props: { + intent: this.opts.toast.current.intent, + loading: this.opts.toast.current.loading, + swipeState: this.swipeState === 'idle' ? undefined : this.swipeState, + swipeDirection: this.provider.toaster.swipeDirection + }, + parts: { + title: this.titleId.current || undefined, + description: this.descriptionId.current || undefined + } + }), tabindex: 0, style: { '--soma-toast-swipe-move-x': `${this.swipeDeltaX}px`, @@ -408,7 +404,7 @@ export class ToastTitleProvider extends Provider { } private constructor(opts: ToastTitleOpts) { - super(opts, 'Toast', 'title', attrs.title); + super(opts, { morfo: toastMorfo, part: 'title' }); const toastItem = ToastItemProvider.require(); toastItem.titleId.current = opts.id.current; } @@ -430,7 +426,7 @@ export class ToastDescriptionProvider extends Provider { } private constructor(opts: ToastDescriptionOpts) { - super(opts, 'Toast', 'description', attrs.description); + super(opts, { morfo: toastMorfo, part: 'description' }); const toastItem = ToastItemProvider.require(); toastItem.descriptionId.current = opts.id.current; } @@ -457,14 +453,17 @@ export class ToastActionProvider extends Provider { } private constructor(opts: ToastActionOpts) { - super(opts, 'Toast', 'action', attrs.action); + super(opts, { morfo: toastMorfo, part: 'action' }); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - type: 'button' as const, - 'aria-label': this.opts.altText.current + ...this.resolveMorfoProps({ + props: { + altText: this.opts.altText.current + } + }) } as const) ); } @@ -481,7 +480,7 @@ export class ToastCloseProvider extends Provider { readonly toastItem: ToastItemProvider; private constructor(opts: ToastCloseOpts) { - super(opts, 'Toast', 'close', attrs.close); + super(opts, { morfo: toastMorfo, part: 'close' }); this.toastItem = ToastItemProvider.require(); } @@ -492,8 +491,9 @@ export class ToastCloseProvider extends Provider { readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, - type: 'button' as const, - 'aria-label': this.toastItem.provider.soma?.langs.ts(TOAST_LANGS.CLOSE), + ...this.resolveMorfoProps({ + translations: (key) => this.toastItem.provider.soma?.langs.ts(key) + }), onclick: this.onclick } as const) ); diff --git a/src/uix/soma/components/toast/toaster.svelte.ts b/src/uix/soma/components/toast/toaster.svelte.ts index b7ffd2a86..6c8a96c40 100644 --- a/src/uix/soma/components/toast/toaster.svelte.ts +++ b/src/uix/soma/components/toast/toaster.svelte.ts @@ -1,8 +1,9 @@ import { useId } from '../../id'; +import type { SemaIntent } from '../../../sema'; // ── Types ──────────────────────────────────────────────────────────────────── -export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading'; +export type ToastIntent = SemaIntent; export type SwipeDirection = 'left' | 'right' | 'up' | 'down'; @@ -15,8 +16,10 @@ export interface ToastData { description?: string; /** Whether the toast is in the process of being dismissed (exit animation). */ dismissing?: boolean; - /** Toast type. Affects ARIA urgency (error/warning use assertive). */ - type: ToastType; + /** Semantic intent. Drives ARIA urgency and public DOM surface. */ + intent: ToastIntent; + /** Whether the toast is in a loading/pending phase. */ + loading?: boolean; /** Auto-dismiss duration in ms. Overrides toaster default. */ duration?: number; /** Action button config. */ @@ -32,8 +35,10 @@ export interface CreateToastOpts { title?: string; /** Toast description. */ description?: string; - /** Toast type. @default 'default' */ - type?: ToastType; + /** Semantic intent. @default 'neutral' */ + intent?: ToastIntent; + /** Whether the toast is in a loading/pending phase. */ + loading?: boolean; /** Auto-dismiss duration in ms. Overrides toaster default. Set 0 for no auto-dismiss. */ duration?: number; /** Action button config. */ @@ -46,9 +51,9 @@ export interface PromiseToastOpts { /** Shown while the promise is pending. */ loading: CreateToastOpts; /** Shown when the promise resolves. Receives the resolved value. */ - success: CreateToastOpts | ((value: T) => CreateToastOpts); + fulfill: CreateToastOpts | ((value: T) => CreateToastOpts); /** Shown when the promise rejects. Receives the error. */ - error: CreateToastOpts | ((err: unknown) => CreateToastOpts); + threat: CreateToastOpts | ((err: unknown) => CreateToastOpts); } export interface ToasterConfig { @@ -76,8 +81,7 @@ export interface ToasterConfig { * Usage: * ```ts * const toaster = createToaster({ duration: 5000, max: 5 }); - * toaster.create({ title: 'Saved!' }); - * toaster.success({ title: 'Done', description: 'All changes saved.' }); + * toaster.create({ title: 'Saved!', intent: 'fulfill' }); * toaster.dismiss(id); * ``` */ @@ -105,8 +109,9 @@ export class Toaster { id, title: opts.title, description: opts.description, - type: opts.type ?? 'default', - duration: opts.duration, + intent: opts.intent ?? 'neutral', + loading: opts.loading, + duration: opts.duration ?? (opts.loading ? 0 : undefined), action: opts.action, onDismiss: opts.onDismiss, createdAt: Date.now() @@ -124,48 +129,22 @@ export class Toaster { return id; } - /** Add a success toast. */ - success(opts: CreateToastOpts): string { - return this.create({ ...opts, type: 'success' }); - } - - /** Add an error toast. */ - error(opts: CreateToastOpts): string { - return this.create({ ...opts, type: 'error' }); - } - - /** Add a warning toast. */ - warning(opts: CreateToastOpts): string { - return this.create({ ...opts, type: 'warning' }); - } - - /** Add an info toast. */ - info(opts: CreateToastOpts): string { - return this.create({ ...opts, type: 'info' }); - } - - /** Add a loading toast. Duration defaults to 0 (persistent until dismissed). */ - loading(opts: CreateToastOpts): string { - return this.create({ duration: 0, ...opts, type: 'loading' }); - } - /** - * Track a promise. Shows loading toast while pending, then updates to - * success or error based on the result. + * Track a promise. Shows a loading toast while pending, then updates it + * to a fulfill/threat semantic outcome. */ promise(promise: Promise, opts: PromiseToastOpts): string { - const id = this.loading(opts.loading); + const id = this.create({ duration: 0, ...opts.loading, loading: true }); promise .then((value) => { - const successOpts = typeof opts.success === 'function' ? opts.success(value) : opts.success; - this.update(id, { ...successOpts, type: 'success', duration: undefined }); - // Restart auto-dismiss for success toast + const fulfillOpts = typeof opts.fulfill === 'function' ? opts.fulfill(value) : opts.fulfill; + this.update(id, { ...fulfillOpts, intent: 'fulfill', loading: false, duration: undefined }); this.restartTimer(id); }) .catch((err) => { - const errorOpts = typeof opts.error === 'function' ? opts.error(err) : opts.error; - this.update(id, { ...errorOpts, type: 'error', duration: undefined }); + const threatOpts = typeof opts.threat === 'function' ? opts.threat(err) : opts.threat; + this.update(id, { ...threatOpts, intent: 'threat', loading: false, duration: undefined }); this.restartTimer(id); }); @@ -199,7 +178,14 @@ export class Toaster { /** Update an existing toast's data. */ update(id: string, opts: Partial): void { this.toasts = this.toasts.map((t) => - t.id === id ? { ...t, ...opts, type: opts.type ?? t.type } : t + t.id === id + ? { + ...t, + ...opts, + intent: opts.intent ?? t.intent, + loading: opts.loading ?? t.loading + } + : t ); } } diff --git a/src/uix/soma/core/soma.svelte.ts b/src/uix/soma/core/soma.svelte.ts index a48dc1110..21cb6f519 100644 --- a/src/uix/soma/core/soma.svelte.ts +++ b/src/uix/soma/core/soma.svelte.ts @@ -1,6 +1,7 @@ import { Context } from 'runed'; import { App } from '$lib/ext/app'; import type { + AppDom, AppLangs, AppNums, AppMoney, @@ -41,6 +42,9 @@ export class Soma { get langs(): AppLangs { return this.app.langs; } + get dom(): AppDom { + return this.app.dom; + } get nums(): AppNums | undefined { return this.app.nums; } diff --git a/src/uix/soma/layers/scroll-lock.svelte.ts b/src/uix/soma/layers/scroll-lock.svelte.ts index 4ccdb6e1a..a8e56dc87 100644 --- a/src/uix/soma/layers/scroll-lock.svelte.ts +++ b/src/uix/soma/layers/scroll-lock.svelte.ts @@ -1,179 +1,23 @@ /** - * # ScrollLock — body scroll lock with refcounting + * Soma wrapper over `uix/adom` body scroll lock. * - * Multiple instances share the same global lock state. - * When all instances unlock, body style is restored after a configurable delay. - * - * ## Usage - * - * ```ts - * readonly scrollLock = new ScrollLock(); - * // later: this.scrollLock.locked.current = true; - * ``` + * Keeps the current `ScrollLock` API for providers while delegating the + * actual body-lock implementation to the higher DOM runtime layer. */ -import { SvelteMap } from 'svelte/reactivity'; -import { on } from 'svelte/events'; -import { tick } from 'svelte'; -import { watch } from 'runed'; -import { readableActive, writableActive, type State } from '$soma/reactive'; -import { isIOS } from '$soma/dom'; -import { useId } from '$soma/id'; - -export interface ScrollLockOption { - padding?: boolean | number; - margin?: boolean | number; -} - -const lockMap = new SvelteMap(); - -let initialBodyStyle: string | null = $state(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) { - for (const [, value] of map) { - if (value) return true; - } - return false; -} - -function resetBodyStyle() { - if (typeof document === 'undefined') return; - document.body.setAttribute('style', initialBodyStyle ?? ''); - document.body.style.removeProperty('--scrollbar-width'); - if (isIOS) stopTouchMoveListener?.(); - initialBodyStyle = null; -} - -function cancelPendingCleanup() { - if (cleanupTimeoutId === null) return; - window.clearTimeout(cleanupTimeoutId); - cleanupTimeoutId = null; -} - -function ensureInitialStyleCaptured() { - if (initialBodyStyle === null && lockMap.size === 0 && !isInCleanupTransition) { - initialBodyStyle = document.body.getAttribute('style'); - } -} - -function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void) { - cancelPendingCleanup(); - isInCleanupTransition = true; - - cleanupScheduledAt = Date.now(); - const currentCleanupId = cleanupScheduledAt; - - const cleanupFn = () => { - cleanupTimeoutId = null; - if (cleanupScheduledAt !== currentCleanupId) return; - if (!isAnyLocked(lockMap)) { - isInCleanupTransition = false; - callback(); - } else { - isInCleanupTransition = false; - } - }; - - cleanupTimeoutId = window.setTimeout(cleanupFn, delay ?? 24); -} - -// Global watcher — applies/removes scroll lock when any lock changes -let watchInitialized = false; - -function ensureGlobalWatch() { - if (watchInitialized) return; - watchInitialized = true; - - watch( - () => anyLocked.current, - () => { - if (!anyLocked.current) { - scheduleCleanupIfNoNewLocks(null, resetBodyStyle); - return; - } - ensureInitialStyleCaptured(); - isInCleanupTransition = false; - - const htmlStyle = getComputedStyle(document.documentElement); - const bodyStyle = getComputedStyle(document.body); +import { BodyScrollLock, type BodyScrollLockOption } from '$uix/adom' - const hasStableGutter = - htmlStyle.scrollbarGutter?.includes('stable') || - bodyStyle.scrollbarGutter?.includes('stable'); - - const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const paddingRight = Number.parseInt(bodyStyle.paddingRight ?? '0', 10); - - if (verticalScrollbarWidth > 0 && !hasStableGutter) { - document.body.style.paddingRight = `${paddingRight + verticalScrollbarWidth}px`; - document.body.style.setProperty('--scrollbar-width', `${verticalScrollbarWidth}px`); - } - document.body.style.overflow = 'hidden'; - - if (isIOS) { - stopTouchMoveListener = on( - document, - 'touchmove', - (e: TouchEvent) => { - if (e.target !== document.documentElement) return; - if (e.touches.length > 1) return; - e.preventDefault(); - }, - { passive: false } - ); - } - - tick().then(() => { - document.body.style.pointerEvents = 'none'; - document.body.style.overflow = 'hidden'; - }); - } - ); -} - -export class ScrollLock { - readonly id = useId(); - readonly locked: State; +export type ScrollLockOption = BodyScrollLockOption +export class ScrollLock extends BodyScrollLock { constructor( initialState?: boolean, - private readonly restoreScrollDelay: () => number | null = () => null + restoreScrollDelay: () => number | null = () => null ) { - ensureGlobalWatch(); - cancelPendingCleanup(); - ensureInitialStyleCaptured(); - lockMap.set(this.id, initialState ?? false); - - this.locked = writableActive( - () => lockMap.get(this.id) ?? false, - (v: boolean) => lockMap.set(this.id, v) - ); + super(initialState, restoreScrollDelay) $effect(() => () => { - lockMap.delete(this.id); - if (isAnyLocked(lockMap)) return; - scheduleCleanupIfNoNewLocks(this.restoreScrollDelay(), resetBodyStyle); - }); - } - - static reset() { - lockMap.clear(); - cancelPendingCleanup(); - resetBodyStyle(); - initialBodyStyle = null; - isInCleanupTransition = false; - cleanupScheduledAt = null; - watchInitialized = false; + this.destroy() + }) } } diff --git a/src/uix/soma/provider/provider.svelte.ts b/src/uix/soma/provider/provider.svelte.ts index f0eed176b..0ba9b054e 100644 --- a/src/uix/soma/provider/provider.svelte.ts +++ b/src/uix/soma/provider/provider.svelte.ts @@ -73,9 +73,18 @@ import { untrack } from 'svelte'; import { createAttachmentKey } from 'svelte/attachments'; -import { assertContract } from '../attrs'; +import { assertContract, createAttrs, registerContract } from '../attrs'; import type { SomaContext } from './context'; import { isState, type Active, type State } from '../reactive'; +import type { + Morfo, + MorfoPart, + MorfoCondition, + MorfoPrimitiveValueSource, + MorfoValueSource, + MorfoData, + MorfoAriaEntry +} from '../../morfo/types'; // ---- Ref attachment ---- @@ -122,6 +131,94 @@ export interface WithRefOpts extends ProviderOpts { ref: State; } +export interface ProviderMorfoBindings { + props?: Record; + states?: Record; + parts?: Record; + translations?: (key: string) => string | undefined; +} + +export interface ProviderMorfoSpec { + morfo: M; + part: string; +} + +function findMorfoPart(parts: readonly MorfoPart[], target: string): MorfoPart | undefined { + for (const part of parts) { + if (part.kebab === target) return part; + if (part.parts) { + const nested = findMorfoPart(part.parts, target); + if (nested) return nested; + } + } + return undefined; +} + +function shouldEmitMorfoEntry( + condition: MorfoCondition | undefined, + bindings: ProviderMorfoBindings +): boolean { + if (!condition || condition === 'always') return true; + if (condition.when === 'part-present') return Boolean(bindings.parts?.[condition.part]); + if (condition.when === 'state-equals') return bindings.states?.[condition.state] === condition.value; + if (condition.when === 'prop-truthy') return Boolean(bindings.props?.[condition.prop]); + if (condition.when === 'prop-falsy') return !bindings.props?.[condition.prop]; + return true; +} + +function resolveMorfoPrimitiveSource( + source: MorfoPrimitiveValueSource, + bindings: ProviderMorfoBindings +): unknown { + if (source.kind === 'literal') return source.value; + if (source.kind === 'stateRef') return bindings.states?.[source.state]; + if (source.kind === 'partRef') return bindings.parts?.[source.target]; + if (source.kind === 'propRef') return bindings.props?.[source.prop]; + if (source.kind === 'translationRef') return bindings.translations?.(source.key); + return undefined; +} + +function resolveMorfoSource(source: MorfoValueSource, bindings: ProviderMorfoBindings): unknown { + if (source.kind !== 'mapRef') { + return resolveMorfoPrimitiveSource(source, bindings); + } + + const raw = resolveMorfoPrimitiveSource(source.source, bindings); + if (raw === undefined || raw === null) { + return source.fallback; + } + + const mapped = source.map[String(raw)]; + return mapped ?? source.fallback; +} + +function resolveMorfoDataValue(data: MorfoData, bindings: ProviderMorfoBindings): unknown { + const source = data.value; + if (!source) return undefined; + const raw = resolveMorfoSource(source, bindings); + + if (!data.values || data.values.length === 0) { + return raw ? '' : undefined; + } + + if (source.kind === 'stateRef' && typeof raw === 'boolean') { + if (raw) return source.state; + return data.values.find((value) => value !== source.state); + } + + return raw; +} + +function resolveMorfoAriaValue(entry: MorfoAriaEntry, bindings: ProviderMorfoBindings): unknown { + const raw = resolveMorfoSource(entry.value, bindings); + + if (entry.value.kind === 'stateRef') { + return Boolean(raw); + } + + return raw; +} + export abstract class Provider { readonly opts: S; readonly attachment: RefAttachment | undefined; @@ -129,7 +226,15 @@ export abstract class Provider { protected readonly _component: string; protected readonly _part: string; protected readonly _partAttr: string; + protected readonly _morfo: Morfo | undefined; + protected readonly _morfoPartMeta: MorfoPart | undefined; + protected constructor( + opts: S, + spec: ProviderMorfoSpec, + ctx?: SomaContext, + onRefChange?: (v: HTMLElement | null) => void + ); protected constructor( opts: S, component: string, @@ -137,11 +242,36 @@ export abstract class Provider { partAttr: string, ctx?: SomaContext, onRefChange?: (v: HTMLElement | null) => void + ); + protected constructor( + opts: S, + componentOrSpec: string | ProviderMorfoSpec, + partOrCtx?: string | SomaContext, + partAttrOrOnRefChange?: string | ((v: HTMLElement | null) => void), + ctx?: SomaContext, + onRefChange?: (v: HTMLElement | null) => void ) { this.opts = opts; - this._component = component; - this._part = part; - this._partAttr = partAttr; + + if (typeof componentOrSpec === 'string') { + this._component = componentOrSpec; + this._part = partOrCtx as string; + this._partAttr = partAttrOrOnRefChange as string; + this._morfo = undefined; + this._morfoPartMeta = undefined; + } else { + const spec = componentOrSpec; + const attrs = createAttrs(spec.morfo); + registerContract(spec.morfo); + this._component = spec.morfo.name; + this._part = spec.part; + this._partAttr = attrs[spec.part] ?? `data-${spec.morfo.kebab}-${spec.part}`; + this._morfo = spec.morfo; + this._morfoPartMeta = findMorfoPart(spec.morfo.parts as readonly MorfoPart[], spec.part); + ctx = partOrCtx as SomaContext | undefined; + onRefChange = partAttrOrOnRefChange as ((v: HTMLElement | null) => void) | undefined; + } + this.attachment = opts.ref ? attachRef(opts.ref, onRefChange) : undefined; if (ctx) ctx.set(this); } @@ -155,6 +285,35 @@ export abstract class Provider { }; } + protected resolveMorfoProps(bindings: ProviderMorfoBindings = {}): Record { + if (!this._morfoPartMeta) return {}; + + const props: Record = {}; + + if (this._morfoPartMeta.role) { + props.role = this._morfoPartMeta.role; + } + + for (const data of this._morfoPartMeta.data) { + if (!data.value) continue; + if (!shouldEmitMorfoEntry(data.condition, bindings)) continue; + const value = resolveMorfoDataValue(data, bindings); + if (value !== undefined) { + props[data.attr] = value; + } + } + + for (const aria of this._morfoPartMeta.aria) { + if (!shouldEmitMorfoEntry(aria.condition, bindings)) continue; + const value = resolveMorfoAriaValue(aria, bindings); + if (value !== undefined) { + props[aria.attr] = value; + } + } + + return props; + } + /** Assert data contract + return props. */ protected assertProps

>(props: P): P { assertContract(this._component, this._part, props);