diff --git a/src/uix/audit-codex-full-1.md b/src/uix/audit-codex-full-1.md deleted file mode 100644 index c3687ba4b..000000000 --- a/src/uix/audit-codex-full-1.md +++ /dev/null @@ -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. diff --git a/src/uix/audit-uix-kimi-1.md b/src/uix/audit-uix-kimi-1.md deleted file mode 100644 index 60c118dae..000000000 --- a/src/uix/audit-uix-kimi-1.md +++ /dev/null @@ -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.* diff --git a/src/uix/eidos/components/color-picker/color-picker.css b/src/uix/eidos/components/color-picker/color-picker.css index 9a800a802..672da8807 100644 --- a/src/uix/eidos/components/color-picker/color-picker.css +++ b/src/uix/eidos/components/color-picker/color-picker.css @@ -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 diff --git a/src/uix/eidos/components/color-picker/color-picker.svelte b/src/uix/eidos/components/color-picker/color-picker.svelte index 9272ded17..2f71a4bbf 100644 --- a/src/uix/eidos/components/color-picker/color-picker.svelte +++ b/src/uix/eidos/components/color-picker/color-picker.svelte @@ -10,7 +10,7 @@ color = 'primary', value = $bindable(), placeholder = $bindable(), - format = $bindable(), + format = $bindable('hex'), open = $bindable(false), children, ...rest diff --git a/src/uix/eidos/components/words/index.ts b/src/uix/eidos/components/words/index.ts new file mode 100644 index 000000000..0d56edf47 --- /dev/null +++ b/src/uix/eidos/components/words/index.ts @@ -0,0 +1,9 @@ +// Eidos `` — 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'; +// +export { default } from './words.svelte'; + +export type { WordsProps, WordsSize } from './types'; diff --git a/src/uix/eidos/components/words/types.ts b/src/uix/eidos/components/words/types.ts new file mode 100644 index 000000000..f8b104134 --- /dev/null +++ b/src/uix/eidos/components/words/types.ts @@ -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; + +/** + * 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 `` 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 & { + /** Sizing scale — drives padding, height and reading size. @default 'md' */ + size?: ResponsiveProp; + /** Where the block inspector lives. @default 'sidebar' */ + inspector?: WordsInspectorMode; +}; diff --git a/src/uix/eidos/components/words/words-block-gutter.svelte b/src/uix/eidos/components/words/words-block-gutter.svelte new file mode 100644 index 000000000..f9799df4e --- /dev/null +++ b/src/uix/eidos/components/words/words-block-gutter.svelte @@ -0,0 +1,225 @@ + + +{#if (show || menuOpen) && rect} + {#if inGutter || menuOpen} +
+ {/if} +
+ + + {#snippet icon()} + + {/snippet} + Block actions + + + Move up + Move down + Duplicate + + + Insert below + + {#each inserts as ins (ins.id)} + insert(ins.create())}>{ins.label} + {/each} + + + + Delete + + +
+{/if} diff --git a/src/uix/eidos/components/words/words-bubble.svelte b/src/uix/eidos/components/words/words-bubble.svelte new file mode 100644 index 000000000..6f3a8224b --- /dev/null +++ b/src/uix/eidos/components/words/words-bubble.svelte @@ -0,0 +1,257 @@ + + + + {#snippet children(bubble)} + {#if bubble.open} + {#if linkOpen} +
+ + + +
+ {:else} + + + {blockLabel} + {#snippet endIcon()}{/snippet} + + + {#each turnInto as item (item.id)} + + {item.label} + {#if item.active}{/if} + + {/each} + + + + + + {#each MARKS as mark (mark.command)} + {@const Glyph = mark.icon} + + {#snippet child({ props })} + + {/snippet} + + {/each} + + + + + {#if api.selectedLink} + + {#snippet child({ props })} + + {/snippet} + + {/if} + {/if} + {/if} + {/snippet} +
diff --git a/src/uix/eidos/components/words/words-color-row.svelte b/src/uix/eidos/components/words/words-color-row.svelte new file mode 100644 index 000000000..4ceefab24 --- /dev/null +++ b/src/uix/eidos/components/words/words-color-row.svelte @@ -0,0 +1,110 @@ + + +
+ +
+ {label} + {#if current} + + {/if} +
+ + { + // `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)} + > + + + + + + + + + + + {#each presets as c (c)} + + + + + {/each} + + + + + + + + + + +
diff --git a/src/uix/eidos/components/words/words-inspector.svelte b/src/uix/eidos/components/words/words-inspector.svelte new file mode 100644 index 000000000..6e97241d1 --- /dev/null +++ b/src/uix/eidos/components/words/words-inspector.svelte @@ -0,0 +1,317 @@ + + +{#snippet numRow(label: string, value: number, min: number, max: number, step: number, onChange: (n: number) => void)} +
+
+ {label} + {value} +
+ onChange(v[0] ?? min)} + > + + + +
+{/snippet} + +
+ {#if activeBlock} + {@const block = activeBlock} +
{blockTitle(block)}
+ + + + + + + Typography + + +
+
+ Font + edit({ fontFamily: v[0] ? v[0] : undefined })} + aria-label="Font family" + > + {#each FONTS as f (f.v)} + {f.label} + {/each} + +
+ {@render numRow('Font size', block.fontSize ?? 16, 12, 40, 1, (n) => + edit({ fontSize: n }) + )} +
+ Weight + + edit({ fontWeight: v[0] ? Number(v[0]) : undefined })} + aria-label="Font weight" + > + {#each WEIGHTS as w (w.v)} + {w.label} + {/each} + +
+ {@render numRow('Line height', block.lineHeight ?? 1.6, 1, 2.5, 0.1, (n) => + edit({ lineHeight: Math.round(n * 10) / 10 }) + )} +
+
+
+ + + + Color + + +
+ edit({ color: hex })} + /> + + edit({ background: hex })} + /> + +
+
+
+ + + + Layout + + +
+
+ Align + edit({ align: v[0] as AlignValue | undefined })} + aria-label="Align" + > + {#each ALIGNS as a (a.v)} + {a.label} + {/each} + +
+
+
+
+ + + + Spacing + + +
+ {@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 } }) + )} +
+
+
+ + + + Border + + +
+
+ Style + { + 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)} + {b.label} + {/each} + +
+ {@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 } }) + )} +
+
+
+ + +
+ + + + {:else} +

