- 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>
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>
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>
`<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>
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>
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>
`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>
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 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>
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>