astra
alpha-0.1-background
alpha-0.1-dir-prefs
alpha-0.1-sec-dom
menubar-v4-safe
active-uix
morfo-runtime
morfo-driven-soma
semantuix
glm-5
main
sium-v1.0
${ noResults }
771 Commits (eb946148cd214d2cb03cf9e317913ca6ff398322)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
eb946148cd |
fix(words): column inserter — caret lands in new block, typing replaces stub
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>
|
4 months ago |
|
|
bcad6647a7 |
feat(words): column inserter + provider DOM-selection sync after commands
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>
|
4 months ago |
|
|
da8d03085b |
feat(words): inspector ColorPicker integration + 4 root-cause picker fixes
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>
|
4 months ago |
|
|
73636d07a9 |
refactor(words)!: F1 — rework engine to base-Block model, drop v2 + sema
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> |
4 months ago |
|
|
1bb16ff636 |
feat(words): border-light chrome restyle (declutter)
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> |
4 months ago |
|
|
8afe2728bb |
feat(words): comfortable reading scale for the content surface
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> |
4 months ago |
|
|
9e13d0083e |
fix(words): refine the left margin band per feedback
- 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> |
4 months ago |
|
|
540c35e679 |
feat(words): gutter rework — drop "+", move insert into drag menu, silver band
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>
|
4 months ago |
|
|
005239c732 |
feat(words): inline heading-level control in the Block drawer panel
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
(
|
4 months ago |
|
|
cc4412b8e9 |
feat(words): complete image visual panel (R5 / POLISH-1b)
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> |
4 months ago |
|
|
4a0661a53b |
chore(words): sweep D2 orphans from eidos CSS + README
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> |
4 months ago |
|
|
10ed029b91 |
refactor(words)!: remove V1 engine — V2 is now the sole engine
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>
|
4 months ago |
|
|
5450d5f717 |
fix(words): gutter redesign — rail out, handle at block lower-left, "+" at block bottom-center
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>
|
4 months ago |
|
|
74e54384ea |
feat(words): R4A.1 — foundation V2 ops (types + normalize + setBlockVisual + 5 block ops) + 31 tests
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>
|
4 months ago |
|
|
194ca208f9 |
feat(words): R3 — render-v2 + HTML + MD serializers + 56 tests
R3 closes the pure-V2 output path. Take a WordsDocumentV2 and turn it
into either an abstract render tree (for Eidos/React/Vue/vanilla to
walk), an HTML string with inline styles (roundtrip-friendly), or a
Markdown string (lossy by design — visual.* dropped).
engine/render-v2.ts (~370 lines):
- Pure renderWordsDocumentV2(doc) → readonly WordsRenderElementV2[].
- WordsRenderNodeV2 is a superset of V1's WordsRenderNode (adds 'hr',
'div', 'section' tags for divider / callout). V1 stays untouched.
- Per-block dispatch for all 9 V2 types (paragraph/heading/quote/code/
list/table/image/divider/callout). Same node shape as V1 — Svelte
walker doesn't need changes.
- visualToStyle(visual) helper exports the central translation from
BlockVisual props to CSS declarations. Numbers get 'px', hex strings
pass through. borderColor without borderWidth defaults to 1px solid.
- Mark wrapping: parametric color/background marks combine into one
<span style="...">; boolean marks stack via <code>/<span> with
data-words-mark attr + inline style fallback.
- Callout renders as <div role="note" data-words-callout-intent="X">
with optional title block + nested children.
engine/serialize-html-v2.ts (~120 lines):
- Pure serializeHtmlV2(doc, { pretty? }) → string.
- Walks renderWordsDocumentV2 output and emits escaped HTML.
- Properly escapes &, <, > in text; &, <, >, " in attribute values.
- Void elements (img/hr/br/input/meta/link) emit self-closing form.
- Pretty mode (opt-in) indents nested elements with 2 spaces.
- Roundtrip-friendly: inline style="..." preserves visual.* lossless.
engine/serialize-markdown-v2.ts (~190 lines):
- Pure serializeMarkdownV2(doc) → string.
- LOSSY by design (D-Q2): visual.* dropped entirely; color/background
marks dropped (no MD vocabulary); underline mark dropped.
- Preserves structural semantics: headings (# ## ###), quotes (>),
code blocks (``` with language fence), lists (- / 1. / - [x] - [ ]
GFM task lists), tables (GFM with :--- / :---: / ---: alignment),
images (), dividers (---), callouts (GFM
admonitions via INTENT_TO_GFM_ADMONITION mapping).
- INTENT_TO_GFM_ADMONITION exported: P7 boundary mapping —
neutral→NOTE, affirm→TIP, fulfill→IMPORTANT, risk→WARNING,
threat→CAUTION, loss→CAUTION.
- Mark order in output: bold(**) outer → italic(*) → strike(~~) →
code(`) inner.
- Cells escape | as \| to avoid GFM table parser confusion.
Tests (56 new):
- render-v2.test.ts (23 tests): basic blocks, visual sidecar with
exact style strings, table headerRow/headerCol semantics, marks
rendering, path encoding, visualToStyle direct.
- serialize-html-v2.test.ts (12 tests): paragraph, escape semantics,
void elements, inline styles, callout role attr, pretty mode,
table th/td.
- serialize-markdown-v2.test.ts (21 tests): every block type, marks,
visual.* dropped (lossy), callout intent → admonition tag mapping
(all 6 SemaIntent values), link with title, table cell | escaping.
Important deferral: POLISH-1b (image radius/shadow/border slider)
needs the provider to write block.visual.*. Provider is V1 internally.
Deferred to R5 (after R4 migrates provider to V2). Pure render path
is ready when provider catches up.
Verification: 226/226 engine tests pass (85 V1 + 50 V2 validator +
35 V2 migrator/parity + 56 V2 render/serializers). `npm run check`:
0 errors. V1 model + render path UNTOUCHED — V2 lives in parallel.
Next: R4 — provider V2 migration. Operations rewritten over
WordsBlockV2. Runtime imageStatus sidecar wired at mount. Public API
preserved via adapter during transition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
92f0432eb0 |
docs(continue): R2 cerrado — migrator + parity check + 35 tests
|
4 months ago |
|
|
6c70699ff7 |
feat(words): R2 — V1→V2 migrator + sema parity check + 35 tests
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>
|
4 months ago |
|
|
d18b32dd93 |
docs(continue): update with R1 V2 architecture status + roadmap
R1 closed → audit + types-v2 + validator + 50 tests. Doctrine signed (P1-P8 + D-SEM-1/2/3 + Q1-Q6). Roadmap R2-R5 listed for next sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
9b13e3cf23 |
feat(words): R1 — V2 architecture proposal + types-v2 + validator + 50 tests
Architectural refactor proposal landed for review. R1 closes the
audit + type design + validator phase. V1 stays untouched in parallel.
ARCHITECTURE_PROPOSAL.md (~500 lines):
- 8 principles signed (P1 content↔design strict, P2 raw values only,
P3 renderer translates, P4 runtime state out, P5 framework-neutral
engine, P6 single source of truth, P7 sema intents canonical for
evaluative vocabulary, P8 per-property entry rules).
- D-SEM-1/2/3 + Q1-Q6 all signed with recommendations accepted by
user. Notable: CalloutBlock.intent uses SemaIntent canonical
(neutral|affirm|fulfill|risk|threat|loss), NOT web admonition
vocabulary (warning|info|danger|etc.) — those live as UI labels +
Markdown serializer mappings ONLY.
engine/types-v2.ts (~320 lines):
- WordsDocumentV2 with version '2.0.0'.
- BlockBase with optional id (autogen at load) + optional visual
sidecar.
- BlockVisual with RAW values only (numbers in px, hex color
strings). Per-type Pick<> subsets: ParagraphVisual / HeadingVisual
/ QuoteVisual / CodeVisual / ListVisual / TableVisual / ImageVisual
/ DividerVisual / CalloutVisual.
- 9 block types: paragraph, heading, quote, code, list, table,
image PLUS new divider (semantic <hr>) and callout (with
WordsEvalIntent + nestable block children).
- Marks refactored from V1 template-literal `color:${string}` to
structured `{type:'color'|'background', value:'#hex'}`. Type-safe.
- Image upload status REMOVED from the content model (P4): the
WordsImageBlockV2 no longer has `status`; it goes to runtime
sidecar `provider.runtime.imageStatus: Map<blockId, status>`.
- Table V2 drops `striped` + `compact` (presentation tokens). Adds
`headerRow` + `headerCol` (semantic accessibility). Per-cell and
per-row `visual` allowed (background for zebra; cell padding).
- Cell `tone: 'muted'|'accent'` REMOVED (was a design system token).
Replace with cell.visual.background hex if user wants a tinted
cell.
- WordsEvalIntent declared locally in engine to keep engine
framework-neutral. Parity with $uix/sema asserted at boot
(TODO in R2).
engine/validate-v2.ts (~520 lines):
- Pure function validateWordsDocument(input) → ValidationResult.
- 11 error codes: invalid-version, invalid-block-type,
invalid-block-shape, invalid-children, invalid-visual-key,
invalid-visual-value, invalid-mark, invalid-href,
invalid-eval-intent, invalid-enum-value, invalid-number,
duplicate-id, invalid-text-content, invalid-link-children.
- Enforces P2: hex regex on color fields, finite numbers on
dimension fields, rejects tokens / classes / var() refs / non-
canonical mark shapes.
- Enforces P8: per-type Pick<> at runtime via
WORDS_VISUAL_KEYS_PER_TYPE whitelist.
- Enforces P7: callout.intent must be one of the 6 canonical sema
intents; rejects web vocabulary (`warning`/`info`/etc.).
- Each error carries a JSON-pointer path (e.g.
/children/1/visual/background).
engine/validate-v2.test.ts (50 tests):
- Top-level shape, block type registry, marks (boolean + structured
+ V1 reject + token reject), visual properties (allowed +
rejected tokens/classes/var()/non-numeric + per-type Pick<>),
id uniqueness, heading levels, list kind + indent, code text-only
children, image src + width, callout SemaIntent + reject of web
vocabulary + nested blocks, divider, quote.cite URL parseability,
link nesting + URL parseability, table headerRow/headerCol +
per-cell visual whitelist + per-row visual.
Verification: 135/135 engine tests pass (85 V1 + 50 V2). V1
untouched. npm run check: 0 errors, 26 warnings (pre-existing).
Next: R2 — migrator V1→V2, runtime image-status sidecar, refactor
provider to consume V2 internally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
d126e16e65 |
fix(words): silence canvas truly, drag for list/table, no overlay hovers + day-end docs
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> |
4 months ago |
|
|
125c62cc20 |
fix(words): silence canvas, silver rail, direct undo/redo, popover widths, Enter empty
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>
|
4 months ago |
|
|
ce95473261 |
feat(words): internal scroll for the content area + capture-phase scroll sync
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>
|
4 months ago |
|
|
c4be6dbe1b |
fix(words): silence body event spam + slim top toolbar + reposition handle/inserter
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> |
4 months ago |
|
|
c250f7d790 |
fix(words): close 12 type-drift errors from F2/F3/COLOR sprints
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>
|
4 months ago |
|
|
d2f184da44 |
refactor(toggle-group): item is structurally a Toggle (zero derivation duplication)
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> |
4 months ago |
|
|
949cc88d75 |
fix(codex-audit): narrow content non-null in block-handle + inserter
`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> |
4 months ago |
|
|
d767d88c03 |
fix(codex-audit): close P0 + tokenize drift introduced by Words sprint
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>
|
4 months ago |
|
|
08ae867caf |
fix(words): grip lands in the rail (rem→px) + drag preview is the block
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>
|
4 months ago |
|
|
ccd9d3e0ab |
fix(words): "+" inserter only shows in the rail gap between blocks
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> |
4 months ago |
|
|
6bcbd8a983 |
refactor(words): rail is now a real column outside the editor box
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>
|
4 months ago |
|
|
33b3a580b7 |
feat(words): visible dotted band in the grip rail + trim to 1rem
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> |
4 months ago |
|
|
0edb7be26c |
fix(words): trim the gutter rail to 1.5rem (~24px)
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> |
4 months ago |
|
|
5a3029e589 |
feat(words): real gutter rail for the block-handle + inserter
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> |
4 months ago |
|
|
377168550c |
feat(words): drawer follows emptiness — Insert when empty, Inspect when filled
User-clarified rule for the right drawer: when the current block is empty, the drawer shows ONLY the constructors (Insert panel) — no point in showing format/block-scope options on a blank line. When the block has any content, the inspector for that content type appears (paragraph + text-align, heading, image, code, etc.). Implementation: - New helper `isEmptyEditableTextBlock(block)` next to the slash command catalog. It returns true for paragraph / heading / quote / code-without-language whose single text child is the empty string. Atomic blocks (image / table / list) never qualify — they always carry structure. - `WordsDrawerProvider.modes` derivation gates the scope-stack computation behind `!isEmptyTextBlock(currentNode)`. Empty text blocks fall through directly to `['default']` (just Insert); blocks with content build the scope stack as before (format / block / cell / row / table / list-item / list) and Insert collapses at the bottom of the stack. - Atomic blocks (image / code with language) still push their scope unconditionally because their "content" lives in attributes, not children. Before: clicking "+" → empty paragraph inserted → caret on it → drawer showed `['block', 'default']` with Block expanded (text-align controls for an empty line — useless), Insert collapsed below. After: clicking "+" → empty paragraph inserted → caret on it → drawer shows `['default']` only (Insert expanded). As soon as the user types a character, Block panel appears above with Insert collapsed underneath. 152/152 soma words tests still pass; `npm run check` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
2bf6aef035 |
fix(words): eliminate block-handle flicker in the gutter + bump hit area
Two issues with the block-handle grip the user just hit:
1. Same flicker pattern the inserter had before its fix: the
handle's listener was on `[data-words-content]`, and the grip
sits OUTSIDE that box in the gutter. The moment the cursor
moved toward the grip, `mouseleave` fired on content → grip
vanished → cursor back inside content → grip reappeared → loop.
2. The 24×24 hit area sitting in a 28-px-wide gutter was hard to
land, especially when the gutter also got the flicker treatment.
Fixes:
- **Document-level mousemove** (was: content-level). Bounds are
checked manually against the content rect with an EXPANDED buffer
on the side where the grip lives (56 px) and a tight 8 px on the
opposite. Drawer side is read from the DOM so the bounds flip
automatically with `<Words.Drawer side="left">`.
- **Early-return when the cursor is over the handle or its menu**
(`target.closest('[data-words-block-handle]' | '...-menu')`) so
hovering the grip itself never recomputes/hides the state.
- **Geometric fallback** when the cursor is in the gutter and
`findBlockElement(e.target)` would return null: walk the top-level
blocks and pick the one whose vertical band contains `e.clientY`.
Keeps the grip pinned to the right block while the user is
moving toward it.
- **Mouseleave listener removed entirely** — the bounds check above
handles the "cursor truly left the editor area" case without the
flicker-prone per-element leave events.
- **Hit area bumped to 28×28** (`1.75rem`) so it's comfortably above
WCAG 2.5.5's 24×24 minimum and easier to grab in the narrow
gutter. Visible icon stays at `size="sm"`. Centering math updated
(-14 instead of -12); horizontal offset bumped from `-28` to
`-32` to keep a small gap between grip and text descenders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
4cd54e2111 |
feat(words): drawer `side` prop (left | right) + overlays auto-mirror
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>
|
4 months ago |
|
|
3128b5f13c |
refactor(words): drawer reinvented as Insert ↔ Inspect
The right drawer's default panel is reframed: instead of an outline +
stats overview that doesn't compose well with the editing surface,
the default mode now shows an "Insert" catalogue of block constructors
(Paragraph, Headings 1-3, Quote, Code, three list types, Image,
Table). When there's contextual scope (image / table / code / format),
those panels stack above with Insert collapsed at the bottom as a
secondary affordance. Same Figma / Linear "right inspector" pattern
but Notion-aware on the empty-context default.
Architecture:
- New `WordsDrawerMode` value: `'document'` (outline + stats +
suggestions, opt-in via a header toggle). The old default content
moved there verbatim. `'default'` was renamed (label-side) to
"Insert".
- `WordsDrawerProvider.modes` now ALWAYS appends `'default'` to the
end of the stack. So:
- No selection → `['default']`
- Caret on paragraph → `['block', 'default']`
- Inside table cell → `['cell', 'row', 'table', 'default']`
- With doc panel toggled on → `['document', ...above]`
Eidos auto-collapses the trailing `'default'` whenever any scope
is also active, so it sits as a footer chip the user can expand
to drop a new block without leaving the current inspector.
- `documentPanelOpen = $state(false)` + `toggleDocumentPanel()`
method on the provider. Exposed in the drawer snippet props as
`documentPanelOpen` / `toggleDocumentPanel()`.
- New provider method `insertBlockOfType(id: WordsSlashCommandId)`:
reuses the slash-id space (paragraph / heading-* / quote /
code-block / *-list / image / table) but with different semantics:
- Empty paragraph context → `setBlock` (transform in-place,
no stray blank).
- Non-empty → insert a fresh block AFTER current via
`insertBlock`. Caret lands at start of the new block.
- Image / table → existing `insertImage` / `insertTable`
commands (URL prompt still inline).
Exposed in `ProviderSnippetProps` so the Insert panel buttons
call it directly without going through the slash menu state.
Eidos panels:
- `insertPanel` snippet: a 2-column grid of `[data-words-drawer-
insert-item]` buttons, each with icon + label. Click →
`s.insertBlockOfType(item.id)`.
- `documentPanel` snippet: outline + stats + suggestions
(verbatim from the old default).
- Header gains a circular `FileText` toggle on the right
(`[data-words-drawer-document-toggle]`) — `data-active` when
doc panel is in the stack. Same hover affordance as the rest of
the drawer header.
CSS additions:
- `[data-words-drawer-insert]` grid layout.
- `[data-words-drawer-insert-item]` button styling — neutral
border, accent on hover.
- `[data-words-drawer-document-toggle]` header chip with active
state.
Verified: 152/152 soma words tests pass. `npm run check` clean
(only pre-existing errors). Browser smoke-test confirmed clicking
"Heading 1" with caret on the image block inserts a new heading
after the image and switches drawer to Block scope, Insert collapsed
below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
71c24dca9b |
refactor(words): drop vertical toolbar orientation
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>
|
4 months ago |
|
|
a62b23538d |
feat(words): HTML5 drag-drop block reorder + keyboard grab on grip
The block-handle grip is now actually grabbable. Native HTML5 drag:
mousedown + drag → dragstart fires → wordsDragState.sourceIndex is
set → the BlockInserter switches to drop-target mode (line goes
prominent, "+" hides) → dragover snaps to the nearest seam → drop
commits `moveBlockTo({fromIndex, toIndex})`.
Keyboard equivalent: focus the grip → Space toggles "grabbed" mode
(grip lights up accent-filled, aria-pressed) → ArrowUp / ArrowDown
swaps with the neighbour (via the existing moveBlock command, so
each press is committed individually) → Space or Escape exits.
Architecture:
- `words-drag-state.svelte.ts` — module-level `$state` shared between
handle (writer) and inserter (reader). Lives in eidos because this
is a visual interaction pattern, not a soma/morfo contract.
- `moveBlockToAt(state, from, to)` in operations.ts — splice out, then
splice in at the post-removal destination. Normalizes + drops caret
at the moved block's new position.
- `moveBlockTo` case in commands.ts. The existing `moveBlock`
(swap-with-neighbor) stays because the click-menu uses it.
- BlockInserter dragover/drop handlers reuse `findSeamForCursor` —
same seam math as the regular insert hover, so the drop indicator
lands exactly where the "+" would have. `seam.insertIndex >
fromIndex ? -1` correction accounts for the source's removal
shifting subsequent indices down by one.
CSS:
- `[data-words-block-inserter][data-mode='drop']` makes the line solid
+ thicker + with an outer glow during drag, and hides the "+"
button entirely (the line IS the drop indicator).
- `[data-words-block-handle][data-grabbed]` paints the grip accent-
filled with a focus ring for keyboard-grab state.
aria: `aria-pressed={keyboardGrabbed}`, descriptive aria-label on the
grip mentioning Space + arrows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
305fd70129 |
fix(words): eliminate flicker on block inserter "+" button
The previous pass listened to mousemove ON the content element and cleared the seam on mouseleave. The "+" button lives at left:-28px of the content (in the gutter), so the moment the cursor reached the button it triggered mouseleave on content → seam cleared → button vanished → cursor was over content again → mousemove → button shown → loop. That was the constant flicker. Three fixes, smallest to largest: 1. **Listen on document, not content.** A single document-level mousemove handler with manual bounds checking. No more mouseleave/mouseout firing as the cursor crosses element edges. 2. **Hold steady when over the inserter itself.** If `e.target` is under `[data-words-block-inserter]`, return early — don't recompute or clear the seam. The button can stay reachable forever. 3. **Memoize the seam.** Reuse the existing Seam object whenever the snap target (`insertIndex`) is unchanged. Cursor jitter within the same snap window no longer triggers re-renders. Bounds-check buffer: 48px to the left (so the "+" button at -28 is safely inside the keep-alive zone), 16px on the right, 12px top/bottom. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
9b1a964c69 |
feat(words): "+ between blocks" hover-zone inserter (POLISH-2b)
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> |
4 months ago |
|
|
e6caf308b0 |
refactor(words): refine drawer Editable — hover pencil + textarea + icon-only controls
User feedback on the previous DRAWER-EDITABLE pass: the "Edit / Save /
Cancel" labeled buttons were too heavy. The reference design is the
Notion / Linear inline-edit pattern — resting state shows only the
preview text, a small pencil reveals on hover, click opens a textarea
with compact icon-only ✓ / ✕ controls beneath.
Drawer refactor:
- `activationMode="none"` + `submitMode="none"` — only the explicit
triggers drive state. Removes accidental commits on blur / Enter
(Enter must be a newline in a textarea anyway).
- `Editable.Input` swapped to a textarea via the `child` snippet:
`{#snippet child({ props })}<textarea {...props} rows={2}></textarea>`.
ALT and CAPTION can run long; a single-line input was wrong.
- `EditTrigger` / `SubmitTrigger` / `CancelTrigger` now hold only an
icon (Pencil / Check / X) with `decorative` flag + `aria-label` on
the trigger button for screen readers.
- Wrapper attribute `data-words-drawer-editable` scopes all the
custom CSS to these two Editables — other Editable instances in
the app keep the canonical wrapper look.
CSS (`words.css`, scoped to `[data-editable][data-words-drawer-editable]`):
- Pencil hidden by default (opacity 0); revealed on `:hover` /
`:focus-within` of the editable row.
- Edit mode flips layout to column: textarea fills the row, the
Control bar sits beneath, right-aligned.
- Textarea has a real border + focus ring (was inheriting the ghost
variant's transparent treatment, looked invisible against the drawer).
- Submit chip is filled accent (✓ stands out), Cancel chip is
outlined neutral. Both 28×28 square.
- Preview placeholder ("Describe the image…") is italic + muted.
Verified in browser:
- Hover over ALT row → pencil appears at the right (opacity 0.7)
- Click pencil → textarea visible with current value preselected
- Below: purple ✓ + outlined ✕
- Type new value → click ✓ → `<img alt="...">` in the editor and the
drawer preview both update; mode returns to preview.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
ffe2c56f42 |
feat(words): use Editable for ALT/CAPTION in image drawer panel
User-suggested polish: replace the read-only `<p>` previews for the
image alt and caption fields with the canonical Editable component.
Same inline-edit shape used in the dedicated Editable demo — preview
text with a small "Edit" trigger that swaps to an input + Save/Cancel
on activation. Closer to the Notion/Figma/Linear interaction the user
expects out of a sidebar property panel.
Wiring:
- `imageBlockAtCaret` now returns `{ index, block }` so the panel
can dispatch `applyCommand({ type:'updateBlock', blockIndex, patch })`
with the right index instead of re-walking the selection.
- ALT: `onValueCommit={(next) => s.applyCommand({ type:'updateBlock',
blockIndex, patch: { alt: next } })}`. `placeholder` "Describe the
image…" surfaces when alt is empty.
- CAPTION: same shape; `patch: { caption: next || undefined }` so
clearing the field drops the field entirely from the block.
- `size="sm" variant="ghost" color="primary"` chosen to fit the
drawer's compact density without competing with the image preview.
- Status (pending/error) stays as the existing readonly tag — not
user-editable.
Also: add `selectedBlockIndex: number | undefined` to
`WordsProviderSnippetProps`. The provider was already emitting it
(consumed by `imageBlockAtCaret` and the image float bar) but the
type definition didn't list it.
Verified in browser: clicking the image enters image mode in the
drawer; clicking ALT's Edit trigger reveals the input; typing a new
value and clicking Save propagated end-to-end — `<img alt="…">` in
the editor and the preview both updated. Same flow for caption.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
66225404e1 |
feat(words): drag handle + block action menu per block (POLISH-2a)
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> |
4 months ago |
|
|
5acee94777 |
fix(words): color picker uses applyCommand (no sema) + onchange instead of oninput
Two related fixes to the color picker UX:
1. Swatches now call applyCommand({type:'toggleMark', mark:'color:#x'})
directly instead of runCommand('color:#x'). runCommand emits the
'commit-set-format' sema event (which fires sound + visual feedback
on the editor surface). For discrete color picks the sema event is
noise — applyCommand bypasses the trigger and just mutates the doc.
2. Native <input type=color> uses `onchange` instead of `oninput`.
`oninput` fires continuously while the user drags the OS color
picker — each fire was queueing a separate command + sema event,
producing a horror cascade of sound / visual flashes on every
pixel of the slider drag. `onchange` only fires when the user
releases / commits the picker (closes the OS popup), so we get
exactly one command per intent.
Same pattern applied to both Foreground and Background pickers
+ the clear (×) buttons.
Sema events are still emitted by the rest of the drawer's action
chips (run via runCommand) because those are user-facing intent
actions (Bold, Italic, etc.) where the perceptual feedback aligns
with the user's notion of "I just did a thing". Color slider drags
are NOT in that category — they're continuous parameter tuning.
Verified: clicking the red swatch on selected "ActiveUI" produces
the expected red <span> in the document AND the trace shows no new
commit-set-format event (was previously firing on every swatch
click, drowning audio + animation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
5c10ab74b3 |
feat(words): floating image control bar + generic updateBlock/deleteBlock commands (POLISH-1a)
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>
|
4 months ago |
|
|
c1c3cd9404 |
feat(words): parametric color marks — foreground + background pickers (COLOR-3)
Marks now support parametric values via colon syntax:
- color:#hex — foreground text color
- bgcolor:#hex — highlight / background color
Existing boolean marks (bold/italic/underline/strike/code) are
unchanged. Empty value (color:, bgcolor:) clears that slot.
Engine
- WordsMark union extended with `color:\${string}` and
`bgcolor:\${string}` template literal variants.
- isWordsMark accepts the parametric form (validates against #hex
3/4/6/8 digits).
- normalizeMarks de-duplicates by prefix for parametric marks (only
one color, one bgcolor per inline survives).
- toggleMarkSet REPLACES same-prefix entries instead of toggling
them; empty value strips the slot.
- New helpers markValue() and markPrefix() for reading parametric
data.
Serializers
- HTML out: wrapMarks collects color/bgcolor into a single
`<span style="color:..;background-color:..">` instead of nested
spans, then wraps with the boolean-mark tags.
- HTML in: not extended this pass — round-trip via style attr is a
follow-up. The marks survive in the document model + render OK.
- Markdown out: silently drops parametric marks (no MD syntax for
inline color; HTML round-trip is the supported path).
DOM render
- createWordsTextAttrs splits marks into booleans (→
data-words-marks) and parametrics (→ inline `style` attr) so the
contenteditable paints the colors directly.
Drawer Format panel
- New "Foreground" + "Background" sections with:
- 10-swatch palette (red/orange/amber/green/cyan/blue/violet/pink/
white/grey) — hand-picked editorial set
- Native <input type=color> for free hex pick (no preset constraint)
- "×" clear button (only when a value is set for that slot)
- Active swatch outline when the current value matches the preset
- runCommand passes the `color:#hex` / `bgcolor:#hex` string straight
through commandFromName → toggleMark.
WordsCommandName extended with `color:\${string}` and
`bgcolor:\${string}` for type-safe consumer calls.
152/152 tests pass. Verified visually: clicking the red swatch on
selected "ActiveUI" text in the demo produces
`<span data-words-marks="bold" style="color:#ef4444">ActiveUI</span>`
in the rendered HTML, text paints red in the editor, the swatch
shows active outline + clear (×) button appears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
ab06562abe |
style(words): drawer per-scope accent color + tinted header (COLOR-2)
Each drawer panel category gets a distinct hue so the user reads the panel's *kind* at a glance: - Text scopes (format, block) → primary accent (project violet) - Structure scopes (cell, row, table, list, list-item) → secondary (slate-blue) — the contextual scaffolding around the text - Media scopes (image, code) → fulfill (teal-mint) — opaque blocks - Default (document outline) → neutral toolbar tone Implementation: scope hue resolves into `--_drawer-scope-accent` via `[data-words-drawer-panel][data-mode=...]` selectors. Then: - The left border (already used as depth indicator) inherits the scope accent at decreasing alpha as depth grows (leaf 100%, depth 1 65%, 2 40%, 3 25%). - Open panel headers paint with a 6% tint of the scope accent + the accent color for the title text. - Active action chips inside a panel inherit the scope accent for background + border + text, so the active variant matches the scope's color identity (no more "always-violet" Striped/Plain). Visual cohesion: a user in a table cell with text selected now sees: - FORMAT panel header in violet (text scope) - CELL/ROW/TABLE headers in slate-blue when expanded (structure scope) - IMAGE/CODE panels in teal-mint (media scope) - DEFAULT in neutral CSS-only change; nothing in soma/morfo touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
b3524b3446 |
fix(words): image block selection — click figure activates image drawer mode (COLOR-1)
Image blocks are contenteditable=false so the text caret can never live inside them. The drawer never entered image mode and the user had no visible "selected image" state. Fixed with a separate atomic- block selection layer: - New provider state `selectedBlockIndex` (number | undefined) for atomic block selection. Distinct from text selection. - `currentBlock` derived now short-circuits to the atomic block's type when `selectedBlockIndex` is set, so the drawer enters image mode whenever the user has selected an image. - New methods `selectAtomicBlock(blockIndex)` + `clearSelectedBlock()` on the provider. selectAtomicBlock also focuses the editor root so keyboard handlers (esc, delete) work on the atomic block. - `syncSelectionFromDom` clears the atomic-block highlight only when the text selection actually moved (was clearing on every selectionchange echo, which wiped the highlight a tick after the click landed). - Provider onclick handler detects clicks on figure[data-words-block= 'image'][data-words-path], decodes the path, calls selectAtomicBlock(blockIndex). - Render passes `selectedBlockIndex` through findOpts; image block render adds `data-words-block-selected` attr when the index matches. - Eidos CSS paints a 3px accent outline + glow on the selected figure. Image panel's imageBlockAtCaret() now falls back to selectedBlockIndex first, then text-selection anchor. Result: clicking an image in the editor now highlights it (accent outline + glow), the drawer enters IMAGE mode and shows its source/alt/caption/status panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
e25ac301b5 |
feat(words): drawer hierarchical mode stack — Format → Cell → Row → Table (HIER 1-3)
The drawer now renders the FULL hierarchy of scopes that apply at the caret, instead of only the most-specific one. Inside a table cell with text selected the user sees four panels stacked: Format ← active selection's inline marks/links Cell ← tone, vertical align, cell text-align Row ← +/- row, row header toggle Table ← striped, compact, +/- column, col header Each panel is independently collapsible. Default: the leaf (most specific) is open, the rest are collapsed but accessible via the chevron next to their header. User-explicit toggles persist across context changes. HIER-1 — soma: - WordsDrawerMode union expanded: split 'table' → 'cell' | 'row' | 'table'; split 'list' → 'list-item' | 'list'; added 'block' for the paragraph/heading/quote text-align scope. - WordsDrawerProvider gains `modes: readonly DrawerMode[]` derived that walks the caret context outward (most-specific first). The legacy `mode` field is kept as the top of the stack for consumers that just want the leaf. - snippetProps surfaces both `modes` (stack) and `mode` (leaf). - Morfo `data-words-drawer-mode` values updated to the new union. HIER-2 — eidos panel split: - Table panel decomposed into three: cell-scope (tone + vertical + text-align), row-scope (+/- row, row header), table-scope (+/- col, col header, striped, compact). - List panel decomposed into list-item-scope (indent/outdent) + list-scope (kind). - New 'block' panel hosts text-align when caret is in a heading / paragraph / quote outside a table cell. - Each panel is a `<section data-words-drawer-panel data-depth=N>` with its own toggleable header + body snippet. HIER-3 — per-panel collapse: - SvelteMap stores user-explicit open/closed overrides per mode. isPanelOpen() falls back to "open if leaf, else collapsed" when no override exists. - Drawer header now shows leaf title in bold + breadcrumb of deeper panels in muted text (Format · Cell · Row · Table). - Visual depth cue: border-inline-start of each panel renders at decreasing strength as depth grows (leaf solid, deeper at 60% / 30% accent border). Visual verification: selected text inside a sample table cell shows 4 panels stacked (Format open, Cell/Row/Table collapsed), header breadcrumb "FORMAT · Cell · Row · Table". Click on any collapsed header expands it. 214/214 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |