The previous split was by the text pick (white/dark) - wrong. Group the on-solid
chips by the SAME taxonomy as the Roles section: Jerarquia (primary/secondary/
tertiary) and Intents (neutral/affirm/fulfill/risk/threat/loss), reusing GROUPS.
Removed the now-unused onSolidGroups derived.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Revert ssr:false (it sidestepped the problem, did not fix it). Set the color
inputs imperatively via a `colorValue` action instead of reactive value=/bind:value.
With no Svelte-managed value, hydration no longer assigns "" (the warning) -- and
SSR stays ON. Verified: SSR HTML emits the 6 color inputs with NO value attr; the
action populates them client-side with valid hex.
- Split the "Texto on-solid" section into 2 groups by the APCA pick: "Texto blanco"
(dark solids) and "Texto oscuro" (light solids), computed from the live builder
solids (effectiveSolids) so the groups match the rendered chips. Default: white(8)
/ dark(1 = risk).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diagnosis: the SSR HTML already emits valid hex on all 6 <input type="color">
(verified), and the warning stack pointed to SvelteKit hydration ("await in
start"). Svelte momentarily sets a color input's value to "" while hydrating
before applying the real hex -> "specified value '' does not conform" x6. The page
is a client-only interactive tool (no SEO/SSR need), so ssr:false removes
hydration entirely -> no warning. Belt-and-suspenders with the existing value
guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The seed <input type="color"> used bind:value; switch to value + guarded oninput
so it can never receive "" (the recurring "specified value '' does not conform"
warning, fired at hydration). Both color inputs are now guarded.
- The Roles "contrast" slot IS the on-solid text color: white for dark solids
(most intents), dark only for light ones (risk). White swatches looked invisible
on the light card; add a faint inset ring to .slots .sw so they read as white,
not "no color". Behavior unchanged - only risk needs dark text (correct, APCA).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Radix's gold/bronze were near-identical in UI (chroma ~0.05, indistinguishable).
Re-authored via uix.color (generateScale from metallic seeds): gold #d4af37
(yellow-gold, OKLCH H91) and bronze #cd7f32 (copper, H61), chroma ~0.13 — ~2.6x
more saturated so the hue gap reads clearly (gold yellow vs bronze copper).
Verified in-browser: gold rgb(212,175,55) vs bronze rgb(205,127,50), distinct.
The other 29 scales stay exact Radix v3; header notes the exception. Palette stays
Radix-as-default (swappable) per the engine-not-hues principle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 31-scale library was a flat list, so the 6 tinted grays (gray/mauve/slate/
sage/olive/sand) looked like near-duplicates. Group it like Radix: Grises (tinted
neutrals) - Colores - Brillantes (light solids) - Metales, with a fallback "Otros"
bucket so no scale is ever dropped. Intro note explains the grays cluster because
they are near-neutral (chroma ~0.01) with a subtle per-accent hue tint. Verified
in-browser: 31 scales, none dropped/duped, 4 group labels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-role override <input type="color"> took value={r.solidHex} directly; an
expression-driven color input warns ("specified value '' does not conform") if it
ever receives an empty string. r.solidHex is always a valid hex in practice, but
guard it (`|| '#000000'`) so the input can never receive '' — belt-and-suspenders
for a warning seen during live HMR editing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Gemini's sharp note: rotating an intent's HUE toward the brand (harmonize)
erodes its meaning — a red stops reading as "error". What coheres a palette is
sharing the chroma + lightness PROFILE, not the hue. New temper(color, reference,
amount) keeps the hue and lerps L+C toward the reference. The demo's intent
cohesion switches harmonize -> temper, and the slider MOVES to the "Roles
canonicos" section (next to the intents, dynamic). Verified in-browser: threat hue
stays 358 (red) at 0% and 40% temper, only chroma/lightness shift; affirm stays
teal. harmonize stays in the engine for brand accents. RFC §6.2 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Canonical intents (red/green/...) can clash next to a brand color. Replace the
harmonize on/off toggle with a slider (0 = pure canonical -> 0.35 strong), default
a SUBTLE 12%: intents lean toward the brand enough to feel cohesive but stay
recognizable (red is still red). Verified: green seed -> threat #e35013 at 12%
(warmer red), pure #e5484d at 0. Also clarified that "La paleta" is the FIXED
library (does not derive from the seed; the seed derives the roles) to resolve the
recurring confusion. RFC §6.2 notes the subtle-default guidance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deriveScheme gives DEFAULTS, not a cage: each role row gets a color input that
PINS that role to the designer's exact color, while the rest keep deriving from
the seed. "auto" un-pins; changing the seed re-derives only the unpinned roles.
Mirrors M3 (custom colors per role) + Radix (pick accent/gray) + the hand-authored
path (grafito maps every role). Verified in-browser: pin secondary=blue +
tertiary=gold while primary/neutral stay derived; auto reverts to derived.
Documented in COLOR_ENGINE_RFC §6.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The seed now re-themes the ENTIRE page, not just the preview: it overrides
--primitive-{role}-* (+ contrast + surface alpha) on .root, reproyecting every
--color-{role}-* and the neutral-driven surface/content/border chrome. Mode-aware
templates (light/dark). New "harmonize intents" toggle nudges the canonical intents
toward the seed's temperature (M3 blend.harmonize); off by default so intents stay
canon. The 31-scale library stays fixed. Verified in-browser: green seed -> canonical
primary / on-solid / topbar all go green; threat stays red until harmonize.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seed color + variant (tonal/vibrant/monochrome) -> deriveScheme -> generateScale
-> live preview of the 5 hierarchy role scales (12 steps each, step 9 ringed) +
their APCA on-solid chips. The full uix.color builder pipeline, reactive and
pure. Verified in-browser: blue seed -> tertiary purple (+60deg), monochrome
collapses to one gray, primary = seed verbatim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Theme-builder core in uix.color: one brand seed -> hierarchy role seeds
(primary/secondary/tertiary/neutral/neutralVariant). Ports M3's HCT CorePalette
to OKLCH — secondary = same hue/low chroma, tertiary = hue+60deg, neutral =
near-gray; variants tonal/vibrant/monochrome (structured for more). harmonize()
nudges hues toward the brand (M3 blend.harmonize). The 6 canonical intents are
NOT derived (an error is always red); APCA replaces HCT's tone->contrast. Pure
+ isomorphic — produces values behind the frozen --color-{role}-{slot}
contract, so zero component impact.
Docs: COLOR_ENGINE_RFC.md §6.2 + color README theme-builder section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Palette (31 scales x 12 steps), the 9 canonical roles with their slots, the
APCA on-solid pick live via uix.color, translucent role surfaces, and the
neutral chrome. Rendered through live CSS custom properties with a light/dark
toggle. Verified in-browser: swatches resolve, risk flips to dark text, modes
invert. Not linked from the /temas index (it is a system demo, not a brand
theme — like /temas/animations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tertiary was `gray` — literally the same scale as neutral, and secondary
(slate) is a near-gray too, so the three desaturated roles blurred together.
Per Material 3's tertiary rule (rotate the primary hue ~60deg), tertiary now
uses `indigo`: purple's cool neighbor, saturated enough to be distinct, in a
hue band no intent occupies. Updated the one test that pinned tertiary=gray;
regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First consumer of uix.color. The theme generator's on-solid text pick
(white vs dark) now decides by APCA (|Lc| >= 60) instead of WCAG 2
(< 3:1), with a WCAG 2 ratio kept as a conservative cross-check — white
must clear BOTH or the contrast slot flips to onSolidContrast. APCA is
accurate in the mid-tones where WCAG 2 mis-estimates (the risk=orange
case). Reproduces the documented base behavior (only risk flips) via a
better metric; generated/base.css unchanged (the pick lives in the
runtime theme block).
- render-css: import apcaLc / oklchToGammaRgb / safeParseColor /
wcagContrastRatio from $color; replace the local WCAG pick; drop the
now-orphaned local wcagRelativeLuminance + wcagContrastRatio.
- color: add safeParseColor (null instead of throw for var()/color-mix
theme values the engine can't introspect).
- wire $color alias (vite.config.ts + svelte.config.js + CLAUDE.md).
Verified: color 20/20, eidos 162/165 (3 pre-existing words failures),
active-eidos-config contrast asserts pass, npm run check 0 new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New art `src/arts/color` — pure, DOM-free, deterministic color math that
runs identically at build (eidos render-css) and at runtime (white-label
theming). Because every decision is computed in JS before a value is
written, APCA introspection and compositing-inverse alpha are preserved
in every mode (COLOR_ENGINE_RFC §6.1).
- convert: OKLCH<->OKLab<->linear-sRGB<->gamma-sRGB<->hex (Ottosson),
chroma-reduction gamut mapping (no channel clip), parseColor, oklchToCss.
- apca: APCA-W3 0.1.9 Lc + WCAG2 ratio cross-check.
- generate: seed->12-step scale by template morph (re-hue, rescale chroma,
anchor solid to seed), pickOnSolid (APCA, prefer-onSolid policy per
THEMING §24.1), compositing-inverse alphaOverBackground.
Phase 0 only: module + 19 unit tests, NOT consumed yet — zero behavior
change. Full plan in src/uix/eidos/COLOR_ENGINE_RFC.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The video mode now paints a frame immediately: on `loadedmetadata` it
seeks to the initial scroll position instead of waiting for the first
scroll (the progress-0 loop guard used to leave some browsers black).
New `start` / `end` props (seconds, clamped to the real bounds) scrub
only a SEGMENT of a clip — progress 0->1 maps to `[start, end]`, `end`
defaults to the full duration. Time-based seek means frame rate is
irrelevant and duration stays browser-authoritative (read from
`loadedmetadata`, never passed by hand).
Demo: same-origin `/demos/video.mp4` sample (external URLs fail on
cross-origin range requests), live `start` / `end` / `smooth` controls,
API rows + snippet parity, README segment example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
libs/logger/test/diagnostics.test.ts no longer imports SILENT_LOGGER from the
consuming `$logger` art -- it uses a local no-op Logger fixture (the art exports
the identical shape). Removes one of the test-only layer inversions (SU2).
The prefs half of SU2 (libs/prefs/test importing dimension constructors from
$prefs) is left for a deliberate call: those constructors are pure (import only
$libs/prefs + $libs/locale), so the root fix is relocating them to $libs/prefs
with arts/prefs re-exporting -- an ~11-file move, disproportionate to force for a
BAJA, not-a-build-violation item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.
F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).
F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).
Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).
Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalize.ts: `normalizeTableCells` now drops null/undefined slots BEFORE
mapping through normalizeBlock — a genuine undefined child made
normalizeBlock throw at `.id`. Completes the table-in-column crash fix
(76b0d1c8, 4a8f6331): all four corrupt-cell shapes ([undefined], mixed,
[], inline) self-heal to a paragraph with no throw, verified against the
browser-loaded engine.
Docs: continue.md gains the 2026-06-03 session hand-off (chrome work + the
3-layer crash fix + tomorrow's pending items); Words README gains a
"Bitacora de diseno" backlog of binding decisions + changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the column-insert crash + "can't edit any cell". Since P5m,
`TableCell.children` holds WordsBlock[], but the slash-menu "Table" factory still
seeded cells with bare `emptyText()` (inline `text` nodes). `normalizeBlock` has
no case for `'text'` and returns undefined, so `normalizeTableCells` stored
`[undefined]` — which then crashed `getActiveMarksForSelection`'s walk and left
the cell with no real block to edit.
- built-ins.ts: wrap each seeded cell in a paragraph block (matches the demo doc,
the callout factory, and `createTableCell`).
- normalize.ts: `normalizeTableCells` now drops children that fail to normalize
and re-seeds an empty paragraph when none remain — self-heals any document that
already got a malformed table from the old factory.
Pairs with the defensive guard in selection-walkers (76b0d1c8). Engine tests
430/430 pass; changed files type-check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`getActiveMarksForSelection` → `collectBlockText` switched on `block.type`
without a null check. When a table cell (inside a column) momentarily held an
undefined child slot, the walk threw `Cannot read properties of undefined
(reading 'type')`. The throw propagated out of `insertBlockInColumn` →
`applyHistoryCommand` uncaught, aborting the command and leaving the editor
unable to edit anything (and the column "add block" silently failing).
Add an `if (!block) return` guard at the top of `collectBlockText` so a partial
slot is skipped instead of crashing the whole command. Surfaced via the Chrome
console (TypeError in selection-walkers.ts ← insert-block-types.ts:309).
Engine tests 42/42 pass; selection-walkers type-checks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three rich-text-chrome fixes:
- The active rail tracked the clicked element, so a table cell gave a 39px
rail on a 118px table. `words-active-rail` now climbs to the TOP-LEVEL block
(direct child of content) and spans its full height.
- Delete-block was only in the (hidden) gutter grip menu. Added a Trash button
to the inspector title row — always visible for the active block.
- Deleting a block left the active dangling (it fell back to the first block).
Both delete paths (inspector + gutter menu) now capture the previous block's
id BEFORE the delete (reading the doc AFTER `deleteBlock` returns the wrong
index) and move the active to it — so there's always a sensible active block.
Verified in-browser: clicking a table cell, the rail spans the whole table
(118px); the inspector delete button is present; deleting a block moves the
active to the previous one (e.g. delete "Lists" → active "const greet"). Check
clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the active-block marker per feedback (it changed colour by depth,
shifted right for nested blocks, and showed nothing on load):
- New `words-active-rail.svelte`: a single vertical bar in the gutter whose
top/height track the CLICKED block, re-measured on activation / render /
scroll. It is FRAME-relative at a fixed gutter column (CSS
`--_words-content-px - 1.4rem`), so it never shifts right for nested blocks,
and uses ONE fixed colour (`--color-primary-solid`).
- Removed the block-anchored `[data-words-active]::before` rail (block-relative
→ shifted; depth-coloured → changed colour).
- `words-activate.svelte`: there is now ALWAYS an active block — on first load
it seeds the first top-level block, so the inspector + rail have a target
instead of "nothing selected".
Verified: on load the first block is active and the rail shows at a fixed 27px
gutter column, 3px, single colour, height = active block. Check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four feedback fixes:
- Grip "trembles": the handle's `:hover` did `scale(1.18)`, which grew the
button under the cursor and shifted its hitbox → a hover↔scale feedback
loop. Removed the hover transform; the grip is ambient (per EV-G doctrine).
- Active-block marker was a box/rail tied to the HOVERED block. Moved it to a
vertical accent rail in the GUTTER driven by `[data-words-active]::before`,
so it tracks the CLICKED block (the inspector's target), 0.7rem left of the
block, spanning its height, depth-coloured. Removed the gutter component's
hover overlay div.
- Preview showed empty columns' dashed border + min-height (editing
affordances). `[data-mode='preview']` now makes the column border
transparent and min-height 0 — empty columns vanish in the read-only view.
Verified in-browser: active rail sits 11px left of the clicked block (violet,
3px, full height); column border is transparent + min-height 0 in preview;
grip has no hover transform. Check clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes from feedback:
- The active/hovered-block marker belonged in the GUTTER, not as an overlay
on the block. `[data-words-block-outline]` is now a thin (3px) vertical
accent rail pinned to the gutter column (left margin, just left of where
the text begins) spanning the block's height — only top/height track the
block. Replaces the tinted fill that landed on the block content.
- Preview is read-only, but `WordsActivate` (click-to-select → the dotted
active outline) still ran, so blocks could be selected in preview. Add an
`enabled` prop (false in preview): no click-activation and the active stamp
is cleared. `<Words>` passes `enabled={mode === 'edit'}`.
Verified in-browser: the gutter rail sits 8px left of the block text (not
over it), spanning the block height; clicking a block in preview no longer
stamps `data-words-active`. Check clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three issues found while testing the menu bar:
- Preview "did nothing": clicking Preview does switch to read-only
(contenteditable=false), but a read-only surface looks identical to an
editable one, so the change was invisible. Now `[data-mode='preview']`
collapses the bar's formatting groups, leaving just the view controls
(inspector + Edit/Preview) — the bar visibly simplifies.
- Gutter active block: the dashed outline read poorly. Replace it with a
faint primary-tinted fill + a 3px accent bar on the left edge (toward the
grip), so the block the handle acts on is obvious.
- Bold / insert "don't work" with no caret: a selection-dependent command
fired from the menu bar (outside the editable) no-op'd when the editor
was never focused — `toggleMark` bailed on a null selection, and
`insertBlockOfType` silently fell back to the last block. Add a TipTap
`.focus()`-style guard `ensureCaret()` in the provider: when `selection`
is null it drops a collapsed caret at the document start, so marks arm
(stored mark → next typed text) and inserts land predictably after the
first block. Wired into `runCommandName` (marks / align) and
`insertBlockOfType`. The provider freezes the selection across blur, so
this only fires on a never-focused editor.
Verified in-browser: Preview hides the format groups (toggle stays); the
hovered block shows the tinted fill + accent; a fresh editor's Bold now
arms + places a caret. Words tests 477/477 pass; check clean for the
touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The editor had no persistent formatting bar — commands lived only in the
bubble (selection), slash (/) and drawer. Add a docked menu bar at the top
of `<Words>`, built from the existing (previously unused) soma
`Words.Toolbar` / `Words.CommandButton` primitives, the same way the bubble
composes them.
- words-menubar.svelte (new): role=toolbar with six groups — history
(undo/redo) · block style (Text/H1-3/Quote/Code via a DropdownMenu) ·
marks (B/I/U/S/code) · lists · align · insert (image/table/divider via
`api.insertBlockOfType`). Marks/align are `Words.CommandButton`s rendered
as eidos `Button`s (range restored before the command runs, active state
reflected). Right side: an Edit/Preview toggle + an inspector toggle.
- words.svelte: mounts the bar; owns `mode` ('edit'|'preview') mapped to the
provider's `readonly` (preview = contenteditable off, editing chrome —
bubble/gutter/drag — suppressed, bar stays visible). Unifies the inspector
open state (drawer open / sidebar visibility) behind one toggle the bar
drives; the floating drawer button is gone (folded into the bar).
- types.ts: `toolbar?: boolean` prop (default true).
- words.css: `[data-words-menubar]` fixed-height top bar (`--_words-bar-h`),
group dividers, active-control accent (`data-active` + `aria-pressed`), and
offsets the docked inspector panels below the bar.
- demo: drop the now-duplicate Edit/Preview toggle + serialized-HTML preview
swap (the bar owns preview as read-only); keep the inspector picker.
Verified in-browser: bar renders (role=toolbar, 44px, 6 groups, 18 controls);
Edit→Preview flips contenteditable to false, disables format buttons and hides
the chrome while the bar persists; Bold applies to a selection from the bar
(onpointerdown range-restore holds across the out-of-editor click); the
inspector toggle hides/shows the sidebar; align-left reads active for a
left-aligned block; no console errors; check clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gutter grip rendered (cursor: grab) but the drag-to-move
functionality was lost when the soma-DragDrop migration was abandoned —
clicking it only opened the action menu (Move up / Move down). Restore
the reorder as native HTML5 drag-drop:
- words-block-drag-type.ts: private MIME `application/x-words-block` the
grip writes (its top-level index) and the controller reads, so block
reorders are told apart from native text drags.
- words-block-gutter.svelte: the grip's DropdownMenu.Trigger is now
`draggable`; `ondragstart` stamps the block index on the transfer. The
menu still opens on click (the dropdown opens on click, not
pointerdown — no collision with the drag gesture).
- words-block-drag.svelte: the drop sink. Listens on the contenteditable
for dragover/drop, paints a horizontal indicator at the nearest
inter-block seam, and repositions via the engine's `moveBlockTo`
(target is post-removal: `to = from < n ? n - 1 : n`). Bails unless the
drag carries our private type, leaving native text drag untouched.
A document-capture `dragend` clears the indicator wherever the drag ends.
- words.svelte: mount the controller next to the gutter.
- words.css: `[data-words-block-drop-line]` accent indicator.
Reorder is top-level only (the gutter is top-level only), mirroring the
gutter's `[data-words-node='block']` block detection exactly.
Verified in-browser: grip is draggable=true; dragstart writes index;
dragover paints the indicator + preventDefaults; drop reorders correctly
downward (0→end) and upward (14→1) with no block lost; native text drags
are not hijacked; no console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two loose ends from the rich-paste work:
- The block drag-handle menu was hard-coded English (Inspect / Move up /
Duplicate / Insert below / Paste below / Delete) inside a Spanish editor.
Route every label through the langs system like the inspector already
does: new langs-menu.ts (idlangref catalog + en/es bundle), registered by
the eidos <Words> wrapper (`components.words.menu`); the gutter reads them
via ActiveEidos.require().langs.ts(). Verified the menu renders in Spanish
(Inspeccionar / Subir / Bajar / Duplicar / Insertar debajo / Pegar debajo
/ Eliminar) under the ES locale.
- README serialization doctrine: the "Si algun dia hace falta pegar HTML
externo" section ANTICIPATED exactly this feature (per-block parseHtml,
clipboard best-effort, persistence stays JSON). Mark it IMPLEMENTED
(2026-06-02) with the actual modules, fix the table's HTML Import cell,
and correct the now-stale "paste HTML is rejected" line — HTML now parses
to blocks; only content-free HTML logs unsupported-html. importContent
unchanged (JSON + plain text); HTML enters only via paste.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A discoverable, click-driven paste — the answer to "where do I paste?".
The block drag-handle menu gains a "Paste below" item that reads the
clipboard and inserts its content as new blocks right after that block,
no Ctrl+V needed.
- provider.pasteAfterBlock(index): reads the async Clipboard API
(navigator.clipboard.read, falling back to readText), parses text/html
into rich blocks via the inert-document parser (else splits plain text
into paragraphs), and inserts them at top-level index+1. No-op when the
clipboard is empty / unreadable / permission-denied. Exposed on the
provider snippet api.
- insertBlocks gains an optional `at` index: insert as siblings at an
explicit top-level position (ignoring the caret) for the menu paste.
Command carries `at`.
- eidos block gutter: "Paste below" DropdownMenu item -> api.pasteAfterBlock.
Tests: insert-blocks 7/7 (added explicit-index case). Reuses the
browser-verified parseWordsHtml + insertBlocks core.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paste previously flattened everything to plain text (the signed import
doctrine deferred rich paste). Now the editor parses clipboard text/html
into the V2 model, the inverse of serialize-html.ts:
- parse-html.ts: parseWordsHtml(domRoot) -> WordsBlock[]. Maps p / h1-6 /
blockquote / pre / ul-ol-li (incl. checkboxes) / hr / img / figure /
table, with inline marks (bold/italic/underline/strike/code, colour +
background from style) and links. URLs sanitised; unknown blocks fall
back to a paragraph, unknown inline tags are transparent.
- insert-blocks.ts: insertBlocks(state, blocks) drops blocks at the caret.
Single pasted paragraph inline-merges (a phrase stays in the sentence);
multiple/block-level paste splits the host; a nested or non-inline caret
degrades to a structure-preserving plain-text insert; a trailing
paragraph guarantees a caret home after a terminal table/image. New
insertBlocks command + dispatch.
- provider onpaste: parse text/html in an INERT document
(createHTMLDocument off the active dom — no scripts run, no resources
load), then applyCommand insertBlocks. Image-files / single-URL /
plain-text paths unchanged; plain text is still the fallback.
Tests: insert-blocks 6/6 (merge/split/replace/append/no-op), parse-html
10/10 browser (marks, lists, tables, colour, javascript: URL sanitised).
Browser-verified: pasting an h2 + a bold/italic paragraph + a list inserts
the heading, the formatted paragraph and the list at the caret. Updated
the provider's unsupported-paste test (real HTML now parses; only
content-free fragments log unsupported-html).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalizePastedPlainText strips NUL chars from pasted text, but the regex
held a LITERAL 0x00 byte (/<NUL>/g) instead of the escape /\0/g. Runtime
behaviour was correct, but the raw NUL made ripgrep treat the whole file
as BINARY — so input.ts silently dropped out of every codebase content
search. Swap the byte for the \0 escape: identical at runtime (verified
input.test.ts 18/18), clean ASCII source, searchable again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two inspector-input issues:
- The image alt field (eidos <TextArea>) used the GLOBAL neutral skin
(transparent fill, lighter border) while the URL/caption inputs use the
words palette — so the textarea looked unframed and out of place.
Retokenise [data-textarea] in the inspector to --words-border / --words-bg
/ radius-2 / words font-size; now it reads identically to the text inputs
(verified both render border rgb(232,232,232) + fill rgb(249,249,249)).
- Every field drew an accent border AND a box-shadow ring on focus — two
stacked frames. Drop the border-accent change; the single accent ring is
the focus signal, the border stays structural. Applied to text inputs,
NumberField and TextArea so all inspector fields focus identically.
Scoped to the words inspector (not the global eidos input recipe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The radius control was a 0-48 slider plus a none/full-only toggle — the
preset toggle 'didn't work' for anything between, and a bare slider value
is invisible until the block also has a border or background. Replace both
with one attached ToggleGroup of named steps: None / S / M / L / Full →
0 / 4 / 8 / 16 / 9999 px (render.ts already emits border-radius from
border.radius). Verified the control renders [Ninguno, S, M, L, Completo].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating selection menu now carries text + background colour, applied
to the selection like bold/italic. A 'Color' (A) dropdown opens Text +
Background swatch grids; each swatch is a Words.CommandButton whose
color:#hex / bgcolor:#hex command the provider already parses into a
parametric toggleMark on the live selection — reusing the same
range-restoring path the mark buttons use, no bespoke selection wiring.
The 'Default' chip clears the mark (empty value).
- words-bubble.svelte: colour DropdownMenu (Baseline / Highlighter icons),
9-hue palette + clear, marked data-words-bubble-color.
- words-provider: add [data-words-bubble-color] to the bubble's
keep-selection-alive closed surface so interacting with the dropdown
doesn't collapse the model selection.
- words.css: swatch-grid styling (hue from inline --words-swatch; clear
chip is a diagonal strike).
Verified: selecting a word → A → blue swatch recolours it (rendered
rgb(0,145,255), model carries {type:'color',value:'#0091ff'}); bubble +
selection stay alive so multiple swatches can be applied in a row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three demo/chrome fixes:
- Drawer inspector: the open toggle (SlidersHorizontal) stayed mounted
while the panel was open, colliding with the panel's own X close in the
same corner. Render it only while closed; the panel's header X closes it.
- Preview columns: serialize as <div data-words-columns><div data-words-column>,
which no preview rule matched, so they stacked. Mirror the editor's row flex.
- Preview headings: were --font-size-2xl/xl/lg at weight 700 — louder than
the editor. Reference the SAME recipe tokens (xl, lg+4, lg; semibold;
heading line-height + primary color) so Preview reads like Edit. Verified
editor + preview both render h1=28px/600, h2=24px/600.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inserting an image into a column (slash menu or inspector "Add block")
left an empty trailing paragraph beside it. An image is a placeholder
configured through the inspector (URL panel), not a block you type after,
so the trailing paragraph was pure noise — the reported bug.
insertIntoColumn / insertIntoCell / insertBlockInColumn now skip the
trailing paragraph for images and return a null (caret-free) selection:
the atomic image has no inline text to host a caret, and the provider
auto-activates it for the URL panel. A divider keeps its trailing
paragraph (it separates typed content); table/callout keep theirs too
(they carry inline text, so the caret stays in the block).
5 new engine tests pin the behavior across both insert paths + the cell
case; divider regression guarded. Browser-verified: image into a column
yields [image] only, no empty text block.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P5m made the model accept blocks in cells, but the atomic-insert path
(`insertAtomicBlock`) only special-cased columns — with the caret in a cell it
fell through to the top-level split, dropping the block NEXT TO the table
instead of inside the cell. So "blocks in cells" was model-only, not reachable.
Adds an `insertIntoCell` branch mirroring `insertIntoColumn`, one level deeper
(container path [tableIdx, rowIdx, cellIdx, blockInCell, ...]): empty-cell
paragraph → replace in place (+ trailing paragraph); non-empty → split the
cell's block around the inserted one. So insert-callout / divider / image (and
any atomic insert) with the caret in a cell now land inside that cell.
New test `insert-block-types.test.ts` (3/3): inserts into the cell (single
top-level table preserved), callout lands inside, non-empty cell splits
(para("a") · divider · para("b")). npm run check: 0 new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the long-open "export HTML roundtrip?" question as export-only — the
architecturally-correct, industry-aligned choice (TipTap/ProseMirror, Lexical,
Slate, Notion all keep JSON as the canonical lossless persistence format and
treat HTML/MD as projections).
- README "Serializacion e intercambio" doctrine: JSON = truth; HTML/MD =
export-only projections; import = JSON + plain text. Explains the
state-complete-HTML trap that JSON avoids, why markdown is the most lossy
(GFM cells are inline-only — block cells from P5m can't roundtrip), and the
conditional `parseHtml` per-block path (parseDOM/importDOM pattern) IF
paste-from-external is ever needed — as clipboard interop, never persistence.
- continue.md: the "NO firmada" note flipped to FIRMADA with the rationale.
Docs only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POLISH-3. Replaces the bare Inspector-mode row in the Words demo with a real
topbar: a document breadcrumb (FileText icon + the doc's first-heading title),
a "Saved / Editing…" indicator that flips on document change and settles after
a short idle, and the Inspector-mode + Edit/Preview toggles.
Preview mode renders the document as a clean, read-only reading view via the
export-only `serializeHtml` serializer (bare semantic HTML with its own
typography — headings, blockquote, code, lists, table, links). Showcases the
export path now that table cells serialize block content (<td><p>…</p></td>).
Also exposes the export-only serializers on the public soma barrel
(`serializeHtml`, `serializeMarkdown`, `renderWordsPlainText`) — apps need them
for preview / copy / download.
Browser-verified: topbar renders; Edit↔Preview swaps the editor for the
read-only article (table + headings render); Saved badge shows. npm run check:
0 new errors (1 pre-existing grafito baseline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P5m. A table cell now holds full BLOCKS (paragraphs, lists, images, nested
tables) like Callout / Column, not just inline text. A fresh cell seeds one
empty paragraph; the existing generic block machinery (path walker, render,
validate, normalize) handles cells the same way it handles callout / column.
Atomic model change across the engine:
- types: TableCell.children: WordsInline[] -> WordsBlock[].
- factories / table-ops / extra-ops: cells seed [paragraph], not [text].
- normalize: normalizeTableCells recurses each child block (mirrors callout);
empty cell seeds a paragraph.
- built-ins table spec: render -> renderBlock per child; validate ->
validateBlock per child; HTML -> blockToHtml per child (<td><p>...</p></td>);
Markdown -> flatten blocks to inline joined with <br> (GFM cells are
inline-only; lossy, consistent with markdown being export-only).
- serialize-text + selection-walkers: cell text recurses block children.
- delete-ops: inline cross-cell merge (mergeTablePrev/Next) replaced by
recurseIntoTableMerge — treats the cell's blocks as a sub-document, so
backspace merges blocks WITHIN a cell; at a cell's first block it is a no-op
(cells never merge across the fixed grid — matches mainstream editors).
Caret paths into a cell are one level deeper now ([t,r,c,blockIdx,...]). All
table test fixtures wrapped (inline -> paragraph) and 13 assertions updated to
the new behavior (<p> wrappers, deeper paths, no cross-cell merge).
Browser-verified: cells render block content (path 14.0.0.0), table renders
clean. npm run check: 0 words errors (1 pre-existing grafito baseline). Words
tests: 452/452.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1. Image was missing from the per-column "Add block" menu — the inspector's
`columnInsertableSpecs` filter excluded `image` outright. Removed the
exclusion: the image spec's `create()` builds an empty-src placeholder and
`insertIntoActiveColumn` auto-activates it, so the inspector's image panel
immediately prompts for the URL / Upload — the canonical placeholder-on-
insert flow (same one the gutter inserter and slash menu use). Verified:
menu now lists "Imagen"; selecting it adds a placeholder image to the column
and switches the inspector to the image panel (URL + Upload + alt + caption).
2. Drawer inspector mode "only shifted" instead of hiding/sliding. The panel is
parked off the right edge via translateX(100%) when closed, but the frame's
overflow was `visible`, so the closed panel just sat outside the right border
(visible) and toggling merely slid it sideways. Added `overflow-x: clip` to
`[data-words][data-inspector='drawer']` so the closed panel is genuinely
hidden and the open one emerges from the edge. X-axis only: the block-gutter
grip (left margin, inside the frame) and the vertically-overflowing bubble
toolbar stay visible. Verified: closed panel clipped/hidden; open panel slides
in inside the frame's right edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bleed-through: the portaled [data-dropdown-menu-content] had z-index:auto, so
any positioned page element with a positive z-index (e.g. a selected
[data-toggle-group-item], z-index 1) painted THROUGH it. In the Words inspector
the Add-block menu showed the Typography toggle chips ("Predeterminada" /
"Normal") bleeding through. Added `content-z: 80` to the dropdown-menu recipe
(matches `select`, its sibling click-triggered overlay) + `z-index:
var(--dropdown-menu-content-z, 80)` on content + sub-content; regenerated
base.css. The soma floating layer reads this computed z-index and mirrors it
onto the positioner wrapper. The rest of the menu family (context-menu /
menubar / navigation-menu) shares the same latent gap — deferred.
Canonization (finishes the dom.raf rollout — zero raw requestAnimationFrame
left in src/uix):
- tabs-indicator.svelte: requestAnimationFrame/cancelAnimationFrame +
new MutationObserver + new ResizeObserver -> eidos.dom.raf /
observeMutation / observeResize (disposer pattern via ActiveEidos.require).
- tabs.svelte: new ResizeObserver -> eidos.dom.observeResize.
- resize-observer.svelte.ts: requestFrame/cancelFrame (raw handle) ->
dom.raf (disposer); fixed the stale "Debounces via requestAnimationFrame"
comment.
Browser-verified in the Words demo: column Add-block menu is opaque and on top
(elementFromPoint at the chip centers returns menu items, inMenu:true); the
menu options are collapsed + localized (one Encabezado, one Lista con viñetas);
empty-column delete works (column persists addressable with 0 children).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in the column inspector's "Add block" flow:
1. Wrong options + untranslated. The menu rendered
`defaultWordsSchema.insertable()` — the flattened menu ENTRIES, which
expand heading → "Heading 1/2/3" and list → "Bulleted/Numbered/Check"
with hard-coded English labels. Now it lists one entry per block KIND
from `schema.all()` (paragraph / heading / quote / code / list / table /
divider / callout), labelled via `blockKindLabel(type)` → fully
localized ("Texto", "Encabezado", "Lista con viñetas", …). The exact
level / list-kind is tuned afterward in that block's own panel. Inserts
the kind's default via `spec.menu[0].create()`. Excludes `columns`
(no columns-in-columns) and `image` (insertable:false — needs a URL).
2. Couldn't leave a column empty. `deleteChildInColumn` re-seeded an empty
paragraph when the last block was removed ("must stay typable"). But an
empty column is a valid, supported state — the engine renders the
wrapper with `data-words-id` so it stays clickable, and neither the
validator nor `normalizeColumns` re-seeds it (only the doc root + lists
do). Removed the re-seed; deleting the last block now leaves the column
genuinely empty.
Also added the 5 missing column-panel aria bundle entries
(select/delete/move-up/move-down/add-block-to-column-n) so the block-list
buttons read in ES instead of the English fallback.
Verified in the browser: menu shows 8 localized kinds (1 heading, 1 list,
no "Heading 1"); deleting the last column block → child count 0, no reseed,
column still clickable; aria-labels localized. npm run check 1 pre-existing
error (grafito, not Words) · words soma 452/452 · prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `raf(callback, node?): ActiveDomFrameCleanup` to the ActiveDom surface —
a one-shot animation frame that returns an idempotent **disposer** (the same
`() => void` shape as `listen` / `observe*`), so an `$effect` can
`return dom.raf(...)` and Svelte cancels the pending frame on teardown. It
wraps the existing `requestFrame` / `cancelFrame` (which already resolve the
instance's `targetWindow` — iframe / popup / happy-dom safe), adding no new
scheduling logic. New `ActiveDomFrameCleanup` type exported from the barrel;
`raf` also implemented on the disabled-dom stub (throws, like `requestFrame`).
Why: the doctrine is "all DOM activity via ActiveDom". `listen`/`observe*`
already returned disposers; the animation frame was the gap — `requestFrame`
exposes a raw numeric handle (per-call bookkeeping + leak risk), and layout
components were falling back to the GLOBAL `requestAnimationFrame`, which
targets the wrong window in iframe/popup contexts (the exact bug getWindow/
getDocument fix elsewhere). `raf` closes it.
Migrated the 3 raw `requestAnimationFrame` sites the Words audit surfaced —
words-block-gutter (reposition), words-bubble-menu + words-slash-menu
(overlay position) — to `dom.raf(...)`. Bubble/slash keep their `tick()`
fallback for no-rAF environments.
Documented the decision + rationale as a dated Backlog entry at the end of
`src/arts/adom/README.md` (and listed `raf` in the API + imperative-actions
sections). Notes the kept distinction: `raf` is for layout frames, NOT the
`$timer` lifecycle scheduler; low-level requestFrame/cancelFrame stays for
consumers that already hold the handle (drawer/slider/splitter/floating/
focus-scope). Remaining raw rAF in other eidos components (tabs-indicator…)
left for when those are touched — flagged in the backlog.
Gates: npm run check 1 error (pre-existing grafito, not adom/Words) · soma
words + adom 479/479 · prettier clean · browser smoke: gutter repositions,
bubble menu positions, no console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- D1 (soma) block-tree.ts:104 — replace `doc as never` (which disabled all
type checking on the descend container) with `doc as unknown as {...}`, the
honest cast that still asserts the permissive descend-node shape
childStacks() reads.
- D2 (eidos) — extract the duplicated ~25-line "file picker → FileReader →
data URL" flow (copy-pasted in words-block-gutter.svelte and
words-block-panel.svelte) into a shared `pickImageFile(doc, onPick)` helper
in words-image-file.ts. Both consumers now call it; zero inline FileReader
blocks remain. Kept as a plain helper (not ActiveDom) — a transient
<input type=file> + FileReader is not instrumented by active-dom, so a
direct Document touch is correct; the caller resolves the right Document.
Gates: npm run check 1 error (pre-existing grafito, not Words) · words soma
452/452 · prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>