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}
{@render icon(snippetProps)}
@@ -79,7 +93,7 @@
{#if loading && spinnerPlacement === 'end'}
- {#if spinner}{@render spinner()}{:else}{/if}
+ {#if spinner}{@render spinner()}{:else}{/if}
{:else if endIcon}
{@render endIcon(snippetProps)}
diff --git a/src/uix/eidos/components/button/types.ts b/src/uix/eidos/components/button/types.ts
index 85c87df2b..6014f5c20 100644
--- a/src/uix/eidos/components/button/types.ts
+++ b/src/uix/eidos/components/button/types.ts
@@ -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 `` ring; forwarded from the Button's size. @default 'md' */
+ size?: SpinnerSize;
+};
diff --git a/src/uix/eidos/components/spinner/spinner.css b/src/uix/eidos/components/spinner/spinner.css
index 82da6e2c1..69c6c2c6e 100644
--- a/src/uix/eidos/components/spinner/spinner.css
+++ b/src/uix/eidos/components/spinner/spinner.css
@@ -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] {
diff --git a/src/uix/eidos/components/spinner/spinner.svelte b/src/uix/eidos/components/spinner/spinner.svelte
index d0e7ac67c..108b8ba57 100644
--- a/src/uix/eidos/components/spinner/spinner.svelte
+++ b/src/uix/eidos/components/spinner/spinner.svelte
@@ -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'}
diff --git a/src/uix/eidos/components/spinner/types.ts b/src/uix/eidos/components/spinner/types.ts
index 5b55af4f2..f655f91d0 100644
--- a/src/uix/eidos/components/spinner/types.ts
+++ b/src/uix/eidos/components/spinner/types.ts
@@ -3,7 +3,7 @@ import type { ColorRole, ResponsiveProp, Size } from '$uix/eidos/lib/types';
import type { BoxProps } from '../box/types';
export type SpinnerSize = Extract;
-export type SpinnerColor = ColorRole;
+export type SpinnerColor = ColorRole | 'inherit';
export type SpinnerVariant = 'ring' | 'dots' | 'bars';
/**
@@ -19,8 +19,18 @@ export type SpinnerProps = Omit & {
variant?: SpinnerVariant;
/** Spinner size. @default 'md' */
size?: ResponsiveProp;
- /** 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. */
diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css
index 72a729965..82a87c10e 100644
--- a/src/uix/eidos/generated/base.css
+++ b/src/uix/eidos/generated/base.css
@@ -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);
diff --git a/src/uix/eidos/lib/config-types.ts b/src/uix/eidos/lib/config-types.ts
index 735ac1a63..728e5a2df 100644
--- a/src/uix/eidos/lib/config-types.ts
+++ b/src/uix/eidos/lib/config-types.ts
@@ -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;
readonly border?: BorderPrimitiveSet;
readonly focusRing?: FocusRingPrimitiveSet;
+ readonly press?: PressPrimitiveSet;
readonly layout?: LayoutPrimitiveSet;
readonly density?: DensityPrimitiveSet;
readonly typography?: TypographyPrimitiveSet;
diff --git a/src/uix/eidos/lib/contract.ts b/src/uix/eidos/lib/contract.ts
index 86b287ea3..ce0d322df 100644
--- a/src/uix/eidos/lib/contract.ts
+++ b/src/uix/eidos/lib/contract.ts
@@ -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)
}
diff --git a/src/uix/eidos/lib/primitives/static.ts b/src/uix/eidos/lib/primitives/static.ts
index 1761f101b..6a38c56c2 100644
--- a/src/uix/eidos/lib/primitives/static.ts
+++ b/src/uix/eidos/lib/primitives/static.ts
@@ -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,
diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts
index 79c0619b9..76527ef60 100644
--- a/src/uix/eidos/lib/recipes/base.ts
+++ b/src/uix/eidos/lib/recipes/base.ts
@@ -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)'
diff --git a/src/uix/eidos/lib/render-css.ts b/src/uix/eidos/lib/render-css.ts
index ae87b7a62..712f5b622 100644
--- a/src/uix/eidos/lib/render-css.ts
+++ b/src/uix/eidos/lib/render-css.ts
@@ -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