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>