From 61ea97b5bb51878a8249030ed2998b2967d100d7 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 28 Jun 2026 05:30:16 +0200 Subject: [PATCH] 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. --- CONTINUE-floating-gap-canon.md | 80 ++++++++++++++++++ src/uix/eidos/THEMING.md | 23 ++++-- src/uix/eidos/archetypes.css | 81 +++++++++++++------ src/uix/eidos/components/README.md | 2 +- src/uix/eidos/components/button/types.ts | 8 +- .../components/context-menu/context-menu.css | 1 + src/uix/eidos/components/dialog/dialog.css | 1 + src/uix/eidos/components/drawer/drawer.css | 1 + .../dropdown-menu/dropdown-menu.css | 1 + .../components/float-panel/float-panel.css | 1 + src/uix/eidos/components/menubar/menubar.css | 5 +- .../navigation-menu/navigation-menu.css | 47 ++++++++--- src/uix/eidos/components/popover/popover.css | 1 + .../components/scroll-area/scroll-area.css | 7 ++ .../eidos/components/textarea/textarea.css | 6 +- src/uix/eidos/components/toggle/toggle.css | 19 +++-- src/uix/eidos/components/toggle/toggle.svelte | 3 + src/uix/eidos/components/toggle/types.ts | 14 ++++ src/uix/eidos/components/tooltip/tooltip.css | 1 + .../components/virtual-grid/virtual-grid.css | 9 +-- .../components/virtual-list/virtual-list.css | 9 +-- src/uix/eidos/generated/base.css | 13 +-- src/uix/eidos/lib/recipes/base.ts | 15 ++-- src/uix/eidos/lib/render-css.ts | 4 +- .../soma/layers/floating/floating.svelte.ts | 25 +++--- 25 files changed, 281 insertions(+), 96 deletions(-) create mode 100644 CONTINUE-floating-gap-canon.md diff --git a/CONTINUE-floating-gap-canon.md b/CONTINUE-floating-gap-canon.md new file mode 100644 index 000000000..d0e47308f --- /dev/null +++ b/CONTINUE-floating-gap-canon.md @@ -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 ``. 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 `` + 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). diff --git a/src/uix/eidos/THEMING.md b/src/uix/eidos/THEMING.md index 6d6c34d87..c19a199ac 100644 --- a/src/uix/eidos/THEMING.md +++ b/src/uix/eidos/THEMING.md @@ -738,7 +738,7 @@ Donde `{prefix}` es uno de: | `--duration-{key}` | Motion duration | `--duration-fast` | | `--ease-{key}` | Motion ease | `--ease-out` | | `--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}-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`** `` — registrada para que JS resuelva el px 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 `[data-floating-gap='menu'|'panel'] { --floating-gap: … }`. Todo emitido en `render-css.ts`. - **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 **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 @@ -2307,14 +2309,25 @@ eso cada flotante hardcodeaba su gap (0/4/6/8, incoherente). Canon: | 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`) | 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`. +> **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 doc. diff --git a/src/uix/eidos/archetypes.css b/src/uix/eidos/archetypes.css index dff17fa5e..78183bb97 100644 --- a/src/uix/eidos/archetypes.css +++ b/src/uix/eidos/archetypes.css @@ -6,14 +6,24 @@ * `Dialog.Trigger`, a `Popover.Trigger` and a `DropdownMenu.Trigger` all * carry `data-archetype="trigger"`, so the rule below covers them all. * - * Specificity is intentionally low (single attribute selector). Component - * tokens or scoped overrides win when present. + * These are DEFAULTS — a component recipe must ALWAYS be able to override them. + * 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 ─────────────────────────────────────────────────────────────── * Applies to anything that the user clicks/taps to activate or open. */ -[data-archetype='trigger'] { +:where([data-archetype='trigger']) { cursor: pointer; user-select: none; transition: @@ -21,12 +31,11 @@ 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; } -[data-archetype='trigger'][data-disabled], -[data-archetype='trigger'][disabled] { +:where([data-archetype='trigger'][data-disabled], [data-archetype='trigger'][disabled]) { cursor: not-allowed; opacity: 0.5; } @@ -41,6 +50,7 @@ * 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 * `[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']) { padding-inline-end: 0; @@ -49,19 +59,24 @@ /* ── Overlay ────────────────────────────────────────────────────────────── * Modal backdrops behind dialog/drawer/popover content. */ -[data-archetype='overlay'] { +:where([data-archetype='overlay']) { position: fixed; inset: 0; pointer-events: auto; } -/* ── Content ─────────────────────────────────────────────────────────────── */ -[data-archetype='content'] { +/* ── Content ──────────────────────────────────────────────────────────────── */ +:where([data-archetype='content']) { position: relative; } -/* ── Indicator ───────────────────────────────────────────────────────────── */ -[data-archetype='indicator'] { +/* ── 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; transition: transform var(--duration-fast) var(--ease-default); } @@ -69,41 +84,41 @@ /* ── Thumb ──────────────────────────────────────────────────────────────── * Draggable handles (slider, switch, scroll thumb). */ -[data-archetype='thumb'] { +:where([data-archetype='thumb']) { cursor: grab; user-select: none; transition: transform var(--duration-fast) var(--ease-default); } -[data-archetype='thumb']:active { +:where([data-archetype='thumb']:active) { cursor: grabbing; } -[data-archetype='thumb'][data-disabled] { +:where([data-archetype='thumb'][data-disabled]) { cursor: not-allowed; opacity: 0.5; } /* ── Track ──────────────────────────────────────────────────────────────── */ -[data-archetype='track'] { +:where([data-archetype='track']) { position: relative; } /* ── Close ──────────────────────────────────────────────────────────────── * Inherits trigger's interactive feel; overrides only what's specific. */ -[data-archetype='close'] { +:where([data-archetype='close']) { cursor: pointer; user-select: none; transition: opacity var(--duration-fast) var(--ease-default); } -[data-archetype='close']:hover { +:where([data-archetype='close']:hover) { opacity: 0.85; } /* ── Action ─────────────────────────────────────────────────────────────── */ -[data-archetype='action'] { +:where([data-archetype='action']) { cursor: pointer; user-select: none; } @@ -117,8 +132,22 @@ * 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, * 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: 0 0 0 var(--focus-ring-offset) var(--focus-ring-bg, transparent), 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. */ /* ── Item / Option ──────────────────────────────────────────────────────── */ -[data-archetype='item'], -[data-archetype='option'] { +:where([data-archetype='item'], [data-archetype='option']) { cursor: pointer; user-select: none; /* 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)); } -/* Canonical "active option" highlight — hover, keyboard highlight - * (data-highlighted) and active (aria-selected / data-selected), EXCEPT a chosen +/* Canonical "active option" highlight — hover, keyboard focus (`:focus-visible`, + * 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 * 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 * content-primary — black in light themes — on hover). * One neutral surface so the active row reads identically across select, * 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='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='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']), @@ -173,8 +205,7 @@ color: var(--color-content-primary); } -[data-archetype='item'][data-disabled], -[data-archetype='option'][data-disabled] { +:where([data-archetype='item'][data-disabled], [data-archetype='option'][data-disabled]) { cursor: not-allowed; opacity: 0.5; } diff --git a/src/uix/eidos/components/README.md b/src/uix/eidos/components/README.md index 219698e58..e6b9260b7 100644 --- a/src/uix/eidos/components/README.md +++ b/src/uix/eidos/components/README.md @@ -412,7 +412,7 @@ ni `--soma-`. | 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` | Los `[data-{component}]` y `[data-{component}-{part}]` selectors son la diff --git a/src/uix/eidos/components/button/types.ts b/src/uix/eidos/components/button/types.ts index 0721d1308..c4218edbb 100644 --- a/src/uix/eidos/components/button/types.ts +++ b/src/uix/eidos/components/button/types.ts @@ -51,7 +51,13 @@ export type ButtonProps = Omit & { variant?: ButtonVariant; /** Sizing scale. @default 'md' */ size?: ResponsiveProp; - /** 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; /** * Corner shape family — `rounded` (arc) · `continuous` (squircle) · `cut` (bevel) · `scoop` diff --git a/src/uix/eidos/components/context-menu/context-menu.css b/src/uix/eidos/components/context-menu/context-menu.css index 2f3cb4baa..354092a1c 100644 --- a/src/uix/eidos/components/context-menu/context-menu.css +++ b/src/uix/eidos/components/context-menu/context-menu.css @@ -24,6 +24,7 @@ min-inline-size: var(--context-menu-content-min-width, 12rem); max-block-size: var(--context-menu-content-max-height, min(60vh, 22rem)); overflow-y: auto; + overscroll-behavior: contain; padding: var(--context-menu-content-padding, var(--space-1)); /* Elevation — surface · border · shadow — from the `overlay` plane via `data-depth` (A1, Decisión 8). Radius / font / color stay the panel's own. */ diff --git a/src/uix/eidos/components/dialog/dialog.css b/src/uix/eidos/components/dialog/dialog.css index 86f37bc57..63f98c376 100644 --- a/src/uix/eidos/components/dialog/dialog.css +++ b/src/uix/eidos/components/dialog/dialog.css @@ -67,6 +67,7 @@ max-height: var(--_dialog-content-max-height-override, var(--dialog-content-max-height)); padding: var(--_dialog-padding); overflow: auto; + overscroll-behavior: contain; /* Elevation bundle (surface · border · shadow) comes from the `modal` plane 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 diff --git a/src/uix/eidos/components/drawer/drawer.css b/src/uix/eidos/components/drawer/drawer.css index 0f3ab1ef1..144f10591 100644 --- a/src/uix/eidos/components/drawer/drawer.css +++ b/src/uix/eidos/components/drawer/drawer.css @@ -78,6 +78,7 @@ gap: var(--drawer-stack-gap); padding: var(--_drawer-padding); overflow: auto; + overscroll-behavior: contain; min-inline-size: var(--_drawer-content-min-width-override, auto); max-inline-size: var(--_drawer-content-max-width-override, none); min-block-size: var(--_drawer-content-min-height-override, auto); diff --git a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css index 6e018a733..4f0e6909e 100644 --- a/src/uix/eidos/components/dropdown-menu/dropdown-menu.css +++ b/src/uix/eidos/components/dropdown-menu/dropdown-menu.css @@ -35,6 +35,7 @@ min-inline-size: var(--dropdown-menu-content-min-width, 12rem); max-block-size: var(--dropdown-menu-content-max-height, min(60vh, 22rem)); overflow-y: auto; + overscroll-behavior: contain; padding: var(--dropdown-menu-content-padding, var(--space-1)); /* Elevation — surface · border · shadow — from the `overlay` plane via `data-depth` (A1, Decisión 8). Radius / font / color stay the panel's own. */ diff --git a/src/uix/eidos/components/float-panel/float-panel.css b/src/uix/eidos/components/float-panel/float-panel.css index 018c26ed0..66400e0af 100644 --- a/src/uix/eidos/components/float-panel/float-panel.css +++ b/src/uix/eidos/components/float-panel/float-panel.css @@ -273,6 +273,7 @@ flex: 1 1 auto; min-block-size: 0; overflow: auto; + overscroll-behavior: contain; padding-block: var(--float-panel-body-py); padding-inline: var(--float-panel-body-px); } diff --git a/src/uix/eidos/components/menubar/menubar.css b/src/uix/eidos/components/menubar/menubar.css index d2c36d5df..1a9ca79d8 100644 --- a/src/uix/eidos/components/menubar/menubar.css +++ b/src/uix/eidos/components/menubar/menubar.css @@ -26,7 +26,8 @@ --_menubar-height: var(--control-height-md); --_menubar-padding: 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-font-size: var(--font-size-md); /* Concentric nesting (Arq. 6 / shape engine): the in-bar trigger consumes the @@ -48,7 +49,6 @@ --_menubar-height: var(--control-height-sm); --_menubar-padding: 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-font-size: var(--font-size-sm); } @@ -57,7 +57,6 @@ --_menubar-height: var(--control-height-lg); --_menubar-padding: var(--space-2); --_menubar-gap: var(--space-2); - --_menubar-radius: var(--radius-lg); --_menubar-trigger-px: var(--space-4); --_menubar-font-size: var(--font-size-lg); } diff --git a/src/uix/eidos/components/navigation-menu/navigation-menu.css b/src/uix/eidos/components/navigation-menu/navigation-menu.css index 03a6848ae..0164ac32e 100644 --- a/src/uix/eidos/components/navigation-menu/navigation-menu.css +++ b/src/uix/eidos/components/navigation-menu/navigation-menu.css @@ -22,7 +22,8 @@ --_navigation-menu-trigger-px: var(--space-3); --_navigation-menu-list-gap: var(--space-1); --_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; display: inline-flex; @@ -37,14 +38,12 @@ --_navigation-menu-trigger-px: var(--space-2); --_navigation-menu-list-gap: 0; --_navigation-menu-font-size: var(--font-size-sm); - --_navigation-menu-radius: var(--radius-sm); } [data-navigation-menu][data-size='lg'] { --_navigation-menu-trigger-px: var(--space-4); --_navigation-menu-list-gap: var(--space-2); --_navigation-menu-font-size: var(--font-size-lg); - --_navigation-menu-radius: var(--radius-lg); } /* ── List ─ */ @@ -152,14 +151,25 @@ * recipe paints the panel chrome (border, shadow, padding) and the * 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 * opening the mega-menu doesn't push siblings. `top: 100%` anchors * 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 * extends past the trigger if needed. */ 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; z-index: var(--navigation-menu-content-z); @@ -218,7 +228,7 @@ * of the trigger column instead of below. */ [data-navigation-menu][data-orientation='vertical'] [data-navigation-menu-content] { 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'] { @@ -278,20 +288,35 @@ * between triggers. The indicator itself stays inside the navigation * 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; - inset-inline-start: 0; - inset-block-end: 0; z-index: 1; pointer-events: none; inline-size: var(--navigation-menu-indicator-w, 0px); block-size: 2px; - transform: translateX(var(--navigation-menu-indicator-x, 0px)); + transform: translateY(var(--navigation-menu-indicator-h, 0px)); background: var(--color-primary-solid); border-radius: var(--radius-full); 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: - transform var(--duration-fast) var(--ease-default), + left var(--duration-fast) var(--ease-default), inline-size var(--duration-fast) var(--ease-default), opacity var(--duration-fast) var(--ease-default); } diff --git a/src/uix/eidos/components/popover/popover.css b/src/uix/eidos/components/popover/popover.css index 0f82d3e27..cfb1c1b35 100644 --- a/src/uix/eidos/components/popover/popover.css +++ b/src/uix/eidos/components/popover/popover.css @@ -127,6 +127,7 @@ max-block-size: var(--_popover-max-height); padding: var(--_popover-py) var(--_popover-px); overflow: auto; + overscroll-behavior: contain; border-radius: inherit; } diff --git a/src/uix/eidos/components/scroll-area/scroll-area.css b/src/uix/eidos/components/scroll-area/scroll-area.css index a9dbd53da..8cbba1e87 100644 --- a/src/uix/eidos/components/scroll-area/scroll-area.css +++ b/src/uix/eidos/components/scroll-area/scroll-area.css @@ -27,6 +27,13 @@ [data-scroll-area-viewport] { position: absolute; 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 (``) 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 diff --git a/src/uix/eidos/components/textarea/textarea.css b/src/uix/eidos/components/textarea/textarea.css index 68e265c67..6350dd34b 100644 --- a/src/uix/eidos/components/textarea/textarea.css +++ b/src/uix/eidos/components/textarea/textarea.css @@ -6,7 +6,8 @@ --_textarea-gap: var(--space-2); --_textarea-font-size: var(--font-size-md); --_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); /* `--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 @@ -34,7 +35,6 @@ --_textarea-px: var(--space-2); --_textarea-py: var(--space-1); --_textarea-font-size: var(--font-size-xs); - --_textarea-radius: var(--radius-sm); } [data-textarea][data-size='sm'] { --_textarea-px: var(--space-2); @@ -45,13 +45,11 @@ --_textarea-px: var(--space-4); --_textarea-py: var(--space-3); --_textarea-font-size: var(--font-size-lg); - --_textarea-radius: var(--radius-lg); } [data-textarea][data-size='xl'] { --_textarea-px: var(--space-5); --_textarea-py: var(--space-4); --_textarea-font-size: var(--font-size-xl); - --_textarea-radius: var(--radius-lg); } /* Color accents — applied on focus + when invalid. Each role's `-solid` (the diff --git a/src/uix/eidos/components/toggle/toggle.css b/src/uix/eidos/components/toggle/toggle.css index fd7ee82a1..d7c05aaef 100644 --- a/src/uix/eidos/components/toggle/toggle.css +++ b/src/uix/eidos/components/toggle/toggle.css @@ -4,7 +4,10 @@ --_toggle-gap: var(--toggle-gap-md); --_toggle-font-size: var(--toggle-font-size-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 * --toggle-{variant}-* tokens (host-scoped) are emitted by the * generator into a separate `[data-toggle]` block. Per-color @@ -67,7 +70,6 @@ --_toggle-gap: var(--toggle-gap-xs); --_toggle-font-size: var(--toggle-font-size-xs); --_toggle-font-weight: var(--toggle-font-weight-xs); - --_toggle-radius: var(--toggle-radius-xs); } [data-toggle][data-size='sm'] { @@ -76,7 +78,6 @@ --_toggle-gap: var(--toggle-gap-sm); --_toggle-font-size: var(--toggle-font-size-sm); --_toggle-font-weight: var(--toggle-font-weight-sm); - --_toggle-radius: var(--toggle-radius-sm); } [data-toggle][data-size='lg'] { @@ -85,7 +86,6 @@ --_toggle-gap: var(--toggle-gap-lg); --_toggle-font-size: var(--toggle-font-size-lg); --_toggle-font-weight: var(--toggle-font-weight-lg); - --_toggle-radius: var(--toggle-radius-lg); } [data-toggle][data-size='xl'] { @@ -94,9 +94,18 @@ --_toggle-gap: var(--toggle-gap-xl); --_toggle-font-size: var(--toggle-font-size-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'] { --_toggle-bg: var(--toggle-outline-bg); --_toggle-fg: var(--toggle-outline-fg); diff --git a/src/uix/eidos/components/toggle/toggle.svelte b/src/uix/eidos/components/toggle/toggle.svelte index 12c8c4362..3d6c7ddce 100644 --- a/src/uix/eidos/components/toggle/toggle.svelte +++ b/src/uix/eidos/components/toggle/toggle.svelte @@ -24,6 +24,7 @@ let { variant = 'solid', size = 'md', + rounded, block = false, iconOnly = false, icon, @@ -41,6 +42,7 @@ const eidos = ActiveEidos.require(); const resolvedSize = $derived(eidos.resolve(size, 'md')); + const resolvedRounded = $derived(eidos.resolve(rounded)); diff --git a/src/uix/eidos/components/toggle/types.ts b/src/uix/eidos/components/toggle/types.ts index ee513bc2e..1ad31e582 100644 --- a/src/uix/eidos/components/toggle/types.ts +++ b/src/uix/eidos/components/toggle/types.ts @@ -35,11 +35,25 @@ export type ToggleVariant = SelectionVariant; */ export type ToggleSize = Extract; +/** + * 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 & { /** Visual variant. @default 'solid' */ variant?: ToggleVariant; /** Sizing scale. @default 'md' */ size?: ResponsiveProp; + /** + * 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; /** Stretch to fill the container's inline-size. @default false */ block?: boolean; /** Square aspect, padding-inline reduced. @default false */ diff --git a/src/uix/eidos/components/tooltip/tooltip.css b/src/uix/eidos/components/tooltip/tooltip.css index 75d5f0f4f..c490c3ab3 100644 --- a/src/uix/eidos/components/tooltip/tooltip.css +++ b/src/uix/eidos/components/tooltip/tooltip.css @@ -28,6 +28,7 @@ max-block-size: var(--_tooltip-max-height); padding: var(--_tooltip-py) var(--_tooltip-px); overflow: auto; + overscroll-behavior: contain; border: var(--tooltip-border-width) solid var(--_tooltip-border); border-radius: var(--tooltip-radius); background: var(--_tooltip-bg); diff --git a/src/uix/eidos/components/virtual-grid/virtual-grid.css b/src/uix/eidos/components/virtual-grid/virtual-grid.css index 0a2a6a36d..d0bbc3724 100644 --- a/src/uix/eidos/components/virtual-grid/virtual-grid.css +++ b/src/uix/eidos/components/virtual-grid/virtual-grid.css @@ -9,7 +9,8 @@ */ [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-accent: var(--color-primary-solid); --_virtual-grid-accent-soft: var(--color-primary-element); @@ -26,22 +27,18 @@ 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. ─ */ [data-virtual-grid-root][data-size='xs'] { - --_virtual-grid-radius: var(--radius-sm); --_virtual-grid-font-size: var(--font-size-xs); } [data-virtual-grid-root][data-size='sm'] { - --_virtual-grid-radius: var(--radius-sm); --_virtual-grid-font-size: var(--font-size-sm); } [data-virtual-grid-root][data-size='lg'] { - --_virtual-grid-radius: var(--radius-lg); --_virtual-grid-font-size: var(--font-size-md); } [data-virtual-grid-root][data-size='xl'] { - --_virtual-grid-radius: var(--radius-lg); --_virtual-grid-font-size: var(--font-size-lg); } diff --git a/src/uix/eidos/components/virtual-list/virtual-list.css b/src/uix/eidos/components/virtual-list/virtual-list.css index 595927aee..761e5a383 100644 --- a/src/uix/eidos/components/virtual-list/virtual-list.css +++ b/src/uix/eidos/components/virtual-list/virtual-list.css @@ -13,7 +13,8 @@ */ [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-accent: var(--color-primary-solid); --_virtual-list-accent-soft: var(--color-primary-element); @@ -30,22 +31,18 @@ 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. ─ */ [data-virtual-list-root][data-size='xs'] { - --_virtual-list-radius: var(--radius-sm); --_virtual-list-font-size: var(--font-size-xs); } [data-virtual-list-root][data-size='sm'] { - --_virtual-list-radius: var(--radius-sm); --_virtual-list-font-size: var(--font-size-sm); } [data-virtual-list-root][data-size='lg'] { - --_virtual-list-radius: var(--radius-lg); --_virtual-list-font-size: var(--font-size-md); } [data-virtual-list-root][data-size='xl'] { - --_virtual-list-radius: var(--radius-lg); --_virtual-list-font-size: var(--font-size-lg); } diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index bc362ae45..2bb77234c 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1363,7 +1363,7 @@ --date-field-height-xl: var(--control-height-xl); --date-field-px-xs: var(--space-2); --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-xl: var(--space-4); --date-field-gap-xs: var(--space-0-5); @@ -1411,7 +1411,7 @@ --time-field-height-xl: var(--control-height-xl); --time-field-px-xs: var(--space-2); --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-xl: var(--space-4); --time-field-gap-xs: var(--space-0-5); @@ -1459,7 +1459,7 @@ --color-field-height-xl: var(--control-height-xl); --color-field-px-xs: var(--space-2); --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-xl: var(--space-4); --color-field-gap-xs: var(--space-0-5); @@ -3156,11 +3156,6 @@ --toggle-font-weight-md: var(--font-weight-medium); --toggle-font-weight-lg: 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-shadow: none; --toggle-transition-duration: var(--duration-fast); @@ -6239,7 +6234,7 @@ } :root { - --floating-gap-menu: 0px; + --floating-gap-menu: var(--space-1); --floating-gap-panel: var(--space-1-5); } diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index d5ed947b2..c297e4cec 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -1224,7 +1224,7 @@ export const THEME_BASE_RECIPE_TOKENS = { 'height-xl': 'var(--control-height-xl)', 'px-xs': 'var(--space-2)', 'px-sm': 'var(--space-2-5)', - 'px-md': 'var(--space-3)', + 'px-md': 'var(--space-2)', 'px-lg': 'var(--space-3-5)', 'px-xl': 'var(--space-4)', 'gap-xs': 'var(--space-0-5)', @@ -1276,7 +1276,7 @@ export const THEME_BASE_RECIPE_TOKENS = { 'height-xl': 'var(--control-height-xl)', 'px-xs': 'var(--space-2)', 'px-sm': 'var(--space-2-5)', - 'px-md': 'var(--space-3)', + 'px-md': 'var(--space-2)', 'px-lg': 'var(--space-3-5)', 'px-xl': 'var(--space-4)', 'gap-xs': 'var(--space-0-5)', @@ -1328,7 +1328,7 @@ export const THEME_BASE_RECIPE_TOKENS = { 'height-xl': 'var(--control-height-xl)', 'px-xs': 'var(--space-2)', 'px-sm': 'var(--space-2-5)', - 'px-md': 'var(--space-3)', + 'px-md': 'var(--space-2)', 'px-lg': 'var(--space-3-5)', 'px-xl': 'var(--space-4)', '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-lg': 'var(--font-weight-medium)', 'font-weight-xl': 'var(--font-weight-medium)', - 'radius-xs': 'var(--radius-sm)', - 'radius-sm': 'var(--radius-sm)', - 'radius-md': 'var(--radius-md)', - 'radius-lg': 'var(--radius-md)', - 'radius-xl': 'var(--radius-md)', + // Radius is NOT a per-size token: it is DECOUPLED from `size` (Radix model). + // toggle.css resolves the resting radius from `--radius-default` (control + // archetype default) and the `rounded` override from the global `--radius-*` + // scale. No `--toggle-radius-*` aliases — see toggle.css "Rounded override". 'border-width': 'var(--border-width)', shadow: 'none', 'transition-duration': 'var(--duration-fast)', diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index aa9be0698..6b45c81e3 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -359,13 +359,13 @@ export function renderStaticCss(options: EidosConfig): string { // `sideOffset` can't take a token, so the shared positioner (soma's // FloatingContent) reads THIS `` @property off the content and feeds it as // 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 // back to its numeric `sideOffset` (legacy, per-component). blocks.push(`@property --floating-gap {\n\tsyntax: '';\n\tinherits: false;\n\tinitial-value: 0px;\n}`) blocks.push( renderBlock(':root', [ - cssVar('floating-gap-menu', '0px'), + cssVar('floating-gap-menu', 'var(--space-1)'), cssVar('floating-gap-panel', 'var(--space-1-5)') ]) ) diff --git a/src/uix/soma/layers/floating/floating.svelte.ts b/src/uix/soma/layers/floating/floating.svelte.ts index b740c6b1a..680d4f932 100644 --- a/src/uix/soma/layers/floating/floating.svelte.ts +++ b/src/uix/soma/layers/floating/floating.svelte.ts @@ -185,9 +185,21 @@ export class FloatingContent { return centerOffset !== undefined && centerOffset !== 0; }); contentZIndex = $state(); - // Canonical floating gap (px) read from the content's `--floating-gap` when it - // opts into `data-floating-gap`; null → fall back to the numeric `sideOffset`. - canonicalGapPx = $state(null); + // Canonical floating gap (px): when the content opts into `data-floating-gap`, + // the offset comes from the resolved `--floating-gap` ( @property) instead + // 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(() => { + 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]); wrapperProps = $derived.by( () => @@ -301,13 +313,6 @@ export class FloatingContent { const rafId = this.provider.opts.dom.requestFrame(() => { if (this.contentRef.current !== contentNode || !this.opts.enabled.current) return; 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` ( @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; if (zIndex !== this.contentZIndex) { this.contentZIndex = zIndex;