palabras: retirar el chrome words deprecado (FW)

Decision de usuario 2026-07-14: words (chrome eidos + demo) quedo
deprecado en pos de palabras; existia como referencia historica.

- Migradas las 4 piezas que palabras importaba (git mv, historia
  conservada, sin shims): words-activate -> palabras-activate,
  words-block-drag -> palabras-block-drag, words-block-drag-type ->
  palabras-block-drag-type, words-image-file -> palabras-image-file;
  imports actualizados a rutas locales.
- Borrados src/uix/eidos/components/words/** (wrapper, menubar, bubble,
  inspector, gutter, rail, words.css, langs-menu/inspector, AUDIT
  visual) y la demo web/routes/uix/components/words/.
- Sidebar: fuera el link Words, entra Palabras (la demo era
  inalcanzable desde la nav).
- Podado el bloque `words:` de lib/recipes/base.ts + regeneracion
  (generated/base.css: -84 lineas, solo bajas --words-*).
- Docs del track: referencias al AUDIT visual de words apuntan a git
  history.

NO muere: soma/components/words (motor + provider + tests), morfo y
langs de words — el fundamento vivo de palabras.

Verificacion: engine 493/493 - guards eidos (recipe-css-contract +
api-contract + visual-attrs) 35/35 - grep eidos/components/words en
codigo = 0 - navegador (server propio): demo palabras monta sin
errores de consola, activate fija bloque, grip abre panel (tabs +
pills H1-H6/HC + texto/enlace).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent b5adaeee07
commit 326a25ec4d

@ -8,8 +8,8 @@
> Referencias contrastadas: Froala · TinyMCE · CKEditor 5 · TipTap (ProseMirror) · Quill ·
> Lexical · Slate.
>
> Documento hermano: [`../words/AUDIT.md`](../words/AUDIT.md) (auditoría visual de la
> recipe de words, 2026-06-06 — fases A–D hechas, E pendiente). Este dossier es la
> Documento hermano: el `AUDIT.md` visual de words (2026-06-06, fases A–D hechas —
> hoy solo en git history: el chrome words se retiró en FW). Este dossier es la
> auditoría de PRODUCTO/ARQUITECTURA; aquel era de homogeneización CSS.
>
> **Actualización 2026-07-14 (decisión de usuario)**: el chrome `words`

@ -225,9 +225,9 @@ Con **presupuesto de latencia** por interacción (se verifica tras F1): pulsaci
code / quote / callout ES la identidad visual del contenido. Se diseña como esquema
(F3.2 lo implementa), no como CSS suelto.
3. **Vocabulario de tokens del chrome `--palabras-*`**: aplicar las lecciones del
[`AUDIT.md` de words](../words/AUDIT.md) ANTES de que `palabras.css` (1 008 líneas
clean-slate) críe su propia sopa — escalas wash/radius/duraciones/anchos definidas de
entrada; cero hex, cero px mágicos, chrome-font única.
`AUDIT.md` visual de words (en git history desde FW) ANTES de que `palabras.css`
(1 008 líneas clean-slate) críe su propia sopa — escalas wash/radius/duraciones/
anchos definidas de entrada; cero hex, cero px mágicos, chrome-font única.
4. **Modo oscuro · densidad · reduced-motion** del chrome completo.
5. **Dirección estética** (DG-13): el mood se elige contigo ANTES de tocar CSS.

@ -20,7 +20,7 @@
*/
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words';
import { WORDS_BLOCK_DRAG_TYPE } from './words-block-drag-type';
import { WORDS_BLOCK_DRAG_TYPE } from './palabras-block-drag-type';
let {
api,

@ -27,7 +27,7 @@
import { Box } from '$uix/eidos/components/box';
import { Button } from '$uix/eidos/components/button';
import { EllipsisVertical } from '$uix/eidos/components/icon';
import { WORDS_BLOCK_DRAG_TYPE } from '$uix/eidos/components/words/words-block-drag-type';
import { WORDS_BLOCK_DRAG_TYPE } from './palabras-block-drag-type';
let {
api,

@ -1,11 +1,11 @@
/**
* Native image-file picker → data URL.
*
* Shared by the two Words consumers that let the user insert an image
* from disk: the gutter inserter (`words-block-gutter.svelte`) and the
* inspector's image panel upload button (`words-block-panel.svelte`).
* Both need the identical "open file dialog → FileReader → data URL"
* flow, so it lives here once instead of being copy-pasted.
* Shared by the palabras surfaces that let the user insert an image
* from disk: the block-type grids (grip menu + column inserter) and the
* panel's image upload button (`palabras-panel.svelte`). All need the
* identical "open file dialog → FileReader → data URL" flow, so it
* lives here once instead of being copy-pasted.
*
* Kept as a plain helper (NOT routed through ActiveDom): a transient
* `<input type="file">` plus `FileReader` is not instrumented by the

@ -41,7 +41,7 @@
import PalabrasColorRow from './palabras-color-row.svelte';
import PalabrasSpacing from './palabras-spacing.svelte';
import PalabrasTypography from './palabras-typography.svelte';
import { pickImageFile } from '$uix/eidos/components/words/words-image-file';
import { pickImageFile } from './palabras-image-file';
import { FloatPanel } from '$uix/eidos/components/float-panel';
import { deleteBlockAndReanchor } from './palabras-block-actions';
import type { PalabrasFieldDef, PalabrasSectionDef } from './types';

@ -17,8 +17,8 @@
import * as Words from '$soma/components/words';
import { Card } from '$uix/eidos/components/card';
import { Flex } from '$uix/eidos/components/flex';
import WordsActivate from '$uix/eidos/components/words/words-activate.svelte';
import WordsBlockDrag from '$uix/eidos/components/words/words-block-drag.svelte';
import WordsActivate from './palabras-activate.svelte';
import WordsBlockDrag from './palabras-block-drag.svelte';
import PalabrasBreadcrumb from './palabras-breadcrumb.svelte';
import PalabrasColumnInserter from './palabras-column-inserter.svelte';
import PalabrasHandle from './palabras-handle.svelte';

@ -1,109 +0,0 @@
# Words — auditoría visual + plan de homogeneización
> Estado: **auditado 2026-06-06**. La capa soma está limpia; el trabajo es 100 % en
> la recipe `words.css` (eidos). Documento de trabajo — se actualiza por fase.
## Diagnóstico
**Soma (`src/uix/soma/components/words/`) está limpia (95/100):** headless, emite
`data-words-*`, sin estilos inline / `<style>` / valores visuales. Único pecadillo:
`engine/blocks/built-ins.ts:975` hardcodea `margin-left: ${indent*1.5}em` en el
*serializador HTML* (export, no render) — baja prioridad. **NO tocar soma por estética.**
**El problema es `words.css` (recipe, ~55 KB): se desincronizó de su propio contrato
de tokens.** La recipe (`lib/recipes/base.ts`, bloque words) declara ~117 tokens; el CSS
usa ~42 + decenas de valores hardcodeados y deja **41 tokens huérfanos**. El mismo concepto
se expresa de 3-4 formas según la superficie → incoherencia visible.
## Patrones de incoherencia (lo accionable)
| Patrón | Síntoma | Canon objetivo |
|---|---|---|
| **Radius** | `4px` literal · `var(--words-command-radius)` · `var(--radius-2,4px)` · `2px`/`3px` | una sola escala `--words-radius-{sm,md,lg}` |
| **Tints (mix%)** | hardcoded 4/8/12/14/16/22/30/40/45/50 % | escala `--words-wash-{faint,subtle,soft,strong}` (= % aplicados al color base de cada superficie) |
| **Focus ring** | 2px unificado pero mix dispersa (30/40/45 %) | un token `--words-focus-ring` (spec completa) |
| **Tamaños bespoke** | swatch 1.15rem · checkbox 1.1em · intent 0.625rem · control widths 6.75/7.5/15rem · sidebar/drawer 16rem (×3) | tokens `--words-swatch-size`, `--words-field-width`, `--words-panel-width`… |
| **Unidades** | `em` vs `rem` vs `px` vs token en font-size/spacing | `rem`/tokens; reservar `em` solo para lo relativo-al-texto intencional |
| **Durations** | `120ms`/`140ms` inline | `--words-transition-duration` siempre |
| **Padding** | `--space-*` vs `4px 8px` vs `1px 5px` | siempre `--space-*` |
| **Paletas color** | 3 arrays de hex DISTINTOS (bubble / block-panel / inspector) | una fuente única |
| **Rail** | `--words-rail-bg/border` huérfanos; rail hardcodea `#f5f5f5`/`#e3e3e3` | cablear los tokens (fixed-tone está bien, pero vía token) |
## Deuda de contrato (los 3 tests rojos)
- **Usados sin declarar (2):** `--words-font-size-xs`, `--words-swatch` (este último es var
inline por-swatch → documentar como excepción o declararlo con default).
- **Huérfanos (41):** `gap-*`, `toolbar-*`, `button-size-*`, `command-{px,*-hover}`, `status-*`,
`rail-*`, `selection-color`, `heading-font-size`, `_accent-*`, etc. → triar: *cablear* o *podar*.
- **API (`component-api-contract.test.ts`):** `words-inspector.svelte` tiene un `<style>` vacío;
`words.svelte` usa `onMount` (→ `$effect`).
## Plan por fases
- **A — Vocabulario canónico (fuente de verdad).** Triar los 41 huérfanos (cablear/podar) +
añadir las escalas que matan la sopa (`--words-wash-*`, `--words-focus-ring`, tokens de tamaño) +
declarar los 2 que faltan. → 2 tests de recipe verdes.
- **B — Homogeneizar `words.css`** superficie por superficie consumiendo el vocabulario (radius,
wash, focus, durations, sizes, padding). El grueso.
- **C — Centralizar las 3 paletas** de color en una sola fuente.
- **D — Fixes de API** (quitar `<style>` del inspector, `onMount→$effect`). → 3er test verde.
- **E — Verificar + README** (3 tests verdes · `check` · regresión visual en `/uix/components/words`
· README que fije vocabulario + look para que no vuelva a derivar).
## Arranque: piloto = **inspector**
La superficie más cargada de sopa. Introducir ahí los tokens de escala, reescribirla token-limpia,
verificar en navegador, y con el enfoque validado → generalizar tokens + barrer el resto.
## Tracker
- [x] **Escala `wash` + contrato** (2026-06-06): añadidos `--words-wash-{faint,subtle,soft,strong}`
(6/16/30/44 %) + `font-size-xs` + `swatch` al recipe; aplicada la escala a los **18** sitios
de `color-mix` de `words.css` (la sopa 4…45 % → 4 pasos) y colapsadas las durations
hardcoded (120/140/100 ms → `--words-transition-duration`). Geometría (50/100 %) intacta.
→ test "declares every public var" **verde** (1 de 3 fallos de words resuelto). Editor
verificado en navegador (sin regresión).
- [x] **Radius unificado** (2026-06-06): nuevo `--words-radius-xs` (= `--radius-sm`, 4px) para el
tier de controles pequeños; snap de `4px`/`var(--radius-2,4px)`/`3px`/`var(--space-1)` (sopa de
8 sintaxis) → la escala `--words-radius-{xs,sm,md,lg}`. Micro decorativo (1-2px) + círculos
(50%) intactos por diseño. radius-xs verificado (=4px), sin regresión.
- [ ] A — resto del vocabulario: triar los 41 huérfanos (cablear `rail-*`/`toolbar-*`/… o podar)
- [x] B — barrido de tamaños: tier de control inline unificado a 28px·12px (ver entrada B detallada abajo)
- [ ] C — centralizar las 3 paletas de color
- [x] **D — fixes API** (2026-06-06): quitado el `<style>` vacío de `words-inspector.svelte`;
`onMount → $effect` en `words.svelte` (corre una vez, sin import de lifecycle). →
`component-api-contract` **verde**. Eidos suite 196/197 (solo queda el test de huérfanos).
- [x] **A — huérfanos podados** (2026-06-06): 55 tokens declarados-sin-usar eliminados del recipe
(38 directos: `gap-*`, `toolbar-*` ×9, `button-size-*`, `command-*-hover`, `content-px-*`,
`status-*`, `rail-*`, `selection-color`, `_accent-*` ×5, etc. + 17 en cascada — la paleta de
intents `*-solid`/`*-solid-hover`/secondary-* que solo alimentaba `_accent-*`; los callouts
usan los slots *tintados* track/border/text, que se quedan). Sin candidatos a cablear (rail
sin consumidor, `::selection` ya usa `wash-soft`). **Eidos suite 197/197 verde** — el contrato
del recipe ya coincide con el uso real. Poda visualmente inerte (el test "declares" garantiza
que words.css no referenciaba ninguno).
- [x] **Tipografía del chrome — UNA sola sans** (2026-06-06): el inspector mezclaba 3 fuentes
(label **Lora**/serif, title **system-ui**, input Instrument Sans) — la "content" archetype de
eidos (Accordion content) mapea a la serif de lectura y se colaba; `[scope] *` es solo (0,1,0),
empata con el `[data-x]` del componente → el code-split de la Fase 3 invirtió el orden y ganaba
la serif. Fix: token `--words-chrome-font` (= `--font-ui`, independiente del `font-family` de
contenido que el tema puede poner serif) + regla que fuerza esa sans en TODO el chrome
(toolbar·bubble·slash·drawer·inspector·menús) con atributo doblado **(0,2,0)** que gana a
cualquier fuente de componente sin importar el orden de carga. Verificado: todo el chrome =
Instrument Sans.
- [x] **B — tier de control armónico** (2026-06-06): el inspector mezclaba 3 alturas
(chip 26 · number-field 28 · color-trigger 36) y 2 fuentes (chip/trigger 10px vs
label/value/input 12px) → el control PRIMARIO (chip) era el texto más pequeño del
panel. Fix: nuevos tokens `--words-control-height` (1.75rem, antes un literal mágico
del number-field) + `--words-field-width` (7.5rem) aplicados a TODO el tier inline
(chip, color-trigger, number-field) → una sola altura 28px + ancho de columna derecho
alineado. Chip + color-trigger suben al body tier de 12px (el `xs` del ToggleGroup se
conserva sólo por su padding compacto; subir la fuente sin subir el padding mantiene
los grupos de 4 segmentos en una línea). Etiquetas largas que se apelotonaban
("Predeterminada"→"Auto", "Semi-negrita"→"Semi") acortadas → Fuente/Grosor leen como
4 segmentos parejos. Limpiados los fallbacks muertos e inconsistentes del mismo token
(`xs, 0.6875rem`/`xs, 0.75rem`/`sm, 0.8125rem` → token pelado). Escala de chrome final
armónica: **14 (sección) / 12 (cuerpo: label·value·input·chip) / 11 (micro-label)**.
Verificado en navegador: chip/color-trigger/number-field = 28px·12px; sin overflow de
grupos. 19/19 contract verde. Sizes restantes (swatch 1.15rem, checkbox 1.1em, panel
16rem ×4) son micro o ya consistentes → no rompen armonía, deferidos.
- [ ] E — verificación final (build + navegador) + README

@ -1,9 +0,0 @@
// Eidos `<Words>` — 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';
// <Words bind:value placeholder="Write something…" />
export { default } from './words.svelte';
export type { WordsProps, WordsSize } from './types';

@ -1,434 +0,0 @@
/**
* Translation refs consumed by the block inspector (and its
* `WordsNumRow` / `WordsColorRow` / `WordsSpacingRow` building blocks).
*
* Each entry is an `idlangref` of the form `#?components.words.{key}|fallback`
* — the eidos runtime (`eidos.langs.ts(KEY)`) returns the localized
* string when a translation is registered, or the post-`|` fallback when
* not. Apps that bundle the canonical en/es packs see translations
* automatically; consumers without packs see the English fallback.
*
* Three layers of labels:
*
* - **Block titles** — what kind of block the inspector is editing
* (`Heading 1`, `Text`, `Quote`, …). The heading title uses a
* placeholder for the level number rendered by the consumer.
* - **Section titles** — the accordion section headers
* (`Typography`, `Color`, `Layout`, `Spacing`, `Border`).
* - **Field labels + options** — every form control's visible label and
* every enumerated option that the inspector renders (font family,
* font weight, alignment, border style, etc.). Includes the aria
* labels used by the per-side spacing inputs and the link toggle.
*
* Adding a new label: insert the entry here AND in the consumer
* component, then call `eidos.langs.ts(WORDS_INSPECTOR_LANGS.YOUR_KEY)`
* to read it. The fallback after `|` is the canonical English string
* (the source of truth — translation packs derive from it).
*/
export const WORDS_INSPECTOR_LANGS = {
// Block titles -------------------------------------------------------
BLOCK_HEADING: '#?components.words.inspector.block.heading|Heading',
BLOCK_PARAGRAPH: '#?components.words.inspector.block.paragraph|Text',
BLOCK_QUOTE: '#?components.words.inspector.block.quote|Quote',
BLOCK_CODE: '#?components.words.inspector.block.code|Code',
BLOCK_LIST_ORDERED: '#?components.words.inspector.block.list-ordered|Numbered list',
BLOCK_LIST_CHECK: '#?components.words.inspector.block.list-check|Check list',
BLOCK_LIST_BULLET: '#?components.words.inspector.block.list-bullet|Bulleted list',
BLOCK_TABLE: '#?components.words.inspector.block.table|Table',
BLOCK_IMAGE: '#?components.words.inspector.block.image|Image',
BLOCK_DIVIDER: '#?components.words.inspector.block.divider|Divider',
BLOCK_CALLOUT: '#?components.words.inspector.block.callout|Callout',
BLOCK_GENERIC: '#?components.words.inspector.block.generic|Block',
BLOCK_COLUMNS: '#?components.words.inspector.block.columns|Columns',
LABEL_COLUMN_WIDTH: '#?components.words.inspector.label.column-width|Width',
LABEL_COLUMN_GAP: '#?components.words.inspector.label.column-gap|Gap',
PLACEHOLDER_COLUMN_GAP: '#?components.words.inspector.placeholder.column-gap|1rem, 24px, 0',
LABEL_COLUMN_N: '#?components.words.inspector.label.column-n|Column {n}',
ARIA_ADD_COLUMN_END: '#?components.words.inspector.aria.add-column-end|Add column at end',
ARIA_REMOVE_COLUMN_N: '#?components.words.inspector.aria.remove-column-n|Remove column {n}',
ARIA_ADD_BLOCK_TO_COLUMN_N:
'#?components.words.inspector.aria.add-block-to-column-n|Add block to column {n}',
LABEL_ADD_BLOCK: '#?components.words.inspector.label.add-block|Add block',
LABEL_COLUMN_BLOCKS: '#?components.words.inspector.label.column-blocks|Blocks',
ARIA_SELECT_BLOCK_IN_COLUMN:
'#?components.words.inspector.aria.select-block-in-column|Select this block',
ARIA_DELETE_BLOCK_IN_COLUMN:
'#?components.words.inspector.aria.delete-block-in-column|Delete this block',
ARIA_MOVE_BLOCK_UP: '#?components.words.inspector.aria.move-block-up|Move block up',
ARIA_MOVE_BLOCK_DOWN: '#?components.words.inspector.aria.move-block-down|Move block down',
BREADCRUMB_DOCUMENT: '#?components.words.inspector.breadcrumb.document|Document',
BREADCRUMB_LIST_ITEM: '#?components.words.inspector.breadcrumb.list-item|Item {n}',
BREADCRUMB_TABLE_ROW: '#?components.words.inspector.breadcrumb.table-row|Row {n}',
PLACEHOLDER_COLUMN_WIDTH: '#?components.words.inspector.placeholder.column-width|1fr, 200px, 30%',
// Section titles -----------------------------------------------------
SECTION_BLOCK: '#?components.words.inspector.section.block|Block',
SECTION_TYPOGRAPHY: '#?components.words.inspector.section.typography|Typography',
SECTION_COLOR: '#?components.words.inspector.section.color|Color',
SECTION_LAYOUT: '#?components.words.inspector.section.layout|Layout',
SECTION_SPACING: '#?components.words.inspector.section.spacing|Spacing',
SECTION_BORDER: '#?components.words.inspector.section.border|Border',
// Block-specific labels (heading / list / code / image) -------------
LABEL_HEADING_LEVEL: '#?components.words.inspector.label.heading-level|Level',
LABEL_LIST_KIND: '#?components.words.inspector.label.list-kind|Kind',
LABEL_CODE_LANGUAGE: '#?components.words.inspector.label.code-language|Language',
LABEL_SRC: '#?components.words.inspector.label.src|URL',
LABEL_ALT: '#?components.words.inspector.label.alt|Alt text',
LABEL_CAPTION: '#?components.words.inspector.label.caption|Caption',
LABEL_WIDTH: '#?components.words.inspector.label.width|Width',
LABEL_HEIGHT: '#?components.words.inspector.label.height|Height',
LABEL_IMAGE_ALIGN: '#?components.words.inspector.label.image-align|Align',
LABEL_IMAGE_FULL_WIDTH: '#?components.words.inspector.label.image-full-width|Full width',
IMAGE_FULL_WIDTH_ON: '#?components.words.inspector.image-full-width.on|On',
IMAGE_FULL_WIDTH_OFF: '#?components.words.inspector.image-full-width.off|Off',
LABEL_INTENT: '#?components.words.inspector.label.intent|Intent',
LABEL_TITLE: '#?components.words.inspector.label.title|Title',
LABEL_ROWS: '#?components.words.inspector.label.rows|Rows',
LABEL_COLUMNS: '#?components.words.inspector.label.columns|Columns',
LABEL_HEADER_ROW: '#?components.words.inspector.label.header-row|Header row',
LABEL_HEADER_COLUMN: '#?components.words.inspector.label.header-column|Header column',
LABEL_CELL_ALIGN: '#?components.words.inspector.label.cell-align|Cell align',
LABEL_CELL_VALIGN: '#?components.words.inspector.label.cell-valign|Vertical align',
SECTION_CELL: '#?components.words.inspector.section.cell|Cell',
SECTION_ROW: '#?components.words.inspector.section.row|Row',
SECTION_TABLE: '#?components.words.inspector.section.table|Table',
VALIGN_TOP: '#?components.words.inspector.valign.top|Top',
VALIGN_MIDDLE: '#?components.words.inspector.valign.middle|Middle',
VALIGN_BOTTOM: '#?components.words.inspector.valign.bottom|Bottom',
ARIA_CELL_ALIGN: '#?components.words.inspector.aria.cell-align|Cell horizontal align',
ARIA_CELL_VALIGN: '#?components.words.inspector.aria.cell-valign|Cell vertical align',
// Callout intent options — labels carry the perceptual reading the
// sema doctrine assigns to each value (cap. 12). Used as chip text
// in the intent ToggleGroup; the chip background is the doctrinal
// color of the intent (rendered via the `data-intent` data-attr).
INTENT_NEUTRAL: '#?components.words.inspector.intent.neutral|Info',
INTENT_AFFIRM: '#?components.words.inspector.intent.affirm|Tip',
INTENT_FULFILL: '#?components.words.inspector.intent.fulfill|Success',
INTENT_RISK: '#?components.words.inspector.intent.risk|Warning',
INTENT_THREAT: '#?components.words.inspector.intent.threat|Error',
INTENT_LOSS: '#?components.words.inspector.intent.loss|Critical',
// List kind options --------------------------------------------------
LIST_BULLETED: '#?components.words.inspector.list.bulleted|Bulleted',
LIST_NUMBERED: '#?components.words.inspector.list.numbered|Numbered',
LIST_CHECK: '#?components.words.inspector.list.check|Check',
// Block-specific ARIA + placeholders --------------------------------
ARIA_HEADING_LEVEL: '#?components.words.inspector.aria.heading-level|Heading level',
ARIA_LIST_KIND: '#?components.words.inspector.aria.list-kind|List kind',
ARIA_CODE_LANGUAGE: '#?components.words.inspector.aria.code-language|Code language',
ARIA_SRC: '#?components.words.inspector.aria.src|Image URL',
ARIA_ALT: '#?components.words.inspector.aria.alt|Image alternative text',
ARIA_CAPTION: '#?components.words.inspector.aria.caption|Image caption',
ARIA_IMAGE_ALIGN: '#?components.words.inspector.aria.image-align|Image alignment',
ARIA_IMAGE_FULL_WIDTH:
'#?components.words.inspector.aria.image-full-width|Image fills container width',
ARIA_UPLOAD_IMAGE:
'#?components.words.inspector.aria.upload-image|Upload image (encodes inline as data URL)',
ARIA_INTENT: '#?components.words.inspector.aria.intent|Callout intent',
ARIA_TITLE: '#?components.words.inspector.aria.title|Callout title',
ARIA_ADD_ROW_ABOVE: '#?components.words.inspector.aria.add-row-above|Insert row above',
ARIA_ADD_ROW_BELOW: '#?components.words.inspector.aria.add-row-below|Insert row below',
ARIA_REMOVE_ROW: '#?components.words.inspector.aria.remove-row|Delete row',
ARIA_ADD_COLUMN_LEFT: '#?components.words.inspector.aria.add-column-left|Insert column left',
ARIA_ADD_COLUMN_RIGHT: '#?components.words.inspector.aria.add-column-right|Insert column right',
ARIA_REMOVE_COLUMN: '#?components.words.inspector.aria.remove-column|Delete column',
ARIA_HEADER_ROW: '#?components.words.inspector.aria.header-row|Toggle header row',
ARIA_HEADER_COLUMN: '#?components.words.inspector.aria.header-column|Toggle header column',
PLACEHOLDER_CODE_LANGUAGE:
'#?components.words.inspector.placeholder.code-language|e.g. javascript',
PLACEHOLDER_SRC: '#?components.words.inspector.placeholder.src|https://...',
PLACEHOLDER_ALT: '#?components.words.inspector.placeholder.alt|Describe the image',
PLACEHOLDER_CAPTION: '#?components.words.inspector.placeholder.caption|Optional caption',
PLACEHOLDER_CALLOUT_TITLE:
'#?components.words.inspector.placeholder.callout-title|Optional title',
// Field labels -------------------------------------------------------
LABEL_FONT: '#?components.words.inspector.label.font|Font',
LABEL_FONT_SIZE: '#?components.words.inspector.label.font-size|Font size',
LABEL_WEIGHT: '#?components.words.inspector.label.weight|Weight',
LABEL_LINE_HEIGHT: '#?components.words.inspector.label.line-height|Line height',
LABEL_TEXT: '#?components.words.inspector.label.text|Text',
LABEL_BACKGROUND: '#?components.words.inspector.label.background|Background',
LABEL_ALIGN: '#?components.words.inspector.label.align|Align',
LABEL_MARGIN: '#?components.words.inspector.label.margin|Margin',
LABEL_PADDING: '#?components.words.inspector.label.padding|Padding',
LABEL_STYLE: '#?components.words.inspector.label.style|Style',
LABEL_BORDER_WIDTH: '#?components.words.inspector.label.border-width|Border width',
LABEL_COLOR: '#?components.words.inspector.label.color|Color',
LABEL_CORNER_RADIUS: '#?components.words.inspector.label.corner-radius|Corner radius',
LABEL_PRESET: '#?components.words.inspector.label.preset|Preset',
// Font family options ------------------------------------------------
FONT_DEFAULT: '#?components.words.inspector.font.default|Auto',
FONT_SANS: '#?components.words.inspector.font.sans|Sans',
FONT_SERIF: '#?components.words.inspector.font.serif|Serif',
FONT_MONO: '#?components.words.inspector.font.mono|Mono',
// Font weight options ------------------------------------------------
WEIGHT_REGULAR: '#?components.words.inspector.weight.regular|Regular',
WEIGHT_MEDIUM: '#?components.words.inspector.weight.medium|Medium',
WEIGHT_SEMIBOLD: '#?components.words.inspector.weight.semibold|Semi',
WEIGHT_BOLD: '#?components.words.inspector.weight.bold|Bold',
// Align options ------------------------------------------------------
ALIGN_LEFT: '#?components.words.inspector.align.left|Left',
ALIGN_CENTER: '#?components.words.inspector.align.center|Center',
ALIGN_RIGHT: '#?components.words.inspector.align.right|Right',
ALIGN_JUSTIFY: '#?components.words.inspector.align.justify|Justify',
// Border style options -----------------------------------------------
BORDER_NONE: '#?components.words.inspector.border.none|None',
BORDER_SOLID: '#?components.words.inspector.border.solid|Solid',
BORDER_DASHED: '#?components.words.inspector.border.dashed|Dashed',
BORDER_DOTTED: '#?components.words.inspector.border.dotted|Dotted',
// Corner radius preset options ---------------------------------------
PRESET_NONE: '#?components.words.inspector.preset.none|None',
PRESET_FULL: '#?components.words.inspector.preset.full|Full',
// ARIA labels (aria-label / title) -----------------------------------
ARIA_FONT_FAMILY: '#?components.words.inspector.aria.font-family|Font family',
ARIA_FONT_WEIGHT: '#?components.words.inspector.aria.font-weight|Font weight',
ARIA_ALIGN: '#?components.words.inspector.aria.align|Align',
ARIA_BORDER_STYLE: '#?components.words.inspector.aria.border-style|Border style',
ARIA_RADIUS_PRESET: '#?components.words.inspector.aria.radius-preset|Corner radius preset',
// Spacing per-side aria labels ---------------------------------------
SPACING_TOP: '#?components.words.inspector.spacing.top|Top',
SPACING_RIGHT: '#?components.words.inspector.spacing.right|Right',
SPACING_BOTTOM: '#?components.words.inspector.spacing.bottom|Bottom',
SPACING_LEFT: '#?components.words.inspector.spacing.left|Left',
SPACING_LINK: '#?components.words.inspector.spacing.link|Chain pairs',
SPACING_UNLINK: '#?components.words.inspector.spacing.unlink|Unchain sides',
SPACING_LINK_TITLE:
'#?components.words.inspector.spacing.link-title|Chain pairs — top with bottom, left with right',
SPACING_UNLINK_TITLE:
'#?components.words.inspector.spacing.unlink-title|Unchain — edit each side independently',
// Empty state --------------------------------------------------------
EMPTY: '#?components.words.inspector.empty|Select a block to edit its style.'
} as const;
// ── Translation bundle ───────────────────────────────────────────────────────
//
// The eidos langs engine resolves a ref's fallback (the text after `|`)
// ONLY when no entry is registered at the ref's path. Declaring refs is
// not enough — apps must call `langs.extend(namespace, bundle)` so the
// resolver finds localized values when the active locale changes.
//
// Words registers this bundle automatically from `Words.svelte`'s
// `onMount` (one-shot, idempotent). Apps that want to override or add
// locales can call `eidos.langs.extend('components.words.inspector', …)`
// after the component mounts — later registrations deep-merge over this
// one (the engine warns on leaf overwrites in DEV).
//
// Each leaf is a `LangRecord` — `{ [locale]: string }`. The engine
// picks the entry that matches the active locale, falling back through
// the configured chain when an entry is missing.
/**
* Localized strings for the inspector. Shape mirrors the ref path —
* every entry sits under `components.words.inspector.{section}.{key}`.
*
* Adding a locale: add the locale key to each leaf (e.g. `pt: '...'`).
* Adding a new entry: declare it in `WORDS_INSPECTOR_LANGS` above AND
* here; keep both ends in sync. Lint / runtime falls back to the ref's
* inline string when the locale lookup misses.
*/
export const WORDS_INSPECTOR_BUNDLE = {
block: {
heading: { en: 'Heading', es: 'Encabezado' },
paragraph: { en: 'Text', es: 'Texto' },
quote: { en: 'Quote', es: 'Cita' },
code: { en: 'Code', es: 'Código' },
'list-ordered': { en: 'Numbered list', es: 'Lista numerada' },
'list-check': { en: 'Check list', es: 'Lista de verificación' },
'list-bullet': { en: 'Bulleted list', es: 'Lista con viñetas' },
table: { en: 'Table', es: 'Tabla' },
image: { en: 'Image', es: 'Imagen' },
divider: { en: 'Divider', es: 'Separador' },
callout: { en: 'Callout', es: 'Aviso' },
generic: { en: 'Block', es: 'Bloque' },
columns: { en: 'Columns', es: 'Columnas' }
},
section: {
block: { en: 'Block', es: 'Bloque' },
typography: { en: 'Typography', es: 'Tipografía' },
color: { en: 'Color', es: 'Color' },
layout: { en: 'Layout', es: 'Disposición' },
spacing: { en: 'Spacing', es: 'Espaciado' },
border: { en: 'Border', es: 'Borde' },
cell: { en: 'Cell', es: 'Celda' },
row: { en: 'Row', es: 'Fila' },
table: { en: 'Table', es: 'Tabla' }
},
label: {
'heading-level': { en: 'Level', es: 'Nivel' },
'list-kind': { en: 'Kind', es: 'Tipo' },
'code-language': { en: 'Language', es: 'Lenguaje' },
src: { en: 'URL', es: 'URL' },
alt: { en: 'Alt text', es: 'Texto alternativo' },
caption: { en: 'Caption', es: 'Pie' },
width: { en: 'Width', es: 'Ancho' },
height: { en: 'Height', es: 'Alto' },
'image-align': { en: 'Align', es: 'Alinear' },
'image-full-width': { en: 'Full width', es: 'Ancho total' },
intent: { en: 'Intent', es: 'Intención' },
title: { en: 'Title', es: 'Título' },
rows: { en: 'Rows', es: 'Filas' },
columns: { en: 'Columns', es: 'Columnas' },
'header-row': { en: 'Header row', es: 'Fila de cabecera' },
'header-column': { en: 'Header column', es: 'Columna de cabecera' },
'cell-align': { en: 'Cell align', es: 'Alineación celda' },
'cell-valign': { en: 'Vertical align', es: 'Alineación vertical' },
'column-width': { en: 'Width', es: 'Ancho' },
'column-n': { en: 'Column {n}', es: 'Columna {n}' },
'column-blocks': { en: 'Blocks', es: 'Bloques' },
'add-block': { en: 'Add block', es: 'Añadir bloque' },
font: { en: 'Font', es: 'Fuente' },
'font-size': { en: 'Font size', es: 'Tamaño' },
weight: { en: 'Weight', es: 'Grosor' },
'line-height': { en: 'Line height', es: 'Interlineado' },
text: { en: 'Text', es: 'Texto' },
background: { en: 'Background', es: 'Fondo' },
align: { en: 'Align', es: 'Alinear' },
margin: { en: 'Margin', es: 'Margen' },
padding: { en: 'Padding', es: 'Relleno' },
style: { en: 'Style', es: 'Estilo' },
'border-width': { en: 'Border width', es: 'Grosor del borde' },
color: { en: 'Color', es: 'Color' },
'corner-radius': { en: 'Corner radius', es: 'Radio de esquina' },
preset: { en: 'Preset', es: 'Preajuste' }
},
intent: {
neutral: { en: 'Info', es: 'Info' },
affirm: { en: 'Tip', es: 'Consejo' },
fulfill: { en: 'Success', es: 'Éxito' },
risk: { en: 'Warning', es: 'Aviso' },
threat: { en: 'Error', es: 'Error' },
loss: { en: 'Critical', es: 'Crítico' }
},
list: {
bulleted: { en: 'Bulleted', es: 'Viñetas' },
numbered: { en: 'Numbered', es: 'Numerada' },
check: { en: 'Check', es: 'Verificación' }
},
'image-full-width': {
on: { en: 'On', es: 'Sí' },
off: { en: 'Off', es: 'No' }
},
font: {
default: { en: 'Auto', es: 'Auto' },
sans: { en: 'Sans', es: 'Sans' },
serif: { en: 'Serif', es: 'Serif' },
mono: { en: 'Mono', es: 'Mono' }
},
weight: {
regular: { en: 'Regular', es: 'Normal' },
medium: { en: 'Medium', es: 'Media' },
semibold: { en: 'Semibold', es: 'Semi' },
bold: { en: 'Bold', es: 'Negrita' }
},
align: {
left: { en: 'Left', es: 'Izquierda' },
center: { en: 'Center', es: 'Centro' },
right: { en: 'Right', es: 'Derecha' },
justify: { en: 'Justify', es: 'Justificar' }
},
valign: {
top: { en: 'Top', es: 'Arriba' },
middle: { en: 'Middle', es: 'Medio' },
bottom: { en: 'Bottom', es: 'Abajo' }
},
border: {
none: { en: 'None', es: 'Ninguno' },
solid: { en: 'Solid', es: 'Sólido' },
dashed: { en: 'Dashed', es: 'Discontinuo' },
dotted: { en: 'Dotted', es: 'Punteado' }
},
preset: {
none: { en: 'None', es: 'Ninguno' },
full: { en: 'Full', es: 'Completo' }
},
aria: {
'heading-level': { en: 'Heading level', es: 'Nivel del encabezado' },
'list-kind': { en: 'List kind', es: 'Tipo de lista' },
'code-language': { en: 'Code language', es: 'Lenguaje del código' },
src: { en: 'Image URL', es: 'URL de la imagen' },
alt: { en: 'Image alternative text', es: 'Texto alternativo de la imagen' },
caption: { en: 'Image caption', es: 'Pie de la imagen' },
'image-align': { en: 'Image alignment', es: 'Alineación de la imagen' },
'image-full-width': {
en: 'Image fills container width',
es: 'La imagen ocupa todo el ancho del contenedor'
},
'upload-image': {
en: 'Upload image (encodes inline as data URL)',
es: 'Subir imagen (codifica en línea como URL de datos)'
},
intent: { en: 'Callout intent', es: 'Intención del aviso' },
title: { en: 'Callout title', es: 'Título del aviso' },
'add-row-above': { en: 'Insert row above', es: 'Insertar fila encima' },
'add-row-below': { en: 'Insert row below', es: 'Insertar fila debajo' },
'remove-row': { en: 'Delete row', es: 'Eliminar fila' },
'add-column-left': { en: 'Insert column left', es: 'Insertar columna a la izquierda' },
'add-column-right': { en: 'Insert column right', es: 'Insertar columna a la derecha' },
'remove-column': { en: 'Delete column', es: 'Eliminar columna' },
'add-column-end': { en: 'Add column at end', es: 'Añadir columna al final' },
'remove-column-n': { en: 'Remove column {n}', es: 'Eliminar columna {n}' },
'add-block-to-column-n': {
en: 'Add block to column {n}',
es: 'Añadir bloque a la columna {n}'
},
'select-block-in-column': { en: 'Select this block', es: 'Seleccionar este bloque' },
'delete-block-in-column': { en: 'Delete this block', es: 'Eliminar este bloque' },
'move-block-up': { en: 'Move block up', es: 'Subir el bloque' },
'move-block-down': { en: 'Move block down', es: 'Bajar el bloque' },
'header-row': { en: 'Toggle header row', es: 'Alternar fila de cabecera' },
'header-column': { en: 'Toggle header column', es: 'Alternar columna de cabecera' },
'cell-align': {
en: 'Cell horizontal align',
es: 'Alineación horizontal de celda'
},
'cell-valign': {
en: 'Cell vertical align',
es: 'Alineación vertical de celda'
},
'font-family': { en: 'Font family', es: 'Familia tipográfica' },
'font-weight': { en: 'Font weight', es: 'Grosor de fuente' },
align: { en: 'Align', es: 'Alinear' },
'border-style': { en: 'Border style', es: 'Estilo del borde' },
'radius-preset': { en: 'Corner radius preset', es: 'Preajuste de radio' }
},
placeholder: {
'code-language': { en: 'e.g. javascript', es: 'p. ej. javascript' },
src: { en: 'https://...', es: 'https://...' },
alt: { en: 'Describe the image', es: 'Describe la imagen' },
caption: { en: 'Optional caption', es: 'Pie opcional' },
'callout-title': { en: 'Optional title', es: 'Título opcional' },
'column-width': { en: '1fr, 200px, 30%', es: '1fr, 200px, 30%' }
},
spacing: {
top: { en: 'Top', es: 'Arriba' },
right: { en: 'Right', es: 'Derecha' },
bottom: { en: 'Bottom', es: 'Abajo' },
left: { en: 'Left', es: 'Izquierda' },
link: { en: 'Chain pairs', es: 'Encadenar pares' },
unlink: { en: 'Unchain sides', es: 'Desencadenar lados' },
'link-title': {
en: 'Chain pairs — top with bottom, left with right',
es: 'Encadenar pares — arriba con abajo, izquierda con derecha'
},
'unlink-title': {
en: 'Unchain — edit each side independently',
es: 'Desencadenar — editar cada lado independiente'
}
},
empty: {
en: 'Select a block to edit its style.',
es: 'Selecciona un bloque para editar su estilo.'
}
} as const;

@ -1,30 +0,0 @@
/**
* Translation refs for the block-handle action menu (`WordsBlockGutter`).
*
* Same `idlangref` pattern as `langs-inspector.ts`: each entry is
* `#?components.words.menu.{key}|fallback`. `eidos.langs.ts(ref)` returns
* the localized string when `WORDS_MENU_BUNDLE` is registered (the eidos
* `<Words>` wrapper extends it on mount), else the English fallback.
*/
export const WORDS_MENU_LANGS = {
ACTIONS: '#?components.words.menu.actions|Block actions',
INSPECT: '#?components.words.menu.inspect|Inspect',
MOVE_UP: '#?components.words.menu.move-up|Move up',
MOVE_DOWN: '#?components.words.menu.move-down|Move down',
DUPLICATE: '#?components.words.menu.duplicate|Duplicate',
INSERT_BELOW: '#?components.words.menu.insert-below|Insert below',
PASTE_BELOW: '#?components.words.menu.paste-below|Paste below',
DELETE: '#?components.words.menu.delete|Delete'
} as const;
export const WORDS_MENU_BUNDLE = {
actions: { en: 'Block actions', es: 'Acciones del bloque' },
inspect: { en: 'Inspect', es: 'Inspeccionar' },
'move-up': { en: 'Move up', es: 'Subir' },
'move-down': { en: 'Move down', es: 'Bajar' },
duplicate: { en: 'Duplicate', es: 'Duplicar' },
'insert-below': { en: 'Insert below', es: 'Insertar debajo' },
'paste-below': { en: 'Paste below', es: 'Pegar debajo' },
delete: { en: 'Delete', es: 'Eliminar' }
};

@ -1,40 +0,0 @@
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<Size, 'sm' | 'md' | 'lg'>;
/**
* 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 `<Words>` 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<ProviderProps, 'children' | 'child'> & {
/** Sizing scale — drives padding, height and reading size. @default 'md' */
size?: ResponsiveProp<WordsSize>;
/** Where the block inspector lives. @default 'sidebar' */
inspector?: WordsInspectorMode;
/**
* Show the formatting menu bar at the top of the editor (history / block
* style / marks / lists / align / insert + Edit·Preview + inspector
* toggle). @default true
*/
toolbar?: boolean;
};

