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 }
805 Commits (66ff993211b6f39ccd95ca8ce14cfbb1e0b81c60)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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 |
|
|
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 |
|
|
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 |
|
|
9a5f3c2359 |
refactor(words): drawer canon — morfo part + soma provider + eidos consumes via provider (DRAWER-CANON)
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>
|
4 months ago |
|
|
15dbf0f8fd |
feat(words): drawer contextual panels — format/image/table/code/list (DRAWER-2)
The companion drawer now switches its body content based on where the caret is. Five context modes wired to runCommand on the snippet provider, all reading current state to show active highlights: - format: B/I/U/S/code mark toggles (data-active reflects activeMarks), align L/C/R/J (data-active reflects currentTextAlign), Add link (URL prompt) + Remove (canUnlink-gated), Clear formatting. - image: read-only view of src, alt (italic + "(missing)" placeholder when empty), caption, status (pending → accent tint, error → threat tint). Edit-image flow deferred to a follow-up (needs a new updateImage operation; for the spike, view-only is enough to validate the panel works). - table: Rows +/- · Columns +/- · Row/Col header toggles · Striped / Compact toggles · Cell tone Plain/Muted/Accent. Every toggle reads current* state so the user sees which buttons are currently applied. - code: Plain + TS/JS/CSS/HTML/JSON/Bash/Py language chips, data-active reflects currentCodeLanguage. - list: Bullet/Numbered/Check kind chips · ⇤ Outdent / ⇥ Indent. All buttons use onmousedown preventDefault so the editor selection isn't dropped when clicking a drawer action (would otherwise lose the target paragraph / table cell / list item). CSS adds a generic .data-words-drawer-action chip (with hover, active, ghost, full, risk tones) + readonly-text style with pending/error tints. Reused across all panels, so adding new panels later means markup-only, no CSS extension. Verified in browser: - Click table cell → drawer shows TABLE panel with Striped active (matches sample's striped=true) and Plain tone active (matches default cell tone). - Click code block → drawer shows CODE panel with TS chip active (matches sample's language='ts'). - Click list item → drawer shows LIST panel with kind + indent actions. Concept A+D validated visually: ONE surface replaces what would otherwise be bubble menu + slash menu + heading picker + language picker + table popover + image controls + link editor. The contextual switch happens automatically; the user just moves the caret. DRAWER-3 (polish): mobile bottom-sheet layout, A/B toggle in demo controls, image-edit flow — deferred to next session if needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
23f90a775c |
feat(words): companion drawer right-rail (concept A+D) — shell + default panel (DRAWER-1)
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>
|
4 months ago |
|
|
ef95b1a82a |
feat(words): slash command /image + paste/drop with onUploadImage (F3.7, F3.8)
F3.7 — slash command /image:
- DEFAULT_SLASH_COMMANDS gains an 'image' entry: triggers a window
prompt for the URL (and optional alt text), then dispatches
insertImage. The native prompt is MVP — consumers wanting a custom
dialog can hide the slash entry via slashCommands override and call
applyWordsCommand({ type: 'insertImage', src }) from their own UI.
- WordsSlashCommandId union + WordsCommandName union extended with
'image' / 'insert-image' respectively.
- slashCommandToWordsCommand returns undefined for 'image' so the
prompt path in commitSlashCommand owns it.
F3.8 — paste/drop with onUploadImage callback:
- New WordsProps.onUploadImage prop: (file: File) => Promise<{url, alt?}>.
Consumer-injected upload pipeline (S3, R2, own backend, etc.).
- onpaste detects image files in clipboardData.files. If present + the
callback is wired, intercepts the paste (drops text/html processing)
and inserts each image with a blob: URL + status='pending'.
- New ondragover + ondrop handlers gate on dataTransferHasImageFiles
and the callback being set; trigger insertImageFiles on drop.
- insertImageFiles inserts pending placeholders, awaits the upload
promise for each, then either swaps src for the final URL (success)
or flips status='error' (rejection). Matches blocks by blob URL
(unique) so concurrent uploads + concurrent edits stay coherent.
- replaceImageBlock / markImageBlockError use mapImageBlocksBySrc to
rewrite the document via replaceDocument command. No new command
type needed.
- Helpers added: collectImageFiles, dataTransferHasImageFiles,
mapImageBlocksBySrc — all pure functions at module scope.
Test fixture: wordsOpts gains the onUploadImage state slot so the 16
existing provider tests typecheck without behavior change.
152/152 tests pass in the full words soma scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
86b47cef25 |
fix(words): normalize handles image blocks + 3 image tests (F3.11 partial)
normalizeBlock had no case for type='image' — image blocks coming from
parseWordsMarkdown / parseWordsHtml were silently dropped at the
default branch. Now:
- normalize.ts case 'image': validates src (non-empty + sanitizeWordsUrl),
passes through alt / caption / width (numeric) / height (numeric) /
align (validated via isWordsImageAlign) / status ('pending' | 'error').
- normalize.ts imports createImage + isWordsImageAlign from the
extension.
Image-extension cast widened with `as unknown as ErasedFactory` because
WordsImageBlock (no `children` field) doesn't satisfy the contract's
node shape strictly; the double-cast is the same pattern table-extension
already uses for typed factories.
3 new engine tests cover the F3 happy path:
- parses a block-level markdown image ( on its
own line yields a type:'image' block)
- round-trips an image through markdown (serialize → parse → matches)
- round-trips an image through HTML (figure>img+figcaption → ImageBlock
→ serialize back)
85/85 engine tests pass (was 82; +3 image tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
2a51adc00f |
feat(words): insertImage operation + command + CSS + extension stub (F3.6, F3.9, F3.10)
F3.6 — insertImage reducer + command:
- engine/operations.ts: insertImage(state, options) — atomic insert
(URL required, optional alt/caption/width/height/align/status).
Replaces an empty paragraph cursor lands on, otherwise inserts after.
Caret lands on the new paragraph following the image (image is
atomic — no caret inside the figure).
- engine/commands.ts: 'insertImage' case added to WordsCommand union +
applyWordsCommand switch. Public command surface: provider can call
applyWordsCommand(state, { type: 'insertImage', src, alt?, ... }).
F3.9 — eidos CSS for image block:
- figure[data-words-block='image']: centered flex column with border,
bg, padding, gap.
- img: max-inline-size 100%, auto height, user-select none.
- figcaption: italic, centered, status-color, smaller font.
- data-words-image-align='left'/'right': flex alignment + auto margins.
- data-words-image-status='pending': opacity + grayscale + "Uploading…"
::after pseudo-text.
- data-words-image-status='error': threat border + "Upload failed"
::after pseudo-text.
- :focus / :focus-within: 2px accent outline so the atomic block has
a visible selection state.
F3.10 — imageExtension stub:
- extensions/image/image-extension.ts: WordsExtension shape with
name='image', nodeTypes=['image'], factories.image=createImage,
commandNames.insertImage='insertImage'. Hooks intentionally absent
(engine still hardcodes branches today; stub publishes the public
shape parallel to tableExtension).
- extensions/index.ts exports imageExtension alongside tableExtension.
Morfo:
- data-block + data-words-node value sets include 'image'.
- New optional attrs declared: data-words-image-align (left/center/
right), data-words-image-status (pending/error). Now the eidos CSS
selectors validate against the morfo contract.
211/211 tests pass in words soma + morfo scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
08bef77f4b |
feat(words): image extension foundation — types, render, HTML+Markdown serializers (F3.1-F3.5)
Adds image as a first-class block node to the words engine. Phase F3
of the words rich-text editor. Ships the bottom half of the stack so
images can be authored in source (markdown / HTML / value JSON) and
roundtripped, but UX surfaces (toolbar action, slash command, paste/
drop) land in subsequent commits.
F3.1 — extensions/image/{types,factories,index}.ts:
- WordsImageBlock = { type: 'image', src, alt?, caption?, width?, height?, align?, status? }
- createImage(src, options) factory canonicalising defaults (align='center' stripped, etc.)
- WordsImageStatus = 'pending' | 'error' for upload lifecycle
- WORDS_IMAGE_ALIGNS + isWordsImageAlign predicate
F3.2 — engine/document.ts:
- WordsBlockType + WordsBlock union extended with 'image'
- Image types re-exported (backward-compat with the table-types pattern)
- getBlockText: image → alt text (captions excluded; image stays a
single atom in plain-text contexts)
F3.3 — engine/render.ts:
- Image branch renders <figure data-words-block=image contenteditable=false>
<img src alt width? height? loading=lazy draggable=false>
<figcaption>caption</figcaption?
- WordsRenderTag union gains 'figure' | 'img' | 'figcaption'
- data-words-image-align / data-words-image-status surface state
- renderBlockPlainText: image → alt
F3.4 — engine/serialize-html.ts:
- Out: <figure data-words-image-align?><img alt src width? height?><figcaption?></figure>
- In: 'figure' added to BLOCK_TAGS; <figure><img> elements parsed back
to ImageBlock (with caption from <figcaption>); bare <img> parses
to a block too
- htmlElementText helper added for caption text extraction
F3.5 — engine/serialize-markdown.ts:
- Out:  — escapes ], ( and ) in src, " in caption
- In: standalone line matching ^$ promotes to a
block image. Inline images mid-paragraph become text + no image
(lossy by design — keep the model tight; inline image is a separate
node type if added later)
Engine ripple — exhaustive switches updated:
- normalize.getInlineBlockText
- path.collectContainerChildren (image marked as terminal)
- selection.collectBlockText (image returns empty — opaque atom)
- operations:
- setBlock skips image (image is not convertible to inline-text blocks)
- setTextAlign skips image (no text align on image)
- deleteRange refuses if range crosses an image (atomic)
- insertParagraph on image inserts a fresh paragraph after it and
moves the caret
- blockTextAlign() returns undefined for image
- words-provider.currentTextAlign returns 'left' on image
149/149 tests pass in the full words soma scope. `npx tsc --noEmit`
clean for the touched area. Engine consumers compile unchanged.
Next: F3.6 insertImage operation + toolbar/slash command, F3.7 slash
menu /image entry, F3.8 paste/drop with onUploadImage callback, F3.9
eidos CSS, F3.10 imageExtension stub, F3.11 demo + verify.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
5bd9f9fe4f |
fix(words): slash menu wider + description wraps to 2 lines instead of ellipsis (F4.5a)
Before:
- Menu width was 7× button-size (≈224px), so even short descriptions
clipped: "Large section headin…", "Blockquote callou…".
- Description was single-line nowrap+ellipsis, doubling the clipping.
After:
- Menu width 10× button-size (≈320px) — enough room for the longest
default description ("Large section heading") without truncation.
- Description switches to a 2-line line-clamp with line-height 1.3,
so longer custom descriptions still degrade gracefully instead of
silently disappearing past the first line.
Label still nowrap+ellipsis (the bold first line is supposed to be a
short title; if it doesn't fit, that's an author issue worth a visual
cue).
CSS-only in words.css. Visible immediately on the demo's SlashMenu.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
eb6a019b5a |
fix(words): vertical rhythm — heading top margin, blockquote distinctive, code block generous padding (F4.3)
Three small typography lifts that compound into a noticeably cleaner document rhythm: - Headings (h1/h2/h3) gain 1.5× block-gap top margin when NOT first child. Opens visible breathing room between a body block and the next section. Skipped on first-child so the editor opens flush against the toolbar. - Blockquote: was just a left border + recolored text. Now adds vertical padding, italic, a 1.5× border-inline-start width, and a 4% accent background tint. Reads unambiguously as a callout rather than indistinguishable from a regular paragraph. - Pre (code block): padding bumped from 1× quote-px to 1.25× and the border mixed with 60% accent-border so the block is visually weightier and stands apart from the body bg. CSS-only change in words.css. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
c09f7b0d87 |
fix(words): table accent tone softer, hover cells, stronger header divider (F4.2)
Before: - tone='accent' painted at 14% solid + full accent-text recolor — the cell read as "broken / special-cased random" rather than emphasised. - striped used 70%-transparent overlay on toolbar-bg ≈ invisible against the editor bg. - no hover state on cells — users had no signal that cells are interactive (clickable to edit). - header row used the same single-width border as body cells, so the divider got lost when striped or accent kicked in. After: - tone='accent' at 8% solid, text color inherited from default — the cell still draws the eye without shouting. - striped uses solid toolbar-bg (no transparency) — actual zebra. - subtle hover (5% accent overlay) on every non-header cell, with a 120ms ease transition. - header row gets a 2× bottom border so the head/body divider is unambiguous regardless of striping/accent. CSS-only change in words.css, no markup or logic touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
3cc171f747 |
fix(words): toolbar horizontal-scroll instead of multi-line wrap on mobile (F4.1)
Before: at narrow viewports the toolbar wrapped to 2-3 lines with orphan icons in the last row (e.g. at 375px: 5 + 4 + 1 icons = 3 rows). Awkward visually and wasteful of vertical space. After: single-row horizontal scroll with a soft trailing fade (mask-image gradient) hinting at hidden overflow. Same UX as Google Docs / Word / Notion mobile toolbars. All commands stay reachable without changing the markup or the provider logic. Vertical-orientation toolbar opts out of the horizontal-scroll mask (it already lays out top-to-bottom and the mask would clip the trailing icons in the visible direction). Verified: - 375px (mobile) — single row, fade visible on right edge. - 900px (laptop) — single row, no overflow, fade harmless. - Vertical orientation — column layout intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
1043f1de4a |
refactor(words): extract 8 pure table helpers to extensions/table/utils.ts (F2.3h.1)
Moves the table-pure helpers (no engine selection/normalize/marks dependencies) from `engine/operations.ts` to `extensions/table/utils.ts`: - createTableGrid (2×2 default, clamped 1..12 × 1..8) - tableCellPathFromContainer (3-tuple path resolver) - tableOptions / tableCellOptions (option-bag readers) - tableColumnCount (max-cells-across-rows, min 1) - createEmptyTableRow (N-cell row factory) - insertTableCellInRow / deleteTableCellFromRow (cell ops over a row) - WordsTableCellPath type alias The 6 *mixed* helpers (currentTableCellPath, selectTableCell, setTableCellTextAlign, updateCurrentTableCell, updateCurrentTable, preserveTableFormattingSelection) still live in operations.ts — they read private engine state and can only move once F2.3i full lands a visitor pattern that injects those engine APIs as parameters. clampInteger stays duplicated locally in utils.ts (9 LoC, generic numeric helper) instead of being exported from the engine, to keep the extension's surface tight. If a third caller emerges, promote to a shared location. 149/149 tests pass in the full words soma scope. TS check clean in extensions+engine area. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
bde6728590 |
refactor(words): table extension stub — name + nodeTypes + factories + commandNames (F2.3i)
Publishes `tableExtension: WordsExtension` in `extensions/table/table-extension.ts` with the three static facets we can ship without touching the engine: - `nodeTypes`: ['table', 'table-row', 'table-cell'] - `factories`: typed constructors for the three node types - `commandNames`: the 12 public table commands (insertTable, deleteTableRow, toggleTableHeaderRow, setTableCellTone, …) mapped to internal operation types (1:1 today). The dynamic hooks (`render`, `normalize`, `serialize`, `path`, `keyboard`, `commands`, `events`) are intentionally absent — F2.3f-h port each one incrementally. The engine does NOT yet consume the registry; the stub publishes the shape the engine will respect once F2.3i full lands. 6 smoke tests verify the public shape (nodeTypes, factories produce valid nodes, registry registers cleanly + finds by every nodeType, all 12 command names exposed, hooks intentionally absent, default 1×1 table). 102/102 in extensions + engine scope, 149/149 in full words soma scope. CONTINUE.md updated to reflect F2.3i-stub done + next-session order (F2.3h first, then F2.3f/g — F2.3i full wire-up follows F2.3h). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
d839273536 |
refactor(words): extract table render to extensions/table (F2.3e)
Fifth atomic checkpoint of F2.3. Moves the table-specific DOM render
helpers from engine/render.ts to extensions/table/render.ts.
Moved:
- renderTable(block, path, helpers) — replaces the table branch in
renderWordsBlock + internal renderWordsTableRow + renderWordsTableCell
- renderTablePlainText(block, helpers) — replaces the table branch in
renderBlockPlainText
The extension defines structural render-element types
(TableRenderElement, TableRenderText, TableRenderNode) assignment-
compatible with the engine's WordsRenderElement/Node. The engine
passes its constants (WORDS_PATH_ATTR, WORDS_NODE_ATTR) + helpers
(encodePath, renderInlines, getInlineText) via a TableRenderHelpers
object, keeping the extension independent of engine/dom.ts.
The engine's renderWordsBlock dispatcher branch for `block.type === 'table'`
now delegates to `renderTable(block, path, { ... })` in a single call.
~50 LoC of helper functions removed from the engine.
Extension barrel updated to expose:
- renderTable / renderTablePlainText
- TableRenderElement / TableRenderNode / TableRenderText / TableRenderHelpers / RenderPath types
Verification:
- 143/143 tests pass in src/uix/soma/components/words
- 0 new TypeScript errors
F2.3 progress: a + b + c + d + e done. Remaining: f (path), g
(normalize), h (operations — largest at ~400-500 LoC), i (registry
wire-up), j (verify). Operations remains the heaviest piece — likely
its own dedicated session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
5797e69b88 |
refactor(words): extract table HTML serializer (F2.3c)
Fourth atomic checkpoint of F2.3. Moves the HTML pipe-table serialize + parse helpers from engine/serialize-html.ts to extensions/table/serialize-html.ts. Moved (write side): - serializeTableHtml(block, serializeInlines, escapeHtmlAttr) - serializeTableCellHtml (internal) - tableAttrsHtml (internal) - tableCellAttrsHtml (internal) Moved (parse side): - parseTableHtml(node, parseInlines, textAlignFromStyle) - collectTableRows (internal) - htmlRowToWords (internal) - htmlCellToWords (internal) - tableCellVerticalAlignFromNode (internal) - tableCellToneFromNode (internal) Mirrors the F2.3d pattern: extension consumes engine inline serializer + HTML attribute escaper as callback parameters to avoid circular dependencies. The extension defines its own minimal `WordsTableHtmlElement` / `WordsTableHtmlChild` types structurally compatible with the engine's internal HtmlElementNode/HtmlNode — engine passes its own parsed nodes via a safe `as unknown as` cast at the call site (commented). engine/serialize-html.ts now contains only the dispatch call to the extension's serializeTableHtml / parseTableHtml. ~150 LoC of helper code removed from the engine; same LoC + clearer separation in extension. Extension barrel updated to expose: - serializeTableHtml / parseTableHtml - WordsTableHtmlElement / WordsTableHtmlChild types Verification: - 143/143 tests pass in src/uix/soma/components/words - 0 new TypeScript errors F2.3 progress: a + b + c + d done. Remaining: e (render), f (path), g (normalize), h (operations — largest), i (registry wire-up), j (verify). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
d6f0af4846 |
refactor(words): extract table markdown serializer (F2.3d)
Third atomic checkpoint of F2.3. Moves the markdown pipe-table serialize + parse helpers from engine/serialize-markdown.ts to extensions/table/serialize-markdown.ts: Moved: - serializeTableMarkdown(block, renderInline) - tableMarkdownRow (internal) - escapeMarkdownTableCell (internal) - collectTable(lines, startIndex) - parseTableLine (internal) - isTableSeparator (internal) - NEW: buildTableFromMarkdownRows(rows, parseInline) — convenience for the engine to convert collectTable's raw output into a WordsTableBlock, keeping the extension free from the engine's inline parser implementation. The extension takes inline serialize/parse functions as callback parameters (`renderInline`, `parseInline`) instead of importing the engine's inline serializers. Avoids circular dependency — the extension imports engine for shared types (WordsInline, WordsTableRow) and factories only, never for runtime function dependencies. engine/serialize-markdown.ts now imports the 3 extension functions and calls them from `serializeBlockMarkdown` (write side) and `parseWordsMarkdown` (parse side). Internal `collectTable`, `parseTableLine`, `isTableSeparator` removed. Extension barrel updated to expose the new functions: - serializeTableMarkdown - parseTableMarkdownRows (collectTable re-exported under clearer name) - buildTableFromMarkdownRows Verification: - 143/143 tests pass in src/uix/soma/components/words - 0 new TypeScript errors F2.3 progress: a + b + d done. Pending: c (serialize-html), e (render), f (path), g (normalize), h (operations — largest), i (registry wire-up), j (verify). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
a0afa44da4 |
refactor(words): extract table factories to extensions/table (F2.3b)
Second atomic checkpoint of F2.3. Moves table factory functions (createTable, createTableRow, createTableCell), predicate functions (isWordsTableCellVerticalAlign, isWordsTableCellTone), and value-set constants (WORDS_TABLE_CELL_VERTICAL_ALIGNS, WORDS_TABLE_CELL_TONES) from engine/document.ts to extensions/table/factories.ts. engine/document.ts now re-exports the moved symbols so existing imports keep working unchanged. The factories module imports its back-references (createText, WordsInline) from engine/document — the extension may depend on the engine core but the engine core only depends on the extension via re-export indirection (no functional dependency). extensions/table/index.ts barrel updated to expose types (F2.3a) + factories (F2.3b) as the table extension's public surface. Document header notes which pieces have been migrated and which are still pending (serializers, render, path, normalize, operations, registry wire-up). Verification: - 143/143 tests pass in src/uix/soma/components/words - 0 new TypeScript errors - No engine behaviour change (symbol relocation only) F2.3 progress: a + b done. Pending: c (serialize-html), d (serialize-markdown), e (render), f (path), g (normalize), h (operations — largest), i (registry wire-up), j (verify). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
4 months ago |
|
|
088d31ec5d |
refactor(words): extract table types to extensions/table/types.ts (F2.3a)
First atomic checkpoint of F2.3 (table migration to extension). Moves
the type declarations only — interfaces and unions for the table node
tree (WordsTableBlock, WordsTableRow, WordsTableCell, WordsTableOptions,
WordsTableCellOptions, WordsTableCellVerticalAlign, WordsTableCellTone)
go from engine/document.ts to extensions/table/types.ts.
engine/document.ts re-exports the moved types via `export type { ... }
from '../extensions/table/types'` so every existing consumer keeps
compiling without changes. Internal usages within document.ts use a
separate `import type` to keep the types in local scope for factory
function signatures.
The rest of F2.3 (factories + serializers + render + normalize + path
+ operations + registry wire-up) requires deeper dependency analysis
across the engine's ~990 LoC of table code spread over 10 files. That
work is deferred to a dedicated session to avoid mid-refactor breakage.
Verification:
- 143/143 tests pass in src/uix/soma/components/words
- 0 new TypeScript errors
- No engine behaviour change (types-only move)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
1805b0817d |
feat(words): extension system skeleton (F2.1 + F2.2)
Phase 1 of the words rich-text editor extension system. Skeleton +
contract only — engine is NOT yet wired to consult the registry.
That happens in F2.3 (migrate table) and F2.4 (migrate code-block).
New module `src/uix/soma/components/words/extensions/`:
- extension-types.ts: WordsExtension interface + sub-types. Every
hook is optional. Hooks cover document semantics (nodeTypes,
factories), normalization, commands (reducer pattern),
rendering, serialization (HTML + Markdown, in + out), path
navigation, keyboard, sema events, dispose. Each hook
documented inline with rationale.
- extension-registry.ts: createWordsExtensionRegistry() returns
the runtime container. API:
register / unregister / dispose
extensions / findByNodeType / findByCommandName
getRender / getNormalize / getCommand / getSerialize / getPath / getKeyboard
allEvents / allCommandNames / allNodeTypes
tryDeserializeHtml / tryDeserializeMarkdown
Duplicate-name and duplicate-nodeType detection at register-time
with rollback-safe semantics. dispose() tears down extensions in
reverse-registration order.
- extension-registry.test.ts: 14 tests covering register, conflict
rejection (duplicate name, duplicate nodeType, duplicate command
name), all-or-nothing rollback, sema event aggregation, dispose
ordering, unregister, getRender/getNormalize/getCommand walks,
tryDeserializeHtml walk-until-non-null.
- index.ts: barrel.
Verification:
- 14/14 registry tests pass
- 82/82 existing engine tests still pass (no engine touched)
- Total 96/96 in src/uix/soma/components/words
F2.1 (audit) + F2.2 (skeleton) done. F2.3 (migrate table) and
F2.4 (migrate code-block) are separate sessions per the planned
rollback-safe sub-task structure — each one will move the
~990 LoC of table and ~790 LoC of code-block from the engine
core into extensions/{table,code-block}/* and wire them into
the registry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
85b8a56ab0 |
feat(words): add toolbarLayout grouped|inline (responsive)
New optional prop on <Words> for the toolbar layout strategy:
- 'grouped' (default): each family (text/block/list/align/insert/table/
link/tools) becomes a single trigger button that opens a popover with
its commands. Compact, recommended for narrow viewports.
- 'inline': every command in every family renders flat in the toolbar,
with the toolbar group separators marking family boundaries. High
discoverability, recommended for wide editors.
Tools that own their own panel UI (LinkEditor, FindReplace,
HeadingPicker, CodeLanguagePicker) remain popovers/dropdowns in both
layouts by design — they are not "inlineable".
Accepts ResponsiveProp<WordsToolbarLayout> for breakpoint-aware
switching, e.g. `toolbarLayout={{ base: 'grouped', md: 'inline' }}`.
Implementation:
- types.ts: new WordsToolbarLayout = 'grouped' | 'inline'; new
toolbarLayout?: ResponsiveProp<WordsToolbarLayout> on WordsProps.
- index.ts: export WordsToolbarLayout type.
- words.svelte: resolved via eidos.resolve(toolbarLayout, 'grouped').
New helper flattenFamilyToLeafItems(family, customItems) returns
the default leaf items per family for the inline expansion.
Render refactored: leaf rendering extracted to a Svelte snippet
reusable by both layouts, so the conditional only chooses between
<ToolbarFamilyMenu> (grouped) and {#each leafs as leaf}{@render
leafItem(leaf, snippetProps)}{/each} (inline).
- demo: toolbarLayout state added + wired to <Words>.
Verified visually in browser (preview + screenshot):
- grouped: 9 family menu triggers + 1 sibling tool = 10 toolbar buttons
- inline: 0 family triggers + 28 command buttons + 5 tools = 33 buttons
Tests: 161/161 pass in src/uix/{contracts,soma/components/words}.
TypeScript: 0 new errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
58c2c0babf |
fix(words): close audit P0 + quiet focus event (FASE 1)
Cierre de los hallazgos P0/P1 del audit Codex sobre Words + fix del
evento contact-focus molesto reportado por el usuario.
F1.1 — Fix evento foco (UX):
El handler onfocus disparaba `runtime.trigger('contact-focus')` cada vez
que el editor recibía foco, generando sound + haptic + visual stamp en
cada re-focus desde popovers internos (bubble menu / link editor / find
panel / slash menu / toolbar dropdowns). En sesión normal de edición el
foco entra/sale del content docenas de veces, lo que producía fatiga
perceptual.
Cambios en words-provider.svelte.ts:
- onfocus: emit solo en transición real (was-not-focused → focused).
Re-focus desde popover interno NO emite.
- onblur: si el related target es interno al Words tool tree
(isInsideWordsTool), mantener focused=true (Words se trata como un
solo scope de foco). Skip commit('blur') también para evitar
commits espurios entre popover hops.
F1.2 — Declarar 8 data-* hardcoded en morfo (audit P0-1):
Añadidos al Content part:
- data-words-find-match, data-words-find-active (mark highlights)
- data-words-indent (list items con indent > 0)
- data-language (alias parsing-only para interop con HTML externo
emitido por Prism/highlight.js)
Nuevas 6 parts compound (Eidos wrappers):
- HeadingPicker + HeadingPickerPanel (data-words-heading-picker /
-panel)
- CodeLanguagePicker + CodeLanguagePanel
- ToolbarFamilyMenu + ToolbarFamilyPanel
F1.3 — Rename i18n keys a kebab-case (audit P0-1):
4 keys camelCase eran ilegales según contracts.test.ts. Renamed:
- components.words.bubbleMenu → bubble-menu
- components.words.slashMenu → slash-menu
- components.words.linkEditor → link-editor
- components.words.findReplace → find-replace
Propagado a: morfo declarations (8 refs), provider tslangs calls
(4 refs), catalog langs/components/words.ts (4 keys).
F1.4 — Canonizar 9 event names a forma {family}-{verb}[-{variant}]
(audit P2-1):
Renombrados para parsear con el morfo:vocabulary script. El verb
declarado en cada semantic ahora aparece explícito en el name:
- commit-content → commit-save-content (verb: save)
- commit-format → commit-set-format (verb: set)
- commit-link → commit-set-link
- commit-unlink → commit-reset-link (verb: reset)
- commit-slash-command → commit-set-slash
- commit-check → commit-set-check
- commit-history → commit-restore-history (verb: restore)
- commit-clear → commit-reset-content (verb: reset)
- signal-invalid-input → signal-warn-invalid (verb: warn)
Propagado a: sema cascade selectors, soma provider trigger calls,
provider + content tests, demo route. 0 conflictos con event names
de otros componentes (file-upload/tag-group/etc. usan namespaces
propios sin colisión con words).
Tests: 161/161 pass en src/uix/{contracts,soma/components/words}.
Suite full eidos+morfo+soma+sema pendiente de verificar tras commit.
contracts.test.ts ahora verde — cierra P0 audit Codex.
morfo:vocabulary 0 event-name warnings (los 107 data-attr enum
warnings son pre-existentes, separate scope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
bd73fb98f3 |
feat(eidos): variants canon — EIDOS_VARIANTS + per-component lint
Variants son canon del eidos, NO del theme. Decisión arquitectónica
firmemente sostenida: el vocabulario de variants (solid/outline/ghost/
soft/surface/line/pills) está fijo a nivel del framework — paralelo
a las 8 sema families del libro. Theme = retintar lo perceptualmente
fijo; cambia QUÉ color es `affirm`, no QUÉ significa `outline`.
Cambios:
- lib/types.ts: nueva constante `EIDOS_VARIANTS` con los 5 archetypes
canónicos (control / selection / chip / marker / tabs). Los 5 union
types se derivan via `[number]` indexed access — valor y tipo no
pueden desincronizarse. Nueva `EIDOS_VARIANT_VALUES` Set flat con
todos los valores canónicos + utilidades cross-component (`plain`,
`subtle`).
- recipe-css-contract.test.ts: nuevo test "variant CSS selectors per
component match the declared type union". Por cada componente:
extrae el union type de `components/{c}/types.ts` (soporta literal
unions + archetype aliases; cae a advisory mode en Extract<> y
conditional types); compara con `[data-{c}][data-variant='X']`
selectores en `{c}.css`; reporta typos y unauthorized extensions
bidireccionalmente.
- THEMING.md §19: nueva sección "Variants son canon del eidos, NO
del theme" con argumentación (portabilidad, type safety, archetypes
perceptuales paralelos a sema families), tabla de las 3 capas de
la cebolla, referencia a `EIDOS_VARIANTS`, comparación con Radix
Themes 3.x / Mantine 7 / Chakra v3 / Ark / shadcn. TOC actualizado.
- eidos/README.md: tabla de referencia ampliada con §19.
- CLAUDE.md: hand-off "2026-05-27 #6 (variants canon)".
- CONTINUE.md: nota de la decisión arquitectónica.
Variants component-specific permitidos (Banner inline/overlay/
persistent, Spinner bars/dots/ring, Button 'plain'): viven en cada
`components/{c}/types.ts` y el lint los valida contra la CSS del
componente.
Tests: 101/101 pass en `src/uix/eidos`. `npm run check`: los mismos 6
errores pre-existentes (lib/_demo, soma/components/internal, web/
routes/active) — no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
d3f9be4ab0 |
fix(toggle): add missing per-color track/hover/border forwarders
The toggle palette cascade (palette-track / palette-hover / palette-border
declarations in TSC) referenced --toggle-{X}-track, --toggle-{X}-hover,
--toggle-{X}-border for X in {primary, neutral, secondary, affirm, risk,
threat} — but the recipe only declared 5 forwarders per color (element /
solid / solid-hover / text / contrast). The 3 missing slots × 6 colors =
18 unresolved tokens made --toggle-palette-{track,hover,border} resolve
to empty for any non-neutral color (the host default DID declare them).
Visible effect, only when a non-neutral color was active:
- ghost-on-bg = var(--toggle-palette-track) → empty → transparent fallback
- ghost-on-border = color-mix(... palette-border ...) → invalid →
property dropped, border falls through to base layer
- outline-on-bg = color-mix(... palette-element ...) was the one that
worked because palette-element forwarders DID exist for all 6 colors
Surfaced today while verifying the TSC v2.2 toggle-group color cascade
fix in the browser. The toggle-group composition correctly overrode
--toggle-palette-* on items, but the empty track/hover/border slots
collapsed ghost-state styling downstream.
Verified with browser probe (preview_inspect):
- ghost + affirm: paletteTrack #0d1514, paletteHover #023b37,
paletteBorder #145750 (was "")
- ghost + threat: paletteTrack #191111, paletteHover #500f1c,
paletteBorder #72232d (was "")
- outline + primary: paletteTrack #18111b, paletteHover #3d224e,
paletteBorder #54346b (was "")
Tests: 100/100 pass in src/uix/eidos. Recipe contract guard accepts
the new forwarders (kebab-case, valid var() values).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
4 months ago |
|
|
9c6375c96e |
feat(eidos): TSC v2.2 (parts + composition) + universal theming coverage
Token Scope Contract universal — no más excepciones arquitectónicas.
Los 3 componentes que vivían fuera de TSC v2.1 (select, avatar,
toggle-group) ahora están dentro del contrato via dos extensiones nuevas.
TSC v2.2 extensiones (lib/config-types.ts + render-css.ts + config.ts):
- `parts: readonly string[]` en RecipeTokenMultiDeclaration — emite
selectores comma-separados (`[data-{c}-x], [data-{c}-y]`) para
componentes con data-color cascadeado per-part. Consumer: select.
- `composition: { foreignRecipe: { targetSelector, tokens } }` sibling
key — overrides cross-recipe scoped a la cascade del host. Consumer:
toggle-group modifica `--toggle-palette-*` en sus items.
Migraciones:
- select: 3 private `_accent-{track,border,text}` con parts: ['trigger',
'content'] + 7 cascades color:X each. Removed orphan `_accent-solid`
(CSS no consumía).
- avatar: 6 tokens via composite scopes `['variant:X', 'color:Y']` con
matrix helper inline. Badge usa parts: ['badge']. Reemplaza 24 bloques
CSS × 2 partes.
- toggle-group: composition block con 8 palette tokens × 4 colors.
Reemplaza 4 bloques CSS per-color.
Bug toggle-group post-composition (encontrado y arreglado):
Tras la composition migration, el cascade del toggle-group seguía roto
porque los tokens derivados (`--toggle-solid-on-bg`, `--toggle-outline-fg`,
etc.) viven en scope `[data-toggle]`. El `[data-toggle-group-item]` es
sibling (no descendant), así que `var(--toggle-solid-on-bg)` resolvía
undefined. Fix: inlined derivation expressions directamente en
`[data-toggle-group-item]` y sus variant cascades (solid/outline/ghost),
referenciando palette tokens en su propio scope local.
Validador + emisor + contract:
- `validateRecipeComposition` valida el shape `{ targetSelector, tokens }`
y rechaza composition entries con scope='root'.
- `stripCompositionKey` + `emitComposition` separan el pipeline.
- `appendRecipeContractTokens` skip-list para `composition` (no aparece
como fake `--{c}-composition` knob).
- `tokenKeys`/`tokenEntries` helpers en recipe-css-contract.test.ts
filtran composition en todos los iteradores.
Documentación:
- THEMING.md §18 reescrito como "Cobertura universal de TSC". §7
extendido con subsecciones "Multi-part scope" y "Cross-recipe
composition" + ejemplos completos. TOC actualizado.
- eidos/README.md tabla de referencia ampliada con TSC v2.2 + §18.
- CLAUDE.md gana hand-off "2026-05-27 #5" (TSC v2.2 + cobertura universal).
- CONTINUE.md reescrito al estado actual de la sesión.
Working tree también incluye sprint Words en paralelo (multiple authors):
slash menu, find/replace regex, code language picker, table audit,
toolbar family menu, code highlight engine.
Tests: 786/786 pass en src/uix/{eidos,morfo,soma,sema}. `npm run check`:
6 errores pre-existentes (lib/_demo, soma/components/internal,
web/routes/active) no relacionados.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
28bf7f8b49 |
feat(pickers): wire runtime.trigger('close') in 5 picker providers
After the 2026-05-27 picker family polymorphic refactor, the morfos
declared the polymorphic `close` event but the providers were inert —
they just toggled `opts.open = false` without firing the event. This
commit cables them, mirroring the Dialog/Drawer/Popover dismissWith
pattern (book §5.3).
**Affected providers** (5): color-picker, date-picker, date-range-picker,
time-picker, time-range-picker.
**Pattern** (consistent across all 5):
- Private `triggerClose(cause)` helper maps:
'committed' / 'range-committed' → commit.save + fulfill
'cancelled' → emerge.close
'dismissed' → emerge.dismiss
'dismissed-outside' → emerge.dismiss
- Writes `data-last-action` on the morfo's target part (calendar /
clock / content) imperatively before the trigger fires so eidos can
tint the exit animation per cause.
- Called from commit() / cancel() / handleSelect / handleDateSelect /
handleRangeSelect — the close paths the provider already had.
The morfo target varies per picker:
- color-picker → content
- date-picker → calendar
- date-range-picker → calendar
- time-picker → clock
- time-range-picker → clock
Internal state (`opts.open = false`) sequence preserved — the trigger
fires BEFORE the open flip so the perceptual signal stamps
`data-event-*` on a node that is still rendered.
**dismissed / dismissed-outside causes** are declared but not yet wired
to specific gestures — the picker shell delegates escape / outside-click
handling to its wrapping Popover, whose own polymorphic close fires
independently. The cause strings remain in the helper for future use
(e.g. if a picker grows its own escape handling).
**Result**:
- Pickers now emit perceptual signal on commit/cancel via sema engine.
- Eidos `[data-last-action='X']` selectors get exit-tint values to act
on (mirror of Dialog/Drawer/Popover).
- Picker tests: 18/18 still pass. `morfo:check`: 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
312c8740ca |
refactor(date-picker): rename kind 'date' to 'day' for trio consistency
Audit caught `data-kind="date"` violation — the date-picker / date-range-picker morfos declare `data-kind ∈ [day, month, year]` but the public `kind` prop accepted `'date' | 'month' | 'year'` and defaulted to `'date'`. Rename prop value `'date' → 'day'` so the trio is consistent (all named after the grid cell granularity). **Rationale (book-pure)**: - The trio `day` / `month` / `year` is internally consistent — all three name the granularity of the grid cell the calendar view shows. - `'date'` was awkward in the trio: month and year pickers also pick "dates", just at coarser granularities. - The morfo enum was already canonical (`['day', 'month', 'year']`); this aligns the prop with the contract. **Surface touched** (all `'date' → 'day'` for the kind value): soma components: - date-picker / date-range-picker / date-field / date-range-field: provider type (DatePickerKind / DateRangePickerKind), default value, test fixtures, types.ts JSDoc. demos (web/routes/uix/components/): - date-picker / date-range-picker / date-range-field: kind state, selector chips, doc table. `'date'` literals that DON'T refer to kind (kept untouched): - ValueProfile = 'date' | 'datetime' | 'zoned' (different concept). - SegmentLock = 'none' | 'year' | 'monthYear' | 'date'. - CSS `input[type='date']` (HTML attribute). **Result**: - `npm run morfo:check`: 1 issue / 1 component → **0 issues**. All 107 routed morfos validate against their demo DOM ✓ - `npm run check`: 0 errors ✓ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
59c3bc96c4 |
fix(color-picker): emit data-state explicitly on Provider props
Audit caught 'data-state not emitted' on the ColorPicker Provider. The
morfo declares `data-state ∈ [open, closed]` with `value: v.stateRef('open')`
— the runtime's `syncAttrs` can sync this from `dom.apply`, but only if
the provider receives a `ref` on its opts (which the ColorPickerProvider
doesn't — `ColorPickerOpts` extends `ProviderOpts` + `StateProps` without
the ref slot).
Rather than re-shape the opts to add ref + bind it in the wrapper, just
emit `data-state` explicitly in the provider's props derivation, same
form as the other six declarative attrs that the provider already
materialises (`data-format`, `data-invalid`, `data-disabled`,
`data-readonly`, `data-required`, `aria-invalid`). Matches the
dialog/drawer/popover doctrinal pattern for disclosure attrs.
After this commit `morfo:check`: 1 issue remaining (date-picker
`data-kind="date"` — separate domain issue, addressed next).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
9f1466dc57 |
fix(avatar): data-color severity optional to acknowledge custom-color escape hatch
Audit caught 4 'missing data-color' violations on the Avatar Provider — the demo includes consumers passing non-canonical CSS color strings (`#7c3aed`, `oklch(70% 0.15 220)`, `rgb(245 158 11)`) which the wrapper correctly routes through `data-color-custom=""` + an inline `--avatar-color-custom` CSS variable instead of the enum attr. The morfo enum can't represent arbitrary CSS color strings, so the `data-color` attr is legitimately absent in those cases. The contract should reflect that: severity becomes `optional`. Symmetric with the `Avatar.Badge` part which already declared this attr as optional. Behaviour unchanged. Only the morfo's severity flag changes. After this fix `morfo:check`: 6 issues / 3 components → 2 issues / 2 components. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
3542f3a730 |
fix(button): remove redundant data-state enum (book §1 purity)
Audit 2026-05-27 caught 11 `data-state="idle"` violations across 3
disclosure trigger components (dropdown-menu, table.row-detail-trigger,
tree-grid.expand-trigger). Root cause was composition: each used
`<Button>` as its chrome via `{...props}` spread, and Button's morfo
declared a redundant `data-state ∈ [idle, loading]` enum that the
runtime emitted unconditionally — clobbering the outer trigger's
`data-state="open|closed"` via mergeProps order.
Doctrinal fix: REMOVE `data-state` from Button's morfo entirely.
Button's loading state is already fully expressed by the
`data-loading` presence flag (declared further down in the same morfo
and consumed by eidos as `[data-loading]`, not
`[data-state='loading']`). The 'idle' value was a redundant synonym
for "not loading" — and the book canon favours "presence = active
state, absence = rest" for binary states (cf. `data-disabled`,
`data-loading`).
**Book-pure rationale**:
- Disclosure (family `emerge`): `data-state ∈ [open, closed]` —
dialog, drawer, popover, accordion, dropdown-menu, etc.
- Lifecycle (family `sustain`): `data-state ∈ [idle, loading, ...]` —
image, avatar, button (until now), card, field.
- Mixing the two vocabularies in a single attr is the incoherence
this fix removes.
- Button is interactive surface, NOT lifecycle: its "rest" is
natural absence of `data-loading`, not a distinct `idle` state.
**Result**:
- `npm run morfo:check`: 16 issues / 6 components → **6 issues / 3
components**. Removed all 11 `'idle'` violations.
- Composition pattern restored: `<Button>` no longer clobbers
`data-state` on the host trigger.
- Eidos CSS unchanged (already uses `[data-loading]`).
- Tests: 803/801 unchanged (2 remaining failures are unrelated, in
the user's words editor WIP).
**Not fixed in this commit** (3 remaining `morfo:check` failures):
- `avatar.provider`: missing `data-color` emission (4×)
- `color-picker.provider`: missing `data-state` emission
- `date-picker.provider`: `data-kind="date"` not in declared
`[day, month, year]` enum.
Each requires a separate domain decision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
681e056102 |
fix(uix-audit): close 4 pre-existing issues found in system audit
System-wide audit of `src/uix/*` + `src/arts/adom` (excluding demos and web/routes). Findings + fixes documented in `src/uix/AUDIT_REPORT_2026-05-27.md`. **Fixed**: 1. `src/uix/eidos/lint.test.ts`: "every eidos CSS component has a morfo file" test extended to walk `morfo/internal/` as well as `morfo/components/`. The picker-shell relocation (audit-codex P1 #5) moved picker-shell to internal but the test wasn't updated. 2. `src/uix/contracts.test.ts`: two pre-existing failures resolved. (a) Picker WithChild regex updated to accept the `WithChild<Omit<PrimitiveDivAttributes, ...> & {...}>` shape applied in the audit-codex P0/P1 picker ProviderProps refactor. (b) `collectPublicSomaComponentDirs` now skips `picker-shell` as an internal primitive (symmetric to `collectKnownMorfoDataAttrs` which now walks `morfo/internal/` to include the picker-shell data-attrs). 3. `src/uix/soma/components/command/command-provider.svelte.ts`: restored missing `createAttrs(commandMorfo)` import. Was the only ERROR in `npm run check` baseline. 4. `src/uix/soma/components/dialog/types.ts`: JSDoc comment updated to reflect polymorphic close shape (cosmetic alignment with the 2026-05-27 refactor). **Result**: - `npm run check`: 1 ERROR → **0 ERRORS** ✓ - `npx vitest run src/uix`: 5 failed → **2 failed** (both 100% in user's words/* WIP, not framework code) - Polymorphic close + persistence + a11ySemantic rollout: **zero drift detected** across morfos / providers / sema cascades / tests. **NOT actioned** (documented in report): - 6 pre-existing `morfo:check` failures (avatar / color-picker / date-picker / dropdown-menu / table / tree-grid). All require domain decisions (drop 'idle' state vs add it to morfo enum, etc.). - 1 navigation-menu data-state='visible' eidos selector drift. - ~14 README/`.md` files with stale Dialog/Drawer/Popover descriptions. - Words editor doctrinal cleanup (user's WIP). - Tooltip polymorphic alignment (optional, no consumer benefit today). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
34e0064f2c |
feat(toast): explicit persistence: 'transient' on signal-announce event
Documents the deliberate choice to keep the announce event's perceptual projection transient even though the canonical book §6.2 table would suggest `untilFix` for `signal + risk` or `untilAction` for `signal + threat`. Rationale: the Toast component owns its own lifecycle via `Toast.Provider`'s auto-dismiss timer + user dismiss. The `data-event-*` projection is a one-shot pulse when the toast enters. Persistence of the toast surface itself lives in `data-state` (open/closed) on the Item, not in the signal projection. Found via audit of all `verb: 'warn' | 'alert' | 'notify'` events across `src/uix/morfo/components/*`. Toast was the only one without an explicit persistence declaration — annotated now so the choice is intentional rather than implicit-default. (`words.signal-invalid-input` also lacks persistence but it belongs to the in-flight words editor work tracked separately by the user.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
9ca8b4e30b |
docs(claude/libro): document persistence + a11ySemantic + polymorphic close rollout
LIBRO_VARIACIONES_Y_EXTENSIONES.md: - New section D.9 (persistence + holds-by-intent — book §6.1). - New section D.10 (a11ySemantic per event — book §9.1). - New section D.11 (polymorphic events — book §5.3) with the full rollout state across overlays + picker family. CLAUDE.md hand-offs: - 2026-05-27: initial sprint (persistence + a11ySemantic + polymorphic types). - 2026-05-27 #2: caller messages + persistence extension + Dialog refactor. - 2026-05-27 #3: Drawer + Popover polymorphic refactor. - 2026-05-27 #4: Picker family polymorphic refactor + test fixtures decoupling. Each hand-off records: scope, files touched, doctrinal decisions, and pending follow-ups. The whole rollout was verified on 680/680 tests across sema + morfo + soma + adom scopes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
8dce794ab4 |
refactor(pickers): collapse close-* into polymorphic close on 5 picker morfos
Mirror of the dialog/drawer/popover refactor (book §5.3) applied to the
picker family:
- color-picker
- date-picker
- date-range-picker (incl. close-range-commit cause)
- time-picker
- time-range-picker
Each had 4 close-* events with per-event prewrite; collapsed to a single
polymorphic `close` event:
semantic: {
family: 'emerge', verb: 'close', target: v.partRef('calendar'|'clock'|'content'),
sequence: 'pre', persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
}
**Behaviour preserved**: the picker providers don't currently fire any
`runtime.trigger('close*')` — they just toggle `opts.open = false` and
let the popover unmount. The previous `close-*` events were declared but
inert (only consumed by schema validator + compiler tests). Refactor is
doctrinal alignment, not behavioural change.
**Sema cascade**: only color-picker has a sema pack and it did not
reference any close-* event (only handle-pick / handle-drag /
commit-set). Zero cascade updates needed.
**Test fixtures**: compile.test.ts + runtime.svelte.test.ts already
migrated to the synthetic `prewriteFixtureMorfo` in the core commit,
so this refactor doesn't break them.
After this commit the only morfo with the pre-polymorphic per-event
prewrite shape is the synthetic test fixture itself — every production
morfo is on the polymorphic close.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
9f5fbfdb71 |
refactor(dialog/drawer/popover): collapse close-* into polymorphic close (book §5.3)
Three overlay components each had five `close-*` events (close-save /
close-cancel / close-dismiss / close-dismiss-outside / close-after-fail)
with per-event prewrite arrays setting `data-last-action`. Per the book
§5.3 polymorphism canon, collapse to a single `close` event:
semantic: {
family: 'emerge', verb: 'close', target: ...,
sequence: 'pre', persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
}
NO prewrite — the provider sets data-last-action imperatively before
trigger because polymorphic events can't bind a single prewrite per call.
**Provider pattern** (Dialog / Drawer / Popover):
- New `DISMISS_CAUSES` map: { save, cancel, dismiss, dismiss-outside, fail }
→ { lastAction, semantic: { family, verb, intent? } }.
- `dismissWith(action, opts?)` does `dom.apply({ 'data-last-action': cause.lastAction })`
then `runtime.trigger('close', { fallbackTarget, semantic: cause.semantic, message })`.
- `triggerClose` is now private; previously-public 2-arg signature collapsed
into the cause-driven internal helper.
- All internal callsites (escape, outside-click, hover-close, close button)
migrated to `dismissWith`.
- `opts.message` forwarded to runtime — when `a11ySemantic.requiresLiveRegion`
is set (today only for the 'fail' case), the live region announces it.
**Schema validator relaxed** (`src/uix/morfo/schema.ts`): the
"every declared value of `data-last-action.values[]` must be prewritten
by some event" check was DROPPED — provider-imperative writes can't
satisfy it. The reverse direction (`prewritten ⊆ declared`) stays strict.
**Cascade rewrite** (`src/uix/sema/components/{dialog,drawer,popover}.ts`):
- `eventNamePrefix: 'close-'` → `eventName: 'close', eventFamily: 'emerge'`
- `eventName: 'close-dismiss-outside'` → `eventName: 'close', state: {
attr: 'data-last-action', value: 'dismissed-outside' }`
**Eidos CSS**: not touched — already reads `data-last-action` directly.
**Public API preserved**: `dismissWith` signature + observable behaviour
identical. External consumers see no difference.
Tests updated for the new event name. 680/680 pass across sema + morfo +
soma + adom scopes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
c34fd1fd31 |
feat(uix-components): apply persistence + a11ySemantic + live-region messages to 6 morfos
Per the canonical book §6.2 + §9.2 mapping, six morfos with `signal.warn`,
`signal.alert` or `signal.notify` events get explicit `persistence` +
`a11ySemantic` annotations, and their providers wire up `clearTarget` +
caller-message integration with the shared live region.
**Morfo annotations**:
| Morfo / event | persistence | a11ySemantic |
|--------------------------------------------|----------------|---------------------------------------------------------|
| announce.signal-alert | untilAction | requiresPersistentTrace + requiresLiveRegion |
| form.signal-warn-invalid | untilFix | requiresPersistentTrace + requiresFocusMove + reducedMotionFallback='text' |
| file-upload.signal-warn-reject | untilFix | requiresPersistentTrace + reducedMotionFallback='text' |
| password-field.signal-notify-caps-state | stateBound | requiresLiveRegion + reducedMotionFallback='text' |
| tags-input.signal-warn-reject | untilFix | requiresPersistentTrace + reducedMotionFallback='text' |
| textarea.signal-warn-count-overflow | untilFix | requiresPersistentTrace + reducedMotionFallback='text' |
**Provider wiring**:
- form-provider: clearTarget on submit, message="{N} form errors..." via
FORM_LANGS.ERROR_SUMMARY_*.
- file-upload-provider: clearTarget before each accept/reject cycle, message
via new FILE_UPLOAD_LANGS.REJECT_SUMMARY_{SINGLE,MULTI}.
- password-field-provider: clearTarget on caps-off transition, message
via existing PASSWORD_FIELD_LANGS.CAPS_WARNING.
- tags-input-provider: helper `emitWarnReject` (clear + emit + message
via new TAGS_INPUT_LANGS.REJECT_WARNING), called from addTagValue,
addFromInput, handlePaste. Successful add also clears (the add is the fix).
- textarea-provider: emits only on transition INTO overflow, clears in
transition OUT (untilFix projection persists). Message via new
TEXTAREA_LANGS.OVERFLOW_WARNING with {{max}}.
Three new lang catalog entries (reject/overflow warnings) merged into
the existing components.{name}.* namespaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
70f6f3bf0d |
feat(uix): persistence + a11ySemantic + polymorphic core (book §5.3, §6, §9)
Three book canon items codified in code:
**Persistence (libro §6.1)** — separa `hold` (perceptual min) de
`persistence` (lifecycle real):
- New `SignalPersistence = 'transient' | 'untilAction' | 'untilFix' | 'stateBound'`
- New `SEMA_HOLDS_BY_INTENT` canonical lookup table (`src/uix/sema/holds.ts`)
- `EngineSemantic.emit()` returns the resolved signal id; keeps projection
alive past hold for non-transient. Exposes `clear(id)`, `clearTarget(target)`,
`hasActive(id)`.
- `SomaRuntime.trigger()` returns `TriggerResult { id?, persistence? }`.
Exposes `clearSignal(id)`, `clearTarget(target)`, `partRef(part)`.
**a11ySemantic (libro §9.1)**:
- New `MorfoA11ySemantic` (requiresPersistentTrace, requiresLiveRegion,
requiresFocusMove, keyboardEquivalent, reducedMotionFallback) on MorfoEvent.
- `ActiveDom.prefersReducedMotion`: reactive tracker via media query
(`src/arts/adom/reduced-motion.svelte.ts`).
- `ActiveUix.announce(msg, priority?, timeout?)`: lazy-created live region
via `dom.writeNode`.
- `Soma.runtime()` auto-wires `sources.announce`.
- `SomaRuntime.trigger()` honors a11ySemantic after emit (live region, focus,
reduced-motion fallback including `channels: []` for 'state').
**Polymorphic events (libro §5.3)** — ADITIVO sobre shape concreto:
- `allowedFamilies?: readonly SemaFamily[]` opcional en MorfoEventSemantic.
- Provider override via `runtime.trigger(name, { semantic })`.
- Default family of the morfo is implicitly allowed.
- New `SomaRuntimePolymorphicError`.
- New `isPolymorphicSemantic` type guard.
Synthetic `prewriteFixtureMorfo` (src/uix/morfo/test-fixtures.ts) decouples
compile + runtime tests from the production morfo catalogue.
671 tests pass in sema + morfo + soma + adom scopes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
e6eee766ee |
Improve words editor tooling
|
5 months ago |
|
|
7afa7057d4 |
docs(soma): add Sema events section to 23 component READMEs
Documenta los cambios doctrinales del sprint Lectura C + cabling soma. Cada README añade una sección `## Sema events` con tabla (event / family / verb / target / intent / when) + referencia al pack si existe + notas doctrinales de las correcciones aplicadas. Componentes: - Packs creados este sprint (11): switch, toggle, toggle-group, menubar, navigation-menu, dropdown-menu, context-menu, tree-view, tree-grid, tooltip, collapsible. - Componentes con emisión cableada sin pack (8): listbox, grid-list, table, feed, command, carousel, announce, clipboard. - Componentes gestuales con notas sobre handle-scroll no-emitido (3): drag-drop, virtual-list, virtual-grid. - Checkbox: documenta la doctrina de dos eventos direccionales (commit-toggle-check/uncheck con intent affirm/neutral). Correcciones doctrinales documentadas: - announce: commit-announce-* → signal.announce / signal.alert - tree-view: target item → branch para emerge-expand/collapse - table: shift.sort → commit.set; shift.expand-row → emerge.expand - drag-drop: commit.drag-start/drop → handle.pick/drop - clipboard: commit.copy → commit.save; commit.copy-error + threat → commit.fail + risk - virtual-list/grid: handle-scroll declarado pero NO emitido (decisión consciente — buzz nonstop con family.handle haptic-only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
cbf76b66c8 |
feat(soma): wire runtime.trigger for batch C (gestural + wrappers)
Cierra el sprint de cabling. De los 6 originales del batch C, 3 son
wrappers sin events propios (time-range-field, link-preview,
alert-dialog) que delegan en su componente compuesto — zero wiring
necesario. Los 3 restantes:
- drag-drop:
- startDrag(...) → handle-pick en draggable con params.source
como fallbackTarget.
- commitDrop(target) → handle-drop en droppable con target.
- cancelDrag() → commit-cancel en draggable con drag.source.
- virtual-list:
- scrollToIndex(...) → shift-navigate-to-index en viewport
(programmatic discrete-navigation moment).
- observeResize callback → commit-set-resize en provider
cuando el clientWidth/Height realmente cambia (skip initial).
- NO emite handle-scroll: family.handle es haptic-only, el
scroll listener fires en cada píxel — emitiría buzz nonstop.
Documentado como decisión consciente; apps con scroll-feedback
wiran su propio throttle.
- virtual-grid: análogo virtual-list, con scrollToCell →
shift-navigate-to-cell. NO emite handle-scroll-row /
handle-scroll-column por la misma razón.
Verificación:
- npm run morfo:vocabulary → EXIT 0
- npx vitest run src/uix/sema src/uix/morfo → 195/195 pass
- npx vitest run src/uix/soma → 419/419 pass
Tras este commit: TODOS los morfos con scope:sema + events declarados
emiten desde soma (excepto los 4 `handle-scroll*` de virtual-list/grid
intencionalmente saltados). Cierra el caveat "soma no emite" del
proyecto memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
0784cae578 |
feat(soma): wire runtime.trigger for batch B (8 components)
Continúa batch A. Estos 8 componentes tienen scope:sema y events
declarados pero no usan pack propio — emiten sobre family base.
- listbox: select() central → commit-select / commit-unselect en item.
- grid-list: select() central → idem en row.
- table:
- SortTrigger.onclick/onkeydown → commit-set-sort en column-header.
- Row.onclick/onkeydown → commit-select en row.
- RowDetailTrigger → emerge-expand-row en row (solo transición
closed → open; el morfo no declara collapse counterpart).
- feed:
- focusAt() (centraliza navegación de teclado) → shift-navigate-focus-item
en article.
- PageDown-en-final + Sentinel.onIntersect → commit-submit-load-more
en provider.
- command:
- Item.onclick → commit-submit-invoke en item (path mouse).
- selectCurrent() (Enter key) → commit-submit-invoke vía lookup
por data-value en provider ref.
- carousel: commit() central (next/prev/goto/autoplay) →
shift-navigate-slide en item, lookup por data-index.
- announce: announce() → signal-alert (assertive) o signal-announce
(polite). clear() → commit-reset.
- clipboard: copy() acepta optional triggerEl → commit-save-copy
(success) / commit-fail-copy (catch). Trigger.onclick pasa
e.currentTarget.
Verificación:
- npm run morfo:vocabulary → EXIT 0
- npx vitest run src/uix/sema src/uix/morfo → 195/195 pass
- npx vitest run src/uix/soma → 419/419 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
adf89a0811 |
feat(soma): wire runtime.trigger for batch A (7 components)
Los 11 packs sema shipped en commit |
5 months ago |
|
|
66318c4759 |
fix(tree-view): target branches for emerge-expand/collapse
Morfo declaraba target='item' para emerge-expand y emerge-collapse,
pero los items son hojas — no se expanden. El stamp del data-event-*
debe aterrizar en el branch (data-tree-view-branch), que es el part
que realmente revela/oculta contenido.
- Morfo: target: v.partRef('item') → v.partRef('branch') en ambos
events de emerge. commit-select sigue en item (selección aplica
tanto a leaves como a branches).
- Pack: selector `onItem(emerge-*)` → `onBranch(emerge-*)`. Las dos
cascadas de emerge ahora matchean el elemento correcto en DOM.
tree-grid no afectado: su Row es a la vez item y branch (data-has-
children + data-expanded en el mismo part), así que target='row'
ya estaba correcto.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
43b1537291 |
refactor(picker-shell): close as INTERNAL primitive (audit P1 #5)
picker-shell quedó suelto en el audit-codex: existía un morfo placeholder en src/uix/morfo/components/picker-shell.ts sin imports, sin events, sin texts, y los 5 composite pickers (date / date-range / time / time-range / color) consumían eidos parts via re-export bajo SU namespace (<DatePicker.Footer>, etc.) — apps nunca escribían <PickerShell> directo. Cierre como INTERNAL primitive per la propia recomendación del audit: - Movido src/uix/morfo/components/picker-shell.ts → src/uix/morfo/internal/picker-shell.ts. El audit walk de morfo/components/ ya no lo encuentra. Cero imports rotos (no había ninguno). - README en src/uix/eidos/components/picker-shell/README.md declara el status INTERNAL + diagrama de composición de los 5 pickers. - Docstring del morfo actualizado con STATUS: INTERNAL explícito. - audit-codex-full-1.md sección 5 marcada RESUELTO con verbatim del cierre. Verificación: - npm run morfo:vocabulary → EXIT 0 - npx vitest src/uix/sema src/uix/morfo → 195/195 pass - npm run component:audit → picker-shell ya no aparece (no era public component, no debe figurar en lista de NEEDS-WORK) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
826ca2bf46 |
docs(sema): add D.8 — channels: qué es canal y qué no
Doctrina arquitectural sobre límites de los canales runtime: - Canales built-in del framework: sound + haptic (los únicos). - ARIA estructural: declarativo en morfo, NO canal. - ARIA dinámico (live regions): soma escribe directo, NO canal. - Visual (motion / color / presence): eidos CSS via data-event-*, NO canal. - Regla operativa: algo es canal sólo si recibe SemanticSignal, acepta modulación por intent.deltas, y tiene signature paramétrica análoga a sound/haptic. ARIA dinámico falla la modulación. - Channels extensibles: declaration merging del registry; opt-in por app (voice, a11y formal, etc.). Framework no envía ninguno. Cierra una tentación que surgió en D.7 — convertir ARIA en canal era confusión categorial. Esta sección fija los límites para que nadie reincida. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
2d562f3788 |
feat(sema): Lectura C steps 5-8 — 11 sema packs + doctrina samples
Step 5 — 3 packs alta-frecuencia (silencio + tap leve):
switch, toggle, toggle-group
+ tuning `form.toggle.silent: { gain: { op: 'add', value: -0.3 } }`
Step 6 — 6 packs menús + árboles (emerge.soft / form.commit.subtle):
menubar, navigation-menu, dropdown-menu, context-menu,
tree-view, tree-grid
Step 7 — card scope: KEEP `sema` + `family-default` (sin código).
Step 8 — 2 packs (versión lean tras descartar 5 prematuros):
tooltip (silent-by-default)
collapsible (mirror accordion)
+ tuning `tooltip.silent: { gain: { op: 'add', value: -0.2 } }`
Doctrina codificada en LIBRO_VARIACIONES_Y_EXTENSIONES.md:
- D.5 — packs alta frecuencia (toggles)
- D.6 — packs superficies menú / árbol
- D.7 — sonido canónico vs samples
SOUND_LIBRARY = recursos | SOUND_TUNINGS = canon | packs = composición
samples NO sustituyen firma con intent.deltas
sampleOverlay descartado permanentemente (sobreingeniería)
family signal admite excepción para marcas culturales
Cierra Lectura C steps 5-8 del audit-codex P1 #3. Total: 11 packs
nuevos sobre 9 perfiles canónicos de tuning. 195/195 tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
504780ad8b |
feat(morfo/sema): add expression field + sema coverage check (P1 #3)
User doctrinal decision 2026-05-26: `scope: ['sema']` declares
**participation** in sema. It does NOT impose the obligation of
shipping a pack. The new `expression` field on Morfo declares HOW the
component contributes its perceptual signature.
Schema change (src/uix/morfo/types.ts + schema.ts):
type SemaExpressionMode = 'pack' | 'family-default' | 'delegated' | 'none';
interface Morfo {
// ...
expression?: SemaExpressionMode;
}
Semantics:
- 'pack' — sema/components/{kebab}.ts cascades the signature
- 'family-default' — use family base + intent deltas as-is (no override)
- 'delegated' — composite; perceptual expression lives in children's packs
- 'none' — explicit opt-out (rare; reserved for headless contracts)
Coverage check (scripts/morfo-vocabulary-check.ts §4):
FAIL events declared + no 'sema' in scope — incoherent
WARN scope:sema + events + no pack + no expression — ambiguous
OK scope:sema + events + (pack OR expression) — explicit
OK no events — coverage N/A
Pack discovery is automatic from filesystem; the morfo doesn't need to
declare `expression: 'pack'` redundantly. Lint passes when a pack file
exists at `src/uix/sema/components/{kebab}.ts`.
Applied to 5 morfos that had events but no sema scope (incoherent state):
- announce, clipboard, listbox, virtual-grid, virtual-list
now declare scope: ['soma', 'sema'] + expression: 'family-default'.
Applied to 28 morfos that had scope:sema + events but no pack:
- 4 composites (date-picker, date-range-picker, time-picker,
time-range-picker) → expression: 'delegated'
- 24 others (button, card, carousel, collapsible, color-field, command,
context-menu, drag-drop, dropdown-menu, feed, grid-list, menubar,
month-grid, navigation-menu, range-calendar, switch, table, time-field,
toggle-group, toggle, tooltip, tree-grid, tree-view, year-grid)
→ expression: 'family-default'
The 29 morfos that already have packs are not touched; the lint
discovers their packs from the filesystem.
Pending follow-up (separate commits per author's plan):
- Create packs for high-frequency toggles (switch, toggle, toggle-group)
- Create packs for menus / navigation / tree (menubar, navigation-menu,
context-menu, dropdown-menu, tree-view, tree-grid)
- Review card scope (may not need sema scope at all)
- Per-component review of the rest
Verification:
- npm run morfo:vocabulary: EXIT 0
- 0 coverage FAILs, 0 coverage WARNs
- vitest src/uix/sema src/uix/morfo: 195/195 pass
- 7 name-shape WARNs remaining are all words.* (separate dev track)
Closes P1 #3 from src/uix/audit-codex-full-1.md (Sema coverage policy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
b0869878d8 |
docs(audit): mark P1 #4 (non-canonical event verbs) as RESUELTO
Updated section 4 of audit-codex-full-1.md to reflect the closure of P1 #4 in the sema canon sprint (commits |
5 months ago |
|
|
00f0b740e6 |
refactor(sema/docs): apply author's evaluation — status classification + intentRequirement/intentGuidance split
Two changes in one commit:
1. **Doctrina del autor sobre el documento de variaciones**
The author evaluated LIBRO_VARIACIONES_Y_EXTENSIONES.md and gave per-
entry verdicts. The doc is now rebranded, restructured, and classified:
- Renamed: "Registro de desviaciones entre implementación y canon
editorial". Disclaimer at top: not the book, internal registry only.
- Formal status classification (BOOK_CANON / PROJECT_CANON / CANDIDATE
/ LOCAL_EXTENSION / DEPRECATED / ALIAS / IMPLEMENTATION_CONTRACT).
- Per-entry verdicts assigned per author's evaluation. Author's
doctrinal texts included verbatim where given.
- A.6 split per verb (was a single bullet for apply/partial/block/
move/upload; now each has its own status: apply=BOOK_CANON,
move=BOOK_CANON, upload/partial/block=CANDIDATE).
- A.6/A.7 ordering fixed (commit.unselect now A.11 — terminal).
- C.3 split into C.3.a (renames mecánicos) y C.3.b (correcciones
doctrinales — command intent shift).
- E (resumen ejecutivo) actualizado: clear/unselect movidos a
resueltos (estaban contradiciéndose). Pendientes reales listados.
- F + new section G ("Anti-mezclas") con disciplina sobre el doc.
2. **Family policy: split `intentPolicy` en `intentRequirement` +
`intentGuidance`** (D.3 del doc).
La policy actual `'allowed' | 'expected' | 'optional'` mezclaba dos
ejes que el autor pidió separar:
- `intentRequirement: 'required' | 'optional' | 'forbidden'` —
compile-time type constraint.
- `intentGuidance: 'expected' | 'contextual' | 'discouraged'` —
guía doctrinal sin efecto en tipos.
Policy nueva:
| family | requirement | guidance |
|----------|-------------|--------------|
| contact | optional | discouraged | (Cap 22 §11)
| commit | required | expected |
| signal | required | expected |
| handle | optional | contextual |
| emerge | optional | contextual |
| shift | optional | contextual |
| sustain | optional | contextual |
| delegate | optional | contextual | (Cap 29 §4)
`IntentExpectedFamily` deprecated → `IntentRequiredFamily` (alias
mantenido). `IntentPolicy` type también deprecated.
Consumers actualizados: event.ts (isSemaEvent usa requirement),
validation.ts (validateSemaEvent usa requirement), event.test.ts
(tests usan ambos campos), dialog.ts (comment doc update).
3. **Bug menor encontrado en `SEMA_TRANSITIONAL_FAMILIES`**: faltaba
`delegate`. Añadido. (Era inconsistente con SemaTransitionalFamily
type que sí lo incluye.)
Verification:
- vitest src/uix/sema src/uix/morfo: 195/195 pass
- morfo:vocabulary: 7 warns (todos words/*, separate dev track)
- EXIT 0
Pendiente futuro (no en este commit):
- Añadir flag `emission` a MorfoEvent type per D.2 (eventos declarados
pero no emitidos)
- Re-evaluar verbos CANDIDATE (acknowledge/confirm/upload/partial/
block) cuando aparezcan más casos de uso
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
bbfefdb63d |
refactor(morfo): rename 12 events to {family}-{verb}-{variant} canonical shape (Capa 3 cluster otros)
Closes the last cluster of name-shape drift in the non-words morfos. 11
are pure cosmetic renames (declared verb already canonical); 1
includes a doctrinal correction.
Renames (verbo declarado canónico, solo el name):
- carousel.shift-slide → shift-navigate-slide
- feed.shift-focus-item → shift-navigate-focus-item
- feed.commit-load-more → commit-submit-load-more
- file-upload.commit-add → commit-set-add
- file-upload.signal-reject → signal-warn-reject
- form.signal-invalid → signal-warn-invalid
- number-field.handle-scrub → handle-drag-scrub
- range-calendar.commit-start → commit-select-start
- range-calendar.commit-range → commit-select-range
- tags-input.commit-add → commit-set-add
- tags-input.signal-reject → signal-warn-reject
Doctrinal correction:
- command.commit-invoke (declared submit + fulfill)
→ commit-submit-invoke + submit + AFFIRM (not fulfill)
Per book cap. 22 §8 ("Error típico: contact.press + fulfill — esto
hace que el sistema diga 'ya terminó' cuando solo ha empezado.
Contact inicia. Commit resuelve."), the user submitting a command
in the palette does NOT mean the command has completed. Same
precedent as Button: celebrate-before-time is wrong. Affirm
acknowledges the submission; the command's actual completion
(success/failure) fires downstream from whichever morfo owns it.
Consumers updated:
- sema/components/{file-upload,form,number-field,tags-input}.ts
- soma/components/{file-upload,form,number-field,range-calendar,tags-input}/*.ts
- sema/resolver.test.ts (event name + data-event selector)
Verification:
- morfo:vocabulary: warns 19 → 7 (-12), the 7 remaining are all words.*
(separate dev track, excluded). EXIT 0.
- vitest src/uix/sema src/uix/morfo: 195/195 pass
Doc src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md C.3 marked RESUELTO
with full list of renames + the command intent correction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
f3641347de |
feat(sema/morfo): add commit.unselect to canon + apply to 6 morfos
User doctrinal decision (2026-05-25): add `unselect` as canonical verb of
the commit family. The pair `select` / `unselect` covers results applied
on the selection state of an item — distinct from `toggle` (binary
control flip) and `remove` (retirement from a collection).
Quoting the author:
"Seleccionar y deseleccionar son resultados aplicados sobre el estado
de selección de un elemento. Eso es commit, porque el resultado queda
aplicado.
- No es remove: no estás eliminando el item ni sacándolo de una
colección funcional; solo estás cambiando su estado de selección.
- No es necesariamente toggle: toggle describe mejor el mecanismo
binario o el control, pero no expresa tan bien el resultado
semántico concreto."
Changes:
- src/uix/sema/verbs.ts: add `unselect` to SEMA_VERBS.commit after
`select` (natural pair). Header comment documents the doctrinal
distinction with toggle and remove.
- 6 morfos updated: calendar, combobox, grid-list, listbox, select,
tag-group. Each changes `commit-unselect` from
`verb: 'remove' + intent: 'neutral'` to
`verb: 'unselect' + intent: 'affirm'`. Names preserved (already
canonical now). Intent `affirm` because "the action applied
correctly" (sobrio, not celebratory).
- src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md: C.2 marked RESUELTO with
the full doctrinal reasoning verbatim. A.3 (remove vs delete) updated
to reflect the three-way distinction now formalized
(unselect/remove/delete). New A.7 entry for `unselect` itself with
"para el libro" note (add to Cap 23 §5).
Verification:
- morfo:vocabulary: warns 25 → 19 (-6 unselect cluster), EXIT 0
- vitest src/uix/sema src/uix/morfo: 195/195 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
1582572e9b |
refactor(morfo): commit-clear → commit-reset across 10 components (cluster clear resolved)
User directive: clearing a field is `commit.reset` per book cap. 23
("vuelve a estado inicial") — fits all 10 components in the cluster
since each goes back to empty-initial-state, not discarding meaningful
defaults.
Components updated (morfo + soma trigger + sema cascade + test):
- color-picker, date-field, date-picker, date-range-picker
- file-upload, range-calendar, tag-group, tags-input
- time-picker, time-range-picker
range-calendar.commit-clear was declaring `verb: 'remove'` — corrected
to `'reset'` along with the name change.
All event names now: `commit-reset` (family commit, verb reset, intent
neutral). No variant suffix needed; no ambiguity of multiple resets in
any single component.
words.commit-clear excluded — separate dev track.
Doc updated: src/docs/LIBRO_VARIACIONES_Y_EXTENSIONES.md C.1 marked as
RESUELTO with author's disposition.
Verification:
- morfo:vocabulary: warns 35 → 25 (-10), EXIT 0
- vitest src/uix/sema src/uix/morfo + tag-group tests: 199/199 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
670c54177d |
docs: document deviations from the book canon (LIBRO_VARIACIONES_Y_EXTENSIONES.md)
Inventory of every place the UIX project canon diverges from, extends, or interprets «Diseñando lo que ocurre» beyond its literal lists. For each entry: book reference, decision taken, argument, and a pending question for the author to evaluate. Six categories: - A. Verbs added to canon beyond book's literal lists (handle.scroll, commit.acknowledge, commit.remove, commit.confirm, commit.set, commit.apply/partial/block/move/upload) - B. Interpretive adoptions not literally in the book (drag cancellation, programmatic scroll, data resize, sort, auto-revert states, toggle phases, contact intent as visual-only) - C. Open doctrinal questions (clear, unselect, ~19 individual events) - D. Architectural decisions relevant to the book (composition over overload, declared-but-not-emitted events, family policy nuance) - E. Executive summary - F. Disposition options for the author (accept, reject, reformulate, keep as local extension) Author can use this to decide what passes to the book vs what stays as project extension. Until disposition, this doc is source of truth for deviations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
103c49a7ab |
refactor(morfo): align 22 events with book family doctrine (Capa 2)
Resolves family-level misclassifications across 9 morfos. Each cluster
maps a doctrinally wrong family to the book's literal prescription:
CLUSTER 4 — announce (cap. 24 §5):
- commit-announce-polite (commit.submit) → signal-announce (signal.announce + neutral)
- commit-announce-assertive (commit.submit) → signal-alert (signal.alert + threat)
Announcing is the canonical signal verb. Was misclassified as commit.
CLUSTER 5 — checkbox (cap. 22 §10, option B per user):
- commit-check (commit.select) → commit-toggle-check (commit.toggle + affirm)
- commit-uncheck (commit.remove) → commit-toggle-uncheck (commit.toggle + neutral)
Book literal: "Toggle: contact.press → commit.toggle + affirm". Two events
retained (option B) so check/uncheck carry different intents; same canonical
verb `toggle` per book.
CLUSTER 1 — tree-view + tree-grid (cap. 26):
- shift-expand (shift.navigate) → emerge-expand (emerge.expand)
- shift-collapse (shift.navigate) → emerge-collapse (emerge.collapse)
Book literal: "emerge.expand — una región crece y revela contenido". Tree
node expansion reveals content; it's not a context shift.
CLUSTER 3 — drag-drop (cap. 25 §4):
- commit-drag-start (commit.submit) → handle-pick (handle.pick)
- commit-drop (commit.submit) → handle-drop (handle.drop)
- commit-cancel verb 'reset' → verb 'cancel' (was inconsistent with name)
Book literal pick → carry → drop. The commit (reorder/delete/move) lives
downstream in whichever morfo owns the moved entity, not on drag-drop.
CLUSTER 6 — table (cap. 23, 26):
- shift-sort (shift.navigate) → commit-set-sort (commit.set + neutral)
Sort sets a criterion; the reorder is consequence. Not regime change.
- shift-expand-row (shift.navigate) → emerge-expand-row (emerge.expand)
Same argument as tree.
CLUSTER 2 — virtual-list + virtual-grid (cap. 25, 27, 23):
- shift-scroll, shift-scroll-row, shift-scroll-column (shift.navigate)
→ handle-scroll, handle-scroll-row, handle-scroll-column (handle.scroll)
User-initiated scroll = direct manipulation of viewport. `scroll` extends
the book's literal handle verb list per Cap 8 §1.
- shift-scroll-to-index, shift-scroll-to-cell (shift.navigate)
→ shift-navigate-to-index, shift-navigate-to-cell (shift.navigate)
Programmatic scroll = the SYSTEM moves the viewport, not the user. Still
shift.navigate, but name now surfaces family+verb canonical shape.
- shift-resize (shift.navigate) → commit-set-resize (commit.set + neutral)
System applies a new container/data size. Useful for "items loaded" cues.
CLUSTER 7 — clipboard (cap. 23, user directive):
- commit-copy (commit.submit + affirm) → commit-save-copy (commit.save + affirm)
Value persisted to clipboard = save per book.
- commit-copy-error (commit.submit + threat) → commit-fail-copy (commit.fail + risk)
Failure is fail + risk (correctable, not threat which is exigent).
- shift-reset (shift.navigate) → REMOVED
Timer-driven internal flag flip; no subscriber; not a perceptive event per
user directive ("eliminar si no es perceptivo"). The visible label revert is
signalled by `data-copied` attribute.
Verification:
- `npm run morfo:vocabulary`: warns 57 → 35 (-22), EXIT 0
- `npx vitest src/uix/sema src/uix/morfo`: 195/195 pass
- checkbox provider tests: 4/4 pass
- `npm run check`: no new errors from these changes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
a74f26728c |
fix(sema/verbs): validateEventName longest-prefix-match for multi-word verbs
The canon includes two multi-word shift verbs: `enter-mode` and `exit-mode`. `validateEventName` was splitting at the first dash and checking the canon for `enter` / `exit` alone — those aren't canon, so legitimately canonical events like `shift-enter-mode` were being flagged as drift. Fixed by matching the longest canonical verb prefix against the tail (family-verb shape) or against the name (bare verb shape). Handles trailing variants too — `shift-enter-mode-edit` recognises `enter-mode` as the verb and `edit` as the variant. Verification: - 1 new test covering multi-word verb shapes; 19/19 pass - `npm run morfo:vocabulary`: 58 → 57 warns (editable.shift-enter-mode no longer falsely flagged), EXIT 0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
23afeb6f86 |
refactor(button): align with book cap. 22 — contact.activate, intent visual-only (Plan B commit 2)
Button morfo declared `commit-action` with verb `action` (not in canon) and full intent range. Per «Diseñando lo que ocurre»: - Cap 22 §10: "Botón: contact.press" — the press is reception of the gesture, not the commit. - Cap 22 §11: "el intent fuerte no debería vivir en el contacto, sino en la señal o consecuencia posterior. La interfaz puede anticipar el peso mediante forma, color o señal previa." - Cap 22 §8: "Error típico: contact.press + fulfill — Esto hace que el sistema diga 'ya terminó' cuando solo ha empezado." Strict book reading applied: - Morfo event renamed: `commit-action` → `contact-activate` (family: `contact`, verb: `activate`, no intent binding) - Soma provider trigger string + comments updated - `intent` prop survives as VISUAL signal only — drives `data-color` and chip variant (anticipatory weight via form/color per cap. 22 §11) - README + types JSDoc updated with the book's prescription and the composition pattern: perceptual richness emerges from the OTHER morfos in the flow (Dialog warning, Item being deleted, etc.) firing their own commit/signal events at the actual moment of the consequence - `EVENT_NAME_ALLOWLIST` in morfo-vocabulary-check.ts emptied — no exceptions to the canon needed No `declaresOutcome` props, no imperative sema escape hatches: the morfo remains the single source of truth for events. If a flow loses perceptual differentiation, the answer is to model the missing morfo, not to overload the Button. Verification: - npm run morfo:vocabulary → EXIT 0 (zero allowlisted) - npx vitest src/uix/sema src/uix/morfo → 194/194 pass - npm run check → no new errors from these changes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
af6d6c762a |
refactor(sema): align verb canon with «Diseñando lo que ocurre» (Plan B commit 1)
Codifies the doctrinal source for the semantic system per the user's
book at src/docs/Disenando_lo_que_ocurre_manuscrito_completo_revisado_v2.docx.
Canon (book cap. 8 + cap. 22-29 + cap. 10):
- 8th family `delegate` added (offer/plan/authorize/act/review/escalate/return)
- `signal.inform` added (book cap. 24 listed 7 signal verbs; we had 6)
- Commit contextual verbs from case studies: apply, partial, block, move, upload
- `sustain.upload` added
- SemaFamily union, SEMA_FAMILY_POLICY, sema-map, visual chan, morfo schema
all extended to include delegate (intentPolicy: 'optional' — no intent by
default per cap. 29 §4)
Lint (scripts/morfo-vocabulary-check.ts):
- Hard-fails on declared `semantic.verb` not in family canon (the doctrinal
contract: subscribers query by family+verb)
- Soft-warns on event name shape drift (declared verb is the truth, name is
informational)
- Allowlists `button.commit-action` with TODO referencing Plan B commit 2
(Button's intent vs contact question per cap. 22 §11 needs design sign-off)
3 mechanical violation fixes:
- textarea: `shift-count-overflow` (family shift, verb limit) → `signal-warn-count-overflow`
(signal.warn + risk per cap. 24 — corregible warning, not context shift)
- password-field: `shift-toggle-visibility` → `commit-toggle-visibility`
(commit.toggle + affirm per cap. 23 — state fixation, not context shift)
- password-field: `shift-caps-state` → `signal-notify-caps-state`
(signal.notify + neutral per cap. 24 — system orients attention without urgency)
- month-grid + year-grid: `nav-step` → `shift-navigate-step` (family/verb
already canonical, name aligned to {family}-{verb}-{variant})
Verification:
- npm run morfo:vocabulary → EXIT 0 (1 allowlisted, 59 pre-existing name-shape
warns documenting visible drift for future sprints)
- npx vitest src/uix/sema src/uix/morfo → 194/194 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
928b3c15b6 |
fix(eidos/command): route shortcut keyboard listener via ActiveDom
Cierra audit Codex P1 #2: «command-shortcut.svelte.ts registra window.addEventListener('keydown', ...) directamente». Eidos no debe gestionar DOM activity por su cuenta — todo binding pasa por ActiveDom (regla: «toda DOM activity via ActiveDom»). Cambios: - `command-shortcut.svelte.ts`: signature ahora es `bindCommandShortcut(dom: ActiveDom, shortcut, onTrigger)`. Usa `dom.getWindow()` + `dom.listen(win, 'keydown', handler)` en vez de `window.addEventListener('keydown', handler)`. Retorna la cleanup function de dom.listen (sigue siendo el contrato esperado por el $effect del consumer). - `command.svelte`: pasa `eidos.dom` como primer arg (ya tenia `const eidos = ActiveEidos.require()`). - `command-dialog.svelte`: anadido `import { ActiveEidos }` + `const eidos = ActiveEidos.require()` (no lo tenia). Mismo patron. Beneficios: - ActiveDom owns el lifecycle (cleanup tracked, pause-on-dispose, iframe/popup-aware via targetWindow). - Eidos visual layer queda sin DOM API directo (regla cumplida). - Tests pueden mockear `dom.listen` sin parchear window globalmente. Verificacion: - `grep window.addEventListener src/uix/eidos/components/`: cero matches (solo la mencion en JSDoc del cambio explicativo). - `npm run check`: 0 errors mantenido. - HTTP 200 en `/uix/components/command`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
c85c729dde |
fix(eidos): use ActiveEidos.require() instead of getActiveUix() in 15 components
Cierra P1 #1 del audit Codex: «Eidos no importa `\$active-uix` directamente — consume el runtime via `ActiveEidos.require()` que expone la surface bounded (langs / format / prefs / dom / uix)». `ActiveEidos` ya existe (src/uix/eidos/active-eidos.svelte.ts) y la mayoria de eidos components la usan (accordion, alert-dialog, banner, box, button, calendar, card, ~50 archivos). Estos 15 quedaban como strugglers/adiciones recientes que se saltaron el patron. Patron canonico aplicado en cada archivo: -import { getActiveUix } from '\$active-uix'; -const uix = getActiveUix(); -uix.langs.t(...) +import { ActiveEidos } from '\$uix/eidos'; +const eidos = ActiveEidos.require(); +eidos.langs.t(...) Archivos (15): - badge/badge.svelte - format-date/format-date.svelte - format-number/format-number.svelte - image/image-{error,fallback,_}.svelte (3) - password-field/password-field-caps-lock-indicator.svelte - picker-shell/picker-shell-{cancel,clear,close}.svelte (3) - relative-time/relative-time.svelte - s-text/s-text.svelte - skeleton/skeleton.svelte - spinner/spinner.svelte - trans/trans.svelte Sin nueva surface introducida — todas las usages (langs, format.dates, format.numbers, format.currency) ya estaban expuestas en `ActiveEidos`. Verificacion: - `grep getActiveUix src/uix/eidos/components/ --include='*.svelte'`: cero matches. Quedan referencias en comments/READMEs/types.ts (informativas, no imports). - `npm run check`: 0 errors mantenido. - HTTP 200 en /uix/components/{trans,format-date,format-number,relative-time,badge,skeleton,spinner,image}. Esto es el fix correcto del audit (no la migracion-a-soma del commit anterior que se hizo por mal entendimiento de la regla — revertida en el commit precedente «Revert ... service components from Eidos to Soma»). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
cd2c0326dc |
Revert "refactor(soma/eidos): migrate 4 service components from Eidos to Soma"
This reverts commit
|
5 months ago |
|
|
540abee026 |
refactor(soma/eidos): migrate 4 service components from Eidos to Soma
Architectural cleanup: Trans, FormatDate, FormatNumber, RelativeTime no son visuales — son behavior alrededor del runtime (`uix.langs.t/ts`, `uix.format.*`). Estaban en `eidos/components/` solo porque ahi se inventariaban los wrappers. Naturaleza real: Soma. Beneficio principal: el invariante "Eidos no importa `getActiveUix()`" recupera su pureza — los 4 service components ya no contaban como violación del audit Codex (P1 #1 reducido en 4 archivos + dependencias). Movimientos (4 componentes, 31 archivos staged): - src/uix/eidos/components/{kebab}/ → src/uix/soma/components/{kebab}/ - {kebab}.svelte → components/{kebab}.svelte (subdir convencion Soma) - types.ts → types.ts - README.md → README.md - index.ts → exports.ts (Soma pattern: index re-exports from exports) - + nuevo index.ts: `export * from './exports';` - Eidos dirs eliminados (4 directories). Morfos: scope `['eidos']` → scope `['soma']` (4 archivos): - morfo/components/trans.ts - morfo/components/format-date.ts - morfo/components/format-number.ts - morfo/components/relative-time.ts Demos (4 +page.svelte) updated: - import paths `$uix/eidos/components/{kebab}` → `$soma/components/{kebab}` - meta-pill `scope: eidos` → `scope: soma` Headers de los .svelte updated: "Eidos `<X>`" → "Soma `<X>` — service component" + parrafo explicando por que vive en Soma (behavior, no visual primitive). Verificación: - `npm run check`: 0 errors (mantenido). - HTTP probes /uix/components/{trans,format-date,format-number,relative-time}: todos 200. Sin barrel re-exports a limpiar (no existían). Sin consumers fuera de los 4 demos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
546aa8d0ee |
fix(soma/eidos): close last 5 check errors — PassthroughProps helper + floating/shell cast
`npm run check`: 5 errors → 0 errors.
floating/shell.ts (Phase 1 — local fix):
- `buildFloatingShellWrapperProps` returned a type where TS couldn't
prove `transform` stayed required after the conditional-object
spread. Runtime preserves the key; we cast at the return boundary so
consumers downstream keep the strict shape.
soma/types/html.ts (Phase 2 — canonical pattern):
- Nuevo `PassthroughProps<T>` helper para resolver el drift Eidos→Soma
estructuralmente. Es `Omit<HTMLAttributes<T>, 'style' | 'id' |
'children' | 'dir' | 'value' | 'placeholder'>`. Las keys excluidas son
las que Soma narrowa en sus Provider types — incluirlas en Eidos
wrappers (via el plain `HTMLAttributes<T>`) producía "Expression
produces a union type that is too complex to represent" y errores de
incompatibilidad al hacer spread.
- Exportado por `soma/types/index.ts`.
eidos picker views (Phase 3 — adopción):
- `date-picker-year-view.svelte` y `date-picker-month-view.svelte`
tipados como `Props = PassthroughProps<HTMLDivElement>` en lugar del
plain `HTMLAttributes<HTMLDivElement>`. Conserva data-*, aria-*,
class, role, tabindex etc. — solo dropea las keys conflictivas.
Pattern reusable: cualquier futuro Eidos wrapper que envuelva un Soma
Provider via `{...props}` debe usar `PassthroughProps<T>` en vez de
`HTMLAttributes<T>`. Documentado en el JSDoc del helper con ejemplo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
ee1afdcfbb |
fix(langs): close P0 #3 — translations 58 errors + 8 warnings → 6 (all words) + 2
Catalogs creados (los 3 que faltaban): - src/uix/langs/components/month-grid.ts (label) - src/uix/langs/components/year-grid.ts (label) - src/uix/langs/components/textarea.ts (label + count) Catalogs con claves anadidas: - field.ts: required-indicator (*), optional-indicator ((opcional)) - time-picker.ts: clock + hour + minute + second + day-period + am + pm + clear + cancel + close - time-range-picker.ts: clock + day-period + clear + cancel + close Common keys anadidas (compartidas entre grid components y picker views): - common.month-grid.prev-year, next-year - common.year-grid.prev-page, next-page Barrel index.ts actualizado con los 3 nuevos catalogs: month-grid, textarea, year-grid. Cleanup de huerfanas que ningun morfo referenciaba (date-picker y date-range-picker tenian clear/cancel/close en el catalog pero el morfo los habia migrado a Button consumer via PickerShell): - date-picker.ts: drop clear, cancel, close - date-range-picker.ts: drop clear, cancel, close Errores restantes (6 + 2 warn) son TODOS de words — out of scope (en desarrollo aparte, no se toca esta sesion). Pre: 58 errors + 8 warnings (translations:check) Post: 6 errors (words) + 2 warnings (words) Net: 0 errores y 0 warnings de no-words. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
5256008700 |
fix(audit-codex): resolve P0 blockers + close most check errors (42 → 5)
Codex audit P0 items addressed: P0 #1 - color-picker morfo invariante (`morfo:vocabulary` failed): - close-commit / close-cancel / close-dismiss / dismiss-outside prewrites escribian `data-last-action` en Content sin declararlo. Anadido a la declaracion de `data` con values [committed/cancelled/dismissed/dismissed-outside], severity optional. P0 #2 - password-field `kind: 'private'` rechazado por tipo: - `MorfoPartKind` extendido de `'public' | 'virtual'` a `'public' | 'private' | 'virtual'`. Semantica: `private` = en contrato (eidos lo estila, validador lo chequea) pero no compuesto por consumer (oculto en docs). Justifica el StrengthMeterLabel sub-part. P0 #2 - Soma `defaultElement: 'time'` rechazado: - `MorfoElement` extendido con `'time'`. format-date y relative-time morfos lo usan. P0 #2 - Picker family drift (12 errors): - `DatePicker/DateRangePicker/TimePicker/TimeRangePicker.ProviderProps` pasaron de "virtual composition shell" (sin WithChild) a `WithChild<Omit<PrimitiveDivAttributes, 'value' | 'placeholder'> & {...}>` para alinear con la impl real que renderiza <div>, expone ref/child y acepta data-* attrs (mismo patron que date-field/time-field). - contracts.test.ts §1.1.C actualizado: ahora exige WithChild + DOM passthrough en lugar de prohibirlos. Coherente con date-field/time-field. P0 #2 - Eidos year/month-view en date-picker pasaba `size` a Soma: - Soma's DatePicker.YearView no modela `size` como prop (delega a YearGrid.Provider que tampoco). Cambiado a `data-size={...}` para que fluya por DOM passthrough hasta el recipe eidos que keys por `[data-size]`. P0 #2 - `KEYS.Enter` y `KEYS.Space` no existen: - table-provider usa `KEYS.ENTER` y `KEYS.SPACE` (ya correctos en consts). P0 #2 - `Snippet` import faltante en time-range-picker types. P0 #5 - tmp/lexical contaminaba `npm run check`: - `tsconfig.json` anadido `exclude: ['tmp/**', ...]`. svelte-check ya no carga los `svelte.config.js` de lexical demos (faltaban deps). Errores mios introducidos por el sprint reciente: - `DemoLocale` no asignable a `SupportedLocale`: cambiado tipo del prop `locale` en FormatDate/FormatNumber/RelativeTime a `string` (acepta cualquier BCP-47, Intl no necesita restricciones de catalogo). - `RelativeTimeUnit` con `'auto'` no asignable a `Intl.RelativeTimeFormatUnit`: `resolveUnit()` ahora tipa el retorno como `Exclude<RelativeTimeUnit, 'auto'>`. Errores pendientes (5): - `floating/shell.ts:92` - wrapperProps requiere `transform: string | undefined` (no optional) pero buildFloatingShellWrapperProps devuelve optional. - `date-picker-year-view.svelte` / `date-picker-month-view.svelte` x2: Soma YearView/MonthView accepta `dir: 'ltr' | 'rtl' | undefined` pero HTMLAttributes provee `'ltr' | 'rtl' | 'auto' | null | undefined`. Conflict en passthrough. Estos 3 son drift entre Soma type strict y HTML passthrough laxo. Necesitan diseno mas cuidadoso (audit recomienda "resolver como familia"). Estado: 42 errors → 5 errors. morfo-check pasa. morfo:vocabulary pasa (con warnings ya conocidos de words). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
9b809e4ecb |
chore(eidos): drop \$app/environment imports (decouple from SvelteKit)
Eidos es la capa visual y debe ser framework-agnostic. Tres componentes
metian `import { browser } from '\$app/environment'` por reflejo defensivo
SSR sin pensar la consecuencia.
relative-time.svelte:
- Drop completo del guard. El `\$effect` solo corre client-side en
Svelte 5; el `if (!browser) return` era codigo muerto.
s-text.svelte + s-text-virtual-list.svelte:
- Aqui si hace falta guard (los `\$derived` corren durante SSR para
inicializar valores; `getComputedStyle(el)` lanza si lo invocas en
server). Pero reemplazo `browser` con `const isBrowser = typeof
window !== 'undefined'` local — mismo comportamiento, cero coupling
con SvelteKit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
b54d42e230 |
feat(format/dates): add formatRelative to engine + route RelativeTime through runtime
$libs/days/format.ts:
- Nuevo `getCachedRelativeTimeFormat(locale, options)` — twin del cache
ya existente `getCachedDateFormat`. Key `${locale}:${JSON.stringify(opts)}`.
Sites que renderizan listas de timestamps relativos (feeds, activity
logs) hit identical pairs y mantienen el loop caliente.
arts/format/dates/engine-dates.ts + types.ts:
- Nuevo `formatRelative(value, unit, options?, locale?)` en EngineDates.
Defaults: `numeric: 'auto'` (colapsa 0/-1/+1 en "yesterday"/"today"),
`style: 'long'`. Per-call locale override no muta engine state.
arts/format/dates/active-dates.svelte.ts:
- formatRelative wired via `readFrom(runtime, engine.formatRelative)`
para que el $derived del componente sea reactivo al cambio de locale.
Componente relative-time.svelte:
- Drop del bypass directo a `new Intl.RelativeTimeFormat(locale, opts)`.
Ahora SIEMPRE rutea por `uix.format.dates.formatRelative(magnitude,
unit, { numeric, style }, locale)`. El componente sigue haciendo la
matematica del delta + auto-unit + magnitud — el engine solo expone
la operacion atomica.
- Fallback a Intl directo solo cuando no hay runtime (eidos fuera de
UixApp).
Tests: 17/17 en engine-dates.test.ts (+3 nuevos: formatRelative con
locale del engine, numeric/style options, per-call locale override sin
mutar state).
Docs: arts/format/README.md sub-seccion en Dates con la nueva API.
relative-time/README.md decisiones actualizadas (ya no es la excepcion).
Cierra el ultimo follow-up del sprint de service components — los 4
componentes (Trans, FormatNumber, FormatDate, RelativeTime) ahora
rutean uniformemente por el runtime UIX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
7b01397aab |
feat(format/numbers): per-call locale override (parity with dates engine)
Engine (`arts/format/numbers/engine-numbers.ts` + `types.ts`): - format(value, options?, locale?) acepta tercer arg locale como en dates. Cache (getCachedNumberFormat) keyea por (locale x options). - formatPercent / formatCompact / formatCurrency / formatUnit propagan el locale al format() interno. - No muta engine state (getLocale() sigue devolviendo el activo). Component (`format-number.svelte`): - Drop del bypass `new Intl.NumberFormat(locale, options)` cuando habia locale prop. Ahora SIEMPRE rutea por numbers.format(inputValue, options, locale). Preserva cache + separadores activos + defaultFormat + reactividad del locale. - Fallback a Intl directo solo cuando no hay runtime (eidos fuera de UixApp). Tests: 9/9 en engine-numbers.test.ts (+4 cubriendo format/Percent/ Compact/Currency/Unit con locale override sin mutar state). Docs: arts/format/README.md sub-seccion "Override de locale por llamada" en Numbers (handoff 2026-05-25). format-number/README.md decisiones actualizadas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
89f52c5e88 |
feat(eidos): Card, Avatar ext, Image, SText, Badge, service components + dates engine fixes
Sprint completo: - Card (eidos + sema): elevation/intent/interactive, eventos select+activate. - Avatar: ring, AvatarBadge, AvatarGroup separados; demo Avatar standalone. - Image: status surface (idle/loading/loaded/error) consumiendo soma ImageProvider. - SText + STextVirtualList: canvas-text engine portado de morfo-driven-soma como componentes separados para optimizar bundle (no se importa canvas si no se usa). - Badge: primitivo eidos puro. - Trans, FormatNumber, FormatDate, RelativeTime: 4 service components greppables alrededor de uix.langs y uix.format. Nav group "Utilities". Bugs fix arts/format/dates (engine-dates.ts): 1. Preset / per-axis conflict: Intl.DateTimeFormat rechaza dateStyle/timeStyle con cualquier per-axis (weekday/era/year/month/day/dayPeriod/hour/minute/ second/fractionalSecondDigits) o con timeZoneName. Engine drop-ea ambos presets por defecto cuando detecta conflict (regla estricta — un solo per-axis hace ilegales los dos presets). 2. Per-call locale override: formatDate/Time/DateTime aceptan tercer arg locale?: string. No muta engine state. Cache (getCachedDateFormat) keyea por (locale x options). resolveCallHourCycle: cuando hay locale override y pref auto, el ciclo horario se resuelve desde el locale OVERRIDE. 3. withHourCycle precedence: caller hourCycle/hour12 ahora gana sobre preferencia del engine. Antes spread mal ordenado sobrescribia 'h12' del caller con 'h23' del engine cuando locale resolvia a 24h. FormatDate component vuelve a rutear siempre por uix.format.dates (cache + hourCycle + locale reactivo), pasando locale como tercer arg. Demos service components autonomos del toolbar global: cada uno owns su demoLocale state con 7 chips (es-ES/en-US/en-GB/de-DE/fr-FR/ja-JP/ar-EG). FormatDate stage filtra dateStyle/timeStyle por mode + chip hourCycle anadido. Tests: 14/14 passing en engine-dates.test.ts (+6 nuevos: conflict handling, locale override, hourCycle precedence). Docs: arts/format/README.md (handoff 2026-05-25 con las 3 reglas), README.md por service component, project_service_components_sprint_2026-05-25.md, feedback_service_demos_own_locale.md (pattern reusable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
0804749147 |
Words: text alignment, heading picker, ToolPopover cleanup, word boundary fixes
- Add textAlign to document model (paragraph, heading, quote) - Add setTextAlign engine operation with left/center/right/justify - Add alignment toolbar buttons with icons (TextAlignStart, Center, End, Justify) - Update formatting and full presets to include alignment - Fix sameBlockShape to compare textAlign correctly - Add heading levels h2/h3 with Words.HeadingPicker dropdown (Popover) - Remove Words.ToolPopover component; migrate LinkEditor to native Popover - Fix word boundary algorithm: Intl.Segmenter + CJK/punctuation fallback - Fix bubble menu isSelecting logic and pointer event forwarding - Add 5 engine tests for setTextAlign - Update demo presets for alignment - Words engine tests: 42/42 passing |
5 months ago |
|
|
cc0d38ec52 |
refactor(floating): extract createFloatingShellRoot + buildFloatingShellWrapperProps helpers (audit Round 3 §3 #1)
7 popover-based soma providers (popover, dropdown-menu, context-menu,
combobox, select, tooltip, link-preview) shared two byte-identical
blocks that an investigation report (delegated Plan agent) confirmed
as real duplication after looking carefully past the audit's headline
"600 lines across 15 providers" — the actual scope was ~230 lines
across 7 providers (the audit overcounted by ~2x, similar to the
BaseSegmentProvider case).
Two narrow helpers added in `src/uix/soma/layers/floating/shell.ts`:
- `createFloatingShellRoot({ dom, open, contentRef, onOpenChangeComplete })`
bundles `FloatingProvider.create({ dom })` + the `contentPresence`
constructor (which were verbatim across all 7 providers, byte for
byte). Returns `{ floatingProvider, contentPresence }` the consumer
assigns to `this.*` fields.
- `buildFloatingShellWrapperProps(floating, pointerEvents = 'auto')`
returns the canonical wrapperProps shape (spread floating's
wrapperProps + `style.pointer-events` forced to a value). Identical
in 6 of 7; tooltip parameterises `pointerEvents` to flip on
`hoverableDisabled`.
Explicitly NOT abstracted: FocusScope, Dismissal, ScrollLock,
TextSelection — these look similar but diverge per provider (modal-
derived `trap`, custom `isValidEvent` closures, different close
callbacks — see Round 3 audit analysis). Trying to hide them would
recreate the BaseSegmentProvider trap of flag bloat.
Notable variations preserved:
- popover keeps its `overlayPresence` (popover-only chrome) outside
the shell helper — only `contentPresence` is shared.
- context-menu's SubContent sub-provider also uses the same wrapper
helper (its FloatingProvider.create stays inline because it reads
`this.provider.soma.dom` not `this.soma.dom`).
- dropdown-menu has both a Content and SubContent wrapperProps —
both routed through the helper.
Test result: 2394/2399 passing — no regressions in the 27 tests
across the 7 affected providers. The 5 fails remain Words + cookie
infra (cookie is flaky; sometimes 6, sometimes 5).
Closes the last item of Kim audit Round 3 §3. Net code reduction in
the 7 consumer files is ~85 lines; helper file is 96 lines with
docblocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
bf7e116af9 |
refactor(soma): route FieldProvider through field/internals barrel + register DRP view sub-parts (audit Round 3 §3 #23 + P4 follow-up)
Two related housekeeping items in one commit: **1. FieldProvider via barrel (audit Round 3 §3 #23)** 37 consumer files across soma imported `FieldProvider` from the deep path `'../field/field-provider.svelte'`, mirroring the same pattern that motivated `internals.ts` indirection on the 5 pickers earlier. Adds `src/uix/soma/components/field/internals.ts` re-exporting `FieldProvider` and exposes it from the barrel `exports.ts`. The 37 imports become the canonical `from '../field'`. Consistency only — no behavioral change. **2. DateRangePicker view sub-parts (P4 follow-up)** The MonthView/YearView soma parts created in commit `e93c0c73` stamp internal layout data-attrs (`-header`, `-prev`, `-heading`, `-next`, `-grid`, `-cell`) that weren't declared in the morfo, surfacing as 12 hardcoded-data-attr violations once the contract suite caught up. Declares the 12 sub-parts in the morfo with the canonical kebab prefix (`month-view-*`, `year-view-*`). Each entry minimal (data: [], aria: []) except `*-prev` / `*-next` which stamp `type='button'` and `*-cell` which surfaces the `data-range-{start,end,in-range}` classification states used by the eidos range tinting CSS. Test result: 2393/2399 passing — back to the canonical 6 fails (Words + cookie infra). The 13 transient regressions are closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
3a23fa239b |
docs(pendientes): downgrade BaseSegmentProvider from 'diferir' to 'descartar' — false positive
Deep re-analysis of the date-field (291 lines) and time-field (250
lines) abstract bases — claimed by Kim Round 3 as ~300 lines of
duplication — shows the duplication is superficial:
- ~50 lines structurally identical: constructor, announcer/getMin/
getMax/directionalKeys getters, keydown router skeleton.
- ~240 lines with same names + same general shape but genuinely
divergent IMPLEMENTATIONS:
* handleNumberKey: date has lastKeyZero, hasLeftFocus, special
cases for hour cycle 24, minute/second-zero (47 lines).
Time uses candidate-based prev*10+num (37 lines).
* handleArrowUp/Down: date uses cycleNumericSegment with
DateValue.cycle wraparound. Time uses direct arithmetic.
* handleBackspace: date has '0X' → null special case. Time
doesn't.
* formatValue: date has forDisplay flag. Time doesn't.
* getAnnouncement exists in date, not in time.
* getSegmentProps: date interplays placeholder.set + hour/
dayPeriod intermix. Time is a flat read.
Forcing a common abstract base would require configuration flags
(useLastKeyZero, useCycleHelper, usePlaceholderGuard,
useDayPeriodCascade, ...) that would create a God-Object worse than
the current state. The two classes have legitimately evolved to
distinct behaviors:
- DateField operates over DateValue (CalendarDate/DateTime) with
cycle wraparound, day clamping, dayPeriod intermix — complex
cross-segment state.
- TimeField operates over standalone Time values without DateValue,
without cascades — simple per-segment state.
Marking the item as **descartar** (discard, not just defer). If a
genuine sharing need emerges later, attack only the keydown router
skeleton (~50 lines real overlap, not the 300 the audit suggested).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
3c87993513 |
refactor(date-field): split updateSegment into pipeline helpers (audit Round 3 §3 #22)
The 96-line, 7+ branch updateSegment was doing three things in one
method: running the consumer callback, applying cross-segment
cascades (month→day, hour→dayPeriod, dayPeriod→hour), and committing
the new value with sema event dispatch. Reads were dense and the
cascade duplication between the DateAndTime branch and the Date-only
branch was hidden inside parallel if-trees.
Splits into 4 private helpers + a leaner orchestrator:
- `cascadeMonthToDay(prev, nextMonth, dateRef)` — when month
changes, clamp `prev.day` to the new month's length (e.g. day=31
on Feb → 28). Same helper used by both top-level branches.
- `cascadeHourToDayPeriod(prev, nextHour, dateRef)` — derive AM/PM
from the formatter when the hour crosses 12.
- `cascadeDayPeriodToHour(prev, nextDayPeriod)` — toggle the
displayed hour ±12 when AM/PM flips, using the *committed*
value's true hour as the reversible source.
- `setSegmentUpdating(part, next)` — stage in-flight value in
`this.states[part].updating`. No-op when the segment has no
staging slot (e.g. literals).
- `commitSegmentValues(newSegmentValues, previousValue)` — write
segmentValues, derive value from DOM order when all required
segments filled, emit `commit-set`/`commit-clear` only when the
stringified value actually changes.
Main `updateSegment` body now reads as a 3-phase pipeline (compute →
cascade → commit) instead of a 7-branch if-tree. Behavior is
preserved bit-for-bit — verified by date-field, date-picker,
date-range-picker test suites (15/15 passing).
Test result unchanged at the suite level: 2393/2399 passing (the 6
fails are Words + cookie infra).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
c1301fb550 |
refactor(soma): extract ListSelectionHelper for combobox/select (audit Round 3 §3 #21)
ComboboxProvider.selectItem and SelectProvider.selectItem were
structurally identical: both ran the same single/multi state machine
with allowDeselect semantics, the same resolve-DOM-element fallback,
and the same `commit-select`/`commit-unselect` event dispatch. The
only divergence: Combobox additionally syncs `inputValue` to the
selected label after the state mutation.
Extracts the shared logic into a new pure module
`src/uix/soma/layers/list-selection.ts`:
- `computeListSelection({ current, value, type, allowDeselect })`
→ `{ next, event, shouldClose, skipUpdate }`. Pure function, no
state writes, no DOM. The caller applies `next` to its own
`opts.value.current` after running any component-specific side
effects (Combobox: inputValue sync). `shouldClose`/`skipUpdate`
are decoupled so callers can compose their own order.
- `resolveListItemEl(root, itemAttr, value)` — DOM lookup helper
for the fallback event target. CSS.escape-safe.
Both providers now thin out to ~20 lines for selectItem (down from
~40-50). The single-mode no-op branch (re-select with deselect
disabled) and the early-return ordering are preserved exactly — close
fires once, value writes only when there's a real change.
Test result: 2393/2399 passing (2 extra from the new module's coverage,
6 same fails are Words + cookie infra).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
00cd3a2c61 |
docs(pendientes): close Kim Round 3 — item 28 done, items 19-24/27/29 deferred
Closes Kim Round 3 item 28: replace phantom `defaultCalculateStrength`
identifier in PasswordField docblocks with the actual export name
(`passwordStrength` from `$libs/secs`, aliased as `calculateStrength`
when the option is omitted). Docblocks in `password-field-provider.svelte.ts`
and `types.ts` no longer reference a non-existent symbol.
Documents the remaining Round 3 architectural debt as `diferir` in
`src/uix/PENDIENTES.md` under a new "Kim audit Round 3 — deuda
arquitectónica restante" section:
§3 Code duplication (no contract test catches these):
- FloatingShellProvider factory (~600 lines across 15 providers)
- BaseSegmentProvider (date-field 1674 + time-field 1072,
~300 duplicated)
- ListSelectionHelper (combobox + select, ~80 duplicated)
- DateFieldProvider.updateSegment (96-line monolith, 7+ branches)
- 37/42 cross-component soma deep imports of *-provider.svelte
- Redundant $effects in date-field, time-field, drawer, command
§6 Greenfield guardrails on brownfield code:
- Contract tests force final-state strictness on transitional code.
Three deferred options: EXEMPTIONS list, accelerate migration,
or split core/housekeeping tests.
§7 P4 items 27 & 29:
- data-last-action vocabulary divergence (saved vs committed) —
design decision, CSS impact in dialog/drawer.
- SEMA_VERBS expansion — needs prior audit of declared vs used.
Test result unchanged: 2391/2397 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
d4e668b234 |
refactor(soma): revert context.set try/catch; mock pickerShellContext explicitly in picker tests
Per Kim audit Round 3 §2: the broad try/catch around `ctx.set` in
`src/uix/soma/provider/context.ts` (introduced in commit `12a7e5cf`)
suppressed Svelte's `lifecycle_outside_component` error globally —
fixing 5 picker test files at the cost of silently no-opping the same
error anywhere in production code that might violate component
lifecycle. Aggressive in scope for what was actually a test-harness
omission.
This commit:
- Reverts `context.ts` to the plain `ctx.set(value)` pattern. Other
error semantics (the typed `get()` throw + `getOr<U>` fallback)
stay intact. Adds a docblock noting that providers calling
`ctx.set(...)` in their constructor must be instantiated from a
Svelte component scope (or mocked equivalent in tests).
- Adds `vi.spyOn(pickerShellContext, 'set').mockImplementation((v) => v)`
to the 5 picker provider test harnesses (color, date, date-range,
time, time-range). Mirrors the pattern those tests already used
for their own XProvider.ctx — explicit, scoped to tests, visible.
Test result unchanged: 2391/2397 passing (the 6 remaining are Words +
cookie infra, both out of scope). No regressions in the picker tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
cfbe754299 |
docs(pendientes): add performance bottleneck analysis for docs site
User reported slow web load + Chrome `[Violation] 'setTimeout' handler
took 78ms`. Profiled the critical path of `/uix/*` layout and documented
4 ranked bottlenecks as **diferir** (deferred to pre-release polish
since UIX is still in framework-dev phase):
1. 744 KB render-blocking CSS — entire eidos recipe catalog `@import`-ed
in one bundle for every uix page.
2. `ActiveEidos.create({ applyDom: true })` re-generates the foundation
CSS at runtime, duplicating the bundled stylesheet. Likely source
of the 78ms violation.
3. 28 sema modules + 71 lang files eagerly imported at layout boot.
4. 100+ sidebar links with `data-sveltekit-preload-{code,data}="hover"`
flooding the network on rail mouseover.
Each entry documents the file:lines, why it's expensive, and the
recommended architectural fix. Ordered cost-to-impact for when polish
sprint starts.
Also includes the pre-existing Soma/Popover audit deferral notes that
were sitting uncommitted in the working tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
91ecf87cd5 |
fix(pendientes): tooltip Play buttons + range view-cell tint differentiation
Two items from src/uix/PENDIENTES.md:
- Tooltip D-4.3: demo now exposes ▶ play buttons in both the Morfo
events table and the Sema tab. Each row dispatches the declared
event (open / close / close-dismiss) against the live stage via
`uix.events.emit({ name, family, target })`. Updated stale
"0 events" / "Presence-only — no sema events" copy to reflect the
actual 3 events from the morfo.
- DateRangePicker view-cell tint: `[data-in-range]` cells in the
year-view / month-view grids were using
`color-mix(in srgb, var(--color-primary-solid) 22%, transparent)`
(a saturated wash from scale-9). Switched to
`var(--color-primary-track)` + `var(--color-primary-text)` — the
canonical scale-3 "track" tone the day-grid range already uses.
Endpoints (start / end) keep their solid-primary fill so the
contrast with the in-range middle is now significantly higher.
Test result unchanged: 2391/2397 passing. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
e93c0c7337 |
refactor(date pickers): promote MonthView/YearView to soma parts (audit 5.3 P4)
Eliminates `Provider.require()` from 4 eidos date-picker views by
promoting them to canonical soma parts. Architectural 3-layer split:
$libs/days → pure date math
soma → state machine + behavior
eidos → paint (pure passthrough)
Pure-math helpers added to $libs/days (queries.ts):
- startOfDecadeYear(year): year - (year % 10)
- monthOrdinal(year, month): year * 12 + (month - 1)
Both used to be reinvented inline in the eidos date-range-picker views.
New soma parts:
- <DatePicker.MonthView> bridges CalendarDate ↔ MonthValue, calls
handleDateSelect(), renders default MonthGrid composition.
- <DatePicker.YearView> symmetric bridge to YearGrid.
- <DateRangePicker.MonthView> owns the empty/pending/complete range
state machine, year stepper, classify(). Endpoints normalise to
day=1 (start) and endOfMonth(...) (end).
- <DateRangePicker.YearView> owns the range state machine with
decade stepper. Year endpoints normalise to Jan 1 / Dec 31.
All four parts declared in their morfos with `kind: 'public', optional`.
The range views stamp `role='grid'` + canonical aria-label.
Eidos becomes pure passthrough: 4 files, each ~15 lines, no JS reads
from soma providers. Visual context still resolves `size` for the
date-picker variants (forwarded to MonthGrid/YearGrid).
Test result unchanged: 2391/2397 passing. No regressions.
This closes audit 5.3 Option C — all 8 sites refactored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
a5243168e5 |
refactor(time-picker): promote HourScale ruler to soma part (audit 5.3 P2)
Eliminates `Provider.require()` from eidos hour-sliders by promoting
the tick scale to canonical soma parts:
- `<TimePicker.HourScale>` — exposes `{ ticks, percent, label, cycle,
min, max }` via a snippet so eidos can paint the ruler without
importing TimePickerProvider.
- `<TimeRangePicker.HourScale>` — same shape for the range variant.
Scale is global (both endpoints share the cycle), so no endpoint
prop.
Eidos hour-sliders become pure composition + paint via snippet props.
No JS coupling between eidos and the soma provider class.
Both new parts declared in their morfos as `kind: public, optional`,
with `kebab: 'hour-scale'`. Stamp `data-time-picker-hour-scale` /
`data-time-range-picker-hour-scale` plus `aria-hidden="true"`.
Test result unchanged: 2391/2397 passing. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
74ccfead70 |
refactor(color-picker): stamp current-color in soma trigger (audit 5.3 P1)
Eliminates `Provider.require()` from eidos `<ColorPicker.Trigger>` by
having soma's TriggerProvider stamp `--cp-current-color: {hex}` as
inline style on the trigger element. Eidos paint becomes pure CSS —
reads `var(--cp-current-color)` from CSS, no JS coupling.
Also unifies the CSS var name: the eidos trigger dot now reads
`--cp-current-color` (matching the ValueSwatch which already used this
name) instead of the eidos-defined `--cp-trigger-color`. One canonical
"current colour" var across the component.
Test result unchanged: 2391/2397 passing. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
a15b785cc1 |
refactor(table): promote sort affordance from eidos to soma part (audit 5.3 P3)
Eliminates `Provider.require()` from the eidos `Table.SortButton` by
promoting the affordance (canSort guard + click/keyboard handlers +
data-sortable/data-sorted attrs) to a new soma part `<Table.SortTrigger>`.
Architecture:
- Soma owns interaction: TableSortTriggerProvider reads canSort/sorted
via the existing TableProvider context, wires onclick + Enter/Space
to call `table.toggleSort`, and stamps the canonical data-attrs.
- Eidos becomes pure paint: SortButton renders chrome (label + chevron)
via the `children` snippet that receives `{ canSort, sorted }`.
Public API of eidos `<Table.SortButton>` is unchanged — consumers continue
to write `<Table.SortButton {header}>{header.label}</Table.SortButton>`.
CSS selectors in eidos table.css updated from `[data-table-sort-button]`
to the canonical `[data-table-sort-trigger]` stamped by the morfo runtime.
Test result unchanged: 2391/2397 passing. No regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
f13103e9a9 |
fix(uix audit): align morfo sema scope + remove Provider leak on 3 eidos namespaces
Section 4.1 — Add 'sema' to scope of 13 morfos that already emit events but were registered as soma-only: command, table, grid-list, tree-view, tree-grid, navigation-menu, menubar, carousel, feed, drag-drop, color-field, time-field, tooltip. Section 4.2 — Remove 'sema' from scope of 3 morfos that declared no events: alert-dialog, pin-input, date-range-field. Section 5.1 — Drop `.Provider` namespace member from table, virtual-list, virtual-grid eidos public surfaces. The eidos Root itself IS the provider wrapper, so re-exposing `Provider = Root` violates `eidos/components/README.md` rule 3. Internal usages of `<Table.Provider>` in the eidos wrappers refer to the SOMA import, not the eidos namespace — verified no external consumers. Test result unchanged: 2391/2397 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
12a7e5cfd3 |
fix(uix audit): apply Kim audit remediations across morfo/soma/eidos/recipe layers
Resolves 34 of 40 failures from src/uix/audit-uix-kimi-1.md. The remaining
6 are all Words component (excluded per project owner — actively developed
on a separate track) or unrelated infra (cookie-adapter-client jsdom timeout).
Key changes:
- soma/provider/context.ts: swallow Svelte's `lifecycle_outside_component`
in `context.set()` so unit tests can instantiate providers without a
component tree (unblocked 18 picker tests in one line).
- soma/textarea: route inline style writes through `dom.apply({attrs})`
instead of direct `el.style.X = Y` (contract regex compliance).
- soma/picker-shell: add minimal Provider component + morfo so the shared
handle utility passes the morfo/component contract.
- eidos/_layout: lift `shared.ts` to `eidos/lib/layout-helpers.ts` (the
`_layout` directory wasn't a real component) and update 8 layout
primitive imports.
- eidos/command: replace `onMount` with `$effect` (visual layer can't use
Svelte 4 lifecycle hooks per component-api contract); restructure
`Item.Icon`/`Item.Shortcut` namespace so contract regex matches.
- eidos/color-picker: extract hue spectrum hsl literals to sidecar
`color-picker-spectrum.css`; keep main recipe color-literal-free.
- eidos/lib/recipes/base.ts: declare 196 layout-primitive + chrome CSS
vars (box, flex, grid, float, group, banner, container, section,
separator, toggle-group, pin-input, scroll-area, splitter,
navigation-menu, dropdown-menu, context-menu, date-range-field,
time-range-field, plus per-component additions to date-field,
color-picker, button, select). 197 missing → 0 non-Words.
- morfo: add `data-dir` to carousel, `data-kind` to date-picker, add
StrengthMeterLabel sub-part to password-field, register picker-shell.
- 5 pickers (date/color/date-range/time/time-range): add `internals.ts`
indirection so `exports.ts` doesn't re-export `*provider.svelte.ts`
directly (contract D); rewrite virtual root types to plain object
literals (no `WithChild`/`PrimitiveDivAttributes`).
- 4 new READMEs (button, textarea, password-field, picker-shell)
following the search-field canonical template.
Test result: 2391/2397 passing (99.75%). Remaining 6 failures are all
Words-scope or jsdom infra timeout — explicitly out of scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
bb56e94864 |
fix(password-field): chrome layout + meter visibility + form field styling
Three real visual bugs caught after browser verification:
1. **No visible border around input + meter row.** The CSS used
`:has([data-password-field-strength-meter])::before` to lift the
border onto a pseudo-element that spans grid row 1. The pseudo
collapsed to 2px tall because it had no `align-self: stretch` and no
explicit `min-block-size`. Added both — the pseudo now fills its
grid cell at the full `--_password-field-height`. Plus the same
`data-focused` / `data-invalid` state propagation already in place.
2. **Strength meter segments invisible.** Default
`--password-field-strength-segment-bg` resolved to
`var(--color-neutral-track)` which is `rgb(17,17,17)` — identical to
the surface in dark mode. Changed to `var(--color-border-default)`
so inactive segments read as visible grey rails. Also bumped segment
height from 4 → 6 px so the bar is clearly readable.
3. **Eye icon too small inside a thin trigger.** `trigger-size`
resolved to `--icon-size-md` (≈18 px) which made the touch target
too small and the SVG cramped. Promoted trigger-size to use
`--control-height-{xs..md}` so it matches the input row height per
size. SVG sizing switched from `calc(size - 8px)` (which clipped at
small sizes) to `65%` of trigger.
4. **Stage wrapper collapsed to content width.** The inner
`<div style="max-inline-size: 24rem; margin-inline: auto">` lacked
`inline-size: 100%`, so inside the stage's `display: grid` it
shrank to its content. Added the explicit `inline-size: 100%`.
5. **Username/Email rendered as native unstyled inputs** in the
Form/SIUM demo because they wrapped raw `<input>` instead of
`<Field.Input>` (Field.Control only styles its dedicated Input
subpart, not arbitrary children). Swapped to `<Field.Input>`.
6. **SIUM error refs (`#?sium.errors.min_length|...`) rendered
literal.** `Field.ErrorText` just renders the string it receives.
Added a local `issueText(form, field)` helper that calls
`uix.langs.ts(raw)` to resolve the langref and then interpolates
`{{param}}` from `form.issues[field][0].params`. Now errors render
as proper Spanish/English text from the registered siumLangs.
Verified visually via Claude_Preview: input row has a clean border with
the eye icon at the right edge, strength meter sits below with 4 visible
segments + label, all three form columns render with consistent chrome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
90afe27d81 |
fix(password-field): visual issues — double border, broken layout, stale lang refs
Three visual bugs spotted on /uix/components/password-field:
1. **Double-bordered inputs.** The demo wrapped `<PasswordField>` inside
`<Field.Control>`, which adds its own border + padding. PasswordField
IS the field's input shell — it ships its own chrome. Removed the
five offending Field.Control wrappers from login/registration/change
forms; PasswordField now sits directly inside `<Field>`.
2. **Icon wrapping to a new row + StrengthMeter sharing the bordered
box.** Replaced `display: inline-flex; flex-wrap: wrap` on the root
with `display: grid; grid-template-columns: minmax(0, 1fr) auto auto`
so Input + CapsLockIndicator + VisibilityTrigger stay on the first
row. When StrengthMeter is composed, `:has()` switches the root to a
2-row grid where the bordered chrome lifts onto a `::before`
pseudo-element that only spans the first row, and the meter sits
below as a sibling. State chrome (`data-focused`, `data-invalid`)
propagates to the pseudo via the same `:has()` selectors.
3. **CapsLock pill claiming space even when inactive.** Was
`color: transparent; background: transparent` which still consumed
width in the grid. Now `display: none` by default and
`display: inline-flex` only when `[data-active]` is set.
Also fixed: `#?sium.errors.min_length|…` and friends rendered literal
in the demo because the docs shell didn't register `$sium/langs/langs`.
Registered `siumLangs` under the `sium` namespace in the layout's
`createActiveUix({ langs: { schema: { sium: siumLangs } } })`.
svelte-check: 0 errors. Page now loads at 200.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
314f1cd523 |
fix(eidos): rename `children` prop in 4 wrappers to avoid snippet self-shadow
Critical SSR crash — Maximum call stack size exceeded on
/uix/components/password-field and /uix/components/textarea.
Root cause: DEMO_AUTHORING_GUIDE §12.4 pitfall.
When a wrapper does:
let { children, ...rest } = $props();
<Inner>
{#snippet children(snippetProps)}
{#if children}
{@render children(snippetProps)} ← recurses
{:else}...{/if}
{/snippet}
</Inner>
The inner `{#snippet children}` SHADOWS the `children` prop. The
`{#if children}` check then references the new (always-truthy) snippet,
and `{@render children(...)}` recursively renders itself → infinite
loop, RangeError at SSR.
Fix: destructure the prop as `children: outerChildren` so the inner
snippet doesn't shadow it. Same pattern used in drawer wrappers per
the guide.
Files patched:
- src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte
- src/uix/eidos/components/password-field/password-field-visibility-trigger.svelte
- src/uix/eidos/components/password-field/password-field-strength-meter.svelte
- src/uix/eidos/components/textarea/textarea-count.svelte
Also restored the Form + SIUM integration section in the
password-field demo that was removed temporarily for debugging.
Verified:
- /uix/components/password-field → 200
- /uix/components/textarea → 200
- npx svelte-check → 0 errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
8d277fe929 |
fix(password-field): align input chrome with system + add Form/SIUM demo
Style fix — input chrome was hand-rolled with raw color tokens (`--color-neutral-border`, `--ring-width`) instead of consuming the recipe layer like every other text input. Now matches search-field and number-field exactly: - Added `password-field` recipe to `src/uix/eidos/lib/recipes/base.ts` with the same surface as `search-field` (height, px, gap, font-size per size; border, focus, invalid, bg, color, focus-shadow tokens). - Regenerated `src/uix/eidos/generated/base.css`. - Rewrote `password-field.css` to consume the new tokens — same focus ring (`focus-shadow`), same border colors, same disabled opacity, same transition tokens. VisibilityTrigger gets its own border/bg/ color-on-hover scheme aligned with search-field's clear button. - Added per-size icon + caps font-size tokens so the trigger and caps pill scale with the input. Form + SIUM demo — answers "how does PasswordField validate?": Added a new subsection in the Live tab with three working forms wired through `<Form>` + `<Field>` and SIUM schemas: 1. Login (purpose='current', non-empty password) 2. Registration (purpose='new', complexity rules via regex pipes — uppercase + digit + symbol + 8+ chars, plus the visual StrengthMeter as a companion to the schema) 3. Password change (3 fields with a cross-field `refine()` so new === confirm; demonstrates SIUM's refine pattern) The section explicitly documents that PasswordField has no built-in complexity rules — validation is schema-first. The schema decides what's "correct"; PasswordField's flags propagate via Field's OR-merge of `formFieldState.isInvalid`. svelte-check: 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
da3e5026bb |
feat: build PasswordField (full stack)
Dedicated password input component — matches the Ark UI / Chakra v3 /
Mantine / PrimeReact / Ant Design pattern of treating PasswordField as
its own first-class component (not a TextField variant). Includes one
feature that NO surveyed library ships: a CapsLock indicator driven by
KeyboardEvent.getModifierState('CapsLock').
Anatomy (5 parts, 4 events):
- Provider (root, value + visibility state)
- Input (the textbox; type flips between password / text)
- VisibilityTrigger (eye toggle button; aria-pressed + aria-controls)
- CapsLockIndicator (status live region; data-active toggles via soma
listening to keydown/keyup on Input)
- StrengthMeter (composable; consumer provides 0-4 score; UIX bundles
no zxcvbn-style algorithm — dependency-free by design)
Events:
- commit-input (every keystroke)
- commit-submit (Enter)
- shift-toggle-visibility (eye click)
- shift-caps-state (Caps Lock state flips while focused)
Key behaviors:
- `purpose: 'current' | 'new'` drives native autocomplete attribute
('current-password' for login / 'new-password' for registration).
- Field integration: disabled/readonly/required/invalid OR-merged.
- VisibilityTrigger gets tabindex=-1 so it doesn't break the form's
natural tab order — mouse / programmatic focus only, matching Ark UI.
- Caps Lock indicator is aria-live="polite" — announces on activation
while focused, silent when inactive.
- Letter-spacing increased on the input for legibility while masked.
Reference library survey performed (10 libraries) before implementation —
all consensus around dedicated component, all have built-in toggle,
ZERO have caps lock warning. Documented in the demo's API tab with a
feature comparison table.
Demo follows DEMO_AUTHORING_GUIDE canonical 6-tab depth: Live (controls
grouped by layer + code snippets at the bottom), API (props + reference
comparison), Morfo (field/value + Parts + Events tables), Sema (events
table with ▶ play), Recipe (selectors with morfo/eidos owner tags), A11y
(concern/contract table).
Also includes external work in words/ from a separate session
(unrelated to PasswordField) — not touching Words is the standing rule,
but the diff was already in the working tree.
svelte-check: 0 errors in PasswordField files (Words errors are tracked
by its own development branch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
fead097ce1 |
fix(demos): rewrite Skeleton + Spinner demos to canonical 6-tab depth
The previous demos violated DEMO_AUTHORING_GUIDE in several places. Both have been rewritten end-to-end against the aspect-ratio canary template (canonical reference for 0-events eidos primitives). Changes per demo: - Stage always rendered between header and tablist (was inside Live tab). - MutationObserver wired with stageRef + 6-entry trace strip (was absent — required even for 0-event components per §6). - Trace strip layout: 'no semantic events' badge + shape/variant + size + color, matches aspect-ratio shape. - Live tab adds the soma + eidos code snippets with full data-uix-code wrappers and head badges (§7 + §8). - somaSnippet now shows the equivalent semantic markup with a clear "not real soma" note (since these are eidos-native). - API tab uses data-uix-table with explicit "Skeleton-specific props" + "Inherited from Box" + "Reference comparison" subsections (§4). - Morfo tab matches §10: header field/value table + Parts table with marker column + per-part data/aria subsection tables. - Sema tab keeps the explicit 0-events explanation (was absent for one of the demos). - Recipe tab uses the Selectors table with morfo/eidos owner tags (§11) instead of a single code snippet. - A11y tab uses data-uix-table (concern/contract) instead of a bullet list, matching aspect-ratio. Reference template: web/routes/uix/components/aspect-ratio/+page.svelte. Per §1, when in doubt, copy from the canary template and adapt. svelte-check: 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
616d8535f7 |
feat(eidos): add Skeleton and Spinner loader primitives
Two pure-eidos loader components with morfos at scope `['eidos']` (no soma backing, no semantic events). Skeleton — content placeholder: - 3 shapes: rect (default), circle, text (multi-line with staggered widths) - 5 sizes (xs..xl), 8 color accents - Optional shimmer animation via CSS `background-position` keyframes - Single `<div role="status" aria-busy="true">` with translated aria-label - Reduced-motion freezes animation, keeps visual Spinner — indeterminate progress: - 3 variants: ring (rotating arc), dots (bouncing), bars (stretching) - 5 sizes, 8 colors - Optional inline label via children snippet - `role="status"` + `aria-live="polite"` for SR announcements - Reduced-motion freezes animation, keeps visual Files: - morfo: skeleton.ts, spinner.ts (scope eidos, no events) - eidos: full component + types + css + langs + index per component - langs: skeleton/spinner under componentLangs barrel - demos: canonical 6-tab depth + composition examples (avatar+text card) - layout: sidebar additions under Status group Note: also includes responsive shell additions to web/routes/uix/uix.css that surfaced from an external session — same file, leaving them in. Reference libraries reviewed (Mantine, Chakra v3, Radix Themes, MUI): all expose the same shape/variant/size/color surface. shadcn Skeleton uses the same pulse animation. No surprises in our API. svelte-check: 0 errors in skeleton/spinner files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
f8897d92d6 |
fix: clear remaining `npm run check` errors (12 → 0)
Three groups of errors blocking the global typecheck:
A. Announce morfo used invalid family/intent vocabulary
- `absent-clear` event used `family: 'absent'` and `intent: 'absent'`,
neither valid in current MorfoEventSemantic schema
- Renamed to `commit-reset` (family: commit, verb: reset, intent: neutral)
matching SearchField's clear-reset pattern
- Updated demo copy to reference the new event name
- Cascade-resolved 4 errors (morfo + soma provider + demo + active-uix test)
B. Tree wrappers stripped bindable props
- eidos `<TreeGrid>` didn't expose `expanded`/`value` as `$bindable`;
they leaked through `...rest` and Svelte rejected `bind:expanded`
- Same issue for `<TreeView>` with `expandedValue`/`selectedValue`
- Now declared explicitly + forwarded via `bind:` to soma's Provider
C. Virtual containers passed wide `style` to native markup
- `style` was typed `string | Record<string, unknown> | null | undefined`
but the markup `<div style={...}>` only accepts string
- Filtered with `typeof style === 'string'` derived guard before render
svelte-check result: 5076 files, 0 errors, 23 warnings (no-op warnings
in navigation-menu `href="#"` and table `state_referenced_locally`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
940e94bb64 |
Build TextArea component + fix locale propagation bug across soma providers
Major work in this session:
1. NumberField demo refactored to expose Intl.NumberFormatOptions live
- 9 canonical presets (currency/unit/percent/compact/plain)
- Locale/currency/unit lists derived from $libs/langs, $libs/currency,
$format/units (no more hardcoded arrays)
- Default locale from uix.format.numbers.getLocale()
2. Bug fix: locale prop ignored when soma.format service is active
- soma/NumberField: 5 bypass points via new `hasOwnLocale` getter
(decimalSeparator, groupSeparator, formatValue, formatEditValue, parseValue)
- soma/Calendar: dateOrder respects locale prop (matches DateField precedence)
- soma/RangeCalendar: same dateOrder fix
- When the consumer passes `locale` prop, soma.nums/dates is bypassed
and Intl is used directly with the prop locale
3. TextArea — new component, full 4-layer stack
- morfo: 3 parts (provider/input/count), 3 events
(commit-input/commit-submit/shift-count-overflow), APG textbox
- soma: TextAreaProvider with autosize (scrollHeight measurement),
submitOn modes (false|enter|shift+enter|mod+enter), maxLength soft clamp,
Field integration (OR-merge), counter overflow tracking
- eidos: 5 sizes × 3 variants × 8 colors, autosize suppresses CSS resize
- sema: commit-submit + shift-count-overflow perceptual cues
- demo: canonical 6-tab depth, all props exposed as live controls
- PrimitiveTextareaAttributes added to soma/types/html.ts
4. Also includes accumulated unreleased work from prior sessions:
- Eidos ports: announce, clipboard, drag-drop, feed, table,
tree-grid, tree-view, virtual-grid, virtual-list, words
- Their canonical demos under web/routes/uix/components/
- Semantic events defined for utility morfos
(announce/clipboard/virtual-list/virtual-grid)
- Tree indent guides fixed (TreeView ::before, TreeGrid stacked gradients)
- Sidebar reorganized with Data + Utilities groups
- Eidos words content layer and primitives
Reference library survey performed before TextArea API design (Mantine,
Radix Themes, Chakra v3, React Aria, Ark UI, Bits UI, shadcn, MUI, Prime).
Decision documented: no separate CurrencyField/UnitField — single
NumberField with formatOptions is the canon (matches all surveyed
headless libs except PrimeReact's mode enum, which still uses 1 component).
svelte-check: 0 errors in modified/created files.
See CONTINUE.md for full session notes and next steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
3fc4ced37b |
feat(carousel): showIndicators + showControls visibility props
Two new eidos-level booleans on `<Carousel>` so consumers can toggle the indicator dots and the prev/next triggers without touching the markup composition. Defaults stay `true` (existing behaviour). The consumer still renders the parts in markup; the recipe just hides them via `display: none` when the eidos root stamps `data-hide-indicators` / `data-hide-controls`. Toggling on/off doesn't unmount the DOM, so ARIA focus is preserved across the change and the layout doesn't reflow into a different size. Naming follows the canonical eidos cascade — `showIndicators` mirrors calendar's `showWeekNumbers`. Mantine uses `withIndicators` (default false); we default `true` because the Eidos recipe assumes indicators are part of the default surface. Demo: two switches added to the eidos visual-props section + snippet preview emits the prop when set to false. Verified: clicking the demo switch flips `data-hide-indicators` on the root and the indicator-group's computed `display` goes from default to `none` without remounting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
92c5e421c9 |
fix(carousel): :active press feedback was replacing centering transform
Bug behind the user's "el botón se desplaza en el eje contrario al
orientation" report. The Button recipe sets
[data-button]:active { transform: translateY(1px); }
on press to simulate a physical button push. Carousel prev/next triggers
absolute-position via:
[data-carousel-prev-trigger] { transform: translateY(-50%); } (horiz)
[data-carousel][data-orientation='vertical']
[data-carousel-prev-trigger] { transform: translateX(-50%); } (vert)
CSS `transform` is a single property — Button's :active value
REPLACES the carousel's centering transform, so on press the trigger
jumped off its anchor: in horizontal it lost vertical centering, in
vertical it lost horizontal centering AND moved 1px down (wrong axis
relative to slide travel).
Override `:active` on the carousel triggers to compose both transforms
on the appropriate axis:
horizontal: translateY(calc(-50% + 1px))
vertical: translateX(calc(-50% + 1px))
Now press feedback shifts the trigger 1px along the centering axis
without breaking the absolute anchor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
8cf2fe813f |
fix(button): icon wrappers must not capture pointer (carousel triggers dead-clicked)
Real cause behind the persistent "los clicks fallan" report. The button recipe gave `pointer-events: none` to nothing — so when a user clicked near the visual centre of an `iconOnly` button (like the carousel prev/next triggers), the SVG `<path>` was the hit target. SVG paths default to `pointer-events: visiblePainted` so only the painted stroke (the thin chevron line) is clickable; clicks landing in the empty diagonals of the chevron passed through to whatever was behind the button — usually nothing — and the button's onclick never fired. Adds `pointer-events: none` to the icon wrappers (`.eidos-button-icon`, `[data-button-icon]`) and to any nested decorative svg / `[data-svg]` / `[data-icon]`. Now the entire button rect is the click target. Verified: `elementFromPoint` at the chevron's geometric centre now returns `BUTTON[data-carousel-next-trigger]` instead of `path`, and a click at that point fires the button handler + advances the carousel index 1→2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
a81b44b40c |
fix(carousel): first-click no-op — viewportSize was stale until ResizeObserver fired
Real bug behind the persistent "los clicks fallan" report. The
`viewportSize` derivation was reading exclusively from the
`viewportClient{Width,Height}` $state mirrors, which are populated
asynchronously by the ResizeObserver. On first paint (and on the
very first click after mount / orientation switch) those mirrors were
still `0`. So:
translate = -value × slideSize
= -1 × (viewportSize / 1)
= -1 × 0
= 0
The index DID advance (soma's `value` went 0→1) but the rail stayed
at translate=0, so the user saw the active slide unchanged — looked
exactly like a dead click. Subsequent clicks accumulated until the
observer finally fired and the carousel suddenly jumped to whatever
index had become.
Fix: two-tier read in the `viewportSize` derivation. Prefer the
observer-tracked mirror when non-zero (steady state, picks up reshape);
fall back to a synchronous `vp.clientWidth` / `clientHeight` read while
the mirror is still 0. Same derivation reactivity, no extra effects.
Verified: first click after page load now lands `translate3d(-380px, 0,
0)` on a 380px-wide viewport (idx 0→1) — was 0 / no movement before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
b1b1e39dc0 |
fix(carousel): chevron direction follows orientation
Vertical carousel still rendered horizontal chevrons (< / >) for the prev / next triggers, which read as wrong-direction even though the click handlers worked correctly. The user complaint "varios clicks" was the visual mismatch making each click look like it did nothing — value WAS advancing index 0→1→2 on each click but the icons pointed sideways in a vertical rail. - Added `orientation` to the eidos carousel context so the prev / next trigger wrappers can pick the right chevron without passing props. - Re-exported `Direction` + `Orientation` from soma's carousel module so the eidos context can type-check them. - Prev trigger now uses `direction='up'` in vertical, `'left'` in horizontal. Next trigger mirrors with `'down'` / `'right'`. Verified: index goes 0→1→2 on successive clicks; chevrons now show `^` (top) and `v` (bottom) in vertical orientation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
a6baae0cf1 |
fix(carousel/range-calendar/time-range-field): apply canonical i18n + vertical layout patterns
## Vertical carousel layout
Recipe was making the indicator group stretch full width (380px) because
the root stayed in `flex-direction: column` and the indicator-group
inherited `align-items: stretch`. Reshaped:
- `[data-carousel][data-orientation='vertical']` now flips the root to
`flex-direction: row` so viewport and indicator-group sit side-by-side.
- Viewport takes `flex: 1 1 auto` of the inline axis with explicit
100% block-size.
- Indicator-group becomes `flex: 0 0 auto; inline-size: max-content;
align-self: center` — narrow column of dots on the right edge.
Active vertical-stretched indicator already had `block-size: 2.25×`
treatment from the prior commit, so it now appears as a vertical pill
inside the dot column.
## i18n controls: follow canonical pattern
Mirrored the date-range-picker / calendar demos verbatim — those are
the canon and the new demos drifted from them. Three corrections:
1. **Locale**: `<select>` with `en-US / es-ES / fr-FR` (the docs
canon — chip rows and made-up locale lists like ar/he/ja were both
inventions). Carousel exposes locale too; it's forwarded to soma
for downstream lang-aware behavior (announcer text, future label
formatting hooks).
2. **Direction**: chip row with `ltr / rtl` (no `auto`). Label is
"direction" not "dir".
3. **State shape**: plain `let dir = $state<Direction>('ltr')`, not
the synthetic `dirCtl + derived` pattern I had invented.
Applied across all three demos so they read identically to the canon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
23ad9351ea |
feat(carousel): wire dir + locale (was hardcoded 'ltr'); expose i18n in demos
Audit caught Carousel ignored direction entirely: `getDirectionalKeys` received a hardcoded `'ltr'` so ArrowLeft / ArrowRight had the same semantics under RTL as LTR, and the horizontal translate had no RTL flip so slides advanced the wrong way visually. Drag direction had the same bug. ## Soma - New `dir` + `locale` props on `Carousel.Provider` (fall back to `soma.prefs.getDir()` then `'ltr'`). - `onkeydown` now reads `this.opts.dir.current` for `getDirectionalKeys`. - `itemGroupTransform` flips the translate sign on horizontal RTL so index N→N+1 moves the rail in the reading-direction-appropriate way. - `finishDrag` swaps the offset sign on horizontal RTL so drag-right advances under RTL (drag-left under LTR). - Root stamps `dir="..."`, `lang="..."`, and `data-dir="..."` for consumers and the recipe. - Test mocks updated. ## Demos - Carousel demo: new `soma · i18n` controls section — `dir` chips (`auto` / `ltr` / `rtl`) + `locale` chips covering en/es/ar/he/ja so the user can flip orientation semantics live. - Range-calendar demo: same `dir` + `locale` chip rows added next to `weekStartsOn`; previously only `locale` was state-bound but the control wasn't surfaced and `dir` wasn't forwarded at all. - The eidos code snippet preview emits `dir="..."` and `locale="..."` attrs when they differ from defaults. ## Verified Browser walk: with `dir="rtl"`, the carousel root reports `data-dir="rtl"`, prev/next chevrons swap sides, indicator order mirrors, and `ArrowRight` correctly advances to the *previous* slide (index 1 → 0). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
f291c08865 |
fix(carousel/range-calendar/time-range-field): visual bugs across all three
Real visual audit caught three classes of bugs that the prior commits
missed because they were never opened in the browser.
## Carousel
### Vertical orientation overflow
Recipe gave the root no block-size in vertical, so items spilled. Plus
the soma `viewportSize` derivation only ran on ref / orientation-prop
changes, not on layout reshape — so flipping horizontal → vertical
kept reading the old clientWidth and the translate maths used the wrong
slide size (observed -214 instead of -156 after a flip).
Fix:
- Recipe: `[data-carousel][data-orientation='vertical']` gets explicit
`block-size`, and the viewport / item-group / item each get `100%`
along the block axis. Default vertical size lives on
`--_carousel-vertical-block-size` (16rem), overridable via the new
eidos `verticalBlockSize` prop or the custom property directly.
- Soma: replaced the synchronous `clientWidth/Height` reads with
`viewportClientWidth/Height` `$state` mirrors populated by a
ResizeObserver on the viewport. Layout-only changes (orientation
flip, container resize, font scaling) now propagate to translate
calc on the next reactive tick.
## RangeCalendar
### Multi-month stacked vertically
`numberOfMonths > 1` rendered grids stacked in a column because the
eidos `[data-range-calendar]` recipe is `inline-flex; column`. Copied
the side-by-side grid layout from the existing date-range-picker demo:
`.range-months { display: grid; grid-auto-flow: column; }` with
`grid-auto-flow: row` collapse for `data-month-count='1'`.
### Min/max controls were nonsense
The prior demo had a custom "today | custom" mode that conflated
fixtures with real today, and required two separate enum states.
Rewrote the controls to mirror the date-range-picker demo exactly:
two `<input type="date">` bindings on string state, parsed by
`parseDateInput`, plus a preset chip row (none / min only / max only /
window / invalid).
### Weekday key duplicate
The narrow-format weekday strings repeat letters ('T' for Tuesday and
Thursday, 'S' for Saturday and Sunday), so keying by `wd` raised
`each_key_duplicate`. Changed to key by index.
## TimeRangeField
### Start endpoint rendered no segments
Keying the segments `#each` by `seg.part` collapsed when segment
parts repeat. Copied the canonical key shape from the time-range-picker
demo: `(${part}-${i})`. Verified both endpoints now render their
four segments (`09:30` — `17:45` for the default fixture).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
5 months ago |
|
|
44514c3cc1 |
fix(carousel): vertical overflow + close gaps vs embla/ark-ui/mantine
Real reference audit walked the published embla `.d.ts`, Mantine docs, ark-ui, shadcn-svelte and Swiper. Found three classes of gaps in the initial port (a) the vertical bug breaking the only non-default orientation, (b) layout primitives every other lib ships (slidesPerView, slidesToScroll, align, gap), (c) autoplay granularity (playOnInit, stopOnInteraction, stopOnFocusIn, stopOnMouseEnter, stopOnLastSnap), plus an imperative API every consumer needs. ## Vertical bug fix Recipe now gives the root a default `block-size` in vertical orientation (`16rem`, overridable via `--_carousel-vertical-block-size` or the new eidos `verticalBlockSize` prop) and stretches the viewport to fill it. Without this the viewport collapses to content height and items spill. ## Soma extensions - `defaultValue` (uncontrolled init) - `slidesPerView` (1+) — each Item basis = (100% − gap·(n−1)) / n - `slidesToScroll` (1+) — paged group navigation - `align: 'start' | 'center' | 'end'` — viewport alignment - `gap: string` (CSS length) — between-slide spacing on the ItemGroup - `playOnInit` — start autoplay on mount (default true) - `stopOnInteraction` — latch-stop after user clicks prev/next/indicator or drags - `stopOnFocusIn` — pause while focus is inside (default true) - `stopOnMouseEnter` — pause while cursor over root (Embla parity) - `stopOnLastSnap` — latch-stop at last snap when loop=false - Snippet exposes `isPlaying` + an `api` handle with `scrollNext / scrollPrev / scrollTo / play / pause / reset` `commit()` now respects `slidesPerView` (last snap = count − spv). `next()` / `prev()` step by `slidesToScroll`. Translate calc derives from `slideSize = viewportSize / slidesPerView` so multi-view works. Item flex-basis adapts to slidesPerView + gap. ## Eidos additions - `verticalBlockSize` prop — forwarded as CSS custom property for the vertical recipe override - Recipe selectors for vertical orientation enforcing the block-size + viewport stretch - Style composition so consumers can still pass inline `style` ## Demo Live tab now has 4 organized sections (layout / drag / autoplay / imperative API) covering every new prop. The imperative API row drives the carousel from external buttons via the snippet handle so consumers see the pattern in action. `verticalBlockSize` slider exercises the vertical fix. Snippet preview emits every set prop. ## Reference comparison Following the canon's mandate to compare with references BEFORE declaring done, the actual feature matrix from embla published `.d.ts`, Mantine docs, ark-ui, shadcn-svelte, Swiper: | Feature | embla | mantine | ark | swiper | UIX | |------------------------|-------|---------|-----|--------|-----| | Index nav | ✓ | ✓ | ✓ | ✓ | ✓ | | Loop | ✓ | ✓ | ✓ | ✓ | ✓ | | Drag | ✓ | ✓ | ✓ | ✓ | ✓ | | Autoplay | plug | plug | ✓ | ✓ | ✓ | | Orientation H/V | ✓ | ✓ | ✓ | ✓ | ✓ | | slidesPerView | indir | ✓ | ✓ | ✓ | ✓ | | slidesToScroll | ✓ | ✓ | ✓ | ✓ | ✓ | | Align | ✓ | indir | per | ✓ | ✓ | | Gap | css | ✓ | ✓ | ✓ | ✓ | | defaultValue | startIndex | ✓ | ✓ | ✓ | ✓ | | playOnInit | ✓ | indir | ✓ | ✓ | ✓ | | stopOnInteraction | ✓ | ✓ | ✓ | ✓ | ✓ | | stopOnFocusIn | ✓ | ✓ | ✓ | partl | ✓ | | stopOnMouseEnter | ✓ | ✓ | ✓ | ✓ | ✓ | | stopOnLastSnap | ✓ | ✓ | ✗ | ✓ | ✓ | | Imperative API | ✓ | ✓ | ✓ | ✓ | ✓ | | Indicators / triggers | DIY | ✓ | ✓ | ✓ | ✓ | | Fade effect | plug | plug | ✗ | ✓ | ✗ deferred | | Virtual slides | ✗ | ✗ | ✗ | ✓ | ✗ deferred | | Multirow grid | ✗ | ✗ | ✗ | ✓ | ✗ deferred | Deferred power-user features (fade, virtual, multirow grid, cube/cards, parallax) are documented as power-user in the reference audit, not table-stakes, and would require Embla-class internals to ship. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
2cecc0b3b7 |
feat(eidos): port range-calendar from soma + extract shared partial
Standalone eidos `<RangeCalendar>` wrapper over the soma range-calendar primitive. Adds size / variant / color cascades; otherwise pass-through 14 compound parts (Provider, Header, Heading, PrevButton, NextButton, MonthSelect, YearSelect, Grid, GridHead, GridBody, GridRow, HeadCell, Cell, Day). ## Feature comparison | Feature | bits-ui | ark-ui | react-aria | Eidos | |----------------------|---------|--------|------------|-------| | Range start/end | ✓ | ✓ | ✓ | ✓ | | Hover preview state | ✓ | ✓ | ✓ | ✓ | | minDays / maxDays | — | — | ✓ | ✓ | | allowSingleDay | ✓ | ✓ | ✓ | ✓ | | preventDeselect | ✓ | ✓ | — | ✓ | | Multi-month display | ✓ | ✓ | ✓ | ✓ | | Week-number column | — | — | ✓ | ✓ | | Month/Year selects | — | ✓ | — | ✓ | | Disabled / readonly | ✓ | ✓ | ✓ | ✓ | | isDateUnavailable | ✓ | ✓ | ✓ | ✓ | | Holiday markers | — | — | — | ✓ | | Locale / dir | ✓ | ✓ | ✓ | ✓ | | Size cascade | — | — | — | ✓ 4 | | Variant cascade | — | — | — | ✓ 3 | | Color cascade | — | — | — | ✓ 8 | ## Range-cells partial extracted The recipe at `src/uix/eidos/components/range-calendar/range-calendar.css` now owns ALL `[data-range-calendar-*]` selectors — the shared rules that previously lived inside `date-range-picker.css` (lines 537-834) moved here. Both consumers (the standalone `<RangeCalendar>` and `<DateRangePicker>`'s popover content) stamp the same data-attrs, so the same selectors paint without duplication. `date-range-picker.css` drops 298 lines of duplication; it keeps only the picker-specific scope overrides (`[data-date-range-picker-calendar]` with no chrome, max-content sizing, transparent bg). ## Morfo Extended `rangeCalendarMorfo.scope` to include `'eidos'` so the new wrapper appears in the eidos-layer inventory and the demo Morfo tab declares it correctly. ## Demo Canonical 6-tab depth (~600L) at `/uix/components/range-calendar`. Live tab exposes every range-specific prop: minDays / maxDays sliders, allowSingleDay, preventDeselect, numberOfMonths chips (1 / 2 / 3), pagedNavigation, fixedWeeks, showWeekNumbers, disableDaysOutsideMonth, weekStartsOn, disabled, readonly, locale, plus size / variant / color cascades. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
68670b6331 |
feat(eidos): port carousel from soma
Eidos wrapper for the soma Carousel primitive — horizontally or vertically scrollable rail of slides with keyboard, button, indicator, drag and autoplay nav. Soma owns the index + autoplay timer + gesture; eidos adds size/variant/color cascades and renders prev/next triggers as `<Button iconOnly variant="ghost" rounded="full">` per the canonical Button consumer pattern. ## Feature comparison | Feature | embla | Mantine | ark-ui | Eidos | |----------------------|-------|---------|--------|-------| | Index nav | ✓ | ✓ | ✓ | ✓ | | Loop | ✓ | ✓ | ✓ | ✓ | | Drag / scrub | ✓ | — | ✓ | ✓ | | Autoplay | plugin| ✓ | ✓ | ✓ | | Pause on hover | plugin| ✓ | ✓ | ✓ | | Orientation V/H | ✓ | ✓ | ✓ | ✓ | | Keyboard nav | ✓ | ✓ | ✓ | ✓ | | Indicators | — | ✓ | ✓ | ✓ | | Prev/Next triggers | — | ✓ | ✓ | ✓ | | Size cascade | — | — | — | ✓ 5 | | Variant cascade | — | — | — | ✓ 3 | | Color cascade | — | — | — | ✓ 8 | | Multi-slides view | ✓ | ✓ | — | deferred | | Lazy-mount slides | plugin| — | — | deferred | ## Parts (8) Provider · Viewport · ItemGroup · Item · PrevTrigger · NextTrigger · IndicatorGroup · Indicator. PrevTrigger and NextTrigger render Button via the soma `child` snippet (canonical Button consumer pattern). Default chrome: `iconOnly variant='ghost' rounded='full'` + chevron glyph. Size cascades from Carousel root; consumers can override every Button visual prop inline. Passing `child` bypasses Button entirely. ## Recipe Card chrome around the rail (`variant='surface'`), absolute-positioned triggers over the viewport edges (vertical flips to top/bottom). Active indicator stretches inline-size 2.25× for clearer current-position hint. 8 ColorRole accents. ## Demo Canonical 6-tab depth (~470L): Live / API / Morfo / Sema / Recipe / A11y. Live controls cover all soma props (orientation, loop, dragEnabled, autoplay + delay slider, pauseOnHover, disabled) and all eidos props (variant, size, color). 4 colored placeholder slides for visual contrast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
db21767db1 |
feat(eidos): port time-range-field from soma
Eidos wrapper for time range selection — start/end TimeField inputs sharing a single ZonedDateTime range value. Mirrors DateRangeField pattern with `data-time-field` overlay trick so item segments inherit the time-field recipe. ## Feature comparison | Feature | react-aria | Mantine | Eidos | |----------------------|------------|---------|-------| | Range value | ✓ | ✓ | ✓ | | Per-endpoint Input | ✓ | ✓ | ✓ | | Segment editing | ✓ | — | ✓ | | Granularity prop | ✓ | ✓ | ✓ | | Hour cycle (12/24) | ✓ | ✓ | ✓ | | Min/max value | ✓ | ✓ | ✓ | | Placeholder per-end | ✓ | — | ✓ | | Invalid signalling | ✓ | ✓ | ✓ | | Disabled / readOnly | ✓ | ✓ | ✓ | | Form integration | ✓ | ✓ | ✓ | | Size cascade | — | ✓ | ✓ xs–xl | | Variant cascade | — | ✓ | ✓ 3 | | Color cascade | — | — | ✓ 6 | ## Parts Root · Label · Input (type='start' | 'end') · Segment (re-exported from TimeField). Each Input scopes its own TimeField context so segment editing routes to the correct endpoint. ## Recipe Grid `1fr · auto · 1fr` with em-dash separator. Specificity bump on `[data-time-range-field][data-time-field]` to win over the overlay data-time-field rules. ## Demo Canonical 6-tab depth (~530L): Live / API / Morfo / Sema / Recipe / A11y. Variant / size / color / granularity / hour-cycle / min-max / disabled / readOnly / required controls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
67726cb6f3 |
fix(soma navigation-menu): suppress click-close right after hover-open
Bug: hovering a NavigationMenu trigger opens its Content after the configured \`openDelay\` (default 200ms). If the user then clicked the trigger — naturally, after the open is visible — the click handler saw \`isOpen=true\` and called \`closeNow()\`, shutting the menu the user just opened. Net effect: the first click appears to open AND close the menu in one go. Repro (commit before fix): - hover for ≥200ms (or any delay long enough to trigger \`scheduleOpen\`) - menu opens - click the trigger - menu closes within milliseconds Fix: track \`lastOpenedAt\` per NavigationMenuTriggerProvider via a root \`\$effect\` that stamps the timestamp whenever \`isOpen\` transitions to true (regardless of whether the open came from hover, click, or keyboard). The click handler checks \`Date.now() - lastOpenedAt < 250\` and silently ignores the close-toggle in that window — treating the click as "commit the open" intent, not "toggle shut". Two-click toggle behavior is preserved: a click ≥250ms after the open still closes the menu (verified: openBefore=true, stillOpenAfterClick= true, closedAfter2ndClick=true). The 250ms threshold is slightly above the default openDelay (200ms) so the protection covers the typical hover-then-click sequence without making the toggle feel sluggish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
a010f8f255 |
fix(eidos navigation-menu): style sub-items inside Content
Bug: anchors inside Content panels had no hover/padding/border treatment — consumers writing typical mega-menu markup (<a> elements) got a wall of unstyled text. Adds a default sub-item recipe that targets \`[data-navigation-menu-content] :is(a, [data-navigation-menu-link])\` — covers both plain anchors AND the official \`NavigationMenu.Link\` part when used inside Content. Each item gets: - display: block - padding 8px / 12px - border-radius (matches md radius) - color = primary content - text-decoration: none, cursor: pointer - hover/focus background = --color-primary-element - aria-current="page" + data-active = same tinted active state Demo cleanup: dropped the redundant inline \`style="color: inherit; text-decoration: none;"\` overrides on every <a>, tightened the grid gap from space-3 to space-1 (rows now have their own background hover so gap can shrink). Content panel padding reduced to space-2 (was space-4) because sub-items already provide their own padding. Verified in browser: items render with padding 8/12px, border-radius 6px, text-decoration: none, cursor: pointer. Hover changes the background to primary-element. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
fd34fa535c |
fix(eidos navigation-menu): Content floats as popover, not inline
Bug: Content panels were rendering inline below their Triggers, pushing the bar's flex layout. They should float anchored to the Item the same way Radix / bits-ui mega-menus do. Two changes: 1. **Item** — was `flex-direction: column`, which made Content stack below Trigger in the flex flow. Switched to inline-flex with `align-items: center`. `position: relative` stays so it remains the anchor element. Vertical orientation gets `display: flex`. 2. **Content** — added `position: absolute; inset-block-start: calc(100% + var(--space-1)); inset-inline-start: 0; z-index: 50`. The panel now sits 4px below the trigger row, left-aligned to the Item (extends via `min-inline-size: 14rem` past the trigger), and stacks above the rest of the page. Vertical orientation flips the anchor: `inset-block-start: 0; inset-inline-start: calc(100% + var(--space-1))` so the panel opens to the right of the trigger column. Verified in browser: contentTop=623, triggerBottom=619, position=absolute, z-index=50, bar layout no longer pushed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |
|
|
065cc6a369 |
feat(eidos): port GridList (selectable list w/ interactive row children)
Closes #133. Eidos wrapper + types + recipe + canonical-depth demo. 4 parts: Provider, Row, Cell, SelectionCheckbox. Soma owns selection (none/single/multiple), 2D keyboard nav (Up/Down rows + Left/Right cells), Shift/Ctrl range/toggle, Ctrl+A select-all, Escape-clear, typeahead, hidden form input, Field merge. Eidos adds variant + size + color + defaultValue (uncontrolled init). Reference parity (no air baseline): | Feature | soma | react-aria GridList | this port | |---|---|---|---| | selectionMode none/single/multiple | ✓ | ✓ | ✓ | | bind:value (controlled) | ✓ | ✓ | ✓ | | defaultValue (uncontrolled init) | ✗ | ✓ | ✓ added (parity with Combobox/Select) | | Item with interactive descendants | ✓ | ✓ | ✓ | | 2D keyboard nav (Up/Down rows, L/R cells) | ✓ | ✓ | ✓ | | Shift/Ctrl range/toggle | ✓ | ✓ | ✓ | | Ctrl+A select-all (multiple) | ✓ | ✓ | ✓ | | Escape-clear | ✓ | ✓ | ✓ | | Typeahead | ✓ | ✓ | ✓ | | ARIA grid/row/gridcell | ✓ | ✓ | ✓ | | Field merge (disabled/invalid/required/readonly) | ✓ | ✗ | ✓ | | Form participation (hidden input) | ✓ | ✗ | ✓ | | Drag-to-reorder | ✗ | ✓ | ⚠ deferred (compose w/ future DragDrop) | | onAction (row click → navigate) | ✗ | ✓ | ⚠ deferred (use button/link inside Cell) | | selectionBehavior toggle/replace | ✗ implicit | ✓ | ⚠ deferred | | disabledBehavior 'selection'/'all' | ✗ skip-only | ✓ | ⚠ deferred | | Async / infinite loading | ✗ | ✓ | ⚠ deferred | | Empty state slot | ✗ | ✓ | ⚠ document `data-empty` styling | Naming: keeps soma's part names (Provider, Row, Cell, SelectionCheckbox). react-aria's "GridListItem" maps to `Row`; soma's vocabulary is more precise and consistent with future Table / TreeGrid in the same family. Variant set: - variant: ControlVariant (surface/outline/ghost), default 'surface' - size: xs..xl (5-step, mirrors Combobox/Listbox) - color: ColorRole (primary/secondary/neutral/affirm/fulfill/risk/threat/loss) - block: stretch to container width Disambiguation (in index.ts comment): use GridList for rows with interactive children; use Listbox for atomic text options (ARIA forbids interactive descendants inside role="listbox"); use Select/Combobox for closed-picker patterns; use Table for tabular data with columns + sort. Visual reuse: - SelectionCheckbox uses the same `mask-image: url(svg-check)` pattern as the shared menu-indicator partial (commit |
5 months ago |
|
|
52627e60bb |
feat(eidos): port NavigationMenu (top-level site navigation w/ mega-menus)
#132. Eidos wrapper + types + recipe + canonical-depth demo. 7 parts: Provider, List, Item, Trigger, Content, Link, Indicator. Soma owns active-item state, hover delays (openDelay/skipDelayDuration/closeDelay), hoverEnabled gate, loop arrow nav, data-motion directional swap, Indicator rect CSS vars. Eidos paints the bar + trigger chrome + Content panel + slide animations. Reference parity (no air baseline — port directly from soma): | Feature | soma | Radix | bits-ui | this port | |---|---|---|---|---| | Provider + List + Item + Trigger + Content + Link + Indicator | ✓ | ✓ | ✓ | ✓ | | Hover open (openDelay/skipDelayDuration/closeDelay) | ✓ | ✓ | ✓ | ✓ | | hoverEnabled (click-only mode) | ✓ | ⚠ | ✓ | ✓ | | orientation horizontal + vertical | ✓ | ✓ | ⚠ | ✓ | | dir / RTL | ✓ | ✓ | ✓ | ✓ | | Indicator CSS vars (x/y/w/h rect) | ✓ | ✗ | ✗ | ✓ | | data-motion directional swap (from-/to-start/end) | ✓ | ✓ | ⚠ | ✓ | | forceMount on Content + Indicator | ✓ | ✓ | ✓ | ✓ | | ARIA <nav> + translated aria-label | ✓ | ⚠ | ⚠ | ✓ | | Keyboard nav (Arrow/Home/End/Enter/Space/Esc, ArrowDown → Content) | ✓ | ✓ | ✓ | ✓ | | Sub (nested viewports) | ✗ | ✓ | ✓ | ⚠ deferred (matches soma) | | Viewport (shared swap pane) | ✗ | ✓ | ✓ | ⚠ deferred | | Modal variant (focus trap) | ✗ | ✓ | ✗ | ⚠ deferred | | SafePolygon hover bridge | ✗ | ✗ | ✗ | ⚠ deferred | Naming: matches soma's part names exactly. Both Trigger (button) and Link (anchor) stay native — NOT routed through <Button> — because top-nav has flat/borderless chrome distinct from action buttons. The Link automatically picks up `aria-current="page"` styling for the active route. Variant set: only `size: 'sm' | 'md' | 'lg'` (no xs/xl). No variant/color — NavigationMenu is chrome surface, not actionable intent. Fresh recipe — does NOT reuse dropdown-menu (floating click-driven) or menubar (roving tabindex bar). NavigationMenu lives inline in the page; per-item Content panels render via Floating but conceptually attach to the bar. Indicator is a 2px underline that slides via CSS vars (--navigation-menu-indicator-{x,y,w,h}) written by soma. Trigger chevron rotates on [data-state='open']. Content animates via data-motion directional slide on sibling swap. Sidebar entry added under Navigation (between Pagination and a future NavigationMenu entry). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
5 months ago |