diff --git a/src/uix/eidos/components/words/langs-inspector.ts b/src/uix/eidos/components/words/langs-inspector.ts
new file mode 100644
index 000000000..035ed859d
--- /dev/null
+++ b/src/uix/eidos/components/words/langs-inspector.ts
@@ -0,0 +1,421 @@
+/**
+ * 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_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}',
+ 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|Default',
+ 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|Semibold',
+ 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_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}' },
+ 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: 'Default', es: 'Predeterminada' },
+ 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-negrita' },
+ 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: {
+ 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}' },
+ '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;
+
diff --git a/src/uix/eidos/components/words/words-block-gutter.svelte b/src/uix/eidos/components/words/words-block-gutter.svelte
index f9799df4e..84dcd4180 100644
--- a/src/uix/eidos/components/words/words-block-gutter.svelte
+++ b/src/uix/eidos/components/words/words-block-gutter.svelte
@@ -21,7 +21,11 @@
import type { ActiveDom } from '$adom';
import { DropdownMenu } from '$uix/eidos/components/dropdown-menu';
import { GripVertical } from '$uix/eidos/components/icon';
- import { defaultWordsSchema, type ProviderSnippetProps } from '$soma/components/words';
+ import {
+ defaultWordsSchema,
+ type ProviderSnippetProps,
+ type WordsBlockMenuEntry
+ } from '$soma/components/words';
let {
api,
@@ -76,22 +80,37 @@
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 (`
` 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 = blocks[i].getBoundingClientRect();
- if (clientY >= r.top && clientY <= r.bottom) {
+ 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 (r.top <= clientY) {
- target = blocks[i];
- targetIndex = i;
- targetRect = r;
- }
}
if (target && targetRect) {
place(target, targetIndex);
@@ -151,6 +170,21 @@
// 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]);
+ }
function moveUp() {
if (index > 0) api.applyCommand({ type: 'moveBlock', blockIndex: index, direction: 'up' });
}
@@ -168,11 +202,101 @@
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.
+ function insertImageInteractive() {
+ const doc =
+ (typeof document !== 'undefined' ? document : null) ??
+ content?.ownerDocument ??
+ null;
+ if (!doc) return;
+ const input = doc.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.style.display = 'none';
+ input.addEventListener('change', () => {
+ const file = input.files?.[0];
+ input.remove();
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ const src = typeof reader.result === 'string' ? reader.result : '';
+ if (!src) return;
+ const alt = file.name.replace(/\.[^/.]+$/, '') || undefined;
+ insert({ type: 'image', src, alt });
+ };
+ reader.readAsDataURL(file);
+ });
+ doc.body.appendChild(input);
+ input.click();
+ }
+
+ 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.
- // Entries flagged non-insertable (e.g. image, which needs a URL/upload
- // flow) are skipped.
- const inserts = defaultWordsSchema.insertable().filter((entry) => entry.insertable !== false);
+ // 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 = {
+ '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;
+ });
+ });
{#if (show || menuOpen) && rect}
@@ -182,7 +306,14 @@
style="top: {rect.top - 3}px; left: {rect.left - 3}px; width: {rect.width + 6}px; height: {rect.height + 6}px;"
>
{/if}
-
+
+
+ Inspect
+
Move up
Move down
Duplicate
@@ -213,7 +346,7 @@
Insert below
{#each inserts as ins (ins.id)}
- insert(ins.create())}>{ins.label}
+ handleInsert(ins)}>{ins.label}
{/each}
diff --git a/src/uix/eidos/components/words/words-block-panel.svelte b/src/uix/eidos/components/words/words-block-panel.svelte
new file mode 100644
index 000000000..256c11c9e
--- /dev/null
+++ b/src/uix/eidos/components/words/words-block-panel.svelte
@@ -0,0 +1,780 @@
+
+
+{#if block.type === 'heading'}
+
+ {t(L.LABEL_HEADING_LEVEL)}
+ {
+ 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)}
+ H{lvl}
+ {/each}
+
+
+{:else if block.type === 'list'}
+
+ {t(L.LABEL_LIST_KIND)}
+ {
+ 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)}
+ {k.label}
+ {/each}
+
+
+{:else if block.type === 'code'}
+
+ {t(L.LABEL_CODE_LANGUAGE)}
+
+ {
+ const next = (e.currentTarget as HTMLInputElement).value.trim();
+ applyCommand({ type: 'setCodeLanguage', language: next || undefined });
+ }}
+ />
+
+ {#each CODE_LANGUAGE_SUGGESTIONS as lang (lang)}
+
+ {/each}
+
+
+{:else if block.type === 'image'}
+
+ {@const imgFullWidth = block.fullWidth === true}
+
+
{t(L.LABEL_SRC)}
+
+ {
+ const next = (e.currentTarget as HTMLInputElement).value.trim();
+ patchBlock({ src: next });
+ }}
+ />
+ {
+ // File picker → FileReader → data URL. Hostable apps that wire
+ // `onUploadImage` on the editor will see the same path used by
+ // drag-and-drop. Here in the panel we keep the inspector self-
+ // contained and write a data URL straight to `src`; consumers
+ // that want a backend upload can intercept by replacing the
+ // inspector or pre-processing the model.
+ const doc = typeof document !== 'undefined' ? document : null;
+ if (!doc) return;
+ const input = doc.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.style.display = 'none';
+ input.addEventListener('change', () => {
+ const file = input.files?.[0];
+ input.remove();
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ const src = typeof reader.result === 'string' ? reader.result : '';
+ if (!src) return;
+ const altGuess = file.name.replace(/\.[^/.]+$/, '') || undefined;
+ patchBlock({
+ src,
+ ...(block.alt === undefined && altGuess ? { alt: altGuess } : {})
+ });
+ };
+ reader.readAsDataURL(file);
+ });
+ doc.body.appendChild(input);
+ input.click();
+ }}
+ >
+ {#snippet icon()} {/snippet}
+
+
+
+
+
+ {t(L.LABEL_ALT)}
+
+
+
+ {t(L.LABEL_CAPTION)}
+ {
+ const next = (e.currentTarget as HTMLInputElement).value;
+ patchBlock({ caption: next || undefined });
+ }}
+ />
+
+
+ {t(L.LABEL_IMAGE_FULL_WIDTH)}
+ patchBlock({ fullWidth: v || undefined })}
+ aria-label={t(L.ARIA_IMAGE_FULL_WIDTH)}
+ >
+ {imgFullWidth ? t(L.IMAGE_FULL_WIDTH_ON) : t(L.IMAGE_FULL_WIDTH_OFF)}
+
+
+
+ {t(L.LABEL_WIDTH)}
+ patchBlock({ width: n > 0 ? n : undefined })}
+ >
+ −
+
+ +
+
+
+
+ {t(L.LABEL_HEIGHT)}
+ patchBlock({ height: n > 0 ? n : undefined })}
+ >
+ −
+
+ +
+
+
+
+ {t(L.LABEL_IMAGE_ALIGN)}
+
+ 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}
+
+
+
+ {/each}
+
+
+{:else if block.type === 'callout'}
+
+
+ {t(L.LABEL_INTENT)}
+ {
+ const intent = v[0] as WordsIntent | undefined;
+ if (!intent) return;
+ patchBlock({ intent });
+ }}
+ aria-label={t(L.ARIA_INTENT)}
+ >
+ {#each INTENTS as i (i.v)}
+
+
+ {i.label}
+
+ {/each}
+
+
+
+ {t(L.LABEL_TITLE)}
+ {
+ const next = (e.currentTarget as HTMLInputElement).value;
+ patchBlock({ title: next || undefined });
+ }}
+ />
+
+{:else if block.type === 'table'}
+
+
+ {#if activeCell}
+
+
+
+ {t(L.LABEL_CELL_ALIGN)}
+ {
+ 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}
+
+
+
+ {/each}
+
+
+
+ {t(L.LABEL_CELL_VALIGN)}
+ {
+ const verticalAlign = v[0] as WordsVerticalAlign | undefined;
+ if (!verticalAlign) return;
+ applyCommand({ type: 'setCellVerticalAlign', verticalAlign });
+ }}
+ aria-label={t(L.ARIA_CELL_VALIGN)}
+ >
+ {t(L.VALIGN_TOP)}
+ {t(L.VALIGN_MIDDLE)}
+ {t(L.VALIGN_BOTTOM)}
+
+
+
applyCommand({ type: 'setCellVisual', visual: { color: hex } })}
+ />
+
+ applyCommand({ type: 'setCellVisual', visual: { background: hex } })}
+ />
+
+ {/if}
+
+ {#if activeRow}
+
+
+
+ applyCommand({ type: 'setRowVisual', visual: { background: hex } })}
+ />
+
+ {/if}
+
+
+
+
+
+ {t(L.LABEL_ROWS)}
+ {block.rows.length}
+
+
+ applyCommand({ type: 'insertTableRow', position: 'before' })}
+ >
+ ↑ Row
+
+ applyCommand({ type: 'insertTableRow', position: 'after' })}
+ >
+ ↓ Row
+
+ applyCommand({ type: 'deleteTableRow' })}
+ >
+ {#snippet icon()} {/snippet}
+ Row
+
+
+
+ {t(L.LABEL_COLUMNS)}
+ {block.rows[0]?.cells.length ?? 0}
+
+
+ applyCommand({ type: 'insertTableColumn', position: 'before' })}
+ >
+ ← Col
+
+ applyCommand({ type: 'insertTableColumn', position: 'after' })}
+ >
+ → Col
+
+ applyCommand({ type: 'deleteTableColumn' })}
+ >
+ {#snippet icon()} {/snippet}
+ Col
+
+
+
+ {t(L.LABEL_HEADER_ROW)}
+ applyCommand({ type: 'toggleTableHeaderRow' })}
+ aria-label={t(L.ARIA_HEADER_ROW)}
+ >
+ {block.headerRow ? t(L.PRESET_FULL) : t(L.PRESET_NONE)}
+
+
+
+ {t(L.LABEL_HEADER_COLUMN)}
+ applyCommand({ type: 'toggleTableHeaderColumn' })}
+ aria-label={t(L.ARIA_HEADER_COLUMN)}
+ >
+ {block.headerCol ? t(L.PRESET_FULL) : t(L.PRESET_NONE)}
+
+
+
+{:else if block.type === 'columns'}
+
+
+
{t(L.LABEL_COLUMNS)}
+
{block.columns.length}
+
+ {#snippet icon()} {/snippet}
+
+
+ {#each block.columns as col, idx (idx)}
+
+
+ {t(L.LABEL_COLUMN_N).replace('{n}', String(idx + 1))}
+
+
+ {t(L.LABEL_COLUMN_WIDTH)}
+ {
+ const next = (e.currentTarget as HTMLInputElement).value;
+ setColumnWidth(idx, next);
+ }}
+ />
+
+ {#if block.columns.length > 1}
+
+ removeColumnAt(idx)}
+ >
+ {#snippet icon()} {/snippet}
+ {t(L.ARIA_REMOVE_COLUMN_N).replace('{n}', String(idx + 1))}
+
+
+ {/if}
+
+ {/each}
+{/if}
diff --git a/src/uix/eidos/components/words/words-bubble.svelte b/src/uix/eidos/components/words/words-bubble.svelte
index 6f3a8224b..633e2cdfb 100644
--- a/src/uix/eidos/components/words/words-bubble.svelte
+++ b/src/uix/eidos/components/words/words-bubble.svelte
@@ -52,27 +52,57 @@
// 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']);
- const canTurnInto = $derived(CONVERTIBLE_BLOCKS.has(api.currentBlock));
- const currentListKind = $derived.by(() => {
- const index = api.selection?.anchor.path[0];
- if (index == null) return undefined;
- const block = api.document.children[index] as WordsBlock | undefined;
- return block?.type === 'list' ? block.kind : undefined;
+ // 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 (api.currentBlock) {
+ switch (effectiveBlock.type) {
case 'heading':
- return `Heading ${api.currentHeadingLevel ?? 1}`;
+ return `Heading ${effectiveBlock.level ?? 1}`;
case 'quote':
return 'Quote';
case 'code':
return 'Code';
case 'list':
- return currentListKind === 'ordered'
+ return effectiveBlock.kind === 'ordered'
? 'Numbered list'
- : currentListKind === 'check'
+ : effectiveBlock.kind === 'check'
? 'Check list'
: 'Bulleted list';
case 'paragraph':
@@ -85,6 +115,8 @@
return 'Image';
case 'divider':
return 'Divider';
+ case 'columns':
+ return 'Columns';
default:
return 'Turn into';
}
@@ -94,43 +126,43 @@
{
id: 'paragraph',
label: 'Text',
- active: api.currentBlock === 'paragraph',
+ 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: api.currentBlock === 'heading' && (api.currentHeadingLevel ?? 1) === level,
+ active: effectiveBlock.type === 'heading' && (effectiveBlock.level ?? 1) === level,
run: () => api.applyCommand({ type: 'setBlock', block: 'heading', level })
})),
{
id: 'unordered-list',
label: 'Bulleted list',
- active: api.currentBlock === 'list' && currentListKind === 'unordered',
+ active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'unordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'unordered' })
},
{
id: 'ordered-list',
label: 'Numbered list',
- active: api.currentBlock === 'list' && currentListKind === 'ordered',
+ active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'ordered',
run: () => api.applyCommand({ type: 'toggleList', kind: 'ordered' })
},
{
id: 'check-list',
label: 'Check list',
- active: api.currentBlock === 'list' && currentListKind === 'check',
+ active: effectiveBlock.type === 'list' && effectiveBlock.kind === 'check',
run: () => api.applyCommand({ type: 'toggleList', kind: 'check' })
},
{
id: 'quote',
label: 'Quote',
- active: api.currentBlock === 'quote',
+ active: effectiveBlock.type === 'quote',
run: () => api.applyCommand({ type: 'setBlock', block: 'quote' })
},
{
id: 'code',
label: 'Code',
- active: api.currentBlock === 'code',
+ active: effectiveBlock.type === 'code',
run: () => api.applyCommand({ type: 'setBlock', block: 'code' })
}
]);
diff --git a/src/uix/eidos/components/words/words-color-row.svelte b/src/uix/eidos/components/words/words-color-row.svelte
index 4ceefab24..78e1a4f35 100644
--- a/src/uix/eidos/components/words/words-color-row.svelte
+++ b/src/uix/eidos/components/words/words-color-row.svelte
@@ -9,8 +9,6 @@
import { untrack } from 'svelte';
import { ColorPicker } from '$uix/eidos/components/color-picker';
import { PickerShell } from '$uix/eidos/components/picker-shell';
- import { Button } from '$uix/eidos/components/button';
- import { X } from '$uix/eidos/components/icon';
import { parseColor, colorValueFromHsv, DEFAULT_COLOR, type ColorValue } from '$libs/color';
let {
@@ -49,23 +47,8 @@
-
-
-
- {label}
- {#if current}
- onPick(undefined)}
- >
- {#snippet icon()} {/snippet}
-
- {/if}
-
-
+
+
{label}
+
+
{#each presets as c (c)}
diff --git a/src/uix/eidos/components/words/words-column-inserter.svelte b/src/uix/eidos/components/words/words-column-inserter.svelte
new file mode 100644
index 000000000..e30f68f94
--- /dev/null
+++ b/src/uix/eidos/components/words/words-column-inserter.svelte
@@ -0,0 +1,391 @@
+
+
+{#if frame}
+ {#each slots as slot (keyFor(slot))}
+ {@const k = keyFor(slot)}
+
+
+
{
+ openColumnKey = v ? k : openColumnKey === k ? null : openColumnKey;
+ }}
+ >
+
+ {#snippet icon()} {/snippet}
+
+
+ {
+ // Block the dropdown from auto-returning focus to the
+ // `+` trigger button. With focus on the trigger, the
+ // next Space keystroke re-activates it (Space triggers
+ // focused buttons), reopens the menu, fires the first
+ // item — yielding a phantom repeat-insert.
+ e.preventDefault();
+ }}
+ >
+ {#each inserts as entry (entry.id)}
+ handleInsert(slot, entry)}>
+ {entry.label}
+
+ {/each}
+
+
+
+
+ {/each}
+{/if}
+
+
diff --git a/src/uix/eidos/components/words/words-inspector.svelte b/src/uix/eidos/components/words/words-inspector.svelte
index 6e97241d1..8665f2d76 100644
--- a/src/uix/eidos/components/words/words-inspector.svelte
+++ b/src/uix/eidos/components/words/words-inspector.svelte
@@ -19,57 +19,201 @@
*/
import { Accordion } from '$uix/eidos/components/accordion';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
- import { Slider } from '$uix/eidos/components/slider';
+ import { ActiveEidos } from '$uix/eidos';
+ import type { Component } from 'svelte';
+ import {
+ TextAlignStart,
+ TextAlignCenter,
+ TextAlignEnd,
+ TextAlignJustify
+ } 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 type { ProviderSnippetProps, WordsBlock } from '$soma/components/words';
let { api }: { api: ProviderSnippetProps } = $props();
+ // `eidos.langs.ts(KEY)` is the canonical i18n surface for eidos
+ // components (parallel to soma's `langs.ts`). It returns the localized
+ // string when a translation pack is registered for the active locale,
+ // or the post-`|` fallback verbatim when not. Reading inside a
+ // `$derived` makes the inspector re-render when the locale changes.
+ const eidos = ActiveEidos.require();
+ const t = (key: string) => eidos.langs.ts(key);
+
// Active block index + block, derived from the `api` snippet prop — the
// same reactive pattern the selection bubble uses (`currentListKind`):
// pure `$derived`, no effect / listener, so it tracks the provider's
// document + selection through the snippet API and re-derives on change.
- const activeIndex = $derived(api.selection?.anchor.path[0] ?? api.selectedBlockIndex ?? -1);
- const activeBlock = $derived(
- activeIndex >= 0 ? (api.document.children[activeIndex] as WordsBlock | undefined) : undefined
- );
+ //
+ // IMPORTANT — `selectedBlockIndex` wins over `selection.anchor.path[0]`.
+ // Atomic blocks (image, divider) live inside `contenteditable=false`
+ // figures so clicking them DOES NOT move the DOM caret; soma instead
+ // sets `selectedBlockIndex` to flag the explicit atomic selection.
+ // If we read the caret first the inspector stays on whatever paragraph
+ // the caret WAS in (frequently the one before the image). Soma clears
+ // `selectedBlockIndex` the moment the caret genuinely moves elsewhere,
+ // so this priority is safe for the round trip back to text editing.
+ const activeIndex = $derived(api.selectedBlockIndex ?? api.selection?.anchor.path[0] ?? -1);
+
+ // `selectedBlockPath` carries the FULL path to a clicked atomic block
+ // (e.g. `[colsIdx, colIdx, innerIdx]` for an image inside a column).
+ // When set we walk the document by that path to find the real block —
+ // otherwise the inspector reads the top-level wrapper (`ColumnsBlock`)
+ // and shows the wrong panel.
+ function walkPath(path: readonly number[]): WordsBlock | undefined {
+ let node: unknown = api.document;
+ for (const idx of path) {
+ if (!node || typeof node !== 'object') return undefined;
+ const obj = node as Record;
+ const t = obj['type'];
+ if (Array.isArray(obj['children'])) {
+ const child = (obj['children'] as unknown[])[idx];
+ if (child === undefined) return undefined;
+ node = child;
+ continue;
+ }
+ if (t === 'columns' && Array.isArray(obj['columns'])) {
+ const child = (obj['columns'] as unknown[])[idx];
+ if (child === undefined) return undefined;
+ node = child;
+ continue;
+ }
+ if (Array.isArray(obj['items'])) {
+ const child = (obj['items'] as unknown[])[idx];
+ if (child === undefined) return undefined;
+ node = child;
+ continue;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'type' in (node as Record)) {
+ return node as WordsBlock;
+ }
+ return undefined;
+ }
+
+ const activeBlock = $derived.by(() => {
+ const path = api.selectedBlockPath;
+ if (path && path.length > 1) {
+ // Nested atomic — walk the doc to find the actual clicked block
+ // (otherwise we'd land on the columns wrapper).
+ const walked = walkPath(path);
+ if (walked) return walked;
+ }
+ return activeIndex >= 0
+ ? (api.document.children[activeIndex] as WordsBlock | undefined)
+ : undefined;
+ });
+
+ const activeBlockPath = $derived.by(() => {
+ const path = api.selectedBlockPath;
+ if (path && path.length > 1) return path;
+ return activeIndex >= 0 ? [activeIndex] : undefined;
+ });
function edit(visual: Partial) {
- if (activeIndex < 0) return;
- api.applyCommand({ type: 'setBlockVisual', blockIndex: activeIndex, visual });
+ const path = activeBlockPath;
+ if (!path) return;
+ // Path-aware route when the active block 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.
- let openSections = $state(['typography']);
+ // Which inspector sections are expanded. Multiple may be open. The
+ // block-specific section opens by default when present — it's the
+ // most contextual surface ("change H2 to H3", "switch list kind",
+ // etc.) and should not require a click to reveal.
+ let openSections = $state(['block', 'typography']);
- type AlignValue = 'left' | 'center' | 'right' | 'justify';
- const ALIGNS: readonly { v: AlignValue; label: string }[] = [
- { v: 'left', label: 'Left' },
- { v: 'center', label: 'Center' },
- { v: 'right', label: 'Right' },
- { v: 'justify', label: 'Justify' }
- ];
+ // Which block types expose per-type props in the WordsBlockPanel.
+ // Kept here (not inside the panel) so the inspector can decide
+ // whether to render the Block accordion section at all — sections
+ // with empty bodies feel broken.
+ 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-block-type relevance of the generic accordion sections. Each
+ // generic section is shown only when it actually applies to the
+ // selected block's intrinsic nature:
+ // - text-bearing blocks (paragraph/heading/quote/code/list/
+ // callout) → all sections apply.
+ // - image → no text props (Typography / Color / Layout) make
+ // sense; Spacing + Border around the figure do.
+ // - divider → it IS a border line; only Color (line tint) +
+ // Spacing (gap around the line) apply.
+ // - table → ALL generic sections are hidden; the Block panel
+ // owns visual editing via Cell / Row / Table subsections so
+ // edits target the right scope (cell color does NOT propagate
+ // to every cell).
+ type GenericSection = 'typography' | 'color' | 'layout' | 'spacing' | 'border';
+ const SECTION_VISIBILITY: Record> = {
+ 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(['spacing', 'border']),
+ divider: new Set(['color', 'spacing']),
+ table: new Set(), // none — Block panel owns the scopes
+ columns: new Set(['spacing']) // gap belongs to the wrapper; per-column visuals live in Block panel
+ };
+ function shows(section: GenericSection): boolean {
+ const allowed = activeBlock ? SECTION_VISIBILITY[activeBlock.type] : undefined;
+ return allowed ? allowed.has(section) : true;
+ }
+
+ type AlignValue = 'left' | 'center' | 'right' | 'justify';
type BorderStyleValue = 'solid' | 'dashed' | 'dotted';
- const BORDER_STYLES: readonly { v: BorderStyleValue; label: string }[] = [
- { v: 'solid', label: 'Solid' },
- { v: 'dashed', label: 'Dashed' },
- { v: 'dotted', label: 'Dotted' }
- ];
- const FONTS: readonly { v: string; label: string }[] = [
- { v: '', label: 'Default' },
- { v: 'sans-serif', label: 'Sans' },
- { v: 'serif', label: 'Serif' },
- { v: 'monospace', label: 'Mono' }
- ];
+ // Option arrays are `$derived` (not plain `const`) so the labels
+ // re-evaluate when the active locale changes. The value list is
+ // stable; only the visible `label` is reactive. Align ships an
+ // `icon` (visual) AND a `label` (aria-only) — the chip renders the
+ // icon; screen readers read the label.
+ const ALIGNS = $derived([
+ { 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 WEIGHTS: readonly { v: string; label: string }[] = [
- { v: '400', label: 'Regular' },
- { v: '500', label: 'Medium' },
- { v: '600', label: 'Semibold' },
- { v: '700', label: 'Bold' }
- ];
+ const BORDER_STYLES = $derived([
+ { v: 'solid', label: t(L.BORDER_SOLID) },
+ { v: 'dashed', label: t(L.BORDER_DASHED) },
+ { v: 'dotted', label: t(L.BORDER_DOTTED) }
+ ]);
+
+ const FONTS = $derived([
+ { v: '', 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([
+ { 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',
@@ -87,53 +231,35 @@
function blockTitle(b: WordsBlock): string {
switch (b.type) {
case 'heading':
- return `Heading ${b.level}`;
+ return `${t(L.BLOCK_HEADING)} ${b.level}`;
case 'paragraph':
- return 'Text';
+ return t(L.BLOCK_PARAGRAPH);
case 'quote':
- return 'Quote';
+ return t(L.BLOCK_QUOTE);
case 'code':
- return 'Code';
+ return t(L.BLOCK_CODE);
case 'list':
return b.kind === 'ordered'
- ? 'Numbered list'
+ ? t(L.BLOCK_LIST_ORDERED)
: b.kind === 'check'
- ? 'Check list'
- : 'Bulleted list';
+ ? t(L.BLOCK_LIST_CHECK)
+ : t(L.BLOCK_LIST_BULLET);
case 'table':
- return 'Table';
+ return t(L.BLOCK_TABLE);
case 'image':
- return 'Image';
+ return t(L.BLOCK_IMAGE);
case 'divider':
- return 'Divider';
+ return t(L.BLOCK_DIVIDER);
case 'callout':
- return 'Callout';
+ return t(L.BLOCK_CALLOUT);
+ case 'columns':
+ return t(L.BLOCK_COLUMNS);
default:
- return 'Block';
+ return t(L.BLOCK_GENERIC);
}
}
-{#snippet numRow(label: string, value: number, min: number, max: number, step: number, onChange: (n: number) => void)}
-
-
- {label}
- {value}
-
-
onChange(v[0] ?? min)}
- >
-
-
-
-
-{/snippet}
-
{#if activeBlock}
{@const block = activeBlock}
@@ -141,15 +267,35 @@
-
+
+ {#if blockHasPanelProps(block)}
+
+
+ {t(L.SECTION_BLOCK)}
+
+
+
+
+
+
+
+ {/if}
+
+ {#if shows('typography')}
- Typography
+ {t(L.SECTION_TYPOGRAPHY)}
- Font
+ {t(L.LABEL_FONT)}
edit({ fontFamily: v[0] ? v[0] : undefined })}
- aria-label="Font family"
+ aria-label={t(L.ARIA_FONT_FAMILY)}
>
{#each FONTS as f (f.v)}
{f.label}
{/each}
- {@render numRow('Font size', block.fontSize ?? 16, 12, 40, 1, (n) =>
- edit({ fontSize: n })
- )}
+
edit({ fontSize: n })}
+ />
- Weight
+ {t(L.LABEL_WEIGHT)}
edit({ fontWeight: v[0] ? Number(v[0]) : undefined })}
- aria-label="Font weight"
+ aria-label={t(L.ARIA_FONT_WEIGHT)}
>
{#each WEIGHTS as w (w.v)}
{w.label}
{/each}
- {@render numRow('Line height', block.lineHeight ?? 1.6, 1, 2.5, 0.1, (n) =>
- edit({ lineHeight: Math.round(n * 10) / 10 })
- )}
+ edit({ lineHeight: Math.round(n * 10) / 10 })}
+ />
+ {/if}
+ {#if shows('color')}
- Color
+ {t(L.SECTION_COLOR)}
edit({ color: hex })}
/>
edit({ background: hex })}
/>
-
+
+ {/if}
+ {#if shows('layout')}
- Layout
+ {t(L.SECTION_LAYOUT)}
- Align
+ {t(L.LABEL_ALIGN)}
edit({ align: v[0] as AlignValue | undefined })}
- aria-label="Align"
+ aria-label={t(L.ARIA_ALIGN)}
>
{#each ALIGNS as a (a.v)}
- {a.label}
+ {@const Icon = a.icon}
+
+
+
{/each}
+ {/if}
+ {#if shows('spacing')}
- Spacing
+ {t(L.SECTION_SPACING)}
- {@render numRow('Margin top/bottom', block.margin?.block ?? 0, 0, 64, 1, (n) =>
- edit({ margin: { ...block.margin, block: n } })
- )}
- {@render numRow('Margin left/right', block.margin?.inline ?? 0, 0, 64, 1, (n) =>
- edit({ margin: { ...block.margin, inline: n } })
- )}
- {@render numRow('Padding top/bottom', block.padding?.block ?? 0, 0, 64, 1, (n) =>
- edit({ padding: { ...block.padding, block: n } })
- )}
- {@render numRow('Padding left/right', block.padding?.inline ?? 0, 0, 64, 1, (n) =>
- edit({ padding: { ...block.padding, inline: n } })
- )}
+ edit({ margin: next })}
+ />
+ edit({ padding: next })}
+ />
+ {/if}
+ {#if shows('border')}
- Border
+ {t(L.SECTION_BORDER)}
- Style
+ {t(L.LABEL_STYLE)}
{#each BORDER_STYLES as b (b.v)}
- {b.label}
+
+
+
+
{/each}
- {@render numRow('Border width', block.border?.width ?? 0, 0, 8, 1, (n) =>
- edit({ border: { ...block.border, width: n } })
- )}
- {@render numRow('Corner radius', block.border?.radius ?? 0, 0, 24, 1, (n) =>
- edit({ border: { ...block.border, radius: n } })
- )}
+
edit({ border: { ...block.border, width: n } })}
+ />
+ edit({ border: { ...block.border, color: hex } })}
+ />
+ edit({ border: { ...block.border, radius: n } })}
+ />
+
+ {t(L.LABEL_PRESET)}
+ {
+ const preset = v[0];
+ if (preset === 'full') edit({ border: { ...block.border, radius: 9999 } });
+ else if (preset === 'none') edit({ border: { ...block.border, radius: 0 } });
+ }}
+ aria-label={t(L.ARIA_RADIUS_PRESET)}
+ >
+ {t(L.PRESET_NONE)}
+ {t(L.PRESET_FULL)}
+
+
+ {/if}
-
{:else}
-
Select a block to edit its style.
+
{t(L.EMPTY)}
{/if}
diff --git a/src/uix/eidos/components/words/words-num-row.svelte b/src/uix/eidos/components/words/words-num-row.svelte
new file mode 100644
index 000000000..1793ef970
--- /dev/null
+++ b/src/uix/eidos/components/words/words-num-row.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+ {label}
+
+ onCommit(n)}
+ >
+ −
+
+ +
+
+
+
{
+ const n = v[0];
+ if (n !== undefined) draft = n;
+ }}
+ onValueCommit={(v: number[]) => {
+ const n = v[0];
+ if (n !== undefined) onCommit(n);
+ }}
+ >
+
+
+
+
diff --git a/src/uix/eidos/components/words/words-spacing-row.svelte b/src/uix/eidos/components/words/words-spacing-row.svelte
new file mode 100644
index 000000000..314e42765
--- /dev/null
+++ b/src/uix/eidos/components/words/words-spacing-row.svelte
@@ -0,0 +1,250 @@
+
+
+
+
+ {label}
+
+
+
+
+
+ {t(L.SPACING_TOP)}
+ setSide('top', n)}
+ >
+ −
+
+ +
+
+
+
+ {t(L.SPACING_BOTTOM)}
+ setSide('bottom', n)}
+ >
+ −
+
+ +
+
+
+
(blockLinked = 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}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {t(L.SPACING_LEFT)}
+ setSide('left', n)}
+ >
+ −
+
+ +
+
+
+
+ {t(L.SPACING_RIGHT)}
+ setSide('right', n)}
+ >
+ −
+
+ +
+
+
+
(inlineLinked = 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}
+
+ {:else}
+
+ {/if}
+
+
+
+
diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css
index a655dc4bc..49373a18f 100644
--- a/src/uix/eidos/components/words/words.css
+++ b/src/uix/eidos/components/words/words.css
@@ -86,20 +86,27 @@
position: absolute;
inset-inline-start: 0.35rem;
z-index: 2;
- /* nudge so the grip centres on the block's first line */
- margin-block-start: 0.12em;
+ /* The script sets `top` to the block's vertical midpoint; this
+ transform pulls the grip up by half its own height so the icon
+ lands ON the midpoint rather than starting from it. Works
+ regardless of block height (paragraph, code, table, image). */
+ transform: translateY(-50%);
/* slide smoothly between blocks as the cursor moves */
transition: top 120ms var(--words-transition-ease);
animation: words-gutter-in 140ms var(--words-transition-ease);
}
@keyframes words-gutter-in {
+ /* `translateY(-50%)` keeps the grip vertically centered against the
+ block midpoint — the keyframe MUST preserve it on both frames, or
+ the end state snaps the grip back to its `top` anchor and the
+ centering is lost the moment the animation completes. */
from {
opacity: 0;
- transform: translateX(-4px);
+ transform: translateY(-50%) translateX(-4px);
}
to {
opacity: 1;
- transform: translateX(0);
+ transform: translateY(-50%) translateX(0);
}
}
[data-words-block-gutter] [data-words-block-handle] {
@@ -241,8 +248,39 @@
[data-words-inspector] {
display: flex;
flex-direction: column;
- gap: var(--space-4);
- padding: var(--space-4);
+ gap: var(--space-3);
+ /* Tightened from space-4 → space-2 (16px → 8px) — the sidebar is narrow
+ and the previous padding ate ~20% of the usable inline width. */
+ padding: var(--space-2);
+ /* Force the system sans stack on EVERY descendant of the inspector
+ (labels, values, subsection titles, NumberField inputs, ToggleGroup
+ chips, accordion triggers, table cell counters, etc.). The editor's
+ content font can be serif when the surface is themed for reading
+ (`Words` body inherits `--words-font-family`); the inspector chrome
+ must stay sans regardless or the controls render with mixed
+ typography.
+
+ The container declaration alone is NOT enough — eidos primitives
+ (Accordion.Trigger, Button, NumberField.Input, ToggleGroup.Item,
+ …) set their own `font-family` in their recipe CSS, breaking the
+ cascade. The `* { font-family: inherit }` rule forces every
+ descendant to inherit from the container regardless of recipe
+ overrides. This is the standard pattern when you want a subtree
+ to share one typeface against shipped components that ship their
+ own font choices. */
+ font-family:
+ system-ui,
+ -apple-system,
+ 'Segoe UI',
+ Roboto,
+ Helvetica,
+ Arial,
+ sans-serif;
+}
+[data-words-inspector] *,
+[data-words-inspector] *::before,
+[data-words-inspector] *::after {
+ font-family: inherit;
}
[data-words-inspector-title] {
font-size: var(--words-font-size-md);
@@ -266,6 +304,59 @@
flex-direction: column;
gap: var(--space-1-5);
}
+/* Inspector row gated by a sibling toggle (e.g. image Width / Height /
+ Align gated by Fullwidth). Reduce contrast + block pointer events so
+ the user sees the field is dimmed but not removed (gives context for
+ what Fullwidth controls). The actual controls inside still set
+ `disabled`/`aria-disabled`; this is the visual sugar. */
+[data-words-inspector-row][data-disabled] {
+ opacity: 0.45;
+ pointer-events: none;
+}
+/* Image src field shares its row with an inline `Upload` button. The
+ wrapping group is the row's "value" slot — `data-inline` gives it
+ `justify-content: space-between` against the label, and the inner
+ flex distributes input (stretch) + button (auto) along the row. */
+[data-words-inspector-src-group] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--space-1);
+ flex: 1;
+ min-inline-size: 0;
+}
+[data-words-inspector-src-group] input[data-words-inspector-text] {
+ flex: 1;
+ min-inline-size: 0;
+}
+/* Single-line row variant — label on the left, control on the right.
+ Used by `WordsColorRow` (each color = `Label [Picker]` per the
+ inspector's compact spec). Sliders / toggle groups keep the stacked
+ layout (label above, control below) because the control is wider. */
+[data-words-inspector-row][data-inline] {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+/* The eidos ColorPicker root declares `inline-size: 100%` (so it fills
+ form rows by default). Inside an inline inspector row that override
+ collapses the label-to-picker distribution because the picker stretches
+ to the full remaining width. Pin to auto here so `justify-content:
+ space-between` actually pushes the picker's trigger to the right edge. */
+[data-words-inspector-row][data-inline] [data-color-picker] {
+ inline-size: auto;
+}
+/* Pin the trigger to a fixed inline size so every color row's picker has
+ the same visual width regardless of the hex string length (6 chars vs
+ 8 chars with alpha). `tabular-nums` keeps the hex characters at a
+ consistent advance width so the text doesn't jitter while the picker
+ value oscillates during drag. */
+[data-words-inspector-row][data-inline] [data-color-picker-trigger] {
+ inline-size: 7.5rem;
+ justify-content: flex-start;
+ font-variant-numeric: tabular-nums;
+}
[data-words-inspector-rowhead] {
display: flex;
align-items: baseline;
@@ -274,12 +365,193 @@
[data-words-inspector-label] {
font-size: var(--words-font-size-sm);
color: var(--words-command-color);
+ /* Font-family is inherited from `[data-words-inspector]` (forced sans
+ at the container level). */
}
[data-words-inspector-value] {
font-size: var(--words-font-size-sm);
color: var(--words-content-color);
font-variant-numeric: tabular-nums;
}
+/* Compact numeric input in `WordsNumRow` rowhead — pins width so the
+ label / slider layout doesn't shift as the user types longer numbers.
+ `tabular-nums` keeps the digits at uniform advance width. */
+[data-words-inspector-rowhead] [data-number-field] {
+ inline-size: 4.5rem;
+}
+[data-words-inspector-rowhead] [data-number-field-input] {
+ inline-size: 100%;
+ text-align: end;
+ font-variant-numeric: tabular-nums;
+ font-size: var(--words-font-size-sm);
+}
+
+/* WordsSpacingRow — 4-side editor with PAIR chain.
+ Layout: a vertical stack of TWO pair rows. Each pair holds two cells
+ (label-above-input) plus its OWN link toggle. The two pairs are
+ independent — chaining one does not chain the other. This makes the
+ pair semantics unmistakable visually. */
+[data-words-inspector-spacing] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+[data-words-inspector-spacing-head] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+[data-words-inspector-spacing-grid] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+[data-words-inspector-spacing-pair] {
+ display: grid;
+ /* Two equal-width inputs + a small fixed slot for the link toggle. */
+ grid-template-columns: 1fr 1fr auto;
+ align-items: end;
+ gap: var(--space-1);
+ /* Bump icon size in this scope so the link/unlink chip is visible at
+ the Toggle xs size (default `--icon-size` is too small here). */
+ --icon-size: 14px;
+}
+[data-words-inspector-spacing-cell] {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-inline-size: 0;
+}
+[data-words-inspector-spacing-cell-label] {
+ font-size: var(--words-font-size-xs, 0.75rem);
+ color: var(--words-content-color);
+ opacity: 0.7;
+ letter-spacing: 0.02em;
+}
+[data-words-inspector-spacing-cell] [data-number-field] {
+ inline-size: 100%;
+ min-inline-size: 0;
+}
+[data-words-inspector-spacing-cell] [data-number-field-input] {
+ inline-size: 100%;
+ text-align: end;
+ font-variant-numeric: tabular-nums;
+ font-size: var(--words-font-size-sm);
+}
+
+/* Block panel — bare text inputs (code language, image alt + caption).
+ Styled to match the inspector chrome (sans, sm size, ghost
+ background) so they sit among the ToggleGroups without visual noise.
+ `` suggestions render natively on focus. The shared
+ `[data-words-inspector-text]` selector is for plain text edits;
+ `[data-words-inspector-code-language]` keeps the original selector
+ for backward compatibility — both inherit the same base rules. */
+[data-words-inspector-text],
+[data-words-inspector-code-language] {
+ flex: 1 1 0;
+ min-inline-size: 0;
+ font: inherit;
+ font-size: var(--words-font-size-sm);
+ padding: 4px 8px;
+ border: 1px solid var(--words-border);
+ border-radius: var(--radius-2, 4px);
+ background: transparent;
+ color: inherit;
+}
+[data-words-inspector-text]:focus-visible,
+[data-words-inspector-code-language]:focus-visible {
+ outline: 2px solid var(--color-primary, currentColor);
+ outline-offset: -1px;
+}
+
+/* Callout intent swatch — small color dot rendered INSIDE the
+ ToggleGroup chip alongside its localized label. Color comes from
+ the doctrinal palette token for each intent (so a `risk` swatch
+ matches the actual `risk` callout's accent). Lives in the chip
+ button's flex flow; sits at the inline-start. */
+[data-words-intent-swatch] {
+ display: inline-block;
+ inline-size: 0.625rem;
+ block-size: 0.625rem;
+ border-radius: 50%;
+ margin-inline-end: 4px;
+ vertical-align: -1px;
+ background: var(--color-neutral-solid);
+}
+[data-words-intent-swatch][data-intent='neutral'] {
+ background: var(--color-neutral-solid);
+}
+[data-words-intent-swatch][data-intent='affirm'] {
+ background: var(--color-affirm-solid);
+}
+[data-words-intent-swatch][data-intent='fulfill'] {
+ background: var(--color-fulfill-solid);
+}
+[data-words-intent-swatch][data-intent='risk'] {
+ background: var(--color-risk-solid);
+}
+[data-words-intent-swatch][data-intent='threat'] {
+ background: var(--color-threat-solid);
+}
+[data-words-intent-swatch][data-intent='loss'] {
+ background: var(--color-loss-solid);
+}
+
+/* Border-style swatch — the chip IS the line preview. No lucide icon
+ for solid/dashed/dotted is fitting, so we render a short top border
+ in the current ink. The swatch line scales with the chip width so
+ bigger sizes show a longer sample. */
+[data-words-border-style-swatch] {
+ display: inline-block;
+ inline-size: 1.25rem;
+ block-size: 0;
+ border-block-start-width: 2px;
+ border-block-start-color: currentColor;
+}
+[data-words-border-style-swatch='solid'] {
+ border-block-start-style: solid;
+}
+[data-words-border-style-swatch='dashed'] {
+ border-block-start-style: dashed;
+}
+[data-words-border-style-swatch='dotted'] {
+ border-block-start-style: dotted;
+}
+
+/* WordsBlockPanel hierarchical subsections (table cell + table).
+ Sits inside `data-words-inspector-section` and groups related rows
+ under a small label. Keeps the visual separation between scopes
+ (cell-level vs table-level) without the heaviness of a second
+ accordion item. */
+[data-words-inspector-subsection] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding-block: var(--space-1);
+}
+[data-words-inspector-subsection] + [data-words-inspector-subsection] {
+ border-block-start: 1px dashed var(--words-border);
+ padding-block-start: var(--space-2);
+}
+[data-words-inspector-subsection-title] {
+ font-size: var(--words-font-size-xs, 0.75rem);
+ color: var(--words-content-color);
+ opacity: 0.75;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+}
+/* Table insert/delete ops row — 3 small buttons in a horizontal flex
+ group (above/below/delete for rows; left/right/delete for columns).
+ Sits below the count display so the user reads "Rows: 3" then sees
+ the actions, mirroring Notion/Word table panels. */
+[data-words-inspector-table-ops] {
+ display: flex;
+ gap: var(--space-1);
+ flex-wrap: wrap;
+}
+
[data-words-inspector-empty] {
padding: var(--space-2) 0;
font-size: var(--words-font-size-sm);
@@ -404,6 +676,50 @@
margin-block: var(--words-block-gap) 0;
}
+/* Columns block — flex layout for side-by-side content. Each child
+ `[data-words-column]` is a flex item; the engine sets `flex: ` inline so equal-width is the default and the
+ user can resize via the inspector. Gap reuses the block-gap token
+ so columns breathe the same vertical rhythm as the surrounding
+ content. Each column is also a flex container itself (column
+ direction) so its nested blocks stack vertically. */
+[data-words-content] [data-words-block='columns'] {
+ display: flex;
+ flex-direction: row;
+ gap: var(--words-block-gap);
+ margin-block: var(--words-block-gap) 0;
+ align-items: stretch;
+}
+[data-words-content] [data-words-block='columns'] [data-words-column] {
+ display: flex;
+ flex-direction: column;
+ min-inline-size: 0;
+ /* Visual cue while editing — without this, an empty columns block
+ collapses to 0 height and the user can't see where the layout is.
+ The dashed outline + breathing min-height make the structure
+ discoverable; the soft tone fades into the background once the
+ user fills the columns with real content. No padding — child
+ blocks own their own padding via the inspector's Spacing section
+ so the column dashed border traces exactly the content edge. */
+ min-block-size: 3em;
+ padding: 0;
+ border: 1px dashed var(--words-border);
+ border-radius: var(--radius-2, 4px);
+ transition: border-color var(--words-transition-duration) var(--words-transition-ease);
+}
+[data-words-content] [data-words-block='columns'] [data-words-column]:hover,
+[data-words-content] [data-words-block='columns'] [data-words-column]:focus-within {
+ border-color: var(--words-content-color);
+}
+/* Reset the universal block-rhythm `margin-block` for the FIRST nested
+ block of each column — otherwise that extra gap pushes the column
+ contents down relative to the column's frame, breaking vertical
+ alignment with siblings. */
+[data-words-content] [data-words-block='columns'] [data-words-column] > *:first-child {
+ margin-block-start: 0;
+}
+
+
/* ── Paragraph ────────────────────────────────────────────────────────── */
[data-words-block='paragraph'] {
@@ -563,18 +879,56 @@ figure[data-words-block='image'] {
flex-direction: column;
align-items: center;
gap: var(--space-2);
-}
-figure[data-words-block='image'] img {
+ /* Browser default for is `margin-inline: 40px` — it eats
+ ~80px from every column / container the figure sits in. Reset to 0
+ so the image can use the full content area of its parent (the
+ block-gap-aware margin-block reset above only touched the vertical
+ axis). */
+ margin-inline: 0;
+}
+/* When no explicit width attr is set, cap at the container so the image
+ doesn't break layout. When the user typed an explicit width in the
+ inspector (renders as ` `), let that win — including
+ values LARGER than the natural / container size (intentional upscale
+ for hero use-cases). */
+figure[data-words-block='image'] img:not([width]) {
max-inline-size: 100%;
block-size: auto;
border-radius: var(--words-radius-sm);
}
+figure[data-words-block='image'] img[width] {
+ block-size: auto;
+ border-radius: var(--words-radius-sm);
+}
figure[data-words-block='image'][data-words-image-align='left'] {
align-items: flex-start;
}
figure[data-words-block='image'][data-words-image-align='right'] {
align-items: flex-end;
}
+/* Fullwidth: drop the figure's flex layout (which sized it to its
+ content + caused overflow on `inline-size: 100%` chains with the
+ column's flex parent) and force the figure to fill its container.
+ The column is `display: flex; align-items: normal` and its child
+ flex items default to `flex: 0 1 auto` — that auto basis collapses
+ block-level children to their intrinsic content (= the img's natural
+ width). Setting `inline-size: 100%` explicitly + `box-sizing:
+ border-box` is what makes the figure fill the column's content area
+ without overflowing past its padding. The img inside then stretches
+ inside the figure. */
+figure[data-words-block='image'][data-words-image-full-width] {
+ display: block;
+ inline-size: 100%;
+ max-inline-size: 100%;
+ box-sizing: border-box;
+}
+figure[data-words-block='image'][data-words-image-full-width] img {
+ display: block;
+ inline-size: 100%;
+ max-inline-size: 100%;
+ block-size: auto;
+ box-sizing: border-box;
+}
figure[data-words-block='image'] figcaption {
font-size: 0.85em;
color: var(--words-quote-color);
diff --git a/src/uix/eidos/components/words/words.svelte b/src/uix/eidos/components/words/words.svelte
index 78c31f002..1a15bb511 100644
--- a/src/uix/eidos/components/words/words.svelte
+++ b/src/uix/eidos/components/words/words.svelte
@@ -20,7 +20,9 @@
import { SlidersHorizontal, X } from '$uix/eidos/components/icon';
import WordsBlockGutter from './words-block-gutter.svelte';
import WordsBubble from './words-bubble.svelte';
+ import WordsColumnInserter from './words-column-inserter.svelte';
import WordsInspector from './words-inspector.svelte';
+ import { WORDS_INSPECTOR_BUNDLE } from './langs-inspector';
import type { WordsProps } from './types';
let {
@@ -42,6 +44,14 @@
let mounted = $state(false);
onMount(() => {
mounted = true;
+ // Register the inspector's localized strings into the active
+ // langs schema. Without this every label resolves to the inline
+ // English fallback regardless of the active locale — declaring
+ // idlangrefs alone never localizes, the bundle must be extended.
+ // `extend` deep-merges so app-level overrides at the same path
+ // applied AFTER mount still win (the engine warns on leaf
+ // overwrites in DEV).
+ eidos.langs.extend('components.words.inspector', WORDS_INSPECTOR_BUNDLE);
});
let contentEl = $state(null);
@@ -62,6 +72,7 @@
{#if contentEl}
+
{/if}
{#if inspector === 'sidebar'}
diff --git a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts
index d75ebd09a..ce7d26be4 100644
--- a/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts
+++ b/src/uix/soma/components/context-menu/context-menu-provider.svelte.ts
@@ -118,9 +118,11 @@ export class ContextMenuProvider {
openAt(x: number, y: number) {
this.anchorPoint.current = { x, y };
// Morfo declares `open` on `content` (emerge.open, sequence pre).
- // Content may not be mounted yet on first open — pass it as fallback
- // when available, otherwise let the runtime fire with no target.
- const target = this.contentRef.current;
+ // Content may not be mounted yet on first open — fall back to the
+ // trigger element (always mounted at gesture time, and the natural
+ // anchor for the "about to open" cue). The runtime throws if no
+ // target resolves, so undefined is not a silent skip.
+ const target = this.contentRef.current ?? this.runtime.partRef('trigger');
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
this.opts.open.current = true;
}
@@ -128,7 +130,8 @@ export class ContextMenuProvider {
handleClose() {
// Morfo declares `close` on `content` (emerge.close, sequence pre).
// Content still in DOM at this point; cascade matches before flip.
- const target = this.contentRef.current;
+ // Trigger fallback for parity with openAt (content unmounted in races).
+ const target = this.contentRef.current ?? this.runtime.partRef('trigger');
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;
diff --git a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts
index 2d4bca42c..8bc90a68f 100644
--- a/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts
+++ b/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svelte.ts
@@ -113,22 +113,27 @@ export class MenuProvider {
});
}
- handleOpen() {
+ handleOpen(gestureTarget?: HTMLElement | null) {
// Sequence: 'pre' per the morfo — fire before the structural flip
- // so the perceptual signal lands at the gesture moment. If the
- // content hasn't mounted yet (first open), the runtime falls back
- // to no target and the channels still play (sound/haptic don't
- // need a DOM target; visual will pick it up on next emit).
- const target = this.contentRef.current;
+ // so the perceptual signal lands at the gesture moment. On first
+ // open the content part isn't mounted yet, so fall back to the
+ // gesture origin (the trigger button passed by the click/keydown
+ // handler, or partRef('trigger') for openers called outside an
+ // event handler). On subsequent opens the content is registered
+ // and wins.
+ const target =
+ this.contentRef.current ?? gestureTarget ?? this.runtime.partRef('trigger');
void this.runtime.trigger('open', target ? { fallbackTarget: target } : undefined);
this.opts.open.current = true;
}
- handleClose() {
+ handleClose(gestureTarget?: HTMLElement | null) {
// Sequence: 'pre' per the morfo — content is still in the DOM here,
// so the cascade can match `[data-dropdown-menu-content]` cleanly
- // before the close flip unmounts it.
- const target = this.contentRef.current;
+ // before the close flip unmounts it. Same trigger fallback for
+ // parity with handleOpen (content unmounted in rare races).
+ const target =
+ this.contentRef.current ?? gestureTarget ?? this.runtime.partRef('trigger');
void this.runtime.trigger('close', target ? { fallbackTarget: target } : undefined);
this.typeahead.reset();
this.opts.open.current = false;
@@ -183,15 +188,17 @@ export class MenuTriggerProvider {
});
}
- readonly onclick = (_e: SomaMouseEvent) => {
- if (this.provider.opts.open.current) this.provider.handleClose();
- else this.provider.handleOpen();
+ readonly onclick = (e: SomaMouseEvent) => {
+ const target = (e.currentTarget ?? e.target ?? null) as HTMLElement | null;
+ if (this.provider.opts.open.current) this.provider.handleClose(target);
+ else this.provider.handleOpen(target);
};
readonly onkeydown = (e: SomaKeyboardEvent) => {
if (e.key === KEYS.ARROW_DOWN || e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
- this.provider.handleOpen();
+ const target = (e.currentTarget ?? e.target ?? null) as HTMLElement | null;
+ this.provider.handleOpen(target);
}
};
diff --git a/src/uix/soma/components/words/engine/blocks/built-ins.ts b/src/uix/soma/components/words/engine/blocks/built-ins.ts
index bf527ff51..c8d30a597 100644
--- a/src/uix/soma/components/words/engine/blocks/built-ins.ts
+++ b/src/uix/soma/components/words/engine/blocks/built-ins.ts
@@ -35,6 +35,8 @@ import type {
import type {
CalloutBlock,
CodeBlock,
+ Column,
+ ColumnsBlock,
HeadingBlock,
ImageBlock,
ListBlock,
@@ -472,12 +474,23 @@ const imageSpec: WordsBlockSpec = {
}
],
validate(block, ctx) {
- if (typeof block.src !== 'string' || block.src.length === 0) {
- ctx.error(`${ctx.path}/src`, 'invalid-block-shape', 'image.src must be a non-empty string');
+ // `src` may be an empty string for the placeholder state right
+ // after insertion — the inspector exposes a URL field for the
+ // user to fill it in. Renderer shows a placeholder block when
+ // empty. The string-type check still catches non-string values.
+ if (typeof block.src !== 'string') {
+ ctx.error(`${ctx.path}/src`, 'invalid-block-shape', 'image.src must be a string');
}
ctx.validateOptionalNumber(block.width, `${ctx.path}/width`);
ctx.validateOptionalNumber(block.height, `${ctx.path}/height`);
ctx.validateOptionalEnum(block.imageAlign, WORDS_IMAGE_ALIGNS, `${ctx.path}/imageAlign`);
+ if (block.fullWidth !== undefined && typeof block.fullWidth !== 'boolean') {
+ ctx.error(
+ `${ctx.path}/fullWidth`,
+ 'invalid-block-shape',
+ 'image.fullWidth must be a boolean'
+ );
+ }
},
toHtml(block, ctx) {
const b = block as ImageBlock;
@@ -499,21 +512,82 @@ const imageSpec: WordsBlockSpec = {
},
render(block, ctx) {
const b = block as ImageBlock;
- const figureChildren: WordsRenderNode[] = [
- {
- kind: 'element',
- tag: 'img',
- attrs: {
- src: b.src,
- alt: b.alt ?? '',
- ...(b.width !== undefined ? { width: String(b.width) } : {}),
- ...(b.height !== undefined ? { height: String(b.height) } : {}),
- loading: 'lazy',
- draggable: 'false'
- },
- children: []
+ const isPlaceholder = b.src === '';
+ // Border belongs to the IMG, not the surrounding figure — when a
+ // user sets a border in the inspector for an image they expect
+ // to see it around the image itself. Extract the border-related
+ // style declarations from the figure's blockStyle and reroute
+ // them to the img element.
+ const figureStyleObj = ctx.blockStyle(b);
+ const imgBorderStyle: Record = {};
+ for (const key of [
+ 'border-color',
+ 'border-width',
+ 'border-style',
+ 'border-radius',
+ 'border-block-start',
+ 'border-block-end',
+ 'border-inline-start',
+ 'border-inline-end'
+ ]) {
+ if (figureStyleObj[key] !== undefined) {
+ imgBorderStyle[key] = figureStyleObj[key];
+ delete (figureStyleObj as Record)[key];
}
- ];
+ }
+ const figureStyle = ctx.stringifyStyle(figureStyleObj);
+ const imgStyle = ctx.stringifyStyle(imgBorderStyle);
+ const figureChildren: WordsRenderNode[] = isPlaceholder
+ ? [
+ // Placeholder body — rendered when the image was inserted
+ // without a URL yet (slash menu / gutter / drag-handle
+ // "Insert image" all start here). The user fills the URL
+ // from the inspector's image panel; once `src` is non-empty
+ // this branch falls back to the regular ` `.
+ {
+ kind: 'element',
+ tag: 'div',
+ attrs: { 'data-words-image-placeholder': '' },
+ children: [
+ {
+ kind: 'element',
+ tag: 'span',
+ attrs: { 'data-words-image-placeholder-label': '' },
+ children: [{ kind: 'text', text: 'No image URL yet' }]
+ },
+ {
+ kind: 'element',
+ tag: 'span',
+ attrs: { 'data-words-image-placeholder-hint': '' },
+ children: [
+ { kind: 'text', text: 'Open the inspector and paste a URL.' }
+ ]
+ }
+ ]
+ }
+ ]
+ : [
+ {
+ kind: 'element',
+ tag: 'img',
+ attrs: {
+ src: b.src,
+ alt: b.alt ?? '',
+ // fullWidth ignores explicit dimensions — CSS stretches
+ // the img to 100% of the container. Skipping the attrs
+ // here lets the CSS rule win cleanly (browser would
+ // otherwise honor the literal width="X" attribute even
+ // against `inline-size: 100%` due to specificity quirks
+ // on some legacy paths).
+ ...(!b.fullWidth && b.width !== undefined ? { width: String(b.width) } : {}),
+ ...(!b.fullWidth && b.height !== undefined ? { height: String(b.height) } : {}),
+ loading: 'lazy',
+ draggable: 'false',
+ ...(imgStyle ? { style: imgStyle } : {})
+ },
+ children: []
+ }
+ ];
if (b.caption) {
figureChildren.push({
kind: 'element',
@@ -534,9 +608,13 @@ const imageSpec: WordsBlockSpec = {
// Faithful to the pre-registry renderer: it reads the BASE `align`
// (not `imageAlign`) for the float attribute. The model carries
// `imageAlign`; reconciling the two is tracked as a follow-up.
- ...(b.align && b.align !== 'center' ? { 'data-words-image-align': b.align } : {}),
+ ...(!b.fullWidth && b.align && b.align !== 'center'
+ ? { 'data-words-image-align': b.align }
+ : {}),
+ ...(b.fullWidth ? { 'data-words-image-full-width': '' } : {}),
...(status ? { 'data-words-image-status': status } : {}),
...(isSelected ? { 'data-words-block-selected': '' } : {}),
+ ...(isPlaceholder ? { 'data-words-image-placeholder-figure': '' } : {}),
contenteditable: 'false'
}),
children: figureChildren
@@ -745,7 +823,12 @@ function renderTableCell(
): WordsRenderElement {
const isHeader = asHeader || asHeaderCol;
const tag = (isHeader ? 'th' : 'td') satisfies WordsRenderTag;
- const cellStyle = ctx.stringifyStyle(ctx.blockStyle(cell));
+ // Merge `cell.width` into the base block style. Browsers compute a
+ // column's width from the widest declared cell, so it's enough to
+ // set it on any one row's cell for the whole column to size.
+ const styleObj = { ...ctx.blockStyle(cell) };
+ if (cell.width !== undefined) styleObj['width'] = cell.width;
+ const cellStyle = ctx.stringifyStyle(styleObj);
return {
kind: 'element',
tag,
@@ -811,6 +894,13 @@ function validateTableRowFields(
for (let i = 0; i < row.cells.length; i++) {
validateTableCellFields(row.cells[i], `${path}/cells/${i}`, ctx);
}
+ if (row.height !== undefined && typeof row.height !== 'string') {
+ ctx.error(
+ `${path}/height`,
+ 'invalid-block-shape',
+ 'row.height must be a CSS length string (e.g. "48px")'
+ );
+ }
ctx.validateStyle(row, path);
}
@@ -839,6 +929,13 @@ function validateTableCellFields(
ctx.error(`${path}/rowspan`, 'invalid-number', 'cell.rowspan must be an integer >= 1');
}
}
+ if (cell.width !== undefined && typeof cell.width !== 'string') {
+ ctx.error(
+ `${path}/width`,
+ 'invalid-block-shape',
+ 'cell.width must be a CSS length string (e.g. "200px", "30%")'
+ );
+ }
}
// ── List / table HTML sub-serializers ─────────────────────────────────────
@@ -896,6 +993,144 @@ function cellTextToMd(cell: TableCell, ctx: WordsMarkdownSerializeContext): stri
return ctx.inlinesToMd(cell.children).replace(/\|/g, '\\|');
}
+const columnsSpec: WordsBlockSpec = {
+ type: 'columns',
+ group: 'structure',
+ menu: [
+ {
+ id: 'columns',
+ label: 'Columns',
+ description: 'Side-by-side layout (image + text, two columns of notes, …)',
+ keywords: ['layout', 'columns', 'side by side', 'col'],
+ group: 'structure',
+ // Default: two equal columns, each holding an empty paragraph
+ // so the user can immediately type into either side.
+ create: () => ({
+ type: 'columns',
+ columns: [
+ { children: [{ type: 'paragraph', children: emptyText() }] },
+ { children: [{ type: 'paragraph', children: emptyText() }] }
+ ]
+ })
+ }
+ ],
+ validate(block, ctx) {
+ if (!Array.isArray(block.columns)) {
+ ctx.error(
+ `${ctx.path}/columns`,
+ 'invalid-children',
+ 'columns.columns must be an array of Column objects'
+ );
+ return;
+ }
+ if (block.columns.length < 1) {
+ ctx.error(
+ `${ctx.path}/columns`,
+ 'invalid-children',
+ 'columns must contain at least one column'
+ );
+ }
+ for (let i = 0; i < block.columns.length; i++) {
+ validateColumnFields(block.columns[i], `${ctx.path}/columns/${i}`, ctx);
+ }
+ },
+ toHtml(block, ctx) {
+ const b = block as ColumnsBlock;
+ const colsHtml = b.columns
+ .map((col) => {
+ const inner = col.children.map((child) => ctx.blockToHtml(child)).join('');
+ const flex = col.width ?? '1';
+ const styleObj = { ...ctx.blockStyle(col), flex } as Record;
+ const styled = ctx.stringifyStyle(styleObj);
+ return `${inner}
`;
+ })
+ .join('');
+ return `${colsHtml}
`;
+ },
+ toMarkdown(block, ctx) {
+ // Markdown has no native column syntax. Lossy export: flatten all
+ // columns to sequential blocks (left-to-right, top-to-bottom).
+ // Round-tripping a columns block through markdown re-imports as
+ // independent stacked blocks. Use HTML / JSON for fidelity.
+ const b = block as ColumnsBlock;
+ const out: string[] = [];
+ for (const col of b.columns) {
+ for (const child of col.children) {
+ out.push(ctx.blockToMd(child, ctx.depth));
+ }
+ }
+ return out.join('\n\n');
+ },
+ render(block, ctx) {
+ const b = block as ColumnsBlock;
+ const colChildren: WordsRenderNode[] = b.columns.map((col, colIdx) => {
+ // Each column's blocks live at path `[...table-path, colIdx, blockIdx]`.
+ // `ctx.renderBlock` is recursive — works for nested columns,
+ // tables, callouts, anything.
+ const children = col.children.map((child, blockIdx) =>
+ ctx.renderBlock(child, [...ctx.path, colIdx, blockIdx])
+ );
+ const styleObj = { ...ctx.blockStyle(col) } as Record;
+ // `flex` controls the column's share of the row. Defaults to
+ // `1` (equal share) when the user hasn't set an explicit
+ // width. Accepts any CSS `flex` shorthand value (`200px`,
+ // `30%`, `2`, etc.).
+ styleObj['flex'] = col.width ?? '1';
+ const colStyle = ctx.stringifyStyle(styleObj);
+ return {
+ kind: 'element',
+ tag: 'div',
+ attrs: {
+ [WORDS_NODE_ATTR]: 'column',
+ 'data-words-column': '',
+ ...(col.id ? { 'data-words-id': col.id } : {}),
+ ...(colStyle ? { style: colStyle } : {})
+ },
+ children
+ };
+ });
+ return {
+ kind: 'element',
+ tag: 'div',
+ attrs: ctx.composeBlockAttrs(b, 'columns', ctx.path, {
+ 'data-words-column-count': String(b.columns.length)
+ }),
+ children: colChildren
+ };
+ }
+};
+
+function validateColumnFields(
+ col: unknown,
+ path: string,
+ ctx: WordsBlockValidateContext
+): void {
+ if (!ctx.isPlainObject(col)) {
+ ctx.error(path, 'invalid-block-shape', 'column must be an object');
+ return;
+ }
+ ctx.validateId(col.id, `${path}/id`);
+ ctx.validateStyle(col, path);
+ if (!Array.isArray(col.children)) {
+ ctx.error(
+ `${path}/children`,
+ 'invalid-children',
+ 'column.children must be an array of blocks'
+ );
+ return;
+ }
+ for (let i = 0; i < col.children.length; i++) {
+ ctx.validateBlock(col.children[i], `${path}/children/${i}`);
+ }
+ if (col.width !== undefined && typeof col.width !== 'string') {
+ ctx.error(
+ `${path}/width`,
+ 'invalid-block-shape',
+ 'column.width must be a CSS length / flex string (e.g. "1fr", "200px", "30%")'
+ );
+ }
+}
+
/** Every built-in spec, in document/menu order. */
export const BUILT_IN_BLOCK_SPECS: readonly WordsBlockSpec[] = [
paragraphSpec,
@@ -906,7 +1141,8 @@ export const BUILT_IN_BLOCK_SPECS: readonly WordsBlockSpec[] = [
tableSpec,
imageSpec,
dividerSpec,
- calloutSpec
+ calloutSpec,
+ columnsSpec
];
// Register the built-ins into the shared schema (side effect of import).
diff --git a/src/uix/soma/components/words/engine/operations/block-format.ts b/src/uix/soma/components/words/engine/operations/block-format.ts
index 4896bd4d0..cefa1ce94 100644
--- a/src/uix/soma/components/words/engine/operations/block-format.ts
+++ b/src/uix/soma/components/words/engine/operations/block-format.ts
@@ -72,8 +72,59 @@ export function setBlock(
return;
}
- // Callout: pass through (recursive setBlock deferred).
+ // Columns: the caret might be inside one of the column's child
+ // blocks. Recurse into the column to convert that nested block.
+ // Selection path shape: [columnsIdx, colIdx, innerBlockIdx, …].
+ // Callout still passes through (no path-aware recursion yet) —
+ // add the same pattern when needed.
+ if (block.type === 'columns') {
+ const path = range?.start.path;
+ if (!path || path.length < 3 || path[0] !== index) {
+ children.push(block);
+ return;
+ }
+ const colIdx = path[1];
+ const innerIdx = path[2];
+ if (colIdx === undefined || innerIdx === undefined) {
+ children.push(block);
+ return;
+ }
+ const col = block.columns[colIdx];
+ const inner = col?.children[innerIdx];
+ if (
+ !col ||
+ !inner ||
+ (inner.type !== 'paragraph' &&
+ inner.type !== 'heading' &&
+ inner.type !== 'quote' &&
+ inner.type !== 'code')
+ ) {
+ children.push(block);
+ return;
+ }
+ const nextInner = blockOfType(
+ type,
+ inlinesOfInlineBlock(inner as WordsInlineBlock),
+ opts.level,
+ blockTextAlign(inner),
+ opts.language ?? (inner.type === 'code' ? inner.language : undefined)
+ );
+ if (sameBlockShape(inner as WordsInlineBlock, nextInner)) {
+ children.push(block);
+ return;
+ }
+ mutated = true;
+ const nextColChildren = col.children.map((c, i) => (i === innerIdx ? nextInner : c));
+ const nextCol = { ...col, children: nextColChildren };
+ const nextColumns = block.columns.map((c, i) => (i === colIdx ? nextCol : c));
+ children.push({ ...block, columns: nextColumns });
+ return;
+ }
+
if (block.type === 'callout') {
+ // Callout pass-through — recursive setBlock not implemented for
+ // callouts today; same pattern as the `columns` branch above
+ // could be applied if a user case emerges.
children.push(block);
return;
}
diff --git a/src/uix/soma/components/words/engine/operations/commands.ts b/src/uix/soma/components/words/engine/operations/commands.ts
index 5715a2a9f..16118ee96 100644
--- a/src/uix/soma/components/words/engine/operations/commands.ts
+++ b/src/uix/soma/components/words/engine/operations/commands.ts
@@ -32,7 +32,7 @@ import {
moveBlockToAt,
updateBlockAt
} from './block';
-import { setBlockVisual } from './visual';
+import { setBlockVisual, setBlockVisualAtPath, updateBlockAtPath } from './visual';
import { setBlock, setTextAlign, type SetBlockType } from './block-format';
import { decreaseIndent, increaseIndent, toggleCheckItem, toggleList } from './list-ops';
import { insertLink, unlink } from './link-ops';
@@ -43,6 +43,8 @@ import {
insertTableRow,
setCellTextAlign,
setCellVerticalAlign,
+ setCellVisual,
+ setRowVisual,
toggleTableHeaderCol,
toggleTableHeaderRow
} from './table-ops';
@@ -56,6 +58,7 @@ import {
outdentCodeLine
} from './extra-ops';
import {
+ insertBlockInColumn,
insertCallout,
insertDivider,
insertImage,
@@ -143,12 +146,38 @@ export type WordsCommand =
readonly blockIndex: number;
readonly block: Readonly>;
}
+ // Caretless insert into a column slot (the `+` overlay in each
+ // column). Carries the new block AND its post-insert selection in
+ // one transaction — see `insertBlockInColumn` for the placement
+ // rules and why a separate `setSelection` would race.
+ | {
+ readonly type: 'insertBlockInColumn';
+ readonly columnsIdx: number;
+ readonly colIdx: number;
+ readonly block: Readonly>;
+ }
// V2-exclusive: visual sidecar update
| {
readonly type: 'setBlockVisual';
readonly blockIndex: number;
readonly visual: Partial;
}
+ // Path-aware variants — same semantics as the top-level commands above,
+ // but accept a full document path so callers can edit blocks nested
+ // inside `columns` / `callout`. Used by the inspector when the active
+ // block is inside a column (e.g. an image dropped into one of the two
+ // columns: the inspector reads `selectedBlockPath` and routes mutations
+ // through these path-aware commands).
+ | {
+ readonly type: 'updateBlockAtPath';
+ readonly blockPath: readonly number[];
+ readonly patch: Readonly>;
+ }
+ | {
+ readonly type: 'setBlockVisualAtPath';
+ readonly blockPath: readonly number[];
+ readonly visual: Partial;
+ }
// Tables
| { readonly type: 'insertTableRow'; readonly position?: 'before' | 'after' }
| { readonly type: 'insertTableColumn'; readonly position?: 'before' | 'after' }
@@ -162,6 +191,8 @@ export type WordsCommand =
readonly type: 'setCellVerticalAlign';
readonly verticalAlign: WordsVerticalAlign;
}
+ | { readonly type: 'setCellVisual'; readonly visual: Partial }
+ | { readonly type: 'setRowVisual'; readonly visual: Partial }
// Lists
| { readonly type: 'increaseIndent' }
| { readonly type: 'decreaseIndent' }
@@ -251,8 +282,19 @@ export function applyWordsCommand(
command.blockIndex,
command.block as unknown as WordsBlock
);
+ case 'insertBlockInColumn':
+ return insertBlockInColumn(
+ state,
+ command.columnsIdx,
+ command.colIdx,
+ command.block as unknown as WordsBlock
+ );
case 'setBlockVisual':
return setBlockVisual(state, command.blockIndex, command.visual);
+ case 'updateBlockAtPath':
+ return updateBlockAtPath(state, command.blockPath, command.patch);
+ case 'setBlockVisualAtPath':
+ return setBlockVisualAtPath(state, command.blockPath, command.visual);
// Tables
case 'insertTableRow':
return insertTableRow(state, { position: command.position });
@@ -272,6 +314,10 @@ export function applyWordsCommand(
return setCellTextAlign(state, command.align);
case 'setCellVerticalAlign':
return setCellVerticalAlign(state, command.verticalAlign);
+ case 'setCellVisual':
+ return setCellVisual(state, command.visual);
+ case 'setRowVisual':
+ return setRowVisual(state, command.visual);
// Lists
case 'increaseIndent':
return increaseIndent(state);
diff --git a/src/uix/soma/components/words/engine/operations/helpers.ts b/src/uix/soma/components/words/engine/operations/helpers.ts
index c15b5e94e..e29f50e4c 100644
--- a/src/uix/soma/components/words/engine/operations/helpers.ts
+++ b/src/uix/soma/components/words/engine/operations/helpers.ts
@@ -20,6 +20,8 @@ import type {
WordsBlock,
CalloutBlock,
CodeBlock,
+ Column,
+ ColumnsBlock,
WordsDocument,
HeadingBlock,
WordsInline,
@@ -39,6 +41,7 @@ import type {
export type WordsNode =
| WordsDocument
| WordsBlock
+ | Column
| ListItem
| TableRow
| TableCell
@@ -108,6 +111,8 @@ export function replaceAt(items: readonly T[], index: number, value: T): read
* - table-row → cells[i] → table-cell
* - table-cell → children[i] → inline
* - callout → children[i] → block (recursive)
+ * - columns → columns[i] → column
+ * - column → children[i] → block (recursive)
* - link → children[i] → text inline
* - image / divider → (no children)
*/
@@ -159,6 +164,13 @@ export function getNodeAtPath(doc: WordsDocument, path: WordsPath): WordsNode |
continue;
}
+ if (t === 'columns') {
+ const child: Column | undefined = (node as ColumnsBlock).columns[index];
+ if (!child) return undefined;
+ node = child;
+ continue;
+ }
+
if (t === 'link') {
const child: WordsText | undefined = (node as WordsLink).children[index];
if (!child) return undefined;
@@ -166,8 +178,8 @@ export function getNodeAtPath(doc: WordsDocument, path: WordsPath): WordsNode |
continue;
}
- // No `type` discriminator → list-item or table-row or table-cell.
- // They all use either `children` or `cells`.
+ // No `type` discriminator → list-item, table-row, table-cell, or column.
+ // They use either `children` or `cells`.
if ('cells' in node) {
const child: TableCell | undefined = (node as TableRow).cells[index];
if (!child) return undefined;
@@ -176,11 +188,15 @@ export function getNodeAtPath(doc: WordsDocument, path: WordsPath): WordsNode |
}
if ('children' in node) {
- // list-item OR table-cell — both have `children: readonly WordsInline[]`
- const child: WordsInline | undefined =
- (node as { children: readonly WordsInline[] }).children[index];
+ // list-item / table-cell carry `children: readonly WordsInline[]`;
+ // column carries `children: readonly WordsBlock[]`. Both indices
+ // are accessed positionally — the actual element type is what
+ // matters to downstream callers, not the static union.
+ const child = (
+ node as { children: readonly (WordsInline | WordsBlock)[] }
+ ).children[index];
if (!child) return undefined;
- node = child;
+ node = child as WordsNode;
continue;
}
@@ -308,6 +324,17 @@ function updateNode(
children: replaceAt(callout.children, index, newChild as WordsBlock)
};
}
+ if (t === 'columns') {
+ const columns = node as ColumnsBlock;
+ const child: Column | undefined = columns.columns[index];
+ if (!child) return node;
+ const newChild = updateNode(child, path, depth + 1, updater);
+ if (newChild === child) return node;
+ return {
+ ...columns,
+ columns: replaceAt(columns.columns, index, newChild as Column)
+ };
+ }
if (t === 'link') {
const link = node as WordsLink;
const child: WordsText | undefined = link.children[index];
@@ -334,13 +361,18 @@ function updateNode(
return { ...row, cells: replaceAt(row.cells, index, newChild as TableCell) };
}
if ('children' in node) {
- // list-item or table-cell
- const container = node as { children: readonly WordsInline[] };
- const child: WordsInline | undefined = container.children[index];
+ // list-item / table-cell carry WordsInline[]; column carries WordsBlock[].
+ // Both are accessed positionally — the children array element type is
+ // what the caller already knows from the path it walked.
+ const container = node as { children: readonly (WordsInline | WordsBlock)[] };
+ const child = container.children[index];
if (!child) return node;
- const newChild = updateNode(child, path, depth + 1, updater);
+ const newChild = updateNode(child as WordsNode, path, depth + 1, updater);
if (newChild === child) return node;
- return { ...container, children: replaceAt(container.children, index, newChild as WordsInline) } as WordsNode;
+ return {
+ ...container,
+ children: replaceAt(container.children, index, newChild as WordsInline | WordsBlock)
+ } as WordsNode;
}
return node;
}
@@ -539,9 +571,18 @@ function nodeIsInlineContainer(node: WordsNode): boolean {
t === 'paragraph' || t === 'heading' || t === 'quote' || t === 'code' || t === 'link'
);
}
- // list-item or table-cell — both inline containers.
+ // list-item, table-cell, table-row, column all lack a `type` discriminator.
+ // table-row owns `cells`; column owns `children: WordsBlock[]`; list-item /
+ // table-cell own `children: WordsInline[]`. Disambiguate by sniffing the
+ // first child — inline (`type: 'text' | 'link'`) means inline-container;
+ // anything else (paragraph/heading/etc., or empty) is not.
if ('cells' in node) return false; // table-row
- return true;
+ if ('children' in node) {
+ const first = (node as { children: readonly { type?: string }[] }).children[0];
+ if (!first) return false;
+ return first.type === 'text' || first.type === 'link';
+ }
+ return false;
}
function stepInto(node: WordsNode, index: number): WordsNode | undefined {
@@ -554,12 +595,16 @@ function stepInto(node: WordsNode, index: number): WordsNode | undefined {
if (t === 'list') return (node as ListBlock).items[index];
if (t === 'table') return (node as TableBlock).rows[index];
if (t === 'callout') return (node as CalloutBlock).children[index];
+ if (t === 'columns') return (node as ColumnsBlock).columns[index];
if (t === 'link') return (node as WordsLink).children[index];
return undefined;
}
if ('cells' in node) return (node as TableRow).cells[index];
if ('children' in node) {
- return (node as { children: readonly WordsInline[] }).children[index];
+ // list-item / table-cell carry WordsInline[]; column carries WordsBlock[].
+ return (node as { children: readonly (WordsInline | WordsBlock)[] }).children[
+ index
+ ] as WordsNode | undefined;
}
return undefined;
}
diff --git a/src/uix/soma/components/words/engine/operations/insert-block-types.ts b/src/uix/soma/components/words/engine/operations/insert-block-types.ts
index 73f8a44f0..0fded95c9 100644
--- a/src/uix/soma/components/words/engine/operations/insert-block-types.ts
+++ b/src/uix/soma/components/words/engine/operations/insert-block-types.ts
@@ -35,6 +35,8 @@ import { changed, noOp, type WordsEditorState, type WordsOperationResult } from
import {
WORDS_VERSION,
type WordsBlock,
+ type Column,
+ type ColumnsBlock,
type WordsDocument,
type WordsIntent,
type WordsImageAlign,
@@ -104,7 +106,10 @@ export function insertImage(
state: WordsEditorState,
opts: InsertImageOptions
): WordsOperationResult {
- if (!opts.src) return noOp(state);
+ // Empty `src` is a deliberate placeholder per the URL-only image flow:
+ // the slash menu inserts an empty image so the inspector exposes the
+ // URL field for the user. Renderer paints a placeholder figure; the
+ // validator accepts empty string. Do NOT noOp here.
return insertAtomicBlock(state, createImage(opts), { caretAtBlockStart: false });
}
@@ -155,6 +160,25 @@ function insertAtomicBlock(
const topIndex = containerPath[0] ?? 0;
const topBlock = state.document.children[topIndex];
+ // Columns block: the caret sits inside a nested column's child block.
+ // Insert into THAT column's children array, not at top level — otherwise
+ // the new block ends up next to the columns wrapper instead of inside it.
+ if (
+ topBlock?.type === 'columns' &&
+ containerPath.length >= 3 &&
+ containerPath[1] !== undefined &&
+ containerPath[2] !== undefined
+ ) {
+ return insertIntoColumn(
+ state,
+ block,
+ topIndex,
+ containerPath as readonly [number, number, number, ...number[]],
+ point,
+ opts
+ );
+ }
+
// Empty paragraph at top level: replace in place.
if (
topBlock?.type === 'paragraph' &&
@@ -217,6 +241,116 @@ function finalizeAtCaret(
});
}
+/**
+ * Same atomic-block insertion as `insertAtomicBlock`, but scoped to a
+ * single `Column.children` array inside a `ColumnsBlock`. The caret is
+ * known to live inside the column's nested block (path[2]) at the time
+ * of call. Splits the inner block when the caret isn't on an empty
+ * paragraph; replaces in place when it is.
+ *
+ * Returned selection lands inside the inserted block at
+ * `[colsIdx, colIdx, newInnerIdx, ...subPath]`.
+ */
+function insertIntoColumn(
+ state: WordsEditorState,
+ block: WordsBlock,
+ colsIdx: number,
+ containerPath: readonly [number, number, number, ...number[]],
+ point: { path: import('../path').WordsPath; offset: number },
+ opts: InsertAtomicOptions
+): WordsOperationResult {
+ const colsBlock = state.document.children[colsIdx] as ColumnsBlock;
+ const colIdx = containerPath[1];
+ const innerIdx = containerPath[2];
+ const col = colsBlock.columns[colIdx];
+ if (!col) return noOp(state);
+ const innerBlock = col.children[innerIdx];
+ if (!innerBlock) return noOp(state);
+
+ // Empty paragraph inside the column → replace in place.
+ const isEmptyInner =
+ innerBlock.type === 'paragraph' &&
+ containerPath.length === 3 &&
+ innerBlock.children.length === 1 &&
+ innerBlock.children[0].type === 'text' &&
+ innerBlock.children[0].text.length === 0;
+
+ let nextInner: readonly WordsBlock[];
+ let newInnerIdx: number;
+ if (isEmptyInner) {
+ // Always keep a trailing paragraph after the inserted atomic so the
+ // user can type below it inside the column (mirror of the top-level
+ // last-block rule). `block` lands at innerIdx; the trailing paragraph
+ // at innerIdx + 1.
+ const isLastInner = innerIdx === col.children.length - 1;
+ nextInner = isLastInner
+ ? [
+ ...col.children.slice(0, innerIdx),
+ block,
+ createParagraph(),
+ ...col.children.slice(innerIdx + 1)
+ ]
+ : [
+ ...col.children.slice(0, innerIdx),
+ block,
+ ...col.children.slice(innerIdx + 1)
+ ];
+ newInnerIdx = innerIdx;
+ } else {
+ // Split the inner inline-container at the caret. `before` keeps the
+ // original block's shape (with the inlines up to the caret); the
+ // atomic block sits between the two halves; `after` becomes a fresh
+ // paragraph holding the post-caret inlines.
+ const split = splitContainerAtPoint(state.document, containerPath, point);
+ const beforeBlock = mutateInlines(innerBlock, ensureInlineChildren(split.before));
+ const afterBlock = createParagraph(ensureInlineChildren(split.after));
+ nextInner = [
+ ...col.children.slice(0, innerIdx),
+ beforeBlock,
+ block,
+ afterBlock,
+ ...col.children.slice(innerIdx + 1)
+ ];
+ newInnerIdx = innerIdx + 1;
+ }
+
+ const nextCol: Column = { ...col, children: nextInner };
+ const nextColumns = colsBlock.columns.map((c, i) => (i === colIdx ? nextCol : c));
+ const nextColsBlock: ColumnsBlock = { ...colsBlock, columns: nextColumns };
+ const normalized = normalizeDocument({
+ ...state.document,
+ children: replaceAt(state.document.children, colsIdx, nextColsBlock)
+ }).document;
+
+ // Atomic blocks (image / divider) have no inline text — placing the
+ // caret on `[colsIdx, colIdx, newInnerIdx]` makes
+ // `pointFromInlineTextOffset` fall back to a foreign text node
+ // (usually doc.children[0]), which then dirty-clears any
+ // `selectedBlockPath` the caller wants to set. When we just added a
+ // trailing paragraph for the user to keep typing, prefer that paragraph
+ // as the caret landing — the model selection stays inside the same
+ // columns block, no false clearSelectedBlock cascade fires, and the
+ // inserter can mark the atomic as the visually-selected block.
+ const isAtomicInserted = block.type === 'image' || block.type === 'divider';
+ const hasTrailingParagraph =
+ isEmptyInner &&
+ newInnerIdx + 1 < (nextCol.children as readonly WordsBlock[]).length &&
+ (nextCol.children as readonly WordsBlock[])[newInnerIdx + 1]?.type === 'paragraph';
+ const caretInnerIdx =
+ isAtomicInserted && hasTrailingParagraph ? newInnerIdx + 1 : newInnerIdx;
+
+ const targetPath = opts.caretSubPath
+ ? [colsIdx, colIdx, caretInnerIdx, ...opts.caretSubPath]
+ : [colsIdx, colIdx, caretInnerIdx];
+ const targetPoint = pointFromInlineTextOffset(normalized, targetPath, 0);
+ const sel = createCollapsedSelection(targetPoint.path, targetPoint.offset);
+ return changed({
+ document: normalized,
+ selection: sel,
+ activeMarks: getActiveMarksForSelection(normalized, sel)
+ });
+}
+
function mutateInlines(
block: WordsBlock,
inlines: readonly import('../types').WordsInline[]
@@ -238,6 +372,118 @@ function mutateInlines(
}
}
+// ── insertBlockInColumn ─────────────────────────────────────────────────
+
+/**
+ * Insert a block into a column slot from a caretless trigger (the `+`
+ * overlay button under each column).
+ *
+ * Canonical Tiptap-style: ONE transaction — the returned result carries
+ * both the new document and the post-insert selection. The provider
+ * publishes both in one flush, so there's no race against a deferred
+ * `setSelection` (every such race we tried produced phantom carets and,
+ * via dropdown-trigger focus retention, phantom repeat-inserts on the
+ * next keystroke).
+ *
+ * Placement:
+ * - Column is "empty" (only seed empty paragraph) → REPLACE that seed.
+ * - Else → APPEND to the column's children.
+ *
+ * Selection:
+ * - ATOMIC (image / divider) → caret in a fresh trailing paragraph
+ * (mirror of the top-level last-block escape-hatch rule).
+ * - TEXT-BEARING → select any stub text in the new block's first inline
+ * ("Title" for heading, "List item" for list, etc.) so the next
+ * keystroke replaces it Notion-style. Empty stub → collapsed caret
+ * at offset 0.
+ */
+export function insertBlockInColumn(
+ state: WordsEditorState,
+ colsIdx: number,
+ colIdx: number,
+ block: WordsBlock
+): WordsOperationResult {
+ const doc = state.document;
+ const colsBlock = doc.children[colsIdx];
+ if (!colsBlock || colsBlock.type !== 'columns') return noOp(state);
+ const col = colsBlock.columns[colIdx];
+ if (!col) return noOp(state);
+
+ const isEmptySeed =
+ col.children.length === 1 &&
+ col.children[0].type === 'paragraph' &&
+ col.children[0].children.length === 1 &&
+ col.children[0].children[0].type === 'text' &&
+ col.children[0].children[0].text.length === 0;
+
+ const isAtomic = block.type === 'image' || block.type === 'divider';
+ const trailing: readonly WordsBlock[] = isAtomic ? [createParagraph()] : [];
+
+ const baseChildren: readonly WordsBlock[] = isEmptySeed ? [] : col.children;
+ const nextChildren: readonly WordsBlock[] = [...baseChildren, block, ...trailing];
+ const newInnerIdx = baseChildren.length;
+
+ const nextCol: Column = { ...col, children: nextChildren };
+ const nextColumns = colsBlock.columns.map((c, i) => (i === colIdx ? nextCol : c));
+ const nextColsBlock: ColumnsBlock = { ...colsBlock, columns: nextColumns };
+ const normalized = normalizeDocument({
+ ...doc,
+ children: replaceAt(doc.children, colsIdx, nextColsBlock)
+ }).document;
+
+ let sel;
+ if (isAtomic) {
+ // Caret lands inside the trailing paragraph (offset 0). Selection
+ // stays INSIDE the columns block — the column-inserter caller can
+ // then mark the atomic block as visually-selected without the
+ // caret bouncing out to a foreign text node and dirty-clearing.
+ const trailingPath = [colsIdx, colIdx, newInnerIdx + 1];
+ const point = pointFromInlineTextOffset(normalized, trailingPath, 0);
+ sel = createCollapsedSelection(point.path, point.offset);
+ } else {
+ const stub = firstTextOfBlock(block);
+ const inlinePath =
+ block.type === 'list'
+ ? [colsIdx, colIdx, newInnerIdx, 0, 0]
+ : [colsIdx, colIdx, newInnerIdx, 0];
+ const startPoint = pointFromInlineTextOffset(normalized, inlinePath, 0);
+ if (stub.length === 0) {
+ sel = createCollapsedSelection(startPoint.path, startPoint.offset);
+ } else {
+ const endPoint = pointFromInlineTextOffset(normalized, inlinePath, stub.length);
+ sel = {
+ anchor: { path: startPoint.path, offset: startPoint.offset },
+ focus: { path: endPoint.path, offset: endPoint.offset }
+ };
+ }
+ }
+
+ return changed({
+ document: normalized,
+ selection: sel,
+ activeMarks: getActiveMarksForSelection(normalized, sel)
+ });
+}
+
+/** Best-effort first text leaf — used to compute the stub-selection
+ * range for newly inserted text-bearing blocks (heading "Title", list
+ * "List item", etc.). Returns '' when there's no leading text node. */
+function firstTextOfBlock(block: WordsBlock): string {
+ if (block.type === 'list') {
+ const firstItem = block.items[0];
+ if (!firstItem) return '';
+ const firstInline = firstItem.children[0];
+ return firstInline?.type === 'text' ? firstInline.text : '';
+ }
+ if ('children' in block && Array.isArray(block.children)) {
+ const first = (block.children as readonly unknown[])[0] as
+ | { type?: string; text?: string }
+ | undefined;
+ if (first && first.type === 'text') return first.text ?? '';
+ }
+ return '';
+}
+
// ── setCodeLanguage ──────────────────────────────────────────────────────
/**
diff --git a/src/uix/soma/components/words/engine/operations/normalize.ts b/src/uix/soma/components/words/engine/operations/normalize.ts
index 68a7e8ba4..46c0639fc 100644
--- a/src/uix/soma/components/words/engine/operations/normalize.ts
+++ b/src/uix/soma/components/words/engine/operations/normalize.ts
@@ -102,9 +102,33 @@ function normalizeBlock(block: WordsBlock, idGen: () => string): WordsBlock {
case 'callout':
return normalizeCallout(block, baseId, idChanged, idGen);
+
+ case 'columns':
+ return normalizeColumns(block, baseId, idChanged, idGen);
}
}
+function normalizeColumns(
+ block: import('../types').ColumnsBlock,
+ id: string,
+ idChanged: boolean,
+ idGen: () => string
+): import('../types').ColumnsBlock {
+ let mutated = false;
+ const columns = block.columns.map((col) => {
+ const colId = col.id ?? idGen();
+ const colIdChanged = colId !== col.id;
+ // Each column's children are normal blocks — recurse.
+ const children = col.children.map((child) => normalizeBlock(child, idGen));
+ const childrenChanged = children.some((c, i) => c !== col.children[i]);
+ if (!colIdChanged && !childrenChanged) return col;
+ mutated = true;
+ return { ...col, id: colId, children };
+ });
+ if (!idChanged && !mutated) return block;
+ return { ...block, id, columns };
+}
+
function normalizeList(
block: ListBlock,
id: string,
diff --git a/src/uix/soma/components/words/engine/operations/paragraph-ops.ts b/src/uix/soma/components/words/engine/operations/paragraph-ops.ts
index 02d905efa..22af850f1 100644
--- a/src/uix/soma/components/words/engine/operations/paragraph-ops.ts
+++ b/src/uix/soma/components/words/engine/operations/paragraph-ops.ts
@@ -42,6 +42,8 @@ import {
WORDS_VERSION,
type WordsBlock,
type CalloutBlock,
+ type Column,
+ type ColumnsBlock,
type WordsDocument,
type HeadingBlock,
type WordsInline,
@@ -110,6 +112,18 @@ export function insertParagraph(state: WordsEditorState): WordsOperationResult {
return handleCalloutInsertParagraph(workingState, block, blockIndex);
}
+ // Columns: same shape as callout but two levels deep
+ // (`columns.columns[colIdx].children[innerIdx]`). Without this branch
+ // the top-level block lookup matches `columns`, none of the leaf
+ // branches fire, and Enter becomes a silent no-op for any caret
+ // inside a column — that's the user-visible "salto de línea no
+ // funciona" symptom. Delegates to the column's own insertParagraph
+ // against the inner block.
+ if (block.type === 'columns') {
+ if (containerPath.length < 3) return noOp(state);
+ return handleColumnsInsertParagraph(workingState, block, blockIndex, containerPath);
+ }
+
if (block.type === 'list') {
return handleListInsertParagraph(workingState, block, containerPath, split);
}
@@ -425,6 +439,85 @@ function handleCalloutInsertParagraph(
});
}
+function handleColumnsInsertParagraph(
+ state: WordsEditorState,
+ columns: ColumnsBlock,
+ columnsIndex: number,
+ containerPath: WordsPath
+): WordsOperationResult {
+ const selection = state.selection;
+ if (!selection) return noOp(state);
+ const colIdx = containerPath[1];
+ if (colIdx === undefined) return noOp(state);
+ const column = columns.columns[colIdx];
+ if (!column) return noOp(state);
+
+ // Same trick as `handleCalloutInsertParagraph`: build a virtual
+ // sub-document whose children are THIS column's blocks, slice the
+ // `[columnsIndex, colIdx]` prefix off the selection paths, recurse
+ // into `insertParagraph`, then re-attach the prefix and graft the
+ // result back into the original columns block. The recursion picks
+ // up the proper paragraph / heading / list / quote branch inside.
+ const subDoc: WordsDocument = {
+ version: WORDS_VERSION,
+ children: column.children
+ };
+ const subSelection: WordsSelection = {
+ anchor: {
+ path: selection.anchor.path.slice(2),
+ offset: selection.anchor.offset
+ },
+ focus: {
+ path: selection.focus.path.slice(2),
+ offset: selection.focus.offset
+ }
+ };
+ const subState: WordsEditorState = {
+ document: subDoc,
+ selection: subSelection,
+ activeMarks: state.activeMarks
+ };
+
+ const subResult = insertParagraph(subState);
+ if (!subResult.changed) return noOp(state);
+
+ const newColumn: Column = {
+ ...column,
+ children: subResult.state.document.children
+ };
+ const newColumns: ColumnsBlock = {
+ ...columns,
+ columns: columns.columns.map((c, i) => (i === colIdx ? newColumn : c))
+ };
+
+ const subSel = subResult.state.selection;
+ const newSelection: WordsSelection | null = subSel
+ ? {
+ anchor: {
+ path: [columnsIndex, colIdx, ...subSel.anchor.path],
+ offset: subSel.anchor.offset
+ },
+ focus: {
+ path: [columnsIndex, colIdx, ...subSel.focus.path],
+ offset: subSel.focus.offset
+ }
+ }
+ : null;
+
+ const normalized = normalizeDocument({
+ ...state.document,
+ children: replaceAt(state.document.children, columnsIndex, newColumns)
+ }).document;
+
+ return changed({
+ document: normalized,
+ selection: newSelection,
+ activeMarks: newSelection
+ ? getActiveMarksForSelection(normalized, newSelection)
+ : state.activeMarks
+ });
+}
+
// ── Helpers ──────────────────────────────────────────────────────────────
function inlineLengthOf(inlines: readonly WordsInline[]): number {
diff --git a/src/uix/soma/components/words/engine/operations/selection-walkers.ts b/src/uix/soma/components/words/engine/operations/selection-walkers.ts
index 2df313769..4bc65c5fc 100644
--- a/src/uix/soma/components/words/engine/operations/selection-walkers.ts
+++ b/src/uix/soma/components/words/engine/operations/selection-walkers.ts
@@ -31,6 +31,7 @@ import type {
CalloutBlock,
CodeBlock,
WordsDocument,
+ ColumnsBlock,
HeadingBlock,
WordsInline,
WordsLink,
@@ -106,6 +107,17 @@ function collectBlockText(
}
return;
}
+ case 'columns': {
+ const b = block as ColumnsBlock;
+ for (let colIdx = 0; colIdx < b.columns.length; colIdx++) {
+ const col = b.columns[colIdx];
+ const colPath = [...path, colIdx];
+ for (let innerIdx = 0; innerIdx < col.children.length; innerIdx++) {
+ collectBlockText(col.children[innerIdx], [...colPath, innerIdx], out);
+ }
+ }
+ return;
+ }
// image, divider — no text.
default:
return;
diff --git a/src/uix/soma/components/words/engine/operations/table-ops.ts b/src/uix/soma/components/words/engine/operations/table-ops.ts
index c4c66b9ea..d9a3017e6 100644
--- a/src/uix/soma/components/words/engine/operations/table-ops.ts
+++ b/src/uix/soma/components/words/engine/operations/table-ops.ts
@@ -23,6 +23,7 @@ import { normalizeDocument } from './normalize';
import { getActiveMarksForSelection } from './selection-walkers';
import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types';
import type {
+ Block,
WordsBlock,
ParagraphBlock,
TableBlock,
@@ -196,6 +197,117 @@ export function setCellVerticalAlign(
});
}
+// ── setCellVisual / setRowVisual ─────────────────────────────────────────
+//
+// Selection-driven generic visual patchers for the cell + row scopes —
+// mirror `setBlockVisual` but resolve their target from the cursor's
+// table path (no explicit indices). Shallow-merge the patch into the
+// active cell / its row, treating `undefined` values as deletions of
+// the corresponding key (same semantics as `setBlockVisual`).
+//
+// Why both: the inspector edits color / background / padding / border
+// at whichever scope the user is in. Routing every cell-level edit
+// through `setBlockVisual` would mutate the table as a whole and
+// propagate via CSS inheritance to every cell — the canonical bug from
+// 2026-05-30 that this op fixes.
+
+const VISUAL_KEYS = [
+ 'align',
+ 'margin',
+ 'padding',
+ 'background',
+ 'color',
+ 'border',
+ 'fontSize',
+ 'fontFamily',
+ 'fontWeight',
+ 'lineHeight'
+] as const;
+
+function mergeVisual(target: T, visual: Partial): T {
+ const prev = target as unknown as Record;
+ const next: Record = { ...prev };
+ let touched = false;
+ for (const key of VISUAL_KEYS) {
+ if (!(key in visual)) continue;
+ const value = (visual as Record)[key];
+ if (value === undefined) {
+ if (key in next) {
+ delete next[key];
+ touched = true;
+ }
+ } else if (!shallowEqual(next[key], value)) {
+ next[key] = value;
+ touched = true;
+ }
+ }
+ if (!touched) return target;
+ return next as unknown as T;
+}
+
+function shallowEqual(a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
+ const ao = a as Record;
+ const bo = b as Record;
+ const ak = Object.keys(ao);
+ const bk = Object.keys(bo);
+ if (ak.length !== bk.length) return false;
+ return ak.every((k) => ao[k] === bo[k]);
+ }
+ return false;
+}
+
+export function setCellVisual(
+ state: WordsEditorState,
+ visual: Partial
+): WordsOperationResult {
+ return updateActiveCell(state, (cell) => mergeVisual(cell, visual));
+}
+
+export function setRowVisual(
+ state: WordsEditorState,
+ visual: Partial
+): WordsOperationResult {
+ return updateActiveRow(state, (row) => mergeVisual(row, visual));
+}
+
+function updateActiveRow(
+ state: WordsEditorState,
+ updater: (row: TableRow) => TableRow
+): WordsOperationResult {
+ // Row scope = the row enclosing the user's selection. Works whether
+ // the selection is at the row level (`[i, r]`) or deeper inside a
+ // cell (`[i, r, c, ...]`) — we only need the first two indices.
+ const sel = state.selection;
+ if (!sel) return noOp(state);
+ const path = sel.anchor.path;
+ if (path.length < 2) return noOp(state);
+ const blockIndex = path[0];
+ const rowIndex = path[1];
+ if (blockIndex === undefined || rowIndex === undefined) return noOp(state);
+ const block = state.document.children[blockIndex];
+ if (!block || block.type !== 'table') return noOp(state);
+ const row = block.rows[rowIndex];
+ if (!row) return noOp(state);
+
+ const nextRow = updater(row);
+ if (nextRow === row) return noOp(state);
+ const nextTable: TableBlock = {
+ ...block,
+ rows: replaceAt(block.rows, rowIndex, nextRow)
+ };
+ const normalized = normalizeDocument({
+ ...state.document,
+ children: replaceAt(state.document.children, blockIndex, nextTable)
+ }).document;
+ return changed({
+ document: normalized,
+ selection: sel,
+ activeMarks: getActiveMarksForSelection(normalized, sel)
+ });
+}
+
function updateActiveCell(
state: WordsEditorState,
updater: (cell: TableCell) => TableCell
diff --git a/src/uix/soma/components/words/engine/operations/visual.ts b/src/uix/soma/components/words/engine/operations/visual.ts
index c7d81e21d..24626abb3 100644
--- a/src/uix/soma/components/words/engine/operations/visual.ts
+++ b/src/uix/soma/components/words/engine/operations/visual.ts
@@ -8,7 +8,9 @@
*/
import { normalizeDocument } from './normalize';
+import { getNodeAtPath, updateNodeAtPath } from './helpers';
import { changed, noOp, type WordsEditorState, type WordsOperationResult } from './types';
+import type { WordsPath } from '../path';
import type { Block, WordsBlock } from '../types';
const STYLE_KEYS = [
@@ -57,6 +59,78 @@ export function setBlockVisual(
return changed({ ...state, document: nextDoc });
}
+/**
+ * Path-aware twin of `setBlockVisual`. Walks `blockPath` to find the
+ * target block (top-level OR nested inside `columns` / `callout`) and
+ * applies the same style-merge semantics. Used by the inspector when
+ * the user has clicked into a block nested inside a column — the
+ * top-level `blockIndex` would point at the wrapper, so the path
+ * version is the only way to touch the right block.
+ */
+export function setBlockVisualAtPath(
+ state: WordsEditorState,
+ blockPath: WordsPath,
+ patch: Partial
+): WordsOperationResult {
+ if (blockPath.length === 0) return noOp(state);
+ const target = getNodeAtPath(state.document, blockPath);
+ if (!target || !('type' in target)) return noOp(state);
+
+ const prev = target as unknown as Record;
+ const next: Record = { ...prev };
+ let touched = false;
+ for (const key of STYLE_KEYS) {
+ if (!(key in patch)) continue;
+ const value = (patch as Record)[key];
+ if (value === undefined) {
+ if (key in next) {
+ delete next[key];
+ touched = true;
+ }
+ } else if (!styleEqual(next[key], value)) {
+ next[key] = value;
+ touched = true;
+ }
+ }
+ if (!touched) return noOp(state);
+
+ const nextDoc = updateNodeAtPath(state.document, blockPath, () => next as unknown as WordsBlock);
+ if (nextDoc === state.document) return noOp(state);
+ const normalized = normalizeDocument(nextDoc).document;
+ return changed({ ...state, document: normalized });
+}
+
+/**
+ * Path-aware shallow merge — mirrors `updateBlockAt` but accepts a full
+ * document path. Used by the inspector's Block panel (alt / caption /
+ * src / column children…) when the target block sits inside a column.
+ * Preserves the block's discriminator (`type` is filtered out of the
+ * patch, same as the top-level op).
+ */
+export function updateBlockAtPath(
+ state: WordsEditorState,
+ blockPath: WordsPath,
+ patch: Readonly>
+): WordsOperationResult {
+ if (blockPath.length === 0) return noOp(state);
+ const target = getNodeAtPath(state.document, blockPath);
+ if (!target || !('type' in target)) return noOp(state);
+
+ const safePatch: Record = {};
+ for (const [k, v] of Object.entries(patch)) {
+ if (k === 'type') continue;
+ safePatch[k] = v;
+ }
+
+ const nextDoc = updateNodeAtPath(state.document, blockPath, (node) => ({
+ ...(node as Record),
+ ...safePatch
+ }) as unknown as WordsBlock);
+ if (nextDoc === state.document) return noOp(state);
+ const normalized = normalizeDocument(nextDoc).document;
+ return changed({ ...state, document: normalized });
+}
+
// ── Helpers ──────────────────────────────────────────────────────────────
/** Shallow equality, descending one level into the small style objects
diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts
index 38022ff52..80bcea972 100644
--- a/src/uix/soma/components/words/engine/render.ts
+++ b/src/uix/soma/components/words/engine/render.ts
@@ -340,18 +340,45 @@ export function blockStyle(
): Readonly> {
const out: Record = {};
if (block.align !== undefined) out['text-align'] = block.align;
+ // Per-side keys win over axis keys (`blockStart` overrides `block` on the
+ // top side, etc.). See `WordsSpacing` jsdoc — inspector exposes 4-side
+ // editing while old documents using `block`/`inline` still render.
const m = block.margin;
- if (m?.block !== undefined) {
- out['margin-block-start'] = px(m.block);
- out['margin-block-end'] = px(m.block);
- }
- if (m?.inline !== undefined) {
- out['margin-inline-start'] = px(m.inline);
- out['margin-inline-end'] = px(m.inline);
+ if (m) {
+ const top = m.blockStart ?? m.block;
+ const bottom = m.blockEnd ?? m.block;
+ const start = m.inlineStart ?? m.inline;
+ const end = m.inlineEnd ?? m.inline;
+ if (top !== undefined) out['margin-block-start'] = px(top);
+ if (bottom !== undefined) out['margin-block-end'] = px(bottom);
+ if (start !== undefined) out['margin-inline-start'] = px(start);
+ if (end !== undefined) out['margin-inline-end'] = px(end);
}
const p = block.padding;
- if (p && (p.block !== undefined || p.inline !== undefined)) {
- out.padding = `${px(p.block ?? 0)} ${px(p.inline ?? 0)}`;
+ if (p) {
+ const top = p.blockStart ?? p.block;
+ const bottom = p.blockEnd ?? p.block;
+ const start = p.inlineStart ?? p.inline;
+ const end = p.inlineEnd ?? p.inline;
+ const hasPerSide =
+ p.blockStart !== undefined ||
+ p.blockEnd !== undefined ||
+ p.inlineStart !== undefined ||
+ p.inlineEnd !== undefined;
+ const anySet =
+ top !== undefined || bottom !== undefined || start !== undefined || end !== undefined;
+ if (anySet) {
+ if (hasPerSide) {
+ // 4-value shorthand `padding: T R B L` when any side is
+ // independent. Unset sides fall back to 0.
+ out.padding = `${px(top ?? 0)} ${px(end ?? 0)} ${px(bottom ?? 0)} ${px(start ?? 0)}`;
+ } else {
+ // Legacy axis-only shape — preserve the 2-value `V H`
+ // shorthand for backward-compat with existing documents
+ // and tests.
+ out.padding = `${px(p.block ?? 0)} ${px(p.inline ?? 0)}`;
+ }
+ }
}
if (block.background !== undefined) out['background-color'] = block.background;
if (block.color !== undefined) out.color = block.color;
diff --git a/src/uix/soma/components/words/engine/types.ts b/src/uix/soma/components/words/engine/types.ts
index 74e537140..7b6203db9 100644
--- a/src/uix/soma/components/words/engine/types.ts
+++ b/src/uix/soma/components/words/engine/types.ts
@@ -38,10 +38,31 @@ export const WORDS_INTENTS = [
export type WordsAlign = 'left' | 'center' | 'right' | 'justify';
-/** Logical box spacing in pixels. `block` = top/bottom, `inline` = start/end. */
+/**
+ * Logical box spacing in pixels.
+ *
+ * Two levels of specificity:
+ *
+ * - **Axis** — `block` (top + bottom) and `inline` (start + end). Compact
+ * shape, ideal when both sides on an axis are equal.
+ * - **Per-side** — `blockStart`/`blockEnd`/`inlineStart`/`inlineEnd`. Used
+ * when sides need independent values.
+ *
+ * Resolution precedence (render + serialize-html consume both):
+ * `blockStart ?? block`, `blockEnd ?? block`,
+ * `inlineStart ?? inline`, `inlineEnd ?? inline`.
+ *
+ * Inspector UI exposes per-side editing with a "link" toggle (Figma /
+ * Photoshop convention) — when linked, edits update all 4 sides. When
+ * unlinked, each side edits its own per-side key.
+ */
export interface WordsSpacing {
readonly block?: number;
readonly inline?: number;
+ readonly blockStart?: number;
+ readonly blockEnd?: number;
+ readonly inlineStart?: number;
+ readonly inlineEnd?: number;
}
export type WordsBorderStyle = 'solid' | 'dashed' | 'dotted';
@@ -190,10 +211,25 @@ export interface TableCell extends Block {
readonly verticalAlign?: WordsVerticalAlign;
readonly colspan?: number;
readonly rowspan?: number;
+ /**
+ * Column width as a CSS length / percentage (`'200px'`, `'30%'`,
+ * `'auto'`). The renderer applies it as the cell's `style.width`;
+ * setting it on any cell in a column makes the browser propagate
+ * that width to the whole column (HTML tables compute column width
+ * from the widest cell). Optional — `undefined` means the browser
+ * auto-sizes.
+ */
+ readonly width?: string;
}
export interface TableRow extends Block {
readonly cells: readonly TableCell[];
+ /**
+ * Row height as a CSS length (`'48px'`, `'auto'`). The renderer
+ * applies it as the row's `style.height`; the browser treats it as
+ * a minimum (the row grows past it if cell content overflows).
+ */
+ readonly height?: string;
}
export interface TableBlock extends Block {
@@ -210,11 +246,20 @@ export interface ImageBlock extends Block {
readonly src: string;
readonly alt?: string;
readonly caption?: string;
- /** Intrinsic natural dimensions (px). */
+ /** Intrinsic natural dimensions (px). Ignored when `fullWidth` is true. */
readonly width?: number;
readonly height?: number;
- /** Float intent. */
+ /** Float intent. Ignored when `fullWidth` is true (the image spans the
+ * whole container so float has no effect). */
readonly imageAlign?: WordsImageAlign;
+ /**
+ * When true, the image stretches to 100% of the container width and
+ * the renderer ignores `width`/`height`/`imageAlign`. Allows the
+ * image to upscale beyond its intrinsic size — useful for hero /
+ * banner images. The inspector greys out the gated fields while
+ * this is on so the user sees they don't apply.
+ */
+ readonly fullWidth?: boolean;
/** Drop shadow (image-specific). */
readonly shadow?: WordsShadow;
}
@@ -223,6 +268,38 @@ export interface DividerBlock extends Block {
readonly type: 'divider';
}
+/**
+ * One column inside a `ColumnsBlock`. Holds any number of nested
+ * `WordsBlock`s (paragraphs, headings, images, even nested tables /
+ * columns). Extends `Block` so each column carries its own visual
+ * props (background, padding, border, color tint) independent of its
+ * siblings.
+ */
+export interface Column extends Block {
+ readonly children: readonly WordsBlock[];
+ /**
+ * Column width as a CSS flex / length value (`'1fr'`, `'200px'`,
+ * `'30%'`, `'auto'`). The renderer applies it as the column's
+ * `flex-basis`. Empty means `1fr` — equal share with siblings.
+ */
+ readonly width?: string;
+}
+
+/**
+ * Side-by-side layout container. Holds N columns; each column holds
+ * any blocks. Use for image-with-text layouts, multi-column reading
+ * sections, side-by-side comparison panes — anywhere a `` would
+ * be overkill semantically.
+ *
+ * Distinct from `TableBlock` (which models tabular DATA with a row /
+ * cell / header-row grid). Columns model LAYOUT only; there is no
+ * header row, no spreadsheet semantics.
+ */
+export interface ColumnsBlock extends Block {
+ readonly type: 'columns';
+ readonly columns: readonly Column[];
+}
+
export interface CalloutBlock extends Block {
readonly type: 'callout';
readonly intent: WordsIntent;
@@ -258,6 +335,7 @@ export interface WordsBlockMap {
image: ImageBlock;
divider: DividerBlock;
callout: CalloutBlock;
+ columns: ColumnsBlock;
}
export type WordsBlock = WordsBlockMap[keyof WordsBlockMap];
diff --git a/src/uix/soma/components/words/engine/validate.ts b/src/uix/soma/components/words/engine/validate.ts
index ded1f92a1..5af0f5325 100644
--- a/src/uix/soma/components/words/engine/validate.ts
+++ b/src/uix/soma/components/words/engine/validate.ts
@@ -228,11 +228,21 @@ function validateSpacing(value: unknown, path: string, errors: ValidationError[]
errors.push({
path,
code: 'invalid-visual-value',
- message: 'spacing must be an object { block?, inline? }'
+ message:
+ 'spacing must be an object { block?, inline?, blockStart?, blockEnd?, inlineStart?, inlineEnd? }'
});
return;
}
- for (const key of ['block', 'inline'] as const) {
+ // Both the legacy axis keys (`block`/`inline`) and the per-side keys
+ // (`blockStart`/`blockEnd`/`inlineStart`/`inlineEnd`) are accepted.
+ for (const key of [
+ 'block',
+ 'inline',
+ 'blockStart',
+ 'blockEnd',
+ 'inlineStart',
+ 'inlineEnd'
+ ] as const) {
const n = (value as Record)[key];
if (n !== undefined && (typeof n !== 'number' || !Number.isFinite(n))) {
errors.push({
diff --git a/src/uix/soma/components/words/exports.ts b/src/uix/soma/components/words/exports.ts
index 7c26501cc..2294012eb 100644
--- a/src/uix/soma/components/words/exports.ts
+++ b/src/uix/soma/components/words/exports.ts
@@ -43,18 +43,27 @@ export type {
WordsBubbleMenuSnippetProps as BubbleMenuSnippetProps,
WordsSlashMenuSnippetProps as SlashMenuSnippetProps,
WordsLinkEditorSnippetProps as LinkEditorSnippetProps,
- WordsFindReplaceSnippetProps as FindReplaceSnippetProps
+ WordsFindReplaceSnippetProps as FindReplaceSnippetProps,
+ WordsOnUploadImage,
+ WordsImageUploadResult
} from './types';
export type {
+ WordsAlign,
WordsBlock,
WordsBlockType,
WordsDocument,
WordsHeadingLevel,
ImageBlock,
+ WordsImageAlign,
WordsInline,
+ WordsIntent,
WordsListKind,
WordsMark,
+ WordsSpacing,
+ WordsVerticalAlign,
+ Column,
+ ColumnsBlock,
TableBlock,
TableCell,
TableRow
diff --git a/src/uix/soma/components/words/types.ts b/src/uix/soma/components/words/types.ts
index c89a269eb..eb0fba8c2 100644
--- a/src/uix/soma/components/words/types.ts
+++ b/src/uix/soma/components/words/types.ts
@@ -111,6 +111,14 @@ export type WordsProviderSnippetProps = {
* selection rarely points at them.
*/
readonly selectedBlockIndex: number | undefined;
+ /**
+ * Full document path to the currently-selected atomic block. For
+ * top-level atomics `selectedBlockPath === [selectedBlockIndex]`;
+ * for nested atomics (e.g. an image inside a column) it carries the
+ * `[columnsIdx, colIdx, innerIdx]` chain so consumers can route
+ * mutations via the path-aware engine commands.
+ */
+ readonly selectedBlockPath: readonly number[] | undefined;
readonly plainText: string;
readonly isEmpty: boolean;
readonly isFocused: boolean;
@@ -147,8 +155,19 @@ export type WordsProviderSnippetProps = {
readonly focus: () => void;
readonly selectAll: () => void;
readonly selectBlock: () => void;
- readonly selectAtomicBlock: (blockIndex: number) => void;
+ readonly selectAtomicBlock: (
+ blockIndex: number,
+ blockPath?: readonly number[]
+ ) => void;
readonly clearSelectedBlock: () => void;
+ /**
+ * Imperative selection setter. Replaces the current selection with
+ * the supplied range (or clears it). Used by overlays / drag-drop /
+ * the column inserter when they need to place the caret outside the
+ * normal beforeInput flow. Returns `true` when the selection actually
+ * changed.
+ */
+ readonly setSelection: (selection: WordsSelection | null) => boolean;
readonly runCommand: (command: WordsCommandName) => void;
/**
* Apply a command directly without going through the sema runtime
diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts
index 8b80f31e5..e7c4c5a04 100644
--- a/src/uix/soma/components/words/words-provider.svelte.ts
+++ b/src/uix/soma/components/words/words-provider.svelte.ts
@@ -190,8 +190,15 @@ export class WordsProvider {
* an atomic block. Set by the eidos click handler when the user
* clicks an image; cleared by any selectionchange / focus loss /
* text-editing event.
+ *
+ * For top-level atomics `selectedBlockIndex === selectedBlockPath[0]`.
+ * For nested atomics (e.g. an image inside a column) the index points
+ * at the wrapper block (the columns block) while the PATH walks all
+ * the way to the actual image — readers that need to act on the image
+ * itself (inspector, image float bar, …) read `selectedBlockPath`.
*/
selectedBlockIndex = $state(undefined);
+ selectedBlockPath = $state(undefined);
/**
* Image-upload status sidecar — keyed by block id. Per V2 doctrine P4,
@@ -359,9 +366,13 @@ export class WordsProvider {
* extends the text selection to cover the current block's
* contents).
*/
- selectAtomicBlock(blockIndex: number): void {
+ selectAtomicBlock(blockIndex: number, blockPath?: readonly number[]): void {
if (blockIndex < 0 || blockIndex >= this.document.children.length) return;
this.selectedBlockIndex = blockIndex;
+ // Track the full path so callers can act on a nested atomic (image
+ // inside a column). Defaults to a single-segment path equal to the
+ // top-level index for back-compat with top-level callers.
+ this.selectedBlockPath = blockPath && blockPath.length > 0 ? blockPath : [blockIndex];
// Focus the editor root so keyboard handlers (esc, delete) work
// on the atomic block.
const root = this.opts.ref.current;
@@ -372,6 +383,7 @@ export class WordsProvider {
clearSelectedBlock(): void {
if (this.selectedBlockIndex !== undefined) this.selectedBlockIndex = undefined;
+ if (this.selectedBlockPath !== undefined) this.selectedBlockPath = undefined;
}
readonly currentHeadingLevel = $derived.by(() => {
@@ -519,13 +531,24 @@ export class WordsProvider {
return false;
}
// Clear the atomic-block highlight only when the text selection
- // genuinely moved (the user clicked somewhere else / pressed an
- // arrow / typed). Synthetic selectionchange echoes that arrive
+ // genuinely moved AND landed OUTSIDE the wrapper of the currently
+ // selected atomic. Synthetic selectionchange echoes that arrive
// right after `selectAtomicBlock` (which doesn't actually move
// the DOM caret) would otherwise wipe the highlight a tick
- // later, defeating the click.
+ // later, defeating the click. Same intent for nested atomics
+ // inside a column: when the engine drops the caret into the
+ // column's trailing paragraph after `insertImage`, the new
+ // selection is still inside the SAME top-level wrapper as the
+ // selected image — don't clear in that case (the inspector needs
+ // to keep showing the image panel until the user moves to a
+ // different top-level block).
if (!sameWordsSelection(this.selection, next)) {
- this.clearSelectedBlock();
+ const selectedTopIdx =
+ this.selectedBlockPath?.[0] ?? this.selectedBlockIndex;
+ const nextTopIdx = next.anchor.path[0];
+ if (selectedTopIdx === undefined || nextTopIdx !== selectedTopIdx) {
+ this.clearSelectedBlock();
+ }
}
this.updateSelection(next);
return true;
@@ -680,6 +703,7 @@ export class WordsProvider {
): boolean {
if (this.isDisabled || this.isReadonly) return false;
this.ensureSelection();
+ const prevSelection = this.history.present.selection;
const next =
options.batch === 'typing'
? this.typingHistoryCommand(command)
@@ -687,6 +711,23 @@ export class WordsProvider {
const changed = next !== this.history;
this.publishHistory(next);
this.typingBatchOpen = options.batch === 'typing' && changed;
+ // When a command changes the model selection — common for commands
+ // that originate OUTSIDE the editor (overlay buttons, drag-drop,
+ // the column inserter `+`, …) — sync the new selection to the DOM
+ // so the caret actually moves. Without this, the model knows the
+ // caret should be in the freshly-inserted block, but the DOM caret
+ // stays wherever the user last clicked, and typing is dropped.
+ // Typing-batch commands (insertText from beforeInput) already have
+ // the right DOM selection — the browser placed it before our
+ // handler ran — so we skip the restore there to avoid a redundant
+ // tick + potential caret bounce.
+ if (
+ changed &&
+ options.batch !== 'typing' &&
+ !sameWordsSelection(prevSelection, this.history.present.selection)
+ ) {
+ void tick().then(() => this.restoreDomSelection());
+ }
return changed;
}
@@ -952,7 +993,12 @@ export class WordsProvider {
const blockIndex = path?.[0];
if (typeof blockIndex === 'number') {
e.preventDefault();
- this.selectAtomicBlock(blockIndex);
+ // Pass the FULL path — for top-level images path = [idx];
+ // for nested images (inside a column) it's
+ // [colsIdx, colIdx, innerIdx]. Inspector reads
+ // `selectedBlockPath` to resolve the actual image even
+ // when the top-level index points at the wrapper.
+ this.selectAtomicBlock(blockIndex, path ?? undefined);
return;
}
}
@@ -1184,6 +1230,7 @@ export class WordsProvider {
imageStatus: this.imageStatus,
selection: this.selection,
selectedBlockIndex: this.selectedBlockIndex,
+ selectedBlockPath: this.selectedBlockPath,
plainText: this.plainText,
isEmpty: this.isEmpty,
isFocused: this.focused,
@@ -1220,8 +1267,10 @@ export class WordsProvider {
focus: () => this.focus(),
selectAll: () => this.selectAll(),
selectBlock: () => this.selectBlock(),
- selectAtomicBlock: (blockIndex: number) => this.selectAtomicBlock(blockIndex),
+ selectAtomicBlock: (blockIndex: number, blockPath?: readonly number[]) =>
+ this.selectAtomicBlock(blockIndex, blockPath),
clearSelectedBlock: () => this.clearSelectedBlock(),
+ setSelection: (selection: WordsSelection | null) => this.setSelectionPublic(selection),
runCommand: (command: WordsCommandName) => this.runCommandName(command),
applyCommand: (command: WordsCommand) => this.applyCommand(command),
setCodeLanguage: (language?: string) => this.setCodeLanguage(language),
@@ -1263,6 +1312,24 @@ export class WordsProvider {
this.publishPresent(result.state, { documentChanged: false });
}
+ /**
+ * Public selection setter used by overlay tools that need to move the
+ * caret programmatically before issuing a command (e.g. the column
+ * inserter clicks a button outside contenteditable and must focus the
+ * empty paragraph inside the chosen column so the insert op's
+ * selection-driven routing picks the right scope). Returns `true` if
+ * the model selection moved; `false` if the path was invalid or the
+ * selection was already there.
+ */
+ setSelectionPublic(selection: WordsSelection | null): boolean {
+ const result = setWordsSelection(this.history.present, selection);
+ if (!result.changed) return false;
+ this.closeTypingBatch();
+ this.publishPresent(result.state, { documentChanged: false });
+ void tick().then(() => this.restoreDomSelection());
+ return true;
+ }
+
private isInsideWordsTool(element: HTMLElement): boolean {
// Internal-tool predicate for the focus scope. Any tool that
// the user clicks WITHOUT intending to leave the editor must
@@ -1282,6 +1349,9 @@ export class WordsProvider {
'[data-words-bubble-turn-into]',
'[data-words-code-language-panel]',
'[data-words-code-language-picker]',
+ '[data-words-column-inserter]',
+ '[data-words-column-inserter-menu]',
+ '[data-words-column-inserter-trigger]',
'[data-words-drawer]',
'[data-words-find-replace]',
'[data-words-heading-picker]',
@@ -1562,18 +1632,21 @@ export class WordsProvider {
if (!item) return false;
let command: WordsCommand | undefined;
if (item.id === 'image') {
- // Image insert needs a runtime URL. The MVP uses the browser's
- // native prompt; consumers wanting a custom dialog can hide
- // the slash menu's 'image' entry (via `slashCommands` prop
- // override) and trigger `applyWordsCommand({ type: 'insertImage',
- // src, alt? })` from their own toolbar/dialog instead.
- const node = this.opts.ref.current;
- const win = node?.ownerDocument?.defaultView ?? undefined;
- const src = win?.prompt?.('Image URL:')?.trim();
- if (!src) return false;
- const altRaw = win?.prompt?.('Alt text (optional):');
- const alt = altRaw?.trim() ? altRaw.trim() : undefined;
- command = { type: 'insertImage', src, alt };
+ // Slash-menu 'image' inserts a PLACEHOLDER image block (empty
+ // `src`) — the inspector exposes a URL field for the user
+ // to fill in. No prompt, no file picker. Drop / paste stay
+ // on the FileReader path via `onUploadImage`; that's an
+ // implicit upload from a binary the user already has. Going
+ // from "click insert image" to "browse files" was confusing
+ // — the user explicitly asked for a URL-only flow with an
+ // inline placeholder.
+ //
+ // Consumers wanting a richer dialog (preview, drag-drop in
+ // modal, alt + caption pre-fill) can hide the slash menu's
+ // 'image' entry (via `slashCommands` prop override) and
+ // trigger `applyCommand({ type: 'insertImage', src, alt? })`
+ // from their own toolbar.
+ command = { type: 'insertImage', src: '' };
} else {
command = slashInsertionCommand(item.id);
if (!command) {
diff --git a/web/routes/uix/components/words/+page.svelte b/web/routes/uix/components/words/+page.svelte
index 6ae846db2..713bc43c1 100644
--- a/web/routes/uix/components/words/+page.svelte
+++ b/web/routes/uix/components/words/+page.svelte
@@ -5,7 +5,36 @@
import Words from '$uix/eidos/components/words';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import type { WordsInspectorMode } from '$uix/eidos/components/words/types';
- import type { WordsDocument } from '$soma/components/words';
+ 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((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' },
@@ -89,6 +118,18 @@
]
},
{ 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,
@@ -151,7 +192,12 @@
-
+