docs(words): sign the export-only serialization decision (no roundtrip)

Closes the long-open "export HTML roundtrip?" question as export-only — the
architecturally-correct, industry-aligned choice (TipTap/ProseMirror, Lexical,
Slate, Notion all keep JSON as the canonical lossless persistence format and
treat HTML/MD as projections).

- README "Serializacion e intercambio" doctrine: JSON = truth; HTML/MD =
  export-only projections; import = JSON + plain text. Explains the
  state-complete-HTML trap that JSON avoids, why markdown is the most lossy
  (GFM cells are inline-only — block cells from P5m can't roundtrip), and the
  conditional `parseHtml` per-block path (parseDOM/importDOM pattern) IF
  paste-from-external is ever needed — as clipboard interop, never persistence.
- continue.md: the "NO firmada" note flipped to FIRMADA with the rationale.

Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 30192bb853
commit aac80f7630

@ -14,6 +14,7 @@ component CSS` pasa.
### Lo cerrado y verificado este sprint ### Lo cerrado y verificado este sprint
**Ronda 1 — 7 ítems (todos DONE + verificados):** **Ronda 1 — 7 ítems (todos DONE + verificados):**
1. Quitado el dropdown "Add block" del panel de BLOQUE Columns (vive solo 1. Quitado el dropdown "Add block" del panel de BLOQUE Columns (vive solo
en el inspector específico de columna). `dropdownsInColumnsBlockPanel: 0`. en el inspector específico de columna). `dropdownsInColumnsBlockPanel: 0`.
2. Validación de ancho de columna (`isValidColumnWidth` + `commitColumnWidth` 2. Validación de ancho de columna (`isValidColumnWidth` + `commitColumnWidth`
@ -23,13 +24,14 @@ component CSS` pasa.
barrel `soma/components/words/exports.ts`): `javascript:` rechazado. barrel `soma/components/words/exports.ts`): `javascript:` rechazado.
4. Shift+Enter soft break: ya funcionaba (`\n` + `white-space: pre-wrap`); 4. Shift+Enter soft break: ya funcionaba (`\n` + `white-space: pre-wrap`);
verificado con keystrokes reales `MARKA⇧⏎MARKB` → mismo bloque. verificado con keystrokes reales `MARKA⇧⏎MARKB` → mismo bloque.
5+7. Rediseño cabecera inspector: mark de acento por profundidad 5+7. Rediseño cabecera inspector: mark de acento por profundidad
(primary/affirm/loss/risk) + wash + divisor + breadcrumb estilado (primary/affirm/loss/risk) + wash + divisor + breadcrumb estilado
(`[data-words-inspector-breadcrumb]`, single-row scroll). (`[data-words-inspector-breadcrumb]`, single-row scroll).
6. Botones add/delete normalizados a icon-only (Trash2 delete, flechas 5. Botones add/delete normalizados a icon-only (Trash2 delete, flechas
Arrow{Up,Down,Left,Right} para table-ops, Plus para add). Arrow{Up,Down,Left,Right} para table-ops, Plus para add).
**Ronda 2 — chrome systémico (DONE + verificado):** **Ronda 2 — chrome systémico (DONE + verificado):**
- Inputs unificados: un solo frame bordeado (text inputs + NumberFields), - Inputs unificados: un solo frame bordeado (text inputs + NumberFields),
un solo focus-ring de acento (NO más `outline` inset sobre el texto, NO un solo focus-ring de acento (NO más `outline` inset sobre el texto, NO
más mezcla ghost/borde-negro). Specificity `[data-number-field][data-size]` más mezcla ghost/borde-negro). Specificity `[data-number-field][data-size]`
@ -45,20 +47,21 @@ component CSS` pasa.
del popover). del popover).
**Ronda 3 — defectos de controles (DONE + verificado):** **Ronda 3 — defectos de controles (DONE + verificado):**
- ToggleGroups con **valor por defecto seleccionado** (uno siempre activo): - ToggleGroups con **valor por defecto seleccionado** (uno siempre activo):
Fuente=`default`, Grosor=`400`/Normal, Alinear=`left`, Borde=`none`. El Fuente=`default`, Grosor=`400`/Normal, Alinear=`left`, Borde=`none`. El
default se almacena como `undefined` en el modelo (limpio). Verificado: default se almacena como `undefined` en el modelo (limpio). Verificado:
`Predeterminada/Normal/Izquierda/Ninguno` seleccionados al activar bloque. `Predeterminada/Normal/Izquierda/Ninguno` seleccionados al activar bloque.
- Borde gana opción **`none`** (chip que limpia `border: undefined`). Nuevo - Borde gana opción **`none`** (chip que limpia `border: undefined`). Nuevo
type `BorderChipValue = 'none' | BorderStyleValue`. Langs `BORDER_NONE` type `BorderChipValue = 'none' | BorderStyleValue`. Langs `BORDER_NONE`
+ bundle `border.none` ({en:'None', es:'Ninguno'}). - bundle `border.none` ({en:'None', es:'Ninguno'}).
- Image "Ancho total" → **Switch** (era Toggle con texto on/off). - Image "Ancho total" → **Switch** (era Toggle con texto on/off).
- **Bug del radio de esquina arreglado**: el ring de selección era un - **Bug del radio de esquina arreglado**: el ring de selección era un
`outline`/`box-shadow` que HEREDA el `border-radius` del bloque, así que `outline`/`box-shadow` que HEREDA el `border-radius` del bloque, así que
redondear el bloque a 40px redondeaba el ring. Ahora el ring es un redondear el bloque a 40px redondeaba el ring. Ahora el ring es un
**pseudo-elemento `::after`** con radio FIJO (4px), `inset: -3px`, **pseudo-elemento `::after`** con radio FIJO (4px), `inset: -3px`,
`pointer-events: none`. Verificado: `blockRadius=40px, ringRadius=4px, `pointer-events: none`. Verificado: `blockRadius=40px, ringRadius=4px,
decoupled=true`. decoupled=true`.
- **Slider-only** para "Grosor del borde" + "Radio de esquina": nuevo prop - **Slider-only** para "Grosor del borde" + "Radio de esquina": nuevo prop
`sliderOnly` en `WordsNumRow` que oculta el NumberField y muestra un chip `sliderOnly` en `WordsNumRow` que oculta el NumberField y muestra un chip
de valor read-only (el slider + input era redundante). Verificado: de valor read-only (el slider + input era redundante). Verificado:
@ -108,8 +111,8 @@ Fecha de corte: **2026-05-29**. Rama: `active-uix`.
**V1 del editor Words ELIMINADO. V2 es ahora el único motor.** **V1 del editor Words ELIMINADO. V2 es ahora el único motor.**
La sesión arrancó con una auditoría del componente Words La sesión arrancó con una auditoría del componente Words
(`codex-full-audit.md` en la raíz) y la pregunta del usuario: *"elimina (`codex-full-audit.md` en la raíz) y la pregunta del usuario: _"elimina
v1 y continua v2, ¿qué sentido tiene v1?"*. Decisión firmada y v1 y continua v2, ¿qué sentido tiene v1?"_. Decisión firmada y
ejecutada de cabo a rabo (F1 → F6). Ya no hay coexistencia V1/V2 ni ejecutada de cabo a rabo (F1 → F6). Ya no hay coexistencia V1/V2 ni
migrador: el modelo de documento público es `WordsDocumentV2` y punto. migrador: el modelo de documento público es `WordsDocumentV2` y punto.
@ -173,28 +176,35 @@ inverso fiel). El import sólo acepta **JSON** (`JSON.parse` +
- **Tabla**: `{ type:'table', rows, headerRow?, headerCol? }`; - **Tabla**: `{ type:'table', rows, headerRow?, headerCol? }`;
fila `{ cells, visual? }`; celda `{ children, align?, verticalAlign?, fila `{ cells, visual? }`; celda `{ children, align?, verticalAlign?,
colspan?, rowspan?, visual? }`. **Celda usa `align`**, no `textAlign`. colspan?, rowspan?, visual? }`. **Celda usa `align`**, no `textAlign`.
- **Lista**: `{ type:'list', kind, items }`; item `{ children, checked?, - **Lista**: `{ type:'list', kind, items }`; item `{ children, checked?,
indent? }`. **`items`**, no `children`. Sin `type:'list-item'`. indent? }`. **`items`**, no `children`. Sin `type:'list-item'`.
- **Bloques con alineación**: paragraph/heading/quote llevan - **Bloques con alineación**: paragraph/heading/quote llevan
`textAlign?` (a nivel bloque). `textAlign?` (a nivel bloque).
## ⚠️ Decisión consecuente NO firmada — export HTML ## ✅ Decisión FIRMADA (2026-06-02) — export-only (no roundtrip)
`serializeHtmlV2` emite **HTML semántico limpio**, no un formato `serializeHtml` / `serializeMarkdown` emiten proyecciones de export (HTML
roundtrip-lossless. Como V2 no tiene parser HTML, HTML y Markdown son semántico limpio / GFM), **no** un formato re-importable, y así se queda:
export-only. Si el usuario quería export HTML roundtrip-friendly (re- **JSON es el formato canónico lossless; HTML/MD son export-only.** Misma
importable), es un encargo aparte. **Pendiente de confirmar con él.** postura que TipTap/ProseMirror, Lexical, Slate y Notion — el modelo JSON
persiste, HTML/MD son proyecciones. El import acepta sólo JSON (vía
`validateWordsDocument`) + texto plano (`parseWordsPlainText`). Si en el
futuro se necesita _pegar_ HTML externo, se añade un `parseHtml` por-bloque
(patrón `parseDOM`/`importDOM` de ProseMirror/Lexical) como interop
best-effort de portapapeles — **nunca** como persistencia; markdown import no
merece la pena (es lo más lossy). Doctrina completa + tabla de formatos en
`src/uix/soma/components/words/README.md` → "Serializacion e intercambio".
## Verificación al cierre (todo verde) ## Verificación al cierre (todo verde)
| Gate | Status | Notas | | Gate | Status | Notas |
|---|---|---| | ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------ |
| `npm run check` | **0 errors** / 5168 files | 26 warnings pre-existentes, ninguno en archivos tocados | | `npm run check` | **0 errors** / 5168 files | 26 warnings pre-existentes, ninguno en archivos tocados |
| `npx vitest run src/uix/soma/components/words` | **455/456** | el único fallo es el flake de `sema-parity` por timeout 5s bajo carga paralela | | `npx vitest run src/uix/soma/components/words` | **455/456** | el único fallo es el flake de `sema-parity` por timeout 5s bajo carga paralela |
| `sema-parity` aislado | **6/6 en 362ms** | confirma que el flake es de infra, no regresión | | `sema-parity` aislado | **6/6 en 362ms** | confirma que el flake es de infra, no regresión |
| `npm run morfo:vocabulary` | **exit 0** | sólo WARN suaves (vocabularios noveles legítimos) | | `npm run morfo:vocabulary` | **exit 0** | sólo WARN suaves (vocabularios noveles legítimos) |
| Browser smoke `/uix/components/words` | **OK** | mount limpio, 0 console errors | | Browser smoke `/uix/components/words` | **OK** | mount limpio, 0 console errors |
**Smoke detallado**: el doc sample V2 renderiza (tabla 2×2 con **Smoke detallado**: el doc sample V2 renderiza (tabla 2×2 con
`headerRow`, 4 list-items incl. el indentado); export markdown emite `headerRow`, 4 list-items incl. el indentado); export markdown emite
@ -231,7 +241,7 @@ Borrados los selectores CSS muertos de `words.css`
los `[data-words-cell-header]` huérfanos — los headers se renderizan como los `[data-words-cell-header]` huérfanos — los headers se renderizan como
`<th>`). README actualizado a la realidad V2 (tabla lista solo formato `<th>`). README actualizado a la realidad V2 (tabla lista solo formato
vivo; HTML/MD export-only; import JSON + texto). `ARCHITECTURE_PROPOSAL.md` vivo; HTML/MD export-only; import JSON + texto). `ARCHITECTURE_PROPOSAL.md`
intacto: documenta la *decisión* de eliminarlos, no features stale. intacto: documenta la _decisión_ de eliminarlos, no features stale.
Verificado en browser (tabla 2×2 renderiza, `<th>` conserva su tinte vía Verificado en browser (tabla 2×2 renderiza, `<th>` conserva su tinte vía
`:where(th)`). Commit `4a0661a5`. `:where(th)`). Commit `4a0661a5`.
@ -330,6 +340,7 @@ ok desde el primer click. El problema era exclusivamente el flow
imperativo del inserter (focus + insert + selección). imperativo del inserter (focus + insert + selección).
**Lo que se verificó end-to-end en navegador (Chrome):** **Lo que se verificó end-to-end en navegador (Chrome):**
- Click `+` en columna vacía → dropdown abre, primer-item auto-focus. - Click `+` en columna vacía → dropdown abre, primer-item auto-focus.
- Pick "Heading" → "Title" stub aparece + se selecciona. - Pick "Heading" → "Title" stub aparece + se selecciona.
- Tipear inmediatamente → "Title" se reemplaza con el texto tecleado - Tipear inmediatamente → "Title" se reemplaza con el texto tecleado
@ -338,6 +349,7 @@ imperativo del inserter (focus + insert + selección).
- Doc title y resto del doc intactos — typing va al lugar correcto. - Doc title y resto del doc intactos — typing va al lugar correcto.
**Pieces en disco:** **Pieces en disco:**
- `engine/operations/insert-block-types.ts:381` — - `engine/operations/insert-block-types.ts:381` —
`insertBlockInColumn` op con container-path correcto. `insertBlockInColumn` op con container-path correcto.
- `engine/operations/commands.ts` — command + dispatcher. - `engine/operations/commands.ts` — command + dispatcher.
@ -347,7 +359,7 @@ imperativo del inserter (focus + insert + selección).
- `soma/components/words/types.ts` — `restoreCaret` añadido al - `soma/components/words/types.ts` — `restoreCaret` añadido al
snippet API type. snippet API type.
- `words-column-inserter.svelte` — flow `close → tick → focus → - `words-column-inserter.svelte` — flow `close → tick → focus →
applyCommand → setTimeout(setSelection(intended))`. applyCommand → setTimeout(setSelection(intended))`.
### Lo que SÍ quedó cabledado en columns (sesión 2026-05-31) ### Lo que SÍ quedó cabledado en columns (sesión 2026-05-31)
@ -355,7 +367,7 @@ imperativo del inserter (focus + insert + selección).
inserta un bloque en una columna desde un trigger sin caret (el inserta un bloque en una columna desde un trigger sin caret (el
`+` overlay). Devuelve `{document, selection, activeMarks}` en una `+` overlay). Devuelve `{document, selection, activeMarks}` en una
transacción. Verbatim de la doctrina Tiptap `chain().focus() transacción. Verbatim de la doctrina Tiptap `chain().focus()
.insertContent().run()` adaptada a nuestro stack. .insertContent().run()` adaptada a nuestro stack.
- **Command `insertBlockInColumn`** + dispatcher en - **Command `insertBlockInColumn`** + dispatcher en
`engine/operations/commands.ts`. `engine/operations/commands.ts`.
- **`applyCommandWithOptions` ahora dispara `restoreDomSelection`** - **`applyCommandWithOptions` ahora dispara `restoreDomSelection`**

@ -30,6 +30,46 @@ empezar hasta cerrar contrato Morfo y alcance V1.
- Serializacion JSON/HTML/Markdown. - Serializacion JSON/HTML/Markdown.
- Integracion con logger, clipboard, format y events de ActiveUIX. - Integracion con logger, clipboard, format y events de ActiveUIX.
## Serializacion e intercambio — doctrina V2 (FIRMADA 2026-06-02)
**JSON es la verdad. HTML y Markdown son proyecciones de export-only.**
| Formato | Export | Import | Lossless |
| ------------ | ---------------------------------- | ----------------------------------------- | ----------------- |
| **JSON** | `exportContent('json')` | `importContent` + `validateWordsDocument` | si (es el modelo) |
| Texto plano | si | `parseWordsPlainText` | no (solo texto) |
| **HTML** | `serializeHtml` (semantico limpio) | — | n/a |
| **Markdown** | `serializeMarkdown` (GFM) | — | n/a |
### Por que export-only (no roundtrip)
Es la misma postura que TipTap/ProseMirror, Lexical, Slate y Notion: **el modelo
JSON es el formato canonico de persistencia (lossless); HTML/MD son
proyecciones**. Hacer del HTML _limpio_ un formato de persistencia lossless es la
trampa que todos evitan — el HTML semantico no puede cargar cada visual prop /
metadato de bloque sin ensuciarlo de `data-*` (el approach "state-complete" de
TipTap, donde cada nodo declara `toDOM` + `parseDOM` y serializa TODOS sus attrs
al HTML). JSON esquiva el problema entero; por eso es canonico en los cuatro.
Consecuencias deliberadas:
- `serializeHtml` emite HTML semantico limpio (sin `data-words-*`), pensado para
lectura / copia / preview, **no** para re-importar.
- `serializeMarkdown` es lo mas lossy: sin color ni visual props, y las celdas
GFM son inline-only — desde P5m las celdas llevan bloques, que markdown no
puede representar (se aplanan con `<br>`). MD es proyeccion, no roundtrip.
- El import solo acepta **JSON** (via `validateWordsDocument`, que protege los
`$derived` de un doc invalido) y **texto plano** (`parseWordsPlainText`).
### Si algun dia hace falta _pegar_ HTML externo
No se construye "roundtrip de persistencia". Se anade un `parseHtml`
**por-bloque, simetrico al `toHtml` de cada spec** (el patron `parseDOM` /
`importDOM` de ProseMirror/Lexical), declarado como **interop best-effort de
portapapeles**, explicitamente lossy fuera del schema. La persistencia sigue
siendo JSON; el HTML solo entra por el paste. Markdown import: no merece la pena
(es lo mas lossy).
## Submodulos propuestos ## Submodulos propuestos
```text ```text

Loading…
Cancel
Save

Powered by TurnKey Linux.