From 1590684c2e9a0b3497601e67c78a46a78dabb58d Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 23 Jun 2026 01:59:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(floating):=20token-driven=20canonical=20ga?= =?UTF-8?q?p=20=E2=80=94=20foundation=20+=20popover=20pilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit floating-ui's numeric `sideOffset` can't take a token, so the trigger->panel gap was a hardcoded per-component number (0/4/6/8, inconsistent). Canonize it: register a `--floating-gap` @property, set it per archetype via a foundation rule (`--floating-gap-menu: 0`, `--floating-gap-panel: --space-1-5`), and have the shared positioner (soma FloatingContent) read the resolved px off the content and feed it as the offset -- token-driven (follows density x scaling) and arrow-safe (the arrow rides with the offset). A content opts in by stamping `data-floating-gap`; otherwise the positioner falls back to its numeric `sideOffset`, so the rest migrates one at a time. Pilot: Popover.Content stamps `data-floating-gap="panel"`. Verified in-browser: overriding `--floating-gap-panel` to 20px moves the popover gap to 18px (20 minus the popover's ~2px border overlap); the default 6px is a no-op vs the old sideOffset=6. check 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/popover/popover-content.svelte | 1 + src/uix/eidos/generated/base.css | 21 +++++++++++++++++++ src/uix/eidos/lib/render-css.ts | 21 +++++++++++++++++++ .../soma/layers/floating/floating.svelte.ts | 17 +++++++++++---- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/uix/eidos/components/popover/popover-content.svelte b/src/uix/eidos/components/popover/popover-content.svelte index 09cdb5e46..0160f70e8 100644 --- a/src/uix/eidos/components/popover/popover-content.svelte +++ b/src/uix/eidos/components/popover/popover-content.svelte @@ -74,6 +74,7 @@ style={composedStyle} data-size={resolvedSize} data-depth="overlay" + data-floating-gap="panel" data-animation-style={motion === 'none' ? undefined : motion} > {#snippet children(snippetProps)} diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index 929af5b91..57f54108c 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -3392,9 +3392,11 @@ --time-range-field-row-gap: var(--space-3); --time-range-field-separator-color: var(--color-content-muted); --time-range-field-separator-glyph: '—'; + --fab-size-xs: 1.875rem; --fab-size-sm: 2.5rem; --fab-size-md: 3.5rem; --fab-size-lg: 4.5rem; + --fab-icon-xs: 1rem; --fab-icon-sm: 1.25rem; --fab-icon-md: 1.5rem; --fab-icon-lg: 1.75rem; @@ -5636,6 +5638,25 @@ } } +@property --floating-gap { + syntax: ''; + inherits: false; + initial-value: 0px; +} + +:root { + --floating-gap-menu: 0px; + --floating-gap-panel: var(--space-1-5); +} + +[data-floating-gap='menu'] { + --floating-gap: var(--floating-gap-menu); +} + +[data-floating-gap='panel'] { + --floating-gap: var(--floating-gap-panel); +} + [data-container] { container-type: inline-size; } diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts index ada11c354..8f8392382 100644 --- a/src/uix/eidos/lib/render-css.ts +++ b/src/uix/eidos/lib/render-css.ts @@ -352,6 +352,27 @@ export function renderStaticCss(options: EidosConfig): string { blocks.push(renderForcedColorsBlock()) blocks.push(renderPrefersContrastBlock()) + // Floating gap — the trigger→panel separation, canonized. floating-ui's numeric + // `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. + // 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-panel', 'var(--space-1-5)') + ]) + ) + blocks.push( + renderBlock("[data-floating-gap='menu']", [cssVar('floating-gap', 'var(--floating-gap-menu)')]) + ) + blocks.push( + renderBlock("[data-floating-gap='panel']", [cssVar('floating-gap', 'var(--floating-gap-panel)')]) + ) + // Container-query opt-in: marking an element `data-container` makes it a query // container, so its descendants' recipe `@container` overrides resolve against // its inline size. (The `@container` blocks themselves are emitted per-recipe.) diff --git a/src/uix/soma/layers/floating/floating.svelte.ts b/src/uix/soma/layers/floating/floating.svelte.ts index 2fb9c16cd..b740c6b1a 100644 --- a/src/uix/soma/layers/floating/floating.svelte.ts +++ b/src/uix/soma/layers/floating/floating.svelte.ts @@ -144,7 +144,7 @@ export class FloatingContent { () => [ offset({ - mainAxis: this.opts.sideOffset.current + this.arrowHeight, + mainAxis: (this.canonicalGapPx ?? this.opts.sideOffset.current) + this.arrowHeight, alignmentAxis: this.opts.alignOffset.current }), this.opts.avoidCollisions.current && @@ -185,6 +185,9 @@ 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); arrowBaseSide = $derived(OPPOSITE_SIDE[this.placedSide]); wrapperProps = $derived.by( () => @@ -297,9 +300,15 @@ export class FloatingContent { if (!contentNode || !this.opts.enabled.current) return; const rafId = this.provider.opts.dom.requestFrame(() => { if (this.contentRef.current !== contentNode || !this.opts.enabled.current) return; - const zIndex = this.provider.opts.dom - .getWindow(contentNode) - .getComputedStyle(contentNode).zIndex; + 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; }