The scoop family chip cast its box-shadow INTO the concave notches and the 1px
border seamed at the concave corners (native corner-shape, not clip-path) — looked
broken. Flat-filled chips render every family cleanly and are the right home for
cut/scoop (chips/badges), matching the per-component idea below.
Add pendiente.md: per-component shape family (chips/badges differentiator), general
shape adoption, corner-shape support (canon = degrade to arc, no polyfill), depth
scrim cue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuilt from abstract tiles to a real "now playing" card whose every corner is
driven by a control panel — switch family / smoothing / magnitude / concentric and
the whole card retunes live. The avatar stays a true circle (families do not deform
it, by design). Plus: de-cerca round-vs-squircle, the 4 families on one chip, the
press-morph buttons, and a "what only Eidos does" strip.
Fix: the cover no longer sets its own border-radius, so the [data-shape-nest]
concentric rule owns it — the cover inset is now truly concentric (16 - 8 = 8px).
Verified: check 0 errors; browser (Chrome 146) — the family switch retunes card +
cover + play + tag + input live, avatar stays circular, cover concentric.
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>
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>
The first cut was weak (grey tiles + a "dispárame" box — it did not sell depth).
Rebuilt around real UI that responds:
- Reacciona — metric cards that lift on hover (raised -> overlay: shadow grows +
translateY): depth responds to interaction (state moment).
- Asciende — an app mockup whose trigger rises a dialog over a scrim; the dialog
shadow grows from flat -> its modal plane as it ascends (the present-rise firma,
fired by stamping data-event-*) and the backdrop recedes (event moment).
- Cada plano, un rol — the 5 planes as the real components they are for:
chips (flush) / card (raised) / menu (overlay) / dialog (modal) / input (recessed).
Self-contained, dark editorial aesthetic. Verified: check 0 errors; browser — the
dialog rises with the firma over a scrim (firmaFired + scrim true), archetypes render
with their plane shadows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Showcase for the depth channel (mirrors /temas/color and /temas/tipografia):
- ESTADO: the 5 planes (flush/raised/overlay/modal/recessed) as elevated tiles
composing surface + shadow + z (`data-depth`), with a light/dark toggle showing
the mode-aware mix.
- EVENTO: stamping `data-event-*` (as sema would during the hold) fires the firma
live — emerge -> the shadow grows (rise); contact -> it flattens (recede).
Depth occurs.
Self-contained (eidos foundation + data-theme), distinctive dark/editorial aesthetic.
Verified: check 0 errors; browser — 5 planes render with their resting shadows;
firing emerge applies the present-rise firma (firmaFired true).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typography showcase mirroring /temas/color: dogfoods the engine through
live CSS tokens.
- Escala: live `buildTypeScale` ladder — base + modular ratio (+ fluid
ratioMax/viewport) re-derive all 8 sizes; the named styles (hero/h1-h6/
body/caption) re-scale too (they reference --font-size-* via var()).
- Familias: the 4 config-driven @font-face slots in their own fonts.
- Ejes: tracking / leading / measure / wrap / numeric driven live via the
--_text-* per-instance vars the recipe reads.
Self-contained (eidos foundation CSS + raw recipe data-attrs + data-theme),
same posture as the color demo. Editorial Lora-display aesthetic.
Verified in browser: live scale responds (xxxl 16->77px at ratio 1.6);
4 real fonts load (Instrument Sans / Lora / Azeret Mono); axes apply
(measure narrow = 54ch). `npm run check` 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The event-trace inspector in each demo keyed its `{#each}` by a
`Date.now()` timestamp (`entry.at` / `h.at`). A single interaction can
stamp `data-event` on several elements within the same millisecond, so
two trace entries share the key and Svelte throws `each_key_duplicate`
(reported on dropdown-menu).
The trace is an ephemeral, 3-item, text-only log with no transitions or
stateful children, so positional reconciliation is correct: drop the
timestamp key (the each becomes unkeyed). The `fmtTime(...at)` display is
left intact.
Swept all 22 demos carrying the pattern (21 keyed by `entry.at` + announce
by `h.at`): dropdown-menu, context-menu, menubar, navigation-menu, listbox,
grid-list, table, tree-view, tree-grid, feed, command, carousel, drag-drop,
clipboard, announce, alert-dialog, button, color-field, link-preview,
range-calendar, time-field, time-range-field.
Verified: `as \w+ (\w+.at)` -> 0 occurrences site-wide; `npm run check`
0 errors; browser repro on dropdown-menu + table (burst of events, no
each_key_duplicate, console clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eidos-native components have NO soma layer, yet each demo rendered a
misleading "soma" code-snippet block ("n/a · eidos-native — equivalent
markup shown") fed by an orphan `somaSnippet` derived. Sweep the fix
already verified on text/heading across all 26:
layout primitives box flex grid stack container auto-grid wrap group
section aspect-ratio float
typography/inline code code-block kbd mark highlight link badge separator
visual leaves avatar banner skeleton spinner icon display scroll-frames
For each: remove the `somaSnippet` declaration + the soma `data-uix-code`
block, leaving only the real eidos snippet (and dropping its now-unneeded
inline margin-top).
Skipped (correctly): card / avatar-group / image (soma mentioned only in
prose, no fake code block — image reads a real ImageProvider) and all
genuinely soma-backed components.
Verified: `npm run check` 0 errors; 0 orphan somaSnippet refs in swept
files; browser spot-check (box, scroll-frames) shows one eidos code
block, zero soma badges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Text and Heading are eidos-native (no soma layer), but their demos:
1. wrapped the live preview in a content-sized box (no inline-size),
so `align` had no room to render inside the centered stage area;
2. rendered a "soma" code-snippet block ("n/a · eidos-native") with
non-real equivalent markup, muddying the layer story.
Fix both: the live wrapper now `inline-size: 100%` (fills up to its
max-inline-size cap, 36/38rem) so alignment is visible; and the soma
code block + its now-orphan `somaSnippet` derived are removed, leaving
only the real eidos snippet.
Verified in browser: text box 576px / heading box 608px (filling the
918px stage); align=end renders; one eidos code block, zero soma badges.
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>
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>
The previous split was by the text pick (white/dark) - wrong. Group the on-solid
chips by the SAME taxonomy as the Roles section: Jerarquia (primary/secondary/
tertiary) and Intents (neutral/affirm/fulfill/risk/threat/loss), reusing GROUPS.
Removed the now-unused onSolidGroups derived.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Revert ssr:false (it sidestepped the problem, did not fix it). Set the color
inputs imperatively via a `colorValue` action instead of reactive value=/bind:value.
With no Svelte-managed value, hydration no longer assigns "" (the warning) -- and
SSR stays ON. Verified: SSR HTML emits the 6 color inputs with NO value attr; the
action populates them client-side with valid hex.
- Split the "Texto on-solid" section into 2 groups by the APCA pick: "Texto blanco"
(dark solids) and "Texto oscuro" (light solids), computed from the live builder
solids (effectiveSolids) so the groups match the rendered chips. Default: white(8)
/ dark(1 = risk).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diagnosis: the SSR HTML already emits valid hex on all 6 <input type="color">
(verified), and the warning stack pointed to SvelteKit hydration ("await in
start"). Svelte momentarily sets a color input's value to "" while hydrating
before applying the real hex -> "specified value '' does not conform" x6. The page
is a client-only interactive tool (no SEO/SSR need), so ssr:false removes
hydration entirely -> no warning. Belt-and-suspenders with the existing value
guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The seed <input type="color"> used bind:value; switch to value + guarded oninput
so it can never receive "" (the recurring "specified value '' does not conform"
warning, fired at hydration). Both color inputs are now guarded.
- The Roles "contrast" slot IS the on-solid text color: white for dark solids
(most intents), dark only for light ones (risk). White swatches looked invisible
on the light card; add a faint inset ring to .slots .sw so they read as white,
not "no color". Behavior unchanged - only risk needs dark text (correct, APCA).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 31-scale library was a flat list, so the 6 tinted grays (gray/mauve/slate/
sage/olive/sand) looked like near-duplicates. Group it like Radix: Grises (tinted
neutrals) - Colores - Brillantes (light solids) - Metales, with a fallback "Otros"
bucket so no scale is ever dropped. Intro note explains the grays cluster because
they are near-neutral (chroma ~0.01) with a subtle per-accent hue tint. Verified
in-browser: 31 scales, none dropped/duped, 4 group labels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-role override <input type="color"> took value={r.solidHex} directly; an
expression-driven color input warns ("specified value '' does not conform") if it
ever receives an empty string. r.solidHex is always a valid hex in practice, but
guard it (`|| '#000000'`) so the input can never receive '' — belt-and-suspenders
for a warning seen during live HMR editing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Gemini's sharp note: rotating an intent's HUE toward the brand (harmonize)
erodes its meaning — a red stops reading as "error". What coheres a palette is
sharing the chroma + lightness PROFILE, not the hue. New temper(color, reference,
amount) keeps the hue and lerps L+C toward the reference. The demo's intent
cohesion switches harmonize -> temper, and the slider MOVES to the "Roles
canonicos" section (next to the intents, dynamic). Verified in-browser: threat hue
stays 358 (red) at 0% and 40% temper, only chroma/lightness shift; affirm stays
teal. harmonize stays in the engine for brand accents. RFC §6.2 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Canonical intents (red/green/...) can clash next to a brand color. Replace the
harmonize on/off toggle with a slider (0 = pure canonical -> 0.35 strong), default
a SUBTLE 12%: intents lean toward the brand enough to feel cohesive but stay
recognizable (red is still red). Verified: green seed -> threat #e35013 at 12%
(warmer red), pure #e5484d at 0. Also clarified that "La paleta" is the FIXED
library (does not derive from the seed; the seed derives the roles) to resolve the
recurring confusion. RFC §6.2 notes the subtle-default guidance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deriveScheme gives DEFAULTS, not a cage: each role row gets a color input that
PINS that role to the designer's exact color, while the rest keep deriving from
the seed. "auto" un-pins; changing the seed re-derives only the unpinned roles.
Mirrors M3 (custom colors per role) + Radix (pick accent/gray) + the hand-authored
path (grafito maps every role). Verified in-browser: pin secondary=blue +
tertiary=gold while primary/neutral stay derived; auto reverts to derived.
Documented in COLOR_ENGINE_RFC §6.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The seed now re-themes the ENTIRE page, not just the preview: it overrides
--primitive-{role}-* (+ contrast + surface alpha) on .root, reproyecting every
--color-{role}-* and the neutral-driven surface/content/border chrome. Mode-aware
templates (light/dark). New "harmonize intents" toggle nudges the canonical intents
toward the seed's temperature (M3 blend.harmonize); off by default so intents stay
canon. The 31-scale library stays fixed. Verified in-browser: green seed -> canonical
primary / on-solid / topbar all go green; threat stays red until harmonize.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seed color + variant (tonal/vibrant/monochrome) -> deriveScheme -> generateScale
-> live preview of the 5 hierarchy role scales (12 steps each, step 9 ringed) +
their APCA on-solid chips. The full uix.color builder pipeline, reactive and
pure. Verified in-browser: blue seed -> tertiary purple (+60deg), monochrome
collapses to one gray, primary = seed verbatim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Palette (31 scales x 12 steps), the 9 canonical roles with their slots, the
APCA on-solid pick live via uix.color, translucent role surfaces, and the
neutral chrome. Rendered through live CSS custom properties with a light/dark
toggle. Verified in-browser: swatches resolve, risk flips to dark text, modes
invert. Not linked from the /temas index (it is a system demo, not a brand
theme — like /temas/animations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
- Rename theme untitled-ui -> Grafito: route /temas/grafito, theme ids
grafito-light/dark, grafitoConfig, brand/hero/footer/index.
- Palette: Grafito now carries the full 32-scale library (spreads the
framework's 31 Radix scales + brand carbon, overriding the brand-tuned
gray/violet/blue + intents). Each directly usable as --scale-{name}-{step}.
- Roles: hierarchy explicit (primary->carbon, secondary->violet,
tertiary->blue); the 6 intents auto-derive from the palette by convention
(step 9). THEME_SCALES derived from palette keys, labelled by COLOR NAME.
- Demo Color section -> 3 groups (Paleta / Roles jerarquia / Intents
auto-derivados) + scaling control in the topbar.
Verified in browser: 32 distinct scales emitted, intents auto-derive
(affirm=teal, risk=amber, loss=plum), no validation error.
Co-Authored-By: Claude Opus 4.8 <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>
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>
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>
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>
The companion drawer was hard-coded to the right column. Now
`<Words.Drawer side="left">` flips the editor grid so the drawer
takes the start column; the floating overlays (block handle grip,
block inserter "+") automatically render on the OPPOSITE margin so
they don't crash with the drawer's gutter. Default stays `'right'`
so existing consumers see no change.
Wiring:
- Eidos `<Words.Drawer>` gains `side?: 'left' | 'right'` (default
'right'). The value is passthrough'd as `data-side={side}` on the
rendered drawer root so CSS + sibling overlays can read it from
the DOM.
- `words.css`:
- `[data-words]:has([data-words-drawer][data-side='left'])`
flips `grid-template-columns` + `grid-template-areas` so the
drawer column sits at the inline-start.
- `[data-words-drawer][data-side='left']` swaps the visual
seam from `border-inline-start` to `border-inline-end`.
- `[data-words-block-inserter][data-side='left']
[data-words-block-inserter-button]` overrides `left: -28px`
to `right: -28px` so the "+" lands on the block's right
margin.
- Block-handle (`words-block-handle.svelte`): new `detectDrawerSide()`
reads `[data-words-drawer]@data-side` once per measurement. The
grip's `position.left` becomes `rect.right + 4` when drawer is
on the left (was always `rect.left - 28`).
- Block-inserter (`words-block-inserter.svelte`): same `$derived`
read of the drawer's `data-side`, propagated as `data-side` on
the inserter overlay element so the CSS rule above can flip the
"+" button.
- Demo: new `let drawerSide = $state<'left' | 'right'>('right')` +
a chip control "drawer side: right | left". `<Words.Drawer
side={drawerSide} />`.
Verified `npm run check` clean (pre-existing errors only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The companion drawer + new floating overlays (image bar, block handle,
block inserter) assume a horizontal toolbar above the content. The
vertical layout was a CSS-grid two-column repositioning that fights
that geometry and was the cause of the new design breaking when the
user switched to vertical.
`horizontal` is the only orientation now. Cuts:
Morfo (`morfo/components/words.ts`)
- Toolbar + ToolbarGroup parts: `data-orientation` becomes a literal
`'horizontal'` (no longer reads from `propRef('orientation')`).
- Same for the toolbar's `aria-orientation`.
Soma (`soma/components/words/types.ts`, `exports.ts`, provider)
- Removed `WordsToolbarOrientation` type entirely.
- Removed `orientation?` from WordsToolbarProps and WordsToolbarGroupProps.
- Provider opts no longer read or pass through `orientation`.
Eidos (`eidos/components/words/{types,words.svelte,words-toolbar.svelte,words.css}`)
- `WordsProps.toolbarOrientation` removed.
- `<Words.Toolbar>` / `<Words.ToolbarGroup>` no longer accept or
forward `orientation`.
- Six chunks of CSS that targeted `[data-orientation='vertical']`
deleted (toolbar layout, content grid placement, status, find-replace,
link-editor, placeholder).
Demo (`web/routes/uix/components/words/+page.svelte`)
- `orientations` const, `orientation` state, the chip-control UI,
the `toolbarOrientation={orientation}` prop, and the corresponding
line in the generated code snippet all removed.
- Recipe-selector entry for `[data-orientation]` deleted.
- A11y row text simplified.
Verified: `npm run check` clean (only pre-existing 6 errors unrelated);
soma words tests 152/152 pass. Eidos vertical CSS is gone end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the wedge case the user just hit: two atomic blocks back-to-back
(image, table, code) leave no caret position between them, so there's
no way to insert a paragraph by clicking. Now: hover near the seam
between any two top-level blocks and a "+" appears on the left margin
with a faint accent line spanning the seam. Click "+" → a new
paragraph is dropped at that index and the caret lands at the start
so the user starts typing immediately.
Same overlay pattern as the block-handle: single component watches
mousemove inside `[data-words-content]`, computes the boundaries of
all top-level blocks (plus "before first" and "after last" seams),
snaps to the closest seam within 18px of the cursor's Y, and renders
a fixed-positioned strip with the "+" button and accent line.
New engine surface:
- `insertBlockAt(state, blockIndex, block)` in `operations.ts` —
splices the block in, normalizes, drops caret at `[blockIndex, 0]`.
`blockIndex === children.length` appends to the tail.
- `insertBlock` case added to `WordsCommand` + dispatcher in
`commands.ts`. The command surface keeps `block` as a loose JSON
record so consumers don't need to import `WordsBlock` — the
reducer casts on the boundary and `normalizeDocument` validates.
Verified in browser: hover at the seam between code (index 3) and
image (index 4) → "+" appears at the left margin → click → doc
becomes 0:heading | 1:paragraph | 2:quote | 3:code | 4:paragraph |
5:image; caret in the new paragraph ready for input.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notion-style :: grip rendered to the left of the hovered block via a
single overlay component (`Words.BlockHandle`). Click reveals a small
popover with Move up / Move down / Duplicate / Delete. Each fires via
`snippet.applyCommand` so the discrete imperatives bypass the sema
runtime (no extra event noise on top of what the operation already
implies for downstream consumers).
Backed by three new generic operations on the engine:
- `moveBlockAt(state, blockIndex, direction)` — swap with neighbour.
- `duplicateBlockAt(state, blockIndex)` — JSON-clone insert-after.
- `deleteBlockAt(state, blockIndex)` — already existed; now wired
to the menu's Delete entry.
The three operations are exposed as `moveBlock` / `duplicateBlock` /
`deleteBlock` commands in `WordsCommand` so any future block-toolbar
can reuse them without per-feature engine plumbing.
Also: add `applyCommand` / `selectAtomicBlock` / `clearSelectedBlock`
to `WordsProviderSnippetProps` — they were emitted by the provider
but missing from the type. Float bar + block handle both consume
`snippet.applyCommand`.
Verified in browser: hover over a block renders the grip (24×24,
opacity 0.45 → 1 on hover, z-index popover); click opens menu with
all 4 actions correctly labelled; Duplicate on the heading at
index 0 produced a new heading at index 1 and pushed the rest down.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quick-action toolbar that hovers above the selected image, mirroring
the pattern in Notion / Linear / Figma. Lets the user align L/C/R,
delete, or dismiss the image without having to expand the Image panel
of the drawer.
New eidos component (spike — no morfo/soma yet, refactor canon if it
sticks): src/uix/eidos/components/words/words-image-float-bar.svelte
- Position: fixed, calculated from the selected figure's
getBoundingClientRect on every selection / scroll / resize /
figure size change (ResizeObserver + window listeners).
- Visible only when snippet.selectedBlockIndex points to an image
block; null position otherwise so the component reflows away.
- 5 actions: align left / align center / align right / delete /
dismiss. Divider between layout and destructive actions.
- Calls snippet.applyCommand for align / delete instead of running
through runCommand string-name path (commands target an
arbitrary block index, no string lookup needed).
Engine — new generic commands so feature panels stop reaching for
hacky JSON re-imports to mutate single blocks:
- WordsCommand union gains 'updateBlock' (blockIndex + patch)
and 'deleteBlock' (blockIndex).
- updateBlockAt(state, idx, patch) — shallow-merge patch into the
block, normalize doc, clamp selection. No-op when patch matches
current values.
- deleteBlockAt(state, idx) — remove block, fall back to empty
paragraph when doc would be empty, place caret on the next block.
- applyCommand exposed on provider snippetProps so any consumer
(eidos panels, future block toolbars, tests) can drive arbitrary
WordsCommand without needing a string-name route.
CSS additions: float bar styling (position fixed, accent border,
shadow), action chips with hover + active + risk + ghost variants,
divider.
Demo: <Words.ImageFloatBar snippet={snippetProps} /> mounted next to
<Words.Drawer />.
Verified visually: clicking the demo image now (a) outlines the image
(COLOR-1 highlight), (b) opens the drawer in IMAGE mode, (c) shows
the float bar hovering above the figure with "align center" active
(default), trash + dismiss available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the architectural gap from the DRAWER spike: the companion
drawer now follows the same morfo/soma/eidos pattern as every other
Words part (Toolbar/BubbleMenu/SlashMenu/LinkEditor/Status/etc.).
DRAWER-CANON-1 — morfo + soma:
- wordsMorfo gains a 'Drawer' part (kebab 'drawer', archetype 'group',
defaultElement 'aside', role 'complementary'). Declares data-words-
drawer-mode (default/format/image/table/code/list), data-open,
data-disabled, aria-label. New event 'commit-toggle-drawer' fires
on collapse/expand for sema/telemetry hooks.
- New WordsDrawerProvider in words-provider.svelte: owns `open`
bindable state, derives `mode` from parent provider's currentBlock
+ selection (same logic as the spike, now in soma where it
belongs), exposes snippetProps {mode, open, disabled, snippet,
toggle, setOpen}. `toggle()` and `setOpen()` emit the event.
- WordsDrawerProps + WordsDrawerSnippetProps + WordsDrawerMode types
added to types.ts and re-exported via exports.ts.
- soma/components/words/components/words-drawer.svelte (the soma
component) mounts the provider and forwards merged props to either
the `child` snippet or the default <aside> render.
DRAWER-CANON-2 — eidos refactor:
- eidos words-drawer.svelte rewritten as a consumer of
<Words.Drawer>. The shell (aside + header + collapse button) and
the per-mode body now live inside the children snippet that
receives the soma's drawer snippet props. Mode derivation removed
from eidos (it's now in soma). Local open $state removed (now
bindable via the soma component).
- Demo +page.svelte: `<Words.Drawer snippet={snippetProps} />`
collapses to plain `<Words.Drawer />` — the soma provider hands the
parent snippet down internally.
DRAWER-CANON-3 — verify:
- 214/214 tests pass in soma+morfo scope (no regressions).
- npx tsc --noEmit clean for words area.
- Visual verification: drawer still renders with DOCUMENT/OUTLINE/
STATS/SUGGESTIONS in default mode; context-switching to table /
code / list / format still works; aside element now carries
role='complementary', aria-label from morfo translationRef,
data-words-drawer-mode reflecting current panel.
The eidos drawer now passes the same canon checks as every other
Words part: morfo declares its contract, soma owns the state, eidos
only paints. The schema validator can verify the drawer's attrs, the
eidos lint can check selector usage, sema can react to the toggle
event.
Demo header chips reflect the new contract: PARTS 19→20, EVENTS
11→12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A radical disposition shift: instead of N fragmented popovers (bubble
menu + slash menu + link editor + heading picker + language picker +
table popover + image controls) we mount ONE contextual right-rail
that changes its panel based on what the user is editing.
Spike-1 surface (this commit) ships:
- New eidos component <Words.Drawer> (words-drawer.svelte) — a
collapsible right sidebar with mode detection derived from snippet
props (currentBlock + selection). 6 modes mapped: default | format
| image | table | code | list.
- Default panel (visible when no contextual mode active):
- OUTLINE — flat list of h1/h2/h3 headings, indented by level
- STATS — word count + char count + reading time (200wpm)
- SUGGESTIONS — writer-coaching warnings derived from the document
model: missing alt text, long paragraphs (>600 chars), heading
skip patterns (h3 without h2 above). Empty state shows "Nothing
flagged" in affirm color.
- Contextual panels (format/image/table/code/list) render a
placeholder "coming soon" message — implemented in DRAWER-2.
- Eidos CSS:
- Provider host gains a grid-template-areas layout when a drawer is
present: 'toolbar drawer / content drawer / status drawer'.
Existing children (toolbar/content/placeholder/status) get
explicit grid-area assignments.
- Drawer collapses to 2.25rem (just the chevron) when closed.
16rem default open.
- Outline items, stats grid, warning list each have their own
discrete styling.
Demo (+page.svelte):
- New `showDrawer` state (default true).
- Children snippet refactored to capture full snippetProps via @const
destructure (kept selectedLink working).
- <Words.Drawer snippet={snippetProps} /> mounted inside the children
block alongside the other parts.
Visual verification:
- Drawer renders as a dedicated right column with the editor content
on the left, no layout overlap.
- DOCUMENT header + OUTLINE shows "H1 ActiveUIX Words", STATS shows
73 words / 508 chars / 1 min reading, SUGGESTIONS shows "Nothing
flagged" (sample doc has no warnings).
- Collapse button toggles open/closed via animated inline-size
transition.
Next:
- DRAWER-2: format/image/table/code/list contextual panels wired to
runCommand.
- DRAWER-3: polish (mobile drawer-as-bottom-sheet, transitions, A/B
toggle in demo to compare classic toolbar vs companion drawer).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Sample document gains an image block between the code block and the
table, showing figure/img/figcaption render in action.
- readPlainText demo helper gets an 'image' branch returning alt text
(was crashing the SSR with 'Cannot read properties of undefined
(reading map)' because image has no .children).
Demo screenshot confirms:
- Image renders inside a figure with border + padding
- Caption shows centered + italic below
- img max-inline-size cap keeps the image inside the editor frame
- Word/char count in status bar reflects alt + caption text
F3 MVP visually verified end-to-end:
- Block parses from markdown / HTML in the document model
- Renders correctly as figure/img/figcaption in the contenteditable
- Eidos styling (border, caption italic, alignment data-attr) applies
- Status bar counts include image alt text
Pending (UX surfaces, deferred to a follow-up session):
- F3.7 slash menu /image entry (open a URL prompt)
- F3.8 paste/drop handler with onUploadImage callback for file uploads
- F3.7/F3.8 require new svelte components (insert dialog, drop overlay)
and provider event handlers — they're isolatable from this commit's
surface and are the next sub-sprint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: 'Optional editor · 64 words · none'
After: 'Optional · 64 words · 453 chars · none' (no link, no commit)
'Required · 64 words · 453 chars · range · https://...' (selection + link)
'Optional · 64 words · 453 chars · none · save' (after commit)
- Drops the redundant 'editor' suffix; the surface is already an editor.
- Adds char count from plainText.length — useful for size limits.
- Adds the existing selectionState (none/collapsed/range) inline.
- Splits link/commit into separate optional segments instead of
collapsing them into one slot.
Demo-only change in +page.svelte. The eidos Words.Status wrapper
still passes children through unchanged — enriching the eidos default
status snippet itself would require Words provider to expose
wordCount/charCount, which is a larger refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>