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>
Composes the five runtime builders (color · type · depth · shape · space)
from one ThemeSeed { color?, type?, depth?, shape?, space? } in a SINGLE
managed write — vs five separate apply* calls. Atomic whole-theme
semantics: axes you provide are set, axes you omit revert to the authored
foundation; clearTheme() reverts all. Per-axis apply{Color,Type,…} stay
for surgical tweaks. Returns ApplyThemeResult for per-axis introspection.
New: ThemeSeed, ApplyThemeOptions, ApplyThemeResult (exported from
$uix/eidos); also exported the previously-omitted ApplySpacingOptions.
2 tests (composition + atomic-replace). Closes the "jaula abierta" arc —
no referent reunites the 5 perceptual axes under one runtime theme builder.
Updated CHANNELS_SYNTHESIS §5 (quintet → sextet). check clean (the 28
errors are the untracked heroscrolling WIP demo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
base.css shipped all 31 Radix donor scales, but the theme's roles only
reference 9 (gray green indigo orange plum purple red slate teal). The
other 22 were dead weight on every page: no component references raw
--scale-* (they use --color-{role}-*), and runtime theming builds from JS
scale data and writes RESOLVED values (build-scheme: variables[k]=hex),
so it never reads the CSS scale tokens.
renderThemeCss now emits only role-referenced scales by default; the full
31-scale donor palette ships opt-in in generated/palette.css (new
renderColorPaletteCss). The CSS contract stays the full vocabulary
(apps can still override any scale; load palette.css to use the extra 22).
base.css 53.7→35.4 KB gz. Monolith (every page) 54.4→36.2 KB gz —
combined with Phase 3, 113.5→36.2 KB gz total (−68%). Verified: roles
resolve (primary=purple, button bg correct), pruned scales empty on
regular pages, restored on /temas/color via palette.css. Updated the
contract test to verify coverage against the full palette. check clean
(13 errors are untracked heroscrolling demo + gitignored tmp/lexical);
eidos suite at baseline (192/195, 3 pre-existing words failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "imports every component CSS recipe from the Eidos entrypoint" test
encoded the OLD invariant (index.css aggregates all 104 recipes), which
Phase 3 intentionally retired. Replaced it with the new contract: every
component recipe loads EXACTLY ONCE — foundation @import (layout
primitives) XOR self-import in its own .svelte. This also enforces the
no-double-load invariant from Phase 0. Eidos suite back to its
pre-existing baseline (192/195; the 3 remaining failures are the
known words-track issues, unrelated).
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>
Bundle Phase 0. The 10 components that already `import './x.css'` from
their `.svelte` (badge, card, image, password-field, s-text,
s-text-virtual-list, scroll-frames, skeleton, spinner, textarea) were
ALSO @import-ed in index.css — shipping their CSS twice (verified: badge
in both its own chunk and the 851 KB monolith). Removed their @import
lines so they ship only as code-split chunks; added a policy comment so
they're not re-added. Build green (1m21s).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-bleed lost the inset frame entirely — "¿dónde está el radio
interior?". A 16:10 cover can't be concentric on all 4 corners at full
(its height caps every corner to ~half-height), but its TOP corners CAN
match the card if the bottom stays square — the iOS-player geometry.
CSS can't read the card's dimension-capped radius (computed value is
still 9999px; the cap is a *used* value), so measure it via
ResizeObserver (min(w,h)/2 − gap) and apply to the cover's top corners;
bottom corners square, meeting the metadata. The inset frame is kept on
top/sides so the inner radius reads.
Finite radii unchanged (full 4-corner concentric). Toggle at full shows
"auto: solo top". Docs reconciled in SHAPE_ENGINE_RFC §5 + THEMING §30.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous fix dropped the cover to a clean --radius-xl, but a sharp
16px corner floating inside the card's 169px pill curve still read as
broken. The only way a child's corner can match a `full` parent (whose
radius caps per-dimension, so concentric math is impossible) is to let
the parent CLIP it: at `full` the cover now bleeds edge-to-edge with no
radius of its own, and the card's overflow:hidden + real corner round
its top to match exactly. Updated SHAPE_ENGINE_RFC §5 accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
At `full` (--radius-full = 9999px) the border-radius caps to half each
element's shorter dimension. A tall card and its 16:10 cover cap to
different values, so they can never be concentric — the nest inset broke
("el borde interior no sigue el radio exterior").
Concentric nesting requires FINITE radii; `full` is for leaf elements
(chips, buttons, avatars, pill inputs). So at `full` the demo now:
- gates `[data-shape-nest]` off and dims the "radios concéntricos" toggle
with «n/a a full — geometría de hoja»
- drops the cover to a clean --radius-xl (.cover-full) instead of a
mismatched pill
Documented the finite-only constraint in SHAPE_ENGINE_RFC §5 + THEMING §30.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Overcorrected the prior fix into "todo es sema / eidos solo hace visible", which
flattened eidos and skipped morfo + soma. Reframed to the real layer chain, each with
a role: morfo declares the event (family · intent) -> soma triggers (writes data-state,
calls sema.emit) -> sema emits (executes sound + haptic; projects the visual channel as
data-event-* tokens) -> eidos materializes (reads data-state + data-event-* and renders
via its OWN token engines: color · motion · depth · shape · space). Sema = the QUE
(semantic), eidos = the COMO (visual vocabulary) — co-layers, eidos the sole visual
owner, not a shim.
Applied to CHANNELS_SYNTHESIS §3 + the /temas/orquesta footnote + top comment.
Verified: check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refine the doctrine per review: the two moments are the producer/consumer split —
the SEMA moment (it emits: evaluates the occurrence and stamps data-event-* tokens;
sound + haptic it executes there; no DOM/CSS) and the EIDOS moment (it reads those
tokens + data-state and materializes the visual channel in CSS). The token is the
contract — sema writes, eidos reads — which is why sema is DOM-agnostic and eidos is
the sole visual owner. The temporal axis (state persistent / event transient) runs on
top: which token, read by eidos as presets / signatures.
Applied to CHANNELS_SYNTHESIS §1 + the /temas/orquesta "two moments" section.
Verified: check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes:
- Sound + haptic now fire from the REAL EngineSemantic.emit (channels filtered by the
mixer; `visual` excluded so no data-event-* conflict with the toggleable facets) —
replacing the Web Audio / Vibration approximation. One real emit, the chosen channels.
- Correct the architecture framing (was conflating LAYER with CHANNEL): the channels are
SEMANTIC (sema). Sema projects the `visual` channel (-> eidos materializes color ·
motion · depth · forma) and executes sound + haptic. motion/depth/shape/color are NOT
"eidos channels" — they are sema visual channel, made visible by eidos. Mixer now groups
"canal visual · sema -> eidos" vs "canales runtime · sema"; dropped the misleading
per-track eidos/sema tag. CHANNELS_SYNTHESIS reframed: 8 book expression channels -> 3
sema runtime channels (visual / sound / haptic).
Verified: check 0 errors; browser — two mixer groups render, real engine.emit fires
without error, visual facets compose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The book thesis, playable: ONE press fires the firma across channels, with a mixer to
mute each and watch them layer. The 4 visible channels (color · motion · depth · shape)
compose from one event with REAL eidos tokens — verified: press -> primary->affirm-solid
+ scale(0.93) + shadow overlay->raised + --shape-smoothing 2->3.4. Sound + haptic via
Web Audio / Vibration (in-app the sema engine emits them from the same event). + the
two-moment model section.
CHANNELS_SYNTHESIS.md ties the five channel RFCs: two moments x N channels from one
event, the layer ownership (sema runs sound/haptic + projects data-event-*; eidos
materializes the visual channels), the cross-modal firma, the runtime-builder quintet.
Verified: check 0 errors. Browser: all four visible channels compose on press.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live structural workshop: a real settings panel whose every gap/padding is
var(--space-*), retuned by four controls — densidad (data-density) · zoom
(data-scaling) · unidad base · fluido — composing on the panel via the preserved
calc(value * density * scaling). The space ladder (buildSpaceScale steps) visualizes
the modular rhythm + the fluid grow range. Plus THEMING 31 (three structural axes;
space as rhythm).
Verified: check 0 errors. Browser: panel --space-4 = calc(16px * density * scaling);
compact -> 0.84, base/growth re-derive the ladder live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The space scale was the one structural primitive without a builder — density and
scaling were already strong, but the base space scale stayed flat / static / arbitrary.
buildSpaceScale (pure) + ActiveEidos.applySpacing/clearSpacing regenerate the
--space-{key} ladder from one base unit, optionally FLUID (growth > 1 -> each step
clamp()s with the viewport, reusing the type scale fluidClamp), PRESERVING the
density x scaling composition (calc(value * --density-space-scale * --scaling)).
Opt-in over the authored STATIC_SPACE, same posture as applyTypeScale. Completes the
runtime-builder quintet (color/type/depth/shape/space).
Thesis (STRUCTURE_ENGINE_RFC): space is rhythm, not a flat px lookup table — modular,
fluid, composed with density x scaling from a seed. Structural = state-only (no
two-moment; honest).
Verified: check 0 errors; eidos config 58/58 (incl. modular + fluid space tests).
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>
Completes the runtime-builder quartet (color, type, depth, shape). buildShape
(pure) + ActiveEidos.applyShape/clearShape: dial `smoothing` (live continuity /
squircle intensity) + `nestGap`, or override/add `families`, written as a managed
block that wins over the foundation. Exported from $uix/eidos.
Verified: check 0 errors; eidos config 56/56.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shape becomes a two-moment channel: resting form (Fase 1) + the morph (event).
Folded cross-modally into the existing `press-squeeze` contact firma — one press
now drives scale (motion) + shadow-flatten (depth) + corner-firm (shape): the
`continuous` corner squares up at the press peak (--shape-smoothing 2 -> 3 -> 2).
- `@property --shape-smoothing { syntax: <number>; inherits: true }` so the number
interpolates smoothly (mirrors the motion @property pattern).
- Set only at the keyframe peak -> interpolates from/to the element underlying
smoothing (respects a theme override). No-op for non-continuous corners (they do
not read --shape-smoothing). Degrades with reduced-motion via the firma system.
Material 3 Expressive is the only other system with shape morph (bespoke, closed);
this rides the unified firma — one event, many modalities.
Verified: check 0 errors; eidos config+motion+generated 78/78. Regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`--shape-nest-gap` token + `[data-shape-nest]` rule: a nested element derives
`border-radius: max(0px, var(--shape-outer-radius) - var(--shape-nest-gap))`, so
its corners stay parallel to the parent (concentric). The parent exposes its
radius via `--shape-outer-radius` (inherited); gap defaults to the nest token.
nestGap is optional on ShapePrimitiveSet (a shape system can omit nesting); the
token + rule are gated on its presence. Only SwiftUI (recent, platform) has
concentric corners — none on the web as a token rule.
Verified: check 0 errors; eidos config+generated 55/55. Regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 8th book channel (forma) — the one no web design system has elevated. Fase 1
lays the foundation: corner continuity + perceptual families as portable tokens.
- ShapePrimitiveSet (config-driven): `smoothing` (superellipse exponent, 1=arc ->
2=squircle) + `families` map. STATIC_SHAPE ships rounded/continuous/cut/scoop.
- Emission: `--shape-smoothing` token + `[data-shape=`{family}`]` rules setting
`corner-shape` (round / superellipse(var(--shape-smoothing)) / bevel / scoop).
Opt-in: magnitude stays in `--radius-*` (untouched), so corners degrade to the
plain border-radius arc where `corner-shape` is unsupported (progressive, like
the wide-gamut oklch of color). var() works inside superellipse() (Chrome 146).
- Validation (validateShapePrimitives) + 2 tests + regen.
Why it matters: the whole web field (Tailwind/shadcn/Chakra/Mantine/Ant/Radix
Themes/Carbon/Fluent/Spectrum/Polaris/Primer) is "radius scale + circular arc +
static". Continuity exists only in Apple (platform-locked); none on the web ships
squircle as a token. This is the first.
Verified: npm run check 0 errors; eidos config+generated 54/54. data-shape applies
in the live runtime (continuous->squircle, cut->bevel, scoop->scoop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the depth channel. Atmosphere as the channel's materials layer:
- Per-plane `blur` cue (overlay 10px, modal 16px) + an opt-in frost rule
`[data-depth=`{plane}`][data-frost]` -> translucent surface (color-mix 80%) +
backdrop-filter blur. Gated on data-frost so it never turns an opaque overlay
translucent by default; specificity 0,2,0 reliably overrides the component bg.
- buildDepth(planes) (pure) + ActiveEidos.applyDepth/clearDepth — retune any
plane cue (surface/shadow/halo/blur/scrim/z) at runtime, the depth sibling of
applyColorScheme / applyTypeScale. Exported from $uix/eidos.
Showcase: /temas/profundidad section Materiales — a frosted-glass panel over a
vivid color mesh (frost blur + shadow + halo).
The `scrim` cue stays an available token without a wired rule — modal backdrops
are component-managed.
Verified: npm run check 0 errors; eidos 185/188 (3 pre-existing `words` failures,
unrelated). New tests: frost emission + applyDepth. Regenerated generated/base.css.
Docs: DEPTH_ENGINE_RFC Fase 4 + token contract, THEMING 29.
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>
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User-facing summary of the depth system (DEPTH_ENGINE_RFC remains canonical):
the two moments (data-depth resting plane + the present-rise/press-squeeze firma),
the config-driven planes composing existing primitives, the jaula-abierta escape
hatches, and the deferred refinements + component-adoption follow-up. Closes Fase 5 docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The novel core: depth as something that OCCURS. The elevation dimension now lives
in the event firma (the two-moment model event moment) — leveraging the existing
signature system, NOT a parallel one:
- present-rise (emerge): the box-shadow grows from flat -> the element resting
plane shadow as it rises. A flat element (no resting shadow) is a no-op; a
raised/overlay element animates its elevation proportional to ITS plane.
- press-squeeze (contact): the shadow flattens to the surface at the press peak,
then returns (recede).
So depth + position/scale + sound + haptic all fire coordinated from one event.
Degrades with reduced-motion (the global events.css cap); a theme overrides the
keyframes/signatures (jaula abierta). No reference framework treats depth as eventful.
Fase 2 (computed mode-adaptive shadow mix) deferred — the shadows are already
mode-aware, so it is a refinement, not a gap (documented in the RFC).
Verified: check 0 errors; motion + config + generated tests pass; browser — the
present-rise keyframe is live with the box-shadow dimension.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures the alignment raised in review: depth respects the framework two-moment
model — `data-depth` is the STATE moment (resting plane, Fase 1); the rise/recede
signature on `data-event-*` is the EVENT moment (Fase 3). The depth channel is
expressed through the SAME model motion already uses (evento != estado, never collapsed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Arranca el sprint de profundidad: depth como un plano unificado, config-driven y
semántico — la base del canal "la profundidad es algo que OCURRE" del libro (el
disparo eventful por sema llega en Fase 3).
- DEPTH_ENGINE_RFC.md — la guía de diseño: estudio de los límites de los
referentes, la tesis novel, y la doctrina "default fuerte, jaula abierta".
- EidosConfig.depth.planes (DepthPlane + DepthPrimitiveSet). Set canónico:
flush · raised · overlay · modal · recessed — cada uno COMPONE los primitivos
existentes (surface/shadow/z), sin matemática nueva → cero rotura.
- emite tokens --depth-{plane}-{cue} + reglas [data-depth='{plane}'] que aplican
las señales aditivas seguras (box-shadow + z-index); surface + blur/scrim
quedan como tokens opt-in (no pisan fondos de componente).
- validado; config-driven (un tema añade/retunea planos — jaula abierta).
- arregla un punto y coma latente en la emisión de variable-fonts, cazado aquí.
Verificado: check 0 errores; tests nuevos (emisión canónica + plano custom);
generated/base.css regenerado; navegador — data-depth='overlay' aplica la sombra
overlay + z 400, 'recessed' aplica una sombra inset. (3 fallos de words pre-existentes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2 Semantic leading/tracking config-driven: the per-role `--leading-{role}`
(ui/prose/text/heading/display) + `--tracking-{role}` (badge/label/ui/prose/
heading/display) tokens were hardcoded in render-css; moved to
`typography.semanticLeading` / `semanticTracking` (STATIC_TYPOGRAPHY), emitted
config-driven + validated, so a theme can retune them. Byte-identical output
(generated/base.css unchanged — same values, same order).
#3 Font preloads: `collectFontPreloads(typography)` (pure) + `eidos.fontPreloads()`
surface `<link rel=preload>` descriptors for families flagged `preload: true`
(the engine emits CSS, not head markup), for the app `<svelte:head>`. Inert
until a theme opts in.
Docs: TYPOGRAPHY_ENGINE_RFC fases marked closed + Fase 4 (the two-zone scale is
intentional — documented, not rewired); THEMING §10 — applyColorScheme +
applyTypeScale system builders.
`npm run check` 0 errors; new font-preload tests + config/generated tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typography analogue of applyColorScheme: derive a whole `--font-size-*`
ladder from one modular ratio + base, optionally fluid (`ratioMax` grows
the scale on wide screens), applied as a managed `:root` block that
overrides the theme authored sizes at runtime.
- buildTypeScale(seed) — pure, in eidos/lib; mirrors build-scheme. Steps
the 8 named sizes (xxs..xxxl) off `md`=base via the ratio; reuses
fluidClamp; composes with `--scaling`.
- ActiveEidos.applyTypeScale(seed, opts) / clearTypeScale() — managed
block written last so it wins over the static sizes.
- exported from $uix/eidos (buildTypeScale, typeScaleDeclarations, types).
`npm run check` 0 errors; buildTypeScale + applyTypeScale tests pass. The
managed-block DOM path is the same mechanism as applyColorScheme; the
/temas/tipografia showcase will dogfood it live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`FontFamily.axes` ({ wght, opsz }) was declared but never consumed — the
TYPOGRAPHY_ENGINE_RFC §5 promise was unmaterialized. Wire it:
- axes.wght -> when a face omits `weight`, @font-face emits the range
(`font-weight: 100 900`) so one variable face spans the whole axis
- axes.opsz -> `:root { font-optical-sizing: auto }` so the optical-size
axis tracks the rendered font-size
Engine-only, inert until a theme declares axes (shipped fonts are static
TTFs — same posture as wide-gamut color: ready, not yet exercised by
assets). Zero change to generated/base.css (no family declares axes).
`npm run check` 0 errors; new test asserts the emission; the generated-css
test confirms base output unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes the move started in 9f007a84, which left the `id` concern split
across TWO folders (createId in soma/id, useId in active-uix/id) — worse
than one library, and the right call you pushed for. Both functions now
live in a single `$active-uix/id`; `soma/id` is deleted.
+ src/uix/active-uix/id/{create-id,use-id,index}.ts (one authority)
~ 441 soma imports repointed: relative ../../id and ../../../id -> $active-uix/id
- src/uix/soma/id/ (removed)
~ COMPONENT_GUIDE / README examples updated
createId (wraps $props.id(), SSR/ARIA ids) and useId (client-only counter)
sit together; any UIX layer — soma, eidos, demos, apps — shares one id
authority without coupling to soma. Output format unchanged
(`soma-{component}-{n}`) -> zero behavior change.
`npm run check` 0 errors; accordion (createId+useId) verified on a fresh
dev server, console clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The soma `id` module conflated two concerns: `createId` (wraps Svelte
`$props.id()` for SSR/ARIA element ids — ~200 soma importers, intrinsic to
the headless layer) and `useId` (a generic monotonic counter — 6 importers).
Only the counter is a cross-layer utility.
Move `useId` to a new `$active-uix/id` subpath (mirroring the existing
`$active-uix/prefs` that soma already imports), so any UIX layer — soma,
eidos, demos, apps — can mint client-only ids from a cheap, collision-free
counter without coupling to soma. `createId` stays in soma (its domain).
+ src/uix/active-uix/id/index.ts (useId)
~ soma/id now exports createId only
~ 6 useId importers repointed to $active-uix/id
(floating, toaster, date-field, time-field, accordion-item, internal/arrow)
Output format unchanged (`soma-{component}-{n}`) -> zero behavior change.
`npm run check` 0 errors; toast/accordion/popover verified in browser.
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>
TYPOGRAPHY_ENGINE_RFC Phase 3 (engine tokens). Additive scales, pure CSS, no binaries.
- config-types: TypographyPrimitiveSet += tracking / leading / features / measure
(optional Record<string,string>).
- render-css: emits --tracking-{k} / --leading-{k} / --font-feature-{k} / --measure-{k}.
- typography.ts defaults: tracking (tighter…wider), leading (none…loose), features
(tabular = "tnum"+"lnum" for data, oldstyle/smallcaps/ligatures), measure (54/66/78ch).
+ optical tracking baked into the size tokens: small text slightly looser
(xxs +0.01em), display tighter (xxxl -0.02em) — was all 0.
- config.ts: validates the 4 scales (CSS-value maps).
- generated/base.css regenerated. test: scales + optical tracking emission.
Additive tokens (recipes/components consume var(--tracking-tight) etc.) so the scales
are zero-risk; the only rendered change is the gentle optical tracking on headings/small
text. check 0 errors; eidos suite green (3 pre-existing words failures only).
Next (Phase 3b): wire the component props (wrap: balance/pretty, numeric: tabular,
tracking/leading/measure) on <Text>/<Heading>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).
- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
-> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
the base config validates (was the cascade root — FluidSize objects failed the
string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
@1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
updated to the new rem/clamp values.
Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the color RFC approach (audit -> compare -> extend additively behind the frozen
token contract, by phases). Covers: the current state + the gap vs Utopia/Tailwind v4/
Material 3/Apple/Carbon; the inclusion model (extend TypographyPrimitiveSet, never
rename tokens); the fluid clamp() formula (rem-based, with --scaling composing on top);
woff2 + variable fonts + anti-CLS metric-override fallbacks; tracking/leading/features/
measure/text-wrap; the <SText> measurement link; canon-vs-theme doctrine; token contract
additions; and a 5-phase plan.
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>
Found stale color docs while verifying currency:
- arts/color/README.md: said "Status — Phase 0 ... nothing consumes it yet" (false —
consumed at build via render-css + runtime via applyColorScheme) and used
`ActiveEidos.setCssVariables` as the theme-builder mechanism (the real API is
applyColorScheme; setCssVariables is for contract knobs). Updated status, added
deriveScheme/temper/harmonize to the API table, documented temper as the canonical
intent-cohesion tool (vs harmonize for brand accents), fixed the builder pipeline to
buildScheme + applyColorScheme, and corrected the wide-gamut note (strategy A is
live + default-on, not "deferred").
- COLOR_ENGINE_RFC.md: 4 remaining `setCssVariables` references for the runtime
white-label builder -> applyColorScheme (only §6.2 was fixed earlier). Marked
Fase 4-bis (runtime generation) as IMPLEMENTED.
COLOR_MODEL_RFC.md verified current (RESUELTO; loss->plum correct; the anchor-hex
examples are the documented-discarded proposal = history). THEMING/audit/README were
already synced in their own commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes the THEMING_AUDIT P3 backlog.
P3-5 (fixed) · appendScaledMetricDeclarations: the `parseFloat(raw) === 0` guard let
non-numeric values (auto / var() / calc()) fall into `calc(x * …)` = invalid CSS. Now
only finite, non-zero numbers are scaled; zero + non-numeric emit verbatim. No change
to the base config output (all values numeric) — pure robustness.
P3-8 (fixed) · index.ts no longer re-exports the raw render-* fns. The public render
API is the ActiveEidos class (gated by assertValid() + active config); ./lib/render-css
stays reachable for internal/tooling use. Redirected the one internal consumer
(active-eidos-config.test.ts) to import renderThemeCss from the module.
Triaged the rest with rationale (audit updated):
- P3-4 deferred · density wins by deterministic source order (stable); the :where(:root)
restructure to also support scoped density is high-cost for a theoretical nit.
- P3-6 already resolved · dispose() routes documentElement via dom (no direct access).
- P3-7 deferred · ActiveEidos reactivity is callback-driven (apply() on pref change) by
design; a full runes conversion is a risky refactor with no bug to justify it.
- P3-9 deferred · orphan _accent forwarders — low-value recipe surgery with cascade risk.
All P3 now fixed-or-decided; only the P2 secondary halves (contract pruning + bare
identifier color validation, both edge-case) remain, deferred as low-value.
check 0 errors · eidos suite green (3 pre-existing words-track failures unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the forced-colors a11y work. Users who request more contrast (macOS
"Increase contrast", Windows, etc.) now get strengthened neutral chrome: borders
bumped to neutral 7/8/9 (subtle/default/strong) and de-emphasized text to 12/11
(secondary/muted). Solid fills + primary text are already high-contrast, so they stay.
renderPrefersContrastBlock emits a @media (prefers-contrast: more) block using
`:root:root` (specificity 0,2,0) so it wins over the theme's :root regardless of
stylesheet order. Values reference --primitive-neutral-* (resolve from the cascade; a
theme omitting them just no-ops the declaration — graceful). Strictly additive (gated
by the media query) and strictly STRONGER, so it can't regress the default look.
Also marks audit P3-6 resolved: dispose() already routes documentElement access via
dom (#lastAttrs.target + dom.apply), no direct document access remains.
- generated/base.css regenerated. test: renderStaticCss emits the prefers-contrast
block. docs: THEMING §28 + audit P3-2/P3-6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the base light theme `overlay` was neutral-3, identical to `muted` (neutral-3),
so popovers/menus/dialogs were indistinguishable from muted panels in light mode.
Dark already had overlay=neutral-4. Light overlay -> neutral-4 makes the elevation
ladder consistent across both modes: default(1) < raised(2) < muted(3) < overlay(4).
Verified in browser (light popover): overlay L93% now distinct from muted L95.5% and
default L99% (was overlay == muted). Dark unchanged. generated/base.css regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The REAL cause of the slow checkbox (not the stroke duration). The checkbox provider
flips its checked state inside the trigger HANDLER, and the morfo declared the commit
events with sequence: 'pre' — so the runtime ran `await runEmit()` BEFORE the handler.
emit() awaits the visual-channel hold (~240ms), so the functional state change (and
thus data-state) waited the full perceptual hold before flipping. Measured: click ->
data-state='checked' took 244ms.
Fix: commit-toggle-check / commit-toggle-uncheck -> sequence: 'post' (handler runs
FIRST, state flips immediately, the celebratory pulse emits after). This is the
doctrine for control commits (runtime.svelte.ts §577: "toggle's commit-toggle... the
pulse arrives AFTER the state has flipped"). Toggle + Switch were already 'post';
checkbox was the outlier. Measured after: 244ms -> 46ms.
Verified the siblings are NOT affected: radio-group (47ms) and tabs (31ms) are also
'pre' but flip state in the call-site (not the handler), so no lag — left unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The checkmark stroke-dashoffset draw used a hardcoded 220ms while the box fill ran at
--duration-fast (120ms) — nearly 2x, so the check read as laggy when toggling. Tokenize
it to var(--duration-fast) so the stroke draws in sync with the box (one 120ms motion).
Not the motion service (uix.motion) — this is a plain CSS transition in checkbox.css
driven by the recipe `stroke-duration` token. Verified in browser: path transition
0.22s -> 0.12s, synced with the box. generated/base.css regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the two color-quality items from THEMING_AUDIT P3.
P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the
browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so
the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The
foundation now always emits a system-colored outline fallback:
@media (forced-colors: active) {
:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
}
Components that already focus via outline (e.g. Button) keep theirs by specificity; this
is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts.
P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's
functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read
washed-out on real element borders (outline/surface/controls). element/hover/active
(3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS.
- generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7).
- Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80
0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken.
- test: renderStaticCss emits the forced-colors outline block.
- docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The theme builder now carries REAL wide-gamut, not just sRGB reformatted. generateScale
keeps raw OKLCH (no clamp), so a seed whose chroma exceeds sRGB renders more saturated
on P3 than its hex fallback.
- build-scheme.ts: result gains `wideGamut` (oklch() per opaque step) + `roles[].stepsOklch`;
`variables` stays hex (fallback + introspection). New `schemeDeclarations(result, {fallback})`
flattens to CSS lines — dual hex+oklch stack (default) or oklch-only (fallback:false,
for inline style where the CSSOM keeps one value per prop).
- ActiveEidos.#renderSchemeCss: emits the dual stack via schemeDeclarations → the applied
scheme block is wide-gamut on P3, sRGB-safe everywhere.
- index: export schemeDeclarations + SchemeDeclarationsOptions.
- temas/color demo: new "vivacidad P3" slider pushes the seed chroma past sRGB +
a "fuera de sRGB -> P3" badge (isInSrgbGamut). themeOverride now applies oklch
(wide-gamut). Verified: vivacity x1.70 -> primary-9 chroma 0.18 -> 0.31, badge on.
- tests: wide-gamut chroma retention (stepsOklch > hex fallback) + schemeDeclarations
dual/single; active-eidos scheme block asserts oklch(). 31/31 green.
- docs: THEMING §26/§27 + RFC §6.2.
Honest scope unchanged: the AUTHORED Radix palette stays exact sRGB (no regression).
Wide-gamut lives in the generator path (vivid seeds / OKLCH-authored themes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each palette step is now emitted twice: the hex as a universal fallback, then an
oklch() sibling that wins where supported (Chrome 111+/Safari 15.4+/Firefox 113+).
The token layer is now OKLCH-native and wide-gamut-ready, with NO @media and NO
config flag (it is the default behaviour).
- render-css `appendColorScaleDeclarations`: hex line + `oklch()` sibling per
`--scale-{name}-{step}`. Only opaque, parseable steps get the sibling; empty/
non-color values keep just the fallback. `--primitive-*`/`--color-*` are var()
refs (untouched); alpha scales stay color-mix/rgba.
- generated/base.css regenerated (+744 oklch sibling lines: 31 scales x 12 x 2 modes).
Honest scope: the shipped Radix palette is authored in sRGB hex, so its oklch()
siblings are sRGB-equivalent (verified: --scale-purple-9 -> oklch(0.5556 0.1829
305.86) paints #8e4ec6) -- identical today. The win is the OKLCH-native foundation:
an OKLCH-authored theme or a vivid generated scheme now renders wider on P3 with no
extra work. Making the SHIPPED palette visibly wide-gamut is Phase 3.
Tests: full eidos suite green except 3 pre-existing words-track failures (confirmed
unrelated via baseline). Docs: RFC §7 (status) + THEMING §27 + README ref row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RFC Phase 4: derive a whole-system color scheme from ONE brand seed at runtime.
Packages the demo-only builder into a first-class, tested API.
- build-scheme.ts (pure): buildScheme(seed, opts) composes the uix.color engine
(deriveScheme -> generateScale -> APCA on-solid -> compositing-inverse alpha)
into the `--primitive-{role}-*` (+ `--color-{role}-contrast`) override map.
seed -> { variables, roles }. No DOM. 6 tests.
- ActiveEidos.applyColorScheme(seed, opts) / clearColorScheme(): resolves donor
scales + background from the active theme, writes a managed `uix-eidos-scheme`
style block AFTER the theme block (wins the cascade), and RE-DERIVES on mode
change (follows light/dark). Returns BuildSchemeResult for introspection. opts:
variant (tonal|vibrant|monochrome) + temper (intent coherence, keeps hue) +
per-role overrides + selector. 4 tests (return value, intents, DOM block
ordering + clear, mode re-derivation).
- index.ts: export buildScheme + ApplyColorSchemeOptions + BuildScheme* types.
- temas/color demo: themeOverride now dogfoods buildScheme (drops the duplicated
emitRole/rgbaStr; identical output verified in-browser).
- generated/base.css: regenerated for the loss->plum role fix (binding layer
--primitive-loss-* now points at --scale-plum-*; keeps the contract test green).
- docs: THEMING.md SS26 + COLOR_ENGINE_RFC SS6.2 (status: landed) + README ref row.
Overriding the binding layer reprojects every --color-{role}-{slot} + the neutral
chrome downstream; the 31-scale palette stays put. Math in $color, composition in
eidos/lib (pure), DOM application in ActiveEidos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two quick correctness wins for the color system:
- themes/base.ts: `loss` was `purple` -- identical to primary=purple. Map to
`plum` (canonical loss scale): a graver, more magenta violet. This closes
the last role collision in the base theme (after tertiary -> indigo).
- temas/grafito: the "override por componente" section bound raw scale names
(teal/amber/plum...) to Button.color, which only accepts the hierarchy
override (primary|secondary|neutral). That was a type error AND visually
inert (no [data-button][data-color="teal"] rule exists). Split into the two
REAL color axes a component exposes: `color` (hierarchy) + `intent`
(evaluative palette). Closes the last pre-existing svelte-check error.
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>
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>
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>
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>
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>
Reconcile the theming reference with the engine as already committed
(color-model redesign, scaling axis, P2 fixes) so the doc is internally
consistent:
- TOC: add §23 (scaling), §24 (P2 corrections), §25 (color model); fix
the §21 entry to its resolved heading.
- §4: intent->scale mapping was stale (risk->amber not orange,
loss->plum not purple); note intents auto-derive via
CANONICAL_INTENT_SCALES (identity = step 9, cross-ref §25); correct
token counts (31-scale palette = 744 --scale-* tokens; 9 roles = 216
primitives).
- §3 / anti-pattern G: "30 escalas" -> 31; reconcile "never add a scale"
with §25.7 (a brand theme brings its own palette).
- §21 / §22: mark the two-level color RFC as resolved in §25 (anchor
model discarded); drop the stale present-tense "vigente" claim.
- README reference table: add §23 / §24 / §25 rows.
Docs only -- no engine or demo changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the synthetic step-9-at-opacity alpha ramp with a real compositing inverse: each --scale-X-aN is the translucent color that, painted over the scale's background (white for light scales, black for dark, by step-1 luminance), reproduces the opaque solid step N (Radix model). Computed in render-css.ts for ANY theme (base + custom), so alpha is consistent-by-construction with the solid scale and cannot drift. alphaScales opt-in still wins verbatim; non-hex solids fall back to the legacy color-mix ramp. Verified: --scale-purple-a9 = rgb(92 0 173 / 0.6941) over white = #8e4ec6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alpha steps (--scale-*-aN) are unconsumed today (only a test + README ref). The step-9-at-opacity synthesis is a valid 'accent-at-opacity' ramp default, not a bug; it is not a Radix-style reproduction of the solid scale. Documented the model, its limitation, and the already-wired alphaScales opt-in in appendColorAlphaScaleDeclarations. A build-time compositing-inverse generator is deferred until alpha is actually consumed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The runtime token-override API is setCssVariables() / clearCssVariables(); setOverrides() never existed. A dev copying §19 would hit a runtime error. (audit P2-5/A1)
Co-Authored-By: Claude Opus 4.8 <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 provider had a "smart" rule in syncSelectionFromDom that kept the
atomic-block highlight (and therefore the Inspector's panel) sticky
while the caret moved to a different block AS LONG AS that next
block was inside the same TOP-LEVEL wrapper (the rationale: editing
the trailing paragraph after an image-in-column shouldn't kick the
user out of the image inspector).
In practice users got the opposite of what they expected: they clicked
on something new, the inspector didn't follow. They thought the
editor was broken.
Drop the smart rule. Every real selection change now also clears the
atomic-block highlight — the inspector follows the caret, period. If
the user wants the atomic's panel back they click the figure again
(which `handleAtomicClick` re-selects).
We still skip the clear when `sameWordsSelection` reports no-op so
the synthetic selectionchange echo that fires right after
`selectAtomicBlock` (which doesn't move the DOM caret) can't wipe
the highlight a tick after being set.
Co-Authored-By: Claude Opus 4.7 (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>
The wrap was rect-matched to the whole column rect, which meant the
`+` trigger (positioned at the bottom of the wrap, pointer-events:auto
on hover) overlapped the column's TRAILING paragraph — the empty
escape-hatch the engine auto-adds after atomic inserts (image,
divider). Clicking on that paragraph to start typing hit the `+`
trigger and opened the dropdown instead.
Fix: for non-empty columns, position the wrap as a thin 28px strip at
the column's bottom (where the `+` lives) instead of covering the
whole column. The trailing paragraph above the strip is now clickable.
Empty columns keep the full-column wrap (so the centered `+`
invitation remains).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`pointer-events: none` on the inserter wrap (required so caret/text
events pass through to the contenteditable below) ALSO disabled CSS
`:hover` detection — the browser doesn't dispatch `mouseover` to
pointer-events:none elements, so the wrap's `:hover` styles never
fired and the `+` trigger stayed at opacity 0 forever.
Fix: track hover via JS — listen to `mouseenter`/`mouseleave` on the
column elements directly, stash the key in `hoveredColumnKey`, expose
to CSS via `data-hovered` on the wrap. CSS now uses
`[data-words-column-inserter][data-hovered]` instead of `:hover`.
Effect: hovering a non-empty column now reveals the `+` at the bottom
of the column, allowing users to add more blocks below the existing
content. Same fix applied to the empty-column reveal-on-hover (the
opacity-1 boost when fully hovered).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three composing bugs were blocking the `+`→pick-block→type flow:
1. **Path 5-segmentos en `insertBlockInColumn`** — the op passed the
path TO the text node (`[colsIdx, colIdx, innerIdx, 0]`) to
`pointFromInlineTextOffset`, which expects the path to the
CONTAINER block. The helper then appended its own inline-index,
producing a stale 5-segment path that no `[data-words-path]`
selector matches. Fix: pass `[colsIdx, colIdx, innerIdx]` for
heading/paragraph/quote/code/callout, `[colsIdx, colIdx, innerIdx, 0]`
for list (the first list-item IS the container).
2. **Selectionchange echo overwriting the model** — `api.focus()`
landed the caret at doc-start (browser default for focusing a
contenteditable without a prior selection), fired selectionchange,
`syncSelectionFromDom` overwrote the model's intended post-insert
selection with `0.0` before `restoreDomSelection` could run. Fix:
capture `intended = api.selection` immediately after the op, then
re-apply via `setTimeout(0)` AFTER the echo handler has settled.
3. **DropdownMenu focus trap intercepted `api.focus()`** — the
FocusScope's capture-phase `focusin` bouncer kept yanking focus
back inside the menu. Fix: `trapFocus={false}` on Content.
Also expose `api.restoreCaret()` (provider line 1274) for any consumer
that needs to force-write the model selection to DOM after awaiting
enough ticks for new blocks to mount.
Verified end-to-end in Chrome: empty column → click `+` → pick
Heading → "Title" stub selected → typing immediately replaces with
user's text. Inspector switches to Heading panel. Doc title and rest
of the doc untouched.
CONTINUE.md P0 — RESUELTO.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three architectural pieces for inserting blocks into a column slot from
an overlay button, plus the missing DOM-selection sync that any
imperative consumer of `applyCommand` needs.
- Engine op `insertBlockInColumn` returns `{document, selection,
activeMarks}` in one transaction (Tiptap-style). Atomic blocks land
with a trailing paragraph escape hatch + caret there; text-bearing
blocks select any stub text ("Title", "List item") so the next
keystroke replaces it Notion-style.
- Provider `applyCommandWithOptions` now schedules `restoreDomSelection`
via tick when the command changes the model selection (typing-batch
excluded — the browser already placed the caret). Was the hidden gap:
overlay buttons, drag-drop, slash menu, the new column inserter, all
updated the model but the DOM caret stayed wherever the user last
clicked, breaking subsequent text editing.
- `words-column-inserter.svelte` rebuilt around a busy guard with a
hard 250ms safety timeout (the previous pendingInsert + onCloseAuto
Focus pattern could leave the `+` button dead forever if the
dropdown's teardown swallowed the close callback).
Plus type sync: `WordsProviderSnippetProps` now declares
`selectedBlockPath`, the second arg of `selectAtomicBlock`, and
`setSelection` — they were exposed by the runtime but missing from
the type, breaking typecheck on eidos consumers.
Demo carries a `columns` block in the initial doc as a permanent test
fixture for column-related fixes.
**Known issue documented in CONTINUE.md P0:** typing inside a `columns`
block does NOT insert — selection sync (`syncSelectionFromDom`) isn't
mapping nested paths (`12.0.0.0`) to the model correctly. The inserter
flow is wired correctly; once the path encoding for nested selections
lands, the full Notion-style "click + → pick Heading → type" flow
works end to end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the eidos `<WordsColorRow>` (Text / Background pickers inside the
block inspector) on top of the new base-Block words engine + drops 3
obsolete audit MDs.
ColorPicker fixes surfaced while wiring it into the inspector — all
documented in `soma/components/color-picker/README.md` §Integration
pitfalls:
1. Eidos wrapper now declares `format = $bindable('hex')` (matches
soma's default). Without it, `bind:format={undefined}` threw
`props_invalid_value` on every mount → render loop.
2. Eidos wrapper now forwards `ref` to `ColorPickerProvider.create`.
Without it, `attachRef` was never built and any `runtime.trigger`
targeting `provider` threw `SomaRuntimeTargetError`.
3. `triggerClose` falls back to the picker provider's own DOM when
`runtime.partRef('content')` returns null (content is registered on
the Popover's runtime, not the picker's — re-exported part).
4. WordsColorRow draft-pattern: `$effect` reads draft inside `untrack`
so it doesn't overwrite mid-drag value, and `onValueChange` catches
the `Clear` programmatic transition (`onValueChangeEnd` doesn't fire
on Clear).
CSS: `contain: layout style` on `[data-color-picker-area]` and
`scrollbar-gutter: stable` on the picker popover content — stops the
area from shaking during drag when the trigger's ValueText hex changes
width or the popover scrollbar toggles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the user-directed rework. The engine is rebuilt around a single
base `Block` carrying the common properties (id, align, margin,
padding, background, text color, border), with each specialized block
(paragraph/heading/quote/code/list/table/image/divider/callout — plus
list-item/table-row/table-cell) EXTENDING it. The old `visual` sidecar
+ per-type `Pick<>` whitelist + loose `textAlign` are gone; common
props are first-class and universal.
Naming: dropped the `v2` noise everywhere — `operations-v2/`→
`operations/`, `types-v2`/`render-v2`/`validate-v2`/`serialize-*-v2`→
clean names, `*V2` symbols→bare, `WordsEvalIntent`→`WordsIntent`.
Sema: removed entirely from the editor — deleted `sema/components/
words.ts` + `engine/sema-parity`, dropped the registration from the
sema barrel + the demo layout, no `runtime.trigger`/semantic events.
Visual layer: deleted `eidos/components/words/*` (the bespoke chrome)
and stubbed the demo — both rebuilt next on the new model (F2 render
by components, F3 gutters/bubble/drawer, all eidos).
Engine tests 400/400 green; `npm run check` 0 errors (5142 files).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The editor read as "poor" largely from border clutter — hairlines
everywhere. Strip incidental borders and let tonal surfaces + spacing
carry the structure:
- Toolbar: drop the bottom hairline (its surface tone separates it).
- Drawer: drop the seam line (both sides) — tonal separation only.
- Drawer panels: drop inter-panel divider lines (scope accent + spacing).
- Code block: drop the outline — the overlay surface fill defines it.
- Table: no outer box, no vertical lines — light horizontal row rules
only + airier cells.
- Drawer insert items: borderless soft tonal tiles (hover deepens with
the accent) instead of bordered boxes.
Kept the intentional lines (quote accent bar, the silver margin rule).
Verified in browser: no console errors; table is horizontal-rule-only,
insert tiles borderless.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The editor used UI-cramped metrics (14px / 1.25 line-height / 8px block
gap) which read as a form field, not a document. Give the CONTENT (not
the chrome) a reading scale: body 14→16px, line-height 1.25→1.6, block
gap space-2→space-3 (headings derive 1.5x for top margin). Chrome keeps
its smaller --_words-font-size.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Decouple band width from text inset: `--_words-rail-width` (1.25rem,
the thin band) + `--_words-rail-gap` (1.25rem, breathing room) →
text is no longer glued to the band edge.
- Narrow the band: 2.5rem → 1.25rem (20px).
- Lighter tones: rail-bg #ededed → #f5f5f5, rail-border #cfcfcf →
#e3e3e3. Eidos CSS regenerated.
Verified: band 20px + 1px #e3e3e3 edge, then 20px air before text at 40px.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-directed editor rework, step 1 (left gutter):
- Remove the "+" inserter button from the block-handle gutter. Its
action moves into the drag-handle menu as a new "Insert below" item
(folded into `act('insertBelow')`); the standalone `insertBelow`
helper is gone. The separate seam inserter (words-block-inserter)
is untouched.
- Turn the left gutter into a soft silver margin band: narrow it
(`--_words-gutter-size` 3.5rem→2.5rem) and paint it via a
background-gradient on [data-words-content] — `--words-rail-bg`
#ededed band with a 1px, slightly stronger `--words-rail-border`
#cfcfcf right edge. Background only, so the contenteditable text /
selection is undisturbed. Recipe tokens softened + eidos CSS
regenerated.
Verified in browser: gutter shows only the grip on hover (no "+");
the menu reads Insert below / Move up / Move down / Duplicate / Delete
and "Insert below" inserts a paragraph (7→8 blocks) and closes the
menu; the silver band renders at 40px with the 1px border. npm run
check: 0 errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an H1/H2/H3 toggle to the drawer's Block panel, shown only when
the caret is in a heading (currentBlock === 'heading'). The toggle
reflects currentHeadingLevel and dispatches the structured
`setBlock` command with the numeric level via applyCommand:
applyCommand({ type: 'setBlock', block: 'heading', level })
runCommand / WordsCommandName has no per-level id (heading-1/2/3 are
slash/insert-menu ids), so the structured command carries the level;
`as const` keeps each level a 1|2|3 literal.
Verified in browser: caret in a heading shows "Heading level"; the
toggle reflects the active level and converts bidirectionally
(H1→H3→H2) with the toggle updating. npm run check: 0 errors.
Also closes the stale "drag handle outside the rail over <code>" P1:
it does not reproduce — the rail was removed in the gutter redesign
(5450d5f7) and the handle now anchors to contentRect.left+8 for every
block type (verified: identical left over code and paragraph). Both
P1 items marked resolved in continue.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The drawer image panel already had a corner-radius slider and
shadow/border toggles. Adds the controls that were still missing so
the V2 `block.visual` sidecar is fully editable from the UI:
- shadow blur slider (shown when shadow is on) → visual.shadow.blur
- shadow color picker → visual.shadow.color
- border color picker (shown when border is on) → visual.borderColor
- background color picker (always, with clear) → visual.background
All route through a small reusable `imageColorField` snippet (preset
swatches + native <input type=color> + optional clear), mirroring the
format panel's text-color UI, and dispatch `setBlockVisual`. The
engine already merges + renders these via visualToStyle onto the
<figure>.
Verified in browser: panel renders all four new controls when an image
is selected; clicking a Background swatch updates the figure
(background-color:#22c55e) while preserving radius/border/shadow
(shallow-merge intact). npm run check: 0 errors (26 pre-existing
warnings, none new).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes now-dead selectors left after V2 dropped table striped/compact
and cell tone/header (D2). Nothing emits these attrs anymore:
- `[data-command='table-cell-tone-*']` toolbar rules
- `table[data-words-table-striped]` / `[data-words-table-compact]` rules
- `[data-words-cell-tone='muted'|'accent']` cell + focus-within rules
- the dead `[data-words-cell-header]` alternates (headers render as <th>);
`tr:hover td:not([data-words-cell-header])` simplified to `td`
README updated: tables list only the live formatting (header row/column,
cell align, vertical align); HTML/Markdown documented as export-only,
import as JSON + plain text. ARCHITECTURE_PROPOSAL keeps its mentions —
they document the decision to drop these, not stale features.
Verified in browser: sample 2x2 table renders, <th> keeps the accent
tint + weight via :where(th), cell align/vertical intact, no console
errors. CSS + Markdown only — no type surface touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deletes the V1 rich-text engine and the extensions/ tree. The public
document model flips from WordsDocument to WordsDocumentV2 (D1: V2 puro,
sin legacy — no backward-compat importer, V1-persisted docs no longer
load). Per D2, table striped/compact/cell-tone are dropped entirely
(props, commands, morfo attrs).
- Delete engine/{document,operations,commands,history,input,render,
find-replace,serialize-{html,json,markdown,text},dom-selection,index}
and the whole extensions/ tree (registry + image + table).
- Complete the V2 ops under operations-v2/: port commands, history,
input, find-replace, render-dom, serialize-text, extra-ops,
insert-block-types, markdown shortcuts, factories, dom-selection;
land R4A.2-R4A.8 (text/delete/marks-ops/paragraph-ops/delete-ops/
block-format/list-ops/link-ops/table-ops/selection-walkers).
- Rewire provider, exports, types, morfo/words.ts, eidos word
components, soma words.svelte, the content-test harness, and the
/uix/components/words demo to V2.
- Import accepts JSON (JSON.parse + validateWordsDocument gate) and
plain text only; HTML and Markdown remain export-only (lossy, no
faithful inverse parser).
Verify: npm run check 0 errors; words scope 455/456 (lone failure is the
sema-parity 5s timeout flake under parallel load — 6/6 isolated);
morfo:vocabulary exit 0; browser smoke of /uix/components/words green
(sample renders, text + JSON import round-trip, invalid JSON rejected
without mutating the doc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per spec: "la banda de la derecha la eliminamos, el drag aparece con
el hover del block a la izquierda inferior y el insertar (+) en el
centro cuando el hover este en el final del block, la line debe de
ser mas solida sin blur"
Layout:
- Removed the dedicated rail grid column entirely. The editor host
grid drops from 4 columns (rail | gap | content | drawer) to 2
columns (content | drawer), or (drawer | content) when side=left.
- Removed `--_words-rail-bg/border/px/gap` CSS vars + the
`::before` pseudo-element that drew the silver band. The rail
recipe tokens (words.rail-bg / rail-border) become dead and can
be removed in a later sweep; left for now to avoid generated-css
test regen in the same commit.
Block handle (drag grip):
- Position math simplified: lives at the BLOCK'S lower-left corner
with a 4px inset. `top = rect.bottom - 28 - 4`,
`left = rect.left + 4`. No more rail-relative math, no
detectDrawerSide / cssToPx / rememberRailMetrics helpers — all
removed.
- CSS base opacity bumped from 0.75 → 1 (the grip is shown via
JS only on block hover; the visibility is binary, not faded).
Block inserter ("+"):
- findSeamForCursor rewritten: only triggers when the cursor is
INSIDE a top-level block AND in its bottom portion (last 25% of
block height, capped at 24px). When triggered, the seam anchors
at the block's bottom edge spanning the block's full width — CSS
centers the button via left:50% + translateX(-50%).
- Removed listBlockBoundaries + SEAM_RANGE_PX (dead code under the
new model — we don't compute inter-block seams anymore, we react
to the hovered block's bottom).
- Removed drawerSide flip + data-side attr (the "+" lives INSIDE
the block now, not in a side gutter — drawer side is irrelevant).
Line visual:
- Removed transparency: `background: var(--_words-accent-solid)`
(was color-mix(... 35%, transparent)). Opacity 0.9 always when
shown (no opacity-0 default + hover transition).
- Removed box-shadow halo on drop-mode state (was the blur effect
the spec calls out). Drop mode still grows the line block-size
from 2px to 3px so the drop target is unambiguous.
Verification: DOM probe at /uix/components/words confirms:
- Hover middle of block: handle visible at lower-left
(top=666 vs rect.bottom=698, left=66 vs rect.left=62), inserter
hidden.
- Hover bottom of block: handle still at lower-left, inserter
visible spanning block width (left=block.left, width=block.width),
CSS centers the button horizontally.
- `npm run check`: 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R4A.1 opens the operations layer of the V2 engine. Pure functions only;
no provider coupling. Lives in `engine/operations-v2/`. The full V1
op surface (~50 functions, ~3000 lines) splits across multiple R4A.*
sprints — this one establishes the foundation + the V2-exclusive
visual operation that unlocks POLISH-1b in later sprints.
operations-v2/types.ts (~55 lines):
- WordsEditorStateV2 = { document, selection, activeMarks } over V2
shapes. Reuses V1's `WordsSelection` (path + offset) verbatim — the
selection model doesn't change between V1 and V2.
- WordsOperationResultV2 = { state, changed }. Same shape as V1's
result so future provider migration can plug in directly.
- noOp + changed helpers for guard-clause and result construction.
operations-v2/normalize.ts (~250 lines):
- normalizeDocumentV2(doc, opts?) — defensive shape repair after
every operation. Returns same document REFERENCE when nothing
changed (cheap structural compare via mutated flag).
- Autogen `id` for blocks/items/rows/cells without one (delegates
to defaultIdGenerator from migrate-v2 → crypto.randomUUID + fallback).
- Seeds empty containers: empty document → 1 paragraph; empty list
→ 1 item; empty table → 1×1 cell; empty inline children → 1
empty text.
- Coalesces adjacent text inlines with identical marks (V1 invariant
preserved — keeps the model from fragmenting after edits).
- Strips marks from code block children (code is plain text only).
operations-v2/visual.ts (~115 lines):
- setBlockVisual(state, blockIndex, patch) — V2-EXCLUSIVE.
- Shallow-merges patch into block.visual. Defends:
* blockIndex must be valid.
* Every patch key must be in WORDS_VISUAL_KEYS_PER_TYPE[block.type]
(P8 whitelist enforced at runtime; unknown keys silently dropped).
* Patch values of `undefined` REMOVE the corresponding visual
property from the merged result (clears via undefined).
* Empty visual is omitted from the block (no `visual: {}` stored).
* No-op result when patch matches existing visual (avoids
spurious re-renders).
- This is the operation POLISH-1b (image radius slider + shadow
toggles + border controls) will call.
operations-v2/block.ts (~140 lines):
- updateBlockAt(state, blockIndex, patch) — shallow-merges block
fields, refuses to change `type`.
- deleteBlockAt(state, blockIndex) — removes block; normalizer
seeds an empty paragraph if it was the last.
- insertBlockAt(state, blockIndex, block) — inserts at index (clamps
to bounds).
- moveBlockAt(state, blockIndex, direction) — swap with neighbor.
- duplicateBlockAt(state, blockIndex) — inserts a copy with a fresh
autogen id (drops the original's id before normalize so they're
distinguishable).
- moveBlockToAt(state, fromIndex, toIndex) — arbitrary repositioning
for drag-drop.
operations-v2/index.ts: barrel export of the public surface.
Tests (31): normalize (autogen ids preserved when present, empty
containers seeded for doc/list/table/inline children, text coalescing
with marks-aware preservation, code stripping, idempotence via same
reference). setBlockVisual (set/merge/clear/empty-elision/per-type
whitelist enforcement/shadow on image vs paragraph/no-op on match).
Block ops (updateBlockAt with type-change refusal + invalid index
no-op; deleteBlockAt with seed-on-empty; insertBlockAt with clamping;
moveBlockAt with edge no-op; duplicateBlockAt with fresh id;
moveBlockToAt with from-equals-to no-op).
Two TS errors landed mid-write (over-clever readonly modifier on
function param + structural cast across discriminated union) — both
fixed with simpler types. Verification: 257/257 engine tests pass
(85 V1 + 50 V2 validator + 35 V2 migrator/parity + 56 V2 render/
serializers + 31 V2 ops). `npm run check`: 0 errors.
Next: R4A.2 — text + selection operations (insertText, deleteRange,
insertParagraph, deleteBackward, deleteForward, toggleMark, etc.).
~2500 lines of V1 to mechanically translate. Or pause R4 entirely
and tackle the pragmatic shortcut (backport visual to V1 image to
ship POLISH-1b sooner).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R2 closes the engine refactor non-breaking phase. The V2 model from
R1 is now reachable from any V1 document via a pure migrator, and
the engine's local SemaIntent copy stays in lockstep with the
canonical $uix/intent.INTENTS via a runtime assertion.
engine/migrate-v2.ts (~370 lines):
- Pure function migrateV1ToV2(input, opts?) → { document, runtimeState,
warnings }.
- Per-block migration paths for paragraph / heading / quote / code /
list / table / image / divider / callout. Each rejects invalid
values defensively (e.g. invalid heading level coerced to 1 with
warning).
- P4 enforcement on image: drops `image.status` from the content,
moves it to `runtimeState.imageStatus: Map<blockId, status>` keyed
by an autogen id. Caller (provider) restores into in-memory
sidecar on load.
- P1 sweep on table: drops `striped` / `compact` (presentation
tokens) with warnings. Drops `cell.tone` (design-system token)
with warning. Renames list.children → list.items silently.
- Marks reshape: V1 template-literal `'color:#hex'` /
`'bgcolor:#hex'` → V2 structured `{type:'color'|'background',
value:'#hex'}`. Already-structured marks pass through unchanged.
- Code blocks narrow to text-only: flattens any link inside code
into its text content with warning.
- Idempotent on V2 input: a V2 document round-trips with zero
warnings and exact-equal output (validates with
validateWordsDocument).
- Default id generator uses crypto.randomUUID() when available, with
Math.random + monotonic counter fallback. Tests inject a
deterministic sequential generator.
engine/sema-parity.ts (~60 lines):
- assertSemaIntentParity(canonicalIntents) throws SemaIntentParityError
if the engine's local WORDS_EVAL_INTENTS drifts from the canonical
list. Error names both the missing and extra values + points to
the file to update.
- Caller passes canonical list (avoids cross-package import in this
module). Provider mount calls it with $uix/intent.INTENTS.
engine/migrate-v2.test.ts (35 tests):
- Entry-point shape handling (null / non-object / non-array children).
- Per-block migration (paragraph, heading with level clamp, quote
with cite, code with text-only narrowing + link flattening, list
with kind coercion and indent-0 omission, table with token drops,
image with src validation).
- P4 enforcement: image.status → runtimeState.imageStatus keyed by
autogen id; respects caller-provided id.
- Marks reshape: template-literal → structured; structured
pass-through; invalid hex rejected; unknown boolean dropped.
- Idempotence on V2 input (paragraph, callout, divider).
- Callout intent: all 6 canonical SemaIntent values accepted;
invalid (e.g. 'warning') coerced to 'neutral' with warning.
- Post-condition: migrator output ALWAYS passes
validateWordsDocument on any V1 input.
- Sema parity: 5 tests including the real golden cross-check
against $uix/intent.INTENTS.
Verification: 170/170 engine tests pass (85 V1 + 50 V2 validator
+ 35 V2 migrator/parity). `npm run check`: 0 errors.
Next: R3 — visual sidecar implementation. Render emits inline
styles from block.visual.*; POLISH-1b (image radius/shadow/border
controls) becomes implementable under this model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EV-G final pass + EOD documentation.
Words editor:
- Stop emitting `contact-focus` from soma. Removing the per-component
cascade rule earlier didn't fully silence the editor — the `contact`
family carries a BASE sound signature in SEMA_MAP (pitch 800, gain
0.25) that fires even without a cascade rule. The trigger call is
commented out (event no longer emitted; telemetry empty). Per spec:
"sigue apareciendo el puto sonido, el ring".
- Drag handle + inserter now cover lists AND tables. Selectors
expanded from `[data-words-node="block"][data-words-path]` to also
match `[data-words-node="list"]` and `[data-words-node="table"]`.
Top-level lists / tables get the grip + the "+" seam under them.
- Removed :hover from `[data-words-block-handle]` and
`[data-words-block-inserter-button]`. Gutter overlays are AMBIENT —
visual state via data-attrs only (`data-open`, `data-grabbed`,
`data-dragging`). Per spec: "quítale el hover al botón de añadir
bloque, si el handle no tiene este tampoco".
- Rail tokens hoisted to recipe: `words.rail-bg = #d4d4d4`,
`words.rail-border = #9a9a9a`. Fixed-tone (not theme-aware) — the
rail emulates the physical margin of a notebook; same look in
light/dark. Recipe-css-contract test was rejecting raw hex in
words.css.
Docs:
- CLAUDE.md hand-off 2026-05-28 #2 documenting the 6 EV-* Words
sprints + 5 architectural decisions with lasting effect (canvas
silent, fixed-tone tokens for physical metaphors, drag predicate
covers list/table, ambient overlays, insertParagraph empty-block
guard).
- continue.md (CONTINUE.md) rewritten for tomorrow's pickup:
TL;DR of the two halves (toggle-group structural + Words UX),
remaining bugs (drag handle outside rail on code blocks, heading
inline level change), and the heredados (POLISH-1b, F2.3-F2.6,
DRAGDROP-SOMA).
Verification: 467/467 morfo + sema + eidos + soma/words tests pass.
85/85 engine pass. `npm run check`: 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six user-reported issues in one pass.
EV-F1 — canvas silence
Remove the contact-focus entry from the sema cascade. The event still
fires (telemetry / a11y can read the data-event* stamps) but no sound
/ haptic plays. Per spec: "no puede emitir eventos es demencial, una
vez que está trabajando con el no tiene sentido". The editor surface
is now perceptually silent on focus; only explicit commits (save,
format, set-link, etc.) make noise.
EV-F2 — rail visual
`--_words-rail-bg` becomes flat silver `#d4d4d4` and the dot
pattern is gone. New `--_words-rail-border` (`#9a9a9a`) lays a
1px hairline on the rail's inline-end so it reads as a separate
gutter ("paper edge" feel). Fixed-tone (not theme-aware) — the rail
is meant to look like the physical margin of a notebook regardless
of the editor theme. Left-side drawer flips the border to inline-
start.
EV-F3 — direct undo/redo in toolbar
Demo presets + custom config now use `['undo', 'redo', ...]` as
direct command strings instead of `['history-menu', ...]` (which
opened a popover to pick from two items — wasted real estate +
two clicks). `link-menu` and `tools-menu` stay as popovers because
they hold 2-3 items each. `insert-menu` is OUT — block construction
lives in the drawer's empty-state Insert panel and the slash menu.
`demoToolbarFamilyItems.insert` collapsed to `[]` accordingly.
EV-F4 — popover widths
Base `[data-words-toolbar-family-panel]` width 12.5rem → 16rem so
content fits without horizontal scroll. Per-family overrides:
tools → 17rem (descriptive labels), link → 20rem (URL input +
button grid). The default panel CSS no longer carries a max-block-
size + scroll — a vertical scroll inside these popovers is a
misdesign signal; size the panel, don't scroll it.
EV-F5 — link popover (covered by EV-F4)
The vertical scroll the user saw was a side-effect of the
12.5rem width forcing the link-editor input + button to wrap, then
the panel running out of vertical room. The new 20rem width
restores the natural one-row layout and no scroll appears.
EV-F6 — Enter on empty block no longer multiplies
`insertParagraph` engine op now guards the default case
(paragraph/heading): if the block is collapsed AND empty (children
length 0, both halves of split empty), Enter on a paragraph is a
no-op and Enter on a heading demotes it to a paragraph (canonical
Notion / Google Docs UX). For non-empty paragraphs/headings the
existing split-and-create-paragraph behavior stands.
Verification: 467/467 tests pass across morfo + sema + eidos +
soma/components/words. 85/85 engine tests pass (insertParagraph
guard non-regressive on existing fixtures). `npm run check` still 0
errors. DOM probe at /uix/components/words confirms toolbar = 5
items (undo, redo, Links, Tools, Find/replace), rail bg
`rgb(212,212,212)` + 1px `rgb(154,154,154)` right border, link
popover 320px wide with no scroll.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The editor body was unbounded vertically — long documents pushed the
whole page instead of confining the scroll to the editor surface.
Per spec: "la parte de la edicion deberia tener un scroll".
Recipe:
- Three new tokens `content-max-block-size-sm/md/lg` (50vh / 60vh /
70vh) mirror the existing `content-min-block-size-*`. Generated CSS
regenerated.
CSS:
- `[data-words-content]` now carries `max-block-size:
var(--_words-content-max-block-size)` and `overflow-y: auto`. The
min-block-size baseline stays as the starting height for empty
editors. When content grows past the cap, the body scrolls
internally — the host card no longer balloons.
- `scroll-padding-block: 1rem` hint so caret-into-view nudges leave
breathing room.
Block-handle / block-inserter / image-float-bar:
- Switch scroll listeners from `window.addEventListener('scroll')` to
`document.addEventListener('scroll', { capture: true })`. Scroll
events don't bubble; the previous wiring only caught window scrolls.
Now the gutter overlays (handle position, "+" snap target, image
float bar anchor) re-measure when the user scrolls INSIDE the
content area too.
Verification: dev probe at /uix/components/words confirms
`max-block-size: 600px`, `overflow-y: auto`, content scrollHeight
(615px) > clientHeight (600px), `isInternallyScrollable: true` on a
1000px viewport (60vh of 1000 = 600). 467/467 tests pass across
eidos + morfo + sema + soma/components/words.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four user-requested fixes to the Words editor surface — the body
should not emit perceptual cues during editing, the top toolbar must
not duplicate the drawer, and the gutter widgets need clearer anchor
points.
EV-A (events spam):
- Move `contact-focus` event target from `content` to `provider` in
the morfo + sema cascade selector. Focus is a Words-component-level
signal; firing it from the content body conflates "user entered the
editor" with "user is editing the body". The wasFocused guard in
soma already throttles to one emit per real focus transition.
- Expand `isInsideWordsTool` selector list to cover the four
companion overlays added after the original list was written:
`data-words-drawer`, `data-words-block-handle`,
`data-words-block-handle-menu`, `data-words-block-inserter`,
`data-words-image-float-bar`. Before this fix every click on a
companion overlay fired commit-save-content + contact-focus on the
blur/refocus cycle (two perceptual cues per tool interaction).
EV-B (toolbar trim):
- Demo's full / formatting / minimal presets + custom config now host
only GLOBAL actions: history (undo/redo), insert (creates new
blocks), link (selection-bound flow), tools (find/replace + clear),
find-replace in its own group. text-menu / block-menu / align-menu
/ list-menu / table-menu moved out because the drawer already owns
contextual formatting per the HIER-2 split.
EV-C (block-handle drag UX):
- Drop the `setDragImage(hoverBlockEl)` call. The browser now uses
its default snapshot (the grip button itself) as the drag ghost —
the ghost travels with the cursor while the bar in the gutter
stays fixed as a visual anchor. New `data-dragging` attr +
`[data-words-block-handle][data-dragging]` CSS rule fades the
static anchor to 0.35 opacity so it reads as "drag origin" while
the ghost is the moving part.
EV-D (inserter at block bottom):
- Seam positions are now pinned to the BOTTOM EDGE of the preceding
block (`a.bottom`) instead of the midpoint of the gap between two
blocks. The "+" reads as "insert AFTER this block" anchored to
that block's lower edge, per spec — el botón de añadir bloque
debe aparecer en el límite inferior del área en relación al
bloque.
- Tighten the "cursor inside block band" check to a half-open
interval `[top, bottom)` so the exact bottom-edge pixel belongs to
the seam below (otherwise the seam at `y === bottom` is shadowed
by the block and the inserter never snaps).
Verification: dev server, /uix/components/words, DOM probe confirms
handle centered in the rail column (left=48 inside the 32-wide rail
starting at ~46), inserter snaps to block 1's bottom (`top=569.94px`
when block 1 bottom = 570px). 366/366 tests pass in morfo + sema +
soma/components/words. `npm run check` still 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the toggle-group structural migration `npm run check` still had
12 pre-existing errors. All from the active Words sprint (post-2026-05
F2/F3/COLOR work), not introduced by this session. Closes them to
restore green check:
- soma words.svelte: thread `onUploadImage` through the Provider opts
(was required-undefined in WordsOpts but missing from the create()
call).
- eidos words-toolbar-family-menu.svelte + words.svelte: add
'insert-image' label to COMMAND_LABELS (the Record<WordsToolbarButton
CommandName, string> required full coverage).
- eidos words.svelte: hoist the `leafItem` snippet out of <SomaWords.
Provider> to template root level. Snippets defined INSIDE a component
element are passed as named props in Svelte 5; this one was meant as
a local helper. Also kills the "Expression too complex" error at the
Provider tag — the leafItem prop was bloating the union.
- eidos words-drawer.svelte: extract 5 inline button-config arrays to
typed `as const satisfies readonly { id: WordsCommandName; ... }[]`
constants (FORMAT_MARKS, TEXT_ALIGN_ACTIONS, CELL_TONE_ACTIONS,
CELL_VERTICAL_ACTIONS, CELL_TEXT_ALIGN_ACTIONS). The inline literals
were inferring `id: string`, breaking `runCommand` and
`activeMarks.includes`.
Result: npm run check 16 → 0 errors. Tests 658/658 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the deuda left explicit in hand-off 2026-05-27 #5: the inlined
derivation expressions in toggle-group.css duplicated Toggle's recipe
because [data-toggle-group-item] wasn't a descendant of [data-toggle].
Resolution is structural, not a drift test:
- morfo: each item declares { attr: 'data-toggle', value: v.literal(''),
severity: 'required' }. Captures structural identity — an item IS a
toggle in every observable sense (same press, same variant/color/size,
same on/off machine).
- eidos: new context.ts propagates the root's variant + size via
reactive getters; the item wrapper writes them as data-attrs on its
button. Combined with data-toggle, the item is DOM-equivalent to a
standalone <Toggle>.
- eidos css: ~150 lines deleted (base derived tokens, variant cascades,
size cascade, focus/disabled/icon-only duplicates). Toggle's recipe
now paints the item end-to-end. CSS keeps only the grouping concerns:
flex, orientation, attached, block, focus z-index, group disabled.
- composition (TSC v2.2) stays — palette overrides land on the item
under [data-toggle-group][data-color='X'] [data-toggle-group-item],
which Toggle's --toggle-palette-* chain reads at item scope.
Verification:
- DOM probe (12 combinations: 4 colors × 3 variants × 2 states) on
/uix/components/toggle-group. Computed values match bit-a-bit with
baseline pre-refactor (e.g. affirm/solid on = rgb(18,165,148),
risk/outline on = srgb(0.2,0.118,0.043), threat/ghost on =
rgb(25,17,17)).
- vitest src/uix/{morfo,eidos}: 163/163 pass.
- vitest src/uix/soma/components/toggle-group: 4/4 pass.
- npm run check: 12 errors (all pre-existing in active Words sprint;
zero added by this migration).
Drive-by: the same npm-check pass surfaced 4 stale 'orientation' refs
in words-toolbar.svelte + words-toolbar-group.svelte from this session's
earlier toolbar refactor (orientation was removed from types but not
from these soma components). Removed; brings check from 16 → 12 errors.
CLAUDE.md hand-off 2026-05-28 documents the new doctrine: when a
wrapper visually reuses another, declare structural identity in the
wrapper's morfo. Don't duplicate the cascade, don't extend TSC for one
case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`npm run check` was reporting 'content is possibly null' errors in
both block-handle and block-inserter $effect closures — 6 errors
between them. TS doesn't carry the `if (!content) return` narrowing
into inner closures, so we re-bind to a const typed `HTMLElement`
right after the guard.
`npm run check`: 22 → 16 errors. Remaining 10 (drawer command-name
/ mark string-not-assignable, words.svelte missing 'insert-image'
key in the Record, plus 6 pre-existing in src/lib/_demo and others)
need a deeper look at the command-name / mark string unions —
follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three categories of audit drift introduced during the recent Words
sprints (drawer redesign, image float bar, color marks, insert
panel, gutter rail), all caught by the existing contract tests now
that the work has landed:
1. **Undeclared morfo attr** (P0 contracts test):
`data-words-block-selected` is stamped by the engine's render on
the atomic-selected block (today only image) but wasn't in the
morfo's data-attr list. Adds it as optional on the same parts
array that already declares `data-words-image-*`.
2. **Missing i18n key** (translations:check):
The drawer part references `components.words.drawer` for its
aria-label, but the langs catalog was never updated when
DRAWER-CANON landed. Add `drawer` to
`src/uix/langs/components/words.ts` (es + en).
3. **Recipe CSS contract drift** (eidos recipe-css-contract test):
- 3 undeclared CSS vars used in the drawer-Editable styles:
`--words-toolbar-color` → `--words-command-color`
`--words-command-hover-bg` → `--words-command-bg-hover`
`--words-command-active-color` → `--words-command-color-hover`
(Right names per `lib/recipes/base.ts > words`.)
- 15 raw font-size literals (0.65rem / 0.7rem / 0.75rem / 0.8rem
/ 0.9rem) replaced with the existing words font-size tokens:
0.65/0.7rem → `var(--words-status-font-size)`
0.75/0.8rem → `var(--words-font-size-sm)`
0.9rem → `var(--words-font-size-md)`
Result:
- `contracts.test.ts`: 31→32 pass (the data-attr violation is gone).
- `translations:check`: 219 refs / 76 catalogs / 0 errors / 0 warnings.
- `recipe-css-contract.test.ts`: 17→19 pass.
- Full `vitest run src/uix`: **867/867** pass (was 865/2-fail before).
- `npm run check` clean (only 6 pre-existing errors unrelated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs blocking the drag-and-drop UX:
1. **Grip was 22px off** — `parseFloat('2rem')` returns 2, not 32.
I was reading `--_words-rail-px` / `--_words-rail-gap` as raw
strings and treating them as pixels. With the actual rem values
the grip landed in the gap/content padding instead of the rail.
Fix: `cssToPx(raw, rootFontSize)` resolves `rem`/`em` → px via
the documentElement's computed font-size. With the fix the
grip's center matches the rail's center exactly.
2. **Drag preview was the tiny grip icon, not the block** — native
HTML5 drag defaults to a snapshot of the `draggable` element.
Since `draggable=true` lives on the 28×28 grip button, the user
saw a tiny grip ghosted under the cursor instead of the
paragraph/heading/image being moved.
Fix: `e.dataTransfer.setDragImage(hoverBlockEl, 12, 12)` in
`ondragstart`. The browser now uses the BLOCK's element as the
drag image — user sees the whole row/heading/image moving with
the cursor, just like Notion / Linear / Figma.
Note re: soma's `<DragDrop.Provider>`: the user is right that
soma has a first-class drag-drop system (pointer + keyboard + ARIA).
The reason this implementation still uses native HTML5 dragstart/
dragend is architectural: soma's `<DragDrop.Draggable>` is designed
to WRAP each draggable item, but here the blocks are rendered by
the engine's HTML render function as plain DOM (not Svelte
components), so each block isn't wrappable. The block-handle is a
single floating overlay that snaps to whichever block is hovered.
Migrating to soma DragDrop would require restructuring the editor
render to per-block Svelte components — a larger architectural
change. Filed as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the "+" snapped to any seam within 18 px of the cursor's
Y — which meant it appeared even when the cursor was hovering on a
block's body (near its top/bottom edge) or in the rail aligned with
the block. That clashed with the grip's territory.
Per user spec: when the cursor is on a block (text or rail-aligned),
the grip is the actor — the "+" should be hidden. The "+" only
surfaces when the cursor is in the rail column AND vertically
positioned in a gap BETWEEN two blocks.
Two new gates added to `findSeamForCursor`:
1. **Out-of-content X**: if `e.clientX` falls inside the content
box's horizontal extent (the editor's text area), return null
immediately. The cursor is reading text or near the toolbar/
status row, not the rail.
2. **Out-of-block Y**: if `e.clientY` falls inside ANY top-level
block's vertical band, return null. The block-handle owns that
slot.
Only when both gates pass do we snap to the nearest seam. The
existing `dragover` path is untouched — drag indicator still works
inside the content area because the drag operation has different
ergonomics (you're actively moving a block, the line is the drop
target, not a discoverability cue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback was right — the previous approach put the rail INSIDE
the editor as extra content padding, so it was glued to the editor
card with no visual separation. Reframed as a proper SIBLING column
in the grid layout, with its own grey/dotted background and a gap
to the editor mirroring the drawer's gap on the other side.
Layout (right-drawer default):
[rail 2rem] [gap 0.5rem] [editor card] [drawer]
Left-drawer flips both rail + gap to the right side.
CSS changes:
- New tokens: `--_words-rail-px` (2rem ~32px column width),
`--_words-rail-gap` (0.5rem ~8px gap to editor),
`--_words-rail-bg` (subtle grey using toolbar-border tint),
`--_words-rail-dot` (denser status-color dots at 65% opacity).
- Grid `template-columns` and `template-areas` of
`[data-words]:has([data-words-drawer])` extended to include `rail`
and a `.` gap column. Left-drawer mirror reverses them.
- Rail visual: `[data-words]:has(...)::before` with `grid-area:
rail`, dotted radial-gradient on grey bg, rounded corners
matching the editor's radius.
- Reverted content + placeholder to symmetric `padding-inline` (the
rail no longer lives in content's padding). Deleted the obsolete
`[data-words-content]::before` rule.
Overlay positioning (JS):
- `words-block-handle.svelte` reads `--_words-rail-px`,
`--_words-rail-gap`, `--_words-content-px` from the editor host
and computes the grip's `left` as the center of the rail column
in viewport coords:
right-drawer: `block.rect.left - content-px - rail-gap - rail-px/2`
left-drawer: `block.rect.right + content-px + rail-gap + rail-px/2`
Then offset by half the grip's width (14 px) to center on the rail.
Inserter "+" button (CSS-only):
- `left` switched from a hard `-28px` to a calc that mirrors the
grip math:
`-(content-px + rail-gap + rail-px/2) - 12px`
Right-edge mirror for the left-drawer case.
Grip visibility bumped:
- Color from `status-color` to `command-color` so it reads as
active-text against the dotted rail.
- Default opacity 0.45 → 0.75 (visible without hover).
- Hover state gains a toolbar-bg fill + accent-text + accent
border ring so the hovered grip stands out from the rail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two complaints from the user on the rail:
1. Still too wide at 1.5rem (~24px).
2. The band doesn't visually distinguish itself from the canvas
so the grip + "+" look detached.
Fixes:
- **Rail trimmed to 1rem (~16px)** — combined with content-px gives
a 32px total padding on the rail side. The 28×28 grip lands
at content.left+0 → +28 with a 4px gap before the text. This is
the minimum band width that still keeps the grip fully inside
the editor's visible content area.
- **Dotted pattern on the band** — `[data-words-content]::before`
fills the rail's padding zone (32px wide, full height) with a
radial-gradient dot pattern at 8×8 px tiles. Color is
`status-color @ 40% opacity` so the dots read as a "this is a
gutter" cue without competing with content. `pointer-events:
none` and `z-index: 0` so they don't interfere with selection
or caret.
- **Flips with drawer side** — when `<Words.Drawer side='left'>`
the pseudo's inset moves to inline-end so the dotted band tracks
the rail to the right side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous 2.5rem (~40px) made the content's left padding total ~56px
which felt excessive — the band was very wide and the grip looked
lost in the middle of it. Trim to 1.5rem so the total padding is
~40px on the rail side: tight enough that the 28×28 grip occupies
most of the band (content.left+8 → +36) with just enough breathing
room (4 px) before the text starts. Same Notion-style density.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The grip `::` and the "+" inserter were positioned at
`block.rect.left - 28..32` — fine in theory but the content area
had symmetric `padding-inline` so that math landed the overlays in
empty body space outside the editor box. User-visible result: the
left band had no width, the grip dragged on/off the edge unevenly,
and the click target felt invisible.
Fix: dedicate a visible rail of `2.5rem` (~40px) on the side
OPPOSITE the drawer, by switching the content + placeholder from
symmetric `padding-inline` to an asymmetric pair
(`padding-inline-start` + `padding-inline-end`). New token
`--_words-rail-px` is added to one of those sides per drawer
position.
- Drawer on RIGHT (default): rail is on the LEFT — content's
`padding-inline-start` becomes `content-px + rail-px`. The grip
at `block.rect.left - 32` now lands inside that visible band
(≈ 24-52 px from the editor's left edge), with the inserter "+"
button at -28 alongside it. Block text starts 56 px from the
editor's left edge.
- Drawer on LEFT: mirrored — the asymmetric padding flips via
`[data-words]:has([data-words-drawer][data-side='left']) >
[data-words-content]` so the rail moves to the inline-end side
where the overlays now render.
No JS changes needed — the existing geometry-based positioning
(`block.rect.left - 32`, `seam.left - 28` for the "+", with the
drawer-side detection added in an earlier commit) automatically
lands in the new band because `block.rect.left` shifted right by
the rail width.
Verified in browser: hovering anywhere over a paragraph reveals
the grip at the expected band position, with no flicker, and the
hit area is comfortably wide for a click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>