feat(words): inspector ColorPicker integration + 4 root-cause picker fixes

Lands the eidos `<WordsColorRow>` (Text / Background pickers inside the
block inspector) on top of the new base-Block words engine + drops 3
obsolete audit MDs.

ColorPicker fixes surfaced while wiring it into the inspector — all
documented in `soma/components/color-picker/README.md` §Integration
pitfalls:

  1. Eidos wrapper now declares `format = $bindable('hex')` (matches
     soma's default). Without it, `bind:format={undefined}` threw
     `props_invalid_value` on every mount → render loop.
  2. Eidos wrapper now forwards `ref` to `ColorPickerProvider.create`.
     Without it, `attachRef` was never built and any `runtime.trigger`
     targeting `provider` threw `SomaRuntimeTargetError`.
  3. `triggerClose` falls back to the picker provider's own DOM when
     `runtime.partRef('content')` returns null (content is registered on
     the Popover's runtime, not the picker's — re-exported part).
  4. WordsColorRow draft-pattern: `$effect` reads draft inside `untrack`
     so it doesn't overwrite mid-drag value, and `onValueChange` catches
     the `Clear` programmatic transition (`onValueChangeEnd` doesn't fire
     on Clear).

CSS: `contain: layout style` on `[data-color-picker-area]` and
`scrollbar-gutter: stable` on the picker popover content — stops the
area from shaking during drag when the trigger's ValueText hex changes
width or the popover scrollbar toggles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 73636d07a9
commit da8d03085b

@ -1,897 +0,0 @@
# Auditoria UIX completa - Codex full 1
Fecha: 2026-05-25
Rama observada: `active-uix`
Ruta del informe: `src/uix/audit-codex-full-1.md`
Modo de trabajo: auditoria estatica, contractual y de scripts. No se han modificado componentes,
demos ni contratos; este informe es el unico artefacto creado por esta pasada.
## Alcance
Esta auditoria revisa el ecosistema UIX en el estado actual del workspace:
- Arquitectura documentada en `src/uix/README.md`, `src/uix/active_architecture.md`,
`src/uix/morfo/README.md`, `src/uix/soma/README.md`, `src/uix/sema/README.md`,
`src/uix/eidos/README.md`, `src/uix/soma/SOMA_ARCHITECTURE.md` y
`src/uix/soma/COMPONENT_GUIDE.md`.
- Guias obligatorias de componentes y demos:
`web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md`,
`web/routes/uix/lib/DEMO_AUTHORING_GUIDE.md` y
`src/uix/eidos/components/README.md`.
- Morfos, Soma, Sema, Eidos, recetas CSS, traducciones y demos `web/routes/uix`.
- Scripts de verificacion disponibles en el repositorio.
- Busquedas estaticas de patrones prohibidos o sospechosos: imports de capa, selectores,
eventos globales, uso de `Object.assign`, atributos `data-soma`/`data-eidos`, mapas reactivos,
vocabulario Sema y constantes de teclado.
El workspace ya tenia cambios sin commitear antes de esta auditoria. Los resultados reflejan ese
estado vivo; no se ha revertido ni normalizado ningun cambio ajeno.
## Resumen ejecutivo
UIX tiene una arquitectura base coherente: Morfo declara contratos, Soma concentra comportamiento,
Sema proyecta intenciones perceptuales y Eidos envuelve con recetas visuales. El inventario general
tambien es sano en lo estructural: hay 100 morfos y 100 directorios Eidos, no aparecen componentes
Eidos sin Morfo, y todos los morfos con scope Soma tienen directorio Soma.
Pero el ecosistema no esta en estado integrable. Hay bloqueantes de contrato y tooling que impiden
considerar UIX estable:
- `npm run check` falla con errores de tipos en UIX.
- `npm run morfo:vocabulary` y `scripts/morfo-check.ts` fallan por una invariante rota en
`color-picker`.
- `npm run translations:check` falla con 58 errores y 8 warnings.
- Los tests de contrato CSS de Eidos fallan para `words`.
- El audit oficial de componentes marca 27 componentes como `NEEDS-WORK`.
- Hay imports directos de `getActiveUix()` desde componentes Eidos, contra el contrato de Eidos.
- Hay logica de teclado global dentro de un componente Eidos (`command-shortcut`), que deberia vivir
en Soma o en una capa DOM controlada.
- Hay morfos con `scope: ['sema']` sin pack Sema propio, y componentes interactivos con eventos sin
proyeccion Sema completa.
La conclusion fuerte: la direccion arquitectonica es buena, pero el sistema esta en una fase donde
la superficie publica y los contratos automatizados no estan cerrados. Hay que limpiar bloqueantes
antes de seguir escalando componentes.
## Comandos ejecutados
| Comando | Resultado | Lectura |
| --- | --- | --- |
| `npm run component:audit` | OK, 73 PASS, 27 NEEDS-WORK, 0 BROKEN | La estructura existe, pero 27 componentes incumplen README, demo, styling o contrato APG/Sema. |
| `npm run translations:check` | FAIL, 58 errores, 8 warnings | Drift severo entre Morfo/Soma y catalogos `src/uix/langs/components`. |
| `npm run morfo:vocabulary` | FAIL | Invariante rota en `color-picker`: evento escribe `data-last-action` no declarado. |
| `node --import tsx/esm scripts/morfo-check.ts` | FAIL | Mismo bloqueo Morfo que `morfo:vocabulary`. |
| `npm run check` | FAIL | Errores de tipos en UIX y ruido adicional por `tmp/lexical`. |
| `npx vitest run src/uix/contracts.test.ts src/uix/morfo src/uix/sema src/uix/eidos src/uix/active-uix src/uix/soma/runtime.svelte.test.ts` | FAIL, 26 files pass, 1 file fail | Falla `src/uix/eidos/recipe-css-contract.test.ts` por tokens `words`. |
## P0 - bloqueantes que rompen contrato o verificacion
### 1. Morfo roto en `color-picker`
`src/uix/morfo/components/color-picker.ts` declara el evento `close-commit` con un prewrite:
- Parte: `content`
- Atributo: `data-last-action`
- Valor: `committed`
Pero la parte `Content` solo declara `data-state` con valores `open` y `closed`. El atributo
`data-last-action` no existe en el contrato de esa parte.
Impacto:
- Rompe `npm run morfo:vocabulary`.
- Rompe `scripts/morfo-check.ts`.
- Bloquea la generacion/verificacion de vocabulario Morfo.
- Indica que hay eventos que escriben DOM no declarado, justo lo que Morfo debe impedir.
Accion recomendada:
- Declarar `data-last-action` en la parte correcta si el evento lo necesita.
- O eliminar el prewrite si es residuo de una implementacion anterior.
- Despues, volver a ejecutar `morfo:vocabulary` y `morfo-check`.
### 2. `npm run check` falla en UIX
Errores UIX principales:
- `src/uix/morfo/components/password-field.ts`: `kind: 'private'` no es asignable a
`MorfoPartKind`.
- La rotura anterior se propaga a:
`src/uix/sema/components/password-field.ts`,
`src/uix/soma/components/password-field/password-field-provider.svelte.ts` y
`web/routes/uix/components/password-field/+page.svelte`.
- `src/uix/soma/layers/floating/shell.ts`: `buildFloatingShellWrapperProps` devuelve un tipo donde
propiedades obligatorias de `FloatingContent['wrapperProps']` quedan potencialmente opcionales.
- `src/uix/soma/components/date-picker/components/date-picker.svelte`: se destructuran `ref` y
`child`, pero `DatePickerProviderProps` no los define.
- `src/uix/eidos/components/date-picker/date-picker.svelte`: Eidos pasa `data-size` a props Soma que
no lo aceptan.
- `date-picker-month-view`, `date-picker-year-view`, `date-range-picker-month-view` y
`date-range-picker-year-view`: se pasan props `size` no declaradas o con unions demasiado
complejas.
- `src/uix/soma/components/table/table-provider.svelte.ts`: usa `KEYS.Enter` y `KEYS.Space` en vez
de `KEYS.ENTER` y `KEYS.SPACE`.
- `src/uix/soma/components/time-picker/components/time-picker.svelte`: `ref` no existe en
`TimePickerProviderProps`; Eidos tambien pasa `data-size` no aceptado.
- `src/uix/soma/components/time-range-picker/types.ts`: falta import de `Snippet`.
- `src/uix/soma/components/time-range-picker/components/time-range-picker.svelte`: `ref` y `child`
no existen en `TimeRangePickerProviderProps`; Eidos pasa `data-size` no aceptado.
Impacto:
- UIX no puede considerarse verde.
- Varios pickers estan en drift entre wrapper Eidos y Provider Soma.
- Hay una deuda de typing comun en la familia date/time/picker-shell.
Accion recomendada:
- Corregir primero `password-field`, `floating/shell`, `table` y `time-range-picker/types`.
- Despues resolver de una vez el contrato comun de props para pickers: `ref`, `child`, `size`,
`data-size`, views de month/year y wrappers Eidos.
- Ejecutar `npm run check` hasta quedar sin errores UIX.
### 3. Traducciones desincronizadas
`npm run translations:check` falla con 58 errores y 8 warnings.
Problemas detectados:
- `field`: Morfo/Soma referencian `components.field.required-indicator` y
`components.field.optional-indicator`, pero el catalogo no expone esas claves.
- `month-grid`: no existe `src/uix/langs/components/month-grid.ts`; faltan tambien referencias
comunes `common.month-grid.prev-year` y `common.month-grid.next-year`.
- `textarea`: no existe `src/uix/langs/components/textarea.ts`.
- `time-picker`: faltan claves como `clock`, `hour`, `minute`, `second`, `day-period`, `am`, `pm`,
`clear`, `cancel` y `close`.
- `time-range-picker`: faltan claves equivalentes para reloj, periodo, clear/cancel/close, etc.
- `words`: Morfo/Provider usan ids con guion (`bubble-menu`, `link-editor`) mientras el catalogo
define camelCase (`bubbleMenu`, `linkEditor`).
- `year-grid`: no existe `src/uix/langs/components/year-grid.ts`; faltan comunes
`common.year-grid.prev-page` y `common.year-grid.next-page`.
- Warnings por claves huerfanas en `date-picker`, `date-range-picker` y `words`.
Impacto:
- El contrato semantico de textos no es fiable.
- Demos o componentes pueden mostrar labels rotos.
- El sistema de internacionalizacion pierde autoridad como fuente unica.
Accion recomendada:
- Normalizar naming de ids de texto: elegir kebab o camelCase por capa y aplicar una regla.
- Crear catalogos faltantes para `month-grid`, `year-grid` y `textarea`.
- Completar `time-picker` y `time-range-picker`.
- Hacer que `translations:check` sea bloqueo obligatorio antes de cerrar componentes.
### 4. Contrato CSS Eidos roto en `words`
El test `src/uix/eidos/recipe-css-contract.test.ts` falla con dos errores:
- `words` consume `--words-radius`, pero la receta declara `--words-radius-sm`,
`--words-radius-md` y `--words-radius-lg`.
- `words` declara `--words-selection-color`, pero el CSS no consume ese token publico; consume un
alias interno basado en `--words-command-solid-color`.
Impacto:
- La receta publica de `words` no coincide con el CSS real.
- Theming y tokens de diseño no son auditables para este componente.
Accion recomendada:
- Sustituir `--words-radius` por uno de los tokens declarados o declarar formalmente
`--words-radius`.
- Consumir `--words-selection-color` donde corresponde o eliminarlo de la receta publica.
- Reejecutar el test de contrato CSS.
### 5. `tmp/lexical` contamina `npm run check`
`npm run check` intenta cargar configuraciones dentro de `tmp/lexical` y falla por dependencias no
instaladas (`svelte-preprocess`, `@sveltejs/adapter-auto`).
Impacto:
- Una libreria de referencia copiada en `tmp` rompe el check global.
- El workspace mezcla codigo auditable con material de investigacion.
Accion recomendada:
- Excluir `tmp/lexical` de `svelte-check`, `tsconfig` o scripts de verificacion.
- O mover referencias externas fuera del arbol evaluado.
## P1 - violaciones de arquitectura
### 1. Eidos importa `getActiveUix()` directamente
El contrato de `src/uix/eidos/components/README.md` indica que los wrappers Eidos no deben importar
`getActiveUix()` directamente. Deben consumir `ActiveEidos.require()`.
Archivos detectados:
- `src/uix/eidos/components/badge/badge.svelte`
- `src/uix/eidos/components/picker-shell/picker-shell-close.svelte`
- `src/uix/eidos/components/picker-shell/picker-shell-clear.svelte`
- `src/uix/eidos/components/picker-shell/picker-shell-cancel.svelte`
- `src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte`
- `src/uix/eidos/components/skeleton/skeleton.svelte`
- `src/uix/eidos/components/spinner/spinner.svelte`
Impacto:
- Eidos deja de ser una capa puramente visual desacoplada.
- Se duplica la forma de acceder a runtime/langs.
- Componentes visuales empiezan a depender del root activo.
Accion recomendada:
- Mover acceso a runtime/langs a Soma o a helpers Eidos autorizados.
- Exponer labels visuales a traves de props ya resueltas cuando sean componentes eidos-only.
- Anadir una regla de lint/script que falle si aparece `getActiveUix(` bajo
`src/uix/eidos/components`.
### 2. `command-shortcut` contiene comportamiento global en Eidos
`src/uix/eidos/components/command/command-shortcut.svelte.ts` registra
`window.addEventListener('keydown', ...)` directamente.
Impacto:
- Eidos incorpora comportamiento global de teclado.
- El binding no pasa por ActiveDom ni por un Provider Soma.
- Es dificil de testear, limpiar y coordinar con scopes/focus traps.
Accion recomendada:
- Mover la logica a Soma `command` o a una capa DOM controlada.
- Dejar Eidos como renderer del shortcut y su estado visual.
### 3. Cobertura Sema incompleta frente a Morfo
Inventario detectado:
- 100 morfos.
- 29 packs Sema en `src/uix/sema/components`.
- 28 morfos declaran scope Sema pero no tienen pack Sema propio.
- 33 componentes interactivos tienen eventos en Morfo pero no pack Sema propio.
Morfos con scope Sema sin pack Sema:
- `button`
- `card`
- `carousel`
- `collapsible`
- `color-field`
- `command`
- `context-menu`
- `date-picker`
- `date-range-picker`
- `drag-drop`
- `dropdown-menu`
- `feed`
- `grid-list`
- `menubar`
- `month-grid`
- `navigation-menu`
- `range-calendar`
- `switch`
- `table`
- `time-field`
- `time-picker`
- `time-range-picker`
- `toggle-group`
- `toggle`
- `tooltip`
- `tree-grid`
- `tree-view`
- `year-grid`
Componentes interactivos con eventos y sin pack Sema propio:
- `announce`
- `button`
- `card`
- `carousel`
- `clipboard`
- `collapsible`
- `color-field`
- `command`
- `context-menu`
- `date-picker`
- `date-range-picker`
- `drag-drop`
- `dropdown-menu`
- `feed`
- `grid-list`
- `listbox`
- `menubar`
- `month-grid`
- `navigation-menu`
- `range-calendar`
- `switch`
- `table`
- `time-field`
- `time-picker`
- `time-range-picker`
- `toggle-group`
- `toggle`
- `tooltip`
- `tree-grid`
- `tree-view`
- `virtual-grid`
- `virtual-list`
- `year-grid`
Impacto:
- La promesa semantica de UIX queda parcial.
- Las demos pueden declarar eventos Morfo sin proyeccion perceptual Sema completa.
- La documentacion habla de Sema como capa principal, pero la cobertura real es desigual.
Accion recomendada:
- Decidir explicitamente si todo componente con `scope: ['sema']` debe tener pack Sema.
- Si no, ajustar el scope Morfo.
- Si si, crear packs Sema minimos por componente con selectores tipados y eventos canonicos.
### 4. Eventos no canonicos o con vocabulario debil — CERRADO 2026-05-25
**Status**: ✅ RESUELTO en sprint Sema Canon 2026-05-25 (ver
`MEMORY.md > project_sema_canon_sprint_2026-05-25`).
Fixes aplicados:
- `button.commit-action` → `contact-activate` (cap. 22 §10-11). Plan B
commit 2 `23afeb6f`.
- `textarea.shift-count-overflow` → `signal-warn-count-overflow + risk`.
Plan B commit 1 `af6d6c76`.
- `month-grid` y `year-grid` `nav-step` → `shift-navigate-step`. Plan B
commit 1.
- `password-field.shift-toggle-visibility` → `commit-toggle-visibility`;
`shift-caps-state` → `signal-notify-caps-state`. Plan B commit 1.
Además durante Capa 2 + Capa 3 se aplicaron otros 56 events más
(misclasificaciones de family + cluster `clear` → `commit.reset` + cluster
`unselect` → `commit.unselect` añadido al canon + 12 individuales).
`morfo:vocabulary` script ahora hard-fails en verb no canónico (declarado
en `semantic.verb`), soft-warns en name shape drift. Lint en 7 warns
total — todos en `words.*` (track separado).
### 5. Picker-shell existe pero no esta cerrado como pieza de sistema — **RESUELTO 2026-05-26**
**Cierre**: cerrado como **primitiva interna** per la propia recomendación del audit ("Si es interna, no deberia salir como demo publica ni Morfo publico incompleto"). Acciones:
- Morfo movido de `src/uix/morfo/components/picker-shell.ts` → `src/uix/morfo/internal/picker-shell.ts`. El audit walk de `morfo/components/` ya no lo encuentra, evitando los reproches de "falta demo / texts.label / README público" que no aplican a una primitiva interna.
- README añadido en `src/uix/eidos/components/picker-shell/README.md` declarando explícitamente el status INTERNAL + diagrama de la composición (5 pickers re-exportan parts bajo su namespace).
- Verificado que clear/cancel/close usan `ActiveEidos.require()` (no `getActiveUix()` directo — el reproche del audit estaba desactualizado).
- El main barrel `src/uix/eidos/index.ts` NUNCA re-exportó `PickerShell`. Verificado.
- Zero imports rotos: `pickerShellMorfo` nunca fue importado en ningún sitio (era declaración placeholder).
---
`picker-shell` aparece como componente Morfo/Eidos, pero el audit oficial indica:
- No tiene demo.
- Falta `texts.label`.
- Falta README.
Ademas, partes de `picker-shell` importan `getActiveUix()` directamente desde Eidos.
Impacto:
- Hay una abstraccion compartida para pickers, pero no cumple los mismos contratos que exige el
ecosistema.
- Los date/time pickers muestran errores de typing relacionados con props comunes, lo que sugiere
que la abstraccion no esta aun estabilizada.
Accion recomendada:
- Cerrar `picker-shell` como primitiva interna o componente documentado.
- Si es interna, no deberia salir como demo publica ni Morfo publico incompleto.
- Si es publica, necesita README, demo, textos, tokens y contrato completo.
## P2 - deuda de demos, documentacion y experiencia
### Resultado del audit oficial de componentes
`npm run component:audit` devuelve:
- 100 componentes auditados.
- 73 `PASS`.
- 27 `NEEDS-WORK`.
- 0 `BROKEN`.
Componentes `NEEDS-WORK`:
| Componente | Problemas principales |
| --- | --- |
| `alert-dialog` | Falta selector root `[data-alert-dialog]`. |
| `announce` | Falta README, no `focus-visible`, no MutationObserver, falta `somaSnippet`, sin warning APG. |
| `badge` | Falta README. |
| `button` | Evento `commit-action` usa verbo no canonico; falta README. |
| `card` | Falta `texts.label`, README y `somaSnippet`; sin warning APG. |
| `clipboard` | Falta README, no `focus-visible`, falta `somaSnippet`. |
| `command` | Falta README, no `focus-visible`, tipografia literal. |
| `drag-drop` | Falta README, falta `somaSnippet`, sin APG, posible play Sema no cableado. |
| `feed` | Falta README, falta `somaSnippet`. |
| `grid-list` | Falta README, falta `somaSnippet`, `[data-readonly]` declarado no estilado. |
| `link-preview` | Falta README, tipografia literal. |
| `listbox` | Falta README, `[data-readonly]` no estilado, tipografia literal. |
| `menubar` | Falta README, tipografia literal. |
| `navigation-menu` | Falta README, falta `somaSnippet`, sin APG. |
| `password-field` | Falta README, mismatch `shift.toggle`, readonly no estilado, tipografia literal. |
| `picker-shell` | Sin demo, falta `texts.label`, falta README. |
| `range-calendar` | Falta README, falta `somaSnippet`, readonly no estilado. |
| `search-field` | No `focus-visible`, sin APG, readonly no estilado. |
| `skeleton` | Falta README. |
| `spinner` | Falta README. |
| `table` | Falta README, usa `--color-surface-subtle` no declarado, falta `somaSnippet`, sin APG. |
| `textarea` | Verbo `limit` no canonico, falta README, no `focus-visible`, tokens no declarados, tipografia literal, play Sema dudoso. |
| `time-range-field` | Falta README, falta `somaSnippet`, readonly/invalid declarados no estilados. |
| `tree-grid` | Falta README, usa `--color-surface-subtle` no declarado, falta `somaSnippet`, readonly no estilado. |
| `tree-view` | Falta README, falta `somaSnippet`. |
| `virtual-grid` | Falta README, no MutationObserver, falta `somaSnippet`, sin APG, play dudoso. |
| `virtual-list` | Falta README, no MutationObserver, falta `somaSnippet`, sin APG, play dudoso. |
Warnings relevantes en componentes que pasan:
- `date-picker`: `[data-readonly]` declarado pero no estilado; play Sema puede no estar cableado.
- `date-range-picker`: play Sema puede no estar cableado.
- `color-picker`, `date-range-field`, `time-picker`: warnings de estados readonly/invalid.
- `container`, `section`, `text`: warnings de paridad de chips.
- `month-grid`, `year-grid`: evento `nav-step` no sigue naming canonico.
### Demos no cumplen siempre la guia bloqueada
La guia `DEMO_AUTHORING_GUIDE.md` exige una demo con seis tabs, stage persistente, controles,
observer, snippets Soma/Eidos, paridad Morfo y play de eventos Sema cuando aplique.
Incumplimientos repetidos:
- READMEs ausentes.
- `somaSnippet` ausente.
- MutationObserver ausente.
- Estados declarados en Morfo sin CSS visible.
- Play de eventos Sema no cableado.
- Componentes interactivos sin advertencia APG cuando corresponde.
Impacto:
- La demo deja de ser una prueba de aceptacion real.
- Componentes pueden parecer completos aunque no muestren contrato, estados ni eventos.
Accion recomendada:
- Convertir `component:audit` en gate obligatorio.
- Bloquear merge de componentes con `NEEDS-WORK` salvo waiver documentado.
- Crear una tarea de saneamiento solo para demos/README, separada de cambios funcionales.
## Inventario estructural
Inventario detectado por script local:
| Elemento | Conteo |
| --- | ---: |
| Morfos | 100 |
| Directorios Eidos | 100 |
| Directorios Soma | 72 |
| Packs Sema | 29 |
| Demos en `web/routes/uix/components` | 99 |
| Morfos eidos-only | 27 |
Resultado:
- No faltan directorios Eidos para morfos detectados.
- No hay directorios Eidos sin Morfo.
- No hay directorios Soma sin Morfo.
- Todos los morfos con scope Soma tienen directorio Soma.
- Falta demo publica para `picker-shell`, si se considera componente publico.
Morfos eidos-only detectados:
- `aspect-ratio`
- `auto-grid`
- `avatar`
- `badge`
- `banner`
- `box`
- `code-block`
- `code`
- `container`
- `display`
- `flex`
- `float`
- `grid`
- `group`
- `heading`
- `highlight`
- `icon`
- `kbd`
- `link`
- `mark`
- `section`
- `separator`
- `skeleton`
- `spinner`
- `stack`
- `text`
- `wrap`
Lectura:
- La cobertura base Morfo/Eidos es buena.
- La brecha real no esta en "faltan carpetas", sino en contratos incompletos, tipos rotos,
traducciones, Sema y demos.
## Revision por capa
### Morfo
Fortalezas:
- Los componentes estan ampliamente declarados.
- La validacion de prewrites detecta errores reales.
- No se detecta uso activo de `data-soma-*` o `data-eidos-*` como namespaces publicos.
Problemas:
- `color-picker` viola su propio contrato de atributos.
- `password-field` usa `kind: 'private'`, valor no aceptado por el tipo Morfo.
- `button`, `textarea`, `month-grid`, `year-grid` y `password-field` tienen problemas de naming o
vocabulario de eventos.
- Hay textos referenciados por Morfo que no existen en catalogos.
Riesgo:
- Si Morfo no esta verde, toda la cadena Soma/Sema/Eidos queda sin fuente unica fiable.
### Soma
Fortalezas:
- La separacion general de comportamiento esta bien asentada.
- No se detectan directorios Soma huerfanos.
- Donde se usan estructuras reactivas complejas, aparecen patrones correctos como `SvelteMap`.
Problemas:
- Drift de props en date/time pickers: `ref`, `child`, `size`, `data-size`.
- `floating/shell.ts` rompe tipos en la composicion de wrapper props.
- `table-provider` usa constantes de teclado con casing incorrecto.
- `time-range-picker/types.ts` no importa `Snippet`.
- Algunos componentes declaran estados `readonly`/`invalid` que luego no tienen representacion CSS
visible en Eidos o demos.
Riesgo:
- La familia picker no tiene un contrato comun cerrado.
- Si Eidos adapta props no declaradas, se vuelve a romper la frontera de capas.
### Sema
Fortalezas:
- Los packs Sema existentes usan `semaSelector`; no se detecta un patron extendido de selectores
escritos a mano en los packs revisados.
- La idea de eventos semanticos sigue siendo uno de los diferenciales fuertes del ecosistema.
Problemas:
- Cobertura incompleta frente a morfos con scope Sema.
- Verbos no canonicos o nombres no alineados.
- Demos con play Sema potencialmente no cableado.
Riesgo:
- Sema puede quedar como promesa documental si no se completa la cobertura y el vocabulario.
### Eidos
Fortalezas:
- No se detecta `Object.assign(...)` en implementaciones Eidos/Soma; esto respeta el contrato de
attached parts.
- No se detecta uso activo de `data-eidos-*` ni `--eidos-*` como API publica.
- Existe test de contrato CSS que detecta drift real.
Problemas:
- Imports directos de `getActiveUix()` desde componentes Eidos.
- `command-shortcut` contiene comportamiento global.
- Tokens CSS no declarados en `table`, `tree-grid` y `textarea`.
- Contrato CSS de `words` roto.
- READMEs faltantes en muchos componentes.
Riesgo:
- Eidos puede empezar a absorber comportamiento o runtime, rompiendo la separacion visual/headless.
### Demos `web/routes/uix`
Fortalezas:
- Hay 99 demos para 100 morfos detectados.
- Existe una guia fuerte de authoring.
- El audit oficial captura varios incumplimientos.
Problemas:
- `picker-shell` no tiene demo.
- Muchas demos no exponen `somaSnippet`.
- Falta MutationObserver en componentes que lo necesitan.
- Hay estados Morfo no visibles.
- Hay warnings APG ausentes.
- Algunas demos pueden no tener botones de play Sema cableados.
Riesgo:
- La demo deja de ser una prueba funcional y se convierte solo en escaparate.
## Duplicaciones y falta de reutilizacion
### Familia date/time/picker
Sintomas:
- Errores equivalentes de `ref`, `child`, `size` y `data-size` en `date-picker`,
`date-range-picker`, `time-picker` y `time-range-picker`.
- `picker-shell` existe, pero no esta cerrado como contrato.
- Demos y wrappers parecen repetir decisiones de surface visual sin un tipo comun estable.
Lectura:
- Hay una abstraccion compartida en marcha, pero todavia no gobierna la familia.
- La duplicacion no es solo codigo repetido: es drift de contrato entre Soma y Eidos.
Recomendacion:
- Definir un tipo comun de shell/picker parts.
- Hacer que Soma acepte formalmente solo lo que Eidos puede pasar.
- Evitar que Eidos meta `data-size` o props visuales en Provider si el Provider no las declara.
### Eventos y textos
Sintomas:
- Varios componentes repiten problemas de labels faltantes.
- `words` muestra drift entre kebab-case y camelCase.
- `time-picker` y `time-range-picker` comparten listas de textos faltantes.
Lectura:
- Falta una regla mecanica de naming de ids de texto.
- Falta una plantilla generadora o verificador mas estricto para catalogos.
Recomendacion:
- Definir naming unico para `texts.*`.
- Generar stubs de catalogo desde Morfo.
- Hacer que `translations:check` sea obligatorio antes de dar por cerrado un componente.
### Demos
Sintomas:
- Faltan `somaSnippet`, README, MutationObserver y APG en muchos componentes.
Lectura:
- Las demos se han creado por componente, no desde una plantilla suficientemente automatizada.
Recomendacion:
- Generar una plantilla de seis tabs que no permita omitir stage, observer y snippets.
- El audit deberia fallar si falta una seccion obligatoria.
## Patrones revisados sin violacion extendida
No se detectaron problemas sistemicos en estos puntos:
- No hay uso activo de `Object.assign(...)` para attached parts en Eidos/Soma.
- No aparecen namespaces publicos `data-soma-*`, `data-eidos-*`, `--soma-*` o `--eidos-*` en codigo
de componentes, fuera de documentacion/tests.
- No se detecta uso activo de `@internationalized/date` ni de `$lib/util/dates` en componentes UIX;
las referencias aparecen solo en documentacion.
- No se detecta un patron activo de `$state(new Map(...))` o `$state(new Set(...))`; el uso de
`SvelteMap` aparece donde corresponde.
- Los packs Sema existentes usan helpers tipados como `semaSelector`.
## Hallazgos por componente o familia
### Words
Estado:
- Arquitectura en progreso y con buen enfoque por capas.
- El contrato CSS Eidos falla por `--words-radius` y `--words-selection-color`.
- Traducciones desincronizadas por `bubble-menu`/`link-editor` frente a `bubbleMenu`/`linkEditor`.
Riesgo:
- Words es un componente complejo; si tokens y textos no se estabilizan ahora, el coste crece rapido.
Accion:
- Cerrar primero contratos de receta y traducciones.
- Mantener separados render DOM interno, export limpio y popovers/herramientas.
### Date picker, date range picker, time picker, time range picker
Estado:
- Hay drift de tipos entre Soma y Eidos.
- La familia comparte problemas de props visuales y Provider.
- Traducciones incompletas en time picker y time range picker.
Riesgo:
- Los pickers son componentes de alta exposicion; si el contrato comun no se fija, cada fix local
introduce nuevas diferencias.
Accion:
- Resolver como familia, no componente por componente.
- Cerrar `picker-shell` o hacerlo interno.
### Password field
Estado:
- Morfo usa `kind: 'private'`, no aceptado por tipos.
- El audit marca mismatch de evento `shift.toggle`.
- Faltan README y estilos de readonly.
Riesgo:
- Un solo valor invalido rompe Morfo, Sema, Soma y demo.
Accion:
- Sustituir `private` por el kind real soportado o extender formalmente `MorfoPartKind`.
- Revisar evento toggle y contrato de partes.
### Color picker
Estado:
- Bloqueante Morfo por `data-last-action`.
Riesgo:
- El componente impide ejecutar checks globales de vocabulario.
Accion:
- Corregir declaracion/prewrite antes de cualquier otro trabajo cosmetic.
### Table y tree-grid
Estado:
- Usan `--color-surface-subtle` no declarado.
- `table-provider` usa constantes `KEYS.Enter`/`KEYS.Space` incorrectas.
- Faltan README y snippets.
Riesgo:
- Componentes de datos necesitan mayor rigor de teclado, APG y estados.
Accion:
- Corregir tokens y teclado.
- Completar guia APG/demo.
### Virtual list y virtual grid
Estado:
- Faltan README, MutationObserver, `somaSnippet` y warning APG.
- Play Sema puede no estar cableado.
Riesgo:
- Componentes de virtualizacion dependen mucho de medicion y observabilidad; la demo debe validar
DOM real, no solo render visual.
Accion:
- Anadir observer obligatorio y pruebas de estados virtualizados.
### Badge, skeleton, spinner, card
Estado:
- Son componentes eidos-only o visuales simples, pero algunos importan `getActiveUix()` desde Eidos.
- Faltan READMEs y, en `card`, `texts.label`.
Riesgo:
- Incluso componentes simples estan saltandose la regla de acceso a runtime.
Accion:
- Decidir si estos componentes necesitan Morfo/langs o si deben ser puramente visuales.
- Si necesitan textos, resolverlos fuera del wrapper visual.
## Riesgos sistemicos
1. **Contratos no verdes.** UIX no debe crecer mientras `check`, `morfo-check`,
`translations:check` y tests de contrato CSS fallen.
2. **Eidos absorbe runtime.** Los imports directos de `getActiveUix()` rompen una regla explicita.
3. **Sema incompleto.** Hay eventos Morfo sin pack Sema o con vocabulario no canonico.
4. **Demos no son gate real.** El sistema exige demos como prueba de aceptacion, pero muchos
componentes incumplen partes del template.
5. **Familias compartidas no estabilizadas.** Pickers y shell muestran drift comun.
6. **Referencias externas dentro del workspace.** `tmp/lexical` rompe checks globales.
## Plan recomendado de saneamiento
### Fase 1 - dejar el sistema en verde
1. Arreglar `color-picker` Morfo (`data-last-action`).
2. Arreglar `password-field` Morfo (`kind: 'private'`).
3. Arreglar errores directos de `npm run check`: `floating/shell`, `table`, `Snippet` y props de
pickers.
4. Corregir contrato CSS de `words`.
5. Excluir o aislar `tmp/lexical`.
6. Ejecutar:
- `npm run check`
- `npm run morfo:vocabulary`
- `node --import tsx/esm scripts/morfo-check.ts`
- test de contrato Eidos
### Fase 2 - cerrar textos y Sema
1. Arreglar catalogos faltantes: `month-grid`, `year-grid`, `textarea`.
2. Completar `time-picker` y `time-range-picker`.
3. Normalizar ids de `words`.
4. Revisar verbos no canonicos.
5. Decidir y aplicar la regla de packs Sema por scope.
### Fase 3 - sanear Eidos
1. Eliminar imports directos de `getActiveUix()` en `src/uix/eidos/components`.
2. Mover comportamiento global de `command-shortcut` a Soma/ActiveDom.
3. Corregir tokens no declarados.
4. Anadir regla automatica para bloquear estos patrones.
### Fase 4 - demos y documentacion
1. Resolver los 27 `NEEDS-WORK` de `component:audit`.
2. Completar READMEs faltantes.
3. Garantizar seis tabs, stage persistente, observer, snippets y play Sema.
4. Convertir `component:audit` en gate de aceptacion.
### Fase 5 - familias y duplicaciones
1. Replantear `picker-shell` como primitiva interna o componente completo.
2. Unificar props comunes date/time/range.
3. Reducir drift de wrappers Eidos frente a Providers Soma.
4. Crear tests compartidos por familia.
## Criterio de salida propuesto
UIX deberia considerarse saneado solo cuando:
- `npm run check` pasa sin errores UIX.
- `npm run translations:check` pasa.
- `npm run morfo:vocabulary` pasa.
- `scripts/morfo-check.ts` pasa.
- Tests de contrato Eidos pasan.
- `npm run component:audit` devuelve 100 PASS o documenta waivers explicitos.
- No hay `getActiveUix(` bajo `src/uix/eidos/components`.
- No hay listeners globales en Eidos salvo excepcion documentada.
- Todo componente con `scope: ['sema']` tiene pack Sema o una decision documentada de no tenerlo.
## Conclusion
El ecosistema UIX no esta mal concebido: la separacion Morfo/Soma/Sema/Eidos es potente y esta
mucho mejor definida que una libreria de componentes convencional. El problema actual no es la idea,
sino la disciplina de cierre: contratos que no pasan, demos incompletas, Sema parcial, Eidos
saltandose runtime boundaries y familias de componentes con drift de tipos.
La prioridad no deberia ser crear mas componentes. La prioridad debe ser poner en verde los gates,
cerrar vocabulario/textos/tokens y convertir las guias en barreras automaticas. Una vez hecho eso,
UIX puede escalar con mucha mas seguridad.

@ -1,538 +0,0 @@
# UIX Ecosystem Audit — Kimi Round 2 (Deep)
> **Scope:** Full cross-layer audit of `src/uix/` based on **executable evidence**: failing tests, build output, contract validation, and source-level root-cause analysis.
> **Date:** 2026-05-24
> **Auditor:** Kimi
> **Method:** `npm run test`, `npm run check`, `npm run build` + test failure triage + source reads.
---
## Executive Summary
| Metric | Value |
|--------|-------|
| **Total test files** | ~90+ across UIX |
| **Failing tests** | **40** (across 12 files) |
| **Build** | Passes (1m 11s) |
| **Type-check (`svelte-check`)** | **0 errors, 23 warnings** (all in `web/routes/`, none in `src/uix/`) |
| **Contract test failures** | **12 / 32** in `contracts.test.ts` |
| **Recipe CSS contract failures** | **4 / 6** in `recipe-css-contract.test.ts` |
| **Component API contract failures** | **3 / 6** in `component-api-contract.test.ts` |
| **Eidos lint failures** | **1 / 14** in `lint.test.ts` |
| **Provider test failures** | **19** across time-picker, time-range-picker, color-picker, date-picker, date-range-picker |
| **ActiveUix test failures** | **1 / 25** (i18n fallback drift) |
**Bottom line:** The architecture is not just theoretically drifted — it is **actively violated by code that is currently merged**. The test suite is designed to catch these violations, and it is failing. This is not a matter of style or future refactoring; it is a matter of **code that does not pass its own guardrails**.
---
## 1. Contract Violations (The Guardrails Are Broken)
### 1.1 `contracts.test.ts` — 12 Failures
This file is the **authoritative automated enforcer** of cross-layer contracts. It scans the actual file system and source code. Its failures are not opinions; they are executable assertions.
#### A. Soma Barrel Exports Missing Public Modules
```
AssertionError: expected [ 'button -> Button', 'picker-shell -> PickerShell', 'textarea -> Textarea' ]
```
**Root cause:** `src/uix/soma/components/index.ts` does not re-export `button`, `picker-shell`, or `textarea` as public modules. These components exist in the file system but are invisible to consumers importing from `$soma/components`.
**Impact:** Consumers must use deep imports (`$soma/components/button/...`) which breaks the barrel contract and leaks internal structure.
#### B. Soma Provider Export Filename Violation
```
AssertionError: expected [ 'picker-shell/exports.ts' ]
```
**Root cause:** `picker-shell/exports.ts` does not use the canonical line:
```ts
export { default as Provider } from './components/picker-shell.svelte';
```
**Impact:** The contract expects every component to expose its root wrapper via a standardized export line. `picker-shell` deviates.
#### C. Virtual Picker Roots Advertising DOM Attributes
```
AssertionError: date-picker: expected '' to contain 'children?: Snippet;'
```
**Affected:** `date-picker`, `date-range-picker`, `time-picker`, `time-range-picker`.
**Root cause:** The `.svelte` root wrappers for these pickers are missing `children?: Snippet;` in their exported prop types, or they expose `WithChild` / `PrimitiveDivAttributes` which the contract forbids for virtual pickers. The regex that extracts the type definition returns an empty string (`''`), meaning the expected type block is malformed or missing.
**Impact:** These components violate the rule that virtual picker roots should be pure composition shells without DOM attribute advertising.
#### D. Soma Public Barrels Re-export Provider Implementation Files
```
AssertionError: expected [
"\src\uix\soma\components\color-picker\exports.ts",
"\src\uix\soma\components\date-picker\exports.ts",
"\src\uix\soma\components\date-range-picker\exports.ts",
"\src\uix\soma\components\time-picker\exports.ts",
"\src\uix\soma\components\time-range-picker\exports.ts",
]
```
**Root cause:** These 5 components' `exports.ts` files import from `*provider.svelte.ts` (implementation filenames) instead of only exposing the public wrapper and types.
**Impact:** Implementation details leak through the public barrel.
#### E. Soma Public Barrels Re-export Shared Lib Facades
```
AssertionError: expected [ "\src\uix\soma\components\password-field\exports.ts" ]
```
**Root cause:** `password-field/exports.ts` imports from `$libs/...` (a shared library facade). The contract forbids Soma public barrels from depending on `$libs` aliases directly.
#### F. Soma Public Modules ≠ Morfo `soma` Scope
```
AssertionError: expected [ ...70 dirs ] to deeply equal [ ...69 dirs ]
Diff: + "picker-shell"
```
**Root cause:** `picker-shell` exists as a Soma public module but has **no corresponding morfo file**. The contract requires every Soma component to have a matching morfo definition with `scope: ['soma']`.
#### G. Missing READMEs in Soma Components
```
AssertionError: expected [ "button", "password-field", "picker-shell", "textarea" ]
```
**Root cause:** These 4 components have Soma providers but no `README.md` in their directory.
#### H. Broken README Links
```
Error: ENOENT: no such file or directory, open '...\soma\components\button\README.md'
```
**Root cause:** The test tries to read `button/README.md` to validate cross-links. It does not exist. This cascades from (G).
#### I. Direct Mutable DOM Writes in Soma
```
AssertionError: expected [ "...\textarea\textarea-provider.svelte.ts" ]
```
**Root cause:** `TextAreaProvider.measureAutosize()` (lines 172, 190):
```ts
el.style.height = 'auto';
// ...
el.style.height = `${next}px`;
```
This is a **direct mutable DOM write** outside of `ActiveDom.apply()`. The contract enforces that all DOM mutations in Soma must go through the injected `dom` service.
#### J. Direct Event Listeners in Soma
```
AssertionError: expected [ "...\words\words-provider.svelte.ts" ]
```
**Root cause:** `WordsProvider` constructor (lines 191-192):
```ts
doc.addEventListener('selectionchange', handleSelectionChange);
return () => doc.removeEventListener('selectionchange', handleSelectionChange);
```
Uses raw `addEventListener`/`removeEventListener` instead of `this.soma.dom.listen(...)`.
#### K. Hardcoded Data Attributes Without Morfo Contract
```
AssertionError: expected [
"carousel-provider.svelte.ts: data-dir",
"date-picker/components/date-picker.svelte: data-kind",
"password-field/components/password-field-strength-meter.svelte: data-password-field-strength-meter-label",
"words/engine/dom.ts: data-words-node",
"words/engine/dom.ts: data-words-path",
"words/engine/dom.ts: data-words-marks",
"words/engine/dom.ts: data-words-empty-text",
"words/engine/render.ts: data-words-list-kind",
"words/engine/render.ts: data-words-block",
"words/engine/render.ts: data-words-checked",
"words/test/words-content-harness.svelte: data-testid",
]
```
**Root cause:** These 11 files use `data-*` attributes that are **not declared in any morfo contract**. The test builds a canonical set of all known morfo data attributes and flags any hardcoded attribute in Soma source that is not in that set.
- `data-dir` in carousel: not declared in `carousel.ts` morfo.
- `data-kind` in date-picker: not declared.
- `data-words-*` in Words engine: the Words morfo does not declare these internal engine attributes.
- `data-testid`: test-only marker leaking into production source.
#### L. Translation Namespaces Not Kebab-Case
```
AssertionError: expected [
"words-provider.svelte.ts: components.words.linkEditor",
"morfo/components/words.ts: components.words.linkEditor",
"morfo/components/words.ts: components.words.linkEditor",
]
```
**Root cause:** The translation key `linkEditor` uses camelCase. The contract requires kebab-case (`link-editor`) for all translation namespace keys.
---
### 1.2 `recipe-css-contract.test.ts` — 4 Failures
This test validates that Eidos CSS recipes are synchronized with the actual CSS files.
#### A. Undeclared Public Variables Consumed by CSS
**Failure:** 90+ component CSS variables are used in `.css` files but **not declared** in `THEME_BASE_RECIPE_TOKENS`.
**Representative examples:**
- `dropdown-menu: --dropdown-menu-item-height`, `--dropdown-menu-item-px`, etc.
- `flex: --flex-align`, `--flex-align-content`, `--flex-column-gap`, etc.
- `grid: --grid-align`, `--grid-auto-columns`, `--grid-template-columns`, etc.
- `pin-input: --pin-input-cell-active-border`, `--pin-input-cell-bg`, etc. (20+ variables)
- `scroll-area: --scroll-area-bg`, `--scroll-area-radius`, etc. (10+ variables)
- `splitter: --splitter-bg`, `--splitter-grip-bg`, etc. (15+ variables)
- `words: --words-radius`
**Root cause:** `THEME_BASE_RECIPE_TOKENS` (the canonical recipe catalog) is **incomplete**. Component authors added CSS variables to `.css` files but did not register them in the recipe token set.
**Impact:** Build-time recipe validation fails. Dynamic theme generation cannot resolve these tokens.
#### B. Missing CSS Imports in Eidos Entrypoint
```
AssertionError: expected [
"./components/password-field/password-field.css",
"./components/skeleton/skeleton.css",
"./components/spinner/spinner.css",
"./components/textarea/textarea.css",
]
```
**Root cause:** `src/uix/eidos/index.css` does not `@import` these 4 component CSS files. They exist in the filesystem but are not included in the global Eidos CSS bundle.
**Impact:** These components render without their CSS when consumed through the Eidos entrypoint.
#### C. Raw Color Values in Component CSS
```
AssertionError: expected [ "color-picker", "drag-drop", "grid-list" ]
```
**Root cause:** These 3 component CSS files contain raw hex, rgb, or hsl literals instead of referencing design tokens.
**Impact:** Breaks theme switching and dark-mode contracts.
#### D. Orphaned Recipe Variables
```
AssertionError: expected [
"number-field: --number-field-control-size",
"date-field: --date-field-segment-focus-shadow",
"color-field: --color-field-swatch-radius",
"color-field: --color-field-swatch-border",
"color-picker: --color-picker-content-radius",
"color-picker: --color-picker-content-border-width",
"color-picker: --color-picker-content-border",
"color-picker: --color-picker-content-bg",
"color-picker: --color-picker-content-shadow",
"search-field: --search-field-clear-size",
"search-field: --search-field-clear-radius",
"search-field: --search-field-clear-border-width",
"search-field: --search-field-clear-border",
"search-field: --search-field-clear-border-hover",
"search-field: --search-field-clear-bg",
"search-field: --search-field-clear-bg-hover",
"search-field: --search-field-clear-color",
"search-field: --search-field-clear-color-hover",
"search-field: --search-field-clear-focus-shadow",
"password-field: --password-field-trigger-size",
"editable: --editable-submit-hover-brightness",
"words: --words-selection-color",
]
```
**Root cause:** These 22 variables are declared in `THEME_BASE_RECIPE_TOKENS` but **never referenced** in any component source file (`.svelte`, `.css`, `.ts`). They are dead declarations.
**Impact:** Bloats the recipe catalog with unused tokens.
---
### 1.3 `component-api-contract.test.ts` — 3 Failures
#### A. Missing Eidos Root Files
```
AssertionError: expected [ "_layout" ]
```
**Root cause:** `src/uix/eidos/components/_layout/` exists as a directory but has no `_layout.svelte` root file. The contract expects every component directory to have a root `.svelte` file named after the directory.
**Impact:** `_layout` is not a consumable component via the standard Eidos API.
#### B. Namespace Type Members Out of Sync
```
AssertionError: expected [
"command: Item.Icon declared but not assigned",
"command: Item.Shortcut declared but not assigned",
]
```
**Root cause:** In `command/index.ts`, the `CommandNamespace` type declares `Item.Icon` and `Item.Shortcut` as properties, but the runtime object assignment (`Command.Item.Icon = ...`) is missing.
**Impact:** TypeScript allows consumers to write `Command.Item.Icon` but it will be `undefined` at runtime.
#### C. Legacy `onMount` in Eidos Wrappers
```
AssertionError: expected [
"command/command-dialog.svelte: onMount",
"command/command.svelte: onMount",
]
```
**Root cause:** Both `command-dialog.svelte` and `command.svelte` import and call `onMount` to bind keyboard shortcuts. The contract forbids `onMount` in Eidos wrappers because Eidos should be visual-only; side-effect registration belongs in Soma providers.
**Impact:** Eidos components contain runtime logic that should live in the headless layer.
---
### 1.4 `lint.test.ts` — 1 Failure
```
AssertionError: expected [ "picker-shell" ]
```
**Root cause:** `picker-shell` has a CSS file in Eidos but **no morfo file**. The contract requires every Eidos CSS component to have a matching morfo contract.
---
## 2. Provider Runtime Failures (setContext Outside Component)
### Affected Components
- `time-picker`
- `time-range-picker`
- `color-picker`
- `date-picker`
- `date-range-picker`
### Error Pattern (identical across all)
```
Svelte error: lifecycle_outside_component
`setContext(...)` can only be used during component initialisation
```
### Root Cause
All these providers use `SomaContext.create(...)` which internally calls `Context.set()` from `runed`. When the provider class is instantiated **outside a Svelte component initialization** (e.g., in unit tests that do `new TimePickerProvider(...)` directly inside a `$effect` or test harness), `setContext` fails because there is no component context on the stack.
**Stack trace origin:**
```
Object.set src/uix/soma/provider/context.ts:10:25
new TimePickerProvider src/uix/soma/components/time-picker/time-picker-provider.svelte.ts:119:21
TimePickerProvider.create src/uix/soma/components/time-picker/time-picker-provider.svelte.ts:31:10
```
**Impact:** These 5 components **cannot be unit tested** with the current provider architecture. Their test files exist but all tests fail. This means there is **zero automated test coverage** for the runtime logic of 5 significant picker components.
---
## 3. I18n Fallback Drift
### `active-uix.svelte.test.ts` Failure
```
AssertionError: expected 'Borrar' to be 'Borrar búsqueda'
```
**Test expectation:**
```ts
expect(uix.langs.t('components.search-field.clear')).toBe('Borrar búsqueda');
```
**Actual value:** `'Borrar'`
**Root cause:** The `search-field` langs file (or its fallback) defines `clear` as `'Borrar'`, but the test expects `'Borrar búsqueda'`. This is either:
1. The langs file was updated without updating the test, or
2. The test expectation is the canonical value and the langs file is stale.
**Impact:** ActiveUix boots and resolves translations, but the canonical catalog is out of sync with test expectations. This indicates drift in the i18n pipeline.
---
## 4. Morfo Layer Drift (Confirmed by Source Analysis)
### 4.1 Missing `sema` Scope — 13 Components
Same finding as Round 1, but now confirmed by the contract test infrastructure. These components emit semantic events but do not declare `sema` in their `scope`:
`command`, `table`, `grid-list`, `tree-view`, `tree-grid`, `navigation-menu`, `menubar`, `carousel`, `feed`, `drag-drop`, `color-field`, `time-field`, `tooltip`.
### 4.2 Dead `sema` Scope — 3 Components
`alert-dialog`, `pin-input`, `date-range-field` include `'sema'` in `scope` but declare **no events**.
### 4.3 `picker-shell` — Ghost Component
- Exists in Soma (`src/uix/soma/components/picker-shell/`)
- Has CSS in Eidos (implied by recipe failures)
- **No morfo file** (fails `lint.test.ts`)
- Missing from Soma barrel exports (fails `contracts.test.ts`)
- Missing README (fails `contracts.test.ts`)
- Filename violation in `exports.ts` (fails `contracts.test.ts`)
**Conclusion:** `picker-shell` is a partially-implemented shared component that was abandoned or never completed. It breaks 4 independent contract tests.
---
## 5. Eidos Layer Drift
### 5.1 `Provider` Leak on 3 Components
`table`, `virtual-list`, `virtual-grid` expose `.Provider` on their public namespace, violating `src/uix/eidos/components/README.md` rule 3.
### 5.2 CSS Token Fallbacks
All 13 audited Eidos components consume CSS tokens without fallback values. While architectural by design (guaranteed by `EidosConfig.recipes`), the **recipe catalog itself is incomplete** (see §1.2.A), which means the guarantee is broken in practice.
### 5.3 Eidos Imports Soma Directly — 525 Import Statements
Every Eidos component imports its Soma provider via `import * as X from '$soma/components/x'`. This is an **architectural dependency inversion** that the contracts do not yet enforce but the READMEs discourage.
---
## 6. Build / Type-Check / Warnings
### Build (`npm run build`)
- **Status:** Passes.
- **Output:** 125.75 kB server index, largest page entry is `words/_page.svelte.js` at 177.89 kB.
- **Observation:** `password-field.js` chunk is 9.33 kB (small, good). `active-eidos.svelte.js` is 221.07 kB (the visual runtime is heavy but expected).
### Type-Check (`npm run check`)
- **Status:** 0 errors, 23 warnings.
- **All warnings are in `web/routes/`** (demos and doc pages), **none in `src/uix/`**.
- **Warning categories:**
- Non-reactive updates (`formCardEl`, `letterEl` not declared with `$state`)
- A11y: pointer handlers without ARIA roles
- A11y: `href="#"` invalid attributes
- Unused CSS selectors
- Local state referenced in `$state` initializer (captures initial value only)
**Verdict:** The UIX library itself is type-clean. The warnings are in consumer/demo code, not the framework.
---
## 7. What the Git History Reveals
### `git log --oneline -20`
Recent commits show **active, ongoing migration** of components from Soma to Eidos:
```
bb56e948 fix(password-field): chrome layout + meter visibility + form field styling
90afe27d fix(password-field): visual issues — double border, broken layout, stale lang refs
314f1cd5 fix(eidos): rename `children` prop in 4 wrappers to avoid snippet self-shadow
8d277fe9 fix(password-field): align input chrome with system + add Form/SIUM demo
da3e5026 feat: build PasswordField (full stack)
fead097c fix(demos): rewrite Skeleton + Spinner demos to canonical 6-tab depth
616d8535 feat(eidos): add Skeleton and Spinner loader primitives
940e94bb Build TextArea component + fix locale propagation bug across soma providers
20777471 feat(pin-input): port from soma to eidos (Tier 1 sprint, 5/5 — DONE)
21eb8a62 feat(context-menu): port from soma to eidos (Tier 1 sprint, 4/5)
4e477e63 feat(dropdown-menu): port from soma to eidos (Tier 1 sprint, 3/5)
d5630a52 feat(alert-dialog): port from soma to eidos (Tier 1 sprint, 2/5)
61ffbade feat(toggle-group): port from soma to eidos (Tier 1 sprint, 1/5)
```
### Interpretation
The codebase is in the **middle of a multi-sprint migration** (Tier 1, Tier 2, Layout Batch 1/2/3). This explains:
- Why Soma still contains 454 `.svelte` files (not yet migrated to Eidos)
- Why `button`, `textarea`, `picker-shell` are missing from barrels (not yet fully ported)
- Why tests fail: the migration is incomplete and the contract tests enforce the **target state**, not the **current transition state**
**The `9ec2a57a` revert mentioned in `AGENTS.md`** (`Layout Batch 1 was reverted + redone`) is not in the recent 20 commits, indicating the revert happened earlier and the current state is the "redone" version.
---
## 8. Root-Cause Summary Table
| Symptom | Root Cause | Evidence |
|---------|-----------|----------|
| 12 `contracts.test.ts` failures | Code merged before passing guardrails | Test output with exact violation lists |
| 4 `recipe-css-contract.test.ts` failures | Recipe catalog (`THEME_BASE_RECIPE_TOKENS`) incomplete and orphaned | 90+ undeclared variables, 22 orphaned variables, 4 missing imports |
| 3 `component-api-contract.test.ts` failures | Incomplete Eidos component porting | Missing `_layout.svelte`, missing namespace assignments, `onMount` in Eidos |
| 19 provider test failures (5 components) | `setContext` called outside Svelte component init | Identical stack traces across all 5: `runed` Context.set → Svelte internal error |
| `search-field.clear` = "Borrar" | i18n catalog drift | `active-uix.svelte.test.ts:250` |
| `picker-shell` breaks 4 tests | Ghost component: no morfo, no README, bad export | `lint.test.ts`, `contracts.test.ts` |
| 454 `.svelte` in Soma | Active migration Soma→Eidos in progress | Git log: "port from soma to eidos (Tier 1 sprint, X/5)" |
| Words IME/delete bugs | Provider logic gaps | Source read: `onbeforeinput` returns without `preventDefault`, no `onkeydown` fallback |
| Textarea direct DOM write | `el.style.height = ...` in provider | Source: `textarea-provider.svelte.ts:172,190` |
| Words direct listener | `doc.addEventListener(...)` in provider | Source: `words-provider.svelte.ts:191-192` |
| 11 hardcoded data attrs | Internal engine attrs not declared in morfo | `contracts.test.ts` output |
| `linkEditor` camelCase key | Translation namespace violation | `contracts.test.ts` output |
---
## 9. Action Plan (Evidence-Based Priority)
### P0 — Fix Broken Tests (Prevents CI from Passing)
1. **`picker-shell`**: Either complete it (add morfo, README, fix exports) or remove it from the public surface. It alone breaks 4 contract tests.
2. **`button`, `textarea`, `password-field`**: Add missing READMEs and fix barrel exports.
3. **Fix `textarea-provider` DOM writes**: Route `el.style.height` through `ActiveDom` or move autosize logic to Eidos.
4. **Fix `words-provider` listener**: Replace `doc.addEventListener` with `this.soma.dom.listen(...)`.
5. **Fix `search-field.clear` translation**: Align catalog with test expectation (`"Borrar búsqueda"`).
6. **Fix 11 hardcoded data attrs**: Add them to respective morfo files or remove them from source.
7. **Fix `linkEditor` key**: Rename to `link-editor` in morfo, provider, and catalog.
8. **Fix `command` namespace**: Add `Command.Item.Icon = Icon` and `Command.Item.Shortcut = Shortcut` assignments.
9. **Fix `_layout`**: Add `_layout.svelte` root or remove from `components/`.
10. **Remove `onMount` from `command` Eidos wrappers**: Move shortcut binding to Soma provider.
### P1 — Fix Provider Architecture (setContext Bug)
11. **5 picker providers** (`time-picker`, `time-range-picker`, `color-picker`, `date-picker`, `date-range-picker`): Refactor to avoid `setContext` in class constructors. Options:
- Make provider instantiation lazy until inside a Svelte component.
- Replace `runed` Context with a custom registry that does not depend on Svelte component lifecycle.
- Or mock the Svelte context in tests (but this hides the architectural issue).
### P2 — Fix Recipe CSS Catalog
12. **Register 90+ missing CSS variables** in `THEME_BASE_RECIPE_TOKENS`.
13. **Remove 22 orphaned variables** or add their consumption to component source.
14. **Add 4 missing `@import` lines** to `src/uix/eidos/index.css`.
15. **Replace raw colors** in `color-picker.css`, `drag-drop.css`, `grid-list.css` with token references.
### P3 — Complete Migration
16. **Continue Tier 2+ migration** of Soma `.svelte` files to Eidos. The current state is intentionally transitional, but the contract tests enforce the end state. Either:
- Temporarily exempt transitional components from contract tests (not recommended), or
- Accelerate the migration so Soma becomes headless-only.
---
## 10. Patterns Confirmed Healthy (Post-Evidence)
- ✅ **Zero TypeScript errors in `src/uix/`** (`svelte-check` clean)
- ✅ **Build succeeds** (production static site)
- ✅ **Soma providers do not create shared services**
- ✅ **Svelte 5 runes universal** (zero `$:` legacy in providers)
- ✅ **Zero `$frontend` imports**
- ✅ **`passwordStrength` not duplicated**
- ✅ **`VisualChannel` namespace discipline** (only `data-event-*`)
- ✅ **`SoundChannel` DOM injection** (no direct `document.addEventListener`)
- ✅ **`EngineSemantic` fail-fast** on missing `dom`/`projector`
- ✅ **Motion/color/presence fully removed** from Sema
- ✅ **`SEMA_MAP` holds and intent deltas correct**
- ✅ **Morfo `texts` naming clean**
---
*End of deep audit. All findings are backed by executable test output, source code line numbers, and build artifacts. Next step: fix P0 items to get the contract test suite green.*

@ -110,6 +110,13 @@
max-inline-size: 100%;
box-sizing: border-box;
overflow-x: hidden;
/* Reserve space for the scrollbar so the content (incl. area thumb) does
NOT shift laterally when the scrollbar appears/disappears (e.g. when
content exceeds the popover's max-block-size — which happens easily
once the picker has alpha slider + swatches + footer). Without this,
the area visibly jumps during drag as state updates and focus rings
cause the browser to toggle scroll visibility. */
scrollbar-gutter: stable;
}
/* All direct rows of the picker content (area, sliders, channel input,
@ -261,6 +268,12 @@
touch-action: none;
user-select: none;
isolation: isolate;
/* Contain layout + style — any layout shift from siblings (e.g. trigger
width oscillating because ValueText hex grows/shrinks mid-drag) does
NOT propagate inward, and the area's own layout doesn't ripple
outward. Fixes the horizontal trembling of the SV area during drag
inside the Words inspector's color picker. */
contain: layout style;
}
/* The Background element receives `background-color: hsl(H,100%,50%)` inline

@ -10,7 +10,7 @@
color = 'primary',
value = $bindable(),
placeholder = $bindable(),
format = $bindable(),
format = $bindable('hex'),
open = $bindable(false),
children,
...rest

@ -0,0 +1,9 @@
// Eidos `<Words>` — the editor surface (provider + contenteditable +
// placeholder), styled by words.css. Chrome (gutters / bubble toolbar /
// settings drawer) is composed on top in later pieces.
//
// import Words from '$uix/eidos/components/words';
// <Words bind:value placeholder="Write something…" />
export { default } from './words.svelte';
export type { WordsProps, WordsSize } from './types';

@ -0,0 +1,34 @@
import type { ProviderProps } from '$soma/components/words';
import type { ResponsiveProp, Size } from '$uix/eidos/lib/types';
/**
* Sizing subset Words exposes. The recipe maps three steps (sm / md / lg)
* to content padding, min/max block-size and the reading font-size.
*/
export type WordsSize = Extract<Size, 'sm' | 'md' | 'lg'>;
/**
* Where the block inspector lives. `'sidebar'` keeps it docked and always
* visible (builder-style); `'drawer'` slides it in from the edge;
* `'popover'` floats it from a toggle; `'none'` hides it entirely.
*/
export type WordsInspectorMode = 'none' | 'sidebar' | 'drawer' | 'popover';
/**
* Props for the eidos `<Words>` editor.
*
* Extends the headless provider's API with the single visual knob the
* recipe needs — `size`. Eidos owns the editor's internal structure
* (the contenteditable surface + placeholder), so the headless
* `children` / `child` snippets are not surfaced here.
*
* Everything else (value, selection, placeholder, readonly, disabled,
* upload pipeline, change/commit callbacks, native div attrs) flows
* straight through to the soma `Words.Provider`.
*/
export type WordsProps = Omit<ProviderProps, 'children' | 'child'> & {
/** Sizing scale — drives padding, height and reading size. @default 'md' */
size?: ResponsiveProp<WordsSize>;
/** Where the block inspector lives. @default 'sidebar' */
inspector?: WordsInspectorMode;
};

