feat(eidos): button audit — focus canon, press archetype, intrinsic box, plain pure-emphasis, spinner via Spinner

- focus ring: adopt canonical `--focus-ring-*` (outline, HCM-safe, primary-tint) — drops the per-color fork
- press archetype: new `STATIC_PRESS` primitive (`--press-scale: 0.985` / `--press-duration: 80ms`) emitted from foundation. Button switches from hardcoded `translateY(1px)` to canonical `scale(var(--press-scale))`. Card recipe repointed to the shared token — single press signature across the system
- intrinsic box: `inline-size`/`block-size: fit-content` defends the Button against flex/grid stretch (was filling 400×160 in a grid; now 94×36). `data-block` still opts into width-stretch, `data-icon-only` still square
- variant=emphasis only: `plain` no longer mutates layout (`min-block-size: auto` + `padding-inline: 0` removed) — same control box as the other 5 variants. Inline link-like actions belong to <Link>, not here
- spinner composition: Button drops its bespoke SVG ring + `@keyframes button-spinner-spin` + `--button-spinner-duration: 720ms` and composes the canonical <Spinner variant="ring"> via two new modes: `presentation` (no role=status/aria-live, marks aria-hidden — Button owns the a11y) + `color="inherit"` (currentColor + 25% track). One spinner across the system, unified duration (0.9s) and reduced-motion behaviour

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 4878ccfbef
commit 5a132ead22

@ -1,33 +1,22 @@
<script lang="ts">
/**
* Eidos `<Button.Spinner>` — visual wrapper over the soma spinner part.
* Eidos `<Button.Spinner>` — visual wrapper over the soma spinner slot.
*
* Renders an inline SVG ring with a CSS rotate animation. The recipe
* (`button.css`) controls the colour via `currentColor` and animates
* via `@keyframes button-spinner-spin`.
* Composes the canonical `<Spinner>` (variant `ring`) in presentational
* mode (no `role=status` — the Button owns the loading a11y via `aria-busy`)
* with `color="inherit"` so the ring follows the button's text colour.
*/
import * as Button from '$soma/components/button';
import { Spinner } from '../spinner';
import type { ButtonSpinnerWrapperProps } from './types';
let { children, ...rest }: ButtonSpinnerWrapperProps = $props();
let { children, size = 'md', ...rest }: ButtonSpinnerWrapperProps = $props();
</script>
<Button.Spinner {...rest}>
{#if children}
{@render children?.()}
{:else}
<!-- Default ring spinner. Stroked SVG with currentColor so it
inherits the button's text colour. The recipe animates it. -->
<svg
class="eidos-button-spinner-ring"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6" opacity="0.25" />
<path d="M14 8a6 6 0 0 0-6-6" stroke-linecap="round" />
</svg>
<Spinner variant="ring" presentation color="inherit" {size} />
{/if}
</Button.Spinner>

@ -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 <Link>, 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 <Spinner> (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;
}
}