Select a block to edit its style.

+ {/if} +
diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css new file mode 100644 index 000000000..a655dc4bc --- /dev/null +++ b/src/uix/eidos/components/words/words.css @@ -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); +} diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte new file mode 100644 index 000000000..78c31f002 --- /dev/null +++ b/src/uix/eidos/components/words/words.svelte @@ -0,0 +1,129 @@ + + +{#if mounted} + + {#snippet children(api)} + + + + {#if contentEl} + + {/if} + + {#if inspector === 'sidebar'} + + {:else if inspector === 'drawer'} + + + {:else if inspector === 'popover'} +
+ + + {#snippet child({ props }: { props: Record })} + + {/snippet} + + + + + + + +
+ {/if} + {/snippet} +
+{:else} +
+{/if} diff --git a/src/uix/sema/refactorizacion_codex.md b/src/uix/sema/refactorizacion_codex.md deleted file mode 100644 index 11f075ded..000000000 --- a/src/uix/sema/refactorizacion_codex.md +++ /dev/null @@ -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 - intents: Record -} - -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. diff --git a/src/uix/soma/components/color-picker/README.md b/src/uix/soma/components/color-picker/README.md index 21b01c1f3..1efc4fcdb 100644 --- a/src/uix/soma/components/color-picker/README.md +++ b/src/uix/soma/components/color-picker/README.md @@ -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 `` 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 `` wrapper rendered the soma +`` 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 + +`` 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` 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(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 + { + // 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 `` with `` +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`. diff --git a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts index ee3db9eff..3cde73cba 100644 --- a/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts +++ b/src/uix/soma/components/color-picker/color-picker-provider.svelte.ts @@ -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 }); } diff --git a/src/uix/soma/components/color-picker/components/color-picker.svelte b/src/uix/soma/components/color-picker/components/color-picker.svelte index f02a8251b..31c8be03e 100644 --- a/src/uix/soma/components/color-picker/components/color-picker.svelte +++ b/src/uix/soma/components/color-picker/components/color-picker.svelte @@ -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, diff --git a/src/uix/soma/components/words/engine/blocks/built-ins.ts b/src/uix/soma/components/words/engine/blocks/built-ins.ts new file mode 100644 index 000000000..bf527ff51 --- /dev/null +++ b/src/uix/soma/components/words/engine/blocks/built-ins.ts @@ -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/.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 = { + 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 `${ctx.inlinesToHtml(b.children)}

`; + }, + 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 `${ctx.inlinesToHtml(b.children)}`; + }, + 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 `${ctx.inlinesToHtml(b.children)}`; + }, + 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 `${code}`; + }, + 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}`; + }, + 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 = `${cells}`; + 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 `${cells}`; + }) + .join(''); + return `${thead}${body}`; + }, + 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 = ``; + if (b.caption !== undefined && b.caption !== '') { + return `
${img}
${ctx.escapeText(b.caption)}
`; + } + 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 ``; + }, + 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 + ? `${ctx.escapeText(b.title)}` + : ''; + const inner = b.children.map((child) => ctx.blockToHtml(child)).join(''); + return ``; + }, + 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 `${inner}`; + } + return `${inner}`; +} + +function cellToHtml(cell: TableCell, isHeader: boolean, ctx: WordsHtmlSerializeContext): string { + const tag = isHeader ? 'th' : 'td'; + const style: Record = { ...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)}`; +} + +// ── 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); diff --git a/src/uix/soma/components/words/engine/blocks/index.ts b/src/uix/soma/components/words/engine/blocks/index.ts new file mode 100644 index 000000000..9c78db793 --- /dev/null +++ b/src/uix/soma/components/words/engine/blocks/index.ts @@ -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'; diff --git a/src/uix/soma/components/words/engine/blocks/plugin.test.ts b/src/uix/soma/components/words/engine/blocks/plugin.test.ts new file mode 100644 index 000000000..6a7cf3f87 --- /dev/null +++ b/src/uix/soma/components/words/engine/blocks/plugin.test.ts @@ -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 `${ctx.escapeText(b.label)}`; + }, + 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('Hi'); + expect(serializeMarkdown(badgeDoc('Hi'))).toBe('`Hi`'); + }); +}); diff --git a/src/uix/soma/components/words/engine/blocks/registry.ts b/src/uix/soma/components/words/engine/blocks/registry.ts new file mode 100644 index 000000000..2864da7a5 --- /dev/null +++ b/src/uix/soma/components/words/engine/blocks/registry.ts @@ -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(); + + /** 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(); diff --git a/src/uix/soma/components/words/engine/blocks/spec.ts b/src/uix/soma/components/words/engine/blocks/spec.ts new file mode 100644 index 000000000..c87d7b0e6 --- /dev/null +++ b/src/uix/soma/components/words/engine/blocks/spec.ts @@ -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; +} + +/** + * 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`) — 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, 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 + ): 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>; + /** Serialise a CSS declarations object to an inline `style` string. */ + stringifyStyle(style: Readonly>): 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, 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; +} + +/** + * 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>; + /** Serialise a CSS declarations object to an inline `style` string. */ + stringifyStyle(style: Readonly>): 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; +} diff --git a/src/uix/soma/components/words/engine/dom.ts b/src/uix/soma/components/words/engine/dom.ts index 2f4e66c5b..01288a98a 100644 --- a/src/uix/soma/components/words/engine/dom.ts +++ b/src/uix/soma/components/words/engine/dom.ts @@ -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' diff --git a/src/uix/soma/components/words/engine/operations/visual.ts b/src/uix/soma/components/words/engine/operations/visual.ts index 5036ade3e..c7d81e21d 100644 --- a/src/uix/soma/components/words/engine/operations/visual.ts +++ b/src/uix/soma/components/words/engine/operations/visual.ts @@ -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, diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts index 9bb9eb6ac..38022ff52 100644 --- a/src/uix/soma/components/words/engine/render.ts +++ b/src/uix/soma/components/words/engine/render.ts @@ -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; diff --git a/src/uix/soma/components/words/engine/serialize-html.ts b/src/uix/soma/components/words/engine/serialize-html.ts index 9c95cacca..7293d9a55 100644 --- a/src/uix/soma/components/words/engine/serialize-html.ts +++ b/src/uix/soma/components/words/engine/serialize-html.ts @@ -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 ``; - case 'callout': - return calloutToHtml(block); - } -} - -// ── Paragraph / Heading / Quote / Code ─────────────────────────────────── - -function paragraphToHtml(block: ParagraphBlock): string { - return `${inlinesToHtml(block.children)}

