From 5a132ead22fd9b8ffdfe4e31e7b09e74ce9d8211 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 21 Jun 2026 07:50:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(eidos):=20button=20audit=20=E2=80=94=20foc?= =?UTF-8?q?us=20canon,=20press=20archetype,=20intrinsic=20box,=20plain=20p?= =?UTF-8?q?ure-emphasis,=20spinner=20via=20Spinner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - focus ring: adopt canonical `--focus-ring-*` (outline, HCM-safe, primary-tint) — drops the per-color fork - press archetype: new `STATIC_PRESS` primitive (`--press-scale: 0.985` / `--press-duration: 80ms`) emitted from foundation. Button switches from hardcoded `translateY(1px)` to canonical `scale(var(--press-scale))`. Card recipe repointed to the shared token — single press signature across the system - intrinsic box: `inline-size`/`block-size: fit-content` defends the Button against flex/grid stretch (was filling 400×160 in a grid; now 94×36). `data-block` still opts into width-stretch, `data-icon-only` still square - variant=emphasis only: `plain` no longer mutates layout (`min-block-size: auto` + `padding-inline: 0` removed) — same control box as the other 5 variants. Inline link-like actions belong to , not here - spinner composition: Button drops its bespoke SVG ring + `@keyframes button-spinner-spin` + `--button-spinner-duration: 720ms` and composes the canonical via two new modes: `presentation` (no role=status/aria-live, marks aria-hidden — Button owns the a11y) + `color="inherit"` (currentColor + 25% track). One spinner across the system, unified duration (0.9s) and reduced-motion behaviour Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/button/button-spinner.svelte | 25 +++------- src/uix/eidos/components/button/button.css | 48 ++++++++++--------- src/uix/eidos/components/button/button.svelte | 26 +++++++--- src/uix/eidos/components/button/types.ts | 6 ++- src/uix/eidos/components/spinner/spinner.css | 7 +++ .../eidos/components/spinner/spinner.svelte | 8 ++-- src/uix/eidos/components/spinner/types.ts | 14 +++++- src/uix/eidos/generated/base.css | 8 ++-- src/uix/eidos/lib/config-types.ts | 8 ++++ src/uix/eidos/lib/contract.ts | 5 ++ src/uix/eidos/lib/primitives/static.ts | 14 ++++++ src/uix/eidos/lib/recipes/base.ts | 7 +-- src/uix/eidos/lib/render-css.ts | 5 ++ 13 files changed, 123 insertions(+), 58 deletions(-) diff --git a/src/uix/eidos/components/button/button-spinner.svelte b/src/uix/eidos/components/button/button-spinner.svelte index 5bda6dac6..19c1646eb 100644 --- a/src/uix/eidos/components/button/button-spinner.svelte +++ b/src/uix/eidos/components/button/button-spinner.svelte @@ -1,33 +1,22 @@ {#if children} {@render children?.()} {:else} - - + {/if} diff --git a/src/uix/eidos/components/button/button.css b/src/uix/eidos/components/button/button.css index e8558315f..76355e81e 100644 --- a/src/uix/eidos/components/button/button.css +++ b/src/uix/eidos/components/button/button.css @@ -52,6 +52,13 @@ align-items: center; justify-content: center; gap: var(--_button-gap); + /* Defend the intrinsic box: a button is content-sized and NEVER stretched by a + flex/grid parent. As a flex/grid item `inline-flex` blockifies to `flex`, and an + `auto` main/cross size would stretch to fill the cell (filling H+W) — `fit-content` + pins the button to its own size while the container's alignment still positions it. + `[data-block]` opts INTO full width; `[data-icon-only]` overrides to a square. */ + inline-size: fit-content; + block-size: fit-content; min-block-size: var(--_button-height); padding-inline: var(--_button-padding-inline); border: var(--button-border-width) solid var(--_button-border); @@ -81,12 +88,22 @@ } [data-button]:active:not([data-disabled]):not([data-loading]) { - transform: translateY(1px); + /* Canonical press — the shared scale-down "squeeze" (`--press-*`), not a + bespoke translateY. `transform: scale` composes with flex-centred content; + absolutely-centred consumers (e.g. carousel triggers) centre via the + `translate` property so the scale doesn't wipe their offset. */ + transform: scale(var(--press-scale)); + transition-duration: var(--press-duration); } [data-button]:focus-visible { - outline: var(--border-width-medium) solid var(--button-palette-border); - outline-offset: 2px; + /* Canonical focus ring — `outline` (survives forced-colors/HCM, unlike box-shadow) + with the shared `--focus-ring-*` tokens. A single primary-tinted accent ring, + not per-color: a consistent, high-contrast focus indicator (a11y), same as + badge / card / carousel. If `--focus-ring-color` ever becomes intent-driven at + the canon level, the Button inherits it for free. */ + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); } /* ── Size scale ────────────────────────────────────────────────────────── */ @@ -179,13 +196,14 @@ } [data-button][data-variant='plain'] { + /* Pure-emphasis variant (text button): transparent chrome + underline on hover, + but the SAME control box as every other variant — `variant` is the EMPHASIS + axis only, never layout. Inline link-like actions belong to , not here. */ --_button-bg: transparent; --_button-fg: var(--button-palette-text); --_button-border: transparent; --_button-hover-bg: transparent; --_button-hover-border: transparent; - min-block-size: auto; - padding-inline: 0; } [data-button][data-variant='plain']:hover:not([data-disabled]):not([data-loading]) { @@ -283,30 +301,16 @@ /* ── Spinner ───────────────────────────────────────────────────────────── */ [data-button-spinner] { + /* Presentational slot — the composed (variant ring, color=inherit) + sizes + animates itself. The slot just centres it in the icon flow. */ display: inline-flex; align-items: center; justify-content: center; - inline-size: var(--_button-icon-size, 1em); - block-size: var(--_button-icon-size, 1em); - color: currentColor; -} - -.eidos-button-spinner-ring { - inline-size: 100%; - block-size: 100%; - animation: button-spinner-spin var(--button-spinner-duration) linear infinite; -} - -@keyframes button-spinner-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { - [data-button], - .eidos-button-spinner-ring { + [data-button] { transition: none; - animation: none; } } diff --git a/src/uix/eidos/components/button/button.svelte b/src/uix/eidos/components/button/button.svelte index f656f081c..367824f13 100644 --- a/src/uix/eidos/components/button/button.svelte +++ b/src/uix/eidos/components/button/button.svelte @@ -13,16 +13,19 @@ import { ActiveEidos } from '$uix/eidos'; import * as Button from '$soma/components/button'; import ButtonSpinner from './button-spinner.svelte'; + import { getButtonGroupEidosCtx } from '$uix/eidos/components/button-group/context'; import type { ButtonProps } from './types'; let { - variant = 'solid', - size = 'md', + variant, + size, rounded, shape, block = false, iconOnly = false, loading = false, + color, + disabled, icon, endIcon, spinner, @@ -33,16 +36,27 @@ }: ButtonProps = $props(); const eidos = ActiveEidos.require(); - const resolvedSize = $derived(eidos.resolve(size, 'md')); + + // Optional context supplies shared visual DEFAULTS. An + // explicit prop on this Button always wins; outside a group the getter is + // undefined and Button falls back to its own defaults (backward-compatible). + const group = getButtonGroupEidosCtx(); + + const resolvedVariant = $derived(variant ?? group?.variant ?? 'solid'); + const resolvedSize = $derived(eidos.resolve(size ?? group?.size, 'md')); const resolvedRounded = $derived(eidos.resolve(rounded)); + const resolvedColor = $derived(color ?? group?.color); + const resolvedDisabled = $derived(disabled ?? group?.disabled); {#if loading && spinnerPlacement === 'start'} - {#if spinner}{@render spinner()}{:else}{/if} + {#if spinner}{@render spinner()}{:else}{/if} {:else if icon}