@ -1,61 +0,0 @@
<script lang="ts">
/**
* Active-block rail — ONE vertical bar in the left gutter that tracks the
* CLICKED (active) block. Frame-relative + a fixed gutter column (CSS), so
* it never shifts right for nested blocks, and a single fixed colour. Its
* top/height follow the active block, re-measured on activation / render /
* scroll. Hidden when the active block is scrolled out of view.
*/
import type { ActiveDom } from '$adom';
import type { ProviderSnippetProps } from '$soma/components/words';
let {
api,
content,
dom
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
} = $props();
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
let rail = $state<{ top: number; height: number } | null>(null);
function measure() {
const el = frame ? content.querySelector('[data-words-active]') : null;
if (!frame || !(el instanceof HTMLElement)) {
rail = null;
return;
}
// Climb to the TOP-LEVEL block (a direct child of content) so the rail
// spans the whole block — e.g. a full table, not the clicked cell.
let top: HTMLElement = el;
while (top.parentElement && top.parentElement !== content) {
top = top.parentElement;
}
const br = top.getBoundingClientRect();
const fr = frame.getBoundingClientRect();
const cr = content.getBoundingClientRect();
// Hide when the active block is scrolled out of the content viewport.
if (br.bottom < cr.top + 2 || br.top > cr.bottom - 2) {
rail = null;
return;
}
rail = { top: br.top - fr.top, height: br.height };
}
// Re-measure when the active block changes or the doc re-renders (typing
// resizes a block; `{@html}` recreates its element).
$effect(() => {
void api.activeBlockId;
void api.html;
dom.raf(measure, content);
});
// Re-measure on internal content scroll (the rail is frame-relative).
$effect(() => dom.listen(content, 'scroll', measure, { capture: true }));
</script>
{#if rail}
<div data-words-active-rail style="top: {rail.top}px; height: {rail.height}px;"></div>
{/if}

@ -1,377 +0,0 @@
<script lang="ts">
/**
* Left gutter — the block handle.
*
* A grip button that tracks the block under the cursor (in the left
* margin) and, on click, opens a menu: Move up / Move down / Duplicate /
* Delete + an Insert-below submenu of block types. Built entirely from
* the eidos `DropdownMenu` (Trigger renders a `Button`; `Sub` drives the
* submenu) — no bespoke widgets.
*
* Positioning: the gutter is a sibling of the contenteditable inside the
* `[data-words]` frame (a positioned ancestor). It measures the hovered
* top-level block's viewport rect and places itself frame-relative, so
* internal content scroll is handled by re-measuring on `scroll`.
*
* The handle + portalled menu carry `data-words-block-handle` /
* `data-words-block-handle-menu` so the provider's focus-scope predicate
* (`isInsideWordsTool`) treats interactions as internal — no spurious
* blur / commit / refocus cycle.
*/
import type { ActiveDom } from '$adom';
import { ActiveEidos } from '$uix/eidos';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { GripVertical } from '$uix/eidos/components/icon';
import {
defaultWordsSchema,
type ProviderSnippetProps,
type WordsBlockMenuEntry
} from '$soma/components/words';
import { pickImageFile } from './words-image-file';
import { WORDS_MENU_LANGS as L } from './langs-menu';
import { WORDS_BLOCK_DRAG_TYPE } from './words-block-drag-type';
let {
api,
content,
dom
}: {
api: ProviderSnippetProps;
content: HTMLElement;
dom: ActiveDom;
} = $props();
// Localized labels for the block-action menu (the bundle is registered by
// the eidos `<Words>` wrapper on mount).
const eidos = ActiveEidos.require();
const t = (ref: string) => eidos.langs.ts(ref);
const frame = $derived(content.closest('[data-words]') as HTMLElement | null);
let index = $state(-1);
let rect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
let show = $state(false);
let menuOpen = $state(false);
// True while the cursor is in the left gutter band (the margin column),
// not over the text. Drives the dashed outline that emphasises the
// target block — showing it for the whole gutter (not just the small
// grip) makes it a forgiving target and keeps the grip clear of the line.
let inGutter = $state(false);
// Disposer for the pending pointer-move frame (null when none scheduled).
// Doubles as the throttle guard: non-null ⇒ a frame is already queued.
let cancelFrame: (() => void) | null = null;
function topLevelBlocks(): HTMLElement[] {
return Array.from(content.children).filter(
(el): el is HTMLElement =>
el instanceof HTMLElement && el.matches("[data-words-node='block']")
);
}
function place(el: HTMLElement, i: number) {
if (!frame) return;
const br = el.getBoundingClientRect();
const fr = frame.getBoundingClientRect();
const cr = content.getBoundingClientRect();
// Hide when the block is scrolled out of the content viewport.
if (br.bottom < cr.top + 4 || br.top > cr.bottom - 4) {
show = false;
return;
}
index = i;
rect = {
top: br.top - fr.top,
left: br.left - fr.left,
width: br.width,
height: br.height
};
show = true;
}
function locate(clientX: number, clientY: number) {
const blocks = topLevelBlocks();
if (blocks.length === 0) {
show = false;
inGutter = false;
return;
}
// Voronoi-on-Y: each block claims a `[from, to)` zone where the
// boundary with a neighbour is the midpoint of the inter-block
// gap. Every vertical pixel belongs to exactly one block. This is
// the only way thin blocks (`<hr data-words-block='divider'>` has
// `block-size: 0` — the visible line is a border, the element's
// rect is ~1px tall) become hover-targetable. The old code
// matched "cursor inside the block's rect OR the last block whose
// top is ≤ cursor.y", which left dividers with a single-pixel
// hit zone.
const rects = blocks.map((el) => el.getBoundingClientRect());
let target: HTMLElement | null = null;
let targetIndex = -1;
let targetRect: DOMRect | null = null;
for (let i = 0; i < blocks.length; i++) {
const r = rects[i];
const prev = rects[i - 1];
const next = rects[i + 1];
const from = prev ? (prev.bottom + r.top) / 2 : -Infinity;
const to = next ? (r.bottom + next.top) / 2 : Infinity;
if (clientY >= from && clientY < to) {
target = blocks[i];
targetIndex = i;
targetRect = r;
break;
}
}
if (target && targetRect) {
place(target, targetIndex);
// The outline shows only while the cursor is in the gutter band —
// i.e. left of the block's text column, where the grip lives.
inGutter = clientX < targetRect.left;
} else {
show = false;
inGutter = false;
}
}
function reposition() {
if (index < 0) return;
const el = topLevelBlocks()[index];
if (el) place(el, index);
else show = false;
}
function onPointerMove(event: Event) {
if (menuOpen) return;
if (cancelFrame) return; // a frame is already pending — throttle to 1/frame
const e = event as PointerEvent;
const x = e.clientX;
const y = e.clientY;
// `dom.raf` resolves the content's window (iframe / popup safe) and
// returns a disposer; we keep it so teardown can cancel a pending frame.
cancelFrame = dom.raf(() => {
cancelFrame = null;
locate(x, y);
}, content);
}
function onPointerLeave() {
if (!menuOpen) {
show = false;
inGutter = false;
}
}
$effect(() => {
const disposers = [
dom.listen(content, 'pointermove', onPointerMove),
dom.listen(content, 'pointerleave', onPointerLeave),
dom.listen(content, 'scroll', reposition, { capture: true })
];
return () => {
for (const dispose of disposers) dispose?.();
cancelFrame?.();
cancelFrame = null;
};
});
// Re-measure after edits change the block layout. The one-shot frame is
// fire-and-forget (reposition is idempotent + cheap); we still route it
// through `dom.raf` so it targets the content's window, not the global.
$effect(() => {
void api.document;
if (show || menuOpen) dom.raf(reposition, content);
});
// The soma menu owns close-on-select (closeOnSelect defaults true); the
// closed panel hides via opacity, so mutating the document here doesn't
// interfere with the menu's dismissal.
function inspect() {
// Hand the active block to the inspector by marking it as the
// visually-selected atomic. Works for ALL top-level block types
// — image / divider that genuinely can't host a caret, AND blocks
// like `columns` / `table` / `list` whose Block panel offers
// editor surfaces (add column, header toggles, etc.) that the
// caret-driven path wouldn't normally surface.
//
// Passing `[index]` as the path ensures `selectedBlockPath` is set
// too — keeps the inspector's path-aware activeBlock derivation
// happy and lines up with the "still inside same top-level
// wrapper" guard in `syncSelectionFromDom` (no spurious clear on
// the next selectionchange).
api.selectAtomicBlock(index, [index]);
}
// Start an HTML5 drag carrying this block's top-level index. The drop
// controller (`words-block-drag.svelte`) reads it on drop and repositions
// the block. The grip stays a click target too (the menu opens on click,
// the drag on a press-and-move) — they don't collide.
function onGripDragStart(event: DragEvent) {
if (index < 0 || !event.dataTransfer) return;
event.dataTransfer.setData(WORDS_BLOCK_DRAG_TYPE, String(index));
event.dataTransfer.effectAllowed = 'move';
}
function moveUp() {
if (index > 0) api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'up' });
}
function moveDown() {
api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'down' });
}
function duplicate() {
api.applyCommand({ type: 'duplicateBlock', blockIndex: index });
}
// Paste the clipboard (rich HTML → blocks + marks, else plain text) as new
// blocks right below this one — a discoverable, click-driven alternative to
// Ctrl+V. Async (reads the Clipboard API); the menu closes immediately.
function paste() {
void api.pasteAfterBlock(index);
show = false;
}
function remove() {
// Capture the target BEFORE the delete (reading the doc after returns
// the wrong block); move the active to the previous block.
const kids = api.document.children;
const prevId = (index > 0 ? kids[index - 1]?.id : kids[1]?.id) ?? null;
api.applyCommand({ type: 'deleteBlock', blockIndex: index });
api.setActiveBlock(prevId);
show = false;
}
function insert(block: Record<string, unknown>) {
api.applyCommand({ type: 'insertBlock', blockIndex: index + 1, block });
}
// Image entries can't ship a valid `create()` (their `src` is unknown
// until the user picks a file or types a URL). The gutter inserter
// opens a file picker → FileReader → data URL when an image entry
// is selected, then inserts the block with a valid `src`. URLs from
// real backends would replace this with a fetch + upload. The picker
// flow is shared with the inspector's image panel (`pickImageFile`).
function insertImageInteractive() {
const doc =
(typeof document !== 'undefined' ? document : null) ?? content?.ownerDocument ?? null;
if (!doc) return;
pickImageFile(doc, (src, alt) => insert({ type: 'image', src, alt }));
}
function handleInsert(entry: WordsBlockMenuEntry) {
if (entry.id === 'image') {
insertImageInteractive();
return;
}
// Headings inserted from the gutter ship a literal "Title" text
// so the user sees what they got — relying on CSS `:empty:before`
// placeholders is brittle (contenteditable browsers strip the
// pseudo on caret entry, the rule needs `position: relative` on
// the block, etc.). Literal text always renders; the user
// select-all + type to replace. Slash menu users keep getting an
// empty heading (they explicitly typed `/h1` and start typing
// straight away). List gets the same treatment.
if (entry.id === 'heading') {
insert({
type: 'heading',
level: 1,
children: [{ type: 'text', text: 'Title' }]
});
return;
}
if (entry.id === 'list') {
insert({
type: 'list',
kind: 'unordered',
items: [{ children: [{ type: 'text', text: 'List item' }] }]
});
return;
}
insert(entry.create());
}
// Insert-menu entries come from the block registry — the single source
// of truth shared with the slash menu, so the two lists can't drift.
// Image is `insertable: false` because its `create()` returns an empty
// `src`; we surface it anyway and route through the file picker above
// so the user can actually pick an image from the gutter menu.
//
// Collapse multi-variant entries into ONE canonical chip in the
// gutter (creates the default variant — the inspector then exposes
// a level/kind toggle to switch to the others). The slash menu keeps
// every variant because typing `/h2` or `/check` is the natural way
// to pick the variant there; a click-pick menu doesn't get that
// ergonomic, so a flat "Heading" / "List" reads cleaner.
//
// Today the registry exposes:
// heading-1 / heading-2 / heading-3 → "Heading" (creates H1)
// unordered-list / ordered-list / check-list → "List" (creates bullets)
const COLLAPSE_GROUPS: Record<string, { id: string; label: string }> = {
'heading-1': { id: 'heading', label: 'Heading' },
'unordered-list': { id: 'list', label: 'List' }
};
const COLLAPSED_DROPS = new Set(['heading-2', 'heading-3', 'ordered-list', 'check-list']);
const inserts = $derived.by(() => {
const all = defaultWordsSchema.insertable();
return all
.filter((entry) => !COLLAPSED_DROPS.has(entry.id))
.map((entry) => {
const replacement = COLLAPSE_GROUPS[entry.id];
if (replacement) return { ...entry, ...replacement };
return entry;
});
});
</script>
{#if (show || menuOpen) && rect}
<!-- No hover overlay on the block: the active-block marker is a gutter
rail driven by `[data-words-active]` (see words.css), so it tracks the
CLICKED block, not the hovered one. -->
<!-- Center the handle vertically against the block's height: position
it at the block midpoint and let `transform: translateY(-50%)`
in CSS pull it up by half its own height. The previous
`margin-block-start` hack only worked for the FIRST line of
short blocks (paragraph, heading) — tall blocks (code, table,
image) had the handle pinned to the top, far from the cursor's
natural target. -->
<div data-words-block-gutter style="top: {rect.top + rect.height / 2}px;">
<DropdownMenu bind:open={menuOpen}>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Block actions"
data-words-block-handle
draggable={true}
ondragstart={onGripDragStart}
>
{#snippet icon()}
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<circle cx="5.5" cy="3.5" r="1.35" />
<circle cx="10.5" cy="3.5" r="1.35" />
<circle cx="5.5" cy="8" r="1.35" />
<circle cx="10.5" cy="8" r="1.35" />
<circle cx="5.5" cy="12.5" r="1.35" />
<circle cx="10.5" cy="12.5" r="1.35" />
</svg>
{/snippet}
{t(L.ACTIONS)}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-block-handle-menu>
<DropdownMenu.Item onSelect={inspect}>{t(L.INSPECT)}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={moveUp} disabled={index <= 0}>{t(L.MOVE_UP)}</DropdownMenu.Item>
<DropdownMenu.Item onSelect={moveDown}>{t(L.MOVE_DOWN)}</DropdownMenu.Item>
<DropdownMenu.Item onSelect={duplicate}>{t(L.DUPLICATE)}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>{t(L.INSERT_BELOW)}</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent data-words-block-handle-menu>
{#each inserts as ins (ins.id)}
<DropdownMenu.Item onSelect={() => handleInsert(ins)}>{ins.label}</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item onSelect={paste}>{t(L.PASTE_BELOW)}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={remove}>{t(L.DELETE)}</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
{/if}

@ -1,852 +0,0 @@
<script lang="ts">
/**
* Inspector block-specific properties panel.
*
* Sits at the TOP of the inspector body (above the generic
* Typography / Color / … accordion) and renders controls that are
* unique to the active block's TYPE — i.e. properties that the
* shared `Block` interface doesn't carry.
*
* Today the panel exposes:
* - **heading** → level toggle (H1 / H2 / H3, the only levels the
* engine supports per `WordsHeadingLevel`).
* - **list** → kind toggle (Bulleted / Numbered / Check).
* - **code** → language input (free-text; common languages
* are auto-suggested via the `<datalist>`).
*
* Blocks without per-type props (paragraph, quote, divider) render
* nothing — the parent decides whether to hide the whole section.
*
* The panel never reads the engine directly: every edit goes through
* `applyCommand` so undo/redo + sema + history all behave correctly.
* `setBlock` swaps the block shape (paragraph↔heading↔code); the
* generic `updateBlock` patches the active block in-place for ops
* that don't change the shape (list `kind`, code `language`).
*/
import type { Component } from 'svelte';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { NumberField } from '$uix/eidos/components/number-field';
import { Button } from '$uix/eidos/components/button';
import { TextArea } from '$uix/eidos/components/textarea';
import Toggle from '$uix/eidos/components/toggle';
import { Switch } from '$uix/eidos/components/switch';
import WordsColorRow from './words-color-row.svelte';
import { pickImageFile } from './words-image-file';
import {
TextAlignStart,
TextAlignCenter,
TextAlignEnd,
TextAlignJustify,
Plus,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Trash2,
Upload
} from '$uix/eidos/components/icon';
import { ActiveEidos } from '$uix/eidos';
import { WORDS_INSPECTOR_LANGS as L } from './langs-inspector';
import {
sanitizeWordsUrl,
type ProviderSnippetProps,
type WordsAlign,
type WordsBlock,
type WordsHeadingLevel,
type WordsImageAlign,
type WordsIntent,
type WordsListKind,
type WordsVerticalAlign
} from '$soma/components/words';
let {
block,
blockIndex,
blockPath = null,
selectionPath = null,
applyCommand
}: {
block: WordsBlock;
/** Used by `updateBlock`-based edits (image / callout / table).
* Heading / list / code ops use selection-derived commands and
* don't need it, but it's required when patching the block
* in-place. */
blockIndex: number;
/** Full path to the active block. For top-level blocks this is
* `[blockIndex]`; for nested atomics (image inside a column)
* it's the full walk — `patchBlock` routes through the
* path-aware command when the path has more than one segment. */
blockPath?: readonly number[] | null;
/** Full selection path inside the active block — used by the
* table panel to expose cell-scoped controls (text/vertical
* align) when the user's cursor is inside a cell. `null` when
* no caret is set or the block was selected from the gutter. */
selectionPath?: readonly number[] | null;
applyCommand: ProviderSnippetProps['applyCommand'];
} = $props();
const eidos = ActiveEidos.require();
const t = (key: string) => eidos.langs.ts(key);
// Heading level chips — string values so ToggleGroup's single-select
// API (which deals in strings) can drive a typed numeric level.
const HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[];
const LIST_KINDS = $derived<readonly { v: WordsListKind; label: string }[]>([
{ v: 'unordered', label: t(L.LIST_BULLETED) },
{ v: 'ordered', label: t(L.LIST_NUMBERED) },
{ v: 'check', label: t(L.LIST_CHECK) }
]);
// Image align is a 3-way enum (left / center / right) — float intent
// for inline images. Reusing the TextAlign* icons because they
// represent the same directional concept visually; lucide has no
// dedicated image-float icons.
const IMAGE_ALIGNS = $derived<readonly { v: WordsImageAlign; label: string; icon: Component }[]>([
{ v: 'left', label: t(L.ALIGN_LEFT), icon: TextAlignStart },
{ v: 'center', label: t(L.ALIGN_CENTER), icon: TextAlignCenter },
{ v: 'right', label: t(L.ALIGN_RIGHT), icon: TextAlignEnd }
]);
// Full 4-way align used by table cells (image's `imageAlign` doesn't
// have justify). Reuses the `TextAlign*` icons — same visual axis.
const ALIGNS = $derived<readonly { v: WordsAlign; label: string; icon: Component }[]>([
{ v: 'left', label: t(L.ALIGN_LEFT), icon: TextAlignStart },
{ v: 'center', label: t(L.ALIGN_CENTER), icon: TextAlignCenter },
{ v: 'right', label: t(L.ALIGN_RIGHT), icon: TextAlignEnd },
{ v: 'justify', label: t(L.ALIGN_JUSTIFY), icon: TextAlignJustify }
]);
// Callout intents — the 6 doctrinal sema valences (libro cap. 12).
// Each chip carries a `data-intent` so the recipe CSS can paint the
// swatch with the intent's doctrinal color (the same palette token
// the actual callout renders against). Labels are localized — the
// chip is text + a tiny color dot, not pure color.
const INTENTS = $derived<readonly { v: WordsIntent; label: string }[]>([
{ v: 'neutral', label: t(L.INTENT_NEUTRAL) },
{ v: 'affirm', label: t(L.INTENT_AFFIRM) },
{ v: 'fulfill', label: t(L.INTENT_FULFILL) },
{ v: 'risk', label: t(L.INTENT_RISK) },
{ v: 'threat', label: t(L.INTENT_THREAT) },
{ v: 'loss', label: t(L.INTENT_LOSS) }
]);
// Generic in-place patcher for the active block. Used by branches
// (image, callout) that mutate a block's intrinsic data without
// changing its shape. `updateBlock` shallow-merges the patch and
// re-normalises the document — undo-safe, sema-safe.
//
// Routes through the path-aware command when the active block is
// nested (e.g. an image inside a column at path [10,0,1]) — the
// top-level `updateBlock` would patch the WRAPPER block, which is
// the wrong target.
function patchBlock(patch: Record<string, unknown>): void {
if (blockPath && blockPath.length > 1) {
applyCommand({ type: 'updateBlockAtPath', blockPath, patch });
} else {
applyCommand({ type: 'updateBlock', blockIndex, patch });
}
}
// ── Validation ───────────────────────────────────────────────────────
// Column width accepts a flex-grow number ('1'), a CSS length /
// percentage / fraction ('200px', '30%', '1fr', '20rem'), or a sizing
// keyword ('auto', 'min-content', …). Anything else is rejected so a
// typo never reaches the inline `flex` style (where it would silently
// break the column layout). Empty clears the override.
function isValidColumnWidth(value: string): boolean {
const s = value.trim();
if (s === '') return true;
if (/^\d+(\.\d+)?$/.test(s)) return true;
if (/^\d+(\.\d+)?(px|rem|em|%|fr|vw|vh|vmin|vmax|ch)$/i.test(s)) return true;
return /^(auto|min-content|max-content|fit-content)$/i.test(s);
}
// Column gap is a plain CSS length (no flex-grow / fr — a gap can't be a
// fraction). Empty clears it back to the eidos default block-gap.
function isValidCssLength(value: string): boolean {
const s = value.trim();
if (s === '' || s === '0') return true;
return /^\d+(\.\d+)?(px|rem|em|%|vw|vh|vmin|vmax|ch)$/i.test(s);
}
// Commit helpers — validate on blur / Enter, flag the field invalid (red
// ring via `[data-invalid]`) and SKIP the patch when the value is
// malformed, so the model never holds garbage. The input keeps the typed
// text until the next valid commit or a model-driven re-render.
function commitColumnWidth(idx: number, input: HTMLInputElement): void {
if (isValidColumnWidth(input.value)) {
input.removeAttribute('data-invalid');
setColumnWidth(idx, input.value);
} else {
input.setAttribute('data-invalid', '');
}
}
function commitColumnsGap(input: HTMLInputElement): void {
if (isValidCssLength(input.value)) {
input.removeAttribute('data-invalid');
setColumnsGap(input.value);
} else {
input.setAttribute('data-invalid', '');
}
}
// Image src: route through the engine's URL sanitizer (allows http(s),
// mailto, tel and relative / anchor URLs; rejects javascript: and every
// other scheme — data: included). Binary sources go through the Upload
// button, which writes a trusted data URL directly. Empty clears the src
// (keeps the placeholder render). A non-empty value the sanitizer rejects
// marks the field invalid and is NOT committed.
function commitImageSrc(input: HTMLInputElement): void {
const raw = input.value.trim();
if (raw === '') {
input.removeAttribute('data-invalid');
patchBlock({ src: '' });
return;
}
const safe = sanitizeWordsUrl(raw);
if (safe) {
input.removeAttribute('data-invalid');
patchBlock({ src: safe });
} else {
input.setAttribute('data-invalid', '');
}
}
// Columns helpers — operate on the whole `columns` array via
// `updateBlock`. No dedicated engine ops; the array IS the data.
function addColumn(): void {
if (block.type !== 'columns') return;
const next = [
...block.columns,
{
children: [{ type: 'paragraph' as const, children: [{ type: 'text' as const, text: '' }] }]
}
];
patchBlock({ columns: next });
}
function removeColumnAt(idx: number): void {
if (block.type !== 'columns' || block.columns.length <= 1) return;
const next = block.columns.filter((_, i) => i !== idx);
patchBlock({ columns: next });
}
function setColumnWidth(idx: number, width: string): void {
if (block.type !== 'columns') return;
const trimmed = width.trim();
const next = block.columns.map((col, i) =>
i === idx ? { ...col, width: trimmed || undefined } : col
);
patchBlock({ columns: next });
}
// Gap (horizontal separation) between columns. A CSS length on the
// flex container; empty clears it back to the eidos default. Lives on
// the ColumnsBlock itself, not per-column.
function setColumnsGap(gap: string): void {
if (block.type !== 'columns') return;
const trimmed = gap.trim();
patchBlock({ gap: trimmed || undefined });
}
// (Per-column child-block list — label + select / delete / reorder —
// lives in the COLUMN-specific inspector now, not in this Columns-block
// panel. The helpers it used were removed with the list. This panel
// only manages layout: column count, gap, per-column width + Add block.)
// Hierarchical scopes for the table block — the path inside the
// block tells us which level the user's cursor is on:
// path = [blockIdx] → table (gutter)
// path = [blockIdx, rowIdx] → row
// path = [blockIdx, rowIdx, cellIdx] → cell
// path = [blockIdx, rowIdx, cellIdx, inlineIdx] → cell-element
// The "Cell" subsection appears when the user is in a cell or
// deeper (length ≥ 3). The active cell is read off `block.rows`.
const tableCellIndices = $derived.by<{ row: number; cell: number } | null>(() => {
if (block.type !== 'table' || !selectionPath || selectionPath.length < 3) return null;
const row = selectionPath[1];
const cell = selectionPath[2];
if (row === undefined || cell === undefined) return null;
return { row, cell };
});
const activeCell = $derived.by(() => {
if (block.type !== 'table' || !tableCellIndices) return undefined;
return block.rows[tableCellIndices.row]?.cells[tableCellIndices.cell];
});
// Active row = the row enclosing the user's selection. Exists when
// the caret is at least at `[i, r]` deep, regardless of whether a
// cell is identified. Powers the Row subsection.
const tableRowIndex = $derived.by<number | null>(() => {
if (block.type !== 'table' || !selectionPath || selectionPath.length < 2) return null;
return selectionPath[1] ?? null;
});
const activeRow = $derived.by(() => {
if (block.type !== 'table' || tableRowIndex === null) return undefined;
return block.rows[tableRowIndex];
});
// Color presets matched to the inspector's WordsColorRow consumers.
// Kept inline because the panel is a leaf component; the inspector's
// own COLOR_PRESETS is its own private constant.
const TABLE_COLOR_PRESETS = [
'#fff7ed',
'#fef3c7',
'#dcfce7',
'#dbeafe',
'#ede9fe',
'#fce7f3',
'#f1f5f9',
'#e2e8f0',
'#1a1a1a',
'#ffffff'
];
// A small starter set of common languages. Free-text via the input,
// the datalist just nudges typing.
const CODE_LANGUAGE_SUGGESTIONS = [
'plaintext',
'javascript',
'typescript',
'tsx',
'jsx',
'css',
'scss',
'html',
'json',
'yaml',
'toml',
'markdown',
'bash',
'shell',
'python',
'go',
'rust',
'java',
'kotlin',
'swift',
'c',
'cpp',
'csharp',
'sql',
'svelte',
'vue'
];
</script>
{#if block.type === 'heading'}
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_HEADING_LEVEL)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[String(block.level)]}
onValueChange={(v: string[]) => {
const next = v[0];
if (!next) return;
const level = Number(next) as WordsHeadingLevel;
applyCommand({ type: 'setBlock', block: 'heading', level });
}}
aria-label={t(L.ARIA_HEADING_LEVEL)}
>
{#each HEADING_LEVELS as lvl (lvl)}
<ToggleGroup.Item value={String(lvl)}>H{lvl}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{:else if block.type === 'list'}
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_LIST_KIND)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.kind]}
onValueChange={(v: string[]) => {
const kind = v[0] as WordsListKind | undefined;
if (!kind) return;
applyCommand({ type: 'toggleList', kind });
}}
aria-label={t(L.ARIA_LIST_KIND)}
>
{#each LIST_KINDS as k (k.v)}
<ToggleGroup.Item value={k.v}>{k.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{:else if block.type === 'code'}
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_CODE_LANGUAGE)}</span>
<!-- Free-text input with datalist suggestions. `setCodeLanguage`
is the engine's dedicated command for this. The `list` attr
ties to the `<datalist>` below so common languages
auto-suggest while still accepting anything the user types. -->
<input
data-words-inspector-code-language
type="text"
list="words-inspector-code-language-suggestions"
value={block.language ?? ''}
placeholder={t(L.PLACEHOLDER_CODE_LANGUAGE)}
aria-label={t(L.ARIA_CODE_LANGUAGE)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value.trim();
applyCommand({ type: 'setCodeLanguage', language: next || undefined });
}}
/>
<datalist id="words-inspector-code-language-suggestions">
{#each CODE_LANGUAGE_SUGGESTIONS as lang (lang)}
<option value={lang}></option>
{/each}
</datalist>
</div>
{:else if block.type === 'image'}
<!-- Image-specific properties: src URL (first — the user has just
inserted a placeholder image and the very first action expected
is "what URL?" + an Upload picker as a shortcut for binary
sources), alt + caption (text), fullWidth toggle (gates the next
three), width + height (numbers), float align (icon toggle).
Every edit goes through `updateBlock` so undo/redo behaves
correctly. Inputs commit on `change` (blur / Enter) — the engine
never sees mid-keystroke state. Empty strings collapse to
`undefined` for alt/caption (an empty alt is meaningful HTML-wise;
treat truly missing as omission, not empty); the src field
intentionally keeps the literal empty string so the placeholder
render branch stays active when the user clears it.
Text rows use `data-inline` for horizontal `space-between`
(label left + input right) so the panel reads compactly. -->
{@const imgFullWidth = block.fullWidth === true}
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_SRC)}</span>
<div data-words-inspector-src-group>
<input
data-words-inspector-text
type="url"
value={block.src}
placeholder={t(L.PLACEHOLDER_SRC)}
aria-label={t(L.ARIA_SRC)}
oninput={(e) => (e.currentTarget as HTMLInputElement).removeAttribute('data-invalid')}
onchange={(e) => commitImageSrc(e.currentTarget as HTMLInputElement)}
/>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_UPLOAD_IMAGE)}
title={t(L.ARIA_UPLOAD_IMAGE)}
onclick={() => {
// File picker → FileReader → data URL (shared with the gutter
// inserter via `pickImageFile`). Hostable apps that wire
// `onUploadImage` see the same path used by drag-and-drop;
// here in the panel we write a data URL straight to `src` to
// stay self-contained. The derived alt only fills an EMPTY alt
// (never clobbers a user-set one).
const doc = typeof document !== 'undefined' ? document : null;
if (!doc) return;
pickImageFile(doc, (src, altGuess) => {
patchBlock({
src,
...(block.alt === undefined && altGuess ? { alt: altGuess } : {})
});
});
}}
>
{#snippet icon()}<Upload />{/snippet}
</Button>
</div>
</div>
<div data-words-inspector-row>
<!-- Alt stays stacked (textarea wants the full row); a screen reader's
description of the image can be more than one short line. Commits
on blur — the eidos TextArea exposes `onBlur` via the headless soma,
keeping the model out of mid-keystroke history noise. `autosize`
+ minRows/maxRows grows the field with the content; the inspector
row hosts it cleanly because it stays width:100%. -->
<span data-words-inspector-label>{t(L.LABEL_ALT)}</span>
<TextArea
size="xs"
value={block.alt ?? ''}
autosize
minRows={2}
maxRows={6}
onValueChange={(v: string) => patchBlock({ alt: v || undefined })}
>
<TextArea.Input placeholder={t(L.PLACEHOLDER_ALT)} aria-label={t(L.ARIA_ALT)} />
</TextArea>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_CAPTION)}</span>
<input
data-words-inspector-text
type="text"
value={block.caption ?? ''}
placeholder={t(L.PLACEHOLDER_CAPTION)}
aria-label={t(L.ARIA_CAPTION)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value;
patchBlock({ caption: next || undefined });
}}
/>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_IMAGE_FULL_WIDTH)}</span>
<Switch
size="xs"
checked={imgFullWidth}
onCheckedChange={(v: boolean) => patchBlock({ fullWidth: v || undefined })}
aria-label={t(L.ARIA_IMAGE_FULL_WIDTH)}
/>
</div>
<div data-words-inspector-row data-inline data-disabled={imgFullWidth || undefined}>
<span data-words-inspector-label>{t(L.LABEL_WIDTH)}</span>
<NumberField
value={typeof block.width === 'number' ? block.width : 0}
min={0}
step={1}
size="xs"
variant="ghost"
disabled={imgFullWidth}
aria-label={t(L.LABEL_WIDTH)}
onValueCommit={(n: number) => patchBlock({ width: n > 0 ? n : undefined })}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-row data-inline data-disabled={imgFullWidth || undefined}>
<span data-words-inspector-label>{t(L.LABEL_HEIGHT)}</span>
<NumberField
value={typeof block.height === 'number' ? block.height : 0}
min={0}
step={1}
size="xs"
variant="ghost"
disabled={imgFullWidth}
aria-label={t(L.LABEL_HEIGHT)}
onValueCommit={(n: number) => patchBlock({ height: n > 0 ? n : undefined })}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-row data-disabled={imgFullWidth || undefined}>
<span data-words-inspector-label>{t(L.LABEL_IMAGE_ALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
disabled={imgFullWidth}
value={block.imageAlign ? [block.imageAlign] : []}
onValueChange={(v: string[]) =>
patchBlock({ imageAlign: (v[0] as WordsImageAlign | undefined) ?? undefined })}
aria-label={t(L.ARIA_IMAGE_ALIGN)}
>
{#each IMAGE_ALIGNS as a (a.v)}
{@const Icon = a.icon}
<ToggleGroup.Item value={a.v} aria-label={a.label} title={a.label}>
<Icon />
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
{:else if block.type === 'callout'}
<!-- Callout-specific properties: intent (6 doctrinal valences from
the sema canon — neutral / affirm / fulfill / risk / threat /
loss) + title. Each intent chip carries `data-intent` so the
recipe CSS can render a tiny color dot for the doctrinal hue. -->
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_INTENT)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.intent]}
onValueChange={(v: string[]) => {
const intent = v[0] as WordsIntent | undefined;
if (!intent) return;
patchBlock({ intent });
}}
aria-label={t(L.ARIA_INTENT)}
>
{#each INTENTS as i (i.v)}
<ToggleGroup.Item value={i.v} aria-label={i.label} title={i.label}>
<span data-words-intent-swatch data-intent={i.v}></span>
{i.label}
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_TITLE)}</span>
<input
data-words-inspector-text
type="text"
value={block.title ?? ''}
placeholder={t(L.PLACEHOLDER_CALLOUT_TITLE)}
aria-label={t(L.ARIA_TITLE)}
onchange={(e) => {
const next = (e.currentTarget as HTMLInputElement).value;
patchBlock({ title: next || undefined });
}}
/>
</div>
{:else if block.type === 'table'}
<!-- Three hierarchical scopes — Cell (caret in cell), Row (caret in
row), Table (always). Each scope edits ONLY its own element:
`setCellVisual` patches the active cell, `setRowVisual` patches
the active row, `updateBlock` patches the table itself. No edit
ever propagates beyond its scope. This is the Notion / Google
Docs / Word convention — the previous "everything goes to the
table" model was the canonical 2026-05-30 bug. -->
{#if activeCell}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>{t(L.SECTION_CELL)}</header>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_CELL_ALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={activeCell.align ? [activeCell.align] : []}
onValueChange={(v: string[]) => {
const align = v[0] as WordsAlign | undefined;
if (!align) return;
applyCommand({ type: 'setCellTextAlign', align });
}}
aria-label={t(L.ARIA_CELL_ALIGN)}
>
{#each ALIGNS as a (a.v)}
{@const Icon = a.icon}
<ToggleGroup.Item value={a.v} aria-label={a.label} title={a.label}>
<Icon />
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_CELL_VALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={activeCell.verticalAlign ? [activeCell.verticalAlign] : []}
onValueChange={(v: string[]) => {
const verticalAlign = v[0] as WordsVerticalAlign | undefined;
if (!verticalAlign) return;
applyCommand({ type: 'setCellVerticalAlign', verticalAlign });
}}
aria-label={t(L.ARIA_CELL_VALIGN)}
>
<ToggleGroup.Item value="top">{t(L.VALIGN_TOP)}</ToggleGroup.Item>
<ToggleGroup.Item value="middle">{t(L.VALIGN_MIDDLE)}</ToggleGroup.Item>
<ToggleGroup.Item value="bottom">{t(L.VALIGN_BOTTOM)}</ToggleGroup.Item>
</ToggleGroup>
</div>
<WordsColorRow
label={t(L.LABEL_TEXT)}
current={activeCell.color}
presets={TABLE_COLOR_PRESETS}
onPick={(hex) => applyCommand({ type: 'setCellVisual', visual: { color: hex } })}
/>
<WordsColorRow
label={t(L.LABEL_BACKGROUND)}
current={activeCell.background}
presets={TABLE_COLOR_PRESETS}
onPick={(hex) => applyCommand({ type: 'setCellVisual', visual: { background: hex } })}
/>
</div>
{/if}
{#if activeRow}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>{t(L.SECTION_ROW)}</header>
<WordsColorRow
label={t(L.LABEL_BACKGROUND)}
current={activeRow.background}
presets={TABLE_COLOR_PRESETS}
onPick={(hex) => applyCommand({ type: 'setRowVisual', visual: { background: hex } })}
/>
</div>
{/if}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>{t(L.SECTION_TABLE)}</header>
<!-- 6-button matrix (Notion / Google Docs / Word convention):
above/below/delete per row, left/right/delete per column.
Operates relative to the caret cell — the user must be
inside a cell for these to fire; otherwise the engine
silently no-ops. Counts shown stay authoritative. -->
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_ROWS)}</span>
<span data-words-inspector-value>{block.rows.length}</span>
</div>
<div data-words-inspector-table-ops>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_ADD_ROW_ABOVE)}
title={t(L.ARIA_ADD_ROW_ABOVE)}
onclick={() => applyCommand({ type: 'insertTableRow', position: 'before' })}
>
{#snippet icon()}<ArrowUp />{/snippet}
</Button>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_ADD_ROW_BELOW)}
title={t(L.ARIA_ADD_ROW_BELOW)}
onclick={() => applyCommand({ type: 'insertTableRow', position: 'after' })}
>
{#snippet icon()}<ArrowDown />{/snippet}
</Button>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_REMOVE_ROW)}
title={t(L.ARIA_REMOVE_ROW)}
onclick={() => applyCommand({ type: 'deleteTableRow' })}
>
{#snippet icon()}<Trash2 />{/snippet}
</Button>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_COLUMNS)}</span>
<span data-words-inspector-value>{block.rows[0]?.cells.length ?? 0}</span>
</div>
<div data-words-inspector-table-ops>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_ADD_COLUMN_LEFT)}
title={t(L.ARIA_ADD_COLUMN_LEFT)}
onclick={() => applyCommand({ type: 'insertTableColumn', position: 'before' })}
>
{#snippet icon()}<ArrowLeft />{/snippet}
</Button>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_ADD_COLUMN_RIGHT)}
title={t(L.ARIA_ADD_COLUMN_RIGHT)}
onclick={() => applyCommand({ type: 'insertTableColumn', position: 'after' })}
>
{#snippet icon()}<ArrowRight />{/snippet}
</Button>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_REMOVE_COLUMN)}
title={t(L.ARIA_REMOVE_COLUMN)}
onclick={() => applyCommand({ type: 'deleteTableColumn' })}
>
{#snippet icon()}<Trash2 />{/snippet}
</Button>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_HEADER_ROW)}</span>
<Toggle
size="xs"
variant="ghost"
pressed={block.headerRow ?? false}
onPressedChange={() => applyCommand({ type: 'toggleTableHeaderRow' })}
aria-label={t(L.ARIA_HEADER_ROW)}
>
{block.headerRow ? t(L.PRESET_FULL) : t(L.PRESET_NONE)}
</Toggle>
</div>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_HEADER_COLUMN)}</span>
<Toggle
size="xs"
variant="ghost"
pressed={block.headerCol ?? false}
onPressedChange={() => applyCommand({ type: 'toggleTableHeaderColumn' })}
aria-label={t(L.ARIA_HEADER_COLUMN)}
>
{block.headerCol ? t(L.PRESET_FULL) : t(L.PRESET_NONE)}
</Toggle>
</div>
</div>
{:else if block.type === 'columns'}
<!-- Columns block — count + per-column width (CSS flex value:
'1' / '200px' / '30%'). Each column is identified by index.
Add appends an empty column at the end; remove drops the
numbered column (disabled when only 1 column remains so the
block doesn't collapse to nothing). -->
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_COLUMNS)}</span>
<span data-words-inspector-value>{block.columns.length}</span>
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_ADD_COLUMN_END)}
title={t(L.ARIA_ADD_COLUMN_END)}
onclick={addColumn}
>
{#snippet icon()}<Plus />{/snippet}
</Button>
</div>
<!-- Gap between columns (CSS length). Block-level (not per-column);
omitted falls back to the eidos default block-gap. -->
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_COLUMN_GAP)}</span>
<input
data-words-inspector-text
type="text"
value={block.gap ?? ''}
placeholder={t(L.PLACEHOLDER_COLUMN_GAP)}
aria-label={t(L.LABEL_COLUMN_GAP)}
oninput={(e) => (e.currentTarget as HTMLInputElement).removeAttribute('data-invalid')}
onchange={(e) => commitColumnsGap(e.currentTarget as HTMLInputElement)}
/>
</div>
{#each block.columns as col, idx (idx)}
<div data-words-inspector-subsection>
<header data-words-inspector-subsection-title>
<span>{t(L.LABEL_COLUMN_N).replace('{n}', String(idx + 1))}</span>
{#if block.columns.length > 1}
<Button
size="xs"
variant="ghost"
iconOnly
aria-label={t(L.ARIA_REMOVE_COLUMN_N).replace('{n}', String(idx + 1))}
title={t(L.ARIA_REMOVE_COLUMN_N).replace('{n}', String(idx + 1))}
onclick={() => removeColumnAt(idx)}
>
{#snippet icon()}<Trash2 />{/snippet}
</Button>
{/if}
</header>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_COLUMN_WIDTH)}</span>
<input
data-words-inspector-text
type="text"
value={col.width ?? ''}
placeholder={t(L.PLACEHOLDER_COLUMN_WIDTH)}
aria-label={t(L.LABEL_COLUMN_WIDTH)}
oninput={(e) => (e.currentTarget as HTMLInputElement).removeAttribute('data-invalid')}
onchange={(e) => commitColumnWidth(idx, e.currentTarget as HTMLInputElement)}
/>
</div>
</div>
{/each}
{/if}

@ -1,377 +0,0 @@
<script lang="ts">
/**
* Selection bubble toolbar.
*
* Floats above the current text selection (TipTap / Notion-style). A
* "Turn into" dropdown converts the current block (when convertible)
* plus a row of inline-mark toggles and a link control.
*
* Composition — every control is an ecosystem component, no bespoke
* widgets or hand-drawn glyphs:
* - soma `Words.BubbleMenu` owns open/close + viewport positioning +
* the single-focus-scope contract.
* - eidos `DropdownMenu` drives turn-into; its content carries
* `data-words-bubble-turn-into` so the focus scope recognises it.
* - marks are soma `Words.CommandButton`s rendered through their `child`
* snippet as eidos `Button`s (the canonical consumer pattern) — the
* CommandButton restores the DOM range before the command runs.
* - glyphs come from the eidos `Icon` set.
*
* Block transforms go through `api.applyCommand` (direct, no sema).
*/
import { tick } from 'svelte';
import * as Words from '$soma/components/words';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { Button } from '$uix/eidos/components/button';
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Link,
Unlink,
ChevronDown,
Check,
Baseline,
Highlighter
} from '$uix/eidos/components/icon';
import type {
ProviderSnippetProps,
WordsBlock,
WordsHeadingLevel,
WordsListKind
} from '$soma/components/words';
let { api }: { api: ProviderSnippetProps } = $props();
let turnIntoOpen = $state(false);
let colorOpen = $state(false);
let linkOpen = $state(false);
let linkValue = $state('');
let linkInput = $state<HTMLInputElement | null>(null);
// Swatch palette for the bubble's text / background color marks — the
// SAME hues the inspector's color rows offer, minus pure white (invisible
// as a text color on a light surface; the "Default" chip clears instead).
// Each swatch applies via `Words.CommandButton` (command `color:#hex` /
// `bgcolor:#hex`) which the provider parses to a parametric `toggleMark`
// on the live selection — exactly how bold / italic apply, so a recolor
// rides the same range-restoring path with no bespoke wiring.
const COLOR_SWATCHES = [
'#e5484d',
'#f76b15',
'#ffc53d',
'#46a758',
'#0091ff',
'#8e4ec6',
'#e93d82',
'#646a73',
'#1a1a1a'
] as const;
// Blocks whose content the engine can reinterpret (setBlock / toggleList
// no-op on table / image / divider / callout), so turn-into is disabled
// elsewhere instead of offering inert options.
const CONVERTIBLE_BLOCKS = new Set(['paragraph', 'heading', 'quote', 'code', 'list']);
// When the caret sits inside a column the top-level `api.currentBlock`
// is `'columns'` — but the user is editing the NESTED block inside,
// which is the one Turn-into can convert. Resolve the effective
// block (and its heading level) by descending the selection path
// into the active column. The engine's `setBlock` already handles
// columns-recursive conversion (see block-format.ts).
const effectiveBlock = $derived.by<{
type: string;
level?: WordsHeadingLevel;
kind?: WordsListKind;
}>(() => {
const path = api.selection?.anchor.path;
if (!path || path.length === 0) {
return { type: api.currentBlock };
}
const top = api.document.children[path[0]] as WordsBlock | undefined;
if (top?.type === 'columns' && path.length >= 3) {
const col = top.columns[path[1] ?? 0];
const inner = col?.children[path[2] ?? 0];
if (inner) {
const result: { type: string; level?: WordsHeadingLevel; kind?: WordsListKind } = {
type: inner.type
};
if (inner.type === 'heading') result.level = inner.level;
if (inner.type === 'list') result.kind = inner.kind;
return result;
}
}
return {
type: api.currentBlock,
level: api.currentHeadingLevel,
kind: top?.type === 'list' ? top.kind : undefined
};
});
const canTurnInto = $derived(CONVERTIBLE_BLOCKS.has(effectiveBlock.type));
const currentListKind = $derived(effectiveBlock.kind);
const blockLabel = $derived.by(() => {
switch (effectiveBlock.type) {
case 'heading':
return `Heading ${effectiveBlock.level ?? 1}`;
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
return effectiveBlock.kind === 'ordered'
? 'Numbered list'
: effectiveBlock.kind === 'check'
? 'Check list'
: 'Bulleted list';
case 'paragraph':
return 'Text';
case 'table':
return 'Table';
case 'callout':
return 'Callout';
case 'image':
return 'Image';
case 'divider':
return 'Divider';
case 'columns':
return 'Columns';
default:
return 'Turn into';
}
});
const turnInto = $derived.by(() => [
{
id: 'paragraph',
label: 'Text',
active: effectiveBlock.type === 'paragraph',
run: () => api.applyCommand({ type: 'setBlock', block: 'paragraph' })
},
...([1, 2, 3] as const).map((level: WordsHeadingLevel) => ({
id: `heading-${level}`,
label: `Heading ${level}`,
active: effectiveBlock.type === 'heading' && (effectiveBlock.level ?? 1) === level,
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
})),
{
id: 'unordered-list',
label: 'Bulleted list',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'unordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'unordered' })
},
{
id: 'ordered-list',
label: 'Numbered list',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'ordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'ordered' })
},
{
id: 'check-list',
label: 'Check list',
active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'check',
run: () => api.applyCommand({ type: 'toggleList', kind: 'check' })
},
{
id: 'quote',
label: 'Quote',
active: effectiveBlock.type === 'quote',
run: () => api.applyCommand({ type: 'setBlock', block: 'quote' })
},
{
id: 'code',
label: 'Code',
active: effectiveBlock.type === 'code',
run: () => api.applyCommand({ type: 'setBlock', block: 'code' })
}
]);
const MARKS = [
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'underline', label: 'Underline', icon: Underline },
{ command: 'strike', label: 'Strikethrough', icon: Strikethrough },
{ command: 'code', label: 'Inline code', icon: Code }
] as const;
function openLinkEditor() {
linkValue = api.selectedLink?.href ?? '';
linkOpen = true;
void tick().then(() => {
linkInput?.focus();
linkInput?.select();
});
}
function applyLink() {
const href = linkValue.trim();
if (href) api.insertLink(href);
linkOpen = false;
}
function onLinkKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
applyLink();
} else if (event.key === 'Escape') {
event.preventDefault();
linkOpen = false;
}
}
</script>
<Words.BubbleMenu side="top">
{#snippet children(bubble)}
{#if bubble.open}
{#if linkOpen}
<div data-words-bubble-link>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={linkInput}
bind:value={linkValue}
type="url"
inputmode="url"
placeholder="Paste or type a link…"
aria-label="Link URL"
onkeydown={onLinkKeydown}
/>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Apply link"
onpointerdown={(e: PointerEvent) => e.preventDefault()}
onclick={applyLink}
>
{#snippet icon()}<Check />{/snippet}
</Button>
</div>
{:else}
<DropdownMenu bind:open={turnIntoOpen}>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
disabled={!canTurnInto}
data-words-bubble-trigger
>
{blockLabel}
{#snippet endIcon()}<ChevronDown />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-bubble-turn-into>
{#each turnInto as item (item.id)}
<DropdownMenu.Item onSelect={item.run} data-words-bubble-into-item>
<span>{item.label}</span>
{#if item.active}<Check size="sm" />{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu>
<span data-words-bubble-divider></span>
{#each MARKS as mark (mark.command)}
{@const Glyph = mark.icon}
<Words.CommandButton command={mark.command} aria-label={mark.label}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/each}
<span data-words-bubble-divider></span>
<!-- Text + background colour. Each swatch is a `Words.CommandButton`
whose `color:#hex` / `bgcolor:#hex` command the provider parses
into a parametric `toggleMark` on the live selection — the same
range-restoring path bold / italic use. The "Default" chip clears
the mark (empty value). The dropdown carries `data-words-bubble-color`
so the bubble's focus scope keeps the selection alive. -->
<DropdownMenu bind:open={colorOpen}>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
aria-label="Text and background colour"
data-words-bubble-trigger
>
{#snippet icon()}<Baseline />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start" data-words-bubble-color>
<div data-words-bubble-color-section>
<span data-words-bubble-color-label><Baseline size="sm" />Text</span>
<div data-words-bubble-swatches>
<Words.CommandButton command="color:" aria-label="Default text colour">
{#snippet child({ props })}
<button {...props} type="button" data-words-bubble-swatch data-clear></button>
{/snippet}
</Words.CommandButton>
{#each COLOR_SWATCHES as hex (hex)}
<Words.CommandButton command={`color:${hex}`} aria-label={`Text colour ${hex}`}>
{#snippet child({ props })}
<button
{...props}
type="button"
data-words-bubble-swatch
style="--words-swatch: {hex}"
></button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
</div>
<div data-words-bubble-color-section>
<span data-words-bubble-color-label><Highlighter size="sm" />Background</span>
<div data-words-bubble-swatches>
<Words.CommandButton command="bgcolor:" aria-label="No background">
{#snippet child({ props })}
<button {...props} type="button" data-words-bubble-swatch data-clear></button>
{/snippet}
</Words.CommandButton>
{#each COLOR_SWATCHES as hex (hex)}
<Words.CommandButton command={`bgcolor:${hex}`} aria-label={`Background colour ${hex}`}>
{#snippet child({ props })}
<button
{...props}
type="button"
data-words-bubble-swatch
style="--words-swatch: {hex}"
></button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
</div>
</DropdownMenu.Content>
</DropdownMenu>
<span data-words-bubble-divider></span>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Link"
data-active={api.selectedLink ? '' : undefined}
onpointerdown={(e: PointerEvent) => e.preventDefault()}
onclick={openLinkEditor}
>
{#snippet icon()}<Link />{/snippet}
</Button>
{#if api.selectedLink}
<Words.CommandButton command="unlink" aria-label="Remove link">
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly>
{#snippet icon()}<Unlink />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/if}
{/if}
{/if}
{/snippet}
</Words.BubbleMenu>

@ -1,98 +0,0 @@
<script lang="ts">
/**
* Inspector colour row — labelled `ColorPicker` bound to a block's hex.
*
* Pattern: local `$state` draft + `bind:value` + `onValueChangeEnd`.
* The `$effect` syncs `draft` ← `current` only when their hexes differ
* so an external edit propagates in without clobbering a mid-drag draft.
*/
import { untrack } from 'svelte';
import { ColorPicker } from '$uix/eidos/components/color-picker';
import { PickerShell } from '$uix/eidos/components/picker-shell';
import { parseColor, colorValueFromHsv, DEFAULT_COLOR, type ColorValue } from '$libs/color';
let {
label,
current,
presets,
onPick
}: {
label: string;
current: string | undefined;
presets: readonly string[];
onPick: (hex: string | undefined) => void;
} = $props();
let draft = $state<ColorValue | undefined>(undefined);
$effect(() => {
// Track ONLY `current` — read draft inside `untrack` so the effect
// doesn't re-run when the picker writes to draft mid-drag (the
// picker mutates draft via the bind chain on every pointermove).
// Without untrack, every drag tick would re-run this effect and
// overwrite the user's value with the still-committed `current`
// hex, locking the thumb in place.
const hexIn = current;
untrack(() => {
if (!hexIn) {
// External cleared (Clear button / programmatic). Mirror
// it into draft so the picker shows empty state.
if (draft !== undefined) draft = undefined;
return;
}
const hsv = parseColor(hexIn);
const next = hsv ? colorValueFromHsv(hsv) : undefined;
if (next?.hex !== draft?.hex) draft = next;
});
});
</script>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{label}</span>
<ColorPicker
size="sm"
bind:value={draft}
placeholder={DEFAULT_COLOR}
onValueChange={(cv: ColorValue | undefined) => {
// `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)}
>
<ColorPicker.Control>
<ColorPicker.Trigger><ColorPicker.ValueText /></ColorPicker.Trigger>
</ColorPicker.Control>
<ColorPicker.Portal>
<ColorPicker.Content>
<PickerShell.Body>
<ColorPicker.Area />
<ColorPicker.ChannelSlider channel="hue" />
<ColorPicker.ChannelSlider channel="alpha" />
<!-- Manual hex/RGB editing — default rendering gives a
value swatch + segmented inputs + format select.
User can type the hex directly (committed on
blur / Enter by the soma channel-input). -->
<ColorPicker.ChannelInput />
<ColorPicker.SwatchGroup>
{#each presets as c (c)}
<ColorPicker.SwatchTrigger color={c}>
<ColorPicker.Swatch color={c} />
<ColorPicker.SwatchIndicator />
</ColorPicker.SwatchTrigger>
{/each}
</ColorPicker.SwatchGroup>
</PickerShell.Body>
<ColorPicker.Footer>
<ColorPicker.Clear />
<ColorPicker.Cancel />
<ColorPicker.Close />
</ColorPicker.Footer>
</ColorPicker.Content>
</ColorPicker.Portal>
</ColorPicker>
</div>

@ -1,781 +0,0 @@
<script lang="ts">
/**
* Block inspector — the shared body.
*
* A vertical accordion of property sections for the ACTIVE node's
* base-`Block` style. Presentation-agnostic: `<Words inspector>` mounts
* it in a fixed sidebar, a slide-in drawer or a popover.
*
* The active node is tracked by STABLE id (`api.activeBlockLocation`),
* not by the volatile contenteditable selection. It may be a typed
* `WordsBlock` OR a structural Block-subtype with no `type`
* discriminator — `Column`, `TableRow`, `TableCell`, `ListItem`. ALL of
* these extend `Block`, so all carry the common visual props the
* generic sections edit. Type-specific UI (per-type Block panel, block
* title) gates on `activeBlock`; the generic sections use `activeNode`.
*
* Edits go through `api.applyCommand` (direct visual edit, no sema).
*/
import { Accordion } from '$uix/eidos/components/accordion';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { Button } from '$uix/eidos/components/button';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { ActiveEidos } from '$uix/eidos';
import type { Component } from 'svelte';
import {
TextAlignStart,
TextAlignCenter,
TextAlignEnd,
TextAlignJustify,
Plus,
Trash2,
ChevronUp,
ChevronDown
} from '$uix/eidos/components/icon';
import WordsBlockPanel from './words-block-panel.svelte';
import WordsColorRow from './words-color-row.svelte';
import WordsNumRow from './words-num-row.svelte';
import WordsSpacingRow from './words-spacing-row.svelte';
import { WORDS_INSPECTOR_LANGS as L } from './langs-inspector';
import {
defaultWordsSchema,
type Column,
type ProviderSnippetProps,
type WordsAncestryNode,
type WordsBlock,
type WordsBlockSpec
} from '$soma/components/words';
// Any node the inspector can edit visual props on: a typed block OR a
// structural Block-subtype (column / row / cell). All extend `Block`,
// so all carry the common visual fields the generic sections read.
type BlockLike = WordsBlock | Column;
let { api }: { api: ProviderSnippetProps } = $props();
const eidos = ActiveEidos.require();
const t = (key: string) => eidos.langs.ts(key);
// Active node = the STABLE id-tracked location from the provider, NOT
// the volatile contenteditable selection. The inspector follows
// `activeBlockId` (set on block click / insert / breadcrumb) and is
// immune to selection / focus churn.
const activeLoc = $derived(api.activeBlockLocation);
const activeIndex = $derived(activeLoc?.path[0] ?? -1);
// The raw active node — a `WordsBlock` OR a bare structural subtype
// (column / table-row / table-cell). All extend `Block`.
const activeNode = $derived(activeLoc?.node as BlockLike | undefined);
// Structural role of the active node (last ancestry crumb): 'column' |
// 'table-row' | 'table-cell' | 'list-item' | a block type. Drives the
// header label + which generic sections show.
const activeKind = $derived(activeLoc?.ancestry.at(-1)?.kind);
// The active node AS a typed block, or undefined for a bare structural
// node. Per-type UI gates on this; generic sections use `activeNode`.
const activeBlock = $derived.by<WordsBlock | undefined>(() =>
activeNode && 'type' in activeNode ? (activeNode as WordsBlock) : undefined
);
const activeBlockPath = $derived<readonly number[] | undefined>(activeLoc?.path);
// Delete the active TOP-LEVEL block (mirrors the gutter menu's Delete) and
// keep a block active by moving to the previous one (or the new first).
function deleteActiveBlock() {
if (activeIndex < 0) return;
// Capture the target BEFORE the delete — reading the doc AFTER
// `deleteBlock` returns the wrong block. Move to the previous block
// (or, when deleting the first, the one that becomes first).
const kids = api.document.children;
const prevId = (activeIndex > 0 ? kids[activeIndex - 1]?.id : kids[1]?.id) ?? null;
api.applyCommand({ type: 'deleteBlock', blockIndex: activeIndex });
api.setActiveBlock(prevId);
}
// Nesting depth of the active node (1 = top-level block, 2 = column,
// 3 = block-inside-a-column, …), capped at 4. Stamped on the inspector
// root so the header mark/wash picks up the SAME depth accent the canvas
// paints on the active-block outline — the two surfaces read in agreement.
const depth = $derived(Math.min(Math.max(activeLoc?.path.length ?? 1, 1), 4));
// Block TYPES offered in the active-column "Add block" menu. We list one
// entry per block KIND (from `schema.all()`), NOT the expanded menu
// entries (`schema.insertable()` flattens heading → H1/H2/H3 and list →
// bulleted/numbered/check, with hard-coded English labels). The column
// inserter only needs "add a heading" / "add a list" — the precise level
// / kind is then tuned in that block's own inspector panel. Excludes only
// `columns` (a column is a nested Stack; no columns-in-columns). Image IS
// offered: its `create()` builds an empty-src placeholder, and inserting
// auto-activates it so the inspector's image panel immediately prompts for
// the URL / Upload — the canonical placeholder-on-insert flow (same one the
// gutter inserter and slash menu use).
// Labels come from `blockKindLabel(type)` → fully localized.
const columnInsertableSpecs = $derived(
defaultWordsSchema.all().filter(
(spec) =>
spec.type !== 'columns' &&
// Must expose at least one menu entry whose `create()` builds
// the default instance of the kind.
(spec.menu?.length ?? 0) > 0
)
);
// Label for a child block in the active-column block list.
function childBlockLabel(b: WordsBlock): string {
return blockTitle(b);
}
// Make a child of the ACTIVE column the active block. The active
// column's materialized-path id + the child's local id = the child's
// full id path the provider resolves.
function selectChildInColumn(child: WordsBlock): void {
if (api.activeBlockId && child.id) {
api.setActiveBlock(`${api.activeBlockId}/${child.id}`);
}
}
// Insert a block into the active column and auto-activate it (soma owns
// active-block: `insertBlockInColumn` sets it from the pre-assigned id).
// `spec.create()` builds a fresh, default instance of the block kind
// (paragraph / heading-1 / bulleted list / …); the user refines level /
// kind afterwards in that block's inspector panel.
function insertIntoActiveColumn(colsIdx: number, colIdx: number, spec: WordsBlockSpec): void {
// The spec's FIRST menu entry is the kind's default (heading → H1,
// list → bulleted); `create()` lives on the entry, not the spec.
const newBlock = spec.menu?.[0]?.create();
if (!newBlock) return;
const newId = crypto.randomUUID();
api.applyCommand({
type: 'insertBlockInColumn',
columnsIdx: colsIdx,
colIdx,
block: { ...(newBlock as object), id: newId } as unknown as Readonly<Record<string, unknown>>
});
}
// Remove a child block from the active column (immutable rebuild via
// the path-aware update op). An EMPTY column is a valid, supported state
// — the engine renders the column wrapper with `data-words-id` so it
// stays clickable / addressable even with zero children, and neither the
// validator nor `normalizeColumns` re-seeds it (only the document root
// and lists do). So deleting the last block leaves the column genuinely
// empty; we don't reinsert a placeholder paragraph.
function deleteChildInColumn(
colsIdx: number,
colIdx: number,
childIdx: number,
children: readonly WordsBlock[]
): void {
const next = children.filter((_, i) => i !== childIdx);
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: [colsIdx, colIdx],
patch: { children: next }
});
}
// Reorder a child block within the active column by swapping it with
// its neighbour (`dir` = -1 up, +1 down). No-op at the ends. Same
// immutable `updateBlockAtPath` rebuild as delete — the array IS the
// order, so a swap is the whole operation.
function moveChildInColumn(
colsIdx: number,
colIdx: number,
childIdx: number,
dir: -1 | 1,
children: readonly WordsBlock[]
): void {
const target = childIdx + dir;
if (target < 0 || target >= children.length) return;
const next = [...children];
[next[childIdx], next[target]] = [next[target], next[childIdx]];
api.applyCommand({
type: 'updateBlockAtPath',
blockPath: [colsIdx, colIdx],
patch: { children: next }
});
}
// Ancestry crumbs for the breadcrumb (root → active node). Each crumb
// with an id is clickable → activates that ancestor. The document root
// (id null) is a non-clickable home label.
const breadcrumb = $derived(activeLoc?.ancestry ?? []);
function crumbLabel(node: WordsAncestryNode): string {
switch (node.kind) {
case 'document':
return t(L.BREADCRUMB_DOCUMENT);
case 'column':
return t(L.LABEL_COLUMN_N).replace('{n}', String(node.index + 1));
case 'list-item':
return t(L.BREADCRUMB_LIST_ITEM).replace('{n}', String(node.index + 1));
case 'table-row':
return t(L.BREADCRUMB_TABLE_ROW).replace('{n}', String(node.index + 1));
default:
return blockKindLabel(node.kind);
}
}
// Map a block type to its localized label. Reuses the same BLOCK_*
// keys the header title uses so breadcrumb + title stay consistent.
function blockKindLabel(kind: string): string {
const map: Record<string, string> = {
paragraph: L.BLOCK_PARAGRAPH,
heading: L.BLOCK_HEADING,
quote: L.BLOCK_QUOTE,
code: L.BLOCK_CODE,
list: L.BLOCK_LIST_BULLET,
table: L.BLOCK_TABLE,
image: L.BLOCK_IMAGE,
divider: L.BLOCK_DIVIDER,
callout: L.BLOCK_CALLOUT,
columns: L.BLOCK_COLUMNS
};
return t(map[kind] ?? L.BLOCK_GENERIC);
}
function edit(visual: Partial<WordsBlock>) {
const path = activeBlockPath;
if (!path) return;
// Path-aware route when the active node is nested; otherwise the
// top-level command is cheaper (no walk).
if (path.length > 1) {
api.applyCommand({ type: 'setBlockVisualAtPath', blockPath: path, visual });
} else {
api.applyCommand({ type: 'setBlockVisual', blockIndex: path[0], visual });
}
}
// Which inspector sections are expanded. Multiple may be open. The
// block-specific section opens by default when present.
let openSections = $state<string[]>(['block', 'blocks', 'typography']);
// Which block types expose per-type props in the WordsBlockPanel.
function blockHasPanelProps(b: WordsBlock): boolean {
return (
b.type === 'heading' ||
b.type === 'list' ||
b.type === 'code' ||
b.type === 'image' ||
b.type === 'callout' ||
b.type === 'table' ||
b.type === 'columns'
);
}
// Per-kind relevance of the generic accordion sections. Each generic
// section shows only when it applies to the active node's nature.
// Structural Block-subtypes (column / table-cell / list-item) get the
// full set — they extend Block, so their box + cascading text props are
// all editable. `table-row` is visual-box-only (height + color +
// spacing + border; no text props at the row level).
type GenericSection = 'typography' | 'color' | 'layout' | 'spacing' | 'border';
const SECTION_VISIBILITY: Record<string, ReadonlySet<GenericSection>> = {
paragraph: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
heading: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
quote: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
code: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
list: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
callout: new Set(['typography', 'color', 'layout', 'spacing', 'border']),
image: new Set<GenericSection>(['spacing', 'border']),
divider: new Set<GenericSection>(['color', 'spacing']),
table: new Set<GenericSection>(), // none — Block panel owns the scopes
columns: new Set<GenericSection>(['spacing']), // gap on the wrapper; per-column visuals live on the column
column: new Set<GenericSection>(['typography', 'color', 'layout', 'spacing', 'border']),
'table-cell': new Set<GenericSection>(['typography', 'color', 'layout', 'spacing', 'border']),
'list-item': new Set<GenericSection>(['typography', 'color', 'layout', 'spacing', 'border']),
'table-row': new Set<GenericSection>(['color', 'spacing', 'border'])
};
function shows(section: GenericSection): boolean {
const allowed = activeKind ? SECTION_VISIBILITY[activeKind] : undefined;
return allowed ? allowed.has(section) : true;
}
type AlignValue = 'left' | 'center' | 'right' | 'justify';
type BorderStyleValue = 'solid' | 'dashed' | 'dotted';
// UI-level chip value: the stored styles PLUS a `none` sentinel that
// clears the border. `none` is never written to the model — it maps to
// `border: undefined`.
type BorderChipValue = 'none' | BorderStyleValue;
// Option arrays are `$derived` so labels re-evaluate on locale change.
const ALIGNS = $derived<readonly { v: AlignValue; label: string; icon: Component }[]>([
{ v: 'left', label: t(L.ALIGN_LEFT), icon: TextAlignStart },
{ v: 'center', label: t(L.ALIGN_CENTER), icon: TextAlignCenter },
{ v: 'right', label: t(L.ALIGN_RIGHT), icon: TextAlignEnd },
{ v: 'justify', label: t(L.ALIGN_JUSTIFY), icon: TextAlignJustify }
]);
const BORDER_STYLES = $derived<readonly { v: BorderChipValue; label: string }[]>([
{ v: 'none', label: t(L.BORDER_NONE) },
{ v: 'solid', label: t(L.BORDER_SOLID) },
{ v: 'dashed', label: t(L.BORDER_DASHED) },
{ v: 'dotted', label: t(L.BORDER_DOTTED) }
]);
const FONTS = $derived<readonly { v: string; label: string }[]>([
{ v: 'default', label: t(L.FONT_DEFAULT) },
{ v: 'sans-serif', label: t(L.FONT_SANS) },
{ v: 'serif', label: t(L.FONT_SERIF) },
{ v: 'monospace', label: t(L.FONT_MONO) }
]);
const WEIGHTS = $derived<readonly { v: string; label: string }[]>([
{ v: '400', label: t(L.WEIGHT_REGULAR) },
{ v: '500', label: t(L.WEIGHT_MEDIUM) },
{ v: '600', label: t(L.WEIGHT_SEMIBOLD) },
{ v: '700', label: t(L.WEIGHT_BOLD) }
]);
const COLOR_PRESETS = [
'#e5484d',
'#f76b15',
'#ffc53d',
'#46a758',
'#0091ff',
'#8e4ec6',
'#e93d82',
'#646a73',
'#1a1a1a',
'#ffffff'
];
// Corner-radius presets → px. The radius is a ToggleGroup of named steps
// (not a free slider): none / S / M / L / Full, mapping to the global
// radius scale. `9999` is the canonical "pill" sentinel the render clamps
// to half the box. (border-radius only shows once the block carries a
// border OR a background — render.ts emits it either way.)
const RADIUS_PRESETS = { none: 0, sm: 4, md: 8, lg: 16, full: 9999 } as const;
function radiusPreset(radius: number | undefined): string {
switch (radius) {
case 4:
return 'sm';
case 8:
return 'md';
case 16:
return 'lg';
case 9999:
return 'full';
default:
return 'none';
}
}
function blockTitle(b: WordsBlock): string {
switch (b.type) {
case 'heading':
return `${t(L.BLOCK_HEADING)} ${b.level}`;
case 'paragraph':
return t(L.BLOCK_PARAGRAPH);
case 'quote':
return t(L.BLOCK_QUOTE);
case 'code':
return t(L.BLOCK_CODE);
case 'list':
return b.kind === 'ordered'
? t(L.BLOCK_LIST_ORDERED)
: b.kind === 'check'
? t(L.BLOCK_LIST_CHECK)
: t(L.BLOCK_LIST_BULLET);
case 'table':
return t(L.BLOCK_TABLE);
case 'image':
return t(L.BLOCK_IMAGE);
case 'divider':
return t(L.BLOCK_DIVIDER);
case 'callout':
return t(L.BLOCK_CALLOUT);
case 'columns':
return t(L.BLOCK_COLUMNS);
default:
return t(L.BLOCK_GENERIC);
}
}
</script>
<div
data-words-inspector
data-current-block={activeBlock?.type ?? ''}
data-words-inspector-depth={depth}
>
{#if activeNode}
{@const block = activeNode}
<header data-words-inspector-head>
{#if breadcrumb.length > 2}
<nav data-words-inspector-breadcrumb aria-label="Block hierarchy">
{#each breadcrumb.slice(0, -1) as crumb, i (i)}
{#if i > 0}<span data-words-breadcrumb-sep aria-hidden="true">›</span>{/if}
{#if crumb.id}
<button
type="button"
data-words-breadcrumb-crumb
onclick={() => api.setActiveBlock(crumb.idPath)}
>
{crumbLabel(crumb)}
</button>
{:else}
<span data-words-breadcrumb-crumb data-root="">{crumbLabel(crumb)}</span>
{/if}
{/each}
</nav>
{/if}
<div data-words-inspector-title>
<span data-words-inspector-title-mark aria-hidden="true"></span>
<span data-words-inspector-title-text>
{#if activeBlock}{blockTitle(activeBlock)}{:else if activeLoc}{crumbLabel(
activeLoc.ancestry[activeLoc.ancestry.length - 1]
)}{/if}
</span>
{#if activeIndex >= 0}
<Button
data-words-inspector-delete
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Delete block"
onclick={deleteActiveBlock}
>
{#snippet icon()}<Trash2 />{/snippet}
</Button>
{/if}
</div>
</header>
<Accordion type="multiple" bind:value={openSections} variant="ghost" size="sm">
{#if activeBlock && blockHasPanelProps(activeBlock)}
<Accordion.Item value="block">
<Accordion.Header>
<Accordion.Trigger>{t(L.SECTION_BLOCK)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<WordsBlockPanel
block={activeBlock}
blockIndex={activeIndex}
blockPath={activeBlockPath ?? null}
selectionPath={api.selection?.anchor.path ?? null}
applyCommand={api.applyCommand}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if activeKind === 'column' && activeBlockPath}
{@const colsIdx = activeBlockPath[0]}
{@const colIdx = activeBlockPath[1]}
{@const colChildren = (block as Column).children}
<Accordion.Item value="blocks">
<Accordion.Header>
<Accordion.Trigger>{t(L.LABEL_COLUMN_BLOCKS)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
{#if colChildren.length > 0}
<div data-words-inspector-block-list>
{#each colChildren as child, childIdx (child.id ?? childIdx)}
<div data-words-inspector-block-item>
<button
type="button"
data-words-inspector-block-select
aria-label={t(L.ARIA_SELECT_BLOCK_IN_COLUMN)}
onclick={() => selectChildInColumn(child)}
>
{childBlockLabel(child)}
</button>
<Button
variant="ghost"
size="xs"
iconOnly
disabled={childIdx === 0}
aria-label={t(L.ARIA_MOVE_BLOCK_UP)}
onclick={() =>
moveChildInColumn(colsIdx, colIdx, childIdx, -1, colChildren)}
>
{#snippet icon()}<ChevronUp />{/snippet}
</Button>
<Button
variant="ghost"
size="xs"
iconOnly
disabled={childIdx === colChildren.length - 1}
aria-label={t(L.ARIA_MOVE_BLOCK_DOWN)}
onclick={() => moveChildInColumn(colsIdx, colIdx, childIdx, 1, colChildren)}
>
{#snippet icon()}<ChevronDown />{/snippet}
</Button>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label={t(L.ARIA_DELETE_BLOCK_IN_COLUMN)}
onclick={() => deleteChildInColumn(colsIdx, colIdx, childIdx, colChildren)}
>
{#snippet icon()}<Trash2 />{/snippet}
</Button>
</div>
{/each}
</div>
{/if}
<div data-words-inspector-row data-inline>
<DropdownMenu>
<DropdownMenu.Trigger
variant="ghost"
size="xs"
iconOnly
aria-label={t(L.LABEL_ADD_BLOCK)}
title={t(L.LABEL_ADD_BLOCK)}
>
{#snippet icon()}<Plus />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content side="bottom" align="start">
{#each columnInsertableSpecs as spec (spec.type)}
<DropdownMenu.Item
onSelect={() => insertIntoActiveColumn(colsIdx, colIdx, spec)}
>
{blockKindLabel(spec.type)}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('typography')}
<Accordion.Item value="typography">
<Accordion.Header>
<Accordion.Trigger>{t(L.SECTION_TYPOGRAPHY)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_FONT)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.fontFamily ?? 'default']}
onValueChange={(v: string[]) =>
edit({ fontFamily: !v[0] || v[0] === 'default' ? undefined : v[0] })}
aria-label={t(L.ARIA_FONT_FAMILY)}
>
{#each FONTS as f (f.v)}
<ToggleGroup.Item value={f.v}>{f.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<WordsNumRow
label={t(L.LABEL_FONT_SIZE)}
value={block.fontSize ?? 16}
min={12}
max={40}
step={1}
onCommit={(n) => edit({ fontSize: n })}
/>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_WEIGHT)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[String(block.fontWeight ?? 400)]}
onValueChange={(v: string[]) =>
edit({ fontWeight: !v[0] || Number(v[0]) === 400 ? undefined : Number(v[0]) })}
aria-label={t(L.ARIA_FONT_WEIGHT)}
>
{#each WEIGHTS as w (w.v)}
<ToggleGroup.Item value={w.v}>{w.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<WordsNumRow
label={t(L.LABEL_LINE_HEIGHT)}
value={block.lineHeight ?? 1.6}
min={1}
max={2.5}
step={0.1}
onCommit={(n) => edit({ lineHeight: Math.round(n * 10) / 10 })}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('color')}
<Accordion.Item value="color">
<Accordion.Header>
<Accordion.Trigger>{t(L.SECTION_COLOR)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<WordsColorRow
label={t(L.LABEL_TEXT)}
current={block.color}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ color: hex })}
/>
<WordsColorRow
label={t(L.LABEL_BACKGROUND)}
current={block.background}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ background: hex })}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('layout')}
<Accordion.Item value="layout">
<Accordion.Header>
<Accordion.Trigger>{t(L.SECTION_LAYOUT)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_ALIGN)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.align ?? 'left']}
onValueChange={(v: string[]) =>
edit({ align: !v[0] || v[0] === 'left' ? undefined : (v[0] as AlignValue) })}
aria-label={t(L.ARIA_ALIGN)}
>
{#each ALIGNS as a (a.v)}
{@const Icon = a.icon}
<ToggleGroup.Item value={a.v} aria-label={a.label} title={a.label}>
<Icon />
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('spacing')}
<Accordion.Item value="spacing">
<Accordion.Header>
<Accordion.Trigger>{t(L.SECTION_SPACING)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<WordsSpacingRow
label={t(L.LABEL_MARGIN)}
value={block.margin}
onCommit={(next) => edit({ margin: next })}
/>
<WordsSpacingRow
label={t(L.LABEL_PADDING)}
value={block.padding}
onCommit={(next) => edit({ padding: next })}
/>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
{#if shows('border')}
<Accordion.Item value="border">
<Accordion.Header>
<Accordion.Trigger>{t(L.SECTION_BORDER)}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
<div data-words-inspector-section>
<div data-words-inspector-row>
<span data-words-inspector-label>{t(L.LABEL_STYLE)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
block
value={[block.border?.style ?? 'none']}
onValueChange={(v: string[]) => {
// `none` clears the border; the three real styles narrow back
// to BorderStyleValue for the model write below.
const chip = (v[0] as BorderChipValue) ?? 'none';
const style = chip === 'none' ? undefined : (chip as BorderStyleValue);
edit({
border: style
? { ...block.border, style, width: block.border?.width || 1 }
: undefined
});
}}
aria-label={t(L.ARIA_BORDER_STYLE)}
>
{#each BORDER_STYLES as b (b.v)}
<ToggleGroup.Item value={b.v} aria-label={b.label} title={b.label}>
<!-- Visual chip = an actual border-{style} line. No lucide
icon exists for solid/dashed/dotted; rendering the
CSS style itself makes the chip self-documenting. -->
{#if b.v === 'none'}
{b.label}
{:else}
<span data-words-border-style-swatch={b.v}></span>
{/if}
</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
<WordsNumRow
label={t(L.LABEL_BORDER_WIDTH)}
value={block.border?.width ?? 0}
min={0}
max={8}
step={1}
sliderOnly
onCommit={(n) => edit({ border: { ...block.border, width: n } })}
/>
<WordsColorRow
label={t(L.LABEL_COLOR)}
current={block.border?.color}
presets={COLOR_PRESETS}
onPick={(hex) => edit({ border: { ...block.border, color: hex } })}
/>
<div data-words-inspector-row data-inline>
<span data-words-inspector-label>{t(L.LABEL_CORNER_RADIUS)}</span>
<ToggleGroup
type="single"
size="xs"
variant="ghost"
attached
value={[radiusPreset(block.border?.radius)]}
onValueChange={(v: string[]) => {
const px = RADIUS_PRESETS[v[0] as keyof typeof RADIUS_PRESETS];
if (px !== undefined) edit({ border: { ...block.border, radius: px } });
}}
aria-label={t(L.ARIA_RADIUS_PRESET)}
>
<ToggleGroup.Item value="none">{t(L.PRESET_NONE)}</ToggleGroup.Item>
<ToggleGroup.Item value="sm">S</ToggleGroup.Item>
<ToggleGroup.Item value="md">M</ToggleGroup.Item>
<ToggleGroup.Item value="lg">L</ToggleGroup.Item>
<ToggleGroup.Item value="full">{t(L.PRESET_FULL)}</ToggleGroup.Item>
</ToggleGroup>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
</Accordion>
{:else}
<p data-words-inspector-empty>{t(L.EMPTY)}</p>
{/if}
</div>

@ -1,311 +0,0 @@
<script lang="ts">
/**
* Editor menu bar — the persistent formatting toolbar at the top of
* `<Words>`.
*
* Composition mirrors the selection bubble (the canonical consumer
* pattern), just docked instead of floating:
* - soma `Words.Toolbar` is the role=toolbar container.
* - inline marks are soma `Words.CommandButton`s rendered through their
* `child` snippet as eidos `Button`s — the CommandButton restores the
* DOM range before the command runs and reflects active/disabled state.
* - block transforms (turn-into, lists) go through `api.applyCommand`,
* with active state derived from the current block (same `effectiveBlock`
* descent the bubble uses, so it's correct inside columns).
* - history / insert / view controls are plain eidos `Button`s wired to
* the `api`.
*
* View controls on the right: an Edit/Preview toggle (binds `mode`, which
* the parent maps to the provider's `readonly`) and an inspector toggle.
*/
import * as Words from '$soma/components/words';
import { Button } from '$uix/eidos/components/button';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import {
Undo2,
Redo2,
ChevronDown,
Bold,
Italic,
Underline,
Strikethrough,
Code,
List,
ListOrdered,
ListChecks,
TextAlignStart,
TextAlignCenter,
TextAlignEnd,
Image,
Table,
Minus,
Plus,
Check,
SlidersHorizontal
} from '$uix/eidos/components/icon';
import type {
ProviderSnippetProps,
WordsBlock,
WordsHeadingLevel,
WordsListKind
} from '$soma/components/words';
import type { WordsInspectorMode } from './types';
let {
api,
mode = $bindable('edit'),
inspector,
inspectorOpen,
onToggleInspector
}: {
api: ProviderSnippetProps;
mode: 'edit' | 'preview';
inspector: WordsInspectorMode;
inspectorOpen: boolean;
onToggleInspector: () => void;
} = $props();
const preview = $derived(mode === 'preview');
// Convertible blocks — turn-into / lists no-op on table / image / divider /
// callout, so disable those controls there instead of offering inert ops.
const CONVERTIBLE = new Set(['paragraph', 'heading', 'quote', 'code', 'list']);
// Effective block under the caret. When inside a column the top-level
// `api.currentBlock` is `'columns'`, but the user edits the NESTED block —
// descend the selection path to resolve the one turn-into can convert
// (same logic as the bubble; the engine's setBlock is columns-recursive).
const block = $derived.by<{ type: string; level?: WordsHeadingLevel; kind?: WordsListKind }>(
() => {
const path = api.selection?.anchor.path;
if (path && path.length > 0) {
const top = api.document.children[path[0]] as WordsBlock | undefined;
if (top?.type === 'columns' && path.length >= 3) {
const inner = top.columns[path[1] ?? 0]?.children[path[2] ?? 0];
if (inner) {
return {
type: inner.type,
level: inner.type === 'heading' ? inner.level : undefined,
kind: inner.type === 'list' ? inner.kind : undefined
};
}
}
return {
type: api.currentBlock,
level: api.currentHeadingLevel,
kind: top?.type === 'list' ? top.kind : undefined
};
}
return { type: api.currentBlock, level: api.currentHeadingLevel };
}
);
const canTurnInto = $derived(CONVERTIBLE.has(block.type));
const blockLabel = $derived.by(() => {
switch (block.type) {
case 'heading':
return `Heading ${block.level ?? 1}`;
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
return block.kind === 'ordered'
? 'Numbered list'
: block.kind === 'check'
? 'Check list'
: 'Bulleted list';
case 'paragraph':
return 'Text';
default:
return blockLabelFallback(block.type);
}
});
function blockLabelFallback(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
const TURN_INTO = $derived.by(() => [
{ id: 'paragraph', label: 'Text', active: block.type === 'paragraph', run: () => api.applyCommand({ type: 'setBlock', block: 'paragraph' }) },
...([1, 2, 3] as const).map((level: WordsHeadingLevel) => ({
id: `heading-${level}`,
label: `Heading ${level}`,
active: block.type === 'heading' && (block.level ?? 1) === level,
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
})),
{ id: 'quote', label: 'Quote', active: block.type === 'quote', run: () => api.applyCommand({ type: 'setBlock', block: 'quote' }) },
{ id: 'code', label: 'Code', active: block.type === 'code', run: () => api.applyCommand({ type: 'setBlock', block: 'code' }) }
]);
const MARKS = [
{ command: 'bold', label: 'Bold', icon: Bold },
{ command: 'italic', label: 'Italic', icon: Italic },
{ command: 'underline', label: 'Underline', icon: Underline },
{ command: 'strike', label: 'Strikethrough', icon: Strikethrough },
{ command: 'code', label: 'Inline code', icon: Code }
] as const;
const LISTS = $derived.by(() => [
{ kind: 'unordered' as WordsListKind, label: 'Bulleted list', icon: List },
{ kind: 'ordered' as WordsListKind, label: 'Numbered list', icon: ListOrdered },
{ kind: 'check' as WordsListKind, label: 'Check list', icon: ListChecks }
]);
const ALIGN = [
{ command: 'align-left', label: 'Align left', icon: TextAlignStart },
{ command: 'align-center', label: 'Align center', icon: TextAlignCenter },
{ command: 'align-right', label: 'Align right', icon: TextAlignEnd }
] as const;
const INSERTS = [
{ id: 'image', label: 'Image', icon: Image },
{ id: 'table', label: 'Table', icon: Table },
{ id: 'divider', label: 'Divider', icon: Minus }
] as const;
let insertOpen = $state(false);
let blockOpen = $state(false);
const showInspectorToggle = $derived(inspector === 'drawer' || inspector === 'sidebar');
</script>
<Words.Toolbar data-words-menubar data-mode={mode}>
<!-- History -->
<div data-words-menubar-group>
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Undo"
disabled={preview || !api.canUndo}
onclick={() => api.undo()}
>
{#snippet icon()}<Undo2 />{/snippet}
</Button>
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Redo"
disabled={preview || !api.canRedo}
onclick={() => api.redo()}
>
{#snippet icon()}<Redo2 />{/snippet}
</Button>
</div>
<!-- Block style -->
<div data-words-menubar-group>
<DropdownMenu bind:open={blockOpen}>
<DropdownMenu.Trigger variant="ghost" size="xs" disabled={preview || !canTurnInto}>
{blockLabel}
{#snippet endIcon()}<ChevronDown />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start">
{#each TURN_INTO as item (item.id)}
<DropdownMenu.Item onSelect={item.run}>
<span>{item.label}</span>
{#if item.active}<Check size="sm" />{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu>
</div>
<!-- Inline marks -->
<div data-words-menubar-group>
{#each MARKS as mark (mark.command)}
{@const Glyph = mark.icon}
<Words.CommandButton command={mark.command} aria-label={mark.label}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly disabled={preview}>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
<!-- Lists -->
<div data-words-menubar-group>
{#each LISTS as list (list.kind)}
{@const Glyph = list.icon}
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label={list.label}
data-active={block.type === 'list' && block.kind === list.kind ? '' : undefined}
disabled={preview}
onclick={() => api.applyCommand({ type: 'toggleList', kind: list.kind })}
>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/each}
</div>
<!-- Align -->
<div data-words-menubar-group>
{#each ALIGN as a (a.command)}
{@const Glyph = a.icon}
<Words.CommandButton command={a.command} aria-label={a.label}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="xs" iconOnly disabled={preview}>
{#snippet icon()}<Glyph />{/snippet}
</Button>
{/snippet}
</Words.CommandButton>
{/each}
</div>
<!-- Insert -->
<div data-words-menubar-group>
<DropdownMenu bind:open={insertOpen}>
<DropdownMenu.Trigger variant="ghost" size="xs" iconOnly rounded="md" aria-label="Insert" disabled={preview}>
{#snippet icon()}<Plus />{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom" align="start">
{#each INSERTS as ins (ins.id)}
{@const Glyph = ins.icon}
<DropdownMenu.Item onSelect={() => api.insertBlockOfType(ins.id)}>
<Glyph size="sm" />
<span>{ins.label}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu>
</div>
<span data-words-menubar-spacer></span>
<!-- View controls -->
{#if showInspectorToggle}
<Button
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Toggle inspector"
data-active={inspectorOpen ? '' : undefined}
onclick={onToggleInspector}
>
{#snippet icon()}<SlidersHorizontal />{/snippet}
</Button>
{/if}
<ToggleGroup
size="xs"
value={[mode]}
onValueChange={(v: string[]) => {
if (v[0] === 'edit' || v[0] === 'preview') mode = v[0];
}}
aria-label="Edit or preview"
>
<ToggleGroup.Item value="edit">Edit</ToggleGroup.Item>
<ToggleGroup.Item value="preview">Preview</ToggleGroup.Item>
</ToggleGroup>
</Words.Toolbar>

@ -1,120 +0,0 @@
<script lang="ts">
/**
* Inspector numeric row — labelled Slider bound to a single block style
* value (font size, line height, margin/padding, border width, corner
* radius, etc.).
*
* Pattern: local `$state` draft + `onValueChange` (mid-drag, label only)
* + `onValueCommit` (release, engine commit). Same architecture as
* `words-color-row.svelte`.
*
* Anti-pattern that this component replaces — the original inline
* `numRow` snippet wired `onValueChange` straight to
* `inspector.applyCommand({type:'setBlockVisual', ...})`. Every
* `pointermove` during a slider drag dispatched ONE engine edit:
*
* pointermove → onValueChange → applyCommand → normalizeDocument
* → publishHistory → document re-derive → editor + inspector
* re-render → value prop changes → slider re-sync.
*
* At 60-120Hz a 1-second drag pushed 60-120 history entries and
* triggered the same number of editor re-renders. The Editor visibly
* stuttered and the undo stack was unusable.
*
* The fix mirrors the color-picker draft pattern: the slider mutates
* a LOCAL draft per pointermove (label updates live), the engine
* receives ONE commit on release. The `$effect` syncs `draft` ←
* external `value` only when `value` changes — read `draft` inside
* `untrack` so the effect doesn't re-run when the slider writes to it,
* which would otherwise clobber the user's mid-drag value with the
* still-committed `value` prop.
*/
import { untrack } from 'svelte';
import { NumberField } from '$uix/eidos/components/number-field';
import { Slider } from '$uix/eidos/components/slider';
let {
label,
value,
min,
max,
step,
onCommit,
sliderOnly = false
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onCommit: (n: number) => void;
/** Drop the editable NumberField and show a read-only value chip next
* to the label — the slider IS the control. Use when a steppable
* field + a slider would be redundant (e.g. corner radius, border
* width: the slider's range IS the meaningful interaction; a paired
* numeric stepper just duplicates it). @default false */
sliderOnly?: boolean;
} = $props();
// Initialize from `value` once at mount; the `$effect` below keeps it
// synced thereafter (only when `value` actually changes — `draft` is
// read inside `untrack` so mid-drag writes don't re-trigger the sync).
// svelte-ignore state_referenced_locally
let draft = $state(value);
$effect(() => {
const next = value;
untrack(() => {
if (next !== draft) draft = next;
});
});
</script>
<div data-words-inspector-row>
<div data-words-inspector-rowhead>
<span data-words-inspector-label>{label}</span>
{#if sliderOnly}
<!-- Slider-only mode: a read-only value chip, no stepper. The slider
below is the sole control — a paired NumberField would just
duplicate it. `draft` updates live during drag. -->
<span data-words-inspector-value>{draft}</span>
{:else}
<!-- Editable numeric input — typing commits on blur / Enter
(`onValueCommit`). `min` + `max` propagate so the committed value
is clamped to the same range the slider enforces — a font-size or
line-height can't be typed to an absurd value (e.g. line-height
191). The NumberField clamps on commit; the slider clamps
visually. `step` keeps decimals (e.g. 0.1 for line-height). -->
<NumberField
bind:value={draft}
{min}
{max}
{step}
size="xs"
variant="ghost"
onValueCommit={(n: number) => onCommit(n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
{/if}
</div>
<Slider
value={[draft]}
{min}
{max}
{step}
aria-label={label}
onValueChange={(v: number[]) => {
const n = v[0];
if (n !== undefined) draft = n;
}}
onValueCommit={(v: number[]) => {
const n = v[0];
if (n !== undefined) onCommit(n);
}}
>
<Slider.Range />
<Slider.Thumb />
</Slider>
</div>

@ -1,272 +0,0 @@
<script lang="ts">
/**
* Inspector spacing row — per-pair chained 4-side editor.
*
* Restructured 2026-05-30 after the single-link-toggle prototype
* failed user-acceptance: a single toggle reads as "join all 4
* together" no matter what the underlying code does. The fix is
* **two independent link toggles, one per pair**:
*
* Block pair (Top + Bottom) — its own 🔗 toggle
* Inline pair (Left + Right) — its own 🔗 toggle
*
* Each pair behaves as a self-contained unit:
* • Linked → editing one side propagates ONLY to its pair sibling.
* • Unlinked → that side is independent of the rest.
* The two pairs never affect each other, regardless of toggle state.
*
* Default for each pair: linked when its two sides are equal at mount
* (the overwhelmingly common case). The user can break the chain
* per pair without affecting the other.
*
* Data model — engine `WordsSpacing` accepts the compact axis shape
* (`block`/`inline`) AND optional per-side keys
* (`blockStart`/`blockEnd`/`inlineStart`/`inlineEnd`). Whenever a
* pair is symmetric we write its axis key; the moment a pair splits
* we write the two per-side keys for that pair (the other pair can
* still be a single axis key). `emit()` chooses the shape
* automatically based on the resulting `sides` snapshot.
*
* `onCommit` receives the next `WordsSpacing` (or `undefined` to
* clear all spacing on the block). Commits fire on blur / Enter via
* the underlying NumberField; mid-typing keystrokes only mutate the
* local draft, never the engine.
*/
import { untrack } from 'svelte';
import { NumberField } from '$uix/eidos/components/number-field';
import Toggle from '$uix/eidos/components/toggle';
import { Link, Unlink } from '$uix/eidos/components/icon';
import { ActiveEidos } from '$uix/eidos';
import { WORDS_INSPECTOR_LANGS as L } from './langs-inspector';
import type { WordsSpacing } from '$soma/components/words';
const eidos = ActiveEidos.require();
const t = (key: string) => eidos.langs.ts(key);
let {
label,
value,
onCommit
}: {
label: string;
value: WordsSpacing | undefined;
onCommit: (next: WordsSpacing | undefined) => void;
} = $props();
function readSides(s: WordsSpacing | undefined): {
top: number;
right: number;
bottom: number;
left: number;
} {
// Per-side keys win over axis keys (matches engine render.ts).
const top = s?.blockStart ?? s?.block ?? 0;
const bottom = s?.blockEnd ?? s?.block ?? 0;
const left = s?.inlineStart ?? s?.inline ?? 0;
const right = s?.inlineEnd ?? s?.inline ?? 0;
return { top, right, bottom, left };
}
// svelte-ignore state_referenced_locally
let sides = $state(readSides(value));
// Per-pair link state. Each pair gets its OWN toggle so the user can
// have symmetric vertical AND asymmetric horizontal (or any combo).
// svelte-ignore state_referenced_locally
let blockLinked = $state(sides.top === sides.bottom);
// svelte-ignore state_referenced_locally
let inlineLinked = $state(sides.left === sides.right);
$effect(() => {
const next = readSides(value);
untrack(() => {
if (
next.top !== sides.top ||
next.right !== sides.right ||
next.bottom !== sides.bottom ||
next.left !== sides.left
) {
sides = next;
}
});
});
function emit(next: { top: number; right: number; bottom: number; left: number }): void {
const allZero = next.top === 0 && next.right === 0 && next.bottom === 0 && next.left === 0;
if (allZero) {
onCommit(undefined);
return;
}
const blockEqual = next.top === next.bottom;
const inlineEqual = next.left === next.right;
if (blockEqual && inlineEqual) {
// Both pairs symmetric → compact axis shape.
onCommit({ block: next.top, inline: next.left });
return;
}
// At least one pair is split. Write a mixed shape: the symmetric
// pair keeps its axis key, the split pair writes its per-side
// keys. The engine reads per-side ?? axis, so this is unambiguous.
// `WordsSpacing` keys are `readonly`; build a mutable record then
// hand it off as `WordsSpacing` once frozen by the caller.
const out: {
block?: number;
inline?: number;
blockStart?: number;
blockEnd?: number;
inlineStart?: number;
inlineEnd?: number;
} = {};
if (blockEqual) {
out.block = next.top;
} else {
out.blockStart = next.top;
out.blockEnd = next.bottom;
}
if (inlineEqual) {
out.inline = next.left;
} else {
out.inlineStart = next.left;
out.inlineEnd = next.right;
}
onCommit(out as WordsSpacing);
}
function setSide(which: 'top' | 'right' | 'bottom' | 'left', n: number): void {
if ((which === 'top' || which === 'bottom') && blockLinked) {
sides = { ...sides, top: n, bottom: n };
} else if ((which === 'left' || which === 'right') && inlineLinked) {
sides = { ...sides, left: n, right: n };
} else {
sides = { ...sides, [which]: n };
}
emit(sides);
}
// Toggling a pair to LINKED must reconcile its two sides immediately —
// the user expects "linked" to mean the values are already in sync, not
// "they'll sync on the next edit". We adopt the LARGER of the two (so a
// link never silently shrinks a side) and commit it. Unlinking leaves
// the current values untouched.
function setBlockLinked(linked: boolean): void {
blockLinked = linked;
if (linked && sides.top !== sides.bottom) {
const v = Math.max(sides.top, sides.bottom);
sides = { ...sides, top: v, bottom: v };
emit(sides);
}
}
function setInlineLinked(linked: boolean): void {
inlineLinked = linked;
if (linked && sides.left !== sides.right) {
const v = Math.max(sides.left, sides.right);
sides = { ...sides, left: v, right: v };
emit(sides);
}
}
</script>
<div data-words-inspector-spacing>
<div data-words-inspector-spacing-head>
<span data-words-inspector-label>{label}</span>
</div>
<div data-words-inspector-spacing-grid>
<!-- Block pair (Top + Bottom) — its OWN link toggle. -->
<div data-words-inspector-spacing-pair>
<div data-words-inspector-spacing-cell data-side="top">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_TOP)}</span>
<NumberField
value={sides.top}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_TOP)}
onValueCommit={(n: number) => setSide('top', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-spacing-cell data-side="bottom">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_BOTTOM)}</span>
<NumberField
value={sides.bottom}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_BOTTOM)}
onValueCommit={(n: number) => setSide('bottom', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<Toggle
size="xs"
variant="ghost"
pressed={blockLinked}
onPressedChange={(p: boolean) => setBlockLinked(p)}
aria-label={blockLinked ? t(L.SPACING_UNLINK) : t(L.SPACING_LINK)}
title={blockLinked ? t(L.SPACING_UNLINK_TITLE) : t(L.SPACING_LINK_TITLE)}
>
{#if blockLinked}
<Link size="14px" />
{:else}
<Unlink size="14px" />
{/if}
</Toggle>
</div>
<!-- Inline pair (Left + Right) — its OWN link toggle. -->
<div data-words-inspector-spacing-pair>
<div data-words-inspector-spacing-cell data-side="left">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_LEFT)}</span>
<NumberField
value={sides.left}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_LEFT)}
onValueCommit={(n: number) => setSide('left', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<div data-words-inspector-spacing-cell data-side="right">
<span data-words-inspector-spacing-cell-label>{t(L.SPACING_RIGHT)}</span>
<NumberField
value={sides.right}
min={0}
step={1}
size="xs"
variant="ghost"
aria-label={t(L.SPACING_RIGHT)}
onValueCommit={(n: number) => setSide('right', n)}
>
<NumberField.DecrementTrigger>−</NumberField.DecrementTrigger>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
</div>
<Toggle
size="xs"
variant="ghost"
pressed={inlineLinked}
onPressedChange={(p: boolean) => setInlineLinked(p)}
aria-label={inlineLinked ? t(L.SPACING_UNLINK) : t(L.SPACING_LINK)}
title={inlineLinked ? t(L.SPACING_UNLINK_TITLE) : t(L.SPACING_LINK_TITLE)}
>
{#if inlineLinked}
<Link size="14px" />
{:else}
<Unlink size="14px" />
{/if}
</Toggle>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

@ -1,182 +0,0 @@
<script lang="ts">
import './words.css';
/**
* Eidos `<Words>` — visual wrapper over the headless Words editor.
*
* The soma `Words.Provider` owns the document model, selection,
* contenteditable wiring and the `data-words*` attributes the morfo
* runtime emits. Eidos owns the look: the contenteditable surface +
* placeholder, the left block gutter, the selection bubble, and the
* block inspector.
*
* The inspector's presentation is a prop (`inspector`): a fixed sidebar
* (builder-style, always visible), a slide-in drawer, a popover, or
* none. The same `<WordsInspector>` body is reused across all of them.
*/
import { untrack } from 'svelte';
import { ActiveEidos } from '$uix/eidos';
import * as Words from '$soma/components/words';
import { Button } from '$uix/eidos/components/button';
import { Popover } from '$uix/eidos/components/popover';
import { SlidersHorizontal, X } from '$uix/eidos/components/icon';
import WordsActivate from './words-activate.svelte';
import WordsActiveRail from './words-active-rail.svelte';
import WordsBlockDrag from './words-block-drag.svelte';
import WordsBlockGutter from './words-block-gutter.svelte';
import WordsBubble from './words-bubble.svelte';
import WordsInspector from './words-inspector.svelte';
import WordsMenubar from './words-menubar.svelte';
import { WORDS_INSPECTOR_BUNDLE } from './langs-inspector';
import { WORDS_MENU_BUNDLE } from './langs-menu';
import type { WordsProps } from './types';
let {
size = 'md',
value = $bindable(),
selection = $bindable(null),
inspector = 'sidebar',
toolbar = true,
...headlessProps
}: WordsProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const dom = eidos.dom;
// Rich-text contenteditable is inherently client-only: the engine
// stamps content ids with `crypto.randomUUID()`, so an SSR pass and the
// client pass emit different markup and hydration bails. Defer the
// editor to the client; SSR + first paint render the empty frame.
let mounted = $state(false);
// Client-only activation + one-time langs registration. `langs.extend` BOTH
// reads and mutates the reactive langs schema, so it MUST run untracked —
// otherwise this $effect tracks that read, the mutation retriggers it, and it
// loops, re-extending the bundle thousands of times (each logging a DEV
// leaf-overwrite warning). untrack ⇒ the effect has no deps ⇒ runs once on
// mount. Registers the inspector's localized strings so labels localize
// instead of falling back to the inline English.
$effect(() => {
untrack(() => {
mounted = true;
eidos.langs.extend('components.words.inspector', WORDS_INSPECTOR_BUNDLE);
eidos.langs.extend('components.words.menu', WORDS_MENU_BUNDLE);
});
});
let contentEl = $state<HTMLDivElement | null>(null);
let drawerOpen = $state(false);
let sidebarOpen = $state(true);
// Edit vs preview. Preview maps to the provider's `readonly` — the surface
// becomes non-editable (contenteditable=false) and the editing chrome
// (gutter / drag / bubble) is suppressed; the menu bar stays visible so the
// toggle is reachable. The menu bar owns the toggle via `bind:mode`.
let mode = $state<'edit' | 'preview'>('edit');
// One "is the inspector open" concept the menu bar's toggle drives:
// the drawer's open state, or the sidebar's visibility.
const inspectorOpen = $derived(
inspector === 'drawer' ? drawerOpen : inspector === 'sidebar' ? sidebarOpen : false
);
function toggleInspector() {
if (inspector === 'drawer') drawerOpen = !drawerOpen;
else if (inspector === 'sidebar') sidebarOpen = !sidebarOpen;
}
</script>
{#if mounted}
<Words.Provider
{...headlessProps}
bind:value
bind:selection
readonly={mode === 'preview'}
data-size={resolvedSize}
data-inspector={inspector}
data-mode={mode}
>
{#snippet children(api)}
{#if toolbar}
<WordsMenubar
{api}
bind:mode
{inspector}
{inspectorOpen}
onToggleInspector={toggleInspector}
/>
{/if}
<Words.Content bind:ref={contentEl} />
<Words.Placeholder />
<!-- Editing chrome (bubble / gutter / drag) is edit-only: preview is
read-only, so these affordances are suppressed while the menu bar
stays visible to toggle back. -->
{#if mode === 'edit'}
<WordsBubble {api} />
{/if}
{#if contentEl}
<WordsActivate {api} content={contentEl} {dom} enabled={mode === 'edit'} />
{#if mode === 'edit'}
<WordsActiveRail {api} content={contentEl} {dom} />
<WordsBlockGutter {api} content={contentEl} {dom} />
<WordsBlockDrag {api} content={contentEl} {dom} />
{/if}
<!-- Column-content insertion lives in the Inspector's Columns
panel ("Añadir bloque" dropdown per column), React-Bricks-
style. Floating in-canvas overlay was removed: its
pointer-events:none required for caret pass-through ALSO
disabled `:hover` reveal, and any visible variant covered
the column's auto-trailing paragraph, hijacking clicks
meant for typing. Sidebar-driven insertion sidesteps both. -->
{/if}
{#if inspector === 'sidebar' && sidebarOpen}
<aside data-words-sidebar>
<WordsInspector {api} />
</aside>
{:else if inspector === 'drawer'}
<!-- The menu bar's inspector toggle opens/closes the drawer; the
open panel also carries its own X close in the header. -->
<aside data-words-drawer-panel data-open={drawerOpen ? '' : undefined}>
<header data-words-drawer-panel-head>
<span>Block</span>
<Button
variant="ghost"
size="xs"
iconOnly
aria-label="Close settings"
onclick={() => (drawerOpen = false)}
>
{#snippet icon()}<X />{/snippet}
</Button>
</header>
<WordsInspector {api} />
</aside>
{:else if inspector === 'popover'}
<div data-words-popover-anchor>
<Popover>
<Popover.Trigger>
{#snippet child({ props }: { props: Record<string, unknown> })}
<Button
{...props}
variant="ghost"
size="xs"
iconOnly
rounded="md"
aria-label="Block settings"
>
{#snippet icon()}<SlidersHorizontal />{/snippet}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content data-words-inspector-popover side="bottom" align="end">
<WordsInspector {api} />
</Popover.Content>
</Popover.Portal>
</Popover>
</div>
{/if}
{/snippet}
</Words.Provider>
{:else}
<div data-words data-size={resolvedSize} data-inspector={inspector} aria-busy="true"></div>
{/if}

@ -2224,90 +2224,6 @@
--editable-affirm-border: var(--color-affirm-border);
--editable-risk-border: var(--color-risk-border);
--editable-threat-border: var(--color-threat-border);
--words-radius-xs: var(--radius-sm);
--words-radius-sm: var(--radius-md);
--words-radius-md: var(--radius-lg);
--words-radius-lg: var(--radius-xl);
--words-border-width: var(--border-width);
--words-border: var(--color-border-subtle);
--words-border-hover: var(--color-border-default);
--words-bg: var(--color-surface-raised);
--words-shadow: var(--shadow-subtle);
--words-color: var(--color-content-primary);
--words-font-family: var(--style-label-font-family);
--words-font-size-sm: var(--size-xs-font-size);
--words-font-size-md: var(--size-sm-font-size);
--words-font-size-lg: var(--size-md-font-size);
--words-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--words-invalid-border: var(--color-risk-border);
--words-disabled-opacity: var(--opacity-disabled);
--words-font-size-xs: 0.6875rem;
--words-wash-faint: 6%;
--words-wash-subtle: 16%;
--words-wash-soft: 30%;
--words-wash-strong: 44%;
--words-swatch: var(--words-bg);
--words-chrome-font: var(--style-label-font-family);
--words-control-height: 1.75rem;
--words-field-width: 7.5rem;
--words-command-gap: var(--space-1);
--words-command-radius: var(--radius-md);
--words-command-color: var(--color-content-secondary);
--words-command-solid-color: var(--color-content-on-solid);
--words-placeholder-color: var(--color-content-muted);
--words-content-color: var(--color-content-primary);
--words-content-padding-block-sm: var(--space-2-5);
--words-content-padding-block-md: var(--space-3);
--words-content-padding-block-lg: var(--space-4);
--words-content-min-block-size-sm: 10rem;
--words-content-min-block-size-md: 13rem;
--words-content-min-block-size-lg: 16rem;
--words-content-max-block-size-sm: 50vh;
--words-content-max-block-size-md: 60vh;
--words-content-max-block-size-lg: 70vh;
--words-block-gap: var(--space-3);
--words-heading-color: var(--color-content-primary);
--words-heading-h1-font-size: var(--size-xl-font-size);
--words-heading-h2-font-size: calc(var(--size-lg-font-size) + 4px);
--words-heading-h3-font-size: var(--size-lg-font-size);
--words-heading-font-weight: var(--font-weight-semibold);
--words-heading-line-height: var(--leading-heading);
--words-strong-font-weight: var(--font-weight-semibold);
--words-code-radius: var(--radius-sm);
--words-code-bg: var(--color-surface-overlay);
--words-code-color: var(--color-content-primary);
--words-code-font-family: var(--font-family-mono);
--words-code-padding-block: var(--space-0-5);
--words-code-padding-inline: var(--space-1);
--words-quote-padding-inline: var(--space-3);
--words-quote-border-width: var(--border-width-medium);
--words-quote-color: var(--color-content-secondary);
--words-list-padding-inline-start: var(--space-5);
--words-transition-duration: var(--duration-fast);
--words-transition-ease: var(--ease-default);
--words-primary-solid: var(--color-primary-solid);
--words-primary-text: var(--color-primary-text);
--words-secondary-text: var(--color-secondary-text);
--words-affirm-text: var(--color-affirm-text);
--words-fulfill-solid: var(--color-fulfill-solid);
--words-fulfill-track: var(--color-fulfill-track);
--words-fulfill-border: var(--color-fulfill-border);
--words-fulfill-text: var(--color-fulfill-text);
--words-risk-text: var(--color-risk-text);
--words-threat-solid: var(--color-threat-solid);
--words-threat-text: var(--color-threat-text);
--words-loss-track: var(--color-loss-track);
--words-loss-border: var(--color-loss-border);
--words-loss-text: var(--color-loss-text);
--words-primary-border: var(--color-primary-border);
--words-neutral-track: var(--color-neutral-track);
--words-neutral-border: var(--color-neutral-border);
--words-affirm-track: var(--color-affirm-track);
--words-affirm-border: var(--color-affirm-border);
--words-risk-track: var(--color-risk-track);
--words-risk-border: var(--color-risk-border);
--words-threat-track: var(--color-threat-track);
--words-threat-border: var(--color-threat-border);
--tags-input-gap-xs: var(--space-1);
--tags-input-gap-sm: var(--space-1-5);
--tags-input-gap-md: var(--space-2);

@ -2677,118 +2677,6 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
]
}
},
words: {
// xs is the small-control tier (inspector fields, swatches, chips, steppers):
// one step under `sm`. Replaces the ad-hoc 4px / var(--radius-2,4px) / 3px /
// var(--space-1) soup the inspector used for the same thing.
'radius-xs': 'var(--radius-sm)',
'radius-sm': 'var(--radius-md)',
'radius-md': 'var(--radius-lg)',
'radius-lg': 'var(--radius-xl)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-subtle)',
'border-hover': 'var(--color-border-default)',
bg: 'var(--color-surface-raised)',
shadow: 'var(--shadow-subtle)',
color: 'var(--color-content-primary)',
'font-family': 'var(--style-label-font-family)',
'font-size-sm': 'var(--size-xs-font-size)',
'font-size-md': 'var(--size-sm-font-size)',
'font-size-lg': 'var(--size-md-font-size)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'invalid-border': 'var(--color-risk-border)',
'disabled-opacity': 'var(--opacity-disabled)',
// Smallest doc-chrome size (breadcrumb, cell labels) — one step under `sm`.
'font-size-xs': '0.6875rem',
// Wash scale: ONE canonical set of color-mix opacities for the editor's
// accent/role tints (header washes, hover fills, glows, focus rings).
// Replaces the ad-hoc 4/8/12/14/16/22/30/35/40/45/50 % soup with 4 steps.
// They are percentages, applied to whatever base color each surface mixes.
'wash-faint': '6%',
'wash-subtle': '16%',
'wash-soft': '30%',
'wash-strong': '44%',
// Per-swatch hue, set inline by the bubble/inspector color grids; the
// declared default is the surface bg (an unset swatch reads as empty).
swatch: 'var(--words-bg)',
// The CHROME typeface (toolbar · bubble · drawer · inspector · menus). Always a
// UI sans, INDEPENDENT of `font-family` (the content/reading font, which a theme
// may set to serif). Keeps every control + label in one face.
'chrome-font': 'var(--style-label-font-family)',
// The inline-control tier height — every control sitting on an inspector
// row (number-field, colour trigger, ToggleGroup chip) shares ONE height
// so a row of mixed controls aligns. 1.75rem (28px) was the number-field's
// magic height; named here and applied uniformly to kill the 26/28/36 mix.
'control-height': '1.75rem',
// Fixed width for the right-hand control in an inline inspector row so the
// panel's right edge reads as a clean column (number-field was 6.75rem,
// colour trigger 7.5rem — a ragged edge). 7.5rem fits the 8-char alpha hex.
'field-width': '7.5rem',
'command-gap': 'var(--space-1)',
'command-radius': 'var(--radius-md)',
'command-color': 'var(--color-content-secondary)',
'command-solid-color': 'var(--color-content-on-solid)',
'placeholder-color': 'var(--color-content-muted)',
'content-color': 'var(--color-content-primary)',
'content-padding-block-sm': 'var(--space-2-5)',
'content-padding-block-md': 'var(--space-3)',
'content-padding-block-lg': 'var(--space-4)',
'content-min-block-size-sm': '10rem',
'content-min-block-size-md': '13rem',
'content-min-block-size-lg': '16rem',
'content-max-block-size-sm': '50vh',
'content-max-block-size-md': '60vh',
'content-max-block-size-lg': '70vh',
// Document rhythm: airier than the default UI gap so blocks read
// as paragraphs in a document, not stacked form rows. Headings
// derive 1.5x of this for their top margin.
'block-gap': 'var(--space-3)',
'heading-color': 'var(--color-content-primary)',
'heading-h1-font-size': 'var(--size-xl-font-size)',
'heading-h2-font-size': 'calc(var(--size-lg-font-size) + 4px)',
'heading-h3-font-size': 'var(--size-lg-font-size)',
'heading-font-weight': 'var(--font-weight-semibold)',
'heading-line-height': 'var(--leading-heading)',
'strong-font-weight': 'var(--font-weight-semibold)',
'code-radius': 'var(--radius-sm)',
'code-bg': 'var(--color-surface-overlay)',
'code-color': 'var(--color-content-primary)',
'code-font-family': 'var(--font-family-mono)',
'code-padding-block': 'var(--space-0-5)',
'code-padding-inline': 'var(--space-1)',
'quote-padding-inline': 'var(--space-3)',
'quote-border-width': 'var(--border-width-medium)',
'quote-color': 'var(--color-content-secondary)',
'list-padding-inline-start': 'var(--space-5)',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'primary-solid': 'var(--color-primary-solid)',
'primary-text': 'var(--color-primary-text)',
'secondary-text': 'var(--color-secondary-text)',
'affirm-text': 'var(--color-affirm-text)',
'fulfill-solid': 'var(--color-fulfill-solid)',
'fulfill-track': 'var(--color-fulfill-track)',
'fulfill-border': 'var(--color-fulfill-border)',
'fulfill-text': 'var(--color-fulfill-text)',
'risk-text': 'var(--color-risk-text)',
'threat-solid': 'var(--color-threat-solid)',
'threat-text': 'var(--color-threat-text)',
'loss-track': 'var(--color-loss-track)',
'loss-border': 'var(--color-loss-border)',
'loss-text': 'var(--color-loss-text)',
// Restored after accidental sed-removal during switch migration.
// Consumed by words.css for code/link/blockquote color cues.
'primary-border': 'var(--color-primary-border)',
'neutral-track': 'var(--color-neutral-track)',
'neutral-border': 'var(--color-neutral-border)',
'affirm-track': 'var(--color-affirm-track)',
'affirm-border': 'var(--color-affirm-border)',
'risk-track': 'var(--color-risk-track)',
'risk-border': 'var(--color-risk-border)',
'threat-track': 'var(--color-threat-track)',
'threat-border': 'var(--color-threat-border)'
},
'tags-input': {
'gap-xs': 'var(--space-1)',
'gap-sm': 'var(--space-1-5)',

@ -184,7 +184,7 @@
{ slug: '/uix/components/field-langs', label: 'Field langs' },
{ slug: '/uix/components/label', label: 'Label' },
{ slug: '/uix/components/editable', label: 'Editable' },
{ slug: '/uix/components/words', label: 'Words' },
{ slug: '/uix/components/palabras', label: 'Palabras' },
{ slug: '/uix/components/number-field', label: 'Number field' },
{ slug: '/uix/components/css-field', label: 'CSS field' },
{ slug: '/uix/components/search-field', label: 'Search field' },

@ -1,364 +0,0 @@
<script lang="ts">
// Words editor — F2 foundation: the editor surface renders + edits on
// the new base-`Block` model. Chrome (gutters / bubble toolbar /
// settings drawer) lands in F3.
import Words from '$uix/eidos/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import { FileText, Check } from '$uix/eidos/components/icon';
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
import type {
WordsDocument,
WordsImageUploadResult,
WordsOnUploadImage
} from '$soma/components/words';
// Local-only image upload — turns the dropped/pasted File into a
// `data:` URL via FileReader. Lets the demo accept drag-drop and
// paste of images without a backend. Real apps would POST the file
// to their storage and resolve with the returned URL instead.
const handleUploadImage: WordsOnUploadImage = (file: File) =>
new Promise<WordsImageUploadResult>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'));
reader.onload = () => {
const url = typeof reader.result === 'string' ? reader.result : '';
if (!url) {
reject(new Error('Empty data URL'));
return;
}
resolve({
url,
// Use the filename (sans extension) as a default alt — the
// inspector lets the user refine it afterwards.
alt: file.name.replace(/\.[^/.]+$/, '') || undefined
});
};
reader.readAsDataURL(file);
});
const INSPECTOR_MODES: readonly { v: WordsInspectorMode; label: string }[] = [
{ v: 'none', label: 'None' },
{ v: 'sidebar', label: 'Sidebar' },
{ v: 'drawer', label: 'Drawer' },
{ v: 'popover', label: 'Popover' }
];
let inspector = $state<WordsInspectorMode>('sidebar');
let value = $state<WordsDocument>({
version: '2.0.0',
children: [
{ type: 'heading', level: 1, children: [{ type: 'text', text: 'Words' }] },
{
type: 'paragraph',
children: [
{ type: 'text', text: 'A clean editor rebuilt on a base ' },
{ type: 'text', text: 'Block', marks: ['code'] },
{
type: 'text',
text: ' model — common style props (margins, colors, spacing, borders, align) shared by every block and '
},
{ type: 'text', text: 'extended', marks: ['italic'] },
{ type: 'text', text: ' per type.' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Inline marks: ' },
{ type: 'text', text: 'bold', marks: ['bold'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'italic', marks: ['italic'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'underline', marks: ['underline'] },
{ type: 'text', text: ', ' },
{ type: 'text', text: 'strike', marks: ['strike'] },
{ type: 'text', text: ', and a ' },
{
type: 'link',
href: 'https://tiptap.dev',
target: '_blank',
children: [{ type: 'text', text: 'link' }]
},
{ type: 'text', text: '.' }
]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Quote & code' }]
},
{
type: 'quote',
children: [
{
type: 'text',
text: 'Design is not just what it looks like and feels like. Design is how it works.'
}
]
},
{
type: 'code',
language: 'ts',
children: [
{
type: 'text',
text: "const greet = (name: string) => `Hello, ${name}!`\nconsole.log(greet('Words'))"
}
]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Lists' }]
},
{
type: 'list',
kind: 'unordered',
items: [
{ children: [{ type: 'text', text: 'Bulleted item one' }] },
{ children: [{ type: 'text', text: 'Bulleted item two' }] }
]
},
{
type: 'list',
kind: 'check',
items: [
{ checked: true, children: [{ type: 'text', text: 'Engine reworked (base Block)' }] },
{
checked: false,
children: [{ type: 'text', text: 'Chrome (gutters + bubble + drawer)' }]
}
]
},
{
type: 'callout',
intent: 'affirm',
title: 'Foundation',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
text: 'This surface renders directly from the model. Editing round-trips through the engine.'
}
]
}
]
},
{ type: 'divider' },
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Columns' }]
},
{
type: 'columns',
columns: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: '' }] }] }
]
},
{
type: 'heading',
level: 2,
children: [{ type: 'text', text: 'Table' }]
},
{
type: 'table',
headerRow: true,
rows: [
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Phase' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Status' }] }] }
]
},
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Engine' }] }] },
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Done' }] }] }
]
},
{
cells: [
{ children: [{ type: 'paragraph', children: [{ type: 'text', text: 'Visual' }] }] },
{
children: [{ type: 'paragraph', children: [{ type: 'text', text: 'In progress' }] }]
}
]
}
]
}
]
});
// ── Topbar state ──────────────────────────────────────────────────────
// Edit / Preview now lives INSIDE the editor's menu bar (the eidos
// `<Words>` owns the read-only `preview` mode), so the demo no longer
// toggles it here — it just keeps the breadcrumb + Saved + inspector picker.
// Breadcrumb title — the first heading's text, else a placeholder.
const docTitle = $derived.by(() => {
for (const b of value.children) {
if (b.type === 'heading') {
const text = b.children
.map((c) => (c.type === 'text' ? c.text : ''))
.join('')
.trim();
if (text) return text;
}
}
return 'Untitled document';
});
// "Saved" indicator — flips to "Editing…" on any document change, settles
// back to "Saved" after a short idle. Demo-only chrome (a real app would
// debounce a network save here).
let saveState = $state<'saved' | 'editing'>('saved');
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let trackedInitial = false;
$effect(() => {
void value; // re-run whenever the bound document changes
if (!trackedInitial) {
trackedInitial = true;
return;
}
saveState = 'editing';
clearTimeout(saveTimer);
saveTimer = setTimeout(() => (saveState = 'saved'), 600);
return () => clearTimeout(saveTimer);
});
</script>
<header>
<p data-uix-eyebrow>— editors · words</p>
<h1>Words</h1>
<p>
Rebuilt on a clean base-<code>Block</code> model. This is the F2 foundation: the editor surface renders
and edits. Chrome (left gutter menu, right settings gutter, bubble toolbar) is next.
</p>
</header>
<div class="demo-topbar">
<div class="demo-doc">
<FileText size="sm" />
<span class="demo-doc-title">{docTitle}</span>
<span class="demo-save" data-state={saveState}>
{#if saveState === 'saved'}<Check size="sm" />Saved{:else}Editing…{/if}
</span>
</div>
<div class="demo-topbar-actions">
<span class="demo-controls-label">Inspector</span>
<ToggleGroup
type="single"
size="xs"
variant="outline"
attached
value={[inspector]}
onValueChange={(v: string[]) => {
if (v[0]) inspector = v[0] as WordsInspectorMode;
}}
aria-label="Inspector mode"
>
{#each INSPECTOR_MODES as m (m.v)}
<ToggleGroup.Item value={m.v}>{m.label}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</div>
</div>
<div class="demo-canvas">
<!-- Edit / Preview lives in the editor's own menu bar now (read-only
preview). The demo just hosts the live editor. -->
<Words bind:value {inspector} placeholder="Write something…" onUploadImage={handleUploadImage} />
</div>
<details class="demo-trace">
<summary>document value</summary>
<pre>{JSON.stringify(value, null, 2)}</pre>
</details>
<style>
.demo-canvas {
display: flex;
justify-content: center;
padding: var(--space-8) var(--space-4);
/* Theme-aware surface — `--color-surface-sunken` does not exist, so
the old fallback painted a light panel on the dark theme. */
background: var(--color-surface-default);
border-radius: var(--radius-lg);
}
.demo-canvas :global([data-words]) {
inline-size: 100%;
max-inline-size: 62rem;
}
.demo-topbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
margin-block-end: var(--space-3);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
}
.demo-doc {
display: flex;
align-items: center;
gap: var(--space-2);
min-inline-size: 0;
color: var(--color-content-secondary);
}
.demo-doc-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-content-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.demo-save {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-inline-start: var(--space-1);
font-size: var(--font-size-xs);
transition: color var(--duration-fast) var(--ease-default);
}
.demo-save[data-state='saved'] {
color: var(--color-affirm-text, var(--color-content-secondary));
}
.demo-save[data-state='editing'] {
color: var(--color-content-muted);
}
.demo-topbar-actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.demo-controls-label {
font-size: var(--font-size-sm);
color: var(--color-content-secondary);
}
.demo-trace {
margin-block-start: var(--space-6);
font-size: var(--font-size-xs);
color: var(--color-content-secondary);
}
.demo-trace summary {
cursor: pointer;
user-select: none;
}
.demo-trace pre {
max-block-size: 24rem;
overflow: auto;
padding: var(--space-3);
background: var(--color-surface-overlay);
border-radius: var(--radius-md);
}
</style>

@ -1,58 +0,0 @@
# Words Demo
La demo publica vive en `web/routes/uix/components/words/+page.svelte` y sigue
la plantilla bloqueada de UIX:
- Live, API, Morfo, Sema, Recipe y A11y.
- Stage permanente entre header y tabs.
- `MutationObserver` sobre `data-event`.
- Snippets Soma y Eidos reactivos.
- Controles visibles para documento, toolbar, estados Soma, size, variant y
color.
- Toolbar Eidos agrupado por familias/submenus en presets `minimal`,
`formatting`, `full` y `custom`.
- Import/export visible para JSON, texto, HTML limpio/import seguro y Markdown
con listas indentadas, find/replace regex/preserve-case/current-match/selection,
code blocks con highlighting runtime y tablas con formato de celda/tabla.
- API documentada para leer/actualizar `language` de code blocks desde snippets
(`currentCodeLanguage` / `setCodeLanguage`).
- Selector visual `Words.CodeLanguagePicker` dentro de los presets de toolbar
para cambiar el lenguaje del bloque `code` seleccionado.
- Tokens de syntax highlighting visibles desde `data-words-code-token` sin
contaminar el export HTML limpio.
- `SlashMenu` opcional visible en snippets Soma/Eidos: `/query` abre comandos
de bloque/lista/tabla anclados al caret y navegables por teclado.
- Tablas editables visibles en el preset `sample`, con toolbar y slash command
para insertar una tabla 2x2 canonica, botones de fila/columna, navegacion
por celdas con `Tab`/`Shift+Tab` y formato desde el submenu `Table`: header
row/column, alineacion horizontal, vertical align, tonos de celda, striped
rows y compact table. La demo tambien verifica formato inline dentro de
celdas desde el submenu de texto. El submenu `Table` se renderiza por
secciones rotuladas y con ancho acotado para evitar una matriz opaca de
iconos.
- Checklist interactiva: el marcador de cada item se puede clicar y
`Ctrl/Cmd+Enter` alterna el item actual.
- El snippet Soma refleja la misma capacidad con un `<select>` manual usando
`currentCodeLanguage` y `setCodeLanguage`, porque el picker visual pertenece a
Eidos.
- `BubbleMenu` contextual visible cuando hay seleccion de rango, compuesto con
los mismos `CommandButton` y `LinkEditor` del toolbar principal.
- Sema tab con reproduccion de los eventos Morfo sobre el stage real.
## Cobertura Live
| Area | Controles |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Documento | `sample`, `empty`, `long` |
| Soma | placeholder, toolbar, orientation, bubble menu, slash menu, import/export, disabled, readonly, required, invalid, commitOnBlur, status |
| Eidos | `size: sm/md/lg`, `variant: surface/outline/ghost`, `color: full ColorRole` |
| Estado visible | blocks, words, selection, export, import, last commit |
## Notas
- No hay `data-*` inventados por la demo para pintar el editor; la receta lee
los attrs de Morfo/Soma.
- La seleccion se muestra como `none`, `collapsed` o `range`.
- El cambio de preset/import remonta el editor solo como reset explicito de la
demo; Soma ya sincroniza cambios externos de `value` y `selection` sin
depender de remount.
Loading…
Cancel
Save

Powered by TurnKey Linux.