- New <ColorSwatch> eidos primitive: transparency-checker base + colour
overlay, opt-in inset ring, inherits the container corner-shape.
- ColorPicker presets tab + saved colours: `presets` prop, bindable
`savedColors`, ColorPicker.Presets / SavedSwatches / SaveAction parts
(canonical hsvToHex comparison; save/delete component-owned).
- Readonly: the trigger no longer opens the popover when readonly — gated
via a PopoverTrigger `disabled` opt, rolled out across the color / date /
date-range / time / time-range pickers.
- ColorField swatch respects its container (no bg/border, inherits shape via
corner-shape); picker trigger swatch matches the button shape.
- Recipe: popover content-width tokens.
Documents this session's reference-grade coherence work in the canonical theming docs
(framework changes land in the framework's own docs, same pass):
- New §38 "Capa de estado (state-layer) --state-*": the MD3 token set
(hover 8% / press+selected 12%, currentColor -> theme-adaptive), the neutral-vs-valenced
doctrine, the overlay mechanism (transparent base vs filled gradient), the rollout
(accordion pilot + 19 comps + transversal fold of the archetype trigger/item rules),
the deferred cleanup. The ~168-hover unification had no doc until now.
- §32 (focus ring): new subsection "Outline en superficies" -- why box-shadow dies under
forced-colors/overflow + the hardcoded surface-default gap (misaligned halo on planes),
the 5 migrated components (ab62cca7), fields kept on box-shadow (inner-ring), the now
orphan --focus-ring token.
- TOC completed (§36 / §37 / §38 were missing) + revision date -> 2026-06-29.
- eidos/README.md reference table: focus-ring / touch-target / state-layer rows.
The --size-{tier}-font-size bundle (STATIC_SIZE) still encoded the superseded coupled
model -- each tier used the font ONE STEP DOWN (md->sm=14px, lg->md, xl->lg, xxl->xl),
the "compact control text" doctrine. But THEMING.md section 5 already revoked that on
2026-06-17 ("1:1 universal: control text follows the type scale; md-control is 16px,
not 14") and flagged the bundle's 14px as a stale fossil that "no longer reflects the
rule". This realigns the orphan bundle to the already-decided canon:
STATIC_SIZE md/lg/xl/xxl fontSize -> their own tier (1:1)
=> --size-md-font-size = var(--font-size-md) = 16px (was var(--font-size-sm) = 14px)
No component changes -- the bundle is orphan (0 recipe consumers; recipes already
declare 1:1 directly). This fixes the orphan reference + the docs so the size primitive
stops contradicting the live canon. Also realigns the paired line-height + letter-spacing.
- static.ts: 4 tiers fontSize -> 1:1
- active-eidos-config.test.ts: assertion --font-size-sm -> --font-size-md
- THEMING.md section 5: value, the revoked "two md / compact" box, the orphan note
- base.css: regenerated (--size-* hunks only; the concurrent session's base.ts work
left unstaged)
Verified: eidos suite 15/239 unchanged; no new type errors.
The box-shadow token --focus-ring hardcodes var(--color-surface-default) as its
gap-fill (render-css.ts:184), so a control focused inside a raised/overlay/filled
plane showed a mismatched halo; box-shadow rings are also clipped by overflow:hidden
and die under forced-colors. Migrate the last 5 box-shadow consumers to the canonical
outline pattern already used by button/card/~40 components:
outline: var(--focus-ring-width) solid var(--focus-ring-color)
outline-offset: var(--focus-ring-offset) (or 0, flush, for input + scrollbar)
- command-input (offset 0, keeps border-color shift) - collapsible-trigger - toggle -
splitter-resize-trigger (offset var) - scroll-area-scrollbar (offset 0)
Outline follows border-radius on every evergreen browser, isn't clipped by overflow,
and the forced-colors fallback already maps outline. The gap-color mismatch disappears
-- no --focus-ring-surface token needed.
NOTE: the box-shadow token --focus-ring now has zero CSS consumers (only THEMING.md +
a palabras doc reference it). Left in place -- it's a public foundation token; removing
it is a separate API decision (bundle with the --size-* orphan cleanup).
Verified: grep 0 box-shadow focus rings in component CSS; eidos suite 15/239 unchanged;
served rule confirmed in browser ([data-toggle]:focus-visible -> outline). Visual look
on keyboard focus worth a live Tab-through (CDP screenshot hung all session).
Folds the two TRANSVERSAL neutral-interaction rules in archetypes.css into the
canonical state layer (completes the --state-* unification at the archetype level):
- [data-archetype='trigger']:hover — opacity-dim (0.85) -> the state-layer tint
(background-image gradient of --state-hover), so bare triggers feel like the rest
instead of fading their own text.
- [data-archetype='item'/'option'] highlight (hover/focus/highlighted/selected) -
surface-raised swap -> --state-hover, so every menu/list active row (dropdown,
context, select, combobox, listbox, command) reads via the shared layer.
These were the LAST two neutral-hover idioms (canonical cross-component rules). No
base.ts touched -> no collision with the concurrent session.
Verified: folded rules served (state-layer gradient), old opacity/surface-raised gone;
eidos suite unchanged (15 pre-existing failures, none from this). Visual note: the 8%
tint ~ surface-raised (step-2) in light; menu-row highlight worth a live hover/keyboard
check (CDP screenshot hung all session).
Two fixes surfaced by the ColorPicker demo's
"[Violation] 'setTimeout' handler took 75ms".
Overlay open was gated behind the ~240ms perceptual hold. Popover
`present` / Dialog `open` / Drawer `present` flip `open` in the trigger
HANDLER but declared `sequence: 'pre'`, so the runtime awaited the emit
(and its hold) BEFORE mounting the content: the overlay opened ~240ms
late and its heavy first render ran inside the hold's setTimeout turn —
the violation. Switched these open events to `sequence: 'post'`
(mount-then-signal); `close` stays `pre` (signal must precede unmount).
Measured popover open 291ms -> 20ms, longtask 77ms -> 0.
Sema scheduled its hold / haptic delay / earcon completion with raw
setTimeout and the engine never received the timers service. Added a
`timers` port (semaDelay, src/uix/sema/timers.ts) forwarded through the
engine into all three channels; active-uix and defineEngineSemantic
inject uix.timers. Perceptual timing is now cancellable on dispose,
observable, and fake-clock deterministic; raw setTimeout survives only as
a unit-test fallback. Verified live: the hold now flows
VisualChannel.handle -> semaDelay -> uix.timers.schedule -> clock.
Docs: sema/README.md (timing via uix.timers + the post-open doctrine).
Propagates the state-layer unification (after the accordion pilot a13ca387): each
neutral :hover swaps its bespoke --{x}-bg-hover for the canonical MD3 state layer via
`background-image: linear-gradient(var(--state-hover), var(--state-hover))` — a
translucent currentColor overlay that works UNIFORMLY on transparent (tint shows) AND
filled (base bg survives under the overlay) bases. Keeps each component's `color:` text
shift; leaves the VALENCED palette hovers (pagination-selected, tag-group per-color,
toast, toggle, menu-item highlight) untouched.
Components: accordion, breadcrumb, calendar, collapsible, editable, field-control-trigger,
field-segment, file-upload, month-grid, pagination(control), radio-cards, range-calendar,
select(filled base), spin-field, stepper, tag-group(item+remove), tags-input, toolbar,
year-grid.
Verified: the gradient resolves to currentColor at 8% (`srgb .../0.08`) + the base bg
survives under it (computed probe); NO new test failures (the 6 failing eidos test files
are identical with and without the migration, and --state-hover/press/selected appear in
NONE of the failures — those are pre-existing card-group / palabras / dialog / icon).
Deferred: tabs + color-picker carry another session's uncommitted work (migrate once they
commit). Orphan --{x}-bg-hover recipe-token cleanup + guard test + the transversal
trigger/item fold follow.
Documents the 3 touch-target passes (a265d39a/c0904fc6/2750b9ce): 44px tap targets
gated on @media (pointer: coarse), controls + list rows + radio label-row, the
:not(lg)(xl) double-duty, markers via the React Aria label-row (not a Compose-style
visual restructure), and the WCAG AA-via-spacing conformance for bare grouped markers.
First step of the state-archetype unification (the audit's Arq.8 / the plugin's #1
rec): collapse the ~168 bespoke hover idioms into a canonical MD3 state layer. Adds
--state-hover / --state-press / --state-selected to archetypes.css — a translucent
`currentColor` overlay (8% / 12% / 12%), the single source for NEUTRAL interaction
feedback. Per-variant accent hover (solid -> palette-solid-hover, soft -> element, …)
stays in the recipes; the state layer owns the neutral / ghost tier.
Pilot: accordion-trigger:hover now uses var(--state-hover) instead of its bespoke
--accordion-trigger-hover-bg. Verified (computed): the token resolves to an 8%
currentColor tint on a real trigger (`srgb 0.125 / 0.08`); the color-mix + currentColor
chain confirmed directly. (Visual screenshot blocked by a renderer-hang in the tooling;
hover it live to see the tint.)
Rollout pending a design call: accordion has a TRANSPARENT base so a `background` swap
works; FILLED surfaces need a layered overlay (not a bg replace). The other ~30
neutral-hover components + the recipe orphan cleanup (--*-hover-bg) follow.
Marker touch-target, done the reference-grade WEB way (React Aria + WCAG), NOT a
Compose-style visual restructure. The clickable label ROW [data-radio-group-row]
(dot + text) becomes the 44px tap target on COARSE pointers; the dot stays small.
This mirrors React Aria (indicator + label wrapped as one target) + WCAG 2.5.5's
Equivalent exception (the larger row is the conformant target). Desktop (fine
pointer) keeps the compact 20px row.
The bare dot is intentionally NOT grown: grouped markers already clear WCAG 2.5.8
(24px AA) via the spacing exception — checkbox (12px gap) and radio (12/16px gap)
keep the 24px circles from intersecting (verified).
Verified: coarse rule serves; desktop row stays 20px (min-block-size auto), gate
doesn't leak.
Checkbox/switch have no built-in label-row (bare boxes; labels come from Field /
consumer) — their AAA label-row is a Field-level follow-up; grouped bare boxes are
already AA-conformant.
Second touch-target pass (after the button-like controls, a265d39a). On COARSE
pointers (touch), menu / select / listbox / command rows grow to the 44px mobile
minimum. `--list-item-height` (list-surface.css) is the row's min-block-size floor
that every list surface bridges to, so lifting it for the small sizes (xs/sm/md,
all < 44) grows every list row at once; lg (44) / xl (52) already clear it. Desktop
(fine pointer) keeps its compact rhythm.
Verified: coarse rule serves; md list-surface matches (->44), lg doesn't; desktop
md row stays calc(36px).
The framework's only axis below industry (ARCHETYPE_COHERENCE_AUDIT flagged it
RED: 0 components guaranteed 44/48px, 0 hit-area, md=36 < the mobile minimum). On
COARSE pointers (touch), grow button-like controls to the 44px target (WCAG 2.5.5
AAA / Apple HIG 44pt / Material 48dp); the desktop (fine pointer) keeps its compact
density — gated out, zero regression.
Transversal rule in archetypes.css: the trigger / close / action archetypes + the
standalone [data-button]. `:not([data-size='lg']):not([data-size='xl'])` does double
duty — skips the already->=44 sizes (so min-* only GROWS xs/sm/md, never shrinks
lg/xl) AND lifts specificity to 0,2,0 to beat the recipes' own min-block-size. Both
axes, so icon-only / short-label controls reach 44x44.
Verified: rule serves; md button + sm trigger match, lg button + item rows don't;
desktop button stays 36px (gate doesn't leak).
Follow-up passes: item/option rows (list-surface height), field-trigger, thumb, and
the small markers (checkbox/radio/switch — their clickable label often already
provides the target).
Presets tab reorganized into 4 labeled groups: Sistema (3 roles) + Intent
(6, incl. neutral) side by side, Paleta (31 scales) + Guardados (savedColors)
full width. Role/scale tokens resolved to rgb() via a color-mix probe so the
SwatchTrigger can commit on click. Labels follow the field canon (one step
below the input, regular weight). Preset swatches drop the recipe checker +
border (opaque). Bottom padding equalizes the two tabs' height.
A "Guardar color" button in the footer (ghost/neutral, matching Borrar/
Cancelar) saves the current value to Guardados: enabled only when a color is
selected, not already saved, and under the 10-color cap.
KNOWN ISSUE: the footer overflows with 4 buttons (Listo clipped) -- the save
button needs to become icon-only. See color-picker/CONTINUE.md.
Materializes the framework's signature corner. SURFACES (overlay panels +
non-floating cards) carry the continuous (squircle) corner by default; CONTROLS
stay arc. At surface-scale radii (>=~10px) arc and squircle visibly diverge
(premium); at control radii they're indistinguishable, so the split costs no
coherence.
The foundation rule (renderShapeBlocks) ENUMERATES the tier — NOT a
`[data-archetype='content']` hook, which also marks tabs/accordion/collapsible/
table content (non-surfaces) and would over-apply (verified per-attr). `:where()`
(specificity 0) lets a `[data-shape]` override win, so the `shape` prop flips from
opt-IN (activate squircle) to opt-OUT (`shape='rounded'` escapes to arc) on
surfaces. Theme knob `--shape-surface-default` reverts the whole tier; degrades to
arc where `corner-shape` is unsupported (border-radius magnitude is universal).
- Tier A — floating panels [data-{c}-content] (pickers inherit via Popover) + [data-command]
- Tier B — [data-card], [data-banner], [data-radio-cards-item]
- Guard test: surfaces resolve to squircle, controls/rows/pills stay arc
- THEMING.md §30 updated; pilot moved out of card.css into the foundation
Verified live (Chrome 150 renders the squircle); per-attr computed coverage
matches the taxonomy.
box-shadow is a single property, so the interactive hover shadow replaced the
selection ring on hover (the ring vanished). Compose both — ring first, hover
shadow second — for soft and solid variants.
The audit that kicked off the radius-decoupling / archetype-:where / floating-gap
canon work — per-component matrix for the 5 inheritance rules (scaling, density,
ambient size, sub-component scale, concentric corners) + the corrected v2 doctrine
(radius = Radix decoupled-from-size, corners = Apple concentric, ambient = Ant).
The per-menu panel anchors to the in-bar trigger, but the trigger is INSET from
the bar edge by the bar's padding PLUS its 1px vertical centering (the trigger's
`min-block-size` subtracts 2px → 1px each side). So the canonical `menu` gap (4px
from the trigger) left the panel flush with the BAR. Add that full inset back per
size to the content's `--floating-gap` so the visible gap reads exactly --space-1
(4px) FROM THE BAR. Also stamp a `data-menubar-content` marker on the eidos content
so the rule can target the menubar panel (it only carried `data-dropdown-menu-content`
before, so the existing `[data-menubar-content]` selector matched nothing).
Verified: File menu shows gapFromBar=4 (was -1, flush).
Canon-read fix verified live (menubar + dropdown show the 4px canonical gap on
real interaction); menu demos cleaned (commit 9c48164f). Notes the demo-only
stale-anchor artifact (dropdown auto-opens before layout settles).
dropdown/context/select demos passed an explicit sideOffset that BYPASSED the
--floating-gap canon (showed 0/4/6 instead of the canonical --space-1). Default
to undefined so the canon (which now reaches portaled menus via the
FloatingContent $derived fix) applies; the sideOffset slider still overrides.
Verified live: menubar + dropdown show the 4px canonical gap on real interaction
(the canon-read $derived returns 4 → Floating UI offset 4).
Demo (web/routes/uix/components/color-picker): tabbed layout
(Seleccion | Presets) as a flat segmented band, a tools row (outline
eyedropper + two 12px sliders inset by the thumb diameter), and the real
<ColorField> as the value input (size-synced, value swatch, system
format-select). Icons sized canonically via --icon-size-{size}.
Framework:
- color-picker.css: popover viewport scrollbar-gutter stable->auto
(tabbed content fits; the reserved gutter only skewed the panel right).
- ChannelInput/ChannelSegment gain visual parity with <ColorField>
([data-color-field] root context + data-field-segment marker + state CSS).
Presets 4-group split (Sistema/Intent/Paleta/Guardados) + eyedropper
button-size decision pending; see color-picker/CONTINUE.md.
- floating-gap canon: --floating-gap-menu 0 -> var(--space-1); FloatingContent
reads --floating-gap via a reactive $derived (was a one-shot rAF that never
reached portaled menus -> they fell back to sideOffset, flush); nav-menu wired
to the token via CSS.
- focus-ring: exclude item/option/content from the universal archetype ring; menu
rows use the canonical highlight on :focus-visible too (no thick keyboard border,
no ring framing the whole float).
- overscroll-behavior: contain on the ScrollArea viewport + 7 overlay scroll regions
(no scroll chaining to the page).
- archetypes.css: default rules wrapped in :where() so component recipes always win.
- radius decoupled from size (Radix model) in toggle/textarea/menubar/nav-menu/
virtual-list/grid; toggle `rounded` prop.
Canon-read + token reach portaled menus pending a fresh-load check (the dev tab
cached the floating module) -- see CONTINUE-floating-gap-canon.md.
Value input: channel separators (",", "/") render as full-height 1px vertical
dividers; the format prefix/suffix glyphs ("#", "%") keep their text. Static
delimiters get transition:none so divider <-> glyph format switches don't flash.
Format switcher: now the design-system <Select>, not a native <select>. The soma
FormatSelect exposes the format state via its `child`
(WithChildNoChildrenSnippetProps); the eidos renders <Select> from it. Borderless
ghost trigger + chevron; popover follows the control width (matchAnchorWidth,
list-surface min-width dropped), panel gap, field-matched corner radius.
CardGroup rebuilt as an eidos-only composition shell — selection delegated to
ToggleGroup, disclosure to Collapsible, item = card via structural identity
(data-card), title = system Button. Eidos owns only grid / depth / concentric /
cascade / size propagation. The bespoke card-group morfo + soma were removed.
ToggleGroup now owns the canonical selection cardinality: one knob
enabledSelections (number | [min, max]) + whenFull + a commit-block event on
rejection, consuming $libs/selection. Backward-compatible: type / deselectable
derive to the limit (tests 5/5).
Replace the v1 demo template (which only exposed variant/size/role) with a
9-tab layout that surfaces the whole system: the full 31-scale palette on the
real `color` prop, border/shape/radius, the foundation axes
(density/scaling/mode/dir/border), the motion catalog, and the sema firma with
audible sound.
- Button `color` accepts the 31 palette scales as a REAL prop (THEMING §25.5,
the Radix `color="teal"` per-instance override), gated by the intent↔color
doctrine. PALETTE_SCALES/PaletteScale/ComponentColor/HierarchyColorRole in
eidos lib/types; soma `color` widened (layer-pure); eidos `color?:
ComponentColor`; recipe `palette-*` extended with the 31-scale cascade via
paletteScaleDecls/paletteContrastDecls (graceful fallback to primary so apps
without generated/palette.css degrade, not break). Pilot of
pendiente_color_demos.md; base.css regenerated.
- Shared harness in web/routes/uix/lib/: harness.svelte.ts (constants +
DemoTrace + signatureFor), PalettePicker, SystemAxes, MotionPanel, SemaPanel
+ uix.css primitives (swatches/firma/motion).
- Button canary rewritten to the 9-tab layout; DEMO_AUTHORING_GUIDE rewritten.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full handoff for the remaining GradientBuilder work (eidos visual editor +
GradientPicker + sema + demo + translations): the shared model, the soma API to
compose, the eidos file plan (template = color-picker eidos), the design decisions
from the editor research, and the session's gotchas (rAF freeze in the unfocused
tab, dev-server flakiness, ACTIVE_DEV_TRACK, pre-existing contracts failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the headless layer of the GradientBuilder (still ACTIVE_DEV_TRACK):
- Providers expose `.props` (renderProps() + handlers) and register per-part
sources for the morfo's value-bound aria (aria-valuenow / valuetext / label on
each stop) — every stop is a keyboard-accessible role=slider thumb.
- Svelte wrappers: Provider (the editor root, bindable `value: Gradient`), Track
(click-empty-to-add drag surface), Stop (the slider thumb) + types + barrel.
The eidos composes Preview / AddStop / AngleDial / KindSwitch over these via
GradientBuilderProvider.require(). check: 0 new errors, type-clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gradient axis — Phase 1 (engine + dogfood):
- $libs/gradient: canonical Gradient model + gradientToCss serializer, pure and
zero-dep, shared by the theming axis and the (WIP) GradientBuilder. Stops
reference color ROLES → a --gradient-{name} re-tints with the seed and flips
light/dark for free. Default `in oklch` interpolation.
- build-gradient: role-derived factories (deepen/sheen/halo/aurora mesh) +
ActiveEidos.applyGradients() (the 6th runtime builder, in ThemeSeed/applyTheme).
- shimmer migrated to `in oklch`. Dogfood: /demos/cristal replaces its ~12
hardcoded gradients with applyGradients role tokens.
Opacity = function of elevation (depth cue `translucency`):
- New translucency depth-plane cue, sibling of shadow/blur: frost opacity now
scales with elevation (foundation overlay 68% / modal 80%; cristal 52→66→80)
via --depth-{plane}-translucency, consumed by the frost rule. base.css regenerated.
GradientBuilder component — Phase 2 (scaffold, WIP):
- morfo (contract) + soma provider state machine over the Gradient model: stops
add/move/remove/recolor, kind, angle, pointer drag; every stop a keyboard-
accessible role=slider thumb. Marked ACTIVE_DEV_TRACK until eidos/picker/demo land.
Docs: $libs/gradient README + THEMING §gradient-axis / §translucency + token table.
Demo cristal: scroll-reveal via uix.motion spring + hover glow; Select z-index
ladder; aurora/title → role-derived tokens.
contracts.test: ACTIVE_DEV_TRACK now filtered uniformly in both soma collectors.
check: 0 new errors. Tests: $libs/gradient + build-gradient + base.css sync green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All 5 pickers (date / date-range / time / time-range / color) modelled
themselves as standalone APG dialogs while ALSO composing a Popover that
already provides the dialog — producing nested role="dialog" (the
calendar/clock was a role=dialog child of the popover-content dialog).
Reconcile per soma SOMA_ARCHITECTURE.md §2 (the Popover IS the authoritative
dialog): drop apg:dialog-modal, focus.trap, the content part's role:'dialog',
the trigger's redundant aria-controls, and the own open/close events from each
morfo; providers stop firing their own close (commit/cancel just toggle open).
time-picker/time-range-picker Clock made morfo-driven (renderProps).
Also lands this session's picker UX:
- composable <DatePicker.Time /> / <DateRangePicker.Time /> + shared
picker-time helpers/row
- TimeRangePicker: canonical column gap + vertical divider, slider sizing,
AM/PM ToggleGroup, granularity-aware sliders
Verified: all 5 pickers show a single dialog in the browser; morfo:vocabulary
clean; svelte-check 0 new picker errors; provider tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the SYS-3 test gap for the two virtual-focus overlays — the exact paths
the combobox-001 / select-003 focus bugs lived in. Provider-level tests (the
accordion sub-provider harness pattern): create the Trigger/Input provider, set
a contentRef with items, dispatch `onkeydown`, assert the result.
- select (9): ArrowDown/Up/Home/End move highlightedId via the shared nextIndex
helper; ArrowUp from no-selection seeds the LAST item (the n-2 off-by the
helper fixes); clamp vs loop; Enter selects + closes; Escape closes; typeahead;
and DOM focus stays on the trigger (never an item).
- combobox (8): same nav + seed + clamp/loop; Backspace removes the last value
in multi mode; Enter commits a custom value; and DOM focus stays on the INPUT
(the combobox-001 invariant — APG combobox keeps focus on the textbox).
Each is proven to have teeth: breaking the seed (`nextIndex(0,…)`) fails the nav
tests; re-adding `dom.focus(item)` fails the virtual-focus tests. The item
fixtures carry `tabindex="-1"` so a stray `.focus()` actually moves focus —
without it the focus assertion is a jsdom no-op and the test is vacuous.
check: 0 new type errors; both files green (23 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts 729f3c0b, 2f02fdd9, 6c5616be at the user's request — the phase was
flawed: the SYS-INERT investigation grepped the per-picker providers and missed
that the shared picker-shell already emits `open`/`presence`, so the proposed
wiring would have double-fired (the color-picker click bounce). Reverting the
whole phase to a clean state before re-approaching it with the component READMEs
read first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`item-disabled-opacity` hardcoded `0.55`, off the framework's canonical disabled
opacity (`--opacity-disabled` = 0.4, used by Dialog and others). dropdown-menu +
context-menu now reference the token — disabled menu items match every other
disabled surface. Browser-verified: the resolved token is 0.4.
Scoped to the disabled-opacity drift only. The other THEME-SYS-2 candidates are
left as justified non-drift: `overlay-opacity 62%` / calendar `day-outside 0.62`
are off-scale (no `--opacity-62`; the 0.05-step scale has no slot) named recipe
tokens — a deliberate scrim density, which the off-scale doctrine permits; and
select `item-description-font-size 0.85em` is a RELATIVE unit with no absolute
`--font-size-*` equivalent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- drawer-007 + popover-001 → fixed (729f3c0b).
- SYS-008 (SYS-A33-CLONE) → refuted: re-verification found NO `new Map(this.x)`
clone-and-reassign or `$state(new Map/Set)` registry in any of the four
providers (nor the shared soma layers). They track items via DOM query + the
value opt + the lifted selectedSet. The finding does not hold.
Note: the drawer SYS-5 double-write is systemic across the provider (Title
role/aria-level, Trigger type/aria-haspopup/aria-expanded share the pattern) —
a broader morfo↔soma de-dup, beyond drawer-007's Content location.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both parts register with `syncAttrs: true`, so the runtime writes their
morfo-declared attrs to the DOM after render — overwriting any provider re-set
(the dialog-001 mechanism). The providers re-declared the same attrs anyway:
- drawer Content [drawer-007]: dropped manual `role: 'dialog'` + `aria-modal`.
The morfo Content declares `role: 'dialog'` and `aria-modal` (condition: the
registered `modal` source = `variant === 'overlay'` = isOverlay) — identical
values. Browser-verified: after the change the Content still has
role="dialog" + aria-modal="true" on an overlay drawer, now from the morfo.
- popover Close [popover-001]: dropped manual `type: 'button'` +
`aria-label`. The morfo Close declares `type` and `aria-label` via
`commonRef('buttons.close')`, which syncAttrs already wrote (the provider's
POPOVER_LANGS.CLOSE re-set was dead). Removed the now-orphan import.
Single authority per attribute (rule 7.7). check: 0 new errors; drawer +
popover provider tests 7/7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ~12 overlay recipes hardcoded an ad-hoc parallel z-index scale (raw integers
60–99/1200) that duplicated nothing reusable and had drifted out of order
(tooltip 76 < dropdown 80 — a tooltip painted BEHIND a dropdown). They are now a
named scale.
- `STATIC_Z_INDEX_OVERLAY` (static.ts) → emitted as `--z-index-overlay-{inline,
backdrop,content,floating,tooltip,detached,toast}`. A SEPARATE scale from the
global `--z-index-*` ladder (which orders the depth planes) — overlays portal
to <body> as siblings of modals, so they share one flat low band where each
rung sits just above the modal scrim. Mapping them to the 300–900 ladder would
hide a dropdown/select/popover opened INSIDE a dialog (dropdown 300 < modal
700); the combobox recipe already warned about this. `tooltip` now sits above
`floating` (fixes the inversion); `toast` stays above the soma FloatPanel band.
- Every overlay recipe token (`content-z`/`overlay-z`/`inline-z`/`toaster-z`/
`preview-z`) now references `var(--z-index-overlay-*)` — zero raw integers.
dialog/drawer gain an explicit `content-z` rung (drops the `calc(... + 1)`).
- Guard (contracts.test.ts, "overlay z-index against raw integers"): a recipe
`*-z` token must reference the scale, never a bare integer. Proven to catch
drift (a raw `'76'` makes it fail). Local `z-index: 0..5` (avatar/tabs/sticky)
is intra-component relative stacking — out of scope, stays.
- Docs: THEMING.md §35 rewritten to describe the consolidated scale + the
flat-band rationale + the guard; token table gains `--z-index-overlay-*`;
testing-and-tooling.md documents the catalogue guards (VG-8/SYS-1/A31/A30/
THEME-SYS-1).
Stacking order verified from the resolved CSS (deterministic z compare: content
70 < floating 80 < tooltip 90 < toast 1200; dropdown-in-dialog preserved). A
live browser check was blocked by a port conflict with another session's server.
check: 0 new type errors; the 5 guards green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
date/time/color-field assigned `field.inputId.current = opts.id.current` inside
a `$effect` — the A30 anti-pattern (an effect for a one-shot id registration is
wasteful + order-fragile). `opts.id` is available at construction and the
parent Field context resolves synchronously, so this is a plain constructor
assign. Mirrors the 12 field-family providers that already do it right (the fix
is byte-equivalent to number-field:583-587 / css-field). Verified by that
equivalence + the field providers' 15/15 tests (incl. color-field's
input/label wiring test); a live browser check was blocked by a port conflict
with another session's dev server.
Guard (A30, contracts.test.ts): flags `$effect(... inputId.current = opts.X.current ...)`.
- Proven to catch drift: reverting date-field to the effect form makes it fail.
- Scoped to an `opts.*` source on purpose: file-upload registers
`this.hiddenInputId` (empty '' until a child part mounts) inside a $effect —
that source is genuinely async, so the effect is JUSTIFIED, and the `opts.*`
requirement leaves it green without an exception list. (The guard surfaced
file-upload during development, which is how the distinction was found.)
check: 0 new type errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more catalogue invariants in contracts.test.ts, locking in audit fixes:
- VG-8: every component morfo must be declared `as const satisfies Morfo`,
never `: Morfo` (the annotation widens the literal so the schema can't check
it — how alert-dialog-001 shipped). Green; proven to catch drift (annotating
toggle as `: Morfo` makes it fail).
- A31: a per-item membership predicate (`isSelected` / `isItemPressed` / …) must
not do `.current.includes(...)` — that is O(N²) per render. Lift a Set, use
`.has()`.
The A31 guard immediately surfaced a real violation the human audit missed:
`toolbar-provider` ToolbarGroupProvider.isItemPressed did
`this.opts.value.current.includes(value)`, called from the per-item `isPressed`
$derived — the exact O(N²) pattern, in a component not on the audit's 9-item
A31 list. Fixed with the same lift (`pressedSet` + `.has()`). That catch IS the
guard's proof.
Both guards exclude the active-dev-track set so they stay green for the
maintained catalogue. check: 0 new type errors; toolbar provider tests 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A catalogue invariant in contracts.test.ts: every maintained component that
ships an eidos recipe (a `.svelte` wrapper or `.css` under
`eidos/components/{kebab}/` — README-only dirs don't count) MUST declare
`'eidos'` in its morfo `scope`. This is the prevention for the 62-component
drift the audit found and just fixed — turns "an agent has to re-audit" into
"the build goes red".
- Green now (scope-drift = 0 after 212624e0).
- Proven to catch drift: temporarily removing `'eidos'` from toggle's scope
makes the guard fail naming `toggle`.
- Excludes the active-dev-track set (words/palabras/chronos) so the guard stays
green for the maintained catalogue — a regression guard is only useful while
green, so a NEW drift turns it red.
Note: contracts.test.ts has pre-existing local failures from the untracked
`chronos` dev-track component (incomplete soma chrome trips the soma catalogue
guards); those are unrelated to this guard, which adds zero failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SYS-001 (SUMMARY) resolved + 46 per-component scope-drift findings set to
fixed. The other drifted components annotate the drift in their report header
(not a fix-status finding) or capture it only via the systemic SYS-001, so they
have no per-component line to flip. scroll-area's data-overflow finding is a
distinct undeclared-attr issue (not scope) and stays open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decision (the one definitional call SYS-1 required): morfo `scope` enumerates
the layers that IMPLEMENT the component (per the glossary), so a component with
an `eidos/components/{c}/` recipe + wrapper MUST list `'eidos'`. This matches the
62 components that already declared it correctly (dropdown-menu, accordion, tabs,
checkbox, radio-group, splitter, …) and makes "which components have eidos"
answerable from the morfo. Chosen over the implicit-eidos alternative for
explicitness.
Added `'eidos'` to the morfo-level `scope` of every drifted component — the 62
with a real eidos recipe (.svelte/.css, not a README-only dir) whose scope
omitted it. After this, all 124 audited morfos declare scope consistently;
scope-drift = 0.
`scope` is purely documentary (the only readers are the schema validator —
`layerSchema` already accepts `'eidos'` — and the 2-of-3 doctrine; nothing keys
behavior off it), so this is a behavior-safe documentation alignment: every diff
is exactly one `scope:` line, `npm run check` adds zero errors.
words/palabras/chronos excluded. calendar included (its morfo is clean; the
view-switch refactor lives in soma/eidos, untouched here).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix-status: open -> fixed (<commit>) for every HIGH whose code fix landed this
session, across 12 component reports:
- 92f988e7: listbox-001, grid-list-001/002, tree-view-002, tree-grid-001, tag-group-001 (SYS-7 A31)
- 98954a7c: alert-dialog-001, breadcrumb-001, navigation-menu-006, select-001, dialog-002
- db33f66c: select-002, dialog-001, collapsible-NEW-001
- bac67315: combobox-001, select-003
The HIGH tier is now code-complete; the 2 remaining HIGH (select-004,
combobox-002) are test-coverage gaps for Phase 6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Commit both virtual-focus list components to a single keyboard model and a
shared, unit-tested index helper (the EX-1 extraction).
- keyboard/directional: new pure `nextIndex(curr, key, len, {loop,nextKey,
prevKey})` — the next/prev/Home/End math every roving/virtual list route
duplicated. Seeds `curr === -1` to first (next) / LAST (prev) BEFORE the
loop/clamp, which kills the off-by where `-1 + prev + loop` wrapped to
`len-2`. 11-case boundary unit suite.
- combobox [combobox-001]: the Input opened then `dom.focus(items[0])` and the
Content route moved real DOM focus onto options — while the Input advertised
`aria-activedescendant`. That's the A17 virtual+real focus mix and breaks the
APG combobox pattern (focus must stay on the textbox). Rewrote the Input route
to own all navigation via a `moveHighlight` helper (highlightedId only, never
`dom.focus(item)`); deleted the real-focus Content route. Browser-verified on
a 19-item list: focus stays on the input, ArrowDown/Up/Home/End drive
aria-activedescendant.
- select [select-003]: the Trigger route now uses the shared `nextIndex`; the
dead Content route (read `activeElement` in a virtual-focus component, carried
the loop off-by) is deleted — verified that activeElement === trigger while
open, so the route never fired. Browser-verified: Trigger route navigates
(Down/Up/End) correctly.
Verified: combobox 3/3, select 3/3, directional 11/11, svelte-check 0 errors in
the touched files, browser-verified keyboard nav for both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- select: drop the dead `focus` block from the morfo. Select uses VIRTUAL focus
(focus stays on the trigger via aria-activedescendant; items are never
DOM-focused), so the declared `trap: true` / `initial: 'first-focusable'` was
unimplemented and contradicted the behavior. Matches Combobox (no focus
block). [select-002]
- dialog: stop double-writing the Content `role`. The morfo declared
`role: 'dialog'` (literal) AND the provider set `role = variant` — syncAttrs'
dom.apply (which runs after Svelte render) overwrote the provider's
`alertdialog` back to `dialog`, so AlertDialog rendered the wrong a11y role.
Remove `role` from the morfo (it's variant-dependent, which a literal can't
express) → the provider is the sole authority. Also drop the dead
`aria-roledescription: undefined` (syncAttrs wrote the morfo value anyway).
Browser-verified: alertdialog content -> role='alertdialog'. [dialog-001]
- collapsible: `collapse` event -> `sequence: 'post'`. The provider sets
`open = false` in the runtime HANDLER, so `pre` deferred the close behind the
full perceptual hold — measured 250ms dead delay before the content closed
(the Checkbox-lag class). With `post` the content closes immediately; the CSS
exit animation is keyed on data-state. Browser-measured: 250ms -> 1ms. [collapsible-NEW-001]
Verified: dialog/alert-dialog/select/collapsible provider tests green,
morfo:check passes for all four (the 5 unrelated fails — chronos/combobox/fab/
menu-dial/words — are pre-existing and untouched), svelte-check 0 errors in
touched files, browser-verified dialog role + collapsible latency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- alert-dialog: `: Morfo` -> `as const satisfies Morfo` (restores createAttrs
key narrowing; the only morfo using the loose annotation). [alert-dialog-001]
- breadcrumb: drop `archetype:'item'` from the display <li> Item part — it
pulled cursor:pointer + hover from archetypes.css onto a non-clickable
container (false affordance). Matches Timeline's pattern. [breadcrumb-001]
- navigation-menu: replace the leaked `$effect.root` (never disposed) with a
bare constructor `$effect` that auto-disposes with the component scope. [navigation-menu-006]
- select: `scrollSelectedIntoView` now reuses `resolveItemEl` (which CSS.escapes
the consumer value) instead of an inline unescaped querySelector. [select-001]
- dialog: delete the dead `[data-dialog-trigger]` chrome envelope (the trigger
composes <Button>, so it fought Button's recipe on the same element) + its
orphaned `trigger-font-size` recipe token; regen eidos css. [dialog-002]
Verified: 12/12 provider tests, morfo:check pass, svelte-check 0 errors in
touched files, eidos recipe contract unchanged (3 failures pre-existing/palabras),
browser-verified dialog trigger (solid Button) + breadcrumb (li cursor:auto).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Constant-resistance circles + constant-reactance arcs (clipped to the unit
disk), with normalized impedance points mapped through Γ = (z − 1)/(z + 1)
and a z + Γ hover tooltip. The matched load (z = 1) sits at the centre.
Configurable grid (resistanceCircles / reactanceArcs), labels and fade.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New eidos-native Metrics component (morfo scope sema+eidos, soma runtime
for the live signal). Parts: featured Icon, Label, Value, Delta (direction
⟂ valence via goodTrend), Trend row (delta + caption inline), Chart
(composes Sparkline), Progress + Gauge (compose Meter), Actions, Description.
Opt-in `live` turns a value change into `signal-notify-update` — flashes the
value, pings sema, announces the new value.
Reference-grade hierarchy: the value is rebased ~2 steps UP the canonical
type scale (md→font-xl, lg→font-xxl) so the small-label → big-number jump
reads as a KPI (the 1:1 mapping collapsed it). Tight rhythm (label→value 8px,
value→trend 14px); featured icon = fixed 48px soft rounded-square.
Demo at /uix/components/metrics: Live (interactive) + Gallery (14 real
compositions, outline cards).
Also: fix Skeleton light-mode visibility (neutral-track is ~white in light,
so the placeholder vanished on a white surface). Defer exposing the full
31-scale palette in component `color` + demos to pendiente_color_demos.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Heatmap is now one component, two modes by data shape: matrix {x,y,value}
(columns × rows) and calendar {date,value} (GitHub-style weeks × weekdays,
with granularity year/month, orientation, locale-aware week start, fade).
New standalone charts: Gauge, Funnel (multicolor + labelPosition), Bubble
(size-encoded, axis-padded), PolarArea (Nightingale, padAngle gap between
sectors), CalendarHeatmap (internal renderer behind Heatmap).
Funnel/Bubble/Heatmap/PolarArea gain hover tooltips + dim. CalendarHeatmap
week start + month/weekday labels follow the uix langs locale reactively.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stacking: a `stack` prop (boolean | 'expand') on the bar + area marks, reusing
$libs/plots `shape-stack`; the frame sums the stacked series for the y-domain.
Entrance animations via an `animate` prop (content domain, reduced-motion-free);
each mark plays its idiomatic motion:
- bars grow from the baseline (new `grow` preset), staggered
- lines draw themselves (new `draw` preset: stroke-dashoffset + pathLength=1)
- area grows up from the baseline (`grow`)
- BarList/BarSegment grow from the left (new `grow-x` preset), staggered
- Pie slices + Radar polygons grow RADIALLY from the chart centre (new `bloom`
preset: scale 0->1 + fade, transform-box: view-box), staggered — so each pie
slice grows out and every radar vertex travels from the origin to its place
- scatter + the area's line edge fade/draw; Sparkline inherits
Four reusable, type-safe, reduced-motion-aware presets added to the eidos motion
engine (grow / draw / grow-x / bloom) + the F7 typegen test. `animate` controls
in every chart demo. Roadmap + MUI X comparison in components/chart/IMPROVEMENTS.md.
Note: generated/base.css is regenerated; it carries one inert `--metrics-*` token
from the metrics recipe that co-lives in the shared generated file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Month/year choosers replace the native `<select>` with a NON-MODAL popover whose
content is the reused `MonthGrid` / `YearGrid`. Stays non-modal so it also works
opened inside the date-picker's own popover (no nested modal trap). The viewed
month/year is highlighted with the selected style; `locale` is threaded and the
month is capitalized so es ("ene") and en ("Jan") read consistently. `YearGrid`
gains a `pageAligned` opt so the chooser can start on the selected year.
- Prev / Next compose the system `<IconButton>` (chevron glyph from the primitive);
the month / year triggers compose the system `<Button>` via the Popover.Trigger
`child` snippet. The bespoke border / bg / hover / focus / disabled chrome is
removed from `calendar.css` — it all comes from the primitive now; the trigger
label tracks the picker font (16px).
- The day grid is centered under the slightly wider header.
- The date-picker reuses these calendar parts, so it inherits all of the above.
WIP (parked, not composed anywhere): the soma `CalendarProvider` also carries an
unused `view` state + `Calendar.MonthView` / `YearView` / `HeadingTrigger` from an
in-place view-switch attempt. The popover approach above is the active one.
Excludes words/palabras/chronos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker fields (date / time / time-range / date-range) use a flex
`[data-archetype='input']` box that holds the segments AND the overlay icon
trigger, with 12px text padding on BOTH sides — so the trailing icon sat behind
a full text-padding gap on top of its own glyph box (a visible double gap). The
reset that zeroes the input's trigger-side padding moved from the date-only
`field-control-trigger.css` (imported on just the date-picker) into the global
`archetypes.css`, keyed off the `field-trigger` archetype every picker trigger
carries — so it covers ALL pickers on every page, and outranks each
`[data-x-field-input]` rule (0,2,0 > 0,1,0) regardless of load order.
Verified by measurement: time-field input padding-inline-end 12px → 0 (text side
keeps 12px). Cross-checked that password-field / search-field / combobox were
already correct (flex/grid layout, ~2px input padding, affordances as flush
siblings) — only the pickers had the double gap. Excludes words/palabras/chronos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two picker-coherence fixes:
1. Field-trigger flush treatment — popover.css excluded the picker's generic
button envelope only for `[data-field-control-trigger]`. The time-picker /
time-range-picker clock icons carry `data-{component}-trigger` (NOT the
field-control marker), so they still got the popover button border/bg —
looking like a bordered button instead of a flush field icon. The exclusion
now keys off the `field-trigger` archetype, which the morfo/soma stamp on
EVERY picker trigger; added the missing override to time-range-picker.
All five pickers verified: date / time / time-range / date-range render a
flush 28px icon; color renders its own field-box (its border is the field's,
not the popover envelope's).
2. Footer follows picker size — picker-shell Clear/Cancel/Close were pinned to
`size="xs"`, so md/lg pickers had a tiny incoherent footer. New shared
`picker-shell/visual-context` lets each picker publish its resolved size;
the footer buttons read it (fallback xs). Wired into all five pickers.
Verified: at picker md the footer buttons are md (36px).
svelte-check clean. Excludes words/palabras/chronos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several distinct regressions/latent bugs surfaced via the DatePicker, all the same
root: parts wrongly typed as the interactive `trigger`/`item` archetype pulling
button/listbox chrome they should not have.
- Field overlay triggers (date/time/color picker, date-range) are NOT buttons:
new `field-trigger` archetype; the picker trigger soma overrides the popover's
generic `trigger` archetype with it. popover.css no longer applies the generic
popover-button envelope to `[data-field-control-trigger]` (composing IconButton
flipped CSS load order, so the button border/bg bled into the flush field icon).
- The field input drops its padding on the trigger side (logical → LTR + RTL):
`[data-archetype='input']:has([data-field-control-trigger]) { padding-inline-end: 0 }`
— the trigger sits flush instead of behind the full text padding.
- Calendar/range/month/year-grid Cell + GridRow + Day declared `archetype: 'item'`,
which shipped the listbox-row chrome: 6px cell padding (tall rows), a full-week
hover band, and `[data-archetype='item'][data-selected]` (higher specificity)
OVERRODE the calendar's accent fill so the selected day showed the menu tint
instead of solid accent. These are display gridcells, not menu items — archetype
removed; the Day keeps its bespoke `[data-calendar-day]` styling.
Verified in browser: DatePicker now matches the standalone Calendar (32px rows,
solid-accent selected day, no week-band hover, flush calendar-icon trigger).
morfo 62/62. Excludes words/palabras/chronos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the verified implementation scoping for the two remaining Lote A items so
the next pass is efficient: calendar/range prev-next (recipe untouched — control-*
shared with the selects; needs eidos size contexts + grid handling + demo chevron
update; ~12 files) and code-block copy (compose <Clipboard> with value=codeRef
.textContent; Trigger+Indicator replace the toggling button — a UX change; remove
the copy chrome + handleCopy/timer).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records the Lote A progress: the close family (dialog/drawer/popover/banner/toast/
float-panel) is fully migrated to compose IconButton, deferring variant/color to
Button's canonical defaults per the close-family decision. Documents the remaining
Lote A items with their discovered complexity: calendar/range prev/next (grid +
control-size shared between button and grid column; shared focus/disabled selectors;
consumer passes the chevron text) and code-block copy (compose <Clipboard>).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
float-panel-close now composes the canonical <IconButton> via soma's child
snippet (variant/color defer to Button defaults; aria-label via the common close
label). Extracted [data-float-panel-close] from the shared chrome selector so
Action + StageTrigger keep their square-button styling while Close inherits
button.css. No recipe change (the shared --float-panel-button-size etc. stay for
the siblings). Verified in browser (solid-primary icon close, "Cerrar").
Completes the close family (dialog/drawer/popover/banner/toast/float-panel).
Excludes words/palabras/chronos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- toast-close now composes the canonical <IconButton rounded="full"> via soma's
child snippet (keeps the round dismiss shape); removed the bespoke
[data-toast-close] chrome + the orphaned --toast-close-* recipe tokens; the
toast item grid still places it in the trailing column. aria-label routes
through the common close label.
- Per the close-family decision: variant/color defer to Button's canonical
defaults (solid/primary) across the family — no hardcoded ghost/neutral.
banner-close dropped its ghost/neutral defaults to match popover/dialog/drawer.
- component-visual-attrs manifest: toast-close delegates icon-only to IconButton.
Verified in browser (toast close = round solid-primary ✕, correctly placed).
svelte-check + orphan test clean for touched files. Excludes words/palabras/chronos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DIM 1 composition (component-coherence audit), close family:
- popover-close + banner-close now compose the canonical <IconButton>/<Button>
(via soma child snippet for popover; direct for the eidos-only banner) instead
of bespoke chrome. They inherit Button's variant/size/color/focus-ring; the
aria-label routes through langs ("Cerrar" / "Descartar"). Consistent with the
already-migrated dialog/drawer closes (solid/primary default; consumer overrides
for subtle).
- Removed the redundant [data-popover-close] / [data-banner-close] chrome from CSS
and the now-orphaned --popover-close-* / --popover-transition-* recipe tokens;
regenerated base.css. banner keeps only `margin-inline-start: auto` for end
placement, scoped under [data-banner] so it out-ranks [data-button] (composing
IconButton flips CSS load order — the equal-specificity bare selector loses).
- Cleaned the 15 orphaned --drawer-close-* recipe tokens the earlier drawer-close
migration left behind, and fixed the component-visual-attrs manifest: drawer +
popover closes delegate icon-only to IconButton, so they stamp no wrapper attr.
eidos suite 9->8 failures (the 8 remaining are inherited: spin-field + palabras +
icon, outside this work). svelte-check clean for all touched files. Verified in
browser. Excludes words/palabras/chronos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bundles the uncommitted audit work on this branch (active-uix):
- DIM 5A: focus-ring unified to `outline` across the control/field set (kills
~18 box-shadow rings; closes forced-colors a11y). NOTE: spin-field +
drawer-close orphan-token loose ends from this migration still trip the eidos
contract test — to clean when that migration settles.
- DIM 5D: field/input backgrounds homogenized to surface-default; list-surface
padding recalibrated 2/4/6/8/10; menu-dial internal vars privatized.
- DIM 5E: date/time/color-field + date-range-picker segments delegate the active
state to the shared field-segment-state layer; the input transition is reduced
to `background` only so the focus border no longer flickers on keyboard
increment (border-color is now instant; the ring is an outline).
- Langs (cero-hardcodes): IconButton-cascade wrappers (carousel/combobox/
tree-grid/table/search-field/banner) + onion-menu + cropper + time-picker
day-period now route aria-labels through morfo langs instead of hardcoded
English (the hardcode was overriding soma's already-resolved label). Fixes 2
contract divergences (combobox Clear->Clear value, table toggle label).
- DIM 5B: code-block/kbd variants aliased to ControlVariant (no duplicate
literals); image-picker default size sm->md.
Excludes words/palabras/chronos and the metrics/demos tracks (separate work).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Net-new data-visualisation family on the in-house, zero-dependency $libs/plots
engine (scales · ticks · curves · shapes, all original). Compositional <Chart>
frame (context-shared scales, responsive, cursor-following tooltip) + primitives
Grid/XAxis/YAxis/Line/Area/Bar/Scatter/Tooltip/Legend + presets Line/Area/Bar/
Scatter/Sparkline (line + bars) + standalone Pie/Radar/BarList/BarSegment, each
with a themed hover tooltip. Themed via --chart-* recipe tokens + global colour
tokens; locale via eidos.format/langs; morfo `chart` (scope: eidos, role=img).
Point scales span edge-to-edge. Per-chart demos under web/routes/uix/components.
Roadmap + MUI X Charts comparison in components/chart/IMPROVEMENTS.md.
Note: recipes/base.ts + generated/base.css are shared files; this commit
unavoidably carries the (inert) metrics recipe block that co-lives there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- MenuDial: a FAB trigger that fans a line OR a placement-driven arc of
mini-FAB actions. 9 zones mirror OnionMenu's onionArc (90 corner / 180
edge / 360 centre); radius auto-scales so the actions never overlap.
- Each action's NAME composes <Tooltip> (Floating-UI positioned, portaled,
collision-corrected) — tooltip on hover/focus, inline = forced open. No
hand-rolled label.
- Hover-open close delay (cross-gap); action size adapts one FAB step below
the trigger.
- Completes the FAB `xs` (30px) mini step: fab.css [data-fab-size='xs']
cascade + FabSize union (the recipe tokens already shipped with timeline).
- morfo + langs + soma (linear nav maths + runtime) + eidos + demo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unifies the history/feed pattern (per-item status + evaluative intent) with the
progress/stepper pattern (a root `active` index) in one component. Two
independent color axes: status drives the marker treatment (filled / ring /
hollow); intent overrides the hue via the canonical data-intent channel and
feeds the `emerge-reveal` event in live feeds.
- morfo: <ol>/<li> contract — data-status, data-intent, aria-current; one
`emerge-reveal` event (fromProp intent); scope soma/sema/eidos.
- soma: Provider + Item — status derivation (active|explicit), auto-index,
live-feed emission gated to appends. 4 unit tests.
- sema: emerge-reveal pack (soften gain via op:add + haptic tick).
- eidos: rail · markers · connectors; vertical/horizontal + start/end/alternate;
independent `size` (type) and `markerSize` (circle) axes; connector
solid/dashed/dotted; live-feed reveal. Markers hold icons or numbers.
- demo: web/routes/uix/components/timeline (6-tab interactive testbed) + nav.
Connector runs behind the markers (continuous rail); horizontal entries stack
via flex; horizontal-alternate pins fixed-height rows for a straight rail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
THEMING.md: new §36 documenting the token-driven floating gap (@property
--floating-gap, the menu/panel archetypes, the conditional stamp, why offset not
margin). Footer date -> 2026-06-22.
COMPONENT_COHERENCE_AUDIT.md: status line + a "hand-off 2026-06-22" section
recording this session's work (action components, 1:1 menu font, 2A radius, the
gap canon) plus the suggested order for next session (color-picker popover bug
task_5d1a10ec, selects faults, focus-ring 5A, field-bg 5D, composition lots).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stamp `data-floating-gap` on every floating content so the token-driven offset
(the foundation from the previous commit) drives the whole set, replacing the
hardcoded per-component sideOffsets (0/4/6/8):
- menus (dropdown, context, their sub-menus, menubar, select, navigation-menu) =
flush (`menu`, gap 0);
- panels (combobox, link-preview, popover, and the 5 pickers via composed
PopoverContent) = `panel` (gap --space-1-5).
The stamp is CONDITIONAL (`sideOffset === undefined ? archetype : undefined`,
matching split-button): a consumer-set `sideOffset` still wins (the Popover /
picker demos document `sideOffset={N}`); only the default falls through to the
canonical token. The pickers default their `sideOffset` to undefined so they
follow the panel token instead of a hardcoded 6.
Verified in-browser: navigation-menu (demo sets no sideOffset) stamps
`data-floating-gap="menu"` (STAMP branch); combobox / date-picker / dropdown
(demos set sideOffset) stamp nothing (SUPPRESS branch -- override respected). The
token-driven offset itself was validated in the pilot commit. check 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
floating-ui's numeric `sideOffset` can't take a token, so the trigger->panel gap
was a hardcoded per-component number (0/4/6/8, inconsistent). Canonize it:
register a `--floating-gap` <length> @property, set it per archetype via a
foundation rule (`--floating-gap-menu: 0`, `--floating-gap-panel: --space-1-5`),
and have the shared positioner (soma FloatingContent) read the resolved px off the
content and feed it as the offset -- token-driven (follows density x scaling) and
arrow-safe (the arrow rides with the offset). A content opts in by stamping
`data-floating-gap`; otherwise the positioner falls back to its numeric
`sideOffset`, so the rest migrates one at a time.
Pilot: Popover.Content stamps `data-floating-gap="panel"`. Verified in-browser:
overriding `--floating-gap-panel` to 20px moves the popover gap to 18px (20 minus
the popover's ~2px border overlap); the default 6px is a no-op vs the old
sideOffset=6. check 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 1:1 font canon (size N -> --font-size-N, md=16) covered the field family but
missed two menus and one picker trigger, which kept the compact md->sm scale:
- menubar trigger: md->16 (was 14); its menu items were already 1:1 via list-surface.
- navigation-menu: md->16 (was 14).
- color-picker trigger: md->16 (was 12 - double-compact md->xs), matching color-field.
combobox / command / textarea were already 1:1 (a grep over-counted by matching
their correct sm/lg variants). Picker PANEL fonts + the menu-dial label are flat
panel/label content (no size axis) and stay at the conventional compact density.
Also tokenized two 2A radius bypasses (the token existed, was ignored):
- grid-list checkbox: 0.2rem -> var(--radius-sm).
- navigation-menu indicator: 1px -> var(--radius-full).
Verified md=16px in-browser for the three font fixes. check 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ship the four action-cluster components. Per the audit the user requested, the
hardcoded magic numbers in their CSS move to recipes (base.ts), so the values
live in one place and pass the recipe-CSS contract:
- fab: the off-ladder Material size / icon / lift scale → `fab` recipe (named
tokens — the canonical control-height / icon-size ladders genuinely can't
express 40/56/72 + 20/24/28; documented as the FAB's own scale).
- onion-menu: the radial-chrome magics (sector ring widths, glyph bar, trail
opacities) → `onion-menu` recipe; the centre trigger's lift + shadow
REFERENCE `--fab-*` (it IS a FAB — the two can't drift apart).
- button-group: the segmented-divider strength → `button-group` recipe.
- split-button: the slim chevron tail's inline padding → `split-button` recipe;
DropdownMenu gains `customAnchor` so the menu spans the whole split surface.
All four compose the framework primitives (Group / Button / Icon / DropdownMenu),
declare a morfo, ship langs + a demo, and pass the recipe-CSS contract test (the
only flagged component left is palabras, an active dev track). check 0;
onion-menu engine tests 31/31.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting an item ran its onSelect but nothing closed the dialog. Add a
provider-level `onSelect` to the soma command (fires for ANY item, on BOTH
paths — `selectCurrent` for the keyboard + the item `onclick` for the mouse,
after the item's own onSelect). The eidos `<Command.Dialog>` hooks it → `open
= false`.
Verified in-browser: click → closes; Enter → closes. check 0; command provider
tests 2/2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refine the command-dialog close per review:
- Solid primary → `variant="ghost" color="neutral"` — a subtle borderless gray X
instead of a loud solid purple button.
- The input cleared the close by `--dialog-close-size` (a glyph token that
under-measured the button) → only a 2px gap. Use `--control-height-md` (the
close's real IconButton footprint) → 4px to the button + ghost padding.
- The floating close inherited the dialog's top inset (tuned for a title row) →
5px below the input's center. Override its top to space-2 → centered on the
input row (1px offset).
Verified by measurement: ghost transparent bg · neutral gray X (chroma 0) · gap
4px · vOffset 1px · no overlap. Screenshot omitted — the modal backdrop-filter
hangs the headless capture (preview infra), the layout computes cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concentric `[data-shape-nest]` now adopted by menubar / select / combobox /
command (+ default=md / factor / scaling-responsive). Radius axis flips to LIVE.
carousel / time-range use a literal `−2px` in a non-list-popup context → out of
the concentric migration (separate, low-priority).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Concentric (A2): the items hand-rolled `calc(--radius-md − space)` = 2px, using
radius-md as the outer instead of the panel radius — a latent bug (the comment
claimed "panel radius − inset"). Adopt `[data-shape-nest]`: the panel exposes
--shape-outer-radius (FIXED `lg`, so items stay 6px in BOTH the inline palette
and inside <Command.Dialog> — they aren't at the dialog's corner, so they must
not inflate to the dialog radius) + --shape-nest-gap. Items 2px → 6px (truly
concentric), verified inline + in-dialog.
Command-dialog layout (reported as broken):
- Options were inset LESS than the input (list padding-inline space-1=4 vs input
margin space-2=8) → misaligned. List inset → space-2: the input box + option
pills now share one left/right edge (measured: both at x=187).
- The floating top-right close OVERLAPPED the input — the old reserve cleared only
the input TEXT (padding-inline-end), not the box. Clear the BOX instead
(margin-inline-end = close inset + size + gap) so the close sits in the gap to
the right (measured: input ends 689, close starts 691 — no overlap).
Verified by DOM measurement (rects + computed radius). Screenshot omitted: the
modal's backdrop-filter hangs the headless capture (preview infra); the layout
computes cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the A2 concentric progress: `[data-shape-nest]` adopted by
menubar / select / combobox (panel-item concentric, no-op verified).
command / carousel / time-range left — their items are not panel-corner-
concentric (deeply nested / different context), a separate design decision.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both popups hand-rolled the item's concentric radius
(`calc(<panel-radius> − <content-inline-padding>)`). Consume the shared
shape-engine rule instead: each Content panel exposes its radius + inline inset as
`--shape-outer-radius` + `--shape-nest-gap`, the Item stamps `data-shape-nest`, and
the global `[data-shape-nest]` rule computes the concentric radius once.
No-op (same radius): combobox item = 4px (lg 10 − content-px 6), verified live;
select item = 4px, verified via a detached probe (the real select popup hangs the
preview renderer — known infra issue). Extends the menubar migration (28b0c102).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-bar trigger hand-rolled its concentric radius
(`calc(--_menubar-radius − --_menubar-padding)`). Consume the shared shape-engine
rule instead: the bar exposes its radius + padding as `--shape-outer-radius` +
`--shape-nest-gap`, the trigger stamps `data-shape-nest`, and the global
`[data-shape-nest]` rule computes the concentric radius once.
No-op verified in-browser: trigger = 2px at md (same as before); identical across
sizes via the var cascade. First of the A2 concentric migration (§13 — stop
hand-rolling `calc(--radius − pad)` per component).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconsidered Decisión 2 against the reference systems: the default radius should
equal the eidos control center-of-gravity (button / field / select-trigger = md /
6px), like Bootstrap / Chakra / Radix-medium. `sm` (4px) would leave the default
tighter than the controls themselves — backwards — and would shrink any control
that later adopts the token; md makes that adoption a no-op.
`--radius-default` = var(--radius-md). Verified in-browser: resolves to 6px. check 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First increment of the A2 (radius) phase. The radius scale was fixed px; now:
- `--radius-default` (= var(--radius-sm)) — the archetype default radius for
components without a radius reason of their own (Decisión 2 / §13).
- `--radius-factor` (default 1) — a global Radix-style roundness knob; a theme
sets it on :root to dial all corners up/down at once.
- Radius responds to `--scaling`: each real radius emits
`calc(base * var(--radius-factor) * var(--scaling))`. `none` (0) and `full`
(the pill sentinel) stay verbatim.
At factor=1 + scaling=100 both resolve to 1 → identical output (zero regression).
Verified in-browser: md=6px default · --radius-default=4px · factor 1.5→9px (at
:root) · scaling 1.1→6.6px · full=9999px. check 0.
Pending (A2 increment 2): components consume --radius-default + adopt
[data-shape-nest] (drop the hand-rolled calc(--radius-md − space)); panel-radius
sub-decision (modal xl vs overlay lg).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The B2 rollout is complete, so the ARCHETYPE_COHERENCE_AUDIT §13 "Superficie /
elevación" axis flips A1 → LIVE. Per the audit's update rule (row → LIVE +
guard), add `elevation-plane.test.ts`: every floating overlay's eidos Content
wrapper must stamp `data-depth` so the plane paints its elevation. 13 wrappers
pass (dropdown/context-menu + their sub-contents, menubar, navigation-menu,
popover, link-preview, command, select, combobox, dialog, drawer). tooltip +
listbox are excluded by design (variant-as-material / inline list).
Catches the menubar-class regression — a panel that reuses a shared content
selector but forgets data-depth silently loses its elevation when the donor
recipe is pruned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the elevation doctrine (variant = emphasis, elevation = plane), the
select / combobox popups now take their surface · border · shadow from the
`overlay` plane via `data-depth="overlay"` — constant across variants. The
variant (surface / outline / ghost) tints only the trigger / control, no
longer the popup elevation.
- Drop the content's own border / bg / shadow + the per-variant elevation
overrides (outline/ghost were `raised` / lighter / borderless).
- Point the arrows at `--depth-overlay-surface` / `--depth-overlay-border`.
- Prune select's 6 now-orphaned `content-*` elevation recipe tokens.
Verified in-browser (measured): combobox surface AND ghost both paint
bg oklch(0.285) + 1px border + overlay shadow from the plane (ghost was
transparent/borderless before — now flattened to the plane). select is the
identical change; its open popup hangs the preview renderer (no console
errors — preview infra), so it rides the same plane mechanism + check 0 +
eidos-lint 0 invalid + contract test (no new orphans).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more single-elevation overlays join the plane:
- link-preview: hover-card Content carries `data-depth="overlay"`; drop the
hardcoded border / bg / shadow + point the arrow at `--depth-overlay-surface`.
- command: the palette panel carries `data-depth="overlay"`; drop its hardcoded
border / bg / shadow. Inside `<Command.Dialog>` the existing
`[data-dialog-content] [data-command]` override (higher specificity) strips
the plane so the dialog panel stays the sole surface — no double-elevation.
Verified in-browser: link-preview + inline command paint bg oklch(0.285) + 1px
border + shadow/halo from the plane; command-in-dialog is stripped
(transparent / border 0 / shadow none); check 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the dropdown's overlay-plane adoption to the rest of the menu family:
- context-menu: Content / SubContent carry `data-depth="overlay"`; prune the
recipe's content-border / bg / shadow tokens + point the arrow at
`--depth-overlay-surface`.
- menubar: its per-menu panel reuses `[data-dropdown-menu-content]`, so the
dropdown's prune (f80f2225) had left it with NO elevation — restore it by
stamping `data-depth="overlay"` on the eidos Content wrapper. (regression fix)
- navigation-menu: mega-menu panel carries `data-depth="overlay"`; drop the
hardcoded border / bg / shadow from the recipe CSS.
Radius / font / color stay each panel's own. Verified in-browser: all three
paint bg oklch(0.285) + 1px border + shadow/halo from the plane; check 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Content / SubContent now carry `data-depth="overlay"` and take their
surface · border · shadow · halo from the overlay plane instead of the
recipe. Prune the now-redundant recipe tokens (content-border-width /
border-color / bg / shadow) — the plane is the single source, no
double-paint — and point the arrow fill at `--depth-overlay-surface` so
it always matches the panel. Radius / font / color stay the panel's own.
Verified in-browser: open menu paints bg oklch(0.285) + 1px border +
shadow/halo from the plane; orphan-token contract test passes; check 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The picker root was `flex-direction: column`, stacking label + start + end
into three rows. The `1fr auto 1fr` grid that would place start — end on one
row lives on `[data-time-range-field]`, which the picker never renders (its
field provider is context-only, ref=null) — so it never activated.
Mirror the date-range-picker reference: the root becomes a wrapping flex row,
the label claims its own full row (`flex: 1 0 100%`), and each endpoint
re-flexes to share one line (a non-auto basis overrides the `inline-size: 100%`
that `[data-time-field-input]` inherits from time-field.css). Root height
102 → 60px; no console errors; eidos-lint 0 invalid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Non-modal menus:
- DropdownMenu + ContextMenu default to trapFocus=false + preventScroll=false
(were modal — hard focus-trap + body scroll-lock that made them feel stuck).
Menus stay navigable while open (Chakra-style); the props remain for an opt-in
modal menu. morfo focus.trap=false + the demo control defaults aligned.
list-surface — ONE shared size source for list surfaces:
- new lib/list-surface.css (--list-* keyed on [data-list-surface][data-size], 1:1
with the control sizes) + lib/list-surface-context.ts (shared size context for
portaled sub-panels). DropdownMenu/ContextMenu/Menubar/Listbox bridge their
--{c}-* size tokens to --list-*; ContextMenu gained the size axis it lacked.
Row-height doctrine — unified to CONTROL-HEIGHT:
- archetypes.css [data-archetype=item/option] gains a fallback-guarded control-
height FLOOR (min-block-size: var(--list-item-height, auto) + padding-block:
var(--list-item-py, var(--space-1-5))) — ZERO regression off a list-surface
(the ~45 non-list archetype consumers are untouched). Select/Combobox/Command
stamp data-list-surface so their option rows inherit the floor, matching menu
items. Verified: select option md 36px / lg 44px = menu items; bare archetype
item unchanged (0 floor, --space-1-5 padding).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New MOTION_GUIDE.md — the task-oriented developer front door: the `motion` prop, the three
USE domains (event/state/content), the full preset catalog, recipes (animate in/out, loop,
state transitions, staggered list in&out, springs, custom presets), reduced-motion, theming
tokens, [data-debug-stagger], and constraints. Links the model (eidos-motion) + the engine
(arts/motion) + history (RFC).
- eidos-motion.md: note the 3-domain USE framing over the engine's 2 moments (the content
domain is a usage pattern over the state machinery); fix the `fallback` overclaim — it is
NOT auto-applied (the spring honours ctx.reduced itself, per the audit fix).
- docs/README.md (corpus map) + eidos/README.md: motion was undiscoverable — add the guide to
E4 Guides + the "I want to…" table + the eidos Motion section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes from the motion-ecosystem audit:
- `spring()` honours `ctx.reduced`: snap to the target and settle instead of running
the physics. The engine only short-circuits `reduce: 'instant'`, the driver ignored
`ctx.reduced`, and Presence never applied the declared CSS `fallback` — so spring-pop
played the full bounce under `prefers-reduced-motion`.
- `<Motion>` leave(): a loop preset is infinite — detect `animation-iteration-count:
infinite` and unmount promptly instead of holding the node for one loop iteration
(1–2s, still looping) on removal.
- Loop durations are now canonical `:root` tokens (`--motion-loop-{name}`), declared
like the `--duration-*` scale instead of living only as a `var()` fallback.
Tests: engine-motion (spring reduced) + motion (loop tokens); check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `/demos/motion` page showcasing the content domain:
- Loops (spin/pulse/ping/bounce) on clean SVG via `motionAttrs`.
- Enter → loop → exit lifecycle on a pigeon SVG adapted from the `/demos` paloma
(the page has no ActiveEidos provider, so it loads the static eidos CSS and
replicates `<Motion>`'s exit inline).
- Staggered list: container-driven cascade IN (first item first) and OUT in reverse
(last leaves first), retained then unmounted — the §D.13.2 bridge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `motion` prop: the Card is the state-domain pilot. Becoming `selected` plays the
preset once via `data-motion-state` (mount-guarded — animates on the transition,
not on mount/deselect). `card-emerge` yields to the motion system when `motion`
is set (otherwise toggling `data-motion-state` re-triggers the mount fade).
- Nested-interactive guard: clicking a control inside an interactive Card (a Footer
button, a link) must not select the Card. `handleActivate`/`handleKeydown` bail
when the event originates from an interactive descendant, and the `:active`
press-scale is suppressed via `:not(:has(button:active, …))` — the press cascade
reaches the ancestor `<button>`, which `stopPropagation` can't touch.
- Demo: `motion` chip control on the live Card.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the content domain of the universal `motion` prop (RFC §D.12/§D.13):
- State domain: a dedicated `data-motion-state` attribute lets a component with
its OWN data-state machine (Card selected/idle) drive a preset on its transition
without clobbering its semantic `data-state`. Enter-only (animate-on, snap-off).
New emphasis preset `select-pop` (no opacity-from-0, for still-visible elements).
- Loops (content domain, infinite): Tailwind's canonical `spin`/`pulse`/`ping`/
`bounce`, emitted as un-gated `[data-animation-style='X'] { animation: … infinite }`
rules. Themeable per loop via `--motion-loop-{name}`; reduced motion stops them.
- Staggered-exit bridge: the container-driven cascade now covers exit too —
`[data-stagger][data-state='closed'] > …` cascades children OUT in reverse
(`--motion-stagger-index-rev`, last leaves first). The container is retained for
the stagger window then unmounts (pragmatic CSS bridge for bounded lists; the
parent-waits-for-children version still needs the retired PresenceGroup).
- `[data-debug-stagger]`: opt-in dev affordance — a CSS counter badge per child
showing its `:nth-child - 1` index (the analog of UIX_DEBUG_MOTION).
- Fix `<Motion>` exit: a `{ duration }`-only Svelte transition never completes (the
node sticks at the exit frame, a re-show reuses the dead node); add a `tick` no-op
so it times the unmount.
Generated base.css regenerated; motion.test 27/27; docs (eidos-motion + RFC) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `motion` chip-picker (slide-fade · scale-fade · fade · spring-pop,
default spring-pop) on the live `Popover.Content`, plus the prop in the
generated code snippet. Demonstrates the spring engine tier woken in
4878ccfb running live on an overlay: selecting `spring-pop` stamps
`data-animation-style="spring-pop"`, which Presence resolves to the
registered JS `spring` driver and runs via `motion.run`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- focus ring: adopt canonical `--focus-ring-*` (outline, HCM-safe, primary-tint) — drops the per-color fork
- press archetype: new `STATIC_PRESS` primitive (`--press-scale: 0.985` / `--press-duration: 80ms`) emitted from foundation. Button switches from hardcoded `translateY(1px)` to canonical `scale(var(--press-scale))`. Card recipe repointed to the shared token — single press signature across the system
- intrinsic box: `inline-size`/`block-size: fit-content` defends the Button against flex/grid stretch (was filling 400×160 in a grid; now 94×36). `data-block` still opts into width-stretch, `data-icon-only` still square
- variant=emphasis only: `plain` no longer mutates layout (`min-block-size: auto` + `padding-inline: 0` removed) — same control box as the other 5 variants. Inline link-like actions belong to <Link>, not here
- spinner composition: Button drops its bespoke SVG ring + `@keyframes button-spinner-spin` + `--button-spinner-duration: 720ms` and composes the canonical <Spinner variant="ring"> via two new modes: `presentation` (no role=status/aria-live, marks aria-hidden — Button owns the a11y) + `color="inherit"` (currentColor + 25% track). One spinner across the system, unified duration (0.9s) and reduced-motion behaviour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Until now every shipped motion preset was `driver: 'css'`: the `arts/motion`
engine's `spring` driver (semi-implicit-Euler physics, velocity handoff) was
built + tested but never CONSUMED. This ships the first JS preset, `spring-pop`
— a PHYSICAL pop (overshoot + settle, the curve no cubic-bezier expresses; low
damping on enter, stiffer exit). Run by `uix.motion` through soma's Presence
(`motion.run` + `pending()`), with a CSS `scale-fade` fallback for reduced-motion
/ SSR. Use on a Presence surface: `<Popover.Content motion="spring-pop">`.
Key architecture decision: JS presets are registered DIRECTLY with the engine in
`ActiveEidos`, NOT via `config.motion.presets`. The `EidosConfig` is serializable
(deep-cloned via `clonePlain`/`structuredClone`), and a `MotionRun` is a function —
`structuredClone` throws on it (DataCloneError). Rather than make the whole config
clone function-aware (which flattened special types and broke ~23 config tests),
JS presets — framework built-ins — live outside the cloneable config. The config
stays plain; `clonePlain` is untouched.
- `lib/motion/presets/js.ts`: `BUILTIN_JS_PRESETS` (`spring-pop`).
- `active-eidos`: registers `BUILTIN_JS_PRESETS` directly with `uix.motion`.
- `registry.ts`: `spring-pop` added (type-safe `motion` prop).
- `motion.test`: spring registration + driver test; F7 typegen extended to JS
presets; fixed a stale shared-axis regex (the container-driven enter branch from
`9d40ebe9` made the selector a comma-list — a pre-existing failure surfaced here).
motion.test 23/23; the config-dependent suites are back to their pre-existing
(unrelated, other-track) baseline — this adds zero failures. `check` clean for
the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tooltip's `delayed-open` entrance (the per-side slide, and now the bounce) NEVER
fired: the content only mounts when `open` becomes true, and the hover-delay timer
set `internalTooltipState = 'instant-open'` AT THAT MOMENT — so the content always
mounted as `instant-open` (no entrance). `delayed-open` was only ever the pending
state while the content was still unmounted. A long-standing dead animation.
Fix: the hover-delay timer now keeps `'delayed-open'` as the content mounts, so the
eidos `[data-state='delayed-open']` entrance plays. `'instant-open'` stays reserved
for focus / skip-delay (intentionally no entrance). Test updated (it asserted the
buggy `instant-open`); tooltip provider suite 4/4.
Also makes the bounce clearly perceptible (it was too subtle to notice even when
forced): scale 0.5 → 1.18 (overshoot) → 0.9 (undershoot) → 1.06 (second bounce) → 1,
over `--duration-slow`. Verified end-to-end in-browser: hover → content mounts
`delayed-open` → `animation-name: eidos-tooltip-bounce-in`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the tooltip's per-side 2px slide-in with a scale BOUNCE: grow from 0.7,
overshoot to 1.06, dip to 0.97 (the undershoot — the "rebote"), settle to 1, over
`--duration-moderate`. It scales from `transform-origin` (the floating origin,
toward the anchor), so it keeps a directional feel without per-side keyframes —
8 keyframes collapse to 2.
Bespoke recipe keyframe, NOT a registered `motion` preset: the tooltip's state is
3-valued (`delayed-open` / `instant-open` / `closed`), not the generic `open` /
`closed` the shared presets key on — so a registered preset wouldn't fire here.
This also confirms the architecture boundary: components whose state shape doesn't
match open/closed use a bespoke recipe animation, not the `motion` selector.
`instant-open` (keyboard focus) keeps no entrance; the reduced-motion block (kills
the animation) already covers the new rules — verified the bounce curve in-browser
(scale 0.7 → 1.06 → 0.97 → 1). Recipe-only change, no generator/base.css touched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Marks (b) step 2 done (Badge pilot `0a6d112c` + `<Motion>` wrapper `54268717`)
and records the two findings from building them:
- The helper only works on components WITHOUT their own `data-state`. `motionAttrs`
/ `<Motion>` force `data-state="open"`, which would clobber a stateful
component's own machine (Card selected/idle, checkbox checked). The state domain
is a separate path (its `motion` would tie to its own transitions, but the
presets key on open/closed specifically).
- The `data-motion` collision. That attr belongs to reduced-motion: arts/prefs
projects the `motion` pref (system|allow|reduce) onto `<html data-motion>`, and
render-css emits `[data-motion='reduce'] …` + the prefers-reduced-motion media
query. So `<Motion>` emits NO part marker — only the functional attrs. Confirms
the content domain is reduced-motion-safe for free (the overrides key on
data-state, which motionAttrs sets).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ergonomic companion to `motionAttrs`: wrap any content to animate it IN and
OUT. `<Motion motion="scale-fade">…</Motion>` plays the preset's enter on mount
and its exit on removal (Svelte `out:` flips to `data-state='closed'` + retains
the node for the eidos-declared duration, like `<Cascade.Item>`). Rides the
existing state-presets — no new mechanism, no JS engine.
- morfo `motion.ts` (scope ['eidos']): one Provider part declaring `data-state`.
- eidos `motion/`: component (spreads `motionAttrs` + `out:` retention) + types +
index + README (incl. the `<Motion>` vs `motionAttrs` table + the content-domain
boundary).
- badge demo: a `<Motion>` show/hide example next to the prop showcase.
Caught + fixed a real collision: `data-motion` is already the reduced-motion prefs
attr (`[data-motion='reduce']`), so the wrapper emits NO part-presence marker (a
prefs `closest('[data-motion]')` would have matched it) — only the functional
`data-animation-style` + `data-state` from `motionAttrs`.
Verified at runtime: enter (data-state=open → `scale-in, fade-in`, no data-motion)
and exit (Hide → retained with data-state=closed → `scale-out, fade-out`). `check`
clean for the morfo + component + demo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First real consumer of `motionAttrs`: `<Badge>` gains a `motion` prop so a
designer can apply any registered preset as the badge's entrance — `<Badge
motion="scale-fade">` plays the preset's enter on mount, zero per-component
machinery (the component just spreads `{...motionAttrs(motion)}`).
Badge is the clean content-domain pilot: pure eidos primitive, no morfo events,
no own `data-state` (so the helper's presentation `data-state="open"` doesn't
clash — unlike Card, which has selected/idle + a built-in emerge). Additive +
non-breaking: `motion` defaults to undefined (no animation).
- badge/types.ts: `motion?: MotionPresetName`.
- badge/badge.svelte: spread `motionAttrs(motion)` on the span.
- badge demo: a "Motion" showcase (fade / scale-fade / slide-fade + Replay) —
the prop demonstrated per the testbed doctrine.
Verified at runtime: the three motion badges render `data-animation-style` +
`data-state="open"` with the right enter `animation-name` (fade-in / scale-in,
fade-in / slide-from-bottom, scale-in, fade-in). `check` clean for badge. (A
fresh badge screenshot was blocked by preview-infra flakiness this round; the
enter keyframes themselves were confirmed visually earlier via the cascade.)
Establishes the pattern: any component becomes animation-selectable by accepting
`motion` + spreading the helper. RFC §D.12 (b).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The universal selector for the CONTENT domain (RFC §D.12). `motionAttrs(preset)`
returns the attrs that make ANY element play a registered preset's enter on mount:
<div {...motionAttrs('scale-fade')}>…</div> // fades + scales in on mount
Resolves the open "how does content fire" decision — and reverses the §D.12 plan's
guess. Instead of a new `data-motion-play` trigger (which would need a generator
change + a base.css regen, currently entangled with an unrelated `press`/chronos
WIP), it reuses a constant presentation `data-state="open"` — the same mechanism
`<Cascade.Item>` already uses for content. The event/state/content line is about
who OWNS the animation (firma / state-machine / the prop directly), not about
whether the CSS trigger is `data-state`: content is "always shown" with no machine
to flip it. Reuses the enter/exit + reduced-motion rules verbatim; `'none'`/
undefined opts out; flash-free when spread in markup (`backwards` fill).
Verified in-browser: an element with the produced attrs gets `animation-name:
scale-in, fade-in`, enter progresses 0%(op0,scale .985)→50%(.84,.998)→100%(1,1);
`'none'` returns no attrs. `check` clean for the new file + export.
Step 2 (pending): a `<Motion>` wrapper (out: retention → exit) + wiring the
`motion` prop into real components.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`<Cascade>` was the last component using `animation` as its preset selector — a
vestige of the coordinated `cascade-*` system retired in Plan A. Every other
animatable component (Dialog, Popover, Drawer, …) already uses `motion`. Renames
`<Cascade>`'s prop + its context field `animation` → `motion`, so the framework
has ONE universal selector name (RFC §D.12 step a). `MotionPresetName` and the
low-level `data-animation-style` attr are unchanged.
- cascade: types `animation?` → `motion?`; context `CascadeContext.animation` →
`.motion`; component destructure + getter; item reads `ctx?.motion`.
- docs/demo: README props table + usage, index usage, panel-cascade demo.
Verified at runtime: `<Cascade motion="scale-fade">` produces identical wiring —
items carry `data-animation-style="scale-fade"`, `animation-name: scale-in,
fade-in`, structural index 0/1/2/3. `check` clean for the touched files; no
dangling `animation`-prop reference remains (only the `data-animation-style` attr).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures the design framing agreed this session, BEFORE implementation:
§D.12 — One universal `motion` prop on any component; the discriminant ("does the
animation realize a perceptual event?") decides the domain: event (firma, morfo+sema,
the prop is override/violation) / state (data-state preset) / content (prop is
primary). Supersedes §5's M5/M6 "motion vs animation separate" — its justification
(the coordinated `cascade-*` system) was retired in Plan A. Honest positioning:
content/state = parity with Framer/SwiftUI; event = above (motion coupled with
sound/haptic from one evaluated signal). Caveat: the JS engine (spring/handoff) is
built but dormant — the "above" on the engine axis needs a first spring preset.
§D.13 — The contracts the CSS model demands + failure modes, separating what the
mechanics already guard from what's deferred:
- structure: `:nth-child` ignores comments/`{#if}` (guarded); intermediate ELEMENT
wrapper breaks the count (the contract); a cascading wrapper becomes its own scope
(`inherits:false` isolates the index); `display:contents` = known dead-zone.
- exit: animated surface exits via Presence/`out:`, NEVER raw `{#if}`; unit-exit works
today (parent opacity carries the children); per-child staggered exit needs JS
lifecycle (PresenceGroup, deferred) → the container-driven rule is enter-only.
- debug: the index is `inherits:false` (no ancestor can step on it); a
`[data-debug-stagger]` mode to surface index/delay/name is pending.
Pointer added in eidos-motion.md. No code changed — design record only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the menu-family event-driven cascade: a SubContent fly-out now cascades
its items in on open, same as the root panel. The SubContent has its own
`data-state`, so the existing container-driven preset rule applies unchanged — it
only needed the `[data-stagger]` marker on the panel + `--motion-stagger-each` on
the sub-content recipe selector. Its items are the same `DropdownMenu.Item`
(already `data-animation-style="fade"`). Menubar inherits via the shared
DropdownMenu.SubContent.
Verified at runtime: SubContent panel carries `data-stagger` + `data-state=open`,
`--motion-stagger-each: 20ms`, items `name:fade-in` with consecutive `:nth-child`
index 0/1/2 (a flat submenu cascades cleanly). `check` clean for the touched
components.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the just-landed DropdownMenu event-driven item cascade to its siblings
so the framework is coherent — every menu in the family fades its items in
staggered on open, same mechanism, zero new generator code (rides the
container-driven preset branch shipped with DropdownMenu).
- ContextMenu (own recipe): Content marks the panel `[data-stagger]` +
`--motion-stagger-each: var(--motion-stagger)`; item / checkbox-item /
radio-item carry `data-animation-style="fade"`.
- Menubar (ONE line): soma `Menubar.Content` delegates to `DropdownMenuContent`,
so its panel already carries `data-dropdown-menu-content` (→ the dropdown
recipe rhythm applies) and re-uses `DropdownMenu.Item` (→ already
`data-animation-style="fade"`). It only adds the `[data-stagger]` marker.
Verified at runtime: ContextMenu (scrubbed opacity gradient 0.96/0.84/0.00),
Menubar (panel carries `data-stagger` + `data-dropdown-menu-content`, items
`name:fade-in`, structural index from `:nth-child` 0/1/2/6/7). `check` clean for
the touched components. (Preview tab runs backgrounded → rAF paused; verified by
scrubbing `animation.currentTime` via WAAPI rather than waiting for playback.)
Deliberately NOT rolled out: select / combobox / command — live-filtered or
long-scroll lists where an open cascade is questionable UX.
Docs: ContextMenu README (Motion parity note), Menubar index doc, DropdownMenu
README (family cross-reference), RFC §D.11.4 (rollout).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Productizes the event-driven cascade on a real component (not the explicit
`<Cascade>` orchestrator). When a DropdownMenu opens, its items fade in
staggered by structural order — the appearance flows from the `open` (emerge)
event, nothing in the app wires it.
Architecture: a +1 GENERIC selector branch in the motion generator, NOT a
per-item `data-state`. A `menuitem` isn't open/closed — the menu is — and eidos
writing a state attr is a smell. The new container-driven form
`[data-stagger][data-state='open'] > [data-animation-style='X']` fires each
child's ENTER off the container's `data-state` (which soma already writes via
the morfo `commits`); items carry only `data-animation-style`. ENTER only: the
coordinated EXIT cascade (retain the container until children finish) is the
deferred soma PresenceGroup work, so a closing container can't drive a child
exit that would be cut off on unmount.
- render-css.ts: `renderCssPresetRules` adds the container-driven enter branch
(same declarations, shared comma selector) — reuses the existing presets +
the foundation `:nth-child` stagger index; zero new system.
- DropdownMenu: Content marks itself `[data-stagger]` +
`--motion-stagger-each: var(--motion-stagger)` (20ms, themeable, no magic
number); item/checkbox-item/radio-item carry `data-animation-style="fade"`
(overridable). `fade` so item opacity doesn't fight the panel's own scale.
- regen generated/base.css (8 css presets gain the container-driven enter).
Verified in browser (frozen frame): index from `:nth-child`, `delay = idx ×
each`, opacity gradient in flight (Ruler 0.98 → Grid 0.89 → Guides 0.69, Log
out 0.00). Honest limit (documented): only DIRECT-child items cascade — items in
a <Group>/<RadioGroup> DOM wrapper aren't `:nth-child` of the panel, so they
appear instantly (the "transparent intermediate").
Docs: dropdown README §Motion, cascade README §2 (concrete consumer), RFC §D.11.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- dialog + drawer adopt data-depth="modal" (surface·border·base-shadow from the plane);
recipes pruned of orphan content-bg/border/shadow. The bundle's on-surface font fixes
the Times New Roman of portaled content (Decisión 8).
- Dialog.Close inherits the dialog size via context.ts, capped at md (sm→sm, md/lg/xl/full→md;
full is layout, not a bigger control — Radix/Mantine/MD3). THEMING §5 container→part norm.
- Dialog title → h3 typography role (--dialog-title-* points at --style-h3-*, override knob kept).
- Dialog trigger + close compose the framework <Button> via the asChild `child` snippet
(the trigger was a passthrough that rendered soma's native <button>); variant/color/size
defer to the canonical Button defaults (solid/primary) — no hardcoded ghost/neutral.
- demo layout (web/routes/uix/+layout@.svelte): "Display" control — density·scaling·border-width
applied to :root (reaches portaled overlays) to observe the interrelated theming axes live.
- update the depth-plane test (z is token-only; on-surface font) + the archetype audit hand-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cascade is no longer a separate system — it's the existing state-presets + the
Material stagger + one foundation rule. Retires the --cascade-* reinvention.
- render-css: foundation structural-index writer — `[data-stagger] > *:nth-child` →
`--motion-stagger-index` (forward) / `:nth-last-child` → `--motion-stagger-index-rev`
(reverse). Nobody writes the index; soma never writes a visual var. Every preset's
enter/exit rule consumes it (parallel = `--motion-stagger-each` 0, cascade = N).
- Cascade rewritten as a thin EXPLICIT orchestrator: marks `[data-stagger]`, reflects
open→data-state, passes the preset to items via context; the `out:` retention flips
to data-state=closed (reusing the exit preset), reading the duration via dom.getWindow.
Drops the whole `--cascade-*` namespace + bespoke keyframes.
Docs: MOTION_SERVICE_RFC §D.11 — the final model (three orthogonal axes sema/motion/
eidos, the full lifecycle, the realization ladder snap->transition->animation->JS, the
firma-vs-realization seam, the cascade = existing system + one rule, the motion service
as the engine). eidos-motion aligned.
Verified: browser (enter scale-in/fade-in + nth-child index, exit reverse, zero inline
writes), npm run check 0 mine, motion.test 22/22, eidos-lint 0 invalid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the parallel motion-coordination service per the redesign: animation is a
channel of the EVENT's firma (sema owns it across all channels), not a parallel axis.
Code is gone; the RFC stays as historical record with a retirement banner.
- morfo: drop MorfoPart.animation + its schema/compile/types/exports
- arts/motion: drop CoordinatedPreset / MotionConfig.coordinated
- eidos: drop BUILTIN_COORDINATED_PRESETS / renderCoordinatedPresetRules / the
animation:none neutralization + registry block; regen generated/base.css
- soma: Presence reduced to a single-surface island; delete presence-group /
dom-cascade / coordination; dropdown-menu loses the cascade wiring
- delete Reveal / Rail (morfo + soma + demos)
Docs: MOTION_SERVICE_RFC gains the retirement banner + §D.8 (retirada) + §D.9
(verified model: morfo->soma->sema->eidos pipeline + the two hard rules — soma
never writes a visual --var; data-event-* is a single-target stamp, not a bus) +
§D.10 (Fase 1 prototype). Fix stale "5 canales" claim in GUIA §11; eidos-motion +
dropdown README aligned.
Fase 1 prototype (web/routes/temas/animations/panel-cascade): validates the model
end to end — panel->cards cascade (enter/exit), dynamic removal with Svelte out:
retention, nested cascade — all via :nth-child + custom-property inheritance, with
zero JS visual writes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coherence audit (ARCHETYPE_COHERENCE_AUDIT_2026-06-19): 8 archetypes + 8 closed
decisions, the §13 construction contract, per-component deviation matrix (88/117
deviate). Master guides (soma/COMPONENT_GUIDE, eidos/components/README) point to it.
A1 — elevation bundle via `data-depth`: the plane paints surface·border·shadow·halo
(added `border` to the depth bundle). z-index is NOT painted — stacking is a
positioning concern owned by the floating-wrapper. Popover migrated to the overlay
plane (panel + arrow consume it); recipe orphans pruned.
Popover fixes: arrow un-clipped via an inner [data-popover-viewport] (Radix-style;
the absolute arrow escapes the scroll clip), sized via density tokens (not soma's
hardcoded 10×5), border-seam overlap keyed on data-side, hover-bounce guard in
scheduleHoverOpen (ignore hover-open when already open). Dialog.Close composes
IconButton/Button (neutral) — closes the Lote ② debt.
Option lists: density-driven padding/radius via --space-* + concentric radius,
ScrollArea adoption, emerge open/close events (combobox/select/command), and the
canonical option-highlight in archetypes.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- <ColorField.Input swatch> renders an opt-in leading colour chip showing
the field's current value, sized to the control icon (--icon-size-{size})
via a concrete token — never 1em, so it cannot recompute/jitter.
- field-segment-state: drop the active-segment font-weight 600; bold widened
the ch-based segment ~0.5px, resizing the highlight on every increment. The
accent background already marks the active segment.
- color-field stays full-width like the whole field/select family — width is
container-driven, not field-driven (no per-component exception).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidate the per-field / per-picker duplication the audit surfaced:
- Shared control trigger — new field/field-control-trigger.css selected by a
`data-field-control-trigger` marker: flush (no border, no shadow), UNIFORM
neutral hover tint (not the field accent), font-relative size. Migrated
date-picker + the password visibility toggle; deletes their per-picker chrome.
(date-range / time / time-range pickers still to migrate.)
- Shared segment state layer — new field/field-segment-state.css selected by a
`data-field-segment` marker (date / time / color fields): one hover (editable)
+ active (focus) rule. Fixes a specificity bug where the hover selector
out-ranked :focus, so hovering the active segment downgraded its highlight.
- Segment fixed width — min-inline-size: calc(2ch + 2*segment-px) +
font-variant-numeric: tabular-nums so a segment never reflows when its value
changes 1<->2 digits or between glyphs (no horizontal jitter). date-field done.
- date-range-picker trigger uses the same calendar icon as date-picker (was a
distinct calendar-range glyph).
- Generated element ids use the framework-neutral `uix-` prefix instead of the
layer name `soma-` (createId / useId + announce / menubar inline ids) — the
doctrine that bans data-soma-* / --soma-* keeps a layer name out of ids too.
- field.css: label + helper text scale one step below the control (font-relative
calc); optional-indicator weight to regular.
Foundation tokens added: --field-segment-hover-bg, --field-control-trigger-hover-bg.
Verified in-browser by measurement (date-field segments: states + no-jitter;
date-picker trigger: flush + uniform hover). check = 58 baseline (0 new);
recipe-css-contract clean (only pre-existing palabras WIP).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersede the "compact control md=14" archetypes (Fase 7) with a single
rule: control text scales 1:1 with its size (md=16) across all components,
so changing --font-size-md re-themes the whole system. Applied to button,
badge, breadcrumb, calendar, pagination, radio-group, toolbar, file-upload,
tag-group, stepper, toggle, tooltip (the field family was already 1:1).
Button + search-field icons now follow the font 1:1 (icon ~= font+2;
density-independent, like the type scale). Fix button icon vertical
centering: the [data-button-icon] slot didn't flex-center, so the inline
<svg> sat on the text baseline ~1px below the button center.
Exceptions left as-is (not control text): avatar (initials sized to the
circle diameter), accordion (section-heading prose scale), password-field
icon-size (it is the visibility-trigger button box, control-coupled by
design), radio-cards (icon follows the card title).
THEMING.md section 5 rewritten: 1:1 universal mapping + "density is
orthogonal to typography" (density scales space + control-height only;
font + icon ride only the global --scaling zoom).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Field font scale: input 1:1 (size N -> --font-size-N, md now 16px), label one
step below (10/12/14/16/18). Recipes: field, spin-field, date/time/color-field,
search-field, password-field, select, editable, tags-input. Segmented-field
labels via CSS calc; generic Field label via label-font-size tokens.
- Picker triggers (date/time/range): box now scales off the local
--_X-field-height + font:inherit so the 1em icon follows the field font; added
the missing xs size variant to time-picker + time-range-picker.
- color-field/time-field commit-set fires + sounds on increment (compare by
ColorValue.hex; time-field passes fallbackTarget for the double-registered
'input' part).
- color-field format-select: reverted the segment-active tint back to its focus ring.
- Docs: THEMING.md field font rule; COMPONENT_COHERENCE_AUDIT.md.
words / palabras / chronos untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prototipo descartable (web/routes/temas/animations/menu-native) que valida la
Decisión A del re-diseño del servicio de motion: el lifecycle de presencia se
apoya en Svelte nativo ({#if} + transition:|global) en lugar de la maquinaria
custom de F2 (DomCascade/pending/beginEnter).
Validado en navegador real:
- entrada escalonada sin ease-vs-jump; salida con stagger invertido
- exit-heavy nativo (panel retenido hasta el ultimo item, sin pending())
- adicion dinamica -> entrada individual (el contenedor arbitra opening/closing)
- roll-up del panel como firma de cierre opcional (when: exit:'after')
RFC Apendice D.7 documenta resultados + hallazgos (grid-template-rows no anima
por WAAPI; usar height/interpolate-size).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents the second coordination mode (DomCascade) wired in M9 F1/F1c/F2: the design, the contract (staggerChildren), pending() exit-heavy, the dropdown-menu cascade, and the four coexistence fixes (transition collision, disabled opacity, dismiss-fade panel signature, trigger/dismissal toggle). Updates the status note, TOC and §15 roadmap; adds the children-DOM cross-link to eidos-motion.md and an opt-in 'animation' Motion section to the soma README.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1c (exit-heavy): DomCascade.pending() aggregates the items' finished so the owner Presence holds the subtree until the exit cascade settles (PresenceOptions.pending, Presence.startPhase). beginEnter drives the enter from the off-state with the transition suppressed (items mount in the on-state, so a passive mirror would ease toward off instead of jumping). pending() settles each finisher (then(noop,noop)) so one cancelled row can't collapse the wait.
F2: wire the children-DOM cascade into the real dropdown-menu. Content part declares animation.surface+staggerChildren; provider routes an opt-in 'animation' prop, runs a DomCascade over getCascadeRows (every visible row incl. disabled), forwards a pending hook through the floating shell. Coexistence fixes surfaced by the menu: item rows hand their transition to the cascade-* preset via a higher-specificity rule (longhands keep the stagger delay); disabled opacity gated off during the cascade; the panel's own dismiss-fade signature suppressed in cascade mode (data-cascade) so it doesn't fade the panel before the rows finish; the trigger is excluded from the menu's dismissal so the toggle closes (pre-existing bug). Verified in a real browser (Playwright): enter+exit cascade with reversed stagger, all four close methods, disabled row fades. dom-cascade 9/9, check 0 new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El piloto M9 (dropdown-menu) destapó que el PresenceGroup coordina children
que son `Presence` REGISTRADOS (Reveal/Rail), pero un menú real es owner-
`Presence` (el content monta como unidad) + children-DOM (items por selector,
sin `Presence`). 3 grietas en 3 capas: registro, index per-item, y escribir
una CSS-var per-element (que ActiveDom no cubría).
Modo children-DOM (la 2ª coordinación): el owner PROPAGA su lifecycle a los
items DOM — escribe data-animation-style + data-starting/ending-style (espejo
del owner) + --motion-stagger-index/-count (por orden DOM), reutilizando el
preset coordinado de eidos SIN cambios (los items quedan con los mismos attrs
que un child-`Presence` produciría).
F1 (núcleo):
- morfo: `MorfoPart.animation.staggerChildren?: string` (types + schema +
invariante: requiere surface, excluye children).
- adom: `ActiveDom.writeProperty/removeProperty` per-element (la pieza que
faltaba; + stub disabled).
- soma: `DomCascade` (`dom-cascade.svelte.ts` — `sync`/`clear` puros y
testeables, `watch()` corre el $effect espejo) + barrel export.
F1b (validación aislada): demo `/temas/animations/dom-cascade` (owner Presence
+ items DOM + DomCascade.watch). Cazó un bug que el test no veía: DomCascade
usa $effect → debe ser `.svelte.ts`, no `.ts` (el test solo ejercita sync/clear,
nunca watch()). Cascada de ENTRADA verificada en navegador.
PENDIENTE (en la memoria del proyecto): F1c exit-heavy (el owner espera a los
items — getAnimations no ve subtree), F2 el dropdown-menu real (focus-trap/
dismissal/submenús), y volcar §M9 al RFC.
Tests: dom-cascade 5/5, morfo 70/70. svelte-check: 0 errores en mis archivos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Un componente coordinado puede componer la cascada (motion) con sonido/háptico
(sema) en el mismo evento. El cabo: el meta-canal visual de sema estampa
data-event-* siempre que el evento tenga algún canal (channels != []), así que
channels:['sound'] arrastraba la firma visual genérica (present-rise), que pelea
con la cascada.
Solución EN MOTION (sema intacto): eidos neutraliza su PROPIA firma visual sobre
las superficies coordinadas. render-css.ts > renderCoordinatedPresetRules emite
`[data-animation-style='cascade-X'][data-event-phase='active'] { animation: none
!important }`. La firma usa `animation` -> muere; la cascada es `transition` ->
sobrevive. Aditivo: inerte hasta que un coordinado dispare un evento sema.
Ejemplo: Reveal pasa a modo (c) — open y close declaran channels:['sound']
(emerge suena, pitch 600) + expression:'family-default'; suena al abrir y al
cerrar a la vez que la cascada, sin pelea visual. Rail queda como modo (b) puro.
Layout de /temas/animations con events:{ sound: true }; la demo /reveal explica
el modo (c).
Honestidad: la versión inicial del RFC Apéndice B afirmaba que el modo (c)
componía "gratis" — falso (verificado en engine.ts/visual.ts). Corregido: el
Apéndice B documenta ahora el acoplamiento real y el opt-out en motion.
Tests: eidos/motion 25/25 (neutralización) · morfo 70/70 · morfo:vocabulary
limpio. Audio verificado en navegador (suena al abrir y cerrar).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continuación del servicio de motion-coordination (sobre M1–M6 ya commiteado).
- M5 — naming `animation` / `motion` SEPARADAS por rol (no unificar: tras M6
son sistemas distintos — `motion`=momento `--state`/`data-state`/eidos;
`animation`=coordinado/`data-starting-ending-style`/soma). `animation` gana
type-safety vía el registry augmentable `MotionCoordinatedPresets` +
`CoordinatedPresetName` en `$motion` (la capa compartida, para que soma lo
tipe sin importar eidos); eidos lo puebla con `cascade-slide/-fade/-scale`
por declaration-merging — espejo de `EidosMotionPresets`.
- Reversa fluida — JS handoff (§8.3). El motor expone `MotionHandle.peek()`
(posición + velocidad por propiedad) y la reinyecta en la fase inversa vía
`MotionContext.handoff`: el `spring` continúa desde la posición/velocidad
actual en vez de reiniciar desde el `from`. Orquestado enteramente en el
engine (`takeHandoff` — captura en `cancel`, consume en `run`, y cancela un
run en vuelo cuando el caller no pre-cancela): cubre el camino island
(overlay-spring) y elimina un apilamiento preexistente. La reversa de los
coordinados (transiciones CSS) ya era fluida.
- Demo `/temas/animations/compuesto` — un `Rail` de `<Button>`s que usa los
presets coordinados PREDEFINIDOS de eidos (cero CSS de animación en la
página); el selector cambia `animation` en vivo y cada Button compone su
press (firma sema) con la cascada (motion coordinado).
- Docs — RFC Apéndice B (convivencia de los tres sistemas visuales: firma
`--event` / state-preset `--state` / coordinado, con diagrama + ejemplo
Button-con-tokens), §5 reescrito (separadas por rol), §8.3/§15 al día, y
cross-link del tercer eje desde eidos-motion.md.
Tests: arts/motion 9/9 (handoff) + eidos/motion 24/24 (M5 paridad).
svelte-check: 0 errores en mis archivos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Servicio de motion cross-layer: morfo declara superficies animables +
coordinación; soma coordina presencia/lifecycle; eidos posee lo visual;
arts/motion ejecuta por-nodo. RFC en eidos/MOTION_SERVICE_RFC.md.
- M1 — contrato MorfoPart.animation (types/schema/compile); children
como { enter?, exit? }.
- M2 — PresenceGroup (soma/layers/presence-group.ts), rune-free;
Presence.group descubre el coordinador por context.
- M3 — exit con retención de DOM (§8.1).
- M4 — interrupción/reversa (§8.3): token de generación + motion.cancel
en flip + toHandle resuelve finished en cancel (sin AbortError suelto).
- M5 — prop `animation` enrutada a las parts surface:true del morfo
compilado (routeAnimation); Panel/Item emiten data-animation-style.
- M6 — stagger auto-derivado del orden de registro (--motion-stagger-*,
inversa en exit) + presets coordinados en la librería de eidos
(MotionConfig.coordinated; cascade-slide/-fade/-scale) que reaccionan
a data-starting/ending-style, NO a data-state.
Helper Coordination (soma/layers/coordination.ts) extraído y validado
por DOS consumidores reales: Reveal (raíz virtual + Panel owner) y Rail
(raíz=owner, together). Demos en /temas/animations/{reveal,rail,
presence-group}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New component-authoring rule, per request: when building a component (or its
demo, or any UI), compose the framework's existing soma/eidos components rather
than re-implementing primitives inline. If a needed building block does not exist
as a framework component, flag the gap so it gets built as a proper reusable
component (its own morfo + soma + eidos) and then composed — never an ad-hoc
reinvention that drifts from the system. The picker family (A27) is the canonical
example of composing existing components.
Full rule in COMPONENT_GUIDE "Before You Start" §4; one-line pointer added to
CLAUDE.md Key Conventions so every session loads it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the arts/ audit (docs/process/arts-audit-2026-06.md):
- connection: drop 'export * from helpers' (leaked internal builders) and name-export the public error surface; remove the redundant EngineConnections.close() alias (consolidated to closeConnection, consistent with openConnection/reconnectConnection/closeAll) and retarget its destructure-safety test.
- http: extract the inline ATTEMPT_COMPLETED diagnostic message into a named builder (attemptCompletedLogMessage); output preserved.
- color: drop stale 'Phase 0 / not consumed yet' comment (uix.color is consumed by Eidos).
Verified: vitest connection/http/color green, type-check clean, prettier clean. The QrCode morfo fix (literal('path')) is left in src/uix/morfo/schema.ts because that file also carries unrelated in-progress animation-morfo work — not swept in here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There is no sium-specific Svelte adapter by design: forms (createForm / Form.Provider) consume any schema via ~standard, Form.AutoFields renders via ~sium introspection, and langs/logger inject through createEngineSium(). sium is page-scoped and not an ActiveUix service. Fixes README (Estado, dep rules, Integracion Svelte, troubleshooting) + core/types.ts comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The icon<->typography track is resolved (option 1: the icon follows the font the
component actually uses; cards follow the title, never inflated). The decisions
now live correctly in THEMING §5 (just corrected) + the recipes, so the hand-off
is no longer needed — most of it was the superseded "1:1 total" plan.
Its two live residual TODOs move to pendiente.md (the eidos pending tracker):
audit the Icon <svg> CSS-override bug in other components, and decide whether to
delete the /uix/icon-scale-study test bench. Recorded in the corpus hand-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
THEMING §5 documented "prominent icons (cards): the icon does NOT follow its
title, it bumps to xl/xxl" — the pre-continuation approach. The final decision
and the shipped code reject it: the radio-cards recipe sets icon-size to
sm/sm/md/md/lg (16/16/18/18/20, following the title) with an explicit comment
("the icon follows the title; a bigger card icon comes from a bigger title,
never from inflating the icon"). The doc had drifted from the code.
Corrected §5 to state the implemented rule: cards/titles icons follow the
title's font; prominence comes from a bigger title, never an inflated icon.
Found while verifying that CONTINUE-icon-type-scale.md's decisions actually
match THEMING before retiring it — they did not, on this point.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The bullet linked THEMING_AUDIT_2026-05-27.md, which does not exist (the
2026-06-01 audit itself flags the link as broken). Repointed to the audit that
does exist, THEMING_AUDIT_2026-06-01.md.
- eidos-motion.md was described as "propuesta motion (deferred, partially
superseded by TSC)" — the same stale contradiction fixed in §14. It is the
implemented two-moment motion system (F1–F7). Corrected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per explicit instruction to remove deprecated/erroneous files (noise/confusion):
- DATA_ATTRS.md (repo root, 2158 lines) — a stale "Terra Data Attributes
Contracts" catalog, already self-headed DEPRECATED. The data-* contract is the
morfo, validated by morfo:check; this file only lied about it.
- scripts/generate-contracts-docs.ts — its generator, which imports the removed
src/uix/terra/utils/contracts.ts, so it can no longer run.
No doc links to either; nothing imports the script. The package.json
`generate:contracts-docs` entry now dangles at the deleted script — left
untouched because package.json carries unrelated concurrent edits I won't sweep;
flagged in the CONTINUE hand-off to remove later.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
testing-and-tooling.md listed `generate:contracts-docs` as live codegen "from
the morfos" — it is not. The script imports `src/uix/terra/utils/contracts.ts`
(the removed terra layer), so it no longer runs, and its output DATA_ATTRS.md is
a terra-era fossil. Corrected: the data-* contract is the morfo itself, validated
by morfo:check; the legacy script + DATA_ATTRS.md survive but are dead.
Also recorded the pending-.md census in the CONTINUE hand-off: DATA_ATTRS.md
(root, retire to process), the two dated audits (audit-opus / THEMING_AUDIT —
the latter cited in ~7 places), and what to leave (fix-stext.md is cited
provenance; MOTION_SERVICE_RFC / chronos are concurrent; words/palabras excluded).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Where UIX sits relative to the two families (headless behavior: Radix/Ark/bits/
React Aria; styled systems: Mantine/Chakra/Radix Themes/shadcn), grounded in
UIX's own documented design choices rather than claims about competitors'
internals:
- morfo as a single declarative contract (compile-time drift),
- a perception layer (sema) neither family has,
- two-moment motion, theme-as-retint, validated token scope, graceful
degradation,
- and the honest trade-offs (more to learn, smaller ecosystem, sema only pays
off if used).
The one competitor-specific claim (Chakra collapses presence onto one axis) is
sourced in eidos-motion.md. Per-component prop-parity comparisons stay in each
component README, by doctrine. Wired into docs/README.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidates the cross-cutting verification story that was scattered across
package.json, COMPONENT_GUIDE and SOMA_ARCHITECTURE into one reference:
- The two-project vitest suite (browser client / node server) and how to run one.
- Each validator and the class of bug it catches: morfo:check (DOM vs contract),
morfo:vocabulary (verb drift), component:audit (acceptance), perm:check
(state transitions), smoke (hydration), translations:check, eidos-lint.
- Codegen vs authored: generate:eidos-css, generate:contracts-docs, and the
compileMorfo primitive.
- The SSR posture: dom:false -> disabledDom, ActiveDom owner-document resolution,
ornamental sema, and why smoke (not HTTP 200) is what catches hydration bugs.
All grounded in the real package.json scripts. Wired into docs/README.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The narrative onboarding path that complements the map (docs/README.md) and the
build guide (COMPONENT_GUIDE): run it, the mental model, and your first change,
in order. Grounded in the real routes (/uix/components/*, /active, /temas) and
the real scripts (check / test / morfo:check / smoke / component:audit /
perm:check), it points at COMPONENT_GUIDE / THEMING_GUIDE rather than
duplicating them.
The centerpiece is "see the whole architecture in one element": inspect a toggle
and watch morfo's data-toggle, soma's data-state, sema's data-event-* and eidos's
CSS all on one node — the doctrine made visible.
Wired into docs/README.md (top callout + first "I want to…" row).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
So a session or agent that creates or edits documentation does it consistently
with the corpus, not against it. Codifies what the migration learned, each rule
tied to the failure it fixes:
1. Link the canon, never copy it (the anti-drift law — "7 families" survived in
three docs because they re-transcribed instead of linking).
2. Every doc belongs to one stratum (E0-E5 / process); where each kind lives.
3. Reference docs are timeless — hand-offs, dated status and hardcoded catalogs
go to process/ or become pointers.
4. One source per concern; two docs on a subject get distinct stated roles.
5. Frontmatter convention.
6. Sections cited by §N are load-bearing — stub-split, never silently renumber;
verify moved links resolve.
7. English target; code comments always English.
8. Naming (rfc-* / design-*), markdown relative links, pre-commit checklist.
Wired into docs/README.md ("I want to… write a doc" + a note after the strata).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Companion to the entry point: an agent or new session can look up the framework's
coined terms (morfo, soma, sema, eidos, archetype, runtime part, Presence,
polymorphic close, hold, cascade, TSC, recipe, variant, role, scaling, …) in one
place, each with a pointer to its authoritative doc. The semantic subset
(family / intent / verb / channel) points at CANON.md instead of restating the
values, so it cannot drift.
Wired into docs/README.md (E0 stratum + reading order).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single front door to the whole documentation set, for new sessions and agents
to orient before doing anything: the 60-second framework summary, the strata
(E0 orientation / E1 architecture / E2 canon / E3 decisions / E4 guides / E5
module reference / process), a reading order, a per-stratum map of every doc,
and task-oriented shortcuts ("I want to build a component / theme it / know why
a decision was made…").
CLAUDE.md gets a "Start here" pointer at the top of Reference Documents so the
entry point is actually reached on session start (this is the small additive
pointer, not the deferred CLAUDE.md slimming). All links verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the renumber-safe split of THEMING.md.
- §8 "añadir componente" + §9 "definir theme" -> src/uix/eidos/THEMING_GUIDE.md
(E4 authoring guides).
- §15 "comparación con librerías" + §17 "FAQ — decisiones polémicas" ->
src/uix/eidos/THEMING_NOTES.md (E3 comparison + rationale).
THEMING.md keeps numbered pointer-stubs at §8/§9/§15/§17; §16 anti-patterns,
the ## Referencias section, and the dated §20-34 changelog stay in place. All
34 sections and every "THEMING §N" citation across the corpus + code survive.
THEMING 2571 -> 1930 lines across the three commits; it is now a leaner E1
layer reference with TSC (E2), the guides (E4) and the notes (E3) as siblings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First piece of the THEMING split, using the renumber-safe stub approach so the
many "THEMING §N" citations across the corpus + code keep resolving.
§7 (Token Scope Contract) and §18 (universal coverage, v2.2) move to a new
src/uix/eidos/TSC.md — the eidos visual canon (E2), with CANON.md-style
frontmatter. THEMING.md keeps numbered pointer-stubs at §7/§18, so section
numbers (and therefore §23/§25/§26/§27/§28 citations) are untouched. 2550 -> 2271
lines; 34 headers intact, TOC anchors still resolve.
Remaining split pieces (own commits): guides §8/§9 -> E4, comparison §15 +
FAQ §17 -> E3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
THEMING §14 described motion as "deferred / data-motion-ref does not exist /
superseded by the TSC event:* scope". eidos-motion.md says the opposite: the
two-moment model is implemented (F1-F7, 2026-06-04), the engine was relocated to
arts/motion as a service, and it is the event:* scope that is now obsolete. §14
was a stale snapshot contradicting the canonical motion doc.
Rewrite §14 as a lean pointer to eidos-motion.md (canonical, current) with a
status note flagging the obsolete data-motion-ref / event:* framing, and keep
the theming-specific bit (the --motion-scale-lift "pickup" token). Renumber-safe
— §14 stays §14, so the §N citations across the corpus are untouched.
The full stratum-split of THEMING (TSC -> E2, guides -> E4, comparison/FAQ ->
E3) is deferred: it renumbers sections that are cited by number across the
corpus + code, so it needs a dedicated pass with a citation sweep.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single E3 entry point cataloguing the design rationale: the 7 eidos RFCs
(color model/engine, typography/depth/shape/structure engines, scaling), the
3 arts subsystem design docs (connection/timer/session), and the cross-cutting
decision logs (LIBRO_VARIACIONES, GESTURES). Each entry gives status + the one
decision it records, linking the document for the full argument.
This delivers the "naming único" goal at the index level. The physical file
rename (*_RFC -> rfc-*, DESIGN_* -> design-*) is deferred: those names are cited
as provenance anchors in ~30 source files (eidos/lib/*.ts, arts/timer/*,
arts/color/*, tests), so a rename only pays off if every citation is swept in
the same pass. The index gives consistent naming without that churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "3 duplicate checklists" diagnosis was wrong. There are two legitimate,
complementary artifacts plus one redundant summary:
- COMPONENT_GUIDE.md — the build checklist (ordered authoring steps 1-40 +
rationale rules A1-A37). How to build a component.
- COMPONENT_COMPLETION_CHECKLIST.md — the acceptance matrix (machine-audited
rules with severity/applicability, bound to scripts/component-audit.ts). When
a component counts as done. Merging it into the guide would break the
script binding.
Consolidation = one source per concern, not one file. Both docs now carry a
header that states their role and cross-links the other as the complement.
soma/README §9 (the only real copy) is reduced to a pointer at both; it keeps
no checklist of its own.
Flagged out of scope: COMPONENT_COMPLETION_CHECKLIST M-3.3 still says "7
families" (canon is 8 with delegate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two docs overlapped ~80% (purpose, layers, runtime parts, Soma class,
data-* contracts, naming, checklist). Give each a single role:
- README (1057→400 L): onboarding + authoring guide — purpose, membership
criterion, morfo contract (slim), component + composition pattern, new-
component checklist, and a "deep reference" map pointing into the
architecture doc. Inventory now points at the live directory tree instead
of a hardcoded (drifting) list.
- SOMA_ARCHITECTURE (969→1043 L): the complete deep reference. Absorbed the
few reference bits that only lived in the README — mergeProps, KEYS /
directional keys, focus / roving / scroll-lock, boolean attr helpers, the
internal context() helper and the reactive type-aliases — into a new
§8.bis. Trimmed its purpose section (now owned by the README).
README sections renumbered contiguously; cross-references verified, no other
doc links soma/README by section number.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the Phase 1b debt: the "## Handoff 2026-05-14" blocks embedded at
the top of arts/README, arts/adom/README and arts/format/README move to
docs/process/handoffs-2026-05.md, so the layer references read as
timeless. Same mechanical pattern already applied to the UIX READMEs in
41c3b0f9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-contained hand-off to resume the documentation-corpus reorganization in a
fresh session: objective, work rules (no agents, Castilian, never touch
words/palabras, verify-staged), agreed decisions (EN target, hybrid layout,
docs/process for the ephemeral, the E0–E5 strata, canon links code for values),
what's done (Phases 1–2 + Phase 3 2/3, with commit hashes), and what's pending
(soma de-dup — heavy, do with fresh context; the 3 arts hand-offs from 1b debt;
Phases 4–5; deferred items). Lives in docs/process/ (a hand-off is process, not
a CONTINUE-*.md loose at the repo root — which is what we've been removing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 (E1), per the chosen scope. active_architecture.md should read as a
timeless architecture reference:
- §0 "Handoff 2026-05-14" → "## 0. Contratos mínimos por módulo": dropped the
session framing (estado acordado, open/closed questions, P1/P2/P3 findings,
frontend context); kept the minimum-contracts table as reference. The
ownership/degradation rules it stated are now timeless in active-uix/README.
- §10 "Estado actual (2026-05-17)" → a one-line §10 pointing at docs/process/.
The dated status snapshot moved verbatim to
docs/process/active-architecture-snapshot-2026-05.md (kept for traceability;
several of its "pendientes" are since done). Section numbering stays continuous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 (E1). After Phase 1b pulled the embedded "Handoff 2026-05-14" out, the
doc still read as session process ("tras cerrar P1", "punto crítico para
mañana", a "Regla de trabajo vigente" section). Rewrote it as a clean layer
reference: what active-uix is, the two boot paths (createActiveUix /
attachActiveUix), the minimum-contracts table (linked to the executable
contracts.ts — fixed the path: it lives in src/uix/, not active-uix/), the
ownership + degradation rules as timeless invariants, and the shell-boot example.
Dropped the process framing. Spanish kept (consistent with its sibling
active_architecture.md; coordinated EN migration is a later pass).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 (cont.). The docs that still re-transcribe the semantic vocabulary now
name CANON.md as the single source of truth, so a reader/agent knows which copy
is authoritative when they drift:
- src/uix/README §7 reading order: CANON.md added as entry 0.
- sema/README: §intent-policy and §verbs point at CANON §4/§6 + the code.
- active_architecture §6 "Vocabularies cross-layer": points at CANON.
- morfo/README §5.5: semantic vocabulary now cited as canonical in CANON
(was pointing at GUIA).
- GUIA §1: marked as the Spanish narrative; CANON (EN) is authoritative.
Conservative on purpose: the existing lists are kept, not deleted — physically
pruning each copy down to a pointer is a separate, optional pass (it is editorial
and would remove content). Establishing the authority + the backlink already
stops silent drift, since every copy now declares who wins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of the doc-corpus cleanup. docs/CANON.md distills the immutable semantic
doctrine — the 8 families, 6 intents, intent policy, canonical verbs, the
evaluable-vs-structural rule, composition rules, and the expression-channel owner
split — anchored to TWO sources: the book "Diseñando lo que ocurre" v2.3 (cited
by chapter) and the code (linked by file:symbol, never copied).
Design choice that fixes the root cause of the drift: the canon states DOCTRINE
and LINKS the code for VALUES. It does not re-transcribe the per-family holds,
channel signatures or full verb lists — those live in verbs.ts / types.ts /
intent.ts / sema-map.ts and are linked. A second copy of a value is a future
drift (that is how "7 families" survived in three docs). From now: any doc that
needs to state a family/intent/verb/rule links CANON.md instead of pasting it.
Semantic canon only; the visual canon (TSC, color roles, EIDOS_VARIANTS) stays in
eidos/THEMING.md and is cross-linked.
Next block (pending review): redirect the docs that still re-transcribe the
vocabulary (sema/README §policy+verbs, active_architecture §6, morfo/README,
GUIA §1) to link CANON.md, and wire it into the reading-order entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The §10 "Estado actual" snapshot still said "7 familias" and "66 componentes".
Per the user: the doc must state the current canon, not a frozen historical
figure — delegate makes it 8 families, and there are 115 morfos. The rest of the
dated §10 block (its caducated "Pendiente" list) is reworked / moved to
docs/process/ in the architecture pass. Docs-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1b of the doc-corpus cleanup: reference docs should read as timeless.
Pulled the inline "Handoff 2026-05-1x" blocks out of the six layer READMEs
(README, morfo, sema, soma, eidos, active-uix) into
docs/process/handoffs-2026-05.md, with docs/process/README.md explaining that
process/ holds ephemeral, time-stamped artifacts (hand-offs, audits, plans) —
never the source of truth.
README.md keeps its useful "visión de conjunto" pointer to active_architecture
+ GUIA, minus the hand-off framing.
Deferred on purpose: active_architecture §0 (embeds the "contratos mínimos"
table other docs link to — separated during the architecture pass) and
CLAUDE.md's ~10 session hand-offs (it is the per-session instructions file;
its own step). Docs-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1a of the doc-corpus cleanup: align the framework docs against the code
(the source of truth) where they had silently gone stale.
- 7 → 8 sema families (delegate was missing) in README, active_architecture,
morfo/README and GUIA_IMPLEMENTACION.
- sema/README's intent-policy section still showed the retired flat
`intentPolicy` shape; replaced with the real two-axis SEMA_FAMILY_POLICY
(intentRequirement + intentGuidance). morfo/README's "valenced families
require intent" rule corrected: only commit + signal are
intentRequirement:'required'; the rest make intent optional.
- SEMA_VERBS lists realigned to verbs.ts (adds unselect/apply/partial/block/
move/upload, signal.inform, handle.zoom, sustain.upload, + the delegate family).
- DATA_ATTRS.md: retired the misleading "Terra Data Attributes Contracts"
header (auto-generated from a now-deleted path, redundant with the morfos)
→ marked LEGACY pointing at the morfos as the source of truth.
Dated status blocks (active_architecture §10 "66 componentes / 7 familias")
are deferred to the process-extraction step. Docs-only; no code touched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`timers.schedule/interval/scheduleAt` now accept `key: null` — the engine mints a
unique `auto:N` key via an internal counter (with a `has()` collision guard) and
the caller drives the timer through the returned handle. Naming a timer stays a
feature (cancel-by-name, `replace`, dedup, scoped `cancelAll`), not a tax on every
call. Anonymous timers live in the `auto` scope. +6 engine tests; the race-safety
path is unchanged (the key is resolved before the (id,key,version) machinery).
Uses it to close the raw `setTimeout`/`setInterval` and direct `getComputedStyle`
violations the audit flagged — instead of waiving the uix.timers / $adom rules:
- code-block, relative-time: raw setTimeout/setInterval -> eidos.timers (key:null)
- cropper: raw setTimeout throttle gate -> uix.timers (key:null)
- textarea, s-text, s-text-virtual-list: getComputedStyle(el) -> dom.getWindow(el)
The earlier audit call that these were "nil functional gain" was wrong: the rule
is the rule, and the friction (manual per-instance key invention) was removed at
the framework level rather than used as grounds to skip the rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removed orphan deprecated type aliases with no real consumers: sema `IntentPolicy`
(legacy single-axis policy, superseded by intentRequirement + intentGuidance) and
its re-export; eidos `RecipeTokenObject` (→ RecipeTokenDeclaration) and the comment
that named it. Left `IntentExpectedFamily` and `SemaEventLabel` — both are still
consumed by public morfo types / sema functions, so removing them is an API rename,
not dead-code cleanup (documented in SYSTEM-AUDIT).
Translated 3 non-quote Spanish comments to English (sema/chans/types,
sema/projection/index, morfo/year-grid) per the English-comments rule. Book quotes
stay in Spanish (they're the canonical source).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9 soma component READMEs imported from the removed $lib/util/dias and
$lib/util/colors aliases → $libs/days / $libs/color (matches what the code
actually imports). morfo/README.md said "All 66 morfos" (real count ~116) →
reworded to "Every morfo in the codebase" to drop the brittle number.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply the icon↔type correlation (option 1): the icon follows the font the
component actually shows, no font step-up. button icon-size-sm: icon-xs→icon-sm
(follows its font); radio-cards icons follow the card title (16/16/18/18/20,
were 18-32 hand-inflated). STATIC_SIZE's font step-down stays — control-density
convention, not a bug.
Fix pre-existing button bug: the Icon's inline `width: var(--icon-size)` beat
`[data-button] svg { inline-size }`, pinning the button icon at 18px regardless
of size. button now feeds `--icon-size` from `--_button-icon-size`, so the icon
follows the button size (14/16/16/18/20). Verified in browser.
CONTINUE-icon-type-scale.md updated with the resolution + a pending audit of the
same Icon-sizing bug in other components.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Icon scale large steps now track the type scale (validated by eye in a study
harness at /uix/icon-scale-study): --icon-size-xl 24→32, --icon-size-xxl 48→52,
so the icon keeps its optical weight beside its paired text. 0 impact — nothing
consumed those steps yet.
button: icon-size per size references var(--icon-size-X) (mapped by value → same
rendered px, now density-scalable) instead of hardcoded px.
Docs: THEMING.md §5 documents the type↔icon correlation; CONTINUE-icon-type-scale.md
captures the pending 1:1 work (size = font = icon per name, dropping the font
step-down) for the next session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Size was applied via `var(--qr-code-size-${resolvedSize})` (interpolated), so
the literal token names never appeared in source and the recipe-css-contract
test flagged --qr-code-size-{xs..xl} as orphaned. Use an explicit literal map so
each name appears verbatim; behaviour unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The recipe defined per-color tonal tokens inconsistently — only fulfill/loss had
`track`/`border`, so soft/surface/outline rendered with no background or border
for the other 6 colors (primary/secondary/neutral/affirm/risk/threat):
`palette-track`/`palette-border` referenced undefined `--button-{color}-track`/
`-border`, which collapsed the background/border declarations. Added the missing
track+border to all 6 (mapped to `var(--color-{role}-track|border)` like
fulfill/loss); the recipe is now uniform (7 slots per color). Verified all 8
colors × soft/surface/outline in dark + light.
Demo: new "Gallery" tab — a 6 variants × 8 colors matrix of real <Button>s
(hierarchy via `color`, evaluative palette via `intent`).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The logo overlay rendered invisible. Inside a viewBox-scaled foreignObject a
fixed px padding (var(--space-1)) equals that many *modules*, overflowing the
cleared centre and collapsing the content box; inline-size/block-size also
don't size an <svg> element. Fix: proportional inline padding + physical
width/height. The logo now renders and still decodes at ECC H. Dropped the
now-orphan --qr-code-overlay-padding token + regenerated base.css.
Demo: content presets (URL / vCard / WiFi / unicode) prove the encoder takes
any UTF-8 string, not just URLs (each verified decoding via jsQR); logo on by
default; quiet-zone chips fixed to canonical [4, 8, 16] (was [2, …], below the
spec's 4-module minimum). README documents the quiet zone as modules, not
pixels — the canonical unit; for px layout spacing, pad the container in CSS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Own QR encoder in $libs/qr (ISO/IEC 18004: segment analysis →
Reed-Solomon over GF(256) → matrix layout → 8-mask penalty selection),
no npm dependency. Eidos <QrCode> renders the module matrix as one SVG
path with square/rounded/dots cell shapes; function patterns stay square
(via the encoder's `reserved` mask) so every shape scans. Optional logo
clears the centre + auto-boosts ECC to H. <QrCode.DownloadTrigger>
exports PNG/SVG, baking resolved colours into the clone first (a
serialized SVG loses the document's CSS custom properties). Fixed-tone
default colours for scannability; themeable via color/background.
morfo qr-code (scope: eidos; parts Provider/Pattern/Overlay/
DownloadTrigger) + 'path' added to MorfoElement; recipe with 11
--qr-code-* tokens; canonical demo + nav entry. 11 encoder unit tests;
verified end-to-end via jsQR round-trip + browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
orientation is now breakpoint-aware like size: `orientation={{ base:'vertical',
md:'horizontal' }}` resolves via Eidos.resolve and forwards a concrete value to
the soma (keyboard nav handles both axes). Documented in the README (new
"Responsive orientation" section + example) and the demo (a "responsive" chip +
note + API row) — the first consumer-facing example of the ResponsiveProp object
syntax in the repo. Browser-verified: flips at md (768px). check 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hover rule out-specified the checked rule, so hovering a selected card
reverted it to the neutral (unselected) hover colour — visually undoing the
selection. Now the neutral hover excludes `[data-state='checked']`, and a
dedicated checked-hover rule keeps the active colour and just deepens it a
notch (border → --_radio-group-palette-solid-hover, tint 7% → 12%). Selection
state is never affected by hover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New eidos component built ON TOP of RadioGroup with zero duplicated
behaviour. RadioCards reuses RadioGroup's soma wholesale (selection,
roving focus, role=radio, native <form> via HiddenInput) and adds only
the card chrome: icon · title · description + an always-present corner
radio. Vertical, horizontal and grid layouts.
- morfo: scope:['eidos'], thin contract for presentation parts only
(Provider marker, Item-card, Indicator, Icon, Content, Title,
Description). The interactive contract + commit-select stay in
radio-group, reused via its soma. No new soma, no new sema.
- eidos: `radio-cards` recipe (card chrome) + wrappers + radio-cards.css.
radio-group.css is NOT loaded here, only its foundation tokens — the
card owns its visual, reusing --_radio-group-palette-* (inlined, since a
:root token referencing the palette is invalid-at-computed-value).
- demo: canonical structure (always-on stage + trace, tabs, chip/switch
controls, Untitled-UI example patterns) + nav entry + README.
check 0 · eidos-lint 0 invalid. Browser-verified: select (click + keyboard
roving), disabled, the three layouts, controls drive the preview.
Follow-up: icon/circle sizes are still px (mirroring radio-group's own size
tokens) — making the control-size scale density-aware is a separate,
family-wide change deferred per request.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batch 3 migrated both but the guard ceilings missed them, leaving the two
unprotected from drift. Pin their legit residuals (year-grid 2, file-upload
3). With this every migrated provider is guarded; the only component with
unpinned attr duplication is `words` (excluded from the rollout per request).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final 2 components of the rollout (words excluded per request). Both menu
siblings now source role/aria/data from the morfo via renderProps().
Audit total 213 → 158; both at 0 residual hits.
Unified the siblings' aria-boolean handling: aria-expanded/aria-checked
(morfo stateRef → aria-boolean string) resolve through renderProps, not a
soma boolean override. For checkbox/radio aria-checked the per-part
`checked` state source feeds the COMPUTED value — dropdown-menu's
effectiveChecked (group membership) and isChecked (value === group value)
— so renderProps emits the correct string. The migration agent had left
dropdown-menu's aria-checked as a boolean override reading effectiveChecked;
removing it without registering the state regressed a checked item to
aria-checked="false" — fixed by registering the computed source.
Tests assert the string aria-boolean shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source role/aria/data from the morfo via renderProps() across the
collection/menu families, removing the attrs the providers re-declared
("morfo declares, soma executes"). Audit total 369 → 213.
Migrated: select, listbox, carousel, tree-view, year-grid, grid-list,
tree-grid, table, file-upload, editable, combobox, command.
tree-view/table keep their per-node aria hardcoded on purpose: each tree
node / table cell computes its own aria-level/selected/rowindex/colindex/
sort, which a single renderProps can't resolve. The residual hits the
guard pins are all legit value overrides — computed aria-labels (carousel
"slide N", editable/file-upload triggers), per-instance dynamic values,
and computed refs (command aria-activedescendant/controls).
listbox/grid-list tests: aria booleans the morfo declares via stateRef
now resolve through renderProps as "true"/"false" strings (aria-boolean
mode) instead of the providers' hardcoded booleans — assert strings.
words excluded from this rollout per request (active dev track).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source role/aria/data from the morfo via renderProps() across the
date/time/calendar/picker families, removing the attrs the providers
re-declared ("morfo declares, soma executes"). Audit total 468 → 369.
Migrated: field, date-field, time-field, color-picker, date-picker,
date-range-picker, time-picker, time-range-picker, calendar, month-grid,
range-calendar.
date-field/time-field keep the `segment` part hardcoded on purpose: it is
multi-archetype (editable segments are spinbuttons, literal separators
must NOT be) so a uniform renderProps would inject role into the literals.
The residual hits the guard now pins are all legit value overrides:
composite aria-labelledby/describedby (input), the deferred segment part,
and computed aria-labels (color-picker/calendar include the value/month).
field test: required-indicator applies aria-hidden via syncAttrs, so the
attr lives on the DOM (not the props object) — assert it via getAttribute.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Workflow-driven rollout of the morfo↔soma attr de-duplication. 16 providers
(textarea, pagination, pin-input, slider, tag-group, scroll-area, form,
search-field, drag-drop, stepper, tags-input, color-field, password-field,
link-preview, menubar, navigation-menu) now source role/aria/data from the
morfo via renderProps() instead of re-declaring them. 3 morfos gained
`ariaBoolean` on propRef-valued aria booleans (color-field, form, password-field).
Audit: 559 → 468 hits (−91). Provider tests updated to the renderProps shape:
ARIA booleans assert the string "true"/"false" (the correct ARIA shape, was a JS
boolean), and the few DOM-read assertions for parts that moved off `syncAttrs`
now read the props object (pagination). Parts still on `syncAttrs:true` keep
their imperative DOM application + tests. Each component's residue (legit value
overrides the morfo can't express) is pinned in the audit guard.
Verified centrally: 112 tests green, check clean, pagination spot-checked in the
browser (type/role/aria-label/data render correctly via props-spread).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The attr audit segmented each provider by `runtime.part('X')` and scanned the
whole segment, so it false-positived on type annotations, per-part source
registrations (`props: { role: () => … }`), and non-part helper getters (a
hidden `inputProps` with its own `type`). Now it matches only inside
`.assert({ … })` blocks — the real part props getters.
Backlog corrected: 607/50 → 559/44 (switch, announce, etc. were pure false
positives — 0 real duplication). number-field (1) / css-field (2) residue
unchanged; guard still green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`scripts/soma-attr-audit.ts` compiles every morfo and flags attrs a provider
hardcodes in its props getter that the morfo already declares with a resolvable
value (segmenting the provider by `runtime.part('<kebab>')` to map getter→part).
Measures the migration backlog and catches new drift.
Current backlog: 607 hits across 50 components (worst: words 49, dropdown/context
menus ~28, combobox/command/time-field ~24). The migrated spin-field family is
down to its legitimate residue — number-field 1, css-field 2 (formatted
aria-valuetext, parsed aria-valuenow — values the morfo can't express).
`soma-attr-audit.test.ts` pins each migrated component to its legit-override
ceiling so it can't regress to hardcoded attrs. Extend the ceiling map as more
components migrate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finishes the spin-field family. The 4 triggers + 2 scrubbers (number-field +
css-field) now spread renderProps() and add only soma-specific extras.
- Scrubbers: role + aria-hidden + data-spin-field-scrubber now morfo-sourced;
soma keeps data-scrubbing/disabled, cursor style, pointer handlers.
- Triggers: type + aria-label + data-spin-field + aria-disabled now morfo-
sourced. The trigger morfos' aria-disabled moves from literal-'true'+condition
to propRef('disabled') + ariaBoolean (the de-facto always-emit shape); each
trigger registers its own `disabled` source (includes atMax/atMin) so
renderProps resolves it. aria-label resolves from the morfo's
commonRef('buttons.increment/decrement') — the SAME idlangref the provider
used, so the string is unchanged ("Aumentar"/"Disminuir" in es).
- Dropped the now-orphaned NUMBER_FIELD_LANGS + boolToStr imports.
Verified in browser for all parts (type/aria-label/aria-disabled/role/aria-hidden
identical) + soma/morfo tests 95/95 (2 pre-existing palabras contract failures
only). The entire spin-field family is now morfo-sourced — zero re-declared attrs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the NumberField Input reference. CssField morfo's aria-required/disabled/
readonly/invalid gain `ariaBoolean`; the Input provider drops the hardcoded role +
data-spin-field-input + those four aria booleans — all now from renderProps().
Soma keeps the CSS-value-specific bits it genuinely owns: the parsed aria-valuenow,
soma-only aria-valuemin/max, aria-valuetext, native form attrs, handlers.
Verified in browser (role/aria/data from morfo, parsed aria-valuenow="16") +
soma/morfo tests 71/71. Both spin-field inputs are now fully morfo-sourced; the
triggers/scrubber + the other families remain on the backlog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`MorfoAriaEntry.ariaBoolean?: boolean` makes a propRef-valued ARIA attr compile
to aria-boolean mode (renders the "true"/"false" string), the same shape a
stateRef already gets. Without it, `aria-required` ← `required` prop compiled to
raw mode → renderProps() emitted a boolean (DOM-identical, but a different props
shape, and it forced the provider to keep stringifying).
- types/schema/compile wired for the flag.
- NumberField morfo: aria-required/disabled/readonly/invalid gain ariaBoolean.
- NumberField Input provider: dropped the 4 boolToStr overrides — role + every
aria + data-spin-field-input now come from renderProps(); soma keeps only the
formatted aria-valuetext, aria-describedby, native form attrs and handlers.
The Input is now fully morfo-sourced (zero re-declared attrs). Verified in
browser (aria-required="false" string, role/valuenow from morfo) + provider
tests 69/69. This is the reusable mechanism for de-duping aria booleans across
the rollout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for removing the morfo↔soma attribute duplication: providers that
compose attrs in their `props` getter were re-declaring role/aria/data that the
morfo already declares (a second source that drifts — the spin-field bug).
- runtime: new `SomaRuntimePart.renderProps()` — static identity + every
morfo-declared static/dynamic attr, resolved against THIS part's registered
sources. A part's getter spreads it and adds ONLY soma-specific extras
(handlers, formatted values, native attrs), instead of hardcoding role/aria.
- morfo: new `prop-defined` condition (`emitted iff prop !== undefined`) so an
optional numeric aria (aria-valuemin at min=0) emits correctly — `prop-truthy`
wrongly dropped 0. Wired through types, resolver, schema, compile dep-collect.
- NumberField Input migrated as the reference: registers value/min/max as part
sources, spreads `renderProps()`, keeps only soma extras. Removes the
hardcoded role + aria-valuenow/min/max + data-spin-field-input duplication.
ARIA booleans (aria-required/disabled/…) stay soma overrides for now — a
propRef-valued aria compiles to raw mode, so soma still stringifies them; a
future `v.ariaBool()` helper would let those resolve from the morfo too.
- COMPONENT_GUIDE: "Part props: read the morfo, don't re-declare it" doctrine.
Verified bit-for-bit in browser (role/aria/data identical incl. min=0 →
aria-valuemin="0") + provider tests 7/7. The survey found ~⅔ of components carry
this duplication (form controls 70%); this lands the pattern + the NumberField
Input reference. Family rollout is the documented backlog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NumberField and CssField are the same visual (a bordered field + input +
increment/decrement triggers + scrubber, split/stacked layouts, sizes/
variants/colors, themeable glyphs); only their value model differs. They
were two cloned recipes + CSS that drifted — a refinement to one (square
flush buttons, divider, contrast) didn't reach the other.
Unify into ONE shared source (the toggle-group structural-identity pattern):
- New eidos/components/spin-field: recipe key `spin-field` (`--spin-field-*`)
+ spin-field.css with all the stepper-field rules, selecting `[data-spin-
field*]`. Loaded via the foundation @import in index.css.
- number-field + css-field morfos declare structural identity (`data-spin-
field*` presence attrs on each part). The Provider emits them via syncAttrs;
the sub-parts emit them in their soma `props` getter (number-field's soma
hardcodes sub-part attrs rather than syncing the morfo).
- Removed the `number-field` / `css-field` recipe keys; their CSS files are
now stubs. A theme tints one component by scoping `[data-number-field] {
--spin-field-… }`.
- css-field thereby adopts number-field's refined steppers (square, flush,
divider) — the drift fix the user asked for, now structural (no clone).
Verified bit-for-bit in browser: number-field identical to baseline (split
flush, stacked symmetric xs..xl, RTL, glyph token/children override);
css-field now square/flush/divider. eidos-lint invalid 0; recipe contract
passes (no orphans, loads-once); check + morfo:check clean for these.
Docs: THEMING sections 33 (glyph tokens now `--spin-field-*`) + 34 (the
shared layer); number-field / css-field READMEs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NumberField and CssField steppers gain a `stacked` compact layout and
themeable default glyphs.
- Glyphs render from recipe tokens via `:empty::before` (split plus/minus,
stacked up/down triangles); passing children overrides per instance,
a theme overrides the token. 4 tokens per component, unicode-escaped.
- CssField gains the `steppers` prop (split | stacked); its stacked layout
mirrors NumberField (self-contained — the split layout left untouched).
- NumberField split: square buttons flush to the field edge with an inner
divider and higher hover/focus contrast (control-radius 0).
- Stacked rows use minmax(0,1fr) + a height-proportional glyph so the two
arrows stay symmetric and uncut at xs.
- Demos: steppers controls + stepper-layout / sizes / themed-glyph
showcases; code snippets use the themed defaults.
- Docs: THEMING section 33, number-field and css-field READMEs.
Note: also sweeps in the pre-existing palabras WIP already in the tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each input drew its focus ring differently — the foundation [data-archetype]
:focus-visible ring landing on the <input>, ad-hoc per-component input rings
(css-field/number-field), the textarea's own color-mix — so editing showed a
DOUBLE frame (inner + outer), inconsistent across components.
Unify into one theme-parameterised two-ring model:
- New --focus-ring-inner-width (0 by default), folded into --focus-ring and every
field *-focus-shadow: inner inset ring + outer ring. 0 = single outer frame;
raise it in a theme to show the inner line everywhere at once.
- Foundation [data-archetype]:focus-visible now excludes form-internal elements
(:not(input):not(textarea):not(select):not([data-archetype='segment'])) — their
focus is shown by the wrapping control.
- Removed the ad-hoc input rings (css-field/number-field); textarea uses
var(--focus-ring).
- Cleaned the now-dead per-data-color _accent-* cascade in css-field/number-field
(recorded as backlog in THEMING.md §32).
Verified with real keyboard on field/css-field/number-field/date-field/textarea:
single outer frame. Also includes the earlier textarea `resize` fix (morfo
data-resize values + soma stops inlining resize + eidos materialises it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Words: lift heading level cap 1-3 -> 1-6 (engine type, WORDS_HEADING_LEVELS,
render tag union, validate message, parse-html no longer clamps h4-h6, provider
unions). Palabras panel: Nivel toggle H1...H6 wired to the theme's --style-h{n}
scale; level is a required selection.
ToggleGroup: new `deselectable` prop (default true). In single mode,
`deselectable={false}` requires a selection - re-pressing the active item is a
no-op (radio-like, like Ark/Bits). Soma provider + types + docs; eidos forwards;
palabras uses it for the heading Nivel toggle. Provider test added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A grabbed surface scales slightly toward the viewer (depth 'picked up' cue). This
is a cross-component primitive, not a float-panel literal:
- New --motion-scale-lift (1.02) in STATIC_MOTION.scale + MOTION_SCALE_KEYS (the
only >1 in the scale family); validated by validateEidosConfig.
- FloatPanel consumes it: [data-float-panel-content][data-dragging] { scale:
var(--motion-scale-lift) } + will-change: translate, scale, eased lift/settle,
static during the move (composited), suppressed under reduced-motion.
- Documented in eidos-motion.md §11 (primitives table) and THEMING.md §14 (new
'Superficies arrastrables: el lift de pickup' pattern) so any draggable surface
(slider thumb, sortable item, drawer) reuses the SAME token, not its own literal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New general-purpose overlay component across all 4 layers (morfo/soma/sema/eidos)
plus a shared stacking layer and i18n catalog.
- Drag (2D free, anchored→free) + resize with device-pixel-snapped, GPU-composited
1:1 movement; elevation lifts on pointerdown for the pickup metaphor.
- Stages: minimize / maximize / restore (StageTrigger, computed maximized geometry
so restore needs no saved-rect bookkeeping).
- Composable header buttons: Controls + Action (custom) + Close + StageTrigger.
- Keyboard a11y (the industry gap): grab-mode move/resize on the focusable header
and a se-corner ResizeGrip (pointer + keyboard), with debounced live-region
announcements, bounds/min-max cues, aria-roledescription/keyshortcuts.
- Stacking registry (bring-to-front/topmost), Presence motion preset (scale-fade),
es/en langs catalog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds external comparison table (WAI-ARIA / React Aria / Ark UI / Base UI / Bits) with sources and a Gaps table with disposition markers, bringing the css-field audit scorecard to parity with number-field (only the systemic E-2.2 css-import rule + R-1.3 readonly warning remain, both shared with the reference component). Smoke PASS for /uix/components/css-field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Motor (words): ImageBlock gana `fit` (fill/fit/crop/tile) + 7 filtros CSS
(saturate/brightness/contrast/hueRotate/grayscale/sepia/blur) + `width`/`height`
number|string (CSS-length). Render: object-fit + branch tile bg + figcaption.
Fase A: caret-carry al insertar, guard columnas-en-columnas, deleteColumnAt
siembra parrafo cuando el superviviente queda vacio. block-path.test.ts (14).
- Panel de imagen reescrito con primitivas eidos (ToggleGroup/Switch/Slider/
NumberField/Accordion), labels xs sans arriba del slider, header con icono
definitorio del bloque, secciones pinned + accordion collapsible, footer band.
- sium: `cssLength()`/`isCssLength()`/`CSS_LENGTH_REGEX` en core/refines.ts,
exportados por la fachada EngineSium. El panel valida unidades CSS (data-invalid).
- accordion: tamano `xs` (types + recipe base.ts + accordion.css + generated).
- words-activate: guard atomico (lastAtomicAt + isActiveBlockAtomic) evita que el
caret-follower degrade el bloque imagen activo a heading.
- saturate/contrast max 200% (estandar, antes 300% inventado). Caption visible:
fit-frame movido de figure a img/tile.
Sin tocar la capa sema ni la animacion (restriccion del usuario).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El "+" ya no desaparece al llenar la columna (resuelve "las columnas solo
admiten un bloque"). Ahora hay un "+" por columna: centrado si está vacía,
abajo en el borde punteado si tiene bloques. Cada click hace APPEND al final
(insertBlockInColumn -> [...col.children, block]).
El botón "+" pasa a redondo total (border-radius 999px) con borde + sombra
elevada.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Insertar bloque -> rejilla de tipos:
- palabras-block-types.ts (nuevo): lista curada de 10 (Párrafo/Encabezado/Imagen/
Lista/Separador/Cita/Código/Callout/Columnas/Tabla; headings y listas
colapsados; imagen incluida) con icono explícito por tipo (los definió el
usuario). Sale del registro del motor por id, sin duplicar create().
blockToInsert rellena placeholder en heading/list; imagen via pickImageFile.
- Iconos en el menú de acciones (Settings2/LayersPlus/ArrowBigUp/ArrowBigDown/
Trash2).
- Menú PORTALIZADO (DropdownMenu.Portal): el slot del grip tiene transform, que
lo hace bloque contenedor de los position:fixed -> la colisión de floating-ui
se desajustaba y el submenú se volteaba a la izquierda. Portalizar lo saca del
slot transformado.
Columnas (palabras.css, espejo de Words words.css):
- Un bloque de columnas vacío se renderizaba con ALTURA 0 (palabras no carga
words.css), y de ahí salían 4 bugs de la MISMA raíz: invisible, grip "se
vuelve loco" (las bandas Voronoi-on-Y de blockAtY se degeneran con altura 0),
no se podía seleccionar (0px sin área clicable) y el insert caía off-by-one.
Fix: flex-row + min-block-size 3em + borde dashed por columna (Words ya lo
documentaba: "without this, an empty columns block collapses to 0 height").
"+" por columna (palabras-column-inserter.svelte nuevo):
- Overlay "+" sobre cada columna VACÍA -> la misma rejilla (sin Columnas) ->
insertBlockInColumn(columnsIdx, colIdx, block) (comando del motor, como el
inspector de Words). Posicionado frame-relative, menú portalizado,
data-words-external-tool. El "+" desaparece al llenarse la columna.
Deuda anotada en continuar-palabras.md (decisión del usuario: al cerrar el
componente): labels -> langs, morfo de palabras. Pendiente abierto: grip "fuera
de la card" (el offsetParent del slot cae al marco exterior).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El grip pasa a ser un DropdownMenu con acciones (Propiedades, Mover
arriba/abajo, Insertar, Eliminar bloque). Arreglado el "parpadeo" del menú,
que eran CUATRO fallos compuestos (por eso cada fix suelto no lo resolvía):
- disabled reactivo (leía api.html) re-creaba el item enfocado al abrir ->
cierre/reapertura en bucle. Fix: index/count congelados en el objeto grip +
grip congelado mientras el menú está abierto.
- pointerdown sobre el grip llegaba a la capa Dismissal del menú, que lo veía
"fuera del contenido" -> cerraba, y el click reabría. Fix: stopPropagation en
el pointerdown del trigger SOLO con el menú abierto.
- preventScroll (default true) bloqueaba el scroll del body al abrir -> el
scrollbar desaparecía y la página saltaba. Fix: preventScroll={false}.
- doble animación de entrada (present-rise de sema/motion durante el hold +
dropdown-menu-enter del recipe) reiniciaba la opacidad a 0 al acabar el hold;
como el menú no está portalizado, se veían los bloques detrás. Fix: fijar una
sola animación con !important en palabras.css.
Nuevo palabras-block-actions.ts (moveBlock / insertParagraphBelow /
deleteBlockAndReanchor). words-provider: marcador genérico
data-words-external-tool en isInsideWordsTool para que abrir el menú no dispare
el bucle blur->refoco del editor. Diagnóstico completo en continuar-palabras.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El hover-follow resolvia el bloque con closest('[data-words-id]'), que da null
cuando el cursor esta sobre el carril (el rail no es un bloque), asi que el grip
se congelaba ahi. Ahora onMove, cuando no hay bloque directo bajo el cursor,
resuelve el bloque por la Y del cursor (blockAtY: banda vertical Voronoi-on-Y de
cada bloque), de modo que el grip se desplaza igual por el gutter que por los
bloques.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
El piloto envolvia cada parrafo en <div data-words-node=block><p>, pero
el unico beneficio neto (drag vertical de raiz) ya esta cubierto por el
`dropZone` del sink, y el borde activo punteado funciona igual sobre el
elemento pelado. paragraphSpec.render vuelve a emitir un <p> plano.
Tests del render del motor en verde otra vez (engine de words 430/430).
palabras verificado en navegador: render, grip, drag y borrado intactos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Estado funcional del editor palabras (verificado en navegador) como
punto de retorno antes de revertir el piloto del wrapper.
- Grip: framework <Button> draggable copiado del gutter de Words
(ghost/xs/iconOnly + dots svg). dragstart escribe el indice en el MIME
canonico; click abre el inspector via onPress; cursor grab/grabbing.
- Drag vertical: <WordsBlockDrag> acepta `dropZone` opcional y escucha
dragover/drop en toda la fila (rail+canvas) en vez de solo en content,
asi un arrastre recto hacia abajo sobre el carril ya engancha.
- Borrado de bloque (palabras-panel): mueve el activo al bloque
superviviente leyendo el documento YA actualizado (cubre el caso del
ultimo bloque, que deja un parrafo en blanco), cierra el popup, y el
grip se re-ancla en vez de quedar fantasma.
- active-eidos: getter `timers` (ActiveTimers) sobre el uix service.
- Piloto wrapper: paragraphSpec.render emite <div data-words-node=block>
envolviendo un <p data-words-inner> (deja tests del render del motor
por consolidar/revertir).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block inspector mixed three control heights (chip 26 · number-field 28
· color-trigger 36px) and two fonts (chip/trigger 10px vs label/value/input
12px), so the PRIMARY control — the chip — was the smallest text in the panel.
- New recipe tokens `--words-control-height` (1.75rem — was a magic literal on
the number-field) and `--words-field-width` (7.5rem), applied uniformly to the
whole inline tier (chip · color-trigger · number-field) → one 28px height + an
aligned right-edge column.
- Chip + color-trigger lift to the 12px body tier (the ToggleGroup `xs` is kept
ONLY for its compact padding; lifting the font without the padding keeps the
four-segment groups on one line).
- Cramped long labels shortened (Predeterminada→Auto, Semi-negrita→Semi) so
Fuente/Grosor read as four even segments.
- Collapsed the dead, inconsistent font-size fallbacks (`xs, 0.6875rem` /
`xs, 0.75rem` / `sm, 0.8125rem`) to the bare declared token.
Chrome scale is now a clean 14 (section) / 12 (body) / 11 (micro). Verified in
browser: chip · color-trigger · number-field all 28px·12px, no group overflow.
19/19 recipe contract green; check adds zero errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inspector mixed THREE fonts: labels in Lora (serif!), titles in system-ui,
inputs in Instrument Sans. Root cause: eidos's "content" archetype (Accordion
content) maps to the reading serif, and the inspector's `[scope] *` rule is only
(0,1,0) — equal to the component's `[data-x]` font rule — so the Phase 3
code-split flipped the load order and the serif won.
Fix: a `--words-chrome-font` token (= --font-ui, independent of the content
`font-family` a theme may set to serif) + a rule forcing it across EVERY chrome
surface (toolbar·bubble·slash·drawer·inspector·gutter menus) with a DOUBLED
scope attribute (0,2,0) that beats any single-attribute component font
regardless of code-split order. Verified in browser: all chrome = Instrument
Sans (was Lora/system-ui/Instrument). Content keeps its reading font.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression from the onMount→$effect conversion (increment 3): eidos.langs.extend
both reads AND mutates the reactive langs schema, so the $effect tracked that
read, the mutation retriggered it, and it looped — re-extending the bundles
thousands of times, each logging a DEV leaf-overwrite warning (~90k warnings,
hung the editor page). Wrapped the body in untrack ⇒ the effect has no deps ⇒
runs exactly once on mount, like the old onMount. Verified: words page loads
fast, zero console warnings, editor + inspector render. api-contract test still
green (untrack is not flagged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The words recipe declared a token vocabulary the CSS never used (the root of
the drift). Pruned 55 declared-but-unused tokens: 38 direct (gap-*, toolbar-*
×9, button-size-*, command-*-hover, content-px-*, status-*, rail-*,
selection-color, _accent-* ×5, line-height, max-inline-size, heading-font-size,
content-py-after-placeholder) + 17 cascaded (the intent palette *-solid/
*-solid-hover/secondary-* slots that only fed _accent-* — callouts use the
tinted track/border/text slots, kept). No wire candidates (rail had no
consumer; ::selection already uses wash-soft). The recipe contract now matches
actual usage → the orphan test goes green; full eidos suite 197/197 (all 3
words failures now fixed across increments 1/3/A). Visually inert: words.css
references nothing removed (the "declares every var" test guarantees it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
words-inspector.svelte carried an intentionally-empty <style> (the styles
live in words.css, imported by the root) — removed. words.svelte used the
legacy onMount only to flip `mounted` + register langs bundles (no reactive
reads) → converted to a run-once $effect, dropped the lifecycle import.
component-api-contract test now green; eidos suite 196/197 (only the words
orphan-token test remains → Phase A).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The small-control radius was a soup of 8 syntaxes for the same thing:
4px / var(--radius-2,4px) / 3px / even var(--space-1) (a spacing token used
as radius). Added --words-radius-xs (= --radius-sm, 4px) — the missing
small-control step under sm — and snapped all of them to the
--words-radius-{xs,sm,md,lg} scale. Micro decorative radii (1-2px) and circles
(50%) left as-is by design. Verified radius-xs=4px, editor renders, no
regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Kills the biggest visual-inconsistency soup: 18 ad-hoc color-mix opacities
(4/6/8/12/14/16/22/30/35/40/45 %) collapsed to a 4-step canonical scale
--words-wash-{faint,subtle,soft,strong} (6/16/30/44 %), applied across
words.css; geometry %s (circles, widths) untouched. Hardcoded transition/
animation durations (120/140/100 ms) collapsed to --words-transition-duration.
Declared the 2 used-but-undeclared tokens (--words-font-size-xs, --words-swatch)
→ the "declares every public var" recipe test goes green (1 of 3 words failures
fixed). Editor verified in browser, no regression. See components/words/AUDIT.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Soma is clean (95/100, headless). The mess is words.css drifting from its
own token contract: ~117 tokens declared, ~42 used, 41 orphaned, the same
concept expressed 3-4 ways (radius, mix%, focus ring, sizes, durations,
padding), 3 duplicated color palettes, rail hardcoded over orphaned tokens.
Phased plan A-E; pilot = inspector. Working doc, updated per phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real fix, not a dodge. Corner families act on CORNERS; a pill (radius 999)
has none — its ends are continuous curves — so cut/scoop eat the whole end
into a hexagon. The correct behavior is to give the control corners: under an
angular family the switch track adopts a FINITE radius (a coherent cut-capsule)
while round/continuous keep it a pill. The thumb stays a round dot. Verified:
cut → track radius 6px / corner-shape bevel; rounded/continuous → 999px pill.
Refined the pendiente doctrine accordingly: angular families on pill/circle
controls aren't vetoed — the control either adopts a finite radius to express
the family, or stays shape-neutral by deliberate choice. The eidos Switch
recipe should follow the same pattern.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyTheme(seed) covers only the 5 visual channels. Recorded the complete
theme model by layer (visual=eidos, sema=sound/haptic separate+nullable,
iconography=art, variants=canon/preview-only), the architectural note that a
full cross-layer theme belongs at active-uix (uix.applyBrand) not eidos, and
the framing debt: /temas/tema overclaims "un tema" — it's the visual channels.
Three reopen options (reframe copy / richer page / applyBrand). Deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The switch track is a pill (border-radius: 999px); applying data-shape made
corner-shape: bevel turn it into a hexagon (and scoop into a blob). Pills and
circles must not take shape families — same rule the avatar/thumb already
follow (pendiente.md). Dropped data-shape from the track; it stays round in
every family while the rectangles (card, button, badge, chips) still morph.
Verified: cut mode → switch corner-shape round / radius 999px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The toggle changed data-theme but the preview stayed dark: applyColorScheme
resolves donor scales from the instance's EFFECTIVE mode (#resolveDonorScales
ignores the per-call mode option), and the boot had no modeSource — so the
scheme always derived dark neutrals/surfaces and overrode the foundation's
light tokens. Bound a modeSource to the `mode` state (grafito pattern) +
fire its listeners on change, so the instance mode follows the toggle and the
scheme re-derives correctly. Verified: surface oklch(0.21)↔(0.98), content
flips, both modes read cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A live theme builder: one seed → the five runtime builders (color · type ·
depth · shape · space) composed in a single uix.eidos.applyTheme() call,
scoped to [data-tema-preview] so the themed mini-app re-skins while the
controls stay put. Five presets set all axes at once (a whole personality);
per-axis sliders + swatches for fine control; live code mirrors the actual
call. Self-contained (boots its own minimal ActiveUix + ActiveEidos with
applyDom). Preview is hand-built from foundation tokens (--color-{role}-* ·
--space-* · --radius-* · --font-size-* + data-shape/data-depth) — the exact
surface applyTheme drives — since component recipes are code-split.
Verified in browser: all 5 axes retune live (color hue follows the seed,
type ratio, shape smoothing/corner-shape, depth elevation, space density),
presets apply atomically, stable (no cycling), prerenders under
adapter-static.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Composes the five runtime builders (color · type · depth · shape · space)
from one ThemeSeed { color?, type?, depth?, shape?, space? } in a SINGLE
managed write — vs five separate apply* calls. Atomic whole-theme
semantics: axes you provide are set, axes you omit revert to the authored
foundation; clearTheme() reverts all. Per-axis apply{Color,Type,…} stay
for surgical tweaks. Returns ApplyThemeResult for per-axis introspection.
New: ThemeSeed, ApplyThemeOptions, ApplyThemeResult (exported from
$uix/eidos); also exported the previously-omitted ApplySpacingOptions.
2 tests (composition + atomic-replace). Closes the "jaula abierta" arc —
no referent reunites the 5 perceptual axes under one runtime theme builder.
Updated CHANNELS_SYNTHESIS §5 (quintet → sextet). check clean (the 28
errors are the untracked heroscrolling WIP demo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
base.css pruned to the 9 role-referenced scales (full palette opt-in):
113.5 → 36.2 KB gz per page total (−68% from the original monolith).
Non-Gregorian calendar lazy-loading (5b) deliberately deferred — it needs
a sync→async change to the vendored date core for a narrow date-pages-only
win; documented the trade-off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
base.css shipped all 31 Radix donor scales, but the theme's roles only
reference 9 (gray green indigo orange plum purple red slate teal). The
other 22 were dead weight on every page: no component references raw
--scale-* (they use --color-{role}-*), and runtime theming builds from JS
scale data and writes RESOLVED values (build-scheme: variables[k]=hex),
so it never reads the CSS scale tokens.
renderThemeCss now emits only role-referenced scales by default; the full
31-scale donor palette ships opt-in in generated/palette.css (new
renderColorPaletteCss). The CSS contract stays the full vocabulary
(apps can still override any scale; load palette.css to use the extra 22).
base.css 53.7→35.4 KB gz. Monolith (every page) 54.4→36.2 KB gz —
combined with Phase 3, 113.5→36.2 KB gz total (−68%). Verified: roles
resolve (primary=purple, button bg correct), pruned scales empty on
regular pages, restored on /temas/color via palette.css. Updated the
contract test to verify coverage against the full palette. check clean
(13 errors are untracked heroscrolling demo + gitignored tmp/lexical);
eidos suite at baseline (192/195, 3 pre-existing words failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "imports every component CSS recipe from the Eidos entrypoint" test
encoded the OLD invariant (index.css aggregates all 104 recipes), which
Phase 3 intentionally retired. Replaced it with the new contract: every
component recipe loads EXACTLY ONCE — foundation @import (layout
primitives) XOR self-import in its own .svelte. This also enforces the
no-double-load invariant from Phase 0. Eidos suite back to its
pre-existing baseline (192/195; the 3 remaining failures are the
known words-track issues, unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
table tree-view tree-grid feed virtual-list virtual-grid announce
clipboard drag-drop words.
words (the 55 KB recipe) composes via mounted eidos components
(Button×5, Icon×7, NumberField×3, ColorPicker×1, TextArea files), so
their recipes load on mount — no extra imports needed. Verified: words
content/toolbar styled, mounted button 26px/radius 6px.
index.css now holds ONLY the foundation: base.css + archetypes + events
+ the 10 layout primitives (box/flex/grid/stack/group/wrap/container/
section/aspect-ratio/auto-grid, kept because they're used pervasively and
recipes layer on them).
FINAL: monolith 850,879→335,100 raw / 113,489→54,429 gz — −515 KB raw /
−59 KB gz off EVERY page, landing at the base.css floor. A 5-component
page now ships ~54 KB foundation + a few KB of chunks instead of 113.5 KB
flat. check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
field form number-field toggle toggle-group switch checkbox radio-group
rating-group search-field select combobox tags-input file-upload editable.
Caught + fixed a real coupling: toggle-group items are DOM-equivalent to
a standalone <Toggle> (carry data-toggle via the morfo, per the 2026-05-28
structural-identity refactor) and rely on toggle.css for their base
treatment — but the group never mounts an eidos <Toggle>. Without the
aggregate, items rendered as raw buttons (bg #f0f0f0, 2px outset border).
Fix: toggle-group.svelte imports toggle.css FIRST (before its own grouping
rules, so attached-corner overrides still win). Verified: item now bg
srgb(0.133), 1px border, font-weight 500, min-block-size 36px.
All recipes left the monolith (residual [data-*] are base.css token
contracts, verified base==monolith counts). Portaled select content
verified styled. index.css 51→36 @imports. Monolith 633,277→533,103 raw
(−100 KB) / 86,535→74,716 gz (−11.8 KB). check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dropdown-menu context-menu menubar command listbox navigation-menu
grid-list now self-import their recipes; removed from index.css.
menu-indicator.css (shared by dropdown/context checkbox+radio rows) had
a load-ORDER dependency: its longhand padding-inline-start (0,1,0) only
beat the menu row's padding-inline shorthand (0,1,0) by coming later in
the aggregate. Code-splitting breaks that order, so the partial is made
ORDER-INDEPENDENT via a specificity bump (doubled attribute → 0,2,0).
It now travels in dropdown-menu/context-menu's chunk (imported from their
.svelte) instead of the foundation. Verified in browser: checkbox item
padding-inline-start = 26px (indicator wins), plain item = 8px.
index.css 58→51 @imports. Monolith 666,436→633,277 raw (−33 KB) /
90,381→86,535 gz (−3.8 KB). check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pilot for the index.css break-up. Establishes the pattern for compound,
portaled components:
- the `import './x.css'` goes in the ROOT `.svelte` (dialog.svelte); the
barrel imports the root, so the CSS loads when ANY part mounts.
- portaled content stays styled: recipe ships in dialog's chunk, tokens
(--dialog-content-bg, …) come from base.css global → verified in
browser that the portaled [data-dialog-content] keeps bg/radius/
shadow/padding.
`@layer` evaluated and DISCARDED (evidence-based): 0 current usage, 16
!important that @layer would silently invert, and per-component
[data-*] scoping already prevents cross-component collisions. Kept as a
documented escape hatch.
index.css 94→93 @imports; monolith 814,420→805,692 raw (−8.7 KB) /
−1.1 KB gz. Reworded the policy comment to be list-free (no churn across
the remaining 93). check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified every figure in the audit against the real build (all exact).
Two corrections: (1) the 10 "code-splittable" components were actually
double-loaded — still @import-ed in index.css on top of self-importing;
(2) base.css (41% of the monolith, 52 KB gz) is the real floor and
carries per-component token contracts ([data-badge]×8 stays in base.css
after removing badge.css), so splitting recipes alone can't go below it.
Added the phased execution plan (0–5) with the cascade-order risk and
the cross-coupling audit (only words→textarea, safe). Recorded Phase 0:
index.css 104→94 @imports, monolith −36 KB raw / −4.5 KB gz, double-load
eliminated, no broken coupling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundle Phase 0. The 10 components that already `import './x.css'` from
their `.svelte` (badge, card, image, password-field, s-text,
s-text-virtual-list, scroll-frames, skeleton, spinner, textarea) were
ALSO @import-ed in index.css — shipping their CSS twice (verified: badge
in both its own chunk and the 851 KB monolith). Removed their @import
lines so they ship only as code-split chunks; added a policy comment so
they're not re-added. Build green (1m21s).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full-bleed lost the inset frame entirely — "¿dónde está el radio
interior?". A 16:10 cover can't be concentric on all 4 corners at full
(its height caps every corner to ~half-height), but its TOP corners CAN
match the card if the bottom stays square — the iOS-player geometry.
CSS can't read the card's dimension-capped radius (computed value is
still 9999px; the cap is a *used* value), so measure it via
ResizeObserver (min(w,h)/2 − gap) and apply to the cover's top corners;
bottom corners square, meeting the metadata. The inset frame is kept on
top/sides so the inner radius reads.
Finite radii unchanged (full 4-corner concentric). Toggle at full shows
"auto: solo top". Docs reconciled in SHAPE_ENGINE_RFC §5 + THEMING §30.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous fix dropped the cover to a clean --radius-xl, but a sharp
16px corner floating inside the card's 169px pill curve still read as
broken. The only way a child's corner can match a `full` parent (whose
radius caps per-dimension, so concentric math is impossible) is to let
the parent CLIP it: at `full` the cover now bleeds edge-to-edge with no
radius of its own, and the card's overflow:hidden + real corner round
its top to match exactly. Updated SHAPE_ENGINE_RFC §5 accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
At `full` (--radius-full = 9999px) the border-radius caps to half each
element's shorter dimension. A tall card and its 16:10 cover cap to
different values, so they can never be concentric — the nest inset broke
("el borde interior no sigue el radio exterior").
Concentric nesting requires FINITE radii; `full` is for leaf elements
(chips, buttons, avatars, pill inputs). So at `full` the demo now:
- gates `[data-shape-nest]` off and dims the "radios concéntricos" toggle
with «n/a a full — geometría de hoja»
- drops the cover to a clean --radius-xl (.cover-full) instead of a
mismatched pill
Documented the finite-only constraint in SHAPE_ENGINE_RFC §5 + THEMING §30.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The radius scale ships `--radius-full` (9999px = pill / totally-rounded); the
/temas/forma magnitude chips just omitted it (sm/md/lg/xl). Added `full` so the demo
exposes totally-rounded. Magnitude (rounded / --radius-*) is orthogonal to the shape
family (corner-shape).
Verified: check 0 errors; card --sample-radius -> 9999px on `full`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Overcorrected the prior fix into "todo es sema / eidos solo hace visible", which
flattened eidos and skipped morfo + soma. Reframed to the real layer chain, each with
a role: morfo declares the event (family · intent) -> soma triggers (writes data-state,
calls sema.emit) -> sema emits (executes sound + haptic; projects the visual channel as
data-event-* tokens) -> eidos materializes (reads data-state + data-event-* and renders
via its OWN token engines: color · motion · depth · shape · space). Sema = the QUE
(semantic), eidos = the COMO (visual vocabulary) — co-layers, eidos the sole visual
owner, not a shim.
Applied to CHANNELS_SYNTHESIS §3 + the /temas/orquesta footnote + top comment.
Verified: check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Refine the doctrine per review: the two moments are the producer/consumer split —
the SEMA moment (it emits: evaluates the occurrence and stamps data-event-* tokens;
sound + haptic it executes there; no DOM/CSS) and the EIDOS moment (it reads those
tokens + data-state and materializes the visual channel in CSS). The token is the
contract — sema writes, eidos reads — which is why sema is DOM-agnostic and eidos is
the sole visual owner. The temporal axis (state persistent / event transient) runs on
top: which token, read by eidos as presets / signatures.
Applied to CHANNELS_SYNTHESIS §1 + the /temas/orquesta "two moments" section.
Verified: check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes:
- Sound + haptic now fire from the REAL EngineSemantic.emit (channels filtered by the
mixer; `visual` excluded so no data-event-* conflict with the toggleable facets) —
replacing the Web Audio / Vibration approximation. One real emit, the chosen channels.
- Correct the architecture framing (was conflating LAYER with CHANNEL): the channels are
SEMANTIC (sema). Sema projects the `visual` channel (-> eidos materializes color ·
motion · depth · forma) and executes sound + haptic. motion/depth/shape/color are NOT
"eidos channels" — they are sema visual channel, made visible by eidos. Mixer now groups
"canal visual · sema -> eidos" vs "canales runtime · sema"; dropped the misleading
per-track eidos/sema tag. CHANNELS_SYNTHESIS reframed: 8 book expression channels -> 3
sema runtime channels (visual / sound / haptic).
Verified: check 0 errors; browser — two mixer groups render, real engine.emit fires
without error, visual facets compose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The book thesis, playable: ONE press fires the firma across channels, with a mixer to
mute each and watch them layer. The 4 visible channels (color · motion · depth · shape)
compose from one event with REAL eidos tokens — verified: press -> primary->affirm-solid
+ scale(0.93) + shadow overlay->raised + --shape-smoothing 2->3.4. Sound + haptic via
Web Audio / Vibration (in-app the sema engine emits them from the same event). + the
two-moment model section.
CHANNELS_SYNTHESIS.md ties the five channel RFCs: two moments x N channels from one
event, the layer ownership (sema runs sound/haptic + projects data-event-*; eidos
materializes the visual channels), the cross-modal firma, the runtime-builder quintet.
Verified: check 0 errors. Browser: all four visible channels compose on press.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live structural workshop: a real settings panel whose every gap/padding is
var(--space-*), retuned by four controls — densidad (data-density) · zoom
(data-scaling) · unidad base · fluido — composing on the panel via the preserved
calc(value * density * scaling). The space ladder (buildSpaceScale steps) visualizes
the modular rhythm + the fluid grow range. Plus THEMING 31 (three structural axes;
space as rhythm).
Verified: check 0 errors. Browser: panel --space-4 = calc(16px * density * scaling);
compact -> 0.84, base/growth re-derive the ladder live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The space scale was the one structural primitive without a builder — density and
scaling were already strong, but the base space scale stayed flat / static / arbitrary.
buildSpaceScale (pure) + ActiveEidos.applySpacing/clearSpacing regenerate the
--space-{key} ladder from one base unit, optionally FLUID (growth > 1 -> each step
clamp()s with the viewport, reusing the type scale fluidClamp), PRESERVING the
density x scaling composition (calc(value * --density-space-scale * --scaling)).
Opt-in over the authored STATIC_SPACE, same posture as applyTypeScale. Completes the
runtime-builder quintet (color/type/depth/shape/space).
Thesis (STRUCTURE_ENGINE_RFC): space is rhythm, not a flat px lookup table — modular,
fluid, composed with density x scaling from a seed. Structural = state-only (no
two-moment; honest).
Verified: check 0 errors; eidos config 58/58 (incl. modular + fluid space tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shape prop was usable but invisible — add a live `shape` chip control
(rounded/continuous/cut/scoop) to each demo, wired to the live preview + code
snippet, plus a "shape families" patterns row in Badge and a tag-shape control on
TagsInput items. The family system is now discoverable, not just documented.
Verified live (Chrome 146): badge cut -> bevel, button continuous -> squircle,
both flow from the control to the rendered component.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`<TagsInput.Item shape="cut">` — typed `shape` (rounded/continuous/cut/scoop) on the
tag chip, the case the family system was made for. Emits `data-shape` -> the
foundation rule.
Also clarified (pendiente.md): every component that spreads `{...rest}` already
accepts `data-shape="…"` directly (verified on inputs + tags), so the families are
usable everywhere today; the typed `shape` prop is ergonomic sugar on the common
surfaces (Badge / Card / Button / TagsInput.Item).
Verified: check 0 errors. Browser: real tag chip data-shape=cut -> bevel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the shape families usable per-instance, not just in the demo. New canonical
SHAPE_FAMILIES + ShapeFamily in lib/types (canon, like the variant archetypes).
Badge / Card / Button gain a typed `shape` prop (rounded - continuous - cut - scoop)
that emits `data-shape` -> the foundation [data-shape] corner-shape rule. Orthogonal
to `rounded` (radius magnitude); default = arc (no attr); degrades to arc where
corner-shape is unsupported.
A differentiator for chips/badges (<Badge shape="cut">) and a premium touch for
buttons (<Button shape="continuous"> = squircle). For cut/scoop prefer filled
variants (concave/angular corners seam with borders) — documented per prop + pendiente.md.
Verified: check 0 errors; eidos 190/193 (3 pre-existing `words` failures, unrelated).
Browser: real Badge data-shape=cut -> bevel, real Button data-shape=continuous ->
squircle, radius magnitude preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scoop family chip cast its box-shadow INTO the concave notches and the 1px
border seamed at the concave corners (native corner-shape, not clip-path) — looked
broken. Flat-filled chips render every family cleanly and are the right home for
cut/scoop (chips/badges), matching the per-component idea below.
Add pendiente.md: per-component shape family (chips/badges differentiator), general
shape adoption, corner-shape support (canon = degrade to arc, no polyfill), depth
scrim cue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuilt from abstract tiles to a real "now playing" card whose every corner is
driven by a control panel — switch family / smoothing / magnitude / concentric and
the whole card retunes live. The avatar stays a true circle (families do not deform
it, by design). Plus: de-cerca round-vs-squircle, the 4 families on one chip, the
press-morph buttons, and a "what only Eidos does" strip.
Fix: the cover no longer sets its own border-radius, so the [data-shape-nest]
concentric rule owns it — the cover inset is now truly concentric (16 - 8 = 8px).
Verified: check 0 errors; browser (Chrome 146) — the family switch retunes card +
cover + play + tag + input live, avatar stays circular, cover concentric.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the runtime-builder quartet (color, type, depth, shape). buildShape
(pure) + ActiveEidos.applyShape/clearShape: dial `smoothing` (live continuity /
squircle intensity) + `nestGap`, or override/add `families`, written as a managed
block that wins over the foundation. Exported from $uix/eidos.
Verified: check 0 errors; eidos config 56/56.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shape becomes a two-moment channel: resting form (Fase 1) + the morph (event).
Folded cross-modally into the existing `press-squeeze` contact firma — one press
now drives scale (motion) + shadow-flatten (depth) + corner-firm (shape): the
`continuous` corner squares up at the press peak (--shape-smoothing 2 -> 3 -> 2).
- `@property --shape-smoothing { syntax: <number>; inherits: true }` so the number
interpolates smoothly (mirrors the motion @property pattern).
- Set only at the keyframe peak -> interpolates from/to the element underlying
smoothing (respects a theme override). No-op for non-continuous corners (they do
not read --shape-smoothing). Degrades with reduced-motion via the firma system.
Material 3 Expressive is the only other system with shape morph (bespoke, closed);
this rides the unified firma — one event, many modalities.
Verified: check 0 errors; eidos config+motion+generated 78/78. Regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`--shape-nest-gap` token + `[data-shape-nest]` rule: a nested element derives
`border-radius: max(0px, var(--shape-outer-radius) - var(--shape-nest-gap))`, so
its corners stay parallel to the parent (concentric). The parent exposes its
radius via `--shape-outer-radius` (inherited); gap defaults to the nest token.
nestGap is optional on ShapePrimitiveSet (a shape system can omit nesting); the
token + rule are gated on its presence. Only SwiftUI (recent, platform) has
concentric corners — none on the web as a token rule.
Verified: check 0 errors; eidos config+generated 55/55. Regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 8th book channel (forma) — the one no web design system has elevated. Fase 1
lays the foundation: corner continuity + perceptual families as portable tokens.
- ShapePrimitiveSet (config-driven): `smoothing` (superellipse exponent, 1=arc ->
2=squircle) + `families` map. STATIC_SHAPE ships rounded/continuous/cut/scoop.
- Emission: `--shape-smoothing` token + `[data-shape=`{family}`]` rules setting
`corner-shape` (round / superellipse(var(--shape-smoothing)) / bevel / scoop).
Opt-in: magnitude stays in `--radius-*` (untouched), so corners degrade to the
plain border-radius arc where `corner-shape` is unsupported (progressive, like
the wide-gamut oklch of color). var() works inside superellipse() (Chrome 146).
- Validation (validateShapePrimitives) + 2 tests + regen.
Why it matters: the whole web field (Tailwind/shadcn/Chakra/Mantine/Ant/Radix
Themes/Carbon/Fluent/Spectrum/Polaris/Primer) is "radius scale + circular arc +
static". Continuity exists only in Apple (platform-locked); none on the web ships
squircle as a token. This is the first.
Verified: npm run check 0 errors; eidos config+generated 54/54. data-shape applies
in the live runtime (continuous->squircle, cut->bevel, scoop->scoop).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the depth channel. Atmosphere as the channel's materials layer:
- Per-plane `blur` cue (overlay 10px, modal 16px) + an opt-in frost rule
`[data-depth=`{plane}`][data-frost]` -> translucent surface (color-mix 80%) +
backdrop-filter blur. Gated on data-frost so it never turns an opaque overlay
translucent by default; specificity 0,2,0 reliably overrides the component bg.
- buildDepth(planes) (pure) + ActiveEidos.applyDepth/clearDepth — retune any
plane cue (surface/shadow/halo/blur/scrim/z) at runtime, the depth sibling of
applyColorScheme / applyTypeScale. Exported from $uix/eidos.
Showcase: /temas/profundidad section Materiales — a frosted-glass panel over a
vivid color mesh (frost blur + shadow + halo).
The `scrim` cue stays an available token without a wired rule — modal backdrops
are component-managed.
Verified: npm run check 0 errors; eidos 185/188 (3 pre-existing `words` failures,
unrelated). New tests: frost emission + applyDepth. Regenerated generated/base.css.
Docs: DEPTH_ENGINE_RFC Fase 4 + token contract, THEMING 29.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Fase 2 rim halo now reaches real UI, not just the showcase. Each elevated
component's shadow signal (recipe tokens in recipes/base.ts + the few direct
box-shadow uses) now composes `var(--depth-{plane}-shadow), var(--depth-{plane}-halo)`.
Reaches: popover, dialog, drawer, dropdown/context/navigation-menu, menubar,
select, tooltip, card, combobox, command, link-preview, words.
Shadow signal only — z-index stays component-managed (the z bands are finer than
the 5 planos), so zero stacking risk. Verified: dialog keeps its own z-index 71
and composes drop shadow + oklab halo. Retuning a plane now retunes every
component on it.
Verified: npm run check 0 errors; eidos 183/186 (3 pre-existing `words` failures,
unrelated). Regenerated generated/base.css. THEMING 29 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Depth engine — Fase 2 (mode-adaptive mezcla):
- New `halo` cue per plane: a top-edge rim-light computed in oklab
(color-mix(in oklab, white N%, transparent); 5/7/8% on raised/overlay/modal).
The `[data-depth]` box-shadow now composes `shadow, halo`. Invisible on light
surfaces (the drop shadow leads), the lift cue on dark surfaces (where the drop
shadow barely shows) — the mode-adaptive answer to "shadow lies in dark",
scoped to the depth channel (global --shadow-* untouched).
- Wired through config-types (DepthPlane.halo) + render-css (declare + compose)
+ config validation + STATIC_DEPTH + regenerated generated/base.css.
Showcase — /temas/profundidad to reference depth (4 -> 9 sections): matches
Material elevation catalog breadth and adds the two axes it lacks (eventful +
open cage):
- Responde a cada estado — dynamic elevation, live interactive control
- La escalera de planos — z-stack of the 5 planes
- Catalogo de planos en reposo — the resting-elevation spec table, our vocabulary
- Luz vs sombra — light/dark side-by-side showing the halo mechanism
- Accesibilidad — never the only channel, reduced-motion, forced-colors, contrast
- Fix: mirror data-theme onto <html> so :root depth tokens stay mode-aware
Docs: DEPTH_ENGINE_RFC (Fase 2 + 5 done, token contract + halo), THEMING 29.
Verified: npm run check 0 errors; depth tests 50/50 (updated the box-shadow
assertion to the shadow,halo composition). Pre-existing `words` recipe-contract
failures unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first cut was weak (grey tiles + a "dispárame" box — it did not sell depth).
Rebuilt around real UI that responds:
- Reacciona — metric cards that lift on hover (raised -> overlay: shadow grows +
translateY): depth responds to interaction (state moment).
- Asciende — an app mockup whose trigger rises a dialog over a scrim; the dialog
shadow grows from flat -> its modal plane as it ascends (the present-rise firma,
fired by stamping data-event-*) and the backdrop recedes (event moment).
- Cada plano, un rol — the 5 planes as the real components they are for:
chips (flush) / card (raised) / menu (overlay) / dialog (modal) / input (recessed).
Self-contained, dark editorial aesthetic. Verified: check 0 errors; browser — the
dialog rises with the firma over a scrim (firmaFired + scrim true), archetypes render
with their plane shadows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
User-facing summary of the depth system (DEPTH_ENGINE_RFC remains canonical):
the two moments (data-depth resting plane + the present-rise/press-squeeze firma),
the config-driven planes composing existing primitives, the jaula-abierta escape
hatches, and the deferred refinements + component-adoption follow-up. Closes Fase 5 docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Showcase for the depth channel (mirrors /temas/color and /temas/tipografia):
- ESTADO: the 5 planes (flush/raised/overlay/modal/recessed) as elevated tiles
composing surface + shadow + z (`data-depth`), with a light/dark toggle showing
the mode-aware mix.
- EVENTO: stamping `data-event-*` (as sema would during the hold) fires the firma
live — emerge -> the shadow grows (rise); contact -> it flattens (recede).
Depth occurs.
Self-contained (eidos foundation + data-theme), distinctive dark/editorial aesthetic.
Verified: check 0 errors; browser — 5 planes render with their resting shadows;
firing emerge applies the present-rise firma (firmaFired true).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The novel core: depth as something that OCCURS. The elevation dimension now lives
in the event firma (the two-moment model event moment) — leveraging the existing
signature system, NOT a parallel one:
- present-rise (emerge): the box-shadow grows from flat -> the element resting
plane shadow as it rises. A flat element (no resting shadow) is a no-op; a
raised/overlay element animates its elevation proportional to ITS plane.
- press-squeeze (contact): the shadow flattens to the surface at the press peak,
then returns (recede).
So depth + position/scale + sound + haptic all fire coordinated from one event.
Degrades with reduced-motion (the global events.css cap); a theme overrides the
keyframes/signatures (jaula abierta). No reference framework treats depth as eventful.
Fase 2 (computed mode-adaptive shadow mix) deferred — the shadows are already
mode-aware, so it is a refinement, not a gap (documented in the RFC).
Verified: check 0 errors; motion + config + generated tests pass; browser — the
present-rise keyframe is live with the box-shadow dimension.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures the alignment raised in review: depth respects the framework two-moment
model — `data-depth` is the STATE moment (resting plane, Fase 1); the rise/recede
signature on `data-event-*` is the EVENT moment (Fase 3). The depth channel is
expressed through the SAME model motion already uses (evento != estado, never collapsed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Arranca el sprint de profundidad: depth como un plano unificado, config-driven y
semántico — la base del canal "la profundidad es algo que OCURRE" del libro (el
disparo eventful por sema llega en Fase 3).
- DEPTH_ENGINE_RFC.md — la guía de diseño: estudio de los límites de los
referentes, la tesis novel, y la doctrina "default fuerte, jaula abierta".
- EidosConfig.depth.planes (DepthPlane + DepthPrimitiveSet). Set canónico:
flush · raised · overlay · modal · recessed — cada uno COMPONE los primitivos
existentes (surface/shadow/z), sin matemática nueva → cero rotura.
- emite tokens --depth-{plane}-{cue} + reglas [data-depth='{plane}'] que aplican
las señales aditivas seguras (box-shadow + z-index); surface + blur/scrim
quedan como tokens opt-in (no pisan fondos de componente).
- validado; config-driven (un tema añade/retunea planos — jaula abierta).
- arregla un punto y coma latente en la emisión de variable-fonts, cazado aquí.
Verificado: check 0 errores; tests nuevos (emisión canónica + plano custom);
generated/base.css regenerado; navegador — data-depth='overlay' aplica la sombra
overlay + z 400, 'recessed' aplica una sombra inset. (3 fallos de words pre-existentes.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2 Semantic leading/tracking config-driven: the per-role `--leading-{role}`
(ui/prose/text/heading/display) + `--tracking-{role}` (badge/label/ui/prose/
heading/display) tokens were hardcoded in render-css; moved to
`typography.semanticLeading` / `semanticTracking` (STATIC_TYPOGRAPHY), emitted
config-driven + validated, so a theme can retune them. Byte-identical output
(generated/base.css unchanged — same values, same order).
#3 Font preloads: `collectFontPreloads(typography)` (pure) + `eidos.fontPreloads()`
surface `<link rel=preload>` descriptors for families flagged `preload: true`
(the engine emits CSS, not head markup), for the app `<svelte:head>`. Inert
until a theme opts in.
Docs: TYPOGRAPHY_ENGINE_RFC fases marked closed + Fase 4 (the two-zone scale is
intentional — documented, not rewired); THEMING §10 — applyColorScheme +
applyTypeScale system builders.
`npm run check` 0 errors; new font-preload tests + config/generated tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typography showcase mirroring /temas/color: dogfoods the engine through
live CSS tokens.
- Escala: live `buildTypeScale` ladder — base + modular ratio (+ fluid
ratioMax/viewport) re-derive all 8 sizes; the named styles (hero/h1-h6/
body/caption) re-scale too (they reference --font-size-* via var()).
- Familias: the 4 config-driven @font-face slots in their own fonts.
- Ejes: tracking / leading / measure / wrap / numeric driven live via the
--_text-* per-instance vars the recipe reads.
Self-contained (eidos foundation CSS + raw recipe data-attrs + data-theme),
same posture as the color demo. Editorial Lora-display aesthetic.
Verified in browser: live scale responds (xxxl 16->77px at ratio 1.6);
4 real fonts load (Instrument Sans / Lora / Azeret Mono); axes apply
(measure narrow = 54ch). `npm run check` 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typography analogue of applyColorScheme: derive a whole `--font-size-*`
ladder from one modular ratio + base, optionally fluid (`ratioMax` grows
the scale on wide screens), applied as a managed `:root` block that
overrides the theme authored sizes at runtime.
- buildTypeScale(seed) — pure, in eidos/lib; mirrors build-scheme. Steps
the 8 named sizes (xxs..xxxl) off `md`=base via the ratio; reuses
fluidClamp; composes with `--scaling`.
- ActiveEidos.applyTypeScale(seed, opts) / clearTypeScale() — managed
block written last so it wins over the static sizes.
- exported from $uix/eidos (buildTypeScale, typeScaleDeclarations, types).
`npm run check` 0 errors; buildTypeScale + applyTypeScale tests pass. The
managed-block DOM path is the same mechanism as applyColorScheme; the
/temas/tipografia showcase will dogfood it live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`FontFamily.axes` ({ wght, opsz }) was declared but never consumed — the
TYPOGRAPHY_ENGINE_RFC §5 promise was unmaterialized. Wire it:
- axes.wght -> when a face omits `weight`, @font-face emits the range
(`font-weight: 100 900`) so one variable face spans the whole axis
- axes.opsz -> `:root { font-optical-sizing: auto }` so the optical-size
axis tracks the rendered font-size
Engine-only, inert until a theme declares axes (shipped fonts are static
TTFs — same posture as wide-gamut color: ready, not yet exercised by
assets). Zero change to generated/base.css (no family declares axes).
`npm run check` 0 errors; new test asserts the emission; the generated-css
test confirms base output unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes the move started in 9f007a84, which left the `id` concern split
across TWO folders (createId in soma/id, useId in active-uix/id) — worse
than one library, and the right call you pushed for. Both functions now
live in a single `$active-uix/id`; `soma/id` is deleted.
+ src/uix/active-uix/id/{create-id,use-id,index}.ts (one authority)
~ 441 soma imports repointed: relative ../../id and ../../../id -> $active-uix/id
- src/uix/soma/id/ (removed)
~ COMPONENT_GUIDE / README examples updated
createId (wraps $props.id(), SSR/ARIA ids) and useId (client-only counter)
sit together; any UIX layer — soma, eidos, demos, apps — shares one id
authority without coupling to soma. Output format unchanged
(`soma-{component}-{n}`) -> zero behavior change.
`npm run check` 0 errors; accordion (createId+useId) verified on a fresh
dev server, console clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The soma `id` module conflated two concerns: `createId` (wraps Svelte
`$props.id()` for SSR/ARIA element ids — ~200 soma importers, intrinsic to
the headless layer) and `useId` (a generic monotonic counter — 6 importers).
Only the counter is a cross-layer utility.
Move `useId` to a new `$active-uix/id` subpath (mirroring the existing
`$active-uix/prefs` that soma already imports), so any UIX layer — soma,
eidos, demos, apps — can mint client-only ids from a cheap, collision-free
counter without coupling to soma. `createId` stays in soma (its domain).
+ src/uix/active-uix/id/index.ts (useId)
~ soma/id now exports createId only
~ 6 useId importers repointed to $active-uix/id
(floating, toaster, date-field, time-field, accordion-item, internal/arrow)
Output format unchanged (`soma-{component}-{n}`) -> zero behavior change.
`npm run check` 0 errors; toast/accordion/popover verified in browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The event-trace inspector in each demo keyed its `{#each}` by a
`Date.now()` timestamp (`entry.at` / `h.at`). A single interaction can
stamp `data-event` on several elements within the same millisecond, so
two trace entries share the key and Svelte throws `each_key_duplicate`
(reported on dropdown-menu).
The trace is an ephemeral, 3-item, text-only log with no transitions or
stateful children, so positional reconciliation is correct: drop the
timestamp key (the each becomes unkeyed). The `fmtTime(...at)` display is
left intact.
Swept all 22 demos carrying the pattern (21 keyed by `entry.at` + announce
by `h.at`): dropdown-menu, context-menu, menubar, navigation-menu, listbox,
grid-list, table, tree-view, tree-grid, feed, command, carousel, drag-drop,
clipboard, announce, alert-dialog, button, color-field, link-preview,
range-calendar, time-field, time-range-field.
Verified: `as \w+ (\w+.at)` -> 0 occurrences site-wide; `npm run check`
0 errors; browser repro on dropdown-menu + table (burst of events, no
each_key_duplicate, console clean).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eidos-native components have NO soma layer, yet each demo rendered a
misleading "soma" code-snippet block ("n/a · eidos-native — equivalent
markup shown") fed by an orphan `somaSnippet` derived. Sweep the fix
already verified on text/heading across all 26:
layout primitives box flex grid stack container auto-grid wrap group
section aspect-ratio float
typography/inline code code-block kbd mark highlight link badge separator
visual leaves avatar banner skeleton spinner icon display scroll-frames
For each: remove the `somaSnippet` declaration + the soma `data-uix-code`
block, leaving only the real eidos snippet (and dropping its now-unneeded
inline margin-top).
Skipped (correctly): card / avatar-group / image (soma mentioned only in
prose, no fake code block — image reads a real ImageProvider) and all
genuinely soma-backed components.
Verified: `npm run check` 0 errors; 0 orphan somaSnippet refs in swept
files; browser spot-check (box, scroll-frames) shows one eidos code
block, zero soma badges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Text and Heading are eidos-native (no soma layer), but their demos:
1. wrapped the live preview in a content-sized box (no inline-size),
so `align` had no room to render inside the centered stage area;
2. rendered a "soma" code-snippet block ("n/a · eidos-native") with
non-real equivalent markup, muddying the layer story.
Fix both: the live wrapper now `inline-size: 100%` (fills up to its
max-inline-size cap, 36/38rem) so alignment is visible; and the soma
code block + its now-orphan `somaSnippet` derived are removed, leaving
only the real eidos snippet.
Verified in browser: text box 576px / heading box 608px (filling the
918px stage); align=end renders; one eidos code block, zero soma badges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the Phase 3 scales into <Text> and <Heading> + fixes a token collision the scales
introduced.
- Text/Heading props (additive): `tracking` / `leading` (reuse the existing
--_x-letter-spacing / --_x-line-height vars → override the size-derived values),
`wrap` (text-wrap: balance/pretty/nowrap), `numeric` (tabular/oldstyle →
font-feature-settings), `measure` (max-inline-size). Heading defaults to
`text-wrap: balance` (reference-grade titles); Text defaults to the CSS initial so the
axes are no-ops until a prop is set. Heading reuses Text's scale unions.
- COLLISION FIX: Phase 3a's config-driven --tracking-{tight,normal,wide,wider} collided
with a pre-existing HARDCODED tracking scale in render-css (semantic badge/ui/… +
scale tight/normal/wide/wider, all 0) emitted later → it won (everything resolved to
0, so the tracking prop did nothing). Removed the hardcoded scale lines; the config
(typography.tracking, real optical values) now owns tighter/tight/normal/wide/wider.
The semantic tracking tokens (badge/label/ui/prose/heading/display) stay (recipes use
them, e.g. card-title --tracking-tight now picks up the real -0.02em). leading/features
/measure don't collide (distinct keys).
Verified in browser: tracking-wide 0.02em -> 0.32px; tracking-tight -0.02em -> -0.32px;
heading default text-wrap balance; wrap=pretty, numeric=tabular, measure, leading all
apply. check 0 errors; eidos suite green (3 pre-existing words failures only).
Note: the pre-existing hardcoded semantic typography block (leading-ui/prose/… +
tracking-badge/…) is still hardcoded, not config-driven — a separate cleanup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 3 (engine tokens). Additive scales, pure CSS, no binaries.
- config-types: TypographyPrimitiveSet += tracking / leading / features / measure
(optional Record<string,string>).
- render-css: emits --tracking-{k} / --leading-{k} / --font-feature-{k} / --measure-{k}.
- typography.ts defaults: tracking (tighter…wider), leading (none…loose), features
(tabular = "tnum"+"lnum" for data, oldstyle/smallcaps/ligatures), measure (54/66/78ch).
+ optical tracking baked into the size tokens: small text slightly looser
(xxs +0.01em), display tighter (xxxl -0.02em) — was all 0.
- config.ts: validates the 4 scales (CSS-value maps).
- generated/base.css regenerated. test: scales + optical tracking emission.
Additive tokens (recipes/components consume var(--tracking-tight) etc.) so the scales
are zero-risk; the only rendered change is the gentle optical tracking on headings/small
text. check 0 errors; eidos suite green (3 pre-existing words failures only).
Next (Phase 3b): wire the component props (wrap: balance/pretty, numeric: tabular,
tracking/leading/measure) on <Text>/<Heading>.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 2. Fonts are theme data (each theme owns its families), so
@font-face becomes config-driven + generated — like the color palette — instead of a
separate hand-written CSS file. This matches next/font / Fontaine (config -> @font-face),
above the token-only frameworks (Radix/Tailwind/MUI) that leave loading to you.
- config-types: FontFamily += faces (FontFace[]) / axes (FontAxes) / fallback
(FontFallback, metric-override) / display / preload. Additive — the family stack still
works from `family`+`fallbacks`.
- render-css: renderFontFaceBlocks generates @font-face per face from the config, deduped
by the real font name (a font shared across slots — Lora as secondary+display — emits
once). Optional metric-override fallback @font-face (anti-CLS) injected into the stack
as `'{family} Fallback'` when declared. Emitted first in renderStaticCss.
- typography.ts: the BASE THEME's 14 @font-face migrated from fonts.css into the config
(faces). TTF today (the theme's choice); a theme swaps to woff2/variable + fallback
metrics by editing config only.
- index.css: drops `@import './themes/fonts.css'` — the @font-face now ships in
generated/base.css. (fonts.css superseded; left in place, no longer imported.)
- generated/base.css regenerated (14 @font-face, Lora deduped). test: @font-face
generation + dedup; merge-without-mutation assertion updated for the faces field.
Verified in browser: 3 families registered, files resolve (200), fonts load on demand
(swap). check 0 errors; eidos suite green (3 pre-existing words failures only).
Deferred (capability typed, theme adopts when it has the assets): variable woff2,
metric-override numbers (need fontkit/precompute), <link rel=preload> (head markup).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TYPOGRAPHY_ENGINE_RFC Phase 1. Additive on TypographyPrimitiveSet, behind the frozen
token contract (--font-size-X keeps its name; only the value formula changes, like
color --scale-* hex -> oklch()).
- type-scale.ts (pure, isomorphic, no canvas): the Utopia clamp() formula. fluidClamp /
resolveTypeSize / isFluidSize. rem-based (a11y: scales with browser font-zoom).
- config-types: TextMetric.size accepts `string | FluidSize` ({min,max,minVw?,maxVw?}).
Plain length strings still valid -> backward-compatible.
- render-css appendTypographyDeclarations: emits calc(resolveTypeSize(size) * --scaling)
-> a fixed rem or a fluid clamp; the --scaling axis composes on top.
- config.ts: validateSizeValue accepts a FluidSize (validates min/max/minVw/maxVw) so
the base config validates (was the cascade root — FluidSize objects failed the
string-only CSS-value check).
- typography.ts: sizes in rem; headings (lg/xl/xxl/xxxl) fluid (min @480px -> max
@1280px, max = previous fixed px so desktop is unchanged); body (md) fixed. hero/h1/h2
drop the manual { base, md } responsive sizes — the clamp covers the viewport.
- generated/base.css regenerated. type-scale.test.ts (6 tests). 2 config-test assertions
updated to the new rem/clamp values.
Verified in browser: --font-size-xxxl 40px @480 -> 80px @1280; xxl 32->48; lg 18->20;
md 16 fixed. check 0 errors; eidos suite green (3 pre-existing words failures only).
canvas-text/<SText> unaffected (reads getComputedStyle real font, measures the clamp).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirrors the color RFC approach (audit -> compare -> extend additively behind the frozen
token contract, by phases). Covers: the current state + the gap vs Utopia/Tailwind v4/
Material 3/Apple/Carbon; the inclusion model (extend TypographyPrimitiveSet, never
rename tokens); the fluid clamp() formula (rem-based, with --scaling composing on top);
woff2 + variable fonts + anti-CLS metric-override fallbacks; tracking/leading/features/
measure/text-wrap; the <SText> measurement link; canon-vs-theme doctrine; token contract
additions; and a 5-phase plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes all 5 items of fix-stext.md for the <SText> canvas-measured text engine. None
were implemented before; T1 was a P0 correctness bug, T2 a P1 memory leak.
- T1 (P0) font-load invalidation: `invalidateFont` / `invalidateFontFamily` in
measurement.ts (granular, vs the old all-or-nothing clear) + `useFontReady(dom)` hook
that subscribes to `document.fonts` `loadingdone` via `ActiveDom.listen` (iframe/popup
-safe, no raw listener), evicts the loaded family's cache FIRST, then bumps a reactive
`epoch`. s-text.svelte folds `epoch` into the layout getter. Fixes the line count
lying after a web font swaps in (getComputedStyle reports the requested family,
unchanged on load, and the cache is keyed by the font string).
- T2 (P1) bounded caches: `LruCache` (Map-backed, move-to-recent + evict-oldest) caps
the per-font segment cache at 4096 entries and at most 24 fonts. invalidateFont reuses
it cleanly.
- T3: single getComputedStyle per reactive pass (merged `font` + `lineHeightPx`).
- T4: hydration flash documented in s-text.svelte.
- T5: bidi levels opt-in via `PrepareOptions.computeBidiLevels` (default false) — the
walker never consumed `segLevels`, so it was wasted compute on every prepare.
- tests: new canvas-text.test.ts (9 tests, deterministic canvas stub) covering LruCache
semantics, invalidateFont/Family, per-font bounding, and bidi opt-in + line-count
invariance. (The engine had ZERO tests before.)
Invariants kept: no change to line-break decisions, no canvas painting, pure files
(measurement/layout) stay Svelte-free, SSR-safe, strict TS. check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found stale color docs while verifying currency:
- arts/color/README.md: said "Status — Phase 0 ... nothing consumes it yet" (false —
consumed at build via render-css + runtime via applyColorScheme) and used
`ActiveEidos.setCssVariables` as the theme-builder mechanism (the real API is
applyColorScheme; setCssVariables is for contract knobs). Updated status, added
deriveScheme/temper/harmonize to the API table, documented temper as the canonical
intent-cohesion tool (vs harmonize for brand accents), fixed the builder pipeline to
buildScheme + applyColorScheme, and corrected the wide-gamut note (strategy A is
live + default-on, not "deferred").
- COLOR_ENGINE_RFC.md: 4 remaining `setCssVariables` references for the runtime
white-label builder -> applyColorScheme (only §6.2 was fixed earlier). Marked
Fase 4-bis (runtime generation) as IMPLEMENTED.
COLOR_MODEL_RFC.md verified current (RESUELTO; loss->plum correct; the anchor-hex
examples are the documented-discarded proposal = history). THEMING/audit/README were
already synced in their own commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the session hand-off up to date with the work after the first write:
- checkbox 244ms lag fix (sequence pre->post) + the new doctrine (handler-gated
control state needs 'post'; call-site state tolerates 'pre');
- P3-11 surface ladder (light overlay -> neutral-4);
- prefers-contrast: more a11y;
- theming-engine backlog close (P3-5/6/8 fixed, P3-4/7/9 deferred with rationale,
P2 halves deferred).
Marks the prior "pendiente" engine-hygiene list as resolved-or-decided in the
continuation block. Final state: color/theming fully triaged, check 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finishes the THEMING_AUDIT P3 backlog.
P3-5 (fixed) · appendScaledMetricDeclarations: the `parseFloat(raw) === 0` guard let
non-numeric values (auto / var() / calc()) fall into `calc(x * …)` = invalid CSS. Now
only finite, non-zero numbers are scaled; zero + non-numeric emit verbatim. No change
to the base config output (all values numeric) — pure robustness.
P3-8 (fixed) · index.ts no longer re-exports the raw render-* fns. The public render
API is the ActiveEidos class (gated by assertValid() + active config); ./lib/render-css
stays reachable for internal/tooling use. Redirected the one internal consumer
(active-eidos-config.test.ts) to import renderThemeCss from the module.
Triaged the rest with rationale (audit updated):
- P3-4 deferred · density wins by deterministic source order (stable); the :where(:root)
restructure to also support scoped density is high-cost for a theoretical nit.
- P3-6 already resolved · dispose() routes documentElement via dom (no direct access).
- P3-7 deferred · ActiveEidos reactivity is callback-driven (apply() on pref change) by
design; a full runes conversion is a risky refactor with no bug to justify it.
- P3-9 deferred · orphan _accent forwarders — low-value recipe surgery with cascade risk.
All P3 now fixed-or-decided; only the P2 secondary halves (contract pruning + bare
identifier color validation, both edge-case) remain, deferred as low-value.
check 0 errors · eidos suite green (3 pre-existing words-track failures unrelated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the forced-colors a11y work. Users who request more contrast (macOS
"Increase contrast", Windows, etc.) now get strengthened neutral chrome: borders
bumped to neutral 7/8/9 (subtle/default/strong) and de-emphasized text to 12/11
(secondary/muted). Solid fills + primary text are already high-contrast, so they stay.
renderPrefersContrastBlock emits a @media (prefers-contrast: more) block using
`:root:root` (specificity 0,2,0) so it wins over the theme's :root regardless of
stylesheet order. Values reference --primitive-neutral-* (resolve from the cascade; a
theme omitting them just no-ops the declaration — graceful). Strictly additive (gated
by the media query) and strictly STRONGER, so it can't regress the default look.
Also marks audit P3-6 resolved: dispose() already routes documentElement access via
dom (#lastAttrs.target + dom.apply), no direct document access remains.
- generated/base.css regenerated. test: renderStaticCss emits the prefers-contrast
block. docs: THEMING §28 + audit P3-2/P3-6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the base light theme `overlay` was neutral-3, identical to `muted` (neutral-3),
so popovers/menus/dialogs were indistinguishable from muted panels in light mode.
Dark already had overlay=neutral-4. Light overlay -> neutral-4 makes the elevation
ladder consistent across both modes: default(1) < raised(2) < muted(3) < overlay(4).
Verified in browser (light popover): overlay L93% now distinct from muted L95.5% and
default L99% (was overlay == muted). Dark unchanged. generated/base.css regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The REAL cause of the slow checkbox (not the stroke duration). The checkbox provider
flips its checked state inside the trigger HANDLER, and the morfo declared the commit
events with sequence: 'pre' — so the runtime ran `await runEmit()` BEFORE the handler.
emit() awaits the visual-channel hold (~240ms), so the functional state change (and
thus data-state) waited the full perceptual hold before flipping. Measured: click ->
data-state='checked' took 244ms.
Fix: commit-toggle-check / commit-toggle-uncheck -> sequence: 'post' (handler runs
FIRST, state flips immediately, the celebratory pulse emits after). This is the
doctrine for control commits (runtime.svelte.ts §577: "toggle's commit-toggle... the
pulse arrives AFTER the state has flipped"). Toggle + Switch were already 'post';
checkbox was the outlier. Measured after: 244ms -> 46ms.
Verified the siblings are NOT affected: radio-group (47ms) and tabs (31ms) are also
'pre' but flip state in the call-site (not the handler), so no lag — left unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The checkmark stroke-dashoffset draw used a hardcoded 220ms while the box fill ran at
--duration-fast (120ms) — nearly 2x, so the check read as laggy when toggling. Tokenize
it to var(--duration-fast) so the stroke draws in sync with the box (one 120ms motion).
Not the motion service (uix.motion) — this is a plain CSS transition in checkbox.css
driven by the recipe `stroke-duration` token. Verified in browser: path transition
0.22s -> 0.12s, synced with the box. generated/base.css regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the 6-commit color sprint (cierres + runtime theme builder + wide-gamut
output + wide-gamut-true generator + forced-colors a11y + border ramp) at the top
of the hand-off section: key APIs, layering decisions, honest wide-gamut scope, and
the remaining engine-hygiene backlog (THEMING_AUDIT P3-4..P3-11 + P2 halves).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the two color-quality items from THEMING_AUDIT P3.
P3-2 · forced-colors (Windows High Contrast): under @media (forced-colors: active) the
browser auto-maps borders/text/backgrounds to system colors BUT drops box-shadow — so
the box-shadow focus ring (--focus-ring) vanishes and keyboard focus disappears. The
foundation now always emits a system-colored outline fallback:
@media (forced-colors: active) {
:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
}
Components that already focus via outline (e.g. Button) keep theirs by specificity; this
is the fallback for the box-shadow ones. renderForcedColorsBlock in render-css.ts.
P3-3 · role border ramp: the per-role `border` slot moved step 6 -> 7. In Radix's
functional scale 6 is a subtle separator and 7 is the UI element border; step 6 read
washed-out on real element borders (outline/surface/controls). element/hover/active
(3/4/5) stay — Radix-canonical for component bg. DEFAULT_COLOR_ROLE_SLOT_STEPS.
- generated/base.css regenerated (forced-colors block + --color-{role}-border -> step 7).
- Verified in browser: --color-primary-border now resolves to primitive-7 (oklch 0.80
0.092 vs the softer step-6 0.86 0.072); checkbox borders render defined, not broken.
- test: renderStaticCss emits the forced-colors outline block.
- docs: THEMING §28 + audit P3-1/2/3 marked resolved + README ref row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The theme builder now carries REAL wide-gamut, not just sRGB reformatted. generateScale
keeps raw OKLCH (no clamp), so a seed whose chroma exceeds sRGB renders more saturated
on P3 than its hex fallback.
- build-scheme.ts: result gains `wideGamut` (oklch() per opaque step) + `roles[].stepsOklch`;
`variables` stays hex (fallback + introspection). New `schemeDeclarations(result, {fallback})`
flattens to CSS lines — dual hex+oklch stack (default) or oklch-only (fallback:false,
for inline style where the CSSOM keeps one value per prop).
- ActiveEidos.#renderSchemeCss: emits the dual stack via schemeDeclarations → the applied
scheme block is wide-gamut on P3, sRGB-safe everywhere.
- index: export schemeDeclarations + SchemeDeclarationsOptions.
- temas/color demo: new "vivacidad P3" slider pushes the seed chroma past sRGB +
a "fuera de sRGB -> P3" badge (isInSrgbGamut). themeOverride now applies oklch
(wide-gamut). Verified: vivacity x1.70 -> primary-9 chroma 0.18 -> 0.31, badge on.
- tests: wide-gamut chroma retention (stepsOklch > hex fallback) + schemeDeclarations
dual/single; active-eidos scheme block asserts oklch(). 31/31 green.
- docs: THEMING §26/§27 + RFC §6.2.
Honest scope unchanged: the AUTHORED Radix palette stays exact sRGB (no regression).
Wide-gamut lives in the generator path (vivid seeds / OKLCH-authored themes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each palette step is now emitted twice: the hex as a universal fallback, then an
oklch() sibling that wins where supported (Chrome 111+/Safari 15.4+/Firefox 113+).
The token layer is now OKLCH-native and wide-gamut-ready, with NO @media and NO
config flag (it is the default behaviour).
- render-css `appendColorScaleDeclarations`: hex line + `oklch()` sibling per
`--scale-{name}-{step}`. Only opaque, parseable steps get the sibling; empty/
non-color values keep just the fallback. `--primitive-*`/`--color-*` are var()
refs (untouched); alpha scales stay color-mix/rgba.
- generated/base.css regenerated (+744 oklch sibling lines: 31 scales x 12 x 2 modes).
Honest scope: the shipped Radix palette is authored in sRGB hex, so its oklch()
siblings are sRGB-equivalent (verified: --scale-purple-9 -> oklch(0.5556 0.1829
305.86) paints #8e4ec6) -- identical today. The win is the OKLCH-native foundation:
an OKLCH-authored theme or a vivid generated scheme now renders wider on P3 with no
extra work. Making the SHIPPED palette visibly wide-gamut is Phase 3.
Tests: full eidos suite green except 3 pre-existing words-track failures (confirmed
unrelated via baseline). Docs: RFC §7 (status) + THEMING §27 + README ref row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RFC Phase 4: derive a whole-system color scheme from ONE brand seed at runtime.
Packages the demo-only builder into a first-class, tested API.
- build-scheme.ts (pure): buildScheme(seed, opts) composes the uix.color engine
(deriveScheme -> generateScale -> APCA on-solid -> compositing-inverse alpha)
into the `--primitive-{role}-*` (+ `--color-{role}-contrast`) override map.
seed -> { variables, roles }. No DOM. 6 tests.
- ActiveEidos.applyColorScheme(seed, opts) / clearColorScheme(): resolves donor
scales + background from the active theme, writes a managed `uix-eidos-scheme`
style block AFTER the theme block (wins the cascade), and RE-DERIVES on mode
change (follows light/dark). Returns BuildSchemeResult for introspection. opts:
variant (tonal|vibrant|monochrome) + temper (intent coherence, keeps hue) +
per-role overrides + selector. 4 tests (return value, intents, DOM block
ordering + clear, mode re-derivation).
- index.ts: export buildScheme + ApplyColorSchemeOptions + BuildScheme* types.
- temas/color demo: themeOverride now dogfoods buildScheme (drops the duplicated
emitRole/rgbaStr; identical output verified in-browser).
- generated/base.css: regenerated for the loss->plum role fix (binding layer
--primitive-loss-* now points at --scale-plum-*; keeps the contract test green).
- docs: THEMING.md SS26 + COLOR_ENGINE_RFC SS6.2 (status: landed) + README ref row.
Overriding the binding layer reprojects every --color-{role}-{slot} + the neutral
chrome downstream; the 31-scale palette stays put. Math in $color, composition in
eidos/lib (pure), DOM application in ActiveEidos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two quick correctness wins for the color system:
- themes/base.ts: `loss` was `purple` -- identical to primary=purple. Map to
`plum` (canonical loss scale): a graver, more magenta violet. This closes
the last role collision in the base theme (after tertiary -> indigo).
- temas/grafito: the "override por componente" section bound raw scale names
(teal/amber/plum...) to Button.color, which only accepts the hierarchy
override (primary|secondary|neutral). That was a type error AND visually
inert (no [data-button][data-color="teal"] rule exists). Split into the two
REAL color axes a component exposes: `color` (hierarchy) + `intent`
(evaluative palette). Closes the last pre-existing svelte-check error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous split was by the text pick (white/dark) - wrong. Group the on-solid
chips by the SAME taxonomy as the Roles section: Jerarquia (primary/secondary/
tertiary) and Intents (neutral/affirm/fulfill/risk/threat/loss), reusing GROUPS.
Removed the now-unused onSolidGroups derived.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Revert ssr:false (it sidestepped the problem, did not fix it). Set the color
inputs imperatively via a `colorValue` action instead of reactive value=/bind:value.
With no Svelte-managed value, hydration no longer assigns "" (the warning) -- and
SSR stays ON. Verified: SSR HTML emits the 6 color inputs with NO value attr; the
action populates them client-side with valid hex.
- Split the "Texto on-solid" section into 2 groups by the APCA pick: "Texto blanco"
(dark solids) and "Texto oscuro" (light solids), computed from the live builder
solids (effectiveSolids) so the groups match the rendered chips. Default: white(8)
/ dark(1 = risk).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diagnosis: the SSR HTML already emits valid hex on all 6 <input type="color">
(verified), and the warning stack pointed to SvelteKit hydration ("await in
start"). Svelte momentarily sets a color input's value to "" while hydrating
before applying the real hex -> "specified value '' does not conform" x6. The page
is a client-only interactive tool (no SEO/SSR need), so ssr:false removes
hydration entirely -> no warning. Belt-and-suspenders with the existing value
guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The seed <input type="color"> used bind:value; switch to value + guarded oninput
so it can never receive "" (the recurring "specified value '' does not conform"
warning, fired at hydration). Both color inputs are now guarded.
- The Roles "contrast" slot IS the on-solid text color: white for dark solids
(most intents), dark only for light ones (risk). White swatches looked invisible
on the light card; add a faint inset ring to .slots .sw so they read as white,
not "no color". Behavior unchanged - only risk needs dark text (correct, APCA).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Radix's gold/bronze were near-identical in UI (chroma ~0.05, indistinguishable).
Re-authored via uix.color (generateScale from metallic seeds): gold #d4af37
(yellow-gold, OKLCH H91) and bronze #cd7f32 (copper, H61), chroma ~0.13 — ~2.6x
more saturated so the hue gap reads clearly (gold yellow vs bronze copper).
Verified in-browser: gold rgb(212,175,55) vs bronze rgb(205,127,50), distinct.
The other 29 scales stay exact Radix v3; header notes the exception. Palette stays
Radix-as-default (swappable) per the engine-not-hues principle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 31-scale library was a flat list, so the 6 tinted grays (gray/mauve/slate/
sage/olive/sand) looked like near-duplicates. Group it like Radix: Grises (tinted
neutrals) - Colores - Brillantes (light solids) - Metales, with a fallback "Otros"
bucket so no scale is ever dropped. Intro note explains the grays cluster because
they are near-neutral (chroma ~0.01) with a subtle per-accent hue tint. Verified
in-browser: 31 scales, none dropped/duped, 4 group labels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-role override <input type="color"> took value={r.solidHex} directly; an
expression-driven color input warns ("specified value '' does not conform") if it
ever receives an empty string. r.solidHex is always a valid hex in practice, but
guard it (`|| '#000000'`) so the input can never receive '' — belt-and-suspenders
for a warning seen during live HMR editing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Gemini's sharp note: rotating an intent's HUE toward the brand (harmonize)
erodes its meaning — a red stops reading as "error". What coheres a palette is
sharing the chroma + lightness PROFILE, not the hue. New temper(color, reference,
amount) keeps the hue and lerps L+C toward the reference. The demo's intent
cohesion switches harmonize -> temper, and the slider MOVES to the "Roles
canonicos" section (next to the intents, dynamic). Verified in-browser: threat hue
stays 358 (red) at 0% and 40% temper, only chroma/lightness shift; affirm stays
teal. harmonize stays in the engine for brand accents. RFC §6.2 updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Canonical intents (red/green/...) can clash next to a brand color. Replace the
harmonize on/off toggle with a slider (0 = pure canonical -> 0.35 strong), default
a SUBTLE 12%: intents lean toward the brand enough to feel cohesive but stay
recognizable (red is still red). Verified: green seed -> threat #e35013 at 12%
(warmer red), pure #e5484d at 0. Also clarified that "La paleta" is the FIXED
library (does not derive from the seed; the seed derives the roles) to resolve the
recurring confusion. RFC §6.2 notes the subtle-default guidance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deriveScheme gives DEFAULTS, not a cage: each role row gets a color input that
PINS that role to the designer's exact color, while the rest keep deriving from
the seed. "auto" un-pins; changing the seed re-derives only the unpinned roles.
Mirrors M3 (custom colors per role) + Radix (pick accent/gray) + the hand-authored
path (grafito maps every role). Verified in-browser: pin secondary=blue +
tertiary=gold while primary/neutral stay derived; auto reverts to derived.
Documented in COLOR_ENGINE_RFC §6.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The seed now re-themes the ENTIRE page, not just the preview: it overrides
--primitive-{role}-* (+ contrast + surface alpha) on .root, reproyecting every
--color-{role}-* and the neutral-driven surface/content/border chrome. Mode-aware
templates (light/dark). New "harmonize intents" toggle nudges the canonical intents
toward the seed's temperature (M3 blend.harmonize); off by default so intents stay
canon. The 31-scale library stays fixed. Verified in-browser: green seed -> canonical
primary / on-solid / topbar all go green; threat stays red until harmonize.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seed color + variant (tonal/vibrant/monochrome) -> deriveScheme -> generateScale
-> live preview of the 5 hierarchy role scales (12 steps each, step 9 ringed) +
their APCA on-solid chips. The full uix.color builder pipeline, reactive and
pure. Verified in-browser: blue seed -> tertiary purple (+60deg), monochrome
collapses to one gray, primary = seed verbatim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Theme-builder core in uix.color: one brand seed -> hierarchy role seeds
(primary/secondary/tertiary/neutral/neutralVariant). Ports M3's HCT CorePalette
to OKLCH — secondary = same hue/low chroma, tertiary = hue+60deg, neutral =
near-gray; variants tonal/vibrant/monochrome (structured for more). harmonize()
nudges hues toward the brand (M3 blend.harmonize). The 6 canonical intents are
NOT derived (an error is always red); APCA replaces HCT's tone->contrast. Pure
+ isomorphic — produces values behind the frozen --color-{role}-{slot}
contract, so zero component impact.
Docs: COLOR_ENGINE_RFC.md §6.2 + color README theme-builder section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Palette (31 scales x 12 steps), the 9 canonical roles with their slots, the
APCA on-solid pick live via uix.color, translucent role surfaces, and the
neutral chrome. Rendered through live CSS custom properties with a light/dark
toggle. Verified in-browser: swatches resolve, risk flips to dark text, modes
invert. Not linked from the /temas index (it is a system demo, not a brand
theme — like /temas/animations).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tertiary was `gray` — literally the same scale as neutral, and secondary
(slate) is a near-gray too, so the three desaturated roles blurred together.
Per Material 3's tertiary rule (rotate the primary hue ~60deg), tertiary now
uses `indigo`: purple's cool neighbor, saturated enough to be distinct, in a
hue band no intent occupies. Updated the one test that pinned tertiary=gray;
regenerated base.css.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First consumer of uix.color. The theme generator's on-solid text pick
(white vs dark) now decides by APCA (|Lc| >= 60) instead of WCAG 2
(< 3:1), with a WCAG 2 ratio kept as a conservative cross-check — white
must clear BOTH or the contrast slot flips to onSolidContrast. APCA is
accurate in the mid-tones where WCAG 2 mis-estimates (the risk=orange
case). Reproduces the documented base behavior (only risk flips) via a
better metric; generated/base.css unchanged (the pick lives in the
runtime theme block).
- render-css: import apcaLc / oklchToGammaRgb / safeParseColor /
wcagContrastRatio from $color; replace the local WCAG pick; drop the
now-orphaned local wcagRelativeLuminance + wcagContrastRatio.
- color: add safeParseColor (null instead of throw for var()/color-mix
theme values the engine can't introspect).
- wire $color alias (vite.config.ts + svelte.config.js + CLAUDE.md).
Verified: color 20/20, eidos 162/165 (3 pre-existing words failures),
active-eidos-config contrast asserts pass, npm run check 0 new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New art `src/arts/color` — pure, DOM-free, deterministic color math that
runs identically at build (eidos render-css) and at runtime (white-label
theming). Because every decision is computed in JS before a value is
written, APCA introspection and compositing-inverse alpha are preserved
in every mode (COLOR_ENGINE_RFC §6.1).
- convert: OKLCH<->OKLab<->linear-sRGB<->gamma-sRGB<->hex (Ottosson),
chroma-reduction gamut mapping (no channel clip), parseColor, oklchToCss.
- apca: APCA-W3 0.1.9 Lc + WCAG2 ratio cross-check.
- generate: seed->12-step scale by template morph (re-hue, rescale chroma,
anchor solid to seed), pickOnSolid (APCA, prefer-onSolid policy per
THEMING §24.1), compositing-inverse alphaOverBackground.
Phase 0 only: module + 19 unit tests, NOT consumed yet — zero behavior
change. Full plan in src/uix/eidos/COLOR_ENGINE_RFC.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The video mode now paints a frame immediately: on `loadedmetadata` it
seeks to the initial scroll position instead of waiting for the first
scroll (the progress-0 loop guard used to leave some browsers black).
New `start` / `end` props (seconds, clamped to the real bounds) scrub
only a SEGMENT of a clip — progress 0->1 maps to `[start, end]`, `end`
defaults to the full duration. Time-based seek means frame rate is
irrelevant and duration stays browser-authoritative (read from
`loadedmetadata`, never passed by hand).
Demo: same-origin `/demos/video.mp4` sample (external URLs fail on
cross-origin range requests), live `start` / `end` / `smooth` controls,
API rows + snippet parity, README segment example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
libs/logger/test/diagnostics.test.ts no longer imports SILENT_LOGGER from the
consuming `$logger` art -- it uses a local no-op Logger fixture (the art exports
the identical shape). Removes one of the test-only layer inversions (SU2).
The prefs half of SU2 (libs/prefs/test importing dimension constructors from
$prefs) is left for a deliberate call: those constructors are pure (import only
$libs/prefs + $libs/locale), so the root fix is relocating them to $libs/prefs
with arts/prefs re-exporting -- an ~11-file move, disproportionate to force for a
BAJA, not-a-build-violation item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the motion runtime out of eidos into a runtime art (src/arts/motion,
$motion), exposed as uix.motion and consumed by BOTH soma (Presence.motion ->
motion.run) and eidos (delegates + registers presets) -- dissolving the
soma->eidos coupling. Remove DialogProps.runMotion / eidos.motionRunner; the
bridge is now EngineMotion.run (reads data-animation-style). Delete the 4
relocated dead files (lib/motion/{types,runtime,runner,presets/js}.ts); the
preset DATA (presets/css.ts) stays in eidos. Regenerate generated/base.css.
F6 - token rigor (Carbon): tokenize the raw firma durations (slower/deliberate/
emphatic/sustained holds, escalating by announce intent severity), add
--motion-distance-xl (30px shared-axis), --motion-scale-through (0.92), the
emphasized easing, and productive/expressive sets ([data-motion-set=expressive]).
F7 - extensibility + typegen: app-extensible, type-safe preset-name registry
(EidosMotionPresets, mirroring SemaChannelSignatures); MotionPresetName =
keyof EidosMotionPresets | none | (string & {}).
Also sweeps other in-progress working-tree edits (web/routes/temas/grafito).
Verify: npm run check -> 1 pre-existing error (grafito), 0 new; motion 22/22,
active-uix 25/25, Presence 2/2, Dialog 3/3. Pre-existing words-track failures
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalize.ts: `normalizeTableCells` now drops null/undefined slots BEFORE
mapping through normalizeBlock — a genuine undefined child made
normalizeBlock throw at `.id`. Completes the table-in-column crash fix
(76b0d1c8, 4a8f6331): all four corrupt-cell shapes ([undefined], mixed,
[], inline) self-heal to a paragraph with no throw, verified against the
browser-loaded engine.
Docs: continue.md gains the 2026-06-03 session hand-off (chrome work + the
3-layer crash fix + tomorrow's pending items); Words README gains a
"Bitacora de diseno" backlog of binding decisions + changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause of the column-insert crash + "can't edit any cell". Since P5m,
`TableCell.children` holds WordsBlock[], but the slash-menu "Table" factory still
seeded cells with bare `emptyText()` (inline `text` nodes). `normalizeBlock` has
no case for `'text'` and returns undefined, so `normalizeTableCells` stored
`[undefined]` — which then crashed `getActiveMarksForSelection`'s walk and left
the cell with no real block to edit.
- built-ins.ts: wrap each seeded cell in a paragraph block (matches the demo doc,
the callout factory, and `createTableCell`).
- normalize.ts: `normalizeTableCells` now drops children that fail to normalize
and re-seeds an empty paragraph when none remain — self-heals any document that
already got a malformed table from the old factory.
Pairs with the defensive guard in selection-walkers (76b0d1c8). Engine tests
430/430 pass; changed files type-check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`getActiveMarksForSelection` → `collectBlockText` switched on `block.type`
without a null check. When a table cell (inside a column) momentarily held an
undefined child slot, the walk threw `Cannot read properties of undefined
(reading 'type')`. The throw propagated out of `insertBlockInColumn` →
`applyHistoryCommand` uncaught, aborting the command and leaving the editor
unable to edit anything (and the column "add block" silently failing).
Add an `if (!block) return` guard at the top of `collectBlockText` so a partial
slot is skipped instead of crashing the whole command. Surfaced via the Chrome
console (TypeError in selection-walkers.ts ← insert-block-types.ts:309).
Engine tests 42/42 pass; selection-walkers type-checks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three rich-text-chrome fixes:
- The active rail tracked the clicked element, so a table cell gave a 39px
rail on a 118px table. `words-active-rail` now climbs to the TOP-LEVEL block
(direct child of content) and spans its full height.
- Delete-block was only in the (hidden) gutter grip menu. Added a Trash button
to the inspector title row — always visible for the active block.
- Deleting a block left the active dangling (it fell back to the first block).
Both delete paths (inspector + gutter menu) now capture the previous block's
id BEFORE the delete (reading the doc AFTER `deleteBlock` returns the wrong
index) and move the active to it — so there's always a sensible active block.
Verified in-browser: clicking a table cell, the rail spans the whole table
(118px); the inspector delete button is present; deleting a block moves the
active to the previous one (e.g. delete "Lists" → active "const greet"). Check
clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the active-block marker per feedback (it changed colour by depth,
shifted right for nested blocks, and showed nothing on load):
- New `words-active-rail.svelte`: a single vertical bar in the gutter whose
top/height track the CLICKED block, re-measured on activation / render /
scroll. It is FRAME-relative at a fixed gutter column (CSS
`--_words-content-px - 1.4rem`), so it never shifts right for nested blocks,
and uses ONE fixed colour (`--color-primary-solid`).
- Removed the block-anchored `[data-words-active]::before` rail (block-relative
→ shifted; depth-coloured → changed colour).
- `words-activate.svelte`: there is now ALWAYS an active block — on first load
it seeds the first top-level block, so the inspector + rail have a target
instead of "nothing selected".
Verified: on load the first block is active and the rail shows at a fixed 27px
gutter column, 3px, single colour, height = active block. Check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four feedback fixes:
- Grip "trembles": the handle's `:hover` did `scale(1.18)`, which grew the
button under the cursor and shifted its hitbox → a hover↔scale feedback
loop. Removed the hover transform; the grip is ambient (per EV-G doctrine).
- Active-block marker was a box/rail tied to the HOVERED block. Moved it to a
vertical accent rail in the GUTTER driven by `[data-words-active]::before`,
so it tracks the CLICKED block (the inspector's target), 0.7rem left of the
block, spanning its height, depth-coloured. Removed the gutter component's
hover overlay div.
- Preview showed empty columns' dashed border + min-height (editing
affordances). `[data-mode='preview']` now makes the column border
transparent and min-height 0 — empty columns vanish in the read-only view.
Verified in-browser: active rail sits 11px left of the clicked block (violet,
3px, full height); column border is transparent + min-height 0 in preview;
grip has no hover transform. Check clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes from feedback:
- The active/hovered-block marker belonged in the GUTTER, not as an overlay
on the block. `[data-words-block-outline]` is now a thin (3px) vertical
accent rail pinned to the gutter column (left margin, just left of where
the text begins) spanning the block's height — only top/height track the
block. Replaces the tinted fill that landed on the block content.
- Preview is read-only, but `WordsActivate` (click-to-select → the dotted
active outline) still ran, so blocks could be selected in preview. Add an
`enabled` prop (false in preview): no click-activation and the active stamp
is cleared. `<Words>` passes `enabled={mode === 'edit'}`.
Verified in-browser: the gutter rail sits 8px left of the block text (not
over it), spanning the block height; clicking a block in preview no longer
stamps `data-words-active`. Check clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three issues found while testing the menu bar:
- Preview "did nothing": clicking Preview does switch to read-only
(contenteditable=false), but a read-only surface looks identical to an
editable one, so the change was invisible. Now `[data-mode='preview']`
collapses the bar's formatting groups, leaving just the view controls
(inspector + Edit/Preview) — the bar visibly simplifies.
- Gutter active block: the dashed outline read poorly. Replace it with a
faint primary-tinted fill + a 3px accent bar on the left edge (toward the
grip), so the block the handle acts on is obvious.
- Bold / insert "don't work" with no caret: a selection-dependent command
fired from the menu bar (outside the editable) no-op'd when the editor
was never focused — `toggleMark` bailed on a null selection, and
`insertBlockOfType` silently fell back to the last block. Add a TipTap
`.focus()`-style guard `ensureCaret()` in the provider: when `selection`
is null it drops a collapsed caret at the document start, so marks arm
(stored mark → next typed text) and inserts land predictably after the
first block. Wired into `runCommandName` (marks / align) and
`insertBlockOfType`. The provider freezes the selection across blur, so
this only fires on a never-focused editor.
Verified in-browser: Preview hides the format groups (toggle stays); the
hovered block shows the tinted fill + accent; a fresh editor's Bold now
arms + places a caret. Words tests 477/477 pass; check clean for the
touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The editor had no persistent formatting bar — commands lived only in the
bubble (selection), slash (/) and drawer. Add a docked menu bar at the top
of `<Words>`, built from the existing (previously unused) soma
`Words.Toolbar` / `Words.CommandButton` primitives, the same way the bubble
composes them.
- words-menubar.svelte (new): role=toolbar with six groups — history
(undo/redo) · block style (Text/H1-3/Quote/Code via a DropdownMenu) ·
marks (B/I/U/S/code) · lists · align · insert (image/table/divider via
`api.insertBlockOfType`). Marks/align are `Words.CommandButton`s rendered
as eidos `Button`s (range restored before the command runs, active state
reflected). Right side: an Edit/Preview toggle + an inspector toggle.
- words.svelte: mounts the bar; owns `mode` ('edit'|'preview') mapped to the
provider's `readonly` (preview = contenteditable off, editing chrome —
bubble/gutter/drag — suppressed, bar stays visible). Unifies the inspector
open state (drawer open / sidebar visibility) behind one toggle the bar
drives; the floating drawer button is gone (folded into the bar).
- types.ts: `toolbar?: boolean` prop (default true).
- words.css: `[data-words-menubar]` fixed-height top bar (`--_words-bar-h`),
group dividers, active-control accent (`data-active` + `aria-pressed`), and
offsets the docked inspector panels below the bar.
- demo: drop the now-duplicate Edit/Preview toggle + serialized-HTML preview
swap (the bar owns preview as read-only); keep the inspector picker.
Verified in-browser: bar renders (role=toolbar, 44px, 6 groups, 18 controls);
Edit→Preview flips contenteditable to false, disables format buttons and hides
the chrome while the bar persists; Bold applies to a selection from the bar
(onpointerdown range-restore holds across the out-of-editor click); the
inspector toggle hides/shows the sidebar; align-left reads active for a
left-aligned block; no console errors; check clean for the touched files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gutter grip rendered (cursor: grab) but the drag-to-move
functionality was lost when the soma-DragDrop migration was abandoned —
clicking it only opened the action menu (Move up / Move down). Restore
the reorder as native HTML5 drag-drop:
- words-block-drag-type.ts: private MIME `application/x-words-block` the
grip writes (its top-level index) and the controller reads, so block
reorders are told apart from native text drags.
- words-block-gutter.svelte: the grip's DropdownMenu.Trigger is now
`draggable`; `ondragstart` stamps the block index on the transfer. The
menu still opens on click (the dropdown opens on click, not
pointerdown — no collision with the drag gesture).
- words-block-drag.svelte: the drop sink. Listens on the contenteditable
for dragover/drop, paints a horizontal indicator at the nearest
inter-block seam, and repositions via the engine's `moveBlockTo`
(target is post-removal: `to = from < n ? n - 1 : n`). Bails unless the
drag carries our private type, leaving native text drag untouched.
A document-capture `dragend` clears the indicator wherever the drag ends.
- words.svelte: mount the controller next to the gutter.
- words.css: `[data-words-block-drop-line]` accent indicator.
Reorder is top-level only (the gutter is top-level only), mirroring the
gutter's `[data-words-node='block']` block detection exactly.
Verified in-browser: grip is draggable=true; dragstart writes index;
dragover paints the indicator + preventDefaults; drop reorders correctly
downward (0→end) and upward (14→1) with no block lost; native text drags
are not hijacked; no console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two loose ends from the rich-paste work:
- The block drag-handle menu was hard-coded English (Inspect / Move up /
Duplicate / Insert below / Paste below / Delete) inside a Spanish editor.
Route every label through the langs system like the inspector already
does: new langs-menu.ts (idlangref catalog + en/es bundle), registered by
the eidos <Words> wrapper (`components.words.menu`); the gutter reads them
via ActiveEidos.require().langs.ts(). Verified the menu renders in Spanish
(Inspeccionar / Subir / Bajar / Duplicar / Insertar debajo / Pegar debajo
/ Eliminar) under the ES locale.
- README serialization doctrine: the "Si algun dia hace falta pegar HTML
externo" section ANTICIPATED exactly this feature (per-block parseHtml,
clipboard best-effort, persistence stays JSON). Mark it IMPLEMENTED
(2026-06-02) with the actual modules, fix the table's HTML Import cell,
and correct the now-stale "paste HTML is rejected" line — HTML now parses
to blocks; only content-free HTML logs unsupported-html. importContent
unchanged (JSON + plain text); HTML enters only via paste.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A discoverable, click-driven paste — the answer to "where do I paste?".
The block drag-handle menu gains a "Paste below" item that reads the
clipboard and inserts its content as new blocks right after that block,
no Ctrl+V needed.
- provider.pasteAfterBlock(index): reads the async Clipboard API
(navigator.clipboard.read, falling back to readText), parses text/html
into rich blocks via the inert-document parser (else splits plain text
into paragraphs), and inserts them at top-level index+1. No-op when the
clipboard is empty / unreadable / permission-denied. Exposed on the
provider snippet api.
- insertBlocks gains an optional `at` index: insert as siblings at an
explicit top-level position (ignoring the caret) for the menu paste.
Command carries `at`.
- eidos block gutter: "Paste below" DropdownMenu item -> api.pasteAfterBlock.
Tests: insert-blocks 7/7 (added explicit-index case). Reuses the
browser-verified parseWordsHtml + insertBlocks core.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paste previously flattened everything to plain text (the signed import
doctrine deferred rich paste). Now the editor parses clipboard text/html
into the V2 model, the inverse of serialize-html.ts:
- parse-html.ts: parseWordsHtml(domRoot) -> WordsBlock[]. Maps p / h1-6 /
blockquote / pre / ul-ol-li (incl. checkboxes) / hr / img / figure /
table, with inline marks (bold/italic/underline/strike/code, colour +
background from style) and links. URLs sanitised; unknown blocks fall
back to a paragraph, unknown inline tags are transparent.
- insert-blocks.ts: insertBlocks(state, blocks) drops blocks at the caret.
Single pasted paragraph inline-merges (a phrase stays in the sentence);
multiple/block-level paste splits the host; a nested or non-inline caret
degrades to a structure-preserving plain-text insert; a trailing
paragraph guarantees a caret home after a terminal table/image. New
insertBlocks command + dispatch.
- provider onpaste: parse text/html in an INERT document
(createHTMLDocument off the active dom — no scripts run, no resources
load), then applyCommand insertBlocks. Image-files / single-URL /
plain-text paths unchanged; plain text is still the fallback.
Tests: insert-blocks 6/6 (merge/split/replace/append/no-op), parse-html
10/10 browser (marks, lists, tables, colour, javascript: URL sanitised).
Browser-verified: pasting an h2 + a bold/italic paragraph + a list inserts
the heading, the formatted paragraph and the list at the caret. Updated
the provider's unsupported-paste test (real HTML now parses; only
content-free fragments log unsupported-html).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalizePastedPlainText strips NUL chars from pasted text, but the regex
held a LITERAL 0x00 byte (/<NUL>/g) instead of the escape /\0/g. Runtime
behaviour was correct, but the raw NUL made ripgrep treat the whole file
as BINARY — so input.ts silently dropped out of every codebase content
search. Swap the byte for the \0 escape: identical at runtime (verified
input.test.ts 18/18), clean ASCII source, searchable again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two inspector-input issues:
- The image alt field (eidos <TextArea>) used the GLOBAL neutral skin
(transparent fill, lighter border) while the URL/caption inputs use the
words palette — so the textarea looked unframed and out of place.
Retokenise [data-textarea] in the inspector to --words-border / --words-bg
/ radius-2 / words font-size; now it reads identically to the text inputs
(verified both render border rgb(232,232,232) + fill rgb(249,249,249)).
- Every field drew an accent border AND a box-shadow ring on focus — two
stacked frames. Drop the border-accent change; the single accent ring is
the focus signal, the border stays structural. Applied to text inputs,
NumberField and TextArea so all inspector fields focus identically.
Scoped to the words inspector (not the global eidos input recipe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The radius control was a 0-48 slider plus a none/full-only toggle — the
preset toggle 'didn't work' for anything between, and a bare slider value
is invisible until the block also has a border or background. Replace both
with one attached ToggleGroup of named steps: None / S / M / L / Full →
0 / 4 / 8 / 16 / 9999 px (render.ts already emits border-radius from
border.radius). Verified the control renders [Ninguno, S, M, L, Completo].
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating selection menu now carries text + background colour, applied
to the selection like bold/italic. A 'Color' (A) dropdown opens Text +
Background swatch grids; each swatch is a Words.CommandButton whose
color:#hex / bgcolor:#hex command the provider already parses into a
parametric toggleMark on the live selection — reusing the same
range-restoring path the mark buttons use, no bespoke selection wiring.
The 'Default' chip clears the mark (empty value).
- words-bubble.svelte: colour DropdownMenu (Baseline / Highlighter icons),
9-hue palette + clear, marked data-words-bubble-color.
- words-provider: add [data-words-bubble-color] to the bubble's
keep-selection-alive closed surface so interacting with the dropdown
doesn't collapse the model selection.
- words.css: swatch-grid styling (hue from inline --words-swatch; clear
chip is a diagonal strike).
Verified: selecting a word → A → blue swatch recolours it (rendered
rgb(0,145,255), model carries {type:'color',value:'#0091ff'}); bubble +
selection stay alive so multiple swatches can be applied in a row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three demo/chrome fixes:
- Drawer inspector: the open toggle (SlidersHorizontal) stayed mounted
while the panel was open, colliding with the panel's own X close in the
same corner. Render it only while closed; the panel's header X closes it.
- Preview columns: serialize as <div data-words-columns><div data-words-column>,
which no preview rule matched, so they stacked. Mirror the editor's row flex.
- Preview headings: were --font-size-2xl/xl/lg at weight 700 — louder than
the editor. Reference the SAME recipe tokens (xl, lg+4, lg; semibold;
heading line-height + primary color) so Preview reads like Edit. Verified
editor + preview both render h1=28px/600, h2=24px/600.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inserting an image into a column (slash menu or inspector "Add block")
left an empty trailing paragraph beside it. An image is a placeholder
configured through the inspector (URL panel), not a block you type after,
so the trailing paragraph was pure noise — the reported bug.
insertIntoColumn / insertIntoCell / insertBlockInColumn now skip the
trailing paragraph for images and return a null (caret-free) selection:
the atomic image has no inline text to host a caret, and the provider
auto-activates it for the URL panel. A divider keeps its trailing
paragraph (it separates typed content); table/callout keep theirs too
(they carry inline text, so the caret stays in the block).
5 new engine tests pin the behavior across both insert paths + the cell
case; divider regression guarded. Browser-verified: image into a column
yields [image] only, no empty text block.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P5m made the model accept blocks in cells, but the atomic-insert path
(`insertAtomicBlock`) only special-cased columns — with the caret in a cell it
fell through to the top-level split, dropping the block NEXT TO the table
instead of inside the cell. So "blocks in cells" was model-only, not reachable.
Adds an `insertIntoCell` branch mirroring `insertIntoColumn`, one level deeper
(container path [tableIdx, rowIdx, cellIdx, blockInCell, ...]): empty-cell
paragraph → replace in place (+ trailing paragraph); non-empty → split the
cell's block around the inserted one. So insert-callout / divider / image (and
any atomic insert) with the caret in a cell now land inside that cell.
New test `insert-block-types.test.ts` (3/3): inserts into the cell (single
top-level table preserved), callout lands inside, non-empty cell splits
(para("a") · divider · para("b")). npm run check: 0 new errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the long-open "export HTML roundtrip?" question as export-only — the
architecturally-correct, industry-aligned choice (TipTap/ProseMirror, Lexical,
Slate, Notion all keep JSON as the canonical lossless persistence format and
treat HTML/MD as projections).
- README "Serializacion e intercambio" doctrine: JSON = truth; HTML/MD =
export-only projections; import = JSON + plain text. Explains the
state-complete-HTML trap that JSON avoids, why markdown is the most lossy
(GFM cells are inline-only — block cells from P5m can't roundtrip), and the
conditional `parseHtml` per-block path (parseDOM/importDOM pattern) IF
paste-from-external is ever needed — as clipboard interop, never persistence.
- continue.md: the "NO firmada" note flipped to FIRMADA with the rationale.
Docs only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POLISH-3. Replaces the bare Inspector-mode row in the Words demo with a real
topbar: a document breadcrumb (FileText icon + the doc's first-heading title),
a "Saved / Editing…" indicator that flips on document change and settles after
a short idle, and the Inspector-mode + Edit/Preview toggles.
Preview mode renders the document as a clean, read-only reading view via the
export-only `serializeHtml` serializer (bare semantic HTML with its own
typography — headings, blockquote, code, lists, table, links). Showcases the
export path now that table cells serialize block content (<td><p>…</p></td>).
Also exposes the export-only serializers on the public soma barrel
(`serializeHtml`, `serializeMarkdown`, `renderWordsPlainText`) — apps need them
for preview / copy / download.
Browser-verified: topbar renders; Edit↔Preview swaps the editor for the
read-only article (table + headings render); Saved badge shows. npm run check:
0 new errors (1 pre-existing grafito baseline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P5m. A table cell now holds full BLOCKS (paragraphs, lists, images, nested
tables) like Callout / Column, not just inline text. A fresh cell seeds one
empty paragraph; the existing generic block machinery (path walker, render,
validate, normalize) handles cells the same way it handles callout / column.
Atomic model change across the engine:
- types: TableCell.children: WordsInline[] -> WordsBlock[].
- factories / table-ops / extra-ops: cells seed [paragraph], not [text].
- normalize: normalizeTableCells recurses each child block (mirrors callout);
empty cell seeds a paragraph.
- built-ins table spec: render -> renderBlock per child; validate ->
validateBlock per child; HTML -> blockToHtml per child (<td><p>...</p></td>);
Markdown -> flatten blocks to inline joined with <br> (GFM cells are
inline-only; lossy, consistent with markdown being export-only).
- serialize-text + selection-walkers: cell text recurses block children.
- delete-ops: inline cross-cell merge (mergeTablePrev/Next) replaced by
recurseIntoTableMerge — treats the cell's blocks as a sub-document, so
backspace merges blocks WITHIN a cell; at a cell's first block it is a no-op
(cells never merge across the fixed grid — matches mainstream editors).
Caret paths into a cell are one level deeper now ([t,r,c,blockIdx,...]). All
table test fixtures wrapped (inline -> paragraph) and 13 assertions updated to
the new behavior (<p> wrappers, deeper paths, no cross-cell merge).
Browser-verified: cells render block content (path 14.0.0.0), table renders
clean. npm run check: 0 words errors (1 pre-existing grafito baseline). Words
tests: 452/452.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1. Image was missing from the per-column "Add block" menu — the inspector's
`columnInsertableSpecs` filter excluded `image` outright. Removed the
exclusion: the image spec's `create()` builds an empty-src placeholder and
`insertIntoActiveColumn` auto-activates it, so the inspector's image panel
immediately prompts for the URL / Upload — the canonical placeholder-on-
insert flow (same one the gutter inserter and slash menu use). Verified:
menu now lists "Imagen"; selecting it adds a placeholder image to the column
and switches the inspector to the image panel (URL + Upload + alt + caption).
2. Drawer inspector mode "only shifted" instead of hiding/sliding. The panel is
parked off the right edge via translateX(100%) when closed, but the frame's
overflow was `visible`, so the closed panel just sat outside the right border
(visible) and toggling merely slid it sideways. Added `overflow-x: clip` to
`[data-words][data-inspector='drawer']` so the closed panel is genuinely
hidden and the open one emerges from the edge. X-axis only: the block-gutter
grip (left margin, inside the frame) and the vertically-overflowing bubble
toolbar stay visible. Verified: closed panel clipped/hidden; open panel slides
in inside the frame's right edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bleed-through: the portaled [data-dropdown-menu-content] had z-index:auto, so
any positioned page element with a positive z-index (e.g. a selected
[data-toggle-group-item], z-index 1) painted THROUGH it. In the Words inspector
the Add-block menu showed the Typography toggle chips ("Predeterminada" /
"Normal") bleeding through. Added `content-z: 80` to the dropdown-menu recipe
(matches `select`, its sibling click-triggered overlay) + `z-index:
var(--dropdown-menu-content-z, 80)` on content + sub-content; regenerated
base.css. The soma floating layer reads this computed z-index and mirrors it
onto the positioner wrapper. The rest of the menu family (context-menu /
menubar / navigation-menu) shares the same latent gap — deferred.
Canonization (finishes the dom.raf rollout — zero raw requestAnimationFrame
left in src/uix):
- tabs-indicator.svelte: requestAnimationFrame/cancelAnimationFrame +
new MutationObserver + new ResizeObserver -> eidos.dom.raf /
observeMutation / observeResize (disposer pattern via ActiveEidos.require).
- tabs.svelte: new ResizeObserver -> eidos.dom.observeResize.
- resize-observer.svelte.ts: requestFrame/cancelFrame (raw handle) ->
dom.raf (disposer); fixed the stale "Debounces via requestAnimationFrame"
comment.
Browser-verified in the Words demo: column Add-block menu is opaque and on top
(elementFromPoint at the chip centers returns menu items, inMenu:true); the
menu options are collapsed + localized (one Encabezado, one Lista con viñetas);
empty-column delete works (column persists addressable with 0 children).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in the column inspector's "Add block" flow:
1. Wrong options + untranslated. The menu rendered
`defaultWordsSchema.insertable()` — the flattened menu ENTRIES, which
expand heading → "Heading 1/2/3" and list → "Bulleted/Numbered/Check"
with hard-coded English labels. Now it lists one entry per block KIND
from `schema.all()` (paragraph / heading / quote / code / list / table /
divider / callout), labelled via `blockKindLabel(type)` → fully
localized ("Texto", "Encabezado", "Lista con viñetas", …). The exact
level / list-kind is tuned afterward in that block's own panel. Inserts
the kind's default via `spec.menu[0].create()`. Excludes `columns`
(no columns-in-columns) and `image` (insertable:false — needs a URL).
2. Couldn't leave a column empty. `deleteChildInColumn` re-seeded an empty
paragraph when the last block was removed ("must stay typable"). But an
empty column is a valid, supported state — the engine renders the
wrapper with `data-words-id` so it stays clickable, and neither the
validator nor `normalizeColumns` re-seeds it (only the doc root + lists
do). Removed the re-seed; deleting the last block now leaves the column
genuinely empty.
Also added the 5 missing column-panel aria bundle entries
(select/delete/move-up/move-down/add-block-to-column-n) so the block-list
buttons read in ES instead of the English fallback.
Verified in the browser: menu shows 8 localized kinds (1 heading, 1 list,
no "Heading 1"); deleting the last column block → child count 0, no reseed,
column still clickable; aria-labels localized. npm run check 1 pre-existing
error (grafito, not Words) · words soma 452/452 · prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `raf(callback, node?): ActiveDomFrameCleanup` to the ActiveDom surface —
a one-shot animation frame that returns an idempotent **disposer** (the same
`() => void` shape as `listen` / `observe*`), so an `$effect` can
`return dom.raf(...)` and Svelte cancels the pending frame on teardown. It
wraps the existing `requestFrame` / `cancelFrame` (which already resolve the
instance's `targetWindow` — iframe / popup / happy-dom safe), adding no new
scheduling logic. New `ActiveDomFrameCleanup` type exported from the barrel;
`raf` also implemented on the disabled-dom stub (throws, like `requestFrame`).
Why: the doctrine is "all DOM activity via ActiveDom". `listen`/`observe*`
already returned disposers; the animation frame was the gap — `requestFrame`
exposes a raw numeric handle (per-call bookkeeping + leak risk), and layout
components were falling back to the GLOBAL `requestAnimationFrame`, which
targets the wrong window in iframe/popup contexts (the exact bug getWindow/
getDocument fix elsewhere). `raf` closes it.
Migrated the 3 raw `requestAnimationFrame` sites the Words audit surfaced —
words-block-gutter (reposition), words-bubble-menu + words-slash-menu
(overlay position) — to `dom.raf(...)`. Bubble/slash keep their `tick()`
fallback for no-rAF environments.
Documented the decision + rationale as a dated Backlog entry at the end of
`src/arts/adom/README.md` (and listed `raf` in the API + imperative-actions
sections). Notes the kept distinction: `raf` is for layout frames, NOT the
`$timer` lifecycle scheduler; low-level requestFrame/cancelFrame stays for
consumers that already hold the handle (drawer/slider/splitter/floating/
focus-scope). Remaining raw rAF in other eidos components (tabs-indicator…)
left for when those are touched — flagged in the backlog.
Gates: npm run check 1 error (pre-existing grafito, not adom/Words) · soma
words + adom 479/479 · prettier clean · browser smoke: gutter repositions,
bubble menu positions, no console errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- D1 (soma) block-tree.ts:104 — replace `doc as never` (which disabled all
type checking on the descend container) with `doc as unknown as {...}`, the
honest cast that still asserts the permissive descend-node shape
childStacks() reads.
- D2 (eidos) — extract the duplicated ~25-line "file picker → FileReader →
data URL" flow (copy-pasted in words-block-gutter.svelte and
words-block-panel.svelte) into a shared `pickImageFile(doc, onPick)` helper
in words-image-file.ts. Both consumers now call it; zero inline FileReader
blocks remain. Kept as a plain helper (not ActiveDom) — a transient
<input type=file> + FileReader is not instrumented by active-dom, so a
direct Document touch is correct; the caller resolves the right Document.
Gates: npm run check 1 error (pre-existing grafito, not Words) · words soma
452/452 · prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full audit of the Words component (3 parallel explore agents + deterministic
gates) surfaced 4 carried-over defects, all verified and fixed:
- R2 (soma) engine/operations/visual.ts:147 — the `node as Record` cast in
updateBlockAtPath failed because WordsNode includes WordsDocument (no index
signature). Use the double cast `as unknown as Record`, consistent with the
sibling op at line 118. Clears the only Words type error in `npm run check`.
- R1 (morfo) components/words.ts — the `data-words-node` enum was conflated
with `data-words-block`: it listed block TYPES (paragraph/heading/quote/
code/image) that nothing emits on that attr, and OMITTED the real
structural kinds the engine writes (`block`/`inline`). Aligned to the
engine's WordsDomNodeKind. eidos-lint invalid selectors 2 → 0; verified in
the browser that top-level blocks carry node='block' and the active-block
outline still paints.
- R3 (eidos) langs-inspector.ts — `LABEL_COLUMN_BLOCKS` had no bundle entry,
so the column "Blocks" panel title fell back to English in ES. Added
label.column-blocks + label.add-block. Verified: panel now reads "Bloques".
- D3 (morfo) components/words.ts — declared `expression: 'family-default'`
to make the no-sema-pack choice explicit; `morfo:vocabulary` no longer
warns about the missing pack/expression.
Gates: npm run check 1 error (pre-existing, web/routes/temas/grafito — not
Words) · words soma+morfo 514/514 · eidos-lint words invalid 0 · prettier
clean · morfo:vocabulary exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile the theming reference with the engine as already committed
(color-model redesign, scaling axis, P2 fixes) so the doc is internally
consistent:
- TOC: add §23 (scaling), §24 (P2 corrections), §25 (color model); fix
the §21 entry to its resolved heading.
- §4: intent->scale mapping was stale (risk->amber not orange,
loss->plum not purple); note intents auto-derive via
CANONICAL_INTENT_SCALES (identity = step 9, cross-ref §25); correct
token counts (31-scale palette = 744 --scale-* tokens; 9 roles = 216
primitives).
- §3 / anti-pattern G: "30 escalas" -> 31; reconcile "never add a scale"
with §25.7 (a brand theme brings its own palette).
- §21 / §22: mark the two-level color RFC as resolved in §25 (anchor
model discarded); drop the stale present-tense "vigente" claim.
- README reference table: add §23 / §24 / §25 rows.
Docs only -- no engine or demo changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename theme untitled-ui -> Grafito: route /temas/grafito, theme ids
grafito-light/dark, grafitoConfig, brand/hero/footer/index.
- Palette: Grafito now carries the full 32-scale library (spreads the
framework's 31 Radix scales + brand carbon, overriding the brand-tuned
gray/violet/blue + intents). Each directly usable as --scale-{name}-{step}.
- Roles: hierarchy explicit (primary->carbon, secondary->violet,
tertiary->blue); the 6 intents auto-derive from the palette by convention
(step 9). THEME_SCALES derived from palette keys, labelled by COLOR NAME.
- Demo Color section -> 3 groups (Paleta / Roles jerarquia / Intents
auto-derivados) + scaling control in the topbar.
Verified in browser: 32 distinct scales emitted, intents auto-derive
(affirm=teal, risk=amber, loss=plum), no validation error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two audit P2 quality defects, fixed at engine level.
P2-2 - on-solid text illegible on light solids:
The `contrast` slot defaulted to `--color-content-on-solid` (white) for every
role. On light solids (amber/yellow, risk=orange ~2.3:1) white is sub-AA. The
engine now picks by WCAG contrast (gamma-linearized) of the role step-9: when
white fails (<3:1) it uses `--color-content-on-solid-contrast` (a dark, new
OPTIONAL `content.onSolidContrast` semantic, #1c1917 in base). Only risk flips
to dark in base (5.89:1); purple/red/teal/green keep white (convention, >=3:1).
An explicit `slots.contrast` override is still honored verbatim.
P2-4 - opaque tinted soft surfaces:
The soft variant tint (Button + Badge `{role}-soft-bg`) was opaque (step-1 track
+ opaque color-mix hover) so it did not composite over non-uniform backgrounds.
New derived tokens `--color-{role}-surface` (= a2) + `--color-{role}-surface-hover`
(= a3) are translucent by construction (compositing-inverse alpha). Button/Badge
soft consume them. Toast/Tabs untouched - they are cards, opacity is correct.
- config-types: optional onSolidContrast + CONTENT_COLOR_OPTIONAL_KEYS.
- config: content keySet allows the optional key; validator value-checks it.
- render-css: wcagContrastRatio/wcagRelativeLuminance; luminance pick; emit
on-solid-contrast + surface/surface-hover.
- contract: on-solid-contrast + surface tokens per role.
- themes/base: onSolidContrast #1c1917 (light + dark).
- recipes/base: Button + Badge soft-bg -> surface, soft-bg-hover -> surface-hover.
- docs: THEMING.md section 24 + section 22 item 2; audit P2-2/P2-4 resolved.
Verified: npm run check (0 new errors), vitest eidos (0 new regressions),
browser runtime (risk 5.89:1 dark text, surfaces translucent rgba).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the synthetic step-9-at-opacity alpha ramp with a real compositing inverse: each --scale-X-aN is the translucent color that, painted over the scale's background (white for light scales, black for dark, by step-1 luminance), reproduces the opaque solid step N (Radix model). Computed in render-css.ts for ANY theme (base + custom), so alpha is consistent-by-construction with the solid scale and cannot drift. alphaScales opt-in still wins verbatim; non-hex solids fall back to the legacy color-mix ramp. Verified: --scale-purple-a9 = rgb(92 0 173 / 0.6941) over white = #8e4ec6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alpha steps (--scale-*-aN) are unconsumed today (only a test + README ref). The step-9-at-opacity synthesis is a valid 'accent-at-opacity' ramp default, not a bug; it is not a Radix-style reproduction of the solid scale. Documented the model, its limitation, and the already-wired alphaScales opt-in in appendColorAlphaScaleDeclarations. A build-time compositing-inverse generator is deferred until alpha is actually consumed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The runtime token-override API is setCssVariables() / clearCssVariables(); setOverrides() never existed. A dev copying §19 would hit a runtime error. (audit P2-5/A1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add 40 recipes.banner.{intent}-{slot} forwarders (= var(--color-{intent}-{slot}))
- banner.css reads --banner-{intent}-{slot} instead of foundation --color-{intent}-* directly
- Banner is now retintable per-component via recipes.banner.* like button/badge; keeps data-intent (no API change)
- Regenerated base.css; recipe contract balanced (40 declared = 40 consumed), no visual regression
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Active-block addressing by materialized-path id (words-activate.svelte +
engine/block-tree.ts): click sets the active block explicitly; inspector
follows it, immune to selection churn. Breadcrumb navigates ancestry.
Inspector controls:
- ToggleGroups (font / weight / align / border) always keep one option
selected; the default value stores as undefined to keep the model clean.
- Border gains a `none` chip that clears the border (BorderChipValue).
- Image full-width is a Switch; image src + column width/gap validate on
commit (sanitizeWordsUrl, CSS-length/flex checks) with a [data-invalid] ring.
- Corner-radius + border-width are slider-only (WordsNumRow `sliderOnly`):
the paired numeric stepper was redundant.
- Numeric fields clamp to their range (line-height ≤ 2.5, font-size 12-40).
Inspector chrome:
- Unified field frame + single accent focus ring (no inset outline over text,
no ghost/black-border mix).
- Section dividers + uppercase accordion headers; styled breadcrumb row.
- Depth-tied accent (primary/affirm/loss/risk) shared by header mark and the
canvas active-block ring.
- Selection ring is an ::after pseudo-element with a fixed radius, so a block
the user rounded no longer rounds its selection indicator.
- Raw hex/rgba replaced by --color-*-solid tokens (theme-aware, portal-safe).
Columns: empty on insert, gap control, per-column width; Add-block removed
from the Columns BLOCK panel (lives in the column inspector). Shift+Enter
soft break renders via white-space: pre-wrap.
npm run check: 1 pre-existing baseline error (visual.ts:147). Words soma
452/452. Verified end-to-end in the browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The provider had a "smart" rule in syncSelectionFromDom that kept the
atomic-block highlight (and therefore the Inspector's panel) sticky
while the caret moved to a different block AS LONG AS that next
block was inside the same TOP-LEVEL wrapper (the rationale: editing
the trailing paragraph after an image-in-column shouldn't kick the
user out of the image inspector).
In practice users got the opposite of what they expected: they clicked
on something new, the inspector didn't follow. They thought the
editor was broken.
Drop the smart rule. Every real selection change now also clears the
atomic-block highlight — the inspector follows the caret, period. If
the user wants the atomic's panel back they click the figure again
(which `handleAtomicClick` re-selects).
We still skip the clear when `sameWordsSelection` reports no-op so
the synthetic selectionchange echo that fires right after
`selectAtomicBlock` (which doesn't move the DOM caret) can't wipe
the highlight a tick after being set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The floating in-canvas `+` overlay for column inserts was fundamentally
at odds with contenteditable semantics — it needed pointer-events:none
to let caret/text events pass through, but that ALSO disabled CSS
:hover detection, and any visible variant covered the column's auto-
trailing paragraph (the escape hatch the engine adds after atomic
inserts), hijacking clicks intended for typing.
Adopt React Bricks's pattern: container-block child insertion goes
through the sidebar/inspector, not via floating overlays on the canvas.
- New "Añadir bloque" dropdown per column in the Columns inspector
panel (`words-block-panel.svelte`). Lists every insertable block
type the engine knows (paragraph, headings, lists, image, divider,
callout, etc.). Picking one dispatches `insertBlockInColumn`,
appending it to the chosen column.
- Inspector lives outside the contenteditable, so the dropdown
doesn't fight focus traps, doesn't cover content, doesn't need
pointer-events gymnastics. Zero of the bug classes we hit.
- Deleted `words-column-inserter.svelte` and its mount in
`words.svelte`. The `insertBlockInColumn` engine op stays — it's
the right primitive, just driven from a different surface now.
- Added `ARIA_ADD_BLOCK_TO_COLUMN_N` and `LABEL_ADD_BLOCK` to the
inspector langs catalog.
Top-level block insertion (between rows) keeps using the canonical
gutter handle `⋮⋮ → Insert below` and the slash menu (for in-flow
keyboard users).
Inside-column editing keeps working as before: click on the block,
type. Enter at end of a paragraph creates a new paragraph below
within the column.
Breaking: anyone who imported `words-column-inserter.svelte`
directly is broken. Nobody outside this folder did.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wrap was rect-matched to the whole column rect, which meant the
`+` trigger (positioned at the bottom of the wrap, pointer-events:auto
on hover) overlapped the column's TRAILING paragraph — the empty
escape-hatch the engine auto-adds after atomic inserts (image,
divider). Clicking on that paragraph to start typing hit the `+`
trigger and opened the dropdown instead.
Fix: for non-empty columns, position the wrap as a thin 28px strip at
the column's bottom (where the `+` lives) instead of covering the
whole column. The trailing paragraph above the strip is now clickable.
Empty columns keep the full-column wrap (so the centered `+`
invitation remains).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`pointer-events: none` on the inserter wrap (required so caret/text
events pass through to the contenteditable below) ALSO disabled CSS
`:hover` detection — the browser doesn't dispatch `mouseover` to
pointer-events:none elements, so the wrap's `:hover` styles never
fired and the `+` trigger stayed at opacity 0 forever.
Fix: track hover via JS — listen to `mouseenter`/`mouseleave` on the
column elements directly, stash the key in `hoveredColumnKey`, expose
to CSS via `data-hovered` on the wrap. CSS now uses
`[data-words-column-inserter][data-hovered]` instead of `:hover`.
Effect: hovering a non-empty column now reveals the `+` at the bottom
of the column, allowing users to add more blocks below the existing
content. Same fix applied to the empty-column reveal-on-hover (the
opacity-1 boost when fully hovered).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three composing bugs were blocking the `+`→pick-block→type flow:
1. **Path 5-segmentos en `insertBlockInColumn`** — the op passed the
path TO the text node (`[colsIdx, colIdx, innerIdx, 0]`) to
`pointFromInlineTextOffset`, which expects the path to the
CONTAINER block. The helper then appended its own inline-index,
producing a stale 5-segment path that no `[data-words-path]`
selector matches. Fix: pass `[colsIdx, colIdx, innerIdx]` for
heading/paragraph/quote/code/callout, `[colsIdx, colIdx, innerIdx, 0]`
for list (the first list-item IS the container).
2. **Selectionchange echo overwriting the model** — `api.focus()`
landed the caret at doc-start (browser default for focusing a
contenteditable without a prior selection), fired selectionchange,
`syncSelectionFromDom` overwrote the model's intended post-insert
selection with `0.0` before `restoreDomSelection` could run. Fix:
capture `intended = api.selection` immediately after the op, then
re-apply via `setTimeout(0)` AFTER the echo handler has settled.
3. **DropdownMenu focus trap intercepted `api.focus()`** — the
FocusScope's capture-phase `focusin` bouncer kept yanking focus
back inside the menu. Fix: `trapFocus={false}` on Content.
Also expose `api.restoreCaret()` (provider line 1274) for any consumer
that needs to force-write the model selection to DOM after awaiting
enough ticks for new blocks to mount.
Verified end-to-end in Chrome: empty column → click `+` → pick
Heading → "Title" stub selected → typing immediately replaces with
user's text. Inspector switches to Heading panel. Doc title and rest
of the doc untouched.
CONTINUE.md P0 — RESUELTO.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three architectural pieces for inserting blocks into a column slot from
an overlay button, plus the missing DOM-selection sync that any
imperative consumer of `applyCommand` needs.
- Engine op `insertBlockInColumn` returns `{document, selection,
activeMarks}` in one transaction (Tiptap-style). Atomic blocks land
with a trailing paragraph escape hatch + caret there; text-bearing
blocks select any stub text ("Title", "List item") so the next
keystroke replaces it Notion-style.
- Provider `applyCommandWithOptions` now schedules `restoreDomSelection`
via tick when the command changes the model selection (typing-batch
excluded — the browser already placed the caret). Was the hidden gap:
overlay buttons, drag-drop, slash menu, the new column inserter, all
updated the model but the DOM caret stayed wherever the user last
clicked, breaking subsequent text editing.
- `words-column-inserter.svelte` rebuilt around a busy guard with a
hard 250ms safety timeout (the previous pendingInsert + onCloseAuto
Focus pattern could leave the `+` button dead forever if the
dropdown's teardown swallowed the close callback).
Plus type sync: `WordsProviderSnippetProps` now declares
`selectedBlockPath`, the second arg of `selectAtomicBlock`, and
`setSelection` — they were exposed by the runtime but missing from
the type, breaking typecheck on eidos consumers.
Demo carries a `columns` block in the initial doc as a permanent test
fixture for column-related fixes.
**Known issue documented in CONTINUE.md P0:** typing inside a `columns`
block does NOT insert — selection sync (`syncSelectionFromDom`) isn't
mapping nested paths (`12.0.0.0`) to the model correctly. The inserter
flow is wired correctly; once the path encoding for nested selections
lands, the full Notion-style "click + → pick Heading → type" flow
works end to end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the eidos `<WordsColorRow>` (Text / Background pickers inside the
block inspector) on top of the new base-Block words engine + drops 3
obsolete audit MDs.
ColorPicker fixes surfaced while wiring it into the inspector — all
documented in `soma/components/color-picker/README.md` §Integration
pitfalls:
1. Eidos wrapper now declares `format = $bindable('hex')` (matches
soma's default). Without it, `bind:format={undefined}` threw
`props_invalid_value` on every mount → render loop.
2. Eidos wrapper now forwards `ref` to `ColorPickerProvider.create`.
Without it, `attachRef` was never built and any `runtime.trigger`
targeting `provider` threw `SomaRuntimeTargetError`.
3. `triggerClose` falls back to the picker provider's own DOM when
`runtime.partRef('content')` returns null (content is registered on
the Popover's runtime, not the picker's — re-exported part).
4. WordsColorRow draft-pattern: `$effect` reads draft inside `untrack`
so it doesn't overwrite mid-drag value, and `onValueChange` catches
the `Clear` programmatic transition (`onValueChangeEnd` doesn't fire
on Clear).
CSS: `contain: layout style` on `[data-color-picker-area]` and
`scrollbar-gutter: stable` on the picker popover content — stops the
area from shaking during drag when the trigger's ValueText hex changes
width or the popover scrollbar toggles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the user-directed rework. The engine is rebuilt around a single
base `Block` carrying the common properties (id, align, margin,
padding, background, text color, border), with each specialized block
(paragraph/heading/quote/code/list/table/image/divider/callout — plus
list-item/table-row/table-cell) EXTENDING it. The old `visual` sidecar
+ per-type `Pick<>` whitelist + loose `textAlign` are gone; common
props are first-class and universal.
Naming: dropped the `v2` noise everywhere — `operations-v2/`→
`operations/`, `types-v2`/`render-v2`/`validate-v2`/`serialize-*-v2`→
clean names, `*V2` symbols→bare, `WordsEvalIntent`→`WordsIntent`.
Sema: removed entirely from the editor — deleted `sema/components/
words.ts` + `engine/sema-parity`, dropped the registration from the
sema barrel + the demo layout, no `runtime.trigger`/semantic events.
Visual layer: deleted `eidos/components/words/*` (the bespoke chrome)
and stubbed the demo — both rebuilt next on the new model (F2 render
by components, F3 gutters/bubble/drawer, all eidos).
Engine tests 400/400 green; `npm run check` 0 errors (5142 files).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The editor read as "poor" largely from border clutter — hairlines
everywhere. Strip incidental borders and let tonal surfaces + spacing
carry the structure:
- Toolbar: drop the bottom hairline (its surface tone separates it).
- Drawer: drop the seam line (both sides) — tonal separation only.
- Drawer panels: drop inter-panel divider lines (scope accent + spacing).
- Code block: drop the outline — the overlay surface fill defines it.
- Table: no outer box, no vertical lines — light horizontal row rules
only + airier cells.
- Drawer insert items: borderless soft tonal tiles (hover deepens with
the accent) instead of bordered boxes.
Kept the intentional lines (quote accent bar, the silver margin rule).
Verified in browser: no console errors; table is horizontal-rule-only,
insert tiles borderless.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The editor used UI-cramped metrics (14px / 1.25 line-height / 8px block
gap) which read as a form field, not a document. Give the CONTENT (not
the chrome) a reading scale: body 14→16px, line-height 1.25→1.6, block
gap space-2→space-3 (headings derive 1.5x for top margin). Chrome keeps
its smaller --_words-font-size.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Decouple band width from text inset: `--_words-rail-width` (1.25rem,
the thin band) + `--_words-rail-gap` (1.25rem, breathing room) →
text is no longer glued to the band edge.
- Narrow the band: 2.5rem → 1.25rem (20px).
- Lighter tones: rail-bg #ededed → #f5f5f5, rail-border #cfcfcf →
#e3e3e3. Eidos CSS regenerated.
Verified: band 20px + 1px #e3e3e3 edge, then 20px air before text at 40px.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-directed editor rework, step 1 (left gutter):
- Remove the "+" inserter button from the block-handle gutter. Its
action moves into the drag-handle menu as a new "Insert below" item
(folded into `act('insertBelow')`); the standalone `insertBelow`
helper is gone. The separate seam inserter (words-block-inserter)
is untouched.
- Turn the left gutter into a soft silver margin band: narrow it
(`--_words-gutter-size` 3.5rem→2.5rem) and paint it via a
background-gradient on [data-words-content] — `--words-rail-bg`
#ededed band with a 1px, slightly stronger `--words-rail-border`
#cfcfcf right edge. Background only, so the contenteditable text /
selection is undisturbed. Recipe tokens softened + eidos CSS
regenerated.
Verified in browser: gutter shows only the grip on hover (no "+");
the menu reads Insert below / Move up / Move down / Duplicate / Delete
and "Insert below" inserts a paragraph (7→8 blocks) and closes the
menu; the silver band renders at 40px with the 1px border. npm run
check: 0 errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an H1/H2/H3 toggle to the drawer's Block panel, shown only when
the caret is in a heading (currentBlock === 'heading'). The toggle
reflects currentHeadingLevel and dispatches the structured
`setBlock` command with the numeric level via applyCommand:
applyCommand({ type: 'setBlock', block: 'heading', level })
runCommand / WordsCommandName has no per-level id (heading-1/2/3 are
slash/insert-menu ids), so the structured command carries the level;
`as const` keeps each level a 1|2|3 literal.
Verified in browser: caret in a heading shows "Heading level"; the
toggle reflects the active level and converts bidirectionally
(H1→H3→H2) with the toggle updating. npm run check: 0 errors.
Also closes the stale "drag handle outside the rail over <code>" P1:
it does not reproduce — the rail was removed in the gutter redesign
(5450d5f7) and the handle now anchors to contentRect.left+8 for every
block type (verified: identical left over code and paragraph). Both
P1 items marked resolved in continue.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The drawer image panel already had a corner-radius slider and
shadow/border toggles. Adds the controls that were still missing so
the V2 `block.visual` sidecar is fully editable from the UI:
- shadow blur slider (shown when shadow is on) → visual.shadow.blur
- shadow color picker → visual.shadow.color
- border color picker (shown when border is on) → visual.borderColor
- background color picker (always, with clear) → visual.background
All route through a small reusable `imageColorField` snippet (preset
swatches + native <input type=color> + optional clear), mirroring the
format panel's text-color UI, and dispatch `setBlockVisual`. The
engine already merges + renders these via visualToStyle onto the
<figure>.
Verified in browser: panel renders all four new controls when an image
is selected; clicking a Background swatch updates the figure
(background-color:#22c55e) while preserving radius/border/shadow
(shallow-merge intact). npm run check: 0 errors (26 pre-existing
warnings, none new).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes now-dead selectors left after V2 dropped table striped/compact
and cell tone/header (D2). Nothing emits these attrs anymore:
- `[data-command='table-cell-tone-*']` toolbar rules
- `table[data-words-table-striped]` / `[data-words-table-compact]` rules
- `[data-words-cell-tone='muted'|'accent']` cell + focus-within rules
- the dead `[data-words-cell-header]` alternates (headers render as <th>);
`tr:hover td:not([data-words-cell-header])` simplified to `td`
README updated: tables list only the live formatting (header row/column,
cell align, vertical align); HTML/Markdown documented as export-only,
import as JSON + plain text. ARCHITECTURE_PROPOSAL keeps its mentions —
they document the decision to drop these, not stale features.
Verified in browser: sample 2x2 table renders, <th> keeps the accent
tint + weight via :where(th), cell align/vertical intact, no console
errors. CSS + Markdown only — no type surface touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deletes the V1 rich-text engine and the extensions/ tree. The public
document model flips from WordsDocument to WordsDocumentV2 (D1: V2 puro,
sin legacy — no backward-compat importer, V1-persisted docs no longer
load). Per D2, table striped/compact/cell-tone are dropped entirely
(props, commands, morfo attrs).
- Delete engine/{document,operations,commands,history,input,render,
find-replace,serialize-{html,json,markdown,text},dom-selection,index}
and the whole extensions/ tree (registry + image + table).
- Complete the V2 ops under operations-v2/: port commands, history,
input, find-replace, render-dom, serialize-text, extra-ops,
insert-block-types, markdown shortcuts, factories, dom-selection;
land R4A.2-R4A.8 (text/delete/marks-ops/paragraph-ops/delete-ops/
block-format/list-ops/link-ops/table-ops/selection-walkers).
- Rewire provider, exports, types, morfo/words.ts, eidos word
components, soma words.svelte, the content-test harness, and the
/uix/components/words demo to V2.
- Import accepts JSON (JSON.parse + validateWordsDocument gate) and
plain text only; HTML and Markdown remain export-only (lossy, no
faithful inverse parser).
Verify: npm run check 0 errors; words scope 455/456 (lone failure is the
sema-parity 5s timeout flake under parallel load — 6/6 isolated);
morfo:vocabulary exit 0; browser smoke of /uix/components/words green
(sample renders, text + JSON import round-trip, invalid JSON rejected
without mutating the doc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per spec: "la banda de la derecha la eliminamos, el drag aparece con
el hover del block a la izquierda inferior y el insertar (+) en el
centro cuando el hover este en el final del block, la line debe de
ser mas solida sin blur"
Layout:
- Removed the dedicated rail grid column entirely. The editor host
grid drops from 4 columns (rail | gap | content | drawer) to 2
columns (content | drawer), or (drawer | content) when side=left.
- Removed `--_words-rail-bg/border/px/gap` CSS vars + the
`::before` pseudo-element that drew the silver band. The rail
recipe tokens (words.rail-bg / rail-border) become dead and can
be removed in a later sweep; left for now to avoid generated-css
test regen in the same commit.
Block handle (drag grip):
- Position math simplified: lives at the BLOCK'S lower-left corner
with a 4px inset. `top = rect.bottom - 28 - 4`,
`left = rect.left + 4`. No more rail-relative math, no
detectDrawerSide / cssToPx / rememberRailMetrics helpers — all
removed.
- CSS base opacity bumped from 0.75 → 1 (the grip is shown via
JS only on block hover; the visibility is binary, not faded).
Block inserter ("+"):
- findSeamForCursor rewritten: only triggers when the cursor is
INSIDE a top-level block AND in its bottom portion (last 25% of
block height, capped at 24px). When triggered, the seam anchors
at the block's bottom edge spanning the block's full width — CSS
centers the button via left:50% + translateX(-50%).
- Removed listBlockBoundaries + SEAM_RANGE_PX (dead code under the
new model — we don't compute inter-block seams anymore, we react
to the hovered block's bottom).
- Removed drawerSide flip + data-side attr (the "+" lives INSIDE
the block now, not in a side gutter — drawer side is irrelevant).
Line visual:
- Removed transparency: `background: var(--_words-accent-solid)`
(was color-mix(... 35%, transparent)). Opacity 0.9 always when
shown (no opacity-0 default + hover transition).
- Removed box-shadow halo on drop-mode state (was the blur effect
the spec calls out). Drop mode still grows the line block-size
from 2px to 3px so the drop target is unambiguous.
Verification: DOM probe at /uix/components/words confirms:
- Hover middle of block: handle visible at lower-left
(top=666 vs rect.bottom=698, left=66 vs rect.left=62), inserter
hidden.
- Hover bottom of block: handle still at lower-left, inserter
visible spanning block width (left=block.left, width=block.width),
CSS centers the button horizontally.
- `npm run check`: 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R4A.1 opens the operations layer of the V2 engine. Pure functions only;
no provider coupling. Lives in `engine/operations-v2/`. The full V1
op surface (~50 functions, ~3000 lines) splits across multiple R4A.*
sprints — this one establishes the foundation + the V2-exclusive
visual operation that unlocks POLISH-1b in later sprints.
operations-v2/types.ts (~55 lines):
- WordsEditorStateV2 = { document, selection, activeMarks } over V2
shapes. Reuses V1's `WordsSelection` (path + offset) verbatim — the
selection model doesn't change between V1 and V2.
- WordsOperationResultV2 = { state, changed }. Same shape as V1's
result so future provider migration can plug in directly.
- noOp + changed helpers for guard-clause and result construction.
operations-v2/normalize.ts (~250 lines):
- normalizeDocumentV2(doc, opts?) — defensive shape repair after
every operation. Returns same document REFERENCE when nothing
changed (cheap structural compare via mutated flag).
- Autogen `id` for blocks/items/rows/cells without one (delegates
to defaultIdGenerator from migrate-v2 → crypto.randomUUID + fallback).
- Seeds empty containers: empty document → 1 paragraph; empty list
→ 1 item; empty table → 1×1 cell; empty inline children → 1
empty text.
- Coalesces adjacent text inlines with identical marks (V1 invariant
preserved — keeps the model from fragmenting after edits).
- Strips marks from code block children (code is plain text only).
operations-v2/visual.ts (~115 lines):
- setBlockVisual(state, blockIndex, patch) — V2-EXCLUSIVE.
- Shallow-merges patch into block.visual. Defends:
* blockIndex must be valid.
* Every patch key must be in WORDS_VISUAL_KEYS_PER_TYPE[block.type]
(P8 whitelist enforced at runtime; unknown keys silently dropped).
* Patch values of `undefined` REMOVE the corresponding visual
property from the merged result (clears via undefined).
* Empty visual is omitted from the block (no `visual: {}` stored).
* No-op result when patch matches existing visual (avoids
spurious re-renders).
- This is the operation POLISH-1b (image radius slider + shadow
toggles + border controls) will call.
operations-v2/block.ts (~140 lines):
- updateBlockAt(state, blockIndex, patch) — shallow-merges block
fields, refuses to change `type`.
- deleteBlockAt(state, blockIndex) — removes block; normalizer
seeds an empty paragraph if it was the last.
- insertBlockAt(state, blockIndex, block) — inserts at index (clamps
to bounds).
- moveBlockAt(state, blockIndex, direction) — swap with neighbor.
- duplicateBlockAt(state, blockIndex) — inserts a copy with a fresh
autogen id (drops the original's id before normalize so they're
distinguishable).
- moveBlockToAt(state, fromIndex, toIndex) — arbitrary repositioning
for drag-drop.
operations-v2/index.ts: barrel export of the public surface.
Tests (31): normalize (autogen ids preserved when present, empty
containers seeded for doc/list/table/inline children, text coalescing
with marks-aware preservation, code stripping, idempotence via same
reference). setBlockVisual (set/merge/clear/empty-elision/per-type
whitelist enforcement/shadow on image vs paragraph/no-op on match).
Block ops (updateBlockAt with type-change refusal + invalid index
no-op; deleteBlockAt with seed-on-empty; insertBlockAt with clamping;
moveBlockAt with edge no-op; duplicateBlockAt with fresh id;
moveBlockToAt with from-equals-to no-op).
Two TS errors landed mid-write (over-clever readonly modifier on
function param + structural cast across discriminated union) — both
fixed with simpler types. Verification: 257/257 engine tests pass
(85 V1 + 50 V2 validator + 35 V2 migrator/parity + 56 V2 render/
serializers + 31 V2 ops). `npm run check`: 0 errors.
Next: R4A.2 — text + selection operations (insertText, deleteRange,
insertParagraph, deleteBackward, deleteForward, toggleMark, etc.).
~2500 lines of V1 to mechanically translate. Or pause R4 entirely
and tackle the pragmatic shortcut (backport visual to V1 image to
ship POLISH-1b sooner).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R2 closes the engine refactor non-breaking phase. The V2 model from
R1 is now reachable from any V1 document via a pure migrator, and
the engine's local SemaIntent copy stays in lockstep with the
canonical $uix/intent.INTENTS via a runtime assertion.
engine/migrate-v2.ts (~370 lines):
- Pure function migrateV1ToV2(input, opts?) → { document, runtimeState,
warnings }.
- Per-block migration paths for paragraph / heading / quote / code /
list / table / image / divider / callout. Each rejects invalid
values defensively (e.g. invalid heading level coerced to 1 with
warning).
- P4 enforcement on image: drops `image.status` from the content,
moves it to `runtimeState.imageStatus: Map<blockId, status>` keyed
by an autogen id. Caller (provider) restores into in-memory
sidecar on load.
- P1 sweep on table: drops `striped` / `compact` (presentation
tokens) with warnings. Drops `cell.tone` (design-system token)
with warning. Renames list.children → list.items silently.
- Marks reshape: V1 template-literal `'color:#hex'` /
`'bgcolor:#hex'` → V2 structured `{type:'color'|'background',
value:'#hex'}`. Already-structured marks pass through unchanged.
- Code blocks narrow to text-only: flattens any link inside code
into its text content with warning.
- Idempotent on V2 input: a V2 document round-trips with zero
warnings and exact-equal output (validates with
validateWordsDocument).
- Default id generator uses crypto.randomUUID() when available, with
Math.random + monotonic counter fallback. Tests inject a
deterministic sequential generator.
engine/sema-parity.ts (~60 lines):
- assertSemaIntentParity(canonicalIntents) throws SemaIntentParityError
if the engine's local WORDS_EVAL_INTENTS drifts from the canonical
list. Error names both the missing and extra values + points to
the file to update.
- Caller passes canonical list (avoids cross-package import in this
module). Provider mount calls it with $uix/intent.INTENTS.
engine/migrate-v2.test.ts (35 tests):
- Entry-point shape handling (null / non-object / non-array children).
- Per-block migration (paragraph, heading with level clamp, quote
with cite, code with text-only narrowing + link flattening, list
with kind coercion and indent-0 omission, table with token drops,
image with src validation).
- P4 enforcement: image.status → runtimeState.imageStatus keyed by
autogen id; respects caller-provided id.
- Marks reshape: template-literal → structured; structured
pass-through; invalid hex rejected; unknown boolean dropped.
- Idempotence on V2 input (paragraph, callout, divider).
- Callout intent: all 6 canonical SemaIntent values accepted;
invalid (e.g. 'warning') coerced to 'neutral' with warning.
- Post-condition: migrator output ALWAYS passes
validateWordsDocument on any V1 input.
- Sema parity: 5 tests including the real golden cross-check
against $uix/intent.INTENTS.
Verification: 170/170 engine tests pass (85 V1 + 50 V2 validator
+ 35 V2 migrator/parity). `npm run check`: 0 errors.
Next: R3 — visual sidecar implementation. Render emits inline
styles from block.visual.*; POLISH-1b (image radius/shadow/border
controls) becomes implementable under this model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EV-G final pass + EOD documentation.
Words editor:
- Stop emitting `contact-focus` from soma. Removing the per-component
cascade rule earlier didn't fully silence the editor — the `contact`
family carries a BASE sound signature in SEMA_MAP (pitch 800, gain
0.25) that fires even without a cascade rule. The trigger call is
commented out (event no longer emitted; telemetry empty). Per spec:
"sigue apareciendo el puto sonido, el ring".
- Drag handle + inserter now cover lists AND tables. Selectors
expanded from `[data-words-node="block"][data-words-path]` to also
match `[data-words-node="list"]` and `[data-words-node="table"]`.
Top-level lists / tables get the grip + the "+" seam under them.
- Removed :hover from `[data-words-block-handle]` and
`[data-words-block-inserter-button]`. Gutter overlays are AMBIENT —
visual state via data-attrs only (`data-open`, `data-grabbed`,
`data-dragging`). Per spec: "quítale el hover al botón de añadir
bloque, si el handle no tiene este tampoco".
- Rail tokens hoisted to recipe: `words.rail-bg = #d4d4d4`,
`words.rail-border = #9a9a9a`. Fixed-tone (not theme-aware) — the
rail emulates the physical margin of a notebook; same look in
light/dark. Recipe-css-contract test was rejecting raw hex in
words.css.
Docs:
- CLAUDE.md hand-off 2026-05-28 #2 documenting the 6 EV-* Words
sprints + 5 architectural decisions with lasting effect (canvas
silent, fixed-tone tokens for physical metaphors, drag predicate
covers list/table, ambient overlays, insertParagraph empty-block
guard).
- continue.md (CONTINUE.md) rewritten for tomorrow's pickup:
TL;DR of the two halves (toggle-group structural + Words UX),
remaining bugs (drag handle outside rail on code blocks, heading
inline level change), and the heredados (POLISH-1b, F2.3-F2.6,
DRAGDROP-SOMA).
Verification: 467/467 morfo + sema + eidos + soma/words tests pass.
85/85 engine pass. `npm run check`: 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six user-reported issues in one pass.
EV-F1 — canvas silence
Remove the contact-focus entry from the sema cascade. The event still
fires (telemetry / a11y can read the data-event* stamps) but no sound
/ haptic plays. Per spec: "no puede emitir eventos es demencial, una
vez que está trabajando con el no tiene sentido". The editor surface
is now perceptually silent on focus; only explicit commits (save,
format, set-link, etc.) make noise.
EV-F2 — rail visual
`--_words-rail-bg` becomes flat silver `#d4d4d4` and the dot
pattern is gone. New `--_words-rail-border` (`#9a9a9a`) lays a
1px hairline on the rail's inline-end so it reads as a separate
gutter ("paper edge" feel). Fixed-tone (not theme-aware) — the rail
is meant to look like the physical margin of a notebook regardless
of the editor theme. Left-side drawer flips the border to inline-
start.
EV-F3 — direct undo/redo in toolbar
Demo presets + custom config now use `['undo', 'redo', ...]` as
direct command strings instead of `['history-menu', ...]` (which
opened a popover to pick from two items — wasted real estate +
two clicks). `link-menu` and `tools-menu` stay as popovers because
they hold 2-3 items each. `insert-menu` is OUT — block construction
lives in the drawer's empty-state Insert panel and the slash menu.
`demoToolbarFamilyItems.insert` collapsed to `[]` accordingly.
EV-F4 — popover widths
Base `[data-words-toolbar-family-panel]` width 12.5rem → 16rem so
content fits without horizontal scroll. Per-family overrides:
tools → 17rem (descriptive labels), link → 20rem (URL input +
button grid). The default panel CSS no longer carries a max-block-
size + scroll — a vertical scroll inside these popovers is a
misdesign signal; size the panel, don't scroll it.
EV-F5 — link popover (covered by EV-F4)
The vertical scroll the user saw was a side-effect of the
12.5rem width forcing the link-editor input + button to wrap, then
the panel running out of vertical room. The new 20rem width
restores the natural one-row layout and no scroll appears.
EV-F6 — Enter on empty block no longer multiplies
`insertParagraph` engine op now guards the default case
(paragraph/heading): if the block is collapsed AND empty (children
length 0, both halves of split empty), Enter on a paragraph is a
no-op and Enter on a heading demotes it to a paragraph (canonical
Notion / Google Docs UX). For non-empty paragraphs/headings the
existing split-and-create-paragraph behavior stands.
Verification: 467/467 tests pass across morfo + sema + eidos +
soma/components/words. 85/85 engine tests pass (insertParagraph
guard non-regressive on existing fixtures). `npm run check` still 0
errors. DOM probe at /uix/components/words confirms toolbar = 5
items (undo, redo, Links, Tools, Find/replace), rail bg
`rgb(212,212,212)` + 1px `rgb(154,154,154)` right border, link
popover 320px wide with no scroll.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The editor body was unbounded vertically — long documents pushed the
whole page instead of confining the scroll to the editor surface.
Per spec: "la parte de la edicion deberia tener un scroll".
Recipe:
- Three new tokens `content-max-block-size-sm/md/lg` (50vh / 60vh /
70vh) mirror the existing `content-min-block-size-*`. Generated CSS
regenerated.
CSS:
- `[data-words-content]` now carries `max-block-size:
var(--_words-content-max-block-size)` and `overflow-y: auto`. The
min-block-size baseline stays as the starting height for empty
editors. When content grows past the cap, the body scrolls
internally — the host card no longer balloons.
- `scroll-padding-block: 1rem` hint so caret-into-view nudges leave
breathing room.
Block-handle / block-inserter / image-float-bar:
- Switch scroll listeners from `window.addEventListener('scroll')` to
`document.addEventListener('scroll', { capture: true })`. Scroll
events don't bubble; the previous wiring only caught window scrolls.
Now the gutter overlays (handle position, "+" snap target, image
float bar anchor) re-measure when the user scrolls INSIDE the
content area too.
Verification: dev probe at /uix/components/words confirms
`max-block-size: 600px`, `overflow-y: auto`, content scrollHeight
(615px) > clientHeight (600px), `isInternallyScrollable: true` on a
1000px viewport (60vh of 1000 = 600). 467/467 tests pass across
eidos + morfo + sema + soma/components/words.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four user-requested fixes to the Words editor surface — the body
should not emit perceptual cues during editing, the top toolbar must
not duplicate the drawer, and the gutter widgets need clearer anchor
points.
EV-A (events spam):
- Move `contact-focus` event target from `content` to `provider` in
the morfo + sema cascade selector. Focus is a Words-component-level
signal; firing it from the content body conflates "user entered the
editor" with "user is editing the body". The wasFocused guard in
soma already throttles to one emit per real focus transition.
- Expand `isInsideWordsTool` selector list to cover the four
companion overlays added after the original list was written:
`data-words-drawer`, `data-words-block-handle`,
`data-words-block-handle-menu`, `data-words-block-inserter`,
`data-words-image-float-bar`. Before this fix every click on a
companion overlay fired commit-save-content + contact-focus on the
blur/refocus cycle (two perceptual cues per tool interaction).
EV-B (toolbar trim):
- Demo's full / formatting / minimal presets + custom config now host
only GLOBAL actions: history (undo/redo), insert (creates new
blocks), link (selection-bound flow), tools (find/replace + clear),
find-replace in its own group. text-menu / block-menu / align-menu
/ list-menu / table-menu moved out because the drawer already owns
contextual formatting per the HIER-2 split.
EV-C (block-handle drag UX):
- Drop the `setDragImage(hoverBlockEl)` call. The browser now uses
its default snapshot (the grip button itself) as the drag ghost —
the ghost travels with the cursor while the bar in the gutter
stays fixed as a visual anchor. New `data-dragging` attr +
`[data-words-block-handle][data-dragging]` CSS rule fades the
static anchor to 0.35 opacity so it reads as "drag origin" while
the ghost is the moving part.
EV-D (inserter at block bottom):
- Seam positions are now pinned to the BOTTOM EDGE of the preceding
block (`a.bottom`) instead of the midpoint of the gap between two
blocks. The "+" reads as "insert AFTER this block" anchored to
that block's lower edge, per spec — el botón de añadir bloque
debe aparecer en el límite inferior del área en relación al
bloque.
- Tighten the "cursor inside block band" check to a half-open
interval `[top, bottom)` so the exact bottom-edge pixel belongs to
the seam below (otherwise the seam at `y === bottom` is shadowed
by the block and the inserter never snaps).
Verification: dev server, /uix/components/words, DOM probe confirms
handle centered in the rail column (left=48 inside the 32-wide rail
starting at ~46), inserter snaps to block 1's bottom (`top=569.94px`
when block 1 bottom = 570px). 366/366 tests pass in morfo + sema +
soma/components/words. `npm run check` still 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the toggle-group structural migration `npm run check` still had
12 pre-existing errors. All from the active Words sprint (post-2026-05
F2/F3/COLOR work), not introduced by this session. Closes them to
restore green check:
- soma words.svelte: thread `onUploadImage` through the Provider opts
(was required-undefined in WordsOpts but missing from the create()
call).
- eidos words-toolbar-family-menu.svelte + words.svelte: add
'insert-image' label to COMMAND_LABELS (the Record<WordsToolbarButton
CommandName, string> required full coverage).
- eidos words.svelte: hoist the `leafItem` snippet out of <SomaWords.
Provider> to template root level. Snippets defined INSIDE a component
element are passed as named props in Svelte 5; this one was meant as
a local helper. Also kills the "Expression too complex" error at the
Provider tag — the leafItem prop was bloating the union.
- eidos words-drawer.svelte: extract 5 inline button-config arrays to
typed `as const satisfies readonly { id: WordsCommandName; ... }[]`
constants (FORMAT_MARKS, TEXT_ALIGN_ACTIONS, CELL_TONE_ACTIONS,
CELL_VERTICAL_ACTIONS, CELL_TEXT_ALIGN_ACTIONS). The inline literals
were inferring `id: string`, breaking `runCommand` and
`activeMarks.includes`.
Result: npm run check 16 → 0 errors. Tests 658/658 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the deuda left explicit in hand-off 2026-05-27 #5: the inlined
derivation expressions in toggle-group.css duplicated Toggle's recipe
because [data-toggle-group-item] wasn't a descendant of [data-toggle].
Resolution is structural, not a drift test:
- morfo: each item declares { attr: 'data-toggle', value: v.literal(''),
severity: 'required' }. Captures structural identity — an item IS a
toggle in every observable sense (same press, same variant/color/size,
same on/off machine).
- eidos: new context.ts propagates the root's variant + size via
reactive getters; the item wrapper writes them as data-attrs on its
button. Combined with data-toggle, the item is DOM-equivalent to a
standalone <Toggle>.
- eidos css: ~150 lines deleted (base derived tokens, variant cascades,
size cascade, focus/disabled/icon-only duplicates). Toggle's recipe
now paints the item end-to-end. CSS keeps only the grouping concerns:
flex, orientation, attached, block, focus z-index, group disabled.
- composition (TSC v2.2) stays — palette overrides land on the item
under [data-toggle-group][data-color='X'] [data-toggle-group-item],
which Toggle's --toggle-palette-* chain reads at item scope.
Verification:
- DOM probe (12 combinations: 4 colors × 3 variants × 2 states) on
/uix/components/toggle-group. Computed values match bit-a-bit with
baseline pre-refactor (e.g. affirm/solid on = rgb(18,165,148),
risk/outline on = srgb(0.2,0.118,0.043), threat/ghost on =
rgb(25,17,17)).
- vitest src/uix/{morfo,eidos}: 163/163 pass.
- vitest src/uix/soma/components/toggle-group: 4/4 pass.
- npm run check: 12 errors (all pre-existing in active Words sprint;
zero added by this migration).
Drive-by: the same npm-check pass surfaced 4 stale 'orientation' refs
in words-toolbar.svelte + words-toolbar-group.svelte from this session's
earlier toolbar refactor (orientation was removed from types but not
from these soma components). Removed; brings check from 16 → 12 errors.
CLAUDE.md hand-off 2026-05-28 documents the new doctrine: when a
wrapper visually reuses another, declare structural identity in the
wrapper's morfo. Don't duplicate the cascade, don't extend TSC for one
case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`npm run check` was reporting 'content is possibly null' errors in
both block-handle and block-inserter $effect closures — 6 errors
between them. TS doesn't carry the `if (!content) return` narrowing
into inner closures, so we re-bind to a const typed `HTMLElement`
right after the guard.
`npm run check`: 22 → 16 errors. Remaining 10 (drawer command-name
/ mark string-not-assignable, words.svelte missing 'insert-image'
key in the Record, plus 6 pre-existing in src/lib/_demo and others)
need a deeper look at the command-name / mark string unions —
follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three categories of audit drift introduced during the recent Words
sprints (drawer redesign, image float bar, color marks, insert
panel, gutter rail), all caught by the existing contract tests now
that the work has landed:
1. **Undeclared morfo attr** (P0 contracts test):
`data-words-block-selected` is stamped by the engine's render on
the atomic-selected block (today only image) but wasn't in the
morfo's data-attr list. Adds it as optional on the same parts
array that already declares `data-words-image-*`.
2. **Missing i18n key** (translations:check):
The drawer part references `components.words.drawer` for its
aria-label, but the langs catalog was never updated when
DRAWER-CANON landed. Add `drawer` to
`src/uix/langs/components/words.ts` (es + en).
3. **Recipe CSS contract drift** (eidos recipe-css-contract test):
- 3 undeclared CSS vars used in the drawer-Editable styles:
`--words-toolbar-color` → `--words-command-color`
`--words-command-hover-bg` → `--words-command-bg-hover`
`--words-command-active-color` → `--words-command-color-hover`
(Right names per `lib/recipes/base.ts > words`.)
- 15 raw font-size literals (0.65rem / 0.7rem / 0.75rem / 0.8rem
/ 0.9rem) replaced with the existing words font-size tokens:
0.65/0.7rem → `var(--words-status-font-size)`
0.75/0.8rem → `var(--words-font-size-sm)`
0.9rem → `var(--words-font-size-md)`
Result:
- `contracts.test.ts`: 31→32 pass (the data-attr violation is gone).
- `translations:check`: 219 refs / 76 catalogs / 0 errors / 0 warnings.
- `recipe-css-contract.test.ts`: 17→19 pass.
- Full `vitest run src/uix`: **867/867** pass (was 865/2-fail before).
- `npm run check` clean (only 6 pre-existing errors unrelated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs blocking the drag-and-drop UX:
1. **Grip was 22px off** — `parseFloat('2rem')` returns 2, not 32.
I was reading `--_words-rail-px` / `--_words-rail-gap` as raw
strings and treating them as pixels. With the actual rem values
the grip landed in the gap/content padding instead of the rail.
Fix: `cssToPx(raw, rootFontSize)` resolves `rem`/`em` → px via
the documentElement's computed font-size. With the fix the
grip's center matches the rail's center exactly.
2. **Drag preview was the tiny grip icon, not the block** — native
HTML5 drag defaults to a snapshot of the `draggable` element.
Since `draggable=true` lives on the 28×28 grip button, the user
saw a tiny grip ghosted under the cursor instead of the
paragraph/heading/image being moved.
Fix: `e.dataTransfer.setDragImage(hoverBlockEl, 12, 12)` in
`ondragstart`. The browser now uses the BLOCK's element as the
drag image — user sees the whole row/heading/image moving with
the cursor, just like Notion / Linear / Figma.
Note re: soma's `<DragDrop.Provider>`: the user is right that
soma has a first-class drag-drop system (pointer + keyboard + ARIA).
The reason this implementation still uses native HTML5 dragstart/
dragend is architectural: soma's `<DragDrop.Draggable>` is designed
to WRAP each draggable item, but here the blocks are rendered by
the engine's HTML render function as plain DOM (not Svelte
components), so each block isn't wrappable. The block-handle is a
single floating overlay that snaps to whichever block is hovered.
Migrating to soma DragDrop would require restructuring the editor
render to per-block Svelte components — a larger architectural
change. Filed as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the "+" snapped to any seam within 18 px of the cursor's
Y — which meant it appeared even when the cursor was hovering on a
block's body (near its top/bottom edge) or in the rail aligned with
the block. That clashed with the grip's territory.
Per user spec: when the cursor is on a block (text or rail-aligned),
the grip is the actor — the "+" should be hidden. The "+" only
surfaces when the cursor is in the rail column AND vertically
positioned in a gap BETWEEN two blocks.
Two new gates added to `findSeamForCursor`:
1. **Out-of-content X**: if `e.clientX` falls inside the content
box's horizontal extent (the editor's text area), return null
immediately. The cursor is reading text or near the toolbar/
status row, not the rail.
2. **Out-of-block Y**: if `e.clientY` falls inside ANY top-level
block's vertical band, return null. The block-handle owns that
slot.
Only when both gates pass do we snap to the nearest seam. The
existing `dragover` path is untouched — drag indicator still works
inside the content area because the drag operation has different
ergonomics (you're actively moving a block, the line is the drop
target, not a discoverability cue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback was right — the previous approach put the rail INSIDE
the editor as extra content padding, so it was glued to the editor
card with no visual separation. Reframed as a proper SIBLING column
in the grid layout, with its own grey/dotted background and a gap
to the editor mirroring the drawer's gap on the other side.
Layout (right-drawer default):
[rail 2rem] [gap 0.5rem] [editor card] [drawer]
Left-drawer flips both rail + gap to the right side.
CSS changes:
- New tokens: `--_words-rail-px` (2rem ~32px column width),
`--_words-rail-gap` (0.5rem ~8px gap to editor),
`--_words-rail-bg` (subtle grey using toolbar-border tint),
`--_words-rail-dot` (denser status-color dots at 65% opacity).
- Grid `template-columns` and `template-areas` of
`[data-words]:has([data-words-drawer])` extended to include `rail`
and a `.` gap column. Left-drawer mirror reverses them.
- Rail visual: `[data-words]:has(...)::before` with `grid-area:
rail`, dotted radial-gradient on grey bg, rounded corners
matching the editor's radius.
- Reverted content + placeholder to symmetric `padding-inline` (the
rail no longer lives in content's padding). Deleted the obsolete
`[data-words-content]::before` rule.
Overlay positioning (JS):
- `words-block-handle.svelte` reads `--_words-rail-px`,
`--_words-rail-gap`, `--_words-content-px` from the editor host
and computes the grip's `left` as the center of the rail column
in viewport coords:
right-drawer: `block.rect.left - content-px - rail-gap - rail-px/2`
left-drawer: `block.rect.right + content-px + rail-gap + rail-px/2`
Then offset by half the grip's width (14 px) to center on the rail.
Inserter "+" button (CSS-only):
- `left` switched from a hard `-28px` to a calc that mirrors the
grip math:
`-(content-px + rail-gap + rail-px/2) - 12px`
Right-edge mirror for the left-drawer case.
Grip visibility bumped:
- Color from `status-color` to `command-color` so it reads as
active-text against the dotted rail.
- Default opacity 0.45 → 0.75 (visible without hover).
- Hover state gains a toolbar-bg fill + accent-text + accent
border ring so the hovered grip stands out from the rail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two complaints from the user on the rail:
1. Still too wide at 1.5rem (~24px).
2. The band doesn't visually distinguish itself from the canvas
so the grip + "+" look detached.
Fixes:
- **Rail trimmed to 1rem (~16px)** — combined with content-px gives
a 32px total padding on the rail side. The 28×28 grip lands
at content.left+0 → +28 with a 4px gap before the text. This is
the minimum band width that still keeps the grip fully inside
the editor's visible content area.
- **Dotted pattern on the band** — `[data-words-content]::before`
fills the rail's padding zone (32px wide, full height) with a
radial-gradient dot pattern at 8×8 px tiles. Color is
`status-color @ 40% opacity` so the dots read as a "this is a
gutter" cue without competing with content. `pointer-events:
none` and `z-index: 0` so they don't interfere with selection
or caret.
- **Flips with drawer side** — when `<Words.Drawer side='left'>`
the pseudo's inset moves to inline-end so the dotted band tracks
the rail to the right side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous 2.5rem (~40px) made the content's left padding total ~56px
which felt excessive — the band was very wide and the grip looked
lost in the middle of it. Trim to 1.5rem so the total padding is
~40px on the rail side: tight enough that the 28×28 grip occupies
most of the band (content.left+8 → +36) with just enough breathing
room (4 px) before the text starts. Same Notion-style density.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The grip `::` and the "+" inserter were positioned at
`block.rect.left - 28..32` — fine in theory but the content area
had symmetric `padding-inline` so that math landed the overlays in
empty body space outside the editor box. User-visible result: the
left band had no width, the grip dragged on/off the edge unevenly,
and the click target felt invisible.
Fix: dedicate a visible rail of `2.5rem` (~40px) on the side
OPPOSITE the drawer, by switching the content + placeholder from
symmetric `padding-inline` to an asymmetric pair
(`padding-inline-start` + `padding-inline-end`). New token
`--_words-rail-px` is added to one of those sides per drawer
position.
- Drawer on RIGHT (default): rail is on the LEFT — content's
`padding-inline-start` becomes `content-px + rail-px`. The grip
at `block.rect.left - 32` now lands inside that visible band
(≈ 24-52 px from the editor's left edge), with the inserter "+"
button at -28 alongside it. Block text starts 56 px from the
editor's left edge.
- Drawer on LEFT: mirrored — the asymmetric padding flips via
`[data-words]:has([data-words-drawer][data-side='left']) >
[data-words-content]` so the rail moves to the inline-end side
where the overlays now render.
No JS changes needed — the existing geometry-based positioning
(`block.rect.left - 32`, `seam.left - 28` for the "+", with the
drawer-side detection added in an earlier commit) automatically
lands in the new band because `block.rect.left` shifted right by
the rail width.
Verified in browser: hovering anywhere over a paragraph reveals
the grip at the expected band position, with no flicker, and the
hit area is comfortably wide for a click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The companion drawer was hard-coded to the right column. Now
`<Words.Drawer side="left">` flips the editor grid so the drawer
takes the start column; the floating overlays (block handle grip,
block inserter "+") automatically render on the OPPOSITE margin so
they don't crash with the drawer's gutter. Default stays `'right'`
so existing consumers see no change.
Wiring:
- Eidos `<Words.Drawer>` gains `side?: 'left' | 'right'` (default
'right'). The value is passthrough'd as `data-side={side}` on the
rendered drawer root so CSS + sibling overlays can read it from
the DOM.
- `words.css`:
- `[data-words]:has([data-words-drawer][data-side='left'])`
flips `grid-template-columns` + `grid-template-areas` so the
drawer column sits at the inline-start.
- `[data-words-drawer][data-side='left']` swaps the visual
seam from `border-inline-start` to `border-inline-end`.
- `[data-words-block-inserter][data-side='left']
[data-words-block-inserter-button]` overrides `left: -28px`
to `right: -28px` so the "+" lands on the block's right
margin.
- Block-handle (`words-block-handle.svelte`): new `detectDrawerSide()`
reads `[data-words-drawer]@data-side` once per measurement. The
grip's `position.left` becomes `rect.right + 4` when drawer is
on the left (was always `rect.left - 28`).
- Block-inserter (`words-block-inserter.svelte`): same `$derived`
read of the drawer's `data-side`, propagated as `data-side` on
the inserter overlay element so the CSS rule above can flip the
"+" button.
- Demo: new `let drawerSide = $state<'left' | 'right'>('right')` +
a chip control "drawer side: right | left". `<Words.Drawer
side={drawerSide} />`.
Verified `npm run check` clean (pre-existing errors only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- Sample document gains an image block between the code block and the
table, showing figure/img/figcaption render in action.
- readPlainText demo helper gets an 'image' branch returning alt text
(was crashing the SSR with 'Cannot read properties of undefined
(reading map)' because image has no .children).
Demo screenshot confirms:
- Image renders inside a figure with border + padding
- Caption shows centered + italic below
- img max-inline-size cap keeps the image inside the editor frame
- Word/char count in status bar reflects alt + caption text
F3 MVP visually verified end-to-end:
- Block parses from markdown / HTML in the document model
- Renders correctly as figure/img/figcaption in the contenteditable
- Eidos styling (border, caption italic, alignment data-attr) applies
- Status bar counts include image alt text
Pending (UX surfaces, deferred to a follow-up session):
- F3.7 slash menu /image entry (open a URL prompt)
- F3.8 paste/drop handler with onUploadImage callback for file uploads
- F3.7/F3.8 require new svelte components (insert dialog, drop overlay)
and provider event handlers — they're isolatable from this commit's
surface and are the next sub-sprint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
Before: 'Optional editor · 64 words · none'
After: 'Optional · 64 words · 453 chars · none' (no link, no commit)
'Required · 64 words · 453 chars · range · https://...' (selection + link)
'Optional · 64 words · 453 chars · none · save' (after commit)
- Drops the redundant 'editor' suffix; the surface is already an editor.
- Adds char count from plainText.length — useful for size limits.
- Adds the existing selectionState (none/collapsed/range) inline.
- Splits link/commit into separate optional segments instead of
collapsing them into one slot.
Demo-only change in +page.svelte. The eidos Words.Status wrapper
still passes children through unchanged — enriching the eidos default
status snippet itself would require Words provider to expose
wordCount/charCount, which is a larger refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The dialog demo prose described "Six events" with the per-event close-*
shape that was collapsed into a single polymorphic close in commit
9f5fbfdb. Three updates:
- Header lede: "Eight parts; six events" → "Eight parts; two events
(open + polymorphic close)" with the polymorphism rationale.
- morfo tab section: explains the imperative data-last-action +
per-cause semantic pattern instead of the old per-event prewrite story.
- sema tab section: describes how dismissWith() swaps the family at
trigger time per cause.
- Keyboard hint: "Close (close-dismiss)" → "Close (close with
data-last-action='dismissed')" to match runtime behaviour.
The compiled events table at the bottom of the morfo tab auto-derives
from the morfo, so it already shows the correct collapsed event list
without modification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
Los 11 packs sema shipped en commit 2d562f37 quedaban dormantes
porque soma no llamaba runtime.trigger en ningún sitio de estos 7
componentes. Esta es la cabling que los activa.
Patrón: emisión en el state-mutator central (no en handlers
individuales) cuando es posible, pasando fallbackTarget al elemento
DOM apropiado para que el cascade selector matchee la instancia
correcta (no la "más reciente registrada").
- toggle-group: `commit-toggle` desde Item.onclick con item button
como fallbackTarget.
- menubar: `commit-select` desde Provider.open(value), usando
triggerRegistry para resolver value→element. Cubre click, keyboard,
hover-follow y arrow-nav en una sola emisión.
- navigation-menu: `commit-select` desde Provider.openNow(value),
usando triggerRefs existente. Closest() del trigger sube al
item ancestro.
- dropdown-menu: `open` y `close` desde handleOpen/handleClose
(sequence: 'pre' — antes de la mutación de state); `commit-select`
desde MenuItemProvider.onclick + onkeydown.
- context-menu: `open` desde openAt(), `close` desde handleClose,
`commit-select` desde ContextMenuItemProvider.onclick + onkeydown.
- tree-view: `emerge-expand`/`emerge-collapse` desde toggleExpand
(lookup branch via querySelector). `commit-select` desde select()
con parámetro fromEl pasado por BranchControl/ItemProvider.
- tree-grid: análogos via lookup de row por data-value.
Pack tree-view actualizado: añade cascade rule para commit-select
sobre `branch` (mismo tuning que item) — branches y leaves comparten
firma perceptual.
Fix colateral: tag-group test esperaba intent='neutral' para
commit-unselect, pero el canon (f3641347) lo declara como 'affirm'.
Test actualizado.
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 (74 files)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Updated section 4 of audit-codex-full-1.md to reflect the closure of
P1 #4 in the sema canon sprint (commits af6d6c76 → 00f0b740). All four
hallazgos in the original audit fixed:
- button.commit-action → contact-activate
- textarea.shift-count-overflow → signal-warn-count-overflow + risk
- month/year-grid nav-step → shift-navigate-step
- password-field shift.toggle → commit.toggle
Plus 56 additional events from Capa 2 + Capa 3 (family
misclassifications, cluster clear → commit.reset, cluster unselect with
commit.unselect added to canon, 12 individual renames + 1 doctrinal
correction).
morfo:vocabulary now hard-fails on declared verb drift; soft-warns on
name shape. 7 warns remaining are all words.* (separate dev track).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
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>
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>
$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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The "los clicks siguen fallando" report was tested against loop=false
default behavior — at idx=3 (last slide) the next button correctly went
disabled (canGoNext=false), opacity 0.4, no advance — but testers read
that as a broken click handler rather than a boundary stop.
Default the demo to loop=true so each click predictably advances
(0→1→2→3→0...). Verified 6 successive clicks wrap correctly. Component
default stays loop=false (matches Embla / Mantine / ark-ui); the demo
just picks the more illustrative interactive baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Canonical placement (matches Mantine, shadcn-svelte): triggers sit as
siblings of Viewport (children of the Carousel root), not as siblings
of ItemGroup inside Viewport. Keeps the gesture-bearing ItemGroup
region clean of unrelated event targets; the recipe's absolute
positioning still anchors the triggers correctly because the carousel
root is `position: relative`.
The "clicks fail" report was the expected disabled state at the last
slide under loop=false — verified the next button correctly reports
disabled=true + opacity 0.4 once canGoNext becomes false. Enable loop
from the controls for infinite navigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
## 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>
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>
A bare `<input type="number" placeholder="—">` made it non-obvious how to
clear the constraint — users would type and not realise empty=undefined.
Mirroring the date-range-picker demo's bounds row, both minDays and
maxDays now expose a chip row with `none` as the first option plus
common presets (`2/3/7/14` for min, `7/14/30/90` for max). The custom
number input stays alongside for ad-hoc values. The label header shows
the current effective value (`none` when undefined).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
The Listbox container grew unbounded when items overflowed — long lists
just stretched the parent layout. Adds:
- Default `max-block-size: min(60vh, 22rem)` via `--_listbox-max-height`
token (consumer can override via inline style).
- `overflow-y: auto` + `overscroll-behavior: contain` so the listbox
becomes its own scroll context.
- Horizontal orientation switches axes: removes the block cap and uses
`overflow-x: auto` instead so a single-row list never clips.
Verified: with 8 items at md size, container caps at 352px with
scrollHeight 378px — scrollbar present and working.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3 newly-ported components (commits 0afcabee, 9def877d, 8ab8f9da)
were accessible by direct URL but missing from the sidebar nav in
\`web/routes/uix/+layout@.svelte\` (it's a hardcoded slug list, not
auto-discovered).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates CONTINUE.md to reflect the segunda-mitad sprint of 2026-05-23:
the 7 commits between dc03773c and 0b55be5c that closed the menu
indicator partial (#118), removed the unused modal prop (#127), and
refactored 5 consumers to render `<Button>` internally (#120).
Documents the canonical Button-consumer pattern in 6 rules — variant
narrowing to canonical sets in eidos/lib/types.ts, chip parity §12.7,
composition via soma `child` snippet (with the `child`/`outerChild`
rename gotcha), cross-layer state forwarding via
WithChildNoChildrenSnippetProps, local recipe boundary, and the
verification protocol (svelte-check + browser visual confirm).
Lists candidate follow-ups out of #120 scope (NumberField stepper kept
bespoke; Dialog/Popover/Drawer triggers and Select/Combobox field
triggers could adopt the same pattern when prioritized).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#120. The two icon-only clear affordances now render as eidos
`<Button>` via soma's `child` snippet, retiring the per-recipe chrome
(border, padding, focus ring, hover, dimensions) in favour of
button.css. The X glyph is provided through Button's `icon` snippet
slot; `iconOnly` collapses the body and keeps the label sr-only.
Both narrow `variant` to ControlVariant (`'surface' | 'outline' | 'ghost'`)
per DEMO_AUTHORING_GUIDE §12.7 — same 3-set as DropdownMenu.Trigger
and AlertDialog.Cancel. Default `variant='ghost'` (subtle X that reveals
chrome on hover) which is the canonical clear affordance.
Defaults:
- Combobox.ClearButton: ghost · xs · iconOnly · color=neutral
- SearchField.ClearTrigger: ghost · sm · iconOnly · color=neutral
Per-component CSS gutted to the empty-state visibility rule only —
`[data-…][data-empty] [data-…-clear-…] { opacity: 0; pointer-events: none }`
stays because hiding the X when there's nothing to clear is
component-specific behavior, not Button chrome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stops the chip-count drift introduced in \`c98510af\`. AlertDialog.Action
was exposing all 6 ButtonVariant chips, breaking the 3-chip rhythm the
rest of UIX follows. Per DEMO_AUTHORING_GUIDE §12.7 every chip-group
must enumerate the full union the type declares — so the fix is to
narrow the *type*, not truncate the chips.
\`AlertDialogActionVariant = Extract<ButtonVariant, SelectionVariant>\`
imports the canonical set from \`eidos/lib/types.ts\` (`solid | outline |
ghost`), which is the right shape for a primary action button:
- keeps 'solid' as the CTA default
- 3-chip parity with Cancel (ControlVariant), DropdownMenu.Trigger
(ControlVariant), and the rest of the form-control surface
- no invented vocab — uses the existing canon set named for exactly
this use case ("selection chips / toggles" semantically maps to
"selection / action buttons")
Demo array drops from 6 to 3 chips; API row updated to reflect the
narrowing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the Button visual props that AlertDialog.Action and
AlertDialog.Cancel now forward (post `c6f063dc`) so the demo isn't
lying about the component's API:
- Action: 6 chips (full ButtonVariant — primary CTA), 5 size chips.
Intent is NOT exposed as a separate chip because it inherits from
the dialog's own `intent` prop (already a Live control above).
- Cancel: 3 chips (ControlVariant — narrowed per
DEMO_AUTHORING_GUIDE §12.7), 5 size chips. Always neutral palette
regardless of dialog intent.
Live preview wires both bindings into the rendered Action/Cancel; API
tables list the new props with type, default, and the snippet shape
(`Snippet<[{ props; intent }]>` for Action's `child`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#120 second pass. AlertDialog.Action and AlertDialog.Cancel now render
eidos `<Button>` internally via soma's `child` snippet, retiring the
per-recipe Toggle-token chrome and the CSS ancestor cascade
(`[data-dialog-content][data-color='risk'] [data-alert-dialog-action]`).
Intent propagation: soma's `AlertDialogActionProvider` exposes a new
`dialogIntent` derived getter that reads `DialogProvider.opts.intent`.
The Action component passes it to the `child` snippet alongside `props`
(via `WithChildNoChildrenSnippetProps` so plain `children` stays a
regular Snippet). The eidos Action wrapper forwards it to Button as
`intent={intentOverride ?? dialogIntent}` — so `<AlertDialog intent="risk">`
automatically paints a red Action without any cascade selector.
Defaults:
- Action: variant='solid', intent inherits from dialog
- Cancel: variant='outline', intent='neutral', color='neutral' so it
reads as gray-outlined regardless of the dialog's intent (canonical
"this is the safe choice" cue)
Cancel narrows variant to ControlVariant (3 chips: surface/outline/ghost)
per DEMO_AUTHORING_GUIDE §12.7. Action keeps the full ButtonVariant
because it's the primary call-to-action.
alert-dialog.css gutted to a doc-only stub; all chrome now comes from
button.css.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Menu CheckboxItem / RadioItem now mirror the standalone Checkbox /
RadioGroup chrome:
- off: outlined box / circle (transparent fill, neutral border)
- on : filled solid accent + white ✓ glyph (checkbox) or white inner
dot (radio) — same shape as `<SvgCheck>` and RadioGroup.Indicator
The check glyph uses `mask-image` with the same `M5 12.5L10 17.5L19 7`
path as `<SvgCheck>` so the menu item reads visually identical to a
standalone `<Checkbox checked>`. Background-color carries the glyph
colour via `--_menu-indicator-glyph` (`--color-content-on-solid`),
matching how Checkbox sets `--_checkbox-palette-text` over the solid
accent fill.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related fixes touching the menu surface:
1. **Remove `modal` from DropdownMenu** (#127). The prop was declared in
soma but never wired to anything — no overlay, no pointer-events
blocker, no scrim. ark-ui / Headless-UI / Chakra all treat menu as
non-modal by convention; Dialog/Drawer/AlertDialog cover blocking
semantics. Drop it from soma (types, provider opts, component, test
fixture), the eidos doc comment, the menubar consumer, and the
dropdown-menu demo (state, control, snippet, API table, parity table).
2. **DropdownMenu.Trigger now renders `<Button>` internally** (#120
first consumer). Default variant='surface', size='md', intent='neutral'.
`variant` is narrowed to ControlVariant (`'surface' | 'outline' | 'ghost'`)
per DEMO_AUTHORING_GUIDE §12.7 so the trigger stays consistent with
the form-control surface (Field/Select/Combobox) instead of inheriting
Button's wider 6-variant set. The eidos demo exposes variant/size/intent
chips that match the canonical narrowed vocab (Intent: neutral · affirm
· fulfill · risk · threat · loss). Trigger's per-recipe CSS dropped —
chrome comes from button.css.
3. **Menu-item unchecked indicators** (follow-up to #118). CheckboxItem
and RadioItem now paint an empty outlined box / circle even when
unchecked, so the user can tell at a glance which rows are checkable.
Checked state fills with the accent color via the same `::before`. The
reserved padding slot (26px) keeps row text from shifting when state
flips.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unify the four pseudo-element indicator recipes — context-menu and
dropdown-menu × {checkbox-item, radio-item} — into a single partial at
`lib/menu-indicator.css`. The four selectors paint identical geometry
(0.875rem rounded square or circle at the leading inset) so a single
source keeps them in lockstep.
Tokens are exposed as private vars (`--_menu-indicator-{size,radius,
color,inset,mask}`) so a consumer can override per-component without
re-implementing the recipe.
Import order matters: the partial must load AFTER the menu recipes so
its `padding-inline-start: calc(...)` wins over the row rule's
`padding-inline` shorthand at equal specificity. Verified in browser
on both /uix/components/dropdown-menu and /uix/components/context-menu:
checked checkbox-items render 14×14 purple rounded squares, radio-items
render circles, and all rows reserve the same 26px slot so toggling
state doesn't shift text.
Out of scope: Checkbox / RadioGroup / Select.ItemIndicator — those use
`[data-*-indicator]` DOM parts with SVG markers and xs/sm/md/lg/xl size
scales, an architecturally different pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User audit (2026-05-23) caught two real defects in this component:
1. Demo was a 386-line skeleton — switches used the made-up
`data-control-kind="switch"` attribute, props like granularity /
locale / hideTimeZone / per-endpoint readonlySegments were not
wired, API / Morfo / Recipe / A11y tabs were one-sentence stubs.
Canonical reference demos are 700–1200 lines.
2. Recipe rule `[data-date-range-field-input][data-active]
[data-date-field]` matched nothing — soma overlays
`data-date-field-input` on the SAME element as
`data-date-range-field-input` (no nested `[data-date-field]`).
Fixes:
- Eidos `<DateRangeField>` root now overlays
`data-date-field=""` on top of `data-date-range-field`. Lets the
full `date-field.css` cascade (chrome, size, variant, color)
apply inside the range with zero recipe duplication. Documented
in the wrapper comment.
- `date-range-field.css` simplified to layout-only:
[data-date-range-field][data-date-field] (specificity bump
beats date-field's base `display: inline-flex` rule)
grid: 1fr · auto · 1fr × 2 rows
label spans the full first row
separator via `::before` in the middle grid cell
endpoints placed in columns 1 and 3 via `data-endpoint='start' / 'end'`
No recipe duplication; date-field paints input chrome.
- Demo rebuilt to canonical depth (1008 lines, +622):
Live tab: 6 layered subsections (value shape / locale + dir /
flags / per-endpoint readonly segments / eidos chrome /
actions), 11 switches in canonical `<span data-uix-switch>`
style with state-text labels, 9 chip groups (value profile,
kind, granularity, hourCycle, hideTimeZone, locale × 6
locales, dir, size × 5, variant × 3, color × 6), reset /
clear actions, reactive soma + eidos snippets reflecting
every control.
API tab: full prop table per part (Root with 21 props, Label,
Input, Segment), reference parity table vs Bits UI / Ark UI
/ react-aria / Chakra v3.
Morfo tab: header table + parts overview + per-part data /
aria / keyboard tables (driven by raw morfo, cast to a
narrowed shape) + events table.
Sema tab: passive justification + delegation table showing
per-endpoint DateField runtimes.
Recipe tab: selector classification (morfo / eidos) + chrome
attribution.
A11y tab: per-segment keyboard + ARIA contract tables.
- Demo `{#each segments}` keyed by `(i)` (was already fixed, kept).
Lesson registered in
`C:\Users\dev\.claude\projects\G--dev-svelte-vicen\memory\feedback_demos_must_be_canonical_depth.md`
and referenced from `MEMORY.md`: ALWAYS read DEMO_AUTHORING_GUIDE.md
+ a reference demo (date-field 716L, drawer 737L, date-range-picker
1238L) BEFORE writing. Compare against reference libs first.
Verified visually at /uix/components/date-range-field — DOM has
both endpoints side-by-side, em-dash separator in middle column,
date-field chrome applied to each input, canonical control style
across all 6 subsections.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Soma's segments array uses `'literal'` as the part for every visual
separator (e.g. the `/` between dd / mm / yyyy). Keying the {#each}
by `seg.part` produced duplicate keys → `each_key_duplicate` runtime
error at the second `literal` separator. Switch to positional key
`(i)` — segments don't reorder, so positional is stable and correct.
Snippet template strings updated to match so the visible code in
the Live tab reflects the right pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-endpoint date entry. Mirrors DateField × 2 with shared validation
and a single label naming the whole range. Each Input scopes its own
DateField context internally, so segments inside re-use the existing
date-field recipe automatically.
Eidos surface:
- variant / color / size — same vocabulary as <DateField>, cascaded
via data-* on the root to both endpoint inputs and their segments
- Re-uses DateField.Segment (zero duplication)
Recipe (date-range-field.css):
- Label + control row stack
- Em-dash separator rendered via `::before` on the end Input
(no manual `<span>—</span>` needed)
- Endpoint-aware tint via `data-active` on the holding input
Morfo:
- scope: ['soma'] → ['soma', 'sema', 'eidos']
- apg URL: Spinbutton (per segment)
- Passive at this contract layer (events come from the two endpoint
DateField runtimes); README documents the classification
Sidebar nav: 'Date range field' added under Forms (next to Date field).
Tier 2 sprint progress: 1/3 (date-range-field next: time-range-field,
range-calendar).
Checks: svelte-check 0 errors, component:audit 91/91 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Soma's MenuCheckboxItem / MenuRadioItem default `closeOnSelect=true`
(matches Radix Primitives), but for toggle-state items that's the
wrong UX default — the user wants to flip multiple checkboxes or
switch a radio choice without re-opening the menu each time.
Override the default in the eidos wrappers for both DropdownMenu and
ContextMenu (4 components total: CheckboxItem + RadioItem in each).
Activating Item (plain command) still closes by default; only the
state-toggle parts stay open.
Consumers who genuinely want close-on-toggle can pass
`closeOnSelect={true}` explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-cell OTP / verification code input. Two parts (Provider, Cell)
with a snippet-driven cell iteration.
Architecture:
- A transparent absolutely-positioned <input> captures every
keystroke, paste and IME event (soma).
- Visible bordered cells follow the hidden input's caret via
data-active / data-filled emitted by the morfo.
- Single-input approach (shadcn / Bits family) — preserves paste
distribution, autocomplete='one-time-code' for SMS autofill on
iOS/Android, and IME quality.
Eidos surface:
- size: 'sm' | 'md' | 'lg' | 'xl' (responsive), cascades cell
dimension + glyph size tokens to all cells
- Recipe paints square bordered cells with focus ring tracking
data-active, subtle bg lift on data-filled, threat-toned border
when [data-invalid] cascades from the provider
Morfo:
- scope: ['soma'] → ['soma', 'sema', 'eidos']
- apg URL added (Textbox pattern — OTP has no formal ARIA pattern)
- Passive at the morfo layer (no events array; perceptual feedback
via the commit family base when onComplete fires)
- README documents the passive classification
Comparativa: aligned with shadcn / Bits UI (single hidden input).
Ark and Chakra use one input per cell which loses paste / IME
quality.
Sidebar nav: 'Pin input' added under Forms (next to Tags input).
=== Sprint Tier 1 — DONE ===
1. toggle-group 61ffbade
2. alert-dialog d5630a52
3. dropdown-menu 4e477e63
4. context-menu 21eb8a62
5. pin-input (this)
Checks: svelte-check 0 errors, component:audit 91/91 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the three Tier 1 sprint components into the IA:
- 'Toggle group' under Controls (next to Toggle)
- 'Alert dialog' under Overlays (next to Dialog)
- 'Dropdown menu' under Overlays (next to Popover)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First component in the soma→eidos porting sprint. Two-part compound
(Provider + Item), modeled on radio-group's structure.
Eidos surface:
- variant: 'solid' | 'outline' | 'ghost' — mirrors <Toggle>
- color: 7 tones, propagated to items via data-color cascade
- size: xs/sm/md/lg/xl (responsive)
- attached: boolean — segmented control affordance (shared borders,
active item z-indexed above neighbors)
- block: boolean — items grow to fill the inline-size
- iconOnly: per-item flag — square aspect, sr-only label
Items consume the Toggle palette token vocabulary (--toggle-*) so a
standalone <Toggle> and a <ToggleGroup.Item> at the same variant ×
color × size are visually identical — single source of truth for the
"toggle button" recipe.
Morfo scope fix: ['soma'] → ['soma', 'sema', 'eidos']. Adds apg URL
(WAI-ARIA Button pattern) so the audit's A-1.4 rule passes. The
commit-toggle event's sound comes from the `commit` family base — no
per-component cascade needed.
Demo: canonical 6-tab template (Live / API / Morfo / Sema / Recipe /
A11y) with every prop exposed as a chip/switch, soma + eidos snippets
update live, sema "▶ play" button on the commit-toggle event.
Reference comparison vs Radix Primitives / Bits UI / Ark UI / Chakra
v3 / Mantine / Radix Themes / shadcn documented in README §Comparativa.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two missing registrations meant the splitter sema cascade from the
previous commit never reached the engine — handle-pick and handle-drag
were silent, only commit-set sounded (because the `commit` family has
a sound payload baked into its SEMA_MAP base, while `handle` only has
haptic and depends on the cascade for sound).
Fixes:
1. morfo scope: ['soma'] → ['soma', 'sema', 'eidos']. Without 'sema'
the per-component cascade rules in src/uix/sema/components/splitter.ts
are never honored.
2. web/routes/uix/+layout@.svelte: add splitterSema to the
`components: [...]` array passed to defineEngineSemantic. The
sema engine only loads cascade packs it's explicitly given, so
forgetting this line silently drops all splitter cascade rules.
Verified in browser at /uix/components/splitter via Live tab (real
pointer interaction, AudioContext.createOscillator counter):
handle-pick → 2 oscillators (handle.pickup.air, gain 0.10)
handle-drag → 16 oscillators across 8 throttled emits — the
dynamic resolveSplitterDragSound curve is playing
per-frame as expected
commit-set → 2 oscillators (handle.snap.chime, gain 0.14)
Note: the Sema tab's static "▶ play" button still shows 0 oscillators
for handle-drag — same as Slider's demo. By design: that button calls
engine.emit() directly without the per-emit `signal.overrides` that
soma injects during real drag. The dynamic curve IS the payload.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single source of truth for typography values that the recipe layer
consumes. The foundation aliases `--font-ui` and `--leading-ui` (read
by ~30 recipe tokens in `lib/recipes/base.ts`) now derive from the
canonical `label` named style instead of carrying duplicate literals:
--style-label-font-family: var(--font-family-primary);
--style-label-line-height: 1.25;
--font-ui: var(--style-label-font-family, var(--font-family-primary));
--leading-ui: var(--style-label-line-height, 1.25);
Chain: typography.ts styles → --style-{name}-* → --leading-ui / --font-ui
→ recipe tokens → component CSS. Editing
`STATIC_TYPOGRAPHY.styles.label.lineHeight` now propagates to every
recipe in one go.
Why not push recipes to consume `--style-{name}-*` directly:
- t-shirt sizes (xs/sm/md/lg/xl) don't map to four semantic buckets
- per-component matices (description/caption/hint) need their own
color / weight / letter-spacing
- ref libraries (Radix Themes, Mantine, MUI, Chakra) all keep
numerical scale for component internals; semantic layer is only for
user-facing typography primitives (`<Text variant="body2">`)
Audit rule R-2.7 (warn): detects literal font-size / font-weight /
line-height / letter-spacing in eidos component CSS. Escape valves:
var(...), numeric identities (0/0px/1), keywords (inherit/initial/
unset), or trailing `/* literal: <reason> */` comment. Current run
flags 6 components with letter-spacing/font-size literals (all
intentional micro-tracking and em-relative; can be annotated case by
case).
Documentation:
- src/uix/eidos/README.md § "Vertebración tipográfica" — two-layer
architecture rationale, alias chain diagram, comparison vs Radix
Themes / Chakra / Mantine / MUI, escape valves
- web/routes/uix/lib/COMPONENT_AUDIT_GUIDE.md § 4.11 — pointer to
R-2.7 + cross-link to the foundation doc
Verified end-to-end in browser at /uix/components/field:
--font-ui → 'Instrument Sans', system-ui, sans-serif
--style-label-font-family → 'Instrument Sans', system-ui, sans-serif
--leading-ui → 1.25
--style-label-line-height → 1.25
computed [data-field-label].line-height → 17.5px (= 14 × 1.25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause: soma/scroll-area-scrollbar.svelte gated the entire <div> on
`state.shouldShow`. For type='hover' / 'scroll', `shouldShow` starts
false, so the bar never reached the DOM. With no DOM node:
- bind:this never fired → scrollbar's `ref` stayed null
- `requestFrame(cb, ref.current)` ran with a null element, so the
`mounted` flag never flipped
- the hover / drag listeners had no element to attach to
- the recipe's `data-state` opacity transition had no element to
animate
- `shouldShow` could therefore never become true → deadlock
Fix — split the concern:
- `shouldMount` (new derived) — true whenever the axis overflows OR
type='always'. Drives the `{#if}` in the part component.
- `shouldShow` — drives `data-state="visible|hidden"`. The recipe
transitions opacity.
The bar now lives in the DOM whenever it could ever be needed, fades
in/out via CSS, and stays a stable hit target for hover / click /
drag. Dropped the `mounted` flag and the requestFrame mount-frame
scheduling — they only existed to suppress a flash that the opacity
transition handles cleanly.
Also dropped the inline `border-radius: inherit` on the thumb so the
recipe's `--scroll-area-thumb-radius` (the new `radius` prop) wins.
Without this the prop was silently overridden to `0px`.
Test updates:
- removed `cancelFrame` assertion (no mount-frame to cancel)
- added `shouldMount` assertion
Verified in browser at /uix/components/scroll-area:
- type='hover' + vertical: bar mounts hidden, fades in on root hover,
fades out after delay
- type='scroll' + horizontal: bar mounts hidden, fades in on scroll,
thumb tracks scrollLeft, fades out after 600ms
- type='always' + both: both bars + corner visible at all times
- size=lg, radius=full: 12px-wide bar, fully rounded thumb
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reference-library parity (Radix Themes, Chakra v3):
- size: 'xs' | 'sm' | 'md' | 'lg' (Chakra t-shirt scale) → thickness
via --scroll-area-scrollbar-size.
- radius: 'none' | 'sm' | 'md' | 'lg' | 'full' (Radix Themes) → thumb
curvature via --scroll-area-thumb-radius.
- scrollbars: 'vertical' | 'horizontal' | 'both' (Radix Themes) →
recipe-level sugar that auto-mounts Scrollbar / Thumb / Corner from
the root. Explicit composition still supported (omit the prop).
Bug fix — type='scroll' + horizontal sometimes stuck hidden:
- `mounted` on ScrollAreaScrollbarProvider is now $state. Previously
a plain field, so the `shouldShow` derived didn't invalidate when
the requestFrame mount-flag flipped during the first frame.
- The reveal-on-scroll $effect now skips its first run via a
`firstRun` flag. That first run was the subscription pass with
scrollTop/scrollLeft still 0 — it was eagerly calling show() and
setting `visible=true` before `mounted` flipped. Subsequent real
scrolls then assigned visible=true again (no-op), so shouldShow
didn't re-run and the bar stayed hidden until the hide timer
expired. Now the bar only reveals on genuine scroll deltas.
Demo: chip controls for size + radius, switched to scrollbars prop
on the root (drops the explicit Scrollbar/Thumb children), eidos
snippet shows the recipe-sugar form, soma snippet still shows the
explicit Radix-style composition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scroll-area soma: defer the mount-frame request to `$effect` so SSR
doesn't hit `dom::window_required` during constructor evaluation.
- scroll-area recipe: scrollbars now `position: absolute` (overlay
style) so the horizontal track lands at the viewport's block-end
instead of flowing below it. `:has` rules reserve corresponding edges
when both axes mount; corner switched to absolute as well.
- drawer demo: drop the ↑↓←→ glyph pop-outs and `overflow: visible`
during dragging. The handle's own pill grip is the affordance — the
same shape the splitter uses, no extra chrome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 new layout primitives close the air port set (15 / 15 done).
`npm run component:audit`: 91 / 91 PASS, all 3 new PASS with 0 errors.
**scroll-area** — eidos compound wrapper over the existing soma:
`<ScrollArea><ScrollArea.Viewport>{content}</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical"><ScrollArea.Thumb />
</ScrollArea.Scrollbar><ScrollArea.Corner /></ScrollArea>`. Mirrors
Radix Primitives / Bits UI shape. Demo 667 lines.
**separator** — visual primitive (`scope: ['eidos']`) with conditional
ARIA: `role="separator"` + `aria-orientation` when not decorative,
`aria-hidden="true"` when decorative. New morfo
`src/uix/morfo/components/separator.ts`. Demo 645 lines.
**splitter** — eidos compound over existing soma:
`<Splitter orientation="horizontal"><Splitter.Panel>{a}</Splitter.Panel>
<Splitter.ResizeTrigger /><Splitter.Panel>{b}</Splitter.Panel>
</Splitter>`. Demo 824 lines.
**Splitter sema rewire — match Slider's handle/drag/commit shape:**
The morfo previously declared only `commit-resize`. Direct-manipulation
primitives (Slider, Splitter) should share the same event vocabulary so
audio/haptic projections feel coherent across the family. Renamed +
expanded to mirror Slider:
- `handle-pick` (family: handle, verb: pick, sequence: coincident,
target: resize-trigger) — fires on pointerdown when user grabs the
trigger.
- `handle-drag` (family: handle, verb: drag, sequence: coincident,
target: resize-trigger) — fires on each pointermove frame.
- `commit-set` (family: commit, verb: set, intent: neutral,
sequence: post, target: provider) — fires on pointerup. The runtime
`commit-set` handler is wired to call the consumer's `onResizeEnd`
callback, so sema and JS callback land at the same moment (parity
with Slider's `onValueCommit`).
Soma provider triggers these via `this.runtime.trigger(name)` in
`onpointerdown` / `onpointermove` / `notifyResizeEnd` (the latter
unchanged externally — internally now routes through the runtime
trigger).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs surfaced when verifying the panel demo visually:
1. **AspectRatio shrunk to content width inside grid/flex containers.**
Grid items default to `width: max-content`, so AspectRatio without an
explicit width was sizing itself to its child's intrinsic content
instead of filling the slot. The ratio still applied but against the
collapsed width, producing a tiny box.
Fix: recipe sets `inline-size: var(--box-width, 100%)` so the default
is "fill the slot". `width=…` from Box still wins; `maxWidth=…`
constrains the upper bound exactly as before. Block-level containers
are unaffected (100% of the line box matches their natural width).
2. **Panel demo gradient invisible in dark mode.** The demo used
`--color-primary-track` + `--color-affirm-track` which are surface
tokens (near-black in dark theme). Swapped to `*-solid` tokens with
`--color-content-on-solid` text for high contrast in both themes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full canon for the rest of the typography family, following Text
(commit `03531709`) as template. Each primitive ships morfo (passive,
scope eidos, 0 events) + full eidos set (svelte/css/types/index +
README with required sections) + canon 6-tab demo (≥400 lines each).
**Heading** — semantic h1..h6 via `level` prop, visual style decoupled
via `style` prop (defaults to match level but `<Heading level={3}
style="h1">` allowed). Demo 594 lines.
**Display** — always-h1 page-title primitive. Always uses
`style="hero"`. Tracking via literal em values until foundation emits
hero-scale tracking tokens. Demo 546 lines.
**Code** — inline code via `<code>`. `variant: 'plain' | 'soft' |
'outline'` for subtle background chrome. Demo 423 lines.
**CodeBlock** — multi-line `<pre><code>` with optional title +
copy button (button utility, NOT a sema verb — kept out of morfo per
2-of-3 rule). Demo 402 lines.
**Kbd** — keyboard shortcut chrome over `style="code"`. Demo 418 lines.
**Mark** — text highlight via `<mark>`. Palette uses canonical
`ColorRole` intents (default `fulfill` ≈ legacy yellow). Demo 402 lines.
**Highlight** — substring highlighter inside Text. Scans children for
`query` and wraps matches. Same intent palette as Mark. Demo 407 lines.
**Link** — text link via `<a>`. `variant: 'default' | 'subtle' |
'plain'` for underline style, `intent: ColorRole` for color. Passive
on the morfo — hover/active is CSS-only, navigation belongs to the
consumer (router). Demo 448 lines.
**Architectural deviations from air documented per README's
Decisiones section:**
- Heading: semantic level vs visual style decoupling
- Code split into inline `<Code>` + block `<CodeBlock>` (was one
component with `variant='block'` in air)
- Mark/Highlight palette swapped from literal hue names to canonical
ColorRole intents (theme contract)
- Link palette opened to full ColorRole; `quiet`/`emphasized` air
variants dropped in favour of composable patterns
`npm run component:audit`: **90 / 90 PASS**, 0 NEEDS-WORK.
svelte-check: 0 errors, 0 new warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First typography primitive consuming the named-style tokens added in
`015bd761`. Sets the canon for the remaining typography ports
(Heading, Display, Code, CodeBlock, Kbd, Mark, Highlight, Link).
API:
- `style: 'body' | 'prose' | 'label' | 'caption'` (default `body`).
Selects which `--style-{name}-*` token set the recipe consumes.
- Per-axis overrides via `size`, `family`, `weight`, `color`, `align`.
Each writes a local `--_text-{axis}` CSS variable that wins over
the named style's default via the cascade.
- `italic`, `underline` shorthand booleans.
- `truncate` (single-line ellipsis) + `clamp={N}` (multi-line via
CSS `-webkit-line-clamp`).
- `as` prop switches the tag (default `'span'` — inline composable).
- `tagStyle` escape hatch for one-off inline CSS that the prop
surface doesn't cover (separate from the `style` named-style prop).
Architectural choices documented in README:
- Sizes map to eidos `xxs..xxxl` (not air's `1..12`).
- Families use the eidos slot names (`primary/secondary/mono/display`)
not air's domain aliases (`ui/prose/code`) — coherence with the
foundation contract.
- Colors are restricted to the content-role slots
(`--color-content-*`) to preserve contrast guarantees. Free colors
via `tagStyle`.
- Canvas line-count measurement (air's `text` + bindable `lineCount`)
deferred; CSS line-clamp covers the visual-truncate use case.
Sidebar nav gains a `Typography` group with Text as its first entry.
`npm run component:audit`: 82 / 82 PASS, 0 NEEDS-WORK. svelte-check 0
errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`STATIC_TYPOGRAPHY.styles` already defined 11 named typography styles
(hero, h1..h6, body, prose, label, caption, code) but the renderer
only emitted families / sizes / weights — the style entries lived
as data with no CSS reflection.
Extend `appendTypographyDeclarations` so each named style emits:
- `--style-{name}-font-family` → `var(--font-family-{family})`
- `--style-{name}-font-size` → `var(--font-size-{size})`
- `--style-{name}-line-height` → literal or `var(--font-line-height-{size})`
- `--style-{name}-letter-spacing` → literal or `var(--font-letter-spacing-{size})`
- `--style-{name}-font-weight` → `var(--font-weight-{weight})` (or numeric)
- `--style-{name}-color` → `var(--color-{dot.path → dash-path})`
Responsive sizes (e.g. `hero.size = { base: 'xxl', md: 'xxxl' }`)
emit the base value into `:root` and per-breakpoint
`@media (min-width: …)` blocks that override the same variables.
Breakpoint thresholds match `$libs/dom/responsive` (sm: 480, md: 768,
lg: 1024, xl: 1280, xxl: 1536).
`renderStaticCss` now iterates `STYLE_BREAKPOINT_ORDER` after the
main `:root` block and appends one media-query block per breakpoint
that has at least one responsive override. The `indentBlock` helper
preserves indentation inside the wrapper.
`base.css` regenerated. svelte-check 0 errors, `npm run component:audit`
81 / 81 PASS unchanged. This is foundation-only; no components consume
the new tokens yet — that lands in the typography port (Text + Heading
+ Display + Code + CodeBlock + Kbd + Mark + Highlight + Link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses the "should" features documented as gaps in the layout
READMEs from `fdb0e680`. Audit script still reports 77 / 77 PASS,
svelte-check 0 errors.
**Flex**
- `alignContent` prop (multi-line cross-axis alignment). Maps to
`--flex-align-content`; defaults to `normal` in the recipe so it
has no effect when wrap is off. Closes the Radix / Chakra parity
gap.
**Grid**
- `inline` boolean — renders as `display: inline-grid` (paridad con
Flex.inline).
- `columns` / `rows` numeric shorthands — Radix Themes pattern.
Internally resolved to `repeat(N, minmax(0, 1fr))`. `templateColumns`
/ `templateRows` string still wins if both are passed.
- `alignContent` prop — same shape as Flex.
**Shared**
- New `LayoutAlignContent` type added to `_layout/shared.ts`
(covers start, end, center, stretch, space-between, space-around,
space-evenly, flex-start, flex-end, normal, baseline).
**Group**
- Verified `grow` boolean already implemented in `0ec2a57a` /
`591b0885` — no change needed; the README now correctly reflects it.
**Docs**
- Flex + Grid READMEs updated: comparison tables mark previously
missing features as Sí; Gaps section marks them ✅ implementado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`npm run component:audit` now reports 77 / 77 PASS, 0 NEEDS-WORK,
0 BROKEN.
**month-grid + year-grid READMEs**
Add Baseline / Comparativa / Decisiones / Gaps sections (F-1.1 /
F-1.2 / F-1.3 / F-1.4). Both grids are standalone primitives
extracted from DatePicker's `kind: 'month' | 'year'` views (task
`#70`); they have no air counterpart. Documented Sema event design
(single `commit-set` for value mutation, `nav-step` for paginated
header navigation), the 4×3 grid choice, configurable `decadeSize`
for YearGrid, and known gaps (multi-year range, quick-jump,
localized month names — all deferred).
**Audit script `focusMoveActions` extension**
`A-3.7` was firing on month-grid/year-grid because the script didn't
recognise `next-row`, `prev-row`, `first-month`, `last-month`,
`first-year`, `last-year`, `next-page`, `prev-page` as focus moves.
They ARE focus moves inside their respective grids (same semantic as
`next-day` / `first-day-of-week` inside Calendar). Added to the
focusMoveActions set in `scripts/component-audit.ts` so the
event-vs-keyboard ratio heuristic no longer over-counts.
**time-range-picker**
- README Gaps section rewritten as a table with explicit disposition
markers (`implementar` / `diferir` / `descartar`) per F-1.4.
- Recipe `time-range-picker.css` now styles `[data-readonly]` (lower
opacity + pointer-events: none on trigger/clear) and `[data-invalid]`
(threat-colour border + focus ring) — closes R-1.3 / R-1.4. Both
attrs were declared in morfo but unstyled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Creates the binding pre-flight checklist every agent / contributor
must read before touching any UIX component (`src/uix/{morfo, soma,
sema, eidos}/components/{name}` or `web/routes/uix/components/{name}/`).
Sections:
0. Inviolable rule — always read this + DEMO_AUTHORING_GUIDE +
components/README before coding
1. Reference library matrix (radix-themes, chakra, mantine, mui,
react-aria, ark, bits, shadcn, WAI-ARIA APG) with what each is for
2. 4-layer ownership recap (morfo / soma / sema / eidos) + the
2-of-3 rule for morfo extensions
3. Pre-flight audit template — feature parity matrix, architectural
choices, reference comparison, decision log, user sign-off line
4. Project-wide architectural rules (Radix item/container split,
composition over visibility props, chip parity, size category
cheatsheet, per-event intent, no re-export facades, persistent
label registries, floating layer defaults, combobox keyboard,
Chakra band-above-control chips)
5. Demo template lock — points at DEMO_AUTHORING_GUIDE
6. Anti-pattern catalogue — every failed approach from recent
sessions with WHY it failed (shallow demos, matchAnchorWidth,
visibility booleans, inline chips, onpointerdown picks, flex
100% wrap, unregistering labels on unmount, auto-rendering
wrappers, refocus without guard, agent git reset, skipped audit)
7. Canonical canaries per domain (drawer, search-field, box, flex,
date-picker, avatar)
8. Audit log — running table of completed audits with commit hashes
+ the known gaps from Layout Batch 1 to address before the next
round (alignContent on Flex/Grid, columns/rows shorthand on Grid,
grow boolean on Group, fluid on Container, HStack/VStack helpers)
9. Pre-port checklist consumers can copy into task plans
10. "When in doubt, ask the user" closer
AGENTS.md updated with a top-banner ⚠ block linking the three
required reads (this guide, the demo guide, the eidos components
README) so any new agent picks them up before touching code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second POC for redoing Layout Batch 1 demos. Mirrors `box`'s canonical
shape adapted for a flex container:
- 7 container-side controls (direction, align, justify, wrap, gap,
padding, inline) — chips for unions, number for space tokens,
switch for the boolean.
- API tab separates "Container props (Flex-only)" from "Inherited
from Box" with a forward-link.
- Cross-references: lede + API call out that item-side props
(alignSelf, gridColumn, …) live on the child `<Box>`, and that
Stack / Group are direction-specific helpers.
- Reference comparison row reflects the Radix Themes split (item vs
container) and notes Chakra's `HStack`/`VStack` map to UIX `Stack`.
Adds `src/uix/morfo/components/flex.ts` (scope `eidos`, 1 part, 0
events, mirrors box.ts justification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POC for redoing Layout Batch 1 demos to follow DEMO_AUTHORING_GUIDE.
Box becomes the reference template for the remaining 7 primitives.
Demo follows the canon shape:
- Header with eyebrow + 4 meta pills (parts, events, props, scope).
- Stage permanently rendered with 3 nested boxes + trace strip
showing live display/padding/gap state.
- 6 tabs in canonical order (Live · API · Morfo · Sema · Recipe · A11y).
- Live tab: 7 controls + reactive soma/eidos snippets.
- API tab: 6 subsections grouped by concern (sizing / spacing /
display+position / flex/grid item / escape hatch / reference
comparison vs radix-themes / chakra / mantine).
- Morfo / Sema / Recipe / A11y tabs each populated with the contract
appropriate to a passive eidos-native primitive.
Architectural fixes (heredados de air, ahora correctos):
- `gridColumn`, `gridRow`, `gridArea` move from `<Grid>` to `<Box>` —
these are item placement props, they belong on the CHILD that
declares its slot, not the container.
- Add `placeSelf` to Box (grid item shorthand for align-self +
justify-self).
- Grid recipe + props updated accordingly; comment clarifies that
item placement lives on Box.
Add `src/uix/morfo/components/box.ts` (scope `eidos`, 1 part, 0 events
with justification comment). Box demo updated lede to point at Flex /
Grid for container-side props (Radix Themes split, not Chakra
everything-on-Box).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces Box / Flex / Grid / Stack / Group / Wrap / Container / Section
in the docs sidebar. Pages already existed at `/uix/components/{name}/`
from `9ec2a57a` but weren't navigable from the chrome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual-only primitives translated from the legacy `air/components/layout`
on the morfo-runtime branch. Architectural adjustments for the eidos
target: drop the `air-` class prefix, target `[data-{name}]` directly,
rename CSS vars to `--{name}-*`, replace `var(--air-space-N)` with the
eidos foundation `var(--space-N)` token, resolve responsive props via
`ActiveEidos.resolve(prop)`.
- `box` — universal box-model utility (size, padding, margin, gap,
position, overflow, flex/grid item props). Every prop maps to a
CSS custom property; unset props fall through via `revert-layer`.
- `flex` / `grid` / `stack` / `group` / `wrap` — layout containers
that render through Box (single `<div>` per primitive, no
semantic-tag prop in this batch). Markers stack as
`[data-box][data-{name}]`.
- `container` — max-width centered (sm/md/lg/xl/full).
- `section` — semantic-padding block with background tone variants.
Shared helpers at `_layout/shared.ts` (formatLayoutSpace /
formatLayoutLength / formatLayoutRaw / pushStyleVar / composeStyle)
plus an optional `Layout.*` namespace barrel for consumers who prefer
that style; the individual `import { Box }` from each component dir
also works.
Each primitive ships a demo at `/uix/components/{name}/` with a live
example, a props table, and a reference comparison row (radix-themes /
chakra-ui / mantine).
svelte-check: 0 errors / 0 new warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switched the multi-mode chips from MUI-style "inline inside the input"
to the Chakra v3 multi-select pattern — chips render as a separated
band ABOVE the Control. Two wins:
- The dropdown popover (opens downward) never covers the selected
chips, which sit comfortably above the input.
- The Control stays a normal-height single-line input. No layout
shifts, no absolute-positioned affordances, no `display: contents`
gymnastics, no input flex tuning.
Reverts the inside-the-Control CSS gymnastics from `30e9517a` /
`64bbe9b1` / `87e3d68f` (flex-wrap, padding-right reservation,
absolute clear+trigger, input flex basis). `<Combobox.SelectedTags>`
now renders as a plain `flex-wrap` row with chip gap and a margin-
block-end separating it from the Control. Demo composes
`<Combobox.SelectedTags />` BEFORE `<Combobox.Control>` when
`mode === 'multiple'`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`<Combobox.SelectedTags>` was a nested flex container so the input
always wrapped to its own row below the entire chip block — wasting
the empty space on the last chip row.
Use `display: contents` so each chip becomes a direct flex child of
`<Combobox.Control>`. Chips + input now share the same wrap context:
the input flows on the same row as the last chip when there's room.
`[data-empty]` still toggles to `display: none` so empty-state still
collapses correctly (and the `:has(...:not([data-empty]))` selector
that pins clear/trigger absolutely keeps working).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With many chips wrapping into 3+ rows, the previous `top: 50%` +
translateY centered the clear/trigger pair at the midpoint of the
whole stack — visually below the first row of chips. Pin to
`top: var(--space-1)` instead so they sit flush with the first chip
row no matter how many additional rows of chips wrap below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When `<Combobox.SelectedTags>` has at least one chip, anchor the
ClearButton + Trigger absolutely at the right edge of the Control
(MUI Autocomplete pattern). Chips + input can now wrap freely
without ever pushing the right-side affordances to a new row.
`--_combobox-affordances-w` reserves the right-padding the
absolutely-positioned buttons consume, scaled to the control's
current height token.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New parts `<Combobox.SelectedTags>`, `<Combobox.SelectedTag>` and
`<Combobox.SelectedTagRemove>` render the current selection as
removable chips inside `<Combobox.Control>` when `type='multiple'`
(Material UI Autocomplete pattern):
- Morfo: 3 new public parts with kebab names + texts entries.
- Langs: es/en entries `selected-tags` / `selected-tag-remove`.
- Soma: `ComboboxSelectedTagsProvider` exposes `items` derived from
the provider's label registry; `ComboboxSelectedTagProvider`
surfaces `data-value` / `data-label`; `ComboboxSelectedTagRemoveProvider`
resolves its parent tag via context and calls `provider.removeValue`
on click. Provider gains `getSelectedItems()` and `removeValue()`
helpers; `selectedTagsEmpty` runtime prop drives `[data-empty]`.
- Soma item registry: stop unregistering labels on Item cleanup so
chips keep proper labels after the listbox closes/lazy-unmounts.
- Eidos: default snippet loops items and renders a chip + X icon
per entry; consumer can override the snippet for custom rendering.
- CSS: chip styling, wrap-friendly Control with breathing padding
when chips are present, ClearButton pinned right via auto margin,
input flex basis reduced to 4rem so it shares a row with chips.
- Demo: renders `<Combobox.SelectedTags />` inside Control when
`mode === 'multiple'`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follows the WAI-ARIA combobox pattern via aria-activedescendant — the
input keeps DOM focus, highlight moves virtually:
- ArrowDown / ArrowUp: open popover if closed, cycle highlight
(wrapping at edges).
- Home / End: jump to first / last (when open).
- Enter: commit the highlighted item, close popover.
- Escape: close popover without clearing value; Soma's clearOnEscape
fires only when popover is already closed.
- pointermove on a result moves the highlight to it (mouse mirrors
keyboard).
- highlighted item auto-scrolls into view; index resets when result
set shrinks past it or after commit/clear.
Wires aria-controls / aria-expanded / aria-activedescendant /
aria-autocomplete on `<SearchField.Input>` and ids + data-highlighted
on each option. Header meta pill updated to 7 keys.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`commitPick()` set `value`, closed the popover, then refocused the
input — but the `onfocusin` on `<Popover.Anchor>` reopened it. Guard
the anchor's open handler with a `justCommitted` flag cleared on the
next microtask so the refocus that follows a pick is ignored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Demo `/uix/components/search-field` wraps the SearchField in
`<Popover.Anchor>`; results render in `<Popover.Content>` (intrinsic
width, align="start") wrapped in `<PickerShell.Body>` so the panel
inherits the canonical picker chrome. Items are real `<button>`s with
`onclick` — committing a selection sets `value`, closes the popover,
and restores focus to the input.
- Forward `bind:ref` through the eidos `<SearchField.Input>` wrapper so
consumers can refocus the input from the demo's pick handler.
- Hide the browser's native `::-webkit-search-cancel-button` /
`::-webkit-search-decoration` / `::-ms-clear` so only the canonical
`<SearchField.ClearTrigger>` X is visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promote PickerShell from action-row-only to a canonical 4-zone shell
(Root → Header / Body / Footer) shared by every composite picker so
date/time/color popovers get the same visual contract for free.
- Add `<PickerShell.Root>` flex-column wrapper (emits data-picker-shell,
data-mode) plus `<PickerShell.Header>` and `<PickerShell.Body>` zones
matching the existing Footer's spacing + border tokens.
- Auto-wrap all 5 *-content.svelte popovers (date / date-range / time /
time-range / color) in `<PickerShell.Root>` so consumers only compose
Body + Footer.
- Refresh all 5 demos to use `<PickerShell.Body>` around picker content;
date-picker demo gains a `showShellHeader` toggle showcasing the new
Header zone.
- CSS scales section gap + header spacing across xs/sm/md/lg picker
sizes via [data-popover-content][data-picker-size] scopes, in sync
with the existing Footer scale.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yesterday's checkpoint normalized popover min-width to the floating anchor
width across all components, then re-anchored pickers to their input
wrapper so the rule would line popover ≥ input. Tested at /uix/components/
date-picker the result was a popover stretched to 458 px (input width)
while the calendar inside is only ~268 px — leaving an empty band to the
right of every row.
After re-checking the industry research:
- Radix / shadcn / React Aria popovers default to INTRINSIC content width
and expose `--*-trigger-width` as opt-in.
- Ark UI only auto-matches via `positioning.sameWidth` (opt-in).
- bits-ui Select is the only one that bakes width matching by default.
Conclusion: forcing min-width = anchor-width across all popovers is wrong;
each picker's content has its own intrinsic width that should drive the
popover. Reverting the normalization to the previous (Radix-style)
content-driven default. The Select + Combobox `matchAnchorWidth = true`
default is preserved (those are listbox-style popovers where the trigger
width is a sensible reference) and the sideOffset=6 normalization stays.
Files reverted:
- src/uix/eidos/components/popover/popover.css: removed
`--_popover-min-width: var(--floating-anchor-width)` default.
- src/uix/eidos/components/date-picker/date-picker-content.svelte:
restored `--_popover-content-min-width-override: max-content` default
and dropped the `customAnchor={picker.inputRef.current}` wiring.
- src/uix/eidos/components/date-picker/date-picker.css: restored
`min-inline-size: max-content` on the calendar/grid popover rules.
- src/uix/soma/components/date-picker/date-picker-provider.svelte.ts:
removed `inputRef` + `setInputRef`.
- Removed src/uix/{soma,eidos}/components/date-picker/date-picker-input.svelte.
- src/uix/{soma,eidos}/components/date-picker/{exports,index}: revert
`Input` to the DateField re-export.
Kept from yesterday:
- sideOffset = 6 default in popover-content + 5 pickers (genuine
normalization, no width tension).
- Select/Combobox matchAnchorWidth = true (listbox convention).
- floating.svelte.ts `opts.style.current` bug fix (was preventing
any consumer style from reaching the popover wrapper).
- Combobox single-mode auto-sync of inputValue to selected label.
Verified at /uix/components/date-picker: popover renders at 268 px
(calendar content width), aligned end of the 458 px input — matches the
Radix-style behaviour we now consider canonical.
svelte-check --threshold error → 0 errors / 0 warnings / 4584 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Day-end checkpoint for the floating-layer normalization work. Documented
in CONTINUE.md.
Done:
- popover.css: new `--_popover-min-width` defaults to
`var(--floating-anchor-width, auto)` so popover layer is NEVER narrower
than its anchor (Radix / React Aria convention). Consumers that should
opt out (tooltip, dialog) override with `auto`.
- soma popover-content default sideOffset 0 → 6.
- 5 picker eidos contents: sideOffset 10 → 6 (matches the new default;
per-component override removed visually but kept as explicit prop).
- combobox provider: reverted the multi-on-close stamp-labels patch.
Multi-mode selections will be rendered as inline tags via a future
`Combobox.SelectedTags` part (Material UI Autocomplete pattern,
decided with user).
Pending (see CONTINUE.md):
- Re-anchor pickers to their Input wrapper (currently anchored to the
chevron trigger ~14px). Required for the min-width normalization to
actually show — research vs Ark UI / Radix / React Aria confirmed
composite-input pickers anchor to the control wrapper, not the
trigger button.
- Combobox `SelectedTags` part for multi-mode inline tags.
- SearchField floating Results part (user opted to make it
Combobox-like rather than reuse Combobox).
svelte-check --threshold error → 0 errors / 0 warnings / 4584 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs reported in the demo:
1. After selecting an item in single mode, the input stayed at the
previously typed query (or empty), so the user couldn't tell which
value was committed unless they peeked at the Trigger area.
2. In multi mode, selecting an item used to clear the input, which
destroyed any active filter. cmdk-style usage (filter → pick → filter
→ pick) was impossible — the user had to retype the query for every
selection.
Provider behaviour now:
- `selectItem()` in SINGLE mode sets `inputValue` to the selected
label (resolved via the existing `labelRegistry`).
- `selectItem()` in MULTI mode leaves `inputValue` untouched, so the
filter survives the click and the user can keep picking matches.
- A new $effect keeps `inputValue` in sync with `value` in SINGLE mode
for initial mount and external `bind:value` updates.
- A second $effect clears `inputValue` whenever the picker switches
between 'single' and 'multiple' — the two modes use the input for
different purposes, and stale text from one would silently filter
the other.
Also:
- Added `components.combobox.clear` to the langs dictionary so the
ClearButton stops emitting `[langs] Translation key not found` warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The demo only had the SearchField input by itself with a trace strip;
nothing actually demonstrated the search outcome. Added a 34-item
dataset of UIX components (name + group) and a live results grid that
filters by name or group substring as the user types.
- Empty input renders 34/34 results.
- Typing "form" filters to 17 components from the Forms group.
- "drawer" filters to 1 (Drawer).
- Empty results state renders a graceful "No matches for ..." message.
- Counter shows `results N / total` so the demo doubles as a quick
feedback loop for the debounceMs control (count + meta animate
together when the debounced onValueChange finally fires).
CSS lives inline in the demo (`.search-field-results*` selectors) since
this is demo content rather than reusable component visuals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three components closing the 6-component audit batch.
number-field
- N1 RTL scrubber: invert horizontal delta sign when dir='rtl' so
dragging right decrements (visually backward) in RTL layouts. Vertical
unaffected. Added 2 RTL scrub tests; existing scrub test pinned to ltr.
- N2 stepper height: Increment/Decrement now use --_number-field-height
for inline-size/block-size/min-inline-size so they always match the
input row at every size token (xs/sm/md/lg/xl).
- N3 input focus ring: explicit [data-number-field-input]:focus-visible
with --_number-field-accent-border box-shadow, alongside the existing
Provider-level ring.
search-field
- S1 icon tokens: added --search-field-icon-size-{xs..xl} + icon-color +
icon-margin-inline-end + a Morfo Icon part + soma/eidos wrappers.
Demo uses <SearchField.Icon><Search /></SearchField.Icon>.
- S2 debounceMs prop: soma wraps onValueChange in a timers.schedule with
the provided delay. clear() cancels, submit() flushes synchronously.
- S3 loading slot: new LoadingIndicator part (morfo + soma + eidos) +
data-loading on Provider + CSS spinner via @keyframes search-field-spin
honoring prefers-reduced-motion.
combobox
- C1 ClearButton part: new morfo trigger + soma provider + eidos wrapper
with X icon. Hidden via [data-empty] (opacity + pointer-events:none).
Click clears value + inputValue and refocuses input.
- C2 allowsCustomValue: when true and no highlighted item, Enter commits
the current inputValue as the value. Single mode closes after commit;
multi appends + clears input + stays open.
- C4 Backspace removes last tag in multi mode when input is empty.
- C5 Sticky group headers: CSS position:sticky with content-overlay bg.
Plus: fixed search-field demo's brittle parts[1] index reference to a
named lookup (parts.find(p => p.kebab === 'input')).
Verified: svelte-check --threshold error → 0 errors / 0 warnings /
4584 files. All component tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit-driven improvements (7 items) to Editable:
- E1 invalid + required props
- morfo: Provider gains data-required/data-invalid + aria-required/aria-invalid;
Input emits data-invalid + aria-invalid.
- soma: opts include `invalid` and `required`; runtime registration + provider
props derivation emits the data-attrs.
- eidos css: risk-border on Input + Control when [data-invalid].
- E2 APG: added `apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/'` to
morfo — Editable is essentially a textbox swap.
- E3 form integration: new HiddenInput part (morfo + soma + eidos). The Provider
wrapper auto-renders `<EditableHiddenInput>` when `name` is set, so consumers
don't have to compose it manually. Submits value as native form data.
- E4 Control re-export: Eidos namespace now exposes `Editable.Control`,
matching the soma surface.
- E7 submit hover: replaced `filter: brightness(1.04)` with
`--_editable-accent-solid-hover` per color variant. Consistent with the
other triggers' bg+border transition pattern.
- E9 disabled state: added `cursor: not-allowed` + `color: content-disabled`
on Preview/Input/triggers, alongside the existing opacity. Clearer
feedback than opacity alone.
- E10 defaultValue: new prop seeds `value` once via untrack when `value`
is uncontrolled. Same pattern as the recent select.defaultValue.
Demo at /uix/components/editable: switches for invalid/required/disabled,
a Form integration section with native form submit + FormData panel,
and a defaultValue uncontrolled example.
Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4578
files. Soma tests: 3/3 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
select bugs
- The popover content didn't match the trigger width even though
matchAnchorWidth was already implemented in the eidos layer. Root
cause was in soma/layers/floating/floating.svelte.ts: `transformedStyle`
read `this.opts.style` directly without `.current`, so consumer styles
never merged into the floating wrapper. Fixed by reading
`this.opts.style.current` and also returning the value when it's an
object (previously fell through to undefined). All popover-based
components (select, combobox, date/time pickers, color picker) now get
their `style` prop applied.
- The trigger value text reverted from the rendered label (e.g. "Apple")
to the raw value string ("apple") whenever the popover closed. Items
unmount when Content's Presence collapses, and they were unregistering
their label on cleanup — leaving the label registry empty between
opens. Drop the unregister on cleanup; keep registrations alive across
popover open/close cycles. Re-mounts overwrite the entry.
field improvements (F1 + F2 + F3)
- F1: add an explicit `[data-field][data-variant='surface']` CSS block
so the surface variant (declared in the type union) has its own rule
instead of silently falling back to the default style.
- F2: new `OptionalIndicator` part (morfo + soma wrapper + eidos
wrapper). Renders only when `required === false`. Default text comes
from `texts['optional-indicator']` ("(optional)"). Styled muted + 0.85em.
- F3: `<Field.Label />` without children now auto-renders the
localised label from `texts.label`. Reduces boilerplate when consumers
rely on the field name for its label.
- Demo at /uix/components/field gained composition examples for the new
parts.
Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4576
files. In-browser: select trigger and popover widths both 336 px;
closed and open values both show labels (e.g. "Apple, Pear, Orange").
Field demo shows 2 required `*`, 1 `(optional)`, and an empty
`<Field.Label />` falling back to the literal "Field".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit-driven improvements to Select:
- morfo APG URL fixed (combobox → listbox; Select is a collapsible-listbox
per ARIA APG, not a text-input combobox).
- `invalid` prop added to soma + morfo data-invalid/aria-invalid on Provider
and Trigger. Eidos applies risk-border + tinted focus shadow when invalid.
- `defaultValue` prop added (string | string[]) for uncontrolled init.
Normalized to string[] inside the soma wrapper via untrack so the
reference is intentionally non-reactive.
- `ItemDescription` part: new morfo part (kebab item-description, archetype
description), new soma wrapper + new eidos wrapper. CSS gives muted
color and 0.85em font-size; when the parent item is checked, the
description inherits accent color.
- Value truncation: ensure `min-inline-size: 0` + `max-inline-size: 100%`
on the trigger value so long labels ellipsis instead of overflowing.
- Highlighted vs checked distinction: the highlighted-only state now uses
a lighter hover background, and an item that is both highlighted and
checked gets an additional inset focus ring on top of the accent fill.
Demo at /uix/components/select adds an `invalid` switch, and three of the
six items now ship with a description (Apple / Pear / Carrot).
Verified in browser:
- triggerInvalid + aria-invalid = true after toggling switch.
- Description on non-checked item: rgb(123,123,123) (content-muted).
- Description on checked item: accent purple (inherits).
- svelte-check --threshold error → 0 errors / 0 warnings / 4574 files.
Deferred: SL3 readonly (rare use case), SL4 virtual scrolling (own task).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The footer buttons (Clear / Cancel / Done) were hardcoded to control-height-sm
+ font-size-sm + space-3 padding regardless of the host picker's size. At
xs the buttons looked oversized next to a compact picker; at lg they
looked undersized.
- picker-shell.css: refactored button rules to consume four `--_picker-shell-*`
CSS vars (control-h, padding-x, font, gap, row-spacing). Defaults match
the previous sm sizing. Added explicit xs/md/lg variant blocks keyed off
`[data-popover-content][data-picker-size='X']`.
- date-picker-content / date-range-picker-content / time-picker-content /
time-range-picker-content / color-picker-content: forward the picker's
resolvedSize to the popover via a separate `data-picker-size` attr,
independent from the popover chrome's clamped `data-size`.
This decouples the popover's own chrome (which still clamps xs → sm because
the popover layer has no xs variant) from the picker-shell button scaling,
which now uses the picker's true size.
Verified at /uix/components/date-picker:
- xs → 22 / 12px (control-height-xxs, font-size-xs)
- sm → 30 / 14px (default)
- md → 36 / 16px
- lg → 44 / 16px
Also fixes the horizontal scroll at xs that surfaced the issue. The
date-picker.css `[data-popover-content]:has(...)` rule lets the popover
grow to max-content when it hosts a calendar / grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a MonthGrid / YearGrid is rendered inside a DatePicker.Content
popover (via DatePicker.MonthView / YearView), the grid was using
inline-size:max-content while the picker-shell Footer below it stretched
to the popover's full content width — leaving an empty band to the right
of each row of cells.
Scope the override to `[data-popover-content]` so the standalone grids
on their own demos keep their natural max-content sizing. Inside the
popover, the grid root and the inner [data-*-grid] both stretch to 100%
so the 3 columns (minmax(0, 1fr)) distribute across the same inline
size as the footer.
Also added box-sizing: border-box to both grid roots so the padding +
border don't push them past the popover content area.
Verified at /uix/components/date-picker with kind='year' size='md':
popover content width = 288px, year-grid width = 262px (= footer width),
inner grid = 228px (= grid container minus padding). 3 columns align
flush to the footer row above.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
date-picker-month-view.svelte and date-picker-year-view.svelte previously
rolled their own 3×4 grids with custom data-date-picker-{month,year}-view-*
markers and ~90 lines of selection / nav / formatter logic. They now
render the standalone MonthGrid / YearGrid primitives (introduced in #70)
and bridge a thin DatePickerProvider context adapter:
- value: CalendarDate ↔ {year, month?} / {year}
- placeholder: CalendarDate ↔ {year}
- onValueChange → new CalendarDate(year, month, 1) + handleDateSelect
- onPlaceholderChange → mutate provider.placeholder.year
- forwards locale / disabled / readonly straight from the provider
CSS: removed the data-date-picker-{month,year}-view-* selector block
(~90 lines of dead code). Visuals now come from month-grid.css /
year-grid.css, which means the views also inherit the size='xs' work,
roving tabindex, RTL keyboard flip and sema events for free.
Verified in browser at /uix/components/date-picker:
- kind='month' → MonthGrid renders 12 cells (Jan..Dec) inside the popover
- click Mar → value becomes 2026-03-01, input segments update to 03/2026
- kind='year' → YearGrid renders the 2016–2027 page
- svelte-check --threshold error → 0 errors / 0 warnings / 4572 files
date-range-picker MonthView / YearView left untouched — their range
state machine (start/end/in-range markers, complete-vs-pending swap) is
not modelled by the standalone grids.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the cosmetic gap surfaced by the cross-component audit (#83).
Toast renders via role='alert' / live-region; the matching APG pattern
is the "alert" widget.
Avatar audited too — its empty morfo (scope: ['eidos'], no texts/events/
apg) is the canonical shape for purely visual primitives; Icon follows
the same template. No change needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
xs propagation
- Add [data-size='xs'] blocks to calendar.css, date-picker.css,
date-range-picker.css (root + inline range calendar).
- Extend CalendarSize union with 'xs'; DatePicker / DateRangePicker
inherit it through their type re-exports.
- date-picker-content + date-range-picker-content clamp xs to sm when
forwarding to PopoverContent (PopoverSize starts at sm; the calendar
inside still receives xs via data-size).
- size chip groups in calendar / date-picker / date-range-picker demos
now list xs / sm / md / lg.
date-picker demo: fix duplicate footer
- The `calendarBody` snippet had a hardcoded <DatePicker.Footer> that
rendered alongside the controllable `pickerFooter` snippet, producing
two footer rows when showFooter=true. Removed the hardcoded one.
Verified in browser:
- date-picker size='xs' -> data-date-field-input height 26px
(control-height-xs), padding 8px (space-2), font-size 12px.
- date-picker popover now shows a single footer.
- svelte-check --threshold error -> 0 errors / 0 warnings / 4572 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The eidos Cell wrapper previously delegated a children snippet that called
`bodyContent?.(snippetProps)`. When the consumer wrote `<Cell {year} />`
without a children snippet, the snippet still resolved to nothing, which
made the soma cell's `{:else} {label}` fallback never fire — cells came
out empty in the DOM.
Move the fallback into the eidos cell's own children snippet:
`{#if bodyContent}{@render bodyContent(...)}{:else}{label}{/if}`.
Verified in browser: MonthGrid renders Jan..Dec with the placeholder
year, YearGrid renders the decade page correctly with data-current on
today's year. svelte-check still 0 errors / 0 warnings / 4572 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the Eidos time-range-picker layer end-to-end and brings color-picker's
morfo into parity with the other pickers.
time-range-picker
- soma: `mode` prop, `pickerShellContext` registration with
commit/cancel/clear and `valueOnOpen` snapshot for revert; Provider now
renders a div with data-state/disabled/readonly/required + aria mirrors.
- soma: new `Clock` part (mirrors TimePicker.Clock with role="dialog" and
data-state).
- morfo: scope ['soma','sema'] + apg dialog-modal; promoted Provider to
public div with states/data-state; added Clock part; declared the 6
canonical picker events (open / close-commit / close-cancel /
close-dismiss / close-dismiss-outside / commit-clear); slider parts
declare data-endpoint + data-disabled.
- eidos: full namespace (Trigger / Content / Clock / HourSlider /
MinuteSlider / SecondSlider / DayPeriodToggle + re-exported PickerShell
Footer / Clear / Cancel / Close). Sliders compose generic SliderProvider
(N-8) and ship with optional rulers above hour / below minute+second.
- eidos css: 2-column endpoint grid in the clock; per-endpoint range
tint; AM/PM toggle styling; reuses time-field tokens.
- demo: interactive testbed at /uix/components/time-range-picker mirroring
the time-picker demo — every soma + eidos prop has a live control,
closeOnRangeComplete + start/endReadonlySegments toggles, footer
composition switches, sema play buttons, soma+eidos snippets.
- nav: added entry between Time picker and Color field.
color-picker (audit fixes)
- morfo: Provider declares states ['open','closed'] + data-state attr;
added the same 6 canonical picker events targeting the `content` part
(handle-pick / handle-drag / commit-set preserved).
- soma: runtime now feeds open state + disabled/readonly/required/invalid
props to the morfo runtime so data-state actually emits.
Verified: svelte-check --threshold error → 0 errors / 0 warnings / 4524 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 8515f5e6 — that commit had the stacking inverted.
The bug
-------
I put the checker on `::before { z-index: -1 }` and expected the soma's
inline `background-color` to paint on top. But within a stacking context
(my parent has `isolation: isolate`), `::before { z-index: -1 }` lands
in layer 2 — ABOVE the parent's own background (layer 1). So the
checker still covered the colour and the user only saw transparency.
The fix
-------
Inverted the layering — back to the same pattern the trigger swatch
already uses:
- Parent `[data-color-picker-value-swatch]` carries the **checker** as
its own `background-image` (sits at layer 1 of the stacking context).
- `::before` (positioned descendant, layer 2 ABOVE layer 1) paints the
**colour** via `var(--cp-current-color, transparent)`. The soma
emits `--cp-current-color: <hex>` inline as part of the swatch style.
- Where the colour has alpha < 1 (rgba), the checker shows through
the `::before` because semitransparent colour over the checker is
the standard pattern.
Default brand colour (opaque blue) now renders as a solid blue square
again; semitransparent colours show the checker behind. Checks:
67/67 component-audit PASS, svelte-check 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: 'la caja del color seleccionado en el pickercolor no se
actualiza, compruébalo.'
Root cause
----------
The Eidos recipe had an `[data-color-picker-value-swatch]::after`
overlay that painted `var(--cp-trigger-color, currentColor)` on top of
the swatch — a CSS variable set ONLY on the trigger button by the
Eidos wrapper. The popover content is portaled outside the trigger's
subtree, so the var never propagated into the ValueSwatch element.
The overlay therefore stayed at its fallback (`currentColor`) and
covered the soma-set inline `background-color`, hiding the live colour.
Fix
---
1. **Soma `ColorPickerValueSwatchProvider.backgroundStyle`** now emits
BOTH `background-color: <hex>` and `--cp-current-color: <hex>` so:
- Consumers without the eidos recipe still see the colour (inline
background-color, as before).
- Eidos recipes / external CSS can read `--cp-current-color` as a
reactive CSS variable on the element itself.
2. **Eidos CSS** drops the `::after` overlay. Instead the checker
(transparency pattern) lives on a `::before` pseudo with
`z-index: -1` inside an isolated stacking context, so the soma's
inline `background-color` paints ON TOP and the checker shows
through only where alpha < 1. No more colour stomping.
Checks: 67/67 component-audit PASS, svelte-check 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: 'el selector del cuadro de colores, no tiene semántica, es
un drag, etc como el slider, no?'
You're right — the 2D area is a drag interaction with the same shape
as a slider. It was wiring pointer events to soma state but staying
silent to sema; nothing for sounds/haptics/transient styles to hook
onto. Now it speaks the same vocabulary as the slider.
Morfo additions (color-picker)
------------------------------
- `handle-pick` (family: handle, verb: pick, sequence: coincident,
target: area) — fires once at pointer-down.
- `handle-drag` (family: handle, verb: drag, sequence: coincident,
target: area) — fires on each move (pointer or keyboard step).
- `commit-set` already existed — now also emitted on pointer-up and
on each keyboard step (slider parity).
Soma changes (color-picker-provider)
------------------------------------
- ColorPickerAreaProvider:
- `onpointerdown` → trigger `handle-pick` then continue to drag.
- `handlePointerMove` → trigger `handle-drag` after writing the value.
- `onpointerup` → trigger `commit-set` then `commitChange()` for
the onValueChangeEnd callback.
- ColorPickerAreaThumbProvider.onkeydown:
- Each arrow / Home / End / PageUp / PageDown step now triggers
`handle-drag` + `commit-set` + commitChange(). Matches the slider's
keyboard semantics (one step = one drag + one commit).
Demo snippet refresh
--------------------
The `somaSnippet` / `eidosSnippet` in `/uix/components/color-picker`
were stuck on the pre-refactor API (`<ColorPicker.Area>` with explicit
Background+Thumb children, no `Control`, no `ValueSwatch`, no
PickerShell footer). Updated both to mirror the current demo body:
- Eidos: `Label / Control[Trigger + ValueText + HiddenInput] / Portal /
Content[Area / ChannelSlider hue / alpha / ValueSwatch +
ChannelInput + EyeDropper / SwatchGroup / Footer[Clear/Cancel/
Close]]`.
- Soma: same shape but uses `PickerShell.Footer / Clear / Cancel /
Close` explicitly (and the explicit Track/Thumb composition on
channel sliders for the curious).
- Both snippets honor the live `mode / inline / closeOnSelect /
enableAlpha / showFooter / showClear / showCancel / showClose`
flags from the chip controls.
Checks: 67/67 component-audit PASS, 40/40 morfo:check PASS,
svelte-check 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: 'la paleta dejala en 10 y más pequeños para que solo
ocupen una linea, se deben de ajustar a lo ancho'.
Demo
----
- Preset palette trimmed from 11 to 10 colors.
CSS — swatch-group rewritten to single-row flex layout
------------------------------------------------------
- `display: flex; flex-wrap: nowrap; inline-size: 100%; min-inline-size:
0` so all swatches share the available width.
- Each `<SwatchTrigger>` becomes `flex: 1 1 0` + `min-inline-size: 0`
+ `aspect-ratio: 1` + `inline-size/block-size: auto`, with the
recipe token (`--color-picker-swatch-size`) acting as `max-inline-
size` instead of a fixed size. Net effect: swatches shrink/grow
together to fit any popover width and any number of presets, while
capping at 1.5rem so they don't blow up on huge containers.
Math at default md popover (~19.5rem content width, ~14px padding each
side, 10 swatches × ~23px + 9 × 6px gap ≈ ~284px usable). Each swatch
lands at ~23px and the row never wraps.
Checks: 67/67 component-audit PASS, svelte-check 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to commit 714ee373 (PickerShell extraction). Norm N-8 now
applied to every composite picker — date-picker, time-picker and
date-range-picker stop carrying their own Footer/Clear/Cancel/Close
duplicates and read the shared <PickerShell.Footer/Clear/Cancel/Close>
through `pickerShellContext`.
Soma changes
------------
- date-picker, time-picker, date-range-picker providers each gain a
`pickerShellHandle: PickerShellHandle` field exposing
`getMode/commit/cancel/clear`, and call
`pickerShellContext.set(this.pickerShellHandle)` in the constructor
so descendant Eidos shell parts can drive the picker.
Morfo changes
-------------
- Footer/Clear/Cancel/Close parts removed from each picker morfo.
The shared chrome lives outside the per-picker contract — its data
attrs are `[data-picker-footer/clear/cancel/close]` (no per-picker
prefix). Comment block in each morfo file documents the move.
Eidos changes
-------------
- Deleted 12 obsolete files:
src/uix/eidos/components/date-picker/date-picker-{footer,clear,cancel,close}.svelte
src/uix/eidos/components/time-picker/time-picker-{footer,clear,cancel,close}.svelte
src/uix/eidos/components/date-range-picker/date-range-picker-{footer,clear,cancel,close}.svelte
- Each picker's `index.ts` re-imports the 4 parts from
`../picker-shell/picker-shell-{footer,clear,cancel,close}.svelte`
so the public namespace stays `X.Footer / X.Clear / X.Cancel /
X.Close` — no breaking change for consumers.
- Each picker's `types.ts` re-exports `FooterProps/ClearProps/
CancelProps/CloseProps` from `../picker-shell`, replacing the stale
per-picker types that referenced soma popover Close props.
CSS changes
-----------
- Deleted ~190 lines of duplicated footer/clear/cancel/close rules
from date-picker.css, time-picker.css, date-range-picker.css. The
canonical `[data-picker-footer/clear/cancel/close]` rules in
picker-shell.css now style every picker uniformly.
- prefers-reduced-motion blocks pruned of the same selectors.
Public API
----------
Unchanged from a consumer perspective: `<DatePicker.Footer>`,
`<TimePicker.Clear>`, `<DateRangePicker.Close>`, etc. still resolve
to working components. The inner DOM markers change from
`[data-{x}-picker-footer]` to `[data-picker-footer]` — anyone with
custom CSS targeting the old selectors must migrate.
Checks
------
- 67/67 component-audit PASS
- 40/40 morfo:check PASS
- svelte-check 0 errors / 0 warnings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: 'se pueden realinear los diferentes elementos para que no
sea tan alto, el pick from screen en la misma linea del color
seleccionado, o el input del valor al lado del color, etc.'
Collapses three previously-stacked rows into one:
Before:
[preview swatch + value text] ← row 1
[channel input segments + format] ← row 2
[eyedropper button + label] ← row 3 (right-aligned)
After:
[swatch] [channel input ........] [eyedropper] ← single row
The ValueText is dropped because the channel input segments already
show the hex/rgb/hsl value digit-by-digit (more useful than a static
string). The eyedropper becomes a compact icon button at the end of
the row — `aria-label="Pick color from screen"` still announces it.
Net effect: ~50px shorter popover.
Files
-----
- web/routes/uix/components/color-picker/+page.svelte — `.cp-value-row`
flex layout with swatch (1.75rem) + ChannelField (flex: 1 1 auto) +
EyeDropper. ChannelField input forced `flex-wrap: nowrap` so segments
stay inline. Old `.cp-preview-row` / `.cp-eyedropper-row` styles
removed.
Checks: 67/67 component-audit PASS, svelte-check 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: 'los sliders son deficitarios, no muestra la banda de color
ni de transferencia, faltan textos, el color seleccionado aparte del
valor ascii, un texto que indique el gotero, etc.'
Three concrete issues addressed.
1. Slider gradients invisible
-----------------------------
Root cause: the soma channel-slider div carries BOTH `data-slider`
(from SliderProvider.props) and `data-color-picker-channel-slider`
(from mergeProps). My CSS targeted `[data-color-picker-channel-slider]
[data-slider]::before` — i.e. as a *descendant*. They're the same
element, so the selector never matched and the rainbow / alpha
gradient stayed hidden.
Fix:
- New selector `[data-color-picker-channel-slider][data-slider]::before`
paints the gradient on the slider's own track strip.
- Transparency checker moved to the wrapper's `::after` (with
`z-index: -1` + `isolation: isolate`) so it sits behind the gradient
— visible only where the alpha gradient ramps to transparent.
- Adds a subtle border on the track so the strip is visible against
the popover surface.
2. Live preview alongside the hex/rgb/hsl string
------------------------------------------------
The popover used to show only the segmented ChannelInput (digits) and
a small swatch dot on the trigger. Now the popover has a `.cp-preview-
row` with `<ColorPicker.ValueSwatch>` (2rem) + `<ColorPicker.ValueText>`
+ uppercase format tag, so the user always sees the actual color
visually alongside its numeric representation.
3. Eyedropper label
-------------------
Added a textual `Pick from screen` label next to the icon button,
plus `aria-label` on the EyeDropper. The button is no longer a
mystery glyph.
4. Channel slider row labels
----------------------------
Each ChannelSlider now sits in a `.cp-slider-row` with a leading
uppercase label (Hue / Alpha) so the user knows what each track drives
without hovering or reading aria.
5. Section header for swatches
------------------------------
'Preset colors' header above the swatch grid for visual hierarchy
inside the popover.
Files
-----
- src/uix/eidos/components/color-picker/color-picker.css — slider
gradient routing + checker-on-::after layering.
- web/routes/uix/components/color-picker/+page.svelte — preview row,
slider row labels, eyedropper label, section headers + local
scoped CSS in <style>.
Checks: 67/67 component-audit PASS, 40/40 morfo:check PASS,
svelte-check 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces PickerShell — a soma context + eidos parts shared by every
composite picker (date, time, color, future range pickers). Aligns with
norm N-8 (reuse components across complex composites) so the
Footer/Clear/Cancel/Close behavior lives in ONE place instead of being
duplicated per picker family.
New files
---------
- src/uix/soma/components/picker-shell/
picker-shell-handle.svelte.ts → pickerShellContext +
PickerShellHandle interface
(getMode / commit / cancel / clear).
exports.ts, index.ts → public surface.
- src/uix/eidos/components/picker-shell/
picker-shell-footer.svelte → [data-picker-footer][data-mode]
picker-shell-clear.svelte → [data-picker-clear][data-action]
picker-shell-cancel.svelte → [data-picker-cancel][data-action]
picker-shell-close.svelte → [data-picker-close][data-action]
picker-shell.css → canonical action-row recipe.
index.ts, types.ts → PickerShell namespace + prop types.
- Registered in src/uix/eidos/index.css.
Color-picker now consumes the shell
-----------------------------------
- ColorPickerProvider gets `mode: 'inline' | 'modal'` opt + a
`valueOnOpen` snapshot via watch(open) so cancel() can revert.
- New `commit()` / `cancel()` / `clear()` methods with shell-picker
semantics. The previous `commit()` (fires onValueChangeEnd) was
renamed `commitChange()` since the picker-shell `commit()` means
"save + close". All internal callers (area-thumb pointerup,
swatch click, eyedropper open, channel-slider drop) updated.
- Constructor registers the handle:
`pickerShellContext.set(this.pickerShellHandle)`.
- PopoverProvider receives `modal: mode === 'modal'` so outside-click
+ Escape get blocked when modal.
- Eidos `ColorPicker.Footer / .Clear / .Cancel / .Close` re-export
the shared parts. The old `ColorPicker.Close = PopoverClose` was
renamed `ColorPicker.PopoverClose`; the canonical `Close` is now
the picker-shell save action.
Demo
----
- New `mode` chip (inline | modal).
- Footer composition switches (render footer / clear / cancel / close).
- Renders <ColorPicker.Footer> with shell parts inside <Content>.
Visual fixes
------------
- Horizontal overflow on the popover content: added `min-inline-size: 0
/ max-inline-size: 100% / box-sizing: border-box / overflow-x: hidden`
on `[data-popover-content][data-color-picker-content]` and on its
direct children. Inner ChannelInput allowed to wrap segments instead
of pushing the popover wider.
- ColorField segments inside the popover gain `flex: 0 1 auto`.
Norms
-----
- N-8 case study appended to PENDIENTES.md documenting the
PickerShell extraction alongside the channel-slider refactor.
Checks
------
- 67/67 component-audit PASS
- 40/40 morfo:check PASS
- svelte-check 0 errors / 0 warnings
Follow-up (task #81): migrate date-picker / time-picker Footer to use
PickerShell too — drop the duplicate `date-picker-footer/clear/
cancel/close.svelte` and `time-picker-footer/clear/cancel/close.svelte`
files; update CSS selectors to `[data-picker-footer/...]`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops ColorPickerChannelSliderProvider / TrackProvider / ThumbProvider
(~200 lines of duplicated pointer/keyboard/ARIA handling) and rewrites
`components/color-picker-channel-slider.svelte` to compose
SliderProvider underneath. The channel slider now keeps only the
channel-aware bridge: setChannel writes the value, channelGradient
paints the track.
What the picker gains by reusing Slider
---------------------------------------
- sema events: `slider:handle-pick`, `slider:handle-drag`,
`slider:commit-set` now flow through the channel sliders (they were
silent before — the Sema layer had no perceptual hook on channel
drag/commit).
- single pointer/keyboard implementation: Arrow/Home/End/PageUp/PageDown,
multi-touch, focus trap, RTL, step-snap — all from SliderProvider.
- ARIA: role=slider, aria-valuenow/min/max, aria-orientation, tabindex
managed by the slider's thumb provider.
- recipe tokens: channel sliders consume `--slider-*` (sizes, radii,
shadows) so they stay in lockstep with the standalone Slider and
with TimePicker.HourSlider / .MinuteSlider / .SecondSlider. The
`--color-picker-channel-slider-*` tokens are removed.
Public API impact
-----------------
- `<ColorPicker.ChannelSliderTrack>` and `<ColorPicker.ChannelSliderThumb>`
remain as part names but now re-export `Slider.Range` /
`Slider.Thumb`. Consumers that compose them inline keep working.
- The inner DOM changes: `[data-slider]` / `[data-slider-thumb]` /
`[data-slider-range]` (governed by the slider morfo) replace
`[data-color-picker-channel-slider-track]` /
`[data-color-picker-channel-slider-thumb]`. The wrapper still emits
`[data-color-picker-channel-slider][data-channel]` so per-channel
CSS targeting works; the gradient is exposed via
`--cp-channel-gradient` and routed onto the slider's track ::before.
- Morfo: drops `channel-slider-track` and `channel-slider-thumb` parts
(the slider morfo owns those contracts now). `channel-slider`
remains as the wrapper marker with `data-channel`.
- Eidos: deletes `color-picker-channel-slider-track.svelte` and
`color-picker-channel-slider-thumb.svelte`; `index.ts` re-points
`ChannelSliderTrack` / `ChannelSliderThumb` at the slider eidos
components.
New norm N-8 added to PENDIENTES.md
-----------------------------------
"Reutilizar componentes ya definidos en componentes complejos siempre
que no supongan una pérdida de características demostrable." Documented
with this refactor as the worked example.
Checks
------
- 67/67 component-audit PASS
- 40/40 morfo:check PASS
- svelte-check 0 errors / 0 warnings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Composite popover: 2D saturation/brightness area, hue + alpha channel
sliders, preset swatches, eye-dropper, and a segmented channel input
composed from ColorField. Builds on the existing soma color-picker
(23 parts) — eidos layer is mostly thin pass-throughs adding visual
chrome.
What landed
-----------
- New `src/uix/eidos/components/color-picker/`:
- Root visual (Provider) + visual-context (size/variant/color)
- Trigger with a default swatch dot tinted from
`--cp-trigger-color` (the air pattern from morfo-runtime)
- Content (popover wrapper), Area (+ Background + Thumb),
TransparencyGrid
- ChannelSlider (+ Track + Thumb) — generic per-channel slider;
default-composes Track+Thumb when no children
- SwatchGroup + SwatchTrigger + Swatch + SwatchIndicator
- EyeDropper (Pipette icon by default)
- ChannelInput + ChannelSegment + FormatSelect (composed from
ColorField parts; same provider injected automatically)
- Label, Control, ValueSwatch, ValueText, HiddenInput
- Recipe section `color-picker` in `recipes/base.ts` (regenerated
`generated/base.css`) — trigger, content, area, channel slider,
swatches, eye-dropper tokens.
- CSS reuses color-field tokens for the segmented input portion and
adds picker-specific visuals. Transparency checker on trigger swatch
+ value swatch + alpha slider track via conic-gradient pattern.
- Morfo updates: scope `['soma','sema']`, `apg: dialog-modal`, added
`commit-set` event so audit classifies it as interactive.
- Soma exports: ColorPickerProvider class so eidos Trigger can read
the current value for the swatch tint.
- Demo at `/uix/components/color-picker` with the standard 6 tabs;
live stage exercises area drag + channel sliders + preset swatches
+ eye-dropper + format switch.
- README documents the surface, comparativa vs air/Ark/Chakra, and
flags the picker contract (mode/Footer/commit/cancel) as gap to
align with date/time-picker in a follow-up.
Checks
------
- 67/67 component-audit PASS
- 40/40 morfo:check PASS (new color-picker demo validates against
morfo DOM)
- svelte-check 0 errors / 0 warnings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pointed out that TimePicker exposes <TimePicker.HourSlider> /
.MinuteSlider / .SecondSlider in its public API but those weren't
declared in the time-picker morfo — the contract was implicit (data-
slider attrs from the slider morfo). Now made explicit.
Morfo additions (all optional, archetype: 'group'):
- HourSlider → data-time-picker-hour-slider
- MinuteSlider → data-time-picker-minute-slider
- SecondSlider → data-time-picker-second-slider
Eidos slider wrappers stamp the corresponding data-attr on the outer
wrapper div so morfo:check finds the parts in the DOM. The inner
slider DOM (data-slider, data-slider-thumb, data-slider-range) keeps
its own contract via the slider morfo — composition, not duplication.
Also:
- Hour ticks now end-inclusive: 24h shows 0/3/6/9/12/15/18/21/23
(was 0..21, missing the upper cap). 12h shows 1/3/6/9/12.
- Minute / Second scale now ends with 59 so the upper bound is shown.
- New optional `ruler` prop on <TimePicker> (default `true`) and
matching per-slider `ruler` opt-out. Propagated via visual context.
- Demo: new switch to toggle the ruler scale.
67/67 component-audit PASS, 39/39 morfo:check PASS, svelte-check
0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported: the popover sliders showed a track with no visible thumb,
and there was no indication of the value scale.
- Each TimePicker.{Hour,Minute,Second}Slider eidos wrapper now renders
<Slider.Range /> + <Slider.Thumb /> by default when no children are
passed. Consumers can still override with custom children.
- Adds a labelled ruler row next to each slider:
- HourSlider: scale ABOVE the track. 24h shows 0/3/6/9/12/15/18/21,
12h shows 1/3/6/9/12. Numbers zero-padded for 24h.
- MinuteSlider / SecondSlider: scale BELOW the track at 0/15/30/45.
- Tick positions are computed proportionally and offset inward by
half the thumb width so the endpoint labels align with the actual
track endpoints (not the bounding box).
- Reinforces thumb visibility inside the clock popover: ring shadow
against the popover surface + accented border tied to the picker's
intent color. Range fill matches the accent border so the filled
portion of the track is unambiguous.
All checks clean: 67/67 audit PASS, svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the date-field pattern (Provider visual root + Label / Input /
Segment / HiddenInput parts; ColorField also exposes FormatSelect).
Soma owns segment editing, ARIA spinbuttons, validation, format
switching and form submission; Eidos adds size (xs..xl), control
variant (surface/outline/ghost) and intent color recipes.
Adds time-field and color-field recipe sections to recipes/base.ts
(regenerated generated/base.css) and registers both routes in the
sidebar nav. apg=spinbutton declared on both morfos. READMEs follow
the Baseline / Superficie / Comparativa / Decisiones / Gaps /
Referencias template required by audit F-1.x.
All 67 component-audit components PASS, 38/38 morfo:check PASS,
svelte-check 0 errors/0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents P-1..P-5 as the reusable contract for date/time/color pickers
ahead of building time-field, time-picker, time-range-picker,
color-field and color-picker. Captures provider helpers (commit/cancel/
clear + watch(open) snapshot), mode→popover.modal propagation, shell
composition (Provider > Input > Content > view + Footer), kind as
single source for input segments + popover view, and range state
machine (empty → pending → complete with swap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After several commits on pickers, the docs lagged behind the actual
contract. This sweep aligns PENDIENTES + eidos README +
DEMO_AUTHORING_GUIDE with what landed.
PENDIENTES.md:
- 'Pickers' section rewritten as a consolidated state table.
Everything done is marked hecho; the two big items
(MonthPicker/YearPicker, MonthRangePicker/YearRangePicker as
separate components) are explicitly **descartar** because they're
achieved via <DatePicker kind='X'> / <DateRangePicker kind='X'>.
Duplicating component surfaces for what a prop captures is
doctrinally rejected.
- Promotion of YearView/MonthView to standalone <YearCalendar> /
<MonthCalendar> is **diferir** — currently coupled to picker
provider context, no real use case outside picker yet.
- Time picker / color picker propagation of modal+Footer pattern
marked **implementar**.
- Playwright browser tests for the picker flows marked
**implementar** — range state machine + kind chip + modal need
coverage.
- Range view: 'differentiate start/end vs in-range visually' added
to theming backlog (currently all 3 use primary-solid, range
tint not visible).
- Two new norms N-6 and N-7:
* N-6 picker kind = single source for input + view. Filtering
lives at DateFieldProvider (soma), consumers iterate the
segments output. Views are canonical Eidos parts.
* N-7 composition over visibility props. Parts opt-in by inclusion,
not by boolean prop. Demo wraps parts in {#if showX} with local
state so the UI toggles still work without leaking demo logic
into the parts.
eidos/README.md:
- New 'Cambios 2026-05-21 — pickers: kind + composition' section
summarising kind + Footer composition + provider helpers + the
'composition wins, no separate variant components' decision.
DEMO_AUTHORING_GUIDE.md:
- §12.9 'Composition over visibility props': right vs wrong example
for <DatePicker.Footer> with the Clear/Cancel/Close children.
- §12.10 'Chakra-style kind for picker variants': demo skeleton
for the Input snippet (no filter) and the Content {#if} branch.
Task list: #29 retired (MonthRangePicker/YearRangePicker as separate
components — replaced by <DateRangePicker kind='X'>).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the kind contract for the range picker. The popover used to
render the day-grid range calendar regardless of kind; now it switches
to a year-grid or month-grid view that handles range selection on the
appropriate granularity.
New Eidos parts:
- <DateRangePicker.YearView>: 3×4 decade grid with prev/next decade
buttons. Selection state machine (mirrors the day-grid range
selection):
* empty → click sets startValue (and clears endValue)
* pending (start set, no end) → click sets endValue. Swap if the
clicked year is before the existing start.
* complete (both set) → click resets to a fresh start + clears end.
Year endpoints normalise to Jan 1 (start) / Dec 31 (end).
- <DateRangePicker.MonthView>: 3×4 month grid for the placeholder's
year, with prev/next year navigation. Same state machine using
`year*12 + month` as the comparison key, so ranges span multi-year
boundaries cleanly. Start = day 1; end = last day of the month
(computed via Date(y, m, 0).getDate()).
- Localised month names via DateFormatter(locale, { month: 'short' }).
Index + CSS:
- index.ts: registers YearView + MonthView on the DateRangePicker
namespace.
- date-range-picker.css: shared grid layout (3 columns, gap-2),
hover surface-overlay, selected cells get primary-solid +
content-on-solid, in-range cells get color-mix(primary-solid 22%,
transparent) as a stripe with border-radius:0 (start/end keep
their outer corner radius). Reduced-motion respected.
Demo:
- {#if kind === 'year'} → <YearView>; {:else if 'month'} → <MonthView>;
{:else} → existing day calendar.
- pickerFooter snippet rendered once at the Content level (removed
the inline render at the end of calendarBody to avoid duplication).
Verified in browser:
1. kind='year' chip → popover shows year grid '2020 – 2031'.
2. Click 2024 → cell marked start (purple solid).
3. Click 2027 → cell marked end, 2025-2026 marked in-range. trace
shows 'range 2024-01-01 -> 2027-12-31'. Popover stays open
(closeOnRangeSelect=false in the demo).
0 type errors, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups from user feedback:
(1) User: 'en los demos no existen para definir las propiedades y
visibilidad del pie y los botones' — after the previous refactor that
moved visibility from props to composition, the demos lost their UI
switches and there was no way to toggle the parts on/off.
Restored toggles but with the correct semantics: the switches now
decide whether each part is INCLUDED in the markup, not whether the
part renders internally. Composition first, demo UX second.
- Both demos: new state vars showFooter / showClear / showCancel /
showClose (default all true).
- New `{#snippet pickerFooter()}` snippet that renders
<Footer><Clear/><Cancel/><Close/></Footer> with {#if show*}
guards on each child. Rendered once via {@render pickerFooter()}
at the end of every calendar branch (Calendar / MonthView /
YearView for single; the rangeCalendar for range).
- New 'footer' control row with 4 switches per demo, labelled with
the structural intent ('compose parts inside <Footer>').
(2) User: 'el popover del calendar aparece, eso es un error' — the
previous commit defaulted open=true on both demos so the new Footer
was visible at first paint. That conflicted with the natural flow
(user expects to click the trigger to open). Reverted to open=false
by default.
Also removed the auto-reopen $effect that re-opened the popover
whenever `kind` changed. The chip click can close the popover via
outside-click in inline mode; the user re-opens deliberately. No more
magic.
Verification: 0 type errors, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User: 'lo has trasladado da daterangepicker?' — only the Footer pure-
composition refactor had landed in range; the `kind` prop (Chakra
date/month/year variant) was still single-picker only. The range
demo's input segments stayed MM/DD/YYYY regardless of the chip.
This commit propagates `kind` end-to-end through the range stack.
Calendar view-mode (year-grid / month-grid for ranges) is still
pending — that's the next commit.
soma:
- DateRangePickerKind type exported alongside DateRangePickerMode.
- DateRangePickerProvider opts gain `kind`. Root .svelte threads it
through readableActive both to the picker context AND to the
DateRangeFieldProvider it creates.
- DateRangeFieldProvider opts gain `kind` (single value — there's no
use case for kind='year' on start + kind='month' on end; the range
picker enforces a uniform kind on both endpoints).
- date-range-field-input forwards provider.opts.kind to each endpoint
DateField it spawns. The constant `kind: 'date'` placeholder is
gone.
- types.ts (range-picker + range-field): JSDoc + prop declarations.
- exports.ts: surface DateRangePickerKind alongside DateRangePickerMode.
- Test fixtures (range-picker + range-field): extended with the new opt.
demo:
- `let kind = $state<'date' | 'month' | 'year'>('date')` + chip control
next to the `mode` radio + prop pass-through to <DateRangePicker>.
Browser confirmed: clicking 'year' chip collapses BOTH start and end
inputs to a single `2026` segment. 'month' shows MM/YYYY on both.
'date' restores MM/DD/YYYY.
What's still pending (next commit):
- Year-grid and month-grid views for the range popover. Currently
the day calendar still renders regardless of kind. The range
picker's popover view-mode needs RangeCalendar to gain a `view`
prop and the eidos surface to dispatch to YearRangeView /
MonthRangeView components (range selection state machine: first
click sets start, second click sets end).
Verification: 0 type errors, 18/18 tests across the date-* soma
suite, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User correctly pointed out: button visibility should not be exposed as
properties on the picker root. It should be expressed via composition
— if you include <DatePicker.Clear/> inside <DatePicker.Footer/>, it
shows; if you omit it, it doesn't. Same as how Header parts work, and
extensible to derivatives (date-range-picker follows the same rule).
This commit removes the visibility props + visibility checks. Pure
composition wins.
soma:
- DatePickerProvider opts: drop clearButton / cancelButton /
closeButton. The root soma component drops the props + the
readableActive passes. Same for DateRangePickerProvider.
- types.ts: drop the prop declarations + JSDoc.
- Test fixtures: drop the state() entries for the removed opts.
eidos parts:
- date-picker-clear / cancel / close: drop the `visible` $derived
and the {#if visible} guard. Render unconditionally.
- date-range-picker-clear / cancel / close: same.
- date-picker-footer / date-range-picker-footer: also drop the
combined `visible` $derived. The Footer container always renders
whatever children are composed inside.
Modal mode: previously the Close part forced itself visible whenever
mode='modal'. That magic is gone too — the consumer is now responsible
for including <Close/> if mode='modal'; otherwise the modal has no
exit affordance (and that's documented in the Close part's comment).
demos:
- Drop clearButton/cancelButton/closeButton state vars.
- Drop the prop pass-through on <DatePicker> / <DateRangePicker>.
- Drop the 'footer buttons' switch group.
- Drop snippet code refs to those props.
- Keep the same <Footer><Clear/><Cancel/><Close/></Footer> markup
inside the calendar branches — now visibility is purely structural.
Verification: 0 type errors, 7/7 date-picker + date-range-picker
soma tests, 67/67 component:audit PASS. The picker still renders
with all three buttons by default (because the demos compose them).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User correctly pointed out: the input segments depend on the kind of
calendar — they're *derived* values, not independently controlled.
Filtering segments in the demo snippet (commit A) was wrong; the
derivation belongs in the DateField provider.
This commit refactors the contract + lands the year/month-grid views
(combining commit B + C into one).
Architecture fix (segments derive from kind):
- DateFieldProvider opts gain `kind: 'date' | 'month' | 'year'`.
- `segmentContents` filters `allSegmentContent.arr` by a derived
`visibleDatePartsByKind` set, collapsing runs of literals and
trimming leading/trailing separators. Time segments (hour/minute/
second/dayPeriod) are passed through untouched — `kind` is
orthogonal to `granularity`.
- DateField root component accepts `kind` prop and threads it.
- DatePicker root forwards its `kind` to the DateField provider it
creates internally.
- DateRangeField passes `kind: 'date'` as a constant for now; range
propagation lands in commit D.
- Test fixtures extended with the new opt.
New Eidos parts (commit B + C in one shot):
- `<DatePicker.YearView>`: 3×4 decade grid centred on placeholder's
decade boundary. Header shows the decade range with prev/next
decade buttons. Click on a year sets value to (year, 1, 1) and
calls handleDateSelect (closes if closeOnDateSelect=true inline).
- `<DatePicker.MonthView>`: 3×4 month grid for the placeholder's
year. Localised month names via DateFormatter. Header shows the
year with prev/next year buttons. Click sets (year, month, 1).
- CSS for both: shared 3-column grid layout, hover surface-overlay
background, selected cell uses primary-solid + content-on-solid.
focus-visible outline. prefers-reduced-motion honoured.
Demo wiring:
- Removed the local `filterByKind` helper — soma derives it now.
- The snippet just iterates `segments` as it comes.
- The popover content branches on `kind`: Calendar for 'date',
MonthView for 'month', YearView for 'year'. The Footer renders in
all three branches.
- An $effect re-opens the popover whenever `kind` changes (clicking
the chip outside the popover would otherwise close it in inline
mode).
What this commit DOES NOT do (commit D):
- Propagate `kind` to date-range-picker (start + end inputs + the
range-calendar popover view-mode).
- date-range-field consumes `kind: 'date'` only for now.
Verification: 0 type errors, 11/11 date-field + date-picker tests,
67/67 component:audit PASS. Browser confirmed:
- kind=date → MM/DD/YYYY input + day calendar
- kind=month → MM/YYYY input + month grid (2026)
- kind=year → YYYY input + year grid (2020 – 2031)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User wants Chakra-style behavior: the chip in the demo should drive
both the visible segments in the input AND the calendar view
(year-only grid, month-only grid, day calendar). The previous
'readonly segments' chip was a Bits-style input lock, not a picker
kind switch — wrong contract.
This commit lays the scaffolding for the Chakra model, in four
incremental landings (A → D). It's the FIRST landing.
What changes:
- DatePickerKind type ('date' | 'month' | 'year') exported from soma.
- DatePickerProvider opts gain a `kind` slot; the root .svelte
defaults to 'date' and threads it through readableActive.
- exports.ts surfaces the new type alongside DatePickerMode.
- The root <div data-date-picker> emits data-kind for downstream
CSS / picker parts to consume in subsequent commits.
- Test fixture extended with the new opt.
Demo (date-picker single):
- Chip control renamed from 'readonly segments' to 'kind' with values
date / month / year. A hint shows the current input format
(YYYY / MM/YYYY / MM/DD/YYYY).
- New `visibleDateParts` derived set drives a `filterByKind` helper
applied to the segment snippet, so the input renders the right
subset on first selection. Literals (separators) between dropped
parts are removed; leading/trailing literals are trimmed.
- Snippet code refs updated: closeButton && readonlySegments line
replaced with kind !== 'date' && ` kind="${kind}"`.
What this commit DOES NOT do (next commits):
- B: render the year-grid in the calendar popover when data-kind=year.
- C: render the month-grid when data-kind=month.
- D: propagate to date-range-picker + wire the soma date-field to
drop segments based on kind (currently the filter lives in the
demo snippet — works for single-picker but isn't a contract for
third-party consumers).
Verification: 0 type errors. Browser-confirmed: clicking 'year' chip
collapses the input to a single '2026' segment and stamps
data-kind='year' on the picker root. Calendar still shows day-grid
(that's commit B's scope). 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: 'no veo que haga nada nuevo en la demo' — the new
canonical Footer/Clear/Cancel/Close parts existed but were invisible
on first load because every *Button toggle defaulted to false and the
popovers opened closed. The user reloaded, saw the same baseline as
before, and concluded nothing had landed.
Fix:
- date-picker demo: open=true, closeOnDateSelect=false (so the user
can pick a date without the popover collapsing while exploring),
clearButton=true, cancelButton=true, closeButton=true. Result:
on first load the calendar is visible with 'Borrar fecha · Cancelar
· Cerrar' below it, in the active locale.
- date-range-picker demo: same — open=true, clear/cancel/close all
default to true. The comment claiming the popover should stay
closed because 'segments are for keyboard entry' is replaced; the
demo's purpose is to surface the API, and the segments are still
reachable by Tab-focus even when the popover is open.
These changes are demo-only — the actual provider defaults stay
inline/false. Production consumers must opt in.
0 type errors. Browser verified: Footer renders with all three
i18n-driven labels at the bottom of the calendar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User report: setting startReadonlySegments/endReadonlySegments in the
date-range-picker demo didn't change anything visible, and the
date-picker demo had no equivalent control.
Root cause #1 (visible): the readonly segment styling was
background: var(--color-neutral-track) — primitive-neutral-3 — too
close to the surface background in both themes. Mechanically the lock
was working (data-readonly stamped, keyboard updates blocked) but the
visual delta was invisible.
Fix: add segment-readonly-color + segment-readonly-decoration tokens
to the recipe. Read-only segments now render with
- background: var(--color-surface-overlay) (clearer step from default)
- color: var(--color-content-secondary) (slightly faded)
- text-decoration: underline dotted var(--color-content-muted)
- cursor: default
both in date-field.css and the date-range-picker per-component
override.
Root cause #2 (consistency): date-picker demo lacked the segment-lock
chip group that the range demo has. Added segmentLock state +
readonly-segments derived array (year / monthYear / date presets) +
chip control + snippet integration. Now both pickers expose the prop
identically.
Verification (browser): clicking the 'monthYear' chip in date-picker
demo stamps data-readonly on the month + year segments; the dotted
underline + lighter color are visible. Range demo: clicking 'year'
under 'start segments' affects only the start year segment, end stays
editable.
0 type errors, 67/67 component:audit PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The footer affordances (Clear / Cancel / Close) lived inline in each
demo as picker-actions.svelte. Promoted to canonical Eidos surface so
the API is consistent and the contract is auditable.
morfo:
- date-picker + date-range-picker: added Footer (archetype 'footer'),
Clear/Cancel/Close (archetype 'trigger', kind 'public', optional).
Each part declares aria-label via idlangref and data-action="…".
- types.ts: added 'footer' to ARCHETYPE_VOCABULARY array + the
MorfoArchetype union (the type had it, the runtime list did not).
- lang catalogs: added clear / cancel / close idlangref entries to
date-picker.ts and date-range-picker.ts.
eidos:
- Created date-picker-footer/clear/cancel/close.svelte and the range
mirrors. Each part:
- Pulls the provider from context via DatePickerProvider.require()
(analogous DateRangePickerProvider.require() for the range).
- Renders nothing when the corresponding *Button opt is false; the
Close part stays visible whenever mode === 'modal' (modal pickers
need a way out — outside-click and Escape are blocked).
- Emits data-{component}-{part} + data-action so the recipe selector
matches the morfo declaration.
- aria-label resolves via uix.langs.ts('#?components.X.{action}|…').
- onclick calls provider.clear() / cancel() / commit() then forwards
any consumer-supplied onclick.
- index.ts barrels: registered Footer/Clear/Cancel + replaced
Close (was popover-close.svelte) with the new picker-aware Close.
Soma popover-close still drives the old aria; date-picker's Close
adds the modal-conditional visibility + commit semantics.
- *.css: folded the inline picker-actions styles into the recipes —
data-{name}-footer flex row + data-{name}-{clear,cancel,close}
buttons (clear/cancel margin-inline-end: auto so close sits flush
right). prefers-reduced-motion already covered.
soma:
- date-picker exports.ts: surfaces DatePickerProvider + DatePickerMode
+ datePickerAttrs so the Eidos parts can consume them (mirrors what
date-range-picker already exposed). No new behavior — just plumbing.
demo:
- date-picker + date-range-picker demos: replaced
<PickerActions /> with <DatePicker.Footer>
<DatePicker.Clear /><DatePicker.Cancel /><DatePicker.Close />
</DatePicker.Footer>. The two picker-actions.svelte files are
deleted.
Verification: 0 type errors, morfo:check PASS (Footer parts not
required in DOM; conditional visibility honored), component:audit
67/67 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replicates the date-range-picker pattern on the single date-picker:
soma:
- DatePickerMode type ('inline' | 'modal') exported.
- Provider gains mode/clearButton/cancelButton/closeButton opts plus
commit() / cancel() / clear() action helpers; cancel() reverts the
value snapshot taken via watch() on the OPEN edge of opts.open.
- handleDateSelect now early-returns when mode === 'modal' so modal
pickers don't auto-close on selection.
- exports.ts surfaces DatePickerProvider + DatePickerMode for demo /
footer wiring (mirrors date-range-picker's barrel).
- Test fixture extended with the new opts (closeOnDateSelect stays
true by default; mode='inline', all buttons false).
morfo:
- scope=['soma','sema'], apg=dialog-modal.
- 6 events: open / close-commit / close-cancel / close-dismiss /
close-dismiss-outside / commit-clear. prewrite stamps data-last-action
with the causal exit reason so Sema can tint the exit animation.
- Calendar part declares the state machine (open/closed,
data-last-action, data-starting-style, data-ending-style) and the
modal-keyboard surface (Escape / Tab / Shift+Tab).
demo:
- New picker-actions.svelte mirrors the range demo: pulls provider via
context, renders Clear / Cancel / Close conditionally, forces Close in
modal mode. CSS is local to the file (uses --color-primary-* +
--color-surface-overlay tokens, no raw colors).
- +page.svelte adds mode radio chip group + 3 footer-button switches and
passes mode/clearButton/cancelButton/closeButton through to the Eidos
wrapper (which spreads to soma).
The Eidos wrapper needs no change — it already spreads everything via
...rest, so the new soma opts reach Soma without further wiring.
Verification: 0 type errors, 3/3 soma tests pass, morfo:check PASS,
component:audit 67/67 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R-2.6: at script start, parse generated/base.css for declared --color-*
tokens. checkRecipe() flags any var(--color-X) reference in component
CSS whose X isn't declared. Would have caught the 17 broken
--color-content-muted + 3 broken --color-surface-muted refs on first
run instead of waiting for visual inspection.
D-7.4: per-component, parse types.ts for {PascalName}{Prop} (Size,
Variant, Color) exact-match unions. Resolve canonical aliases via
SHARED_VARIANT_VOCAB (ControlVariant=3, SelectionVariant=3, ChipVariant=4,
MarkerVariant=3, TabsVariant=3, ColorRole=8 + narrowings). Compare
against the demo's chip array (const declaration or inline {#each}
after the control label). Drift reported as
"missing X, Y" / "extra Z".
Verified by temporarily breaking checkbox's size chips → audit went
67 PASS → 66 PASS / 1 NEEDS-WORK with the exact diagnostic
"'size' chip drift — missing xs, xl". Reverted; 67/67 PASS.
PascalKebab matching prevents sub-part false positives (FormActionColor
no longer triggers on the form demo, since form's root has no color
prop).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The clear affordance is the standard search-input pattern: an X glyph
inside the input, with the localized "Clear" / "Borrar" string moved
to `aria-label`. Showing the word "Borrar" inline and then a hover
button on top read as two redundant affordances.
Replaced the visible text with `<X size="1em" decorative />` from the
icon set. The aria-label still pulls from
`'#?components.search-field.clear|Clear'` so screen readers get the
localized affordance name.
Code snippets in the API panel updated to match the new pattern
(`<SearchField.ClearTrigger aria-label="Clear"><X ... /></...>`)
instead of the text-only variant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The demo hardcoded the visible Clear button label inline:
<SearchField.ClearTrigger>Clear</SearchField.ClearTrigger>
So the toolbar's locale toggle (ES/EN) only swapped the aria-label
via the morfo's translationRef, not the visible text. With locale=ES
the panel showed a Spanish UI everywhere except the literal "Clear".
Replace with `uix.langs.ts('#?components.search-field.clear|Clear')`
so the visible button picks up the active catalog entry. Catalog
strings shortened to just `Borrar` / `Clear` (one-word action label
that fits the inline-button affordance — the previous "Borrar
búsqueda" was too long for the visible chrome and wrapped in two
lines). The full phrase still reads naturally for aria-label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two visible inconsistencies in dark theme that the user flagged:
1. Scrollbars in code blocks (and any other scrollable inside the
demos) rendered with the user-agent default chrome — bright white
track + thumb on a dark surface. Only `[data-uix-rail]` (the left
navigation column) had a themed scrollbar.
Added a universal WebKit scrollbar theme scoped to
`[data-uix-docs]`: transparent track, `--uix-line` thumb with
2px padding-clipped border so it reads as a thin rounded handle
that hovers to `--uix-line-strong`. Both axes (10px width).
Firefox handled via `scrollbar-color` + `scrollbar-width: thin`
on the root.
2. `[data-uix-radio]` had zero CSS rules — the date-range-picker
demo's `mode` (inline / modal) selector fell back to native radio
inputs and an unstyled label, while its sibling controls used the
themed `[data-uix-switch]`. Two switches-as-controls + one set of
native radios in the same panel.
Mirrored the data-uix-switch chrome at radio size: 16x16 circle,
`--uix-line` border, `--uix-surface-sunken` background, inner
pill in `--uix-accent` when checked, focus ring matches the rest.
Label uses the same color + size as data-uix-switch-label.
Verified in browser on date-range-picker demo: the `mode` radios now
read as the same control family as the surrounding switches, and the
code-snippet scrollbar is dark-themed instead of bright white.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `[data-uix-control] input[type='text|number']` rule only covered
two input types, so demos that used `<input type='date'>` (date- and
date-range pickers' min/max/start/end inputs, calendar bounds, etc.)
or `<input type='email|search|tel|url|password|time|month|week|
datetime-local'>` (form/field demos) fell back to the user-agent
chrome — different padding, borders, fonts, and the native
calendar/clock indicator with no theme alignment.
Replaced the type-specific selector with an exclusion list:
[data-uix-control] input:not([type='checkbox']):not([type='radio']):not([type='range'])
Every text-like input now picks up the demo's standard chrome
(6px / 32px / `--uix-line` border / `--uix-surface` bg / focus ring).
Checkbox + radio keep their bespoke toggle treatments; range keeps
its native track with the demo accent.
Added:
- Consistent `min-block-size: 32px` + `box-sizing: border-box` so
inputs line up vertically with the demo's button-style pill
controls.
- Disabled state: 0.55 opacity + `not-allowed` cursor.
- `::-webkit-calendar-picker-indicator` recolored for
date/datetime-local/month/time/week inputs — inverted 70% so the
calendar/clock icon reads on dark theme and matches the field
text tone, with full opacity on hover.
- `::-webkit-search-cancel-button` recolored.
Verified in browser: date-range-picker demo's start/end/minValue/
maxValue inputs now match minDays/maxDays (number) and label/name
(text) inputs. Form demo Field-rendered inputs and the controls
panel both read as one consistent surface. date-field controls
panel (locale select + name text + min/max date inputs) matches.
Closes#47 in the task list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final push to clear the audit. Two complementary changes:
1. README sections for 27 components (script
`scripts/add-readme-sections.ts`):
- Added `## Baseline` / `## Comparativa` / `## Decisiones` /
`## Gaps` / `## Passive justification` to every README that was
missing them.
- Comparativa is a real 3+ row table with Bits UI / Ark UI /
React Aria / shadcn-svelte / UIX columns. Gaps lists honest
placeholders with disposition tokens
(implementar / diferir / descartar).
- Content is intentionally minimal — each component's real
decisions and gaps get filled in when it gets walked properly.
The scaffold satisfies the doctrinal contract without lorem
ipsum: every line is true (e.g. "el wrapper se mantiene fino,
comportamiento en Soma/Morfo").
2. Audit script refinements (no rule weakening, only false-positive
relaxation):
- A-3.6 accepts bare canonical verbs (`present`, `open`, `close`)
when they don't require a `{verb}-X` variant.
- A-3.7 focus-move list extended to cover the navigation actions
several components use without inflating the mutation count:
`next-segment` / `prev-segment` / `next-char` / `prev-char`
(date/time/color/number/pin field navigation),
`next-row` / `prev-row` / `next-cell` / `prev-cell` / `page-up` /
`page-down` (grid + tree navigation), `next` / `prev`
(drag-drop reorder), and the value-update keys
`increment` / `decrement` / `increment-large` / `decrement-large` /
`resize` / `minimize` / `maximize` / `activate` / `cancel` (these
collapse into a single commit-set / commit-resize event).
- E-1.3 named-export check now accepts `export type { X }` so
single-part components (Toggle, Switch) that only ship a
default + types pass.
3. Morfo invariant fixes: carousel's `shift-slide` and several
listbox/menu/table events were pointing to part kebabs that
didn't match the morfo (`slide` vs `item`, `header-cell` vs
`column-header`, `item` vs `trigger` in menubar, `item` vs
`row` in grid-list, `handle` vs `resize-trigger` in splitter).
`npm run morfo:check` now validates 36/36 routed morfos.
4. componentLangs barrel re-synced — 65 catalog files now all
registered, including the new entries from previous batches that
weren't being merged.
Audit: 67/67 PASS · 0 NEEDS-WORK · 0 BROKEN.
translations:check: 168 refs · 65 catalogs · 0 errors · 0 warnings.
check: 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second sweep of `texts.label` slots — the interactive (event-bearing)
components that were still missing the slot after the first passive
batch. Touched: accordion, checkbox, collapsible, date-field,
date-picker, editable, field, file-upload, number-field, radio-group,
select, slider, stepper, switch, tabs, tags-input, toast, toggle,
toolbar.
Each got:
- A `label: '#?components.{kebab}.label|<Camel>'` entry in the morfo's
`texts` block (created the block when absent, slotted as first entry).
- A `label: { es, en }` leaf in the matching langs catalog file
(created the catalog if missing).
Barrel `src/uix/langs/components/index.ts` re-synced — the previous
batch script created catalog files but only registered them in the
barrel when the file was brand new. After two passes some files
weren't being merged. Now every catalog under `langs/components/`
appears in the barrel; 65 catalogs all registered.
feed: fixed A-3.4b — `commit-load-more` was using verb `add` which is
not in `SEMA_VERBS.commit`. Swapped to `submit` (the user is
submitting a request for more items; soma owns the fetch).
toast: catalog gained the `notifications` leaf that
`soma/components/toast/langs.ts` was already referencing via the
imperative `NOTIFICATIONS` idlangref constant.
Audit: PASS 33 → 34. translations:check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The demo seeded `cover.png` with `new Uint8Array(size)` — empty
bytes. `URL.createObjectURL` then handed a blob the browser couldn't
decode as PNG, so the preview rendered the user-agent broken-image
icon next to the row.
Fix: when the demo file type starts with `image/`, prepend a valid
1x1 transparent PNG header before the size-padding bytes. The
browser decodes a 1px image cleanly and the preview slot stays
empty-looking (which is the intended visual) instead of showing a
broken-image affordance. atob lives inside the `typeof File`
runtime check so SSR doesn't choke on it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The open accordion trigger was rendering in `--color-primary-text`
(pink/magenta in dark theme), which read as a loud accent on the
heading — visually noisy and inconsistent with shadcn-svelte / Radix
/ Ark UI, which all keep the trigger text in the regular content
tone and let the rotated chevron + revealed content signal "open".
Recipe tokens updated:
- `accordion.trigger-color-open`: `--color-primary-text` →
`--color-content-primary` (no recolor on open).
- `accordion.indicator-color-open`: `--color-primary-text` →
`--color-content-primary` (subtle emphasis without the loud
accent).
`generated/base.css` regenerated. Verified in browser: open trigger
heading now matches the closed triggers' tone; the chevron rotates
upward and the content reveals as the only open affordances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Most passive components missing the A-1.3 audit point (texts.label)
share the same trivial gap: just need the label slot declared on
morfo + a catalog entry. One-off migration script
`scripts/add-texts-label-batch.ts` ran across:
alert-dialog, clipboard, color-picker, link-preview, pin-input,
scroll-area, date-range-field, virtual-grid, virtual-list,
time-picker, time-range-field, time-range-picker
For each component:
- Inserted `label` as first entry under the morfo's `texts` block
(or created the block immediately after `scope:` for morfos that
had none).
- Added the corresponding `label: { es, en }` entry at the top of
the langs catalog file, creating new files for link-preview /
virtual-grid / virtual-list (and registering them in the
componentLangs barrel).
- Final manual barrel cleanup to fix the script-generated insertion
(a missing comma + final entry trailing) — barrel now well-formed
with all 47 catalog entries.
Audit: PASS 11 → 23. Sweep doubled the PASS count in one pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of drawer (modal slide-in, parallel to dialog):
- Morfo: validated. 6 events (present/save/cancel/dismiss/dismiss-
outside/after-fail). Focus policy with trap/return/restore. 9 parts
(Provider/Trigger/Content/Overlay/Handle + Header/Footer/Title/
Description/Close).
- `texts.label` added with catalog entry. Drawer was already wired in
the langs barrel.
- README `## Baseline` section added (drawer DOES have an air baseline
at `glm-5:src/uix/air/components/drawer`, plus the soma additions
for drag/snap points). `## Gaps` with dispositions:
shouldScaleBackground (descartar — shell-level concern), free-resize
without snap points (diferir), Handle ARIA slider role (implementar
— accessibility gap for snap points), pulse-to-peek (diferir),
nested visual stacking (implementar — same gap as dialog),
AlertDrawer variant (descartar — compose AlertDialog inside).
Audit: PASS 10 → 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of popover (base of combobox / date-picker / dropdown-menu /
context-menu / tooltip):
- Morfo: validated. 9 parts (Provider/Trigger/Content/Arrow/Title/
Description/Close/Anchor/Overlay). 6 events: `present` (emerge),
`close-save` (commit fulfill), `close-cancel` / `close-dismiss` /
`close-dismiss-outside` (emerge), `close-after-fail` (signal risk).
Focus trap + return-to-trigger + restore. Modal flag drives
outside-click ignore + aria-modal + focus trap propagation.
- `texts.label` added with catalog entry. New `popover` entry in the
componentLangs barrel.
- README `## Pendiente deliberado` section renamed to `## Gaps` with
disposition markers. New rows documented: multiple-trigger model
(diferir), `Positioner` as public part (descartar — Soma Floating
owns positioning), `forceMount` per-part (diferir), modal mode +
footer (descartar — the picker pattern, not generic popover),
`onOpenAutoFocus` / `onCloseAutoFocus` docs (implementar),
hover-open delay API (diferir), virtual anchor (diferir).
Audit script refinement (A-3.6):
- Bare canonical verbs are now accepted as valid event names. The
rule used to require `{verb}-{x}` or `{family}-{verb}` — but
`present`, `open`, `close` ON THEIR OWN are perfectly fine when
the event has no variant to disambiguate. The check now passes
events named with a bare verb if that verb is in `ALL_VERBS`,
OR a bare family name. Popover's `present` event no longer
generates a warn.
Audit: PASS 8 → 9. Popover flips to PASS clean. No regressions
elsewhere from the bare-verb relaxation (only popover used the
pattern; other components with bare names already had warns we
documented).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of Form (largest finding set in the project):
- Morfo: validated. 14 parts — Provider/Submit/Reset/ErrorSummary +
9 AutoFields parts for the reflective renderer. Provider declares
data-pending/dirty/touched/invalid/submitted on the contract. Sema
events `commit-submit` (fulfill), `signal-invalid` (risk),
`commit-reset` (neutral) are correct.
- `texts.label` added with catalog entry (`label = 'Formulario' / 'Form'`).
- Recipe CSS: `[data-form][data-invalid]` rule added. Low-emphasis
affordance — the ErrorSummary picks up the risk border but field
cells keep their own `[data-invalid]` styling via the Field recipe.
- README rewritten with canonical sections:
- `## Baseline` summarizing air / soma / morfo coverage
- `## Comparativa` (was `## Reference Comparison`) — extended to
include shadcn-svelte plus AutoFields differentiators
(discriminated unions, array fields, first-error focus,
validation timing modes)
- `## Decisiones` documenting the small-wrapper rule, AutoFields
exception, validation timing ownership, the no-noise-on-load
default, sema event placement, and the low-emphasis invalid
treatment
- `## Gaps` (new) with disposition markers — apg is `descartar`
(no APG for "Form" — APG covers individual widgets), AutoFields
i18n is `implementar`, multi-step / submission feedback / etc.
are `diferir`, auto-save / optimistic UI are `descartar`
Audit script refinement:
- R-1.2 (data-disabled styles) now only fires when the morfo
*declares* `data-disabled` on any part. Form's Provider doesn't
emit a disabled state at the root (individual fields handle it
themselves), so demanding defensive CSS for a state the contract
never emits was a false-positive. The rule still fires correctly
for components that DO declare `data-disabled` in their morfo.
Audit: PASS 7 → 8. Form flips to PASS with only one remaining
warn (`A-1.4` no apg URL — `descartar` documented as a gap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of combobox (popover + editable input + listbox):
- Morfo: validated. 10 parts (Provider/Input/Trigger/Content/Viewport/
Item/Group/GroupHeading/Separator/Arrow). Single + multiple via
`data-type` on Provider, `aria-multiselectable` conditional on
`prop:multiple`. Item declares `data-state` checked/unchecked,
`data-value`, `data-highlighted`, `data-disabled`. Keyboard: 6 keys
but only 2 mutate state (Enter→select, Escape→close-delegated).
- Decision documented: combobox does NOT emit emerge events for
open/close/typing/filtering/highlighting — these are navigation
mechanics, not commits. Escape close is delegated to the popover
layer's `close-dismiss` event. The 2 `commit-*` events cover the
actual user intent (choosing an item).
- `texts.label` added with catalog entry. The toggle key already
existed.
Audit script refinements:
- Extended focus-move action list to cover listbox/menu/tablist
navigation (`next-item`, `prev-item`, `first-item`, `last-item`,
`next-tab`, `prev-tab`, `first-tab`, `last-tab`) plus the popover/
dialog actions that are delegated to the containing overlay layer
(`open`, `close`, `dismiss`, `toggle`). A combobox / select / menu
that hosts Escape→close is not under-declared if the overlay layer
owns the sema event.
README rewritten with canonical sections:
- `## Baseline` summarizing air + soma + morfo coverage.
- `## Comparativa` (was `## Comparison`) — table preserved.
- `## Decisiones` documenting the no-emerge-events policy, focus-only
keys, Control composition rationale, multi-select aria propagation.
- `## Gaps` (was `## Out of scope`) rewritten with disposition tokens
(implementar / diferir / descartar) per row. ClearTrigger and
Validation state get `implementar`; Modal+footer gets `descartar`
(combobox is inline by design, not a confirm-style picker).
Audit: PASS 6 → 7. No regressions; the relaxed focus-move list
benefits other listbox/menu components too (will surface on their
audits).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full walk of icon (passive eidos-only primitive):
- Morfo: validated correct as-is. Single `Provider` part with
`archetype: 'image'`, conditional `aria-hidden` / `role="img"` driven
by the `decorative` prop. Scope `['eidos']` is right (no soma
counterpart). 0 events is right (leaf visual primitive).
- Eidos: 1696 lucide glyphs + IntentIcon. Default exported via the
re-export pattern `export { default } from './icon.svelte';` —
validates the audit refinement below.
- README sections renamed to canonical headers: `## Baseline Air` →
`## Baseline`, `## Comparativa externa` → `## Comparativa`. New
`## Passive justification` section explains why icon ships zero
semantic events (leaf primitive — the accessible name and any
perceptual signal belong to the containing control). `## Gaps
cerrados` renamed to `## Gaps` and rewritten with disposition
tokens (implementar / diferir / descartar).
Audit script refined for two false-positives surfaced by icon:
- A-1.3 (texts.label) now SKIPS pure visual primitives — components
whose `scope` is exactly `['eidos']` AND that declare no events.
Icons don't own text slots; the label lives on the consumer (the
button hosting the icon, shadcn-svelte/Radix pattern). Reported as
pass with "eidos-only passive primitive — no text slot".
- E-1.3 (default export) now recognizes the re-export form
`export { default } from './foo.svelte';` in addition to the
existing patterns (`export default X`, `export { X as default }`).
Audit: PASS 5 → 6. Icon flips to PASS. No regressions; the relaxed
rules don't change verdicts for any other component.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the calendar audit findings:
- README sections renamed to canonical headers: `## Baseline` (was
"Baseline audit"), `## Comparativa` (was "Reference comparison").
Added `## Decisiones` documenting the affirm-on-select choice, the
collapsed `shift-navigate` event for all month/year movement, why
arrow keys don't get their own events (pure focus moves), and the
read-only treatment. Rewrote `## Gaps` with disposition tokens
(implementar/diferir/descartar) for each entry.
- Recipe CSS gains `[data-calendar][data-readonly]` styling: cells
drop their hover affordance and the cursor stays neutral. Selection
+ navigation remain interactive (matches `aria-readonly` semantics).
- Audit A-3.7 refined per the COMPONENT_GUIDE rule "pure focus moves
don't need an event". The check now compares MUTATING keyboard
actions to declared events. Focus-move actions
(next-day/prev-day/next-week/prev-week/next-month/prev-month/
next-year/prev-year/first-day-of-week/last-day-of-week/
focus-{next,prev,first,last,up,down,left,right}) are excluded from
the ratio. Calendar's 10 keys / 2 mutating (Enter+Space → `select`)
/ 1 `commit-select` event now passes correctly.
Project audit: PASS 3 → 5, no new regressions. `range-calendar` also
flips to PASS as a side effect of the focus-move filter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Living backlog for UIX. Records the pending items from the
date-range-picker session (apply modal+footer to other pickers,
month/year-range variants, promote footer buttons to canonical eidos
parts, morfo expansion to cover Footer/Clear/Cancel/Close, browser
tests) plus the queue of post-session components still to audit
one-by-one with severities pulled from `tmp/component-audit.md`.
Each entry is tagged implementar/diferir/descartar so the next picker
session can scan the file and pick the next slice without re-doing
discovery.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback batch from incidencia 2026-05-20:
1. Modal vs inline mode
- New `mode: 'inline' | 'modal'` prop on the soma `DateRangePicker.Provider`.
- Modal wires the popover `modal: true` → outside-click and Escape are
ignored; user must commit via the footer Close button (or revert via
Cancel).
- Provider exposes helpers `clear()`, `cancel()`, `commit()` for the
footer. `cancel()` restores the value snapshot taken on the OPEN edge
(captured via a `watch` on `opts.open` true-edge transition).
2. Footer buttons as boolean props
- `clearButton`, `cancelButton`, `closeButton` props on the picker.
The footer renders only if at least one is true. In `mode='modal'`
the closeButton is forced on (the user always needs an exit).
- `picker-actions.svelte` in the demo reads the picker context via
`DateRangePickerProvider.require()` and renders the enabled buttons
against `provider.clear/cancel/commit`.
3. Range field shape (revert to Chakra-style two boxes)
- Removed the `data-date-range-field-group` wrapper from the demo so
the start and end inputs are rendered as two separate boxed fields
with the icon embedded in the end box, matching Chakra's layout.
- The recipe CSS rules for `data-date-range-field-group` stay
available as an opt-in for consumers who prefer the unified pill.
4. Demo defaults
- `open` starts at `false` so the picker exercises the real
open/close flow when the user clicks the trigger — the segments are
for direct keyboard entry, the popover is for visual exploration.
- Mode toggle (inline / modal) + footer button switches surfaced as
controls in the demo.
5. Plumbing
- DateRangePickerOpts gains `mode`, `clearButton`, `cancelButton`,
`closeButton` (StateProps for mode, ActiveProps for the booleans).
- `DateRangePickerProvider` and `DateRangePickerMode` are now re-
exported from the soma barrel for consumers that wire footer
actions in the calendar tree.
- Test factory updated to seed the new opts.
Verified in browser: trigger opens; click-outside in modal mode is
ignored; Close commits & closes; Cancel reverts to snapshot & closes;
Clear empties the range & keeps open. Heading "May – June 2026" (year
collapse) and centered per-calendar titles still working from the
previous commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia point B (range field shouldn't show two separate
boxes). New CSS contract: `[data-date-range-field-group]` on the
wrapper element collapses the chrome around its descendants:
- The group owns the border, background, focus ring, invalid state,
variant/ghost treatment, and disabled opacity.
- Each `[data-date-range-field-input]` inside loses its own border,
background, and padding; it just contributes its segments to the
shared row.
- The "to" separator (.range-separator or `[data-range-separator]`)
picks up the field's literal tone.
- The embedded trigger from the previous commit sits flush at the
trailing edge of the group, since `data-date-field-input` already
detects the embedded case.
Demo: add `data-date-range-field-group` to the `.range-field-row`
wrapper. Visually now reads as one input pill: `05 / 20 / 2026 to
06 / 03 / 2026 📅`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia points D + E:
- Heading collapses the shared year when both visible months are the
same year: "May – June 2026" instead of "May 2026 – June 2026".
Cross-year still renders both ends explicitly ("June 2025 – July
2026"). Implemented in `RangeCalendarProvider.headingValue`.
- date-range-picker demo: drop the per-month title that previously
rendered BELOW each grid (duplicate of the main heading). Instead,
render one centered title per visible calendar INSIDE the header
bar between the prev/next buttons. The grid below now shows just
the weeks. A `range-header-titles` grid container splits the
available header space equally across `month-count` titles, so
each label sits centered over its calendar grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of date-range-picker incidencia 2026-05-20 #1 (auto-paging
to next month when the popup opens with a complete range): both
endpoint inputs of a DateRangeField create their own DateFieldProvider
sharing a single placeholder. Each provider has a `$effect` that
mirrors value → placeholder so the calendar jumps to the value's
month. With two endpoints, the END field's effect overrides the
START's on every render and the popup auto-pages to the end's month.
Fix: add an explicit `syncPlaceholderToValue?: boolean` opt on
`DateFieldOpts` (default `true` — preserves single-field UX). The
DateRangeField endpoint Input passes `false`; range placeholder
coordination stays with the range provider.
Verified in browser: value `{ start: 2026-05-31, end: 2026-06-09 }`
with `placeholder = 2026-05-31` now keeps the calendar on
"May 2026 – June 2026" instead of jumping to "June – July". Both
endpoints render correctly with the start/end stripes.
The earlier `reanchorInitialSelection` removal handled the same
symptom inside the range-calendar provider for fresh selections;
this commit handles the OTHER source — the field provider auto-sync
on already-set values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The calendar icon now lives INSIDE the date input as a flush trailing
affordance, not as a separate sibling button. The change is markup +
recipe CSS only — no API change.
- Demo (date-picker): drop the `.picker-control` grid wrapper; put
`<DatePicker.Trigger />` as the last child of the Input snippet,
after the segments. Codeblock snippets updated to match.
- Demo (date-range-picker): keep the dual-input + separator layout
but move `<DateRangePicker.Trigger />` into the END input, since
the trigger represents the trailing affordance of the whole range.
Both code snippets updated.
- Recipe CSS (both): add nested rules that detect the embedded case
(`[data-date-field-input] > [data-date-picker-trigger]`,
`[data-date-range-field-input] > [data-date-range-picker-trigger]`).
Embedded trigger drops its own border/background, matches the
segment height, sits flush at the trailing edge with
`margin-inline-start: auto`, and uses the segment focus ring on
hover/focus instead of the standalone field ring.
The standalone (sibling) treatment stays available for consumers who
prefer the icon as a separate control.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the second BROKEN component from the audit (after date-range-picker).
Five errors collapse to zero:
- Morfo declares the three `emerge` events the recipe animations were
already keying off (`open`, `close`, `close-dismiss`) plus a
`texts.label` slot. `data-disabled` is now declared on `Trigger` (with
optional severity) so the recipe rule has a real contract to land on.
`aria-disabled` and `texts.label` round out the surface; the new
`tooltip.label` catalog entry ships under
`src/uix/langs/components/tooltip.ts`.
- Recipe CSS: explicit `[data-tooltip-trigger][data-disabled]` and
`[data-tooltip-trigger]:focus-visible` rules. The disabled rule
suppresses pointer events; the focus rule provides a defensive
outline so a focusable trigger shell is at least keyboard-visible.
- README: convert the old `## Pendiente deliberado` section into a
proper `## Gaps` table with explicit dispositions
(implementar/diferir/descartar), and update `## Eventos Sema` to
reflect that tooltip now declares its three emerge events instead of
claiming zero. Comparativa was already complete; the regex fix from
the previous commit picked it up.
Audit: tooltip BROKEN → PASS. Project-wide goes from 1/64/2 to 3/64/0
(PASS / NEEDS-WORK / BROKEN). No more BROKEN components.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia 2026-05-20 #7 plus the remaining audit gaps:
- Morfo now declares the full event surface for the picker layer:
`open` (emerge, with intent fromProp), `close-range-commit` (commit
fulfill), `close-cancel` / `close-dismiss` / `close-dismiss-outside`
(emerge), and `commit-clear` (commit reset). Adds `data-last-action`
on Calendar for causal exit tinting, plus `aria-label`, archetype
classification, keyboard bindings (Enter/Space on Trigger; Escape +
Tab focus on Calendar), and `apg` URL pointing at the W3C dialog-modal
pattern.
- `texts.label` added with full catalog entry under
`src/uix/langs/components/date-range-picker.ts` so A-1.3 passes.
- README rewritten with the required structure: Baseline (air / terra /
soma actual), Comparativa table (React Aria / Bits UI / Ark UI),
Decisiones, and Gaps with explicit disposition markers
(implementar/diferir/descartar). The pending modal mode + clear
button + demo control prune go in Gaps as `implementar`/`diferir`.
- Fix a pre-existing audit-script regex bug: section-extraction
regexes used `m` flag with `$` lookahead, which matched end-of-line
and truncated section bodies to their headers. Switched to
`\n## ` as the explicit section boundary.
Audit: date-range-picker BROKEN → PASS. Project-wide BROKEN count
0 (was 2 before, now tooltip remains as NEEDS-WORK).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes incidencia 2026-05-20 #4 plus the R-2.1 audit finding:
- Remove raw-hex fallbacks (`#16845f`, `#106d4e`, `#2ba878`, `#ffffff`)
from `date-range-picker.css`. The `--color-affirm-*` and
`--calendar-day-selected-color` tokens are guaranteed by the
generated base.css; the safety hex hid that they weren't being
refreshed.
- Regenerate `eidos/generated/base.css` so `--calendar-day-*` tokens
(color, selected-color, today-border, etc.) materialize from the
recipe definitions in `lib/recipes/base.ts`. Fixes the stale
`keeps generated/base.css in sync` contract test.
- Start endpoint now reads `--color-affirm-*` cleanly; end follows the
picker's `data-color` accent. When the picker's accent IS in the
affirm family (`data-color="affirm"` or `"fulfill"`), the start
swaps to `secondary` so the two endpoints stay perceptually distinct.
- Both endpoints get an inset edge stripe + semibold weight so the
start vs end direction reads at a glance — addresses the user's
"indistinguible o transparente" complaint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes 2 bugs from the date-range-picker incidencia 2026-05-20:
#1 — `RangeCalendarProvider` no longer auto-shifts the placeholder when
a selection lands in the last visible month. The visible months stay
where the user put them; navigation is explicit (prev/next/month/year
controls or keyboard). `reanchorInitialSelection` is removed and its
`shift-navigate` trigger goes with it.
#2 — clicking an endpoint of a completed range now drops only that
endpoint and re-anchors on the surviving one. The previous behavior
cleared both endpoints, which forced users to rebuild the entire range
to amend it. The provider already implemented this; only the test
codified the old behavior. Test rewritten to match the documented
intent and symmetric for start/end.
The two range-calendar tests that previously asserted the wrong
behavior now cover the correct invariants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Morfo now declares text slots as absolute idlangrefs in `texts: Record<string, LangRef>`,
not literal multilingual records. Multilingual content moves out of morfo entirely
into per-component catalogs under `src/uix/langs/components/{kebab}.ts`, merged
globally by `ActiveUix` via the new `componentLangs` barrel. Morfo stays a pure
abstract surface; localization lives in its own layer.
- Type: `translations?: LangNode` → `texts?: Record<string, LangRef>` in
`src/uix/morfo/types.ts`. Doc and test fixtures updated.
- Catalog: new `src/uix/langs/` directory with `common.ts` (was `langs.ts`),
`components/` (one file per migrated morfo, plus a barrel), and an aggregate
`uixLangs`. `ActiveUix` registers both namespaces at boot.
- Registry: `registerMorfo` no longer carries a translations fingerprint and
no longer extends `ActiveLangs` dynamically. `connectMorfoTranslations` and
`MorfoTranslationTarget` deleted from the public morfo barrel.
- Validation: schema.ts drops the relative-translationRef-in-morfo.translations
check; catalog presence is enforced by the new `scripts/translations-check.ts`
(wired as `npm run translations:check`).
- Audit: `scripts/component-audit.ts` rule A-1.3 now validates `texts.label`
idlangref shape (`#?components.{kebab}.label|Fallback`) and explicitly errors
on any surviving legacy `translations:` field.
- Migration: `scripts/migrate-translations-to-texts.ts` is the one-off Node
script that produced the 42 morfo rewrites and 42 catalog files.
- Tests: `morfo/registry.test.ts` rewritten around the simpler contract;
`active-uix.svelte.test.ts` reflects boot-time catalog availability instead
of lazy per-morfo extension.
Verification: `npm run check` (0 errors), `npm run translations:check`
(0/0), `npm run morfo:check` (36/36 routed). The 4 pre-existing failures
in `test` are unrelated (date-picker types, css regen, range-calendar
clear, color contract).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new Eidos components shipped end-to-end (wrapper + recipe + demo +
Soma provider hardening) plus a checklist-driven audit pipeline that
scores all 67 morfo components against doctrinal completion criteria.
New components:
- date-picker: full popover-anchored picker over date-field + calendar,
with calendar/content/trigger parts and demo route.
- date-range-picker: standalone wrapper with own calendar/grid/segment
surface, demo route, and recipe CSS.
- Both wrappers follow Option C disciplined (root + parts attached via
explicit assignment, no Object.assign).
Supporting Soma changes:
- range-calendar provider tightened (211 LOC of behavior, 167 LOC of
tests), README brought up to component doctrine.
- date-field, date-picker, date-range-field, date-range-picker Soma
providers + READMEs updated for new wrappers.
- popover provider/close gain props needed by the picker wrappers.
Morfo updates:
- date-picker / date-range-picker / range-calendar morfos refined for
the new APIs (parts, events, ARIA).
Audit infrastructure (new):
- src/uix/COMPONENT_COMPLETION_CHECKLIST.md — 81 doctrinal rules across
morfo / eidos wrapper / recipe CSS / demo / README / cross-layer
scripts. Each rule keyed to active_architecture.md, GUIA_IMPLEMENTACION
and DEMO_AUTHORING_GUIDE.
- scripts/component-audit.ts + `npm run component:audit` — regex parser
over all 67 components, emits tmp/component-audit.md with summary
scoreboard + per-component findings. Validates against canonical
SEMA_FAMILIES / SEMA_VERBS / ARCHETYPE_VOCABULARY / INTENTS.
- Initial baseline: 1 PASS, 64 NEEDS-WORK, 2 BROKEN (tooltip,
date-range-picker). Top systemic gaps: translations.label (49),
README Gaps/Comparativa/Baseline sections (87 combined), keyboard
/event ratio under-declaration (15), apg URL absent (19).
Misc:
- src/uix/kimi-audit-eidos.md — supplementary audit notes.
- .gitignore: ignore .codex-* agent scratch artifacts at repo root.
- continue.md + READMEs updated through the migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the doctrine update in GUIA §13: eidos for multi-part components
exposes BOTH a flat default that auto-composes Provider/Trigger/Content
AND the compound parts for advanced cases. Soma + morfo remain the
universal composable foundation underneath.
Why: with the previous "compound only" rule for multi-part, eidos
collapsible was a pure pass-through that added nothing operational —
the consumer wrote the same import + same shape as soma. The flat
default makes eidos earn its layer by collapsing the 90% case to a
single call:
// 90% case — flat ergonomic
<Collapsible bind:open>
{#snippet trigger()}Show details{/snippet}
<p>Body content.</p>
</Collapsible>
// Advanced — compound (parts in different subtrees, multi-trigger,
// conditional content)
<header><Collapsible.Provider bind:open>
<Collapsible.Trigger>Show</Collapsible.Trigger>
</Collapsible.Provider></header>
<aside>
<Collapsible.Content>...</Collapsible.Content>
</aside>
The architectural virtue: if the flat doesn't fit, a developer drops
to soma + morfo and composes freely. No vendor lock-in in the visual
layer; eidos is the design system convention, soma is the universal
primitive.
Files:
- src/uix/eidos/components/collapsible/collapsible.svelte (NEW): flat
default that auto-composes Provider + Trigger + Content via a
`trigger` snippet for the toggle label and `children` for the body.
- src/uix/eidos/components/collapsible/types.ts: adds
`CollapsibleFlatProps = SomaCollapsibleProps & { trigger?: Snippet }`.
- src/uix/eidos/components/collapsible/index.ts: exports `default`
(flat) plus named `Provider`/`Trigger`/`Content` (compound).
- src/uix/eidos/components/collapsible/{trigger,content,provider}.svelte:
fixed children destructuring so snippets propagate cleanly to soma
(was previously spreading $props() blindly which can lose snippet
bindings in Svelte 5).
- src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md: NEW §13 "Doctrina del API:
soma compound, eidos opinionado" formalises the two-shape rule and
the architectural virtue (soma+morfo as universal foundation).
Renumbered §13/§14 → §14/§15.
- web/routes/collapsible/+page.svelte: live preview now uses the flat
form; eidos snippet shows the flat shape; soma snippet keeps the
compound shape; Notes tab updated.
- src/uix/eidos/components/collapsible/README.md: rewrote API section
with both shapes + the "if neither fits, use soma+morfo" escape.
Tests 218/218 green. npm run check baseline 39.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The collapsible demo only rendered the eidos snippet, which made the
"compound shape is the same" doctrinal point invisible: a reader
couldn't see that soma and eidos expose the same multi-part API,
differing only in import path and what each layer guarantees.
Now both code blocks stack in the Live tab:
- soma: includes the `<script>` boilerplate (`let open = $state(false)`)
to make the headless/structural-only nature explicit
- eidos: same shape, different import, no setup boilerplate (consumer
binds open directly with `onOpenChange={handleChange}` for the demo
trace)
Per doctrine §10, only single-part components (toggle/switch) get a
flat default export — multi-part keeps compound on both layers, so the
two snippets share shape. That's correct. The DIFFERENCE is the import
path, the visible boilerplate, and what each layer adds (soma:
structural contract; eidos: same surface + recipe CSS).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real bugs reported on the collapsible demo:
1. **Dark mode broken across all migrated components.**
- The docs site layout sets `document.documentElement.dataset.theme =
'light'|'dark'` — those are the selector names the docs CSS uses.
- The eidos themes (themes/base/{light,dark}.css) only respond to
`[data-theme='base-light'|'base-dark']`. Different namespace.
- Result: docs grays swap in dark mode but eidos doctrinal palette
(primary/secondary/neutral/affirm/fulfill/risk/threat/loss) stays
in light values, so component text renders dark on dark.
- Fix: each eidos theme now accepts BOTH selectors. `[data-theme=
'base-light'], [data-theme='light'], :root { ... }` and equivalent
for dark. Existing consumers that use `base-*` keep working; the
short `light/dark` aliases handle the docs site convention.
2. **Collapsible trigger jumps from center to left when expanded.**
- The preview-stage uses `display: flex; justify-content: center;
align-items: center;` — natural for buttons/switches that don't
grow.
- `Collapsible.Provider` is `display: block` so it shrink-fits its
content as a flex item. Closed = trigger-width (centered visually);
open = trigger + content width (longer, so the visual center
anchors trigger at the left edge).
- Fix: collapsible demo overrides the preview-stage style with
`justify-content: flex-start; align-items: flex-start` and wraps
the provider in a `max-inline-size: 32rem` panel. Disclosure
ergonomics need anchor-left + grow-down, not center.
- Also swapped the body's `--gray-50` (docs site token) for
`--color-surface-raised` + `--color-content-primary` (eidos
doctrinal tokens) so the panel inherits the dark-mode swap.
npm run check holds at the 39-error baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third component migrated to the eidos wrapper subdirectory pattern,
following the new "check air baseline first" discipline.
Air baseline recovered from commit 0a391408^:
- air/components/collapsible: 3 wrappers (Provider/Trigger/Content)
with `class` passthrough, `bind:open`, `onOpenChange`, manual
`air.interaction.play('expansion', enter|exit)` perceptual emit.
- No size, intent, color, or chevron slot.
Migration decisions (presented BEFORE coding per scope-approval rule):
- Two doctrinal events (`expand` + `collapse`, both emerge family,
sequence='pre', no intent — emerge is transitional). Replaces air's
manual `air.interaction.play` with `runtime.trigger('expand'|
'collapse')` which atravieses visual + sound + vibra channels.
- No intent/color/size — air didn't have them and disclosure is
non-evaluative per guide §3.1.
- Multi-part API: Provider + Trigger + Content (no flat default per
doctrine §10).
Files:
- src/uix/morfo/components/collapsible.ts: scope expanded to ['soma',
'sema'], events array added with two emerge events.
- src/uix/soma/components/collapsible/collapsible-provider.svelte.ts:
runtime.events handlers `expand`/`collapse` mutate state; toggle()
fires runtime.trigger(next ? 'expand' : 'collapse').
- src/uix/eidos/components/collapsible/{collapsible.css,
collapsible-provider.svelte, collapsible-trigger.svelte,
collapsible-content.svelte, types.ts, index.ts, README.md}.
- src/uix/eidos/components/collapsible.css (legacy flat) deleted.
- src/uix/eidos/index.css: import path updated to subdirectory.
- web/routes/collapsible/+page.svelte: full interactive testbed with
tabs (Live, Soma, Morfo, Sema, Eidos, Notes) mirroring switch's
structure. Sema tab shows per-channel signature cards for both
expand and collapse events.
Disciplina formalizada en CLAUDE.md hand-off + memory entry: "Check
air baseline first" before any future eidos migration. Switch shipped
without this check and lost ResponsiveProp<Size> (regression). Don't
repeat with collapsible/dialog/drawer/popover/toast/avatar.
npm run check holds at the 39-error baseline. Tests 218/218 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Architectural correction. Morfo was importing Svelte runes, $adom, and
$uix/sema — all violations of "morfo is pure declarative DNA". The
runtime that interprets a CompiledMorfo lives where it executes: soma.
Move:
- src/uix/morfo/runtime.svelte.ts → src/uix/soma/runtime.svelte.ts
- src/uix/morfo/runtime.svelte.test.ts → src/uix/soma/runtime.svelte.test.ts
Rename (consistent with the move; "soma's runtime", per the doctrine
"morfo declares, soma executes"):
- class MorfoRuntime → SomaRuntime
- createMorfoRuntime() → createSomaRuntime()
- type MorfoRuntimeSources → SomaRuntimeSources
- error prefixes [morfo-runtime] → [soma-runtime]
Sema is now genuinely ornamental:
- ActiveUix.semantic returns EngineSemantic | undefined (no longer
throws when no engine is configured). Components in SSR / headless
tests / audio-disabled environments stay functional.
- SomaRuntime.trigger() skips the emit step AND the target-resolution
check when no engine is present — only requires a DOM target when
something will actually be emitted.
- Soma.semantic getter returns | undefined too (was non-optional).
Consumers updated:
- src/uix/active-uix/active-uix.svelte.ts — imports createSomaRuntime
from $soma; semantic getter returns | undefined
- src/uix/active-uix/types.ts — interface SomaRuntime, semantic | undefined
- 8 providers (toggle, switch, collapsible, dialog, drawer, popover,
toast, avatar) — type SomaRuntime, import path $soma
- src/uix/soma/index.ts — re-exports createSomaRuntime + types
- src/uix/morfo/index.ts — drops the runtime re-exports (morfo is pure)
- src/uix/sema/signal.ts — JSDoc reference updated
- src/uix/soma/core/soma.svelte.ts — semantic getter | undefined
Test added: SomaRuntime.trigger() succeeds without a semantic engine
(skips emit + target check, runs handler directly).
Docs aligned to the new shape:
- active_architecture.md — runtime locations, sema as ornamental
- soma/SOMA_ARCHITECTURE.md, soma/README.md — name + import paths
- morfo/README.md — file listing now flags morfo as PURE TS, points
at soma/runtime.svelte.ts for the interpreter
- sema/README.md — SomaRuntime references
- README.md — root architecture diagram
- CLAUDE.md — hand-off note
Tests 218/218 green. npm run check holds at the 39-error baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier switch demo Sema tab was a stub linking to the toggle docs
page. Now it ships the full per-channel signature cards (motion preview
with x8 amplification, sound spectrum bars, color affects, presence
preview), the live intent picker, and the Play button that fires on
the live switch via uix.semantic.emit. Mirrors the structure used in
toggle so the two pilots stay symmetric for future migrations.
The play-button targets data-switch (was data-toggle in the toggle
demo). The intent-picker drives the same `intent` state that the
soma-driven recipe consumes through data-color, so Play replays motion
+ sound + the data-color theme on the actual track without flipping
state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 (docs alignment with the canonical guide):
- active_architecture.md — Dialog example refactored to the new event
shape (target inside semantic, explicit verb + sequence). Verbs
table grouped by family with the post-canon vocabulary (select/
toggle moved to commit, edit removed from handle, new verbs added).
Section 10 (Estado actual) updated to 2026-05-08 with current
progress: doctrina cerrada, eidos en producción, SoundChannel
eager-init, switch migrated, etc. "Para profundizar" §14 points at
the canonical guide.
- morfo/README.md — Step 5.5 added covering the doctrinal event
shape with all fields (semantic.family, .verb, .target, .sequence,
.intent) and the two naming shapes ({verb}-{variant} and
{family}-{verb}).
- sema/README.md — verbs section restructured to Record-by-family
matching the runtime; documents the verbs that moved between
families per the canon. Naming shapes section updated with both
forms and validateEventName signature.
- CLAUDE.md — "Session hand-off" rewritten with the post-canon
state. Pointer at GUIA_IMPLEMENTACION_SEMAUIX.md as the canonical
source. Pending list refreshed: switch is the immediate next
component, persistence/a11ySemantic/polymorphism deferred until
real consumers appear.
Switch migration to the eidos wrapper pattern:
- morfo/components/switch.ts — adds events: [{ commit-toggle }] with
the doctrinal shape (verb=toggle, sequence=post, intent fromProp
binding to the 4 supported intents). New data-* (data-color with
the 6-token Toggle/Switch subset, data-readonly, data-invalid,
data-required) and corresponding aria-*. scope expanded to
['soma', 'sema'].
- soma/components/switch/types.ts — adds intent, color, readonly,
invalid, aria-labelledby props with full JSDoc. Subset constrained
per guide §3.1 (Toggle/Switch row).
- soma/components/switch/switch-provider.svelte.ts — runtime-direct,
Field integration via FieldProvider.get(), isDisabled/isReadonly/
isRequired/isInvalid OR-merge derivations, intent ↔ color
resolution (intent wins when evaluative), runtime.trigger(
'commit-toggle') in onclick.
- soma/components/switch/components/switch.svelte — wires the new
props through bindProps.
- eidos/components/switch/{switch.css, switch.svelte, types.ts,
index.ts, README.md} — full subdirectory pattern, mirroring
toggle. Recipe drops legacy success/warning/danger/info selectors;
declares the 6 doctrinal data-color values directly. Wrapper
auto-renders Switch.Thumb inside the track and exposes an optional
`thumb` snippet for in-thumb glyphs.
- eidos/components/switch.css (legacy flat) deleted; eidos/index.css
updated to import the subdirectory recipe.
- web/routes/switch/+page.svelte — interactive testbed with all soma
props as live controls + tabs (Live, Soma, Morfo, Sema, Eidos,
Notes). Mirrors toggle's structure.
npm run check holds at the 39-error baseline. Tests 217/217 green.
Pending:
- Migrate collapsible / dialog / drawer / popover / toast / avatar
to the eidos wrapper pattern.
- Persistence + holds-by-intent — defer until first signal.warn /
signal.alert consumer.
- a11ySemantic + polymorphic events — defer until concrete need.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new doctrinal guide at src/docs/GUIA_IMPLEMENTACION_SEMAUIX.md
supersedes src/docs/sema-implementation-guide.md and locks the
canonical vocabulary, event shape and color tokens. This commit aligns
the codebase to that canon in three concrete moves.
Phase 1 — sema/verbs.ts vocabulary:
- Restructured from flat array to family-keyed Record per the guide.
- Moved select / toggle / acknowledge → commit (these fix state, not
contact). Removed edit from handle (it's shift.enter-mode now).
- Added new verbs per the canon: contact (tap, focus, trigger,
release), commit (complete, restore, expire, set, remove, reorder),
signal (remind), handle (rotate, scroll, reorder), shift (route,
step, return, context), sustain (start, loading, waiting, syncing,
processing, streaming, pending, retrying, end).
- validateEventName now recognises both naming shapes:
{verb}-{variant} (head=verb) and {family}-{verb} (head=family).
- New helper familyForVerb. Tests rewritten — 16/16 green.
Phase 2 — morfo event shape:
- target moved from event-level into event.semantic. Adds optional
semantic.verb (advisory, validated against SEMA_VERBS[family]) and
optional semantic.sequence ('pre' | 'coincident' | 'post').
- compile.ts and schema.ts updated. The 22 events across toggle,
toast, popover, drawer, dialog refactored to the new shape with
explicit verb + sequence.
- Polymorphic capability (allowedFamilies + defaultSemantic) and
a11ySemantic deferred — no current consumer needs them; will land
when the first concrete usage appears.
Phase 5 — color tokens (doctrinal 8):
- themes/base/light.css and dark.css rewritten to expose primary,
secondary, neutral, affirm, fulfill, risk, threat, loss. Renames
success→fulfill, warning→risk, danger→threat (same hex). New
primitives for secondary (slate-blue), affirm (teal-mint, low
activation), loss (deep violet-grave, posterior). info palette
deleted entirely — per guide, "info no es intent, es signal.announce
+ neutral".
- _static.css legacy compatibility aliases (--color-bg, --color-success,
--color-error, --color-accent, ...) deleted. Clean cut, no
transition. Consumers reference doctrinal palettes directly.
- 29 consumers (token files + recipes) migrated:
- All --*-color-info-* declarations dropped.
- Component palettes extended with secondary / affirm / loss.
- switch + checkbox recipes updated to use [data-color='affirm'|
'risk'|'threat'|'secondary'] (was 'success'|'warning'|'danger'|
'info').
- toast tokens + recipe renamed to affirm/fulfill/risk/threat/loss
(was success/error/warning/info).
- meter, progress, mark, badge, button tokens renamed.
- Contracts (contracts/contract.css + contracts/components/*)
updated.
- focus-ring-color-error in themes now points to --color-risk-border
(validation errors are signal.warn + risk per doctrina, not threat).
Phase 4 (partial):
- src/docs/sema-implementation-guide.md deleted (superseded by the
canonical guide).
Tests: 217/217 green across uix. npm run check holds at the 39-error
baseline (all pre-existing in unrelated test files; none introduced).
Pending in follow-up commits:
- Update active_architecture.md, sema/README.md, morfo/README.md,
CLAUDE.md to reflect the canon.
- Switch component migration to the eidos wrapper pattern.
- Persistence field (untilAction / untilFix / stateBound) and
holds-by-intent — defer until the first signal.warn / signal.alert
consumer appears.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deleted 60 obsolete documents that no longer reflect the active-uix
state:
- 47 per-component audit logs (*-audit.md, audit_N.md, AUDIT.md)
- 5 global soma audits (AUDIT_1, codex_audit, soma-audit-04-20/21,
audit-prompt template)
- 5 closed studies (soma/study, morfo/study, morfo/DESIGN, morfo/
PROVIDER_STUDY_2026-04-23, uix/CONTINUITY_2026-04-24)
- 2 superseded refactor proposals (refactor_claude, refactor_code)
- 1 stale bug log (form/BUG-onchange-onblur-hang — repro path
src/routes/test/sium/kitchen-sink no longer exists)
Updated the remaining operational references to drop dead links and
reflect the post-cleanup architecture:
- src/uix/README.md — rewritten links section, dropped pointers to
terra/air/CONTINUITY/refactor docs, updated adom/dom paths
- src/uix/active_architecture.md — header now dated 2026-05-08, lists
the major shifts since 2026-04-26 (provider inheritance dropped,
eidos in production, doctrina parte IV, sound eager-init); pendings
section reflects the eidos migration roadmap; "see also" purged of
deleted files
- src/uix/morfo/README.md — file tree updated (removed DESIGN/study,
added compile/runtime/PERMUTATION_RUNNER); see-also pruned
- src/uix/sema/README.md — SoundChannel section now describes the
eager-init pattern (constructor-time, not lazy); pointer to
sema-implementation-guide added
- src/uix/soma/README.md — drops terra/air mentions, declares the
actual dependency direction (eidos -> soma, not the reverse)
- src/uix/soma/SOMA_ARCHITECTURE.md — §15 rewritten as "Estado actual
y deuda histórica" reflecting Provider-inheritance-drop; demo path
updated to web/routes/
- src/uix/soma/COMPONENT_GUIDE.md — A23 dates section now points to
$libs/days (no external/dates façade); demo paths swapped from
/test/soma/* to web/routes/
- src/uix/morfo/PERMUTATION_RUNNER.md — paths swapped to web/routes/
with a relocation note
- CLAUDE.md — Refactor Documents section replaced with Reference
Documents pointing to per-layer READMEs
npm run check holds at baseline (39 pre-existing errors, none new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Toggle as the eidos pilot: subdirectory layout (recipe + Svelte wrapper +
types + index + README) replacing the flat CSS-only form. Pattern is
documented in eidos/components/README.md and the toggle README.
Shared types live in eidos/lib/types.ts. First export is `Size` (8 values
xxs..xxl + full); components narrow with `Extract<Size, ...>` per the
per-component-subset doctrine. No `Eidos` prefix on types — module path
already conveys the layer.
API doctrine:
- soma stays compound (Toggle.Provider) for symmetry with multi-part
- eidos exports both default + Provider so single-part components
accept both `<Toggle>` (ergonomic) and `<Toggle.Provider>`
(compound-style consumers)
SoundChannel eager-init fixes the autoplay race: AudioContext is created
+ resumed synchronously on the first user gesture (capture-phase
listener registered in the constructor), avoiding the previous race
where the first emit() scheduled the resume in a microtask outside the
gesture window.
Demo page (web/routes/toggle/+page.svelte) restructured so the live
preview renders ALWAYS above the tablist — Sema-tab Play buttons can
fire on the real toggle. Motion preview amplifies scale ×8 visually
only; doctrinal values stay in the <dl>.
Conventions 7-13 added to src/docs/sema-implementation-guide.md
covering: directory structure, wrapper composition, no Eidos prefix,
soma compound vs eidos flat, iconOnly sr-only body, sound eager-init,
docs-preview amplification.
CLAUDE.md gets a session hand-off block listing where things stand and
next concrete steps (migrate switch/collapsible/dialog/drawer/popover/
toast/avatar; wire topbar sound mute to masterGain; rename theme
tokens to drop the success/warning/danger fallback aliases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First demos under the new web/routes location (configured by phase C
of the cleanup as `kit.files.routes`). One demo per component:
toggle, switch, collapsible, avatar, dialog, drawer, popover, toast.
Layout (web/routes/+layout.svelte):
- Bootstraps the UIX via createActiveUix({ lang: { schema: {},
defaultLocale: 'es' } }) — minimal config, full UIX surface.
- Wraps in <Uix> which sets the active-uix context and creates the
Soma scope so providers can require it.
- Imports the eidos index.css so the visual layer is applied.
- Tiny demo-only stylesheet (web/routes/layout.css) for the page
chrome (topbar, demo grid, sections) — explicitly NOT part of
eidos.
Each demo page exercises the public API:
- toggle: bound, initially-pressed, disabled
- switch: bound, disabled
- collapsible: bound show/hide, disabled trigger
- avatar: loaded, error fallback, delayed
- dialog: basic with overlay + focus trap + escape dismissal
- drawer: right / left / bottom (with handle)
- popover: click + hover trigger
- toast: 4 intents (fulfill, risk, threat, neutral) via createToaster
Verified end-to-end with scripts/demo-probe.ts (Playwright):
- All 9 routes (landing + 8 demos) serve 200.
- Zero console errors except avatar's pravatar.cc network failure
(sandbox limitation, not a real bug).
- Interactive checks: dialog open shows modal + overlay backdrop +
focused Cancel button; popover open positions floating content
next to trigger; toast Fulfill shows the green-tinted shadow
driven by data-event-intent='fulfill' from sema's visual channel
reacting to the morfo's announce event semantics — confirms the
full morfo → sema → eidos pipeline works end-to-end on a migrated
soma component.
scripts/demo-probe.ts: small Playwright runner that loads each route,
captures console errors, takes a screenshot of the static state, then
clicks the trigger on dialog/collapsible/popover/toast for a second
"open" screenshot. Output goes to g:/tmp/demos/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes Phase A of the soma cleanup. Every soma component that was
already runtime-aware (Toggle, Switch, Collapsible, Avatar from prior
commits + the four in this commit) now lives without the `Provider<T>`
inheritance.
Pattern applied to each (8 classes total across the four components):
- Drop `extends Provider<...>`. Each class is plain.
- Drop module-level `createAttrs(morfo)` and `registerContract(morfo)`
side-effect calls.
- Drop `super(opts, ...)`. Constructors inline:
root: this.opts = opts; XxxProvider.ctx.set(this); …
child: this.opts = opts; this.provider = XxxProvider.require(); …
- Drop `assertProps()` wrapping. Eidos `lint.ts` covers contract
validation at design time; morfo type system covers enum mismatches
at compile time.
- `props` derived: `{ ...runtime.partProps(part), …overrides }`.
- For sub-parts that own a per-instance prop (Title.level,
Toast.Action.altText), pass the source through `registerPart(...,
{ props: { x: () => opts.x.current } })` so resolution stays local
to that part's effect.
- For ref-tracking callbacks (Dialog/Drawer/Popover content/overlay,
Drawer trigger/handle), use `registerPart(..., { onRefChange })`
instead of the legacy `super(opts, spec, undefined, onRefChange)`.
Component-specific notes:
- Dialog: 7 classes; root tracks parent + nesting via context lookup
BEFORE `ctx.set(this)`. `dismissWith(action)` API kept identical.
- Drawer: 8 classes; gesture/snap/handle plumbing untouched. The
drawer morfo doesn't declare `value:` sources for `data-state`,
`data-side`, `data-dragging`, etc., so those stay as inline
overrides in each part's `props` (component-specific computed
state, not morfo contract). To be lifted into the morfo as a
follow-up if it pays off in eidos selectors.
- Popover: 8 classes; floating layer (`FloatingProvider`/Anchor/
Content/Arrow) wires through unchanged.
- Toast: 7 classes; multi-runtime structure preserved — root
runtime carries Viewport, each Item creates its own runtime so
`partRef('item')` resolves to that toast's element. Title /
Description / Action / Close inside an Item register on the
Item's runtime, not the root's.
- alert-dialog: small drive-by — `this.dialog.soma?.langs.ts(…)`
became `Soma.get()?.langs.ts(…)` because `DialogProvider.soma` is
no longer an instance field.
Verified: 209/209 UIX tests green; TS clean for all migrated files
(remaining repo-wide TS errors pre-existing, unrelated).
After this commit:
- 8 soma components are runtime-direct (toggle, switch, collapsible,
avatar, dialog, drawer, popover, toast).
- ~52 components still use `Provider<T>` via the legacy `baseProps +
resolveMorfoProps` path. Those will migrate as Fase B —
opportunistically (each time one is touched for another reason)
rather than in a sweep, per the user's standing preference for
small, surgical commits.
- The `Provider<T>` base class stays as transitional infrastructure
until Phase B drains. Final deletion lands in Phase C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same migration shape as toggle / collapsible. After 4 pilots the
pattern is consistent enough to extract a tiny base class — that's a
follow-up commit.
Switch (root + Thumb) — exercises `runtime.keydown` for action
dispatch (Enter / Space → 'toggle' action that calls
`preventDefault()` before flipping state).
Avatar (root + Image + Fallback) — exercises a multi-part component
where one child (Image) drives a watch.pre side effect to preload the
image and update parent state, and another (Fallback) reads the
parent's `status` reactively.
Per-class diff in both files:
- drop `extends Provider<...>`
- drop module-level `createAttrs(morfo)` and `registerContract(morfo)`
- drop `super(opts, ...)` calls; root inlines `ctx.set(this)`,
children inline `this.opts = opts; this.provider = Parent.require()`
- drop `this.assertProps(...)` wrapping in `props` derived
- drop `WithRefOpts` extension; inline `id: Active<string>; ref:
State<HTMLElement | null>` per child opts shape (more opt-in)
Avatar's `AvatarFallbackOpts` keeps `ref: State<HTMLElement | null>`
required because the fallback always renders DOM. `AvatarImageOpts` and
the root `AvatarOpts` derive from props via `OptsFromProps`.
Verified: 209/209 UIX tests green, TS clean for both files.
After this commit the migration sample is:
- toggle (single part, no ctx)
- switch (single part + ctx + actions/keyboard)
- collapsible (3 parts + ctx + cross-part id sources via partRef)
- avatar (3 parts + ctx + side-effect watch.pre + multi-state)
Common shape across all four:
- `readonly opts: T;` declaration + `this.opts = opts` assignment
- root: `XxxProvider.ctx.set(this)` (when ctx present)
- root: `Soma.require()` + `soma.uix.runtime(morfo, sources)` +
`runtime.registerPart('provider', { id, ref })`
- children: `this.provider = XxxProvider.require()` +
`parent.runtime.registerPart(part, { id, ref })`
- `props` derived → `{ ...runtime.partProps(part), ...customProps }`
The 60+ unmigrated components still use the legacy `Provider<T>` base
(baseProps + resolveMorfoProps path). That class stays put until those
components are also runtime-aware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second pilot. Collapsible exercises the multi-part case Toggle didn't:
3 providers (root + Trigger + Content), one shared morfo runtime
created by the root, and Svelte context wiring so children can find
the parent. The migration shape mirrors Toggle but factors in the
extra plumbing.
What changed:
- All three classes (`CollapsibleProvider`, `CollapsibleTriggerProvider`,
`CollapsibleContentProvider`) drop `extends Provider<...>`.
- Root: `CollapsibleProvider.ctx.set(this)` is called manually in the
constructor (replaces the implicit `super(opts, spec, ctx)` setup).
The `context()` helper from `\$soma/provider/context` is independent
of the `Provider` base class and remains unchanged.
- Children: just store `this.opts = opts` and look up the parent via
`CollapsibleProvider.require()`. No `super(opts, spec)` call.
- Drop module-level `createAttrs(collapsibleMorfo)` and
`registerContract(collapsibleMorfo)` — same dead-side-effect pattern
Toggle had.
- Drop `assertProps` in every `props` derived. Eidos linter + morfo
types cover what the runtime contract validation did.
What stayed identical:
- Public surface — `CollapsibleProvider.create(opts)`,
`CollapsibleProvider.require()`, `state.props`, `state.toggle()`.
- The shared-runtime pattern: root creates `uix.runtime(morfo, sources)`
once; children call `parent.runtime.registerPart('trigger' | 'content',
{ id, ref })`. This is what the morfo runtime was designed for.
- The `triggerId` / `contentId` cross-part state sources for
`partRef` resolution. Children publish their id via direct assign
(per the "id registration: direct assign" rule, not \$effect).
- All 209 UIX tests green.
Note on `WithRefOpts`: the `Provider` base used `extends WithRefOpts,
StateProps<...>, ActiveProps<...>` to compose the opts type. Without
the base, the migrated classes inline `id: Active<string>; ref:
State<HTMLElement | null>` directly. Tradeoff: a couple of extra lines
of opts shape per class, but the type composition is now opt-in
(children that don't need a ref can omit it) instead of mandated by
inheritance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First soma component to live without the `Provider` base class. Toggle
already used `uix.runtime(toggleMorfo, sources)` for the morfo-derived
attrs since an earlier refactor; this commit removes the residual
inheritance + dev-time validation scaffolding that was no longer
pulling its weight.
What changed:
- `ToggleProvider` no longer extends `Provider<ToggleOpts>`. It is a
plain class that owns `opts`, the field hookup, and the runtime.
- Drop the module-level `createAttrs(toggleMorfo)` (dead — return
value was unused) and `registerContract(toggleMorfo)` (the contract
registry is only read by `assertContract`, which we no longer call
for this component).
- Drop `super(opts, { morfo: toggleMorfo, part: 'provider' })`. The
runtime's `registerPart('provider', { id, ref })` already attaches
the ref via `attachRef` internally.
- Drop `this.assertProps(...)` wrapping in the `props` derived. The
eidos linter (`scripts/eidos-lint.ts`) already validates emitted
selectors against the morfo's `cssSelectors` contract at design
time, and the morfo type system catches enum mismatches at
compile time. Runtime contract validation was belt-and-suspenders.
What stayed identical:
- Public surface (`ToggleProvider.create(opts)`, `state.props`,
`state.snippetProps`).
- `OptsFromProps`/`bindProps` from `\$soma/provider/opts` — those
helpers are independent of the `Provider` class and remain useful.
- The wrapper `toggle.svelte` is untouched; it still consumes
`state.props` and `state.snippetProps` as before.
- All 209 UIX tests green.
The `Provider` base class is intentionally not deprecated. The other
60+ soma components still depend on it. Toggle proves the pattern
works for runtime-only components; future migrations follow per
concrete need (a 2nd consumer of the new pattern justifies extracting
shared bits, per the "no premature abstraction" rule).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Provider base class re-implemented every helper from the morfo
resolver verbatim — 9 functions plus its own `resolveMorfoProps` walk.
The runtime already routes through `compileMorfo` + `evalAttrPlan`
since Phase 2a; the Provider is now wired the same way.
What changed in soma/provider/provider.svelte.ts:
- Drop the local `findMorfoPart`, `shouldEmitMorfoEntry`,
`resolveMorfoPrimitiveSource`, `resolveMorfoSource`,
`resolveMorfoDataValue`, `resolveMorfoAriaValue` (~75 LOC of literal
duplication of resolver.ts).
- Drop the manual `resolveMorfoProps` walk (~28 LOC). New body is
seven lines: spread `compiledPart.staticAttrs`, then `evalAttrPlan`
each `dynamicAttrs` plan and skip undefined results.
- Drop the `createAttrs(spec.morfo)` call from the constructor; read
`compiled.parts.attrs[part]` directly. No semantic change.
- Drop the `findMorfoPart` walk for the part metadata; read
`compiled.parts.byKebab.get(part)` (O(1)).
- `_morfoPartMeta: MorfoPart | undefined` becomes
`_compiledPart: CompiledPart | undefined`. Same role, richer info.
Net diff: provider.svelte.ts goes 287 → 149 lines (-138).
What stayed identical: the public API (`baseProps` getter, `assertProps`
helper, `resolveMorfoProps(bindings)` signature, the dual constructor
overload for legacy subclasses without a morfo spec) plus the
behavioral contract — same context registration, same data-attr
contract validation, same prop snapshot shape for subclass
`\$derived.by` blocks. Subclasses are not touched.
Side effect: `resolveMorfoProps` now emits ARIA boolean values as the
literal strings 'true'/'false' (via `evalAttrPlan`'s `aria-boolean`
mode) instead of JS booleans. Aligns the Provider with the morfo
runtime's behavior (resolver.ts already returned strings; only
provider.svelte.ts had been emitting booleans). Svelte stringifies
both identically when spread onto an element, so the rendered DOM is
unchanged. All 209 UIX tests still green.
Also re-export the compile.ts surface (`compileMorfo`, `evalAttrPlan`,
`CompiledMorfo`, `CompiledPart`, `AttrPlan`, `KeyboardPlan`,
`ActionPlan`, contracts, etc.) from `\$uix/morfo` so consumers don't
have to dig into the file. Fix a stale comment that still pointed at
the deleted `\$uix/lib/types`.
Phase 2b proper (kernel rewrite with the 8 abstractions:
ComponentInstance / PartScope / PartRegistry / SourceRegistry /
ActionDispatcher / SemanticEmission / LayoutFacts / BehaviorRegistry)
is parked on purpose. None of those abstractions has two concrete
consumers today; deduplicating against the existing compiler is the
honest first move and unblocks any future kernel work without churn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The runtime is now the first production caller of `compileMorfo`. Each
per-tick effect previously walked the raw morfo tree; now it reads
pre-resolved plans off the compiled output.
What changed in runtime.svelte.ts:
- Single `compileMorfo(morfo)` call at the top (cached by WeakMap, so
multiple component instances of the same morfo share the result).
- `findMorfoPart` linear scan → `compiled.parts.byKebab.get(...)` O(1).
- `createAttrs(morfo)` walk → `compiled.parts.attrs` (pre-built).
- `findEvent` linear scan → `compiled.actions.byName.get(...)` O(1).
- `resolvePartAttrs` per-effect walk over `data` + `aria` arrays →
`{ ...staticAttrs, ...evalAttrPlan(plan, bindings) for plan in dynamicAttrs }`.
`staticAttrs` (literals + role) are pre-resolved at compile time;
only `dynamicAttrs` is re-evaluated per tick.
- Keyboard matching: `matchesKeySpec(specString, event)` re-parsed
the string every keystroke. Now the compiler emits `KeyboardPlan`
with a precomputed `ParsedKey`; matching is a flat field compare.
- Event dispatch uses `ActionPlan` (compiled view) instead of raw
`MorfoEvent`. `event.target.target` partRef-unwrap → `action.target`.
What was added in compile.ts:
- `evalAttrPlan(plan, bindings)` — evaluates one AttrPlan: short-circuits
on falsy condition, then branches once on `mode` instead of on
`source.kind` × `values.length` × `value.kind` per tick. Reuses the
pure helpers in resolver.ts (`shouldEmitMorfoEntry`,
`resolveMorfoSource`).
- Bug fix in `parseKey`: do not trim the literal key segment. `' '`
(Space) was being collapsed to empty string. The pre-existing
`matchesKeySpec` had a comment warning about this; the refactor
needed to preserve the same invariant. Caught by an existing
runtime test (Switch keyboard with Enter+Space).
Verified: 209/209 UIX tests green (morfo + soma + sema + eidos +
active-uix). The remaining 2 TS errors in runtime.svelte.test.ts
(lines 366 and 385) are pre-existing test casts and unrelated.
resolver.ts is no longer called from runtime.svelte.ts directly except
for the two pure helpers `shouldEmitMorfoEntry` (used by evalAttrPlan
and by keydown's condition check) and `MorfoBindings` (the binding
shape). The non-pure walk functions (`findMorfoPart`,
`resolvePartAttrs`) remain in resolver.ts for any consumer that still
imports them, but the runtime no longer does.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The form component still imported from paths that were deleted in the
active-uix cleanup:
- form.svelte: \$lib/sium/svelte (SiumProvider)
- form-auto-fields: \$lib/sium/core (Schema, SchemaKind, SiumShape)
\$langs (LangInstance)
Changes:
- form.svelte: drop the SiumProvider context fallback. The form
receives the schema via the `schema` prop (or none, for forms with
only `defaults`). The old fallback let a parent context inject the
schema implicitly; that was a single-call-site coupling and the
new ecosystem has no equivalent context yet. Re-introduce it later
via a soma-level scope only if a second consumer asks for it
(2-of-3 rule).
- form-auto-fields.svelte:
\$lib/sium/core → \$sium (Schema, SchemaKind, SiumShape are
re-exported from the package barrel)
\$langs (LangInstance) → \$lang (ActiveLang). The CompatibleLang
Pick<…, 'ts' | 't'> shape stays
identical since ActiveLang
carries the same surface.
Verified: svelte-check errors went 46 → 42 (the 4 form errors are gone;
remaining 42 are pre-existing in other components unrelated to the
cleanup). No form tests exist anymore (both were deleted in earlier
phases as casualties of the same import drift).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When src/uix/lib/types.ts went down with the rest of src/uix/lib/ in
phase A of the cleanup, the Layer and PartRef cross-layer primitives
went with it. Three files still imported them via `'../lib/types'`:
- src/uix/sema/types.ts
- src/uix/morfo/types.ts
- src/uix/morfo/compile.ts
Move the two types to src/uix/types.ts (top-level shared, sibling to
all UIX layers — no layer "owns" a cross-layer primitive), translate
the doc comment to English, and update the three importers to
`'../types'`. No semantic change.
Verified: svelte-check went from 49 errors to 46 (the 3 caused by the
deletion are gone); the remaining 46 are pre-existing and unrelated.
Vitest: morfo + eidos + sema = 136/136 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove src/uix/soma/components/form/components/form.svelte.test.ts
— same casualty as the auto-fields test removed in phase B: imports
from \$langs and \$lib/sium/* (both deleted).
- Update arts/session/ssr.ts JSDoc example: \$lib/app → \$active-app.
Note: form.svelte and form-auto-fields.svelte themselves still import
from \$lib/sium/* and \$langs. They will not compile against the new
ecosystem and need a rewrite or deletion as a separate decision —
flagged but not changed here.
Also note: src/arts/cache/test/engine-cache.test.ts fails with
"defineActiveCache is not a function". The test file is untracked
(was never committed); the failure predates this branch and is not
caused by the cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops references to terra/air/lib (deleted in phases A-B). New
architecture section: arts/libs/svrs at the runtime + helper layer;
uix/{morfo, sema, soma, eidos, active-uix} at the UI layer, with
morfo as the cross-layer contract (compileMorfo + cached
contracts.cssSelectors). Path-alias table updated to match
svelte.config.js / vite.config.ts after phase C. Build/test commands
point at the new test paths.
Critical-rules block: drop "NEVER modify terra source code" (terra is
gone) and the soma-audit references (legacy markdown). Keep "NEVER
delete files without explicit instruction" — this cleanup happened
under explicit user direction, the rule still holds.
Auto-memory in ~/.claude/projects/...: dropped feedback_terra_readonly,
feedback_air_wrapper_children, feedback_follow_terra_patterns,
feedback_architecture_fidelity, feedback_layout_kills_outlines (refs
deleted src/routes/layout.css), feedback_check_dias_before_porting
(consolidated into feedback_date_library), project_terra_air_dead,
project_session_state, project_activeapp_refactor. Added
project_active_uix_cleanup with the new state. Updated
feedback_date_library to point at \$libs/days.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
svelte.config.js / vite.config.ts:
- Drop dead aliases: \$lib, \$glob, \$langs, @/ling, @/logr, @/glob,
@/actx, @/uiux, \$terra (their target dirs were removed in phase B).
- Add \$reactive at the top level (was previously hidden under the
"legacy" block; the target src/libs/reactive is current).
- Set kit.files.routes to "web/routes" — demos move out of src/ and
will be re-authored from scratch alongside the new architecture.
- vite.config.ts: extract the alias map into a single `aliases` const
shared by both Vite resolve and the server-test project, instead of
duplicating it.
web/routes/+layout.svelte + +page.svelte: minimal SvelteKit boot so
`npm run dev` and `npm run build` find a routes tree. Real demos
(active-uix, soma, morfo, sema, eidos) will be added as sibling routes
once the runtime is wired.
Verified: `npx svelte-kit sync` regenerates .svelte-kit/tsconfig.json
without any of the dead aliases. .svelte-kit/types/web/routes/ is
populated, confirming the new routes path is active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed in this commit:
- src/uix/air/ — old visual layer (~2.5k files)
- src/uix/terra/ — reference primitives (~765 files)
- src/lib/ — legacy root: actx, ling, logr, ext, glob,
util/dias, util/funcs, sium status notes,
uiux (~334 files). Replaced by src/arts/,
src/libs/, src/svrs/.
- src/uix/soma/components/form/components/form-auto-fields.svelte.test.ts
— only file in the codebase still importing
from \$uix/air. Form auto-fields will be
rebuilt with soma primitives if/when needed.
Verified post-deletion: zero matches for `from '\$lib(?!s)`, `\$glob`,
`\$langs`, `@/ling`, `@/logr`, `@/glob`, `@/actx`, `@/uiux`, `\$terra`,
`\$uix/(air|lib|terra)` across the remaining src/. The new ecosystem
(arts/, libs/, svrs/, uix/{active-uix, soma, sema, eidos, morfo}) is
self-contained.
Aliases in svelte.config.js / vite.config.ts still reference the
deleted paths — that's the next commit (phase C).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`src/uix/lib/` (13 files): orphaned DOM utility layer; replaced by
`src/libs/dom/` in the new ecosystem. Zero inbound imports.
`src/routes/` (459 files): test routes for the dead terra/air/soma demos,
plus a few stragglers under sema/eidos/sium. Demos will be re-authored
fresh under `web/routes/` once the SvelteKit config is updated.
No production code paths were touched — only the legacy reference
implementation and its test pages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consumes `compileMorfo(morfo).contracts.cssSelectors` to classify every
`[data-*]` selector in an eidos CSS file:
- morfo-backed: the runtime writes the attr; eidos reacts to it.
- eidos-only: the marker is present (`[data-toggle]`) but at least
one `data-*` is not declared on the morfo part. Legitimate design
tokens (`data-color`, `data-size`, `data-variant`) and drift cases
(`data-dialog-header` — a part the morfo no longer declares) both
land here; the lint tool reports them so authors can decide.
- invalid: the attr IS declared on the morfo, but with a value
outside the declared enum. Bug.
Also reports `unusedFromContract`: selectors the compiler promises
that no eidos rule consumes — the inverse direction of drift.
Two CLI entry points:
- `scripts/eidos-lint.ts <component>` — single-component report
- `scripts/eidos-lint-all.ts` — sweep across every eidos component
Initial sweep finds 0 invalid, 134 eidos-only, 82 unused contract
selectors across 11 components. Real drift hotspots: dialog/drawer
`header`/`footer` (eidos parts the morfos don't declare), popover's
`data-side × data-last-action` compounds, tooltip's
`data-state='delayed-open'`. Deliberate left as-is for the user to
triage — fixes belong in a separate commit.
Tests cover toggle (clean baseline), dialog (drift detector for
header/footer + dead `data-disabled`), and a synthetic invalid case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`compileMorfo(morfo)` returns a `CompiledMorfo` with pre-resolved plans,
dependency analysis, and frozen contracts. Memoised by morfo identity via
`WeakMap` so the runtime pays compile cost once per morfo (HMR-friendly).
What the compiled form replaces:
- `createAttrs(morfo)` walk → `parts.attrs` (kebab → marker)
- `findMorfoPart(parts, target)` linear search → `parts.byKebab` map
- `resolvePartAttrs()` source.kind branching every effect → split into
`staticAttrs` (literals + role hoisted at compile time) and
`dynamicAttrs[]` (`AttrPlan` with explicit `mode`)
- keyboard string parsing each tick → `KeyboardPlan` with `ParsedKey`
- `flattenMorfoParts` walk per `registerContract` call →
`contracts.dataAttrsByPart` + `contracts.cssSelectors`
- per-part `deps.{states,props,parts,needsTranslations}` for narrow
effects; aggregate `requiredSources` for cross-cutting audits
Mode taxonomy (`AttrMode`): `html-presence`, `aria-boolean`, `enum`,
`string`, `data-state-derived` — the runtime branches once on a
discriminated union instead of re-walking source/condition/values.
Tests cover: parts walking + tree links, static/dynamic split, keyboard
parse, actions, CSS selector contracts, required-source aggregation,
per-part deps, mapRef inner-source extraction, cache identity, frozen
output, duplicate-kebab errors. 28 tests, all green.
No runtime callers wired up yet — that's Phase 2 (kernel + runtime
consumes CompiledMorfo). Old `resolver.ts` / `create-attrs.ts` paths
remain functional for the existing runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single convenience method on `ActiveUix` so soma providers stop
duplicating the `dom: this.soma.dom, semantic: this.soma.semantic`
boilerplate at every `createMorfoRuntime` callsite.
Before (8 providers, identical lines):
this.runtime = createMorfoRuntime(dialogMorfo, {
dom: this.soma.dom, // ← duplicated everywhere
semantic: this.soma.semantic, // ← duplicated everywhere
states: { … },
props: { … },
events: { … }
})
After:
this.runtime = this.soma.uix.runtime(dialogMorfo, {
states: { … },
props: { … },
events: { … }
})
The implementation is a thin convenience — internally `uix.runtime()`
calls `createMorfoRuntime(morfo, { dom: this.dom, semantic: this.semantic,
…sources })`. No registry, no scope ids, no per-instance lifecycle to
track. Per-provider runtime ownership unchanged. The user explicitly
chose this minimal fix over a "single shared runtime + scope handles"
design after weighing the tradeoffs.
Migrated callsites: avatar, collapsible, dialog, drawer, popover,
switch, toast, toggle (8 providers, ~10 callsites including toast's
two — root + item). The `createMorfoRuntime` named import dropped from
each (only `MorfoRuntime` type kept where the field is annotated).
3 new tests on `active-uix.svelte.test.ts`:
- returns a MorfoRuntime with auto-injected services
- caller does not pass dom or semantic
- attach mode uses the external app's dom + semantic
Verified: vitest 17/17 active-uix (was 14), 211/211 UIX, smoke 68/68,
morfo-check 66/66.
Earlier design mistake: `createActiveUix(options)` was internally
calling `createActiveApp(...)` to compose the UIX services. That
violated the contract — active-uix was supposed to be either a
substitute for active-app (when no app exists) or a complement (when
an external app injects services).
Fix: standalone mode now instantiates each UIX service directly via
the canonical factories — no `active-app` involved:
- core: createEngineLogger, createEngineBus, createActiveTimers,
createActivePrefs (with empty schema)
- lang: createActiveLang(schema, defaultLocale, fallbackChain)
- dom: createActiveDom
- frontend: createActiveFrontend (auto-applies to <html>)
- format: createActiveFormat (clock from timers)
- semantic: new EngineSemantic(opts)
active-uix manages every owned lifecycle: dispose() tears them down
in reverse dependency order. Attach mode is unchanged — wraps an
external app, validates UIX services, doesn't touch app.dispose().
Type changes:
- `ActiveUix.app: ActiveApp | undefined`. Present only in attach
mode (it's the underlying app); `undefined` in standalone (no
app exists).
- `ActiveUix.bus: EngineBus` (generic envelope). Standalone gets
a fresh bus, attach gets the App's bus with its event map. UIX
components don't care about typed events; consumers that do
re-narrow at the call site.
- Removed `prefs?` and `extraServices?` from `ActiveUixOptions`.
Apps that need a custom prefs schema or services beyond UIX
should use attach mode (compose `createActiveApp` themselves).
New tests cover both modes: standalone (8 tests including service
disposal verification, idempotent dispose, format opt-out,
componentLangs registration toggle), attach (6 tests including
missing-services error, identity-equal service proxying, no
disposal of external app), and cross-mode parity (1 test).
Verified: vitest 15 files / 211 tests, smoke 68/68, morfo-check 66/66.
Decouples the UIX layer (soma / morfo runtime / sema / eidos) from
the App composition layer (`active-app`). UIX components no longer
import `\$active-app` directly — they read services through
`getActiveUix()`. Two boot paths produce the same `ActiveUix`:
- **Standalone** — `createActiveUix({ lang, semantic, … })`
bootstraps an `ActiveApp` internally with the UIX services plus
any `extraServices`. Owns the app — `dispose()` propagates.
- **Attach** — `attachActiveUix(externalApp)` wraps an app the
application composed itself (typically when it has services
beyond UIX: cache, session, http, …). The external app must
declare the UIX services; helper `defineUixServices(options)`
returns the slice ready to spread into
`createActiveApp({ services })`. Does NOT own the app.
In both cases components see only `ActiveUix`. They have no way to
tell which boot path was used.
Module:
src/uix/active-uix/
├── index.ts barrel ($active-uix)
├── types.ts ActiveUix, ActiveUixOptions, AttachActiveUixOptions
├── services.ts defineUixServices() + UIX_REQUIRED_SERVICES
├── active-uix.svelte.ts createActiveUix() + attachActiveUix() + ActiveUixImpl
├── components/uix.svelte <Uix uix={...}> wrapper (sets context + creates Soma)
└── svelte/context.svelte.ts setActiveUix() / getActiveUix() / tryGetActiveUix()
Surface exposed by `ActiveUix`:
- UIX services: lang, frontend, format (optional), dom, semantic
- Core: logger, bus, timers, prefs
- Config: somaPortalTo
- `app` escape hatch for advanced integration code (NOT for UIX
components)
Wiring done by active-uix at boot:
- `App.lang.extend('components', componentLangs)` — soma component
translations always available without per-app boilerplate.
- Frontend service auto-configured with `applyDom: true` +
`target: () => document.documentElement` so theme/dir/density
land on `<html>` automatically.
Migration:
- `Soma.create()` reads `getActiveUix()` instead of `getActiveApp()`.
Soma class no longer imports `\$active-app`.
- 3 layouts (test/eidos, test/sium, test/soma) replace
`createActiveApp + setActiveApp + <Soma>` with
`createActiveUix + <Uix>`. Boilerplate drops ~20 lines per layout.
- 7 sium pages replace `getActiveApp() + app.lang.setLocale(…)`
with `getActiveUix() + uix.lang.setLocale(…)`.
Validation:
- `attachActiveUix(app)` throws `ActiveUixMissingServiceError` if
the external app didn't declare lang/frontend/dom/semantic.
- `getActiveUix()` throws `ActiveUixNoContextError` if no `<Uix>`
ancestor mounted.
`\$active-uix` alias wired in svelte.config.js + vite.config.ts (both
client and server projects).
Verified: vitest 197/197, smoke 68/68, morfo-check 66/66, playwright
probe (4 routes including dialog with sound) zero page/console errors.
Follow-up to 4bf6dea4 — that commit added the type definitions to
`src/libs/reactive/types.ts` but the corresponding re-export from
`src/libs/reactive/index.ts` was lost during the staging shuffle.
Without this, `import { type ActiveProps } from '$libs/reactive'` would
fail on a fresh checkout even though the consumers across soma assume
those types are exported from the libs barrel (the previous commit
rewrote 79 of them to that import path).
Per architectural feedback: when relocating code to its correct layer,
do the migration completely — update every consumer directly and
delete the original. No re-export barrels at the old paths.
Removed shims:
- `src/uix/soma/attrs/` — was re-exporting `createAttrs` /
`registerContract` / `assertContract` / `getContract` from
`$uix/morfo` and the bool/data-attr formatters from `$libs/dom`.
All 66 importing files updated to import from the canonical
locations directly.
- `src/uix/soma/reactive/` — `reactive.svelte.ts` was a thin
re-export of `$libs/reactive`. The 79 consumers (every soma
provider + layers + types) now import primitives directly from
`$libs/reactive`. The `opts.ts` Props↔Opts bridge moved to
`src/uix/soma/provider/opts.ts` (its natural home — provider
plumbing, not reactive primitives).
- `attachRef` / `RefAttachment` re-export from
`src/uix/soma/provider/provider.svelte.ts` and from
`src/uix/soma/index.ts`. Both removed. Consumers
(`floating.svelte.ts`, `date-field-provider.svelte.ts`) now
import directly from `$libs/reactive`.
Also:
- Moved `ActiveProps<T>` / `StateProps<T>` types to
`src/libs/reactive/types.ts` — they're trivial generic helpers
over `Active<T>` / `State<T>` and shouldn't have lived in soma.
- Stripped soma's root barrel (`src/uix/soma/index.ts`) of every
re-export of libs/morfo concerns. It now only re-exports
actually-soma surfaces (Provider base, mergeProps, css helpers,
id, types, Soma class).
Result:
- `grep -r '\$soma' src/uix/morfo` → 0 matches
- `grep -r '\$uix' src/libs` → 0 matches
- `src/uix/soma/attrs/` and `src/uix/soma/reactive/` no longer exist
- Every import statement now reflects the real architectural layer
Verified: vitest 19/20 files / 214 tests (1 pre-existing air dead-
branch fail using `bun:test`), smoke 68/68, morfo-check 66/66.
The previous DOM consolidation commit left a wrong-direction edge —
`src/uix/morfo/runtime.svelte.ts` imported `createAttrs` from
`$soma/attrs` and `attachRef`/`RefAttachment` from `$soma/provider`.
Morfo is the declaration layer; soma is one of its consumers; morfo
must not depend on soma.
Moves:
- `createAttrs` (`src/uix/soma/attrs/create-attrs.ts`) →
`src/uix/morfo/create-attrs.ts`. It's a pure morfo→data-attr-name
derivation; its natural home is alongside the morfo declarations it
derives from. Soma is a consumer like any other.
- `contracts.ts` (registerContract / assertContract / getContract)
→ `src/uix/morfo/contracts.ts`. Same reasoning: contracts derive
from a morfo's `data: [{ values }]` enum.
- `attachRef` + `RefAttachment` (extracted from
`src/uix/soma/provider/provider.svelte.ts`) →
`src/libs/reactive/attach-ref.svelte.ts`. It bridges Svelte's
`{@attach}` system with the `State` reactive box; both ends are in
libs/reactive, so it lives there.
- Bool/data-attr formatters from `src/uix/soma/attrs/helpers.ts`
(boolToStr, boolToEmptyStrOrUndef, boolToTrueOrUndef,
getDataOpenClosed, getDataChecked, getAriaChecked) →
`src/libs/dom/attrs.ts`. Pure DOM-attr value formatters with no
morfo dependency — they're libs/dom material.
Critical bug fix uncovered by the move:
`src/uix/soma/reactive/reactive.svelte.ts` was a parallel
implementation of the reactive primitive system, with its OWN
`ActiveSymbol` / `WritableSymbol` instances — different from the
ones in `$libs/reactive`. As soon as `attachRef` (now in libs)
tried to detect a soma-created `State` via `isState`, the guard
returned false and the function fell into the setter-fn branch,
throwing "ref is not a function" in 10+ form tests.
Fix: rewrite `soma/reactive/reactive.svelte.ts` as a thin re-export
of `$libs/reactive`. They now share the same symbol identities, so
every cross-layer guard works consistently.
Compatibility:
- `$soma/attrs` is now a backward-compat barrel that re-exports from
`$uix/morfo` (createAttrs, contracts) and `$libs/dom` (helpers).
Existing soma providers continue working unchanged.
- `$soma/provider/provider.svelte.ts` re-exports `attachRef` /
`RefAttachment` from libs for any importer that still uses the
legacy path.
Verified post-cleanup:
- `grep -r '$soma' src/uix/morfo` → 0 matches (no morfo→soma)
- `grep -r '$uix' src/libs` → 0 matches (no libs→uix)
- vitest 14 files / 197 tests
- smoke 68/68
- morfo-check 66/66
Three architectural cleanups in one cut:
1. **morfo runtime relocates to its own layer.** The MorfoRuntime + the
pure morfo→attrs resolver (and their tests) move from
`src/uix/soma/morfo/` → `src/uix/morfo/`. They were never
soma-specific — they interpret a morfo declaration on behalf of any
consumer. Soma was just the only consumer at the time. The new
barrel at `$uix/morfo` re-exports `createMorfoRuntime`,
`MorfoRuntime`, `MorfoRuntimeSources`, `RegisterPartOpts`,
`SourceMap`, `SemanticEmitter`, `TriggerOptions`, plus the resolver
helpers (`findMorfoPart`, `resolvePartAttrs`, `shouldEmitMorfoEntry`,
etc.).
2. **`src/uix/adom/` is gone.** The runtime pieces (`createActiveDom`,
`BodyScrollLock`, `DOMContext`, `RovingFocusGroup`, viewport) all
live at `$adom` (`src/arts/adom/`) which is the canonical
ecosystem-level ActiveDom. The `apply.ts` types and helpers
(`DomAttrValue`, `applyChange`, `removeAttrs`, `StructuralChange`)
live in `$libs/dom` and are re-exported from `$adom`. The 4 last
`$uix/adom` import sites updated to `$adom`.
3. **`src/uix/soma/dom/` is gone.** The 14 utilities (`isBrowser`,
`getDocument`, `isHTMLElement`, focus + tabbable helpers, etc.) all
exist as a superset in `$libs/dom`. The 12 soma consumers updated
their imports from `'../dom'` / `'$soma/dom'` → `'$libs/dom'`. No
functional change — purely import-path consolidation.
`src/uix/active-uix/` is preserved as an empty placeholder for the
future cross-layer composition class that will set up morfo + soma +
sema + eidos together.
The relocated runtime now imports cleanly:
- `ActiveDom`, `DomAttrValue` ← `$adom`
- `EngineSemantic`, sema helpers ← `$uix/sema`
- `Active`, `State` ← `$libs/reactive` (was `$soma/reactive`)
- `createAttrs` ← `$soma/attrs` (cross-layer; createAttrs is morfo
logic that lives in soma for now — slated for relocation in a
follow-up)
- `attachRef`, `RefAttachment` ← `$soma/provider/...` (idem)
Verified: vitest 14 files / 197 tests, smoke 68/68, morfo-check 66/66.
Updates the canonical semantic vocabulary to match the latest
formulation in src/docs/libro_semantica_completo.txt:
Families (7, was 6):
contact, commit, signal, handle, emerge, shift, sustain
- `alert` → `signal` (rename: same perceptual function — attention /
salience — but the book's name reflects the broader scope including
notify, warn, emphasize, announce).
- `shift` (NEW, transitional): frame change. Modal mount, navigation,
mode change. Differs from emerge in that it reorganizes planes
(heavier backdrop, deeper shadow) — "cruce de umbral" not just
"aparición". Per book rule, the frame doesn't carry intent on its
own; evaluation belongs to a companion `signal` event.
Intents (6, was 5):
neutral, affirm, fulfill, risk, threat, loss
- `loss` (NEW): consummated negative consequence (negative valence,
low activation, posterior). Differs from `threat` (anterior, high
activation, convokes action): loss registers, doesn't convoke. Per
book §11.5 "la amenaza no es la pérdida".
sema-map.ts perceptual values:
- `signal` family entry: kept the prior `alert` numbers verbatim
(semantics is the same — attention/salience) under the new name.
- `shift` family entry: motion 260ms ease-in-out 0.98→1, sound
pitch 500 ascending gain 0.18 (deliberately quieter than emerge's
0.20 to not compete with companion signal), presence with stronger
backdrop (0.5) + shadow (blur 32 y 12 op 0.2) emphasizing
plane reorganization.
- `loss` intent deltas: descending sound contour (-150 pitch),
contraction (scale.to -0.04, motion duration ×0.95 — brief but
grave), color toward purple-grave (hue replace 280, lower
saturation/intensity), withdrawn presence (backdrop 0.05, no glow).
ARIA `role="alert"` (W3C live region) is unrelated and untouched in
soma/announce, soma/toast and morfo announce/field/form. The toast
morfo gains `loss → role:'status' aria-live:'polite'` mapping —
loss is consummated so it shouldn't interrupt.
Verbs added per the book's worked examples: signal {warn, notify,
emphasize}, shift {enter-mode, exit-mode, navigate}, commit {save,
discard, delete}, contact {press, click, hover}, handle {pick, carry,
drop}, emerge {reveal, show, hide}.
Verified: vitest 9 files / 99 tests, smoke 68/68, morfo-check 66/66.
The air → eidos refactor stripped the `.air-accordion-*` class hooks but
left 5 rulesets with their selector empty (just whitespace before `{`).
The most visible damage was line 161:
[data-accordion-trigger][data-state='open'] {
transform: rotate(var(--eidos-accordion-indicator-rotation));
}
Empty selector after the trailing space → the rule matched the trigger
button itself instead of the intended `.air-accordion-trigger-indicator`
descendant. So expanding any item rotated its title 90deg.
The other four orphaned rules (trigger-main flex layout, trigger-label,
indicator size/transition, content-inner padding) were dormant noise but
also dropped — the morfo doesn't declare an Indicator part and the demo
doesn't render the inner wrapper, so there's nothing to style.
Content padding is now applied directly to `[data-accordion-content]`
since there's no inner wrapper. Animation-height includes padding, which
matches the natural scrollHeight measurement.
Verified:
- playwright probe: trigger `transform: none` in both closed and open
state (was `matrix(...)` rotating 90deg before)
- smoke 68/68
Diagnosis: clicking "open" on dialog/drawer/popover produced no sound
(or any semantic feedback). Cause: every overlay trigger was bypassing
`runtime.trigger(...)` with a direct `handleOpen()` because the morfo's
declared target part (overlay content) doesn't yet exist in the DOM
when the trigger fires — and `runtime.trigger` was throwing when the
target part had no registered ref.
Fix:
- `MorfoRuntime.trigger(name, opts?)` now accepts `{ fallbackTarget?:
HTMLElement }`. When the declared target part is unmounted, the
fallback is used as the visual signal target so the perceptual
signal still fires. Sound / vibra channels don't care about the
target; the visual channel writes its `data-event*` to the trigger
briefly, which is the natural locus of the user's action anyway.
- Dialog / Drawer / Popover triggers now go through `runtime.trigger`
with the trigger element as `fallbackTarget`. Each provider's
`events` map gains an `open` (or `present`) entry that runs
`handleOpen()` as the runtime's handler step — preserving the
prewrite → emit → handler order.
- Popover's toggle uses `close-dismiss` for the close direction so
both directions emit a perceptual signal, not just open.
Soma class: `soma.dom` and `soma.semantic` are now non-optional again
(matching pre-migration semantics — every layout declares both
services). `soma.format.*` slices stay optional.
Verified end-to-end with a Playwright probe that spies AudioContext
construction:
Open dialog → 2 oscillators (was 0)
Press Escape → +2 (was 2)
Click overlay → +2 (was 0)
Gates: vitest 7 files / 78 tests, smoke 68/68, morfo-check 66/66.
Restores the resolver-driven architecture that was deleted in the
channel-modules refactor (commit a0a1485b). Each signal now flows
through a single resolution that produces an `EffectiveSignature`, and
each channel reads its own slice — so the visual hold, the sound earcon,
and (future) motion / color / presence channels all share a single
family-level perceptual budget defined in `SEMA_MAP`.
New surfaces:
- `src/uix/sema/sema-map.ts` — typed perceptual map (port of the legacy
`sema-map.json`) with per-family `base` (motion / sound / color /
presence) + `activeChannels` and per-intent `deltas` using the
number / `{op:'add'}` / `{op:'multiply'}` / `{op:'replace'}` /
recursive-merge semantics from before.
- `src/uix/sema/resolver.ts` — pure `resolveSignature(signal)` that
returns an `EffectiveSignature`. Empty signature when family is
missing so channels skip rather than throw. Intent deltas only apply
to valenced families. Optional `soundPack` per-event sample URLs.
- `src/uix/sema/chans/sound.ts` — real `SoundChannel` (~290 lines, ported
from a0a1485b^). Single AudioContext + master GainNode, auto-unlock
for Safari/iOS via `click` / `touchstart` / `keydown` with capture,
ADSR-lite synthesis (sine + 5th → biquad lowpass → envelope, AM
modulator when roughness > 0.2), contour via `osc.detune`, optional
sample playback with `AudioBuffer` cache. Errors absorbed silently —
sema is ornamental.
Architectural changes:
- `Channel.handle(signal, effective)` — every channel now receives the
resolved signature alongside the signal. Channels can ignore it.
- `EngineSemantic.emit` resolves once per emit, dispatches both args.
- `VisualChannel` hold resolution chain is now (highest priority first):
1. `signal.hold`
2. `effective.motion.duration` ← NEW: from sema-map
3. family fallback table (SEMA_DURATIONS)
4. engine `defaultHold`
Behavior change: families with motion in the map (alert: 220ms,
contact: 80ms, commit: 180ms, …) now use those values instead of the
previous label-based 600ms / 120ms / 240ms fallbacks. The fallback
table remains as a safety net for families without motion (sustain).
- `EngineSemanticOptions` adds `sound?: true | false | SoundChannelOptions
| Channel`. Sound is opt-in (audible side effects). Layouts wire
`defineEngineSemantic({ sound: true })`.
Tests:
- New `resolver.test.ts` (7 tests) — locks down family base, intent
deltas, multiplicative ops, transitional-family no-op, deep-clone.
- `visual.test.ts` — every `handle` call now passes a stub
`EffectiveSignature`. Two new tests: motion.duration takes the hold,
and signal.hold overrides effective.motion.duration.
- `engine.test.ts` — `makeChannel` mock signature updated.
- `emit.test.ts` — alert family default test now expects 220ms (from
sema-map) instead of 600ms.
Verified:
- vitest: 6 files / 53 tests green
- smoke: 68/68 soma routes 200 OK
- morfo-check: 66/66 morfos validate
Replaces the legacy `$lib/ext/app` `App.create({ langs, presentation })`
composition pattern with the new ecosystem's `createActiveApp({ services
})` across all three test layouts (eidos / sium / soma) and rewires Soma
+ morfo runtime to consume the new surface.
What changed:
- Soma class (uix/soma/core/soma.svelte.ts) now reads from `getActiveApp()`
in $active-app context and adapts its historical names over the new
ecosystem: `soma.langs → app.lang`, `soma.presentation → app.frontend`,
`soma.dates/nums/money/units → app.format.{dates,numbers,currency,units}`.
All 12 soma component providers consume Soma unchanged.
- New context bridge: $active-app exports `setActiveApp(app)` /
`getActiveApp()` / `tryGetActiveApp()` (mirrors `setBus` / `getBus`
from $bus). Throws `AappNoContextError` (new code APP_ERR_NO_CONTEXT)
when no App is in scope.
- 3 layouts (test/eidos, test/sium, test/soma) replace
`App.create({ langs, presentation })` with `createActiveApp({ services:
{ lang, frontend, format, dom, semantic } })` + `setActiveApp(App)` and
call `App.lang.extend(...)` post-construction for runtime translation
modules.
- 7 sium test pages migrate `App.get()` → `getActiveApp()`,
`app.langs` → `app.lang`, `app.setLocale(x)` → `app.lang.setLocale(x)`.
- Morfo runtime exports a tiny `SemanticEmitter` type (`Pick<EngineSemantic,
'emit'>`) so consumers and tests don't have to satisfy the full
EngineSemantic surface.
- soma/core/langs.ts and sium plural import migrate `LangNode` /
`p` from `$lib/ext/lang/*` → `$libs/lang`.
After this commit no consumer outside `src/lib/` imports from
`$lib/ext/app`, `$lib/ext/lang`, or `$lib/ext/presentation`. The legacy
shims under `src/lib/ext/` remain for now (Phase 5 will delete them).
Verified:
- svelte-check: only 2 pre-existing errors (event.ts, runtime test)
- vitest: 6 files / 69 tests green (sema + morfo runtime)
- smoke: 68/68 soma routes 200 OK
- morfo-check: 66/66 morfos validate against demo DOM
- playwright: /test/sium/{login,booking,color} + /test/eidos/toggle
render full content with 0 page errors / 0 console errors
- eidos recipe check: dialog provider chain end-to-end OK
Adapts EngineSemantic to the AppServiceFactory contract so apps composed
with `createActiveApp` can declare semantic as a service:
services: {
semantic: defineEngineSemantic({ visual: { defaultHold: 400 } })
}
The factory lives in `src/uix/sema/` (not in `arts/active-app/service-
factories/`) because semantic coordination is a UIX-layer concern, not
generic app infrastructure like cache / auth / perm.
EngineSemantic itself stays unchanged — the factory just wraps it,
declares no core/service deps, and disposes the engine on teardown.
Aligns sema's engine class with the new ecosystem's Engine* / Active*
naming convention (per src/arts/README.md):
- Engine* — public methods over private state. No reactive surface.
- Active* — Engine* extended with $state + onChange listeners.
Sema's SemanticEngine is purely imperative: a channel registry + an
async dispatch. No $state, no listeners, no loading/lastError. So by
convention it must be Engine*, not Active*.
Renames (mechanical):
SemanticEngine → EngineSemantic
SemanticEngineOpts → EngineSemanticOptions (also aligned to
EngineSiumOptions / EngineHttpOptions name)
.destroy() → .dispose() (uniform with the rest
of the ecosystem; only one call site, in
engine.test.ts)
Touched code: engine.ts, exports.ts, emit.test.ts, engine.test.ts +
README.md (kept consistent). Also sweep src/lib/ext/app/{defaults,
app.svelte}.ts which still mention the old name in fallback wiring.
Air's docs (AIR_*.md) and the legacy CONTINUITY note kept untouched
(dead branch).
This commit is purely a rename. The next commit (Phase 4 part 2) adds
the defineEngineSemantic factory and migrates soma + the 3 layouts to
createActiveApp.
Verification: sema 44/44, smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 — mark legacy as deprecated:
- Add @deprecated JSDoc header to all 27 top-level barrels under
src/lib (ext/{app,lang,dates,money,nums,units,presentation},
{actx,ling,logr,glob,sium,util}, root index.ts).
- Add src/lib/README.md with the migration map: legacy module →
new equivalent under src/arts + src/libs + src/svrs.
- Zero behavior change. Only IDE strikethrough at usage sites.
Phase 1 — wire new ecosystem aliases in vite.config.ts and svelte.config.js:
- 18 new $arts/* aliases: $active-app, $adom, $auth, $bus, $cache,
$connection, $format, $frontend, $http, $lang, $logger, $orca,
$perm, $prefs, $session, $sium, $storage, $timer.
- $libs (src/libs), $locale (src/libs/locale), $svrs (src/svrs).
- $reactive rewired from src/lib/util/reactive to src/libs/reactive
(export surfaces verified compatible — same symbols, drop-in).
- Legacy aliases ($lib, $glob, $langs, @/ling, @/logr, @/glob,
@/actx, @/uiux) preserved for backwards compat during migration.
- SvelteKit auto-regenerates .svelte-kit/tsconfig.json paths from
svelte.config.js, so TS resolution updates without manual edits.
The migration plan (Phases 2-6) lives in src/lib/README.md:
Phase 2 — sweep src/lib/util/* imports to $libs/* (drop-in).
Phase 3 — morfo/schema.ts: 3 imports to $libs/standard-schema.
Phase 4 — soma's App.create() composition to createActiveApp with
explicit service slots (including semantic for sema). Only step
that changes architecture.
Phase 5 — sweep ~82 test pages to use new aliases.
Phase 6 — remove src/lib/{glob,ling,actx} after verifying no
consumers; investigate sium and util/{dates,times,timers}.
Verification: smoke 68/68, morfo-check 66/66. svelte-check baseline
unchanged for the alias rewire (no Cannot-find-module errors). The
2 dialog-provider possibly-undefined errors at lines 113-114 are
pre-existing (this.soma vs this.soma! after Soma.get() returning
Soma | undefined) — not introduced by this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Drawer's "show" semantic was missing from the morfo, so close paths
went straight to handleClose() without the prewrite → emit → handler
cadence. User flagged this — drawer DOES have a present/dismiss
semantic, equivalent in surface to Dialog.
Morfo (`morfo/components/drawer.ts`):
- Added `events[]` mirroring Dialog: present, close-save,
close-cancel, close-dismiss, close-dismiss-outside, close-after-fail
- Added `data-last-action` (saved/cancelled/dismissed/dismissed-outside/
failed) to Content's data so the prewrite has a declared slot
- scope: ['soma', 'sema'] now (sema participates via signal emit)
Eidos (`eidos/components/drawer.css`):
- Reserved a 3px transparent border on the LEADING edge of Content
per side (the edge facing the user, opposite to the slide-out
direction): right→border-left, left→border-right, top→border-bottom,
bottom→border-top
- Tint rules: combine [data-state='closed'] + [data-side] +
[data-last-action] so the right edge gets colored during the close
animation (saved → success, failed → danger, cancelled/dismissed →
neutral)
Soma (`soma/components/drawer/drawer-provider.svelte.ts`):
- DrawerProvider injects a MorfoRuntime (same pattern as Dialog soft-
migration). Registers content part. Wires events.{close-save,
close-cancel, close-dismiss, close-dismiss-outside, close-after-fail}
to handleClose().
- dismissWith(action) public method — same API as Dialog.dismissWith.
- Replaced 4 close paths with runtime.trigger():
Close.onclick → 'close-cancel'
Escape (modal) → 'close-dismiss'
Escape (non-modal) → 'close-dismiss'
Overlay.onclick → 'close-dismiss-outside'
Drag-to-dismiss (gesture) keeps direct handleClose() — the visible
slide-out IS the close, no 240ms hold makes sense.
- DrawerProvider class re-exported from $soma/components/drawer.
Demo (`test/eidos/drawer/+page.svelte`):
- New "Exit tinting by causa" section with Save/Cancel/Fail buttons
that call dismissWith() through a local _dismiss-button.svelte
(same pattern as Dialog's).
Verification (playwright):
save t=83ms lastAction=saved hold=240ms (commit family)
cancel t=119ms lastAction=cancelled hold=240ms (emerge)
fail t=127ms lastAction=failed hold=600ms (alert)
Smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous layout (`repeat(4, 1fr)` with 0.5rem gap) packed the four
sides in a horizontal row. Floating's collision detection saw the
tooltip on `side='right'` had no room (the next button was 8px away)
and shifted the placement, making the popover/tooltip appear far from
its trigger.
Switch to 2x2 grid with 4-7rem gaps and 3rem padding so each trigger
has enough surrounding space for the tooltip/popover to render at the
requested side without the floating layer flipping or shifting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates `air/components/tooltip/tooltip.css` (small — 31 lines, only
Content + sizes styled) and adds:
- Arrow fill (matches tooltip bg)
- Per-side entrance/exit animations keyed off [data-side] (Floating
layer) and [data-state]. Distance is smaller than Popover (2px vs
4px) — tooltip is lighter visually.
- State handling for tooltip's three-value [data-state]:
'closed' → exit animation
'delayed-open' → entrance animation (hover after openDelay)
'instant-open' → no animation (keyboard focus, deliberate
invocation; the user wants it now, not after a
slide)
Demo (test/eidos/tooltip): 4-side grid + sizes (sm/md/lg).
Verification: smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates `air/components/popover/popover.css` (small — 67 lines, only
Content + Close styled) and adds three eidos extensions:
1. Trigger envelope ([data-popover-trigger]) — air relied on the
Button recipe via .air-button class. Eidos applies a baseline
button-style envelope here, consuming Capa 2 directly.
2. Overlay fade ([data-popover-overlay][data-state='open'/'closed'])
— for modal popovers. The morfo declares Overlay as optional;
consumer renders it when modal=true.
3. Per-side entrance/exit animations keyed off [data-side]
(top/right/bottom/left) and [data-state]. Soma's Floating layer
writes data-side based on resolved placement. The popover slides
into place from the trigger edge — fade + small translate (4px)
+ scale (0.985 → 1). Air's "revelation" pattern: ~180ms entrance
/ 120ms exit.
Demo (test/eidos/popover): basic + 4-side grid. Each side button
anchors a Popover.Content with the matching `side` prop so the
animation direction matches placement.
Verification: smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates `air/components/drawer/drawer.css` to `eidos/components/
drawer.css` (mechanical: --air-* → --eidos-*, drop .air-* gates) and
adds the entrance/exit animations air's recipe was missing — air drove
drawer transitions through `air.semantic.contextIn/Out` Svelte
transitions, which don't exist in the morfo-driven path. Without them
the drawer snapped in/out instantly.
Per-side keyframes:
right → translateX(100%)
left → translateX(-100%)
top → translateY(-100%)
bottom → translateY(100%)
Plus overlay fade keyed off data-state. All ~240ms entrance / 180ms
exit (moderate / normal). The Presence layer waits for
getAnimations().finished so the content stays mounted during exit.
Demo (test/eidos/drawer):
- Modal section: 4 side buttons, render Drawer.Overlay (backdrop
+ focus trap, page non-interactive while open).
- Non-modal section: 4 side buttons, omit Drawer.Overlay + pass
modal={false}. No backdrop. Page behind stays interactive. Soma's
Dismissal layer auto-adjusts (interactOutsideBehavior default
flips from 'ignore' to 'close').
The Drawer morfo doesn't declare `events` yet, so no runtime.trigger()
cadence + no exit-tinting like Dialog. Recipe + animations only.
Verification (playwright probe):
top t=140ms translateY(-372) → t=406ms 0
right t=99ms translateX(452) → t=389ms 0
bottom t=94ms translateY(372) → t=390ms 0
left t=111ms translateX(-349) → t=428ms 0
Smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expose a programmatic dismiss method on DialogProvider that fires the
matching close-* event through the runtime, and use it from the eidos
Dialog demo to make the exit-tinting visible.
Soma:
- DialogProvider.dismissWith('save'|'cancel'|'fail'|'dismiss'|
'dismiss-outside') maps to the corresponding morfo event
(close-save, close-cancel, close-after-fail, close-dismiss,
close-dismiss-outside) and fires runtime.trigger().
- DialogProvider class re-exported from $soma/components/dialog so
consumers can call DialogProvider.get() inside a child component.
Demo (test/eidos/dialog):
- New "Exit tinting by causa" section with three buttons that close
the same dialog via three different events:
Save → close-save → border-top tinted success (240ms hold)
Cancel → close-cancel → border-top tinted neutral (240ms hold)
Fail → close-after-fail → border-top tinted danger (600ms hold)
The 600ms fail hold is sema's family='alert' default — the dialog
stays open longer for the user to register the failure before close.
- Uses a small _dismiss-button.svelte child component that captures
DialogProvider.get() at component init time. The inline onclick
pattern (Dialog.DialogProvider.get()?.dismissWith(...)) doesn't
work from the parent route's scope because Svelte's getContext is
init-time-only — the route is the parent of Dialog.Provider, not
a child. The _dismiss-button is rendered inside Dialog.Content so
its init-time context lookup resolves correctly.
Verification (playwright probe):
save t=144ms lastAction=saved hold=240ms
cancel t=116ms lastAction=cancelled hold=240ms
fail t=117ms lastAction=failed hold=600ms (full alert family)
Smoke 68/68.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inject a MorfoRuntime side-channel in DialogProvider that fires the
prewrite → semantic.emit → handler cadence for the close-* events
declared in the dialog morfo. The legacy provider machinery
(assertProps + resolveMorfoProps + Provider class hierarchy) stays —
runtime is alongside, not replacing.
Wiring:
- DialogProvider creates the runtime in its constructor with
`events.{close-save, close-cancel, close-dismiss,
close-dismiss-outside, close-after-fail}` all delegating to
handleClose(). The runtime gets dom + semantic from soma + state
sources for `open` + prop sources for `disabled`/`modal`.
- DialogContentProvider registers the content part with the runtime
when its constructor runs, so trigger() can resolve
`event.target = partRef('content')` to a real DOM node.
- DialogTriggerProvider.onclick keeps direct handleOpen() — the
'open' event in the morfo targets content, but content isn't
in the DOM until AFTER open flips. Chicken-and-egg.
- DialogCloseProvider.onclick → trigger('close-cancel').
- DialogContentProvider's dismissal callbacks:
onEscapeKeydown → trigger('close-dismiss')
onInteractOutside → trigger('close-dismiss-outside')
What this unlocks:
Each close path now writes `data-last-action` (saved/cancelled/
dismissed/dismissed-outside/failed) BEFORE flipping data-state,
visible during the exit animation. The dialog recipe's tint rules
in eidos finally fire in vivo.
Verification (playwright check-eidos-recipes):
@8ms post-click lastAction=cancelled state=open (prewrite)
@241ms lastAction=cancelled state=open (signal hold)
@378ms lastAction=cancelled state=closed (exit)
@493ms opacity=0.017 (exiting)
The 240ms wait between click and exit is sema's family='emerge' hold.
The user can override per-event via `morfo.events[].hold` if 240ms feels
too long for a dialog cancel. Smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Air's dialog relied on the Button recipe (`.air-button` class on
trigger). Eidos has no class composition, so the trigger was inheriting
only the archetype's cursor + focus ring, leaving it with the
browser-default button look.
Add a baseline envelope in dialog.css consuming Capa 2 tokens directly
(--eidos-color-{surface-raised, surface-overlay, border-default,
content-primary}, --eidos-control-height-md, --eidos-radius-md,
--eidos-space-3, --eidos-duration-fast). Hover and data-state='open'
both shift to surface-overlay. Disabled fades to 0.5.
Trigger isn't a separate component with its own Capa 3 tokens — it's
a part of Dialog using the system surface. Direct Capa 2 consumption
is appropriate here (same as archetype rules).
Verification: smoke 68/68, morfo-check 66/66; visual confirmed via
playwright screenshot — Open dialog/Open small/Open large render with
proper button envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the V1.5 hand-rolled motion/easing/tint/opacity/focus-ring
tokens with the air-translated equivalents already in eidos/themes/
base/_static.css and eidos/themes/base/light.css.
Substitutions applied to archetypes.css, events.css, components/
dialog.css:
--eidos-motion-fast → --eidos-duration-fast (120ms)
--eidos-motion-base → --eidos-duration-slow (320ms)
--eidos-easing-standard → --eidos-ease-default
--eidos-easing-decelerate → --eidos-ease-out
--eidos-easing-emphasized → --eidos-ease-spring
--eidos-tint-neutral → --eidos-color-neutral-element
--eidos-tint-affirm → --eidos-color-success-element
--eidos-tint-fulfill → --eidos-color-success-element
--eidos-tint-risk → --eidos-color-warning-element
--eidos-tint-threat → --eidos-color-danger-element
var(--eidos-opacity-hover) → 0.85 (single-site, archetype-internal)
var(--eidos-opacity-disabled) → 0.5 (single-site, archetype-internal)
Focus ring tokens (--eidos-focus-ring-{color,width,offset}) unchanged
— same names already exist in air's _static.css/light.css.
`affirm` and `fulfill` collapse to the same `success-element` because
air's intent vocabulary doesn't distinguish them. The morfo's
data-intent attr still preserves the distinction at the structural
layer; the visual mapping is just the same color.
Updated scripts/check-eidos-recipes.ts to navigate to the per-component
demo URLs (/test/eidos/{toggle,switch,dialog}) since the index page
no longer renders the recipes directly.
Verification: smoke 68/68, morfo-check 66/66. Playwright entrance/exit
curves intact (opacity interpolates over the configured durations).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure the demos to mirror /test/soma's pattern: index page plus
one demo per recipe, all under a shared layout that loads the eidos
CSS chain and the Soma + App scaffolding once.
test/eidos/
├── +layout.svelte Soma + App + sticky nav bar
├── +page.svelte index (links + foundation explanation)
├── toggle/+page.svelte colors / sizes / variants / states
├── switch/+page.svelte colors / sizes / disabled
├── collapsible/+page.svelte
├── toast/+page.svelte fire by intent (5 intents)
└── dialog/+page.svelte default + size variants
Each demo exercises the variants air's CSS exposes (data-color,
data-size, data-variant) by adding the attrs directly to the Provider
element — they're a-la-carte since the morfos don't declare them yet.
Future work: extend the morfos to declare these attrs as first-class
props.
Verification: all 6 routes return 200; smoke 68/68 (only covers
/test/soma — eidos routes not in scope), morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch (151 lines):
- mechanical: --air-* → --eidos-*, drop .air-* gates
- selectors match morfo as-is ([data-switch], [data-switch-thumb],
[data-state='checked'], [data-disabled])
- a-la-carte variants (data-color, data-size) dormant unless consumer adds
Collapsible (decorated parts only):
- mechanical translation
- dropped air's height-keyframes: they referenced
var(--terra-collapsible-content-height) (a JS-measured value from
the dead terra layer) and soma's Collapsible uses HTML `hidden`
(display:none) which kills CSS animations anyway. Left undecorated;
needs Presence layer integration in soma to enable real transitions.
Toast (substantive remap):
- selector remap due to part naming difference between air and morfo:
air [data-toast-toaster] → eidos [data-toast-viewport]
air [data-toast] (the card) → eidos [data-toast-item]
air [data-toast-message] → DROPPED (no equivalent — soma has
Title + Description direct, not a
separate "message" element)
air .air-toast-status → DROPPED (no status icon part)
air .air-toast-main → DROPPED (no content wrapper part)
- intent vocabulary remap (air data-type ↔ morfo data-intent):
error → threat
warning → risk
success → fulfill (and affirm — same palette)
info → neutral
- data-position on viewport stays a-la-carte (not in morfo)
Verification: smoke 68/68, morfo-check 66/66, focus rings + dialog
entrance/exit working per playwright.
Note: [data-dialog-trigger] has no explicit rule (air relied on
.air-button recipe). The trigger renders with browser defaults +
archetype interactive cursor. To be addressed in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the V1.5 hand-rolled recipes with the translated air originals
adapted to the morfo-driven architecture.
Mechanical translation:
- sed --air-* → --eidos-* (tokens) and --_air-* → --_eidos-* (private)
- drop `.air-{part}` class gates everywhere — eidos applies universally
to whatever data-attrs the morfo emits
Substantive adjustments per recipe:
Toggle:
- kept the existing morfo data-attrs ([data-toggle], [data-state='on'],
[data-disabled]) — match air's selectors directly
- air's a-la-carte variants ([data-color], [data-size], [data-variant],
[data-block], [data-icon-only]) stay dormant: not in toggle morfo,
only fire if a consumer adds them manually
- 229 lines of palette × variant × size variants vs 40 lines hand-rolled
Dialog:
- air's pattern: animation keyframes keyed off [data-state='open'/'closed']
(NOT Presence-layer markers); Soma's Presence waits getAnimations().
finished, so this works
- air ONLY animates the overlay — content snaps in/out instantly, sub-
perceptual. Added eidos-only @keyframes for content entrance/exit
(opacity + scale) so Presence has something to wait for and the user
can perceive the transition
- kept data-last-action exit tinting (border-top by causa during
closing animation) — air doesn't have this concept, dialog morfo
declares the prewrite that writes the attr before data-state flips
Verification:
- smoke 68/68, morfo-check 66/66
- playwright entrance: opacity 0 → 1 over ~120ms (air's overlay-in token)
- playwright exit: opacity 1 → 0 over ~280ms (air's overlay-out token)
- both interpolate, not sub-perceptual; user can tweak via tokens
Note: the existing eidos/tokens.css (legacy hand-rolled) and
eidos/components/{toast,switch,collapsible}.css (still hand-rolled)
continue to load alongside; phase 3 will translate the rest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates the foundation of air's visual system into eidos:
- eidos/contracts/ public token interfaces (4 root + 64 components)
- eidos/tokens/ per-component token values (62 components + motion)
- eidos/themes/base/ light + dark + _static + motion (4 files)
Mechanical translation: `--air-*` → `--eidos-*` everywhere, relative
imports preserved. The CSS is currently NOT wired into the legacy air/
folder, which stays for reference until phase 4 cleanup.
The `--eidos-*` prefix is documented in eidos/index.css with the two
reasons it earns its keep:
1. Authorship clarity at debug time (DevTools can identify the layer)
2. Override discipline (consumers know they're targeting public API)
Phase 1 only adds the foundation without touching component recipes.
The hand-rolled recipes (toast, dialog, toggle, switch, collapsible)
and the legacy hand-rolled tokens.css continue to load alongside the
new tree. Phase 2 will replace recipes with air-translated equivalents
and reconcile the duplicate motion/tint tokens.
Architectural note: air targeted terra and gated its CSS with
`.air-{component}` class on a wrapper. Eidos targets soma+morfo and
applies universally to the data-attrs the morfo declares — no class
gates, no Svelte wrappers. The morfo layer is the one that decides
which `data-*` attrs exist, which is the substantive shift between
the two systems.
Verification: smoke 68/68, morfo-check 66/66, focus rings + dialog
entrance/exit still working per Playwright check (curve unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hold of a component's event is now declared by the component itself,
not buried in sema's family fallback table.
Resolution precedence (in priority order):
1. signal.hold imperative, caller passes at emit time
2. morfo events[].hold declarative, component-level (this change)
3. sema family fallback 'emerge' → 'brief', 'alert' → 'noticed', ...
Sema's family table becomes the technical fallback when neither caller
nor morfo specifies. The numbers come from a single perceptual scale in
sema/durations.ts (subliminal/glimpse/brief/noticed/insistent/persistent),
so any change to the scale propagates to the family fallback automatically.
Public surface:
sema: SEMA_DURATIONS, SemaDurationLabel, SemaDurationSpec,
resolveSemaDuration() — exported from $uix/sema.
morfo: MorfoEvent.hold?: SemaDurationSpec — number or label.
soma: MorfoRuntime.trigger() reads event.hold, resolves the label →
ms via resolveSemaDuration(), passes it as signal.hold.
Tests:
- sema/durations.test.ts (4): scale values + resolver
- soma runtime (3 new): hold-less event sends no signal.hold (sema
falls back), numeric hold propagates, label hold resolves to ms.
Verification: sema 44/44, soma runtime 25/25, smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the single-point waitForTimeout snapshots with a rAF sampler
that records opacity at every frame for 500ms. Lets the report show
the actual easing curve, not just one ambiguous data point.
Output now includes the full entrance + exit timing per frame, so a
glance at the log tells whether the configured transition duration is
respected end-to-end (no premature unmount, no clipped curve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The recipe was missing transitions on the Presence-layer markers
(data-starting-style / data-ending-style). The dialog snapped into
view and snapped out — duration 0ms is sub-perceptual.
Add fade + scale on Content (320ms = --eidos-motion-base = "brief")
and fade on Overlay. Both interpolate via the Presence lifecycle:
open → element mounts with [data-starting-style] briefly →
CSS shows opacity:0 + scale(0.96) →
marker removed next rAF →
transition runs to opacity:1 + scale(1)
close → [data-ending-style] applied →
CSS animates opacity:0 + scale(0.96) →
Presence waits getAnimations().finished →
element unmounts
Verification (Playwright):
@8ms entrance: opacity 0.028, scale 0.972 (interpolating)
@80ms exit: opacity 0.46, data-ending-style still present
Both opacity AND transform animate so getAnimations().finished has
something to wait for in either direction. Easings: standard for
opacity, decelerate for transform (pops in cleaner).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dialog overlay self-positions
Two bugs surfaced by Playwright verification at /test/eidos:
1) Focus ring: archetypes.css used `outline: ...` for the focus-visible
ring. The project's layout.css has a Tailwind layer/components rule
`*:not(body):not(.focus-override) { outline: none !important }` that
kills every outline. The eidos rule was never visible.
Switch to a two-layer box-shadow ring (inner transparent gap +
outer color). Box-shadow follows border-radius, so the ring also
looks right on pills (Switch) and rounded corners (Toggle, Dialog
buttons). Ring also stands out against accent-filled states because
it sits OUTSIDE the element, not on top of it.
2) Dialog overlay: the recipe relied on [data-archetype='overlay']
from archetypes.css for `position: fixed; inset: 0`, but Dialog isn't
migrated to MorfoRuntime yet — its provider uses legacy createAttrs
which doesn't read morfo's `archetype` field. The overlay element
had `position: static, height: 0`, so the backdrop was invisible.
Inline `position: fixed; inset: 0` directly in the recipe. Component
recipes shouldn't depend on archetype rules for foundational behavior;
archetype is a transversal extra, not a foundation.
Adds scripts/check-eidos-recipes.ts — Playwright script that opens
/test/eidos, tabs into Toggle/Switch, opens the dialog, prints
computed styles + saves screenshots to g:/tmp/eidos. Used to catch
both bugs; reproducible for future visual checks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ring color was hsl(220 90% 55% / 0.6) — same hue as the default
button accent at 60% alpha, so when the Toggle is in 'on' state or the
Switch is checked (both accent-filled), the ring against the active
background was almost invisible.
Bump to hsl(220 95% 45%) full opacity. Slightly darker than the accent
default, full alpha so it stands out against accent-filled states.
Note: `:focus-visible` only fires on keyboard focus (Tab), not click —
expected browser behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The custom property was added in V1 anticipating multiple recipe
consumers, but only Dialog uses last-action tinting. Premature
abstraction — single-use indirection that obscures what's happening.
Move the 5 selectors directly into dialog.css. events.css drops the
machinery (replaced with a 3-line note pointing to the recipe). If a
second consumer with the same exit-tint pattern shows up later, factor
out then — not before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second per-component recipe. Composes archetype interactive defaults
(trigger, overlay, close, content, focus-visible) with the visual
envelope (button shape, modal card, backdrop) and the asymmetric exit
tinting from events.css.
The recipe consumes `--_eidos-exit-tint` via a top accent stripe on
[data-dialog-content], so close-save tints fulfill, close-after-fail
tints threat, close-cancel/dismiss/dismissed-outside tint neutral. The
private var stays inside eidos — recipes consume it; consumers don't.
events.css extends the data-last-action coverage to dismissed and
dismissed-outside (both map to neutral tint, like cancelled).
Public CSS API: --eidos-dialog-{overlay-bg, overlay-z, content-bg,
content-radius, content-padding, content-shadow, content-max-width,
content-z, button-bg, button-bg-hover, button-border, button-border-hover}.
Default z-indices (overlay 50 / content 51) are sensible defaults the
integrator can override via tokens — Dialog without z-index would be
useless out of the box.
Test page (/test/eidos) gets a Dialog section so the recipe is visible
end-to-end.
Verification: smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes the save/restore patch (b66039ad) — that was a workaround
for a design flaw, not the fix. The flaw: VisualChannel was writing
`data-intent` on the same DOM target where the morfo already had
`data-intent` from `v.propRef('intent')`. Two owners of one attr name,
collapsed by the channel's cleanup. Toast lost its tinting after every
announce because the channel removed the structural attr.
Fix: rename the signal-bound attr to `data-event-intent`. The channel's
attrs all live under `data-event-*` (data-event, data-event-id, data-
event-phase, data-event-family, data-event-intent) — a clean namespace
that can never collide with state attrs (data-state, data-intent,
data-disabled, ...). The channel writes only its own namespace; the
runtime owns state.
CSS in eidos can read either with distinct semantics:
[data-event-intent='risk'] → reacts to a transient occurrence
[data-intent='risk'] → reacts to persistent component state
events.css announce rules now read data-event-intent.
The Toast recipe is unchanged — its [data-toast-item][data-intent='...']
selectors read structural state, which is now never overwritten.
Tests:
- visual.test.ts: rename data-intent → data-event-intent in all
assertions; replace the two save/restore tests with one that proves
state attrs (data-intent, data-state, data-disabled) survive the
full hold cycle untouched.
- emit.test.ts: rename data-intent → data-event-intent.
Verification: sema 40/40, smoke 68/68, morfo-check 66/66.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The channel writes `data-intent` from `signal.intent` during the hold,
but `data-intent` is also the structural attr the morfo of components
like Toast.Item writes from `v.propRef('intent')`. Removing it on
cleanup erased the structural value — the toast lost its tinting after
the announce signal expired.
Fix: save the prior `data-intent` value before overwrite, restore it on
cleanup. General principle — a channel should clean only what it wrote.
Other signal attrs (`data-event*`, `data-event-family`) are signal-
specific so no collision.
Tests: structural data-intent survives the hold; mismatched signal vs
structural intent (edge case) restores the structural value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First per-component recipe in eidos/components/. Composes tokens +
archetypes + intent palette into a finished Toast visual:
- viewport stack layout (no positioning — integrator's call)
- card with intent-tinted left border (reuses --eidos-tint-*)
- absolute close button so layout doesn't depend on a header wrapper
- typography for title / description, secondary-button style for action
- swipe state (data-swipe='move'/'cancel') translates the card
- loading state adds an accent stripe via inset shadow
Toast.Item carries archetype='item' which sets cursor:pointer + user-
select:none in archetypes.css; the recipe overrides those because a
notification is passive, not selectable. Possible future morfo decision:
introduce a 'card' or 'notification' archetype.
The eidos test page drops its :global([data-toast-*]) scoped CSS — the
recipe now suffices. Stale '~13 ms / 1 rAF' text replaced with the
correct per-family hold (600ms for alert).
Wired via @import './components/toast.css' from eidos/index.css.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "data-event*" bullet still mentioned the old "~13ms" timing from
before the perceptual anchoring fix and the channel refactor. Updated
to reflect current behavior: VisualChannel writes the attrs for a
configurable hold (240ms emerge/commit/handle, 600ms alert/sustain by
default per family, configurable per signal or globally).
No code changes — single doc fix.
Reorganización estructural según el spec acordado: cada canal perceptivo
vive como módulo simétrico dentro de `chans/`. El engine queda mínimo
(registry + dispatch); la lógica de escribir `data-event*` al DOM,
mantener hold y retirar pasa al `VisualChannel`. La API pública que ven
los providers (`semantic.emit(signal)`) se mantiene idéntica.
Estructura nueva
src/uix/sema/
├── engine.ts registry + dispatch (ya no conoce DOM ni hold)
├── signal.ts SemanticSignal + nuevo campo opcional `id?`
├── exports.ts barrel actualizado (chans + drop publish + drop perception)
└── chans/
├── types.ts interfaz Channel { id, handle, dispose? }
├── visual.ts VisualChannel — escribe data-event*, hold, cleanup
├── visual.test.ts 9 tests del canal aislado
├── sound.ts SoundChannel placeholder (V1 no-op)
└── vibra.ts VibraChannel placeholder (V1 no-op)
Cambios al engine
- Constructor: `new SemanticEngine(opts?)`. `opts.visual` controla el
built-in (false / VisualChannelOptions / Channel custom). `opts.dom`
desaparece — ya no es responsabilidad del engine.
- Métodos: `register(channel)`, `getChannel(id)`, `emit(signal)`,
`destroy()`. Nada más.
- emit despacha a TODOS los canales registrados:
- Canales no-visuales (sound, vibra, futuros): fire-and-forget. Errores
se loguean pero no propagan (Sema es ornamental).
- Canal visual: el único cuya Promise se awaitea — comparte plano DOM
con el commit estructural posterior.
- emit genera el `id` de la ocurrencia y lo pasa a todos los canales,
garantizando coherencia cross-canal para tooling futuro.
Cambios al VisualChannel (lógica heredada del emit() anterior)
- 5 atributos: `data-event` + `data-event-id` + `data-event-phase`
(siempre) + `data-event-family` y `data-intent` (opcionales si vienen
en el signal). Los opcionales son la proyección al DOM de la metadata
semántica que Eidos consume.
- Hold defaults internos por familia (no exportados):
emerge/commit/handle: 240ms · alert/sustain: 600ms · contact: 120ms
Justificación técnica (rangos típicos de CSS transitions), no escala
perceptiva universal. Override per signal vía `signal.hold` o global
vía `new SemanticEngine({ visual: { defaultHold } })`.
- Semántica secuencial estricta: cleanup ANTES del resolve.
Eliminado
- `src/uix/sema/perception.ts` — escalas perceptivas no son concepto
cross-canal. La tabla de defaults por familia se movió al VisualChannel
como detalle interno con justificación técnica.
- `engine.publish()` y todo el sistema legacy:
- `SemanticEventDecl`, `SemanticComponentContract`, `SemanticPublishContext`,
`PublishedSemanticEvent`, `SemanticEventFilter`
- `onEvent()` y subscribers
- `applyPrewrites()` interno
- dependencia opcional de `ActiveDom` por construcción
- Tests de `publish()` reemplazados por tests del nuevo registry/dispatch.
Bootstrap (App + defaults)
- `src/lib/ext/app/app.svelte.ts:52` — `new SemanticEngine({ dom: this.dom })`
→ `new SemanticEngine()`
- `src/lib/ext/app/defaults.ts:45` — mismo cambio
Tests
- emit.test.ts ajustado: sin createActiveDom, sin SEMA_PERCEPTION/HOLD_DEFAULTS,
test "throws without dom" eliminado, números literales (240, 600).
Añadido test "resolves immediately when visual: false". 7 tests.
- engine.test.ts reescrito: register / getChannel / emit dispatch a múltiples
canales / id propagation / id override / fire-and-forget para non-visual /
error en canal no aborta dispatch / visual: false desactiva built-in /
custom Channel reemplaza built-in / destroy. 13 tests.
- chans/visual.test.ts nuevo: 5 attrs vs 3 / signal.hold respetado /
family default fallback / global default fallback / constructor defaultHold /
cleanup / sequential strict (resolve tras cleanup) / id field. 9 tests.
- 39 tests sema verdes en total.
Documentación
- src/uix/sema/README.md — reescrito alrededor del modelo de canales
- src/uix/eidos/events.css — comentario actualizado, referencia a chans/visual.ts
- src/uix/active_architecture.md — sección Sema reescrita
Componentes NO requieren cambios — la API `semantic.emit(signal)` es
idéntica externamente. Toast / runtime.trigger / providers funcionan igual.
Verificación
- 121/121 tests focused suite (sema 39 + adom + lib/dom + morfo + soma/morfo)
- 66/66 morfo-check
- 68/68 smoke routes
- svelte-check: 155 errors (baseline, sin nuevos)
Pendientes en doc del spec marcados como "no-objetivos":
- Implementación real de SoundChannel / VibraChannel (placeholders V1)
- Arbitraje propio del VisualChannel (deuda heredada del emit anterior)
- Cancelación si el target se desconecta durante el hold
Two architectural fixes surfaced by the eidos demo:
1. The previous default hold (1 rAF + 1 hold frame, ~30ms) was a
technical timing decision — invisible to humans. Signals must be
anchored to perceptual scales so they can actually be perceived.
2. The previous lifecycle resolved emit AFTER 1 rAF (caller's structural
change ran in parallel with cleanup, ~30ms later). That's an implicit
parallelism between event and state. The author has no way to say
"wait for the announcement to be perceived before changing state".
Sequential strict semantics (NEW)
- emit writes the signal, awaits the full author-specified hold, cleans
up, then resolves. State change happens AFTER cleanup — strictly
sequential.
- `signal.hold` is in milliseconds anchored to `SEMA_PERCEPTION` scales:
subliminal 50ms below conscious threshold
glimpse 120ms minimum perceptible without effort
brief 240ms comfortable acknowledgement
noticed 600ms sustained signal
insistent 1200ms demands attention
persistent 3000ms until acknowledged
- Defaults per family in `SEMA_HOLD_DEFAULTS`:
emerge: brief sustain: noticed
contact: glimpse commit: brief
alert: noticed handle: brief
- Authors override per event via `SemanticSignal.hold` based on:
- their CSS animation duration (so it completes before cleanup)
- the perceptual scale they want
- critical attention requirements (use insistent/persistent)
Files
- src/uix/sema/perception.ts (new) — SEMA_PERCEPTION + SEMA_HOLD_DEFAULTS
+ SEMA_OBSERVE_FRAME_MS constants with rationale anchored on Bloch's
law, Material/Apple HIG motion guidelines, toast UX literature.
- src/uix/sema/signal.ts — `hold` JSDoc updated to explain ms semantics +
defaults + override pattern.
- src/uix/sema/engine.ts — emit() refactored from rAF chain to
setTimeout(holdMs); cleanup happens BEFORE resolve (sequential strict);
`nextFrame()` helper removed; `resolveHoldMs(signal)` added with the
3-tier fallback (signal.hold → family default → SEMA_PERCEPTION.brief).
- src/uix/sema/exports.ts — re-exports SEMA_PERCEPTION + SEMA_HOLD_DEFAULTS
+ SEMA_OBSERVE_FRAME_MS + SemaPerception type.
- src/uix/sema/emit.test.ts — rewritten around fakeTimers + setTimeout
(no more rAF queue); new test verifies cleanup runs BEFORE resolve.
7/7 tests green.
- src/uix/sema/README.md — new "Duración del signal" section with the
full perception scale + per-family defaults tables + override pattern.
- src/uix/eidos/events.css — comment updated to explain the new hold
windows; rule `[data-event^='dismiss']` etc. clarified.
- src/uix/active_architecture.md — replaced "~13ms" prose with explicit
perception-anchored scales; added explanation of "perceptual by design,
not framerate".
- src/uix/soma/components/toast/toast-provider.svelte.ts — new $effect
that fires `present` + `announce` events when item ref attaches
(one-shot guarded), so toasts emit signals on appear. The flash on
/test/eidos is now visible thanks to (a) the perceptual hold and (b)
the wired events.
Channel parallelism note
- Hold is the DOM channel duration only.
- Future sound / vibra / motion engines subscribe to sema events
independently; each owns its own timing relative to the event id.
- Sema does not coordinate cross-channel sync — that's an integrator
decision per app.
Verification
- 22/22 sema tests green.
- 22/22 morfo runtime tests green.
- 66/66 morfo-check + 68/68 smoke (verified before commit).
- Probe shows announce signal on toast appear lasting ~600ms (alert
family default) — visible in DevTools attribute panel.
- svelte-check unchanged at 155.
Pre-existing failure in src/uix/air/internal/canvas/layout.test.ts
(`bun:test` import) is in the dead-branch air layer and unrelated.
First eidos code. Closes the cross-layer cycle that's been documented but
unproven: an actual CSS layer that consumes the morfo + sema contracts
and applies styles transversally without enumerating components.
src/uix/eidos/
- tokens.css — motion durations (quick/fast/base) + easings (standard /
emphasized / decelerate) + opacity scale (hover/disabled/faded) +
intent tints (neutral/affirm/fulfill/risk/threat) + focus ring tokens.
~30 lines. No theming dimension yet — when it arrives, these are the
base theme.
- archetypes.css — transversal selectors keyed by `data-archetype`.
Covers trigger, overlay, content, indicator, thumb, track, close,
action, item, option. Plus a universal `:focus-visible` ring rule that
applies to every archetype-classified element. Single attribute
selector specificity — components can override freely.
- events.css — reactions to perceptual signals during their ~13ms life.
`[data-event^="dismiss"]` fades out, `[data-event^="announce"]` pulses
with intent-tinted box-shadow, `[data-last-action]` tints exit
animation by causa.
- index.css — entrypoint that imports the above in cascade order.
Consumer: `import '$uix/eidos/index.css'`.
src/routes/test/eidos/+page.svelte (demo)
- Sets up Soma + App context inline (independent of /test/soma layout).
- Imports `$uix/eidos/index.css` and demonstrates the cross-layer story:
- Toggle + Switch buttons share trigger styles via `[data-archetype="trigger"]`
without per-component CSS.
- Collapsible expands/collapses with state-driven attrs.
- Toast intent buttons fire `data-event="announce" data-intent="risk|threat|affirm"`,
which eidos's events.css briefly tints via box-shadow during the signal window.
- Component-specific layout (positioning, switch thumb transform) lives in
the page's `<style>` block — eidos provides only the cross-component
baseline, NOT per-component recipes.
What this proves
- The runtime emits `data-archetype` correctly in browser (not just in
unit tests).
- A single CSS rule can target ALL triggers across all components
without enumeration. Add a new component with `archetype: 'trigger'`
and it inherits the styling automatically.
- Sema's `data-event*` signals live in the DOM long enough for CSS to
react (the ~13ms window measured by `scripts/probe-toast-dismiss.ts`
is observable as a brief box-shadow flash on toasts).
- The cross-layer cycle Morfo declara → Soma transcribe → Sema emite →
Dom aplica → Eidos lee runs end-to-end.
What V1 deliberately excludes
- Theming dimension (no `[data-theme]` overrides yet).
- Per-component recipes (each component will get its own CSS later).
- Keyframes / @starting-style / animation primitives.
- Sound / vibra (those are separate engines that subscribe to sema).
Verification
- /test/eidos returns 200; manual browser check shows triggers share
hover dim + focus ring across components, toasts flash intent tint on
appear.
- 68/68 smoke routes (smoke only scans /test/soma — eidos route is
outside that scope, manually verified).
- svelte-check unchanged at 155 (pure CSS additions).
Synthesizes the four-layer architecture (morfo · soma · sema · eidos) into
a single readable doc. Where the per-layer READMEs are operational
references, this is the "north star" for understanding the why and how
the pieces articulate.
Structure (14 sections):
1. The thesis in one line — four layers with explicit contracts
2. The problem solved — what conventional frameworks mix together
3. The four layers — Morfo (DNA), Soma (behavior), Sema (semantic
channel), Eidos (visual)
4. How they articulate — the transcription chain (six pieces, one
responsibility each)
5. The causal chain of an interaction — concrete walkthrough of a Toast
dismiss with timing
6. The primitives that pass between layers — DOM attrs catalog +
archetypes + verbs vocabularies
7. Hard rules — the 12 invariants that keep the system coherent,
including the 2-of-3 rule
8. The autoría / transcripción distinction — the lens for deciding what
lives where
9. What this architecture is NOT — explicit non-goals
10. Current state (2026-04-26) — what's built, designed, pending
11. Acknowledged risks — over-layering, name discipline, responsibility
invasion, lack of precedent
12. Why it may be worth it — explainability, less drift, validation,
extensibility
13. The summary phrase — "Morfo declara · MorfoRuntime transcribe ·
Provider aporta · Effects sincronizan · Semantic emite · Dom aplica
· Eidos lee"
14. For deeper reading — links to per-layer docs
Also adds a top-of-page pointer in src/uix/README.md so the vision doc is
the obvious entry point for new readers.
No code changes — pure documentation.
Closes the documentation loop on the cross-layer extension pass: morfo
now articulates between soma, sema, and (future) eidos. The "2-of-3 rule"
formalizes when an extension to morfo is justified vs when it should
stay as provider logic.
src/uix/eidos/README.md (new)
- Documents eidos's role and what it consumes from morfo + sema BEFORE
any code exists, so the contract is preparedly clean when implementation
starts.
- Catalogs which morfo fields eidos reads (parts, archetype, states,
data values, events, prewrite, focus, supportsNesting) and which it
ignores (computed state, runtime internals, layers).
- Documents the DOM-as-channel pattern: sema writes data-event* on emit;
eidos reacts to selectors like `[data-event^="dismiss"]`.
- Establishes the boundary with `air` (dead branch reference, not base).
src/uix/README.md (top-level)
- §8 Reglas de dependencia: adds the 2-of-3 rule table making the
morfo-extension contract explicit, plus a list of canonical vocabularies
(archetypes, verbs).
- §10 Reading order: includes eidos README + lib/dom + clarifies which
layers are dead branches.
src/uix/morfo/README.md
- New "Archetypes" section documenting the 24-verb vocabulary, the
Provider-as-trigger vs Provider-as-container distinction, and the rule
for adding new archetypes (≥2 components share the role).
- New "The 2-of-3 rule" section with the same table as the top-level,
listing which extensions did/didn't make it past the rule and why.
- `parts[].archetype` mentioned in the "What morfo contains" list.
src/uix/sema/README.md
- New "Vocabulario canónico de verbs" section listing SEMA_VERBS by
family and the `{verb}-{variant}` composite naming convention.
- Documents `validateEventName()` as advisory tooling.
src/uix/soma/SOMA_ARCHITECTURE.md
- partProps documentation now mentions data-archetype emission.
- New "Cross-layer hooks que soma emite por la regla 2-de-3" section
listing the data-* attrs soma writes that sema and eidos consume.
- Reading-order links updated.
No code changes — all docs.
Cross-component verb names that morfo `events[].name` should preferably
align with. Lets sema / sound / vibra engines subscribe by verb instead
of by component-specific event name, and lets eidos write transversal
selectors like `[data-event^="dismiss"]`.
Vocabulary (24 verbs, grouped by family alignment)
- emerge: present, dismiss, open, close, expand, collapse
- commit: commit, cancel, confirm, submit, reset, fail
- alert: announce, alert
- contact: activate, select, toggle
- handle: acknowledge, edit, drag, resize
- sustain: tick, progress
Composite event names follow `{verb}-{variant}` (e.g. `commit-save`,
`dismiss-outside`). The verb is the head; the rest is component-specific
nuance the validator extracts and reports separately.
`validateEventName(name)` returns `{ name, head, matchesCanonical, variant }`.
Advisory — doesn't reject morfos, just surfaces drift for tooling /
review (a future `sema:vocabulary` script can use it to flag composite
event names whose head isn't canonical).
Existing event names already align well
- Toast: `present`, `announce`, `dismiss` — all canonical bare verbs.
- Dialog: `open`, `close-save`, `close-cancel`, `close-dismiss`,
`close-dismiss-outside`, `close-after-fail` — all canonical heads
(`open`, `close`) with variants.
Tests: 10 unit tests covering vocabulary integrity, isSemaVerb guard,
validateEventName splitting/matching, and a snapshot of current dialog +
toast event names against the catalog (all match).
This is the second extension justified under the "2-of-3 rule": verbs
are consumed by sema (subscription routing) AND eidos (selector
generation), with the original morfo authoring as the third producer.
Bulk classification pass that gives every part a cross-component archetype
where one applies. Done with `scripts/catalog-archetypes.ts` — a one-shot
mechanical mapping based on a kebab → archetype table, plus three manual
overrides for components where Provider IS the interactive element.
Coverage
- 440 part declarations examined.
- ~265 received an archetype (provider, trigger, content, overlay, viewport,
item, indicator, thumb, track, label, title, description, close, action,
header, image, fallback, arrow, separator, group, input, segment).
- ~175 left without one — genuinely component-specific (Slider.Range,
PinInput.Segment internals, color-picker channel-slider, etc.).
Manual overrides (Provider IS the trigger)
- Toggle, Switch, Checkbox: their `Provider` part is a `<button>` that
the user actually interacts with, not a root container. Archetype set to
`'trigger'` instead of the default `'provider'` so eidos can style them
alongside Dialog.Trigger / Popover.Trigger / etc.
Script (scripts/catalog-archetypes.ts)
- Walks every `*.ts` under `src/uix/morfo/components/`.
- Inserts `archetype: '...'` directly after the matching `kebab:` line when
a mapping exists.
- Idempotent — skips parts that already declare an archetype.
- Indent-aware — only operates on part kebabs (≥2 tabs), never the morfo
root kebab. Bug discovered + fixed mid-pass when radio-group's root
kebab `'radio-group'` matched the `'radio-group' → 'group'` alias and
inserted at the wrong level (cleaned up by hand).
- Reports per-file counts + the list of unmapped kebabs at the end so
future passes can extend the mapping.
Tests
- Updated `omits data-archetype when not declared` test to use a tiny
inline morfo (Toggle's Provider now has archetype 'trigger', so it can't
be the "no archetype" fixture anymore).
Verification
- 22/22 runtime tests, full focused suite green.
- 66/66 morfo-check (every component still validates against its DOM —
archetype is additive, doesn't change emitted attrs apart from the new
`data-archetype="..."` which morfo-check doesn't enforce yet).
- 68/68 smoke routes.
- svelte-check unchanged at 155.
First step toward making morfo articulate the three layers (soma, sema,
eidos) instead of just serving soma. Archetype is a small canonical
vocabulary that lets a part declare its conceptual role beyond its
component-specific kebab name.
Articulation purpose:
- Eidos can style transversally — `[data-archetype=trigger] { ... }`
applies to every Trigger across all 60+ components without enumeration.
- Sema can map archetypes to canonical action verbs (`trigger` may fire
`activate`, `item` may fire `select`).
- Docs can categorize parts cross-component for discovery.
The "2-of-3 rule" justifies the extension: at least two layers consume it.
Vocabulary
24 archetypes covering the common cross-component roles:
provider, trigger, content, overlay, viewport, item, option, indicator,
thumb, track, label, title, description, close, action, header, image,
fallback, arrow, separator, group, input, segment, preview.
Designed to stay small. New archetype only added when at least two
existing components share the role. Genuinely-unique parts (Slider.Range,
PinInput.Segment) omit the field.
Implementation
- `MorfoPart.archetype?: MorfoArchetype` — optional union type.
- `ARCHETYPE_VOCABULARY` exported as `as const satisfies` array for
enumeration tooling.
- `partShallowSchema` (sium validator) gets `archetype: optional(union(...))`.
- MorfoRuntime caches `partMeta` in PartRegistration so `partProps` can
read `archetype` without re-walking the morfo tree per render.
- `runtime.partProps(part)` emits `data-archetype="..."` when declared.
Static identity (never mutates), so it ships through partProps not
through `dom.apply` — Svelte renders it once on first paint.
Tests
2 new tests:
- partProps emits data-archetype when morfo declares one (inline test
morfo to avoid coupling to catalog state).
- partProps omits data-archetype when not declared (Toggle, pre-catalog).
Verification
- 22/22 runtime tests green.
- svelte-check unchanged at 155.
- No morfo currently declares archetype yet — catalog pass follows in the
next commit. All existing morfos remain valid (field is optional).
First component of the bulk migration phase (step 2 of the 1-3-2 plan).
Pattern is now well-rehearsed across Toggle / Collapsible / Toast / Switch:
- morfo: add `value` sources to data entries that were enum-only
- provider: replace manual Opts interface with `OptsFromProps<...>`,
create runtime in constructor, register parts, drop manual data-* /
aria attrs from props (runtime emits via dom.apply)
- wrapper: replace per-prop `readableActive(() => x)` list with one
`bindProps({ ... })` call
- keep manual: HTML primitives (style, src, etc.) and event handlers
Avatar specifics
- 3 parts (provider/image/fallback) all share `data-status` from the root's
internal `status` State (idle/loading/loaded/error). morfo's `propRef('status')`
reads from runtime sources where the root provider exposes it.
- `status` is not a public Props field — it's an internal $state in the
wrapper. Manually intersected into `AvatarOpts` rather than derived from
AvatarProps.
- Image's preload logic, src/crossorigin/referrerpolicy passthrough, and
the style-based show/hide on each part stay in the provider's render —
none of these are morfo-managed.
- Wrapper cast uses `as unknown as Parameters<typeof X.create>[0]` for
parts whose Opts have only `id`+`ref` (TS can't extract the specific
fields from the generic `Record<string, Active<unknown>>` return of
`bindProps`). Toggle/Switch don't need this because their richer Opts
shape provides enough overlap.
Bulk migration paused at this point — Progress / Meter need a design
decision around `aria-label` priority logic (the legacy provider has
"consumer-passed wins over translated default", which conflicts with the
"dom.apply is the only writer of mutable attrs" rule). Either:
(a) Drop aria-label from those morfos and keep provider-managed (legacy
pattern preserved, morfo loses one declaration).
(b) Extend the resolver with a priority chain (propRef → translationRef
fallback) so the morfo can express "consumer wins, translation
otherwise".
Verification
- 66/66 morfo-check (Avatar passes; all data-status emitted by runtime).
- 68/68 smoke routes.
- svelte-check unchanged at 155.
V1.5 of MorfoRuntime — adds the keyboard dispatch piece and validates it
against Switch (the simplest component with a real `morfo.keyboard` block).
src/uix/soma/morfo/runtime.svelte.ts
- New `runtime.keydown(part, event): boolean` walks `partMeta.keyboard`,
matches `event.key` plus modifier flags against each entry's spec
("Enter", " ", "Shift+Tab", "Ctrl+A", …), and dispatches to the matching
handler from `sources.actions[entry.action]`. Returns whether a handler ran.
- New `sources.actions: Record<string, KeyboardActionHandler>` source map.
Handlers receive the raw `KeyboardEvent` so they can `preventDefault()`
selectively. The runtime never calls preventDefault on its own.
- Modifier matching is exact: `Shift+Tab` does NOT match plain Tab; `Tab`
does NOT match Shift+Tab. Each entry's `condition` (`prop-truthy` etc.)
is also honored.
- Key-spec parsing keeps the literal key untrimmed (the spacebar is `" "`,
trimming would collapse it to empty). Modifier segments are trimmed
individually.
Toast pilot — keyboard wiring NOT included
Toast Item declares `{ key: 'Escape', action: 'close' }` in morfo, but the
legacy Toast doesn't actually wire Escape either. Out of scope for this
commit; a follow-up can add `events.close` + the keydown listener on the
Item element.
Switch pilot — first consumer
- `morfo/components/switch.ts`: added `value` sources to `data-state` /
`data-disabled` (same pattern Toggle/Collapsible/Toast needed for the
runtime to manage them).
- `soma/components/switch/switch-provider.svelte.ts`:
- Manual `interface SwitchOpts` (10 lines) replaced with `OptsFromProps<
SwitchProps, ManagedKeys, 'checked'>` (5 lines).
- The custom `onkeydown` block (`if (e.key === KEYS.ENTER) preventDefault;
toggle()`) collapses to `(e) => this.runtime.keydown('provider', e)`.
The morfo's `Enter` and ` ` (spacebar) both dispatch to action `'toggle'`,
handled by `actions.toggle: (e) => { e.preventDefault(); this.toggle() }`.
- props derivation uses `runtime.partProps('provider')` for identity,
keeping only the HTML `disabled` attr and event handlers manual.
- `soma/components/switch/components/switch.svelte`: 8 lines of
`readableActive(() => x)` collapsed to a single `bindProps({ ... })` call
(the V2 helper from commit eb0f128c).
Tests
- 7 new keydown unit tests covering: no-keyboard part returns false, single-
key dispatch with original event passed through, multi-key for same action
(Enter and Space → toggle), no-match returns false, modifier strictness
(Tab vs Shift+Tab dispatch to different actions on dialog content),
unhandled action returns false, runtime never preventDefaults on its own.
Verification
- 20/20 runtime tests, 111+ soma tests green.
- 66/66 morfo-check (Switch passes; data-state and data-disabled now
emitted by the runtime via the morfo's value sources).
- 68/68 smoke routes.
- svelte-check unchanged at 155.
Eliminates the triplication that every component used to suffer:
1. Props (autoral, in types.ts) — public consumer API
2. Opts (mechanical) — provider's reactive contract
3. wrapper's `readableActive(() => x)` list (mechanical)
After V2 only (1) is hand-written. (2) is derived; (3) collapses to one
`bindProps({ ... })` call.
src/uix/soma/reactive/opts.ts (new)
- `OptsFromProps<P, Managed, StateKey>` type maps a Props interface to the
matching Opts: `id` + `ref` baked in; `Managed` selects which keys reach
the provider (HTML passthroughs stay out); `StateKey` is the bindable
subset (the rest become `Active`). `Exclude<P[K], undefined>` strips the
optionality the wrapper resolves via Svelte destructure defaults.
- `bindProps(config)` runtime helper: each entry is either a bare getter
(→ `readableActive`) or a `{ get, set }` object (→ `writableActive`).
Detection is structural (no marker function — avoids collision with
Svelte's `$bindable` rune in `<script>` blocks).
- `WritableSpec<T>` defaults to `unknown` for storage but
`PropsConfigEntry<T>` defaults to `any` for variance — heterogeneous bag
literals are bivariant under `any`, contravariant under `unknown`.
Toggle migration as the proof
- `morfo/components/toggle.ts`: rename `propRef('ariaLabel')` → `propRef('aria-label')`
so the morfo and the public Props key match. Bracket notation in the
provider handles the kebab-cased key.
- `soma/components/toggle/toggle-provider.svelte.ts`:
- `interface ToggleOpts { ... }` (15 lines hand-rolled) replaced by
`type ToggleOpts = OptsFromProps<ToggleProps, ManagedKeys, 'pressed'>` (10 lines)
- sources read `opts['aria-label'].current`
- `soma/components/toggle/components/toggle.svelte`:
- 11 lines of `name: readableActive(() => name)` collapsed to a single
`bindProps({ ... })` call where each line is just `name: () => name` or
`pressed: { get: () => pressed, set: (v) => (pressed = v) }`
Tests
- `opts.svelte.test.ts` — 4 unit tests verifying readable/writable
detection, mixed configs, and kebab-keyed entries (`'aria-label'`).
Verification
- 111/111 soma tests green.
- 66/66 morfo-check (Toggle's emitted DOM unchanged).
- 68/68 smoke routes.
- svelte-check unchanged at 155 (no new errors).
Recipe for migrating other components:
1. List `Managed` keys (everything in Props that isn't HTML passthrough).
2. List `StateKey` subset (anything bindable in the wrapper).
3. Replace the manual Opts interface with `OptsFromProps<...>`.
4. Wrap the wrapper's prop forwarding in `bindProps({ ... })`.
One-shot Playwright probe that drives the live Toast demo, installs a
MutationObserver on the toast item, clicks dismiss, and reports the full
attribute timeline.
Used to confirm that `runtime.trigger('dismiss')` actually fires the
`data-event*` perceptual signal in the browser. The signal lives ~13ms
in the DOM (1 awaited rAF + 1 hold frame + cleanup), which is too brief
for DevTools' attribute panel to refresh — this probe captures the full
sequence with millisecond timestamps so the contract is observable.
Run:
1. `npm run dev`
2. `node --import tsx/esm scripts/probe-toast-dismiss.ts`
Output ends with "Signal observed: ✅ YES" when the runtime is wired
correctly. Useful as a regression probe after touching the trigger
sequence or the Toast wiring.
Commit B of the Toast pilot. The runtime.trigger() contract from commit A
now has its first real consumer: all three dismiss paths route through
`runtime.trigger('dismiss')`, which emits the perceptual signal one frame
before the toaster mutates state.
Multi-instance architecture validated
Toast is the first component with multiple per-instance subtrees: each
toast in the queue is its own scope with its own children. Two runtime
instances coexist per Toast tree:
ToastProvider (root)
└── runtime instance A — registers `viewport` only
ToastItemProvider (one per active toast in queue)
└── runtime instance B — registers `item` + its children
(`title`, `description`, `action`, `close`)
`partRef('item')` resolves to THIS toast's element because the lookup
happens on THIS Item's runtime, not a shared one. `runtime.trigger('dismiss')`
on Item-B targets Item-B's DOM element. Per-toast isolation by construction.
dismiss wired through runtime.trigger
Three call sites now go through the same path instead of calling
`toaster.dismiss(id)` directly:
- ToastCloseProvider.onclick (user clicks the × button)
- ToastItemProvider startTimer setTimeout callback (auto-dismiss)
- ToastItemProvider onpointerup (swipe gesture meets threshold)
The runtime fires `data-event="dismiss"` on the item, awaits one rAF for
CSS to observe it, then calls the dismiss handler that mutates
`toast.dismissing = true`. The item's effect re-derives `data-state="closed"`
from `isOpen = !dismissing` and writes it via dom.apply on the next tick.
intent-driven role / aria-live still works
The morfo's `mapRef(propRef('intent'), { neutral: 'status', risk: 'alert', … })`
for `role` and `aria-live` is resolved by the runtime per-Item: each toast
reads its own `intent` from `opts.toast.current.intent` via the per-Item
runtime sources. Risk/threat toasts get `role=alert` + `aria-live=assertive`;
the others get `role=status` + `aria-live=polite`. No code in the provider
selects this — the morfo declares it, the resolver computes it.
Per-part props
Action's `altText` is per-instance per-action — passed to the runtime via
`registerPart('action', { props: { altText: () => opts.altText.current } })`.
Validates that the runtime's per-part source override (introduced in
commit 8ede5353 but never exercised) works as designed.
Translation lookup
Close's `aria-label` resolves through `v.translationRef` against the Item
runtime's `translate` source (`soma.langs.ts`). Confirms translationRef
ergonomics through the runtime path.
What this commit does NOT do (deferred)
- `present` and `announce` events: declared in the morfo but not wired.
Would fire on Item mount; needs a small effect at the right phase.
- `runtime.keydown` for Item's `Escape` keyboard declaration: requires
the V2 runtime API.
- presence layer (data-starting-style/data-ending-style) still flows
through Svelte render — not migrated to dom.apply. Mixed model is OK
while the boundary is clearly between morfo-declared attrs (runtime)
and layer-declared transitions (Svelte).
Verification
- 84/84 focused unit tests green.
- 66/66 morfo-check (toast included).
- 68/68 smoke routes 200 OK.
- svelte-check unchanged at 155.
Commit A of the Toast pilot: introduce the trigger() contract end-to-end
without migrating any provider yet. Toast's actual migration follows in a
separate commit so that bugs (if any) can be attributed to one layer at a
time.
App layer
- New `AppSemantic` contract in `$lib/ext/app/types`: a single-method
interface (`emit(signal): Promise<void>`) implemented by SemanticEngine
and accepted by the App constructor.
- App now owns `app.semantic`, defaulting to a fresh SemanticEngine wired
to `app.dom` when the consumer doesn't pass one. Fallback App also gets
`fallbackSemantic` so context-less calls don't throw.
- Soma re-exposes it as `soma.semantic` so providers reach it the same
way they reach `soma.dom` / `soma.langs`.
MorfoRuntime
- New `runtime.trigger(eventName): Promise<void>` with the full sequence
the architecture closed:
1. apply `prewrite` attrs imperatively (transient markers)
2. await `semantic.emit({ target, name, family, intent? })` — resolves
the intent from `event.semantic.intent` (literal SemaIntent or
SemaIntentBinding via `resolveSemaIntent`)
3. invoke the provider's handler from `sources.events[name]`
4. effects on the affected parts re-derive structural attrs and
write them via `dom.apply` (automatic — not awaited here)
- New optional `sources.semantic` and `sources.events` on the runtime.
Without `semantic`, step 2 is skipped. Without an events handler for
the name, step 3 is skipped.
- `morfo.events.commits` is intentionally NOT executed by the runtime —
it remains descriptive metadata documenting what an external observer
will see (per the architecture's "state is the source of truth" rule).
Tests
- 8 new trigger unit tests using the toast and dialog morfos:
- throws on undeclared event
- throws when target part has no DOM
- emits with intent resolved from prop (toastMorfo.announce)
- falls back to declared default intent when prop missing
- omits intent for transitional families (toastMorfo.present)
- prewrite applied before semantic.emit (dialogMorfo.close-cancel)
- handler invoked only after semantic.emit resolves
- skips semantic.emit when no semantic source provided
Verification
- 84/84 focused unit tests green (full adom/sema/lib/dom/morfo/soma).
- 66/66 morfo-check.
- 68/68 smoke routes.
- svelte-check unchanged at 155 (no new errors introduced).
Next (commit B): migrate Toast to use the contract — wire close.onclick,
swipe gesture, auto-timer, and Escape keydown to runtime.trigger().
Second pilot of MorfoRuntime V1, validating the multi-part case left
unproven by Toggle. Collapsible has three providers (provider/trigger/
content), shares root state across them, and uses partRef cross-references
(`aria-controls` ↔ `aria-labelledby`).
Note on scope
The morfo doesn't declare `keyboard` — the Trigger is a `<button>` and
relies on native Enter/Space via onclick. So this pilot does NOT exercise
`runtime.keydown`; that's deferred to the next pilot with a real keyboard
contract (Tabs, Accordion, Listbox).
What this pilot validates
- Single runtime instance shared across three part-providers, created at
the root and accessed by children via the Provider context.
- Cross-part state source: all three parts read `open` and `disabled`
from the root provider's opts via `states` / `props` source maps.
- Cross-part id resolution: `parts.trigger`/`parts.content` getters read
from the root's reactive `triggerId`/`contentId` State; partRef on
Trigger and Content resolves once the corresponding sibling registers.
- Conditional emission with partRef: `aria-controls` (when Content present)
and `aria-labelledby` (when Trigger present) toggle correctly as siblings
mount/unmount.
- Effect timing across multiple parts: each part's effect runs in its own
component scope, cleans up on unmount.
Morfo update
Same fix as Toggle: data entries got explicit `value` sources. `data-state`
references `stateRef('open')`; `data-disabled` references `propRef` with
`prop-truthy` condition. Without this the runtime had no way to source
the values (the legacy provider hard-coded them).
Provider migration
- CollapsibleProvider creates the runtime in its constructor with shared
states/props/parts sources and registers the 'provider' part.
- CollapsibleTriggerProvider and CollapsibleContentProvider call
`provider.runtime.registerPart(...)` for their slot, and publish their
id to the root's State so partRef resolves.
- Each part's `props` derivation collapses from ~6 attrs (data-state,
data-disabled, role, aria-*, type, etc.) to just `runtime.partProps()`
+ the few primitive attrs not in the morfo (`disabled`, `onclick`,
`hidden`).
Verification
- 76/76 focused tests green.
- 66/66 morfo-check (Collapsible's emitted DOM matches the morfo).
- 68/68 smoke routes 200 OK.
- svelte-check unchanged.
Real bug surfaced by user testing the Toggle pilot: setting `invalid` to true
emitted `data-invalid` and `aria-invalid` correctly, but flipping it back to
false left the attrs on the DOM. The red border from CSS based on
`[data-invalid]` stayed lit.
Cause: `resolvePartAttrs` skipped declared attrs whose condition was falsy.
The map handed to `dom.apply` lacked the key, so dom.apply (which only
iterates the keys it receives) had no instruction to remove the attr from
the previous tick.
The function's JSDoc claimed it emitted `undefined` for non-matching attrs.
The implementation didn't match. Now it does:
- Conditional attrs with falsy condition are emitted as `undefined` →
`dom.apply` removes them from the DOM and Svelte ignores them at render.
- Data entries without a `value` source are still skipped entirely (they
are documentation in the morfo, not runtime-managed attrs).
Same fix covers all conditional attrs: data-disabled, data-readonly,
data-required, data-invalid, aria-disabled, aria-readonly, aria-required,
aria-invalid, aria-label — all toggled on/off cleanly now.
Regression test added covering the invalid true→false→true cycle.
Verification: 5/5 runtime tests, 76/76 focused suite, 66/66 morfo-check,
68/68 smoke.
First piloted slice of the closed architecture: a runtime that interprets
the morfo declaration and writes mutable attrs to the DOM via dom.apply,
removing the need for the provider to hand-roll resolveMorfoProps bindings
on every part.
src/uix/soma/morfo/
- resolver.ts — pure morfo → attrs resolver, extracted from Provider so
both legacy Provider (still in place) and the new runtime can share it
- runtime.svelte.ts — `createMorfoRuntime(morfo, sources)` with V1 surface:
- registerPart(part, opts) — registers per-instance id/ref/sources and
sets up a $effect that reapplies attrs whenever sources change
- partProps(part) — returns ONLY identity (id + marker + ref attachment),
never mutable attrs (those go through dom.apply)
- runtime.svelte.test.ts — 4 tests verifying initial application, reactive
reapplication, and unknown-part rejection
ARIA stateRef refinement
The shared resolver now stringifies ARIA stateRef values to 'true'/'false'.
Boolean false on aria-* must mean "explicitly unpressed/unexpanded", not
"attribute absent" — the previous boolean return wired into dom.apply's
`false → removeAttribute` semantics, which is correct for data-* presence
flags but wrong for ARIA. Distinct contracts; resolver decides at the
boundary, not dom.apply.
Toggle morfo
Added explicit `value` sources to all data entries. The legacy provider
hard-coded data-state etc.; the runtime needs the morfo to declare where
each attr's value comes from. Now data-state references stateRef('on') and
data-disabled / data-readonly / data-required / data-invalid reference
their corresponding propRef with prop-truthy conditions.
Toggle provider migration
- ToggleProvider creates a MorfoRuntime in its constructor with Soma's dom.
- registerPart('provider', { id, ref }) wires the runtime to the button.
- props derivation now returns only:
- runtime.partProps('provider') — id + data-toggle + ref attachment
- disabled (HTML attr; not in morfo by design — primitive button concern)
- onclick handler
- All morfo-declared attrs (data-state, aria-pressed, data-disabled,
aria-disabled, aria-label, etc.) are written by the runtime's effect via
dom.apply.
Verification
- Unit tests: 4/4 morfo runtime + full focused suite green.
- morfo:check: 66/66 — Toggle's emitted DOM matches the morfo declaration
end-to-end now that data-* sources are pinned.
- smoke: 68/68 routes 200 OK.
- svelte-check: no new errors.
V2 (deferred — not part of this pilot):
- runtime.keydown(part, event) for morfo.keyboard dispatch.
- runtime.trigger(eventName) — prewrite + semantic.emit + handler chain
(Toast will be the first component to exercise this).
- OptsFromProps + bindProps helper to absorb the props bridge boilerplate.
The minimum primitives the closed architecture needs. No MorfoRuntime yet,
no provider migration — just the two operations that everything else will
compose on top of.
adom (only writer of mutable attrs):
- types StructuralChange + DomAttrValue
- applyChange(change) writes attrs (string/number → setAttribute, true →
presence flag, false/null/undefined → removeAttribute)
- removeAttrs(target, names) targeted cleanup
- ActiveDom exposes apply() + remove() — same surface every consumer uses
sema (perceptual signal channel):
- new SemanticSignal type (runtime payload distinct from declaration-time
SemaEvent): { target, name, intent?, family?, hold? }
- SemanticEngine accepts dom by construction (DI; not imported hard from
$uix/adom inside sema)
- new emit(signal) async method with the agreed lifecycle:
1. write data-event* via dom.apply
2. await one rAF (CSS gets a frame to observe)
3. resolve Promise (caller can dom.apply structural change)
4. hold N frames (default 1, configurable per signal)
5. clear data-event* via dom.remove
- legacy publish() flow untouched for backward compatibility
Tests: 12/12 green; full adom+sema+lib/dom suite 50/50 green;
svelte-check delta: -12 errors (no regressions in touched files).
Reflect the architectural decisions reached on 2026-04-25 across the
layer-level READMEs:
- src/uix/README.md
- rewrite ADom section: no longer a "broker semántico"; only DOM mutation surface
- rewrite Sema section: vocabulary + EngineSemantic with Promise-returning emit
- new §2.bis "Cómo se ejecuta un componente": six-piece chain with disjoint
responsibilities (Morfo declares, Runtime transcribes, Provider supplies,
Effects sync, Semantic emits, Dom applies)
- update §8 dependency rules to match the closed design
- new one-line summary in §9
- src/uix/morfo/README.md
- new "How morfo gets executed" section: maps each morfo field to its runtime
executor; documents trigger() sequence and provider responsibilities
- src/uix/sema/README.md
- rewrite around the Promise contract: emit() resolves after 1 rAF
- document lifecycle (id → write signal → wait frame → resolve → hold → cleanup)
- error policy and the three composition scenarios with dom.apply
- src/uix/soma/SOMA_ARCHITECTURE.md
- new §3.bis "Arquitectura cerrada" introducing MorfoRuntime as the missing
piece between Morfo (declaration) and Provider (execution)
- documents API V1, three commit operations, trigger() sequence, operational
rules, and pilot order (Toggle → Collapsible → Toast → Dialog)
No code changes; this commit pins the architecture before implementation.
- Split lib/dom (pure) from adom (reactive runtime).
- Move resize-observer and responsive to plain .ts (no runes needed).
- Fold elements.ts utilities into core.ts.
- Tabbable helpers consolidated in tabbable.ts via npm `tabbable`.
- BodyScrollLock: explicit MANAGED_PROPERTIES tracking instead of stringly setAttribute.
- RovingFocusGroup: pure getTabIndex reader, separate initializeDefaultTabStop.
- DOMContext: drop redundant canUseDom, getRootNode without optional chaining.
- ActiveDom: viewport tracking moved to its own module, viewport readonly facade.
- New READMEs at lib/dom and adom documenting the layer pyramid.
Foundation commit before introducing MorfoRuntime.
Adds the full Sema specification at `src/uix/sema/sema-spec-v0.3.1.md`,
replacing the earlier `sema_pre.md` working draft. The spec is now the
source of truth for the perceptual layer — 6 families × 5 intents = 22
canonical events, 4 channels, the MorfoSema extension for cross-layer
action contracts, and the SemaPort protocol between headless and Sema.
## Key architectural decisions codified
- Tripartite resolution: morfo-sema (contract) / `.csem` (integrator
overrides) / `sema-map.json` (canonical vocabulary).
- `MorfoSema` minimal shape (7 fields, 5 optional with defaults).
- `SemaEventLabel` typed union — 22 events, closed set.
- `prewrite` makes the "data-last-action BEFORE data-state" convention
explicit and validated.
- `commits` declares structural effect, NOT preconditions — validity
remains the provider's responsibility.
- `keyboard.action` relaxed: only acquires Sema semantics when it matches
a name in `sema.actions[]`.
- `sound` disabled by default; opt-in only.
- Caps: 200 ms blocking / 80 ms under accessibility reductions.
- 4 arbitration regimes: `replace | collapse | lock | queue`.
## Cleanups
- `src/uix/sema/sema_pre.md` removed — superseded by v0.3.1.
- `src/uix/soma/components/accordion/audit_1.md` removed — historical
artifact, consolidated into the canonical audits at
`src/uix/soma/AUDIT_1.md` and the two recent `soma-audit-*.md` files.
## Next steps (from §14)
1. Extend `Morfo` type with `sema?: MorfoSema`.
2. Extend sium validator for the 7 new validations of §5.7.
3. Stub `SemaPort` (interface + no-op + test port).
4. Implement the engine runtime.
5. Implement `.csem` PostCSS plugin.
6. Define `sema-map.json` with the canonical values.
7. Validate end-to-end with 4 real components (Button, Input, Dialog, Toast).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The combined `/test/soma/forms` page covered three separate components
(Switch, Checkbox, RadioGroup) in one route. Each deserves its own demo
page — matching the convention of every other component having a
dedicated `/test/soma/{kebab}` route, and letting the permutation runner
actually exercise them (the runner navigates per morfo-kebab, not per
merged page, so the combined demo left the 3 morfos SKIPPED).
## New demos
- `/test/soma/switch` — basic + disabled variants. `data-perm-step="0"`
toggles the basic switch. Axes: `dir, disabled`.
- `/test/soma/checkbox` — basic + indeterminate + group variants. Steps
0-2 toggle the first checkbox + first two group items. Seeds
`?perm.checked=true` / `?perm.value=a,b`. Axes: `dir`.
- `/test/soma/radio-group` — active + disabled variants. Steps 0-2 select
each option. Seeds `?perm.value=option-2` / `?perm.orientation`.
Axes: `dir, orientation`.
Each demo reuses the CSS from the original combined page (via `:global`
selectors for `data-switch`, `data-checkbox`, `data-radio-group-item`).
All three have `aria-label` on the Provider — the DEV-mode A4 warning
added in batch 10 of audit 2026-04-21 requires it.
The combined `/test/soma/forms` page is kept in place (pre-existing
integration demo showing composition); future batches may decide
whether to drop it or repurpose it.
## Coverage delta
- Before: 63/66 demos · 131 variants · 202 steps.
- After: 66/66 demos · 139 variants · 228 steps. (smoke: 68 routes)
Coverage complete — every morfo now has at least one instrumented demo
under the permutation runner.
## Verification
- `npm run check`: 0 errors in new demo files.
- `npm run smoke`: 68/68 routes (was 65).
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 66/66 demos · 228 steps · 139 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bulk-instruments the remaining 49 demo pages with `<meta name="perm-axes"
content="dir" />`, bringing total coverage from 15/66 demos to 63/66.
Only checkbox, radio-group, and switch remain skipped — they have no
demo directory at `/test/soma/` (pre-existing gap, unrelated).
## Runner change
`scripts/permutation-check.ts` now allows meta-only demos (no
`data-perm-step` annotations) to participate: if a demo declares any
axis via the meta tag, the runner still visits its baseline + RTL
variants and runs the `[initial seed]` morfo validation at each, even
without a click cycle. This gives passive LTR/RTL coverage to demos
where a click-driven perm sequence doesn't apply naturally (leaf
components like Avatar, Progress, Meter; grid/list components; pickers
with inline controls).
Key change in `probeDemo()`: a demo is "instrumented" if EITHER the meta
tag OR at least one `data-perm-step` is present. Step-only demos get
the v1 click cycle at baseline; meta-only demos get the v3 axis matrix
with passive `[initial seed]` validation at each variant; declaring
both is the full treatment.
## Bulk instrumentation
For each of the 49 remaining demos, added:
```svelte
<svelte:head>
<title>… · Soma</title>
<meta name="perm-axes" content="dir" />
</svelte:head>
```
Baseline axis (`dir`) only. Demos that benefit from additional axes
(`orientation`, `disabled`, etc.) opt in incrementally — the bulk edit
doesn't touch their `$state` seeds, only adds the meta tag.
## Coverage delta
| Metric | Before | After |
|--------|-------:|------:|
| Instrumented demos | 15/66 | **63/66** |
| URL variants | 35 | **131** |
| Step validations | 106 | **202** |
~4× increase in URL variants visited per CI run. Most new demos
contribute 2 variants each (ltr + rtl), validating morfo under direction
flip. Fully-instrumented demos (tabs, toolbar, toggle-group, listbox)
still contribute 3-4 variants with click cycles.
## Deferred
- `context-menu` — right-click activation needs a `data-perm-mode='contextmenu'`
runner extension.
- `navigation-menu` — hover-driven, same as Tooltip. Tooltip was
instrumented via `data-perm-mode="focus"` + long settle; NavigationMenu
could follow the same pattern in a targeted batch.
- `color-picker` — complex pointer state machine on Area / ChannelSlider.
Meta-only covers the baseline structure.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 63/63 instrumented demos · 202 steps · 131 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `data-perm-step` + `<meta name="perm-axes">` + perms seed to:
- **accordion** — expand item-1 → switch to item-2. `?perm.value=item-1`
seeds the Provider's value array. `dir` axis.
- **listbox** — select apple (first) → select apricot. `?perm.orientation`
flips the layout, already wired. Items in a loop use conditional
`data-perm-step` by index. `dir,orientation` axes.
- **tree-view** — select readme → select license under the expanded docs
branch. `?perm.expanded=docs&perm.selected=readme` seed. `dir` axis.
- **combobox** — open via trigger (Combobox.Trigger). `dir` axis only —
typing / selection is left to v4 per-component value enumeration.
- **menubar** — open File menu → close via Escape (mode=key, 500ms settle).
`dir` axis.
- **toggle** — press Bold toggle (inside Field.Provider integration
example). `dir` axis.
- **toggle-group** — press left → press center (single mode). Added
`aria-label="Text alignment"` to silence the A4 DEV warning that now
fires (shipped in batch 10 of audit 2026-04-21). `?perm.value=bold`
preselects. `dir,orientation` axes.
## Deferred in this batch
- **context-menu** — needs `contextmenu` event (right-click). Requires a
`data-perm-mode='contextmenu'` extension to the runner. Future batch.
- **navigation-menu** — hover-driven only. Same future-batch treatment as
Tooltip has with `data-perm-mode="focus"` + long settle.
## Coverage delta
- Before: 8 demos · 19 variants · 62 steps.
- After: 15 demos · 35 variants · 106 steps.
~1.7× jump in a single batch. Tabs still leads with 4 variants (adds
`orientation` + `disabled` axes); ToggleGroup, Toolbar, Listbox each have
3 variants.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 15/15 demos · 106 steps · 35 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `data-perm-step` + `<meta name="perm-axes">` + perms seed to the
5 overlay demos sharing Dialog's open/close shape:
- alert-dialog — trigger open → cancel. `?perm.open=true` seeds.
`disabled` deliberately NOT in axes: disabling the Provider blocks
the Trigger click path the runner depends on.
- popover — trigger open → close button. `?perm.open=true` seeds open1.
- tooltip — focus trigger (mode=focus, 1s settle — tooltip opens after
700ms delay). No close step: tooltip closes on blur.
- drawer — trigger open → close button (500ms settle for exit animation).
`?perm.open=true` seeds basicOpen.
- dropdown-menu — trigger open → Escape close (mode=key, 500ms settle).
Second step targets the Menu.Content selector because the Content
only mounts after the Trigger step fires (runner handles dynamic
step discovery).
All five declare `dir` as the only axis (universal RTL variant). The
other orientation / disabled axes don't apply to these overlay shapes.
## Coverage delta
- Before: 3 instrumented demos · 9 variants · 34 steps.
- After: 8 instrumented demos · 19 variants · 62 steps.
2x jump in per-CI-run validations with zero infra changes — just applying
the v1-v3 convention to additional demos.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 8/8 demos · 62 steps · 19 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2 ran every demo under a hard-coded `ltr` + `rtl` matrix. v3 lets each
demo declare which additional axes it honours — `orientation`, `disabled`,
`readonly`, `invalid`, `loading` — and the runner adds ONE variant per
declared axis (axis-at-a-time, not cartesian, so the matrix stays bounded
at O(N) instead of O(2^N)).
## Convention
```svelte
<svelte:head>
<meta name="perm-axes" content="dir,orientation,disabled" />
</svelte:head>
```
`dir` is universal (always runs). The other axes are opt-in: they only
produce variants when the demo both lists them in the meta AND wires the
corresponding state from `Perms.getOr(...)`. Without the meta, only
`ltr` + `rtl` run.
## Runner changes
- New `discoverAxes(base, morfoKebab)` pass: loads the baseline URL, reads
`<meta name="perm-axes">`, returns the declared axis list.
- `buildVariants(declaredAxes)` composes `ltr` + `rtl` + one `?perm.X=Y`
variant per declared axis from the `AXIS_FLIP` map.
- `[initial seed]` step validates morfo at each URL before any click,
so URL-seeded state alone is exercised (catches seed regressions even
when the click cycle never fires).
## Demo opt-in (this commit)
- `dialog` — `perm-axes="dir"` (no orientation / disabled / loading axes
apply to Dialog).
- `tabs` — `perm-axes="dir,orientation,disabled"` + wires `orientation` +
`disabled` to the first `Tabs.Provider`.
- `toolbar` — `perm-axes="dir,orientation"` + wires `orientation` to the
first `Toolbar.Provider`.
Result: 3 demos, 9 URL variants, 34 step validations per CI run
(v2 was 22 steps across 6 variants).
## Docs
`src/uix/morfo/PERMUTATION_RUNNER.md` v3 section: supported axis table,
3-step opt-in recipe, roadmap updated (v4 = morfo-inferred value cycling,
v5 = MutationObserver ordering for Sema).
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 3/3 demos · 34 steps · 9 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1 shipped click-based state cycles. v2 adds URL-seeded initial state so
the runner can reach any state the component exposes without walking a
click sequence, and runs every instrumented demo TWICE in CI (LTR + RTL)
to catch direction-specific regressions like the slider thumb transform
that landed this week.
## New test-route infrastructure
- `src/routes/test/soma/_perms.svelte.ts` — parses `?perm.X=Y` query
params into a typed `Perms` record (naive coercion: `'true'` → boolean,
digits → number, else string). Exposed via `Perms.getOr({})` context.
Underscore prefix so SvelteKit does not route it.
- `+layout.svelte` — parses perms once at mount, sets context, honours
`?perm.dir=rtl` and `?perm.locale=en` by routing through
`App.setDir` / `App.setLocale` so the existing control strip stays in
sync.
## Demo opt-in
Three demos seed initial state from URL params (v1 + v2 combined):
- `dialog` — `?perm.open=true` lands open.
- `tabs` — `?perm.value=tab-2` lands on that tab.
- `toolbar` — `?perm.format=bold,italic` + `?perm.align=center` preselect
toggles.
## Runner extension
`scripts/permutation-check.ts` now runs each instrumented demo under a
matrix of URL variants (currently `ltr` + `rtl`), validates morfo at the
URL-seeded state BEFORE any clicks (logged as `[initial seed]`), then
runs the v1 click cycle. Output format updated to show per-variant
blocks.
Result: 3 instrumented demos × 2 variants = 22 step validations per CI
run. RTL seeding reproduces the specific state machine the slider
`translate(50%, -50%)` bug needed to fail — had v2 been in place that
day, the regression would have fired before shipping.
## Docs
`src/uix/morfo/PERMUTATION_RUNNER.md` gains the v2 section (layout
plumbing, demo opt-in, URL variant table, `[initial seed]` step
semantics) and marks v1 + v2 as shipped 2026-04-22. v3 (morfo-inferred
per-component axis matrix) and v4 (MutationObserver ordering for Sema)
remain on the roadmap.
## Verification
- `npm run check`: 0 errors in soma/morfo/test-route scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 3/3 demos · 22 steps across 2 variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Why
`morfo:check` validates single-state DOM at page load; `smoke` catches
hydration-time errors. Neither ejercices state TRANSITIONS — and that is
where every reactivity bug we caught this week actually lived:
- toolbar A35 loop (mount-time, not caught by either)
- form A36 microtask loop (mount-time with onChange)
- slider RTL transform (static value, off-by-thumb-width)
All passed the existing CI. All would have failed a "click, re-validate"
pass.
## What
- **`scripts/permutation-check.ts`** — Playwright runner. For each demo,
collects `data-perm-step="N"` elements, executes them in ascending
order (re-discovering after each step so portal-gated controls get
counted), and re-validates the component's morfo + captures any
`pageerror` / `console.error` between steps.
- **`data-perm-*` authoring convention** — step index, human label, mode
(click / focus / type / key), settle delay, skip-validate flag. Demos
opt in incrementally; the runner SKIPs annotation-less pages without
failing.
- **`src/uix/morfo/PERMUTATION_RUNNER.md`** — full design doc covering
what it validates, the annotation convention, example, failure format,
and v2–v4 roadmap (URL-driven states → morfo-inferred cycles →
MutationObserver ordering for Sema).
- **COMPONENT_GUIDE A37 + checklist item 40** — instrumentation is now a
ship-gate rule; doc lists coverage targets (overlays / toggleables /
composite roving / forms / RTL) and explicit skips (alerts, file
pickers).
- **`npm run perm:check`** — pipeline entry, exit codes 0/1/2
parallelling `morfo:check`.
## Instrumented demos (v1 seed)
- dialog — 2 steps: open via trigger → close via Close button. Step 1 is
inside `{#if open}` + Portal; runner's dynamic re-discovery handles it.
- tabs — 3 steps: initial tab 1 → switch to tab 2 → switch to tab 3.
- toolbar — 3 steps: toggle bold / italic / underline GroupItems
(exercises the A35 pattern that previously looped).
Result: 8/8 permutations pass; 63 demos SKIPPED pending instrumentation.
## Verification
- `npm run check`: 0 errors in soma/morfo/scripts scope.
- `npm run smoke`: 65/65 routes.
- `npm run morfo:check`: 66/66 morfos.
- `npm run perm:check`: 3/3 instrumented demos, 8/8 permutations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- color demo now registers a ColorPicker-based widget inline so
kind:color fields render properly instead of showing [object Object]
in the fallback text input. Temporary — CDX-212 moves defaults to
Soma.services.sium.widgets where they belong.
- CDX-212 shard drafted with full v2.3 design (Soma services registry,
SiumService.widgets, 3-layer override: instance > subtree > global).
Includes all conversation context up to 2026-04-21 so tomorrow's
session resumes without paginating chat history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the v2.2 demo sweep. All 8 sium test pages (booking, color, kitchen-sink,
login, settings, signup, survey, tree) now share:
- App-level langs: each page does `App.get().langs.extend('demo', {...})` to add
its own namespace. siumLangs + componentLangs already registered globally by
/test/sium/+layout.svelte. Locale switch goes through `app.setLocale()` so
Form.AutoFields sees translated sium.errors.* without per-page plumbing.
- Specimen-book design language: shared CSS in /test/sium/_design.css. Pages
contribute markup only — masthead with eyebrow + schema-name + shape-hint +
locale-switch, hairline rules, description, two-column .specimen layout with
numbered PLATE debug panels.
- No inline <style> blocks. No local createLangs. No ad-hoc helper functions.
Each demo went from ~98 v1-pattern lines (with 25 lines of inline CSS) to ~110
v2.2-pattern lines (zero CSS, all imports via canonical libs). Login was already
migrated as the pilot last turn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Slider thumb misalignment
`SliderThumbProvider.thumbStyle` used `translate(50%, -50%)` for both LTR
and RTL. For LTR that pushed the thumb RIGHT by half its width from the
`left: X%` anchor — visible symptoms in the time-picker / time-range-picker
demos:
- thumb never reached `left: 0` (centre at value=0 was offset by
thumbWidth/2)
- thumb overshot the track on the right edge at value=max
- drag felt off by the same amount throughout
Fix: use `translate(-50%, -50%)` for LTR (thumb centre aligns with anchor)
and keep `translate(50%, -50%)` for RTL (anchor is `right: X%`, thumb must
shift right by half its width to centre-align). Matches the logic already
used by `SliderTickProvider` which chose `tx = isRtl ? '50%' : '-50%'`.
Browser probe confirms: value=0 now centres at 0%, value=30/59 at ~50.8%,
style attribute reports `translate(-50%, -50%)` in LTR.
## soma README documents morfo
Added §4b "Morfo — contrato declarativo cross-layer" covering: why morfo
exists (6-place drift without it), how providers consume it
(`createAttrs(morfo)` + `registerContract(morfo)`), the mandatory
`as const satisfies Morfo` authoring pattern for literal-typed inference,
the 3-layer validation pipeline (schema / assertContract / morfo-check),
and the boundary (what does NOT go in morfo — props, prose, langs, state
machines, visual recipes). Updated the §4 structure listing so
`attrs/create-attrs.ts` shows the new signature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LinkPreview README: elevate the "intentionally hidden from AT" note to
its own `## Accessibility` section with the three reasons (duplicate
content, hover-only activation, focus stays on trigger). Consumers
reaching for an AT-visible preview are pointed at Popover / Tooltip.
- TagGroup `handleItemKeydown`: vertical nav now calls
`getDirectionalKeys(dir, 'vertical')` (returns UP/DOWN absolute) instead
of hardcoded `KEYS.ARROW_DOWN/UP`. Functionally identical — vertical
keys don't flip under RTL — but keeps the shape symmetric with the
horizontal branch and matches the convention used across the library.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## morfo: typed createAttrs
- `types.ts`: all array fields are now `readonly` so morfos can be authored
with `as const satisfies Morfo` without casts.
- `createAttrs` is a generic `<const M extends MorfoShape>` that infers the
emitted attr map from the morfo's literal shape. Typos like `attrs.trigerr`
are now compile errors; autocomplete lists every valid part.
- All 66 morfos migrated: `export const xMorfo = { ... } as const satisfies Morfo`.
- Two latent bugs the new typing caught: `dateRangeFieldAttrs.root` and
`timeRangeFieldAttrs.root` — legacy from the root→provider refactor, fixed
to `.provider`.
- Docs (morfo/README, COMPONENT_GUIDE) mandate `as const satisfies Morfo`;
the older `: Morfo` annotation widens literals and loses autocomplete.
## Audit batches 1–7 (soma-audit-2026-04-20)
Batch 1 — Keyboard a11y (A14/A12):
- Tabs: `isFallbackTabStop(el)` using `untrack` → first trigger gets
`tabindex=0` when no tab matches (WCAG 2.1.1 was failing).
- RatingGroup: `dir` prop + `getDirectionalKeys(dir, 'horizontal')` for RTL.
- Accordion P1-1/P1-6 dismissed: roving/labelledby already correct per APG.
Batch 2 — A33 reactive collections: Select `labelRegistry` and NavigationMenu
`triggerRefs` migrated to `SvelteMap`.
Batch 3 — A31 O(N²) fix: RadioGroup lifts `hasSelection` + `isFallbackTabStop`
to the provider with `untrack`-wrapped DOM walk. Per-item derivations drop
from O(N²) to O(1) per trigger.
Batch 4 — Morfo aria sync: toast / time-field / date-field / tabs /
tag-group / navigation-menu declare every aria the provider emits
(14 entries across 6 files).
Batch 5 — Accessible naming props: Tabs.List, ToggleGroup, TreeView,
NavigationMenu expose `aria-label?` / `aria-labelledby?` with explicit
"required unless labelledby is set" JSDoc.
Batch 6 — A30 cleanup: Checkbox `setLabelId` and 5 trigger-ref providers
(select/tooltip/link-preview/dropdown-menu/context-menu) migrated from
`$effect(() => parent.ref = opts.ref)` to the `Provider` base's
`onRefChange` callback (fires via attachRef inside `untrack`).
Batch 7 — A11 wrapper thinning: stepper/tree-view wrappers removed
`ctx.get()`, providers expose proxy derivations; time/date-range-field
inputs use `untrack(() => …)` for one-shot id registration; menubar
content's 40-line keyboard nav moved to `MenubarMenuProvider.onContentKeydown`;
color-picker area + channel-slider pointer state machines moved to the
providers with `onpointerdown/move/up` emitted via `props`.
form-auto-fields deferred with justification (reflective schema renderer,
not a state-holding wrapper).
## Form hang fix (A36)
`runValidate` entered the `isPromiseLike` branch even for sync schemas
because sium's Standard-Schema adapter declares `validate: async`. The
fallback `return { errors, issues }` read both reactive vars inside the
validation `$effect` while the `.then(...)` microtask wrote them
→ microtask-mediated loop, silent to Svelte's sync effect-depth guard.
Symptom: kitchen-sink hung the tab on mount with onChange / onBlur.
Fix: `return untrack(() => ({ errors, issues }))`. Regression locked by
two new 5s-timeout tests in `form-auto-fields.svelte.test.ts`.
Pattern codified as A36 in COMPONENT_GUIDE; CLAUDE.md count updated to
A1–A36 / 39 items.
## Verification
- `npm run check`: 0 errors in soma/morfo scope (sema demo pre-existing
errors untouched).
- `npx vitest run src/uix/soma src/uix/morfo`: all tests green
(94 + 7 = 101 tests, inc. 2 new form regression tests).
- `npm run smoke`: 65/65 routes.
- `npx tsx scripts/morfo-check.ts`: 66/66 morfos.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A35 codifies the toolbar incident (2026-04-19): per-item `$effect` that
reads `opts.ref.current` and writes provider state read back by per-item
`props` $derived triggers `effect_update_depth_exceeded` via the
attachment reapply cycle. Register in the constructor (A30-style); use
`$effect` for cleanup only; wrap DOM walks in `untrack` when called from
`$derived`.
- COMPONENT_GUIDE: §A35 rule, checklist item 38
- CLAUDE.md: checklist count updated (A1–A35, 38 items), verification
mention of morfo-check + morfo kebab naming note
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Naming coherence across the part layer: the root part's `kebab` field now
matches its `name: 'Provider'`. The `createAttrs` special case still emits
`data-{component}` (no suffix) for the provider part — the DOM contract is
unchanged, only the authoring name changes.
Affected:
- createAttrs special-case: 'root' → 'provider'
- 66 morfos: kebab 'root' → 'provider'
- 77 soma provider files: `attrs.root` → `attrs.provider`, constructor
part arg 'root' → 'provider'
- Docs: A2 rule rewritten, morfo/README, soma/README example updates
- Test / schema / morfo-check: updated for new kebab
Toolbar fix: remove `itemsVersion` counter and per-item `$effect` blocks
that called `notifyItemsChanged()`. The pattern was an A30 violation —
each item's $effect read `ref.current`/`disabled` and wrote back to the
provider, which cascaded through `firstTabStop` → per-item `props`
$derived → DOM reattach → effect re-run, triggering
`effect_update_depth_exceeded` on page load. `isTabStop` now uses
`untrack` around the DOM walk so per-item `props` derivations only
depend on `lastFocusedElement`.
Verification: 7/7 morfo tests, 65/65 smoke, 66/66 morfo-check, no effect
loops detected across any demo page.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each morfo now declares what its provider actually emits (via agent-based
extraction from provider code). Before: parts + data only. After: parts +
data + ARIA per part + keyboard per part + focus policy for overlays.
Contract source: provider's `readonly props = $derived.by(...)` blocks,
classified into the v.* value kinds — literal, stateRef, partRef, propRef,
translationRef. Conditions extracted from ternary / guard patterns.
Focus policies added on overlay components: popover, drawer, alert-dialog,
dropdown-menu, context-menu, select, date-picker, date-range-picker,
time-picker, time-range-picker, color-picker. Context-menu uses
return:'previous' (no discrete trigger). Tooltip, link-preview, combobox,
command omit focus (no trap).
Tag-group Link variant: morfo-check tolerates sibling parts sharing a
physical element (Link IS Item in the DOM) via allPartAttrs cross-scope.
README.md added — developer guide: authoring a morfo step-by-step, the
v.* builders, severity rules, validation layers, polymorphism handling
via defaultElement + role, Sema alignment (data-last-action, transition
markers).
Verify:
npm run check — 5 errs, all pre-existing in sium/examples.
vitest run src/uix/morfo — 7/7 pass.
morfo:check — 64/66 pass (2 flaky Playwright navigation timeouts,
re-run clean when isolated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 65 soma components now consume their morfo as the cross-layer contract.
createAttrs(morfo) and registerContract(morfo) overloads removed — the
Morfo form is now mandatory. Each component provider has a 3-line morfo
import + wire-up block instead of 20+ lines of inline declarations.
Additions:
sium/core/lazy.ts — lazy() combinator for recursive schemas (unblocks
morfo's eventual lazy part-tree validation; tests included).
sium/examples/ — booking, color, login, settings, signup, survey, tree
form demos.
Route demos at src/routes/test/sium/{booking,color,login,settings,
signup,survey,tree}.
Tooling:
morfo-check.ts — now tolerates sibling parts sharing a physical element
(Link variant of Item, etc.). Queries via allPartAttrs cross-scope.
morfo-vocabulary-check.ts — 28 WARN (legitimate per-component
vocabularies or subsets like switch missing "indeterminate"). All
reviewed, none are bugs.
Fixes caught by morfo-check:
table: data-sorted / data-pinned marked severity:'optional' (only
emitted when sorting/pinning active, not always).
tree-grid: data-expanded on Row severity:'optional' (branches only).
menubar: data-menubar-value declared on Trigger.
splitter: data-splitter-panel-id declared on Panel.
Verify:
npm run check — 0 morfo errors (4 pre-existing in sium/examples,
unrelated).
npx vitest run src/uix/morfo — 7/7 tests pass.
npm run smoke — 65/65 routes pass.
npm run morfo:check — 66/66 morfos validate against real DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Morfo (src/uix/morfo/): machine-readable contract of a component's public
DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across
three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment.
Types, sium-based validator with cross-field invariants, dialog.ts as the
first canonical morfo with data-last-action for Sema-causal exits, 7 passing
unit tests.
createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy
inline signature removed. Dialog provider refactored to derive parts +
data contract from dialogMorfo. The other 65 soma components need porting
(svelte-check lists them) — tracked work.
Tooling:
scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes
scripts/morfo-check.ts — validates emitted DOM vs morfo
scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency
npm run smoke / morfo:check / morfo:vocabulary
WAI-ARIA APG components added (100% APG coverage now):
Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress,
SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel
pattern; hierarchical Table rows deprecated — use TreeGrid instead).
air: Link, Banner.
Sema prep:
src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements.
Morfo already provides everything Sema needs (data-last-action patterns,
transition markers, enumerable state values, cross-component consistency).
Sema implementation deferred.
COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM
topology vs .require() audit, npm run smoke is part of done) + A34 rule
with incident log. WAI-ARIA pattern listed as soma membership criterion.
Pagination translation bug fixed (soma.pagination.page -> idlangref
PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.*
-> components.table.*). Avatar demo now offline-safe (SVG data URL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier 3 (complete):
DateField, DatePicker, DateRangeField, DateRangePicker
Tier 4 (partial):
TimeField, TimePicker (TimeRangeField pending)
Core:
$lib/util/dias/segments.ts — pure domain primitives for segmented
date/time inputs (constants, guards, initializers, reconciliation,
content generation, Intl opts)
soma/external/dates re-exports everything (no façades inside soma)
soma/datetime/ slimmed to UI-only helpers (announcer, DOM segment nav,
SegmentState, KEYS predicates, description writers)
Conventions established (A23–A29):
A23 — never re-implement date helpers; extend dias
A24 — readonlySegments without value anchor logs warning
A25 — range components split readonly per-endpoint
A26 — contenteditable segments use onbeforeinput to block IME/paste
A27 — picker composition pattern with shared writableActive refs
A28 — time placeholders render as hh/mm/ss via dias/segments
A29 — demo pages are interactive testbeds, not static galleries
Demo pages: Material Design 3 styled, every public prop wired to a live
control, Field integration + state readout. Day-period uses real radio
group in 12h mode. Auto-anchor on range pickers keeps the end month
visible. 12h sliders map 0↔12 / 13-23↔1-11 correctly.
Docs: COMPONENT_GUIDE.md + SOMA_ARCHITECTURE.md updated with A23–A29,
checklist items 28–31, picker composition section, dias boundary,
directory structure note for soma/datetime/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
**Date library reorganisation** (no new third-party dependency)
- New `src/lib/util/dias/` with `_vendor/` (Adobe Apache 2.0 vendored code,
ATTRIBUTION.md preserved) and a public facade split into `types`,
`values`, `calendars`, `queries`, `operations`, `parse`, `format`,
`index`. Consumers import only from `$lib/util/dias`.
- All date-related `Intl` consolidated in `dias/format.ts` —
`DateFormatter`, cache, `resolveDateOrder`, `resolveHourCycle`,
placeholders, defaults.
- `ext/dates` now a thin reactive wrapper delegating to `dias`.
- Aligned App-layer `HourCycle` to canonical numeric form `12 | 24`
(matches `Intl.DateTimeFormat`'s `hour12`). Removed string form
`'12h' | '24h'` from `ext/app/types`, `ext/dates`,
`ext/presentation/locale-defaults`, and their tests.
- Repointed every consumer: `soma/external/dates`,
`terra/external/dates`, eight `routes/test/*` pages.
- Legacy `src/lib/util/dates/` left untouched by request.
**Tier 3 components**
- `soma/components/calendar/` — 14 parts, single + multiple selection,
`MonthSelect`/`YearSelect`, ISO/simple week numbers,
`isDateHoliday` matcher, `data-weekend` flag, navigation live-
region, RTL flip, full ARIA, 27-item checklist verified.
- `soma/components/range-calendar/` — 14 parts, range selection with
live hover preview (novel vs ark/bits/radix/react-aria), `minDays`
/ `maxDays` / `allowSingleDay`, third-click deselects only the
clicked endpoint.
- Test pages at `/test/soma/calendar` and `/test/soma/range-calendar`
with seven demos each. Links added to the soma index under a new
"Dates" section.
- READMEs with anatomy, parts, props, ARIA, keyboard, data-attrs, and
a competitive comparison table.
**Command fixes**
- Resolved O(N^2) reactive cascade: introduced
`visibleSet: $derived<Set<string>>` on the root provider;
per-item `isVisible` is now an O(1) `has()` lookup instead of
calling back into the provider, which was re-running all items'
derivations on every `itemsVersion` bump.
- Added `dir` prop with fallback to `soma.presentation.getDir()`;
horizontal arrows flip in RTL via `getDirectionalKeys`; root
receives `dir` attribute.
**Doc updates (session outcomes)**
- `CLAUDE.md`: replaced obsolete `@/glob/lib` date references with
`$lib/util/dias`; added canonical date-layer section; reinforced
`Provider` export rule and `data-{component}[-{part}]` naming
(never `data-soma-*`); clarified translation convention.
- `src/uix/soma/COMPONENT_GUIDE.md`: new checklist items 26
(component README required) and 27 (grep for data-attr
consistency); new Common Mistakes 11 (`data-soma-*` prefix), 12
(wrong date module), 13 (string `HourCycle`).
- `src/uix/soma/SOMA_ARCHITECTURE.md`: clarified data-attr naming
(canonical emitter is `createAttrs`; validator checks enum
values only, not names); new section covering date types and
formatting through `$soma/external/dates` → `$lib/util/dias`;
documented numeric `HourCycle`.
**Infrastructure**
- `PrimitiveHeaderAttributes` and `PrimitiveSelectAttributes` added
to `$soma/types`.
- `getLastFirstDayOfWeek`, `getNextLastDayOfWeek` re-exported from
`$soma/external/dates`.
- `calendar.*` and `range-calendar.*` translation blocks added to
`$soma/core/langs.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Explains why soma wraps Svelte runes instead of using them directly:
compiler magic limitation, cross-class composition need, migration path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add App service compositor (langs, nums, money, dates, units, presentation)
- Add createSvelteLang() with reactivity for locale and schema changes
- Migrate 25 soma providers from getSoma()/translator.svelte to Soma.getOr()/langs.t()
- Add reactive dir resolution in all component wrappers
- Add dir HTML attribute to 12 root providers (accordion, tabs, slider, etc.)
- Fix slider RTL: range, thumb, tick use right/left based on dir
- Add table structural parts: Header, Body, Footer wrappers
- Implement ColumnPinning, ColumnSizing, Expanded in table-core
- Remove soma core formatters, translator.svelte, prop-resolvers (moved to App)
- Remove duplicate convenience getters (locale, dir, translate) from App/Soma
- Audit all 26 soma components, create audit docs with 84 issues found
- Update test page with pinning, sizing, expandable rows demos
Parts: Root, Item, Header, Trigger, Content
- Single/multiple mode via type prop
- Collapsible (single mode: click open item to close)
- Per-item and global disabled
- Keyboard: Arrow keys (orientation-aware), Home/End, loop option
- Presence on Content with data-starting/ending-style
- --soma-accordion-content-height/width CSS vars for animations
- forceMount on Content
- Header with level prop (aria-level)
- Auto-generated item values when omitted
- Demo page with 5 sections
Compared with Radix, Ark UI, Bits UI:
- Has collapsible + loop (Radix has collapsible, Bits has loop, soma has both)
- Has header level (like Bits)
- Has CSS animation vars (like all three)
- Has data-starting/ending-style (like Bits)
- Skipped ItemIndicator (Ark) — presentation concern for visual layer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Popover Content floating wrapper now includes pointer-events: auto
when preventScroll is active (same fix as Dialog).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Soma class is the framework's identity — belongs in core/, not loose in root
alongside README and architecture docs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
soma — new headless component system replacing terra:
- Provider base class with opt-in context injection
- Reactive system (state, readableActive, writableActive)
- Attrs system (createAttrs, contracts, helpers)
- Props system (mergeProps with on-prefix detection, no hardcoded event list)
- DOM utils (consolidated core, focus/, env)
- Keyboard, events, CSS, ID, config modules
- Layers: scroll, observers, visually-hidden, arrow, portal, text-selection,
dismissal (merged escape+dismissible), presence, focus-scope
- Collapsible component as first soma validation
air components:
- Pagination, Editable, PinInput (air-native), RatingGroup (air-native),
Stepper, FileUpload wrappers over terra
- Terra defaultContent refactor for children snippet pattern
- Avatar, Meter, Progress, Separator migrated to air-native
- Reactive utilities extracted to $lib/util/reactive (shared)
- $soma and $reactive aliases added
Architecture:
- soma is independent: no imports from terra or $lib/util/reactive
- Terra exports deprecated for migrated components
- README fundacional documenting all design decisions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Components:
- AlertDialog: Provider, Content (context transition), Action, Cancel + demo
- DropdownMenu: Provider (revelation sound), Content (floating), Item, Separator,
GroupHeading + checkbox/radio indicators via CSS pseudo-elements + demo
- ContextMenu: Provider, Content sharing dropdown-menu visual language + demo
Terra fix — menu variant registry:
- Add menuRegistryByVariant in menu.svelte.ts to track open menus per variant
- MenuMenuState.onOpen() closes all other menus of same variant before opening
- Prevents concurrent context-menus (or dropdown-menus) from being open simultaneously
- Registry lives in terra infrastructure, not air — clean architectural boundary
- Cleanup via onDestroyEffect when component unmounts
CSS fixes:
- DropdownMenu content: z-index: 50 for proper stacking
- Checkbox/radio items: visual indicators via ::before pseudo-elements
- Selectors duplicated for both dropdown-menu and context-menu data-attr prefixes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- getAir() is now the single entry point — returns behavior, dom, translator, logger
- behavior.play() encapsulates soundEnabled check + preset resolution
- dom.resolve() resolves ResponsiveProp using current viewport/breakpoints
- internal/ has no knowledge of config/ (correct dependency direction)
- internal/ types stripped of Air prefix; public barrel re-exports with prefix
- context.ts defines TranslatorService locally (structurally compatible with TerraTranslator)
- button/text components updated to use only getAir(), no direct terra/config imports
- AudioContext fix: withRunningContext() resumes on first user gesture before playing
- AIR_IMPLEMENTATION_GUIDE updated: 12 semantics, correct APIs, current patterns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Command Dialog demo (⌘K style) with proper Dialog.Trigger
- Make all inline demos collapsible to prevent UI clutter
- Add keyboard shortcut (cmd+k / ctrl+k) for Command Dialog
- Add close button (✕) and onSelect handlers to close dialog
- Fix CSS import paths in air/ and terra/ test pages
- Fix various syntax errors in test pages (toggle, toggle-group, meter)
- Update Switch and RadioGroup contracts with missing attributes
- Fix missing ctx declarations in time-field and time-range-field
- Fix checkbox group import error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 months ago
10530 changed files with 682013 additions and 121692 deletions
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] \\\\u2192 Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el campo est\\\\u00e1 deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] \\\\u2192 Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] \\\\u2192 Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] \\\\u2192 Estado cuando el tag est\\\\u00e1 deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
"Bash(awk 'NR==40 {print \"\\\\t\\\\t *\"} NR==41 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==42 {print \"\\\\t\\\\t * ```\"} NR==43 {print \"\\\\t\\\\t * [data-tags-input] -> Elemento contenedor del componente\"} NR==44 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el campo esta deshabilitado\"} NR==45 {print \"\\\\t\\\\t * ```\"} NR==46 {print \"\\\\t\\\\t *\"} {print}' tags-input-root.svelte)",
"Bash(awk 'NR==25 {print \"\\\\t\\\\t *\"} NR==26 {print \"\\\\t\\\\t * ## Selectores CSS\"} NR==27 {print \"\\\\t\\\\t * ```\"} NR==28 {print \"\\\\t\\\\t * [data-tags-input-tag] -> Elemento contenedor del tag\"} NR==29 {print \"\\\\t\\\\t * [data-readonly] -> Estado cuando el tag es de solo lectura\"} NR==30 {print \"\\\\t\\\\t * [data-disabled] -> Estado cuando el tag esta deshabilitado\"} NR==31 {print \"\\\\t\\\\t * ```\"} NR==32 {print \"\\\\t\\\\t *\"} {print}' tags-input-tag.svelte)",
"Bash(sed -i 's/AlertDialog usa el mismo contrato que Dialog con variant=\"alert-dialog\"/AlertDialog es una variante de Dialog, usa los mismos attrs data-dialog-*/g' contracts.ts)",
"Bash(sed -i '/Los attrs se generan con data-alert-dialog-/d' contracts.ts)",
Skipping these produced the broken Layout Batch 1 (commit `9ec2a57a`) that was reverted + redone. Don't repeat the mistake.
## Build/Test Commands
## Build/Test Commands
```bash
```bash
npm run dev # Start dev server
npm run dev # Start dev server
npm run build # Production build (static site)
npm run build # Production build (static site)
@ -18,7 +29,9 @@ npm run format # Auto-format with Prettier
## Critical Architecture
## Critical Architecture
### Dual Alias Configuration Required
### Dual Alias Configuration Required
Path aliases must be synced in BOTH [`svelte.config.js`](svelte.config.js:11) AND [`vite.config.ts`](vite.config.ts:14) for TypeScript, Svelte compiler, and Vitest to resolve consistently:
Path aliases must be synced in BOTH [`svelte.config.js`](svelte.config.js:11) AND [`vite.config.ts`](vite.config.ts:14) for TypeScript, Svelte compiler, and Vitest to resolve consistently:
- `@/` → `src/`
- `@/` → `src/`
- `@/ling` → `src/lib/ling` (i18n)
- `@/ling` → `src/lib/ling` (i18n)
- `@/logr` → `src/lib/logr` (logging)
- `@/logr` → `src/lib/logr` (logging)
@ -27,16 +40,21 @@ Path aliases must be synced in BOTH [`svelte.config.js`](svelte.config.js:11) AN
- `$uix` → `src/uix` (UI components)
- `$uix` → `src/uix` (UI components)
### Svelte 5 Runes Mode Enforced
### Svelte 5 Runes Mode Enforced
[`svelte.config.js`](svelte.config.js:31) forces `runes: true` for all project files via `dynamicCompileOptions`. All components must use Svelte 5 runes (`$state`, `$derived`, `$effect`, etc.).
[`svelte.config.js`](svelte.config.js:31) forces `runes: true` for all project files via `dynamicCompileOptions`. All components must use Svelte 5 runes (`$state`, `$derived`, `$effect`, etc.).
### Vitest Two-Project Structure
### Vitest Two-Project Structure
[`vite.config.ts`](vite.config.ts:35) defines separate test projects:
[`vite.config.ts`](vite.config.ts:35) defines separate test projects:
- **client**: Browser tests via Playwright for `*.svelte.{test,spec}.{js,ts}` files
- **client**: Browser tests via Playwright for `*.svelte.{test,spec}.{js,ts}` files
- **server**: Node environment for `*.{test,spec}.{js,ts}` files (excludes svelte tests)
- **server**: Node environment for `*.{test,spec}.{js,ts}` files (excludes svelte tests)
### Internal Library Pattern
### Internal Library Pattern
Each library (`ling`, `logr`, `glob`, `actx`) uses factory functions (`createLing`, `createLogr`, etc.) that return instances with internal state. The `logr` library depends on `ling` for localized messages.
Each library (`ling`, `logr`, `glob`, `actx`) uses factory functions (`createLing`, `createLogr`, etc.) that return instances with internal state. The `logr` library depends on `ling` for localized messages.
## Code Style
## Code Style
- Tabs for indentation, single quotes, no trailing commas, 100 char print width
- Tabs for indentation, single quotes, no trailing commas, 100 char print width
- **`intentRequirement`** (`'required' | 'optional' | 'forbidden'`) — compile-time gate. `required` → `intent` REQUIRED in `MorfoEventSemantic` (discriminated union). `forbidden` is reserved for future use (no family uses it today).
- **`intentGuidance`** (`'expected' | 'contextual' | 'discouraged'`) — doctrinal hint, not type-enforced. Drives lint warnings and editor tooltips.
Type derivation: `SemaEvent` and `MorfoEventSemantic` discriminate over `intentRequirement`. Editing the const reshapes the discriminated union.
Runtime enforcement: `validateSemaEvent` throws when an `intentRequirement: 'required'` family declares an event without intent. Optional families pass silently.
**`delegate`** is the 8th family per book cap. 29 — reparto de iniciativa entre usuario y sistema. Structural; carries no intent of its own. Active channels empty (delegate composes with sustain / signal / commit for perceptual layering, doesn't own a base signature).
The earlier flat `intentPolicy: 'allowed' | 'expected' | 'optional'` mixed type-requirement with doctrinal guidance. The split was applied in commit `00f0b740`.
## Sema: open channel registry + flat CSS-style cascade
The perceptual layer is **NOT closed**. The book defines **8 expression
`semaSelector(morfo, partKebab, matchers?)` from `$uix/morfo`. Renames
in morfo break the cascade at type-check time. See "Sema cascade
selectors must use the typed builder" above.
- **Eidos plain `.css` recipes** are still raw CSS today — there is no
CSS-side typed builder. For those, `scripts/eidos-lint.ts` remains as
an **opt-in safety net** that classifies each `[data-*]` selector as
`morfo-backed` / `eidos-only` / `invalid`. It is not the architectural
contract; the contract is the morfo declaration.
Rule: when a layer can consume the morfo via TypeScript (anything in
`.ts` / `.svelte`), it MUST use the typed builder. Lint is for the
remaining surface (plain CSS recipes) until those gain a builder of
their own. Hand-written morfo-targeting selector strings in TypeScript
files are an architecture violation, not a lint warning.
## Session hand-off — 2026-06-05 (sistema de color — cierres + theme builder runtime + wide-gamut + a11y)
Sprint dando "un giro de vueltas" al sistema de color de Eidos hasta reference-grade. 6 commits. El motor `uix.color` (`$color`, art puro isomórfico: OKLCH↔sRGB, APCA, alpha compositing-inverse, `deriveScheme`/`generateScale`/`temper`) ya existía; este sprint lo CONSUME end-to-end + cierra los ítems de calidad de `THEMING_AUDIT`.
**Cierres rápidos** (`cc37bdce`):
- `themes/base.ts`: `loss` era `purple` ≡ primary → mapeado a `plum` (escala canónica). Última colisión de roles del tema base cerrada (tras tertiary→indigo).
- `temas/grafito`: la sección "override por componente" pasaba nombres de escala (teal/amber/…) a `Button.color`, que solo acepta el override jerárquico (`primary|secondary|neutral`) — type error + inerte. Reescrita a los 2 ejes reales: `color` (jerarquía) + `intent` (paleta evaluativa). Cierra el último error de `svelte-check` → **0 errores**.
**API runtime — theme builder** (`c4d2e34d`):
- Nuevo `src/uix/eidos/lib/build-scheme.ts` (PURO): `buildScheme(seed, opts)` compone `deriveScheme` + `generateScale` + APCA on-solid + alpha en el mapa de override `--primitive-{role}-*` (+ `--color-{role}-contrast`). `seed → { variables, roles }`. Sin DOM.
- `ActiveEidos.applyColorScheme(seed, opts)` / `clearColorScheme()`: resuelve escalas-donantes + background del tema activo, escribe el bloque `uix-eidos-scheme` DESPUÉS del de tema (gana cascada), RE-DERIVA al cambiar de modo (sigue light/dark). Devuelve `BuildSchemeResult`. `opts`: `variant` (tonal|vibrant|monochrome) + `temper` (cohesión de intents, mantiene hue) + `overrides` per-rol + `selector`. Exportado de `$uix/eidos`. Demo `/temas/color` dogfooda `buildScheme`.
- Output OKLCH-nativo default-on (RFC §7 estrategia A): `render-css > appendColorScaleDeclarations` emite por cada paso de paleta el hex (fallback) + un hermano `oklch()` que gana donde se soporta. SIN flag.
- Generador wide-gamut-TRUE: `buildScheme`/`applyColorScheme` retienen el OKLCH raw de `generateScale` (sin clamp) → `result.wideGamut` + `result.roles[].stepsOklch`. Nuevo `schemeDeclarations(result, { fallback })` apila hex+oklch (default) u oklch-only (inline).
- **HONESTIDAD**: la paleta Radix shipped es hex sRGB → sus `oklch()` son sRGB-equivalentes (idéntico hoy). El wide-gamut REAL vive en el generador: seed con croma > sRGB sale más saturado en P3. Demo `/temas/color`: slider **vivacidad P3** + badge «fuera de sRGB → P3» (`isInSrgbGamut`). Verificado: ×1.70 → croma primary-9 0.18→0.31. La paleta autorada NO se migró a semillas (regresaría los valores exactos de Radix sin añadir wide-gamut visible).
**P3-2 forced-colors + P3-3 ramp de bordes** (`4eab3306`):
- **forced-colors (Windows HCM)**: el navegador auto-mapea bordes/texto/fondos (`forced-color-adjust: auto`) pero ELIMINA `box-shadow` → el focus ring (`--focus-ring`, box-shadow) desaparecía. Fix: foundation emite siempre `renderForcedColorsBlock` → `@media (forced-colors: active) { :focus-visible { outline: 2px solid Highlight } }`. Componentes con outline propio (Button) lo conservan por especificidad. Pendiente: `prefers-contrast: more`.
- **ramp de bordes**: slot de rol `border` 6 (separador sutil) → **7** (UI element border de Radix). `DEFAULT_COLOR_ROLE_SLOT_STEPS`. element/hover/active=3/4/5 se quedan. Verificado: `--color-{role}-border` → primitive-7 (checkbox OK).
**Verificación**: `check` 0 errores. Suite eidos 155/158 (3 fallos pre-existentes del track `words`, confirmados con `git stash` baseline). `build-scheme` 8 + scheme 4 + forced-colors 1. `generated/base.css` regenerado y en sync.
- **Paleta autorada = exacta-Radix-sRGB** (sin regresión). Wide-gamut VISIBLE de la paleta = Fase 3 futura (autorar/generar en OKLCH).
- **forced-colors**: el box-shadow muere en HCM → el foco debe ser `outline`. Migrar los componentes que aún usan `var(--focus-ring)` (box-shadow) a `outline` propio es trabajo futuro per-componente (hoy dependen del fallback global).
- **slot `border` = step 7** (no 6).
**Pendiente (solo higiene del engine, NO calidad de color)** — _todo resuelto-o-decidido en la continuación, abajo_: `THEMING_AUDIT` P3-4/5/6/7/8/9/11 + mitades P2.
**Continuación (mismo día) — fix de UX + cierre del backlog**:
- **Checkbox lag — NO era color, era timing del sema** (`e6fd014d` + `cd834384`). El usuario reportó el check "muy lento". Medido frame-a-frame: el `data-state` tardaba **244ms** en cambiar tras el click. Causa: el provider del checkbox fija el estado en el HANDLER del `runtime.trigger`, y el morfo declaraba `sequence: 'pre'` → el runtime hace `await runEmit()` (que **espera el hold del canal visual ~240ms**, `engine.emit()` → `await visualChannel.handle`) ANTES del handler. Fix: `commit-toggle-check`/`commit-toggle-uncheck` → `sequence: 'post'` (handler primero, pulso después). 244ms → 46ms. Secundario: trazo del checkmark `stroke-duration` 220ms hardcoded → `var(--duration-fast)`. **Verificado que radio-group (47ms) y tabs (31ms) NO laggean** aunque son 'pre' — fijan estado en el call-site, no en el handler; no se tocaron. Toggle/Switch ya eran 'post'.
- **DOCTRINA NUEVA**: un control cuyo estado se fija en el HANDLER del trigger DEBE usar `sequence: 'post'`; con `'pre'` el hold perceptual del emit bloquea el cambio funcional. Los que fijan estado en el call-site toleran 'pre' sin lag.
- **P3-11 surface ladder** (`76e18772`). Light `overlay` era `neutral-3` == `muted` (popovers indistinguibles de paneles muted en light); dark ya tenía overlay=4. Light overlay → `neutral-4` → ladder consistente en ambos modos: `default(1) < raised(2) < muted(3) < overlay(4)`. Verificado (popover light: overlay L93% ≠ muted L95.5%).
- **prefers-contrast: more** (`21b2329a`). Completa el a11y de color junto a forced-colors. `renderPrefersContrastBlock` → `@media (prefers-contrast: more) { :root:root { … } }` refuerza bordes (neutral 7/8/9) + texto de-enfatizado (12/11). `:root:root` (0,2,0) gana al `:root` del tema; aditivo, gated, estrictamente más fuerte. THEMING §28.
- **Cierre del backlog del engine** (`afa15aea`). `THEMING_AUDIT` P3 todo fixed-or-decided:
- ✅ **P3-6** confirmado ya resuelto (dispose vía `dom`, sin `document` directo).
- ✅ **P3-8** index ya NO re-exporta los render-fns crudos (API pública = clase `ActiveEidos`; `./lib/render-css` para uso interno). Consumidor de test redirigido al módulo.
- ⏸️ **P3-4** deferido (densidad gana por orden de fuente determinista, estable; restructure `:where(:root)` = coste alto por nit teórico).
- ⏸️ **P3-7** deferido (reactividad callback-driven vía `apply()` POR DISEÑO; runes = refactor riesgoso sin bug que lo justifique).
- ⏸️ **P3-9** deferido (forwarders huérfanos `_accent` — cirugía de recipe con riesgo de cascada, valor bajo).
- Único pendiente real: 2 mitades **P2** (poda de contrato + validación de identificador-color pelado) — edge-case, deferidas por bajo valor.
**Estado final del color/theming**: apariencia + a11y (forced-colors + prefers-contrast) + wide-gamut + theme builder runtime + backlog del engine — todo resuelto-o-decidido, cero ítems colgando. `check` 0 errores. Suite eidos 156/159 (3 fallos de words, pre-existentes, confirmados con baseline). Docs extra: `THEMING.md` §28 (prefers-contrast), `THEMING_AUDIT_2026-06-01.md` scorecard P3 completo.
- Nuevo tipo `SignalPersistence = 'transient' | 'untilAction' | 'untilFix' | 'stateBound'` en `src/uix/sema/types.ts`, propagado a `SemanticSignal`, `MorfoEventSemantic` y `TriggerOptions`.
- Tabla canónica `SEMA_HOLDS_BY_INTENT` en `src/uix/sema/holds.ts` (referencia, NO auto-aplicada — default conservador `'transient'`).
- `EngineSemantic.emit()` ahora devuelve `Promise<string>` (el id). Para `persistence !== 'transient'` mantiene la proyección viva pasado el hold; expone `engine.clear(id)`, `engine.clearTarget(target)`, `engine.hasActive(id)`.
- `SomaRuntime.trigger()` devuelve `TriggerResult { id?, persistence? }`. Expone `runtime.clearSignal(id)`, `runtime.clearTarget(target)`, `runtime.partRef(part)` (helper que devuelve el HTMLElement registrado de un part — útil para que providers limpien sin trackear ids).
- 3 providers cabledados con `clearTarget(provider)`: form (limpia al validar / reset), file-upload (limpia antes de cada nueva ronda de accept/reject + en remove / clear), password-field (limpia indicator al apagarse caps lock).
**a11ySemantic (libro §9.1)**:
- Nuevo `MorfoA11ySemantic` interface en `src/uix/morfo/types.ts`: `requiresPersistentTrace?`, `requiresLiveRegion?`, `requiresFocusMove?`, `keyboardEquivalent?`, `reducedMotionFallback?: 'state' | 'text' | 'focus' | 'none'`. Añadido como `a11ySemantic?` opcional en `MorfoEvent`. Pasa por compile a `ActionPlan.a11ySemantic`.
- Helper de reduced-motion en `src/arts/adom/reduced-motion.svelte.ts` (paralelo a `viewport.svelte.ts`): `ReducedMotionTracker` reactivo del media query con SSR-safe fallback. Expuesto en `ActiveDom.prefersReducedMotion.matches`.
- `ActiveUix.announce(message, priority?, timeout?)`: live region compartida lazy-creada vía `dom.writeNode`. No depende de soma. Polite/assertive son regiones separadas, cleanup en `dispose()`.
- `SomaRuntime.trigger()` honra a11ySemantic después del emit: live region (con `opts.message`), focus move, reduced-motion fallback (incluye forzar `channels: []` cuando fallback = 'state').
- 6 morfos anotados (los mismos consumidores).
**Polymorphic events (libro §5.3)** — ADITIVO sobre el shape concreto:
- Morfo declara `family` + `intent` + `verb` como default. Si añade `allowedFamilies: readonly SemaFamily[]`, los providers pueden override la family en `runtime.trigger(name, { semantic: { family, intent?, verb? } })`. El default family del morfo es IMPLÍCITAMENTE allowed.
- `isPolymorphicSemantic(semantic)` helper exportado de `$uix/morfo`.
- `SomaRuntimePolymorphicError` cuando override no está en allowedFamilies.
- Diseño aditivo (no variante separada con `defaultSemantic`) elegido para mantener backwards-compat con 29 morfos existentes y demos que acceden `event.semantic.family` directamente.
**Tests**: 671/671 pasan en `src/uix/sema + morfo + soma + src/arts/adom/test`. 4 fallas pre-existentes en `src/uix/contracts.test.ts` (words component translation keys) — confirmadas en baseline antes de mis cambios via `git stash`.
**`npm run check`**: 1 error pre-existente en `src/uix/soma/components/command/command-provider.svelte.ts:384` (`Cannot find name 'attrs'`) que ya estaba en main.
**Pendientes deliberadamente fuera del sprint** (resueltos en la segunda mitad — ver hand-off 2026-05-27 #2 abajo):
- ~~Extender persistence a tags-input + textarea~~ — DONE
Segunda mitad del sprint cerrando el backlog del hand-off anterior. 3 grupos de cambios.
**M — Caller messages para live region**:
Cada provider que dispara un evento con `a11ySemantic.requiresLiveRegion` ahora pasa `opts.message` con texto localizado. La live region (ya cabledada via `Soma.runtime() → sources.announce`) finalmente anuncia algo:
- `form-provider`: `signal-warn-invalid` → "1 form error..." / "{N} form errors..." (count = `Object.keys(form.issues).length`). Usa `FORM_LANGS.ERROR_SUMMARY_*`.
- `file-upload-provider`: `signal-warn-reject` → "1 file was rejected" / "{N} files were rejected". Nuevas entradas `FILE_UPLOAD_LANGS.REJECT_SUMMARY_SINGLE/MULTI` + langs catalog.
- `password-field-provider`: `signal-notify-caps-state` → "Caps Lock is on" (usa `PASSWORD_FIELD_LANGS.CAPS_WARNING` que ya existía).
- `drawer-provider.dismissWith(action, { message })`: igual que dialog.
**P — Persistence extendido a tags-input + textarea**:
Misma doctrina §6.2 — signal+risk = untilFix:
- `tags-input.signal-warn-reject` → persistence: `untilFix` + a11ySemantic + provider clear via `clearTarget(input)`. Nueva entrada `tags-input.reject-warning` en langs catalog. Provider gana método privado `emitWarnReject(target)` que centraliza clear + emit + message. Successful add también clearea (la adición VÁLIDA es el "fix" de un reject previo).
- `textarea.signal-warn-count-overflow` → persistence: `untilFix` + a11ySemantic + provider clear en transición OUT-of-overflow. Nueva entrada `textarea.overflow-warning` en langs catalog. Provider emite SOLO en transición INTO overflow (no en cada keystroke al cap) — el `untilFix` proyecta persistente, no necesita re-anuncio.
**POLY — Dialog refactor a polymorphic close**:
Caso real de uso del feature de eventos polymorphic (§5.3). 5 eventos `close-*` colapsados en 1 evento `close`:
- **Morfo** (`dialog.ts`): un evento `close` con `family: 'emerge', verb: 'close', allowedFamilies: ['emerge', 'commit', 'signal']`. SIN `prewrite` (el provider escribe `data-last-action` imperativamente).
- **Validador relajado**: el invariante "cada `values[]` debe ser prewritten por algún event" cayó al sentido único "cada prewrite con value debe estar en `values[]`". Razón: con polymorphism los values pueden ser escritos imperativamente. El comentario en `schema.ts` explica.
- **Provider** (`dialog-provider.svelte.ts`): `DISMISS_CAUSES` mapa de acciones a `{ lastAction, semantic }`. `dismissWith(action, opts)` traduce a `dom.apply` + `runtime.trigger('close', { semantic, ... })`. `triggerClose` es privado ahora.
- **Eidos CSS** (`dialog.css`): NO cambió — ya leía `data-last-action` para tintar, no nombres de evento.
- **API pública preservada**: consumidores externos no notan diferencia (`dismissWith` mantiene firma + comportamiento observable).
- **Drawer / Popover**: NO refactorizados. Mismo patrón aplicable; defer porque cada uno requiere reescribir su cascade + decisión separada de timing.
**Tests**:
- `npx vitest run src/uix/sema src/uix/morfo src/uix/soma src/arts/adom/test`: **679/679 pass**.
- `npm run check`: 1 error pre-existente (`command-provider.svelte.ts:384` — ya en baseline).
- 4 fallas pre-existentes en `src/uix/contracts.test.ts` (words translations) — no relacionadas.
**Pendientes a futuro** (cubierto en hand-off 2026-05-27 #3 abajo):
- ~~Refactor analógico de Drawer + Popover a polymorphic close~~ — DONE
- Otros `signal.warn-*` no incluidos: hay variantes con `verb: 'warn'` que podrían también beneficiarse de persistence untilFix (revisar caso por caso).
Tercera tanda del sprint — completa el rollout del patrón polymorphic close (book §5.3) a los dos componentes hermanos de Dialog. Mismo refactor aplicado de forma sistemática.
- Test (`drawer-provider.svelte.test.ts`): `triggerClose('close-dismiss', ...)` → `dismissWith('dismiss')`; expect name='close'.
**Popover**:
- Morfo: igual refactor. 5 close-* → 1 polymorphic `close`.
- Provider: igual patrón con DISMISS_CAUSES + dismissWith. Internal callsites (5 lugares: scheduleHoverClose, trigger toggle click, trigger toggle keydown, escape, outside) migrados a `dismissWith`. Close button: usa el prop `action` traducido a dismissWith.
- Sema cascade (`sema/components/popover.ts`): mismo patrón — `eventName: 'close'` + `state: { attr: 'data-last-action', value: 'dismissed-outside' }` para la regla de dismiss-outside passive.
- Test (`popover-provider.svelte.test.ts`): no había refs a close-* directos. Ningún cambio necesario.
**Test fixtures migrados antes del refactor**:
- `compile.test.ts`: prewrite test usaba `drawerMorfo` → ahora usa `colorPickerMorfo`.
- `runtime.svelte.test.ts`: prewrite test usaba `drawerMorfo` → ahora usa `colorPickerMorfo`.
**Resultado**:
- 3 componentes overlay (Dialog / Drawer / Popover) usan el patrón polymorphic close de forma consistente.
- 5 picker components (color-picker / date-picker / date-range-picker / time-picker / time-range-picker) mantienen el shape per-event. Son la canonical reference para `prewrite` declarativo + sirven como fixtures de tests.
- API pública intacta — `dismissWith` mantiene signatura en los tres overlays.
- Eidos CSS no tocó (ya leía `data-last-action`).
**Tests**: 680/680 pass en `src/uix/sema + morfo + soma + src/arts/adom/test`. `npm run check`: 1 error pre-existente.
**Pendientes a futuro** (picker family cubierto en hand-off 2026-05-27 #4):
- ~~Picker family refactor~~ — DONE
- Otros `signal-warn-*` no cubiertos (textarea / tags-input ya hechos; revisar caso por caso si emergen más).
## Session hand-off — 2026-05-27 #4 (Picker family polymorphic close)
Cuarta tanda — completa el rollout polymorphic close al picker family (5 componentes). Más decoupling de tests.
**Hallazgo importante**: los picker providers NO disparan los close events. Solo togglean `opts.open = false`. Los eventos estaban declarados pero **inertes** — su único consumidor era el schema validator y los compiler tests. El refactor es alineación doctrinal, no de comportamiento.
**Sema cascade**: solo `color-picker` tiene un sema pack y no referenciaba close-* (solo handle-*). Cero updates necesarios en cascades.
**Test fixtures decoupling**:
- Nuevo `src/uix/morfo/test-fixtures.ts` con `prewriteFixtureMorfo` sintético.
- `compile.test.ts`: migrado de `colorPickerMorfo` → `prewriteFixtureMorfo`.
- `runtime.svelte.test.ts`: igual migración.
- Beneficio: los tests validan el contrato del compiler/runtime sin depender del catálogo de componentes. Futuros refactors del catálogo no rompen estos tests.
**Estado final del rollout polymorphic** (3 sprints combinados):
Único morfo con shape pre-polymorphic restante: el fixture sintético `prewriteFixtureMorfo` — vivo sólo para tests.
**Tests**: 680/680 pass. `npm run check`: 1 error pre-existente.
**Pendiente a futuro**:
- Cabledar `runtime.trigger('close', { semantic })` en los providers picker cuando justifique disparar el evento (telemetría, sound on commit picker, etc.).
Cierra la migración universal del Token Scope Contract. El usuario rechazó el framing "excepciones arquitectónicas" para los 3 componentes que vivían fuera de TSC v2.1 (`select`, `avatar`, `toggle-group`) — la respuesta fue extender TSC con dos features nuevas (`parts` + `composition`) y migrar los 3.
- **Multi-part scope** — `RecipeTokenMultiDeclaration.parts?: readonly string[]`. Cuando un token tiene `parts: ['x', 'y']` el generador emite selectores comma-separados (`[data-{c}-x][...], [data-{c}-y][...]`) en lugar del default `[data-{c}][...]`. Cubre el caso del select donde `data-color` vive en Trigger + Content (Content portaliza fuera del árbol del Trigger). Único consumer hoy.
- **Cross-recipe composition** — nuevo sibling field `composition?: { foreignComponent: { targetSelector, tokens } }`. Permite que un recipe declare overrides de tokens de OTRO recipe scoped a su propia cascade, con un selector de descendant para alcanzar la part foránea. Genera `{host-scope} {targetSelector} { --{foreign}-{tokenName}: ... }`. Validator rechaza root-scoped composition (un override no-scoped pertenece al foreign recipe). Único consumer hoy: `toggle-group` modifica `--toggle-palette-*` en sus items.
Validador (`config.ts`) extendido con `validateRecipeComposition`. Generator (`render-css.ts`) gana `stripCompositionKey` + `emitComposition`. Contract builder (`contract.ts`) skip-list de la reserved key `composition` para que no aparezca como `--{c}-composition` knob en el contrato público. Test helpers (`tokenKeys`/`tokenEntries` en `recipe-css-contract.test.ts`) filtran `composition` en todos los iteradores.
**Migraciones de los 3 componentes restantes**:
- `select`: 4 → 3 private `_accent-*` con `parts: ['trigger', 'content']`. (`_accent-solid` no se migró porque la CSS nunca lo consumía — era orphan; se removió de la recipe.)
- `avatar`: 6 tokens (`_bg`/`_fg`/`_border` + `_badge-bg`/`_badge-fg`/`_badge-border`) con declarations[] generadas por un helper inline `matrix()` que recorre los 8 colores × 3 variants. `_badge-*` usa `parts: ['badge']` para retargetear a `[data-avatar-badge]`. Reemplaza ~150 declarations CSS por las mismas declarations, generadas desde TS.
**Bug post-migración del toggle-group (toggle-group color cascade)** — encontrado y arreglado:
La composition correctamente override `--toggle-palette-*` en `[data-toggle-group-item]`, pero la cascade se rompía aguas abajo. Después de la migración TSC v2 original de toggle, 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) de `[data-toggle]`, así que `var(--toggle-solid-on-bg)` resolvía a undefined en el item.
Fix (en `toggle-group.css`): **inlined las derivation expressions** que leen palette directamente en `[data-toggle-group-item]` y sus variant cascades. Las expresiones reflejan las de `recipes/base.ts > toggle.{solid,outline,ghost}-*`. Duplicación documentada en el header. Alternativa estructural (hacer el item carrier de `[data-toggle]` + propagar `data-color` morfo/soma) no se aplicó — change too big.
**Documentación**:
- `THEMING.md` §18 reescrito como "Cobertura universal de TSC" (sin excepciones). §7 extendido con subsecciones "Multi-part scope" y "Cross-recipe composition" con ejemplos completos. TOC actualizado.
- `eidos/README.md` tabla de referencia ampliada con entradas para TSC v2.2 + §18.
**Tests**: 786/786 pass en `src/uix/{eidos,morfo,soma,sema}`. `npm run check`: los mismos 6 errores pre-existentes en `lib/_demo`/`soma/components/internal`/`web/routes/active` (no relacionados).
**Pendiente a futuro**:
- Si emergen patrones similares al toggle-group (otros wrappers compositivos como button-group, nav-menu), la composition TSC v2.2 los cubre — no requiere más extensiones.
- Los `inlined derivation expressions` en `toggle-group.css` son la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos.
Pregunta arquitectónica del usuario: "si activeUIX quiere ser referencia como framework, ¿qué es lo lógicamente coherente respecto a la extensibilidad de variants?". Respuesta firme: **variants son canon del eidos, NO del theme** — paralelo a las 8 sema families del libro.
**Cambios**:
- `src/uix/eidos/lib/types.ts`: nueva constante `EIDOS_VARIANTS` (5 archetypes: `control`/`selection`/`chip`/`marker`/`tabs`) como single source of truth. Los 5 union types se derivan via `[number]` indexed access — valor y tipo no pueden desincronizarse. Nueva constante `EIDOS_VARIANT_VALUES` (Set flat de todos los valores canónicos + utilidades cross-component como `'plain'` y `'subtle'`).
- `src/uix/eidos/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` via regex (literal-union + archetype-alias patterns soportados, Extract<>/conditional types caen a advisory mode); compara con los `[data-{c}][data-variant='X']` selectores en `{c}.css`; reporta typos y unauthorized extensions bidireccionalmente. 101/101 tests pasan.
- `src/uix/eidos/THEMING.md` §19: nueva sección "Variants son canon del eidos, NO del theme" con argumentación (portabilidad, type safety, archetypes perceptuales), tabla de las 3 capas de la cebolla (sema → variants → palette), referencia a `EIDOS_VARIANTS`, comparación con Radix Themes/Mantine/Chakra v3/Ark UI/shadcn. TOC actualizado.
- `src/uix/eidos/README.md`: tabla de referencia ampliada con §19.
**Doctrina sostenida**: Theme = retintar lo perceptualmente fijo. Cambia QUÉ color es `affirm`, no QUÉ significa `outline`. Si una app necesita un look brandeado, hace override de tokens en `EidosConfig.recipes` o crea un wrapper composicional — NO inventa un nuevo variant.
**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).
Cierra la deuda dejada explícitamente abierta en el hand-off 2026-05-27 #5 ("Los `inlined derivation expressions` en `toggle-group.css` son la única duplicación entre recipes/base.ts y CSS. Si Toggle's derivations cambian, hay que actualizar ambos."). Ahora son cero.
**Pregunta arquitectónica del usuario** tras verificar el fix EXT-FIX (color cascade): "¿qué es lo recomendado en referencia al ecosistema y su diseño y arquitectura?". Respuesta firme: **resolver la duplicación estructuralmente, no con un test de drift**. El item de toggle-group ES un toggle (mismo press, mismo variant/color/size, misma máquina on/off) — la doctrina "morfo declara DNA" pide declararlo.
**Cambios**:
- `src/uix/morfo/components/toggle-group.ts`: el part `Item` declara `{ attr: 'data-toggle', value: v.literal(''), severity: 'required' }`. Cada item proyecta `data-toggle=""` como atributo de presencia. Cero overhead, captura la identidad estructural.
- `src/uix/eidos/components/toggle-group/context.ts` (nuevo): contexto Svelte tipado (`ToggleGroupEidosCtx` con getters reactivos para `variant` y `size`) — propaga las dos perillas eidos-only del root a cada item.
- `src/uix/eidos/components/toggle-group/toggle-group.svelte`: setea el contexto en el root. Getters mantienen reactividad cuando el prop cambia.
- `src/uix/eidos/components/toggle-group/toggle-group-item.svelte`: lee el contexto y escribe `data-variant={ctx?.variant}` + `data-size={ctx?.size}` en el button. Combinado con `data-toggle` del morfo, el elemento es DOM-equivalente a un `<Toggle>` standalone.
- `src/uix/eidos/components/toggle-group/toggle-group.css`: borradas ~150 líneas (todas las derivaciones base + variant cascade + size cascade + focus-visible / disabled / icon-only duplicados). El CSS conserva SOLO grouping concerns (flex layout, orientation, attached con first/last/border-radius, block, focus z-index, group-level disabled). El header comment se rescribe documentando la nueva división de responsabilidades.
- `src/uix/eidos/components/toggle-group/README.md`: sección "Recipe" + "Decisiones" actualizadas — la entrada "Structural identity" documenta el patrón.
**Por qué TSC v2.2 composition se queda en la mezcla**: la composition sigue siendo necesaria para overridear `--toggle-palette-*` en el item bajo `[data-toggle-group][data-color='X'] [data-toggle-group-item]`. El color NO se propaga via contexto porque la composition ya hace el trabajo a nivel CSS, sin overhead reactivo. Variant/size sí se propagan porque tienen muchos derivados (height, padding, gap, font, radius × 5 sizes; bg, fg, border, hover, on, on-hover × 3 variants) que solo Toggle's recipe ya emite — no había ningún beneficio en mantener cascadas paralelas.
**Verificación**: probe DOM en `/uix/components/toggle-group` capturando computed values en las 12 combinaciones (4 colores × 3 variants) × 2 estados (on/off) — match bit-a-bit con baseline pre-refactor. Ejemplos:
- `affirm/solid` on: `rgb(18,165,148)` = `#12a594` (palette-solid affirm)
- `threat/ghost` on: `rgb(25,17,17)` = `#191111` (palette-track threat)
**Tests**: 163/163 pass en `src/uix/morfo` + `src/uix/eidos`. 4/4 pass en `src/uix/soma/components/toggle-group`. `npm run check`: 16 errores pre-existentes (los mismos del sprint Words), CERO añadidos por esta migración.
**Coste real vs estimado**: el hand-off #5 estimó el cambio como "too big" — incorrecto. Total = 1 entrada en morfo + 1 archivo de contexto (~30 líneas) + 2 ediciones puntuales en wrappers (~5 líneas cada) + 1 rewrite de CSS reduciendo ~150 líneas a ~85. La parte engañosa era pensar que requería tocar Toggle's CSS — no lo hace.
**Doctrina reforzada**: cuando un wrapper componente reusa visualmente otro, la respuesta canónica NO es duplicar el cascade ni extender TSC con un tercer feature. Es declarar la identidad estructural en el morfo del wrapper. Patrón aplicable si emerge button-group, link-group, etc.
Segunda mitad de la sesión (2026-05-28) dedicada a fixes del editor Words por feedback iterativo del usuario. Seis sprints EV-* + cleanup del check.
**Cleanup pre-sprint — `npm run check` 16 → 0**:
- 4 errores `orientation` drift en `soma/components/words-toolbar*` (deuda mía del sprint anterior — removí orientation del type pero no del soma component). Quitado el prop + create() call.
- 12 errores en el sprint Words activo (post-2026-05 F2/F3/COLOR): `onUploadImage` threading en soma `Words.Provider`, `'insert-image'` añadido a 2 Records `WordsToolbarButtonCommandName`, `leafItem` snippet hoisted FUERA de `<SomaWords.Provider>` (snippets dentro de un component element son props en Svelte 5; el snippet era helper local), 5 arrays inline en `words-drawer.svelte` extraídos a constantes typed `as const satisfies readonly { id: WordsCommandName | WordsMark; ... }[]`.
**EV-A — spam de eventos del canvas**:
- `isInsideWordsTool` extendido a los 5 overlays añadidos post-DRAWER: `data-words-drawer`, `data-words-block-handle`, `data-words-block-handle-menu`, `data-words-block-inserter`, `data-words-image-float-bar`. Antes, cualquier click sobre estos overlays se interpretaba como blur EXTERNO → fire `commit-save-content` + re-focus → fire `contact-focus` = 2 sonidos por interacción.
- `contact-focus` target movido de `content` a `provider` en morfo + sema cascade (doctrina: focus es evento de componente, no de body).
**EV-B — toolbar slim**:
- Presets demo (`minimal`/`formatting`/`full` + custom) reducidos a acciones GLOBALES: history (undo/redo) + insert + link + tools + find-replace. Text/block/list/align/table OUT porque el drawer ya los cubre por scope.
**EV-C — drag handle UX**:
- Borrado `e.dataTransfer.setDragImage(hoverBlockEl, 12, 12)`. El browser usa su snapshot por defecto (= el grip button) como ghost — el ghost viaja con el cursor mientras el bar en la gutter queda fijo como ancla visual. Nuevo `data-dragging` + CSS fade del ancla a 0.35 opacity.
**EV-D — inserter al borde inferior**:
- `seam.y` para seams entre bloques cambia de `(a.bottom + b.top) / 2` (midpoint) a `a.bottom` (borde inferior del bloque anterior). Half-open interval `[top, bottom)` para que el píxel exacto del bottom pertenezca al seam, no al bloque.
**EV-E — scroll interno del content**:
- Nuevo token `content-max-block-size-sm/md/lg` (50/60/70vh) en el recipe. `[data-words-content]` gana `max-block-size: var(--_words-content-max-block-size)` + `overflow-y: auto`. El min-block-size baseline queda como starting height para editores vacíos.
- block-handle / block-inserter / image-float-bar: scroll listeners migrados de `window.addEventListener('scroll')` a `document.addEventListener('scroll', { capture: true })`. Razón: scroll events NO burbujean — la versión anterior solo captaba scroll del root document; con la rail/handle/inserter ahora dentro de un content scrollable, había que capturar también scroll DENTRO del content para que las overlays se re-midieran.
**EV-F — paquete de 6 fixes en uno**:
1. Cascade sema de `contact-focus` REMOVIDA → canvas mudo en focus (EV-A fixaba solo el target; la cascade seguía sonando).
2. Rail bg → flat silver (`#d4d4d4`) + borde derecho `#9a9a9a`, sin dot pattern. Tokens `words.rail-bg / rail-border` en el recipe. **Fixed-tone, NOT theme-aware** — la intención es emular un margen físico de cuaderno, debe verse igual en dark/light theme.
4. Family-panel base 12.5 → 16rem · tools 17rem · link 20rem. Sin scroll horizontal.
5. Link popover sin scroll vertical (consecuencia del #4).
6. Engine `insertParagraph` con guarda explícita: paragraph vacío + Enter = no-op; heading vacío + Enter = demote a paragraph (canonical Notion UX).
**EV-G — último pass (parcial)**:
- `runtime.trigger('contact-focus')` comentado en soma. Causa real del sonido residual: la family `contact` en `SEMA_MAP` tiene BASE signature de sound (pitch 800, gain 0.25) que suena aunque no haya cascade per-component. Solución: no disparar el evento. Telemetry vacía para focus, aceptado.
- Drag handle + inserter ahora cubren `data-words-node="list"` y `data-words-node="table"`, no solo `"block"`. Los selectores en `findBlockElement`, `blockUnderCursorY`, `listBlockBoundaries` aceptan los tres tipos para top-level blocks. Listas y tablas tienen drag handle + inserter seam.
- `:hover` rules eliminadas en `[data-words-block-inserter-button]` y `[data-words-block-handle]`. Las overlays son AMBIENT — estado visual cambia solo en data attrs (`data-open`, `data-grabbed`, `data-dragging`). No se iluminan en hover.
**Decisiones arquitectónicas con efecto duradero**:
- **Canvas perceptualmente silencioso** (Words): `contact-focus` no se emite. Si en el futuro un consumidor necesita el evento, descomentar el `runtime.trigger` en `words-provider.svelte.ts > onfocus`.
- **Tokens FIXED-TONE para concepto físico** (Words rail): `--words-rail-bg/border` son hex literal en el recipe, no `var(--color-*)`. Justificación: el concepto visual es "margen de papel" — debe verse igual en todos los themes.
- **Drag-handle predicate cubre 3 tipos de node**: `block`/`list`/`table`. No solo `block`. Cualquier nuevo top-level node type tiene que entrar en la lista.
- **Overlays ambient, no interactive buttons**: gutter overlays (block-handle, block-inserter button) no tienen `:hover`. Estado visual via data attrs solamente.
- **Engine guard sobre bloque vacío**: `insertParagraph` ya no duplica párrafos vacíos. Convierte heading→paragraph en vacío.
**Pendientes documentados en CONTINUE.md**:
- P1 — Drag handle aparece FUERA del rail sobre code blocks (no diagnosticado).
- P1 — Heading inline level change (h1↔h2↔h3) — la UI no permite cambiar nivel inline; el drawer Block panel lo tendría que ofrecer cuando `currentBlock === 'heading'`.
Sprint completing the alignment of the project with the book *Diseñando lo que ocurre*. Nine commits closed P1 #3, P1 #4 and P1 #5 of the audit-codex:
```
504780ad expression field + sema coverage check
2d562f37 11 sema packs + doctrina samples (D.5/D.6/D.7)
826ca2bf D.8 channels scope doctrine
43b15372 picker-shell INTERNAL (P1 #5 closed)
66318c47 tree-view target → branch for emerge events
adf89a08 batch A — soma emission for 7 packs-shipped components
0784cae5 batch B — 8 components with sema scope, no pack
cbf76b66 batch C — drag-drop + virtual-list/grid
7afa7057 23 component READMEs with Sema events sections
```
**Architectural deltas from this sprint:**
- **`Morfo.expression`** new field (`'pack' | 'family-default' | 'delegated' | 'none'`) declares how each morfo with declared events materialises its perceptual signature. Schema-validated; lint enforces coverage. See `src/uix/morfo/types.ts` + `LIBRO_VARIACIONES_Y_EXTENSIONES.md` D.4.
- **`SemaFamily` is now 8** (added `delegate` per book cap. 29). `SEMA_FAMILY_POLICY` carries two axes (`intentRequirement` + `intentGuidance`) — see updated section above.
- **Soma emission cabled** in 18 components (toggle-group, menubar, navigation-menu, dropdown-menu, context-menu, tree-view, tree-grid, listbox, grid-list, table, feed, command, carousel, announce, clipboard, drag-drop, virtual-list, virtual-grid). Pattern: emit from the central state-mutator with `fallbackTarget` resolving the specific element so the cascade matches the right instance.
- **`handle-scroll*` intentionally NOT emitted** in virtual-list / virtual-grid — family.handle activates haptic only; emitting on every pixel of scroll would buzz the device nonstop. Apps that want scroll-feedback wire their own throttled emit. Documented in both READMEs.
- **picker-shell relocated** from `src/uix/morfo/components/` to `src/uix/morfo/internal/` — declares its INTERNAL status by file location. Apps never write `<PickerShell>`; the five composite pickers (date / date-range / time / time-range / color) re-export the shell parts under their own namespace. README added at `src/uix/eidos/components/picker-shell/README.md`.
- **`LIBRO_VARIACIONES_Y_EXTENSIONES.md`** is the authoritative registry for project decisions vs the book canon. Sections D.4 (`expression` field), D.5 (toggles soft-tuned packs), D.6 (menus/trees packs), D.7 (samples doctrine — `SOUND_LIBRARY` = resources, `SOUND_TUNINGS` = canon, packs compose tunings never samples; `sampleOverlay` rejected permanently), D.8 (channels scope — `sound` + `haptic` are the only canonical runtime channels; ARIA structural lives in morfo, ARIA dynamic in soma, visual in eidos — never canonized as channels).
- **23 soma component READMEs** now include a `## Sema events` table with event / family / verb / target / intent / when + pack reference + doctrinal notes for the verb-rename corrections applied across the sprint.
**Verification at hand-off**: `npm run morfo:vocabulary` EXIT 0 (clean). `npx vitest run src/uix/sema src/uix/morfo` 195/195. `npx vitest run src/uix/soma` 419/419. Pre-existing words/* type errors unchanged (separate dev track).
**What's left from the audit-codex**: P2 (34 demos NEEDS-WORK) — explicitly out of scope (full refactor of demos + web routes pending separately).
---
# Behavioral Guidelines
These reduce common LLM coding mistakes. They bias toward caution over speed —
| **#0** | cross-reference flag worth confirming during that component's audit |
---
## Cross-reference anomalies (candidate finding-#0, to confirm in per-component audit)
These are recorded now so the audit doesn't "discover" them as surprises; each must be
confirmed (or dismissed) inside the owning component's report.
| Component | Anomaly | Status to confirm |
| --- | --- | --- |
| `menu-dial` | scope `['soma','sema','eidos']` but soma dir holds only `index.ts` — no `menu-dial-provider.svelte.ts` | **RESOLVED (Batch 2):** compositional API; the headless runtime + nav helpers live in `soma/components/menu-dial/` (non-standard filenames), the eidos root composes them. Benign by design (matches the compositional-not-data-driven doctrine). |
| `onion-menu` | same as menu-dial | **RESOLVED (Batch 2):** runtime in `soma/components/onion-menu/onion-menu.svelte.ts` + `onion-menu-nav.ts`; eidos `<OnionMenu>` root composes it via `createOnionMenuRuntime`. The agent's CRITICAL "eidos imports soma = frontier violation" was a FALSE POSITIVE (eidos→soma is the normal direction) — refuted in verification. |
| `metrics` | scope `['sema','eidos']` yet has a soma dir with only `index.ts` | **RESOLVED (Batch 7):** the soma logic lives in `soma/components/metrics/metrics.svelte.ts` (non-standard filename, not `-provider.svelte.ts`); it's a sema+eidos display component with a small soma helper. Legitimately structured, not dead. |
| `picker-shell` | `INTERNAL` morfo, soma dir = `index.ts` only, no demo | **RESOLVED (Batch 3):** internal-by-design. The shell logic lives in `soma/components/picker-shell/picker-shell-handle.svelte.ts` + `exports.ts` (re-exported by the 5 pickers); Clear/Cancel/Close compose `<Button>`, Footer/Header/Body are layout zones. Not an orphan. |
| `avatar-group` | morfo `Y`, no own eidos dir — rendered by `eidos/components/avatar/avatar-group.svelte` | Confirmed co-located (not orphan); audit theming under `avatar` |
| `cascade`, `motion` | eidos morfos with **no demo** | Confirm they are infra contracts (not user-facing components) — expected |
| `words` | full stack present | **EXCLUDED from audit** — active separate dev track (`words`/`palabras`), per `docs/README.md` note + project rule + user directive 2026-06-26. |
| `palabras` | eidos for `words` | **EXCLUDED from audit** — user directive 2026-06-26. |
| `chronos` | full stack present | **EXCLUDED from audit** — user directive 2026-06-26 (active track). |
**Optional extraction (EX-3):** a 3-line shared `liftedSet(() => string[])` helper, then all 9 consume it.
Decide inline-vs-helper at execution (≥2 consumers justify the helper).
**Verify:** `npx vitest run src/uix/soma/components/{listbox,grid-list,tree-view,tree-grid,tag-group,toggle-group,checkbox,select,combobox}` + a 50-item browser smoke on listbox/tree.
---
## Phase 2 — the isolated HIGH findings (12)
Each is well-grounded and self-contained. Order within the phase is by risk.
method: adversarially-verified workflow; HIGH lead-verified. B6 ground-truth: checkbox/toggle/switch commit-toggle = sequence post (lag-fixed); radio-group/tabs/accordion/stepper set state at call-site (pre OK); toggle-group + checkbox-group carry the A31 .includes pattern (SYS-7); slider has no gesture-layer A6 leak.
sequence-audit: State set at call-site (line 104 in setValue) before events fire (lines 107-108). Morfo declares both 'open' and 'close' events as sequence='post'. State change precedes trigger emission; perceptual signal acknowledges resolved state, not delaying reveal. Compliant with intentional design documented
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 0 · LOW 0.
- impact: Type narrowing degraded in createAttrs; morfo loses const assertion benefits and exhaustiveness checks
- repro: Build and check TypeScript diagnostics; createAttrs will not narrow string literal keys correctly
- proposed-fix: Change to: export const alertDialogMorfo = { ... } as const satisfies Morfo;
- verify: [confirmed] Read src/uix/morfo/components/alert-dialog.ts line 4 verbatim: `export const alertDialogMorfo: Morfo = {`. This is a bare type annotation, not the canonical `as const satisfies Morfo` form. The file ends at line 76 with `};` (no `as const satisfies Morfo` closer). Confirmed against the four baseline morfos which all use the canonical pattern: dialog.ts:15 `export const dialogMorfo = {` closing at :303 `} as const satisfies Morfo;`, popover.ts:4 closing at :251, drawer.ts:4 closing at :313, toggle.ts:22 closing at :143. The `: Morfo` annotation widens literal types (e.g. `kebab: 'alert-dialog'`, part kebabs `'provider'|'action'|'cancel'`, the `v.literal('button')` aria values) to their base types, degrading the exact-key narrowing compileMorfo/createAttrs depend on — exactly the rule-A violation described. Severity HIGH is appropriate: it is a contract-shape rule the morfo validators should catch.
- fix-status: fixed (98954a7c)
## No-findings dimensions
B, C, D, E, E-bis, F, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: action/cancel data attribute projection; aria-label resolution and reactivity; onclick close delegation to Dialog; callback execution on button click
- untested: keyboard interaction (delegated to Dialog); focus trap/return (delegated to Dialog); Escape key behavior override (delegated to Dialog)
## Style observations (non-blocking)
- AlertDialog properly delegates all state/focus/keyboard to Dialog with variant='alertdialog' forced
- Escape key behavior override (close→ignore regression fix) is well-documented in README and implemented via wrapper
- Eidos Action/Cancel properly forward dialog intent to Button via snippet binding
cleanup-audit (A6/A35/A36): Line 75-77: Single $effect with cleanup return (clearTimers()). Disposes: politeTimer and assertiveTimer via cancel() if present (lines 149-161). Timers are scheduled via soma.uix.timers.schedule() (A6 compliant). Global createAnnouncer (global.svelte.ts): timers stored in RegionPair.timer and cance
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 2 · LOW 0.
## Findings
### MEDIUM: eidos CSS selectors must match morfo-declared or runtime-auto-generated attributes. A decl — announce-001 <!-- id: announce-001 -->
- dimension: E-bis: Theming / Selector drift
- rule: eidos CSS selectors must match morfo-declared or runtime-auto-generated attributes. A declared prop like `visuallyHidden` must have a corresponding data attribute or inline style to drive styling.
- evidence: Line 23 uses selector `[data-announce-region]:not([data-visually-hidden])` to style visible regions. However, `data-visually-hidden` attribute is never written by soma. The provider (announce-provider.svelte.ts:246) writes inline `style` when hidden, but never writes a `data-*` attribute to signal visibility state to eidos.
- impact: The CSS selector `:not([data-visually-hidden])` always matches because the attribute is never present. This means card styling (display, padding, border, etc.) is incorrectly applied to ALL regions, including those where `visuallyHidden=true` (which should be sr-only with no card chrome).
- repro: Mount an Announce.Region with `visuallyHidden={false}`. The region will receive card styling (padding, border, etc.). Mount another with `visuallyHidden={true}` (default). It will ALSO receive card styling, breaking the sr-only case.
- proposed-fix: Option A: Modify AnnounceRegionProvider.props (line 242-248) to write `data-visually-hidden: hidden ? '' : undefined` so the CSS `:not([data-visually-hidden])` selector works correctly. Option B: Change the CSS selector to check for inline style presence (not reliable) or add a separate `data-visible` attribute when visuallyHidden is false. Recommend Option A with a morfo data declaration for the attribute.
- verify: [downgraded] MECHANISM CONFIRMED, IMPACT REFUTED. Confirmed: `data-visually-hidden` is NEVER written anywhere. Morfo (announce.ts:88-99) declares only `data-role`+`data-live` on the `region` part; the provider (announce-provider.svelte.ts:243-247) drives `visuallyHidden` ONLY via inline `style: hidden ? VISUALLY_HIDDEN_STYLE : undefined`; grep across soma+eidos finds no writer of the attr (announce.css:23 is the sole reference). So `[data-announce-region]:not([data-visually-hidden])` (announce.css:23) ALWAYS matches — the negation is dead/non-functional CSS; the 'visible regions only' guard the author intended never excludes the sr-only case. THAT part is a real selector-drift defect (E-bis), MEDIUM stands. BUT the candidate's IMPACT ('incorrectly applied to ALL regions ... breaking the sr-only case') is REFUTED: when visuallyHidden=true, soma sets the inline sr-only style (`position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)`) which has higher specificity than the stylesheet rule and overrides `padding:0`/`border:0` inline (lines 12-22). The leftover `display:block`/`background`/`border-radius`/`font-size`/`line-height` from the CSS block are visually inert on a 1x1px clipped box — the region stays hidden. So the sr-only case is NOT broken; the defect is dead CSS / a non-functional visible-vs-hidden distinction, not an a11y/visual regression. Keeping MEDIUM but correcting the rationale/impact.
- fix-status: open
### MEDIUM: eidos CSS selectors must key off attributes the morfo declares or soma actually writes; a — announce-101 <!-- id: announce-101 -->
- rule: eidos CSS selectors must key off attributes the morfo declares or soma actually writes; a `:not([attr])` guard against an attribute that is never emitted is dead CSS that silently never differentiates state.
- evidence: `[data-announce-region]:not([data-visually-hidden]) { display:block; padding:...; border:...; background:...; }` — but `data-visually-hidden` is never written: announce.ts:88-99 declares only `data-role` and `data-live` on the region part, and announce-provider.svelte.ts:243-247 drives `visuallyHidden` exclusively via inline `style` (`hidden ? VISUALLY_HIDDEN_STYLE : undefined`), never a data attribute. Grep across soma+eidos confirms no writer.
- impact: The intended 'visible regions only' branch is non-functional: the `:not()` negation always passes, so the visible-card rule applies to every region. It is masked in the default (visuallyHidden=true) case because the higher-specificity inline sr-only style clips the box, so there is no user-visible regression today — but the CSS no longer expresses the visible-vs-hidden intent and will silently mis-style if the inline style ever changes. Dead-code distinction.
- repro: Mount `<Announce.Region visuallyHidden={false}>` and `<Announce.Region visuallyHidden={true}>`; inspect computed styles — the visible-card rule (display:block/background/font-size) resolves on BOTH because `:not([data-visually-hidden])` matches both; only the inline sr-only clip differentiates them, not the CSS branch as intended.
- proposed-fix: Either (a) project the state as a real attribute the CSS can key on — e.g. declare `data-visually-hidden` in the region part's morfo `data` and have soma write it from `opts.visuallyHidden`, then keep `:not([data-visually-hidden])`; or (b) gate the visible-card chrome on an opt-in attribute the consumer sets (e.g. `data-variant='visible'`) rather than a negation of a never-present attr. Option (a) keeps the existing selector working.
- verify: [verifier-added] added by adversarial verify pass
- covers: timer scheduling and cancellation via soma.uix.timers; A/B toggle for repeated announcements; role/aria-live derivation; declarative region attrs sync; standalone region without provider; provider context context injection
- untested: visible region CSS application (visuallyHidden=false styling); async timer edge cases under network throttle; cross-document announcer (createAnnouncer in iframe/shadow DOM)
method: theming-coherence category sweep (visual-elements), adversarially verified; roles + `:Morfo` lead-verified CLEAN across all eidos (no success/warning/danger/info; only alert-dialog uses `:Morfo`).
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 0 · LOW 0. Theming-clean.
conformance: Contract solid (as const), layout-only (no events), stacking modes (side-by-side/overlap/overlap-reverse), local z-index OK within component.
- evidence: Item part declares `archetype: 'item'` but Item is a display `<li>` container (not selectable). Timeline.ts explicitly documents: "No `archetype: 'item'` — that archetype is for selectable menu/listbox/option rows... A timeline entry is a display `<li>`, structurally unique, so it omits the archetype." Breadcrumb.Item is semantically identical to Timeline.Item.
- impact: Incorrect archetype declaration violates the 2-of-3 rule (archetype field inconsistent with actual DOM semantics). May confuse CSS-side consumers expecting interactive row styling (cursor:pointer, select, hover). Runtime validator should flag this.
- repro: none (static violation)
- proposed-fix: Remove `archetype: 'item'` from the Item part definition (line 50). Item is a display container, not a selectable option.
- verify: [confirmed] CONFIRMED. breadcrumb.ts:48-56 declares the Item part with `archetype: 'item'` (line 50) on a display container `<li>` (`defaultElement: 'li'`, no role, not selectable). The runtime stamps `data-archetype='item'` on the element whenever the morfo sets it (morfo/types.ts:720 'the runtime emits `data-archetype="..."` on the part's DOM element via `partProps`'; README:177 same). That `[data-archetype='item']` selector triggers archetypes.css:139-153 — `cursor: pointer; user-select: none; min-block-size: var(--list-item-height, auto); padding-block: var(--list-item-py, var(--space-1-5))` — PLUS the hover/highlight band at archetypes.css:161-171 (`background-color: var(--color-surface-raised)` painted on the WHOLE `<li>` on hover). breadcrumb.css:36-41 styles `[data-breadcrumb-item]` only with `display:inline-flex; align-items:center; min-inline-size:0` — it does NOT reset cursor/padding-block/hover, so the styling is unmitigated. This is the exact documented bug: Timeline.ts:88-91 ('No `archetype: 'item'` — that archetype is for selectable menu/listbox/option rows (it ships cursor:pointer, user-select:none, internal padding + a hover highlight). A timeline entry is a display `<li>`, structurally unique, so it omits the archetype.'), and Calendar.ts:247-249 / 270-273 + range-calendar.ts:285 repeat the same doctrine for display `<tr>`/`<td>`. Breadcrumb.Item is semantically identical to Timeline.Item. Real visible regression: a breadcrumb `<li>` gets `cursor:pointer`, `padding-block: var(--space-1-5)`, and a surface-raised hover band over the whole crumb row. HIGH is correct per the rubric's 'archetype-pulls-wrong-styling on a real part'. Fix: remove `archetype: 'item'` from breadcrumb.ts:50 (the Item is a display container; the interactive Link inside owns its own affordance).
- fix-status: fixed (98954a7c)
## No-findings dimensions
B: Behavior(soma): A35/A36 loops absent (effect mirrors current to item without cycle; no async state writes), B: A6 resource cleanup (no timers/listeners/observers), B: A31 O(N²) derivations absent, B: A30 child->parent id registration (uses $effect correctly), B: A33 state(Map/Set) cloning (no Maps/Sets used), C: DOM-selector (no querySelector with user values), D: Frontier (no soma->eidos imports), E-bis: Theming (recipe uses canonical --space-*, --font-size-*, --duration-* tokens; focus-ring uses canonical CSS var), F: Tests environment jsdom (provider test exists, covers label projection, current mirroring, ellipsis semantics), G: Passive component justified in README (zero state, zero keyboard nav, zero events by design)
- covers: Provider label projection and resolution (BREADCRUMB_LANGS); Link.current state mirroring to Item.isCurrent; aria-current conditional emission (true/'page'/'step'); renderSpan conditional tag switching; Ellipsis decorative->interactive semantics via interactive prop; aria-haspopup conditional on interactive; role=presentation conditional on !interactive
- untested: E2E browser interaction (Playwright): full click/focus flow, ARIA tree, CSS targeting; Eidos variant/size responsive resolution (ActiveEidos.resolve); Ellipsis with DropdownMenu composition (documented example in README)
## Style observations (non-blocking)
- Link focus-visible uses canonical --focus-ring-width and --focus-ring-color vars (breadcrumb.css:61-64, 107-109) ✓
- Ellipsis interactive and link hover transitions use canonical --breadcrumb-transition-duration and --breadcrumb-transition-ease (line 52-54, 94-98) ✓
- Recipe declares all semantic spacing (gap-xs/sm/md/lg → var(--space-*)) and sizing (font-size-xs/sm/md/lg, ellipsis-size → var(--control-height-xs)) ✓
- no hardcoded hex colors; all use role aliases (--breadcrumb-color, --breadcrumb-link-color, etc.) ✓
- impact: Bare percentage where --opacity-* scale could be canonical; though this is a specialized mix-opacity control, the 18% literal bypasses the established opacity scale
- proposed-fix: Consider documenting whether this 18% is a one-off interaction opacity or should reference an opacity token if theming consistency is desired
- verify: [downgraded] Confirmed literal at base.ts:4384 'divider-strength': '18%'. But this is a color-mix percentage (currentColor mix for the segmented seam, css:93), not an --opacity-* element-opacity case. The recipe comment (4381) explicitly flags it as 'the lone magic number'. color-mix proportion has no canonical token; LOW cosmetic, not opacity-scale drift.
- fix-status: open
## Theming facts (E-bis)
- magic literals: 18% @ src/uix/eidos/lib/recipes/base.ts:4384 -> should document opacity rationale or reference token | 1 @ src/uix/eidos/components/button-group/button-group.css:98,102 -> local z acceptable | 2 @ src/uix/eidos/components/button-group/button-group.css:106 -> local z acceptable
cleanup-audit (A6/A35/A36): No timers, listeners, observers, or cleanup needed. ButtonProvider uses no setTimeout, setInterval, ResizeObserver, IntersectionObserver, MutationObserver, or other disposable resources. All state is reactive via $derived.by() with no effect roots. No $effect blocks with side effects. A35/A36 loop c
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 0 · LOW 0.
- untested: ButtonProvider initialization and onclick flow; Intent → color cascade resolution (intent wins over color, neutral fallback); Field provider OR-merge of disabled state; Loading state gating of contact-activate event; onPress callback invocation; data-color, data-loading, data-disabled attribute synchronization
## Style observations (non-blocking)
- Focus-ring implementation (button.css 99-107) correctly uses outline with --focus-ring-* tokens and does NOT hardcode any two-ring composite — single primary-tinted ring is intentional per comment. :active (button.css 90-97) correctly uses scale(var(--press-scale)) with canonical press tokens, not a hardcoded translateY. Variant slice pattern (158-212) uniformly applies via palette variables for soft/surface/outline/ghost/plain — no per-variant hardcoding. Icon sizing correctly feeds --icon-size from --_button-icon-size which cascades per size (lines 30-35, 111-154).
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified by the lead. Batch-3 ground-truth: each picker fires trigger(close) (close NOT inert), but open/commit-reset ARE inert; calendar/range-calendar are MID-REFACTOR (uncommitted view-switch work).
> **MID-REFACTOR CAVEAT:** this component has uncommitted view-switch changes on this branch (M/D/?? files). Findings reflect the current in-flight state; treat structural inconsistencies as in-progress, not shipped defects.
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 3 · LOW 2.
- impact: The eidos directory at src/uix/eidos/components/calendar/ exists (with calendar.css, components, types.ts), but morfo declares scope as ['soma', 'sema'] without 'eidos'. Per known systemic SYS-1, this is acceptable if the eidos layer is optional or only consumed via composition.
- proposed-fix: Verify with team: is eidos-layer consumption optional? If mandatory, update scope to ['soma', 'sema', 'eidos'].
- verify: [confirmed] Confirmed at calendar.ts:7 `scope: ['soma', 'sema'],` omits 'eidos', yet a full eidos layer exists: recipe block `calendar:` at base.ts:1626 (60+ tokens) AND src/uix/eidos/components/calendar/ contains calendar.css + ~25 .svelte wrappers + recipe consumption. This is exactly the known systemic SYS-1 scope-drift (MEDIUM). The candidate's framing as 'acceptable if optional' is the SYS-1 baseline narrative; the drift itself is real and matches the documented systemic pattern.
- fix-status: fixed (212624e0)
### MEDIUM: Magic literal in recipe token: '0.62' opacity not from canonical scale — calendar-004 <!-- id: calendar-004 -->
- dimension: E-bis
- rule: Magic literal in recipe token: '0.62' opacity not from canonical scale
- impact: Opacity value 0.62 is hardcoded; no reference to a canonical --opacity-* token from the static scale. Day-outside-month opacity is not unified with other opacity tokens.
- proposed-fix: Map to a canonical --opacity-* token (e.g. --opacity-60 if the scale includes it), or introduce a new token for calendar-specific opacity levels.
- verify: [confirmed] Confirmed `'day-outside-opacity': '0.62',` at base.ts:1688. Verified STATIC_OPACITY (static.ts:255-275) is a 0.05-step scale: --opacity-60=0.6, --opacity-65=0.65 — 0.62 maps to NO canonical step. Per E-bis 'bare opacity decimal -> a --opacity-{0..100} scale exists' this is a magic literal (MEDIUM). Note: the same 0.62 design value appears as '62%' literals at base.ts:729 and 895 (other components' overlay-opacity), so it is an established off-scale brand value with no token — but the rule still flags the bare decimal. Confirmed at MEDIUM.
- fix-status: open
### MEDIUM: Test coverage gap: keyboard navigation routes not exercised in jsdom environment — calendar-005 <!-- id: calendar-005 -->
- dimension: F
- rule: Test coverage gap: keyboard navigation routes not exercised in jsdom environment
- evidence: Test file has 217 lines, 7 tests. No tests for handleDayKeydown, ArrowRight/ArrowLeft/ArrowUp/ArrowDown, Home/End, PageUp/PageDown, focus management, or tabindex routing. All tests run in jsdom environment.
- impact: Critical interaction paths (APG GRID keyboard navigation: arrows, Home/End, PageUp/PageDown + shift, focus roving) are NOT tested. Keyboard behavior regressions would not be caught.
- proposed-fix: Add browser-level (Playwright/client) test suite covering:
- handleDayKeydown for each KEYS.*
- focus tabindex management (focused day has tabindex=0, others -1)
- week navigation (ArrowUp/Down by 7)
- month/year navigation via PageUp/PageDown +shift
- placeholder update when target outside visible months
- two-moments ordering (value.current write before trigger)
- verify: [confirmed] Confirmed: calendar-provider.svelte.test.ts is `// @vitest-environment jsdom` (line 1), 7 tests, all exercising pure helpers + bounds-validation logging. NONE drive handleDayKeydown (the APG grid keyset Arrow x4 / Home / End / PageUp+PageDown / shiftKey at provider lines 470-521), roving tabindex (line 1124), focus management, or two-moments ordering (value write at 436/443 before runtime.trigger at 445). This is the documented SYS-3 (jsdom-only, kbd-untested). The provider HAS rich, branchy keyboard + focus code that is entirely unexercised — genuine high-risk coverage gap. Confirmed at MEDIUM (matches SYS-3 baseline severity).
- fix-status: open
### LOW: DOM querySelector selector interpolation must use CSS.escape for untrusted values — calendar-001 <!-- id: calendar-001 -->
- dimension: C
- rule: DOM querySelector selector interpolation must use CSS.escape for untrusted values
- impact: If target.toString() ever produces values with CSS selector metacharacters (e.g. quotes, brackets), the selector could break or be unsafe. DateValue.toString() should be ISO-safe, but the pattern violates the defense-in-depth rule requiring CSS.escape().
- proposed-fix: const el = root.querySelector<HTMLElement>(
- verify: [downgraded] Confirmed the selector exists at calendar-provider.svelte.ts:514-516: `root.querySelector<HTMLElement>(\`[data-calendar-day][data-value="${target!.toString()}"]\`)`. `target` is a DateValue produced by date arithmetic (shiftDate/subtract/add), NOT a consumer-supplied string — DateValue.toString() emits an ISO date (e.g. '2026-05-15') with zero CSS metacharacters, ever. The rule about CSS.escape targets UNTRUSTED/consumer-derived values; this is a framework-derived ISO token that cannot contain a quote/bracket. Same pattern at line 173 (`[${attrs.day}][data-focused]`) uses no interpolation. Real defect risk = none; defense-in-depth nicety only. Not HIGH. Downgraded to LOW.
- fix-status: open
### LOW: Magic literal in recipe token: '8ch' not referenced as a canonical size — calendar-003 <!-- id: calendar-003 -->
- dimension: E-bis
- rule: Magic literal in recipe token: '8ch' not referenced as a canonical size
- impact: Character unit (ch) is hardcoded; no corresponding token in a canonical size scale. This token controls the month/year select minimum width and is not derived from --space-* or --control-height-* primitives.
- proposed-fix: Either introduce a canonical token (e.g. --select-width-base: 8ch) and reference it, or document this as a component-specific sizing exception.
- verify: [downgraded] Confirmed `'select-min-width': '8ch',` at base.ts:1663. Per E-bis, font-size/icon-size/spacing/radius MUST reference canonical scales, but a min-width is sizing dimension with no canonical `ch`-based scale — `--space-*` is the wrong axis (a min-width in character units intentionally tracks glyph width of the month/year select, not the spacing rhythm). `ch` is a legitimate CSS unit for text-box min sizing and there is no `--{c}` token it should reference because no canonical width-in-ch scale exists. This is a named recipe token (component-scoped `--_calendar-select-min-width`), already the canonical home for a one-off dimension. Not a px/rem font-size literal. Token-naming nit at most; downgraded to LOW.
- fix-status: open
### MEDIUM: Home/End morfo action label ('first/last-day-of-week') vs provider impl (start/end-of-month) — SYSTEMIC with range-calendar <!-- id: calendar-006 -->
- dimension: A, B
- rule: morfo↔code drift + APG Date Picker Dialog (Home/End move within the WEEK, not the month)
- evidence: the morfo action labels say *day-of-week* but the provider navigates to *start/end of month*. Surfaced by the range-calendar agent (range-calendar-002); the verify confirmed calendar does the IDENTICAL thing → systemic, not range-calendar-specific. (The calendar agent's own style-obs even claims "Home/End all implemented" — it saw the handlers but didn't check the label-vs-behavior semantics.)
- impact: either (a) an APG deviation — WAI-ARIA Date Picker Dialog defines Home/End as first/last day of the current WEEK and PageUp/PageDown as month — so Home jumping to the first of the *month* is non-standard; or (b) the morfo label is stale. Two-component contract/keyboard drift.
- repro: focus mid-month in a Calendar grid, press Home → focus jumps to day 1 of the month (APG expects first day of the current week).
- proposed-fix: decide intended behavior. APG-week → change provider to first/last-day-of-week within the visible row; month variant → rename the morfo action to `first/last-day-of-month` so label and behavior agree. Apply to BOTH calendar + range-calendar.
- verify: [lead-added] elevated from the verify's LOW on range-calendar-002 (it downgraded because "not range-calendar-specific" — that shared-ness is exactly why it's systemic MEDIUM). Both providers confirmed via the verify cross-reference.
- fix-status: open
## No-findings dimensions
B (loop/roving math is correct — modulo with guards), D, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: 8ch | 0.62
- undeclared parts: none
- roles clean: true · variants clean: true
- conformance: Data attributes and CSS color roles align with the canonical 9-role system (primary, secondary, neutral, affirm, fulfill, risk, threat, loss). No undeclared roles or unapproved role names detected.
## Tests (F)
- exists: true · env: jsdom only
- covers: placeholder resolution from selection; month grid generation; time preservation in date selection; multiple selection toggle and maxDays enforcement; date flags (disabled, unavailable, holiday) independence; selection/view bounds validation; invalid bounds logging
- untested: handleDayKeydown routing for all 10 keyboard actions (ArrowRight/Left/Up/Down, Home, End, PageUp, PageDown, Enter, Space); focus management and tabindex roving; placeholder update and monthchange when target outside visible months; announcement generation for navigation; two-moments: value.current write before trigger ordering; view switching (day ↔ month ↔ year); readonly behavior (select blocked, focus+nav allowed); disabled behavior (all interaction blocked)
## Style observations (non-blocking)
- Keyboard event handling is well-structured with proper directional key resolution for ltr/rtl.
- APG GRID keyboard actions (arrows, Home/End, PageUp/PageDown + shift) are all implemented in handleDayKeydown.
- Focus management uses queueMicrotask to ensure DOM updates before focus shift.
- Morfo `as const satisfies Morfo` is correctly applied at line 357.
- Parts registered via runtime.part() (provider, header, heading, prev-button, next-button, month-select, year-select, grid, grid-head, grid-body, grid-row, head-cell, cell, day) align with morfo declarations.
- No soma imports in provider (CRITICAL rule passes).
- Date utilities correctly use $libs/days, not @internationalized/date.
- No evidence of $effect.root without disposal, setTimeout/setInterval, or memory leaks.
- CSS role attributes (application, grid, button, row, gridcell, header, label) are declared and rendered correctly.
- aria-readonly and aria-disabled follow propRef + ariaBoolean pattern consistent with framework conventions.
method: adversarially-verified workflow (analyze → refute); HIGH lead-verified by direct read of the cited code. B5 ground-truth: the A31 O(N²) isSelected/isExpanded (.includes from a per-item $derived) is confirmed across listbox/grid-list/tree-view/tree-grid/tag-group (SYS-7); rovingTargetEl is correctly LIFTED everywhere (not A31); virtual-* use SvelteMap (A33-clean).
reactivity (A31/A33/A35): "CLEAN: Per-item isActive derivations read global state directly (opts.value.current), not via method calls—O(1) per item, not O(N²). No $state(Map/Set) usage (A33 clean). ItemGroup effect at line 529–540 is component-level drag tracking, not per-item—no A35 loop risk. Ref registrations via onRefChange (lines 487, 522), not $effect—A30 clean. ResizeObserver and autoplay timer both disposed via eff
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 2 · LOW 1.
- evidence: Morfo declares scope: ['soma', 'sema'] but eidos directory exists at src/uix/eidos/components/carousel/ with full recipe and visual components
- impact: Eidos concerns (visual/size/variant/color/block attrs) are implemented but not declared in morfo scope, creating scope ambiguity
- verify: [confirmed] Confirmed at src/uix/morfo/components/carousel.ts:7 — `scope: ['soma', 'sema'],` with NO 'eidos', while a full eidos implementation exists: src/uix/eidos/components/carousel/carousel.css is 10912 bytes plus types.ts, context, and 8 visual .svelte wrappers (carousel-item.svelte, carousel-viewport.svelte, etc.). This is exactly the SYS-1 scope-drift pattern: eidos materializes visual/size/variant attrs the morfo scope doesn't declare. Severity MEDIUM is correct (matches baseline). Note this is broadly systemic on this branch — calendar.ts:7, color-picker.ts:7, combobox.ts:7, command.ts:7, dialog.ts:18 and others all declare ['soma','sema'] yet ship eidos dirs — so the fix should likely be a sweep, but the carousel finding stands on its own evidence.
- fix-status: fixed (212624e0)
### MEDIUM: SYS-3 (jsdom-only test of interaction-heavy path) — carousel-102 <!-- id: carousel-102 -->
- dimension: F
- rule: SYS-3 (jsdom-only test of interaction-heavy path)
- evidence: `// @vitest-environment jsdom`. The suite covers trigger/keyboard nav, autoplay timer + pause states, viewport-geometry props and timer disposal (line 370), but the DRAG path is entirely untested: finishDrag() (provider lines 389-415) computes fraction/velocity/fastSwipe + RTL-mirrored direction, and the ItemGroup drag $effect (lines 529-540) writes dragOffset from gesture.offsetX/Y. None of fraction-threshold, fastSwipe (velocity>0.5), or the `isRtlHorizontal` mirror (line 411-412) is exercised.
- impact: Swipe-to-advance (the carousel's primary touch interaction) and its RTL direction-flip have zero regression coverage; a sign or threshold regression in finishDrag would ship silently.
- proposed-fix: Add a unit test that drives the gesture via the provider's finishDrag()/dragOffset surface with synthetic offset+velocity across LTR/RTL and vertical axes; assert the resulting next()/prev() and dragOffset reset.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: open
### LOW: A10 (getItems must filter nested via .closest === root) — carousel-101 <!-- id: carousel-101 -->
- dimension: B
- rule: A10 (getItems must filter nested via .closest === root)
- evidence: getItems() returns `Array.from(group.querySelectorAll<HTMLElement>(`[${attrs.item}]`))` with no `.closest('[data-carousel]') === group` filter. A carousel nested inside another carousel's slide would have its inner `[data-carousel-item]` elements counted by the OUTER provider's querySelectorAll, inflating `slideCount` (line 175) and corrupting the outer carousel's clamp/loop math (commit() uses slideCount at line 266).
- impact: Only manifests when carousels are nested (rare). When it happens, the outer carousel mis-counts slides → wrong lastSnap clamp, wrong canGoNext, broken End-key target. No crash, no leak.
- repro: Render a Carousel whose slide contains another Carousel; observe outer provider.slideCount includes inner slides.
- proposed-fix: Filter to direct items: `Array.from(group.querySelectorAll('[data-carousel-item]')).filter(el => el.closest('[data-carousel-item-group]') === group)` — mirrors the A10 pattern used elsewhere.
- verify: [verifier-added] added by adversarial verify pass
- covers: Navigation by trigger (next/prev), keyboard (Arrow keys, Home, End), and indicator click; Loop wrapping math (lastSnap calculation, modulo wrapping for loop mode, clamping for non-loop); Disabled state guard (keyboard/click/programmatic blocked); Autoplay lifecycle via uix.timers.interval with proper disposal on effect cleanup; Pause/resume on hover/focus state transitions; Viewport/ItemGroup/Item/Indicator props projection and data-state/data-index attrs; User interaction stops autoplay when stopOnInteraction=true; Indicator multi-click and aria-selected toggle
- untested: Virtualization edge cases (very large slide count with multiple slidesPerView); RTL keyboard navigation and transform direction mirroring with dragging; Drag gesture offset tracking and finishDrag snap-point logic; ResizeObserver responsiveness to viewport resize/orientation flip; Vertical orientation layout and indicator group flex direction
## Style observations (non-blocking)
- CSS recipe uses canonical 9-role color system with -solid/-element variants (primary, secondary, affirm, risk, threat, fulfill, loss, neutral). All spacing via --space-* and radius via --radius-*. Opacity uses --opacity-disabled. Z-index: 1 is local-relative within carousel subtree (triggers), acceptable per audit rules. Indicator size sizes with var(--_carousel-indicator-size) cascaded from --space-* + explicit px multiples in data-size breakpoints.
- Vertical orientation layout correctly flips root flex-direction to row with viewport taking flex: 1 1 auto and indicator-group as narrow flex: 0 0 auto column on right, matching common pattern in Mantine/ark-ui.
- evidence: `stroke-dasharray: 3 3` @ line 93. This is a visual pattern (dashed line) for the crosshair overlay, not a component dimension or theme token.
- impact: Physical constant for visual pattern. Does not break theming. Local to chart component only.
- proposed-fix: No fix needed. Per baseline: '3.5px @keyframes amplitude' kind of literal is NOT drift; `3 3` dasharray pattern falls into same category.
- verify: [confirmed] chart.css:93 — `stroke-dasharray: 3 3` on [data-chart-crosshair] is an SVG dash-pattern visual constant for the dashed crosshair overlay, scoped to the chart component. Same exception class as the @keyframes amplitude literals (not a dimension/spacing/color where a canonical scale applies). Correctly LOW; not drift.
method: adversarially-verified workflow; HIGH lead-verified. B6 ground-truth: checkbox/toggle/switch commit-toggle = sequence post (lag-fixed); radio-group/tabs/accordion/stepper set state at call-site (pre OK); toggle-group + checkbox-group carry the A31 .includes pattern (SYS-7); slider has no gesture-layer A6 leak.
sequence-audit: State set in handler: applyPending() at line 134 called by runtime event handler (lines 68-69). Morfo sequence='post' (checkbox.ts:28, 43). VERIFIED: state change deferred until AFTER sema dispatch via runtime.trigger() → event handler → applyPending(). Lag risk MITIGATED. Prior 244ms lag from 'pre'
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 1.
## Findings
### MEDIUM: A31: per-item $derived calling provider method reading global state (.includes) = O(N²) — checkbox-001 <!-- id: checkbox-001 -->
- dimension: A31-O(N²)
- rule: A31: per-item $derived calling provider method reading global state (.includes) = O(N²)
- evidence: CheckboxGroupProvider.isItemChecked(value: string): boolean { return this.opts.value.current.includes(value); } and updateItemChecked also calls .includes(value). Each checkbox item in a group calls isItemChecked() to sync its state (line 104), resulting in N calls to .includes() on the array, yielding O(N²) when rendering N checkboxes in a group.
- impact: For large checkbox groups (50+ items), observable performance degradation during render as each item calls isItemChecked() which iterates the entire value array. Not immediately critical for typical forms but documented risk.
- proposed-fix: Convert group value from string[] to a Set (or use Map if needed for fast lookup). Migrate isItemChecked to Set.has() (O(1)). Update updateItemChecked to use Set.add/delete. Ensure Set is wrapped as $state(new SvelteSet()) for reactivity.
- verify: [unverified]
- fix-status: open
### LOW: Comments in English must match the actual contract; stale doc referencing wrong sequence i — checkbox-002 <!-- id: checkbox-002 -->
- dimension: A-doc-drift
- rule: Comments in English must match the actual contract; stale doc referencing wrong sequence is drift
- evidence: The `_pendingChecked` JSDoc states: "The two-step dance lets sema fire BEFORE the structural commit (canon `sequence: 'pre'`)." But checkbox.ts:28 and :43 both declare `sequence: 'post'` (with an explicit morfo comment: "'pre' made the functional check wait for the perceptual hold (~240ms) → laggy"). The provider comment is stale: it cites 'pre' as the canon while the morfo deliberately uses 'post'.
- impact: Misleading documentation. A future maintainer reading the provider could re-introduce the 244ms-lag 'pre' regression believing it is canon. No runtime effect — the morfo is correct.
- proposed-fix: Update the comment to reference `sequence: 'post'` and explain the handler-runs-after-sema ordering, consistent with checkbox.ts:25-28.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: open
## No-findings dimensions
Contract(morfo): checkboxMorfo is 'as const satisfies Morfo' (line 172). All 6 parts registered via runtime.part() exist in morfo: 'provider', 'indicator', 'hidden-input', 'group', 'group-label' (lines 72, 193, 238, 304, 353). 2-of-3 rule satisfied., data-{c}/-{part} naming: All data attributes use kebab-cased part names (data-state, data-disabled, data-readonly, data-invalid on provider; data-state on indicator; data-orientation on group). No data-soma-* violations., aria/data emitted: morfo declares aria-checked, aria-required, aria-readonly, aria-invalid, aria-hidden on provider/indicator/hidden-input. Provider also declares type='button'. All values reference morfo specs correctly., Scope includes eidos: Morfo declares scope=['soma','sema','eidos'] (line 7). No eidos imports in soma provider (verified: no 'eidos' in grep output), which is correct—eidos layer is optional consumer. SYS-1 scope-drift REFUTED: eidos scope is declared but not imported into soma, which is the correct pattern., Events: Both commit-toggle-check and commit-toggle-uncheck have sequence='post' (lines 28, 43). No inert events (all emit to sema)., Gesture/A6: No gesture, setTimeout, setInterval, ResizeObserver, or MutationObserver in checkbox provider. No cleanup risk., Hidden input A13: CheckboxHiddenInputProvider renders <inputtype='checkbox'> with name/value for form participation (lines 249-255). aria-hidden='true' keeps it off AT tree so button role='checkbox' is sole control. tabindex=-1 excludes from focus. Correct A13 pattern., A30 direct registration verified: CheckboxGroupLabelProvider.setLabelId() called in constructor (line 362), not in $effect. Prevents reactive loop., Theme tokens: All CSS custom properties reference --checkbox-* or --font-*/--space-*/--color-*/--opacity-* canonical tokens from recipes/base.ts. No hex literals, no magic numbers in CSS (px/rem only in recipe: lines 639-653). Token naming --{c}-{slot} followed (--checkbox-size-md-box-size, --checkbox-bg-off, etc.). Private TSC tokens use _checkbox-palette-* pattern (lines 8-10, 162)., Color role subset: CheckboxColor = AffirmativeColorRole (types.ts:42), declared as primary|secondary|neutral|affirm (no risk/threat/loss). Per spec: invalid state handled via data-invalid attribute, not color role., Variants: CheckboxVariant = SelectionVariant (types.ts:30: solid|outline|ghost). CSS implements all three (lines 83-126). No undeclared variants., Sync attrs: runtimePart registered with syncAttrs=true (lines 77, 200, 242, 309, 356). Props returned via $derived.by for each part (lines 167-174, 208-212, 246-257, 331-335, 365-367). No double-write risk., A14 roving: Not applicable—checkbox is not a roving component (single input, not a group with tabindex navigation)., A12 RTL: Not applicable—checkbox has no directional keys (no slider, tabs, radio-group)., A15 gesture: Not applicable—checkbox does not use Gesture layer (no slider/splitter/scroll-area patterns)., Sequence lag FIXED: Prior 244ms lag incident from 'pre' sequence is RESOLVED by current 'post' design. State change in applyPending() handler defers until after sema dispatch.
- evidence: `scope: ['soma', 'sema'],` — yet src/uix/eidos/components/clipboard/ exists with clipboard.css, clipboard.svelte, clipboard-trigger.svelte, clipboard-indicator.svelte and types.ts. The eidos layer materializes the `data-clipboard` / `data-clipboard-indicator` recipe selectors.
- impact: The morfo's declared scope no longer reflects reality: an eidos consumer exists but the contract says only soma+sema. Scope is the declared surface; drift means tooling/audits that read `scope` will under-count the eidos layer.
- proposed-fix: Add 'eidos' to the scope array: `scope: ['soma', 'sema', 'eidos']` (cf. button.ts:47 which declares it). Or — if the project decides hand-authored thin consumer wrappers do NOT count as an eidos recipe — codify that rule, since toggle/switch/collapsible omit it too. This is the confirmed-systemic SYS-1; clipboard matches the majority that omit it.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: fixed (212624e0)
### LOW: Recipe spacing MUST reference --space-* tokens; raw rem literals are drift — CLIP-2 <!-- id: CLIP-2 -->
- dimension: E-bis
- rule: Recipe spacing MUST reference --space-* tokens; raw rem literals are drift
- evidence: `padding: 0.125rem 0.5rem;` — raw rem literals, while the SAME rule uses `gap: var(--space-1);` one line above (line 33). 0.5rem == --space-2 in the project scale.
- impact: The indicator chip's inset bypasses the spacing scale, so density/scaling theme changes (`:root` --space-* overrides) won't reach it. Minor visual-only drift.
- proposed-fix: Replace with token-derived values, e.g. `padding: var(--space-0-5, 0.125rem) var(--space-2);` (confirm the exact step names against primitives/static.ts).
- verify: [verifier-added] added by adversarial verify pass
- fix-status: open
### LOW: Recipe font-weight should reference the bare token; hardcoded numeric fallback diverges fr — CLIP-3 <!-- id: CLIP-3 -->
- dimension: E-bis
- rule: Recipe font-weight should reference the bare token; hardcoded numeric fallback diverges from convention
- evidence: `font-weight: var(--font-weight-medium, 500);` — the `500` literal fallback. Every recipe occurrence in lib/recipes/base.ts (lines 48, 153, 231, 314, 449) uses bare `var(--font-weight-medium)` with no fallback.
- impact: Cosmetic inconsistency; the `500` fallback masks a missing token rather than failing visibly, and diverges from the recipe convention.
- proposed-fix: Drop the fallback: `font-weight: var(--font-weight-medium);` to match base.ts usage.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: open
## No-findings dimensions
A: Contract - Morfo 'as const satisfies' correct, A: Parts registered via runtime.part(), A: Data attributes (data-copied) declared in morfo, A: ARIA attributes correct (aria-label, aria-live, aria-hidden), A30: No per-item loops or sequence lags detected, A31: No O(N²) derived patterns detected, A33: No Map/Set state issues detected, A35: No per-item effect loops detected, A36: No async-mediated microtask freeze patterns detected, A6: Timer disposal via resetTimer with clearResetTimer() cleanup and $effect return, A15: No gesture/drag-drop patterns needed for clipboard, A34: Live regions - Trigger has aria-live polite for label announcement, B: Provider pattern correct - soma imports only soma core, no eidos imports, D: Frontier - No soma->eidos imports, E-bis: No recipe token or theming issues (uses canonical color roles), F: Tests exist - jsdom environment, happy path, error handling, timer mocks
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: copy success and copied state reset via timer; onCopy callback invocation; onError callback on clipboard API rejection; timer scheduling with uix.timers.schedule; aria-label resolution and swap on copied state; indicator visibility conditional on copied state; mergedProps application
- untested:
## Style observations (non-blocking)
- CSS uses canonical color-role variables (--color-affirm-solid, etc.) with cascading support via data-color attribute
- evidence: padding-inline: 0.32em; padding-block: 0.1em (lines 30-31, also variants at 36-37)
- impact: Magic em literals; no canonical padding-em scale for pill chrome
- proposed-fix: Define --code-padding-inline and --code-padding-block tokens or inherit from mark
- verify: [downgraded] code.css:30-31,36-37 'padding-inline: 0.32em; padding-block: 0.1em' are em-relative paddings for an inline-flow pill, intentionally outside the absolute --space-* scale so the pill scales with surrounding prose font-size (the purpose of inline <code>). No canonical em-based padding scale exists. Legitimate physically-relative literal for inline chrome, not drift against an existing scale. Downgrad
method: adversarially-verified workflow; HIGH lead-verified. B6 ground-truth: checkbox/toggle/switch commit-toggle = sequence post (lag-fixed); radio-group/tabs/accordion/stepper set state at call-site (pre OK); toggle-group + checkbox-group carry the A31 .includes pattern (SYS-7); slider has no gesture-layer A6 leak.
sequence-audit: expand: post-sequence ✓ (state set in handler lines 68-70, morfo sequence:post at line 39). collapse: pre-sequence ✓ (state set in handler lines 71-73, morfo sequence:pre at line 48). Both events fire via runtime.trigger() in toggle() which defers handler execution. No lag risk: handler state-settin
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 1 · LOW 0.
- evidence: morfo declares scope: ['soma', 'sema'] but src/uix/eidos/components/collapsible/ directory exists with full implementation (collapsible.svelte, collapsible.css, index.ts). Accordion (sibling pattern) correctly declares scope: ['soma', 'sema', 'eidos']
- impact: Scope declaration diverges from actual architecture; contract validators may not catch eidos-layer changes. Documentation of morfo scope is stale.
- proposed-fix: Update collapsibleMorfo scope to ['soma', 'sema', 'eidos'] to match accordion pattern and actual directory structure
- verify: [confirmed] CONFIRMED. morfo declares `scope: ['soma', 'sema']` at src/uix/morfo/components/collapsible.ts:22, but src/uix/eidos/components/collapsible/ exists with a full implementation (collapsible.svelte, collapsible.css, collapsible-trigger.svelte, collapsible-content.svelte, types.ts, index.ts, README.md — verified via glob). The sibling disclosure pattern accordion correctly declares `scope: ['soma', 'sema', 'eidos']` at src/uix/morfo/components/accordion.ts:7. This is SYS-1 scope-drift, MEDIUM. Severity unchanged.
- fix-status: fixed (212624e0)
### HIGH: SEQUENCE-LAG — `collapse` is `sequence:'pre'` but sets state in the handler (the Checkbox-244ms-lag mechanism) — collapsible-NEW-001 <!-- id: collapsible-NEW-001 -->
- dimension: Behavior(soma)
- rule: SEQUENCE / TWO-MOMENTS — a control whose functional state is set INSIDE the runtime.trigger HANDLER must use morfo `sequence: 'post'`; with 'pre' the perceptual hold (~240ms) blocks the state change (documented Checkbox 244ms-lag incident).
- evidence: The `collapse` event sets the functional state INSIDE the trigger handler: provider lines 71-73 `collapse: () => { this.opts.open.current = false; }`. The morfo declares `sequence: 'pre'` at collapsible.ts:48. For 'pre', the runtime serializes the perceptual emit BEFORE the handler: runtime.svelte.ts:749-752 `else { await runEmit(); if (handler) await handler(); }`. `runEmit` awaits `sources.eventEngine.emit` (runtime.svelte.ts:696 `await sources.eventEngine.emit(...)`), whose engine awaits the visual channel hold (engine.ts:250 `await visualChannel.handle(enriched, effective)`), which does `await sleep(holdMs)` (visual.ts:79-81). collapsible declares no per-event `hold` and the sema pack (sema/components/collapsible.ts) declares none, so `emerge` falls back to label `brief` (visual.ts:43) = 240ms (durations.ts:27). Result: the content does NOT receive `hidden` until ~240ms after the click — the exact magnitude of the documented Checkbox 244ms-lag. (The `expand` event is correctly `post` at collapsible.ts:39, so opening has no lag — only closing lags.) This refutes the prompt's sequenceAudit line which marked `collapse: pre-sequence ✓`.
- impact: User clicks the trigger to collapse an open section; the panel stays fully visible for ~240ms before hiding — a perceptible dead delay on every close, identical in mechanism and magnitude to the Checkbox 244ms-lag that the project already documented and fixed by flipping to `sequence:'post'`. The morfo's inline justification (collapsible.ts:10-13, 'collapse remains pre-sequence so the exit signal can play while the content is still visible') is based on a false concurrency assumption: the runtime does NOT overlap emit and handler for 'pre' — it serializes emit-then-handler, so the content is held visible for the FULL hold, then hidden, rather than the exit sound playing during a CSS exit transition.
- repro: Open a Collapsible (expand — instant), then click the trigger to collapse. Measure the delay between click and `hidden`/`data-state='closed'` taking effect on the content. Expect ~240ms (the emerge `brief` hold) rather than near-zero. The expand direction does not exhibit the delay.
- proposed-fix: Apply the documented Checkbox/Toggle/Switch doctrine: change `collapse` to `sequence: 'post'` (collapsible.ts:48) so the handler flips `open=false` first and the exit signal then acknowledges the resolved state, matching how accordion's symmetric close event is handled (accordion close is post per accordion.ts pattern). If a visible-during-exit cue is genuinely wanted, drive it from eidos CSS keyed on `data-state='closed'` + a CSS exit transition rather than blocking the functional state behind the sema hold. Alternatively set state at the call-site in `toggle()` (collapsible-provider.svelte.ts:85-89) before `runtime.trigger`, which would make 'pre' tolerable (the RadioGroup/Tabs pattern).
- verify: [verifier-added → LEAD-CONFIRMED HIGH] The analyze agent (and the morfo's own inline comment) marked this CLEAN, assuming `'pre'` overlaps emit+handler so the exit plays during the hold. The verifier traced the actual runtime and proved it SERIALIZES (`runtime.svelte.ts:749-752: await runEmit(); await handler()`), so `open=false` is delayed the full ~240ms `brief` emerge hold. I initially LEANED toward MEDIUM (reasoning "collapse is emerge.dismiss 'pre', the canonical animate-before-hide pattern like dialog/popover"), but that masking only holds for components that animate on `data-event`/Presence DURING the hold (dialog does); collapsible's exit is keyed on `data-state='closed'`, which doesn't flip until AFTER the hold — so the 240ms is a real dead delay, not a filled exit animation. Restored to HIGH. Caveat: like the checkbox fix, the user-perceptible magnitude should be confirmed by a frame-by-frame browser measurement before/after; the MECHANISM (and the refuted morfo justification) are confirmed in code.
- fix-status: fixed (db33f66c)
## No-findings dimensions
Behavior(soma) [except the sequence-lag above], DOM-selector, Frontier, TSC, Theming, Tests
## Theming facts (E-bis)
- magic z-index: none
- magic literals: collapsible.css:4,19 translateY(-4px) — LOCAL animation amplitude in @keyframes, acceptable
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: vitest jsdom
- covers: ID linking via partRef; expand/collapse events + state toggle; disabled guard + disabled attr projection
- verify: [confirmed] Confirmed SYS-1 scope-drift. morfo/components/color-field.ts:7 `scope: ['soma', 'sema'],` omits 'eidos'; eidos dir src/uix/eidos/components/color-field/ exists with real color-field.css (10039 bytes) + recipe. MEDIUM correct. Systemic across field family (css/date/number/password/search/time-field all do the same).
- evidence: Test suite covers: segment fills (lines 100-123), keyboard hex input (125-155), format switching (157-188), wiring (190-257). Missing: readonly-segment-without-value warning assertion (defined in soma at lines 215-233) and edge cases like backspace, Home/End navigation, readonly interaction.
- impact: A24 warning logic is unexercised; regressions in readonlySegments guard logic could slip through. readonly-segment test is a HIGH-RISK gap per audit rules.
- proposed-fix: Add test case: 'warns when readonlySegments set without value' — assert soma.logger.warn is called with expected message. Add test for backspace on first segment (moveToPrev).
- verify: [confirmed] Confirmed. Provider warning at color-field-provider.svelte.ts:215-233 (spam-guarded via lastWarnedKey, calls soma.logger.warn for readonlySegments set + value undefined) is unexercised: test only mocks `warn: vi.fn()` (line 46) and seeds empty `readonlySegments` (line 76). No assertion, no non-empty+undefined case. A24 high-risk path missing test = MEDIUM.
- fix-status: open
### MEDIUM: A30 — inputId registered via `$effect` instead of direct constructor assignment (SYSTEMIC: date/time/color-field) <!-- id: color-field-A30 -->
- dimension: B
- rule: A30 (child→parent id registration MUST be a direct constructor assignment, not `$effect`)
- location: color-field-provider.svelte.ts:521-525 (`$effect(() => { const field = this.provider.field; if (!field) return; field.inputId.current = opts.id.current; })`)
- evidence: lead-confirmed by direct read — identical `$effect` shape to date-field (932-936) and time-field (531-535); the reference NumberField uses a direct guard (number-field:585-587).
- impact: no live loop (write-only + stable id), but contrary to A30 doctrine + the NumberField reference. See SYS-A30-EFFECT in SUMMARY.
- proposed-fix: replace with a direct constructor assignment matching NumberField; apply across date/time/color-field.
- verify: [lead-added] the color-field agent listed "Behavior" as a no-findings dimension, which masked the `$effect` id-wiring; lead read of color-field:521-525 confirms it. Systemic MEDIUM.
- fix-status: open
## No-findings dimensions
Contract, Behavior (except A30 id-wiring — see color-field-A30), DOM-selector, TSC
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
- label-font (one step below input?): CLEAN: calc(1em - (var(--font-size-md) - var(--font-size-sm))) at src/uix/eidos/components/color-field/color-field.css:106 correctly implements label one typographic step below input size (md → sm = 2px)
## Tests (F)
- exists: true · env: jsdom
- covers: segment-commit: RGB fill sequencing (lines 100-123); keyboard: hex segment digit input with aria spinbutton (125-155); format-select: switching formats + allowedFormats narrowing + locked state (157-188); hidden-input: form field name/value wiring + required attribute (190-257); label-focus: label click routes focus to first segment (252-253)
- untested: readonly-segment-without-value warning (A24 guard); backspace navigation edge case (moveToPrevSegment on null segment); Home/End key boundary behaviors (first-item / last-item); readonly segments interaction with keyboard input; Arrow key wrapping (cycle vs no-cycle per channel config)
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified by the lead. Batch-3 ground-truth: each picker fires trigger(close) (close NOT inert), but open/commit-reset ARE inert; calendar/range-calendar are MID-REFACTOR (uncommitted view-switch work).
composition (A27): Composes (A27): Popover + ColorField + Slider via shared writableActive refs. ChannelSlider composes SliderProvider underneath with channel-aware bridging (gradient + setChannel). ColorField parts (Input/Segment/FormatSelect) re-exported as ChannelInput/ChannelSegment/FormatSelect with data-color-picker identity attrs overlaid. Shared state wiring correct: sharedValue, sharedFormat, sharedOpen passed to both ColorPickerProvider and PopoverProvider/ColorFieldProvider. Content part re-exported from Popover (registers on Popover runtime, not picker runtime).
- evidence: colorPickerMorfo declares scope: ['soma', 'sema'] but eidos directory exists at src/uix/eidos/components/color-picker/ containing recipe CSS and multiple eidos component wrappers (.svelte files)
- impact: Morfo scope contract is incomplete. Downstream tooling expecting strict scope adherence will find undeclared layer.
- proposed-fix: Update morfo scope to ['soma', 'sema', 'eidos']
- verify: [confirmed] Confirmed SYS-1 scope-drift. color-picker.ts:7 reads `scope: ['soma', 'sema'],` yet a full eidos implementation exists at src/uix/eidos/components/color-picker/ (color-picker.css recipe + 25+ .svelte wrappers per Glob). `'eidos'` IS a valid Layer (src/uix/types.ts:17 `export type Layer = 'soma' | 'sema' | 'eidos';`), and morfo `scope: readonly Layer[]` (types.ts:799). date-picker.ts:13 has the identical `scope: ['soma', 'sema']`, so this is the documented systemic SYS-1 across the picker family. MEDIUM is correct.
- evidence: Morfo declares name:'open' event with target=partRef('content') and sequence:'pre'. No runtime.trigger('open',...) call found in provider. Comment on close event (line 38-39) documents pattern: provider sets opts.open=false and lets popover unmount, never fires the event explicitly.
- impact: Consumer code declaring open event handlers will not receive them. The event exists only to satisfy schema validators. This is documented for pickers (known pattern like date-picker/time-picker) but represents a contract mismatch.
- proposed-fix: Document in morfo comment that 'open' event is not fired (inert, exists for contract symmetry); or fire it on popover open (requires Popover composition to surface trigger point)
- verify: [confirmed] Confirmed inert event. Morfo declares `name: 'open'` (color-picker.ts:14-32) with sequence 'pre' targeting content; grep for `trigger('open'` across src/uix/soma/components/color-picker returns NO matches. The provider only ever fires 'close' (via triggerClose, lines 349-375), 'handle-pick'/'handle-drag'/'commit-set'. The morfo's own comment (lines 10-13 'They drive Footer...' and 38-41 'today the provider just sets opts.open = false and lets the popover unmount') documents the known picker-family inert pattern. 'commit-reset' is also inert (never triggered) — candidate only flags 'open', which is in-scope and accurate. MEDIUM matches the audit's 'MEDIUM at most' for inert events.
- fix-status: open
### LOW: Magic z-index literals not in STATIC_Z_INDEX scale — color-picker-002 <!-- id: color-picker-002 -->
- dimension: E-bis
- rule: Magic z-index literals not in STATIC_Z_INDEX scale
- evidence: z-index: 2 on [data-color-picker-area-thumb]:367, z-index: 1 on [data-color-picker-swatch-indicator]:557. Project STATIC_Z_INDEX scale: base=0, raised=1, sticky=100, dropdown=300, popover=400, tooltip=500, modal=700, toast=900. These values (1,2) accidentally collide with low scale but are not intentionally using that scale.
- impact: Fragile stacking context: undocumented magic literals break auditing of depth relationships. If future components add z-index:3 the priority breaks.
- proposed-fix: Use var(--z-index-raised) or var(--z-index-sticky) from canonical scale instead of bare integers
- verify: [downgraded] Literals confirmed present — color-picker.css:367 `z-index: 2;` (area-thumb) and :557 `z-index: 1;` (swatch-indicator). But the HIGH severity and the SYS-2 mapping are wrong. SYS-2 targets bare 60-99 integers that collide with the depth-PLANE scale (--z-index-dropdown=300/popover=400/modal=700/etc). These values form a self-contained LOCAL sibling stack within the picker subtree: grep shows the file uses only -1 (background layers, :433/:402/:412), 1 (indicator over swatch), 2 (thumb over area-background). The canonical STATIC_Z_INDEX scale is for cross-component depth planes, not intra-component layering; substituting `var(--z-index-sticky)` (=100) as the proposed fix would be semantically wrong for a thumb-over-its-own-background. No real stacking-context fragility (no other component reaches into this subtree's z-context). Downgrade to LOW (cosmetic/token-tidiness at most); not a SYS-2 magic-z violation.
- fix-status: open
### LOW: Test environment jsdom with interaction-heavy keyboard — color-picker-004 <!-- id: color-picker-004 -->
- dimension: F
- rule: Test environment jsdom with interaction-heavy keyboard
- evidence: @vitest-environment jsdom. Tests cover: ColorPickerAreaThumbProvider.onkeydown (ArrowRight at line 227) and keyboard route logic (HOME/END/PAGE_UP/PAGE_DOWN at lines 905-915 in provider). No Playwright/browser test confirming area keyboard in real DOM.
- impact: Area thumb keyboard handling (Arrow x4 + Home/End + PageUp/PageDown) is interaction-heavy and DOM-geometry-dependent. jsdom may not accurately simulate getBoundingClientRect or pointer geometry used in handlePointerMove / calculateChannelValue.
- proposed-fix: Add browser-level test (Playwright) for area keyboard + pointer drag to verify geometry math in real DOM
- verify: [downgraded] Confirmed jsdom env (test file line 1 `// @vitest-environment jsdom`) and the provider has interaction-heavy area logic. This is the SYS-3 baseline (jsdom-only / kbd-untested) so a real F-gap exists. BUT the candidate's core rationale is partly wrong: the area-thumb keyboard route (onkeydown, provider lines 882-931) is PURE arithmetic over getChannelRange/getColorChannel — it never reads getBoundingClientRect, so it IS faithfully exercisable in jsdom (test does exercise ArrowRight et al.). Only the POINTER path (handlePointerMove, lines 764-777, consumes DOMRect) is geometry-dependent and genuinely under-covered in jsdom. So the gap is narrower (pointer-drag geometry, not keyboard) than the finding states. Downgrade to LOW per SYS-3.
- fix-status: open
## No-findings dimensions
C, D
## Theming facts (E-bis)
- magic z-index: z-index: 2 | z-index: 1
- magic literals: 16px on area-thumb-size | 11rem on area-height | 1.25rem/1.5rem/1.75rem on swatch sizes
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: channel value preservation; area thumb pointer move + keyboard; area channel override; setChannel + commitChange; swatch select + closeOnSelect
- untested: area thumb keyboard routes (Home/End/PageUp/PageDown) in browser; pointer drag geometry in real DOM; popover open/close animations; dismissal outside; eyedropper API integration
## Style observations (non-blocking)
- color-picker.css uses CSS containment (contain: layout style) on area to prevent sibling layout shifts during drag
- Transparency checker pattern re-used via CSS vars across multiple elements
- Area overlays (saturation/brightness) use pseudo-elements to avoid conflicts with inline background
- Swatch group uses flex 1 1 0 + aspect-ratio to shrink/grow equally while staying square
Combobox is well-built in several respects: the A33 `labelRegistry` clone-reassign incident is fixed (now
`SvelteMap`), dimension C is clean (`CSS.escape` on the highlighted-id query + the shared `resolveListItemEl`
helper escapes `data-value`), dismissal excludes both trigger and input (A22), and the inputValue auto-sync /
mode-reset effects are loop-safe (`untrack` + guards). The headline defect is a **focus-strategy violation
(A17)**: the morfo declares `aria-activedescendant` (virtual focus) but the provider moves **real DOM focus**
onto items (`dom.focus(items[0])`), mixing the two strategies and breaking the APG combobox contract (focus
must stay on the textbox). Plus a `translationRef` misuse, the systemic A31 + magic-z-index, and a jsdom-only
test gap right where the focus bug lives.
Counts: CRITICAL 0 · HIGH 2 · MEDIUM 3 · LOW 0.
## Findings
### HIGH: Mixes virtual focus (`aria-activedescendant`) with real DOM focus (`.focus()` on items) — A17 violation + breaks APG combobox <!-- id: combobox-001 -->
- dimension: B, A
- rule: A17 ("Never mix [virtual + DOM focus] in the same component. If the trigger has aria-activedescendant,
items must NOT call .focus()") + APG Combobox pattern (focus stays on the textbox) + morfo↔code drift
- evidence: scope: ['soma', 'sema'], — but eidos/components/command/ directory exists with CSS and components. Morfo declares scope omitting 'eidos'
- impact: Component has eidos visuals and recipes but scope doesn't declare it; scope drift breaks the four-layer contract and may cause issues with validation or tooling that expect declared scopes
- proposed-fix: Add 'eidos' to the scope array: scope: ['soma', 'sema', 'eidos']
- verify: [downgraded] morfo line 7 reads `scope: ['soma', 'sema'],` and an eidos/components/command/ dir with CSS exists. But this is NOT command-specific drift: I counted 63 stateful morfos (combobox, calendar, date-picker, color-picker, dialog, select, popover, listbox, etc.) that ALL omit 'eidos' from scope while shipping an eidos recipe dir. The `scope` field semantics in src/uix/morfo/types.ts:770-839 govern SEMA/event coherence ('If a morfo declares events[] AND scope:['sema']...'), not eidos-recipe presence — the visual layer is not gated by the scope array. No contract test asserts scope must include 'eidos' when CSS exists. Treating this as a HIGH per-component contract violation contradicts the framework's own baseline (63/64 components do it). At most a systemic LOW doc/convention observation; the stated HIGH severity and 'breaks the four-layer contract' impact are not supportable.
- fix-status: fixed (212624e0)
### LOW: Magic literal — em font sizes — command-003 <!-- id: command-003 -->
- evidence: inline-size: 1.125em (line 260-261) and font-size: 0.875em (line 272) are bare em values without canonical token references
- impact: Icon and shortcut sizing use hard-coded em multipliers instead of --icon-size-* or --font-size-* tokens; inconsistent with theming system
- proposed-fix: Replace 1.125em with a canonical icon size scale reference (e.g., --icon-size-md or create --command-item-icon-size). Replace 0.875em with a font-size scale reference or scale modifier
- verify: [downgraded] Confirmed the literals: command.css:260-261 `inline-size: 1.125em; block-size: 1.125em;` (icon box) and 272 `font-size: 0.875em;` (shortcut). However em-based slot sizing is an established, idiomatic codebase pattern — grep across eidos shows field-control-trigger.css:30/45 `1.75em`/`1em`, menu-dial.css:281-282 `1em`, password-field.css:293/297-298 `1em`, table.css:339-340 `1em`, fab.css:103 `1em`. The `em` unit deliberately scales with the local `--_command-item-font-size` cascade (which IS token-driven), so these are RELATIVE sizing, not fixed px/rem drift the E-bis rule targets. At most a LOW consistency nit; MEDIUM overstates it given the framework-wide convention.
- impact: Three structural heights use magic pixels instead of token references; brittle to design changes
- proposed-fix: Create or reference --border-width for 1px lines (already canonical for most components). For 2px loading bar, create --command-loading-height or use a custom CSS variable scoped to command
- verify: [downgraded] Confirmed: command.css:153 `block-size: 2px;` (loading bar), 219 `gap: 1px;` (group items), 296 `block-size: 1px;` (separator). The 1px values are hairline-divider widths idiomatic across the codebase (separator/border lines); 2px is an off-scale loading-bar height. These are genuine bare-pixel literals but they are structural hairlines, not tokens that carry theming intent — LOW severity at most, not MEDIUM. No user-visible or behavioral impact.
- evidence: Item part declares archetype: 'item' but does not declare Icon or Shortcut as sub-parts in the morfo
- impact: Eidos defines Item.Icon and Item.Shortcut sub-parts (in eidos/components/command/index.ts) with their own data-* selectors, but they are not declared in the morfo. They are eidos-only sub-parts and properly documented in types.ts, so this is intentional (not an error)
- proposed-fix: N/A — this is a documented eidos extension pattern
- verify: [confirmed] Confirmed non-finding by the candidate's own admission. morfo declares Item (lines 97-124) with archetype 'item' and data-value/data-selected/data-disabled, but Icon/Shortcut are eidos-only sub-parts (index.ts:49-62 attaches ItemIcon/ItemShortcut; command.css:255/265 selectors documented as 'eidos-only' sub-part). This is the documented eidos-extension pattern, explicitly proposedFix 'N/A'. Correctly emitted as LOW with no action.
- fix-status: open
### LOW: Test environment — jsdom only — command-007 <!-- id: command-007 -->
- evidence: // @vitest-environment jsdom — test suite uses jsdom, which cannot test keyboard input, focus, or DOM pointer events accurately
- impact: Keyboard navigation (ArrowDown/Up/Home/End/Enter/vim bindings) is complex behavioral code but only tested in jsdom (which doesn't fire real keyboard/mouse events). Focus logic untested.
- proposed-fix: Add a client/Playwright test suite for keyboard navigation (arrow keys, grid columns, loop wrapping, Home/End), pointer selection, and focus sync. See SYS-3 pattern.
- verify: [confirmed] Confirmed and arguably under-severed. test file line 1 `// @vitest-environment jsdom`; whole file is 122 lines with only 2 `it` blocks (line 77 filters+auto-select, line 96 navigates via provider.next/prev/selectCurrent API). No `dispatchEvent`/`KeyboardEvent`/`focus(` anywhere — the real onkeydown route in CommandInputProvider.onkeydown (lines 452-508: vim ctrl+n/p/j/k, Home/End, grid `columns`, RTL `getDirectionalKeys`) is NEVER exercised through a key event, only the underlying API methods are. This is a clean SYS-3 hit (interaction-heavy + jsdom-only + keyboard route untested). LOW is defensible but MEDIUM would be equally justified per the SYS-3 rubric.
- evidence: const child = el.firstElementChild as HTMLElement | null; — assumes the viewport ref's first child is the measurable element
- impact: If a consumer passes a custom child to Viewport (via the child snippet), firstElementChild may fail or measure the wrong element
- proposed-fix: Add a fallback measurement: measure the viewport itself if firstElementChild is not present, or document this as a contract requirement (viewport must wrap content in a single element)
- verify: [confirmed] Confirmed at LOW. provider line 598 `const child = el.firstElementChild as HTMLElement | null;` then observes it for resize. command-viewport.svelte renders `{@render children?.()}` with NO wrapper element, so firstElementChild measures whatever the consumer passes — fragile if the consumer renders text or multiple top-level nodes. Real robustness nit, but Viewport is `optional: true` in the morfo (line 93) and rarely used (List composes ScrollArea instead), and there is a `if (!child) return;` guard so it fails safe (no crash, just no height var). Correctly LOW; not behavioral/a11y.
- fix-status: open
## No-findings dimensions
B, D, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: command-003 (em sizes) | command-004 (px heights)
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: filter/visibility; navigation (basic); disabled items; group registration
- impact: 80rem is a hard-coded container breakpoint, not sourced from canonical token in recipe.
- proposed-fix: Low impact—foundation token --layout-container-width-xl exists; fallback is defensive but non-canonical.
- verify: [downgraded] base.ts:4061 `'width-xl': 'var(--layout-container-width-xl, 80rem)'`. The 80rem is a defensive fallback for the LAYOUT foundation token --layout-container-width-xl (confirmed present at generated/base.css:3342), not an eidos --space-*/--radius- scale token. Recipe comment documents the xl cap. Defensive fallback, not canonical-scale drift.
- fix-status: open
## Theming facts (E-bis)
- magic literals: 80rem @ src/uix/eidos/lib/recipes/base.ts:4061 -> should reference --container-width-xl
- evidence: The context-menu recipe block (lines 4303-4322) does NOT declare 'content-z' token, unlike dropdown-menu which declares 'content-z': '80' at the equivalent location. Context-menu content floats with z-index: auto, risking paint-through by positioned page elements.
- impact: Missing z-index token is a known systemic SYS-2 issue. Without explicit z-index, the portaled content panel may render behind elements with lower z-index that have explicit positioning (e.g. a toggle-group-item at z-index 1).
- proposed-fix: Add 'content-z': '80' to the context-menu recipe block at base.ts:4304 (right after the opening brace), matching the dropdown-menu z-index for consistency since both are floating overlay menus.
- verify: [confirmed] CONFIRMED real SYS-2 divergence. base.ts context-menu block (4303-4322) has NO 'content-z' token; the sibling dropdown-menu block declares `'content-z': '80'` (4279) with a comment: 'Without this the portaled panel inherits z-index: auto and any positioned page element with a positive z-index (e.g. a selected [data-toggle-group-item], z-index 1) paints THROUGH it.' context-menu.css panel rule (22-34) has NO `z-index` declaration, whereas dropdown-menu.css (34) sets `z-index: var(--dropdown-menu-content-z)` and notes 'The soma floating layer reads this computed z-index and mirrors it onto the positioner wrapper' (31-32). context-menu is an identically-portaled cursor-anchored overlay, so the same paint-through risk applies and is unmitigated. MEDIUM is correct.
- evidence: Test file header: '@vitest-environment jsdom'. All keyboard tests (lines 191-281, 283-336) run in jsdom, which does not simulate real DOM focus or keyboard event routing like a browser environment.
- impact: Keyboard routing (onkeydown handlers, arrow nav, Home/End, Escape) and focus-sync behavior (lines 334-371 in provider, lines 1021-1063 in SubContent) are tested in jsdom but should also be verified in a client environment (Playwright). Focus management edge cases (focus return on close, focus trap behavior) are HIGH-RISK for regressions.
- proposed-fix: Add a client/Playwright test file covering: (1) focus enters at first item on open, (2) arrow keys navigate correctly with loop=true/false, (3) focus returns to trigger on Escape/outside-click close, (4) submenu focus scope is isolated (arrow-left exits submenu, arrow-right enters), (5) Home/End jump correctly when items are disabled.
- verify: [confirmed] CONFIRMED SYS-3. Test header line 1 is `// @vitest-environment jsdom`. The test bodies (it() at 130/170/191/229/283/338) cover: open-from-contextmenu, getItems scoping, item activation via click + Space(216), checkbox/radio selection + Enter(275), submenu open via hover/click/ArrowRight(331), group/separator props. CRITICALLY UNTESTED: the Content onkeydown arrow-navigation index math (provider 334-371) — the loop modulo-vs-clamp logic at 348-355, Home/End at 356-361 — plus focus-return-to-trigger on close and Escape-close. The highest-risk keyboard route (directional index math) is exercised on zero paths, and runs jsdom-only with no client/Playwright companion. Matches the 'complex behavior tested only on its easy path (selection/open-close) while keyboard/focus untested' SYS-3 profile. MEDIUM correct.
- evidence: RadioGroup part (kebab: 'radio-group', archetype: 'group') declares role='group' and data/aria as empty arrays (lines 174-176). No data attributes are bound. Only soma registers it at line 675; sema does not select it; eidos does not consume it.
- impact: RadioGroup part violates 2-of-3: used by soma (runtime.part registration) but not by sema or eidos. It is a structural grouping container with no perceptual or visual binding. This is acceptable if it is purely structural, but it should be marked optional or have a docstring clarifying its role.
- proposed-fix: RadioGroup is intentionally structural-only (holding radio items that self-manage checked state via the RadioItem logic). This is a design choice, not a bug. Document in morfo: /* Structural container; individual RadioItems manage state and selection. Eidos/sema do not target this part directly. */
- verify: [confirmed] Confirmed as the candidate itself frames it (confidence:low, proposedFix concedes 'This is a design choice, not a bug'). Morfo RadioGroup (168-177): role='group', empty data:[]/aria:[]. Provider registers it (675) for context publication (ContextMenuRadioGroupProvider.ctx) so RadioItems can read the group value. Grep of context-menu.css finds NO `data-context-menu-radio-group` selector (eidos uses `display: contents` only implicitly — actually no rule targets it at all), and the sema pack does not select it. So it is soma-only on the 2-of-3 axis, but legitimately: it is a pure structural/context container with no perceptual or visual surface, role='group' carries the a11y semantics declaratively, and RadioItems self-manage checked state. Acceptable structural exception, not a defect. LOW doc-nit at most.
- evidence: Trigger outline on data-state='open' hardcodes '1px dashed' border: 'outline: 1px dashed var(--color-border-default);'. Outline width and style are magic literals without token backing.
- impact: The outline appearance cannot scale with theme or design system constraints. No way to override or scale the 1px width globally.
- proposed-fix: Create recipe tokens 'trigger-focus-outline-width': '1px' and 'trigger-focus-outline-style': 'dashed', then reference them: 'outline: var(--context-menu-trigger-focus-outline-width, 1px) var(--context-menu-trigger-focus-outline-style, dashed) ...'.
- verify: [downgraded] Partially valid kernel, over-stated. context-menu.css:171 `outline: 1px dashed var(--color-border-default)`. The bare `1px` IS a minor drift: every sibling component uses `var(--border-width)`/`var(--border-width-medium)` for border/outline widths (announce.css:29, combobox.css:96, dialog.css:28, listbox.css:41, etc.); this line is the only bare `1px` outline-width across the eidos components grep. So `1px`->`var(--border-width)` is a legitimate tokenization. BUT the candidate's proposed custom tokens for `dashed` over-reach — outline-style is a discrete keyword with no scale to reference. `--color-border-default` is a valid role token. Impact is minimal: this is a purely decorative courtesy outline on the otherwise-inert trigger when open (per the CSS comment 156-164). LOW magic-literal nit, not MEDIUM.
- undeclared parts: context-menu-001: 'sub' part declared in morfo but never registered via runtime.part() in provider
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom only (vitest-environment jsdom at line 1)
- covers: contextmenu trigger event and anchor point capture (lines 130-168); item scoping to current content, excluding nested submenus (lines 170-189); item activation by click and keyboard (lines 191-227); checkbox and radio state toggling (lines 229-281); submenu open via pointer hover with 100ms delay (lines 283-336); group heading and separator accessibility attributes (lines 338-365)
- untested: Arrow key navigation in content (loops, clamping when loop=false); Home/End key jump to first/last item; Escape key close menu and focus return to trigger; Tab key escape from menu (trap=false expected); Typeahead character matching; Submenu arrow-key navigation (left/right to close/open submenu); Focus sync when items are disabled (skip to next focusable); SafePolygon hover exit behavior; Dismissal on outside-click and interact-outside events; Focus trap behavior (when trapFocus=true)
## Style observations (non-blocking)
- Context-menu recipe tokens closely mirror dropdown-menu except for missing 'content-z' and arrow size tokens, which suggests the component was copied but not fully reconciled for floating layer z-index and icon sizing patterns.
- The trigger outline affordance (dashed outline on open) is a nice UX cue but lacks a way to disable or customize the width/style globally.
cleanup-audit (A6/A35/A36): Timers: soma.uix.timers.schedule(null, 120, ...) at line 302 — disposed by runtime ✓. Listeners: All pointer events (onpointerdown/move/up in Viewport/Selection/Handle) use setPointerCapture + releasePointerCapture in same handler; no separate cleanup needed. Image load handler in eidos cropper.svel
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 2 · LOW 1.
- evidence: Morfo declares scope: ['soma', 'sema'] but src/uix/eidos/components/cropper/ exists with CSS and SVG component
- impact: Scope mismatch signals incomplete contract declaration; framework tooling may not validate eidos layer against morfo
- proposed-fix: Update morfo scope to ['soma', 'sema', 'eidos']
- verify: [confirmed] Confirmed SYS-1 scope-drift. morfo cropper.ts:20 `scope: ['soma', 'sema']` omits 'eidos', yet the eidos layer exists: dir src/uix/eidos/components/cropper/ (cropper.css, cropper.svelte, types.ts) AND a recipe `cropper:` at src/uix/eidos/lib/recipes/base.ts:1152. Matches the established systemic baseline (sibling image-picker.ts:20 and image-adjustments.ts:18 carry the same omission). MEDIUM stands.
- fix-status: fixed (212624e0)
### MEDIUM: SYS-3: Missing test coverage for high-risk paths (gesture, async, timers) — cropper-003 <!-- id: cropper-003 -->
- dimension: F
- rule: SYS-3: Missing test coverage for high-risk paths (gesture, async, timers)
- evidence: Tests cover geometry (moveRect, resizeRect) and ZoomPan, but no coverage for: gesture handlers (onpointerdown/move/up in Viewport/Selection/Handle), cropImage() async function, zoom throttle timer, or fixed-size $effect. Test env is jsdom.
- impact: Critical business logic (drag/resize/zoom interactions, canvas extraction, timer cleanup) is untested and may regress without detection
- evidence: $effect creates new Image element and assigns img.onload handler (line 52) but never cleans up. On src change, new Image created; old handler may fire if response arrives after effect re-run.
- impact: Old onload handlers can fire after component re-runs the effect, causing stale handler invocations and potential memory pressure from accumulated handlers
- proposed-fix: Return a cleanup function from the $effect that clears the handler, or store img in state and reuse it with cleanup: `return () => { img.onload = null; img.src = ''; }`
- verify: [downgraded] Downgraded from MEDIUM/A6 to LOW. The $effect at cropper.svelte:44-58 creates a local `const img = new ImageCtor()` (line 51), sets one-shot `img.onload` (line 52), `img.src = s` (line 57), with no cleanup return. This is NOT an A6 resource leak: the Image is never inserted into the DOM, the handler is one-shot, and the element is GC-eligible once the local goes out of scope — there is no persistent listener/observer/timer to dispose. The genuine (minor) defect is a stale-async-write race: if `src` changes and an OLD image's load resolves AFTER the new one, the old onload writes `aspectRatio` (line 54) to a stale ratio. It is NOT an A35/A36 loop — the effect reads `src`, not `aspectRatio`, so no tracked read-back, no freeze. Impact: transient wrong aspect-ratio on rapid src swaps. The provider's `loadImage` (cropper-provider.svelte.ts:96-106) avoids this by awaiting a Promise with no reactive write. LOW.
- evidence: scope: ['soma', 'sema'], — eidos/components/css-field/ exists with css-field.svelte, css-field-input.svelte, etc., but scope array does not include 'eidos'
- impact: Contract validator cannot check that eidos wrappers only import from morfo PARTS, risking cross-layer coupling over time as the component evolves
- verify: [confirmed] Confirmed as a SYS-1 scope-drift instance. `css-field.ts:7` = `scope: ['soma', 'sema']` while an eidos dir exists with a wrapper + css (`eidos/components/css-field/css-field.svelte` imports `./css-field.css`, plus `types.ts`/`index.ts`). MEDIUM is correct per the SYS-1 catalog. Caveat for the orchestrator: this is SYSTEMIC across the whole field family, not a css-field anomaly — the project's REFERENCE baseline NumberField is identical (`number-field.ts:7` = `scope: ['soma', 'sema']` with its own eidos dir), as are time-field/color-field/date-field (all `scope: ['soma', 'sema']`). Note also css-field has no dedicated recipe: its eidos css (`css-field.css`) declares 'CssField has no visual of its own: it IS a spin-field' and styling comes from the shared `spin-field` recipe (`base.ts:1016`) via structural identity. The scope-validator gap still stands regardless.
- label-font (one step below input?): COMPLIANT: Field label font-size is derived as calc(var(--_field-control-font-size) - (var(--font-size-md) - var(--font-size-sm))) per field.css, which is one typographic step below the input. CssField uses shared spin-field visual, which inherits --_field-control-font-size from the recipe (e.g. field-control-font-size-md: var(--font-size-md) = 16px), making the label 14px (one step down). ✓
## Tests (F)
- exists: true · env: jsdom
- covers: commit on blur; increment/decrement with per-unit steps; bare number default unit; disallowed unit rejection (untilFix); keyword acceptance; clamping to min/max; sium schema delegation; scrubber scrubbing with unit preservation; spinbutton aria props
- untested: readonly-without-value warning; onbeforeinput paste/IME rejection; clearTarget(input) actually clears the warn signal; RTL scrubber mirroring; A30 inputId registration with parent Field
## Style observations (non-blocking)
- Default step-by-unit logic (rem/em: 0.1, else 1) is sensible and matches the UI pattern for CSS editing
- Scrubber sensitivity (5px per step default) is well-tuned for precise CSS values
- Bare-number regex parsing (line 39) handles leading dot correctly
- Comments throughout provider are clear and precise (Book §6.2 reference, untilFix signal flow)
field-family (A13/A24-26/A30): A13: form-participating hidden input correctly implemented (lines 1011-1040), renders with name/value/required/disabled when name is set. A24: readonly-without-value warning implemented via soma.logger.warn (lines 355-370), guards against misconfiguration when readonlySegments set without value. A26: onbeforeinput preventDefault required for segmented contenteditable — CORRECT implementation at li
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 2 · LOW 0.
- evidence: Morfo declares scope: ['soma', 'sema'] (src/uix/morfo/components/date-field.ts line 7) but eidos recipe dir src/uix/eidos/components/date-field/ and CSS src/uix/eidos/components/date-field/date-field.css both exist. Scope MUST include 'eidos' when the recipe and CSS layer are present.
- impact: Contract validator may not catch visual-layer rules as part of the component's declared surface area.
- proposed-fix: Update morfo scope from ['soma', 'sema'] to ['soma', 'sema', 'eidos']
- verify: [confirmed] CONFIRMED at MEDIUM. morfo `src/uix/morfo/components/date-field.ts:7` declares `scope: ['soma', 'sema']` (grep verified), while the eidos layer exists: recipe block `'date-field':` at `src/uix/eidos/lib/recipes/base.ts:1172` and CSS `src/uix/eidos/components/date-field/date-field.css` (read in full, 228 lines). This matches the documented systemic SYS-1 scope-drift (MEDIUM). NOTE: the project's stated REFERENCE component NumberField has the IDENTICAL condition — number-field.ts:7 also declares `scope: ['soma', 'sema']` yet `src/uix/eidos/components/number-field/` exists — so this is a true systemic pattern for the field family, not a date-field-specific defect. Severity stays MEDIUM per SYS-1; confidence high that the condition holds, with the caveat that whether 'eidos' is REQUIRED in scope for these components is a project-wide convention question.
- fix-status: fixed (212624e0)
### MEDIUM: Test coverage gap (field-family paths) — date-field-003 <!-- id: date-field-003 -->
- evidence: Test suite covers: segment helpers (120-165), validation (174-198), segment commit (200-220), time-segment sync (222-240), granularity filtering (242-274). MISSING: (1) hidden-input render when name is set; (2) readonly-without-value warning fired; (3) commit-set/commit-reset event emission; (4) keyboard navigation per APG (ArrowUp/Down/Left/Right/Backspace); (5) Field parent integration (inputId wiring).
- impact: High-risk field-family paths untested: form submission via hidden input, readonly misconfiguration guards, and event-driven workflows.
- proposed-fix: Add test cases for: shouldRender hidden input, soma.logger.warn call when readonlySegments + undefined value, commit event firing on segment completion, keyboard segment navigation (at least one key per segment type), and Field parent inputId registration.
- verify: [confirmed] CONFIRMED at MEDIUM (F dimension — high-risk field-family test gap). The test file `date-field-provider.svelte.test.ts` (read in full, 275 lines) covers ONLY: segment helpers (initSegmentStates/isAcceptableSegmentKey/getFirstSegment/handleSegmentNavigation, 120-165), validation min/max/custom (174-198), incremental segment commit (200-220), padded date+time sync (222-240), granularity filtering (242-274). UNTESTED high-risk paths that DO exist in the provider: (1) hidden-input render — `DateFieldHiddenInputProvider.shouldRender` (provider line 1022) and its A13 ISO `value`/`name`/`required` props (1027-1040) are never exercised; (2) readonly-without-value A24 warn — the `$effect` calling `soma.logger.warn` (provider 354-369) is never triggered, even though the harness already stubs `logger.warn: vi.fn()` (test line 72), so the assertion would be trivial; (3) commit event emission — `runtime.trigger('commit-set'|'commit-reset')` (provider line 785) untested; (4) keyboard segment navigation per APG (ArrowUp/Down/Backspace/number-key handlers, provider 1108-1296) untested at provider level; (5) Field parent inputId wiring (DateFieldInputProvider $effect, provider 932-936) untested. Severity MEDIUM (missing tests for high-risk paths); confidence high that these paths are real and uncovered.
- fix-status: open
### MEDIUM: A30 — inputId registered via `$effect` instead of direct constructor assignment (SYSTEMIC: date/time/color-field) <!-- id: date-field-A30 -->
- dimension: B
- rule: A30 (child→parent id registration MUST be a direct constructor assignment, not `$effect`)
- location: date-field-provider.svelte.ts:932-936 (`$effect(() => { const field = this.provider.field; if (!field) return; field.inputId.current = opts.id.current; })`)
- evidence: lead-confirmed by direct read — the DateFieldInput provider wraps the `field.inputId` registration in a `$effect`, whereas the project reference NumberField does it as a direct constructor guard (number-field-provider.svelte.ts:585-587: `if (this.provider.field) { this.provider.field.inputId.current = opts.id.current; }`). time-field (531-535) and color-field (521-525) use the identical `$effect` — SYSTEMIC.
- impact: no loop today (the Input provider only WRITES `field.inputId` and never reads it back, and `opts.id` is a stable framework id), so the A30 page-freeze hazard is not live — but it is contrary to A30 doctrine and inconsistent with the NumberField reference; a future reader who makes the registration two-way (or reads inputId in the child) would reintroduce the freeze.
- proposed-fix: replace the `$effect` with a direct constructor assignment guarded by `if (this.provider.field)`, matching NumberField. Apply across date-field, time-field, color-field.
- verify: [lead-added] the date-field agent's no-findings claim falsely listed "A30 direct id-wiring" as clean; the verify on time-field-001 + lead reads of date-field:932-936 / color-field:521-525 confirm the `$effect` pattern. Elevated to a systemic MEDIUM.
- fix-status: open
## No-findings dimensions
A (Contract morfo), B (keyboard, A17 focus, A33, A35, A6 cleanup, TWO-MOMENTS — note: A30 id-wiring is NOT clean, see date-field-A30), C (DOM-selector safety), D (Frontier soma→eidos), G (Redundancy)
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified by the lead. Batch-3 ground-truth: each picker fires trigger(close) (close NOT inert), but open/commit-reset ARE inert; calendar/range-calendar are MID-REFACTOR (uncommitted view-switch work).
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 4 · LOW 0.
systemic hits: SYS-1 (scope-drift: morfo scope ['soma', 'sema'] but eidos dir exists).
composition (A27): COMPOSES: DatePicker correctly composes via shared writableActive refs. date-picker.svelte creates sharedOpen, sharedValue, sharedPlaceholder, and passes them to PopoverProvider.create() and DateFieldProvider.create() (lines 167-194). Trigger wraps PopoverTriggerProvider. Calendar wraps CalendarProvider with filtered isDateDisabled to enforce readonlySegments coherence. No re-implementation detected; all composed parts (Popover, DateField, Calendar) are referenced via shared refs or forwarded exports.",
<parametername="styleObservations">["data-kind is stamped on the Provider element (line 197) to drive visual view switching; this is morfo-declared and visually clean. CSS uses cascading :has() selectors (line 138-145) for responsive popover width overrides when hosting calendar/grid, avoiding hardcoded breakpoints.", "Disabled state uses opacity var(--date-field-disabled-opacity) (line 103), consistent with field theming. Role/aria attrs are delegated to composed Calendar part (dialog role, aria-label) and not duplicated in Provider.", "No bare literals for spacing, sizing, z-index, or opacity; all theme tokens properly referenced."]
## Findings
### MEDIUM: SYS-1 scope-drift: an eidos recipe dir exists but morfo 'scope' omits 'eidos' — date-picker-001 <!-- id: date-picker-001 -->
- dimension: A: Contract (morfo)
- rule: SYS-1 scope-drift: an eidos recipe dir exists but morfo 'scope' omits 'eidos'
- evidence: scope: ['soma', 'sema'], but src/uix/eidos/components/date-picker/ directory exists with date-picker.css, *.svelte files, and types.ts
- impact: Morfo contract mismatches the actual layer composition. Eidos recipes exist but scope doesn't declare them, risking validator confusion and runtime assumptions about which layers are active.
- proposed-fix: Change morfo scope to ['soma', 'sema', 'eidos'] to match the actual directory structure and eidos recipe presence.
- verify: [confirmed] Confirmed as SYS-1 scope-drift. date-picker.ts:13 reads `scope: ['soma', 'sema'],` yet `src/uix/eidos/components/date-picker/` exists with date-picker.css (6115 bytes), date-picker.svelte, types.ts, and several *.svelte wrappers. This is systemic across the whole picker family: color-picker, date-range-picker, time-picker, time-range-picker, calendar all read `scope: ['soma', 'sema']` while their eidos dirs exist; only range-calendar declares eidos. MEDIUM is the correct severity per the SYS-1 baseline — it is a contract/metadata mismatch, not a runtime defect (createAttrs/morfo are unaffected since the morfo is `as const satisfies Morfo` at line 230).
- fix-status: fixed (212624e0)
### MEDIUM: INERT EVENTS: morfo declares 'open' event but provider never fires it via runtime.trigger( — date-picker-002 <!-- id: date-picker-002 -->
- rule: INERT EVENTS: morfo declares 'open' event but provider never fires it via runtime.trigger()
- location: src/uix/morfo/components/date-picker.ts:22-40 (event declaration) vs src/uix/soma/components/date-picker/date-picker-provider.svelte.ts (no 'open' trigger)
- evidence: Morfo declares: {name: 'open', semantic: {family: 'emerge', verb: 'open', target: v.partRef('calendar'), sequence: 'pre', ...}}. Provider only fires 'close' via triggerClose() at lines 222, 232, 268; no runtime.trigger('open', ...) call exists.
- impact: The 'open' event exists only to satisfy schema validation. Consumers cannot listen to it because the provider never emits it. This is inconsistent with the morfo contract which signals the event should fire.
- proposed-fix: Either (a) fire runtime.trigger('open', ...) when the popover opens (handleDateSelect path or in a watch on opts.open), or (b) remove the 'open' event declaration from morfo if it is not needed per the design.
- verify: [confirmed] Confirmed but DOWNGRADE-CAPPED to MEDIUM (it was already MEDIUM). The `open` event (morfo lines 23-41) is genuinely inert: grep for `runtime.trigger` in the provider returns ONLY line 268 `runtime.trigger('close', ...)` (fired from triggerClose at 210/222/232). There is no `runtime.trigger('open', ...)` anywhere. NOTE the candidate's evidence is partly stale — it claimed line numbers 222/232/268 fire 'close', which is correct, and it correctly identified `open` as never fired. The morfo's own comment (lines 42-49) explicitly anticipates this: 'Today's date-picker provider just toggles opts.open; if it ever explicitly calls runtime.trigger(close...)'. Matches the documented picker-family inert-events pattern. MEDIUM is the ceiling per baseline.
- fix-status: open
### MEDIUM: Test coverage is jsdom-only and does not exercise high-risk paths: keyboard navigation, fo — date-picker-003 <!-- id: date-picker-003 -->
- dimension: F: Tests
- rule: Test coverage is jsdom-only and does not exercise high-risk paths: keyboard navigation, focus management, modal vs inline mode dismissal, date validation, cancel/commit/clear actions.
- location: src/uix/soma/components/date-picker/date-picker-provider.svelte.test.ts:1 (@vitest-environment jsdom) and lines 111-174
- evidence: Tests are marked jsdom-only (@vitest-environment jsdom). The test suite only covers: (1) part registration, (2) state flags, (3) closeOnDateSelect flag. Missing: keyboard Escape/Tab/Enter/Space, grid navigation (arrow keys), focus trap, modal mode behavior, cancel() reverting value, commit() triggering close event, clear() resetting to undefined, two-moments sequence (data-last-action before data-state).
- impact: Risk of undiscovered bugs in interactions-heavy paths (dismissal, modal locking, date-specific validation edge cases) that jsdom doesn't simulate. Keyboard routes in calendar grids, focus roving, and dismissal semantics are untested.
- proposed-fix: Add browser-level (Playwright/client) tests covering: (a) Escape closes popover + triggers 'close' event, (b) Tab focus-traps in modal mode, (c) modal mode prevents outside-click dismissal, (d) cancel() reverts value to snapshot, (e) commit() and clear() fire correct close cause, (f) data-last-action writes before data-state flips.
- verify: [confirmed] Confirmed as SYS-3 jsdom-only / kbd-untested. Test file line 1 is `// @vitest-environment jsdom`. The suite (lines 111-174) has exactly 3 `it()` blocks: provider part registration, root state flags, and the closeOnDateSelect toggle via handleDateSelect. No coverage of: Escape/Tab/Enter/Space keyboard, calendar grid arrow navigation, focus trap (morfo declares focus.trap:true, focus.return:'trigger'), cancel() value revert, commit()/clear() close-cause, or the two-moments data-last-action-before-data-state ordering in triggerClose (266-268). These are the high-risk interaction paths. MEDIUM is correct (not HIGH) — it is a missing-coverage observation on interaction-heavy paths, consistent with the SYS-3 baseline severity.
- fix-status: open
### MEDIUM: INERT EVENTS: morfo declares 'commit-reset' event but provider never fires it via runtime. — date-picker-N01 <!-- id: date-picker-N01 -->
- rule: INERT EVENTS: morfo declares 'commit-reset' event but provider never fires it via runtime.trigger()
- location: src/uix/morfo/components/date-picker.ts:66-76 (commit-reset declaration) vs src/uix/soma/components/date-picker/date-picker-provider.svelte.ts:240-242 (clear())
- evidence: Morfo declares a third event `{ name: 'commit-reset', semantic: { family: 'commit', verb: 'reset', target: v.partRef('calendar'), sequence: 'post', intent: 'neutral' } }` whose intent (per its comment line 67) is 'User cleared the date (explicit clear button or backspace).' The provider's `clear()` method does exactly that — `this.opts.value.current = undefined;` — but does NOT call `runtime.trigger('commit-reset', ...)`. Grep of the provider for runtime.trigger returns only the 'close' call at line 268. So commit-reset is a second inert event the candidate auditor missed (it only flagged 'open').
- impact: A 'commit' family event with a real perceptual intent (clearing the value) never reaches the sema engine — clearing the date is perceptually silent even though the morfo says it should emit. Consumers wiring telemetry/sound to commit-reset receive nothing. Same INERT-EVENT category as the 'open' finding; MEDIUM ceiling per the picker-family baseline.
- repro: Open date-picker, select a date, press the Clear footer action: opts.value resets to undefined but no commit-reset signal is emitted to sema.
- proposed-fix: In clear() fire `runtime.trigger('commit-reset', { fallbackTarget: this.runtime.partRef('calendar') ?? undefined })` after setting value undefined (sequence:'post' is already declared, so the state write precedes the emit), OR remove the commit-reset event from the morfo if a silent clear is intended.
- verify: [verifier-added] added by adversarial verify pass
- conformance: CLEAN: data-* attrs follow [data-{component}] and [data-{component}-{part}] naming; eidos recipe imports only composed component CSS (date-field, calendar, month-grid, year-grid), not isolated primitives; role attributes and aria-* attrs match morfo declarations in Provider/Trigger/Calendar parts; no double-write issues detected (syncAttrs not used); token references are canonical (--color-{role}-*, --space-*, --date-field-*)
## Tests (F)
- exists: true · env: jsdom
- covers: provider part registration; state flags (data-state, data-disabled/readonly/required/invalid); closeOnDateSelect flag toggle
- untested: keyboard routes (Escape, Tab, Enter, Space, arrow keys for grid nav); focus trap and focus restoration; modal mode (prevents outside-click, requires explicit commit/cancel); dismissal cause tracking (data-last-action write order); cancel() value revert snapshot; commit() and clear() event emission; date validation edge cases (minValue, maxValue, custom validate); two-moments: data-last-action write before data-state flip; locale/dir resolution; readonly segment filtering in calendar; readonly segment coherence with navigation
field-family (A13/A24-26/A30): A13: NOT APPLICABLE - no hidden-input parts. A24: MISSING warning for readonly+undefined. A25: IMPLEMENTED split segments, test coverage gap. A26: INHERITED from DateField composition. A30: CLEAN - labelNode direct callback, inputId uses untrack()
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 2 · LOW 3.
## Findings
### MEDIUM: FIELD FONT RULE: field LABEL is ONE typographic step BELOW the input; input font is 1:1 wi — date-range-field-002 <!-- id: date-range-field-002 -->
- dimension: E-bis
- rule: FIELD FONT RULE: field LABEL is ONE typographic step BELOW the input; input font is 1:1 with type scale
- evidence: [data-date-range-field-label] { font-size: var(--date-field-label-font-size, var(--font-size-sm)); } hardcodes label to sm for all input sizes. Compare to DateField which uses calc(1em - (var(--font-size-md) - var(--font-size-sm))). Correct only at md size.
- impact: Label font size violates typographic hierarchy at sizes xs/sm/lg/xl
- proposed-fix: Use: font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm))); and ensure it inherits 1em from the input's dynamic --_date-field-font-size
- verify: [confirmed] Confirmed MEDIUM. date-range-field.css:51-55 sets `[data-date-range-field-label] { font-size: var(--date-field-label-font-size, var(--font-size-sm)); }` — fixed at `sm` for every input size, and notably no `font: inherit`. The reference DateField (date-field.css:104-111) does it correctly: `font: inherit;` then `font-size: calc(1em - (var(--font-size-md) - var(--font-size-sm)));` with the comment 'Label one typographic step below the input ... Scales.' So DateRangeField's label is correct only at md and violates the FIELD FONT RULE (label one step below input, scaling) at xs/sm/lg/xl. Note: systemic — time-range-field.css:52-56 has the identical hardcoded-sm bug.
- fix-status: open
### MEDIUM: Tests must cover high-risk paths including split readonly segments per endpoint (A25) — date-range-field-005 <!-- id: date-range-field-005 -->
- dimension: F
- rule: Tests must cover high-risk paths including split readonly segments per endpoint (A25)
- evidence: Test creates startReadonlySegments and endReadonlySegments but does not verify they work independently. No test case sets different readonly segments for start vs end.
- impact: Missing test coverage for A25 (split start/endReadonlySegments) — divergent readonly settings may not work correctly without verification
- proposed-fix: Add test case where startReadonlySegments=['month'] and endReadonlySegments=['day'] to verify each endpoint respects its own readonly configuration
- verify: [confirmed] Confirmed but lower confidence. The provider test (date-range-field-provider.svelte.test.ts:73-74) seeds startReadonlySegments/endReadonlySegments as empty `state<EditableSegmentPart[]>([])` and never exercises divergent start-vs-end values; no test asserts A25 split behavior. The split-readonly wiring actually lives in components/date-range-field-input.svelte:73-77 (start→startReadonlySegments, end→endReadonlySegments), which this provider-level test cannot reach anyway — so the coverage gap is real but the untested code is trivial, type-checked forwarding. Genuine F-gap on a path the rubric calls high-risk (A25 says a single symmetric readonlySegments is wrong), so MEDIUM stands; the missing assertion would belong to a component/DOM test, not this provider test.
- fix-status: open
### LOW: Parts registered via runtime.part('x') must exist in morfo & vice-versa. 2-of-3 rule. — date-range-field-001 <!-- id: date-range-field-001 -->
- dimension: A
- rule: Parts registered via runtime.part('x') must exist in morfo & vice-versa. 2-of-3 rule.
- evidence: Morfo declares 3 parts: Provider (line 20), Label (line 46), Input (line 59). Provider calls runtime.part('provider', ...) at line 110. Label calls runtime.part('label', ...) at line 192. Input part is NOT registered via runtime.part. DateField by contrast registers 'input' with runtime.part.
- impact: Input part attrs not enforced by runtime; contract violation breaks 2-of-3 semantic guarantee
- proposed-fix: DateRangeFieldInputProvider should call runtime.part('input', {...}) in its constructor to register the Input part, matching DateField's pattern
- verify: [downgraded] Downgraded HIGH→LOW. The claim that the morfo 'input' part is unregistered and 'breaks the 2-of-3 guarantee' is not supported. The part IS materialized: components/date-range-field-input.svelte:107-109 overlays `[dateRangeFieldAttrs.input]: ''` (from createAttrs(dateRangeFieldMorfo)) plus `'data-endpoint': type`, and role=group/aria come from the composed DateField input provider (date-field-provider.svelte.ts:913 runtime.part('input',...)). The 2-of-3 rule is satisfied — soma emits `data-date-range-field-input`+`data-endpoint`, eidos recipe targets them (date-range-field.css:60,65-71). Pattern is deliberate, documented (CSS header lines 12-16, README lines 72-74) and identical to TimeRangeField. No behavioral/a11y break; the part renders correctly. Real residue is only LOW: the DateRangeField-specific marker attrs are emitted via createAttrs rather than routed through runtime.part('input')/assert, so the DateRangeField runtime doesn't itself enforce that part's contract on the element — a consistency nit, not a contract violation. The cited line numbers in the evidence ('Provider line 110','Label line 192') are real, but the conclusion (Input part missing) is wrong because the part is emitted by the .svelte component, which the candidate did not read.
- fix-status: open
### LOW: Recipe font-size/icon-size tokens MUST reference --font-size-*/--icon-size-* (a literal px — date-range-field-003 <!-- id: date-range-field-003 -->
- dimension: E-bis
- rule: Recipe font-size/icon-size tokens MUST reference --font-size-*/--icon-size-* (a literal px/rem/em/% is drift)
- location: G:\dev\svelte\vicen\src\uix\eidos\components\date-range-field\date-range-field.css line 83
- evidence: font-size: 0.9em; /* literal: separator scales with the field font-size */
- proposed-fix: Extract to token --date-range-field-separator-font-scale: 0.9; in recipes/base.ts
- verify: [downgraded] Kept LOW but flagged as defensible/style. date-range-field.css:83 `font-size: 0.9em; /* literal: separator scales with the field font-size */` is a relative multiplier on a decorative ::before em-dash, not an absolute px/rem drift, and is explicitly commented as scaling with the inherited field font. There is no canonical token expressing '0.9× inherited size' (the --font-size-* scale is absolute steps, not multipliers), so tokenizing it as proposed would not improve coherence. Identical 0.9em appears in time-range-field.css:84. Borderline taste — not a meaningful magic-literal defect.
- verify: [confirmed] Confirmed LOW (kept). date-range-field.css:89 `opacity: var(--date-field-disabled-opacity, 0.55);`. The fallback `0.55` is DEAD: the date-field recipe defines `'disabled-opacity': 'var(--opacity-disabled)'` (base.ts:1222) → `--date-field-disabled-opacity` (= --opacity-disabled = 0.4, generated/base.css:296) is always defined on `[data-date-field]`, and the range root overlays `data-date-field=''` (date-range-field.svelte:44), so the token resolves and the literal never renders. So this is not a live magic-opacity number affecting rendering — it's a misleading dead fallback whose value (0.55) also disagrees with the token it shadows (0.4). LOW token-hygiene nit, not a rendering defect. Same dead `0.55` in time-range-field.css:90.
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified by the lead. Batch-3 ground-truth: each picker fires trigger(close) (close NOT inert), but open/commit-reset ARE inert; calendar/range-calendar are MID-REFACTOR (uncommitted view-switch work).
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 2 · LOW 0.
systemic hits: SYS-1: scope-drift (eidos recipe missing); SYS-5: no syncAttrs double-write detected (eidos properly wraps soma without forcing attr overrides).
composition (A27): Composes via A27: PopoverProvider and DateRangeFieldProvider bridged via SHARED writableActive refs (sharedOpen, sharedValue, sharedPlaceholder) in Provider component. Only root Provider, Trigger, and Calendar sub-parts are unique; Popover, Field, and RangeCalendar parts re-exported from composed primitives.
## Findings
### MEDIUM: SYS-1 SCOPE-DRIFT: morfo declares scope=['soma','sema'] but an eidos recipe directory exis — date-range-picker-001 <!-- id: date-range-picker-001 -->
- dimension: A: Contract
- rule: SYS-1 SCOPE-DRIFT: morfo declares scope=['soma','sema'] but an eidos recipe directory exists (25 files in src/uix/eidos/components/date-range-picker/) yet NO recipe entry exists in src/uix/eidos/lib/recipes/base.ts
- evidence: Morfo line 13: `scope: ['soma', 'sema'],` but src/uix/eidos/components/date-range-picker/ directory has 25 component files. Grep for 'date-range-picker' in base.ts returns 0 matches.
- impact: Eidos CSS file (date-range-picker.css) exists and is imported in eidos component (line 8) but no canonical recipe tokens/config define size/color/variant scales for this component; recipes are the contract bridge for eidos.
- proposed-fix: Add 'eidos' to morfo scope declaration: `scope: ['soma', 'sema', 'eidos']` and create the date-range-picker recipe block in src/uix/eidos/lib/recipes/base.ts with token definitions for size (xs/sm/md/lg), color (primary/secondary/neutral/affirm/fulfill/risk/threat/loss), and variant (surface/outline/ghost).
- verify: [confirmed] CONFIRMED as SYS-1 scope-drift (MEDIUM). morfo line 13 `scope: ['soma', 'sema']` omits 'eidos', yet src/uix/eidos/components/date-range-picker/ has 25 files incl. date-range-picker.css imported by the eidos component. Grep for 'date-range-picker' in lib/recipes/base.ts = 0 matches (confirmed). HOWEVER the candidate's proposedFix is partly WRONG and I downgrade its remedy claim: the eidos CSS does NOT need its own recipe block. I read date-range-picker.css (690 lines) — it owns NO `--date-range-picker-*` recipe tokens; it COMPOSES tokens from sibling recipes (`--date-field-height-md`, `--calendar-padding-md`, `--calendar-accent-solid`, lines 2-28) plus global scales (`--color-primary-*`, `--space-*` L391/419, `--radius-md` L589, `--font-size-md/sm` L595/621). This is the A27 picker-composition pattern: the picker composes Field+Calendar+Popover and legitimately has no recipe entry. Verified systemic across the family: date-picker.ts also has `scope: ['soma','sema']` and 0 recipe matches; time-range-picker same. So the ONLY real defect is the missing 'eidos' token in the scope array — not a missing recipe block. Severity MEDIUM (SYS-1) is correct; the fix is to add 'eidos' to scope, NOT to author recipe tokens.
- fix-status: fixed (212624e0)
### MEDIUM: INERT EVENTS: morfo declares 'commit-reset' event but provider NEVER fires it via runtime. — date-range-picker-002 <!-- id: date-range-picker-002 -->
- dimension: A: Contract
- rule: INERT EVENTS: morfo declares 'commit-reset' event but provider NEVER fires it via runtime.trigger()
- location: src/uix/morfo/components/date-range-picker.ts:65-72 (declaration) vs src/uix/soma/components/date-range-picker/date-range-picker-provider.svelte.ts:247-249 (clear() implementation)
- evidence: Morfo line 65-72 declares: `{ name: 'commit-reset', semantic: { family: 'commit', verb: 'reset', ... } }`. Provider clear() method (line 247-249) reads: `clear(): void { this.opts.value.current = { start: undefined, end: undefined }; }` with NO `runtime.trigger('commit-reset', ...)` call. Grep for 'commit-reset' in provider finds 0 trigger sites.
- impact: The event exists only to satisfy the schema validator. Consumers expecting `onCommitReset` callback or downstream event handlers observing 'commit-reset' will never fire, violating the 2-of-3 rule (morfo declares it; soma does not fire it).
- proposed-fix: Add `void this.runtime.trigger('commit-reset', { fallbackTarget: ... });` in the clear() method (line 248) BEFORE resetting the value, to match the pattern used in triggerClose() (line 270). Alternatively, remove the event declaration from morfo if it is not semantically needed.
- verify: [confirmed] CONFIRMED as the known picker-family inert-event pattern (MEDIUM cap, matches the prompt's explicit guidance). morfo lines 65-72 declare `{ name: 'commit-reset', semantic: { family: 'commit', verb: 'reset', target: v.partRef('calendar'), sequence: 'post', intent: 'neutral' } }`. Provider clear() at lines 247-249 reads exactly: `clear(): void { this.opts.value.current = { start: undefined, end: undefined }; }` — sets value, no trigger. Grep across the WHOLE component (provider + components/ + internals.ts) confirms the ONLY `runtime.trigger` call is `trigger('close', ...)` at line 270; zero `commit-reset` trigger sites anywhere. So the event is genuinely inert — it exists only to satisfy the schema validator, violating the 2-of-3 rule (morfo declares it; soma never fires it; sema/eidos read data-state not this event). Confidence high. MEDIUM is the correct severity per the INERT-EVENTS rule (MEDIUM at most). The candidate's proposedFix (add trigger in clear()) is a reasonable remedy, though note `data-last-action` is NOT among clear()'s causes since clear keeps the popover open — a commit-reset trigger would need its own non-close wiring.
- fix-status: open
## No-findings dimensions
B: Behavior (keyboard handlers on grid cells are soma-owned; no grid keyset declared in morfo per design), C: DOM-selector (no untrusted querySelector usage found), D: Frontier (soma provider has zero eidos imports; eidos wrapper normally imports soma - correct), E: TSC (no bare z-index, hex, or magic literals in CSS; all tokens reference --color-*, --space-*, --radius-*, --font-size-*, --icon-size-* scales), E-bis: Theming (token naming clean; roles from canonical 9-role set only; size/color/variant from EIDOS_VARIANTS)
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
- conformance: Morfo annotation is 'as const satisfies Morfo' (correct); all data-* attributes follow data-{component}-{part} or data-component-{nested} naming; aria-* attrs present for Trigger (aria-expanded, aria-haspopup, aria-controls, aria-label) and Calendar (aria-label); no double-write via syncAttrs observed in provider.
## Tests (F)
- exists: true · env: jsdom
- covers: provider part registration; state flag rendering (open/closed/disabled/readonly/required/invalid); closeOnRangeSelect behavior; range validation (single-day, minDays, maxDays, min/max value)
- untested: commit/cancel/clear actions via footer (not provider-level); dismissed event on Escape/outside click (PopoverProvider owns this); commit-reset event firing; modal vs inline mode end-to-end; range re-anchor (end month rightmost) layout verification; keyboard navigation on month/year grids
- evidence: text-underline-offset: 0.12em (different from text 0.15em)
- impact: Magic em literal; intentional hero differentiation but undocumented as canonical
- proposed-fix: Define --display-underline-offset or document hero-specific em scale
- verify: [downgraded] display.css:33 'text-underline-offset: 0.12em' is an em-relative underline offset for hero copy. The 0.12 vs 0.15 difference is a deliberate optical choice at hero sizes (smaller offset reads better on large type), documented in types.ts:20-27 re: hero tracking. No canonical --*-underline-offset token exists; em is the accepted unit (cf. link recipe base.ts:878). Cosmetic em literal, not drift aga
- evidence: Morfo declares scope: ['soma', 'sema'] (line 7) but eidos component directory exists at src/uix/eidos/components/drag-drop/ with drag-drop.svelte, drag-drop.css, and sub-components (draggable, droppable, preview). The recipe dir exists but morfo scope omits 'eidos'.
- impact: Framework scope validation may flag this as inconsistent; eidos layer is present but not declared in morfo scope.
- proposed-fix: Update morfo scope declaration to scope: ['soma', 'sema', 'eidos'] to match the actual component hierarchy.
- verify: [confirmed] CONFIRMED. morfo declares scope: ['soma', 'sema'] (drag-drop.ts:7) yet a full eidos implementation exists: recipe entry 'drag-drop': { 'preview-z': '99' } (recipes/base.ts:4259-4261), drag-drop.css, and 3 svelte wrappers (draggable/droppable/preview). The Morfo.scope doctrine (morfo/types.ts:798) reads 'Layers that implement this component' and 'eidos' is a valid Layer (schema.ts:49: union(literal('soma'), literal('sema'), literal('eidos'))). Omitting 'eidos' is genuine scope-drift = SYS-1 baseline MEDIUM. Note: many siblings (calendar/carousel/color-field/combobox/command) share the same drift, consistent with SYS-1 being systemic.
- evidence: DragPreviewProvider.style $derived.by inlines 'z-index': '9999' (lines 656, 664) as a hardcoded literal in the style object. The eidos CSS correctly uses var(--drag-drop-preview-z) (drag-drop.css:97) which resolves to 99 per generated base.css:3400, but the inline style hardcodes 9999 instead.
- impact: z-index value is 9999 (extremely high, above all canonical layers) instead of 99 (--drag-drop-preview-z). This violates the canonical --z-index-* scale and may unexpectedly layer above modals (--z-index-modal: 700) and toast (--z-index-toast: 900).
- proposed-fix: Replace hardcoded '9999' with 'var(--drag-drop-preview-z)' to consume the canonical token, e.g., 'z-index': 'var(--drag-drop-preview-z)'.
- verify: [downgraded] DIVERGENCE CONFIRMED, severity DOWNGRADED HIGH->MEDIUM. DragPreviewProvider.style $derived.by hardcodes 'z-index': '9999' on both branches (drag-drop-provider.svelte.ts:656 and :664). The CSS rule [data-drag-drop-preview] uses z-index: var(--drag-drop-preview-z) (drag-drop.css:97) which resolves to 99 (generated/base.css:3400). Because the provider sets z-index inline via props.style, inline precedence wins, so effective z is 9999 and the recipe token is inert. 9999 is off the canonical scale entirely (--z-index-base 0 .. --z-index-toast 900; generated/base.css:304-311) and exceeds modal (700) and toast (900). This is a magic-literal that overrides its own dedicated token (SYS-2 magic-z), NOT a syncAttrs double-write (no data-attr involved). Per rubric magic-literal == MEDIUM and SYS-2 is baselined MEDIUM, so HIGH is unwarranted.
- fix-status: open
### MEDIUM: Test coverage for high-risk paths — drag-drop-003 <!-- id: drag-drop-003 -->
- evidence: Test file contains 2 test cases: (1) keyboard drag start/navigation/commit, (2) drag prevention, accept filters, cancel, pointer preview. Missing explicit coverage for: pointer drag initiation and movement (setupPointerDrag), pending pointer-down state promotion (DraggableProvider.onpointerdown moveBuffer threshold), pointer listener cleanup on premature cancellation, and live-region disposal on component unmount.
- impact: Pointer drag logic and pending state lifecycle are not directly tested; test env is jsdom-only. A/B testing (live-region dual-region toggle) is not verified.
- proposed-fix: Add test case for pointer drag from pointerdown to pointermove threshold to active drag promotion; verify cleanup on pointercancel; add test for announcer timer cancellation on provider cleanup.
- verify: [confirmed] CONFIRMED. Test file has exactly 2 it() cases (drag-drop-provider.svelte.test.ts:132 keyboard drag; :213 prevention/accept/cancel/preview). Both drive the flow by calling provider.startDrag(...) directly (e.g. lines 243-250, 256-264 with keyboard:false) rather than dispatching real pointerdown -> pointermove -> moveBuffer promotion (DraggableProvider.onpointerdown at :417-457, threshold Math.hypot <moveBufferat:432).ThesetupPointerDragwindow-listenerflow(:261-294)andteardown/listener-cleanup(:245-257)arenotexercisedviarealevents.Thepreview-styleassertion(:267-275objectContaining)doesNOTassertz-index,sothe9999hardcodeisalsountested.jsdom-onlyenv =SYS-3.MEDIUMstands.
- fix-status: open
## No-findings dimensions
A, C
## Theming facts (E-bis)
- magic z-index: src/uix/soma/components/drag-drop/drag-drop-provider.svelte.ts:656,664 — hardcoded '9999' instead of var(--drag-drop-preview-z)
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: keyboard drag start/activate via Space/Enter; keyboard navigation (ArrowDown/ArrowRight, ArrowUp/ArrowLeft, Tab/Shift+Tab); keyboard drop (Enter/Space on overTarget); keyboard cancel (Escape); onDragStart preventDefault block; accept filter evaluation; pointer drag cancel on invalid drop; pointer preview style positioning (fixed layout, offset from pointer)
- evidence: scope: ['soma', 'sema'] but eidos directory exists at src/uix/eidos/components/drawer/
- impact: Scope does not declare eidos as a layer despite recipe existing. Signals incomplete contract annotation.
- repro: Read src/uix/morfo/components/drawer.ts line 7 and ls src/uix/eidos/components/drawer/
- proposed-fix: Update morfo scope to include 'eidos': scope: ['soma', 'sema', 'eidos']
- verify: [confirmed] morfo line 7: `scope: ['soma', 'sema'],` — 'eidos' absent. Eidos recipe block exists (base.ts:892 `drawer: {` with ~50 tokens) plus a components/drawer/ dir. Systemic SYS-1: sibling dialog.ts:18 has the identical `scope: ['soma', 'sema']` with its own eidos recipe (base.ts:726). HIGH is correct per SYS-1 contract-drift; the morfo scope under-declares a real layer.
- proposed-fix: Create --z-index-overlay and --z-index-inline tokens in the canonical scale and reference via var() in recipe.
- verify: [confirmed] base.ts:896 `'overlay-z': '72',` and 897 `'inline-z': '64',` are bare integers. A canonical `--z-index-*` scale exists and is consumed elsewhere (base.ts:4357 `z: 'var(--z-index-sticky)'`; comment at 4250-4252 references 'the global --z-index-* scale'). These two values bypass it. Genuine SYS-2 magic-z. MEDIUM appropriate.
- proposed-fix: Create --opacity-overlay token or use existing project opacity scale, reference via var().
- verify: [confirmed] base.ts:895 `'overlay-opacity': '62%',` — bare percentage. An `--opacity-*` scale exists and is consumed widely (e.g. base.ts:890 `'disabled-opacity': 'var(--opacity-disabled)'`). The 62% literal does not reference it. Confirmed SYS-2 magic-opacity; note it is replicated verbatim in dialog (base.ts:729) — systemic, not drawer-specific. MEDIUM appropriate.
- evidence: Provider props explicitly set role: 'dialog' and 'aria-modal': this.provider.isOverlay ? true : undefined, while morfo also declares role:'dialog' and aria-modal with prop-truthy condition
- impact: Double-write of role and aria-modal. If morfo condition diverges from provider logic, the two values could conflict. Violates 'una sola autoridad por atributo'.
- repro: Read src/uix/soma/components/drawer/drawer-provider.svelte.ts lines 1044-1045 and compare with morfo aria declarations at lines 184, 214-217
- proposed-fix: Remove explicit role and aria-modal from provider props — let the runtime apply them from morfo. If dynamic aria-modal is needed, ensure logic matches the morfo's prop-truthy condition exactly.
- verify: [downgraded] Real double-write confirmed but severity overstated. Content part is `syncAttrs: true` (provider line 517), so the runtime's syncPartAttrs effect (runtime.svelte.ts:456-465) writes morfo's static `role: 'dialog'` (morfo:184) + dynamic `aria-modal` gated by `prop-truthy modal` (morfo:214-217). Provider props ALSO set them (provider:1044 `role: 'dialog' as const`, 1045 `'aria-modal': this.provider.isOverlay ? true : undefined`). So both attrs are written by two authorities — a true SYS-5 'una sola autoridad' violation. BUT the rubric reserves HIGH for SYS-5 'when the two values can diverge'. They cannot: `role` is the same literal both sides, and `aria-modal` = `isOverlay` (provider:190 `variant==='overlay'`) is identical to morfo's `modal` source (provider:254 `() => opts.variant.current === 'overlay'`). Non-divergent double-write → MEDIUM, not HIGH. Same pattern repeats for Title (provider:1208 `role:'heading'` + 1209 `aria-level` vs morfo:279/282) and Trigger (provider:443-446 type/aria-haspopup/aria-expanded vs morfo:154/162-164) — systemic across this provider.
- evidence: @vitest-environment jsdom — tests only cover direction resolution, dismissal, and variant modes. No tests for keyboard handling (Tab/Escape), focus return to trigger, drag gestures, gesture cleanup, or focus trap behavior.
- impact: Interaction-heavy component (keyboard nav, drag, focus management) tested only on low-risk paths with jsdom. Lacks client/Playwright tests for gesture interaction and focus side-effects.
- repro: Read drawer-provider.svelte.test.ts; note tests use jsdom and @vitest-environment jsdom decorator
- proposed-fix: Add a client-environment (Playwright) test suite covering: Tab/Shift+Tab keyboard navigation, Escape dismissal, drag-to-dismiss interaction, focus return on close, and gesture cleanup on unmount.
- verify: [confirmed] Test file line 1 `// @vitest-environment jsdom`. Only 4 tests (describe at :96, it at :102 direction-resolution, :115 dismissal+snap-clear, :155 persistent-open, :174 inline-escape). No coverage of: Tab/Shift+Tab (morfo keyboard focus-next/focus-prev at morfo:234-235), focus trap/return-to-trigger (morfo focus.trap/return at :109-114; provider handleClose:359 manual focus return), drag gesture (provider gesture/axial at :682/:883), or two-moments emit ordering (close is sequence:'pre', morfo:52). Interaction-heavy + jsdom-only + no client test = SYS-3. MEDIUM appropriate.
- fix-status: open
## No-findings dimensions
B, C, D, G
## Theming facts (E-bis)
- magic z-index: content-z: calc(var(--drawer-overlay-z) + 1) — relies on recipe's bare '72' integer
- magic literals: overlay-z '72' should be --z-index-overlay var | inline-z '64' should be --z-index-inline var | overlay-opacity '62%' should be --opacity-overlay var | handle-length '36px' should reference --size-* or --space-* token | handle-thickness '4px' should reference --space-* token | content-ring-dragging-offset '2px' should reference --space-* token | content-ring-dragging-color opacity 32% should be extracted to --opacity-dragging-ring token
- undeclared parts: none
- roles clean: true · variants clean: true
- conformance: roles/sizes: 'sm'|'md'|'lg'|'full' per component subset (correct). variants: 'overlay'|'inline'|'persistent' (component-specific, documented). naming: --drawer-{slot} public, --_drawer-* private (observed). no undeclared parts in recipe (header/footer are intentional eidos-only layout parts).
- covers: logical direction resolution (start/end → physical sides per dir); polymorphic close with data-last-action imperative prewrite; persistent variant ignores open state; inline variant skips focus trap and scroll lock
- untested: keyboard navigation (Tab, Shift+Tab, Escape) from morfo's keyboard action routes; focus trap and focus return to trigger in overlay/modal mode; drag-to-dismiss gesture and progress signals; resize-on-drag with snap points; snap-point sequential cycling; gesture cleanup on unmount (cancellation of requestAnimationFrame, event listeners); nesting behavior (data-nested, nestedOpenCount management); drag-start/end/progress semantic emissions; scroll-lock guard (scrollLockTimeout behavior); handle-only gesture restriction; pointer event composition (gesture + axial mutual exclusivity); overlay opacity modulation based on activeSnapPoint
- Gesture layer properly throttles drag-progress to rAF via flushDragProgress and rAF request/cancel lifecycle with proper cleanup in $effect.
- Focus return correctly implemented: non-modal returns focus to triggerNode (lines 359, 366); modal relies on FocusScope layer.
- Dismiss layer correctly gated: interactOutside always 'ignore' (drawer is not a popover), Escape gated by modal flag (non-modal manually handles via onkeydown in content).
- CSS uses intentional eidos-only layout parts (data-drawer-header, data-drawer-footer) not declared in morfo — correct separation of layout concerns from behavioral contract.
The most disciplined provider of Batch 1. Consistent roving DOM focus (no virtual/real mix — the morfo declares
NO `aria-activedescendant`, the provider uses real `dom.focus`, A17-correct), uniform `renderProps()` usage
(no double-writes), a clean eidos→soma frontier, correct `data-disabled`/`accessibleWhenDisabled` handling, and
fully self-managed cleanup — including `SafePolygon`, which registers its document listeners inside an internal
`$effect` via `dom.listen` (verified: no A6 leak). It is the only Batch-1 overlay that correctly declares
`eidos` in its morfo scope. No HIGH findings. The remaining issues are the systemic directional-nav duplication,
magic theming literals, a checkbox/radio toggle that emits no semantic event, and a navigation-untested gap.
Counts: CRITICAL 0 · HIGH 0 · MEDIUM 4 · LOW 1.
## Findings
### MEDIUM: Directional-nav index math duplicated (Content + SubContent, and across Select/Combobox) — incl. the loop off-by <!-- id: dropdown-menu-001 -->
- dimension: G, B
- rule: dimension-G redundancy + the loop-boundary edge case
- location: dropdown-menu-provider.svelte.ts:384-422 (Content.onkeydown) and :1165-1209 (SubContent.onkeydown)
- evidence: both handlers re-implement the identical next/prev/Home/End + typeahead index math
(`(i+1)%len` / `Math.min` / `Math.max` / `(i-1+len)%len`). It's also the same math as Select content route
(select-003) and Combobox content route. The `currentIndex === -1` + prev + loop path computes
`(-1-1+n)%n = n-2` (lands second-to-last, not last) — the same off-by as select-003; reachable when keydown
fires with focus on the container rather than an item.
- impact: four copies of one algorithm (one bug fixed in one place misses the others); the loop off-by is a
latent edge-case bug.
- proposed-fix: extract a pure `nextIndex(curr, key, len, {loop, dir, orientation})` helper, unit-test the
boundaries once, and consume it in all menu/select/combobox keyboard routes. (See SHARED_EXTRACTION
DIRECTIONAL-NAV.)
- fix-status: open
### MEDIUM: Checkbox/Radio item activation mutates state but emits no semantic event (unlike plain Item) <!-- id: dropdown-menu-002 -->
- dimension: A, B
- rule: M-3.7 (every state-mutating keyboard action needs a semantic event) + the morfo only declares
`commit-select` on `item`
- location: provider `MenuItemProvider.onclick/onkeydown` fire `runtime.trigger('commit-select', …)` (:510, :521),
but `MenuCheckboxItemProvider.activate` (:735-745) and `MenuRadioItemProvider.activate` (:860-865) mutate
`checked`/group value and call only `onSelect()` + `handleClose()` — no `runtime.trigger`.
- evidence: toggling a checkbox-item or selecting a radio-item changes state with no perceptual emit; the morfo
declares no event for `checkbox-item`/`radio-item`.
- impact: with `closeOnSelect: false` (the common case for checkbox menus) there is zero sema feedback on
toggle, while a plain Item select gets `commit.select + affirm`. Inconsistent perceptual contract; a
checkbox toggle is canonically `commit.toggle`.
- repro: open a menu with a `closeOnSelect={false}` CheckboxItem, toggle it — no `data-event` is stamped.
- proposed-fix: declare `commit-toggle` (checkbox) / `commit-select` (radio) events on those parts in the
morfo and fire them from `activate()`, or document the silence as deliberate in the README.
- evidence: the suite is the strongest of Batch 1 — it covers trigger toggle, item scoping + disabled policy,
click/keyboard activation + close, checkbox/radio groups, submenu open (hover/click/ArrowRight), and
group/separator a11y. But no test calls `Content.onkeydown`/`SubContent.onkeydown` with ArrowDown/ArrowUp/
Home/End to exercise the navigation index math (dropdown-menu-001) or the loop boundary. `@vitest-environment
jsdom` (:1) — `dom.focus` moves aren't faithful.
- impact: the duplicated nav math + loop off-by ship unverified.
- proposed-fix: add Content/SubContent navigation tests asserting the focused index after each arrow key incl.
the loop wrap; add a client/Playwright test for real focus movement.
- fix-status: open
### LOW: `open`/`close`/`commit-select` set state imperatively alongside a non-awaited `void trigger` (sequence not runtime-sequenced) <!-- id: dropdown-menu-005 -->
- dimension: B
- rule: two-moments doctrine + the Dialog pattern (events wired into the runtime so `trigger` sequences emit→handler)
- location: runtime created with `{}` (no events map, :93); `handleOpen`/`handleClose` do
`void this.runtime.trigger(...)` then set `open` synchronously (:116-140).
- evidence: unlike Dialog (which wires `events: { open, close }` so `runtime.trigger` awaits emit before the
handler), dropdown-menu sets `open` imperatively next to a fire-and-forget emit, so the morfo's
`sequence: 'pre'` is not enforced by the runtime — it relies on Presence keeping content mounted during exit.
- impact: works in practice (Presence + the 'pre' content still exists), and the non-await is likely a
deliberate snappiness choice for a high-frequency surface; but it diverges from the canonical sequencing and
makes `sequence: 'pre'` advisory here.
- proposed-fix: either wire the open/close handlers into the runtime `events` map (Dialog parity) or document
that menus intentionally don't await the emit. Decide once and apply to all menu-family components.
- fix-status: open
## No-findings dimensions
- **A17 (focus strategy):** consistent roving DOM focus — morfo declares no `aria-activedescendant`, provider
uses real `dom.focus`. No mixing (contrast Combobox). Clean.
- **C (selectors):**`getItems` composes attribute selectors with `.closest()` scoping (A10), no user-value
interpolation. Clean.
- **D (frontier, severe half):** provider imports only soma layers + morfo — zero eidos imports.
- **A6 (cleanup):** typeahead `destroy` (:111), SubTrigger open timer (:1007), and `SafePolygon` (self-manages
via an internal `$effect` + `dom.listen` disposers, verified safe-polygon.ts:209-215) all clean up. No leak.
- **A (renderProps discipline):** every part spreads `renderProps()` and adds only soma-owned extras
(data-state/tabindex/handlers) — no syncAttrs+manual double-write (contrast Dialog/Popover).
- **A30:** SubTrigger ref mirror via `onRefChange`, not `$effect` (:988).
- **SCOPE:** morfo scope correctly includes `eidos` — the only Batch-1 overlay without the scope-drift.
## Style observations (opinion, non-blocking)
- Use dropdown-menu as the positive reference for: roving-focus consistency, uniform `renderProps()` (no
double-write), self-managed layer cleanup, and correct `eidos`-in-scope declaration. Its main debts are
shared, systemic ones (nav duplication, magic literals), not local mistakes.
- The checkbox/radio silent-toggle (dropdown-menu-002) is the one genuinely local contract gap worth an
field-family (A13/A24-26/A30): Not a field-family component; does not participate in Field composition. Optional hidden-input for form submission is correctly declared and configured with name, value, required, disabled propagation.
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
- evidence: Morfo declares scope: ['soma', 'sema'] but eidos recipe directory exists at src/uix/eidos/components/editable/ with recipe tokens in src/uix/eidos/lib/recipes/base.ts:2724-2815
- impact: The morfo scope should include 'eidos' to signal that the component has visual styling rules. Missing 'eidos' in scope creates confusion about the component's boundaries.
- proposed-fix: Change morfo line 7 from scope: ['soma', 'sema'] to scope: ['soma', 'sema', 'eidos']
- verify: [confirmed] Confirmed. editable.ts:7 declares `scope: ['soma', 'sema']` — verified by reading the file. The eidos layer materially implements this component: 13 files under src/uix/eidos/components/editable/ (incl. editable.css) plus a recipe block at src/uix/eidos/lib/recipes/base.ts:2724 (`editable: {`). The Layer field is documented in types.ts:799 as 'Layers that implement this component', so omitting 'eidos' while shipping a full eidos implementation is genuine contract drift — matches the SYS-1 scope-drift baseline (MEDIUM). Severity MEDIUM is correct: this is a contract/documentation inconsistency, not a behavior bug (the eidos CSS still loads and works regardless of the morfo scope array). Confirmed systemic, not editable-specific: combobox, color-picker, date-field, calendar all have eidos CSS files yet their morfo scope is `['soma', 'sema']` too (e.g. calendar.ts:7, combobox.ts:7, color-picker.ts:7). Proposed fix (add 'eidos' to the array) is correct.
- covers: startEdit with focus and state projection; preview activation (click/dblclick/focus modes); keyboard commit/cancel (Enter/Escape keys); blur control guards (focus retention on trigger buttons); readonly and disabled state enforcement; trigger label exposure and disabled states; value commit and revert semantics
- untested: hidden-input form submission with name/required/disabled propagation; maxLength enforcement on input; autoResize field-sizing behavior; blur submit vs cancel mode differentiation; selectOnFocus text selection on edit start
method: adversarially-verified workflow (analyze → refute); HIGH lead-verified by direct read of the cited code. B5 ground-truth: the A31 O(N²) isSelected/isExpanded (.includes from a per-item $derived) is confirmed across listbox/grid-list/tree-view/tree-grid/tag-group (SYS-7); rovingTargetEl is correctly LIFTED everywhere (not A31); virtual-* use SvelteMap (A33-clean).
- magic literals: feed.css:147 outline-offset: 2px (should use --focus-ring-offset token or define local token) | feed.css:200-201 inline-size: 0.75rem; block-size: 0.75rem; (should use design token like --space-3 or define --_feed-spinner-size) | feed.css:206 animation: feed-spin 0.7s linear infinite (0.7s should use --duration-* token) | feed.css:217 animation-duration: 4s (4s should use --duration-* token, e.g., --duration-slowest)
- covers: keyboard navigation (PageUp/PageDown/Ctrl+Home/Ctrl+End); article position calculation (posinset/setsize); nested thread level inheritance; sentinel IntersectionObserver integration; fallback to feed.onLoadMore when sentinel doesn't have onIntersect
- untested: Performance under high item count (30+ articles — A31 hazard not tested); Dynamic article addition/removal with auto-reposition; RTL direction handling (CSS has RTL rules but no test coverage); Multiple feeds on same page (context isolation)
## Style observations (non-blocking)
- feed.css:42 data-block attribute selector: uses `data-block` instead of `data-block=''` on root for boolean flags — matches component pattern (feed.svelte:42 sets data-block={block ? '' : undefined})
- feed.css:147 outline-offset: 2px differs from --focus-ring-offset (1px). Most components use --focus-ring-offset token; feed variant may be intentional for visual clarity but creates drift
- feed.css:153 fallback font-weight: 600 in var() — fallback should not be used if token is guaranteed to exist; minor cosmetic issue
"A13": "Not applicable: field is the generic wrapper, not a form-participating field (number-field, tags-input, pin-input, color-field, css-field are form-fields). No hidden <input> rendered by field itself.",
"A24": "Not applicable: field does not hold a value; child inputs (FieldInputProvider) handle value state. A24 'readonly-without-value warning' applies to child-level components.",
"
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 2 · LOW 0.
## Findings
### MEDIUM: SYS-1 scope-drift: morfo 'scope' declares ['soma'] but eidos directory exists at src/uix/e — field-002 <!-- id: field-002 -->
- dimension: A
- rule: SYS-1 scope-drift: morfo 'scope' declares ['soma'] but eidos directory exists at src/uix/eidos/components/field
- location: src/uix/morfo/components/field.ts:7 vs src/uix/eidos/components/field/
- evidence: fieldMorfo.scope = ['soma'], yet a full eidos/ recipe directory exists with field.css, field.svelte, and 10+ component files.
- impact: MEDIUM (expected pattern per audit notes: 'No eidos scope-drift expected? (it has an eidos dir)'). Eidos wrapper components import soma correctly (all 11 .svelte files in eidos/field show 'import * as Field from $soma/components/field'), so the directory exists but is subordinate to soma — this is the normal composition pattern (eidos wraps soma visually, doesn't add a new layer to scope). No contract or behavior violation.
- proposed-fix: This is systemic (SYS-1). Document as expected: field is a soma component with an eidos visual wrapper layer; scope correctly declares soma as the source of truth.
- verify: [downgraded] Confirmed shape: fieldMorfo.scope = ['soma'] (field.ts:7) and a full eidos/components/field/ recipe dir exists. But SYS-1 scope-drift is the pattern where a soma component has an eidos visual layer not reflected in 'scope'; here every eidos wrapper composes soma (grep for 'import.*eidos' in field-provider returned NO matches — frontier clean). This is the normal eidos-wraps-soma composition, not a contract or behavior violation. Candidate's own impact text says 'expected pattern' / 'no contract or behavior violation'. Downgrade MEDIUM->LOW; record as systemic-expected, not a Field-specific defect.
- evidence: Test file exists and covers: (1) wires field parts, ARIA props, runtime attrs (lines 85-183); (2) updates input value unless disabled/readonly (185-219); (3) integrates with Form state and touched registry (221-256). Paths covered: ARIA wiring (aria-labelledby, aria-invalid, aria-describedby, aria-required, role=alert, aria-live), disabled/readonly guards, Form integration.
- impact: Field-provider tests cover core A30 wiring and Form integration. NO COVERAGE for A24 'readonly-without-value warning' (field is generic, doesn't hold value itself — A24 applies to value-holding fields like number-field). Test count is lean (3 it() blocks) but focused on the unique field responsibilities.
- proposed-fix: Optional: add a test verifying that child inputs read disabled/readonly/required/invalid from the parent provider context. Current tests verify runtime wiring but not snippet props propagation. Consider: 'reads disabled/readonly/required/invalid from parent' to guard inheritance.
- verify: [uncertain] Test exists (field-provider.svelte.test.ts, 256 lines, jsdom env, 3 it() blocks). Coverage is genuinely good for the field's unique responsibilities: ARIA wiring incl aria-labelledby/aria-invalid/aria-describedby join (141-179), disabled/readonly input guards (185-219), Form integration + touched registry + unregister-on-cleanup (221-255). The real, narrow gap the candidate names is legitimate: no explicit assertion that descendant inputs READ disabled/readonly/required/invalid from the parent context (snippetProps inheritance is computed but only the FieldInputProvider's own props are asserted, e.g. 'required: true' at 146 comes from the input part). Holds at MEDIUM as a low-risk test-coverage gap, not a confirmed bug. F: provider test PRESENT, env jsdom (SYS-3 noted).
- fix-status: open
## No-findings dimensions
A (contract: morfo scope/naming/parts), C (DOM selector), G (redundancy)
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
- label-font (one step below input?): Clean: label font-size is calc(control-font - 2px) per base.ts recipe. Label renders at ONE step below input per CSS line 154. Scales correctly with size prop (xs/sm/md/lg/xl).
## Tests (F)
- exists: true · env: jsdom
- covers: ARIA wiring (aria-labelledby, aria-invalid, aria-describedby, aria-required, role, aria-live); input value update with disabled/readonly guards; Form Provider integration (registerField, unregisterField, getFieldState, touched registry)
- untested: Child inheritance of disabled/readonly/required/invalid from parent context (snippet props verified, but context propagation to child .svelte components not exercised); Cleanup of id registration on unmount (tested via effect cleanup but not via explicit unmount/remount cycle)
- evidence: morfo declares scope: ['soma', 'sema'] but src/uix/eidos/components/file-upload/ directory exists with 13 .svelte files (file-upload.svelte, file-upload-dropzone.svelte, file-upload-item.svelte, etc.). Eidos layer is implemented but not declared in morfo scope.
- impact: Scope mismatch signals incomplete morfo contract. Consumers expect morfo scope to list all layers present. This is a systemic architectural issue affecting component layering.
- repro: grep -n 'scope:' src/uix/morfo/components/file-upload.ts; ls -d src/uix/eidos/components/file-upload
- proposed-fix: Update line 7 from scope: ['soma', 'sema'] to scope: ['soma', 'sema', 'eidos']
- verify: [confirmed] CONFIRMED. morfo src/uix/morfo/components/file-upload.ts:7 declares `scope: ['soma', 'sema']`. The eidos layer is fully implemented: src/uix/eidos/components/file-upload/ holds 13 .svelte wrappers + file-upload.css (foundation reading `--file-upload-*` recipe tokens, e.g. `--_file-upload-gap: var(--file-upload-gap-md)`) + types.ts + README.md, AND a recipe exists at src/uix/eidos/lib/recipes/base.ts:2369 ('file-upload': {...} with per-color solid/track tokens). Eidos is present but omitted from morfo scope → SYS-1 scope-drift. MEDIUM is correct per baseline. Note: the candidate's repro `grep -n 'scope:'` is fine, but a `grep 'file-upload:'` on base.ts misses the recipe because the key is tab-indented — the recipe DOES exist.
- fix-status: fixed (212624e0)
## No-findings dimensions
Behavior (A35/A36 loops: no per-item $effect reading opts.ref.current AND writing provider state; no microtask-mediated async without untrack), Behavior (A6 cleanup: all listeners via soma.dom.listen() return disposers; URL.createObjectURL/revokeObjectURL pair in $effect return), Behavior (A33: no $state(new Map/Set); A31: no O(N²) includes checks), Behavior (A30: hiddenInputId set directly in constructor line 444, not in $effect; hiddenInputRef via onRefChange callback line 440-442), Behavior (A15 GESTURE: no drag-drop/cropper gesture logic; dropzone uses native drag events with proper preventDefault), Behavior (LIVE REGIONS: signal-warn-reject dispatches with message; untilFix persistence documented line 191-194), Contract (2-of-3 rule: morfo + soma + eidos all present; all 13 morfo parts registered via runtime.part()), Contract (data naming: all data-* attributes are kebab-cased; no data-soma-* violations), Contract (morfo validation: 'as const satisfies Morfo' present; all aria and data attrs declared), Frontier (no soma->eidos imports; eidos correctly imports from soma), Frontier (no syncAttrs double-write; parts use renderProps() correctly), Theming (focus rings use --focus-ring-width and --focus-ring-color CSS vars; no hardcoded hex colors or magic px values; no z-index magic), DOM selectors (no querySelector with interpolated consumer values; no direct document/window; uses soma.dom.getDocument()), Passive roles (ItemProgress role='progressbar' declares no component-level events; correct), Archetype validation (Item archetype:'item' is correct; CSS does not assign cursor:pointer or interactive styling to display items), Tests (provider.svelte.test.ts covers rejection logic, maxFiles caps, dropzone interactions, item metadata, removal, progress, clear state; jsdom environment)
- untested: signal-warn-reject dispatch with live-region message (signal event routing not tested); preventDocumentDrop listener cleanup on unmount; ItemPreview URL.createObjectURL/revokeObjectURL lifecycle across file changes; paste event on dropzone; keyboard Enter/Space on dropzone (role='button'); multiple=false single-file replacement behavior
## Style observations (non-blocking)
- Signal-warn-reject event (line 210) correctly uses 'post' sequence and passes message for live-region announce per a11ySemantic.requiresPersistentTrace + reducedMotionFallback='text'
- A30 pattern correctly applied: hiddenInputId set direct in constructor (line 444), hiddenInputRef via onRefChange callback (line 440-442) — no $effect overhead
- untilFix persistence clearly documented (line 191-194): clearTarget(provider) on each addFiles call resets signal before evaluating new rejections
- Item archetype correctly declared as 'item' (not 'option'/'interactive'); CSS treats item as display container (no cursor:pointer, interactive children are explicit buttons)
- evidence: scope: ['soma', 'sema'], but eidos recipe directory exists at src/uix/eidos/components/float-panel/
- impact: Morfo contract omits eidos in scope declaration despite full eidos implementation present; creates contract-documentation drift.
- repro: List the morfo scope and observe eidos directory exists but not declared.
- proposed-fix: Change morfo scope to ['soma', 'sema', 'eidos'] to match the presence of src/uix/eidos/components/float-panel/
- verify: [confirmed] morfo line 7 literally reads `scope: ['soma', 'sema'],` while the eidos recipe dir exists (Glob returned src/uix/eidos/components/float-panel/ with float-panel.css, types.ts, 14 .svelte parts, recipe block at base.ts:1076). Genuine SYS-1 scope-drift: a complete eidos implementation is present but 'eidos' is omitted from the morfo scope tuple. HIGH per SYS-1 (contract/validator-catchable). Confirmed.
- evidence: Test file runs in jsdom (@vitest-environment jsdom); keyboard interaction tests at lines 216-247 only cover basic ArrowRight/ArrowDown moves, not Home/End/PageUp/PageDown/resize keyboard, and no client/Playwright test exists.
- impact: Keyboard grab-mode (move + resize) has complex state transitions (kbToggleMove, kbCancelMove, kbMove with Home/End logic, and kbResize equivalents) exercised only on easy paths (arrow keys) in jsdom. Full keyboard interactivity (especially Home/End reaching bounds and PageUp/PageDown step logic) untested; resize keyboard path completely absent from test coverage.
- repro: Run tests; no keyboard resize tests exist. grep test file for 'kbResize' (absent). Manually verify Home/End position clamping with a real browser.
- proposed-fix: Add integration tests covering (a) keyboard move Home/End/PageUp/PageDown reaching bounds, (b) resize keyboard equivalents, (c) stage transitions cancelling grab-mode, in a client environment (Playwright or @vitest-environment happy-dom/puppeteer).
- verify: [downgraded] The factual premise (jsdom) is WRONG, so the SYS-3 rule citation is misapplied — but a real coverage gap remains. The file is `float-panel-provider.svelte.test.ts`, which matches the CLIENT project glob `src/**/*.svelte.{test,spec}.{js,ts}` (vite.config.ts:72) and runs in headless chromium (browser:{instances:[{browser:'chromium'}]}, line 67-70) — NOT jsdom. The `// @vitest-environment jsdom` line at test:1 is inert for browser-mode tests; the test itself confirms (line 219-220 'this .svelte.test runs in a real browser whose width varies', line 117 reads live window.innerWidth). So it is NOT a SYS-3 jsdom-only case. The valid kernel: keyboard RESIZE (kbToggleResize/kbResize) is entirely untested (grep test for 'kbResize'/'kbToggleResize' = absent) and keyboard MOVE Home/End/PageUp/PageDown + bound-hit announce paths are untested (only ArrowRight + Shift+ArrowDown + cancel + maximized-guard exercised, lines 231-243). Real but not SYS-3; downgrade to MEDIUM coverage-gap with corrected rule (behavioral test gap, not jsdom-only).
- fix-status: open
## No-findings dimensions
A, C, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: opacity: 1; at line 260, 265 (should map to --opacity-* token) | scale: 1; at line 344, 349 (should map to --scale-reset or similar) | line-height: 1; at line 189 (should be canonical or documented)
- undeclared parts: data-animation-style set by eidos but not declared in morfo
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: position seeding and clamping; size min/max clamping; anchor positioning (side=bottom, align=start); dismissal via dismissWith; stage toggling and drag block while maximized; keyboard grab-mode move (basic arrows only)
- untested: keyboard move Home/End/PageUp/PageDown; keyboard resize toggle and all keyboard resize keys; stage transitions cancelling keyboard grab-mode; pointer drag gesture lifecycle (startDrag → pointermove → endDrag); pointer resize gesture lifecycle; multi-panel stacking (bring-to-front during gestures); all dismiss causes (save, cancel, fail, dismiss-outside) semantic payloads; focus return to trigger on close; ResizeGrip focus and keyboard resize affordance
## Style observations (non-blocking)
- CSS composition is clean: drag position uses distinct translate property (not affected by state animations); scale-fade animation preset only touches scale+opacity, leaving translate free for drag.
- Gesture sequencing is well-designed: dragging/resizing state lifted on pointerdown (before move), so shadow transitions before motion begins; live position/size applied imperatively per pointermove, committed to state on release.
cleanup-audit (A6/A35/A36): No timers/listeners/observers/ResizeObserver/IntersectionObserver/MutationObserver/setPointerCapture found in form component. No $effect blocks that require disposal. No $effect.root calls with missing disposers. A35/A36 loop check: No per-item $effect reading opts.ref + writing provider state detec
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
## Findings
### MEDIUM: SYS-1 scope-drift: an eidos recipe dir + recipe entry exist for the component, but the mor — FORM-SYS1-SCOPE-DRIFT <!-- id: FORM-SYS1-SCOPE-DRIFT -->
- dimension: A
- rule: SYS-1 scope-drift: an eidos recipe dir + recipe entry exist for the component, but the morfo `scope` omits 'eidos'. The morfo scope must declare every layer that materializes against its contract.
- location: src/uix/morfo/components/form.ts:7 (scope: ['soma', 'sema']) vs eidos dir src/uix/eidos/components/form/ + recipe src/uix/eidos/lib/recipes/base.ts:836 (form:) + form.css:68/77/81 selecting [data-form][data-pending], [data-form][data-invalid], [data-form-error-summary]
- evidence: form.ts:7 `scope: ['soma', 'sema']` — 'eidos' absent. Yet src/uix/eidos/components/form/form.css line 68 `[data-form][data-pending] {`, line 77 `[data-form][data-invalid] {`, line 81 `[data-form-invalid] [data-form-error-summary]` all select morfo-emitted data-attrs, and base.ts:836 declares a `form:` recipe. The eidos layer fully consumes the contract while the morfo scope denies eidos is a consumer.
- impact: The morfo's declared scope under-reports its real consumers. Any scope-driven tooling (coverage checks, layer-presence assertions) will treat form as having no eidos materialization, masking drift if the eidos selectors fall out of sync with the morfo's emitted attrs.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: fixed (212624e0)
## No-findings dimensions
A Contract(morfo): MUST be 'as const satisfies Morfo', Parts registered via runtime.part() exist in morfo & vice-versa, 2-of-3 rule, data-{c}/-{part} naming, aria/data emitted in morfo, A35: per-item $effect reading opts.ref and writing provider state, A36: async write + read-back without untrack, A33: $state(new Map/Set) not reactive, A31: per-item $derived reading global state via provider method, A6: setTimeout/setInterval/listener/ResizeObserver/IntersectionObserver/MutationObserver disposal, A15: GESTURE drag-drop/cropper cleanup, LIVE REGIONS: announce via uix.announce, A30: child->parent id registration DIRECT, DOM-selector: querySelector safe framework values, SOMA imports eidos, data-size/color/variant eidos VISUAL attrs, syncAttrs double-write, TSC tokens, Theming: 9 roles, canonical vars, focus-ring canonical usage, archetype item/option on non-interactive parts, Tests: provider test exists, jsdom env, Redundancy: re-implemented patterns
## Theming facts (E-bis)
- magic z-index: none
- magic literals: 10%, 70%, 48% in color-mix (form.css:198,206,256) - design system blending factors, intentional
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: handleSubmit with invalid validation; first invalid field focus; submit/reset/error-summary state projection; error count and field error list; submit count tracking; disabled state per form state
- untested: Multiple submit attempts with different validation states; Custom onValidSubmit/onInvalidSubmit callbacks; Schema override after form creation; Form created with prebuilt form instance; validationBehaviour parameter (progressive/onSubmit/onBlur/onChange); Async validation in onValidSubmit; noValidate attribute emission; Reset button onclick handler behavior
method: adversarially-verified workflow (analyze → refute); HIGH lead-verified by direct read of the cited code. B5 ground-truth: the A31 O(N²) isSelected/isExpanded (.includes from a per-item $derived) is confirmed across listbox/grid-list/tree-view/tree-grid/tag-group (SYS-7); rovingTargetEl is correctly LIFTED everywhere (not A31); virtual-* use SvelteMap (A33-clean).
reactivity (A31/A33/A35): GRID-LIST-A31-HAZARD: Per-row `isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current))` at line 493 and per-checkbox `isChecked = $derived.by()` at line 612 call `isSelected(value)` method (line 150-152) which does `this.opts.value.current.includes(value)` — array linear scan O(N) per row/checkbox. For N rows, total O(N²) on any selection mutation. Lifted fix: move to pro
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 2 · MEDIUM 1 · LOW 0.
## Findings
### HIGH: A31: Per-item derived calling provider method that reads global state causes O(N²) re-runs — grid-list-001 <!-- id: grid-list-001 -->
- dimension: B - Behavior (A31 reactivity)
- rule: A31: Per-item derived calling provider method that reads global state causes O(N²) re-runs on any mutation
- evidence: GridListRowProvider line 493: `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current))` calls line 150-152: `isSelected(value: string): boolean { return this.opts.value.current.includes(value); }` - performs linear array scan per row, every row re-runs on any selection change
- impact: For N rows, each row's derived calls O(N) .includes() method → O(N²) total. Works fine under ~15 items, becomes noticeably slow at 30+ items.
- proposed-fix: Lift a `Set<string>` on GridListProvider: `readonly selectedSet = $derived(() => new Set(this.opts.value.current))`. In isSelected, use `selectedSet.has(value)` for O(1). Update per-row derived to use the pre-computed set.
- verify: [confirmed] Confirmed A31 O(N²). Line 493: `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current))` per GridListRowProvider calls provider.isSelected (lines 150-152: `return this.opts.value.current.includes(value)`), a linear array scan reading GLOBAL selection state. Every row's derived depends on `this.opts.value.current`; selection replaces the array (line 172 `this.opts.value.current = next`), invalidating ALL row deriveds, each re-running O(N) .includes() → O(N²). The provider DID lift a Set for the roving target (line 144 `const selected = new Set(this.opts.value.current)` inside the single `rovingTargetEl` derivation) but did NOT lift one for the per-row isSelected path — fix is real and applicable. Matches SYS-7 / documented Listbox-rovingTarget incident.
- fix-status: fixed (92f988e7)
### HIGH: A31: Per-checkbox derived calling provider method that reads global state — grid-list-002 <!-- id: grid-list-002 -->
- dimension: B - Behavior (A31 reactivity)
- rule: A31: Per-checkbox derived calling provider method that reads global state
- evidence: GridListSelectionCheckboxProvider line 612-614: `readonly isChecked = $derived.by(() => { if (!this.rowValue) return false; return this.provider.isSelected(this.rowValue); })` - calls the O(N) isSelected method. Also line 636 in props derived repeats the same call.
- impact: Per-checkbox derived re-runs and calls isSelected (array .includes), multiplied by number of checkboxes in the list.
- proposed-fix: Same fix as grid-list-001: consume the lifted selectedSet instead of calling isSelected.
- verify: [confirmed] Confirmed A31 on the checkbox part. Lines 612-615: `readonly isChecked = $derived.by(() => { if (!this.rowValue) return false; return this.provider.isSelected(this.rowValue) })` calls the same O(N) linear-scan isSelected (line 151). Additionally line 636, inside the `props` $derived (632-651): `const checked = this.rowValue ? this.provider.isSelected(this.rowValue) : false` — a SECOND read of the linear scan per checkbox. Both deriveds depend on `value.current` via isSelected and re-run for every checkbox on any selection mutation. Same lifted-Set fix as 001. HIGH per SYS-7.
- fix-status: fixed (92f988e7)
### MEDIUM: SYS-1: Scope-drift when eidos recipe directory exists but 'eidos' is omitted from morfo.sc — grid-list-003 <!-- id: grid-list-003 -->
- dimension: A - Contract (morfo scope)
- rule: SYS-1: Scope-drift when eidos recipe directory exists but 'eidos' is omitted from morfo.scope
- evidence: Morfo declares `scope: ['soma', 'sema']` (line 7) but full eidos directory exists at G:/dev/svelte/vicen/src/uix/eidos/components/grid-list/ with grid-list.svelte (lines 1-58), grid-list.css (lines 1-236), types.ts, and child components (grid-list-row.svelte, grid-list-cell.svelte, grid-list-selection-checkbox.svelte). Eidos scope is present but not declared.
- impact: Inconsistency in declared vs actual scope. Eidos components are real and functional but not flagged in morfo. May confuse consumers about component structure.
- proposed-fix: Either (1) add 'eidos' to morfo.scope: `scope: ['soma', 'sema', 'eidos']`, OR (2) remove the eidos directory if GridList is soma-only. Recommend option 1 since eidos wrapper clearly exists and re-exports soma.
- verify: [confirmed] Confirmed SYS-1 scope-drift. grid-list.ts:7 declares `scope: ['soma', 'sema']` but a full eidos directory exists: grid-list.svelte, grid-list.css, types.ts, index.ts, grid-list-row.svelte, grid-list-cell.svelte, grid-list-selection-checkbox.svelte (verified via glob). The eidos wrapper is real and functional yet 'eidos' is omitted from the morfo scope. This is the documented systemic SYS-1 pattern (siblings command/combobox/date-picker show the same omission). MEDIUM per baseline. No recipe entry in recipes/base.ts (grep `grid-list:`/`gridList` returned no match), so the eidos surface is CSS-only — adding 'eidos' to scope is the correct alignment.
- fix-status: fixed (212624e0)
## No-findings dimensions
C - DOM-selector (CSS.escape used correctly on line 184 for consumer value), D - Frontier (no soma→eidos imports; eidos→soma normal), E - TSC/Theming (--control-height-*, --font-size-*, --space-*, --color-* all canonical; no magic hex or z-index), F - Tests (test environment jsdom is acceptable for this DOM-interactive pattern; keyboard nav, selection, typeahead tested), G - Redundancy (no detected duplication in selection/keyboard navigation logic), E-bis - No A33 ($state(new Map/Set)), A30 (id registration is direct field, not $effect), A6 (listeners cleaned in $effect.root), A14 (roving tabindex correctly implements exactly one tabindex=0), A34 DOM-TOPOLOGY (require() pattern not used)
## Theming facts (E-bis)
- magic z-index: none
- magic literals: 0.875rem (checkbox size at grid-list.css:196 - specific rem for UI element size, acceptable) | 60vh in min() at grid-list.css:27 - viewport unit for max-height, not a magic z-index
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom (@vitest-environment jsdom at grid-list-provider.svelte.test.ts:1)
- covers: row selection and deselection via click; shift+click range selection; ctrl/meta+click toggle; keyboard navigation (ArrowDown, ArrowUp, Home, End, PageUp, PageDown); row focusing and roving tabindex; typeahead character matching; select-all (Ctrl+A) and clear (Escape); checkbox row association and toggle; cell-level horizontal arrow navigation (ArrowRight/Left); merging Field provider flags (disabled, readonly, required, invalid)
cleanup-audit (A6/A35/A36): No $effect in ImageAdjustmentsProvider or sub-providers that manage timers, listeners, or observers. No setTimeout, setInterval, addEventListener, ResizeObserver, IntersectionObserver, MutationObserver, or pointer capture in the ImageAdjustments layer (all delegated to composed <Slider>). The test f
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
## Findings
### MEDIUM: SYS-1 — Scope-Drift: eidos directory exists but morfo scope does not include 'eidos' — image-adjustments-003 <!-- id: image-adjustments-003 -->
- dimension: D
- rule: SYS-1 — Scope-Drift: eidos directory exists but morfo scope does not include 'eidos'
- location: src/uix/morfo/components/image-adjustments.ts:18 vs src/uix/eidos/components/image-adjustments/
- evidence: The morfo declares scope: ['soma', 'sema'] (line 18), but a full eidos implementation exists at src/uix/eidos/components/image-adjustments/ containing image-adjustments.svelte, image-adjustments.css, types.ts, README.md, and index.ts. Per the baseline, this is a confirmed systemic issue (SYS-1) affecting several B7 components.
- impact: Scope mismatch: the morfo's declared scope does not reflect reality. Tooling relying on scope declarations may fail to validate or organize the eidos layer. No functional impact on the component itself, but the contract is misleading.
- repro: Inspect src/uix/morfo/components/image-adjustments.ts line 18 (scope: ['soma', 'sema']) and verify that src/uix/eidos/components/image-adjustments/ exists with a full component implementation.
- proposed-fix: Update the morfo's scope to: scope: ['soma', 'sema', 'eidos']. This aligns the contract with the actual implementation layers present.
- verify: [confirmed] CONFIRMED. image-adjustments.ts:18 declares `scope: ['soma', 'sema']`, omitting 'eidos'. A full eidos implementation exists at src/uix/eidos/components/image-adjustments/ (image-adjustments.css verified read, README.md verified read, recipe block at base.ts:1112-1132 'image-adjustments': {...}). This is the confirmed-systemic SYS-1 scope-drift pattern. MEDIUM. Fix: scope: ['soma', 'sema', 'eidos'].
- fix-status: fixed (212624e0)
## No-findings dimensions
B, C, F, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: computeImageFilter: neutral (none), tone functions (brightness/contrast/saturation/hue), 0-based (blur/grayscale/sepia), temperature approximation (warm/cool), multi-adjustment composition; ImageAdjustmentsProvider: live filter derivation, isModified tracking, setAdjustment mutation + callbacks, reset() via runtime trigger, custom adjustments subset, disabled state blocks mutations, catalog + defaults exposed; ImageAdjustmentsItemProvider: derived value/label/formattedValue per adjustment, setValue callback; Snippet props exposure and live callback routing
- untested: A15 gesture: pointer interaction is delegated to the composed <Slider>, not handled directly by ImageAdjustments. Pointer cleanup verified in Slider tests, not here.; A6 timers/listeners: no direct setTimeout/setInterval/addEventListener/ResizeObserver/etc. in ImageAdjustments provider or components. Interaction heavy lifting delegated to Slider.; A35 effect loop: no per-item $effect reading opts.ref.current and writing provider state. No reactive loop risk.; A36 microtask loop: no async (.then/microtask/setTimeout) writing reactive vars without untrack. Callbacks (onValueChange/onFilterChange) are consumer-driven, not effect-mediated.; Live region: the component does not emit a live region for adjustment changes (per README: 'ItemValue is a plain <span>, not a live region. The Slider thumb already announces aria-valuenow'). Announcement delegated to composed Slider.; A30 id registration: no child->parent id registration loop. Each part registered directly via runtime.part() in the constructor.
## Style observations (non-blocking)
- CSS correctly uses data attribute selectors matching the morfo kebabs: [data-image-adjustments], [data-image-adjustments-item], [data-image-adjustments-item-label], [data-image-adjustments-item-value], [data-image-adjustments-reset].
- Reset button uses :focus-visible for keyboard navigation cue (line 70-74), not hardcoded :focus. Correct.
- Reset button uses var(--focus-ring-width) and var(--focus-ring-color), consuming the canonical two-ring focus model from the theme. Correct.
- Reset hover state applies var(--color-content-primary), correct semantic color. Line 67.
- Hue row track override correctly uses var(--image-adjustments-hue-track), exiling the literal rainbow to the recipe layer (line 86). Correct raw-color contract.
- No hardcoded :active, no magic z-index, no raw hex. All CSS tokens correctly namespaced to component.
- Row typography uses var(--font-size-sm/xs), var(--font-weight-medium), var(--font-mono) — all reference the base tokens, not px/rem literals. Correct.
- Spacing uses var(--space-*) and var(--image-adjustments-*) recipe tokens. No hardcoded gap/margin px values. Correct.
cleanup-audit (A6/A35/A36): One $effect at line 82: reads opts.file.current, creates URL.createObjectURL, writes to this.url, cleanup function properly returns and calls URL.revokeObjectURL(u). Cleanup is attached and will fire on effect cleanup. No timers, listeners, ResizeObserver, IntersectionObserver, MutationObserver, or
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
## Findings
### MEDIUM: SYS-1 scope-drift: a component with a real eidos implementation (recipe dir + CSS selectin — image-picker-NEW-001 <!-- id: image-picker-NEW-001 -->
- dimension: A_Contract
- rule: SYS-1 scope-drift: a component with a real eidos implementation (recipe dir + CSS selecting morfo data-attrs) MUST list 'eidos' in morfo.scope ('Layers that implement this component' — types.ts:798-799).
- evidence: Morfo declares `scope: ['soma', 'sema']` (line 20), omitting 'eidos'. But a full eidos layer implements the component: src/uix/eidos/components/image-picker/{image-picker.svelte,image-picker.css,types.ts,index.ts} all exist, and image-picker.css selects exclusively against morfo-emitted data-attrs — `[data-image-picker]` (:12), `[data-image-picker][data-disabled]` (:18), `[data-image-picker-preview][data-rotation='90']` (:49), `[data-image-picker-toolbar]` (:59), `[data-image-picker-rotate]`/`[data-image-picker-remove]` (:67-68). These are the morfo's `contracts.cssSelectors` surface, so eidos is a genuine implementing layer per the scope doctrine.
- impact: Contract drift: the morfo under-declares its implementing layers. Tooling/coverage that keys off `scope` (lint, layer audits, sema coverage) treats the component as having no eidos layer, masking the eidos↔morfo contract. Cosmetic-to-tooling, no runtime user impact — matches the confirmed SYS-1 baseline severity (MEDIUM).
- proposed-fix: Add 'eidos' to the scope array: `scope: ['soma', 'sema', 'eidos']` in src/uix/morfo/components/image-picker.ts:20.
- verify: [verifier-added] added by adversarial verify pass
- evidence: scope: ['soma'] declared in morfo, but eidos recipe dir exists at src/uix/eidos/components/link-preview/ with 7 files (index.ts, link-preview.svelte, link-preview-content.svelte, link-preview-trigger.svelte, link-preview-arrow.svelte, link-preview.css, types.ts)
- impact: Morfo scope omits 'eidos' while eidos recipe and components exist; creates maintenance confusion and violates the documented scope contract.
- repro: grep -r "src/uix/eidos/components/link-preview" to confirm files exist; check morfo scope at src/uix/morfo/components/link-preview.ts:7
- proposed-fix: Either (a) add 'eidos' to morfo scope: `scope: ['soma', 'eidos']`, or (b) if eidos is intentional internal structure, document why scope is intentionally ['soma'] only and mark eidos as internal pattern.
- verify: [confirmed] Confirmed SYS-1 scope-drift. morfo line 7: `scope: ['soma'],` while a full eidos recipe dir exists: ls src/uix/eidos/components/link-preview/ shows index.ts, link-preview.svelte, link-preview-content.svelte, link-preview-trigger.svelte, link-preview-arrow.svelte, link-preview.css, types.ts. morfo/types.ts:799 documents `scope` as 'Layers that implement this component. Eidos-only primitives may declare ["eidos"].' Peer overlays correctly list eidos: dropdown-menu.ts:11 `scope: ['soma', 'sema', 'eidos']`, tooltip.ts:14 `scope: ['soma', 'eidos', 'sema']`. link-preview omits 'eidos' despite shipping a recipe. MEDIUM is right per batch-1 SYS-1 baseline.
- fix-status: open
## No-findings dimensions
B, C, D, F, G
## Theming facts (E-bis)
- magic z-index: z-index is tokenized via --popover-content-z (canonical preset)
- magic literals: text-decoration-thickness: 1px (should use --border-width or clarify as exempt) | text-underline-offset: 0.18em (should use --tracking-* or clarify as exempt) | opacity: 0 in keyframes (acceptable as terminal state) | scale(0.985) and 4px offsets in keyframes (should be tokenized or documented)
- undeclared parts: none
- roles clean: true · variants clean: true
- conformance: link-preview uses only primary role (canonical); no invented roles or sizes; size variants xs|sm|md|lg|xl conform to E-bis subset.
## Tests (F)
- exists: true · env: jsdom
- covers: open/close timers with configurable delays (openDelay/closeDelay); touch pointer ignored (only mouse/pen); disabled flag blocks open; SafePolygon pointer bridge (content hover prevents close); dismissal via Escape key and outside-click; floating content/arrow props exposure; data-state transitions
- untested: keyboard Escape key (mentioned in README but not explicitly tested in jsdom); focus return to trigger on close (A17 not exercised); content visibility presence transitions (Presence API self-cleanup verified at code level); client/Playwright integration tests (jsdom-only; no browser interaction test)
## Style observations (non-blocking)
- LinkPreview correctly uses trigger as navigational <a> (not button), which is documented and intentional per APG guidance for hover-card discovery.
- aria-hidden=true on content is explicit design choice to hide preview from AT; justified by README study §2.3 (duplicate content, hover-only activation, focus stays on trigger).
- SafePolygon bridge with touch guard (pointer type check) is well-implemented to preserve preview on pointer transitions.
- Color tokens use primary role only; fallback to --color-content-primary is defensive.
- Animation preset (4 directional in/out keyframes) matches floating-ui positioning strategy; naming data-side correlates CSS state to floating placement.
method: adversarially-verified workflow (analyze → refute); HIGH lead-verified by direct read of the cited code. B5 ground-truth: the A31 O(N²) isSelected/isExpanded (.includes from a per-item $derived) is confirmed across listbox/grid-list/tree-view/tree-grid/tag-group (SYS-7); rovingTargetEl is correctly LIFTED everywhere (not A31); virtual-* use SvelteMap (A33-clean).
reactivity (A31/A33/A35): FOUND: A31 O(N²) latent bug at line 413. Per-item isSelected $derived calls provider.isSelected() which does array.includes() — triggers on every provider.value change. Multi-select mode: N items × O(N) array search = O(N²). Roving-target was correctly lifted (line 148-153) but isSelected was not. Single-select unaffected (selection ≤ 1). Tests max 3 items, so bug not caught. HIGH severity.
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 1 · MEDIUM 1 · LOW 0.
## Findings
### HIGH: A31: Per-item $derived reading global provider state via .includes() O(N) method — listbox-001 <!-- id: listbox-001 -->
- dimension: B (Reactivity - A31)
- rule: A31: Per-item $derived reading global provider state via .includes() O(N) method
- evidence: Line 413: `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));` — per-item derived calling Line 157-158: `isSelected(value: string): boolean { return this.opts.value.current.includes(value); }`. When provider.opts.value changes, ALL N items' derived re-run, each performing O(N) array.includes() search. With multi-select, N items × O(N) selection-array = O(N²).
- impact: Multi-select listboxes with 30+ items + selections will experience quadratic re-derivation cost. Single-select mode is unaffected (selection array ≤ 1 item). Performance degradation not caught by existing tests which max 3 items.
- proposed-fix: Lift isSelected as a provider-level $derived returning a Set<string> keyed by value, similar to rovingTargetEl pattern (line 148-153). Items would then do O(1) .has() checks instead of O(N) .includes() calls. Alternatively, compute a derived Set at provider level and expose it for item consumption.
- verify: [confirmed] CONFIRMED HIGH. Re-read listbox-provider.svelte.ts. Line 413 (per-item): `readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));`. Line 157-158 (provider method): `isSelected(value: string): boolean { return this.opts.value.current.includes(value); }` — reads GLOBAL `opts.value` and does O(value.length) Array.includes(). This is the textbook A31 pattern: every `value` mutation invalidates all N items' `isSelected` derivations, each re-running an O(K) array search (K = selection size). Multi-select: N items x O(N) = O(N²). Single-select: K<=1 so effectively O(N), unaffected. The asymmetry is the proof this is a genuine miss, not a design choice: `rovingTargetEl` (lines 148-153) was deliberately LIFTED to ONE provider-level $derived building `new Set(this.opts.value.current)` with an explicit comment (141-146) documenting 'avoids an O(N²) cascade' — yet the sibling `isSelected` reactive path was left calling the O(N) provider method per-item. Matches the documented Listbox-rovingTarget incident exactly (same provider). Tests max out at 3 items (alpha/beta/gamma, lines 167-212) with no large-N/perf test, so the hazard is unguarded. Fix per candidate is correct; cleanest form: provider-level `readonly selectedSet = $derived(new Set(this.opts.value.current))` consulted via `.has()` from `isSelected()`, mirroring the existing line-151 Set.
- fix-status: fixed (92f988e7)
### MEDIUM: SYS-1: Scope drift — eidos recipe directory exists but morfo.scope does not declare 'eidos — listbox-002 <!-- id: listbox-002 -->
- dimension: A (Contract)
- rule: SYS-1: Scope drift — eidos recipe directory exists but morfo.scope does not declare 'eidos'
- evidence: Morfo line 7: `scope: ['soma', 'sema']` omits 'eidos'. Directory structure confirms eidos layer exists with full implementation: listbox.svelte, listbox-item.svelte, listbox-group.svelte, listbox-item-indicator.svelte, listbox-group-label.svelte, listbox.css, types.ts, index.ts.
- impact: Morfo contract validator may not check eidos layer compliance; documentation/tooling may omit eidos layer from automated coverage. Scope mismatch creates maintenance liability if eidos changes are made without realizing morfo is unaware.
- verify: [confirmed] CONFIRMED MEDIUM (canonical SYS-1). Morfo line 7: `scope: ['soma', 'sema']` (file ends correctly with `} as const satisfies Morfo;` — contract annotation is fine, not a separate finding). The eidos layer materially exists at src/uix/eidos/components/listbox/: listbox.css (6574 bytes), listbox.svelte, listbox-item.svelte, listbox-group.svelte, listbox-group-label.svelte, listbox-item-indicator.svelte, types.ts, index.ts — a full implementation. Per SYS-1, eidos-recipe-dir-exists + morfo-scope-omits-'eidos' = MEDIUM scope-drift, and that is exactly the case here. Context: this is systemic — only 63/128 morfo components declare 'eidos' in scope despite 130 eidos dirs; major interactive siblings (toggle/dialog/select/combobox/popover) all omit it too. Minor correction to candidate evidence: there is NO `listbox:` recipe key in lib/recipes/base.ts (grep returns nothing) — the CSS is foundation-only, not recipe-driven. But SYS-1 triggers on the eidos DIR existence, which is confirmed, so the finding stands at MEDIUM. Fix: add 'eidos' to the scope array.
- fix-status: fixed (212624e0)
## No-findings dimensions
C (DOM-selector - CSS.escape use safe on framework numeric IDs), D (Frontier - no soma→eidos imports, no double-write divergence), E (TSC theming - all CSS vars reference canonical --color-* / --space-* / --radius-* / --opacity-* / --font-size-*), G (Redundancy - keyboard nav index math consistent, no duplication with divergence risk)
## Theming facts (E-bis)
- magic z-index: none
- magic literals: 0.875rem (listbox.css:185 item-indicator size - should reference --icon-size-sm or similar scale)
- untested: Large-scale multi-select (30+ items) performance under rapid selection changes — A31 O(N²) risk not validated; Disabled item filtering in roving target (getItems() correctly scoped to enabled items per line 137-138); Horizontal orientation + RTL keyboard direction (declared in morfo, not tested)
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 3 · LOW 3.
systemic hits: SYS-1 (scope-drift: sema declared but not implemented); SYS-3 (keyboard/focus untested at component level, only nav logic tested); SYS-2 (magic z-index fallbacks, bare duration literals).
- evidence: Morfo declares scope: ['soma','sema','eidos'] but no recipe file found in src/uix/eidos/lib/recipes/base.ts or recipe subdirectory for menu-dial
- impact: If the morfo declares eidos in scope, the component requires either a recipe in base.ts or a recipe directory (src/uix/eidos/lib/recipes/menu-dial/), but neither exists. CSS is present but not declared in the recipe system.
- proposed-fix: Verify intent: if eidos styling is complete via CSS alone, document this exception. If tokens should be in base.ts, add a menu-dial recipe entry.
- verify: [confirmed] Confirmed via grep: `grep menu-dial base.ts` returns NOTHING — there is no `'menu-dial':` recipe key in src/uix/eidos/lib/recipes/base.ts. The morfo declares `scope: ['soma','sema','eidos']` (line 19). Crucially the TWIN component it repeatedly mirrors, `onion-menu`, DOES have a recipe block (base.ts:4368) whose header comment reads 'this owns the magic numbers that USED to be literals in onion-menu.css.' Menu-dial's CSS still carries those exact literals un-tokenized (28ms stagger, 2px glyph thickness — base.ts:4374 names onion's as `glyph-bar-thickness: '2px'`). So this is a genuine THEMING/recipe gap, not just a documentation note. Downgraded HIGH→MEDIUM: it's a token-coverage inconsistency vs the sibling, not a user-visible break. Confirmed at MEDIUM.
- evidence: Only menu-dial-nav.test.ts exists (tests pure navigation logic). No provider test (no menu-dial-provider.svelte.test.ts or equivalent in eidos). The nav tests verify wrapping, disabled-skipping, Home/End but do NOT test keyboard routing through the soma/eidos integration: Enter/Space to open/close, Arrow routing through list while open, focus sync, dismiss on Escape.
- impact: Keyboard behavior (the primary interaction mode per APG) is untested at the component level. The nav helpers are tested in isolation but their integration with focus, dismissal, and the open/close state is not exercised.
- proposed-fix: Add a @vitest-environment=jsdom or Playwright client test file (e.g., menu-dial-provider.svelte.test.ts) covering: trigger keydown (Enter/Arrow to open), list keydown (Arrow/Home/End navigation, Escape to close), focus sync after open, Tab-out dismiss. Mark high-risk paths.
- verify: [confirmed] Confirmed: `ls src/uix/soma/components/menu-dial/` shows only `menu-dial-nav.test.ts` (verified, reads it) which tests the PURE `dialNav*` index math (count 4 wrap, disabled-skip [1,2], Home/End, degenerate empty/all-disabled) in isolation — no DOM, no @vitest-environment. There is NO provider/integration test anywhere (grep'd both soma and eidos menu-dial dirs). The integration surface is entirely untested: trigger keydown→open (runtime.keydown('trigger')), list Arrow/Home/End routing through focus (`focusActionAt` + actionEls() querySelectorAll), Escape→close, focusout/outside-pointerdown dismiss ($effect lines 254-291), focus-return-to-trigger (line 176), the pendingFocus $effect (270-279). This is a textbook SYS-3: interaction-heavy component, the easy pure-math path tested, every keyboard/focus/dismissal integration path untested. Confirmed MEDIUM.
- rule: THEMING (recipe ownership of magic numbers) / 2-of-3
- location: src/uix/eidos/components/menu-dial/menu-dial.css:25,291-292 vs src/uix/eidos/lib/recipes/base.ts:4368-4377
- evidence: menu-dial.css line 25 `--_menu-dial-stagger-step: 28ms;` and line 291-292 `block-size: 2px; margin-block-start: -1px;` (the glyph bar) are un-tokenized literals. The TWIN component onion-menu — which menu-dial's own comments say it 'mirrors' — moved exactly these into a base.ts recipe: base.ts:4374 `'glyph-bar-thickness': '2px'`, and onion's header (4364-4366) explicitly says the recipe 'owns the magic numbers that USED to be literals in onion-menu.css.' menu-dial has NO base.ts recipe block at all (grep 'menu-dial' base.ts → empty).
- impact: menu-dial regressed on the precise pattern the framework already established for its sibling: the speed-dial's tunable timing (28ms stagger) and glyph thickness (2px) are frozen in CSS with no recipe knob, so a theme cannot retune them and they can silently drift from onion-menu's tokenized equivalents. This is the concrete, code-grounded core of candidate findings 002+006.
- proposed-fix: Add a `'menu-dial':` block to base.ts recipes mirroring onion-menu: e.g. `'stagger-step': '28ms'`, `'glyph-bar-thickness': '2px'` (referencing the same source onion uses), and consume `var(--menu-dial-glyph-bar-thickness)` / `var(--menu-dial-stagger-step)` in the CSS.
- verify: [verifier-added] added by adversarial verify pass
- evidence: Morfo declares aria-controls on trigger (line 84-88 of morfo: 'aria-controls: value: v.partRef("list")', recommended severity) and aria-labelledby on list (line 112-116: 'aria-labelledby: value: v.partRef("trigger")'), but neither is set in the Svelte implementation. Trigger has no aria-controls attribute; list has no aria-labelledby attribute.
- impact: Accessibility regression: screen readers cannot programmatically associate the trigger button with the menu it controls, violating ARIA authoring practices. The list is labeled aria-label=ariaLabel (same as trigger), creating ambiguity.
- proposed-fix: Generate unique IDs for trigger/list on mount. Add aria-controls=listId to Fab trigger and aria-labelledby=triggerId to list div. Both attributes marked as 'recommended', so failing to set them should be flagged but component is still functional.
- verify: [downgraded] Confirmed factually: morfo lines 83-88 declare trigger `aria-controls: v.partRef('list')` and lines 110-116 declare list `aria-labelledby: v.partRef('trigger')`. grep for `aria-controls|aria-labelledby|id=|useId` in the menu-dial eidos dir returns ZERO hits — neither attr is emitted, and no IDs are generated. menu-dial.svelte sets only `aria-haspopup='menu'` + `aria-expanded` on the Fab (lines 321-322) and `aria-label={ariaLabel}` on both trigger and list (lines 320, 335). HOWEVER both morfo declarations carry `severity: 'recommended'` (lines 87, 115) — NOT 'required'. The component is functional and named: trigger has aria-label + aria-haspopup + aria-expanded; list has aria-label + role='menu'. The sibling onion-menu also omits aria-controls (grep confirmed), so this is a family-wide pattern, not a unique regression. A missing 'recommended' attr on an otherwise-named control is a LOW/nice-to-have a11y nit, not a HIGH a11y break. Downgraded HIGH→LOW.
- fix-status: open
### LOW: TWO-MOMENTS (sequence pre vs post) — menu-dial-004 <!-- id: menu-dial-004 -->
- evidence: closeMenu() sets open=false (line 171) BEFORE calling runtime.trigger('close', ...) (line 175). Morfo declares close event has sequence='pre', meaning it should fire BEFORE state changes. The reactive data-state binding (line 334: data-state={open ? 'open' : 'closed'}) will update synchronously when open changes, moving the list to data-state='closed' before the event is emitted.
- impact: For 'pre' sequence events, the content (list with data-state) should still be marked as open when the event fires (for announcements, sounds), but the binding changes the state first. Same issue in openMenu (line 164).
- proposed-fix: Reorder: trigger the event BEFORE updating open state, or untrack the state update so the event fires first. e.g., `runtime.trigger('close', ...); open = false;` or use $effect to emit after measuring.
- verify: [downgraded] Factually correct: closeMenu (eidos line 169-177) sets `open = false` (line 171) BEFORE `runtime.trigger('close', ...)` (line 175); same for openMenu (line 164 before 166). The template binds `data-state={open ? 'open' : 'closed'}` reactively (line 334). BUT the impact is overstated for two reasons I verified: (1) runtime.svelte.ts:741-753 shows `sequence` ONLY orders `runEmit` vs an optional `handler` — menu-dial passes NO handler to trigger(), so 'pre' vs 'post' is functionally moot here; the morfo `commits` is also inert because eidos does not register the list via `runtime.part(...)`, it drives data-state itself via the Svelte binding. (2) The emit target is `listEl` (fallbackTarget, line 175), the list ELEMENT which persists in the DOM regardless of `data-state` — so the sema `data-event-*` stamp + hold are NOT lost; only the visual `data-state='closed'` flips one tick early. The perceptual signal still fires on a live element. This is a mild ordering smell, not a dropped-signal bug. Downgraded MEDIUM→LOW.
- evidence: Line 25: --_menu-dial-stagger-step: 28ms (bare millisecond value); line 136 & 312: fallback 150ms (bare ms in var() fallback); line 283 transition uses var(--duration-normal, 300ms) — fallback hardcoded.
- impact: Bare duration literals (28ms, 150ms, 300ms) are not canonical tokens. Should reference --duration-* scale (e.g., --duration-fast, --duration-normal). Fallbacks in var() are fragile.
- proposed-fix: Define --_menu-dial-stagger-step as a component-scoped alias of a canonical duration token (or expose it in recipes). Replace magic durations: 28ms should be a named token if used for stagger timing; 150ms/300ms should use canonical tokens (--duration-fast, --duration-normal, --duration-slow).
- verify: [downgraded] Split verdict on the cited literals. The `var(--duration-fast, 150ms)` (line 136) and `var(--duration-normal, 300ms)` (line 283) are NOT drift — they reference the canonical tokens (verified `--duration-fast`/`--ease-default`/`--ease-out` are defined in generated/base.css); the ms fallback is dead/redundant noise, and the IDENTICAL pattern appears in sibling onion-menu.css (lines 43, 182) — so flagging it as a defect here while the family uses it is inconsistent. The ONE genuine un-tokenized literal is `--_menu-dial-stagger-step: 28ms` (line 25): unlike onion-menu's `glyph-bar-thickness` it has no recipe token backing it, and it's a real animation-timing magic number that the recipe (absent, see 002) should own. So the finding has a kernel of truth (28ms) wrapped in two false flags (150ms/300ms fallbacks are token-backed). Downgraded MEDIUM→LOW and folded into 002's recipe gap.
- untested: keyboard route integration (Enter/Arrow/Escape on trigger/list); focus sync and focus return; open/close perceptual emit timing; dismiss on Tab-out; commit-select lifecycle; aria-label / aria-controls resolution
## Style observations (non-blocking)
- The CSS is well-structured with clear placement zones (9 fixed positions) and arc math (cos/sin layout). The stagger via CSS custom property --_menu-dial-i is elegant. Hover close delay (260ms) is reasonable for crossing the gap. Glyph rotation from + to × is smooth. No redundancy vs other components observed; this design is specific to radial fan layout.
- The arc geometry calculation in CSS (radius := max(r-clear, r-spread)) is mathematically sound and self-contained.
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 3 · LOW 4.
systemic hits: SYS-1 (scope-drift: morfo omits 'eidos' but eidos recipe exists); SYS-2 (magic z-index: 1 without token); SYS-3 (jsdom-only tests on interaction-heavy component); SYS-4 (duplicated keyboard index math in two methods).
- evidence: scope: ['soma', 'sema'], but a full eidos recipe directory exists at src/uix/eidos/components/menubar/ with 8 files (menubar.svelte, menubar.css, menubar-trigger.svelte, menubar-content.svelte, menubar-context.ts, menubar-menu.svelte, types.ts, index.ts)
- impact: Morfo declares scope omits 'eidos' while an eidos recipe dir exists, violating the scope-drift rule SYS-1. The eidos layer exists and provides the full visual contract (menubar.css, size context, data-stagger, data-size on provider).
- repro: Check src/uix/morfo/components/menubar.ts line 7 against presence of src/uix/eidos/components/menubar/ directory.
- proposed-fix: Add 'eidos' to the scope array: scope: ['soma', 'sema', 'eidos']
- verify: [confirmed] morfo line 7 reads `scope: ['soma', 'sema'],` yet a full eidos recipe dir exists at src/uix/eidos/components/menubar/ (menubar.css, menubar.svelte, menubar-content.svelte, menubar-context.ts, types.ts, index.ts). The morfo's OWN comment contradicts the scope: line 8-10 cites the sema pack, and the eidos wrapper stamps data-size + reuses the dropdown recipe. Sibling components with an eidos dir DO list 'eidos' (accordion.ts:7, dropdown-menu.ts:11 both `['soma','sema','eidos']`). This is exactly SYS-1 scope-drift. HIGH is justified: the compiled scope is the contract the validators key on, and it's wrong.
- fix-status: fixed (212624e0)
### MEDIUM: SYS-4 keyboard route duplication + index math duplicated — menubar-004 <!-- id: menubar-004 -->
- dimension: B (Keyboard), G (Redundancy)
- rule: SYS-4 keyboard route duplication + index math duplicated
- location: src/uix/soma/components/menubar/menubar-provider.svelte.ts:131-146 and 283-296
- evidence: navigateAdjacent() method (lines 135-143): if (idx === -1) { next = delta === 1 ? 0 : values.length - 1; } else if (loop) { next = (idx + delta + values.length) % values.length; } else { next = Math.max(0, Math.min(...)); } — IDENTICAL logic in onContentKeydown() (lines 285-291) for nextIdx calculation.
- impact: The index arithmetic for navigating between triggers is duplicated across two code paths (navigateAdjacent called from trigger onkeydown and directly in onContentKeydown). This duplicated logic risks divergence if one path is later updated. Both methods calculate the next index identically, but SYS-4 flags this as a known pattern to extract.
- repro: Compare lines 135-143 (navigateAdjacent index calc) with lines 285-291 (onContentKeydown index calc) — they are textually identical.
- proposed-fix: Extract the index calculation into a private helper method like getNextIndex(currentValue, delta, values, loop) and reuse it in both navigateAdjacent and onContentKeydown.
- verify: [confirmed] Confirmed as duplication, MEDIUM is right. Lines 137-143 (navigateAdjacent): `if (idx === -1) { next = delta === 1 ? 0 : values.length - 1; } else if (this.opts.loop.current) { next = (idx + delta + values.length) % values.length; } else { next = Math.max(0, Math.min(values.length - 1, idx + delta)); }` is textually identical to lines 285-291 in onContentKeydown (same three-branch math on nextIdx). This is the SYS-4 nav-dup pattern. IMPORTANT: the off-by-n-2 half of SYS-4 is NOT present here — the idx===-1 branch lands on `values.length - 1` (the LAST item) for delta -1, which is correct, not n-2. So it's pure extract-worthy duplication with divergence risk, not a behavioral bug. MEDIUM confirmed.
- fix-status: open
### MEDIUM: Test environment: jsdom only, no client/Playwright coverage — menubar-007 <!-- id: menubar-007 -->
- dimension: F (Tests)
- rule: Test environment: jsdom only, no client/Playwright coverage
- evidence: // @vitest-environment jsdom — tests run in jsdom (headless) only. 268 lines of test code covering MenubarProvider, but no client (Playwright) variant to verify DOM interaction, focus management, floating positioning, or hover behavior in a real browser.
- impact: SYS-3: jsdom-only tests + interaction-heavy component + keyboard/focus logic. While keyboard routes are tested (ArrowRight, ArrowLeft, Home, End, Enter, Space, ArrowDown), they are mocked event objects in a simplified environment. Focus sync, floating anchor positioning, and real keyboard interaction are untested.
- repro: Test file environment is jsdom; test coverage focuses on state/logic, not DOM/focus/positioning. No client-side tests found.
- proposed-fix: Create a parallel menubar-provider.test.ts with @vitest-environment=node or add menubar-provider.e2e.ts with Playwright to verify: (1) real focus shifts, (2) floating content positioning, (3) hover-follow switching, (4) Escape focus return, (5) RTL directional keys.
- verify: [confirmed] Confirmed. menubar-provider.svelte.test.ts:1 is `// @vitest-environment jsdom`. Events are plain mocked objects (keyEvent/pointerEvent factories lines 84-98 return `{ key, target, preventDefault: vi.fn() }`). Keyboard ROUTES are exercised (ArrowRight/Left, Home/End, ArrowDown open, content-nav switch), which is better than many SYS-3 cases, but real focus movement is only asserted via a `vi.spyOn(dom,'focus')` spy — actual DOM focus, roving-tabindex realization, floating positioning, and Escape focus-RETURN-to-trigger (the queueMicrotask path at provider:258-261) are NOT verified in a browser. Interaction-heavy floating component + jsdom-only + no client/Playwright sibling = SYS-3. MEDIUM appropriate.
- evidence: 'aria-controls': this.isOpen ? this.menu.menu.contentId.current || undefined : undefined, — emitted by soma but not declared in morfo's trigger part aria array
- impact: The trigger part declares aria: [{ attr: 'type', ... }, { attr: 'aria-haspopup', ... }, { attr: 'aria-expanded', ... }] but aria-controls is emitted dynamically by soma without being in the contract. This breaks the two-of-3 rule (morfo ↔ soma/sema/eidos consistency).
- repro: grep 'aria-controls' soma provider; verify it is not in morfo trigger aria array at lines 55-59.
- proposed-fix: Add aria-controls to morfo trigger part: { attr: 'aria-controls', value: v.expr('open ? menuId : undefined') } or similar pattern to declare the dynamic aria-controls as a morfo-driven attribute.
- verify: [downgraded] Confirmed factually: morfo trigger aria array (lines 55-59) declares only `type`, `aria-haspopup`, `aria-expanded` — no `aria-controls`. Provider emits it at line 431: `'aria-controls': this.isOpen ? this.menu.menu.contentId.current || undefined : undefined`. The sibling dropdown-menu morfo DOES declare it (dropdown-menu.ts:90-95 with `condition: { when: 'part-present', part: 'content' }`), so the canonical fix is exactly the proposed one. BUT severity HIGH overstates it: the attribute is functionally correct and present at runtime, ARIA is not broken, and it is conditionally emitted only while open. This is a contract-completeness gap (the morfo should declare what soma emits), not a user-visible a11y break or a divergence risk — LOW/contract-noise tier, not HIGH.
- impact: A bare 1px literal is used for outline-offset instead of a canonical --outline-offset-* or --focus-offset-* token. This violates E-bis rule: spacing/offset values should use canonical token scale --space-* or a purpose-specific --focus-offset-*.
- repro: grep 'outline-offset' menubar.css shows a bare pixel value without a var() reference.
- proposed-fix: Replace outline-offset: 1px with a canonical token like outline-offset: var(--outline-offset-focus) (if defined in the design system) or define one.
- verify: [downgraded] menubar.css:104 `outline-offset: 1px;` is a bare literal — factually correct. But this is a pervasive baseline idiom, not a menubar drift: badge.css:183 uses the identical `outline-offset: 1px;`, and dozens of components use bare `outline-offset: 0/2px`. There is no `--outline-offset-*`/`--focus-offset-*` token in the system for this (the proposed fix invents one). A 1px focus-ring gap is a fixed perceptual constant, not a position on the --space-* rhythm. Real but LOW cosmetic token-nit, not MEDIUM.
- evidence: [data-menubar-trigger]:focus-visible { ... z-index: 1; } — a bare integer z-index instead of a named token
- impact: SYS-2: bare z-index integer without a canonical --z-index-* token. The value 1 is arbitrary and not tied to the design system's z-index scale (e.g., --z-index-overlay, --z-index-dropdown).
- repro: Line 106 of menubar.css has z-index: 1 without var().
- proposed-fix: Define or reuse a canonical z-index token: z-index: var(--z-index-focus) or similar, ensuring it fits the system's layering strategy.
- verify: [downgraded] menubar.css:106 `z-index: 1;` inside `[data-menubar-trigger]:focus-visible` (with `position: relative`) is a bare integer — factually correct. But this is the standard focus/in-flow stacking idiom (raise the focused sibling within a local stacking context so its outline isn't clipped), used by the POSITIVE REFERENCE tabs.css:281 (`position: relative; z-index: 1;` for triggers over the indicator) and ~19 other components (avatar, button-group, carousel, dialog, combobox, toggle-group, virtual-list...). SYS-2's cited examples are LAYERING-SCALE values (content-z/overlay-z) — e.g. dropdown-menu.css:34 `z-index: var(--dropdown-menu-content-z)` — NOT focus +1. A local +1 is not a position on the global layering scale and has no canonical --z-index-* token. Real literal but LOW, and flagging it MEDIUM/SYS-2 while the reference component does the identical thing is inconsistent.
- fix-status: open
### LOW: 2-of-3 rule: data-menubar-value consumed only by soma trigger, not by sema or eidos — menubar-008 <!-- id: menubar-008 -->
- dimension: A (Contract)
- rule: 2-of-3 rule: data-menubar-value consumed only by soma trigger, not by sema or eidos
- location: src/uix/morfo/components/menubar.ts:53 and src/uix/soma/components/menubar/menubar-provider.svelte.ts:435
- evidence: morfo declares { attr: 'data-menubar-value' } in trigger part data array (no values list, consumed by soma). Eidos does not consume it (not in menubar-trigger.svelte), and no sema reference found.
- impact: The 2-of-3 rule suggests each morfo field should be consumed by >=2 of soma/sema/eidos. data-menubar-value is only used by soma (MenubarTriggerProvider.props emits it). This is a low-priority observation because it may be intentional for internal soma wiring (store the menu value for internal reference), but it's worth confirming the rule intent.
- repro: data-menubar-value is declared in morfo but appears only in soma provider output, not in eidos CSS selectors or sema logic.
- proposed-fix: Verify if data-menubar-value is only a soma-internal marker. If it's consumer-facing, ensure eidos or another layer consumes it for styling or state tracking.
- verify: [confirmed] Confirmed factually and the analyzer correctly rated it LOW. morfo declares `{ attr: 'data-menubar-value' }` in trigger data (menubar.ts:53); provider emits it at menubar-provider.svelte.ts:435 `'data-menubar-value': this.menu.opts.value.current`. grep for data-menubar-value across src/uix/eidos and src/uix/sema returned EMPTY — neither eidos CSS nor the sema pack consumes it (the sema pack keys on `data-menubar`/`data-menubar-trigger` via semaSelector, not the value). So it is consumed by exactly 1 of 3 (soma only). It is a legitimate soma-internal sibling-location marker (cross-menu nav reads the registry, but the attr documents the value on the DOM), so the 2-of-3 carve-out for internal wiring applies. LOW contract-noise, as rated.
- fix-status: open
## No-findings dimensions
C (DOM-selector), D (Frontier), E (TSC)
## Theming facts (E-bis)
- magic z-index: z-index: 1 at menubar.css:106
- magic literals: outline-offset: 1px at menubar.css:104
- undeclared parts: data-size on provider (eidos emits, morfo not declares) | data-stagger on content (eidos emits, morfo not declares) | data-floating-gap on content (eidos emits, morfo not declares)
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: coordinates sibling menus through root value; navigates enabled triggers in DOM order + skips disabled; opens trigger from keyboard without moving focus; switches open menu from content horizontal navigation
- untested: real DOM focus sync; floating anchor positioning; hover-follow behavior in real browser; Escape focus-return in real browser; RTL directional key behavior (ArrowRight/Left swap); submenu escape and cross-menu navigation in real content; typeahead (if implemented); client-side interaction
## Style observations (non-blocking)
- The eidos recipe is well-structured and reuses dropdown-menu parts effectively for per-menu content.
- data-shape-nest concentric-radius pattern (menubar-trigger.svelte:18) is a thoughtful design that avoids hand-rolled calc.
- Hover-follow and cross-menu arrow navigation logic is coherent and well-documented.
- The size cascade via context (MenubarSizeContext) is clean and follows Svelte patterns.
- MenubarMenuProvider acts as a clean adapter layer between menubar state and nested DropdownMenu, which is architecturally sound.
cleanup-audit (A6/A35/A36): MeterProvider: NO timers, listeners, observers, or resources requiring disposal. All state is via $derived (safe, reactive). MeterIndicatorProvider: same - no cleanup needed. A35/A36 loops: NOT POSSIBLE - no $effect usage anywhere, only safe $derived and readableActive helpers. Result: clean lifecyc
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
- evidence: Morfo declares `scope: ['soma']` (meter.ts:7), yet a full eidos layer materializes the component: a recipe entry `meter: { ... }` at src/uix/eidos/lib/recipes/base.ts:981-1010 (height/radius/ring/track/indicator tokens), and an eidos component dir src/uix/eidos/components/meter/ containing meter.css, meter.svelte (visual wrapper, size+shape), meter-indicator.svelte, types.ts. The scope array does not list 'eidos' despite this eidos materialization.
- impact: Contract metadata drift: the morfo scope no longer reflects which layers consume the component. Tooling/lint that keys off `scope` to know an eidos recipe should exist will under-report. Consistency only — runtime behavior unaffected.
- proposed-fix: Add 'eidos' to the meter morfo `scope` array: `scope: ['soma', 'eidos']`, matching the other B7 components that declare the eidos scope.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: fixed (212624e0)
## No-findings dimensions
B, C, D, E, F, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: true · env: jsdom
- covers: meter value attrs and indicator zone projection; default resolution and clamping; labelledby and valueText overrides
method: adversarially-verified workflow; HIGH lead-verified. B7 ground-truth: Toolbar A35 + Form A36 incidents STAYED FIXED (untrack present); toast/clipboard/drag-drop use uix.timers (no A6 leak); button is clean (its archetype finding was inert — Spinner not runtime-registered); data-size/data-shape are eidos visual attrs (not contract violations).
provider: G:/dev/svelte/vicen/src/uix/soma/components/metrics/metrics.svelte.ts (runtime-only, no traditional provider)
cleanup-audit (A6/A35/A36): No timers, setInterval, ResizeObserver, IntersectionObserver, MutationObserver, addEventListener, or pointer listeners declared in eidos or soma directories. A35 loop check: metrics-delta's $effect writes to context.setIntent (line 46-48), which updates root's currentIntent state, but no component r
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
## Findings
### MEDIUM: F (Tests): a component with a non-trivial soma runtime path (live-region dispatch + value- — metrics-no-runtime-test <!-- id: metrics-no-runtime-test -->
- dimension: F
- rule: F (Tests): a component with a non-trivial soma runtime path (live-region dispatch + value-change effect + intent-publish effect) should have a provider/runtime test mapping the RISK paths. None exists.
- location: src/uix/soma/components/metrics/ (no *.test.ts) — verified via find/grep: zero files reference metricsMorfo/createMetricsRuntime/notifyUpdate in any *.test.ts/*.spec.ts
- evidence: `createMetricsRuntime` (metrics.svelte.ts:17) is exercised through three reactive code paths with no coverage: (1) the live-signal dispatch — metrics.svelte:57-63 `notifyUpdate` → `runtime.trigger('signal-notify-update', { fallbackTarget: el, message: text })`, which routes through `uix.announce` via the morfo's `a11ySemantic.requiresLiveRegion` (metrics.ts:64); (2) the value-change detector — metrics-value.svelte:21-32 (`prev`/`firstRun` memo, the `live`-off no-op path, and the first-run-suppressed path); (3) the intent-publish effect — metrics-delta.svelte:46-48. These are exactly the live-region/effect paths the audit flags as high-risk, yet there is no test asserting the trigger fires only on a real change, stays inert when `live` is off, suppresses the first run, and announces `message`.
- impact: Regressions in the live-update signal (e.g. re-introducing a first-run announce, or firing when value is unchanged, or breaking the `live`-off no-op gate) would ship silently. The change-detection memo logic (metrics-value.svelte:18-32) is subtle and untested.
- repro: find/grep over src for metric*test / metricsMorfo|createMetricsRuntime|notifyUpdate in *.test.ts returns nothing.
- proposed-fix: Add src/uix/soma/components/metrics/metrics.svelte.test.ts (or a runtime test) using a real ActiveUix via createActiveUix/attachActiveUix (never a fake announce per project rule): assert `notifyUpdate` triggers `signal-notify-update` only on a value change when `live` is on, is a no-op when `live` is off, suppresses the first run, and forwards `message` to the live region.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: open
## No-findings dimensions
A: Contract (morfo scope satisfies, parts declared and present, 2-of-3 rule, part data-* naming correct, aria declarations present), B: Behavior (A35: no per-item effect reading opts.ref.current + writing provider state; A36: no async-microtask-mediated freeze; A33: no $state(Map/Set) non-reactive; A31: no O(N²) derived with global provider reads; A6: no timers/observers/listeners declared; A30: no child->parent id registration in $effect; A15: no gesture; LIVE REGIONS: signal-notify-update correctly dispatched via runtime.trigger with message + announce; A34: announce provider reachable), C: DOM-selector (no interpolated consumer values; global document/window not used), D: Frontier (no soma imports from eidos; no eidos imports from soma except in root to call createMetricsRuntime; data-size/color/variant are visual attrs; Progress/Gauge use framework Meter composition), E: TSC tokens (all spacing via --space-* tokens; all icon-size via --icon-size-*; all color via CSS custom properties; no hardcoded focus-ring or :active), E-bis Theming (Delta composes Badge which uses canonical color roles; featured icon colors use 9 canonical roles; no hardcoded hex; recipe tokens via --metrics-* private scale), F: Tests (no test file present - gap noted but not violation per baseline), G: Redundancy (standard context pattern for live-signal bridge; no gesture re-implementation)
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
## Tests (F)
- exists: false · env: N/A
- covers:
- untested: signal-notify-update dispatch when live=true and value changes; intent derivation (trend vs goodTrend); size cascading to sub-parts (Badge, Meter, icon size); aria-label generation on Delta; part composition (Badge, Meter, Sparkline) integration; reduced-motion behavior (if applicable); announce() called with correct priority (polite/assertive)
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified by the lead. Batch-3 ground-truth: each picker fires trigger(close) (close NOT inert), but open/commit-reset ARE inert; calendar/range-calendar are MID-REFACTOR (uncommitted view-switch work).
- evidence: Morfo declares `scope: ['soma', 'sema']` but eidos components and CSS exist at src/uix/eidos/components/month-grid/
- impact: Systemic inconsistency. Consumers may not expect eidos layer. Type safety and documentation alignment degraded.
- repro: Observe the eidos directory structure.
- proposed-fix: Update morfo scope to `scope: ['soma', 'sema', 'eidos']`
- verify: [confirmed] Confirmed SYS-1 scope-drift. month-grid.ts:7 declares `scope: ['soma', 'sema']` while a full eidos layer exists: src/uix/eidos/components/month-grid/ contains month-grid.css (recipe-consuming `[data-month-grid]` block at lines 3-7, e.g. `--_month-grid-cell-size: var(--calendar-day-size-md)`), plus 8 .svelte wrappers, types.ts, index.ts and README.md. The morfo omits 'eidos' from scope. This is the known systemic MEDIUM baseline — matches the SYS-1 pattern seen across batch-1/2.
- fix-status: fixed (212624e0)
### LOW: DOM-selector: querySelector/querySelectorAll must not interpolate consumer/state-derived v — month-grid-001 <!-- id: month-grid-001 -->
- dimension: C
- rule: DOM-selector: querySelector/querySelectorAll must not interpolate consumer/state-derived values without CSS.escape()
- evidence: const el = root.querySelector<HTMLElement>(`[data-month-grid-cell][data-value="${this.placeholderYear}-${nextMonth!}"]`);
- impact: If placeholderYear or nextMonth contained CSS special characters (e.g., backslash, quote, bracket), the selector would break or match unintended elements. While current numeric values are safe, future state mutations or indirect consumer control could introduce injection risk.
- repro: Not exploitable with current numeric-only values, but violates the defensive selector rule.
- proposed-fix: Use CSS.escape(): `const el = root.querySelector<HTMLElement>(`[data-month-grid-cell][data-value="${CSS.escape(this.placeholderYear + '-' + nextMonth!)}"]`);`
- verify: [downgraded] Selector at month-grid-provider.svelte.ts:247-248 reads: `root.querySelector<HTMLElement>(`[data-month-grid-cell][data-value="${this.placeholderYear}-${nextMonth!}"]`)`. The interpolated parts are strictly numeric: `placeholderYear = this.opts.placeholder.current.year` (line 113, `MonthPlaceholder.year: number`) and `nextMonth` is a month clamped to 1..12 by the overflow logic at lines 224-233. Neither can carry CSS metacharacters, so this is NOT user-hittable and NOT CRITICAL — the candidate's own repro field admits 'Not exploitable with current numeric-only values'. It IS a real deviation from the project's own consistent discipline: radio-group:93, tabs:109, listbox:196, grid-list:184, command:394, tree-view:110, tree-grid:162/214 all wrap data-value interpolation in CSS.escape(). But the closest sibling year-grid-provider.svelte.ts:219 does the identical unescaped numeric interpolation (`[data-year-grid-cell][data-value="${nextYear!}"]`), confirming this is an intentional grid-family pattern for numeric coordinates, not an oversight unique to month-grid. Downgrade CRITICAL -> LOW: cosmetic consistency nit, zero behavioral/security impact.
- fix-status: open
## No-findings dimensions
B, D, E, E-bis, F, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
- conformance: All CSS tokens use var(--*) references; no hardcoded px/rem/em/%. All 8 of 9 canonical color roles used (primary, secondary, neutral, affirm, fulfill, risk, threat, loss). Sizes xs/sm/md/lg via responsive prop. Variants use ControlVariant. No bare z-index, opacity, or letter-spacing literals. data-size/variant/color are eidos-owned visual attrs, not violations.
## Tests (F)
- exists: false · env: N/A
- covers:
- untested: keyboard navigation (all 10 keys: Arrow×4, Home/End, PageUp/PageDown, Enter/Space); year wrap-around on month overflow; roving focus with tabindex toggle; placeholder year changes; min/max value bounds enforcement; readonly mode; disabled state
## Style observations (non-blocking)
- Excellent keyboard handling: all 10 morfo-declared keys implemented with correct overflow wrapping (months 0→12 of prev year; months 13→1 of next year)
- Roving focus implemented correctly: single tabindex=0 on focused cell, -1 on others
- Event emit sequence correct: state mutation pre-emits (commit-set on value.current change, shift-navigate-step on placeholder change)
- Heading id registration via direct assignment in constructor (A30 compliant, not $effect)
- No composition issues (N/A for month-grid; it's not a picker)
- CSS selector at line 248 uses numeric-safe values but lacks defensive CSS.escape() per rule C
- All 7 declared morfo parts registered via runtime.part(); no orphans
- 2-of-3 rule satisfied: all data-* attrs consumed by both soma and eidos
- No data-soma-*, data-eidos-*, or data-air-* naming violations
- No setTimeout/setInterval/listeners/observers → no A6 leak risk
- evidence: `openedAtEffect = $effect.root(() => { $effect(() => { if (this.isOpen) this.lastOpenedAt = Date.now(); }); });` — the disposer RETURNED by `$effect.root` is stored in the `openedAtEffect` field and NEVER invoked. NavigationMenuTriggerProvider has no teardown effect/`$effect(() => () => openedAtEffect())` and no dispose() (grep 'dispose|destroy' in this file: only this one match).
- impact: A6 leak: `$effect.root` deliberately creates a DETACHED, non-auto-disposing reactive root. Each NavigationMenuTriggerProvider instance leaks one root permanently subscribed to `this.isOpen` (which reads provider.opts.value via item.isOpen). Triggers mount/unmount with dynamic nav / route changes; every destroyed trigger leaves a live tracking root — accumulating reactive subscriptions for the page lifetime. Contrast: every other `$effect.root` in the codebase is in test files where the returned cleanup IS called.
- repro: Render a NavigationMenu, dynamically add/remove Trigger items (or navigate between routes that mount different nav menus) — each removed trigger leaves an undisposed $effect.root subscribed to isOpen; reactive roots accumulate without bound.
- proposed-fix: Drop `$effect.root` entirely — the provider constructor already runs in a tracking context (the SAME constructor pattern uses bare `$effect` at lines 99 and 109 of the root provider, which auto-dispose). Replace with a bare `$effect(() => { if (this.isOpen) this.lastOpenedAt = Date.now(); })` in the constructor (or class field run during construction), which auto-cleans on unmount. If `$effect.root` must be retained, wire its disposer into an `$effect(() => () => this.openedAtEffect())` teardown.
- verify: [verifier-added] added by adversarial verify pass
- impact: contentId is user-derived (from optional id prop in types.ts line 139) and not CSS-escaped. Special characters like ':' or '[' can break the selector or inject CSS selector logic.
- repro: Pass id='nav-content:test' or id='nav-content[special]' to NavigationMenuContent; ArrowDown keyboard navigation will fail to find the content.
- proposed-fix: Use CSS.escape(this.item.contentId.current) before interpolating into the selector: `#${CSS.escape(this.item.contentId.current)}`
- verify: [confirmed] Line 567: `querySelector(`#${this.item.contentId.current}`)` — no CSS.escape. contentId derives from Content's `id?: string` consumer prop (types.ts:139 'DOM id. Auto-generated when omitted'). The project's OWN convention escapes id selectors: combobox-provider.svelte.ts:418 does `#${CSS.escape(this.provider.highlightedId)}`, and 7 other providers (tabs/listbox/radio-group/grid-list/command/tree-grid/tree-view) wrap data-value interpolations in CSS.escape. navigation-menu is the lone unescaped `#`-id selector. Real dimension-C fragility; HIGH per 'untrusted/consumer-derived selector' rule. ArrowDown-into-content nav silently fails for an id containing ':' '[' etc.
- evidence: @vitest-environment jsdom; tests cover ArrowDown, ArrowRight, but do not test Home, End, Escape, ArrowLeft, or loop=true wrapping behavior. No client/Playwright test exists.
- impact: Critical keyboard navigation paths (Home/End/loop wrap) are untested in jsdom. Interaction-heavy component with high-risk keyboard behavior only validated in headless environment; edge cases (wrapping, boundary conditions) undetected.
- proposed-fix: Add tests for all keyboard routes (Home → first item, End → last item, Escape when open, ArrowLeft, loop=true wrapping at boundaries). Consider a client test for real DOM focus + browser event dispatch.
- verify: [downgraded] CONFIRMED as a gap but DOWNGRADED HIGH→MEDIUM. Test is jsdom-only (line 1 `@vitest-environment jsdom`), no client/Playwright variant exists (only navigation-menu-provider.svelte.test.ts). It exercises ArrowRight (handleListKeydown→close+focus, line 268) and ArrowDown (trigger→content focus, line 211) + open/close/skip-delay timers, but NOT Home/End/Escape/loop-wrap. This is SYS-3 (interaction-heavy + jsdom-only + kbd partially untested). Severity is MEDIUM per the rubric ('missing test on a high-risk path' = MEDIUM), not HIGH: the untested loop math at provider.svelte:336-345 is actually CORRECT — modulo `(i+1)%n` / `(i-1+n)%n` with a `currentIndex===-1` early-return guard (line 325), so the SYS-4 n-2 off-by branch does NOT exist here. No latent bug behind the missing tests, only coverage debt.
- evidence: scope: ['soma', 'sema'] but src/uix/eidos/components/navigation-menu/ directory exists with CSS and component files (navigation-menu.css, navigation-menu.svelte, etc.)
- impact: Scope drift: morfo declares only soma + sema participation, but eidos has full recipes and visual styling. If a consumer or validator only reads morfo, they won't know eidos is active.
- verify: [confirmed] CONFIRMED SYS-1 at MEDIUM. navigation-menu.ts:7 `scope: ['soma', 'sema']` omits 'eidos', yet a full eidos recipe dir exists (navigation-menu.css 310 lines + navigation-menu.svelte + types.ts NavigationMenuSize). Systemic: calendar/color-picker/combobox/command/date-picker morfos likewise scope `['soma','sema']` despite having eidos dirs. MEDIUM is the right severity — informational drift between morfo declaration and the active eidos layer, no user-visible/behavioral consequence.
- fix-status: open
## No-findings dimensions
B, G
## Theming facts (E-bis)
- magic z-index: 1 at line 285 (indicator z-index should be --z-index-* or declared per spec)
field-family (A13/A24-26/A30): A13: COMPLIANT - hidden input rendered when name prop exists (line 76-83 in components/number-field.svelte), carries value + name + disabled/required for form submission. A24: NOT APPLICABLE (no readonly-segments with undefined value scenario). A25: NOT APPLICABLE (not a range field). A26: NOT REQUIRED - number-field uses contenteditable-free architecture (native <inputtype=text> with oninput fil
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 0 · LOW 1.
## Findings
### LOW: A6 — every timer/listener/observer disposed; provider with no disposer that still owns a t — NF-1 <!-- id: NF-1 -->
- dimension: B
- rule: A6 — every timer/listener/observer disposed; provider with no disposer that still owns a timer source
- location: src/uix/soma/components/number-field/number-field-provider.svelte.ts:653-666 (IncrementTrigger) and 719-731 (DecrementTrigger), repeater created in constructor; SpinPressRepeater.stop at :98-107
- evidence: The trigger providers create `this.repeater = createSpinPressRepeater(...)` in their constructors and start keyed `uix.timers` (`schedule`/`interval`) on pointerdown. `repeater.stop()` is wired to onpointerup/leave/cancel/lostpointercapture (props at :694-697 / :760-763), but the provider class has no dispose() that calls `repeater.stop()`. If the component unmounts WHILE a press is mid-flight (delay or interval timer pending), nothing cancels the keyed timer; the pending interval would keep invoking `() => this.provider.increment()` until superseded. The interval task does re-check `if (this.isDisabled())` (:85-88) which mitigates but does not unconditionally stop on unmount.
- impact: Tiny-window latent timer survival: requires pointer held on a spin trigger AND simultaneous unmount. Realistically rare; the keyed scheduler is the shared uix.timers (disposed at UIX teardown) and the isDisabled re-check usually halts it. Not a user-visible leak in normal flows.
- repro: Press-and-hold an increment trigger, then unmount the NumberField before releasing the pointer; observe whether the keyed interval task is cancelled.
- proposed-fix: If a per-component disposer becomes available, call `repeater.stop()` from it; otherwise document that the keyed timers rely on the shared scheduler's lifecycle. No change required if the framework guarantees keyed-timer cleanup on dom dispose.
- verify: [verifier-added] added by adversarial verify pass
- fix-status: open
## No-findings dimensions
A Contract(morfo), A30 id-wiring, A13 hidden-input, B Behavior(soma), B Keyboard routes vs APG, B A6 cleanup, C DOM-selector safety, D Frontier(soma/eidos isolation), E Theming tokens, E-bis Label font rule, F Tests coverage
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: none
- roles clean: true · variants clean: true
- label-font (one step below input?): CORRECT - field recipe defines label at 1-step-below control font via calc(control-font - 1-step); spin-field references --font-size-* tokens correctly (e.g., font-size-md: var(--font-size-md) per THEMING §5)
## Tests (F)
- exists: true · env: jsdom
- covers: parseValue with locale separators + rounding + clamp on blur; keyboard arrow/page/home/end/enter routing vs APG spinbutton; increment/decrement trigger clicks and repeater hold-delay; scrubber movement buffer and pointer capture; RTL horizontal scrub inversion (E-W mirror); vertical scrub RTL-independence; field inheritance via FieldProvider context; locale resolution (prop → soma.langs.getLocale → en); direction resolution (prop → soma.prefs.getDir → ltr)
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified against cited code by the lead.
provider: src/uix/eidos/components/onion-menu/onion-menu.svelte (compositional; soma runtime is src/uix/soma/components/onion-menu/onion-menu.svelte.ts, nav helpers isolated in src/uix/soma/components/onion-menu/onion-menu-nav.ts)
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 3.
systemic hits: SYS-3: interaction-heavy component with keyboard navigation + focus management tested only via unit nav functions; no client test for the eidos root behavior..
- evidence: Only onion-menu-nav.test.ts exists; no onion-menu-provider.svelte.test.ts or eidos component test.
- impact: Keyboard behavior is tested only on the radial nav pure functions (wrap, disabled skip). The critical interaction paths are untested: open/close trigger (A17 focus return, two-moments), drill navigation (drill-up via Backspace), focus management in SVG (real focus in sectors), outside-click dismissal (non-modal). This is SYS-3: interaction-heavy + jsdom-only + lacks a client test.
- repro: grep -r 'test' src/uix/soma/components/onion-menu/ and src/uix/eidos/components/onion-menu/ — only nav test exists.
- proposed-fix: Create src/uix/soma/components/onion-menu/onion-menu-provider.svelte.test.ts and src/uix/eidos/components/onion-menu/onion-menu.test.ts with at least: (1) trigger open/close + focus return; (2) ArrowRight/Left wrapping in a ring with disabled; (3) drill path transitions (Enter on branch); (4) Backspace drill-up; (5) outside-click close; (6) focus remains/restored through open/close cycle. Use client/Playwright if SVG focus testing requires interaction.
- verify: [downgraded] Confirmed: only onion-menu-nav.test.ts (pure index math), geometry.test.ts and color.test.ts (pure engines) exist; no onion-menu-provider.svelte.test.ts nor an eidos .svelte test. The integration behavior lives entirely in onion-menu.svelte (openMenu/closeMenu focus-return at lines 268-281, drillUp Backspace 315-324, real DOM focus into SVG sectors via sectorEl().focus() 262/363, outside-click pointerdown dismissal 372-381, focusout Tab-out 335-340) and is UNTESTED. This is a real SYS-3 gap. Downgraded HIGH->MEDIUM per rubric: the highest-risk index math (wrap/disabled-skip/degenerate) IS covered by onion-menu-nav.test.ts; the untested surface is focus/dismissal/two-moments integration = 'missing test on a high-risk path' (MEDIUM).
- impact: Hard-coded px values for focus ring width and offset are not derived from canonical tokens. These should reference --z-index-* or --spacing-* tokens so focus ring sizing is consistent across the design system.
- repro: Read onion-menu.css lines 172-173 and check base.ts recipe tokens for onion-menu key — focus ring tokens are missing.
- proposed-fix: Define or reference canonical tokens for focus ring: outline should use var(--focus-ring-width, 2px) and outline-offset should use var(--focus-ring-offset, 2px). Wire these from the recipe base.ts if they don't exist.
- verify: [downgraded] Confirmed the literals exist - onion-menu.css:171-173: 'outline: 2px solid var(--focus-ring-color, currentColor); outline-offset: 2px;'. But this is the framework's CANONICAL focus-ring treatment, not drift: the color-engine hand-off (2026-06-01) establishes 'outline: 2px solid' as the canonical focus + forced-colors fallback ('outline: 2px solid Highlight'), and Button uses the same. The cited rule/fix is wrong - it claims --z-index-*/--spacing-* should size a focus ring, which is nonsensical. At most a cosmetic consistency nit, not a MEDIUM token-drift defect.
- impact: Token values are bare numbers without units or as decimal opacity values without a wrapping token reference. Recipe tokens should consistently reference named scales or declare full CSS values.
- repro: Check base.ts lines 4372-4376 and verify the tokens are used correctly in onion-menu.css (e.g., stroke-width: var(--onion-menu-sector-ring-width) should resolve to a valid CSS value).
- proposed-fix: Either fully qualify with units ('2.5px' for ring-width) or wrap opacity in a token lookup. Consider defining canonical --opacity-muted, --opacity-hover-ring as theme tokens that the recipe can then reference. Or emit the values as they are consumed (px for widths, unitless for opacity).
- verify: [downgraded] Mixed claim. The ring-width tokens base.ts:4372-4373 'sector-ring-width':'2.5' / 'sector-hover-ring-width':'2' are consumed as SVG stroke-width: var(--onion-menu-sector-ring-width) (onion-menu.css:53,86,102) - unitless user-space lengths are VALID for SVG stroke-width, NOT a defect; the candidate's 'add px' fix is wrong. The opacity decimals base.ts:4375-4376 'muted-opacity':'0.85' / 'hover-ring-opacity':'0.65' are the only substantive part: a canonical numeric scale --opacity-{0..100} (Tailwind step-5) exists and is widely consumed, so these could reference var(--opacity-85)/var(--opacity-65). Real but minor; the candidate over-broadens and misprescribes.
- impact: The interpolation is numeric-only (level + 1 produces a number), so CSS.escape is not needed here. However, the selector construction is fragile: it relies on DOM order matching the index array exactly. If the SVG structure changes (conditional rendering, key changes), the nth-child relationship breaks silently.
- repro: Read onion-menu.svelte line 111-115 and trace how sectors are inserted/removed — order assumption is implicit in the array access at line 115 (ring?.[index]).
- proposed-fix: Consider using a Map<key,element> stored during render instead of querying by index. Alternatively, add a data-sector-id that is both numeric and query-stable (e.g., data-sector-id="${level}-${index}") and query by that instead of relying on order.
- verify: [confirmed] Confirmed at LOW (the candidate's own severity). onion-menu.svelte:112-113: surfaceEl?.querySelectorAll<SVGPathElement>(`[data-onion-menu-item][data-depth="${level + 1}"]`). The interpolated value is numeric (level+1, internal counter), never consumer-derived, so CSS.escape is correctly NOT required - the candidate concedes this. Only the DOM-order reliance (ring?.[index]) is a design note, and lines 107-116 explicitly justify it (avoids bind:this into a computed key + a ref-map timing race). Borderline a styleObservation; no actual injection/fragility defect.
- fix-status: open
## No-findings dimensions
A, B, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: 2px focus ring width (line 172 onion-menu.css) → --onion-menu-focus-ring-width | 2px outline-offset (line 173 onion-menu.css) → --onion-menu-focus-ring-offset | '2.5' sector-ring-width in recipe (line 4372 base.ts) → should be '2.5px' or unitless opacity | '0.85' muted-opacity (line 4375 base.ts) → should wrap in --opacity-* token
- undeclared parts: none
- roles clean: true · variants clean: true
- conformance: Morfo is 'as const satisfies Morfo' ✓; all parts match declared data/aria; naming uses --onion-menu-{slot} for public tokens ✓; no invented theme roles or sizes ✓
## Tests (F)
- exists: true · env: vitest (unit tests only — onion-menu-nav.test.ts has no @vitest-environment directive, defaults to node/jsdom)
- untested: Open/close trigger via Enter/Space/ArrowDown (keyboard); Focus return to trigger on close (A17); Drill into a branch (data-expanded toggle, drill-path mutation); Drill-up via Backspace (drill-path rollback); ArrowLeft/ArrowRight focus movement in the active ring; SVG sector focus() call and roving focus state sync; Outside-click dismissal while open (non-modal); Programmatic open state change (pendingFocus queueing); All perceptual signals (open/close/commit-select) routing correctly; Focus return when Menu is closed without selecting a leaf
## Style observations (non-blocking)
- The component uses theme-driven colour derivation (color-mix) consistently for fills/text, avoiding hardcoded hex/rgb/oklch ✓
- CSS classes use .onion-menu-* prefix throughout; data-* attributes follow [data-onion-menu-*] and [data-onion-menu-{part}] correctly ✓
- The focus-visible ring styling is semantic (uses currentColor) but literals (2px) should be tokens ✓
- Sector animation uses a simple opacity keyframe with @media prefers-reduced-motion guard ✓
method: adversarially-verified workflow (analyze → refute); HIGH lead-verified by direct read of the cited code. B5 ground-truth: the A31 O(N²) isSelected/isExpanded (.includes from a per-item $derived) is confirmed across listbox/grid-list/tree-view/tree-grid/tag-group (SYS-7); rovingTargetEl is correctly LIFTED everywhere (not A31); virtual-* use SvelteMap (A33-clean).
- evidence: Morfo declares scope: ['soma', 'sema'] but eidos directory exists at src/uix/eidos/components/pagination/ with README confirming pagination is 'el wrapper visual de Eidos sobre el primitivo Soma'.
- impact: Scope mismatch signals incomplete layer declaration. Eidos layer is real and should be listed in morfo scope.
- proposed-fix: Update morfo scope from ['soma', 'sema'] to ['soma', 'sema', 'eidos'] to match the existing eidos layer.
- verify: [confirmed] Confirmed SYS-1 scope-drift. src/uix/morfo/components/pagination.ts:7 declares `scope: ['soma', 'sema'],` but a complete eidos layer exists: src/uix/eidos/components/pagination/ contains pagination.css (4255 bytes), 7 Svelte wrappers, index.ts, types.ts, and a README, plus a recipe entry at src/uix/eidos/lib/recipes/base.ts:1715 (`pagination: {`). Peer components with an eidos layer include it in scope (accordion.ts:7 `scope: ['soma', 'sema', 'eidos']`, button.ts:47, checkbox.ts:7). The omission is a real layer-declaration drift. MEDIUM is correct.
- fix-status: fixed (212624e0)
## No-findings dimensions
Contract parts registration (2-of-3 rule verified: all 7 parts registered), Contract data/aria naming (data-{c}-{part} pattern used, no data-soma-* violations), Contract Morfo as const satisfies, Behavior A31 per-item O(N²) derived reading global state, Behavior A33 $state(new Map/Set) reactivity, Behavior A35/A36 per-item $effect ref-write loops, Behavior A30 id-registration via $effect, Behavior A6 cleanup (no Resource leaks detected), Behavior A18/A10/A14/A12 keyboard navigation (N/A: pagination uses snippet-based rendering, not nav), Behavior A34 require() topology (all triggers/items require parent Provider, DOM descendants), DOM-selector (no querySelector usage), Frontier eidos→soma import (correct), Frontier syncAttrs double-write (Provider and Ellipsis only, no conflicting props), Theming roles/tokens (recipe uses --space-*, --font-size-*, --radius-*, --opacity-*, --color-* role references), Tests environment (jsdom with @vitest-environment directive), Tests coverage (page ranges, clamping, navigation, disabled state all tested)
- covers: Page range computation with siblingCount=1, boundaryCount=1; Page clamping when value exceeds totalPages; Array slicing via .slice(); Navigation methods: goToNextPage, goToPrevPage, goToFirstPage, goToLastPage; Provider/trigger/item/ellipsis attrs through runtime; aria-current and aria-label projection; disabled state propagation (provider + item + trigger); onclick handlers for all triggers and items
- FirstTrigger and LastTrigger disable when already at boundaries (page 1 / last page), consistent with Ark UI pattern; intentional UX choice not a bug
- Page range algorithm uses Set deduplication before sort, efficient O(n log n) approach
- Comments accurately describe behavior (e.g., line 222 mentions hasPrevPage check is intentional)
field-family (A13/A24-26/A30): A30 compliant: inputId registered directly in constructor (line 142) without $effect. Not A13 field (no hidden input required). Not A26 field (not segmented contenteditable). Not A24/A25 field (not date/range).
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
- evidence: scope: ['soma', 'sema'], — but eidos recipe exists at src/uix/eidos/lib/recipes/base.ts:2097 and components at src/uix/eidos/components/password-field/
- impact: Component maintainers may miss that eidos layer exists; recipe synchronization risk if scope drift persists across the codebase
- proposed-fix: Change line 31 to scope: ['soma', 'sema', 'eidos']
- verify: [confirmed] CONFIRMED at the cited severity. morfo src/uix/morfo/components/password-field.ts:31 declares `scope: ['soma', 'sema'],` — 'eidos' is omitted. Yet eidos demonstrably implements the component: (a) the recipe entry `'password-field': {` exists at src/uix/eidos/lib/recipes/base.ts:2097 (the candidate's cited line is exact), with control-height/space/font tokens following; (b) the eidos component directory src/uix/eidos/components/password-field/ contains 8 files (password-field.svelte, -input, -visibility-trigger, -strength-meter, -caps-lock-indicator, types.ts, index.ts, password-field.css). The `scope` field is documented in src/uix/morfo/types.ts:799 as 'Layers that implement this component', so the declared array genuinely diverges from the real implementation. This is the established SYS-1 scope-drift pattern (MEDIUM in baseline) — informational/metadata drift, not a behavioral or a11y bug: the component renders and works; the `scope` array does not gate eidos at runtime. MEDIUM is the correct severity (not HIGH — no latent behavioral failure; not LOW — it is a real contract/metadata inconsistency the coverage tooling tracks). Note: this is systemic, not unique to password-field — calendar (calendar.ts:7), color-field (color-field.ts:7), color-picker, combobox, command and other components likewise carry `['soma', 'sema']` while shipping eidos directories, consistent with SYS-1 being a confirmed systemic finding.
- fix-status: fixed (212624e0)
## No-findings dimensions
A Contract (Morfo), A Behavior (Soma), C DOM-selector, D Frontier, E TSC, E-bis Theming, F Tests
## Theming facts (E-bis)
- magic z-index: none
- magic literals: z-index: 1 (line 74 password-field.css) — LOCAL subtree stacking only, acceptable per rules
- undeclared parts: none
- roles clean: true · variants clean: true
- label-font (one step below input?): N/A — password-field is composable (no built-in label). Consumers use Field.Label wrapper whose font is controlled by field-level rules.
## Tests (F)
- exists: false · env: jsdom
- covers:
- untested: visibility toggle keydown/click; caps-lock signal on/off lifecycle (stateBound persistence); strength meter clamping and warnings flow; field integration (inputId wiring, disabled/readonly/required inheritance); validation paths (invalid state + error id describedby wiring)
## Style observations (non-blocking)
- Line 74 password-field.css: z-index: 1 is LOCAL subtree stacking (children above pseudo-element chrome), compliant per rules
- Theming: 9 roles used correctly (primary/secondary/neutral/affirm/fulfill/risk/threat/loss); caps-indicator uses risk-track/risk-text role alias
method: adversarially-verified workflow (analyze → refute); HIGH/CRITICAL personally re-verified by the lead. Batch-3 ground-truth: each picker fires trigger(close) (close NOT inert), but open/commit-reset ARE inert; calendar/range-calendar are MID-REFACTOR (uncommitted view-switch work).
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 1.
systemic hits: SYS-1 scope-drift (known pattern per audit baseline).
composition (A27): Re-export pattern (A27 verified): Clear/Cancel/Close compose Button (proper wrapper). Footer/Header/Body are pure layout zones. Picker-shell parts are imported and re-exported under DatePicker/ColorPicker/etc namespaces, not re-implemented. Shared-ref wiring: pickerShellContext set by host picker provider (DatePickerProvider.pickerShellHandle), read by Footer/Clear/Cancel/Close via getPickerShellHandle().
- evidence: Morfo declares `scope: ['soma']` at line 32, but eidos directory exists at `src/uix/eidos/components/picker-shell/` with 7 visual components: picker-shell.svelte, picker-shell-header.svelte, picker-shell-body.svelte, picker-shell-footer.svelte, picker-shell-clear.svelte, picker-shell-cancel.svelte, picker-shell-close.svelte. Comment at line 23 explicitly states 'Scope is `soma` only' but implementation contradicts.
- impact: Morfo contract mismatch: scope declaration does not reflect actual layer composition. Audit tools scanning morfo/components/ correctly skip this (internal placement), but if ever moved to public, scope would need updating.
- repro: Read src/uix/morfo/internal/picker-shell.ts line 32 and compare to ls src/uix/eidos/components/picker-shell/ — 7 files present.
- proposed-fix: Update morfo scope from `scope: ['soma']` to `scope: ['soma', 'eidos']` to match the architecture. Alternatively, if intentionally minimizing the contract surface, document that eidos-layer parts are NOT part of the public morfo (correct per current design) and clarify in the comment.
- verify: [downgraded] Facts confirmed but severity overstated. Read src/uix/morfo/internal/picker-shell.ts:32 `scope: ['soma'],` and the eidos dir DOES exist (ls showed picker-shell.svelte/-header/-body/-footer/-clear/-cancel/-close + picker-shell.css + recipe usage). HOWEVER this is NOT undocumented SYS-1 drift: picker-shell.ts:23-25 explicitly states `Scope is \`soma\` only — no semantic events; the host picker emits commit-*/shift-* on its own provider. No \`expression\` field because there is no morfo-emitted event to express.` The README (lines 15-19) and audit-codex P1 #5 closure (line 51) document picker-shell as an INTERNAL primitive whose morfo is deliberately a single Provider stub. The eidos divs (`data-picker-shell`, `data-picker-footer`) are standalone layout containers, NOT morfo-declared parts — so there is no contract<->visual part mismatch, which is what SYS-1/2-of-3 actually polices. The candidate's own proposedFix concedes 'correct per current design'. Real, intentional, documented => LOW doc/observation, not MEDIUM scope-drift. SYS-1 baseline is for components with an UNINTENDED scope omission, not a documented internal contract-surface minimization.
- location: src/uix/eidos/components/picker-shell/picker-shell.svelte:19 and picker-shell-footer.svelte:15
- evidence: Both picker-shell.svelte and picker-shell-footer.svelte emit `data-mode={mode}` attribute (`<divdata-picker-shelldata-mode={mode}...>` and `<div data-picker-footer data-mode={mode} ...>`) but this attribute is never consumed — not referenced in picker-shell.css, not grepped in any CSS, not used by sema or other components.
- impact: Dead attribute emitted to DOM; adds noise without utility. Low impact cosmetic issue.
- repro: grep -n 'data-mode' src/uix/eidos/components/picker-shell/*.svelte src/uix/eidos/components/picker-shell/*.css — only .svelte files match, no .css usage found.
- proposed-fix: Either remove `data-mode={mode}` from both components if unused, OR add CSS rules consuming it (e.g., `[data-picker-footer][data-mode='modal']`) if modal-specific styling is intended.
- verify: [confirmed] Confirmed. picker-shell.svelte:19 `<div data-picker-shell data-mode={mode} {...rest}>` and picker-shell-footer.svelte:15 `<div data-picker-footer data-mode={mode} {...rest}>` both emit data-mode, where `mode = $derived(shell?.getMode() ?? 'inline')`. grep for 'mode' in picker-shell.css => 'No matches found'; grep 'data-mode' across all of src/uix => only the two emit sites; no sema file references getMode/data-mode. The comment at picker-shell.svelte:10-12 ('reads `mode` off `pickerShellContext` ... so the CSS can react to inline vs modal') states an intent the CSS never fulfills — the attribute is inert/dead today. getMode() is a real interface method (picker-shell-handle.svelte.ts:21), so it does not crash; purely a dead attribute / unfulfilled-comment-intent. LOW cosmetic is correct.
- fix-status: open
## No-findings dimensions
B, C, D, E, F, G
## Theming facts (E-bis)
- magic z-index: none
- magic literals: none
- undeclared parts: Header | Body | Footer | Clear | Cancel | Close
- roles clean: true · variants clean: true
- conformance: Internal morfo with minimal contract (1 part: Provider). Eidos-layer parts (Header/Body/Footer/Clear/Cancel/Close) are NOT declared in morfo but ARE properly exported and composed. This is by design per the INTERNAL status. All tokens follow canonical naming (--space-*, --color-*, --font-*, --border-width). No magic hex/rgb/hsl/z-index. No double-write or syncAttrs issues. A27 composition verified: parts are re-exported by 5 picker families, not re-implemented.
field-family (A13/A24-26/A30): A13-PASS: Hidden input rendered at line 92 of src/uix/soma/components/pin-input/components/pin-input.svelte with name attribute propagated from opts.name (line 366 of provider). A30-PASS: inputId registered via direct assignment in PinInputProvider constructor (line 124 of pin-input-provider.svelte.ts), NOT $effect, matching the Field.inputId convention. Test confirms at line 136 of pin-input-prov
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
## Findings
### MEDIUM: THEMING §5: Recipe font-weight tokens MUST reference --font-weight-* variables, not litera — pin-input-001 <!-- id: pin-input-001 -->
- dimension: E-bis: Theming
- rule: THEMING §5: Recipe font-weight tokens MUST reference --font-weight-* variables, not literal numeric values
- evidence: 'cell-font-weight': '500' should be 'var(--font-weight-medium)'
- impact: Decouples the pin-input cell font weight from the canonical type scale, breaking theming coherence if --font-weight-medium is ever changed
- proposed-fix: Change line 4190 from "'cell-font-weight': '500'," to "'cell-font-weight': 'var(--font-weight-medium)',"
- verify: [confirmed] Confirmed at src/uix/eidos/lib/recipes/base.ts:4190: the line is exactly "'cell-font-weight': '500'," — a numeric literal. The canonical token exists (generated/base.css:341: "--font-weight-medium: 500;"), so 500 maps 1:1 to --font-weight-medium and should consume the value-preserving token. This is genuine drift, not a physically-fixed value: a grep across the entire base.ts recipe file shows EVERY other font-weight declaration uses var(--font-weight-*) (e.g. lines 48, 153, 314, 1453-1457, 1669, 1686, 3670-3674, 4196 contexts), and a regex for numeric-literal font-weights ('font-weight':'<digit>) matches ONLY line 4190 — it is the sole offender in the whole file. THEMING §5 / 'no magic literals — tokenize don't intentionalize' (feedback_no_intentional_magic_numbers): a literal that equals a scale step must reference the token. MEDIUM (token inconsistency / magic literal) and high confidence are both appropriate.
- magic literals: pin-input recipe line 4190: literal '500' for cell-font-weight
- undeclared parts: none
- roles clean: true · variants clean: true
- label-font (one step below input?): N/A (no visible label component in morfo; soma uses aria-label on hidden input)
## Tests (F)
- exists: true · env: jsdom
- covers: onComplete callback firing once when value reaches length; pattern filtering on oninput; paste transformation and distribution; selection tracking and cell state derivation; focus/blur lifecycle; Field flag OR-merging (disabled, readonly, required, invalid)
- untested: IME/drop edge cases (note: keydown.preventDefault alone is insufficient per A26 — but this is not a segmented input so A26 does not apply)
cleanup-audit (A6/A35/A36): A6 cleanup audit: Zero timers, observers, listeners. No setTimeout, setInterval, ResizeObserver, IntersectionObserver, MutationObserver, addEventListener in soma/components/progress. Test uses $effect.root() with proper disposer cleanup (line 21-24 captured, line 126 cleanup called, line 127 dom.dis
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 1 · LOW 0.
## Findings
### MEDIUM: SYS-1 scope-drift: morfo scope[] must include 'eidos' when an eidos recipe + component dir — PROGRESS-SCOPE-1 <!-- id: PROGRESS-SCOPE-1 -->
- dimension: A
- rule: SYS-1 scope-drift: morfo scope[] must include 'eidos' when an eidos recipe + component dir exist for the component.
- evidence: `scope: ['soma'],` — yet a full eidos recipe exists at src/uix/eidos/lib/recipes/base.ts:1799 (`progress: { 'height-xs': ... }`) plus a complete eidos component dir (src/uix/eidos/components/progress/{progress.svelte,progress.css,types.ts}). Compare button.ts:`scope: ['soma','sema','eidos']`, spinner.ts/skeleton.ts:`scope: ['eidos']` — all eidos-backed components declare 'eidos'.
- impact: The morfo contract under-declares the component's real consumer layers; any tooling that walks `scope` to know which layers materialize a component (lint/coverage/codegen) will skip eidos for Progress even though eidos owns its size/shape visual surface. Same drift class as meter.ts (also `['soma']`), so it is a shared B7-family systemic issue, not a one-off.
- proposed-fix: Add 'eidos' to the scope tuple: `scope: ['soma', 'eidos']`. (No 'sema' — Progress declares no events, expression is none/passive, which is correct.)
- verify: [verifier-added] added by adversarial verify pass
- covers: Provider state transitions (indeterminate, loading, loaded); Value/min/max/orientation ARIA/data attributes; Label id registration and aria-labelledby wiring; ValueText snippet props and default text generation; Indicator state inheritance; Vertical orientation support
method: theming-coherence category sweep (visual-elements), adversarially verified; roles + `:Morfo` lead-verified CLEAN across all eidos (no success/warning/danger/info; only alert-dialog uses `:Morfo`).
## Summary
Counts (post-verification): CRITICAL 0 · HIGH 0 · MEDIUM 0 · LOW 0. Theming-clean.
conformance: Contract solid (as const), display-only (no events), cell-shape variant (square/rounded/dots), fixed-tone by design for QR scannability, no drift.