feat(uix): menu gap canon + keyboard focus-ring fix + overscroll + archetype :where

- floating-gap canon: --floating-gap-menu 0 -> var(--space-1); FloatingContent
  reads --floating-gap via a reactive $derived (was a one-shot rAF that never
  reached portaled menus -> they fell back to sideOffset, flush); nav-menu wired
  to the token via CSS.
- focus-ring: exclude item/option/content from the universal archetype ring; menu
  rows use the canonical highlight on :focus-visible too (no thick keyboard border,
  no ring framing the whole float).
- overscroll-behavior: contain on the ScrollArea viewport + 7 overlay scroll regions
  (no scroll chaining to the page).
- archetypes.css: default rules wrapped in :where() so component recipes always win.
- radius decoupled from size (Radix model) in toggle/textarea/menubar/nav-menu/
  virtual-list/grid; toggle `rounded` prop.

Canon-read + token reach portaled menus pending a fresh-load check (the dev tab
cached the floating module) -- see CONTINUE-floating-gap-canon.md.
active-uix
dev 3 months ago
parent 5974be2675
commit 61ea97b5bb

@ -0,0 +1,80 @@
# Continuation — Floating-gap canon + menu focus-ring (2026-06-28)
Where we left off. Spun out of the eidos inherit/radius audit; converged on
canonizing the trigger→panel gap of menus and killing the keyboard focus-ring
that framed the whole menu panel.
## ✅ DONE + VERIFIED LIVE (CSS — HMR is reliable)
1. **Archetype `:where()` systemic fix** (`src/uix/eidos/archetypes.css`) — every
DEFAULT archetype rule wrapped in `:where()` (specificity 0,0,0) so any component
recipe (0,1,0) wins regardless of load order. Kept at full specificity ON PURPOSE:
the `:has([data-archetype='field-trigger'])` padding override + the canonical item
highlight.
2. **Overscroll containment** — `overscroll-behavior: contain` added to the shared
ScrollArea viewport (`scroll-area.css`) + 7 overlay scroll regions (context-menu,
dialog, drawer, dropdown-menu, float-panel, popover, tooltip). Fixes scroll chaining
to the `<body>`. Verified on select + dropdown.
3. **Focus-ring fix** (`archetypes.css`) — the universal `[data-archetype]:focus-visible`
box-shadow ring now EXCLUDES `item`, `option`, AND `content`:
- Menu/listbox ROWS keyboard-focus via roving tabindex → drew the thick ring on
keyboard but only a subtle bg on hover (inconsistent). Now rows use the canonical
highlight for BOTH `:hover` AND `:focus-visible`.
- Menu/popover/dialog PANELS (`data-archetype='content'`) take focus for key capture
→ drew the ring around the WHOLE float (the thick purple frame the user hated). Now
excluded; the panel's own elevation (border + shadow) is the boundary.
- **Verified live**: loaded rule tail = `…:not([item]):not([option]):not([content])`;
highlight selector includes `:focus-visible`.
## ⏳ DONE but NEEDS A FRESH-LOAD CHECK (JS — the tab cached the old module)
4. **`--floating-gap-menu` token**: `0px → var(--space-1)` (4px) in `render-css.ts` +
regenerated `generated/base.css`. Menus get a small gap now, not flush.
5. **nav-menu wired to the canon** (`navigation-menu.css`) — both orientations now
`calc(100% + var(--floating-gap, 0px))` (was hardcoded `--space-1`). nav-menu is
CSS-positioned (not portaled) so it consumes the canon through CSS. **Verified 4px
early, before the cache trouble — reliable.**
6. **Canon-read FIX** (`src/uix/soma/layers/floating/floating.svelte.ts`) — THE fix:
- `FloatingContent.canonicalGapPx` was a `$state` filled by a one-shot rAF
(`requestFrame`) inside a `watch(contentRef)`. For PORTALED menus that rAF callback
**never fired** (instrumented: watch passes the guard, frame scheduled, never run,
not cancelled — `requestFrame`/`resolveWindow` at `arts/adom/active-dom.svelte.ts:481`
suspected). So `canonicalGapPx` stayed `null` → menus fell back to `sideOffset`
(flush). The token change alone never reached them.
- **Fix**: `canonicalGapPx` is now a `$derived` reading `--floating-gap` off
`contentRef.current` directly (reactive, no rAF). The rAF keeps only the zIndex read.
`--floating-gap` is a style value (no layout) so reading on mount is safe.
- **Type-clean** (svelte-check passed) but **NOT visually verified** — see CACHE below.
## ⚠️ CACHE GOTCHA — READ FIRST (it ate most of the session)
- Editing a deeply-imported `.svelte.ts` (e.g. `floating.svelte.ts`) does NOT reliably
HMR, AND the connected tab serves that module from a STRONG cache. `navigate` and a
`?nocache=` query do NOT revalidate the module (it's fetched by path).
- **Tell-tale**: a `gap: 12` from a hardcode I'd reverted long ago persisted through
edits + a full dev-server restart → the tab was running a frozen old module.
- **Detect stale**: temporarily set `mainAxis: 12` in the offset middleware → if the gap
isn't 12, the code is fresh.
- **Only reliable bust**: browser HARD reload (Ctrl+Shift+R). Not `navigate`.
- CSS (`archetypes.css`, recipes) DOES hot-update fine — only the `.svelte.ts` module is
the problem.
## ▶️ FIRST ACTIONS TOMORROW
1. **Hard-reload** (Ctrl+Shift+R) the menubar/dropdown page.
2. Confirm the canon-read fix: **menubar** (passes no `sideOffset`) should now show a
~4px gap (no longer pegado). If still flush → re-instrument; you're guaranteed fresh.
3. **Clean the menu demos** so they follow the canon (now that the read works). These
pass explicit `sideOffset` that BYPASSES the canon:
- `web/routes/uix/components/dropdown-menu/+page.svelte` → `sideOffset = $state(4)`
- `web/routes/uix/components/context-menu/+page.svelte` → `sideOffset = $state(0)`
- `web/routes/uix/components/select/+page.svelte` → hardcoded `<Select.Content sideOffset={6}>`
Default each to `undefined` so the token retunes all. Slider pattern to avoid the
`number|undefined` bind error: `value={sideOffset ?? 0} oninput={(e) => (sideOffset = e.currentTarget.valueAsNumber)}` + label `{sideOffset === undefined ? 'auto' : sideOffset + 'px'}`.
4. If the canon-read fix DIDN'T take on fresh load, isolate why `requestFrame`'s rAF
never fires for portaled menu content — but the `$derived` should sidestep it entirely.
## Dev server
Restarted fresh on 5199: `npm run dev -- --port 5199 --strictPort` (the bare `dev`
script uses the default port, so pass the flags).

@ -738,7 +738,7 @@ Donde `{prefix}` es uno de:
| `--duration-{key}` | Motion duration | `--duration-fast` | | `--duration-{key}` | Motion duration | `--duration-fast` |
| `--ease-{key}` | Motion ease | `--ease-out` | | `--ease-{key}` | Motion ease | `--ease-out` |
| `--style-{name}-*` | Typography named style | `--style-h1-font-size` | | `--style-{name}-*` | Typography named style | `--style-h1-font-size` |
| `--{c}-{slot}` | Component recipe token | `--toggle-radius-md` | | `--{c}-{slot}` | Component recipe token | `--toggle-height-md` |
| `--{c}-{role}-{slot}` | Component color | `--toggle-affirm-solid` | | `--{c}-{role}-{slot}` | Component color | `--toggle-affirm-solid` |
| `--{c}-palette-{slot}` | Component palette runtime | `--toggle-palette-solid` | | `--{c}-palette-{slot}` | Component palette runtime | `--toggle-palette-solid` |
@ -2289,12 +2289,14 @@ eso cada flotante hardcodeaba su gap (0/4/6/8, incoherente). Canon:
- **`@property --floating-gap`** `<length>` — registrada para que JS resuelva el px - **`@property --floating-gap`** `<length>` — registrada para que JS resuelva el px
vía `getComputedStyle` (las custom props **sin registrar** devuelven el `var(...)` vía `getComputedStyle` (las custom props **sin registrar** devuelven el `var(...)`
literal, no el valor). Tokens por arquetipo: `--floating-gap-menu: 0` · literal, no el valor). Tokens por arquetipo: `--floating-gap-menu: var(--space-1)` ·
`--floating-gap-panel: var(--space-1-5)`. Regla foundation `--floating-gap-panel: var(--space-1-5)`. Regla foundation
`[data-floating-gap='menu'|'panel'] { --floating-gap: … }`. Todo emitido en `[data-floating-gap='menu'|'panel'] { --floating-gap: … }`. Todo emitido en
`render-css.ts`. `render-css.ts`.
- **El posicionador compartido** (`soma/layers/floating/floating.svelte.ts`) lee el - **El posicionador compartido** (`soma/layers/floating/floating.svelte.ts`) lee el
`--floating-gap` resuelto del content (en el rAF del watch de `contentRef`) y lo `--floating-gap` resuelto del content vía un **`$derived` sobre `contentRef.current`**
(reactivo; **antes** era un rAF de una pasada que **NUNCA disparaba para menús
portalizados** → caían al `sideOffset` y salían pegados — fix 2026-06-28) y lo
usa como `offset` de Floating UI (fallback al `sideOffset` numérico). Es el usa como `offset` de Floating UI (fallback al `sideOffset` numérico). Es el
**OFFSET, no un margin CSS**: la flecha viaja con él (un margin la despegaría del **OFFSET, no un margin CSS**: la flecha viaja con él (un margin la despegaría del
ancla en popover/tooltip). Por eso NO se reutilizó el `data-canonical-gap` de ancla en popover/tooltip). Por eso NO se reutilizó el `data-canonical-gap` de
@ -2307,14 +2309,25 @@ eso cada flotante hardcodeaba su gap (0/4/6/8, incoherente). Canon:
| Arquetipo | Componentes | Gap | | Arquetipo | Componentes | Gap |
|---|---|---| |---|---|---|
| `menu` (flush) | dropdown · context · sub-menus · menubar · select · navigation-menu | `--floating-gap-menu` (0) | | `menu` (gap pequeño) | dropdown · context · sub-menus · menubar · select · navigation-menu | `--floating-gap-menu` (`var(--space-1)`) |
| `panel` | popover · combobox · link-preview · 5 pickers (vía `PopoverContent`) | `--floating-gap-panel` (`--space-1-5`) | | `panel` | popover · combobox · link-preview · 5 pickers (vía `PopoverContent`) | `--floating-gap-panel` (`--space-1-5`) |
Fuera: `command` (dialog/inline, no anclado a trigger), `onion-menu` (radial). Para Fuera: `command` (dialog/inline, no anclado a trigger), `onion-menu` (radial). Para
retunear el gap de un tema: override `--floating-gap-menu` / `--floating-gap-panel`. retunear el gap de un tema: override `--floating-gap-menu` / `--floating-gap-panel`.
> **Actualización 2026-06-28** — dos cosas: (1) `--floating-gap-menu` pasó de `0` a
> `var(--space-1)` (los menús dejan de salir pegados al trigger); (2) el read del canon
> en `FloatingContent` pasó de un **rAF** (que no entregaba el valor a los menús
> portalizados → solo nav-menu, CSS-posicionado, cogía el token) a un **`$derived`**
> reactivo sobre `contentRef`. Sin el (2), el (1) no llegaba a dropdown/menubar/select.
> En paralelo, en `archetypes.css` el **focus-ring universal** (`[data-archetype]:focus-visible`)
> ahora EXCLUYE `item`/`option`/`content`: las filas de menú/lista usan el highlight
> canónico también en `:focus-visible` (igual que el hover), y los paneles flotantes
> (`content`) ya no dibujan el anillo gordo alrededor de todo el float — su elevación
> (borde + sombra) es el límite.
--- ---
**Última revisión**: 2026-06-22. Si algo en este doc no coincide con **Última revisión**: 2026-06-28. Si algo en este doc no coincide con
el código, el código gana — pero abre un issue para que actualicemos el código, el código gana — pero abre un issue para que actualicemos
el doc. el doc.

@ -6,14 +6,24 @@
* `Dialog.Trigger`, a `Popover.Trigger` and a `DropdownMenu.Trigger` all * `Dialog.Trigger`, a `Popover.Trigger` and a `DropdownMenu.Trigger` all
* carry `data-archetype="trigger"`, so the rule below covers them all. * carry `data-archetype="trigger"`, so the rule below covers them all.
* *
* Specificity is intentionally low (single attribute selector). Component * These are DEFAULTS — a component recipe must ALWAYS be able to override them.
* tokens or scoped overrides win when present. * So they are wrapped in `:where()` (specificity 0,0,0): any component rule
* (`[data-x-content]`, 0,1,0) wins, regardless of stylesheet load order. Before
* this, the plain `[data-archetype='X']` selectors were 0,1,0 — a TIE with
* component recipes, decided by load order, so the archetype silently OVERRODE
* recipes that loaded earlier (e.g. nav-menu's content `position: absolute` and
* its indicator `transition`).
*
* The TWO rules that INTENTIONALLY out-rank component recipes keep their real
* specificity (NOT wrapped, by design): the field-trigger padding (`:has`) and
* the canonical active-option highlight. The universal focus ring is also left
* unwrapped (a11y fallback; components override it with their own focus rule).
*/ */
/* ── Trigger ─────────────────────────────────────────────────────────────── /* ── Trigger ───────────────────────────────────────────────────────────────
* Applies to anything that the user clicks/taps to activate or open. * Applies to anything that the user clicks/taps to activate or open.
*/ */
[data-archetype='trigger'] { :where([data-archetype='trigger']) {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: transition:
@ -21,12 +31,11 @@
background-color var(--duration-fast) var(--ease-default); background-color var(--duration-fast) var(--ease-default);
} }
[data-archetype='trigger']:hover:not([data-disabled]):not([disabled]) { :where([data-archetype='trigger']:hover:not([data-disabled]):not([disabled])) {
opacity: 0.85; opacity: 0.85;
} }
[data-archetype='trigger'][data-disabled], :where([data-archetype='trigger'][data-disabled], [data-archetype='trigger'][disabled]) {
[data-archetype='trigger'][disabled] {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
@ -41,6 +50,7 @@
* input's full text padding adding a second, larger gap. Lives here (global + * input's full text padding adding a second, larger gap. Lives here (global +
* archetype-keyed) so it covers EVERY field on every page, and outranks each * archetype-keyed) so it covers EVERY field on every page, and outranks each
* `[data-x-field-input]` padding rule (0,2,0 > 0,1,0) regardless of load order. * `[data-x-field-input]` padding rule (0,2,0 > 0,1,0) regardless of load order.
* INTENTIONAL override — kept at full specificity (NOT wrapped in :where).
*/ */
[data-archetype='input']:has([data-archetype='field-trigger']) { [data-archetype='input']:has([data-archetype='field-trigger']) {
padding-inline-end: 0; padding-inline-end: 0;
@ -49,19 +59,24 @@
/* ── Overlay ────────────────────────────────────────────────────────────── /* ── Overlay ──────────────────────────────────────────────────────────────
* Modal backdrops behind dialog/drawer/popover content. * Modal backdrops behind dialog/drawer/popover content.
*/ */
[data-archetype='overlay'] { :where([data-archetype='overlay']) {
position: fixed; position: fixed;
inset: 0; inset: 0;
pointer-events: auto; pointer-events: auto;
} }
/* ── Content ─────────────────────────────────────────────────────────────── */ /* ── Content ──────────────────────────────────────────────────────────────── */
[data-archetype='content'] { :where([data-archetype='content']) {
position: relative; position: relative;
} }
/* ── Indicator ───────────────────────────────────────────────────────────── */ /* ── Indicator ──────────────────────────────────────────────────────────────
[data-archetype='indicator'] { * `transition: transform` here is the DEFAULT for indicators that slide via
* `transform` (e.g. tabs). NavigationMenu's underline slides via the inline
* `left` soma writes + a static `translateY`, so its recipe overrides this —
* which the `:where()` now lets it do without a specificity bump.
*/
:where([data-archetype='indicator']) {
pointer-events: none; pointer-events: none;
transition: transform var(--duration-fast) var(--ease-default); transition: transform var(--duration-fast) var(--ease-default);
} }
@ -69,41 +84,41 @@
/* ── Thumb ──────────────────────────────────────────────────────────────── /* ── Thumb ────────────────────────────────────────────────────────────────
* Draggable handles (slider, switch, scroll thumb). * Draggable handles (slider, switch, scroll thumb).
*/ */
[data-archetype='thumb'] { :where([data-archetype='thumb']) {
cursor: grab; cursor: grab;
user-select: none; user-select: none;
transition: transform var(--duration-fast) var(--ease-default); transition: transform var(--duration-fast) var(--ease-default);
} }
[data-archetype='thumb']:active { :where([data-archetype='thumb']:active) {
cursor: grabbing; cursor: grabbing;
} }
[data-archetype='thumb'][data-disabled] { :where([data-archetype='thumb'][data-disabled]) {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }
/* ── Track ──────────────────────────────────────────────────────────────── */ /* ── Track ──────────────────────────────────────────────────────────────── */
[data-archetype='track'] { :where([data-archetype='track']) {
position: relative; position: relative;
} }
/* ── Close ──────────────────────────────────────────────────────────────── /* ── Close ────────────────────────────────────────────────────────────────
* Inherits trigger's interactive feel; overrides only what's specific. * Inherits trigger's interactive feel; overrides only what's specific.
*/ */
[data-archetype='close'] { :where([data-archetype='close']) {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: opacity var(--duration-fast) var(--ease-default); transition: opacity var(--duration-fast) var(--ease-default);
} }
[data-archetype='close']:hover { :where([data-archetype='close']:hover) {
opacity: 0.85; opacity: 0.85;
} }
/* ── Action ─────────────────────────────────────────────────────────────── */ /* ── Action ─────────────────────────────────────────────────────────────── */
[data-archetype='action'] { :where([data-archetype='action']) {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
@ -117,8 +132,22 @@
* reset). Box-shadow rings also follow `border-radius` which looks better * reset). Box-shadow rings also follow `border-radius` which looks better
* on pills (Switch) and rounded buttons. The `0 0 0` sets no offset / blur, * on pills (Switch) and rounded buttons. The `0 0 0` sets no offset / blur,
* so the shadow draws a uniform ring at +offset from the element edge. * so the shadow draws a uniform ring at +offset from the element edge.
*
* Left UN-wrapped (a11y fallback): components override it with their own
* focus rule; it must not silently disappear behind a 0,0,0 default.
*
* `item` / `option` are EXCLUDED: menu / listbox rows are keyboard-navigated by
* roving focus, so the focused row would draw this thick ring while a hovered row
* only gets the subtle highlight below — keyboard and pointer would look different.
* Rows use the canonical highlight (below) for BOTH `:hover` AND `:focus-visible`
* instead, so the active row reads identically however you reached it.
*
* `content` is EXCLUDED too: an overlay PANEL (menu / popover / dialog content) takes
* programmatic / keyboard focus for key capture, so `:focus-visible` would draw this
* ring around the WHOLE float — a thick frame nobody wants. The panel's own elevation
* (border + shadow) is its boundary; its interactive children get the focus.
*/ */
[data-archetype]:focus-visible:not(input):not(textarea):not(select):not([data-archetype='segment']) { [data-archetype]:focus-visible:not(input):not(textarea):not(select):not([data-archetype='segment']):not([data-archetype='item']):not([data-archetype='option']):not([data-archetype='content']) {
box-shadow: box-shadow:
0 0 0 var(--focus-ring-offset) var(--focus-ring-bg, transparent), 0 0 0 var(--focus-ring-offset) var(--focus-ring-bg, transparent),
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width))
@ -136,8 +165,7 @@
The control owns the focus affordance; these delegate to it. */ The control owns the focus affordance; these delegate to it. */
/* ── Item / Option ──────────────────────────────────────────────────────── */ /* ── Item / Option ──────────────────────────────────────────────────────── */
[data-archetype='item'], :where([data-archetype='item'], [data-archetype='option']) {
[data-archetype='option'] {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
/* Height: a CONTROL-HEIGHT FLOOR (not a clamp), so a list row scales 1:1 /* Height: a CONTROL-HEIGHT FLOOR (not a clamp), so a list row scales 1:1
@ -152,17 +180,21 @@
padding-block: var(--list-item-py, var(--space-1-5)); padding-block: var(--list-item-py, var(--space-1-5));
} }
/* Canonical "active option" highlight — hover, keyboard highlight /* Canonical "active option" highlight — hover, keyboard focus (`:focus-visible`,
* (data-highlighted) and active (aria-selected / data-selected), EXCEPT a chosen * roving focus), keyboard highlight (data-highlighted) and active
* (aria-selected / data-selected), EXCEPT a chosen
* value (data-state='checked') or a pressed toggle (data-state='on'), which each * value (data-state='checked') or a pressed toggle (data-state='on'), which each
* paint their own accent. The neutral menu-row highlight must not clobber a * paint their own accent. The neutral menu-row highlight must not clobber a
* pressed toggle's solid fill + contrast text (it flipped the active label to * pressed toggle's solid fill + contrast text (it flipped the active label to
* content-primary — black in light themes — on hover). * content-primary — black in light themes — on hover).
* One neutral surface so the active row reads identically across select, * One neutral surface so the active row reads identically across select,
* combobox, command, listbox and menus — no per-component repetition. * combobox, command, listbox and menus — no per-component repetition.
* INTENTIONAL cross-component accent — kept at full specificity (NOT wrapped).
*/ */
[data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']), [data-archetype='item']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
[data-archetype='option']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']), [data-archetype='option']:hover:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
[data-archetype='item']:focus-visible:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
[data-archetype='option']:focus-visible:not([data-disabled]):not([data-state='checked']):not([data-state='on']),
[data-archetype='item'][data-highlighted]:not([data-state='checked']):not([data-state='on']), [data-archetype='item'][data-highlighted]:not([data-state='checked']):not([data-state='on']),
[data-archetype='option'][data-highlighted]:not([data-state='checked']):not([data-state='on']), [data-archetype='option'][data-highlighted]:not([data-state='checked']):not([data-state='on']),
[data-archetype='item'][aria-selected='true']:not([data-state='checked']):not([data-state='on']), [data-archetype='item'][aria-selected='true']:not([data-state='checked']):not([data-state='on']),
@ -173,8 +205,7 @@
color: var(--color-content-primary); color: var(--color-content-primary);
} }
[data-archetype='item'][data-disabled], :where([data-archetype='item'][data-disabled], [data-archetype='option'][data-disabled]) {
[data-archetype='option'][data-disabled] {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }

@ -412,7 +412,7 @@ ni `--soma-`.
| Forma | Uso | Ejemplo | | Forma | Uso | Ejemplo |
| ------------------ | ----------------------------------------------------- | ------------------------------------------- | | ------------------ | ----------------------------------------------------- | ------------------------------------------- |
| `--{component}-…` | tokens públicos (sobreescribibles por el consumer) | `--dialog-content-bg`, `--toggle-radius-md` | | `--{component}-…` | tokens públicos (sobreescribibles por el consumer) | `--dialog-content-bg`, `--toggle-height-md` |
| `--_{component}-…` | tokens internos del recipe (no parte del API público) | `--_tabs-trigger-height`, `--_toggle-bg` | | `--_{component}-…` | tokens internos del recipe (no parte del API público) | `--_tabs-trigger-height`, `--_toggle-bg` |
Los `[data-{component}]` y `[data-{component}-{part}]` selectors son la Los `[data-{component}]` y `[data-{component}-{part}]` selectors son la

@ -51,7 +51,13 @@ export type ButtonProps = Omit<ProviderProps, 'color'> & {
variant?: ButtonVariant; variant?: ButtonVariant;
/** Sizing scale. @default 'md' */ /** Sizing scale. @default 'md' */
size?: ResponsiveProp<ButtonSize>; size?: ResponsiveProp<ButtonSize>;
/** Corner radius (magnitude). @default inherits from size */ /**
* Corner radius (magnitude). Independent of `size` per the Radix radius model:
* unset → the control archetype default (`md`, the `--radius-default` resting
* radius); set to override per-instance (e.g. `'full'` = pill at any size).
* Composes with the theme `--radius-factor` (global roundness) + `--scaling`.
* @default 'md' (archetype default; NOT derived from `size`)
*/
rounded?: ResponsiveProp<ButtonRounded>; rounded?: ResponsiveProp<ButtonRounded>;
/** /**
* Corner shape family — `rounded` (arc) · `continuous` (squircle) · `cut` (bevel) · `scoop` * Corner shape family — `rounded` (arc) · `continuous` (squircle) · `cut` (bevel) · `scoop`

@ -24,6 +24,7 @@
min-inline-size: var(--context-menu-content-min-width, 12rem); min-inline-size: var(--context-menu-content-min-width, 12rem);
max-block-size: var(--context-menu-content-max-height, min(60vh, 22rem)); max-block-size: var(--context-menu-content-max-height, min(60vh, 22rem));
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: var(--context-menu-content-padding, var(--space-1)); padding: var(--context-menu-content-padding, var(--space-1));
/* Elevation — surface · border · shadow — from the `overlay` plane via /* Elevation — surface · border · shadow — from the `overlay` plane via
`data-depth` (A1, Decisión 8). Radius / font / color stay the panel's own. */ `data-depth` (A1, Decisión 8). Radius / font / color stay the panel's own. */

@ -67,6 +67,7 @@
max-height: var(--_dialog-content-max-height-override, var(--dialog-content-max-height)); max-height: var(--_dialog-content-max-height-override, var(--dialog-content-max-height));
padding: var(--_dialog-padding); padding: var(--_dialog-padding);
overflow: auto; overflow: auto;
overscroll-behavior: contain;
/* Elevation bundle (surface · border · shadow) comes from the `modal` plane /* Elevation bundle (surface · border · shadow) comes from the `modal` plane
via `data-depth` (A1, Decisión 8). The panel keeps only its own radius / via `data-depth` (A1, Decisión 8). The panel keeps only its own radius /
color — radius is a separate axis (Radix factor, A2). The intent variants color — radius is a separate axis (Radix factor, A2). The intent variants

@ -78,6 +78,7 @@
gap: var(--drawer-stack-gap); gap: var(--drawer-stack-gap);
padding: var(--_drawer-padding); padding: var(--_drawer-padding);
overflow: auto; overflow: auto;
overscroll-behavior: contain;
min-inline-size: var(--_drawer-content-min-width-override, auto); min-inline-size: var(--_drawer-content-min-width-override, auto);
max-inline-size: var(--_drawer-content-max-width-override, none); max-inline-size: var(--_drawer-content-max-width-override, none);
min-block-size: var(--_drawer-content-min-height-override, auto); min-block-size: var(--_drawer-content-min-height-override, auto);

@ -35,6 +35,7 @@
min-inline-size: var(--dropdown-menu-content-min-width, 12rem); min-inline-size: var(--dropdown-menu-content-min-width, 12rem);
max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem)); max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem));
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: var(--dropdown-menu-content-padding, var(--space-1)); padding: var(--dropdown-menu-content-padding, var(--space-1));
/* Elevation — surface · border · shadow — from the `overlay` plane via /* Elevation — surface · border · shadow — from the `overlay` plane via
`data-depth` (A1, Decisión 8). Radius / font / color stay the panel's own. */ `data-depth` (A1, Decisión 8). Radius / font / color stay the panel's own. */

@ -273,6 +273,7 @@
flex: 1 1 auto; flex: 1 1 auto;
min-block-size: 0; min-block-size: 0;
overflow: auto; overflow: auto;
overscroll-behavior: contain;
padding-block: var(--float-panel-body-py); padding-block: var(--float-panel-body-py);
padding-inline: var(--float-panel-body-px); padding-inline: var(--float-panel-body-px);
} }

@ -26,7 +26,8 @@
--_menubar-height: var(--control-height-md); --_menubar-height: var(--control-height-md);
--_menubar-padding: var(--space-1); --_menubar-padding: var(--space-1);
--_menubar-gap: var(--space-1); --_menubar-gap: var(--space-1);
--_menubar-radius: var(--radius-md); /* Radius DECOUPLED from size (Radix model) — archetype default, constant across sizes. */
--_menubar-radius: var(--radius-default);
--_menubar-trigger-px: var(--space-3); --_menubar-trigger-px: var(--space-3);
--_menubar-font-size: var(--font-size-md); --_menubar-font-size: var(--font-size-md);
/* Concentric nesting (Arq. 6 / shape engine): the in-bar trigger consumes the /* Concentric nesting (Arq. 6 / shape engine): the in-bar trigger consumes the
@ -48,7 +49,6 @@
--_menubar-height: var(--control-height-sm); --_menubar-height: var(--control-height-sm);
--_menubar-padding: calc(var(--space-1) * 0.5); --_menubar-padding: calc(var(--space-1) * 0.5);
--_menubar-gap: calc(var(--space-1) * 0.5); --_menubar-gap: calc(var(--space-1) * 0.5);
--_menubar-radius: var(--radius-sm);
--_menubar-trigger-px: var(--space-2); --_menubar-trigger-px: var(--space-2);
--_menubar-font-size: var(--font-size-sm); --_menubar-font-size: var(--font-size-sm);
} }
@ -57,7 +57,6 @@
--_menubar-height: var(--control-height-lg); --_menubar-height: var(--control-height-lg);
--_menubar-padding: var(--space-2); --_menubar-padding: var(--space-2);
--_menubar-gap: var(--space-2); --_menubar-gap: var(--space-2);
--_menubar-radius: var(--radius-lg);
--_menubar-trigger-px: var(--space-4); --_menubar-trigger-px: var(--space-4);
--_menubar-font-size: var(--font-size-lg); --_menubar-font-size: var(--font-size-lg);
} }

@ -22,7 +22,8 @@
--_navigation-menu-trigger-px: var(--space-3); --_navigation-menu-trigger-px: var(--space-3);
--_navigation-menu-list-gap: var(--space-1); --_navigation-menu-list-gap: var(--space-1);
--_navigation-menu-font-size: var(--font-size-md); --_navigation-menu-font-size: var(--font-size-md);
--_navigation-menu-radius: var(--radius-md); /* Radius DECOUPLED from size (Radix model) — archetype default, constant across sizes. */
--_navigation-menu-radius: var(--radius-default);
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@ -37,14 +38,12 @@
--_navigation-menu-trigger-px: var(--space-2); --_navigation-menu-trigger-px: var(--space-2);
--_navigation-menu-list-gap: 0; --_navigation-menu-list-gap: 0;
--_navigation-menu-font-size: var(--font-size-sm); --_navigation-menu-font-size: var(--font-size-sm);
--_navigation-menu-radius: var(--radius-sm);
} }
[data-navigation-menu][data-size='lg'] { [data-navigation-menu][data-size='lg'] {
--_navigation-menu-trigger-px: var(--space-4); --_navigation-menu-trigger-px: var(--space-4);
--_navigation-menu-list-gap: var(--space-2); --_navigation-menu-list-gap: var(--space-2);
--_navigation-menu-font-size: var(--font-size-lg); --_navigation-menu-font-size: var(--font-size-lg);
--_navigation-menu-radius: var(--radius-lg);
} }
/* ── List ─ */ /* ── List ─ */
@ -152,14 +151,25 @@
* recipe paints the panel chrome (border, shadow, padding) and the * recipe paints the panel chrome (border, shadow, padding) and the
* directional slide animation per `data-motion` (from / to). * directional slide animation per `data-motion` (from / to).
*/ */
[data-navigation-menu-content] { /* Specificity bump (0,2,0): the transversal `[data-archetype='content']` rule
* sets `position: relative` (0,1,0) and, loading later in the cascade, was
* WINNING here — pinning the panel into the bar's flex flow so opening the
* mega-menu displaced the sibling triggers. NavigationMenu is the one
* NON-portaled floating Content, so it must win to float over the bar.
* Scoped under `[data-navigation-menu]` (always its ancestor) so it beats the
* archetype default without `!important`. */
[data-navigation-menu] [data-navigation-menu-content] {
/* Floats below the parent Item — taken out of the bar's flex flow so /* Floats below the parent Item — taken out of the bar's flex flow so
* opening the mega-menu doesn't push siblings. `top: 100%` anchors * opening the mega-menu doesn't push siblings. `top: 100%` anchors
* it just below the trigger row; `inset-inline-start: 0` left-aligns * it just below the trigger row; `inset-inline-start: 0` left-aligns
* to the Item, but the panel itself has a `min-inline-size` so it * to the Item, but the panel itself has a `min-inline-size` so it
* extends past the trigger if needed. */ * extends past the trigger if needed. */
position: absolute; position: absolute;
inset-block-start: calc(100% + var(--space-1)); /* `100%` reaches the trigger row's bottom; `--floating-gap` adds the canonical
* menu gap — the SAME token the portaled menus' positioner reads. nav-menu is
* CSS-positioned (not portaled), so it consumes the canon through CSS, resolved
* here via the content's `data-floating-gap='menu'` stamp. */
inset-block-start: calc(100% + var(--floating-gap, 0px));
inset-inline-start: 0; inset-inline-start: 0;
z-index: var(--navigation-menu-content-z); z-index: var(--navigation-menu-content-z);
@ -218,7 +228,7 @@
* of the trigger column instead of below. */ * of the trigger column instead of below. */
[data-navigation-menu][data-orientation='vertical'] [data-navigation-menu-content] { [data-navigation-menu][data-orientation='vertical'] [data-navigation-menu-content] {
inset-block-start: 0; inset-block-start: 0;
inset-inline-start: calc(100% + var(--space-1)); inset-inline-start: calc(100% + var(--floating-gap, 0px));
} }
[data-navigation-menu-content][data-state='open'] { [data-navigation-menu-content][data-state='open'] {
@ -278,20 +288,35 @@
* between triggers. The indicator itself stays inside the navigation * between triggers. The indicator itself stays inside the navigation
* bar; it's NOT in the floating layer. * bar; it's NOT in the floating layer.
*/ */
[data-navigation-menu-indicator] { /* Scoped under `[data-navigation-menu]` (0,2,0) so it beats the transversal
* `[data-archetype='indicator']` rule, which (at 0,1,0, loading later) was
* overriding `transition` with `transition: transform` → the translateY offset
* animated, making the underline "fall" from the trigger top on first open. */
[data-navigation-menu] [data-navigation-menu-indicator] {
/* Soma inline-styles `position: absolute` + `left`/`top` to the active
* trigger's TOP-LEFT corner (soma README "Indicator"). The recipe only
* SHAPES the decoration via the rect vars — it must NOT re-position:
* · horizontal tracking IS soma's inline `left`; adding
* `translateX(var(--…-x))` doubled the offset (fine at the first trigger
* where x=0, way off on Solutions/etc.).
* · vertical is soma's inline `top` (the trigger top), which overrode the
* old `inset-block-end: 0` → the underline sat at the TOP. Drop the
* insets and slide DOWN by the trigger height to sit beneath it. */
position: absolute; position: absolute;
inset-inline-start: 0;
inset-block-end: 0;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
inline-size: var(--navigation-menu-indicator-w, 0px); inline-size: var(--navigation-menu-indicator-w, 0px);
block-size: 2px; block-size: 2px;
transform: translateX(var(--navigation-menu-indicator-x, 0px)); transform: translateY(var(--navigation-menu-indicator-h, 0px));
background: var(--color-primary-solid); background: var(--color-primary-solid);
border-radius: var(--radius-full); border-radius: var(--radius-full);
opacity: 0; opacity: 0;
/* NO `transform` transition: the vertical offset is translateY(var(--…-h)),
* which jumps 0 → trigger-height when soma first measures the trigger;
* animating it makes the underline "fall" from the trigger TOP down to the
* bottom on first open. Slide (left) + width + fade (opacity) only. */
transition: transition:
transform var(--duration-fast) var(--ease-default), left var(--duration-fast) var(--ease-default),
inline-size var(--duration-fast) var(--ease-default), inline-size var(--duration-fast) var(--ease-default),
opacity var(--duration-fast) var(--ease-default); opacity var(--duration-fast) var(--ease-default);
} }

@ -127,6 +127,7 @@
max-block-size: var(--_popover-max-height); max-block-size: var(--_popover-max-height);
padding: var(--_popover-py) var(--_popover-px); padding: var(--_popover-py) var(--_popover-px);
overflow: auto; overflow: auto;
overscroll-behavior: contain;
border-radius: inherit; border-radius: inherit;
} }

@ -27,6 +27,13 @@
[data-scroll-area-viewport] { [data-scroll-area-viewport] {
position: absolute; position: absolute;
inset: 0; inset: 0;
/* Trap wheel/touch scroll at the viewport's edges so reaching the top or
bottom of the list doesn't chain to the page (`<body>`) and scroll it.
Both viewport variants (fixed-fill above, autosize below) inherit this —
the autosize rule only overrides position/overflow, not this. Mirrors the
`overscroll-behavior: contain` every other scroll region already sets
(command / listbox / tree-view / table / virtual-list …). */
overscroll-behavior: contain;
} }
/* Autosize mode — content-sized up to a max, then scroll, instead of the /* Autosize mode — content-sized up to a max, then scroll, instead of the

@ -6,7 +6,8 @@
--_textarea-gap: var(--space-2); --_textarea-gap: var(--space-2);
--_textarea-font-size: var(--font-size-md); --_textarea-font-size: var(--font-size-md);
--_textarea-line-height: var(--leading-normal); --_textarea-line-height: var(--leading-normal);
--_textarea-radius: var(--radius-md); /* Radius DECOUPLED from size (Radix model) — control archetype default, constant across sizes. */
--_textarea-radius: var(--radius-default);
--_textarea-border: var(--color-neutral-border); --_textarea-border: var(--color-neutral-border);
/* `--color-{role}-border-strong` is NOT part of this theme's token vocabulary /* `--color-{role}-border-strong` is NOT part of this theme's token vocabulary
(only `--color-{role}-border` exists). Consume the SAME tokens the other inputs (only `--color-{role}-border` exists). Consume the SAME tokens the other inputs
@ -34,7 +35,6 @@
--_textarea-px: var(--space-2); --_textarea-px: var(--space-2);
--_textarea-py: var(--space-1); --_textarea-py: var(--space-1);
--_textarea-font-size: var(--font-size-xs); --_textarea-font-size: var(--font-size-xs);
--_textarea-radius: var(--radius-sm);
} }
[data-textarea][data-size='sm'] { [data-textarea][data-size='sm'] {
--_textarea-px: var(--space-2); --_textarea-px: var(--space-2);
@ -45,13 +45,11 @@
--_textarea-px: var(--space-4); --_textarea-px: var(--space-4);
--_textarea-py: var(--space-3); --_textarea-py: var(--space-3);
--_textarea-font-size: var(--font-size-lg); --_textarea-font-size: var(--font-size-lg);
--_textarea-radius: var(--radius-lg);
} }
[data-textarea][data-size='xl'] { [data-textarea][data-size='xl'] {
--_textarea-px: var(--space-5); --_textarea-px: var(--space-5);
--_textarea-py: var(--space-4); --_textarea-py: var(--space-4);
--_textarea-font-size: var(--font-size-xl); --_textarea-font-size: var(--font-size-xl);
--_textarea-radius: var(--radius-lg);
} }
/* Color accents — applied on focus + when invalid. Each role's `-solid` (the /* Color accents — applied on focus + when invalid. Each role's `-solid` (the

@ -4,7 +4,10 @@
--_toggle-gap: var(--toggle-gap-md); --_toggle-gap: var(--toggle-gap-md);
--_toggle-font-size: var(--toggle-font-size-md); --_toggle-font-size: var(--toggle-font-size-md);
--_toggle-font-weight: var(--toggle-font-weight-md); --_toggle-font-weight: var(--toggle-font-weight-md);
--_toggle-radius: var(--toggle-radius-md); /* Radius is DECOUPLED from size (Radix model) — the resting radius is the
* control archetype default (`--radius-default` = md), constant across sizes.
* `data-rounded` overrides per-instance; the theme `--radius-factor` retunes. */
--_toggle-radius: var(--radius-default);
/* The 8 --toggle-palette-* tokens (neutral default) and the /* The 8 --toggle-palette-* tokens (neutral default) and the
* --toggle-{variant}-* tokens (host-scoped) are emitted by the * --toggle-{variant}-* tokens (host-scoped) are emitted by the
* generator into a separate `[data-toggle]` block. Per-color * generator into a separate `[data-toggle]` block. Per-color
@ -67,7 +70,6 @@
--_toggle-gap: var(--toggle-gap-xs); --_toggle-gap: var(--toggle-gap-xs);
--_toggle-font-size: var(--toggle-font-size-xs); --_toggle-font-size: var(--toggle-font-size-xs);
--_toggle-font-weight: var(--toggle-font-weight-xs); --_toggle-font-weight: var(--toggle-font-weight-xs);
--_toggle-radius: var(--toggle-radius-xs);
} }
[data-toggle][data-size='sm'] { [data-toggle][data-size='sm'] {
@ -76,7 +78,6 @@
--_toggle-gap: var(--toggle-gap-sm); --_toggle-gap: var(--toggle-gap-sm);
--_toggle-font-size: var(--toggle-font-size-sm); --_toggle-font-size: var(--toggle-font-size-sm);
--_toggle-font-weight: var(--toggle-font-weight-sm); --_toggle-font-weight: var(--toggle-font-weight-sm);
--_toggle-radius: var(--toggle-radius-sm);
} }
[data-toggle][data-size='lg'] { [data-toggle][data-size='lg'] {
@ -85,7 +86,6 @@
--_toggle-gap: var(--toggle-gap-lg); --_toggle-gap: var(--toggle-gap-lg);
--_toggle-font-size: var(--toggle-font-size-lg); --_toggle-font-size: var(--toggle-font-size-lg);
--_toggle-font-weight: var(--toggle-font-weight-lg); --_toggle-font-weight: var(--toggle-font-weight-lg);
--_toggle-radius: var(--toggle-radius-lg);
} }
[data-toggle][data-size='xl'] { [data-toggle][data-size='xl'] {
@ -94,9 +94,18 @@
--_toggle-gap: var(--toggle-gap-xl); --_toggle-gap: var(--toggle-gap-xl);
--_toggle-font-size: var(--toggle-font-size-xl); --_toggle-font-size: var(--toggle-font-size-xl);
--_toggle-font-weight: var(--toggle-font-weight-xl); --_toggle-font-weight: var(--toggle-font-weight-xl);
--_toggle-radius: var(--toggle-radius-xl);
} }
/* ── Rounded override (radius decoupled from size — Radix model) ──────────
* Resting radius = the control archetype default (`--radius-default`, set on
* `[data-toggle]` above). `rounded` overrides per-instance to a global radius
* step; `full` = pill at any size. Mirrors Button. */
[data-toggle][data-rounded='sm'] { --_toggle-radius: var(--radius-sm); }
[data-toggle][data-rounded='md'] { --_toggle-radius: var(--radius-md); }
[data-toggle][data-rounded='lg'] { --_toggle-radius: var(--radius-lg); }
[data-toggle][data-rounded='xl'] { --_toggle-radius: var(--radius-xl); }
[data-toggle][data-rounded='full'] { --_toggle-radius: var(--radius-full); }
[data-toggle][data-variant='outline'] { [data-toggle][data-variant='outline'] {
--_toggle-bg: var(--toggle-outline-bg); --_toggle-bg: var(--toggle-outline-bg);
--_toggle-fg: var(--toggle-outline-fg); --_toggle-fg: var(--toggle-outline-fg);

@ -24,6 +24,7 @@
let { let {
variant = 'solid', variant = 'solid',
size = 'md', size = 'md',
rounded,
block = false, block = false,
iconOnly = false, iconOnly = false,
icon, icon,
@ -41,6 +42,7 @@
const eidos = ActiveEidos.require(); const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md')); const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedRounded = $derived(eidos.resolve(rounded));
</script> </script>
<Toggle.Provider <Toggle.Provider
@ -48,6 +50,7 @@
bind:pressed bind:pressed
data-variant={variant} data-variant={variant}
data-size={resolvedSize} data-size={resolvedSize}
data-rounded={resolvedRounded ?? undefined}
data-block={block ? '' : undefined} data-block={block ? '' : undefined}
data-icon-only={iconOnly ? '' : undefined} data-icon-only={iconOnly ? '' : undefined}
> >

@ -35,11 +35,25 @@ export type ToggleVariant = SelectionVariant;
*/ */
export type ToggleSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>; export type ToggleSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
/**
* Corner radius (magnitude). Independent of `size` per the Radix radius model:
* unset → the control archetype default (`--radius-default`, = `md`); set to
* override per-instance (e.g. `'full'` = pill at any size). Composes with the
* theme `--radius-factor` (global roundness) + `--scaling`. Mirrors Button.
*/
export type ToggleRounded = 'sm' | 'md' | 'lg' | 'xl' | 'full';
export type ToggleProps = ProviderProps & { export type ToggleProps = ProviderProps & {
/** Visual variant. @default 'solid' */ /** Visual variant. @default 'solid' */
variant?: ToggleVariant; variant?: ToggleVariant;
/** Sizing scale. @default 'md' */ /** Sizing scale. @default 'md' */
size?: ResponsiveProp<ToggleSize>; size?: ResponsiveProp<ToggleSize>;
/**
* Corner radius (magnitude). Independent of `size` (Radix model): unset →
* the control archetype default (`md`); set to override (`'full'` = pill).
* @default 'md' (archetype default; NOT derived from `size`)
*/
rounded?: ResponsiveProp<ToggleRounded>;
/** Stretch to fill the container's inline-size. @default false */ /** Stretch to fill the container's inline-size. @default false */
block?: boolean; block?: boolean;
/** Square aspect, padding-inline reduced. @default false */ /** Square aspect, padding-inline reduced. @default false */

@ -28,6 +28,7 @@
max-block-size: var(--_tooltip-max-height); max-block-size: var(--_tooltip-max-height);
padding: var(--_tooltip-py) var(--_tooltip-px); padding: var(--_tooltip-py) var(--_tooltip-px);
overflow: auto; overflow: auto;
overscroll-behavior: contain;
border: var(--tooltip-border-width) solid var(--_tooltip-border); border: var(--tooltip-border-width) solid var(--_tooltip-border);
border-radius: var(--tooltip-radius); border-radius: var(--tooltip-radius);
background: var(--_tooltip-bg); background: var(--_tooltip-bg);

@ -9,7 +9,8 @@
*/ */
[data-virtual-grid-root] { [data-virtual-grid-root] {
--_virtual-grid-radius: var(--radius-md); /* Radius DECOUPLED from size (Radix model) — surface archetype default, constant across sizes. */
--_virtual-grid-radius: var(--radius-default);
--_virtual-grid-font-size: var(--font-size-sm); --_virtual-grid-font-size: var(--font-size-sm);
--_virtual-grid-accent: var(--color-primary-solid); --_virtual-grid-accent: var(--color-primary-solid);
--_virtual-grid-accent-soft: var(--color-primary-element); --_virtual-grid-accent-soft: var(--color-primary-element);
@ -26,22 +27,18 @@
inline-size: 100%; inline-size: 100%;
} }
/* ── Sizes — chrome scale (radius + base font-size). Cell dimensions /* ── Sizes — chrome scale (base font-size only; radius is decoupled, constant). Cell dimensions
come from `rowSize` / `columnSize` props, not this scale. ─ */ come from `rowSize` / `columnSize` props, not this scale. ─ */
[data-virtual-grid-root][data-size='xs'] { [data-virtual-grid-root][data-size='xs'] {
--_virtual-grid-radius: var(--radius-sm);
--_virtual-grid-font-size: var(--font-size-xs); --_virtual-grid-font-size: var(--font-size-xs);
} }
[data-virtual-grid-root][data-size='sm'] { [data-virtual-grid-root][data-size='sm'] {
--_virtual-grid-radius: var(--radius-sm);
--_virtual-grid-font-size: var(--font-size-sm); --_virtual-grid-font-size: var(--font-size-sm);
} }
[data-virtual-grid-root][data-size='lg'] { [data-virtual-grid-root][data-size='lg'] {
--_virtual-grid-radius: var(--radius-lg);
--_virtual-grid-font-size: var(--font-size-md); --_virtual-grid-font-size: var(--font-size-md);
} }
[data-virtual-grid-root][data-size='xl'] { [data-virtual-grid-root][data-size='xl'] {
--_virtual-grid-radius: var(--radius-lg);
--_virtual-grid-font-size: var(--font-size-lg); --_virtual-grid-font-size: var(--font-size-lg);
} }

@ -13,7 +13,8 @@
*/ */
[data-virtual-list-root] { [data-virtual-list-root] {
--_virtual-list-radius: var(--radius-md); /* Radius DECOUPLED from size (Radix model) — surface archetype default, constant across sizes. */
--_virtual-list-radius: var(--radius-default);
--_virtual-list-font-size: var(--font-size-sm); --_virtual-list-font-size: var(--font-size-sm);
--_virtual-list-accent: var(--color-primary-solid); --_virtual-list-accent: var(--color-primary-solid);
--_virtual-list-accent-soft: var(--color-primary-element); --_virtual-list-accent-soft: var(--color-primary-element);
@ -30,22 +31,18 @@
inline-size: 100%; inline-size: 100%;
} }
/* ── Sizes — chrome scale (radius + base font-size). Item dimensions /* ── Sizes — chrome scale (base font-size only; radius is decoupled, constant). Item dimensions
come from `itemSize` / `estimateSize` props, not this scale. ─ */ come from `itemSize` / `estimateSize` props, not this scale. ─ */
[data-virtual-list-root][data-size='xs'] { [data-virtual-list-root][data-size='xs'] {
--_virtual-list-radius: var(--radius-sm);
--_virtual-list-font-size: var(--font-size-xs); --_virtual-list-font-size: var(--font-size-xs);
} }
[data-virtual-list-root][data-size='sm'] { [data-virtual-list-root][data-size='sm'] {
--_virtual-list-radius: var(--radius-sm);
--_virtual-list-font-size: var(--font-size-sm); --_virtual-list-font-size: var(--font-size-sm);
} }
[data-virtual-list-root][data-size='lg'] { [data-virtual-list-root][data-size='lg'] {
--_virtual-list-radius: var(--radius-lg);
--_virtual-list-font-size: var(--font-size-md); --_virtual-list-font-size: var(--font-size-md);
} }
[data-virtual-list-root][data-size='xl'] { [data-virtual-list-root][data-size='xl'] {
--_virtual-list-radius: var(--radius-lg);
--_virtual-list-font-size: var(--font-size-lg); --_virtual-list-font-size: var(--font-size-lg);
} }

@ -1363,7 +1363,7 @@
--date-field-height-xl: var(--control-height-xl); --date-field-height-xl: var(--control-height-xl);
--date-field-px-xs: var(--space-2); --date-field-px-xs: var(--space-2);
--date-field-px-sm: var(--space-2-5); --date-field-px-sm: var(--space-2-5);
--date-field-px-md: var(--space-3); --date-field-px-md: var(--space-2);
--date-field-px-lg: var(--space-3-5); --date-field-px-lg: var(--space-3-5);
--date-field-px-xl: var(--space-4); --date-field-px-xl: var(--space-4);
--date-field-gap-xs: var(--space-0-5); --date-field-gap-xs: var(--space-0-5);
@ -1411,7 +1411,7 @@
--time-field-height-xl: var(--control-height-xl); --time-field-height-xl: var(--control-height-xl);
--time-field-px-xs: var(--space-2); --time-field-px-xs: var(--space-2);
--time-field-px-sm: var(--space-2-5); --time-field-px-sm: var(--space-2-5);
--time-field-px-md: var(--space-3); --time-field-px-md: var(--space-2);
--time-field-px-lg: var(--space-3-5); --time-field-px-lg: var(--space-3-5);
--time-field-px-xl: var(--space-4); --time-field-px-xl: var(--space-4);
--time-field-gap-xs: var(--space-0-5); --time-field-gap-xs: var(--space-0-5);
@ -1459,7 +1459,7 @@
--color-field-height-xl: var(--control-height-xl); --color-field-height-xl: var(--control-height-xl);
--color-field-px-xs: var(--space-2); --color-field-px-xs: var(--space-2);
--color-field-px-sm: var(--space-2-5); --color-field-px-sm: var(--space-2-5);
--color-field-px-md: var(--space-3); --color-field-px-md: var(--space-2);
--color-field-px-lg: var(--space-3-5); --color-field-px-lg: var(--space-3-5);
--color-field-px-xl: var(--space-4); --color-field-px-xl: var(--space-4);
--color-field-gap-xs: var(--space-0-5); --color-field-gap-xs: var(--space-0-5);
@ -3156,11 +3156,6 @@
--toggle-font-weight-md: var(--font-weight-medium); --toggle-font-weight-md: var(--font-weight-medium);
--toggle-font-weight-lg: var(--font-weight-medium); --toggle-font-weight-lg: var(--font-weight-medium);
--toggle-font-weight-xl: var(--font-weight-medium); --toggle-font-weight-xl: var(--font-weight-medium);
--toggle-radius-xs: var(--radius-sm);
--toggle-radius-sm: var(--radius-sm);
--toggle-radius-md: var(--radius-md);
--toggle-radius-lg: var(--radius-md);
--toggle-radius-xl: var(--radius-md);
--toggle-border-width: var(--border-width); --toggle-border-width: var(--border-width);
--toggle-shadow: none; --toggle-shadow: none;
--toggle-transition-duration: var(--duration-fast); --toggle-transition-duration: var(--duration-fast);
@ -6239,7 +6234,7 @@
} }
:root { :root {
--floating-gap-menu: 0px; --floating-gap-menu: var(--space-1);
--floating-gap-panel: var(--space-1-5); --floating-gap-panel: var(--space-1-5);
} }

@ -1224,7 +1224,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'height-xl': 'var(--control-height-xl)', 'height-xl': 'var(--control-height-xl)',
'px-xs': 'var(--space-2)', 'px-xs': 'var(--space-2)',
'px-sm': 'var(--space-2-5)', 'px-sm': 'var(--space-2-5)',
'px-md': 'var(--space-3)', 'px-md': 'var(--space-2)',
'px-lg': 'var(--space-3-5)', 'px-lg': 'var(--space-3-5)',
'px-xl': 'var(--space-4)', 'px-xl': 'var(--space-4)',
'gap-xs': 'var(--space-0-5)', 'gap-xs': 'var(--space-0-5)',
@ -1276,7 +1276,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'height-xl': 'var(--control-height-xl)', 'height-xl': 'var(--control-height-xl)',
'px-xs': 'var(--space-2)', 'px-xs': 'var(--space-2)',
'px-sm': 'var(--space-2-5)', 'px-sm': 'var(--space-2-5)',
'px-md': 'var(--space-3)', 'px-md': 'var(--space-2)',
'px-lg': 'var(--space-3-5)', 'px-lg': 'var(--space-3-5)',
'px-xl': 'var(--space-4)', 'px-xl': 'var(--space-4)',
'gap-xs': 'var(--space-0-5)', 'gap-xs': 'var(--space-0-5)',
@ -1328,7 +1328,7 @@ export const THEME_BASE_RECIPE_TOKENS = {
'height-xl': 'var(--control-height-xl)', 'height-xl': 'var(--control-height-xl)',
'px-xs': 'var(--space-2)', 'px-xs': 'var(--space-2)',
'px-sm': 'var(--space-2-5)', 'px-sm': 'var(--space-2-5)',
'px-md': 'var(--space-3)', 'px-md': 'var(--space-2)',
'px-lg': 'var(--space-3-5)', 'px-lg': 'var(--space-3-5)',
'px-xl': 'var(--space-4)', 'px-xl': 'var(--space-4)',
'gap-xs': 'var(--space-0-5)', 'gap-xs': 'var(--space-0-5)',
@ -3725,11 +3725,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'font-weight-md': 'var(--font-weight-medium)', 'font-weight-md': 'var(--font-weight-medium)',
'font-weight-lg': 'var(--font-weight-medium)', 'font-weight-lg': 'var(--font-weight-medium)',
'font-weight-xl': 'var(--font-weight-medium)', 'font-weight-xl': 'var(--font-weight-medium)',
'radius-xs': 'var(--radius-sm)', // Radius is NOT a per-size token: it is DECOUPLED from `size` (Radix model).
'radius-sm': 'var(--radius-sm)', // toggle.css resolves the resting radius from `--radius-default` (control
'radius-md': 'var(--radius-md)', // archetype default) and the `rounded` override from the global `--radius-*`
'radius-lg': 'var(--radius-md)', // scale. No `--toggle-radius-*` aliases — see toggle.css "Rounded override".
'radius-xl': 'var(--radius-md)',
'border-width': 'var(--border-width)', 'border-width': 'var(--border-width)',
shadow: 'none', shadow: 'none',
'transition-duration': 'var(--duration-fast)', 'transition-duration': 'var(--duration-fast)',

@ -359,13 +359,13 @@ export function renderStaticCss(options: EidosConfig): string {
// `sideOffset` can't take a token, so the shared positioner (soma's // `sideOffset` can't take a token, so the shared positioner (soma's
// FloatingContent) reads THIS `<length>` @property off the content and feeds it as // FloatingContent) reads THIS `<length>` @property off the content and feeds it as
// the offset — token-driven (follows density × scaling) and arrow-safe (the arrow // the offset — token-driven (follows density × scaling) and arrow-safe (the arrow
// rides with the offset). Two archetypes: menus sit flush, panels get a small gap. // rides with the offset). Two archetypes: menus get a small gap, panels a larger one.
// A content opts in by stamping `data-floating-gap`; otherwise the positioner falls // A content opts in by stamping `data-floating-gap`; otherwise the positioner falls
// back to its numeric `sideOffset` (legacy, per-component). // back to its numeric `sideOffset` (legacy, per-component).
blocks.push(`@property --floating-gap {\n\tsyntax: '<length>';\n\tinherits: false;\n\tinitial-value: 0px;\n}`) blocks.push(`@property --floating-gap {\n\tsyntax: '<length>';\n\tinherits: false;\n\tinitial-value: 0px;\n}`)
blocks.push( blocks.push(
renderBlock(':root', [ renderBlock(':root', [
cssVar('floating-gap-menu', '0px'), cssVar('floating-gap-menu', 'var(--space-1)'),
cssVar('floating-gap-panel', 'var(--space-1-5)') cssVar('floating-gap-panel', 'var(--space-1-5)')
]) ])
) )

@ -185,9 +185,21 @@ export class FloatingContent {
return centerOffset !== undefined && centerOffset !== 0; return centerOffset !== undefined && centerOffset !== 0;
}); });
contentZIndex = $state<string>(); contentZIndex = $state<string>();
// Canonical floating gap (px) read from the content's `--floating-gap` when it // Canonical floating gap (px): when the content opts into `data-floating-gap`,
// opts into `data-floating-gap`; null → fall back to the numeric `sideOffset`. // the offset comes from the resolved `--floating-gap` (<length> @property) instead
canonicalGapPx = $state<number | null>(null); // of the numeric `sideOffset` — token-driven + arrow-safe. Read REACTIVELY off the
// content node (re-derives when `contentRef` mounts) rather than via a one-shot rAF:
// for portaled menus the rAF read never delivered, so menus fell back to `sideOffset`
// (flush). `--floating-gap` is a style value (no layout needed), safe to read on mount.
// null → fall back to the numeric `sideOffset`.
canonicalGapPx = $derived.by<number | null>(() => {
const node = this.contentRef.current;
if (!node || !node.hasAttribute('data-floating-gap')) return null;
const px = parseFloat(
this.provider.opts.dom.getWindow(node).getComputedStyle(node).getPropertyValue('--floating-gap')
);
return Number.isFinite(px) ? px : null;
});
arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]); arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]);
wrapperProps = $derived.by( wrapperProps = $derived.by(
() => () =>
@ -301,13 +313,6 @@ export class FloatingContent {
const rafId = this.provider.opts.dom.requestFrame(() => { const rafId = this.provider.opts.dom.requestFrame(() => {
if (this.contentRef.current !== contentNode || !this.opts.enabled.current) return; if (this.contentRef.current !== contentNode || !this.opts.enabled.current) return;
const cs = this.provider.opts.dom.getWindow(contentNode).getComputedStyle(contentNode); const cs = this.provider.opts.dom.getWindow(contentNode).getComputedStyle(contentNode);
// Canonical floating gap: when the content opts into `data-floating-gap`,
// the offset comes from the resolved `--floating-gap` (<length> @property)
// instead of the numeric `sideOffset` — token-driven + arrow-safe.
const gapPx = contentNode.hasAttribute('data-floating-gap')
? parseFloat(cs.getPropertyValue('--floating-gap'))
: NaN;
this.canonicalGapPx = Number.isFinite(gapPx) ? gapPx : null;
const zIndex = cs.zIndex; const zIndex = cs.zIndex;
if (zIndex !== this.contentZIndex) { if (zIndex !== this.contentZIndex) {
this.contentZIndex = zIndex; this.contentZIndex = zIndex;

Loading…
Cancel
Save

Powered by TurnKey Linux.