`; -} - -function headingToHtml(block: HeadingBlock): string { - return `${inlinesToHtml(block.children)}`; -} - -function quoteToHtml(block: QuoteBlock): string { - const cite = block.cite !== undefined ? ` cite="${escapeAttr(block.cite)}"` : ''; - return `${inlinesToHtml(block.children)}`; -} - -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 `${code}`; -} - -// ── 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}`; -} - -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 `${inner}`; - } - return `${inner}`; -} - -// ── 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 = `${cells}`; - bodyRows = block.rows.slice(1); - } - - const body = bodyRows - .map((row) => { - const cells = row.cells - .map((cell, i) => cellToHtml(cell, headerCol && i === 0)) - .join(''); - return `${cells}`; - }) - .join(''); - - return `${thead}${body}`; -} - -function cellToHtml(cell: TableCell, isHeader: boolean): string { - const tag = isHeader ? 'th' : 'td'; - const style: Record = { ...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)}`; -} - -// ── 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 = ``; - if (block.caption !== undefined && block.caption !== '') { - return `
${img}
${escapeText(block.caption)}
`; - } - return img; -} - -// ── Callout ───────────────────────────────────────────────────────────── - -function calloutToHtml(block: CalloutBlock): string { - const title = block.title - ? `${escapeText(block.title)}` - : ''; - const inner = block.children.map(blockToHtml).join(''); - return ``; -} + // 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 ─────────────────────────────────────────────────────── diff --git a/src/uix/soma/components/words/engine/serialize-markdown.ts b/src/uix/soma/components/words/engine/serialize-markdown.ts index 04f469056..a0168f573 100644 --- a/src/uix/soma/components/words/engine/serialize-markdown.ts +++ b/src/uix/soma/components/words/engine/serialize-markdown.ts @@ -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 ───────────────────────────────────────────────── diff --git a/src/uix/soma/components/words/engine/types.ts b/src/uix/soma/components/words/engine/types.ts index e832312d7..74e537140 100644 --- a/src/uix/soma/components/words/engine/types.ts +++ b/src/uix/soma/components/words/engine/types.ts @@ -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']; diff --git a/src/uix/soma/components/words/engine/validate.ts b/src/uix/soma/components/words/engine/validate.ts index 3d3886da0..ded1f92a1 100644 --- a/src/uix/soma/components/words/engine/validate.ts +++ b/src/uix/soma/components/words/engine/validate.ts @@ -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, + idPaths: Map +): 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, 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, - idPaths: Map -): 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, - idPaths: Map -): 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, - idPaths: Map -): 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( diff --git a/src/uix/soma/components/words/exports.ts b/src/uix/soma/components/words/exports.ts index 5e808d38a..7c26501cc 100644 --- a/src/uix/soma/components/words/exports.ts +++ b/src/uix/soma/components/words/exports.ts @@ -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'; diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts index 026132314..c89a269eb 100644 --- a/src/uix/soma/components/words/types.ts +++ b/src/uix/soma/components/words/types.ts @@ -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; diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts index 3504c3209..8b80f31e5 100644 --- a/src/uix/soma/components/words/words-provider.svelte.ts +++ b/src/uix/soma/components/words/words-provider.svelte.ts @@ -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; } } diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte index 1bf0bbe3a..6ae846db2 100644 --- a/web/routes/uix/components/words/+page.svelte +++ b/web/routes/uix/components/words/+page.svelte @@ -1,25 +1,202 @@

— editors · words

Words

- The Words editor is being rebuilt on a clean base-Block 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-Block model. This is the F2 foundation: the editor surface + renders and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.

+ +
+ Inspector + { + if (v[0]) inspector = v[0] as WordsInspectorMode; + }} + aria-label="Inspector mode" + > + {#each INSPECTOR_MODES as m (m.v)} + {m.label} + {/each} + +
+ +
+ +
+ +
+ document value +
{JSON.stringify(value, null, 2)}
+
+ +