# Continue tomorrow Fecha de corte: **2026-06-03**. Rama: `active-uix`. ## Sesión 2026-06-03 — Words chrome + crash tabla-en-columna Sprint de UX del editor (gutter / menubar / active-block / drag-drop) por feedback iterativo, cerrado con un crash diagnosticado en Chrome real. Todo verificado en navegador (`localhost:5173/uix/components/words`). ### Chrome del editor (commits locales del sprint, verificados) - **Drag-drop de bloques (HTML5 nativo)** vía el grip del gutter (`words-block-drag.svelte` + `words-block-drag-type.ts`). `dropAt(clientY)` calcula el seam; `to = from < n ? n-1 : n` → `applyCommand({ type:'moveBlockTo' })`. - **Menubar** (`words-menubar.svelte`): undo/redo directos, block-style DropdownMenu, marcas (B/I/U/S/code), listas, alineación, menú Insert, toggle inspector, ToggleGroup Edit|Preview. El editor es un richtext gestionado SIEMPRE por un form (como un ``); en preview el Provider es `readonly`. - **Active block por id materializado + rail del gutter** (`words-active-rail.svelte`, `words-activate.svelte`): el activo se fija SOLO en pointerdown sobre un bloque; auto-seed al primer bloque al cargar; el rail (barra fija en la gutter) abarca el bloque TOP-LEVEL (una tabla entera, no la celda clicada). Frame-relative, un color, sin desplazamiento. En preview se apaga (limpia el stamp). - **Borrar bloque** (gutter + inspector): captura el id del bloque ANTERIOR *antes* del delete (leer `document.children` después daba el bloque equivocado) → `setActiveBlock(prevId)`. - **Modelo confirmado = rich text plano** (`children[]`), NO page-builder (`sections→rows→columns→blocks`). Decisión del usuario. ### Crash arreglado — tabla en columna (3 capas) Reportado en Chrome real: _"no puede seleccionar una columna para insertar un bloque, no puedo editar ninguna celda"_. Stack: `insertBlockInColumn → getActiveMarksForSelection → collectBlockText → TypeError: Cannot read properties of undefined (reading 'type')`. **Causa raíz**: el inserter de bloque-en-columna del inspector (`words-inspector.svelte > insertIntoActiveColumn`) crea el bloque con `spec.menu[0].create()`. La factory del slash-menu de TABLA (`engine/blocks/built-ins.ts > tableSpec`) sembraba las celdas con `emptyText()` = nodos **inline** `{type:'text'}`. Desde P5m las celdas llevan **bloques**, así que `normalizeBlock` (sin `default` para `'text'`) devolvía `undefined` → `cell.children = [undefined]` → el mark-walk petaba al leer `.type`, abortaba el comando sin capturar y dejaba el editor sin poder editar. (El toolbar/slash "Table" NO usan esa factory — delegan a `insertTable` → `createEmptyTable`, que ya sembraba celdas válidas; por eso solo petaba por el inserter de columna.) **3 capas** (commits `76b0d1c8`, `4a8f6331` + normalize sin commitear al abrir este hand-off): 1. `built-ins.ts` — `tableSpec.create()` envuelve cada celda en un párrafo (`{children:[{type:'paragraph',children:emptyText()}]}`), igual que el doc demo y la factory de callout. 2. `normalize.ts > normalizeTableCells` — self-heal: filtra slots `null`/`undefined` ANTES del map + descarta lo que `normalizeBlock` no normaliza + re-siembra un párrafo si la celda queda vacía. Repara docs ya corruptos. 3. `selection-walkers.ts > collectBlockText` — guard `if (!block) return` al inicio (defensa del walk). **Verificación (navegador, motor cargado vía HMR)**: 4 formas de celda corrupta (`[undefined]`, mixto undefined+inline, `[]`, inline) → normalize NO peta + se reparan a `[paragraph]` + el mark-walk devuelve limpio. Insertar tabla por el menú + tipear en una celda → "CELL OK" persiste, consola limpia. Engine tests 430/430. `npm run check`: solo el error pre-existente de grafito (`+page.svelte:361`, NO mío). ### Pendiente para mañana (feedback del usuario al cierre) 1. **El menú Insert de la barra debe ser como el de insertar-bloque de columna** — mismo set de bloques + misma UX. Hoy el toolbar Insert (`words-menubar.svelte`) y el inserter de columna divergen. 2. **El menú `+` (inserter de columna) solo muestra 3 bloques** (Image/Table/Divider). `columnInsertableSpecs` en `words-inspector.svelte` está demasiado filtrado; debe ofrecer también párrafo/heading/quote/code/lista/callout. 3. **La imagen sale con el `prompt()` HTML nativo** — el toolbar (`words-provider > insertBlockOfType('image')`) usa `window.prompt` para la URL. Debe insertar el **placeholder inline** (como el slash menu: `insertImage({ src:'' })`) y dejar que el inspector pida la URL. --- ## Sesión 2026-06-01 — Words inspector UX (sprint en curso) Sprint de pulido del **inspector del editor Words** (sidebar derecha) por feedback iterativo del usuario. Todo verificado en navegador real (`localhost:5173/uix/components/words`, Chrome tab). `npm run check`: 1 error pre-existente (`engine/operations/visual.ts:147`, baseline, NO mío). Words soma 452/452. Contract test `keeps raw color values out of 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` en `words-block-panel.svelte`): nº flex / longitud CSS / fr / keywords; garbage → `[data-invalid]`, no commit. 3. Validación de URL de imagen vía `sanitizeWordsUrl` (export añadido al 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 (primary/affirm/loss/risk) + wash + divisor + breadcrumb estilado (`[data-words-inspector-breadcrumb]`, single-row scroll). 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]` (0,3,0) gana al recipe sin tocar el componente. - NumberField rowhead ensanchado a 6.75rem (decimales visibles); inline rows pinneados a 6.75rem (Ancho/Alto misma anchura). - Clamp de formato: `WordsNumRow` reenvía `max` → line-height clampa a 2.5, font-size 12–40. Adiós "Interlineado 191". - Secciones separadas: divisor hairline + headers uppercase en el acordeón (`[data-accordion-item] + [data-accordion-item]`). - Spacing link sincroniza al vincular (adopta el mayor de los dos lados). - Colores raw → tokens `--color-*-solid` (theme-aware, sobreviven al portal 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'}). - 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`. - **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: `hasNumberField:false, hasValueChip:true, hasSlider:true`. ### Archivos tocados este sprint - `src/uix/eidos/components/words/words-inspector.svelte` — header markup, 4 ToggleGroups con default + border `none`, sliderOnly en 2 numrows. - `src/uix/eidos/components/words/words-block-panel.svelte` — validaciones (width/gap/src), table-ops icon-only, image full-width → Switch. - `src/uix/eidos/components/words/words-num-row.svelte` — `max` forward + prop `sliderOnly`. - `src/uix/eidos/components/words/words-spacing-row.svelte` — link sincroniza. - `src/uix/eidos/components/words/words.css` — chrome unificado, breadcrumb, ring pseudo-elemento, tokens de color, anchuras inline. - `src/uix/eidos/components/words/langs-inspector.ts` — `BORDER_NONE` + bundle. - `src/uix/soma/components/words/exports.ts` — export `sanitizeWordsUrl`. - (sesión previa, ya en el árbol) `words-activate.svelte`, `engine/block-tree.ts` (active-block por id materializado). ### Pendiente / próximos pasos (donde retomar) - El usuario sigue iterando sobre el diseño del inspector — esperar su siguiente ronda de feedback visual. El patrón establecido: leer el estado EXACTO del control antes de editar (los Edit fallan por reformateo de prettier entre llamadas; usar substrings únicos cortos). - Defecto conocido NO abordado: nada pendiente reportado al cierre. - `npm run check` baseline error en `visual.ts:147` (cast WordsNode → Record) sigue ahí — no es de este sprint. ### Nota de proceso El harness de tools devolvió output vacío/corrupto varias veces esta sesión (batches paralelos grandes se cancelan). Mitigación: editar de uno en uno, verificar con `npm run check` (verdad de tipos) + navegador (verdad de runtime) cuando un Read/Bash dé resultado dudoso. --- ## Sesión 2026-05-29 (anterior) Fecha de corte: **2026-05-29**. Rama: `active-uix`. ## TL;DR de la sesión **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 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. ### Decisiones firmadas (vinculantes) - **D1 = "V2 puro, sin legacy"**: borrados `engine/document.ts` + `engine/migrate-v2.ts`. **SIN** importador de retrocompatibilidad. La API pública pasa de `WordsDocument` → `WordsDocumentV2`. Los docs persistidos en V1 **dejan de cargar** (ruptura aceptada por el usuario). - **D2 = "Aceptar el drop"**: eliminados por completo los controles / props / commands / atributos morfo de tabla `striped`, `compact` y `cell-tone`. NO se re-mapean ni se re-portan. ## Qué se borró (V1) y a dónde se movió (V2) **Borrado — motor V1 (`engine/*.ts`):** `commands.ts`, `document.ts`, `dom-selection.ts`, `engine.test.ts`, `find-replace.ts`, `history.ts`, `index.ts` (barrel V1), `input.ts`, `migrate-v2.ts` (+test), `operations.ts`, `render.ts`, `serialize-html.ts`, `serialize-json.ts`, `serialize-markdown.ts`, `serialize-text.ts`. **Borrado — árbol `extensions/` entero:** extension-registry (+test), extension-types, `image/*`, `table/*`. El modelo V2 no usa el sistema de extensiones; tabla/imagen/divider/callout son bloques nativos del union `WordsBlockV2`. **El motor V2 vive SOLO en `engine/operations-v2/`** + un puñado de `.ts` en la raíz de `engine/` (types-v2, validate-v2, normalize, render-v2, serialize-html-v2, serialize-markdown-v2, sema-parity, code-highlight, path, selection, dom). Esta tanda portó al namespace V2 lo que faltaba de V1: `commands`, `history`, `input`, `find-replace`, `render-dom`, `serialize-text`, `extra-ops`, `insert-block-types`, `markdown` (input-rule shortcuts), `factories`, `dom-helpers`, `dom-selection` — además de cerrar R4A.2→R4A.8 (text / delete / marks-ops / paragraph-ops / delete-ops / block-format / list-ops / link-ops / table-ops / selection-walkers / inline-split). **NO existe parser HTML / Markdown / JSON-schema en V2.** Las serializaciones HTML y Markdown son **export-only** (lossy → sin inverso fiel). El import sólo acepta **JSON** (`JSON.parse` + `validateWordsDocument`) y **texto plano** (`parseWordsPlainText`). ## Superficie pública V2 (lo que consume cualquier app) - Tipos: `WordsDocumentV2`, `WordsBlockV2`, `WordsInlineV2`, `WordsSelection`, `WordsExportFormat`, `WordsImportFormat` — todos re-exportados desde `$soma/components/words`. - `WORDS_DOCUMENT_VERSION_V2 = '2.0.0'` desde `engine/types-v2`. - Provider (`words-provider.svelte.ts`): `importContent` (text + json), `exportContent` (json / text / html / markdown), `plainText` derivado vía `renderWordsPlainText`. - Serializers: `serializeHtmlV2(doc)`, `serializeMarkdownV2(doc)`, `renderWordsPlainText(doc)`, `renderWordsDocumentV2`, `renderWordsDomHtml` / `Pretty`. - Comando central: `applyWordsCommand(state, WordsCommandV2)` en `operations-v2/commands.ts`. ## Forma de los contenedores V2 (recordatorio — NO hay discriminador `type` en sub-nodos) - **Tabla**: `{ type:'table', rows, headerRow?, headerCol? }`; fila `{ cells, visual? }`; celda `{ children, align?, verticalAlign?, colspan?, rowspan?, visual? }`. **Celda usa `align`**, no `textAlign`. - **Lista**: `{ type:'list', kind, items }`; item `{ children, checked?, indent? }`. **`items`**, no `children`. Sin `type:'list-item'`. - **Bloques con alineación**: paragraph/heading/quote llevan `textAlign?` (a nivel bloque). ## ✅ Decisión FIRMADA (2026-06-02) — export-only (no roundtrip) `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 | **Smoke detallado**: el doc sample V2 renderiza (tabla 2×2 con `headerRow`, 4 list-items incl. el indentado); export markdown emite tabla GFM válida; import texto round-trip ("imported text"); import JSON válido round-trip a través del gate ("imported json"); import JSON inválido (versión mala) → "invalid json" y **deja el doc intacto** (la razón de añadir el gate `validateWordsDocument`: un doc malo asignado al `value` bindeado rompería los `$derived`). ## Consumidores re-cableados a V2 - `words-provider.svelte.ts` + `words-provider.svelte.test.ts` - `exports.ts` + `types.ts` (superficie pública) - `morfo/components/words.ts` (drop de atributos striped/compact/tone) - eidos: `words.svelte`, `words-drawer.svelte`, `words-block-handle`, `words-block-inserter`, `words-command-icon`, `words-heading-picker`, `words-toolbar-family-menu`, `words.css`, `types.ts` - soma `components/words.svelte` - `test/words-content-harness.svelte` (importaba el barrel V1 borrado; reescrito a `operations-v2` + `types-v2` + `WordsDocumentV2`) - `web/routes/uix/components/words/+page.svelte` (último consumidor; imports V2, importFormats → `['json','text']`, docs sample/empty/long a forma V2, walkers `readPlainText`/`blockToPlainText`/`readInlineText` sobre el union V2, `exportWordsDocument` + `importDraftDocument` reescritos, prosa de la API-tab limpiada de striped/tone) ## Cerrado en la continuación (2026-05-29) ### ✅ Limpieza de huérfanos del drop D2 Borrados los selectores CSS muertos de `words.css` (`[data-command='table-cell-tone-*']`, `table[data-words-table-striped]`, `[data-words-table-compact]`, `[data-words-cell-tone]` + focus-within, y los `[data-words-cell-header]` huérfanos — los headers se renderizan como ``). 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. Verificado en browser (tabla 2×2 renderiza, `` conserva su tinte vía `:where(th)`). Commit `4a0661a5`. ### ✅ R5 — POLISH-1b (image visuals) El panel de imagen del drawer (`eidos/components/words/words-drawer.svelte > imagePanel`) ya tenía radius slider + shadow/border toggles. Añadido lo que faltaba: **shadow blur slider**, **shadow color picker**, **border color picker** y **background color picker**, vía un snippet reutilizable `imageColorField` (presets + `` + clear opcional), mismo patrón que el color de texto del panel format. Todos despachan `setBlockVisual` → `block.visual.*` → `visualToStyle` → inline style en el `
`. Verificado en browser: el panel renderiza los 4 controles nuevos; click en swatch de Background actualiza el figure (`background-color:#22c55e`) preservando radius/border/shadow (shallow-merge correcto). `npm run check` 0 errors. ### ✅ P1 — Drag handle sobre `` (NO reproduce) Investigado: NO reproduce en el código actual. El "rail" al que se refería el bug fue ELIMINADO en el rediseño de gutter (commit `5450d5f7`, "rail out"), posterior al sprint EV donde se reportó. `measureFromEl` (`words-block-handle.svelte`) ancla el handle a `contentRect.left + 8` — block-type-agnostic. Verificado en browser: sobre code y sobre paragraph el gutter cae en `left: 350.333` idéntico (solo difiere `top`). No hay nada que arreglar; bug obsoleto. ### ✅ P1 — Heading inline level change (h1 ↔ h2 ↔ h3) Añadido al Block panel del drawer (`eidos/components/words/words-drawer.svelte > blockPanel`): cuando `currentBlock === 'heading'`, un ToggleGroup H1/H2/H3 (value = `currentHeadingLevel`) despacha `applyCommand({ type: 'setBlock', block: 'heading', level })`. `runCommand` / `WordsCommandName` no tiene id por-nivel (`heading-1/2/3` son ids de slash / insert-menu), por eso se usa el comando estructurado `setBlock` con el nivel numérico (`as const` mantiene cada `level` como literal 1|2|3). Verificado en browser: caret en heading → el panel muestra "Heading level"; el toggle refleja el nivel actual; el click convierte bidireccional (H1→H3→H2) y el toggle se actualiza. `npm run check` 0 errors. ## Rework del editor Words (2026-05-29 #2 — redirect del usuario) El usuario redirigió a un nuevo bloque de trabajo sobre el editor. Plan en 3 pasos (acordado: overlays a eidos POR FASES, con revisión entre cada una): - **Paso 1 — gutter izquierdo (✅ hecho):** quitado el botón "+" del gutter; "insertar bloque" integrado en el menú del drag handle como "Insert below" (vía `act('insertBelow')`). Gutter convertido en banda silver estrecha: `--_words-gutter-size` 3.5rem→2.5rem, fondo `--words-rail-bg` #ededed (muy suave) + borde derecho 1px `--words-rail-border` #cfcfcf (un poco más fuerte), pintado como `linear-gradient` en `[data-words-content]` (solo background, no toca el contenteditable). Tokens del recipe actualizados + `generate:eidos-css`. El inserter de SEAM (`words-block-inserter.svelte`, el "+" entre bloques) NO se tocó — es otro overlay; preguntar si también debe irse. - **Paso 2 — props de bloque (#3, pendiente):** añadir `color` (texto) a `BlockVisual` + per-type subsets + `visualToStyle` + validador; y sección común (margen/espaciado/fondo/color-texto) en el block panel. Construir esos color controls con **eidos ColorPicker** y migrar los `` nativos existentes (foreground/background marks + panel de imagen) a ColorPicker. (Cubre #3 + la parte "color pickers → eidos" de #4-fase1.) - **Paso 3 — menú del drag → eidos (#4-fase1 resto) + #4-fase2:** migrar el menú del block-handle a eidos Menu/Popover; luego link editor / find / status a Popover+Tooltip. ## Pendiente para mañana ### P0 — RESUELTO 2026-05-31 (column inserter end-to-end + path encoding) Lo que ayer documenté como "selection sync con paths nested" resultó ser TRES bugs distintos, todos cerrados: 1. **Path 5-segmentos en `insertBlockInColumn`** — mi op pasaba `[colsIdx, colIdx, innerIdx, 0]` (= el text node) a `pointFromInlineTextOffset`, que espera la ruta al CONTENEDOR. La función añade su propio inline-index al final → producía `[12, 0, 0, 0, 0]` (5 segmentos) en vez del canónico `[12, 0, 0, 0]`. Fix: pasar el container path (`[12, 0, 0]` para heading, `[12, 0, 0, 0]` para list-item) — la función ya añade el `, inlineIdx` al final. 2. **Selectionchange echo sobrescribía el modelo** — `api.focus()` movía el caret DOM a inicio del doc (default browser behavior), disparaba selectionchange → syncSelectionFromDom → updateSelection sobrescribía la selección que el op había puesto. Fix: capturar `intended = api.selection` inmediatamente después del op y re-aplicarla vía `setTimeout(0)` AFTER del echo handler. 3. **DropdownMenu focus trap interceptaba `api.focus()`** — Fix: `trapFocus={false}` en `DropdownMenu.Content` desactiva el `focusin` capture-listener. Edición DOM↔model con paths nested SÍ funciona correctamente — el typing dentro de columnas, callouts, listas en columnas, etc., todo 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 (Notion-style replace-stub-on-first-keystroke). - Inspector cambia automáticamente al panel correcto del nuevo bloque. - 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. - `words-provider.svelte.ts:700-733` — `applyCommandWithOptions` dispara restoreDomSelection tras cambio de selección. - `words-provider.svelte.ts:1274` — `api.restoreCaret()` expuesto. - `soma/components/words/types.ts` — `restoreCaret` añadido al snippet API type. - `words-column-inserter.svelte` — flow `close → tick → focus → applyCommand → setTimeout(setSelection(intended))`. ### Lo que SÍ quedó cabledado en columns (sesión 2026-05-31) - **`insertBlockInColumn` engine op** (`insert-block-types.ts`) — 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. - **Command `insertBlockInColumn`** + dispatcher en `engine/operations/commands.ts`. - **`applyCommandWithOptions` ahora dispara `restoreDomSelection`** cuando la selección cambia (excepto batches de typing). Era el hueco: comandos invocados desde fuera del editor (overlay buttons, drag-drop, column inserter) actualizaban el modelo pero el caret DOM se quedaba donde el usuario clickeó por última vez. Fix en `words-provider.svelte.ts:700-733`. - **`WordsProviderSnippetProps` sincronizado** con la implementación: `selectedBlockPath`, `selectAtomicBlock(idx, blockPath?)`, `setSelection`. Antes faltaban y rompían el typecheck del eidos. - **`words-column-inserter.svelte` simplificado** — patrón: `applyCommand` + close dropdown + `onCloseAutoFocus preventDefault` (bloquea el return-to-trigger que producía Space-repeat). Safety timeout 250ms para el `busy` lock — el botón nunca se queda muerto por una excepción intermedia. - **`web/routes/uix/components/words/+page.svelte`** — Columns block añadido al doc inicial para que el test fixture esté siempre a mano. **Lo que NO consigué cerrar:** el typing dentro de columnas. Ese es el P0 de arriba — el inserter está listo para cuando el path encoding nested funcione. ### P2 — Heredados - **POLISH-3**: topbar del demo (breadcrumb, Edit/Preview, Saved). - **WORDS-DRAGDROP-SOMA**: migrar drag-drop a soma `DragDrop.Provider` en vez de HTML5 nativo (intento previo falló — requiere reestructurar el render del engine). ## Notas de infra - `sema-parity.test.ts` flakea por timeout de 5s **sólo bajo carga paralela** (full-suite). Aislado pasa 6/6 en <500ms. Si molesta, subir el `testTimeout` de ese archivo; no es regresión. - `.claude/scheduled_tasks.lock` es un artefacto transitorio — NO commitear.