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
**Ronda 1 — 7 ítems (todos DONE + verificados):**
1. Quitado el dropdown "Add block" del panel de BLOQUE Columns (vive solo
en el inspector específico de columna). `dropdownsInColumnsBlockPanel: 0`.
2. Validación de ancho de columna (`isValidColumnWidth` + `commitColumnWidth`
@ -23,13 +24,14 @@ component CSS` pasa.
barrel `soma/components/words/exports.ts`): `javascript:` rechazado.
4. Shift+Enter soft break: ya funcionaba (`\n` + `white-space: pre-wrap`);
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
(`[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).
**Ronda 2 — chrome systémico (DONE + verificado):**
- Inputs unificados: un solo frame bordeado (text inputs + NumberFields),
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]`
@ -45,20 +47,21 @@ component CSS` pasa.
del popover).
**Ronda 3 — defectos de controles (DONE + verificado):**
- ToggleGroups con **valor por defecto seleccionado** (uno siempre activo):
Fuente=`default`, Grosor=`400`/Normal, Alinear=`left`, Borde=`none`. El
default se almacena como `undefined` en el modelo (limpio). Verificado:
`Predeterminada/Normal/Izquierda/Ninguno` seleccionados al activar bloque.
- Borde gana opción **`none`** (chip que limpia `border: undefined`). Nuevo
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).
- **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
redondear el bloque a 40px redondeaba el ring. Ahora el ring es un
**pseudo-elemento `::after`** con radio FIJO (4px), `inset: -3px`,
`pointer-events: none`. Verificado: `blockRadius=40px, ringRadius=4px,
decoupled=true`.
decoupled=true`.
- **Slider-only** para "Grosor del borde" + "Radio de esquina": nuevo prop
`sliderOnly` en `WordsNumRow` que oculta el NumberField y muestra un chip
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.**
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
v1 y continua v2, ¿qué sentido tiene v1?"*. Decisión firmada y
(`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
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.
@ -173,28 +176,35 @@ inverso fiel). El import sólo acepta **JSON** (`JSON.parse` +
- **Tabla**: `{ type:'table', rows, headerRow?, headerCol? }`;
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?,
indent? }`. **`items`**, no `children`. Sin `type:'list-item'`.
indent? }`. **`items`**, no `children`. Sin `type:'list-item'`.
- **Bloques con alineación**: paragraph/heading/quote llevan
`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
roundtrip-lossless. Como V2 no tiene parser HTML, HTML y Markdown son
export-only. Si el usuario quería export HTML roundtrip-friendly (re-
importable), es un encargo aparte. **Pendiente de confirmar con él.**
`serializeHtml` / `serializeMarkdown` emiten proyecciones de export (HTML
semántico limpio / GFM), **no** un formato re-importable, y así se queda:
**JSON es el formato canónico lossless; HTML/MD son export-only.** Misma
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)
| Gate | Status | Notas |
|---|---|---|
| `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 |
| `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) |
| Browser smoke `/uix/components/words` | **OK** | mount limpio, 0 console errors |
| Gate | Status | Notas |
| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------ |
| `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 |
| `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) |
| Browser smoke `/uix/components/words` | **OK** | mount limpio, 0 console errors |
**Smoke detallado**: el doc sample V2 renderiza (tabla 2×2 con
`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
`<th>`). README actualizado a la realidad V2 (tabla lista solo formato
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
`: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).
**Lo que se verificó end-to-end en navegador (Chrome):**
- Click `+` en columna vacía → dropdown abre, primer-item auto-focus.
- Pick "Heading" → "Title" stub aparece + se selecciona.
- 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.
**Pieces en disco:**
- `engine/operations/insert-block-types.ts:381` —
`insertBlockInColumn` op con container-path correcto.
- `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
snippet API type.
- `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)
@ -355,7 +367,7 @@ imperativo del inserter (focus + insert + selección).
inserta un bloque en una columna desde un trigger sin caret (el
`+` overlay). Devuelve `{document, selection, activeMarks}` en una
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
`engine/operations/commands.ts`.
- **`applyCommandWithOptions` ahora dispara `restoreDomSelection`**

@ -30,6 +30,46 @@ empezar hasta cerrar contrato Morfo y alcance V1.
- Serializacion JSON/HTML/Markdown.
- 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
```text

Loading…
Cancel
Save

Powered by TurnKey Linux.