El hover-follow resolvia el bloque con closest('[data-words-id]'), que da null
cuando el cursor esta sobre el carril (el rail no es un bloque), asi que el grip
se congelaba ahi. Ahora onMove, cuando no hay bloque directo bajo el cursor,
resuelve el bloque por la Y del cursor (blockAtY: banda vertical Voronoi-on-Y de
cada bloque), de modo que el grip se desplaza igual por el gutter que por los
bloques.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El piloto envolvia cada parrafo en <div data-words-node=block><p>, pero
el unico beneficio neto (drag vertical de raiz) ya esta cubierto por el
`dropZone` del sink, y el borde activo punteado funciona igual sobre el
elemento pelado. paragraphSpec.render vuelve a emitir un <p> plano.
Tests del render del motor en verde otra vez (engine de words 430/430).
palabras verificado en navegador: render, grip, drag y borrado intactos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Estado funcional del editor palabras (verificado en navegador) como
punto de retorno antes de revertir el piloto del wrapper.
- Grip: framework <Button> draggable copiado del gutter de Words
(ghost/xs/iconOnly + dots svg). dragstart escribe el indice en el MIME
canonico; click abre el inspector via onPress; cursor grab/grabbing.
- Drag vertical: <WordsBlockDrag> acepta `dropZone` opcional y escucha
dragover/drop en toda la fila (rail+canvas) en vez de solo en content,
asi un arrastre recto hacia abajo sobre el carril ya engancha.
- Borrado de bloque (palabras-panel): mueve el activo al bloque
superviviente leyendo el documento YA actualizado (cubre el caso del
ultimo bloque, que deja un parrafo en blanco), cierra el popup, y el
grip se re-ancla en vez de quedar fantasma.
- active-eidos: getter `timers` (ActiveTimers) sobre el uix service.
- Piloto wrapper: paragraphSpec.render emite <div data-words-node=block>
envolviendo un <p data-words-inner> (deja tests del render del motor
por consolidar/revertir).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block inspector mixed three control heights (chip 26 · number-field 28
· color-trigger 36px) and two fonts (chip/trigger 10px vs label/value/input
12px), so the PRIMARY control — the chip — was the smallest text in the panel.
- New recipe tokens `--words-control-height` (1.75rem — was a magic literal on
the number-field) and `--words-field-width` (7.5rem), applied uniformly to the
whole inline tier (chip · color-trigger · number-field) → one 28px height + an
aligned right-edge column.
- Chip + color-trigger lift to the 12px body tier (the ToggleGroup `xs` is kept
ONLY for its compact padding; lifting the font without the padding keeps the
four-segment groups on one line).
- Cramped long labels shortened (Predeterminada→Auto, Semi-negrita→Semi) so
Fuente/Grosor read as four even segments.
- Collapsed the dead, inconsistent font-size fallbacks (`xs, 0.6875rem` /
`xs, 0.75rem` / `sm, 0.8125rem`) to the bare declared token.
Chrome scale is now a clean 14 (section) / 12 (body) / 11 (micro). Verified in
browser: chip · color-trigger · number-field all 28px·12px, no group overflow.
19/19 recipe contract green; check adds zero errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inspector mixed THREE fonts: labels in Lora (serif!), titles in system-ui,
inputs in Instrument Sans. Root cause: eidos's "content" archetype (Accordion
content) maps to the reading serif, and the inspector's `[scope] *` rule is only
(0,1,0) — equal to the component's `[data-x]` font rule — so the Phase 3
code-split flipped the load order and the serif won.
Fix: a `--words-chrome-font` token (= --font-ui, independent of the content
`font-family` a theme may set to serif) + a rule forcing it across EVERY chrome
surface (toolbar·bubble·slash·drawer·inspector·gutter menus) with a DOUBLED
scope attribute (0,2,0) that beats any single-attribute component font
regardless of code-split order. Verified in browser: all chrome = Instrument
Sans (was Lora/system-ui/Instrument). Content keeps its reading font.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression from the onMount→$effect conversion (increment 3): eidos.langs.extend
both reads AND mutates the reactive langs schema, so the $effect tracked that
read, the mutation retriggered it, and it looped — re-extending the bundles
thousands of times, each logging a DEV leaf-overwrite warning (~90k warnings,
hung the editor page). Wrapped the body in untrack ⇒ the effect has no deps ⇒
runs exactly once on mount, like the old onMount. Verified: words page loads
fast, zero console warnings, editor + inspector render. api-contract test still
green (untrack is not flagged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The words recipe declared a token vocabulary the CSS never used (the root of
the drift). Pruned 55 declared-but-unused tokens: 38 direct (gap-*, toolbar-*
×9, button-size-*, command-*-hover, content-px-*, status-*, rail-*,
selection-color, _accent-* ×5, line-height, max-inline-size, heading-font-size,
content-py-after-placeholder) + 17 cascaded (the intent palette *-solid/
*-solid-hover/secondary-* slots that only fed _accent-* — callouts use the
tinted track/border/text slots, kept). No wire candidates (rail had no
consumer; ::selection already uses wash-soft). The recipe contract now matches
actual usage → the orphan test goes green; full eidos suite 197/197 (all 3
words failures now fixed across increments 1/3/A). Visually inert: words.css
references nothing removed (the "declares every var" test guarantees it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
words-inspector.svelte carried an intentionally-empty <style> (the styles
live in words.css, imported by the root) — removed. words.svelte used the
legacy onMount only to flip `mounted` + register langs bundles (no reactive
reads) → converted to a run-once $effect, dropped the lifecycle import.
component-api-contract test now green; eidos suite 196/197 (only the words
orphan-token test remains → Phase A).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The small-control radius was a soup of 8 syntaxes for the same thing:
4px / var(--radius-2,4px) / 3px / even var(--space-1) (a spacing token used
as radius). Added --words-radius-xs (= --radius-sm, 4px) — the missing
small-control step under sm — and snapped all of them to the
--words-radius-{xs,sm,md,lg} scale. Micro decorative radii (1-2px) and circles
(50%) left as-is by design. Verified radius-xs=4px, editor renders, no
regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kills the biggest visual-inconsistency soup: 18 ad-hoc color-mix opacities
(4/6/8/12/14/16/22/30/35/40/45 %) collapsed to a 4-step canonical scale
--words-wash-{faint,subtle,soft,strong} (6/16/30/44 %), applied across
words.css; geometry %s (circles, widths) untouched. Hardcoded transition/
animation durations (120/140/100 ms) collapsed to --words-transition-duration.
Declared the 2 used-but-undeclared tokens (--words-font-size-xs, --words-swatch)
→ the "declares every public var" recipe test goes green (1 of 3 words failures
fixed). Editor verified in browser, no regression. See components/words/AUDIT.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Soma is clean (95/100, headless). The mess is words.css drifting from its
own token contract: ~117 tokens declared, ~42 used, 41 orphaned, the same
concept expressed 3-4 ways (radius, mix%, focus ring, sizes, durations,
padding), 3 duplicated color palettes, rail hardcoded over orphaned tokens.
Phased plan A-E; pilot = inspector. Working doc, updated per phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
table tree-view tree-grid feed virtual-list virtual-grid announce
clipboard drag-drop words.
words (the 55 KB recipe) composes via mounted eidos components
(Button×5, Icon×7, NumberField×3, ColorPicker×1, TextArea files), so
their recipes load on mount — no extra imports needed. Verified: words
content/toolbar styled, mounted button 26px/radius 6px.
index.css now holds ONLY the foundation: base.css + archetypes + events
+ the 10 layout primitives (box/flex/grid/stack/group/wrap/container/
section/aspect-ratio/auto-grid, kept because they're used pervasively and
recipes layer on them).
FINAL: monolith 850,879→335,100 raw / 113,489→54,429 gz — −515 KB raw /
−59 KB gz off EVERY page, landing at the base.css floor. A 5-component
page now ships ~54 KB foundation + a few KB of chunks instead of 113.5 KB
flat. check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
field form number-field toggle toggle-group switch checkbox radio-group
rating-group search-field select combobox tags-input file-upload editable.
Caught + fixed a real coupling: toggle-group items are DOM-equivalent to
a standalone <Toggle> (carry data-toggle via the morfo, per the 2026-05-28
structural-identity refactor) and rely on toggle.css for their base
treatment — but the group never mounts an eidos <Toggle>. Without the
aggregate, items rendered as raw buttons (bg #f0f0f0, 2px outset border).
Fix: toggle-group.svelte imports toggle.css FIRST (before its own grouping
rules, so attached-corner overrides still win). Verified: item now bg
srgb(0.133), 1px border, font-weight 500, min-block-size 36px.
All recipes left the monolith (residual [data-*] are base.css token
contracts, verified base==monolith counts). Portaled select content
verified styled. index.css 51→36 @imports. Monolith 633,277→533,103 raw
(−100 KB) / 86,535→74,716 gz (−11.8 KB). check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dropdown-menu context-menu menubar command listbox navigation-menu
grid-list now self-import their recipes; removed from index.css.
menu-indicator.css (shared by dropdown/context checkbox+radio rows) had
a load-ORDER dependency: its longhand padding-inline-start (0,1,0) only
beat the menu row's padding-inline shorthand (0,1,0) by coming later in
the aggregate. Code-splitting breaks that order, so the partial is made
ORDER-INDEPENDENT via a specificity bump (doubled attribute → 0,2,0).
It now travels in dropdown-menu/context-menu's chunk (imported from their
.svelte) instead of the foundation. Verified in browser: checkbox item
padding-inline-start = 26px (indicator wins), plain item = 8px.
index.css 58→51 @imports. Monolith 666,436→633,277 raw (−33 KB) /
90,381→86,535 gz (−3.8 KB). check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pilot for the index.css break-up. Establishes the pattern for compound,
portaled components:
- the `import './x.css'` goes in the ROOT `.svelte` (dialog.svelte); the
barrel imports the root, so the CSS loads when ANY part mounts.
- portaled content stays styled: recipe ships in dialog's chunk, tokens
(--dialog-content-bg, …) come from base.css global → verified in
browser that the portaled [data-dialog-content] keeps bg/radius/
shadow/padding.
`@layer` evaluated and DISCARDED (evidence-based): 0 current usage, 16
!important that @layer would silently invert, and per-component
[data-*] scoping already prevents cross-component collisions. Kept as a
documented escape hatch.
index.css 94→93 @imports; monolith 814,420→805,692 raw (−8.7 KB) /
−1.1 KB gz. Reworded the policy comment to be list-free (no churn across
the remaining 93). check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`<TagsInput.Item shape="cut">` — typed `shape` (rounded/continuous/cut/scoop) on the
tag chip, the case the family system was made for. Emits `data-shape` -> the
foundation rule.
Also clarified (pendiente.md): every component that spreads `{...rest}` already
accepts `data-shape="…"` directly (verified on inputs + tags), so the families are
usable everywhere today; the typed `shape` prop is ergonomic sugar on the common
surfaces (Badge / Card / Button / TagsInput.Item).
Verified: check 0 errors. Browser: real tag chip data-shape=cut -> bevel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the shape families usable per-instance, not just in the demo. New canonical
SHAPE_FAMILIES + ShapeFamily in lib/types (canon, like the variant archetypes).
Badge / Card / Button gain a typed `shape` prop (rounded - continuous - cut - scoop)
that emits `data-shape` -> the foundation [data-shape] corner-shape rule. Orthogonal
to `rounded` (radius magnitude); default = arc (no attr); degrades to arc where
corner-shape is unsupported.
A differentiator for chips/badges (<Badge shape="cut">) and a premium touch for
buttons (<Button shape="continuous"> = squircle). For cut/scoop prefer filled
variants (concave/angular corners seam with borders) — documented per prop + pendiente.md.
Verified: check 0 errors; eidos 190/193 (3 pre-existing `words` failures, unrelated).
Browser: real Badge data-shape=cut -> bevel, real Button data-shape=continuous ->
squircle, radius magnitude preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Fase 2 rim halo now reaches real UI, not just the showcase. Each elevated
component's shadow signal (recipe tokens in recipes/base.ts + the few direct
box-shadow uses) now composes `var(--depth-{plane}-shadow), var(--depth-{plane}-halo)`.
Reaches: popover, dialog, drawer, dropdown/context/navigation-menu, menubar,
select, tooltip, card, combobox, command, link-preview, words.
Shadow signal only — z-index stays component-managed (the z bands are finer than
the 5 planos), so zero stacking risk. Verified: dialog keeps its own z-index 71
and composes drop shadow + oklab halo. Retuning a plane now retunes every
component on it.
Verified: npm run check 0 errors; eidos 183/186 (3 pre-existing `words` failures,
unrelated). Regenerated generated/base.css. THEMING 29 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the Phase 3 scales into <Text> and <Heading> + fixes a token collision the scales
introduced.
- Text/Heading props (additive): `tracking` / `leading` (reuse the existing
--_x-letter-spacing / --_x-line-height vars → override the size-derived values),
`wrap` (text-wrap: balance/pretty/nowrap), `numeric` (tabular/oldstyle →
font-feature-settings), `measure` (max-inline-size). Heading defaults to
`text-wrap: balance` (reference-grade titles); Text defaults to the CSS initial so the
axes are no-ops until a prop is set. Heading reuses Text's scale unions.
- COLLISION FIX: Phase 3a's config-driven --tracking-{tight,normal,wide,wider} collided
with a pre-existing HARDCODED tracking scale in render-css (semantic badge/ui/… +
scale tight/normal/wide/wider, all 0) emitted later → it won (everything resolved to
0, so the tracking prop did nothing). Removed the hardcoded scale lines; the config
(typography.tracking, real optical values) now owns tighter/tight/normal/wide/wider.
The semantic tracking tokens (badge/label/ui/prose/heading/display) stay (recipes use
them, e.g. card-title --tracking-tight now picks up the real -0.02em). leading/features
/measure don't collide (distinct keys).
Verified in browser: tracking-wide 0.02em -> 0.32px; tracking-tight -0.02em -> -0.32px;
heading default text-wrap balance; wrap=pretty, numeric=tabular, measure, leading all
apply. check 0 errors; eidos suite green (3 pre-existing words failures only).
Note: the pre-existing hardcoded semantic typography block (leading-ui/prose/… +
tracking-badge/…) is still hardcoded, not config-driven — a separate cleanup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes all 5 items of fix-stext.md for the <SText> canvas-measured text engine. None
were implemented before; T1 was a P0 correctness bug, T2 a P1 memory leak.
- T1 (P0) font-load invalidation: `invalidateFont` / `invalidateFontFamily` in
measurement.ts (granular, vs the old all-or-nothing clear) + `useFontReady(dom)` hook
that subscribes to `document.fonts` `loadingdone` via `ActiveDom.listen` (iframe/popup
-safe, no raw listener), evicts the loaded family's cache FIRST, then bumps a reactive
`epoch`. s-text.svelte folds `epoch` into the layout getter. Fixes the line count
lying after a web font swaps in (getComputedStyle reports the requested family,
unchanged on load, and the cache is keyed by the font string).
- T2 (P1) bounded caches: `LruCache` (Map-backed, move-to-recent + evict-oldest) caps
the per-font segment cache at 4096 entries and at most 24 fonts. invalidateFont reuses
it cleanly.
- T3: single getComputedStyle per reactive pass (merged `font` + `lineHeightPx`).
- T4: hydration flash documented in s-text.svelte.
- T5: bidi levels opt-in via `PrepareOptions.computeBidiLevels` (default false) — the
walker never consumed `segLevels`, so it was wasted compute on every prepare.
- tests: new canvas-text.test.ts (9 tests, deterministic canvas stub) covering LruCache
semantics, invalidateFont/Family, per-font bounding, and bidi opt-in + line-count
invariance. (The engine had ZERO tests before.)
Invariants kept: no change to line-break decisions, no canvas painting, pure files
(measurement/layout) stay Svelte-free, SSR-safe, strict TS. check 0 errors.
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>
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>
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>
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>
Full audit of the Words component (3 parallel explore agents + deterministic
gates) surfaced 4 carried-over defects, all verified and fixed:
- R2 (soma) engine/operations/visual.ts:147 — the `node as Record` cast in
updateBlockAtPath failed because WordsNode includes WordsDocument (no index
signature). Use the double cast `as unknown as Record`, consistent with the
sibling op at line 118. Clears the only Words type error in `npm run check`.
- R1 (morfo) components/words.ts — the `data-words-node` enum was conflated
with `data-words-block`: it listed block TYPES (paragraph/heading/quote/
code/image) that nothing emits on that attr, and OMITTED the real
structural kinds the engine writes (`block`/`inline`). Aligned to the
engine's WordsDomNodeKind. eidos-lint invalid selectors 2 → 0; verified in
the browser that top-level blocks carry node='block' and the active-block
outline still paints.
- R3 (eidos) langs-inspector.ts — `LABEL_COLUMN_BLOCKS` had no bundle entry,
so the column "Blocks" panel title fell back to English in ES. Added
label.column-blocks + label.add-block. Verified: panel now reads "Bloques".
- D3 (morfo) components/words.ts — declared `expression: 'family-default'`
to make the no-sema-pack choice explicit; `morfo:vocabulary` no longer
warns about the missing pack/expression.
Gates: npm run check 1 error (pre-existing, web/routes/temas/grafito — not
Words) · words soma+morfo 514/514 · eidos-lint words invalid 0 · prettier
clean · morfo:vocabulary exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add 40 recipes.banner.{intent}-{slot} forwarders (= var(--color-{intent}-{slot}))
- banner.css reads --banner-{intent}-{slot} instead of foundation --color-{intent}-* directly
- Banner is now retintable per-component via recipes.banner.* like button/badge; keeps data-intent (no API change)
- Regenerated base.css; recipe contract balanced (40 declared = 40 consumed), no visual regression
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Active-block addressing by materialized-path id (words-activate.svelte +
engine/block-tree.ts): click sets the active block explicitly; inspector
follows it, immune to selection churn. Breadcrumb navigates ancestry.
Inspector controls:
- ToggleGroups (font / weight / align / border) always keep one option
selected; the default value stores as undefined to keep the model clean.
- Border gains a `none` chip that clears the border (BorderChipValue).
- Image full-width is a Switch; image src + column width/gap validate on
commit (sanitizeWordsUrl, CSS-length/flex checks) with a [data-invalid] ring.
- Corner-radius + border-width are slider-only (WordsNumRow `sliderOnly`):
the paired numeric stepper was redundant.
- Numeric fields clamp to their range (line-height ≤ 2.5, font-size 12-40).
Inspector chrome:
- Unified field frame + single accent focus ring (no inset outline over text,
no ghost/black-border mix).
- Section dividers + uppercase accordion headers; styled breadcrumb row.
- Depth-tied accent (primary/affirm/loss/risk) shared by header mark and the
canvas active-block ring.
- Selection ring is an ::after pseudo-element with a fixed radius, so a block
the user rounded no longer rounds its selection indicator.
- Raw hex/rgba replaced by --color-*-solid tokens (theme-aware, portal-safe).
Columns: empty on insert, gap control, per-column width; Add-block removed
from the Columns BLOCK panel (lives in the column inspector). Shift+Enter
soft break renders via white-space: pre-wrap.
npm run check: 1 pre-existing baseline error (visual.ts:147). Words soma
452/452. Verified end-to-end in the browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating in-canvas `+` overlay for column inserts was fundamentally
at odds with contenteditable semantics — it needed pointer-events:none
to let caret/text events pass through, but that ALSO disabled CSS
:hover detection, and any visible variant covered the column's auto-
trailing paragraph (the escape hatch the engine adds after atomic
inserts), hijacking clicks intended for typing.
Adopt React Bricks's pattern: container-block child insertion goes
through the sidebar/inspector, not via floating overlays on the canvas.
- New "Añadir bloque" dropdown per column in the Columns inspector
panel (`words-block-panel.svelte`). Lists every insertable block
type the engine knows (paragraph, headings, lists, image, divider,
callout, etc.). Picking one dispatches `insertBlockInColumn`,
appending it to the chosen column.
- Inspector lives outside the contenteditable, so the dropdown
doesn't fight focus traps, doesn't cover content, doesn't need
pointer-events gymnastics. Zero of the bug classes we hit.
- Deleted `words-column-inserter.svelte` and its mount in
`words.svelte`. The `insertBlockInColumn` engine op stays — it's
the right primitive, just driven from a different surface now.
- Added `ARIA_ADD_BLOCK_TO_COLUMN_N` and `LABEL_ADD_BLOCK` to the
inspector langs catalog.
Top-level block insertion (between rows) keeps using the canonical
gutter handle `⋮⋮ → Insert below` and the slash menu (for in-flow
keyboard users).
Inside-column editing keeps working as before: click on the block,
type. Enter at end of a paragraph creates a new paragraph below
within the column.
Breaking: anyone who imported `words-column-inserter.svelte`
directly is broken. Nobody outside this folder did.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>