@ -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,23 +176,30 @@ 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 |
@ -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` **