@ -0,0 +1,225 @@
<script lang="ts">
/**
* Left gutter — the block handle.
*
* A grip button that tracks the block under the cursor (in the left
* margin) and, on click, opens a menu: Move up / Move down / Duplicate /
* Delete + an Insert-below submenu of block types. Built entirely from
* the eidos `DropdownMenu` (Trigger renders a `Button`; `Sub` drives the
* submenu) — no bespoke widgets.
*
* Positioning: the gutter is a sibling of the contenteditable inside the
* `[data-words]` frame (a positioned ancestor). It measures the hovered
* top-level block's viewport rect and places itself frame-relative, so
* internal content scroll is handled by re-measuring on `scroll`.
*
* The handle + portalled menu carry `data-words-block-handle` /
* `data-words-block-handle-menu` so the provider's focus-scope predicate
* (`isInsideWordsTool`) treats interactions as internal — no spurious
* blur / commit / refocus cycle.
*/
import type { ActiveDom } from '$adom';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { GripVertical } from '$uix/eidos/components/icon';
import { defaultWordsSchema, type ProviderSnippetProps } from '$soma/components/words';
let {
api,
content,
dom
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
} = $props();
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
let index = $state(-1);
let rect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
let show = $state(false);
let menuOpen = $state(false);
// True while the cursor is in the left gutter band (the margin column),
// not over the text. Drives the dashed outline that emphasises the
// target block — showing it for the whole gutter (not just the small
// grip) makes it a forgiving target and keeps the grip clear of the line.
let inGutter = $state(false);
let raf = 0;
function topLevelBlocks(): HTMLElement[] {
return Array.from(content.children).filter(
(el): el is HTMLElement =>
el instanceof HTMLElement && el.matches("[data-words-node='block']")
);
}
function place(el: HTMLElement, i: number) {
if (!frame) return;
const br = el.getBoundingClientRect();
const fr = frame.getBoundingClientRect();
const cr = content.getBoundingClientRect();
// Hide when the block is scrolled out of the content viewport.
if (br.bottom < cr.top + 4 || br.top > cr.bottom - 4) {
show = false;
return;
}
index = i;
rect = {
top: br.top - fr.top,
left: br.left - fr.left,
width: br.width,
height: br.height
};
show = true;
}
function locate(clientX: number, clientY: number) {
const blocks = topLevelBlocks();
let target: HTMLElement | null = null;
let targetIndex = -1;
let targetRect: DOMRect | null = null;
for (let i = 0; i < blocks.length; i++) {
const r = blocks[i].getBoundingClientRect();
if (clientY >= r.top && clientY <= r.bottom) {
target = blocks[i];
targetIndex = i;
targetRect = r;
break;
}
if (r.top <= clientY) {
target = blocks[i];
targetIndex = i;
targetRect = r;
}
}
if (target && targetRect) {
place(target, targetIndex);
// The outline shows only while the cursor is in the gutter band —
// i.e. left of the block's text column, where the grip lives.
inGutter = clientX < targetRect.left;
} else {
show = false;
inGutter = false;
}
}
function reposition() {
if (index < 0) return;
const el = topLevelBlocks()[index];
if (el) place(el, index);
else show = false;
}
function onPointerMove(event: Event) {
if (menuOpen) return;
if (raf) return;
const e = event as PointerEvent;
const x = e.clientX;
const y = e.clientY;
raf = requestAnimationFrame(() => {
raf = 0;
locate(x, y);
});
}
function onPointerLeave() {
if (!menuOpen) {
show = false;
inGutter = false;
}
}
$effect(() => {
const disposers = [
dom.listen(content, 'pointermove', onPointerMove),
dom.listen(content, 'pointerleave', onPointerLeave),
dom.listen(content, 'scroll', reposition, { capture: true })
];
return () => {
for (const dispose of disposers) dispose?.();
if (raf) cancelAnimationFrame(raf);
};
});
// Re-measure after edits change the block layout.
$effect(() => {
void api.document;
if (show || menuOpen) requestAnimationFrame(reposition);
});
// The soma menu owns close-on-select (closeOnSelect defaults true); the
// closed panel hides via opacity, so mutating the document here doesn't
// interfere with the menu's dismissal.
function moveUp() {
if (index > 0) api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'up' });
}
function moveDown() {
api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'down' });
}
function duplicate() {
api.applyCommand({ type: 'duplicateBlock', blockIndex: index });
}
function remove() {
api.applyCommand({ type: 'deleteBlock', blockIndex: index });
show = false;
}
function insert(block: Record<string, unknown>) {
api.applyCommand({ type: 'insertBlock', blockIndex: index + 1, block });
}
// Insert-menu entries come from the block registry — the single source
// of truth shared with the slash menu, so the two lists can't drift.
// Entries flagged non-insertable (e.g. image, which needs a URL/upload
// flow) are skipped.
const inserts = defaultWordsSchema.insertable().filter((entry) => entry.insertable !== false);
</script>
{#if (show || menuOpen) && rect}
{#if inGutter || menuOpen}
<div
data-words-block-outline
style="top: {rect.top - 3}px; left: {rect.left - 3}px; width: {rect.width + 6}px; height: {rect.height + 6}px;"
></div>
{/if}
<div data-words-block-gutter style="top: {rect.top}px;">
<DropdownMenu bind:open={menuOpen}>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Block actions"
data-words-block-handle
>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="5.5" cy="3.5" r="1.35" />
<circle cx="10.5" cy="3.5" r="1.35" />
<circle cx="5.5" cy="8" r="1.35" />
<circle cx="10.5" cy="8" r="1.35" />
<circle cx="5.5" cy="12.5" r="1.35" />
<circle cx="10.5" cy="12.5" r="1.35" />
</svg>
{/snippet}
Block actions
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-block-handle-menu>
<DropdownMenu.Item onSelect={moveUp} disabled={index <= 0}>Move up</DropdownMenu.Item>
<DropdownMenu.Item onSelect={moveDown}>Move down</DropdownMenu.Item>
<DropdownMenu.Item onSelect={duplicate}>Duplicate</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Insert below</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent data-words-block-handle-menu>
{#each inserts as ins (ins.id)}
<DropdownMenu.Item onSelect={() => insert(ins.create())}>{ins.label}</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={remove}>Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
{/if}

@ -0,0 +1,257 @@
<script lang="ts">
/**
* Selection bubble toolbar.
*
* Floats above the current text selection (TipTap / Notion-style). A
* "Turn into" dropdown converts the current block (when convertible)
* plus a row of inline-mark toggles and a link control.
*
* Composition — every control is an ecosystem component, no bespoke
* widgets or hand-drawn glyphs:
* - soma `Words.BubbleMenu` owns open/close + viewport positioning +
* the single-focus-scope contract.
* - eidos `DropdownMenu` drives turn-into; its content carries
* `data-words-bubble-turn-into` so the focus scope recognises it.
* - marks are soma `Words.CommandButton`s rendered through their `child`
* snippet as eidos `Button`s (the canonical consumer pattern) — the
* CommandButton restores the DOM range before the command runs.
* - glyphs come from the eidos `Icon` set.
*
* Block transforms go through `api.applyCommand` (direct, no sema).
*/
import { tick } from 'svelte';
import * as Words from '$soma/components/words';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { Button } from '$uix/eidos/components/button';
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Link,
Unlink,
ChevronDown,
Check
} from '$uix/eidos/components/icon';
import type {
ProviderSnippetProps,
WordsBlock,
WordsHeadingLevel,
WordsListKind
} from '$soma/components/words';
let { api }: { api: ProviderSnippetProps } = $props();
let turnIntoOpen = $state(false);
let linkOpen = $state(false);
let linkValue = $state('');
let linkInput = $state<HTMLInputElement | null>(null);
// Blocks whose content the engine can reinterpret (setBlock / toggleList
// no-op on table / image / divider / callout), so turn-into is disabled
// elsewhere instead of offering inert options.
const CONVERTIBLE_BLOCKS = new Set(['paragraph', 'heading', 'quote', 'code', 'list']);
const canTurnInto = $derived(CONVERTIBLE_BLOCKS.has(api.currentBlock));
const currentListKind = $derived.by<WordsListKind | undefined>(() => {
const index = api.selection?.anchor.path[0];
if (index == null) return undefined;
const block = api.document.children[index] as WordsBlock | undefined;
return block?.type === 'list' ? block.kind : undefined;
});
const blockLabel = $derived.by(() => {
switch (api.currentBlock) {
case 'heading':
return `Heading ${api.currentHeadingLevel ?? 1}`;
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
return currentListKind === 'ordered'
? 'Numbered list'
: currentListKind === 'check'
? 'Check list'
: 'Bulleted list';
case 'paragraph':
return 'Text';
case 'table':
return 'Table';
case 'callout':
return 'Callout';
case 'image':
return 'Image';
case 'divider':
return 'Divider';
default:
return 'Turn into';
}
});
const turnInto = $derived.by(() => [
{
id: 'paragraph',
label: 'Text',
active: api.currentBlock === 'paragraph',
run: () => api.applyCommand({ type: 'setBlock', block: 'paragraph' })
},
...([1, 2, 3] as const).map((level: WordsHeadingLevel) => ({
id: `heading-${level}`,
label: `Heading ${level}`,
active: api.currentBlock === 'heading' && (api.currentHeadingLevel ?? 1) === level,
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
})),
{
id: 'unordered-list',
label: 'Bulleted list',
active: api.currentBlock === 'list' && currentListKind === 'unordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'unordered' })
},
{
id: 'ordered-list',
label: 'Numbered list',
active: api.currentBlock === 'list' && currentListKind === 'ordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'ordered' })
},
{
id: 'check-list',
label: 'Check list',
active: api.currentBlock === 'list' && currentListKind === 'check',
run: () => api.applyCommand({ type: 'toggleList', kind: 'check' })
},
{
id: 'quote',
label: 'Quote',
active: api.currentBlock === 'quote',
run: () => api.applyCommand({ type: 'setBlock', block: 'quote' })
},
{
id: 'code',
label: 'Code',
active: api.currentBlock === 'code',
run: () => api.applyCommand({ type: 'setBlock', block: 'code' })
}
]);
const MARKS = [
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'underline', label: 'Underline', icon: Underline },
{ command: 'strike', label: 'Strikethrough', icon: Strikethrough },
{ command: 'code', label: 'Inline code', icon: Code }
] as const;
function openLinkEditor() {
linkValue = api.selectedLink?.href ?? '';
linkOpen = true;
void tick().then(() => {
linkInput?.focus();
linkInput?.select();
});
}
function applyLink() {
const href = linkValue.trim();
if (href) api.insertLink(href);
linkOpen = false;
}
function onLinkKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
applyLink();
} else if (event.key === 'Escape') {
event.preventDefault();
linkOpen = false;
}
}
</script>
<Words.BubbleMenu side="top">
{#snippet children(bubble)}
{#if bubble.open}
{#if linkOpen}
<div data-words-bubble-link>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={linkInput}
bind:value={linkValue}
type="url"
inputmode="url"
placeholder="Paste or type a link…"
aria-label="Link URL"
onkeydown={onLinkKeydown}
/>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Apply link"
onpointerdown={(e: PointerEvent) => e.preventDefault()}
onclick={applyLink}
>
{#snippet icon()}<Check />{/snippet}
</Button>
</div>
{:else}
<DropdownMenu bind:open={turnIntoOpen}>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
disabled={!canTurnInto}
data-words-bubble-trigger
>
{blockLabel}
{#snippet endIcon()}<ChevronDown />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-bubble-turn-into>
{#each turnInto as item (item.id)}
<DropdownMenu.Item onSelect={item.run} data-words-bubble-into-item>
<span>{item.label}</span>
{#if item.active}<Check size="sm" />{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu>
<span data-words-bubble-divider></span>
{#each MARKS as mark (mark.command)}
{@const Glyph = mark.icon}
<Words.CommandButton command={mark.command} aria-label={mark.label}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/each}
<span data-words-bubble-divider></span>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Link"
data-active={api.selectedLink ? '' : undefined}
onpointerdown={(e: PointerEvent) => e.preventDefault()}
onclick={openLinkEditor}
>
{#snippet icon()}<Link />{/snippet}
</Button>
{#if api.selectedLink}
<Words.CommandButton command="unlink" aria-label="Remove link">
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly>
{#snippet icon()}<Unlink />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/if}
{/if}
{/if}
{/snippet}
</Words.BubbleMenu>

@ -0,0 +1,110 @@
<script lang="ts">
/**
* Inspector colour row — labelled `ColorPicker` bound to a block's hex.
*
* Pattern: local `$state` draft + `bind:value` + `onValueChangeEnd`.
* The `$effect` syncs `draft` ← `current` only when their hexes differ
* so an external edit propagates in without clobbering a mid-drag draft.
*/
import { untrack } from 'svelte';
import { ColorPicker } from '$uix/eidos/components/color-picker';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { Button } from '$uix/eidos/components/button';
import { X } from '$uix/eidos/components/icon';
import { parseColor, colorValueFromHsv, DEFAULT_COLOR, type ColorValue } from '$libs/color';
let {
label,
current,
presets,
onPick
}: {
label: string;
current: string | undefined;
presets: readonly string[];
onPick: (hex: string | undefined) => void;
} = $props();
let draft = $state<ColorValue | undefined>(undefined);
$effect(() => {
// Track ONLY `current` — read draft inside `untrack` so the effect
// doesn't re-run when the picker writes to draft mid-drag (the
// picker mutates draft via the bind chain on every pointermove).
// Without untrack, every drag tick would re-run this effect and
// overwrite the user's value with the still-committed `current`
// hex, locking the thumb in place.
const hexIn = current;
untrack(() => {
if (!hexIn) {
// External cleared (Clear button / programmatic). Mirror
// it into draft so the picker shows empty state.
if (draft !== undefined) draft = undefined;
return;
}
const hsv = parseColor(hexIn);
const next = hsv ? colorValueFromHsv(hsv) : undefined;
if (next?.hex !== draft?.hex) draft = next;
});
});
</script>
<div data-words-inspector-row>
<div data-words-inspector-rowhead>
<span data-words-inspector-label>{label}</span>
{#if current}
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Clear {label.toLowerCase()} color"
onclick={() => onPick(undefined)}
>
{#snippet icon()}<X />{/snippet}
</Button>
{/if}
</div>
<ColorPicker
size="sm"
bind:value={draft}
placeholder={DEFAULT_COLOR}
onValueChange={(cv: ColorValue | undefined) => {
// `onValueChange` fires on EVERY value mutation — including
// programmatic ones like `Clear` / `Cancel` (which don't fire
// `onValueChangeEnd`). Only commit to the engine when the
// value transitions to undefined (clear) — for normal value
// changes during drag, wait for `onValueChangeEnd` to avoid
// per-pointermove engine churn.
if (cv === undefined && current !== undefined) onPick(undefined);
}}
onValueChangeEnd={(cv: ColorValue | undefined) => onPick(cv?.hex)}
>
<ColorPicker.Control>
<ColorPicker.Trigger><ColorPicker.ValueText /></ColorPicker.Trigger>
</ColorPicker.Control>
<ColorPicker.Portal>
<ColorPicker.Content>
<PickerShell.Body>
<ColorPicker.Area />
<ColorPicker.ChannelSlider channel="hue" />
<ColorPicker.ChannelSlider channel="alpha" />
<ColorPicker.SwatchGroup>
{#each presets as c (c)}
<ColorPicker.SwatchTrigger color={c}>
<ColorPicker.Swatch color={c} />
<ColorPicker.SwatchIndicator />
</ColorPicker.SwatchTrigger>
{/each}
</ColorPicker.SwatchGroup>
</PickerShell.Body>
<ColorPicker.Footer>
<ColorPicker.Clear />
<ColorPicker.Cancel />
<ColorPicker.Close />
</ColorPicker.Footer>
</ColorPicker.Content>
</ColorPicker.Portal>
</ColorPicker>
</div>

@ -0,0 +1,317 @@
<script lang="ts">
/**
* Block inspector — the shared body.
*
* A vertical accordion of property sections for the ACTIVE block's
* base-`Block` style. Presentation-agnostic: `<Words inspector>` mounts
* it in a fixed sidebar, a slide-in drawer or a popover.
*
* Architecture:
* - Reads soma state ONLY through the `api` snippet prop (the provider's
* public snippet output — never `WordsProvider.require()`, which would be
* reaching into soma internals; eidos consumes DOM + the sanctioned
* snippet API).
* - All DOM access goes through `eidos.dom` (ActiveDom): the active block
* is mirrored into a local `$state` snapshot on `selectionchange` (a
* listener registered via `eidos.dom.listen`) and right after each edit.
* No raw `addEventListener`, no polling timer.
* - Edits go through `api.applyCommand` (direct visual edit, no sema).
*/
import { Accordion } from '$uix/eidos/components/accordion';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { Slider } from '$uix/eidos/components/slider';
import WordsColorRow from './words-color-row.svelte';
import type { ProviderSnippetProps, WordsBlock } from '$soma/components/words';
let { api }: { api: ProviderSnippetProps } = $props();
// Active block index + block, derived from the `api` snippet prop — the
// same reactive pattern the selection bubble uses (`currentListKind`):
// pure `$derived`, no effect / listener, so it tracks the provider's
// document + selection through the snippet API and re-derives on change.
const activeIndex = $derived(api.selection?.anchor.path[0] ?? api.selectedBlockIndex ?? -1);
const activeBlock = $derived(
activeIndex >= 0 ? (api.document.children[activeIndex] as WordsBlock | undefined) : undefined
);
function edit(visual: Partial<WordsBlock>) {
if (activeIndex < 0) return;
api.applyCommand({ type: 'setBlockVisual', blockIndex: activeIndex, visual });
}
// Which inspector sections are expanded. Multiple may be open.
let openSections = $state<string[]>(['typography']);
type AlignValue = 'left' | 'center' | 'right' | 'justify';
const ALIGNS: readonly { v: AlignValue; label: string }[] = [
{ v: 'left', label: 'Left' },
{ v: 'center', label: 'Center' },
{ v: 'right', label: 'Right' },
{ v: 'justify', label: 'Justify' }
];
type BorderStyleValue = 'solid' | 'dashed' | 'dotted';
const BORDER_STYLES: readonly { v: BorderStyleValue; label: string }[] = [
{ v: 'solid', label: 'Solid' },
{ v: 'dashed', label: 'Dashed' },
{ v: 'dotted', label: 'Dotted' }
];
const FONTS: readonly { v: string; label: string }[] = [
{ v: '', label: 'Default' },
{ v: 'sans-serif', label: 'Sans' },
{ v: 'serif', label: 'Serif' },
{ v: 'monospace', label: 'Mono' }
];
const WEIGHTS: readonly { v: string; label: string }[] = [
{ v: '400', label: 'Regular' },
{ v: '500', label: 'Medium' },
{ v: '600', label: 'Semibold' },
{ v: '700', label: 'Bold' }
];
const COLOR_PRESETS = [
'#e5484d',
'#f76b15',
'#ffc53d',
'#46a758',
'#0091ff',
'#8e4ec6',
'#e93d82',
'#646a73',
'#1a1a1a',
'#ffffff'
];
function blockTitle(b: WordsBlock): string {
switch (b.type) {
case 'heading':
return `Heading ${b.level}`;
case 'paragraph':
return 'Text';
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
return b.kind === 'ordered'
? 'Numbered list'
: b.kind === 'check'
? 'Check list'
: 'Bulleted list';
case 'table':
return 'Table';
case 'image':
return 'Image';
case 'divider':
return 'Divider';
case 'callout':
return 'Callout';
default:
return 'Block';
}
}
</script>
{#snippet numRow(label: string, value: number, min: number, max: number, step: number, onChange: (n: number) => void)}
<div data-words-inspector-row>
<div data-words-inspector-rowhead>
<span data-words-inspector-label>{label}</span>
<span data-words-inspector-value>{value}</span>
</div>
<Slider
value={[value]}
{min}
{max}
{step}
aria-label={label}
onValueChange={(v: number[]) => onChange(v[0] ?? min)}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
</div>
{/snippet}
<div data-words-inspector data-current-block={activeBlock?.type ?? ''}>
{#if activeBlock}
{@const block = activeBlock}
<header data-words-inspector-title>{blockTitle(block)}</header>
<Accordion type="multiple" bind:value={openSections} variant="ghost" size="sm">
<Accordion.Item value="typography">
<Accordion.Header>
<Accordion.Trigger>Typography</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>Font</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.fontFamily ?? '']}
onValueChange={(v: string[]) => edit({ fontFamily: v[0] ? v[0] : undefined })}
aria-label="Font family"
>
{#each FONTS as f (f.v)}
<ToggleGroup.Item value={f.v}>{f.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{@render numRow('Font size', block.fontSize ?? 16, 12, 40, 1, (n) =>
edit({ fontSize: n })
)}
<div data-words-inspector-row>
<span data-words-inspector-label>Weight</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={block.fontWeight ? [String(block.fontWeight)] : []}
onValueChange={(v: string[]) =>
edit({ fontWeight: v[0] ? Number(v[0]) : undefined })}
aria-label="Font weight"
>
{#each WEIGHTS as w (w.v)}
<ToggleGroup.Item value={w.v}>{w.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{@render numRow('Line height', block.lineHeight ?? 1.6, 1, 2.5, 0.1, (n) =>
edit({ lineHeight: Math.round(n * 10) / 10 })
)}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="color">
<Accordion.Header>
<Accordion.Trigger>Color</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<WordsColorRow
label="Text"
current={block.color}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ color: hex })}
/>
<WordsColorRow
label="Background"
current={block.background}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ background: hex })}
/>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="layout">
<Accordion.Header>
<Accordion.Trigger>Layout</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>Align</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={block.align ? [block.align] : []}
onValueChange={(v: string[]) => edit({ align: v[0] as AlignValue | undefined })}
aria-label="Align"
>
{#each ALIGNS as a (a.v)}
<ToggleGroup.Item value={a.v}>{a.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="spacing">
<Accordion.Header>
<Accordion.Trigger>Spacing</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
{@render numRow('Margin top/bottom', block.margin?.block ?? 0, 0, 64, 1, (n) =>
edit({ margin: { ...block.margin, block: n } })
)}
{@render numRow('Margin left/right', block.margin?.inline ?? 0, 0, 64, 1, (n) =>
edit({ margin: { ...block.margin, inline: n } })
)}
{@render numRow('Padding top/bottom', block.padding?.block ?? 0, 0, 64, 1, (n) =>
edit({ padding: { ...block.padding, block: n } })
)}
{@render numRow('Padding left/right', block.padding?.inline ?? 0, 0, 64, 1, (n) =>
edit({ padding: { ...block.padding, inline: n } })
)}
</div>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="border">
<Accordion.Header>
<Accordion.Trigger>Border</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>Style</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={block.border?.style ? [block.border.style] : []}
onValueChange={(v: string[]) => {
const style = v[0] as BorderStyleValue | undefined;
edit({
border: style
? { ...block.border, style, width: block.border?.width || 1 }
: undefined
});
}}
aria-label="Border style"
>
{#each BORDER_STYLES as b (b.v)}
<ToggleGroup.Item value={b.v}>{b.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{@render numRow('Border width', block.border?.width ?? 0, 0, 8, 1, (n) =>
edit({ border: { ...block.border, width: n } })
)}
{@render numRow('Corner radius', block.border?.radius ?? 0, 0, 24, 1, (n) =>
edit({ border: { ...block.border, radius: n } })
)}
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion>
{:else}
<p data-words-inspector-empty>Select a block to edit its style.</p>
{/if}
</div>

@ -0,0 +1,716 @@
/**
* words.css — eidos visual for the Words editor (content layer).
*
* Targets the `data-words-*` attributes the engine's render tree emits
* (see soma `engine/render.ts`) plus the editor chrome that layers over
* the content: the left block gutter and the floating selection bubble
* (their Svelte lives in `words-block-gutter.svelte` /
* `words-bubble.svelte`; the visuals live here). The settings drawer
* keeps its own recipe.
*
* Tokens are the `--words-*` recipe surface (eidos/lib/recipes/base.ts).
* The reading typography is deliberately document-grade (generous size +
* line-height + block rhythm), not the tight UI scale.
*/
/* ── Frame ────────────────────────────────────────────────────────────── */
[data-words] {
/* size cascade — md is the default; sm / lg override below.
content-px is gutter-aware (leaves room for the block handle in the
left margin + the settings button in the right margin) with enough
clearance that the dashed block outline never crowds the grip. */
--_words-content-px: 3rem;
--_words-content-py: var(--words-content-py-md);
--_words-content-min: var(--words-content-min-block-size-md);
--_words-content-max: var(--words-content-max-block-size-md);
--_words-content-font-size: var(--words-font-size-lg);
--_words-radius: var(--words-radius-md);
position: relative;
display: flex;
flex-direction: column;
inline-size: 100%;
font-family: var(--words-font-family);
color: var(--words-color);
background: var(--words-bg);
border: var(--words-border-width) solid var(--words-border);
border-radius: var(--_words-radius);
box-shadow: var(--words-shadow);
transition:
border-color var(--words-transition-duration) var(--words-transition-ease),
box-shadow var(--words-transition-duration) var(--words-transition-ease);
}
[data-words][data-size='sm'] {
--_words-content-px: 2.5rem;
--_words-content-py: var(--words-content-py-sm);
--_words-content-min: var(--words-content-min-block-size-sm);
--_words-content-max: var(--words-content-max-block-size-sm);
--_words-content-font-size: var(--words-font-size-md);
--_words-radius: var(--words-radius-sm);
}
[data-words][data-size='lg'] {
--_words-content-px: 3.5rem;
--_words-content-py: var(--words-content-py-lg);
--_words-content-min: var(--words-content-min-block-size-lg);
--_words-content-max: var(--words-content-max-block-size-lg);
--_words-content-font-size: calc(var(--words-font-size-lg) + 2px);
--_words-radius: var(--words-radius-lg);
}
[data-words]:focus-within {
border-color: var(--words-border-hover);
box-shadow: var(--words-focus-shadow);
}
[data-words][data-invalid] {
border-color: var(--words-invalid-border);
}
[data-words][data-disabled] {
opacity: var(--words-disabled-opacity);
pointer-events: none;
}
/* Pre-mount frame (client-only editor — see words.svelte). Holds the
min height so swapping in the live editor doesn't shift layout. */
[data-words][aria-busy='true'] {
min-block-size: calc(var(--_words-content-min) + 2 * var(--_words-content-py));
}
/* ── Left gutter — block handle ───────────────────────────────────────── */
[data-words-block-gutter] {
position: absolute;
inset-inline-start: 0.35rem;
z-index: 2;
/* nudge so the grip centres on the block's first line */
margin-block-start: 0.12em;
/* slide smoothly between blocks as the cursor moves */
transition: top 120ms var(--words-transition-ease);
animation: words-gutter-in 140ms var(--words-transition-ease);
}
@keyframes words-gutter-in {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
[data-words-block-gutter] [data-words-block-handle] {
color: var(--words-placeholder-color);
cursor: grab;
transition:
transform var(--words-transition-duration) var(--words-transition-ease),
color var(--words-transition-duration) var(--words-transition-ease);
}
/* subtle displacement when the cursor lands on the handle */
[data-words-block-gutter] [data-words-block-handle]:hover {
color: var(--words-content-color);
transform: translateX(1px) scale(1.18);
}
/* dashed outline emphasising the block the handle acts on */
[data-words-block-outline] {
position: absolute;
z-index: 1;
pointer-events: none;
border: 1px dashed var(--words-border-hover);
border-radius: var(--words-radius-sm);
animation: words-outline-in var(--words-transition-duration) var(--words-transition-ease);
}
@keyframes words-outline-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* ── Selection bubble toolbar ─────────────────────────────────────────── */
/* Floating card, viewport-anchored (the soma BubbleMenu writes
--_words-bubble-x / -y from the selection's client rect, so position
is fixed). Centred horizontally on the selection; sits above it for
side='top', below for side='bottom'. */
[data-words-bubble-menu] {
position: fixed;
inset-block-start: var(--_words-bubble-y, 0);
inset-inline-start: var(--_words-bubble-x, 0);
z-index: 30;
display: flex;
align-items: center;
gap: var(--words-command-gap);
padding: var(--space-1);
background: var(--words-bg);
border: var(--words-border-width) solid var(--words-border);
border-radius: var(--words-radius-md);
box-shadow: var(--shadow-overlay);
animation: words-bubble-in 120ms var(--words-transition-ease);
}
[data-words-bubble-menu][data-side='top'] {
transform: translate(-50%, calc(-100% - 0.5rem));
}
[data-words-bubble-menu][data-side='bottom'] {
transform: translate(-50%, 0.5rem);
}
[data-words-bubble-menu][hidden] {
display: none;
}
/* Opacity-only so the keyframe doesn't fight the data-side transform. */
@keyframes words-bubble-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Active mark — the soma CommandButton stamps data-active / aria-pressed
on the composed eidos Button; tint it to read as "on". (The Button
recipe owns every other state: hover, focus, disabled, sizing.) */
[data-words-bubble-menu] [data-button][data-active] {
background: var(--words-primary-solid);
color: var(--words-command-solid-color);
}
/* Thin vertical rule between groups. */
[data-words-bubble-menu] [data-words-bubble-divider] {
flex: 0 0 auto;
inline-size: 1px;
block-size: 1.15rem;
margin-inline: var(--space-0-5);
background: var(--words-border);
}
/* Turn-into trigger (an eidos Button) — keep it slim in the bubble. */
[data-words-bubble-menu] [data-words-bubble-trigger] {
white-space: nowrap;
}
/* Inline link editor row (replaces the toolbar while editing a link). */
[data-words-bubble-menu] [data-words-bubble-link] {
display: flex;
align-items: center;
gap: var(--space-1);
}
[data-words-bubble-menu] [data-words-bubble-link] input {
inline-size: 15rem;
padding: var(--space-1) var(--space-2);
border: var(--words-border-width) solid var(--words-border);
border-radius: var(--words-command-radius);
background: var(--words-bg);
color: var(--words-content-color);
font-family: inherit;
font-size: var(--words-font-size-md);
outline: none;
}
[data-words-bubble-menu] [data-words-bubble-link] input:focus {
border-color: var(--words-border-hover);
}
/* Turn-into menu item — label + trailing active tick. */
[data-words-bubble-into-item] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
/* trailing active tick (eidos Icon) */
[data-words-bubble-into-item] [data-icon] {
color: var(--words-primary-text);
}
/* ── Block inspector ──────────────────────────────────────────────────── */
/* Shared body — a vertical stack of labelled property rows. Reused by
every inspector presentation (sidebar / drawer / popover). The vertical
layout gives each control a full-width row, so nothing crowds. */
/* The inspector body lives inside a soma `Words.Drawer` (for live provider
reactivity); that host adds no layout — only the inner panel does. */
[data-words-inspector-host] {
display: contents;
}
[data-words-inspector] {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-4);
}
[data-words-inspector-title] {
font-size: var(--words-font-size-md);
font-weight: var(--words-strong-font-weight);
color: var(--words-content-color);
}
[data-words-inspector-section] {
display: flex;
flex-direction: column;
gap: var(--space-2-5);
}
[data-words-inspector-section-title] {
font-size: var(--words-font-size-sm);
font-weight: var(--words-strong-font-weight);
color: var(--words-placeholder-color);
text-transform: uppercase;
letter-spacing: 0.05em;
}
[data-words-inspector-row] {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
}
[data-words-inspector-rowhead] {
display: flex;
align-items: baseline;
justify-content: space-between;
}
[data-words-inspector-label] {
font-size: var(--words-font-size-sm);
color: var(--words-command-color);
}
[data-words-inspector-value] {
font-size: var(--words-font-size-sm);
color: var(--words-content-color);
font-variant-numeric: tabular-nums;
}
[data-words-inspector-empty] {
padding: var(--space-2) 0;
font-size: var(--words-font-size-sm);
color: var(--words-placeholder-color);
}
/* Sidebar — docked, always visible (builder-style). The rail reserves a
right gutter; the sidebar itself is absolutely positioned so the frame's
height follows the CONTENT, not the (usually taller) inspector. A flex
row would stretch the frame to the inspector's height and leave dead
space below the text — the inspector instead scrolls within the content
height. */
[data-words][data-inspector='sidebar'] {
padding-inline-end: 16rem;
}
[data-words-sidebar] {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
inline-size: 16rem;
overflow-y: auto;
border-inline-start: var(--words-border-width) solid var(--words-border);
border-start-end-radius: var(--_words-radius);
border-end-end-radius: var(--_words-radius);
background: var(--words-bg);
}
/* Drawer — slide-in panel from the right edge + a fixed toggle. */
[data-words-drawer-toggle] {
position: absolute;
inset-block-start: var(--space-3);
inset-inline-end: var(--space-3);
z-index: 21;
}
[data-words-drawer-panel] {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
z-index: 20;
inline-size: 16rem;
max-inline-size: 85%;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--words-bg);
border-inline-start: var(--words-border-width) solid var(--words-border);
border-start-end-radius: var(--_words-radius);
border-end-end-radius: var(--_words-radius);
box-shadow: var(--shadow-overlay);
transform: translateX(100%);
transition: transform var(--words-transition-duration) var(--words-transition-ease);
}
[data-words-drawer-panel][data-open] {
transform: translateX(0);
}
[data-words-drawer-panel-head] {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
border-block-end: var(--words-border-width) solid var(--words-border);
font-size: var(--words-font-size-md);
font-weight: var(--words-strong-font-weight);
color: var(--words-content-color);
}
/* Popover — a corner toggle floats the inspector. */
[data-words-popover-anchor] {
position: absolute;
inset-block-start: var(--space-3);
inset-inline-end: var(--space-3);
z-index: 21;
}
[data-words-inspector-popover] {
inline-size: 16rem;
padding: 0;
}
/* ── Content surface (the contenteditable) ───────────────────────────── */
[data-words-content] {
position: relative;
flex: 1 1 auto;
min-block-size: var(--_words-content-min);
max-block-size: var(--_words-content-max);
overflow-y: auto;
padding: var(--_words-content-py) var(--_words-content-px);
font-size: var(--_words-content-font-size);
line-height: 1.7;
color: var(--words-content-color);
outline: none;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
[data-words-content] :where([data-words-node='block']):first-child {
margin-block-start: 0;
}
[data-words-content] :where([data-words-node='block']):last-child {
margin-block-end: 0;
}
/* ── Placeholder ──────────────────────────────────────────────────────── */
[data-words-placeholder] {
position: absolute;
inset-block-start: var(--_words-content-py);
inset-inline-start: var(--_words-content-px);
font-size: var(--_words-content-font-size);
line-height: 1.7;
color: var(--words-placeholder-color);
pointer-events: none;
user-select: none;
}
[data-words-placeholder][data-hidden] {
display: none;
}
/* ── Block rhythm ─────────────────────────────────────────────────────── */
[data-words-content] :where(p, h1, h2, h3, blockquote, pre, ul, ol, table, figure, hr, [data-words-block='callout']) {
margin-block: var(--words-block-gap) 0;
}
/* ── Paragraph ────────────────────────────────────────────────────────── */
[data-words-block='paragraph'] {
margin-block: var(--words-block-gap) 0;
}
/* ── Headings ─────────────────────────────────────────────────────────── */
[data-words-block='heading'] {
margin-block: calc(var(--words-block-gap) * 1.5) 0;
color: var(--words-heading-color);
font-weight: var(--words-heading-font-weight);
line-height: var(--words-heading-line-height);
letter-spacing: -0.01em;
}
h1[data-words-block='heading'] {
font-size: var(--words-heading-h1-font-size);
}
h2[data-words-block='heading'] {
font-size: var(--words-heading-h2-font-size);
}
h3[data-words-block='heading'] {
font-size: var(--words-heading-h3-font-size);
}
/* ── Quote ────────────────────────────────────────────────────────────── */
blockquote[data-words-block='quote'] {
padding-inline-start: var(--words-quote-px);
border-inline-start: var(--words-quote-border-width) solid var(--words-primary-border);
color: var(--words-quote-color);
font-style: italic;
}
/* ── Code block ───────────────────────────────────────────────────────── */
pre[data-words-block='code'] {
padding: calc(var(--words-code-py) * 3) calc(var(--words-code-px) * 3);
border-radius: var(--words-radius-sm);
background: var(--words-code-bg);
color: var(--words-code-color);
font-family: var(--words-code-font-family);
font-size: 0.9em;
line-height: 1.6;
overflow-x: auto;
tab-size: 2;
}
pre[data-words-block='code'] code {
font-family: inherit;
white-space: pre;
}
/* syntax tokens (highlightWordsCode) */
[data-words-code-token='keyword'] {
color: var(--words-primary-text);
font-weight: var(--words-strong-font-weight);
}
[data-words-code-token='string'] {
color: var(--words-affirm-text);
}
[data-words-code-token='number'] {
color: var(--words-fulfill-text);
}
[data-words-code-token='comment'] {
color: var(--words-content-color);
opacity: 0.6;
font-style: italic;
}
[data-words-code-token='function'] {
color: var(--words-secondary-text);
}
/* ── Lists ────────────────────────────────────────────────────────────── */
[data-words-block='list'] {
padding-inline-start: var(--words-list-padding-inline-start);
}
[data-words-block='list'][data-words-list-kind='check'] {
padding-inline-start: 0;
list-style: none;
}
li[data-words-node='list-item'] {
margin-block: 0.25em;
}
li[data-words-node='list-item'][data-words-indent='1'] {
margin-inline-start: var(--words-list-padding-inline-start);
}
li[data-words-node='list-item'][data-words-indent='2'] {
margin-inline-start: calc(var(--words-list-padding-inline-start) * 2);
}
/* check toggle */
[data-words-list-kind='check'] li[data-words-node='list-item'] {
display: flex;
align-items: flex-start;
gap: var(--space-2);
}
[data-words-check-toggle] {
flex: 0 0 auto;
inline-size: 1.1em;
block-size: 1.1em;
margin-block-start: 0.3em;
border: 1.5px solid var(--words-border-hover);
border-radius: var(--words-radius-sm);
background: var(--words-bg);
cursor: pointer;
transition:
background var(--words-transition-duration) var(--words-transition-ease),
border-color var(--words-transition-duration) var(--words-transition-ease);
}
[data-words-check-toggle][data-words-checked='true'] {
border-color: var(--words-primary-solid);
background: var(--words-primary-solid)
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E")
center / 70% no-repeat;
}
[data-words-checked='true'] :where(span[data-words-node='text']) {
color: var(--words-content-color);
opacity: 0.55;
text-decoration: line-through;
}
/* ── Table ────────────────────────────────────────────────────────────── */
table[data-words-block='table'] {
inline-size: 100%;
border-collapse: collapse;
font-size: 0.95em;
}
table[data-words-block='table'] :where(td, th) {
padding: var(--space-1-5) var(--space-2-5);
border: var(--words-border-width) solid var(--words-border);
text-align: start;
vertical-align: top;
}
table[data-words-block='table'] th {
background: var(--words-code-bg);
font-weight: var(--words-strong-font-weight);
}
[data-words-node='table-cell'][data-words-cell-align='center'] {
text-align: center;
}
[data-words-node='table-cell'][data-words-cell-align='right'] {
text-align: end;
}
[data-words-node='table-cell'][data-words-cell-vertical='middle'] {
vertical-align: middle;
}
[data-words-node='table-cell'][data-words-cell-vertical='bottom'] {
vertical-align: bottom;
}
/* ── Image ────────────────────────────────────────────────────────────── */
figure[data-words-block='image'] {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
figure[data-words-block='image'] img {
max-inline-size: 100%;
block-size: auto;
border-radius: var(--words-radius-sm);
}
figure[data-words-block='image'][data-words-image-align='left'] {
align-items: flex-start;
}
figure[data-words-block='image'][data-words-image-align='right'] {
align-items: flex-end;
}
figure[data-words-block='image'] figcaption {
font-size: 0.85em;
color: var(--words-quote-color);
text-align: center;
}
figure[data-words-block='image'][data-words-block-selected] {
outline: 2px solid var(--words-primary-solid);
outline-offset: 3px;
border-radius: var(--words-radius-sm);
}
figure[data-words-block='image'][data-words-image-status='uploading'] {
opacity: 0.6;
}
figure[data-words-block='image'][data-words-image-status='error'] {
outline: 2px solid var(--words-threat-solid);
outline-offset: 3px;
}
/* ── Divider ──────────────────────────────────────────────────────────── */
hr[data-words-block='divider'] {
block-size: 0;
border: none;
border-block-start: var(--words-border-width) solid var(--words-border);
margin-block: calc(var(--words-block-gap) * 1.25) 0;
}
hr[data-words-block='divider'][data-words-block-selected] {
border-block-start-color: var(--words-primary-solid);
border-block-start-width: 2px;
}
/* ── Callout ──────────────────────────────────────────────────────────── */
[data-words-block='callout'] {
padding: var(--space-3) var(--space-4);
border: var(--words-border-width) solid var(--words-neutral-border);
border-inline-start-width: 3px;
border-radius: var(--words-radius-sm);
background: var(--words-neutral-track);
}
[data-words-callout-title] {
margin-block-end: var(--space-1);
font-weight: var(--words-strong-font-weight);
}
[data-words-block='callout'] :where([data-words-node='block']):first-child {
margin-block-start: 0;
}
[data-words-callout-intent='affirm'] {
border-color: var(--words-affirm-border);
background: var(--words-affirm-track);
}
[data-words-callout-intent='affirm'] [data-words-callout-title] {
color: var(--words-affirm-text);
}
[data-words-callout-intent='fulfill'] {
border-color: var(--words-fulfill-border);
background: var(--words-fulfill-track);
}
[data-words-callout-intent='fulfill'] [data-words-callout-title] {
color: var(--words-fulfill-text);
}
[data-words-callout-intent='risk'] {
border-color: var(--words-risk-border);
background: var(--words-risk-track);
}
[data-words-callout-intent='risk'] [data-words-callout-title] {
color: var(--words-risk-text);
}
[data-words-callout-intent='threat'] {
border-color: var(--words-threat-border);
background: var(--words-threat-track);
}
[data-words-callout-intent='threat'] [data-words-callout-title] {
color: var(--words-threat-text);
}
[data-words-callout-intent='loss'] {
border-color: var(--words-loss-border);
background: var(--words-loss-track);
}
[data-words-callout-intent='loss'] [data-words-callout-title] {
color: var(--words-loss-text);
}
/* ── Inline marks ─────────────────────────────────────────────────────── */
[data-words-marks~='bold'] {
font-weight: var(--words-strong-font-weight);
}
[data-words-marks~='italic'] {
font-style: italic;
}
[data-words-marks~='underline'] {
text-decoration-line: underline;
text-underline-offset: 0.18em;
}
[data-words-marks~='strike'] {
text-decoration-line: line-through;
}
[data-words-marks~='underline'][data-words-marks~='strike'] {
text-decoration-line: underline line-through;
}
[data-words-marks~='code'] {
padding: var(--words-code-py) var(--words-code-px);
border-radius: var(--words-code-radius);
background: var(--words-code-bg);
font-family: var(--words-code-font-family);
font-size: 0.9em;
}
/* ── Link ─────────────────────────────────────────────────────────────── */
a[data-words-node='link'] {
color: var(--words-primary-text);
text-decoration-line: underline;
text-underline-offset: 0.15em;
text-decoration-thickness: 1px;
cursor: pointer;
}
a[data-words-node='link']:hover {
text-decoration-thickness: 2px;
}
/* ── Find/replace matches ─────────────────────────────────────────────── */
mark[data-words-find-match] {
border-radius: 2px;
background: var(--words-fulfill-track);
color: inherit;
}
mark[data-words-find-match][data-words-find-active] {
background: var(--words-fulfill-solid);
color: var(--words-fulfill-text);
}
/* ── Selection ────────────────────────────────────────────────────────── */
[data-words-content] ::selection {
background: color-mix(in srgb, var(--words-primary-solid) 22%, transparent);
}

@ -0,0 +1,129 @@
<script lang="ts">
/**
* Eidos `<Words>` — visual wrapper over the headless Words editor.
*
* The soma `Words.Provider` owns the document model, selection,
* contenteditable wiring and the `data-words*` attributes the morfo
* runtime emits. Eidos owns the look: the contenteditable surface +
* placeholder, the left block gutter, the selection bubble, and the
* block inspector.
*
* The inspector's presentation is a prop (`inspector`): a fixed sidebar
* (builder-style, always visible), a slide-in drawer, a popover, or
* none. The same `<WordsInspector>` body is reused across all of them.
*/
import { onMount } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import * as Words from '$soma/components/words';
import { Button } from '$uix/eidos/components/button';
import { Popover } from '$uix/eidos/components/popover';
import { SlidersHorizontal, X } from '$uix/eidos/components/icon';
import WordsBlockGutter from './words-block-gutter.svelte';
import WordsBubble from './words-bubble.svelte';
import WordsInspector from './words-inspector.svelte';
import type { WordsProps } from './types';
let {
size = 'md',
value = $bindable(),
selection = $bindable(null),
inspector = 'sidebar',
...headlessProps
}: WordsProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const dom = eidos.dom;
// Rich-text contenteditable is inherently client-only: the engine
// stamps content ids with `crypto.randomUUID()`, so an SSR pass and the
// client pass emit different markup and hydration bails. Defer the
// editor to the client; SSR + first paint render the empty frame.
let mounted = $state(false);
onMount(() => {
mounted = true;
});
let contentEl = $state<HTMLDivElement | null>(null);
let drawerOpen = $state(false);
</script>
{#if mounted}
<Words.Provider
{...headlessProps}
bind:value
bind:selection
data-size={resolvedSize}
data-inspector={inspector}
>
{#snippet children(api)}
<Words.Content bind:ref={contentEl} />
<Words.Placeholder />
<WordsBubble {api} />
{#if contentEl}
<WordsBlockGutter {api} content={contentEl} {dom} />
{/if}
{#if inspector === 'sidebar'}
<aside data-words-sidebar>
<WordsInspector {api} />
</aside>
{:else if inspector === 'drawer'}
<Button
data-words-drawer-toggle
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Block settings"
aria-pressed={drawerOpen}
data-active={drawerOpen ? '' : undefined}
onclick={() => (drawerOpen = !drawerOpen)}
>
{#snippet icon()}<SlidersHorizontal />{/snippet}
</Button>
<aside data-words-drawer-panel data-open={drawerOpen ? '' : undefined}>
<header data-words-drawer-panel-head>
<span>Block</span>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Close settings"
onclick={() => (drawerOpen = false)}
>
{#snippet icon()}<X />{/snippet}
</Button>
</header>
<WordsInspector {api} />
</aside>
{:else if inspector === 'popover'}
<div data-words-popover-anchor>
<Popover>
<Popover.Trigger>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Button
{...props}
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Block settings"
>
{#snippet icon()}<SlidersHorizontal />{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content data-words-inspector-popover side="bottom" align="end">
<WordsInspector {api} />
</Popover.Content>
</Popover.Portal>
</Popover>
</div>
{/if}
{/snippet}
</Words.Provider>
{:else}
<div data-words data-size={resolvedSize} data-inspector={inspector} aria-busy="true"></div>
{/if}

@ -1,477 +0,0 @@
# Refactorizacion Codex de `uix/sema`
## Tesis revisada
La mejor refactorizacion para `Sema` no es hacer mas rico el `SEMA_MAP`
visual, sino hacerlo mas pobre y mas semantico.
El canal visual deberia definirse como la emision transitoria en el DOM de los
atributos `data-event-*`:
```html
data-event="close-after-fail"
data-event-id="sig-42"
data-event-phase="active"
data-event-family="signal"
data-event-intent="threat"
```
Despues, `Eidos` interpreta esos atributos y aplica color, motion, presence,
outline, backdrop, animaciones y transiciones via CSS o recipes visuales. Eso
mantiene la arquitectura limpia:
```text
Sema = que ocurrio, con que familia/intencion, durante cuanto hold
Eidos = como se ve esa ocurrencia
Sound = como suena esa ocurrencia
Haptic = cómo se siente hápticamente esa ocurrencia
```
Con esa frontera, `motion`, `color` y `presence` no deberian vivir como slices
canonicos dentro de `SEMA_MAP`. Son materializacion visual, y UIX ya tiene una
capa visual para eso. `Sema` debe emitir tokens semanticos estables; `Eidos`
debe resolver la apariencia.
## Diagnostico
### 1. El mapa perceptivo mezcla semantica con visual design
Hoy `SEMA_MAP` declara firmas como:
- `motion.duration`, `motion.scale`, `motion.translate`
- `color.hue`, `color.saturation`, `color.lightness`
- `presence.opacity`, `presence.shadow`, `presence.backdrop`
Esos valores son decision visual. No son necesarios para saber que ocurrio; son
una interpretacion de como deberia reaccionar la UI. Al estar en `Sema`, la
capa semantica empieza a competir con `Eidos`.
El resultado es una frontera confusa:
- `Sema` dice que no es visual design, pero declara color y motion.
- `VisualChannel` proyecta `data-event-*` y espera el hold, pero el modelo de
firmas lo trata como si hubiera un subsistema visual resuelto por `Sema`.
- `Eidos` deberia poder cambiar la apariencia sin tocar el dominio semantico.
### 2. `activeChannels` mezcla conceptos
En el mapa, `activeChannels` contiene `motion`, `color`, `presence`, `sound` y
`haptic`. Pero esos nombres no tienen el mismo tipo arquitectonico:
- `sound` y `haptic` son canales runtime reales.
- `motion`, `color` y `presence` son dimensiones visuales.
- `visual` es canal runtime real, pero no aparece en `activeChannels`.
Esto hace que `channels` parezca una allowlist de canales, cuando en realidad
mezcla canales con subdimensiones visuales.
### 3. El canal visual esta mal nombrado para lo que hoy hace
`VisualChannel` no aplica estilos. Su trabajo real es proyectar y sostener en
el tiempo los `data-event-*` antes de resolver la cascade.
Eso sugiere que el concepto correcto no es "canal visual que consume una firma
visual", sino "proyector visual/DOM de senales semanticas".
Nombres posibles:
- `VisualChannel`, si se conserva por compatibilidad publica.
- `DomEventChannel`, si se quiere nombrar lo que hace.
- `SignalProjectionChannel`, si se quiere enfatizar la frontera.
- `EventHoldChannel`, si se separa la escritura DOM del temporizador.
Mi recomendacion: mantener `VisualChannel` como alias publico, pero internamente
dividirlo en `DomSignalProjector` + `EventHoldChannel`.
### 4. La politica de intent sigue duplicada
Hay una incoherencia independiente, pero importante:
- `types.ts` declara `SEMA_FAMILY_POLICY` y permite intent opcional en
`emerge`, `shift` y `sustain`.
- `resolver.ts` ya aplica deltas de intent a cualquier familia si el signal
trae intent.
- `event.ts` todavia rechaza intent en familias transitional y exige intent en
cualquier familia valenced.
- `normalizeSemaEvent()` descarta intent en transitional.
Si `Sema` va a ser el emisor de tokens DOM, esta parte debe quedar impecable:
los atributos `data-event-family` y `data-event-intent` tienen que representar
la politica real, no una taxonomia antigua.
### 5. El engine escribe DOM por una razon correcta, pero la frontera es pobre
El engine escribe los attrs antes de `resolveSignature()` para que la cascade
pueda matchear selectores tipo `[data-event-intent="threat"]`. La razon tecnica
es correcta.
Lo que conviene cambiar es la forma: el engine no deberia conocer directamente
los atributos DOM. Deberia delegar en un proyector.
## Nuevo modelo propuesto
### Sema emite tokens, no estilos
El contrato visual de `Sema` seria:
```ts
export interface SemanticSignal {
target: HTMLElement
name: string
family?: SemaFamily
intent?: Intent
hold?: SemaDurationSpec
id?: string
channels?: readonly SemaRuntimeChannelId[]
overrides?: SemaNonVisualOverride
}
```
Y el canal visual/proyector solo garantiza:
1. escribir `data-event-*` sincronamente;
2. mantenerlos durante el hold;
3. limpiarlos antes de resolver `emit()`.
No calcula color. No calcula shadow. No calcula transform. No decide easing.
### Eidos interpreta los tokens
La expresion visual pasa a CSS/recipes:
```css
[data-event-family='signal'][data-event-intent='threat'] {
animation: eidos-announce-pulse-threat 1000ms var(--ease-spring);
}
[data-dialog-content][data-event='close-after-fail'] {
animation-name: dialog-threat-exit;
animation-duration: var(--duration-slow);
}
```
O en una recipe de Eidos:
```ts
event: {
selector: semaSelector(dialogMorfo, 'content', {
eventFamily: 'signal',
eventIntent: 'threat'
}),
className: 'uix-dialog-event-threat'
}
```
La decision exacta de si Eidos lo resuelve con CSS puro, class recipes o tokens
CSS puede evolucionar sin cambiar `Sema`.
### `SEMA_MAP` queda para canales no visuales
El mapa se simplifica:
```ts
export interface SemaMap {
version: string
families: Record<SemaFamily, FamilyMapEntry>
intents: Record<Intent, IntentMapEntry>
}
export interface FamilyMapEntry {
hold?: SemaDurationSpec
channels: readonly SemaRuntimeChannelId[]
base: {
sound?: SoundSignature
haptic?: HapticSignature
a11y?: A11ySignature
voice?: VoiceSignature
}
}
```
La parte visual ya no aparece como `motion`, `color` o `presence`.
Lo que queda en `Sema`:
- escala de duraciones semanticas (`glimpse`, `brief`, `noticed`, etc.);
- hold por familia/evento cuando el evento necesita ventana perceptiva;
- signatures de canales no visuales;
- cascade para ajustar canales no visuales por componente/app.
### La cascade de Sema tambien deberia ser no visual
Con esta tesis, `sema/components/dialog.ts` no deberia ajustar:
- `motion.duration`
- `presence.backdrop`
- `color.hue`
Eso vive en `eidos/components/dialog`.
El pack de Sema del Dialog deberia limitarse a:
- sonido por evento;
- haptic por evento;
- silencios o activacion de canales no visuales;
- preload de samples;
- hold semantico si hace falta.
Ejemplo:
```ts
export const dialogSema: Sema = {
name: 'dialog',
cascade: [
{
selector: onContent({ eventFamily: 'commit' }),
haptic: { kind: 'tap' }
},
{
selector: onContent({ eventIntent: 'threat' }),
haptic: { kind: 'error', pattern: [50, 80, 50, 80, 50] },
sound: { sampleUrl: '/sounds/dialog/threat.wav' }
}
],
preloadSamples: ['/sounds/dialog/threat.wav']
}
```
Y la apariencia correspondiente iria en Eidos:
```css
[data-dialog-content][data-event-intent='threat'] {
animation: eidos-announce-pulse-threat 1000ms var(--ease-spring);
}
```
## Arquitectura interna recomendada
```text
src/uix/sema/
+-- domain/
| +-- vocabulary.ts # familias, intents, politica
| +-- event.ts # parse/normalize/guards
| +-- validation.ts # invariantes
+-- projection/
| +-- dom.ts # escribe/limpia data-event-*
| +-- hold.ts # resuelve hold
+-- runtime/
| +-- channel.ts # contrato de canales runtime
| +-- engine.ts # emit lifecycle
+-- signatures/
| +-- map.ts # sound/haptic/a11y/voice, no visual CSS
| +-- delta.ts # algebra de overrides
| +-- cascade.ts # matching CSS-like contra target
| +-- resolve.ts # firmas no visuales efectivas
+-- chans/
| +-- sound.ts
| +-- haptic.ts
+-- components/
| +-- dialog.ts # pack no visual del dialog
+-- exports.ts
```
## Cambios concretos
### 1. Quitar `motion`, `color` y `presence` de `SemaChannelSignatures`
Antes:
```ts
export interface SemaChannelSignatures {
motion: MotionSignature
sound: SoundSignature
color: ColorSignature
presence: PresenceSignature
haptic: HapticSignature
}
```
Despues:
```ts
export interface SemaChannelSignatures {
sound: SoundSignature
haptic: HapticSignature
}
```
Y mantener declaration merging para:
```ts
declare module '$uix/sema' {
interface SemaChannelSignatures {
a11y: A11ySignature
voice: VoiceSignature
}
}
```
Si se quiere una migracion suave, primero deprecar `motion/color/presence` y
hacer que no se usen en nuevos packs.
### 2. Renombrar `channels` a canales runtime reales
`channels` deberia significar `visual`, `sound`, `haptic`, `a11y`, `voice`, no
`motion/color/presence`.
```ts
export type SemaRuntimeChannelId =
| 'visual'
| keyof SemaChannelSignatures
| (string & {})
```
Regla:
- `visual` controla la proyeccion DOM y el hold.
- `sound` consume `effective.sound`.
- `haptic` consume `effective.haptic`.
- custom channels consumen su firma si la declaran.
### 3. Separar `DomSignalProjector` del hold
```ts
export interface SignalProjector {
project(signal: SemanticSignal): ProjectionHandle | undefined
}
export interface ProjectionHandle {
cleanup(): void
}
```
`DomSignalProjector` escribe:
- `data-event`
- `data-event-id`
- `data-event-phase`
- `data-event-family`
- `data-event-intent`
`EventHoldChannel` o `VisualChannel` espera el hold y bloquea `emit()`.
### 4. Resolver hold sin depender de `motion.duration`
Hoy `VisualChannel` usa `effective.motion.duration`. Si `motion` sale de
`Sema`, el hold debe resolverse desde fuentes semanticas:
1. `signal.hold`
2. `event.hold` desde Morfo/Soma
3. `SEMA_MAP.families[family].hold`
4. `SEMA_DURATIONS` por familia
5. default global
Esto es mas limpio: el hold es parte del contrato temporal de la senal, no una
consecuencia accidental de una animacion visual.
### 5. Mover la visual cascade a Eidos
Los selectors que hoy ajustan `motion`, `presence` o `color` en
`sema/components/dialog.ts` deberian migrar a un pack visual de Eidos.
`Sema` conserva selectors CSS-like, pero solo para canales no visuales:
```ts
{
selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }),
sound: { contour: 'descending', pitch: { op: 'add', value: -150 } }
}
```
Eidos se queda con:
```ts
{
selector: onContent({ eventNamePrefix: 'close-', eventFamily: 'emerge' }),
visual: { easing: 'ease-in' }
}
```
O su equivalente CSS.
### 6. Arreglar la politica de intent
`event.ts` y `validation.ts` deben derivar de `SEMA_FAMILY_POLICY`.
Reglas:
- `expected`: intent obligatorio.
- `allowed`: intent opcional.
- `optional`: intent opcional.
- cualquier familia puede portar intent si la politica no lo prohibe.
- `normalizeSemaEvent()` no descarta intent transitional.
Tambien conviene reemplazar `SemaEventLabel` por:
```ts
export type SemaEventKey = SemaFamily | `${SemaFamily}-${Intent}`
```
Asi `emerge-threat` puede existir como clave para sound/haptic sin forzar que
Eidos dependa de un enum cerrado antiguo.
## Plan incremental
### Fase 1: cambiar el criterio de lectura sin romper codigo
- Documentar que `motion/color/presence` quedan deprecated en Sema.
- Prohibir nuevos usos visuales en `sema/components/*`.
- Crear issues o TODOs para mover visual rules a Eidos.
### Fase 2: arreglar dominio e intent policy
- Centralizar familias/intents/policy.
- Cambiar `event.ts` y `validation.ts`.
- Agregar tests de transitional con intent y valenced sin intent cuando la
politica lo permite.
### Fase 3: introducir `DomSignalProjector`
- Extraer `stampEventAttrs`/`unstampEventAttrs` a `projection/dom.ts`.
- Hacer que `EngineSemantic` delegue la escritura.
- Mantener exports antiguos por compatibilidad.
### Fase 4: resolver hold semantico
- Anadir `hold` a `FamilyMapEntry` o a una tabla dedicada.
- Cambiar `VisualChannel` para no leer `effective.motion.duration`.
- Mantener fallback temporal a `motion.duration` mientras exista el campo.
### Fase 5: adelgazar `SEMA_MAP`
- Mover reglas visuales de `sema-map.ts` y `sema/components/dialog.ts` a Eidos.
- Dejar `SEMA_MAP` con `sound`, `haptic` y futuros canales no visuales.
- Deprecar tipos `MotionSignature`, `ColorSignature`, `PresenceSignature`.
### Fase 6: renombrar canales de forma real
- Introducir `SemaRuntimeChannelId`.
- Hacer que `channels` signifique canales runtime.
- Eliminar `activeChannels` como lista de dimensiones visuales.
## Tests que deberian proteger este redisenio
- `VisualChannel.prepare()` escribe `data-event-*` antes de resolver cascade.
- `emit()` limpia attrs antes de resolver la Promise.
- `VisualChannel`/hold ya no depende de `effective.motion.duration`.
- `Eidos` puede reaccionar a `[data-event-family]` y `[data-event-intent]`
sin importar `Sema`.
- `Sema` puede emitir sound/haptic sin ningun campo visual en `SEMA_MAP`.
- `dialogSema` no declara `motion`, `color` ni `presence`.
- `emerge` con intent conserva `data-event-intent`.
- `contact` sin intent valida si la politica es `allowed`.
## Resultado esperado
Despues de esta refactorizacion, el modulo queda mas alineado con UIX:
- `Sema` no hace visual design.
- `VisualChannel` es una proyeccion DOM + hold, no un motor de estilos.
- `Eidos` es la unica capa responsable de color/motion/presence visual.
- `SoundChannel` y `HapticChannel` siguen teniendo signatures propias porque
no pueden resolverse con CSS.
- El vocabulario semantico queda estable y reusable por todas las capas.
La primera implementacion recomendada es pequena: actualizar el criterio de
lectura y despues mover `dialogSema` para que solo conserve sound/haptic. Esa
migracion haria visible la frontera nueva sin reescribir todo el engine todavia.

@ -318,3 +318,162 @@ The precedent for this split in the broader ecosystem is the common "display for
- [`ColorField`](../color-field/README.md) — the segmented text editor that ColorPicker composes as `ChannelInput`.
- `ColorRangeField` — planned.
- `ColorRangePicker` — planned (Soma innovation).
## Integration pitfalls (2026-05-30)
Surfaced while embedding `<ColorPicker>` inside the Words inspector. The
demo at `/uix/components/color-picker` exercises the canonical happy path
and does not trip on these — they only surface when the consumer deviates
from that path.
### 1. `format` must default to `'hex'` in the eidos wrapper
The soma root declares `format = $bindable('hex')` (default `'hex'`). The
eidos wrapper used to declare `format = $bindable()` (no default) and pass
`bind:format` through. When the consumer didn't pass a `format` prop,
Svelte 5 threw:
```
Cannot do `bind:format={undefined}` when `format` has a fallback value
```
…on every mount, which caused a render loop because the picker remounted
on each failure. The fix is to mirror the soma default in the eidos
wrapper:
```svelte
format = $bindable('hex'), // matches soma's default
```
Same rule applies to any future `$bindable()` prop that the eidos wrapper
re-binds: **declare the same fallback on both sides or neither side**.
### 2. The eidos root wrapper MUST pass `ref` to `ColorPickerProvider.create`
Until 2026-05-30 the eidos `<ColorPicker>` wrapper rendered the soma
`<ColorPicker.Provider>` without forwarding a `ref`. The soma provider's
runtime part is created with `ref: opts.ref`; without that, the runtime's
`attachRef` is never built, the provider's DOM element is never registered
under the part name `'provider'`, and any later `runtime.trigger(event)`
whose target is `provider` (or any fallback path that reaches for the
provider DOM) throws:
```
soma::runtime.target: [soma-runtime] Trigger "commit-set" target part
"provider" has no DOM element registered (and no fallbackTarget supplied).
```
The fix is the canonical pattern — wrap the `$bindable` ref in
`writableActive` and pass it through:
```ts
const picker = ColorPickerProvider.create({
ref: writableActive(() => ref, (v) => (ref = v)),
// … rest of opts
});
```
When you build a new picker family member (or any soma component whose
runtime part declares `ref: opts.ref`), **forward the ref from every
wrapper layer** — eidos → soma → runtime. Skipping any link silently
breaks event dispatch.
### 3. `'content'` is a popover part, not a picker part
`<ColorPicker.Content>` is re-exported from Popover (`exports.ts` line 41).
It registers the `'content'` part on the **Popover's runtime**, not on the
ColorPicker's. So `this.runtime.partRef('content')` from inside the picker
provider returns `null`.
`triggerClose` originally relied on that lookup to populate
`fallbackTarget` for `runtime.trigger('close', ...)`, which made every
`Cancel`/`Done`/`Clear` click throw `SomaRuntimeTargetError`. The fix is
to fall back to the picker provider's own DOM (now reliably registered
after fix #2):
```ts
const contentTarget = this.runtime.partRef('content') ?? undefined;
if (contentTarget) {
this.soma.dom.apply({ target: contentTarget, attrs: { 'data-last-action': cause } });
}
const fallback = contentTarget ?? this.opts.ref?.current ?? undefined;
void this.runtime.trigger('close', {
...(fallback ? { fallbackTarget: fallback } : {}),
semantic: CAUSES[cause].semantic
});
```
If a future picker provider also targets a part declared on a composed
provider's runtime (e.g. listbox content, dropdown menu), apply the same
fallback pattern or expose the composed runtime's `partRef` lookup.
### 4. Consumers using `bind:value` to a `$state` draft (instead of `value=$derived`)
When the picker is bound to a `$state<ColorValue | undefined>` that the
consumer derives from external state (e.g. a stored hex on a model), two
traps emerge during the mid-drag pipeline:
a. **Draft clobbered by sync `$effect`.** The naive pattern
```ts
let draft = $state<ColorValue | undefined>(undefined);
$effect(() => {
const next = current ? colorValueFromHsv(parseColor(current)) : undefined;
if (next?.hex !== draft?.hex) draft = next; // ← BUG
});
```
re-runs on every draft mutation (because it reads `draft.hex`),
compares against `current` (still the pre-drag committed hex), and
overwrites draft with the pre-drag value. The thumb appears locked
in place. Fix: read draft inside `untrack` so the effect only re-runs
when `current` actually changes externally:
```ts
$effect(() => {
const hexIn = current;
untrack(() => {
if (!hexIn) {
if (draft !== undefined) draft = undefined;
return;
}
const next = colorValueFromHsv(parseColor(hexIn)!);
if (next?.hex !== draft?.hex) draft = next;
});
});
```
b. **`onValueChangeEnd` doesn't fire on `Clear`.** It only fires on
commit transitions (drag end, swatch click, Enter, eyedropper). The
`Clear` button calls `picker.clear()` which sets value to undefined
programmatically — no end-commit event. If the consumer wired only
`onValueChangeEnd` to its engine commit, `Clear` becomes silent.
Fix: also wire `onValueChange` and capture the `undefined` transition:
```svelte
<ColorPicker
bind:value={draft}
onValueChange={(cv) => {
// Catch programmatic clears (Clear button / cancel revert).
if (cv === undefined && current !== undefined) onPick(undefined);
}}
onValueChangeEnd={(cv) => onPick(cv?.hex)}
>
```
### 5. Eidos picker CSS: contain the area against external layout shifts
`[data-color-picker-area]` lives inside a flex column whose other children
(notably the trigger swatch `<ColorPicker.Trigger>` with `<ValueText />`
showing the live hex) can resize on every mid-drag value tick — string
length oscillates as the hex changes (`'#bde040'` → `'#bde0401c'`). That
resize rippled into the area's layout, visibly shaking it during drag.
`color-picker.css` now applies `contain: layout style` on
`[data-color-picker-area]` to isolate it, and `scrollbar-gutter: stable`
on `[data-popover-content][data-color-picker-content]` so the scrollbar
showing/hiding doesn't shift content laterally.
The trigger ValueText is still allowed to grow/shrink (intentional —
shows the live hex). If a consumer doesn't want the trigger to resize,
pin ValueText to a fixed `width: 9ch` + `font-variant-numeric: tabular-nums`.

@ -355,12 +355,21 @@ export class ColorPickerProvider {
dismissed: { semantic: { family: 'emerge' as const, verb: 'dismiss' } },
'dismissed-outside': { semantic: { family: 'emerge' as const, verb: 'dismiss' } }
};
const target = this.runtime.partRef('content') ?? undefined;
if (target) {
this.soma.dom.apply({ target, attrs: { 'data-last-action': cause } });
// `content` is re-exported from Popover (see soma/components/color-picker/
// exports.ts), so it registers on the POPOVER's runtime — not the
// picker's. `this.runtime.partRef('content')` therefore returns null,
// which would make `runtime.trigger('close')` throw `SomaRuntimeTargetError`
// because the morfo declares `close` targets `content`. Fall back to the
// picker provider's own DOM (always registered) so the close event still
// dispatches; the `data-last-action` attr is only written when content
// IS available (eidos uses it to tint the exit animation).
const contentTarget = this.runtime.partRef('content') ?? undefined;
if (contentTarget) {
this.soma.dom.apply({ target: contentTarget, attrs: { 'data-last-action': cause } });
}
const fallback = contentTarget ?? this.opts.ref?.current ?? undefined;
void this.runtime.trigger('close', {
...(target ? { fallbackTarget: target } : {}),
...(fallback ? { fallbackTarget: fallback } : {}),
semantic: CAUSES[cause].semantic
});
}

@ -136,6 +136,10 @@
// ── ColorPicker provider (shared state + channel setters) ────────────────
const picker = ColorPickerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: sharedValue,
placeholder: sharedPlaceholder,
format: sharedFormat,

@ -0,0 +1,913 @@
/**
* Built-in block specs.
*
* The canonical definitions for the blocks the engine ships. For now each
* spec carries only metadata + insert-menu entries (phase R1); the
* behavioural methods (render / validate / create / serialize) are layered
* on in phases R2–R5, at which point the heavier specs split into their
* own `specs/<type>.ts` modules — the same shape a third-party plugin
* uses.
*
* This module unifies what used to be two divergent hard-coded lists (the
* provider's `DEFAULT_SLASH_COMMANDS` and the gutter's inline `INSERTS`)
* into one source of truth. Importing it registers every built-in into the
* shared `defaultWordsSchema`.
*/
import { defaultWordsSchema } from './registry';
import { WORDS_CHECK_TOGGLE_ATTR, WORDS_NODE_ATTR, WORDS_PATH_ATTR } from '../dom';
import {
WORDS_HEADING_LEVELS,
WORDS_IMAGE_ALIGNS,
WORDS_INTENTS,
WORDS_LIST_KINDS,
WORDS_VERTICAL_ALIGNS,
isWordsIntent
} from '../types';
import type { WordsPath } from '../path';
import type {
WordsBlockRenderContext,
WordsBlockSpec,
WordsBlockValidateContext,
WordsHtmlSerializeContext,
WordsMarkdownSerializeContext
} from './spec';
import type {
CalloutBlock,
CodeBlock,
HeadingBlock,
ImageBlock,
ListBlock,
ListItem,
ParagraphBlock,
QuoteBlock,
TableBlock,
TableCell,
TableRow,
WordsIntent
} from '../types';
import type { WordsRenderElement, WordsRenderNode, WordsRenderTag } from '../render';
// Raw inline content for a fresh, empty text block.
const emptyText = () => [{ type: 'text', text: '' }];
// Callout intent → GFM admonition tag (Markdown export). Inlined here
// rather than imported from serialize-markdown.ts to avoid a value import
// cycle (that module imports this registry). The legacy serializer keeps
// its own copy until the fallback switch is removed.
const CALLOUT_GFM_ADMONITION: Record<WordsIntent, string> = {
neutral: 'NOTE',
affirm: 'TIP',
fulfill: 'IMPORTANT',
risk: 'WARNING',
threat: 'CAUTION',
loss: 'CAUTION'
};
const paragraphSpec: WordsBlockSpec = {
type: 'paragraph',
group: 'text',
core: true,
menu: [
{
id: 'paragraph',
label: 'Text',
description: 'Plain paragraph',
keywords: ['text', 'paragraph', 'p'],
group: 'text',
create: () => ({ type: 'paragraph', children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`);
},
toHtml(block, ctx) {
const b = block as ParagraphBlock;
return `<p${ctx.styleAttr(b)}>${ctx.inlinesToHtml(b.children)}</p>`;
},
toMarkdown(block, ctx) {
const b = block as ParagraphBlock;
return ctx.inlinesToMd(b.children);
},
render(block, ctx) {
const b = block as ParagraphBlock;
return {
kind: 'element',
tag: 'p',
attrs: ctx.composeBlockAttrs(
b,
'paragraph',
ctx.path,
b.align ? { 'data-words-align': b.align } : {}
),
children: ctx.renderInlines(b.children, ctx.path)
};
}
};
const headingSpec: WordsBlockSpec = {
type: 'heading',
group: 'text',
menu: [
{
id: 'heading-1',
label: 'Heading 1',
description: 'Large section heading',
keywords: ['h1', 'title', 'heading'],
group: 'text',
create: () => ({ type: 'heading', level: 1, children: emptyText() })
},
{
id: 'heading-2',
label: 'Heading 2',
description: 'Medium section heading',
keywords: ['h2', 'subtitle', 'heading'],
group: 'text',
create: () => ({ type: 'heading', level: 2, children: emptyText() })
},
{
id: 'heading-3',
label: 'Heading 3',
description: 'Small section heading',
keywords: ['h3', 'heading'],
group: 'text',
create: () => ({ type: 'heading', level: 3, children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`);
if (!(WORDS_HEADING_LEVELS as readonly number[]).includes(block.level as number)) {
ctx.error(
`${ctx.path}/level`,
'invalid-enum-value',
`heading.level must be 1, 2, or 3; got ${JSON.stringify(block.level)}`
);
}
},
toHtml(block, ctx) {
const b = block as HeadingBlock;
return `<h${b.level}${ctx.styleAttr(b)}>${ctx.inlinesToHtml(b.children)}</h${b.level}>`;
},
toMarkdown(block, ctx) {
const b = block as HeadingBlock;
return `${'#'.repeat(b.level)} ${ctx.inlinesToMd(b.children)}`;
},
render(block, ctx) {
const b = block as HeadingBlock;
const tag = (`h${b.level}` as const) satisfies WordsRenderTag;
return {
kind: 'element',
tag,
attrs: ctx.composeBlockAttrs(b, 'heading', ctx.path, {
'data-words-heading-level': String(b.level),
...(b.align ? { 'data-words-align': b.align } : {})
}),
children: ctx.renderInlines(b.children, ctx.path)
};
}
};
const quoteSpec: WordsBlockSpec = {
type: 'quote',
group: 'text',
menu: [
{
id: 'quote',
label: 'Quote',
description: 'Blockquote callout',
keywords: ['blockquote', 'cite', 'quote'],
group: 'text',
create: () => ({ type: 'quote', children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateInlineChildren(block.children, `${ctx.path}/children`);
if (block.cite !== undefined && (typeof block.cite !== 'string' || !ctx.isParseableUrl(block.cite))) {
ctx.error(
`${ctx.path}/cite`,
'invalid-href',
`quote.cite must be a parseable URL string; got ${JSON.stringify(block.cite)}`
);
}
},
toHtml(block, ctx) {
const b = block as QuoteBlock;
const cite = b.cite !== undefined ? ` cite="${ctx.escapeAttr(b.cite)}"` : '';
return `<blockquote${ctx.styleAttr(b)}${cite}>${ctx.inlinesToHtml(b.children)}</blockquote>`;
},
toMarkdown(block, ctx) {
const b = block as QuoteBlock;
return ctx
.inlinesToMd(b.children)
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
},
render(block, ctx) {
const b = block as QuoteBlock;
return {
kind: 'element',
tag: 'blockquote',
attrs: ctx.composeBlockAttrs(b, 'quote', ctx.path, {
...(b.align ? { 'data-words-align': b.align } : {}),
...(b.cite ? { cite: b.cite } : {})
}),
children: ctx.renderInlines(b.children, ctx.path)
};
}
};
const codeSpec: WordsBlockSpec = {
type: 'code',
group: 'text',
menu: [
{
id: 'code-block',
label: 'Code block',
description: 'Preformatted code',
keywords: ['code', 'pre', 'snippet'],
group: 'text',
create: () => ({ type: 'code', children: emptyText() })
}
],
validate(block, ctx) {
ctx.validateTextOnlyChildren(block.children, `${ctx.path}/children`);
if (block.language !== undefined && typeof block.language !== 'string') {
ctx.error(
`${ctx.path}/language`,
'invalid-block-shape',
`code.language must be a string if present`
);
}
},
toHtml(block, ctx) {
const b = block as CodeBlock;
const code = b.children.map((t) => ctx.escapeText(t.text)).join('');
const lang = b.language ? ` class="language-${ctx.escapeAttr(b.language)}"` : '';
return `<pre${ctx.styleAttr(b)}><code${lang}>${code}</code></pre>`;
},
toMarkdown(block) {
const b = block as CodeBlock;
const code = b.children.map((t) => t.text).join('');
const lang = b.language ?? '';
return `\`\`\`${lang}\n${code}\n\`\`\``;
},
render(block, ctx) {
const b = block as CodeBlock;
const innerCode: WordsRenderElement = {
kind: 'element',
tag: 'code',
attrs: b.language ? { class: `language-${b.language}` } : {},
children: b.children.map((text, i) => ctx.renderText(text, [...ctx.path, i], b.language))
};
return {
kind: 'element',
tag: 'pre',
attrs: ctx.composeBlockAttrs(
b,
'code',
ctx.path,
b.language ? { 'data-words-code-language': b.language } : {}
),
children: [innerCode]
};
}
};
const listSpec: WordsBlockSpec = {
type: 'list',
group: 'list',
menu: [
{
id: 'unordered-list',
label: 'Bulleted list',
description: 'List with bullets',
keywords: ['ul', 'bullet', 'list'],
group: 'list',
create: () => ({ type: 'list', kind: 'unordered', items: [{ children: emptyText() }] })
},
{
id: 'ordered-list',
label: 'Numbered list',
description: 'List with numbers',
keywords: ['ol', 'number', 'list'],
group: 'list',
create: () => ({ type: 'list', kind: 'ordered', items: [{ children: emptyText() }] })
},
{
id: 'check-list',
label: 'Check list',
description: 'Track tasks inline',
keywords: ['todo', 'task', 'check'],
group: 'list',
create: () => ({
type: 'list',
kind: 'check',
items: [{ checked: false, children: emptyText() }]
})
}
],
validate(block, ctx) {
ctx.validateOptionalEnum(block.kind, WORDS_LIST_KINDS, `${ctx.path}/kind`, true);
if (!Array.isArray(block.items)) {
ctx.error(`${ctx.path}/items`, 'invalid-children', 'list.items must be an array');
} else {
for (let i = 0; i < block.items.length; i++) {
validateListItemFields(block.items[i], `${ctx.path}/items/${i}`, ctx);
}
}
},
toHtml(block, ctx) {
const b = block as ListBlock;
const tag = b.kind === 'ordered' ? 'ol' : 'ul';
const items = b.items.map((item) => listItemToHtml(item, b.kind, ctx)).join('');
return `<${tag}${ctx.styleAttr(b)}>${items}</${tag}>`;
},
toMarkdown(block, ctx) {
const b = block as ListBlock;
const lines: string[] = [];
for (let i = 0; i < b.items.length; i++) {
lines.push(listItemToMd(b.items[i], i, b.kind, ctx.depth, ctx));
}
return lines.join('\n');
},
render(block, ctx) {
const b = block as ListBlock;
const tag = (b.kind === 'ordered' ? 'ol' : 'ul') satisfies WordsRenderTag;
return {
kind: 'element',
tag,
attrs: ctx.composeBlockAttrs(b, 'list', ctx.path, { 'data-words-list-kind': b.kind }),
children: b.items.map((item, i) => renderListItem(item, [...ctx.path, i], b.kind, ctx))
};
}
};
const tableSpec: WordsBlockSpec = {
type: 'table',
group: 'structure',
menu: [
{
id: 'table',
label: 'Table',
description: '2 by 2 editable grid',
keywords: ['grid', 'cells', 'rows', 'columns'],
group: 'structure',
create: () => ({
type: 'table',
rows: [
{ cells: [{ children: emptyText() }, { children: emptyText() }] },
{ cells: [{ children: emptyText() }, { children: emptyText() }] }
]
})
}
],
validate(block, ctx) {
if (!Array.isArray(block.rows)) {
ctx.error(`${ctx.path}/rows`, 'invalid-children', 'table.rows must be an array');
} else {
for (let i = 0; i < block.rows.length; i++) {
validateTableRowFields(block.rows[i], `${ctx.path}/rows/${i}`, ctx);
}
}
},
toHtml(block, ctx) {
const b = block as TableBlock;
const hasHeaderRow = b.headerRow === true && b.rows.length > 0;
const headerCol = b.headerCol === true;
let thead = '';
let bodyRows: readonly TableRow[] = b.rows;
if (hasHeaderRow) {
const cells = b.rows[0].cells.map((cell) => cellToHtml(cell, true, ctx)).join('');
thead = `<thead><tr>${cells}</tr></thead>`;
bodyRows = b.rows.slice(1);
}
const body = bodyRows
.map((row) => {
const cells = row.cells.map((cell, i) => cellToHtml(cell, headerCol && i === 0, ctx)).join('');
return `<tr>${cells}</tr>`;
})
.join('');
return `<table${ctx.styleAttr(b)}>${thead}<tbody>${body}</tbody></table>`;
},
toMarkdown(block, ctx) {
const b = block as TableBlock;
if (b.rows.length === 0) return '';
const colCount = b.rows[0]?.cells.length ?? 0;
if (colCount === 0) return '';
const rowToMd = (row: { cells: readonly TableCell[] }): string => {
const cells = row.cells.map((c) => cellTextToMd(c, ctx));
while (cells.length < colCount) cells.push('');
return `| ${cells.join(' | ')} |`;
};
const lines: string[] = [];
const headerRow = b.headerRow === true ? b.rows[0] : undefined;
if (headerRow) {
lines.push(rowToMd(headerRow));
} else {
lines.push(`| ${Array.from({ length: colCount }, () => ' ').join(' | ')} |`);
}
const alignmentMarkers: string[] = [];
for (let i = 0; i < colCount; i++) {
const firstAlign = b.rows[0]?.cells[i]?.align;
alignmentMarkers.push(
firstAlign === 'center'
? ':---:'
: firstAlign === 'right'
? '---:'
: firstAlign === 'left'
? ':---'
: '---'
);
}
lines.push(`| ${alignmentMarkers.join(' | ')} |`);
const bodyRows = headerRow ? b.rows.slice(1) : b.rows;
for (const row of bodyRows) lines.push(rowToMd(row));
return lines.join('\n');
},
render(block, ctx) {
const b = block as TableBlock;
return {
kind: 'element',
tag: 'table',
attrs: ctx.composeBlockAttrs(b, 'table', ctx.path, {
...(b.headerRow ? { 'data-words-table-header-row': 'true' } : {}),
...(b.headerCol ? { 'data-words-table-header-col': 'true' } : {})
}),
children: [
{
kind: 'element',
tag: 'tbody',
attrs: {},
children: b.rows.map((row, i) =>
renderTableRow(
row,
[...ctx.path, i],
b.headerRow === true,
b.headerCol === true,
i === 0,
ctx
)
)
}
]
};
}
};
const imageSpec: WordsBlockSpec = {
type: 'image',
group: 'media',
menu: [
{
id: 'image',
label: 'Image',
description: 'Insert an image from a URL or upload',
keywords: ['img', 'picture', 'photo', 'figure'],
group: 'media',
// Not directly insertable: needs a URL / upload flow before the
// block is valid. R6 routes this entry through that flow.
insertable: false,
create: () => ({ type: 'image', src: '' })
}
],
validate(block, ctx) {
if (typeof block.src !== 'string' || block.src.length === 0) {
ctx.error(`${ctx.path}/src`, 'invalid-block-shape', 'image.src must be a non-empty string');
}
ctx.validateOptionalNumber(block.width, `${ctx.path}/width`);
ctx.validateOptionalNumber(block.height, `${ctx.path}/height`);
ctx.validateOptionalEnum(block.imageAlign, WORDS_IMAGE_ALIGNS, `${ctx.path}/imageAlign`);
},
toHtml(block, ctx) {
const b = block as ImageBlock;
let attrs = ` src="${ctx.escapeAttr(b.src)}"`;
if (b.alt !== undefined) attrs += ` alt="${ctx.escapeAttr(b.alt)}"`;
if (b.width !== undefined) attrs += ` width="${b.width}"`;
if (b.height !== undefined) attrs += ` height="${b.height}"`;
const img = `<img${attrs}${ctx.styleAttr(b)} />`;
if (b.caption !== undefined && b.caption !== '') {
return `<figure>${img}<figcaption>${ctx.escapeText(b.caption)}</figcaption></figure>`;
}
return img;
},
toMarkdown(block) {
const b = block as ImageBlock;
const alt = b.alt ?? '';
const title = b.caption ? ` "${b.caption.replace(/"/g, '\\"')}"` : '';
return `![${alt}](${b.src}${title})`;
},
render(block, ctx) {
const b = block as ImageBlock;
const figureChildren: WordsRenderNode[] = [
{
kind: 'element',
tag: 'img',
attrs: {
src: b.src,
alt: b.alt ?? '',
...(b.width !== undefined ? { width: String(b.width) } : {}),
...(b.height !== undefined ? { height: String(b.height) } : {}),
loading: 'lazy',
draggable: 'false'
},
children: []
}
];
if (b.caption) {
figureChildren.push({
kind: 'element',
tag: 'figcaption',
attrs: {},
children: [{ kind: 'text', text: b.caption }]
});
}
const isSelected =
ctx.decorations?.selectedBlockIndex !== undefined &&
ctx.path.length === 1 &&
ctx.path[0] === ctx.decorations.selectedBlockIndex;
const status = b.id !== undefined ? ctx.decorations?.imageStatus?.get(b.id) : undefined;
return {
kind: 'element',
tag: 'figure',
attrs: ctx.composeBlockAttrs(b, 'image', ctx.path, {
// Faithful to the pre-registry renderer: it reads the BASE `align`
// (not `imageAlign`) for the float attribute. The model carries
// `imageAlign`; reconciling the two is tracked as a follow-up.
...(b.align && b.align !== 'center' ? { 'data-words-image-align': b.align } : {}),
...(status ? { 'data-words-image-status': status } : {}),
...(isSelected ? { 'data-words-block-selected': '' } : {}),
contenteditable: 'false'
}),
children: figureChildren
};
}
};
const dividerSpec: WordsBlockSpec = {
type: 'divider',
group: 'structure',
menu: [
{
id: 'divider',
label: 'Divider',
description: 'Horizontal rule',
keywords: ['hr', 'rule', 'separator', 'divider'],
group: 'structure',
create: () => ({ type: 'divider' })
}
],
validate() {
// Divider has no type-specific content to validate.
},
toHtml(block, ctx) {
return `<hr${ctx.styleAttr(block)} />`;
},
toMarkdown() {
return '---';
},
render(block, ctx) {
const isSelected =
ctx.decorations?.selectedBlockIndex !== undefined &&
ctx.path.length === 1 &&
ctx.path[0] === ctx.decorations.selectedBlockIndex;
return {
kind: 'element',
tag: 'hr',
attrs: ctx.composeBlockAttrs(block, 'divider', ctx.path, {
...(isSelected ? { 'data-words-block-selected': '' } : {}),
contenteditable: 'false'
}),
children: []
};
}
};
const calloutSpec: WordsBlockSpec = {
type: 'callout',
group: 'structure',
menu: [
{
id: 'callout',
label: 'Callout',
description: 'Highlighted note',
keywords: ['note', 'admonition', 'callout', 'aside'],
group: 'structure',
create: () => ({
type: 'callout',
intent: 'neutral',
children: [{ type: 'paragraph', children: emptyText() }]
})
}
],
validate(block, ctx) {
if (!isWordsIntent(block.intent)) {
ctx.error(
`${ctx.path}/intent`,
'invalid-eval-intent',
`callout.intent must be one of ${WORDS_INTENTS.join(' | ')}; got ${JSON.stringify(block.intent)}`
);
}
if (block.title !== undefined && typeof block.title !== 'string') {
ctx.error(`${ctx.path}/title`, 'invalid-block-shape', 'callout.title must be a string if present');
}
if (!Array.isArray(block.children)) {
ctx.error(
`${ctx.path}/children`,
'invalid-children',
'callout.children must be an array of blocks'
);
} else {
for (let i = 0; i < block.children.length; i++) {
ctx.validateBlock(block.children[i], `${ctx.path}/children/${i}`);
}
}
},
toHtml(block, ctx) {
const b = block as CalloutBlock;
const title = b.title
? `<strong class="callout-title">${ctx.escapeText(b.title)}</strong>`
: '';
const inner = b.children.map((child) => ctx.blockToHtml(child)).join('');
return `<aside class="callout callout-${b.intent}"${ctx.styleAttr(b)}>${title}${inner}</aside>`;
},
toMarkdown(block, ctx) {
const b = block as CalloutBlock;
const tag = CALLOUT_GFM_ADMONITION[b.intent];
const lines: string[] = [`> [!${tag}]`];
if (b.title) lines.push(`> **${b.title}**`);
for (const child of b.children) {
const childMd = ctx.blockToMd(child, ctx.depth + 1);
for (const line of childMd.split('\n')) lines.push(`> ${line}`);
}
return lines.join('\n');
},
render(block, ctx) {
const b = block as CalloutBlock;
const children: WordsRenderNode[] = [];
if (b.title) {
children.push({
kind: 'element',
tag: 'div',
attrs: { 'data-words-callout-title': '' },
children: [{ kind: 'text', text: b.title }]
});
}
for (let i = 0; i < b.children.length; i++) {
children.push(ctx.renderBlock(b.children[i], [...ctx.path, i]));
}
return {
kind: 'element',
tag: 'div',
attrs: ctx.composeBlockAttrs(b, 'callout', ctx.path, {
'data-words-callout-intent': b.intent,
role: 'note'
}),
children
};
}
};
// ── List / table sub-structure helpers ────────────────────────────────────
// Build the `li` / `tr` / `td` nodes the list + table specs emit. They take
// the render context (for inline rendering + path encoding) so they stay
// pure, mirroring the pre-registry private renderers byte-for-byte.
function renderListItem(
item: ListItem,
path: WordsPath,
listKind: ListBlock['kind'],
ctx: WordsBlockRenderContext
): WordsRenderElement {
const checkToggle: WordsRenderNode[] =
listKind === 'check'
? [
{
kind: 'element',
tag: 'span',
attrs: {
[WORDS_CHECK_TOGGLE_ATTR]: '',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
'data-words-checked': item.checked ? 'true' : 'false',
role: 'checkbox',
'aria-checked': item.checked ? 'true' : 'false',
contenteditable: 'false',
tabindex: '-1'
},
children: []
}
]
: [];
return {
kind: 'element',
tag: 'li',
attrs: {
[WORDS_NODE_ATTR]: 'list-item',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
...(item.id ? { 'data-words-id': item.id } : {}),
...(item.indent ? { 'data-words-indent': String(item.indent) } : {}),
...(listKind === 'check' ? { 'data-words-checked': item.checked ? 'true' : 'false' } : {})
},
children: [...checkToggle, ...ctx.renderInlines(item.children, path)]
};
}
function renderTableRow(
row: TableRow,
path: WordsPath,
headerRow: boolean,
headerCol: boolean,
isFirstRow: boolean,
ctx: WordsBlockRenderContext
): WordsRenderElement {
const rowStyle = ctx.stringifyStyle(ctx.blockStyle(row));
return {
kind: 'element',
tag: 'tr',
attrs: {
[WORDS_NODE_ATTR]: 'table-row',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
...(row.id ? { 'data-words-id': row.id } : {}),
...(rowStyle ? { style: rowStyle } : {})
},
children: row.cells.map((cell, i) =>
renderTableCell(cell, [...path, i], headerRow && isFirstRow, headerCol && i === 0, ctx)
)
};
}
function renderTableCell(
cell: TableCell,
path: WordsPath,
asHeader: boolean,
asHeaderCol: boolean,
ctx: WordsBlockRenderContext
): WordsRenderElement {
const isHeader = asHeader || asHeaderCol;
const tag = (isHeader ? 'th' : 'td') satisfies WordsRenderTag;
const cellStyle = ctx.stringifyStyle(ctx.blockStyle(cell));
return {
kind: 'element',
tag,
attrs: {
[WORDS_NODE_ATTR]: 'table-cell',
[WORDS_PATH_ATTR]: ctx.encodeWordsPath(path),
...(cell.id ? { 'data-words-id': cell.id } : {}),
...(cell.align ? { 'data-words-cell-align': cell.align } : {}),
...(cell.verticalAlign ? { 'data-words-cell-vertical': cell.verticalAlign } : {}),
...(cell.colspan ? { colspan: String(cell.colspan) } : {}),
...(cell.rowspan ? { rowspan: String(cell.rowspan) } : {}),
...(cellStyle ? { style: cellStyle } : {})
},
children: ctx.renderInlines(cell.children, path)
};
}
// ── List / table sub-structure validators ─────────────────────────────────
// Validate the raw `item` / `row` / `cell` shapes the list + table specs
// own. Mirror the pre-registry private validators; read fields defensively
// off the untrusted `unknown` input.
function validateListItemFields(
item: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(item)) {
ctx.error(path, 'invalid-block-shape', 'list item must be an object');
return;
}
ctx.validateId(item.id, `${path}/id`);
ctx.validateInlineChildren(item.children, `${path}/children`);
if (item.checked !== undefined && typeof item.checked !== 'boolean') {
ctx.error(`${path}/checked`, 'invalid-block-shape', 'list-item.checked must be boolean if present');
}
if (item.indent !== undefined) {
const n = item.indent as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 8) {
ctx.error(
`${path}/indent`,
'invalid-number',
`list-item.indent must be an integer 0..8; got ${JSON.stringify(item.indent)}`
);
}
}
}
function validateTableRowFields(
row: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(row)) {
ctx.error(path, 'invalid-block-shape', 'table row must be an object');
return;
}
ctx.validateId(row.id, `${path}/id`);
if (!Array.isArray(row.cells)) {
ctx.error(`${path}/cells`, 'invalid-children', 'table-row.cells must be an array');
return;
}
for (let i = 0; i < row.cells.length; i++) {
validateTableCellFields(row.cells[i], `${path}/cells/${i}`, ctx);
}
ctx.validateStyle(row, path);
}
function validateTableCellFields(
cell: unknown,
path: string,
ctx: WordsBlockValidateContext
): void {
if (!ctx.isPlainObject(cell)) {
ctx.error(path, 'invalid-block-shape', 'cell must be an object');
return;
}
ctx.validateId(cell.id, `${path}/id`);
ctx.validateStyle(cell, path);
ctx.validateInlineChildren(cell.children, `${path}/children`);
ctx.validateOptionalEnum(cell.verticalAlign, WORDS_VERTICAL_ALIGNS, `${path}/verticalAlign`);
if (cell.colspan !== undefined) {
const n = cell.colspan as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
ctx.error(`${path}/colspan`, 'invalid-number', 'cell.colspan must be an integer >= 1');
}
}
if (cell.rowspan !== undefined) {
const n = cell.rowspan as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
ctx.error(`${path}/rowspan`, 'invalid-number', 'cell.rowspan must be an integer >= 1');
}
}
}
// ── List / table HTML sub-serializers ─────────────────────────────────────
function listItemToHtml(
item: ListItem,
kind: ListBlock['kind'],
ctx: WordsHtmlSerializeContext
): string {
const indent = item.indent ?? 0;
const style = indent > 0 ? ` style="margin-left: ${indent * 1.5}em"` : '';
const inner = ctx.inlinesToHtml(item.children);
if (kind === 'check') {
const checked = item.checked ? ' checked' : '';
return `<li${style}><input type="checkbox"${checked} disabled>${inner}</li>`;
}
return `<li${style}>${inner}</li>`;
}
function cellToHtml(cell: TableCell, isHeader: boolean, ctx: WordsHtmlSerializeContext): string {
const tag = isHeader ? 'th' : 'td';
const style: Record<string, string> = { ...ctx.blockStyle(cell) };
if (cell.verticalAlign !== undefined) style['vertical-align'] = cell.verticalAlign;
let attrs = '';
if (cell.colspan !== undefined && cell.colspan > 1) attrs += ` colspan="${cell.colspan}"`;
if (cell.rowspan !== undefined && cell.rowspan > 1) attrs += ` rowspan="${cell.rowspan}"`;
const styled = ctx.stringifyStyle(style);
if (styled !== undefined) attrs += ` style="${ctx.escapeAttr(styled)}"`;
return `<${tag}${attrs}>${ctx.inlinesToHtml(cell.children)}</${tag}>`;
}
// ── List / table Markdown sub-serializers ─────────────────────────────────
function listItemToMd(
item: ListItem,
index: number,
kind: ListBlock['kind'],
depth: number,
ctx: WordsMarkdownSerializeContext
): string {
const indent = ' '.repeat(depth + (item.indent ?? 0));
const marker =
kind === 'ordered'
? `${index + 1}.`
: kind === 'check'
? item.checked
? '- [x]'
: '- [ ]'
: '-';
const content = ctx.inlinesToMd(item.children);
return `${indent}${marker} ${content}`;
}
function cellTextToMd(cell: TableCell, ctx: WordsMarkdownSerializeContext): string {
return ctx.inlinesToMd(cell.children).replace(/\|/g, '\\|');
}
/** Every built-in spec, in document/menu order. */
export const BUILT_IN_BLOCK_SPECS: readonly WordsBlockSpec[] = [
paragraphSpec,
headingSpec,
quoteSpec,
codeSpec,
listSpec,
tableSpec,
imageSpec,
dividerSpec,
calloutSpec
];
// Register the built-ins into the shared schema (side effect of import).
for (const spec of BUILT_IN_BLOCK_SPECS) defaultWordsSchema.register(spec);

@ -0,0 +1,16 @@
/**
* Block registry public surface.
*
* Importing this module guarantees the built-in specs are registered into
* `defaultWordsSchema` (the `./built-ins` side effect runs). The engine
* (renderer / validator / serializers / factories / menus) imports from
* here; apps import `defaultWordsSchema` to register plugin blocks.
*/
export { WordsSchema, defaultWordsSchema } from './registry';
export type { WordsBlockSpec, WordsBlockMenuEntry, WordsBlockGroup } from './spec';
export { BUILT_IN_BLOCK_SPECS } from './built-ins';
// Side effect: register the built-ins. Kept last so the exports above are
// resolved first, then the schema is populated.
import './built-ins';

@ -0,0 +1,110 @@
/**
* Open-plugin proof.
*
* Registers a third-party block spec into `defaultWordsSchema` WITHOUT
* touching any engine internal, then asserts it works end-to-end across
* every registry-driven concern: insert menus, render, validate and both
* serializers. This is the contract a plugin block relies on.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { defaultWordsSchema } from './index';
import type { WordsBlockSpec } from './spec';
import { renderWordsDomHtml } from '../operations/render-dom';
import { validateWordsDocument } from '../validate';
import { serializeHtml } from '../serialize-html';
import { serializeMarkdown } from '../serialize-markdown';
import { WORDS_VERSION } from '../types';
import type { Block, WordsBlock, WordsDocument } from '../types';
// A novel block the engine has never heard of — text content + a custom
// `label` field. Cast at the boundaries (a real plugin would also
// declaration-merge `WordsBlockMap` for compile-time safety).
interface BadgeBlock extends Block {
readonly type: 'badge';
readonly label: string;
}
const badgeSpec: WordsBlockSpec = {
type: 'badge',
group: 'media',
menu: [
{
id: 'badge',
label: 'Badge',
description: 'A custom labelled badge',
keywords: ['badge', 'tag', 'chip'],
group: 'media',
create: () => ({ type: 'badge', label: 'New' })
}
],
render(block, ctx) {
const b = block as unknown as BadgeBlock;
return {
kind: 'element',
tag: 'span',
attrs: ctx.composeBlockAttrs(b, 'badge', ctx.path, {}),
children: [{ kind: 'text', text: b.label }]
};
},
validate(block, ctx) {
if (typeof block.label !== 'string' || block.label.length === 0) {
ctx.error(`${ctx.path}/label`, 'invalid-block-shape', 'badge.label must be a non-empty string');
}
},
toHtml(block, ctx) {
const b = block as unknown as BadgeBlock;
return `<span class="badge">${ctx.escapeText(b.label)}</span>`;
},
toMarkdown(block) {
const b = block as unknown as BadgeBlock;
return `\`${b.label}\``;
}
};
const badgeDoc = (label: string): WordsDocument => ({
version: WORDS_VERSION,
children: [{ type: 'badge', label } as unknown as WordsBlock]
});
describe('open-plugin block registration', () => {
afterEach(() => defaultWordsSchema.unregister('badge'));
it('is unknown before registration', () => {
expect(defaultWordsSchema.has('badge')).toBe(false);
expect(defaultWordsSchema.insertable().some((e) => e.id === 'badge')).toBe(false);
// An unregistered type fails validation (registry-driven type check).
expect(validateWordsDocument(badgeDoc('Nope')).valid).toBe(false);
});
it('appears in the insert menus once registered', () => {
defaultWordsSchema.register(badgeSpec);
expect(defaultWordsSchema.has('badge')).toBe(true);
const entry = defaultWordsSchema.insertable().find((e) => e.id === 'badge');
expect(entry?.label).toBe('Badge');
expect(entry?.create()).toMatchObject({ type: 'badge', label: 'New' });
});
it('renders via its spec', () => {
defaultWordsSchema.register(badgeSpec);
const html = renderWordsDomHtml(badgeDoc('Hi'));
expect(html).toContain('data-words-block="badge"');
expect(html).toContain('>Hi<');
});
it('validates via its spec', () => {
defaultWordsSchema.register(badgeSpec);
expect(validateWordsDocument(badgeDoc('Ok')).valid).toBe(true);
const bad = validateWordsDocument({
version: WORDS_VERSION,
children: [{ type: 'badge', label: 42 } as unknown as WordsBlock]
});
expect(bad.valid).toBe(false);
});
it('serializes to html + markdown via its spec', () => {
defaultWordsSchema.register(badgeSpec);
expect(serializeHtml(badgeDoc('Hi'))).toBe('<span class="badge">Hi</span>');
expect(serializeMarkdown(badgeDoc('Hi'))).toBe('`Hi`');
});
});

@ -0,0 +1,68 @@
/**
* Block registry — the runtime schema.
*
* Holds the registered `WordsBlockSpec`s keyed by type. The renderer,
* validator, serializers, factories and insert menus all resolve through
* a `WordsSchema` instance instead of hard-coding the block set. Adding a
* block — built-in or third-party — is a single `register(spec)` call;
* it then appears everywhere automatically (no per-surface drift).
*
* `defaultWordsSchema` is the shared instance the built-ins register into
* and the engine reads from. Apps extend it at boot:
*
* import { defaultWordsSchema } from '$soma/components/words';
* defaultWordsSchema.register(myBlockSpec);
* // + `declare module` to extend WordsBlockMap for type-level safety.
*/
import type { WordsBlockSpec, WordsBlockMenuEntry } from './spec';
export class WordsSchema {
#specs = new Map<string, WordsBlockSpec>();
/** Register (or replace) a block spec. Chainable. */
register(spec: WordsBlockSpec): this {
this.#specs.set(spec.type, spec);
return this;
}
/** Remove a spec by type. Returns whether one was removed. */
unregister(type: string): boolean {
return this.#specs.delete(type);
}
/** The spec for a block type, or `undefined` if unregistered. */
get(type: string): WordsBlockSpec | undefined {
return this.#specs.get(type);
}
/** Whether a block type is registered. */
has(type: string): boolean {
return this.#specs.has(type);
}
/** Every registered spec, in registration order. */
all(): readonly WordsBlockSpec[] {
return [...this.#specs.values()];
}
/** Registered block types, in registration order. */
types(): readonly string[] {
return [...this.#specs.keys()];
}
/**
* Flattened insert-menu entries across all specs, in registration
* order. This is the single source the gutter + slash menus consume.
*/
insertable(): readonly WordsBlockMenuEntry[] {
return this.all().flatMap((spec) => spec.menu ?? []);
}
}
/**
* The shared schema the built-in specs register into and the engine reads
* from. Built-ins are registered as a side effect of importing
* `./built-ins` (re-exported from `./index`).
*/
export const defaultWordsSchema = new WordsSchema();

@ -0,0 +1,225 @@
/**
* Block registry — spec contract.
*
* A `WordsBlockSpec` is the single, self-contained definition of one
* block type. The built-in blocks each ship a spec; third-party blocks
* register their own. Every surface that needs per-type behaviour — the
* renderer, the validator, the serializers, the factories and the insert
* menus — derives from the registry instead of switching over a closed
* union.
*
* Behavioural methods (`render`, then `validate` / `create` /
* `toHtml` / `toMarkdown` in later phases) and their context types are
* added to this interface as each engine concern is migrated onto the
* registry. Each method is optional while its concern is mid-migration so
* the test suite stays green throughout; the engine falls back to its
* legacy switch for any block whose spec hasn't yet implemented the
* method.
*
* `render` takes `block` as the base `WordsBlock` and the spec narrows it
* internally (`block as ParagraphBlock`). This is sound because the
* registry only ever dispatches a block to the spec whose `type` matches —
* the same controlled cast ProseMirror node specs use.
*/
import type { WordsPath } from '../path';
import type { Block, WordsBlock, WordsInline, WordsShadow, WordsText } from '../types';
import type {
WordsRenderAttrs,
WordsRenderDecorations,
WordsRenderElement,
WordsRenderNode
} from '../render';
import type { ValidationErrorCode } from '../validate';
/** Coarse grouping used to organise the insert menus. */
export type WordsBlockGroup = 'text' | 'list' | 'media' | 'structure';
/**
* One entry in the insert menus (left-gutter "Insert below" + the slash
* menu). A single spec may contribute several entries — e.g. the heading
* spec exposes Heading 1/2/3, the list spec exposes bulleted/numbered/
* check — each with its own factory.
*/
export interface WordsBlockMenuEntry {
/** Stable id (e.g. `'heading-1'`, `'unordered-list'`). Unique per menu. */
readonly id: string;
/** Label shown in the menu. */
readonly label: string;
/** Optional one-line description (slash menu shows it). */
readonly description?: string;
/** Search keywords for the slash menu filter. */
readonly keywords?: readonly string[];
/** Grouping for menu organisation. */
readonly group: WordsBlockGroup;
/**
* Whether this entry can be inserted directly. `false` marks an entry
* that needs a follow-up flow before it is valid (e.g. image → URL /
* upload); such entries are surfaced but routed through their flow.
* @default true
*/
readonly insertable?: boolean;
/**
* Produces the block to insert. Returns a raw block literal — the
* engine normalises it (assigns ids, fills defaults) on insertion.
*/
readonly create: () => Record<string, unknown>;
}
/**
* The definition of a block type. Built-ins register the canonical specs;
* apps register more. Behavioural methods are layered on in later phases.
*/
export interface WordsBlockSpec {
/** Discriminant — matches `block.type` and the `WordsBlockMap` key. */
readonly type: string;
/** Coarse grouping (menus, defaults). */
readonly group: WordsBlockGroup;
/**
* Core blocks are always present and cannot be unregistered (paragraph
* is the fallback block). Everything else is optional / plugin.
* @default false
*/
readonly core?: boolean;
/** Insert-menu entries this spec contributes (none → not insertable). */
readonly menu?: readonly WordsBlockMenuEntry[];
/**
* Renders the block to an abstract render node. Receives the base
* `WordsBlock` (narrow it to the spec's own block type) and a context
* exposing the shared render helpers + recursion. Optional while the
* render concern is mid-migration (R2); blocks without it fall back to
* the engine's legacy switch.
*/
render?(block: WordsBlock, ctx: WordsBlockRenderContext): WordsRenderElement;
/**
* Validates the block's TYPE-SPECIFIC shape (children, intrinsic enums,
* required fields). The common concerns — block-type registration, `id`
* uniqueness and the base style props — are checked by the validator
* before dispatch, so a spec only asserts what's unique to it. The block
* is the RAW, untrusted shape (`Record<string, unknown>`) — not yet a
* valid `WordsBlock` — so fields are read defensively. Optional while the
* validate concern is mid-migration (R3); blocks without it fall back to
* the validator's legacy switch.
*/
validate?(block: Record<string, unknown>, ctx: WordsBlockValidateContext): void;
/**
* Serialises the block to portable, editor-markup-free HTML (the
* `exportContent('html')` surface — NOT the editor display). Optional
* while the serialize concern is mid-migration (R5); blocks without it
* fall back to the serializer's legacy switch.
*/
toHtml?(block: WordsBlock, ctx: WordsHtmlSerializeContext): string;
/**
* Serialises the block to (lossy) Markdown — the `exportContent('md')`
* surface. The context carries the current nesting `depth` for indented
* lists / callouts. Optional while the serialize concern is
* mid-migration (R5); blocks without it fall back to the legacy switch.
*/
toMarkdown?(block: WordsBlock, ctx: WordsMarkdownSerializeContext): string;
}
/**
* Helpers + recursion handed to `spec.render`. The engine builds one per
* block (scoped to that block's `path` + the active decorations) so specs
* stay pure — they never import the renderer's internals, which avoids an
* import cycle.
*/
export interface WordsBlockRenderContext {
/** This block's path in the document tree. */
readonly path: WordsPath;
/** Transient editor decorations (find matches, selected block, …). */
readonly decorations?: WordsRenderDecorations;
/** Render a run of inline content (text + links, with marks). */
renderInlines(inlines: readonly WordsInline[], parentPath: WordsPath): readonly WordsRenderNode[];
/** Render a single text inline, optionally tokenised for a code language. */
renderText(text: WordsText, path: WordsPath, codeLanguage?: string): WordsRenderElement;
/** Render a child block (recursion — e.g. callout contents). */
renderBlock(block: WordsBlock, path: WordsPath): WordsRenderElement;
/** Compose the common block attrs (data-words-node / path / block / id /
* style). Accepts any `Block`-shaped node — `blockType` is passed
* explicitly, so plugin blocks (not in the built-in union) work too. */
composeBlockAttrs(
block: Block & { shadow?: WordsShadow },
blockType: string,
path: WordsPath,
extra: Record<string, string | undefined>
): WordsRenderAttrs;
/** Translate common style props into a CSS declarations object. Accepts
* any `Block`-shaped node (blocks, table rows, table cells). */
blockStyle(block: Block & { shadow?: WordsShadow }): Readonly<Record<string, string>>;
/** Serialise a CSS declarations object to an inline `style` string. */
stringifyStyle(style: Readonly<Record<string, string>>): string | undefined;
/** Encode a path into its `data-words-path` attribute value. */
encodeWordsPath(path: WordsPath): string;
}
/**
* Helpers handed to `spec.validate`. Paths are JSON-pointer strings
* (`/children/2/...`). The validator builds one per block, scoped to that
* block's `path`, closing over the shared error list + id bookkeeping.
*/
export interface WordsBlockValidateContext {
/** JSON-pointer path to this block. */
readonly path: string;
/** Record a validation error. */
error(path: string, code: ValidationErrorCode, message: string): void;
/** Validate an optional, document-unique `id`. */
validateId(id: unknown, path: string): void;
/** Validate the common base style props (align / margin / … / shadow). */
validateStyle(node: Record<string, unknown>, path: string): void;
/** Validate a run of inline children (text + links). */
validateInlineChildren(children: unknown, path: string): void;
/** Validate children restricted to plain text inlines (code blocks). */
validateTextOnlyChildren(children: unknown, path: string): void;
/** Validate an optional (or required) enum value against an allow-list. */
validateOptionalEnum(
value: unknown,
allowed: readonly string[],
path: string,
required?: boolean
): void;
/** Validate an optional non-negative finite number. */
validateOptionalNumber(value: unknown, path: string): void;
/** Validate a nested block (recursion — e.g. callout contents). */
validateBlock(block: unknown, path: string): void;
/** Whether a string parses as an absolute or relative URL. */
isParseableUrl(href: string): boolean;
/** Whether a value is a plain (non-array) object. */
isPlainObject(value: unknown): value is Record<string, unknown>;
}
/**
* Helpers handed to `spec.toHtml`. The serializer builds one (it's
* stateless) so specs emit portable HTML without importing the
* serializer's internals.
*/
export interface WordsHtmlSerializeContext {
/** Serialise a run of inline content (text + links + marks) to HTML. */
inlinesToHtml(inlines: readonly WordsInline[]): string;
/** Serialise a nested block (recursion — e.g. callout contents). */
blockToHtml(block: WordsBlock): string;
/** The block's ` style="…"` attribute (or empty string). */
styleAttr(block: Block & { shadow?: WordsShadow }): string;
/** Translate common style props into a CSS declarations object. */
blockStyle(block: Block & { shadow?: WordsShadow }): Readonly<Record<string, string>>;
/** Serialise a CSS declarations object to an inline `style` string. */
stringifyStyle(style: Readonly<Record<string, string>>): string | undefined;
/** Escape text content (`&`, `<`, `>`). */
escapeText(text: string): string;
/** Escape an attribute value (`&`, `<`, `>`, `"`). */
escapeAttr(value: string): string;
}
/**
* Helpers handed to `spec.toMarkdown`. Carries the current nesting
* `depth` (for indented list items / callout bodies) + inline + recursion
* serialisers.
*/
export interface WordsMarkdownSerializeContext {
/** Current nesting depth (0 at the document root). */
readonly depth: number;
/** Serialise a run of inline content (text + links + marks) to Markdown. */
inlinesToMd(inlines: readonly WordsInline[]): string;
/** Serialise a nested block at a given depth (recursion — e.g. callout). */
blockToMd(block: WordsBlock, depth: number): string;
}

@ -16,6 +16,8 @@ export const WORDS_NODE_ATTR = 'data-words-node';
export const WORDS_PATH_ATTR = 'data-words-path';
export const WORDS_MARKS_ATTR = 'data-words-marks';
export const WORDS_EMPTY_TEXT_ATTR = 'data-words-empty-text';
/** Interactive check-list toggle (rendered as the list item's first child). */
export const WORDS_CHECK_TOGGLE_ATTR = 'data-words-check-toggle';
export type WordsDomNodeKind =
| 'block'

@ -11,7 +11,18 @@ import { normalizeDocument } from './normalize';
import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types';
import type { Block, WordsBlock } from '../types';
const STYLE_KEYS = ['align', 'margin', 'padding', 'background', 'color', 'border'] as const;
const STYLE_KEYS = [
'align',
'margin',
'padding',
'background',
'color',
'border',
'fontSize',
'fontFamily',
'fontWeight',
'lineHeight'
] as const;
export function setBlockVisual(
state: WordsEditorState,

@ -21,6 +21,8 @@
*/
import { highlightWordsCode } from './code-highlight';
import { defaultWordsSchema } from './blocks';
import type { WordsBlockRenderContext } from './blocks/spec';
import {
WORDS_EMPTY_TEXT_ATTR,
WORDS_MARKS_ATTR,
@ -33,20 +35,9 @@ import type { WordsTextMatch } from './operations/find-replace';
import type {
Block,
WordsBlock,
CalloutBlock,
CodeBlock,
WordsDocument,
HeadingBlock,
ImageBlock,
WordsInline,
WordsLink,
ListBlock,
ListItem,
ParagraphBlock,
QuoteBlock,
TableBlock,
TableCell,
TableRow,
WordsShadow,
WordsText
} from './types';
@ -85,7 +76,6 @@ interface IndexedWordsTextMatch {
const FIND_MATCH_ATTR = 'data-words-find-match';
const FIND_ACTIVE_ATTR = 'data-words-find-active';
const CODE_TOKEN_ATTR = 'data-words-code-token';
const CHECK_TOGGLE_ATTR = 'data-words-check-toggle';
// ── Output shape ──────────────────────────────────────────────────────────
@ -153,364 +143,34 @@ function renderBlock(
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
switch (block.type) {
case 'paragraph':
return renderParagraph(block, path, decorations);
case 'heading':
return renderHeading(block, path, decorations);
case 'quote':
return renderQuote(block, path, decorations);
case 'code':
return renderCode(block, path, decorations);
case 'list':
return renderList(block, path, decorations);
case 'table':
return renderTable(block, path, decorations);
case 'image':
return renderImage(block, path, decorations);
case 'divider':
return renderDivider(block, path, decorations);
case 'callout':
return renderCallout(block, path, decorations);
// Registry dispatch: every block type resolves to a registered spec.
// A block whose spec is missing or lacks `render` is a registration
// error — surfaced loudly rather than silently dropped.
const spec = defaultWordsSchema.get(block.type);
if (!spec?.render) {
throw new Error(`words: no render spec registered for block type "${block.type}"`);
}
return spec.render(block, makeRenderContext(path, decorations));
}
// ── Paragraph / Heading / Quote ──────────────────────────────────────────
function renderParagraph(
block: ParagraphBlock,
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
return {
kind: 'element',
tag: 'p',
attrs: composeBlockAttrs(block, 'paragraph', path, {
...(block.align ? { 'data-words-align': block.align } : {})
}),
children: renderInlines(block.children, path, decorations)
};
}
function renderHeading(
block: HeadingBlock,
/** Builds the per-block render context handed to `spec.render`. Closes
* over the active decorations and re-enters `renderBlock` for recursion
* so child blocks dispatch through the registry too. */
function makeRenderContext(
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const tag = (`h${block.level}` as const) satisfies WordsRenderTag;
decorations: WordsRenderDecorations | undefined
): WordsBlockRenderContext {
return {
kind: 'element',
tag,
attrs: composeBlockAttrs(block, 'heading', path, {
'data-words-heading-level': String(block.level),
...(block.align ? { 'data-words-align': block.align } : {})
}),
children: renderInlines(block.children, path, decorations)
};
}
function renderQuote(
block: QuoteBlock,
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
return {
kind: 'element',
tag: 'blockquote',
attrs: composeBlockAttrs(block, 'quote', path, {
...(block.align ? { 'data-words-align': block.align } : {}),
...(block.cite ? { cite: block.cite } : {})
}),
children: renderInlines(block.children, path, decorations)
};
}
// ── Code block ────────────────────────────────────────────────────────────
function renderCode(
block: CodeBlock,
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const innerCode: WordsRenderElement = {
kind: 'element',
tag: 'code',
attrs: block.language ? { class: `language-${block.language}` } : {},
// Each text run carries its own data-words-path so the DOM-
// selection bridge can still map caret positions inside a code
// block (V1 parity). Syntax highlighting tokens are emitted as
// nested `data-words-code-token` spans when a language is set.
children: block.children.map((text, i): WordsRenderNode =>
renderTextWithMarks(text, [...path, i], decorations, block.language)
)
};
return {
kind: 'element',
tag: 'pre',
attrs: composeBlockAttrs(block, 'code', path, {
...(block.language ? { 'data-words-code-language': block.language } : {})
}),
children: [innerCode]
};
}
// ── List + items ──────────────────────────────────────────────────────────
function renderList(
block: ListBlock,
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const tag = (block.kind === 'ordered' ? 'ol' : 'ul') satisfies WordsRenderTag;
return {
kind: 'element',
tag,
attrs: composeBlockAttrs(block, 'list', path, {
'data-words-list-kind': block.kind
}),
children: block.items.map((item, i) =>
renderListItem(item, [...path, i], block.kind, decorations)
)
};
}
function renderListItem(
item: ListItem,
path: WordsPath,
listKind: ListBlock['kind'],
decorations?: WordsRenderDecorations
): WordsRenderElement {
// Check lists get an interactive toggle span as the first child. The
// provider's click handler targets `[data-words-check-toggle]`; the
// span is contenteditable=false so the caret never lands on it.
const checkToggle: WordsRenderNode[] =
listKind === 'check'
? [
{
kind: 'element',
tag: 'span',
attrs: {
[CHECK_TOGGLE_ATTR]: '',
[WORDS_PATH_ATTR]: encodeWordsPath(path),
'data-words-checked': item.checked ? 'true' : 'false',
role: 'checkbox',
'aria-checked': item.checked ? 'true' : 'false',
contenteditable: 'false',
tabindex: '-1'
},
children: []
}
]
: [];
return {
kind: 'element',
tag: 'li',
attrs: {
[WORDS_NODE_ATTR]: 'list-item',
[WORDS_PATH_ATTR]: encodeWordsPath(path),
...(item.id ? { 'data-words-id': item.id } : {}),
...(item.indent ? { 'data-words-indent': String(item.indent) } : {}),
...(listKind === 'check'
? { 'data-words-checked': item.checked ? 'true' : 'false' }
: {})
},
children: [...checkToggle, ...renderInlines(item.children, path, decorations)]
};
}
// ── Table + rows + cells ─────────────────────────────────────────────────
function renderTable(
block: TableBlock,
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
return {
kind: 'element',
tag: 'table',
attrs: composeBlockAttrs(block, 'table', path, {
...(block.headerRow ? { 'data-words-table-header-row': 'true' } : {}),
...(block.headerCol ? { 'data-words-table-header-col': 'true' } : {})
}),
children: [
{
kind: 'element',
tag: 'tbody',
attrs: {},
children: block.rows.map((row, i) =>
renderTableRow(
row,
[...path, i],
block.headerRow === true,
block.headerCol === true,
i === 0,
decorations
)
)
}
]
};
}
function renderTableRow(
row: TableRow,
path: WordsPath,
headerRow: boolean,
headerCol: boolean,
isFirstRow: boolean,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const rowStyle = stringifyStyle(blockStyle(row));
return {
kind: 'element',
tag: 'tr',
attrs: {
[WORDS_NODE_ATTR]: 'table-row',
[WORDS_PATH_ATTR]: encodeWordsPath(path),
...(row.id ? { 'data-words-id': row.id } : {}),
...(rowStyle ? { style: rowStyle } : {})
},
children: row.cells.map((cell, i) =>
renderTableCell(
cell,
[...path, i],
headerRow && isFirstRow,
headerCol && i === 0,
decorations
)
)
};
}
function renderTableCell(
cell: TableCell,
path: WordsPath,
asHeader: boolean,
asHeaderCol: boolean,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const isHeader = asHeader || asHeaderCol;
const tag = (isHeader ? 'th' : 'td') satisfies WordsRenderTag;
const cellStyle = stringifyStyle(blockStyle(cell));
return {
kind: 'element',
tag,
attrs: {
[WORDS_NODE_ATTR]: 'table-cell',
[WORDS_PATH_ATTR]: encodeWordsPath(path),
...(cell.id ? { 'data-words-id': cell.id } : {}),
...(cell.align ? { 'data-words-cell-align': cell.align } : {}),
...(cell.verticalAlign ? { 'data-words-cell-vertical': cell.verticalAlign } : {}),
...(cell.colspan ? { colspan: String(cell.colspan) } : {}),
...(cell.rowspan ? { rowspan: String(cell.rowspan) } : {}),
...(cellStyle ? { style: cellStyle } : {})
},
children: renderInlines(cell.children, path, decorations)
};
}
// ── Image ─────────────────────────────────────────────────────────────────
function renderImage(
block: ImageBlock,
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const figureChildren: WordsRenderNode[] = [
{
kind: 'element',
tag: 'img',
attrs: {
src: block.src,
alt: block.alt ?? '',
...(block.width !== undefined ? { width: String(block.width) } : {}),
...(block.height !== undefined ? { height: String(block.height) } : {}),
loading: 'lazy',
draggable: 'false'
},
children: []
}
];
if (block.caption) {
figureChildren.push({
kind: 'element',
tag: 'figcaption',
attrs: {},
children: [{ kind: 'text', text: block.caption }]
});
}
const isSelected =
decorations?.selectedBlockIndex !== undefined &&
path.length === 1 &&
path[0] === decorations.selectedBlockIndex;
const status =
block.id !== undefined ? decorations?.imageStatus?.get(block.id) : undefined;
return {
kind: 'element',
tag: 'figure',
attrs: composeBlockAttrs(block, 'image', path, {
...(block.align && block.align !== 'center'
? { 'data-words-image-align': block.align }
: {}),
...(status ? { 'data-words-image-status': status } : {}),
...(isSelected ? { 'data-words-block-selected': '' } : {}),
// contenteditable=false so the caret can't enter the image
// atom; selection lands on the figure as a whole.
contenteditable: 'false'
}),
children: figureChildren
};
}
// ── Divider ───────────────────────────────────────────────────────────────
function renderDivider(
block: WordsBlock & { type: 'divider' },
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const isSelected =
decorations?.selectedBlockIndex !== undefined &&
path.length === 1 &&
path[0] === decorations.selectedBlockIndex;
return {
kind: 'element',
tag: 'hr',
attrs: composeBlockAttrs(block, 'divider', path, {
...(isSelected ? { 'data-words-block-selected': '' } : {}),
contenteditable: 'false'
}),
children: []
};
}
// ── Callout ───────────────────────────────────────────────────────────────
function renderCallout(
block: CalloutBlock,
path: WordsPath,
decorations?: WordsRenderDecorations
): WordsRenderElement {
const children: WordsRenderNode[] = [];
if (block.title) {
children.push({
kind: 'element',
tag: 'div',
attrs: { 'data-words-callout-title': '' },
children: [{ kind: 'text', text: block.title }]
});
}
for (let i = 0; i < block.children.length; i++) {
children.push(renderBlock(block.children[i], [...path, i], decorations));
}
return {
kind: 'element',
tag: 'div',
attrs: composeBlockAttrs(block, 'callout', path, {
'data-words-callout-intent': block.intent,
role: 'note'
}),
children
path,
decorations,
renderInlines: (inlines, parentPath) => renderInlines(inlines, parentPath, decorations),
renderText: (text, textPath, codeLanguage) =>
renderTextWithMarks(text, textPath, decorations, codeLanguage),
renderBlock: (childBlock, childPath) => renderBlock(childBlock, childPath, decorations),
composeBlockAttrs: (b, blockType, p, extra) => composeBlockAttrs(b, blockType, p, extra),
blockStyle: (b) => blockStyle(b),
stringifyStyle: (style) => stringifyStyle(style),
encodeWordsPath: (p) => encodeWordsPath(p)
};
}
@ -695,6 +355,10 @@ export function blockStyle(
}
if (block.background !== undefined) out['background-color'] = block.background;
if (block.color !== undefined) out.color = block.color;
if (block.fontSize !== undefined) out['font-size'] = px(block.fontSize);
if (block.fontFamily !== undefined) out['font-family'] = block.fontFamily;
if (block.fontWeight !== undefined) out['font-weight'] = String(block.fontWeight);
if (block.lineHeight !== undefined) out['line-height'] = String(block.lineHeight);
const b = block.border;
if (b) {
if (b.color !== undefined) out['border-color'] = b.color;

@ -18,22 +18,14 @@
*/
import { stringifyStyle, blockStyle } from './render';
import { defaultWordsSchema } from './blocks';
import type { WordsHtmlSerializeContext } from './blocks/spec';
import type {
Block,
WordsBlock,
CalloutBlock,
CodeBlock,
WordsDocument,
HeadingBlock,
ImageBlock,
WordsInline,
WordsLink,
ListBlock,
ListItem,
ParagraphBlock,
QuoteBlock,
TableBlock,
TableCell,
WordsShadow,
WordsText
} from './types';
@ -47,129 +39,23 @@ export function serializeHtml(doc: WordsDocument): string {
// ── Block dispatch ───────────────────────────────────────────────────────
function blockToHtml(block: WordsBlock): string {
switch (block.type) {
case 'paragraph':
return paragraphToHtml(block);
case 'heading':
return headingToHtml(block);
case 'quote':
return quoteToHtml(block);
case 'code':
return codeToHtml(block);
case 'list':
return listToHtml(block);
case 'table':
return tableToHtml(block);
case 'image':
return imageToHtml(block);
case 'divider':
return `<hr${styleAttr(block)} />`;
case 'callout':
return calloutToHtml(block);
}
}
// ── Paragraph / Heading / Quote / Code ───────────────────────────────────
function paragraphToHtml(block: ParagraphBlock): string {
return `<p${styleAttr(block)}>${inlinesToHtml(block.children)}</p>`;
}
function headingToHtml(block: HeadingBlock): string {
return `<h${block.level}${styleAttr(block)}>${inlinesToHtml(block.children)}</h${block.level}>`;
}
function quoteToHtml(block: QuoteBlock): string {
const cite = block.cite !== undefined ? ` cite="${escapeAttr(block.cite)}"` : '';
return `<blockquote${styleAttr(block)}${cite}>${inlinesToHtml(block.children)}</blockquote>`;
}
function codeToHtml(block: CodeBlock): string {
const code = block.children.map((t) => escapeText(t.text)).join('');
const lang = block.language ? ` class="language-${escapeAttr(block.language)}"` : '';
return `<pre${styleAttr(block)}><code${lang}>${code}</code></pre>`;
}
// ── List ─────────────────────────────────────────────────────────────────
function listToHtml(block: ListBlock): string {
const tag = block.kind === 'ordered' ? 'ol' : 'ul';
const items = block.items.map((item) => listItemToHtml(item, block.kind)).join('');
return `<${tag}${styleAttr(block)}>${items}</${tag}>`;
}
function listItemToHtml(item: ListItem, kind: ListBlock['kind']): string {
const indent = item.indent ?? 0;
const style = indent > 0 ? ` style="margin-left: ${indent * 1.5}em"` : '';
const inner = inlinesToHtml(item.children);
if (kind === 'check') {
const checked = item.checked ? ' checked' : '';
return `<li${style}><input type="checkbox"${checked} disabled>${inner}</li>`;
}
return `<li${style}>${inner}</li>`;
}
// ── Table ────────────────────────────────────────────────────────────────
function tableToHtml(block: TableBlock): string {
const hasHeaderRow = block.headerRow === true && block.rows.length > 0;
const headerCol = block.headerCol === true;
let thead = '';
let bodyRows: readonly TableBlock['rows'][number][] = block.rows;
if (hasHeaderRow) {
const cells = block.rows[0].cells.map((cell) => cellToHtml(cell, true)).join('');
thead = `<thead><tr>${cells}</tr></thead>`;
bodyRows = block.rows.slice(1);
}
const body = bodyRows
.map((row) => {
const cells = row.cells
.map((cell, i) => cellToHtml(cell, headerCol && i === 0))
.join('');
return `<tr>${cells}</tr>`;
})
.join('');
return `<table${styleAttr(block)}>${thead}<tbody>${body}</tbody></table>`;
}
function cellToHtml(cell: TableCell, isHeader: boolean): string {
const tag = isHeader ? 'th' : 'td';
const style: Record<string, string> = { ...blockStyle(cell) };
if (cell.verticalAlign !== undefined) style['vertical-align'] = cell.verticalAlign;
let attrs = '';
if (cell.colspan !== undefined && cell.colspan > 1) attrs += ` colspan="${cell.colspan}"`;
if (cell.rowspan !== undefined && cell.rowspan > 1) attrs += ` rowspan="${cell.rowspan}"`;
const styled = stringifyStyle(style);
if (styled !== undefined) attrs += ` style="${escapeAttr(styled)}"`;
return `<${tag}${attrs}>${inlinesToHtml(cell.children)}</${tag}>`;
}
// ── Image ─────────────────────────────────────────────────────────────────
function imageToHtml(block: ImageBlock): string {
let attrs = ` src="${escapeAttr(block.src)}"`;
if (block.alt !== undefined) attrs += ` alt="${escapeAttr(block.alt)}"`;
if (block.width !== undefined) attrs += ` width="${block.width}"`;
if (block.height !== undefined) attrs += ` height="${block.height}"`;
const img = `<img${attrs}${styleAttr(block)} />`;
if (block.caption !== undefined && block.caption !== '') {
return `<figure>${img}<figcaption>${escapeText(block.caption)}</figcaption></figure>`;
}
return img;
}
// ── Callout ─────────────────────────────────────────────────────────────
function calloutToHtml(block: CalloutBlock): string {
const title = block.title
? `<strong class="callout-title">${escapeText(block.title)}</strong>`
: '';
const inner = block.children.map(blockToHtml).join('');
return `<aside class="callout callout-${block.intent}"${styleAttr(block)}>${title}${inner}</aside>`;
}
// Registry dispatch: a block serialises via its spec. A block without a
// `toHtml` spec is omitted from the export (HTML is lossy by design;
// JSON is the canonical lossless format).
const spec = defaultWordsSchema.get(block.type);
return spec?.toHtml ? spec.toHtml(block, htmlSerializeContext) : '';
}
/** Stateless serialize context shared by every `spec.toHtml`. */
const htmlSerializeContext: WordsHtmlSerializeContext = {
inlinesToHtml,
blockToHtml: (block) => blockToHtml(block),
styleAttr,
blockStyle,
stringifyStyle,
escapeText,
escapeAttr
};
// ── Inlines + marks ───────────────────────────────────────────────────────

@ -15,23 +15,15 @@
import type {
WordsBlock,
CalloutBlock,
CodeBlock,
WordsDocument,
WordsIntent,
HeadingBlock,
ImageBlock,
WordsInline,
WordsLink,
ListBlock,
ListItem,
WordsMark,
ParagraphBlock,
QuoteBlock,
TableBlock,
TableCell,
WordsText
} from './types';
import { defaultWordsSchema } from './blocks';
import type { WordsMarkdownSerializeContext } from './blocks/spec';
// ── Sema intent → GFM admonition tag (P7 mapping at the I/O boundary) ────
@ -64,162 +56,20 @@ export function serializeMarkdown(doc: WordsDocument): string {
// ── Block dispatch ───────────────────────────────────────────────────────
function blockToMd(block: WordsBlock, depth: number): string {
switch (block.type) {
case 'paragraph':
return paragraphToMd(block);
case 'heading':
return headingToMd(block);
case 'quote':
return quoteToMd(block);
case 'code':
return codeToMd(block);
case 'list':
return listToMd(block, depth);
case 'table':
return tableToMd(block);
case 'image':
return imageToMd(block);
case 'divider':
return '---';
case 'callout':
return calloutToMd(block, depth);
}
}
// ── Paragraph / Heading / Quote ──────────────────────────────────────────
function paragraphToMd(block: ParagraphBlock): string {
return inlinesToMd(block.children);
}
function headingToMd(block: HeadingBlock): string {
const prefix = '#'.repeat(block.level);
return `${prefix} ${inlinesToMd(block.children)}`;
}
function quoteToMd(block: QuoteBlock): string {
const text = inlinesToMd(block.children);
// Split paragraph-by-paragraph in case the inline tree has hard
// line breaks. For now treat as single paragraph.
return text
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
}
// ── Code block ────────────────────────────────────────────────────────────
function codeToMd(block: CodeBlock): string {
const code = block.children.map((t) => t.text).join('');
const lang = block.language ?? '';
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
// ── List ─────────────────────────────────────────────────────────────────
function listToMd(block: ListBlock, depth: number): string {
const lines: string[] = [];
for (let i = 0; i < block.items.length; i++) {
lines.push(listItemToMd(block.items[i], i, block.kind, depth));
}
return lines.join('\n');
}
function listItemToMd(
item: ListItem,
index: number,
kind: ListBlock['kind'],
depth: number
): string {
const indent = ' '.repeat(depth + (item.indent ?? 0));
const marker =
kind === 'ordered'
? `${index + 1}.`
: kind === 'check'
? item.checked
? '- [x]'
: '- [ ]'
: '-';
const content = inlinesToMd(item.children);
return `${indent}${marker} ${content}`;
}
// ── Table (GFM) ──────────────────────────────────────────────────────────
function tableToMd(block: TableBlock): string {
if (block.rows.length === 0) return '';
const colCount = block.rows[0]?.cells.length ?? 0;
if (colCount === 0) return '';
const rowToMd = (row: { cells: readonly TableCell[] }): string => {
const cells = row.cells.map((c) => cellTextToMd(c));
while (cells.length < colCount) cells.push('');
return `| ${cells.join(' | ')} |`;
// Registry dispatch: a block serialises via its spec; a block without a
// `toMarkdown` spec is omitted (Markdown is lossy by design; JSON is the
// canonical lossless format).
const spec = defaultWordsSchema.get(block.type);
return spec?.toMarkdown ? spec.toMarkdown(block, makeMarkdownContext(depth)) : '';
}
/** Builds the depth-scoped Markdown serialize context for `spec.toMarkdown`. */
function makeMarkdownContext(depth: number): WordsMarkdownSerializeContext {
return {
depth,
inlinesToMd,
blockToMd: (block, d) => blockToMd(block, d)
};
const lines: string[] = [];
const headerRow = block.headerRow === true ? block.rows[0] : undefined;
const headerCells =
headerRow?.cells ?? Array.from({ length: colCount }, () => ({ children: [] as readonly WordsInline[] }));
// GFM requires a header row. If V2 doesn't declare one, emit an
// empty header. (Tables without headers aren't GFM-valid; we'd
// lose data on import otherwise.)
if (headerRow) {
lines.push(rowToMd(headerRow as { cells: readonly TableCell[] }));
} else {
lines.push(`| ${headerCells.map(() => ' ').join(' | ')} |`);
}
// Alignment row (GFM `:---` / `:---:` / `---:`)
const alignmentMarkers: string[] = [];
for (let i = 0; i < colCount; i++) {
const firstAlign = block.rows[0]?.cells[i]?.align;
alignmentMarkers.push(
firstAlign === 'center'
? ':---:'
: firstAlign === 'right'
? '---:'
: firstAlign === 'left'
? ':---'
: '---'
);
}
lines.push(`| ${alignmentMarkers.join(' | ')} |`);
// Body rows
const bodyRows = headerRow ? block.rows.slice(1) : block.rows;
for (const row of bodyRows) {
lines.push(rowToMd(row));
}
return lines.join('\n');
}
function cellTextToMd(cell: TableCell): string {
return inlinesToMd(cell.children).replace(/\|/g, '\\|');
}
// ── Image ─────────────────────────────────────────────────────────────────
function imageToMd(block: ImageBlock): string {
const alt = block.alt ?? '';
const title = block.caption ? ` "${block.caption.replace(/"/g, '\\"')}"` : '';
return `![${alt}](${block.src}${title})`;
}
// ── Callout (GFM admonition) ─────────────────────────────────────────────
function calloutToMd(block: CalloutBlock, depth: number): string {
const tag = INTENT_TO_GFM_ADMONITION[block.intent];
const lines: string[] = [`> [!${tag}]`];
if (block.title) lines.push(`> **${block.title}**`);
for (const child of block.children) {
const childMd = blockToMd(child, depth + 1);
for (const line of childMd.split('\n')) {
lines.push(`> ${line}`);
}
}
return lines.join('\n');
}
// ── Inlines + marks → MD ─────────────────────────────────────────────────

@ -85,6 +85,15 @@ export interface Block {
readonly color?: string;
/** Border. */
readonly border?: WordsBorder;
/** Font size (px). Overrides the block type's default reading size. */
readonly fontSize?: number;
/** Font family — a RAW CSS font-family value (e.g. `'serif'`,
* `'monospace'`), NOT a token. Omitted inherits the editor font. */
readonly fontFamily?: string;
/** Font weight (100–900). */
readonly fontWeight?: number;
/** Line height — a RAW unitless multiplier (e.g. `1.6`). */
readonly lineHeight?: number;
}
// ── Inline marks ──────────────────────────────────────────────────────────────
@ -226,16 +235,32 @@ export interface CalloutBlock extends Block {
// ── Block + document union ──────────────────────────────────────────────────────
export type WordsBlock =
| ParagraphBlock
| HeadingBlock
| QuoteBlock
| CodeBlock
| ListBlock
| TableBlock
| ImageBlock
| DividerBlock
| CalloutBlock;
/**
* Built-in block type → block interface. The block union is DERIVED from
* this map so it can be extended by plugins via TypeScript declaration
* merging (the same mechanism used for `SemaChannelSignatures`):
*
* declare module '@/uix/soma/components/words/engine/types' {
* interface WordsBlockMap { 'my-block': MyBlock }
* }
*
* After merging, `WordsBlock` automatically includes the plugin block, so
* code that switches over `block.type` stays exhaustive and type-safe.
* The runtime counterpart is the `WordsSchema` registry (engine/blocks).
*/
export interface WordsBlockMap {
paragraph: ParagraphBlock;
heading: HeadingBlock;
quote: QuoteBlock;
code: CodeBlock;
list: ListBlock;
table: TableBlock;
image: ImageBlock;
divider: DividerBlock;
callout: CalloutBlock;
}
export type WordsBlock = WordsBlockMap[keyof WordsBlockMap];
export type WordsBlockType = WordsBlock['type'];

@ -24,19 +24,11 @@
import {
WORDS_ALIGNS,
WORDS_BLOCK_TYPES,
WORDS_BOOLEAN_MARKS,
WORDS_BORDER_STYLES,
WORDS_VERSION,
WORDS_INTENTS,
WORDS_HEADING_LEVELS,
WORDS_IMAGE_ALIGNS,
WORDS_LIST_KINDS,
WORDS_VERTICAL_ALIGNS,
isWordsBooleanMark,
isParametricMark,
isWordsBlockType,
isWordsIntent,
type WordsBlock,
type WordsBlockType,
type WordsDocument,
@ -44,6 +36,8 @@ import {
type WordsMark,
type WordsText
} from './types';
import { defaultWordsSchema } from './blocks';
import type { WordsBlockValidateContext } from './blocks/spec';
// ── Result shape ──────────────────────────────────────────────────────────
@ -129,11 +123,13 @@ function validateBlock(
return;
}
if (!isWordsBlockType(block.type)) {
// Block-type validity is registry-driven (open-plugin): any registered
// block type — built-in or plugin — is allowed.
if (typeof block.type !== 'string' || !defaultWordsSchema.has(block.type)) {
errors.push({
path: `${path}/type`,
code: 'invalid-block-type',
message: `unknown block type ${JSON.stringify(block.type)} — allowed: ${WORDS_BLOCK_TYPES.join(', ')}`
message: `unknown block type ${JSON.stringify(block.type)} — registered: ${defaultWordsSchema.types().join(', ')}`
});
return;
}
@ -141,116 +137,38 @@ function validateBlock(
validateId(block.id, `${path}/id`, errors, ids, idPaths);
validateStyle(block, path, errors);
switch (block.type) {
case 'paragraph':
case 'heading':
case 'quote':
validateInlineChildren(block.children, `${path}/children`, errors, ids, idPaths);
if (block.type === 'heading') {
if (!(WORDS_HEADING_LEVELS as readonly number[]).includes(block.level as number)) {
errors.push({
path: `${path}/level`,
code: 'invalid-enum-value',
message: `heading.level must be 1, 2, or 3; got ${JSON.stringify(block.level)}`
});
}
}
if (block.type === 'quote' && block.cite !== undefined) {
if (typeof block.cite !== 'string' || !isParseableUrl(block.cite)) {
errors.push({
path: `${path}/cite`,
code: 'invalid-href',
message: `quote.cite must be a parseable URL string; got ${JSON.stringify(block.cite)}`
});
}
}
break;
case 'code':
validateTextOnlyChildren(block.children, `${path}/children`, errors, ids, idPaths);
if (block.language !== undefined && typeof block.language !== 'string') {
errors.push({
path: `${path}/language`,
code: 'invalid-block-shape',
message: `code.language must be a string if present`
});
}
break;
case 'list':
validateOptionalEnum(block.kind, WORDS_LIST_KINDS, `${path}/kind`, errors, true);
if (!Array.isArray(block.items)) {
errors.push({
path: `${path}/items`,
code: 'invalid-children',
message: 'list.items must be an array'
});
} else {
for (let i = 0; i < block.items.length; i++) {
validateListItem(block.items[i], `${path}/items/${i}`, errors, ids, idPaths);
}
}
break;
case 'table':
if (!Array.isArray(block.rows)) {
errors.push({
path: `${path}/rows`,
code: 'invalid-children',
message: 'table.rows must be an array'
});
} else {
for (let i = 0; i < block.rows.length; i++) {
validateTableRow(block.rows[i], `${path}/rows/${i}`, errors, ids, idPaths);
}
}
break;
case 'image':
if (typeof block.src !== 'string' || block.src.length === 0) {
errors.push({
path: `${path}/src`,
code: 'invalid-block-shape',
message: 'image.src must be a non-empty string'
});
}
validateOptionalNumber(block.width, `${path}/width`, errors);
validateOptionalNumber(block.height, `${path}/height`, errors);
validateOptionalEnum(block.imageAlign, WORDS_IMAGE_ALIGNS, `${path}/imageAlign`, errors);
break;
case 'divider':
// Nothing else — divider has no semantic content.
break;
// Type-specific validation dispatches to the block's spec. It's
// optional: a registered block without a `validate` spec is simply not
// type-checked (the common type / id / style checks above already ran).
const spec = defaultWordsSchema.get(block.type);
if (spec?.validate) spec.validate(block, makeValidateContext(path, errors, ids, idPaths));
}
case 'callout':
if (!isWordsIntent(block.intent)) {
errors.push({
path: `${path}/intent`,
code: 'invalid-eval-intent',
message: `callout.intent must be one of ${WORDS_INTENTS.join(' | ')}; got ${JSON.stringify(block.intent)}`
});
}
if (block.title !== undefined && typeof block.title !== 'string') {
errors.push({
path: `${path}/title`,
code: 'invalid-block-shape',
message: 'callout.title must be a string if present'
});
}
if (!Array.isArray(block.children)) {
errors.push({
path: `${path}/children`,
code: 'invalid-children',
message: 'callout.children must be an array of blocks'
});
} else {
for (let i = 0; i < block.children.length; i++) {
validateBlock(block.children[i], `${path}/children/${i}`, errors, ids, idPaths);
}
}
break;
}
/** Builds the per-block validate context handed to `spec.validate`,
* wrapping the shared validators so they close over the error list + id
* bookkeeping. */
function makeValidateContext(
path: string,
errors: ValidationError[],
ids: Set<string>,
idPaths: Map<string, string>
): WordsBlockValidateContext {
return {
path,
error: (p, code, message) => errors.push({ path: p, code, message }),
validateId: (id, p) => validateId(id, p, errors, ids, idPaths),
validateStyle: (node, p) => validateStyle(node, p, errors),
validateInlineChildren: (children, p) =>
validateInlineChildren(children, p, errors, ids, idPaths),
validateTextOnlyChildren: (children, p) =>
validateTextOnlyChildren(children, p, errors, ids, idPaths),
validateOptionalEnum: (value, allowed, p, required) =>
validateOptionalEnum(value, allowed, p, errors, required),
validateOptionalNumber: (value, p) => validateOptionalNumber(value, p, errors),
validateBlock: (b, p) => validateBlock(b, p, errors, ids, idPaths),
isParseableUrl,
isPlainObject
};
}
// ── Style validation (common base props) ──────────────────────────────────
@ -274,6 +192,33 @@ function validateStyle(node: Record<string, unknown>, path: string, errors: Vali
validateHexProp(node.background, `${path}/background`, 'background', errors);
validateHexProp(node.color, `${path}/color`, 'color', errors);
validateBorder(node.border, `${path}/border`, errors);
if (
node.fontSize !== undefined &&
(typeof node.fontSize !== 'number' || !Number.isFinite(node.fontSize))
) {
errors.push({
path: `${path}/fontSize`,
code: 'invalid-number',
message: `fontSize must be a finite number (pixels); got ${JSON.stringify(node.fontSize)}`
});
}
if (node.fontFamily !== undefined && typeof node.fontFamily !== 'string') {
errors.push({
path: `${path}/fontFamily`,
code: 'invalid-visual-value',
message: `fontFamily must be a string (raw CSS font-family); got ${JSON.stringify(node.fontFamily)}`
});
}
for (const key of ['fontWeight', 'lineHeight'] as const) {
const n = node[key];
if (n !== undefined && (typeof n !== 'number' || !Number.isFinite(n))) {
errors.push({
path: `${path}/${key}`,
code: 'invalid-number',
message: `${key} must be a finite number; got ${JSON.stringify(n)}`
});
}
}
if (node.shadow !== undefined) validateShadow(node.shadow, `${path}/shadow`, errors);
}
@ -544,103 +489,6 @@ function validateMark(mark: unknown, path: string, errors: ValidationError[]): v
});
}
// ── List / table inner-shape validation ──────────────────────────────────
function validateListItem(
item: unknown,
path: string,
errors: ValidationError[],
ids: Set<string>,
idPaths: Map<string, string>
): void {
if (!isPlainObject(item)) {
errors.push({ path, code: 'invalid-block-shape', message: 'list item must be an object' });
return;
}
validateId(item.id, `${path}/id`, errors, ids, idPaths);
validateInlineChildren(item.children, `${path}/children`, errors, ids, idPaths);
if (item.checked !== undefined && typeof item.checked !== 'boolean') {
errors.push({
path: `${path}/checked`,
code: 'invalid-block-shape',
message: 'list-item.checked must be boolean if present'
});
}
if (item.indent !== undefined) {
const n = item.indent as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 8) {
errors.push({
path: `${path}/indent`,
code: 'invalid-number',
message: `list-item.indent must be an integer 0..8; got ${JSON.stringify(item.indent)}`
});
}
}
}
function validateTableRow(
row: unknown,
path: string,
errors: ValidationError[],
ids: Set<string>,
idPaths: Map<string, string>
): void {
if (!isPlainObject(row)) {
errors.push({ path, code: 'invalid-block-shape', message: 'table row must be an object' });
return;
}
validateId(row.id, `${path}/id`, errors, ids, idPaths);
if (!Array.isArray(row.cells)) {
errors.push({
path: `${path}/cells`,
code: 'invalid-children',
message: 'table-row.cells must be an array'
});
return;
}
for (let i = 0; i < row.cells.length; i++) {
validateTableCell(row.cells[i], `${path}/cells/${i}`, errors, ids, idPaths);
}
validateStyle(row, path, errors);
}
function validateTableCell(
cell: unknown,
path: string,
errors: ValidationError[],
ids: Set<string>,
idPaths: Map<string, string>
): void {
if (!isPlainObject(cell)) {
errors.push({ path, code: 'invalid-block-shape', message: 'cell must be an object' });
return;
}
validateId(cell.id, `${path}/id`, errors, ids, idPaths);
validateStyle(cell, path, errors);
validateInlineChildren(cell.children, `${path}/children`, errors, ids, idPaths);
validateOptionalEnum(cell.verticalAlign, WORDS_VERTICAL_ALIGNS, `${path}/verticalAlign`, errors);
if (cell.colspan !== undefined) {
const n = cell.colspan as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
errors.push({
path: `${path}/colspan`,
code: 'invalid-number',
message: 'cell.colspan must be an integer >= 1'
});
}
}
if (cell.rowspan !== undefined) {
const n = cell.rowspan as number;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
errors.push({
path: `${path}/rowspan`,
code: 'invalid-number',
message: 'cell.rowspan must be an integer >= 1'
});
}
}
}
// ── Shared helpers ────────────────────────────────────────────────────────
function validateId(

@ -63,3 +63,8 @@ export type {
export type { WordsCommand, WordsSelectedLink, WordsTextMatch } from './engine/operations';
export type { WordsSelection } from './engine/selection';
// Block registry — the single source of truth for block types + their
// behaviour + insert-menu entries. Apps register plugin blocks here.
export { WordsSchema, defaultWordsSchema, BUILT_IN_BLOCK_SPECS } from './engine/blocks';
export type { WordsBlockSpec, WordsBlockMenuEntry, WordsBlockGroup } from './engine/blocks';

@ -20,6 +20,7 @@ import type {
WordsSelectedLink,
WordsTextMatch
} from './engine/operations';
import type { WordsBlockMenuEntry } from './engine/blocks';
export type WordsSelectionKind = 'none' | 'collapsed' | 'range';
export type WordsStatus = 'idle' | 'error';
@ -32,18 +33,12 @@ export type WordsCommitReason = 'programmatic' | 'blur' | 'button';
export type WordsExportFormat = 'json' | 'text' | 'html' | 'markdown';
export type WordsImportFormat = 'json' | 'text';
export type WordsBubbleMenuSide = 'top' | 'bottom';
export type WordsSlashCommandId =
| 'paragraph'
| 'heading-1'
| 'heading-2'
| 'heading-3'
| 'quote'
| 'code-block'
| 'table'
| 'unordered-list'
| 'ordered-list'
| 'check-list'
| 'image';
/**
* A slash-menu / insert-by-id key. Open (`string`) rather than a closed
* union so plugin block ids work too — the canonical set is whatever the
* block registry's insert-menu entries declare (`defaultWordsSchema`).
*/
export type WordsSlashCommandId = string;
export type WordsSelectionRect = {
readonly top: number;
@ -203,15 +198,12 @@ export type WordsBubbleMenuSnippetProps = {
readonly updatePosition: () => boolean;
};
export type WordsSlashCommandItem = {
readonly id: WordsSlashCommandId;
readonly label: string;
readonly description: string;
readonly command: WordsCommandName;
readonly level?: WordsHeadingLevel;
readonly listKind?: WordsListKind;
readonly keywords?: readonly string[];
};
/**
* A slash-menu item — now identical to the registry's insert-menu entry
* (`WordsBlockMenuEntry`), so the slash menu, the gutter and any plugin
* block share one shape and one source of truth.
*/
export type WordsSlashCommandItem = WordsBlockMenuEntry;
export type WordsSlashMenuSnippetProps = {
readonly open: boolean;

@ -22,7 +22,6 @@ import type {
WordsSelectionRect,
WordsSelectionKind,
WordsSlashCommandId,
WordsSlashCommandItem,
WordsStatus
} from './types';
import { comparePath, type WordsPath } from './engine/path';
@ -33,6 +32,8 @@ import {
type WordsSelection
} from './engine/selection';
import { sanitizeWordsUrl } from './engine/normalize';
import { defaultWordsSchema } from './engine/blocks';
import type { WordsBlockMenuEntry } from './engine/blocks';
import {
actionFromBeforeInput,
actionFromPaste,
@ -1278,6 +1279,7 @@ export class WordsProvider {
'[data-words-block-inserter]',
'[data-words-block-inserter-button]',
'[data-words-bubble-menu]',
'[data-words-bubble-turn-into]',
'[data-words-code-language-panel]',
'[data-words-code-language-picker]',
'[data-words-drawer]',
@ -1285,6 +1287,7 @@ export class WordsProvider {
'[data-words-heading-picker]',
'[data-words-image-float-bar]',
'[data-words-link-editor]',
'[data-words-settings-gutter]',
'[data-words-slash-menu]',
'[data-words-toolbar]',
'[data-words-toolbar-family]',
@ -1529,7 +1532,13 @@ export class WordsProvider {
else if (id === 'heading-3') block = createHeading(3);
else if (id === 'quote') block = createQuote();
else if (id === 'code-block') block = createCodeBlock();
else return false;
else {
// Other registered types (callout / divider / plugin blocks):
// insert the registry-created block of this id.
const entry = defaultWordsSchema.insertable().find((e) => e.id === id);
if (!entry) return false;
block = entry.create();
}
const changed = this.applyCommand({
type: 'insertBlock',
@ -1566,7 +1575,14 @@ export class WordsProvider {
const alt = altRaw?.trim() ? altRaw.trim() : undefined;
command = { type: 'insertImage', src, alt };
} else {
command = slashCommandToWordsCommand(item);
command = slashInsertionCommand(item.id);
if (!command) {
// No in-place transform (callout / divider / plugin blocks) —
// insert the entry's created block after the current line. The
// `/query` text is removed by the deleteRange in the batch below.
const blockIndex = (context.path[0] ?? 0) + 1;
command = { type: 'insertBlock', blockIndex, block: item.create() };
}
}
if (!command) return false;
@ -2903,91 +2919,9 @@ function normalizedSinglePastedUrl(text: string): string | undefined {
return sanitizeWordsUrl(normalized);
}
const DEFAULT_SLASH_COMMANDS = [
{
id: 'paragraph',
label: 'Paragraph',
description: 'Plain text block',
command: 'paragraph',
keywords: ['text', 'body', 'normal']
},
{
id: 'heading-1',
label: 'Heading 1',
description: 'Large section heading',
command: 'heading',
level: 1,
keywords: ['h1', 'title']
},
{
id: 'heading-2',
label: 'Heading 2',
description: 'Medium section heading',
command: 'heading',
level: 2,
keywords: ['h2', 'subtitle']
},
{
id: 'heading-3',
label: 'Heading 3',
description: 'Small section heading',
command: 'heading',
level: 3,
keywords: ['h3']
},
{
id: 'quote',
label: 'Quote',
description: 'Blockquote callout',
command: 'quote',
keywords: ['blockquote', 'cite']
},
{
id: 'code-block',
label: 'Code block',
description: 'Preformatted code',
command: 'code-block',
keywords: ['code', 'pre', 'snippet']
},
{
id: 'table',
label: 'Table',
description: '2 by 2 editable grid',
command: 'insert-table',
keywords: ['grid', 'cells', 'rows', 'columns']
},
{
id: 'unordered-list',
label: 'Bulleted list',
description: 'List with bullets',
command: 'unordered-list',
listKind: 'unordered',
keywords: ['ul', 'bullet', 'list']
},
{
id: 'ordered-list',
label: 'Numbered list',
description: 'List with numbers',
command: 'ordered-list',
listKind: 'ordered',
keywords: ['ol', 'number', 'list']
},
{
id: 'check-list',
label: 'Checklist',
description: 'Track tasks inline',
command: 'check-list',
listKind: 'check',
keywords: ['todo', 'task', 'check']
},
{
id: 'image',
label: 'Image',
description: 'Insert an image from a URL',
command: 'insert-image',
keywords: ['img', 'picture', 'photo', 'figure']
}
] as const satisfies readonly WordsSlashCommandItem[];
// The slash catalog now lives in the block registry (engine/blocks) — the
// same source the gutter reads. `filterSlashCommandItems` derives the menu
// from `defaultWordsSchema.insertable()`.
/**
* "Empty text block" check used by the drawer's modes derivation
@ -3022,25 +2956,38 @@ function isEmptyEditableTextBlock(block: unknown): boolean {
return only?.type === 'text' && (only.text ?? '') === '';
}
function filterSlashCommandItems(query: string): readonly WordsSlashCommandItem[] {
// The slash menu's items ARE the registry's insertable entries — one source
// of truth shared with the gutter, so the two lists cannot drift and plugin
// blocks appear automatically.
function filterSlashCommandItems(query: string): readonly WordsBlockMenuEntry[] {
const all = defaultWordsSchema.insertable();
const normalized = query.trim().toLowerCase();
if (!normalized) return DEFAULT_SLASH_COMMANDS;
return DEFAULT_SLASH_COMMANDS.filter((item) => {
const haystack = [item.label, item.description, item.command, ...(item.keywords ?? [])]
if (!normalized) return all;
return all.filter((item) => {
const haystack = [item.label, item.description ?? '', ...(item.keywords ?? [])]
.join(' ')
.toLowerCase();
return haystack.includes(normalized);
});
}
function slashCommandToWordsCommand(item: WordsSlashCommandItem): WordsCommand | undefined {
switch (item.id) {
/**
* Resolves a slash entry id to its in-place insertion command. Built-in
* text blocks transform the current line (`setBlock`); list / table use
* their structural commands. Other ids — image, callout, divider and any
* plugin block — return undefined; the caller inserts the entry's `create()`
* block instead.
*/
function slashInsertionCommand(id: string): WordsCommand | undefined {
switch (id) {
case 'paragraph':
return { type: 'setBlock', block: 'paragraph' };
case 'heading-1':
return { type: 'setBlock', block: 'heading', level: 1 };
case 'heading-2':
return { type: 'setBlock', block: 'heading', level: 2 };
case 'heading-3':
return { type: 'setBlock', block: 'heading', level: item.level ?? 1 };
return { type: 'setBlock', block: 'heading', level: 3 };
case 'quote':
return { type: 'setBlock', block: 'quote' };
case 'code-block':
@ -3053,10 +3000,7 @@ function slashCommandToWordsCommand(item: WordsSlashCommandItem): WordsCommand |
return { type: 'toggleList', kind: 'ordered' };
case 'check-list':
return { type: 'toggleList', kind: 'check' };
case 'image':
// Image needs a runtime prompt for the URL — handled imperatively
// in commitSlashCommand. Returning undefined here lets that path
// short-circuit if the prompt resolves to an empty URL.
default:
return undefined;
}
}

@ -1,25 +1,202 @@
<script lang="ts">
// Words editor demo — temporarily stubbed.
//
// The editor's visual layer (eidos chrome + render) is being rebuilt
// on the new base-`Block` model:
// F1 ✅ engine reworked (base Block + extension, no v2, no sema)
// F2 ⏳ render by components (Block base + one per type)
// F3 ⏳ chrome (left gutter menu + insert submenu, right ⚙ → drawer,
// bubble toolbar) — all eidos components
// F4 ⏳ this demo, rebuilt against the new component
//
// The engine (soma/components/words) is migrated and green; only the
// eidos presentation is pending, so there is nothing to mount yet.
// Words editor — F2 foundation: the editor surface renders + edits on
// the new base-`Block` model. Chrome (gutters / bubble toolbar /
// settings drawer) lands in F3.
import Words from '$uix/eidos/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
import type { WordsDocument } from '$soma/components/words';
const INSPECTOR_MODES: readonly { v: WordsInspectorMode; label: string }[] = [
{ v: 'none', label: 'None' },
{ v: 'sidebar', label: 'Sidebar' },
{ v: 'drawer', label: 'Drawer' },
{ v: 'popover', label: 'Popover' }
];
let inspector = $state<WordsInspectorMode>('sidebar');
let value = $state<WordsDocument>({
version: '2.0.0',
children: [
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Words' }] },
{
type: 'paragraph',
children: [
{ type: 'text', text: 'A clean editor rebuilt on a base ' },
{ type: 'text', text: 'Block', marks: ['code'] },
{ type: 'text', text: ' model — common style props (margins, colors, spacing, borders, align) shared by every block and ' },
{ type: 'text', text: 'extended', marks: ['italic'] },
{ type: 'text', text: ' per type.' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Inline marks: ' },
{ type: 'text', text: 'bold', marks: ['bold'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'italic', marks: ['italic'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'underline', marks: ['underline'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'strike', marks: ['strike'] },
{ type: 'text', text: ', and a ' },
{ type: 'link', href: 'https://tiptap.dev', target: '_blank', children: [{ type: 'text', text: 'link' }] },
{ type: 'text', text: '.' }
]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Quote & code' }]
},
{
type: 'quote',
children: [{ type: 'text', text: 'Design is not just what it looks like and feels like. Design is how it works.' }]
},
{
type: 'code',
language: 'ts',
children: [{ type: 'text', text: "const greet = (name: string) => `Hello, ${name}!`\nconsole.log(greet('Words'))" }]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Lists' }]
},
{
type: 'list',
kind: 'unordered',
items: [
{ children: [{ type: 'text', text: 'Bulleted item one' }] },
{ children: [{ type: 'text', text: 'Bulleted item two' }] }
]
},
{
type: 'list',
kind: 'check',
items: [
{ checked: true, children: [{ type: 'text', text: 'Engine reworked (base Block)' }] },
{ checked: false, children: [{ type: 'text', text: 'Chrome (gutters + bubble + drawer)' }] }
]
},
{
type: 'callout',
intent: 'affirm',
title: 'Foundation',
children: [
{ type: 'paragraph', children: [{ type: 'text', text: 'This surface renders directly from the model. Editing round-trips through the engine.' }] }
]
},
{ type: 'divider' },
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Table' }]
},
{
type: 'table',
headerRow: true,
rows: [
{
cells: [
{ children: [{ type: 'text', text: 'Phase' }] },
{ children: [{ type: 'text', text: 'Status' }] }
]
},
{
cells: [
{ children: [{ type: 'text', text: 'Engine' }] },
{ children: [{ type: 'text', text: 'Done' }] }
]
},
{
cells: [
{ children: [{ type: 'text', text: 'Visual' }] },
{ children: [{ type: 'text', text: 'In progress' }] }
]
}
]
}
]
});
</script>
<header>
<p data-uix-eyebrow>— editors · words</p>
<h1>Words</h1>
<p>
The Words editor is being rebuilt on a clean base-<code>Block</code> model with eidos-native
chrome. The engine is migrated and green; the visual layer (render-by-components + gutters +
bubble toolbar + drawer) is in progress. The interactive demo returns once the new component
lands.
Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface
renders and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.
</p>
</header>
<div class="demo-controls">
<span class="demo-controls-label">Inspector</span>
<ToggleGroup
type="single"
size="xs"
variant="outline"
attached
value={[inspector]}
onValueChange={(v: string[]) => {
if (v[0]) inspector = v[0] as WordsInspectorMode;
}}
aria-label="Inspector mode"
>
{#each INSPECTOR_MODES as m (m.v)}
<ToggleGroup.Item value={m.v}>{m.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<div class="demo-canvas">
<Words bind:value {inspector} placeholder="Write something…" />
</div>
<details class="demo-trace">
<summary>document value</summary>
<pre>{JSON.stringify(value, null, 2)}</pre>
</details>
<style>
.demo-canvas {
display: flex;
justify-content: center;
padding: var(--space-8) var(--space-4);
/* Theme-aware surface — `--color-surface-sunken` does not exist, so
the old fallback painted a light panel on the dark theme. */
background: var(--color-surface-default);
border-radius: var(--radius-lg);
}
.demo-canvas :global([data-words]) {
inline-size: 100%;
max-inline-size: 62rem;
}
.demo-controls {
display: flex;
align-items: center;
gap: var(--space-3);
margin-block-end: var(--space-3);
}
.demo-controls-label {
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
}
.demo-trace {
margin-block-start: var(--space-6);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.demo-trace summary {
cursor: pointer;
user-select: none;
}
.demo-trace pre {
max-block-size: 24rem;
overflow: auto;
padding: var(--space-3);
background: var(--color-surface-overlay);
border-radius: var(--radius-md);
}
</style>

Loading…
Cancel
Save

Powered by TurnKey Linux.