@ -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 <ButtonGroup> 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);
</script>
<Button.Provider
{...headlessProps}
color={resolvedColor}
disabled={resolvedDisabled}
{loading}
{loadingText}
{spinnerPlacement}
data-variant={variant}
data-variant={resolvedVariant}
data-size={resolvedSize}
data-rounded={resolvedRounded ?? undefined}
data-shape={shape}
@ -54,7 +68,7 @@
{#snippet children(snippetProps)}
<!-- Start slot: spinner (when loading) wins over icon at start placement. -->
{#if loading && spinnerPlacement === 'start'}
{#if spinner}<ButtonSpinner>{@render spinner()}</ButtonSpinner>{:else}<ButtonSpinner />{/if}
{#if spinner}<ButtonSpinner size={resolvedSize}>{@render spinner()}</ButtonSpinner>{:else}<ButtonSpinner size={resolvedSize} />{/if}
{:else if icon}
<span class="eidos-button-icon" data-button-icon="start" aria-hidden="true">
{@render icon(snippetProps)}
@ -79,7 +93,7 @@
<!-- End slot: spinner (when loading) wins over endIcon at end placement. -->
{#if loading && spinnerPlacement === 'end'}
{#if spinner}<ButtonSpinner>{@render spinner()}</ButtonSpinner>{:else}<ButtonSpinner />{/if}
{#if spinner}<ButtonSpinner size={resolvedSize}>{@render spinner()}</ButtonSpinner>{:else}<ButtonSpinner size={resolvedSize} />{/if}
{:else if endIcon}
<span class="eidos-button-icon" data-button-icon="end" aria-hidden="true">
{@render endIcon(snippetProps)}

@ -1,6 +1,7 @@
import type { Snippet } from 'svelte';
import type { ProviderProps, SpinnerProps } from '$soma/components/button';
import type { ResponsiveProp, ShapeFamily, Size } from '$uix/eidos/lib/types';
import type { SpinnerSize } from '../spinner/types';
/**
* Button visual variant. The 6-variant set ported from air covers the
@ -76,4 +77,7 @@ export type ButtonProps = ProviderProps & {
spinner?: Snippet;
};
export type ButtonSpinnerWrapperProps = SpinnerProps;
export type ButtonSpinnerWrapperProps = SpinnerProps & {
/** Sizes the embedded `<Spinner>` ring; forwarded from the Button's size. @default 'md' */
size?: SpinnerSize;
};

@ -74,6 +74,13 @@
--_spinner-track: color-mix(in srgb, var(--color-loss-track) 60%, transparent);
}
/* `inherit` — take the surrounding `currentColor` (for embedding in a control
that owns the colour, e.g. a solid Button whose text is the contrast colour). */
[data-spinner][data-color='inherit'] {
--_spinner-color: currentColor;
--_spinner-track: color-mix(in srgb, currentColor 25%, transparent);
}
/* ── Ring variant ──────────────────────────────────────────────────── */
[data-spinner][data-variant='ring'] [data-spinner-track] {

@ -19,6 +19,7 @@
variant = 'ring',
size = 'md',
color = 'primary',
presentation = false,
'aria-label': ariaLabel,
children,
...rest
@ -35,9 +36,10 @@
data-variant={variant}
data-size={resolvedSize}
data-color={color}
role="status"
aria-live="polite"
aria-label={resolvedLabel}
role={presentation ? undefined : 'status'}
aria-live={presentation ? undefined : 'polite'}
aria-label={presentation ? undefined : resolvedLabel}
aria-hidden={presentation ? 'true' : undefined}
{...rest}
>
{#if variant === 'dots'}

@ -3,7 +3,7 @@ import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { BoxProps } from '../box/types';
export type SpinnerSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type SpinnerColor = ColorRole;
export type SpinnerColor = ColorRole | 'inherit';
export type SpinnerVariant = 'ring' | 'dots' | 'bars';
/**
@ -19,8 +19,18 @@ export type SpinnerProps = Omit<BoxProps, 'display'> & {
variant?: SpinnerVariant;
/** Spinner size. @default 'md' */
size?: ResponsiveProp<SpinnerSize>;
/** Accent palette. @default 'primary' */
/**
* Accent palette, or `'inherit'` to take the surrounding `currentColor`
* (for embedding inside a control that owns the colour, e.g. Button).
* @default 'primary'
*/
color?: SpinnerColor;
/**
* Presentational mode — drops `role="status"` / `aria-live` / `aria-label`
* and marks the spinner `aria-hidden`. Use when an ancestor already owns the
* loading a11y (e.g. a Button with `aria-busy`). @default false
*/
presentation?: boolean;
/** Override the SR text. Defaults to a translated `'Loading'`. */
'aria-label'?: string;
/** Optional visible label rendered next to the spinner. */

@ -178,6 +178,8 @@
--focus-ring-inner-width: 0px;
--focus-ring: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--focus-ring-error: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color-error), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color-error);
--press-scale: 0.985;
--press-duration: 80ms;
--field-segment-active-bg: color-mix(in srgb, var(--color-primary-border) 40%, transparent);
--field-segment-active-text: var(--color-primary-text);
--field-segment-hover-bg: color-mix(in srgb, var(--color-content-primary) 6%, transparent);
@ -878,8 +880,8 @@
--card-emerge-distance: 8px;
--card-hover-lift: -2px;
--card-hover-shadow: var(--depth-overlay-shadow), var(--depth-overlay-halo);
--card-press-scale: 0.985;
--card-press-duration: 80ms;
--card-press-scale: var(--press-scale);
--card-press-duration: var(--press-duration);
--card-selected-ring-width: var(--border-width-medium);
--card-disabled-opacity: var(--opacity-disabled);
--card-color-custom: currentColor;
@ -1625,7 +1627,6 @@
--button-shadow: none;
--button-transition-duration: var(--duration-fast);
--button-transition-ease: var(--ease-default);
--button-spinner-duration: 720ms;
--button-disabled-opacity: var(--opacity-disabled);
--button-primary-track: var(--color-primary-track);
--button-primary-element: var(--color-primary-element);
@ -1760,6 +1761,7 @@
--chronos-lane-height-sm: var(--space-5);
--chronos-lane-height-md: var(--space-6);
--chronos-lane-height-lg: var(--space-7);
--chronos-timegrid-block-size: min(70vh, 40rem);
--chronos-focus-shadow: inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
--chronos-disabled-opacity: var(--opacity-disabled);
--pagination-gap-xs: var(--space-0-5);

@ -247,6 +247,13 @@ export interface FocusRingPrimitiveSet {
readonly innerWidth: string;
}
export interface PressPrimitiveSet {
/** Scale-down factor applied on `:active` (unitless, e.g. `0.985`). */
readonly scale: string;
/** Transition duration of the press squeeze (the press IN). */
readonly duration: string;
}
export const SHADOW_PHYSICAL_KEYS = ['1', '2', '3', '4', '5', '6'] as const;
export const SHADOW_SEMANTIC_KEYS = ['none', 'subtle', 'raised', 'overlay'] as const;
// Inner-shadow (recessed/well) tier — soft, mode-aware, drawn `inset`. Distinct
@ -816,6 +823,7 @@ export interface PrimitiveSet {
readonly gradients?: Record<string, string>;
readonly border?: BorderPrimitiveSet;
readonly focusRing?: FocusRingPrimitiveSet;
readonly press?: PressPrimitiveSet;
readonly layout?: LayoutPrimitiveSet;
readonly density?: DensityPrimitiveSet;
readonly typography?: TypographyPrimitiveSet;

@ -92,6 +92,11 @@ function appendStaticContractTokens(tokens: EidosCssContractToken[], options: Ei
pushToken(tokens, 'static', 'focus-ring', 'focus-ring-error', 'primitives.focusRing.error')
}
if (primitives.press) {
pushToken(tokens, 'static', 'press', 'press-scale', 'primitives.press.scale')
pushToken(tokens, 'static', 'press', 'press-duration', 'primitives.press.duration')
}
if (primitives.layout) {
appendLayoutContractTokens(tokens, primitives.layout)
}

@ -111,6 +111,18 @@ export const STATIC_FOCUS_RING = {
innerWidth: '0px'
} as const
export const STATIC_PRESS = {
/* Press feedback — a subtle scale-down "squeeze" on `:active`. ONE shared
tactile signature for every interactive surface (button, card, …), the
sibling of the focus ring: a single recognisable press across the system.
`scale` is unitless; `duration` is snappier than `--duration-fast` for an
immediate, physical response (the press IN; release rides the base
transition). Centred consumers must compose it via the `translate`
property, not `transform`, so the scale doesn't wipe their offset. */
scale: '0.985',
duration: '80ms'
} as const
export const STATIC_LAYOUT = {
containerWidth: {
sm: '448px',
@ -417,6 +429,7 @@ export const STATIC_PRIMITIVES: Pick<
| 'gradients'
| 'border'
| 'focusRing'
| 'press'
| 'layout'
| 'density'
| 'typography'
@ -436,6 +449,7 @@ export const STATIC_PRIMITIVES: Pick<
gradients: STATIC_GRADIENTS,
border: STATIC_BORDER,
focusRing: STATIC_FOCUS_RING,
press: STATIC_PRESS,
layout: STATIC_LAYOUT,
density: STATIC_DENSITY,
typography: STATIC_TYPOGRAPHY,

@ -523,8 +523,8 @@ export const THEME_BASE_RECIPE_TOKENS = {
'emerge-distance': '8px',
'hover-lift': '-2px',
'hover-shadow': 'var(--depth-overlay-shadow), var(--depth-overlay-halo)',
'press-scale': '0.985',
'press-duration': '80ms',
'press-scale': 'var(--press-scale)',
'press-duration': 'var(--press-duration)',
'selected-ring-width': 'var(--border-width-medium)',
'disabled-opacity': 'var(--opacity-disabled)',
// ── Custom color (set inline by consumer via style) ───────────────
@ -1502,7 +1502,6 @@ export const THEME_BASE_RECIPE_TOKENS = {
shadow: 'none',
'transition-duration': 'var(--duration-fast)',
'transition-ease': 'var(--ease-default)',
'spinner-duration': '720ms',
'disabled-opacity': 'var(--opacity-disabled)',
// Per-color palettes — 7 slots per color, uniform across all 8: track /
// element / border / solid / solid-hover / text / contrast. soft + surface
@ -1739,6 +1738,8 @@ export const THEME_BASE_RECIPE_TOKENS = {
'lane-height-sm': 'var(--space-5)',
'lane-height-md': 'var(--space-6)',
'lane-height-lg': 'var(--space-7)',
// Week/Day time-grid viewport height (scrolls internally).
'timegrid-block-size': 'min(70vh, 40rem)',
'focus-shadow':
'inset 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-color), 0 0 0 var(--focus-ring-offset) var(--color-surface-default), 0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color)',
'disabled-opacity': 'var(--opacity-disabled)'

@ -198,6 +198,11 @@ export function renderStaticCss(options: EidosConfig): string {
)
}
if (primitives.press) {
declarations.push(cssVar('press-scale', primitives.press.scale))
declarations.push(cssVar('press-duration', primitives.press.duration))
}
// Active-segment highlight — single foundation source for the date/time/
// color field active segment. `-bg` is the full background colour (bake in
// whatever transparency you want, e.g. via `color-mix`) and `-text` is the

Loading…
Cancel
Save

Powered by TurnKey Linux.