From 3390cc862a5da19f23641fb2372d7aa693bf58cb Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 10:47:33 +0530 Subject: [PATCH 01/41] fix: give h1-h4 their own paired line-height token h1-h3 all used --rs-line-height-t1 (24px) regardless of font size, so a 32px h1 overlapped its own wrapped lines. Point each heading at its matching t4/t3/t2 line-height; h4 already paired correctly. Plan: plans/001-heading-line-heights.md --- packages/raystack/styles/typography.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/raystack/styles/typography.css b/packages/raystack/styles/typography.css index 3d9820895..c5826adc0 100644 --- a/packages/raystack/styles/typography.css +++ b/packages/raystack/styles/typography.css @@ -103,19 +103,19 @@ h1 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t4); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t4); } h2 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t3); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t3); } h3 { font-family: var(--rs-font-title); font-size: var(--rs-font-size-t2); font-weight: var(--rs-font-weight-medium); - line-height: var(--rs-line-height-t1); + line-height: var(--rs-line-height-t2); } h4 { font-family: var(--rs-font-title); From 6adbf18f5b0b34cb3446426d9400298c183828fd Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 10:47:41 +0530 Subject: [PATCH 02/41] fix: stop sidebar width transition from clobbering interactive states A later transition:width rule on .root, .nav-item, .nav-text, and .resizeHandle replaced (not merged with) each element's own transition, silently killing nav-item hover/press fades, the nav-text opacity fade, and the resize-handle hover fade for every motion-enabled user. Scope the width transition to .root, the only element that actually resizes. Plan: plans/002-sidebar-width-transition-scope.md --- packages/raystack/components/sidebar/sidebar.module.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index 676cc412c..a16fdca01 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -380,10 +380,7 @@ } @media (prefers-reduced-motion: no-preference) { - .root, - .nav-item, - .nav-text, - .resizeHandle { + .root { transition: width 0.2s ease; } From 562bcfc1f664e790f5b6080a41a404af95e74ded Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 10:53:17 +0530 Subject: [PATCH 03/41] fix: compose motion tokens into --rs-transition-interactive, add press tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --rs-transition-interactive hardcoded 0.2s ease instead of composing the duration/easing tokens declared a few lines below it, and applied the same 200ms to :active — so a button press arrived as slowly as a hover fade. Compose the token, add --rs-duration-press (100ms) and --rs-transition-pressed, and wire it into button/icon-button :active. Also replace every hardcoded duration/easing across 14 component stylesheets with the matching --rs-duration-*/--rs-ease-* token, and sync tour's FADE_OUT_MS (160, matching nothing) to 150ms, the actual .spotlightCover fade duration. Plan: plans/003-motion-tokens-and-press-tier.md --- .../components/accordion/accordion.module.css | 4 ++-- .../components/button/button.module.css | 4 ++++ .../components/checkbox/checkbox.module.css | 4 +++- .../collapsible/collapsible.module.css | 2 +- .../filter-chip/filter-chip.module.css | 2 +- .../icon-button/icon-button.module.css | 4 ++++ .../raystack/components/link/link.module.css | 2 +- .../components/navbar/navbar.module.css | 2 +- .../raystack/components/radio/radio.module.css | 4 +++- .../scroll-area/scroll-area.module.css | 10 +++++----- .../components/sidebar/sidebar.module.css | 15 +++++---------- .../components/switch/switch.module.css | 4 ++-- .../raystack/components/tabs/tabs.module.css | 2 +- .../raystack/components/toast/toast.module.css | 2 +- .../components/toggle/toggle.module.css | 2 +- .../components/tour-beta/tour.module.css | 2 +- packages/raystack/components/tour/tour-root.tsx | 4 ++-- .../raystack/components/tour/tour.module.css | 2 +- packages/raystack/styles/effects.css | 17 ++++++++++++++++- 19 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/raystack/components/accordion/accordion.module.css b/packages/raystack/components/accordion/accordion.module.css index 6c85bd0bd..0de172de6 100644 --- a/packages/raystack/components/accordion/accordion.module.css +++ b/packages/raystack/components/accordion/accordion.module.css @@ -80,10 +80,10 @@ @media (prefers-reduced-motion: no-preference) { .accordion-icon { - transition: transform 150ms ease-in-out; + transition: transform var(--rs-duration-fast) var(--rs-ease-in-out); } .accordion-content { - transition: height 150ms ease-out; + transition: height var(--rs-duration-fast) var(--rs-ease-out); } } diff --git a/packages/raystack/components/button/button.module.css b/packages/raystack/components/button/button.module.css index 65ff5385e..99a5dc53d 100644 --- a/packages/raystack/components/button/button.module.css +++ b/packages/raystack/components/button/button.module.css @@ -212,6 +212,10 @@ .button { transition: var(--rs-transition-interactive); } + + .button:active { + transition: var(--rs-transition-pressed); + } } .loader-text { diff --git a/packages/raystack/components/checkbox/checkbox.module.css b/packages/raystack/components/checkbox/checkbox.module.css index f93a4a4cd..0bdef70a4 100644 --- a/packages/raystack/components/checkbox/checkbox.module.css +++ b/packages/raystack/components/checkbox/checkbox.module.css @@ -20,7 +20,9 @@ border: 1px solid var(--rs-color-border-base-secondary); cursor: pointer; flex-shrink: 0; - transition: background-color 150ms ease, border-color 150ms ease; + transition: + background-color var(--rs-duration-fast) var(--rs-ease-out), + border-color var(--rs-duration-fast) var(--rs-ease-out); } /* Size variants */ diff --git a/packages/raystack/components/collapsible/collapsible.module.css b/packages/raystack/components/collapsible/collapsible.module.css index 7c94e3b1d..21f3e0bb6 100644 --- a/packages/raystack/components/collapsible/collapsible.module.css +++ b/packages/raystack/components/collapsible/collapsible.module.css @@ -20,6 +20,6 @@ @media (prefers-reduced-motion: no-preference) { .panel { - transition: height 150ms ease-out; + transition: height var(--rs-duration-fast) var(--rs-ease-out); } } diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index 5ba007416..c32e0203b 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -96,7 +96,7 @@ .removeIcon { cursor: pointer; color: var(--rs-color-foreground-base-primary); - transition: color 0.2s ease-in-out; + transition: color var(--rs-duration-normal) var(--rs-ease-out); width: var(--rs-space-4); height: var(--rs-space-4); } diff --git a/packages/raystack/components/icon-button/icon-button.module.css b/packages/raystack/components/icon-button/icon-button.module.css index 2f45dcdf6..f85502c19 100644 --- a/packages/raystack/components/icon-button/icon-button.module.css +++ b/packages/raystack/components/icon-button/icon-button.module.css @@ -18,6 +18,10 @@ color: var(--rs-color-foreground-base-primary); } +.iconButton:active:not(:disabled) { + transition: var(--rs-transition-pressed); +} + .iconButton:disabled { opacity: 0.5; cursor: not-allowed; diff --git a/packages/raystack/components/link/link.module.css b/packages/raystack/components/link/link.module.css index 844246429..bb3ed5dc8 100644 --- a/packages/raystack/components/link/link.module.css +++ b/packages/raystack/components/link/link.module.css @@ -1,7 +1,7 @@ .link { cursor: pointer; margin: 0; - transition: opacity 0.2s ease; + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); } .link:hover { diff --git a/packages/raystack/components/navbar/navbar.module.css b/packages/raystack/components/navbar/navbar.module.css index 6c513efad..d7f7db202 100644 --- a/packages/raystack/components/navbar/navbar.module.css +++ b/packages/raystack/components/navbar/navbar.module.css @@ -11,7 +11,7 @@ @media (prefers-reduced-motion: no-preference) { .root { - transition: transform 0.2s ease-in-out; + transition: transform var(--rs-duration-normal) var(--rs-ease-in-out); } } diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index 53562f1c5..a8ca56ec5 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -20,7 +20,9 @@ justify-content: center; cursor: pointer; box-sizing: border-box; - transition: background-color 150ms ease, border-color 150ms ease; + transition: + background-color var(--rs-duration-fast) var(--rs-ease-out), + border-color var(--rs-duration-fast) var(--rs-ease-out); } /* Group-level size — low specificity via :where() so item-level size wins */ diff --git a/packages/raystack/components/scroll-area/scroll-area.module.css b/packages/raystack/components/scroll-area/scroll-area.module.css index 10395305f..efff3406a 100644 --- a/packages/raystack/components/scroll-area/scroll-area.module.css +++ b/packages/raystack/components/scroll-area/scroll-area.module.css @@ -25,7 +25,7 @@ background: transparent; pointer-events: auto; position: relative; - transition: opacity 150ms ease-out; + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); } .scrollbar[data-orientation="vertical"] { @@ -49,7 +49,7 @@ flex: 1; background: var(--rs-color-overlay-base-a5); border-radius: var(--rs-radius-2); - transition: opacity 150ms ease-out; + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); opacity: 1; pointer-events: auto; touch-action: none; @@ -58,9 +58,9 @@ @media (prefers-reduced-motion: no-preference) { .scrollbar { transition: - width 150ms ease-out, - height 150ms ease-out, - opacity 150ms ease-out; + width var(--rs-duration-fast) var(--rs-ease-out), + height var(--rs-duration-fast) var(--rs-ease-out), + opacity var(--rs-duration-fast) var(--rs-ease-out); } } diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index a16fdca01..9a6103845 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -82,12 +82,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; text-decoration: none; - transition: - background-color 0.2s ease, - color 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease, - opacity 0.2s ease; + transition: var(--rs-transition-interactive); box-sizing: border-box; } @@ -201,7 +196,7 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - transition: opacity 0.2s ease; + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); } .root[data-closed] .nav-text { @@ -217,7 +212,7 @@ width: var(--rs-space-2); height: 100%; cursor: ew-resize; - transition: background-color 0.2s ease; + transition: background-color var(--rs-duration-normal) var(--rs-ease-out); transform: translateX(50%); } @@ -304,7 +299,7 @@ height: var(--rs-space-4); color: var(--rs-color-foreground-base-secondary); transform: rotate(-90deg); - transition: transform 0.2s ease; + transition: transform var(--rs-duration-normal) var(--rs-ease-out); flex-shrink: 0; } @@ -319,7 +314,7 @@ padding: var(--rs-space-2) var(--rs-space-3); color: var(--rs-color-foreground-base-secondary); opacity: 0; - transition: opacity 0.2s ease; + transition: opacity var(--rs-duration-normal) var(--rs-ease-out); } .nav-group-header:hover .nav-group-trailing-icon { diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index 5a7e3853d..3e5ff3196 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -9,7 +9,7 @@ cursor: pointer; background: var(--rs-color-background-neutral-secondary); border-radius: var(--rs-radius-full); - transition: background-color 200ms ease; + transition: background-color var(--rs-duration-normal) var(--rs-ease-out); } .switch.large { @@ -56,7 +56,7 @@ @media (prefers-reduced-motion: no-preference) { .thumb { - transition: transform 200ms ease-in-out; + transition: transform var(--rs-duration-normal) var(--rs-ease-in-out); } } diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index ec0b76d9a..16989490e 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -50,7 +50,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; border-radius: var(--rs-radius-2); - transition: color 0.2s ease; + transition: color var(--rs-duration-normal) var(--rs-ease-out); flex: 1; text-align: center; text-overflow: ellipsis; diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index 7183796d7..531c4723f 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -207,7 +207,7 @@ /* ===== Content (stacking visibility) ===== */ .content { - transition: opacity 250ms; + transition: opacity var(--rs-duration-moderate) var(--rs-ease-out); } .content[data-behind] { diff --git a/packages/raystack/components/toggle/toggle.module.css b/packages/raystack/components/toggle/toggle.module.css index 7cc8a4d12..1c1c2955a 100644 --- a/packages/raystack/components/toggle/toggle.module.css +++ b/packages/raystack/components/toggle/toggle.module.css @@ -6,7 +6,7 @@ color: var(--rs-color-foreground-base-secondary); cursor: pointer; padding: var(--rs-space-1); - transition: color 0.2s ease; + transition: color var(--rs-duration-normal) var(--rs-ease-out); background-color: var(--rs-color-background-base-secondary); border: 0.5px solid var(--rs-color-border-base-primary); border-radius: var(--rs-radius-1); diff --git a/packages/raystack/components/tour-beta/tour.module.css b/packages/raystack/components/tour-beta/tour.module.css index 7d16946b2..70caa62c8 100644 --- a/packages/raystack/components/tour-beta/tour.module.css +++ b/packages/raystack/components/tour-beta/tour.module.css @@ -52,7 +52,7 @@ otherwise the first open would animate in from the viewport origin. */ @media (prefers-reduced-motion: no-preference) { .positioner:has(.popup:not([data-starting-style])) { - transition: transform 350ms var(--rs-ease-out); + transition: transform var(--rs-duration-slow) var(--rs-ease-out); } } diff --git a/packages/raystack/components/tour/tour-root.tsx b/packages/raystack/components/tour/tour-root.tsx index 08d2a6c9b..f6873d774 100644 --- a/packages/raystack/components/tour/tour-root.tsx +++ b/packages/raystack/components/tour/tour-root.tsx @@ -26,8 +26,8 @@ import { usePrefersReducedMotion } from './use-prefers-reduced-motion'; import { resolveTourTarget, useTourTarget } from './use-tour-target'; import { rectsEqual } from './utils'; -// Must match the overlay's opacity transition in `tour.module.css`. -const FADE_OUT_MS = 160; +// Must match --rs-duration-fast — the .spotlightCover fade in tour.module.css. +const FADE_OUT_MS = 150; const REVEAL_TIMEOUT_MS = 2000; diff --git a/packages/raystack/components/tour/tour.module.css b/packages/raystack/components/tour/tour.module.css index 6823972ff..3d7ab44a7 100644 --- a/packages/raystack/components/tour/tour.module.css +++ b/packages/raystack/components/tour/tour.module.css @@ -72,7 +72,7 @@ origin. `fade` mode repositions the card while it is hidden, so no glide. */ @media (prefers-reduced-motion: no-preference) { .positioner[data-transition="move"]:has(.popup:not([data-starting-style])) { - transition: transform 350ms var(--rs-ease-out); + transition: transform var(--rs-duration-slow) var(--rs-ease-out); } } diff --git a/packages/raystack/styles/effects.css b/packages/raystack/styles/effects.css index 648f233dc..c668305bc 100644 --- a/packages/raystack/styles/effects.css +++ b/packages/raystack/styles/effects.css @@ -8,13 +8,28 @@ --rs-shadow-inset: 0px 1px 1px 0px oklch(0 0 0 / 0.04) inset; /* Transitions */ - --rs-transition-interactive: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + --rs-transition-interactive: + background-color var(--rs-duration-normal) var(--rs-ease-out), + border-color var(--rs-duration-normal) var(--rs-ease-out), + color var(--rs-duration-normal) var(--rs-ease-out), + box-shadow var(--rs-duration-normal) var(--rs-ease-out), + opacity var(--rs-duration-normal) var(--rs-ease-out); + /* Pressed states: acknowledge pointer-down near-instantly. Apply on + `:active`; releasing falls back to --rs-transition-interactive (200ms). */ + --rs-transition-pressed: + background-color var(--rs-duration-press) var(--rs-ease-out), + border-color var(--rs-duration-press) var(--rs-ease-out), + color var(--rs-duration-press) var(--rs-ease-out), + box-shadow var(--rs-duration-press) var(--rs-ease-out), + opacity var(--rs-duration-press) var(--rs-ease-out), + transform var(--rs-duration-press) var(--rs-ease-out); /* Motion */ /* Example usage: transition: opacity var(--rs-duration-normal) var(--rs-ease-out); */ --rs-ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* entrances, exits, value changes */ --rs-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* elements moving on screen */ --rs-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer slide */ + --rs-duration-press: 100ms; /* pressed/active feedback */ --rs-duration-fast: 150ms; /* tooltips, menus, small popups */ --rs-duration-normal: 200ms; /* popovers, dialogs, switches */ --rs-duration-moderate: 250ms; /* tabs indicator, preview card, tour steps */ From 5eb7050a1c687be2104dbeec6b576b38a3654c11 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 10:54:29 +0530 Subject: [PATCH 04/41] fix: re-step type ramp tracking and leading to be monotonic Tracking ran backwards (16px large carried more spacing than 14px regular) and titles had zero tracking at every size. Leading was non-monotonic too (mini looser than small; t1 tighter than every larger title). Re-step both ramps: body tracking shrinks toward neutral as size grows, titles step progressively negative in em; body leading rises smoothly to the 16px peak, title leading falls as display size grows. NEEDS VISUAL QA: this reflows all text across every consuming app. Requires a designer eyeball pass in apps/www (docs page, data-table page, t1-t4 headings) before this ships to production, per the plan's verification section. Plan: plans/006-typography-tracking-leading.md --- packages/raystack/styles/typography.css | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/raystack/styles/typography.css b/packages/raystack/styles/typography.css index c5826adc0..eaee8a8cf 100644 --- a/packages/raystack/styles/typography.css +++ b/packages/raystack/styles/typography.css @@ -29,17 +29,17 @@ /* Body Line Heights */ --rs-line-height-micro: 12px; - --rs-line-height-mini: 16px; + --rs-line-height-mini: 14px; --rs-line-height-small: 16px; --rs-line-height-regular: 20px; --rs-line-height-large: 24px; /* Body Letter spacing */ --rs-letter-spacing-micro: 0.5px; - --rs-letter-spacing-mini: 0.5px; - --rs-letter-spacing-small: 0.4px; + --rs-letter-spacing-mini: 0.4px; + --rs-letter-spacing-small: 0.3px; --rs-letter-spacing-regular: 0.25px; - --rs-letter-spacing-large: 0.5px; + --rs-letter-spacing-large: 0px; /* Title Font Sizes */ --rs-font-size-t1: 20px; @@ -48,16 +48,16 @@ --rs-font-size-t4: 32px; /* Title Line Heights */ - --rs-line-height-t1: 24px; + --rs-line-height-t1: 28px; --rs-line-height-t2: 32px; --rs-line-height-t3: 36px; --rs-line-height-t4: 40px; /* Title Letter Spacing */ - --rs-letter-spacing-t1: 0; - --rs-letter-spacing-t2: 0; - --rs-letter-spacing-t3: 0; - --rs-letter-spacing-t4: 0; + --rs-letter-spacing-t1: -0.01em; + --rs-letter-spacing-t2: -0.013em; + --rs-letter-spacing-t3: -0.016em; + --rs-letter-spacing-t4: -0.02em; /* Mono Font Sizes */ --rs-font-size-mono-mini: 11px; From a7b2ddafc0c2199f8bb0ce69589814c3734f3fcc Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:04:46 +0530 Subject: [PATCH 05/41] fix: add distinct pressed (:active) feedback across interactive components :active either duplicated :hover or didn't exist, so pointer-down gave no acknowledgment before an action's result appeared. Add a pressed vocabulary: one shade darker background where the hover ladder allows it, otherwise a subtle scale/stretch (button 0.97, checkbox/radio 0.92, switch thumb stretch, chip dismiss 0.9, slider thumb grow+lift). Also fold in the slider part of audit #55: delete a dead .thumb:hover { border-color } rule and add grab feedback on the thumb children (position itself stays transition-free for 1:1 drag). Plans: plans/004-pressed-states-pass.md --- .../components/accordion/accordion.module.css | 4 ++++ .../components/button/button.module.css | 4 ++++ .../components/calendar/calendar.module.css | 12 +++++++++++ .../components/callout/callout.module.css | 13 ++++++++---- .../components/checkbox/checkbox.module.css | 13 ++++++++++++ .../raystack/components/chip/chip.module.css | 10 +++++++++ .../data-table/data-table.module.css | 4 ++++ .../components/drawer/drawer.module.css | 5 +++++ .../icon-button/icon-button.module.css | 8 +++++-- .../number-field/number-field.module.css | 5 +++++ .../components/radio/radio.module.css | 13 ++++++++++++ .../components/select/select.module.css | 4 ++++ .../components/sidebar/sidebar.module.css | 5 +++++ .../components/slider/slider.module.css | 21 +++++++++++++++---- .../components/switch/switch.module.css | 17 +++++++++++++++ .../raystack/components/tabs/tabs.module.css | 14 ++++++++++++- 16 files changed, 141 insertions(+), 11 deletions(-) diff --git a/packages/raystack/components/accordion/accordion.module.css b/packages/raystack/components/accordion/accordion.module.css index 0de172de6..8e2f13995 100644 --- a/packages/raystack/components/accordion/accordion.module.css +++ b/packages/raystack/components/accordion/accordion.module.css @@ -36,6 +36,10 @@ background-color: var(--rs-color-background-base-primary-hover); } +.accordion-trigger:active:not(:disabled) { + background-color: var(--rs-color-background-neutral-secondary); +} + .accordion-trigger:disabled { pointer-events: none; opacity: 0.5; diff --git a/packages/raystack/components/button/button.module.css b/packages/raystack/components/button/button.module.css index 99a5dc53d..333260234 100644 --- a/packages/raystack/components/button/button.module.css +++ b/packages/raystack/components/button/button.module.css @@ -208,6 +208,10 @@ background-color: inherit; } +.button:active:not(:disabled):not(.button-disabled):not(.button-loading) { + transform: scale(0.97); +} + @media (prefers-reduced-motion: no-preference) { .button { transition: var(--rs-transition-interactive); diff --git a/packages/raystack/components/calendar/calendar.module.css b/packages/raystack/components/calendar/calendar.module.css index 6fa3fc589..9b346a8a6 100644 --- a/packages/raystack/components/calendar/calendar.module.css +++ b/packages/raystack/components/calendar/calendar.module.css @@ -97,6 +97,18 @@ background-color: transparent; } +.day:not(.disabled):not(.outside):not(.selected) .dayButton:active { + background-color: var(--rs-color-background-base-primary-hover); +} + +.selected:not(.rangeMiddle) .dayButton:active { + background-color: var(--rs-color-background-accent-emphasis-hover); +} + +.rangeMiddle .dayButton:active { + background-color: var(--rs-color-background-neutral-secondary); +} + .selected:not(.rangeMiddle) { width: var(--rs-space-10, 40px); height: var(--rs-space-10, 40px); diff --git a/packages/raystack/components/callout/callout.module.css b/packages/raystack/components/callout/callout.module.css index 3c73da0e6..1652e176c 100644 --- a/packages/raystack/components/callout/callout.module.css +++ b/packages/raystack/components/callout/callout.module.css @@ -60,15 +60,20 @@ align-items: center; } -/* Dismiss button stays flat: keep the transparent background and base colour on - hover/active (overrides IconButton's hover feedback). The descendant selector +/* Dismiss button stays flat: keep the transparent background and base colour + on hover (overrides IconButton's hover feedback). The descendant selector out-specifies `.iconButton:hover` regardless of stylesheet order. */ -.callout .dismiss:hover:not(:disabled), -.callout .dismiss:active:not(:disabled) { +.callout .dismiss:hover:not(:disabled) { background-color: transparent; color: var(--rs-color-foreground-base-secondary); } +/* Pressed: background stays flat by design; the glyph darkens one step. */ +.callout .dismiss:active:not(:disabled) { + background-color: transparent; + color: var(--rs-color-foreground-base-primary); +} + /* Type Variants */ .callout-grey { background: var(--rs-color-background-neutral-primary); diff --git a/packages/raystack/components/checkbox/checkbox.module.css b/packages/raystack/components/checkbox/checkbox.module.css index 0bdef70a4..b8e2918c1 100644 --- a/packages/raystack/components/checkbox/checkbox.module.css +++ b/packages/raystack/components/checkbox/checkbox.module.css @@ -45,6 +45,19 @@ border-color: var(--rs-color-border-base-focus); } +.checkbox:active:not([data-disabled]):not([data-readonly]) { + transform: scale(0.92); +} + +@media (prefers-reduced-motion: no-preference) { + .checkbox { + transition: + background-color var(--rs-duration-fast) var(--rs-ease-out), + border-color var(--rs-duration-fast) var(--rs-ease-out), + transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + .checkbox[data-checked] { background: var(--rs-color-background-accent-emphasis); border: none; diff --git a/packages/raystack/components/chip/chip.module.css b/packages/raystack/components/chip/chip.module.css index 4c01ce966..e2fa1643e 100644 --- a/packages/raystack/components/chip/chip.module.css +++ b/packages/raystack/components/chip/chip.module.css @@ -129,4 +129,14 @@ display: inline-flex; align-items: center; justify-content: center; +} + +.dismiss-button:active { + transform: scale(0.9); +} + +@media (prefers-reduced-motion: no-preference) { + .dismiss-button { + transition: transform var(--rs-duration-fast) var(--rs-ease-out); + } } \ No newline at end of file diff --git a/packages/raystack/components/data-table/data-table.module.css b/packages/raystack/components/data-table/data-table.module.css index 42583f19c..4bcca663c 100644 --- a/packages/raystack/components/data-table/data-table.module.css +++ b/packages/raystack/components/data-table/data-table.module.css @@ -120,6 +120,10 @@ cursor: pointer; } +.row.clickable:active { + background: var(--rs-color-background-neutral-secondary); +} + .head { position: sticky; top: 0; diff --git a/packages/raystack/components/drawer/drawer.module.css b/packages/raystack/components/drawer/drawer.module.css index ba436ea52..21cd3688d 100644 --- a/packages/raystack/components/drawer/drawer.module.css +++ b/packages/raystack/components/drawer/drawer.module.css @@ -157,6 +157,11 @@ color: var(--rs-color-foreground-base-primary); } +.close:active { + background-color: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + .close:focus-visible { outline: 2px solid var(--rs-color-border-base-focus); outline-offset: -1px; diff --git a/packages/raystack/components/icon-button/icon-button.module.css b/packages/raystack/components/icon-button/icon-button.module.css index f85502c19..98f8af0c0 100644 --- a/packages/raystack/components/icon-button/icon-button.module.css +++ b/packages/raystack/components/icon-button/icon-button.module.css @@ -12,12 +12,16 @@ padding: var(--rs-space-1); } -.iconButton:hover:not(:disabled), -.iconButton:active:not(:disabled) { +.iconButton:hover:not(:disabled) { background-color: var(--rs-color-background-base-primary-hover); color: var(--rs-color-foreground-base-primary); } +.iconButton:active:not(:disabled) { + background-color: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + .iconButton:active:not(:disabled) { transition: var(--rs-transition-pressed); } diff --git a/packages/raystack/components/number-field/number-field.module.css b/packages/raystack/components/number-field/number-field.module.css index a612349be..9dce0c762 100644 --- a/packages/raystack/components/number-field/number-field.module.css +++ b/packages/raystack/components/number-field/number-field.module.css @@ -38,6 +38,11 @@ background: var(--rs-color-background-base-primary-hover); } +.decrement:active:not([data-disabled]), +.increment:active:not([data-disabled]) { + background: var(--rs-color-background-neutral-secondary); +} + .decrement[data-disabled], .increment[data-disabled] { pointer-events: none; diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index a8ca56ec5..14c1b74b6 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -47,6 +47,19 @@ background: var(--rs-color-background-base-primary-hover); } +.radioitem:active:not([data-disabled]) { + transform: scale(0.92); +} + +@media (prefers-reduced-motion: no-preference) { + .radioitem { + transition: + background-color var(--rs-duration-fast) var(--rs-ease-out), + border-color var(--rs-duration-fast) var(--rs-ease-out), + transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + .radioitem[data-checked] { border: 1px solid var(--rs-color-background-accent-emphasis); background: var(--rs-color-background-accent-emphasis); diff --git a/packages/raystack/components/select/select.module.css b/packages/raystack/components/select/select.module.css index 8fb9297cd..5c45993db 100644 --- a/packages/raystack/components/select/select.module.css +++ b/packages/raystack/components/select/select.module.css @@ -153,6 +153,10 @@ color: var(--rs-color-foreground-base-primary); } +.trigger:active:not([data-disabled]):not(:disabled) { + background-color: var(--rs-color-background-neutral-secondary); +} + .trigger:focus-visible { outline: 1px solid var(--rs-color-border-accent-emphasis); } diff --git a/packages/raystack/components/sidebar/sidebar.module.css b/packages/raystack/components/sidebar/sidebar.module.css index 9a6103845..625e93495 100644 --- a/packages/raystack/components/sidebar/sidebar.module.css +++ b/packages/raystack/components/sidebar/sidebar.module.css @@ -108,6 +108,11 @@ color: var(--rs-color-foreground-base-primary); } +.nav-item:active:not([data-disabled="true"]) { + background-color: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + /* keep item highlighted when menu is open */ .more-trigger[aria-expanded="true"] { background-color: var(--rs-color-background-base-primary-hover); diff --git a/packages/raystack/components/slider/slider.module.css b/packages/raystack/components/slider/slider.module.css index 40b98b19b..2542600d0 100644 --- a/packages/raystack/components/slider/slider.module.css +++ b/packages/raystack/components/slider/slider.module.css @@ -53,10 +53,6 @@ outline: none; } -.thumb:hover { - border-color: var(--rs-color-border-accent-emphasis-hover); -} - .thumbLarge { width: var(--rs-space-7); height: var(--rs-space-6); @@ -92,6 +88,23 @@ background-color: var(--rs-color-background-base-secondary); } +/* Grabbing: the thumb grows slightly and lifts. Scale lives on the child + so the thumb's track position (set inline by Base UI) is never animated. */ +.thumb:active .thumbLarge, +.thumb:active .thumbSmall { + transform: scale(1.08); + box-shadow: var(--rs-shadow-lifted); +} + +@media (prefers-reduced-motion: no-preference) { + .thumbLarge, + .thumbSmall { + transition: + transform var(--rs-duration-fast) var(--rs-ease-out), + box-shadow var(--rs-duration-fast) var(--rs-ease-out); + } +} + .label { position: absolute; top: calc(-1 * (var(--rs-space-8) + 1px)); diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index 3e5ff3196..e6baef886 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -74,3 +74,20 @@ .switch.small[data-checked] .thumb { transform: translateX(var(--rs-space-4)); } + +/* Pressed: the thumb stretches toward the track center (iOS behavior). + transform-origin only affects the scale; the translateX values must + exactly mirror the resting rules above. */ +.switch:active:not([data-disabled]) .thumb { + transform: translateX(var(--rs-space-1)) scaleX(1.15); + transform-origin: left center; +} + +.switch[data-checked]:active:not([data-disabled]) .thumb { + transform: translateX(var(--rs-space-5)) scaleX(1.15); + transform-origin: right center; +} + +.switch.small[data-checked]:active:not([data-disabled]) .thumb { + transform: translateX(var(--rs-space-4)) scaleX(1.15); +} diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index 16989490e..431cd0e56 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -79,6 +79,12 @@ pointer-events: none; } +/* Pressed: one step darker than the list surface. Applies instantly + (no background transition on the trigger) — correct for a press. */ +.trigger:active:not([data-disabled]):not([data-active]) { + background-color: var(--rs-color-background-neutral-secondary-hover); +} + .trigger-icon { display: inline-flex; width: var(--rs-space-5); @@ -163,4 +169,10 @@ .variant-plain .trigger[data-active] { border-bottom-color: var(--rs-color-border-base-emphasis); -} \ No newline at end of file +} + +/* Plain variant is text-only: dim on press instead of painting a box. */ +.variant-plain .trigger:active:not([data-disabled]):not([data-active]) { + background-color: transparent; + opacity: 0.7; +} From 77d00c0154e79d6055391d95b232ad5e89f5c3a5 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:09:46 +0530 Subject: [PATCH 06/41] fix: add missing :focus-visible indicators across the library Many controls stripped the outline (all: unset, outline: none/0) with nothing in its place, leaving keyboard users unable to see where focus is: tabs, collapsible, checkbox, radio, switch, toggle, slider thumb, color-picker thumbs, icon-button, link, borderless input/text-area, chip/filter-chip dismiss buttons, number-field, code-block's floating copy button, and breadcrumb links/dropdown trigger. Apply three house treatments by element type: an accent ring for controls (upgrading the existing faint 1px button ring to 2px with offset), a subtle background for nav-style breadcrumb items (the sidebar pattern from #808), and a soft box-shadow ring for chromeless text-entry surfaces. Also split accordion's focus from its hover (audit #52) and add otp-field's soft ring on top of its border shift (audit #53/#58). Plans: plans/005-focus-indicators-pass.md --- .../components/accordion/accordion.module.css | 8 ++++++-- .../components/breadcrumb/breadcrumb.module.css | 13 +++++++++++++ .../raystack/components/button/button.module.css | 3 ++- .../components/checkbox/checkbox.module.css | 5 +++++ packages/raystack/components/chip/chip.module.css | 6 ++++++ .../components/code-block/code-block.module.css | 4 ++++ .../components/collapsible/collapsible.module.css | 5 +++++ .../components/color-picker/color-picker.module.css | 3 ++- .../components/filter-chip/filter-chip.module.css | 5 +++++ .../components/icon-button/icon-button.module.css | 5 +++++ packages/raystack/components/input/input.module.css | 5 +++++ packages/raystack/components/link/link.module.css | 6 ++++++ .../components/number-field/number-field.module.css | 10 ++++++++++ .../components/otp-field/otp-field.module.css | 5 +++++ packages/raystack/components/radio/radio.module.css | 5 +++++ .../raystack/components/slider/slider.module.css | 6 ++++++ .../raystack/components/switch/switch.module.css | 5 +++++ packages/raystack/components/tabs/tabs.module.css | 5 +++++ .../components/text-area/text-area.module.css | 4 ++++ .../raystack/components/toggle/toggle.module.css | 11 +++++++++++ 20 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/raystack/components/accordion/accordion.module.css b/packages/raystack/components/accordion/accordion.module.css index 8e2f13995..a7e2ddf57 100644 --- a/packages/raystack/components/accordion/accordion.module.css +++ b/packages/raystack/components/accordion/accordion.module.css @@ -31,11 +31,15 @@ border-top: none; } -.accordion-trigger:hover, -.accordion-trigger:focus-visible { +.accordion-trigger:hover { background-color: var(--rs-color-background-base-primary-hover); } +.accordion-trigger:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: -2px; +} + .accordion-trigger:active:not(:disabled) { background-color: var(--rs-color-background-neutral-secondary); } diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index e921ca908..872a91645 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -43,6 +43,13 @@ color: var(--rs-color-foreground-base-secondary); } +.breadcrumb-link:focus-visible { + outline: none; + background-color: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-secondary); + border-radius: var(--rs-radius-1); +} + .breadcrumb-link-active { color: var(--rs-color-foreground-base-primary); font-weight: var(--rs-font-weight-medium); @@ -81,6 +88,12 @@ padding: 0; } +.breadcrumb-dropdown-trigger:focus-visible { + background-color: var(--rs-color-background-base-primary-hover); + color: var(--rs-color-foreground-base-primary); + border-radius: var(--rs-radius-1); +} + .breadcrumb-dropdown-icon { height: var(--rs-space-3); } diff --git a/packages/raystack/components/button/button.module.css b/packages/raystack/components/button/button.module.css index 333260234..73ce263be 100644 --- a/packages/raystack/components/button/button.module.css +++ b/packages/raystack/components/button/button.module.css @@ -16,7 +16,8 @@ } .button:focus-visible { - outline: 1px solid var(--rs-color-border-accent-emphasis); + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; } .button:focus { diff --git a/packages/raystack/components/checkbox/checkbox.module.css b/packages/raystack/components/checkbox/checkbox.module.css index b8e2918c1..509d58cb9 100644 --- a/packages/raystack/components/checkbox/checkbox.module.css +++ b/packages/raystack/components/checkbox/checkbox.module.css @@ -45,6 +45,11 @@ border-color: var(--rs-color-border-base-focus); } +.checkbox:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + .checkbox:active:not([data-disabled]):not([data-readonly]) { transform: scale(0.92); } diff --git a/packages/raystack/components/chip/chip.module.css b/packages/raystack/components/chip/chip.module.css index e2fa1643e..c2d6c6bee 100644 --- a/packages/raystack/components/chip/chip.module.css +++ b/packages/raystack/components/chip/chip.module.css @@ -131,6 +131,12 @@ justify-content: center; } +.dismiss-button:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; + border-radius: var(--rs-radius-1); +} + .dismiss-button:active { transform: scale(0.9); } diff --git a/packages/raystack/components/code-block/code-block.module.css b/packages/raystack/components/code-block/code-block.module.css index 776e69df1..9d9bfe46f 100644 --- a/packages/raystack/components/code-block/code-block.module.css +++ b/packages/raystack/components/code-block/code-block.module.css @@ -104,3 +104,7 @@ .content:hover .floatingCopyButton { opacity: 1; } + +.floatingCopyButton:focus-visible { + opacity: 1; +} diff --git a/packages/raystack/components/collapsible/collapsible.module.css b/packages/raystack/components/collapsible/collapsible.module.css index 21f3e0bb6..f4d83523c 100644 --- a/packages/raystack/components/collapsible/collapsible.module.css +++ b/packages/raystack/components/collapsible/collapsible.module.css @@ -3,6 +3,11 @@ outline: none; } +.trigger:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + .trigger:disabled { pointer-events: none; opacity: 0.5; diff --git a/packages/raystack/components/color-picker/color-picker.module.css b/packages/raystack/components/color-picker/color-picker.module.css index 24606885d..56cb9e9c0 100644 --- a/packages/raystack/components/color-picker/color-picker.module.css +++ b/packages/raystack/components/color-picker/color-picker.module.css @@ -66,7 +66,8 @@ } .sliderThumb:focus-visible { - outline: none; + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; } .sliderThumb:disabled { diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index c32e0203b..87c42b4b9 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -93,6 +93,11 @@ background: var(--rs-color-background-base-primary-hover); } +.removeIconContainer:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: -2px; +} + .removeIcon { cursor: pointer; color: var(--rs-color-foreground-base-primary); diff --git a/packages/raystack/components/icon-button/icon-button.module.css b/packages/raystack/components/icon-button/icon-button.module.css index 98f8af0c0..3d756c8ef 100644 --- a/packages/raystack/components/icon-button/icon-button.module.css +++ b/packages/raystack/components/icon-button/icon-button.module.css @@ -31,6 +31,11 @@ cursor: not-allowed; } +.iconButton:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + .iconButton-size-1 { width: var(--rs-space-4); height: var(--rs-space-4); diff --git a/packages/raystack/components/input/input.module.css b/packages/raystack/components/input/input.module.css index 40b83a42f..37577eb9e 100644 --- a/packages/raystack/components/input/input.module.css +++ b/packages/raystack/components/input/input.module.css @@ -234,3 +234,8 @@ border-color: transparent; outline: none; } + +/* Keyboard focus must still be visible on the chromeless variant. */ +.variant-borderless:has(.input-field:focus-visible) { + box-shadow: 0 0 0 2px var(--rs-color-border-accent-primary); +} diff --git a/packages/raystack/components/link/link.module.css b/packages/raystack/components/link/link.module.css index bb3ed5dc8..3652ffa1d 100644 --- a/packages/raystack/components/link/link.module.css +++ b/packages/raystack/components/link/link.module.css @@ -4,6 +4,12 @@ transition: opacity var(--rs-duration-normal) var(--rs-ease-out); } +.link:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; + border-radius: var(--rs-radius-1); +} + .link:hover { opacity: 0.8; } diff --git a/packages/raystack/components/number-field/number-field.module.css b/packages/raystack/components/number-field/number-field.module.css index 9dce0c762..81181f776 100644 --- a/packages/raystack/components/number-field/number-field.module.css +++ b/packages/raystack/components/number-field/number-field.module.css @@ -38,6 +38,12 @@ background: var(--rs-color-background-base-primary-hover); } +.decrement:focus-visible, +.increment:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: -2px; +} + .decrement:active:not([data-disabled]), .increment:active:not([data-disabled]) { background: var(--rs-color-background-neutral-secondary); @@ -68,6 +74,10 @@ padding: 0 var(--rs-space-2); } +.input:focus { + border-color: var(--rs-color-border-accent-emphasis); +} + .input[data-disabled] { pointer-events: none; opacity: 0.5; diff --git a/packages/raystack/components/otp-field/otp-field.module.css b/packages/raystack/components/otp-field/otp-field.module.css index 0718b6e94..494c67de4 100644 --- a/packages/raystack/components/otp-field/otp-field.module.css +++ b/packages/raystack/components/otp-field/otp-field.module.css @@ -46,6 +46,7 @@ .otp-field-input:focus:not([data-readonly]):not([data-invalid]) { border-color: var(--rs-color-border-accent-emphasis); + box-shadow: 0 0 0 2px var(--rs-color-border-accent-primary); } .otp-field-input[data-invalid] { @@ -59,6 +60,10 @@ border-color: var(--rs-color-border-danger-emphasis-hover); } +.otp-field-input[data-invalid]:focus:not([data-readonly]) { + box-shadow: 0 0 0 2px var(--rs-color-border-danger-primary); +} + .otp-field-input[data-disabled], .otp-field-input:disabled { opacity: 0.5; diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index 14c1b74b6..859e96c03 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -47,6 +47,11 @@ background: var(--rs-color-background-base-primary-hover); } +.radioitem:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + .radioitem:active:not([data-disabled]) { transform: scale(0.92); } diff --git a/packages/raystack/components/slider/slider.module.css b/packages/raystack/components/slider/slider.module.css index 2542600d0..9f6e20c48 100644 --- a/packages/raystack/components/slider/slider.module.css +++ b/packages/raystack/components/slider/slider.module.css @@ -53,6 +53,12 @@ outline: none; } +.thumb:focus-visible .thumbLarge, +.thumb:focus-visible .thumbSmall { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + .thumbLarge { width: var(--rs-space-7); height: var(--rs-space-6); diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index e6baef886..ba0dd61c5 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -12,6 +12,11 @@ transition: background-color var(--rs-duration-normal) var(--rs-ease-out); } +.switch:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + .switch.large { width: 34px; height: var(--rs-space-6); diff --git a/packages/raystack/components/tabs/tabs.module.css b/packages/raystack/components/tabs/tabs.module.css index 431cd0e56..9f422b25e 100644 --- a/packages/raystack/components/tabs/tabs.module.css +++ b/packages/raystack/components/tabs/tabs.module.css @@ -79,6 +79,11 @@ pointer-events: none; } +.trigger:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + /* Pressed: one step darker than the list surface. Applies instantly (no background transition on the trigger) — correct for a press. */ .trigger:active:not([data-disabled]):not([data-active]) { diff --git a/packages/raystack/components/text-area/text-area.module.css b/packages/raystack/components/text-area/text-area.module.css index f7bde8a0c..59b242b20 100644 --- a/packages/raystack/components/text-area/text-area.module.css +++ b/packages/raystack/components/text-area/text-area.module.css @@ -87,3 +87,7 @@ border-color: transparent; outline: none; } + +.variant-borderless:focus-visible:not(:disabled) { + box-shadow: 0 0 0 2px var(--rs-color-border-accent-primary); +} diff --git a/packages/raystack/components/toggle/toggle.module.css b/packages/raystack/components/toggle/toggle.module.css index 1c1c2955a..0439b3c73 100644 --- a/packages/raystack/components/toggle/toggle.module.css +++ b/packages/raystack/components/toggle/toggle.module.css @@ -39,6 +39,17 @@ cursor: not-allowed; } +.toggle:focus-visible { + outline: 2px solid var(--rs-color-border-accent-emphasis); + outline-offset: 1px; +} + +/* Inside a group the container clips overflow and items sit 1px apart: + keep the ring fully inside the item. */ +.group .toggle:focus-visible { + outline-offset: -2px; +} + .size-1 { width: var(--rs-space-4); height: var(--rs-space-4); From b3ab3bcb9aabae3c1101b80926b2a64f10ba77b7 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:11:41 +0530 Subject: [PATCH 07/41] fix: expand sub-24px hit targets with transparent pseudo-elements Small checkbox/radio (12px), icon-button sizes 1-2 (12/16px), toggle sizes 1-2 (12/16px), and the slider's small thumb (8x16px) all render below the 24x24px WCAG 2.5.8 minimum. Add an invisible, absolutely positioned ::before on each that pads the hit box to 24x24px; visible rendering and layout are unchanged. Plan: plans/018-hit-targets.md --- .../components/checkbox/checkbox.module.css | 12 +++++++++++ .../icon-button/icon-button.module.css | 19 +++++++++++++++++ .../components/radio/radio.module.css | 14 +++++++++++++ .../components/slider/slider.module.css | 17 +++++++++++++++ .../components/toggle/toggle.module.css | 21 +++++++++++++++++++ 5 files changed, 83 insertions(+) diff --git a/packages/raystack/components/checkbox/checkbox.module.css b/packages/raystack/components/checkbox/checkbox.module.css index 509d58cb9..4168a9d9e 100644 --- a/packages/raystack/components/checkbox/checkbox.module.css +++ b/packages/raystack/components/checkbox/checkbox.module.css @@ -40,6 +40,18 @@ min-height: var(--rs-space-4); } +/* Expand the small checkbox's 12px hit target to 24px (WCAG 2.5.8). + (24px − 12px) / 2 = 6px per side. Visible size is unchanged. */ +.checkbox.small { + position: relative; +} + +.checkbox.small::before { + content: ""; + position: absolute; + inset: -6px; +} + .checkbox:not([data-disabled]):hover { background: var(--rs-color-background-base-primary-hover); border-color: var(--rs-color-border-base-focus); diff --git a/packages/raystack/components/icon-button/icon-button.module.css b/packages/raystack/components/icon-button/icon-button.module.css index 3d756c8ef..26133e3bb 100644 --- a/packages/raystack/components/icon-button/icon-button.module.css +++ b/packages/raystack/components/icon-button/icon-button.module.css @@ -46,6 +46,25 @@ height: var(--rs-space-5); } +/* Expand sub-24px hit targets to 24px (WCAG 2.5.8); visible size unchanged. + size-1: (24 − 12) / 2 = 6px; size-2: (24 − 16) / 2 = 4px. */ +.iconButton-size-1, +.iconButton-size-2 { + position: relative; +} + +.iconButton-size-1::before { + content: ""; + position: absolute; + inset: -6px; +} + +.iconButton-size-2::before { + content: ""; + position: absolute; + inset: -4px; +} + .iconButton-size-3 { width: var(--rs-space-6); height: var(--rs-space-6); diff --git a/packages/raystack/components/radio/radio.module.css b/packages/raystack/components/radio/radio.module.css index 859e96c03..634d236da 100644 --- a/packages/raystack/components/radio/radio.module.css +++ b/packages/raystack/components/radio/radio.module.css @@ -42,6 +42,20 @@ min-height: var(--rs-space-4); } +/* Expand the small radio's 12px hit target to 24px (WCAG 2.5.8). + (24px − 12px) / 2 = 6px per side. Visible size is unchanged. */ +:where(.group-small) .radioitem, +.radioitem.small { + position: relative; +} + +:where(.group-small) .radioitem::before, +.radioitem.small::before { + content: ""; + position: absolute; + inset: -6px; +} + .radioitem:hover { border-color: var(--rs-color-border-base-tertiary-hover); background: var(--rs-color-background-base-primary-hover); diff --git a/packages/raystack/components/slider/slider.module.css b/packages/raystack/components/slider/slider.module.css index 9f6e20c48..bbdd714f1 100644 --- a/packages/raystack/components/slider/slider.module.css +++ b/packages/raystack/components/slider/slider.module.css @@ -89,6 +89,23 @@ box-shadow: var(--rs-shadow-soft); } +/* Expand the small thumb's 8×16px hit target to 24×24px (WCAG 2.5.8). + Horizontal: (24 − 8) / 2 = 8px; vertical: (24 − 16) / 2 = 4px. + Pointer events bubble from the pseudo through .thumbSmall to the + Base UI thumb element, so grabbing works from the expanded area. */ +.thumbSmall { + position: relative; +} + +.thumbSmall::before { + content: ""; + position: absolute; + top: -4px; + bottom: -4px; + left: -8px; + right: -8px; +} + .thumb:hover .thumbLarge, .thumb:hover .thumbSmall { background-color: var(--rs-color-background-base-secondary); diff --git a/packages/raystack/components/toggle/toggle.module.css b/packages/raystack/components/toggle/toggle.module.css index 0439b3c73..be40ab95c 100644 --- a/packages/raystack/components/toggle/toggle.module.css +++ b/packages/raystack/components/toggle/toggle.module.css @@ -60,6 +60,27 @@ height: var(--rs-space-5); } +/* Expand sub-24px hit targets to 24px (WCAG 2.5.8); visible size unchanged. + size-1: (24 − 12) / 2 = 6px; size-2: (24 − 16) / 2 = 4px. + Inside .group (overflow: clip, 1px gaps) the expansion is clipped at the + group edge — that is a known limit; do not change the group's overflow. */ +.size-1, +.size-2 { + position: relative; +} + +.size-1::before { + content: ""; + position: absolute; + inset: -6px; +} + +.size-2::before { + content: ""; + position: absolute; + inset: -4px; +} + .size-3 { width: var(--rs-space-6); height: var(--rs-space-6); From e6a4265f0962d51a4884488d41129c988185a421 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:20:06 +0530 Subject: [PATCH 08/41] fix: stop CopyButton timer leak, cross-fade icon swap, announce state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset setTimeout was never cleared, so rapid clicks stacked timers and the check icon could revert early; it also fired after unmount. Hold the timer in a ref, clear/restart it per click and on unmount. Replace the boolean copied flag with an idle/copied/error status. Stack all three icons and cross-fade via opacity (plus a subtle scale under motion) instead of a hard swap, and add a visually hidden role="status" region announcing "Copied"/"Copy failed". CopyButton now always mounts CheckCircleFilledIcon and CrossCircleFilledIcon (previously only Check rendered, conditionally). Both are SVG-asset icons that only transform via the rollup build's @svgr plugin, not under vitest, so two consumer tests (code-block, color-picker) that render CopyButton needed their icon mocks extended to cover the new icon — same pattern code-block's test already used. Plan: plans/007-copy-button.md --- .../code-block/__tests__/code-block.test.tsx | 3 +- .../__tests__/color-picker.test.tsx | 6 ++ .../__tests__/copy-button.test.tsx | 7 ++ .../copy-button/copy-button.module.css | 43 ++++++++++++ .../components/copy-button/copy-button.tsx | 69 ++++++++++++++----- 5 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 packages/raystack/components/copy-button/copy-button.module.css diff --git a/packages/raystack/components/code-block/__tests__/code-block.test.tsx b/packages/raystack/components/code-block/__tests__/code-block.test.tsx index 66d462a04..be68fc3c9 100644 --- a/packages/raystack/components/code-block/__tests__/code-block.test.tsx +++ b/packages/raystack/components/code-block/__tests__/code-block.test.tsx @@ -14,7 +14,8 @@ vi.mock('~/hooks/useCopyToClipboard', () => ({ // Mock icon components used inside CopyButton to avoid invalid element errors vi.mock('~/icons', () => ({ - CheckCircleFilledIcon: () => null + CheckCircleFilledIcon: () => null, + CrossCircleFilledIcon: () => null })); vi.mock('@radix-ui/react-icons', () => ({ CopyIcon: () => null, diff --git a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx index 033818d89..9995b459a 100644 --- a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx +++ b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx @@ -7,6 +7,12 @@ vi.mock('~/hooks/useCopyToClipboard', () => ({ useCopyToClipboard: () => ({ copy: mockCopy }) })); +// Mock icon components used inside CopyButton to avoid invalid element errors +vi.mock('~/icons', () => ({ + CheckCircleFilledIcon: () => null, + CrossCircleFilledIcon: () => null +})); + // // Mock ResizeObserver for tests // const originalResizeObserver = global.ResizeObserver; // beforeAll(() => { diff --git a/packages/raystack/components/copy-button/__tests__/copy-button.test.tsx b/packages/raystack/components/copy-button/__tests__/copy-button.test.tsx index 89427916f..32f595156 100644 --- a/packages/raystack/components/copy-button/__tests__/copy-button.test.tsx +++ b/packages/raystack/components/copy-button/__tests__/copy-button.test.tsx @@ -10,6 +10,13 @@ vi.mock('~/hooks/useCopyToClipboard', () => ({ }) })); +// SVG-asset icons aren't transformed under vitest (only via the rollup +// build's @svgr/rollup plugin) — mock them like code-block's test does. +vi.mock('~/icons', () => ({ + CheckCircleFilledIcon: () => null, + CrossCircleFilledIcon: () => null +})); + describe('CopyButton', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/raystack/components/copy-button/copy-button.module.css b/packages/raystack/components/copy-button/copy-button.module.css new file mode 100644 index 000000000..972813f02 --- /dev/null +++ b/packages/raystack/components/copy-button/copy-button.module.css @@ -0,0 +1,43 @@ +.iconStack { + display: grid; + place-items: center; +} + +.icon { + grid-area: 1 / 1; + opacity: 0; + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); +} + +.iconStack[data-status='idle'] .iconCopy, +.iconStack[data-status='copied'] .iconSuccess, +.iconStack[data-status='error'] .iconError { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .icon { + transform: scale(0.6); + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + transform var(--rs-duration-fast) var(--rs-ease-out); + } + + .iconStack[data-status='idle'] .iconCopy, + .iconStack[data-status='copied'] .iconSuccess, + .iconStack[data-status='error'] .iconError { + transform: scale(1); + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/packages/raystack/components/copy-button/copy-button.tsx b/packages/raystack/components/copy-button/copy-button.tsx index 94ddfbf51..7f247ad92 100644 --- a/packages/raystack/components/copy-button/copy-button.tsx +++ b/packages/raystack/components/copy-button/copy-button.tsx @@ -1,10 +1,12 @@ 'use client'; import { CopyIcon } from '@radix-ui/react-icons'; -import { useState } from 'react'; +import { cx } from 'class-variance-authority'; +import { useEffect, useRef, useState } from 'react'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; -import { CheckCircleFilledIcon } from '~/icons'; +import { CheckCircleFilledIcon, CrossCircleFilledIcon } from '~/icons'; import { IconButton, IconButtonProps } from '../icon-button/icon-button'; +import styles from './copy-button.module.css'; export interface CopyButtonProps extends IconButtonProps { text: string; @@ -12,6 +14,8 @@ export interface CopyButtonProps extends IconButtonProps { resetIcon?: boolean; } +type CopyStatus = 'idle' | 'copied' | 'error'; + export function CopyButton({ text, resetTimeout = 1000, @@ -19,28 +23,61 @@ export function CopyButton({ ...props }: CopyButtonProps) { const { copy } = useCopyToClipboard(); - const [isCopied, setIsCopied] = useState(false); + const [status, setStatus] = useState('idle'); + const resetTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current); + }; + }, []); + + function scheduleReset() { + if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current); + resetTimerRef.current = setTimeout(() => { + setStatus('idle'); + resetTimerRef.current = null; + }, resetTimeout); + } async function onCopy() { const res = await copy(text); if (res) { - setIsCopied(true); - if (resetIcon) { - setTimeout(() => { - setIsCopied(false); - }, resetTimeout); - } + setStatus('copied'); + if (resetIcon) scheduleReset(); + } else { + setStatus('error'); + scheduleReset(); } } return ( - - {isCopied ? ( - - ) : ( - - )} - + <> + + + + + {status === 'copied' + ? 'Copied' + : status === 'error' + ? 'Copy failed' + : ''} + + ); } From 5cde23254f009c692a1cbb38fac1a5ec341e3abd Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:22:33 +0530 Subject: [PATCH 09/41] fix: give data-table its missing sort-icon class, rotate icon on sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ordering.tsx referenced styles['display-popover-sort-icon'], a class that never existed in data-table.module.css (it only lived in data-view's module), so the icon rendered unsized. Add the class. Also replace the two-icon swap (TextAlignBottomIcon <-> TextAlignTopIcon) with a single icon rotated 180deg on sort direction, in both data-table and data-view's identical ordering component — reads as one state flipping instead of the icon being replaced. Plan: plans/008-data-table-sort-icon.md --- .../components/data-table/components/ordering.tsx | 13 +++++-------- .../components/data-table/data-table.module.css | 15 +++++++++++++++ .../components/data-view/components/ordering.tsx | 13 +++++-------- .../components/data-view/data-view.module.css | 10 ++++++++++ 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/raystack/components/data-table/components/ordering.tsx b/packages/raystack/components/data-table/components/ordering.tsx index 326a7db53..52b88249d 100644 --- a/packages/raystack/components/data-table/components/ordering.tsx +++ b/packages/raystack/components/data-table/components/ordering.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TextAlignBottomIcon, TextAlignTopIcon } from '@radix-ui/react-icons'; +import { TextAlignBottomIcon } from '@radix-ui/react-icons'; import { Flex } from '../../flex'; import { IconButton } from '../../icon-button'; @@ -70,13 +70,10 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) { size={4} disabled={columnList.length === 0} > - {value.order === SortOrders?.ASC ? ( - - ) : ( - - )} + diff --git a/packages/raystack/components/data-table/data-table.module.css b/packages/raystack/components/data-table/data-table.module.css index 4bcca663c..ee2cccd9d 100644 --- a/packages/raystack/components/data-table/data-table.module.css +++ b/packages/raystack/components/data-table/data-table.module.css @@ -48,6 +48,21 @@ padding: var(--rs-space-3) var(--rs-space-5); } +.display-popover-sort-icon { + height: var(--rs-space-6); + width: var(--rs-space-6); +} + +.display-popover-sort-icon[data-order='desc'] { + transform: rotate(180deg); +} + +@media (prefers-reduced-motion: no-preference) { + .display-popover-sort-icon { + transition: transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + .flex-1 { flex: 1; } diff --git a/packages/raystack/components/data-view/components/ordering.tsx b/packages/raystack/components/data-view/components/ordering.tsx index 1fcaed09e..850954f13 100644 --- a/packages/raystack/components/data-view/components/ordering.tsx +++ b/packages/raystack/components/data-view/components/ordering.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TextAlignBottomIcon, TextAlignTopIcon } from '@radix-ui/react-icons'; +import { TextAlignBottomIcon } from '@radix-ui/react-icons'; import { Flex } from '../../flex'; import { IconButton } from '../../icon-button'; @@ -68,13 +68,10 @@ export function Ordering({ columnList, onChange, value }: OrderingProps) { size={4} disabled={columnList.length === 0} > - {value.order === SortOrders?.ASC ? ( - - ) : ( - - )} + diff --git a/packages/raystack/components/data-view/data-view.module.css b/packages/raystack/components/data-view/data-view.module.css index b61bbe31f..8aea8408e 100644 --- a/packages/raystack/components/data-view/data-view.module.css +++ b/packages/raystack/components/data-view/data-view.module.css @@ -54,6 +54,16 @@ width: var(--rs-space-6); } +.display-popover-sort-icon[data-order='desc'] { + transform: rotate(180deg); +} + +@media (prefers-reduced-motion: no-preference) { + .display-popover-sort-icon { + transition: transform var(--rs-duration-fast) var(--rs-ease-out); + } +} + .flex-1 { flex: 1; } From e4858596849ed86999332fddf8bb8f146add8c0a Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:24:23 +0530 Subject: [PATCH 10/41] fix: invalid toolbar background value, misnamed text decoration class - toolbar.module.css: background-color: none is not valid CSS and was silently dropped; use transparent. - text: the strikeThrough+underline compound variant class was named text-italic-strike-through but applies no italic (it merges the two decoration lines so they don't cancel out). Rename to text-underline-strike-through rather than adding italic, which would wrongly italicize any underlined+struck-through text. Plan: plans/009-misc-css-fixes.md --- packages/raystack/components/text/__tests__/text.test.tsx | 2 +- packages/raystack/components/text/text.module.css | 2 +- packages/raystack/components/text/text.tsx | 2 +- packages/raystack/components/toolbar/toolbar.module.css | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/raystack/components/text/__tests__/text.test.tsx b/packages/raystack/components/text/__tests__/text.test.tsx index 725e7bf5f..b02fcfc10 100644 --- a/packages/raystack/components/text/__tests__/text.test.tsx +++ b/packages/raystack/components/text/__tests__/text.test.tsx @@ -169,7 +169,7 @@ describe('Text', () => { expect(container.firstChild).toHaveClass(styles['text-strike-through']); expect(container.firstChild).toHaveClass(styles['text-underline']); expect(container.firstChild).toHaveClass( - styles['text-italic-strike-through'] + styles['text-underline-strike-through'] ); }); }); diff --git a/packages/raystack/components/text/text.module.css b/packages/raystack/components/text/text.module.css index eaa577886..755e1d77b 100644 --- a/packages/raystack/components/text/text.module.css +++ b/packages/raystack/components/text/text.module.css @@ -138,7 +138,7 @@ text-decoration-line: line-through; } -.text-italic-strike-through { +.text-underline-strike-through { text-decoration-line: underline line-through; } diff --git a/packages/raystack/components/text/text.tsx b/packages/raystack/components/text/text.tsx index 63470d466..fcd72a99f 100644 --- a/packages/raystack/components/text/text.tsx +++ b/packages/raystack/components/text/text.tsx @@ -62,7 +62,7 @@ export const textVariants = cva(styles.text, { { strikeThrough: true, underline: true, - className: styles['text-italic-strike-through'] + className: styles['text-underline-strike-through'] } ] }); diff --git a/packages/raystack/components/toolbar/toolbar.module.css b/packages/raystack/components/toolbar/toolbar.module.css index e9b76ca5f..02b1b44af 100644 --- a/packages/raystack/components/toolbar/toolbar.module.css +++ b/packages/raystack/components/toolbar/toolbar.module.css @@ -37,5 +37,5 @@ } .group { padding: 0; - background-color: none; + background-color: transparent; } From 639dd8825ff95bdb756fd6fd5fab553e186fa54c Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:27:14 +0530 Subject: [PATCH 11/41] fix: animate indeterminate Progress, move fills to transform: scaleX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit value={null} (indeterminate) rendered a frozen empty track since nothing styled it. Add a static dimmed fallback plus a looping sweep (linear) / rotation (circular) gated behind prefers-reduced-motion, keyed off Base UI's data-indeterminate attribute. Also move both Progress and Meter's linear fill from a transitioned width to transform: scaleX(), driven by the existing --rs-progress-percentage / --rs-meter-percentage variables — the indicator now renders full-width and Base UI's inline width is neutralized by an explicit style prop, so value changes composite instead of triggering layout. Plan: plans/010-progress-meter.md --- .../raystack/components/meter/meter-track.tsx | 5 +- .../components/meter/meter.module.css | 4 +- .../components/progress/progress-track.tsx | 5 +- .../components/progress/progress.module.css | 51 ++++++++++++++++++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/raystack/components/meter/meter-track.tsx b/packages/raystack/components/meter/meter-track.tsx index cf8173386..5d530dfe9 100644 --- a/packages/raystack/components/meter/meter-track.tsx +++ b/packages/raystack/components/meter/meter-track.tsx @@ -35,7 +35,10 @@ export function MeterTrack({ return ( - + {children} ); diff --git a/packages/raystack/components/meter/meter.module.css b/packages/raystack/components/meter/meter.module.css index 6e509c1c6..cf7ea59e1 100644 --- a/packages/raystack/components/meter/meter.module.css +++ b/packages/raystack/components/meter/meter.module.css @@ -42,6 +42,8 @@ .indicator { height: 100%; background-color: var(--rs-color-background-accent-emphasis); + transform: scaleX(calc(var(--rs-meter-percentage, 0) / 100)); + transform-origin: left; } /* Circular variant — viewBox is 72×72, SVG scales to container size */ @@ -86,7 +88,7 @@ @media (prefers-reduced-motion: no-preference) { .indicator { - transition: width var(--rs-duration-moderate) linear; + transition: transform var(--rs-duration-moderate) linear; } .circularIndicatorCircle { diff --git a/packages/raystack/components/progress/progress-track.tsx b/packages/raystack/components/progress/progress-track.tsx index 2f1e63af1..576d6bd5c 100644 --- a/packages/raystack/components/progress/progress-track.tsx +++ b/packages/raystack/components/progress/progress-track.tsx @@ -35,7 +35,10 @@ export function ProgressTrack({ return ( - + {children} ); diff --git a/packages/raystack/components/progress/progress.module.css b/packages/raystack/components/progress/progress.module.css index 868daefb4..05fd4ec80 100644 --- a/packages/raystack/components/progress/progress.module.css +++ b/packages/raystack/components/progress/progress.module.css @@ -42,6 +42,8 @@ .indicator { height: 100%; background-color: var(--rs-color-background-accent-emphasis); + transform: scaleX(calc(var(--rs-progress-percentage, 0) / 100)); + transform-origin: left; } /* Circular variant — viewBox is 72×72, SVG scales to container size */ @@ -86,7 +88,7 @@ @media (prefers-reduced-motion: no-preference) { .indicator { - transition: width var(--rs-duration-moderate) linear; + transition: transform var(--rs-duration-moderate) linear; } .circularIndicatorCircle { @@ -103,3 +105,50 @@ text-align: center; white-space: nowrap; } + +/* Indeterminate (value={null}). Static fallback: dimmed centered bar / partial arc. */ +.indicator[data-indeterminate] { + transform: scaleX(0.4); + transform-origin: center; + opacity: 0.6; +} + +.circularSvg[data-indeterminate] .circularIndicatorCircle { + stroke-dashoffset: calc(var(--rs-progress-circumference) * 0.75); + opacity: 0.6; +} + +@media (prefers-reduced-motion: no-preference) { + .indicator[data-indeterminate] { + opacity: 1; + transform-origin: left; + transition: none; + animation: progress-indeterminate-sweep 1.2s var(--rs-ease-in-out) infinite; + } + + .circularSvg[data-indeterminate] { + animation: progress-indeterminate-rotate 1.2s linear infinite; + } + + .circularSvg[data-indeterminate] .circularIndicatorCircle { + opacity: 1; + } +} + +@keyframes progress-indeterminate-sweep { + from { + transform: translateX(-45%) scaleX(0.4); + } + to { + transform: translateX(105%) scaleX(0.4); + } +} + +@keyframes progress-indeterminate-rotate { + from { + transform: rotate(-90deg); + } + to { + transform: rotate(270deg); + } +} From b1f8963b5aaaf768754d64d2f44f1ccc710fa688 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:28:48 +0530 Subject: [PATCH 12/41] fix: give number-field input real hover/focus/invalid states The input had zero focus, hover, or invalid styling -- a focused field looked identical to an idle one. Mirror Field's states verbatim (hover/focus border+background, invalid border) and add transitions to both the input and the steppers, which previously snapped instantly. Stepper :active and :focus-visible already landed via the library-wide passes (plans 004/005); this plan's remaining gap was the missing transitions plus the input's hover/invalid states and the input:focus background-color. Plan: plans/011-number-field-states.md --- .../components/number-field/number-field.module.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/raystack/components/number-field/number-field.module.css b/packages/raystack/components/number-field/number-field.module.css index 81181f776..9664d6ddc 100644 --- a/packages/raystack/components/number-field/number-field.module.css +++ b/packages/raystack/components/number-field/number-field.module.css @@ -23,6 +23,7 @@ color: var(--rs-color-foreground-base-primary); cursor: pointer; padding: 0; + transition: var(--rs-transition-interactive); } .decrement { @@ -70,12 +71,23 @@ color: var(--rs-color-foreground-base-primary); text-align: center; font-variant-numeric: tabular-nums; + transition: var(--rs-transition-interactive); outline: none; padding: 0 var(--rs-space-2); } +.input:hover { + border-color: var(--rs-color-border-base-focus); + background: var(--rs-color-background-base-primary-hover); +} + .input:focus { border-color: var(--rs-color-border-accent-emphasis); + background-color: var(--rs-color-background-base-primary); +} + +.input[data-invalid] { + border-color: var(--rs-color-border-danger-primary); } .input[data-disabled] { From fba02338093e6e80ff5d17fe903dc0068ad2f10f Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:31:04 +0530 Subject: [PATCH 13/41] fix: drawer tracks swipes 1:1, tokenize exit duration, fade under reduced motion The popup kept its 450ms transform transition active while swiping, so it eased toward the finger instead of tracking it directly (the backdrop already zeroed its own transition). Add transition: none on [data-swiping]. The five swipe-strength exit calcs hardcoded 400ms while enter runs 450ms via --rs-duration-drawer -- non-swipe exits ran measurably faster than opens for no reason. Use the token so enter/exit match and retuning the drawer duration doesn't require finding five literals. The panel had no transition outside the motion media query, so under prefers-reduced-motion it popped open/closed instantly. Add a base opacity fade (with matching [data-starting-style]/[data-ending-style] opacity: 0) so reduced motion gets a fade instead of a teleport, and move the per-side slide transforms into the no-preference block alongside the transition they animate. Plan: plans/012-drawer-swipe-and-fades.md --- .../components/drawer/drawer.module.css | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/raystack/components/drawer/drawer.module.css b/packages/raystack/components/drawer/drawer.module.css index 21cd3688d..9ca413bec 100644 --- a/packages/raystack/components/drawer/drawer.module.css +++ b/packages/raystack/components/drawer/drawer.module.css @@ -18,7 +18,7 @@ } .backdrop[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } .viewport { @@ -59,14 +59,21 @@ overscroll-behavior: contain; touch-action: auto; will-change: transform; + transition: opacity var(--rs-duration-drawer) var(--rs-ease-drawer); } .drawerPopup:focus { outline: none; } +.drawerPopup[data-starting-style], +.drawerPopup[data-ending-style] { + opacity: 0; +} + .drawerPopup[data-swiping] { user-select: none; + transition: none; } /* Right side drawer */ @@ -76,13 +83,8 @@ transform: translateX(var(--drawer-swipe-movement-x)); } -.drawerPopup-right[data-starting-style], -.drawerPopup-right[data-ending-style] { - transform: translateX(100%); -} - .drawerPopup-right[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } /* Left side drawer */ @@ -92,13 +94,8 @@ transform: translateX(var(--drawer-swipe-movement-x)); } -.drawerPopup-left[data-starting-style], -.drawerPopup-left[data-ending-style] { - transform: translateX(-100%); -} - .drawerPopup-left[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } /* Top side drawer */ @@ -108,13 +105,8 @@ transform: translateY(var(--drawer-swipe-movement-y)); } -.drawerPopup-top[data-starting-style], -.drawerPopup-top[data-ending-style] { - transform: translateY(-100%); -} - .drawerPopup-top[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } /* Bottom side drawer */ @@ -124,13 +116,8 @@ transform: translateY(var(--drawer-swipe-movement-y)); } -.drawerPopup-bottom[data-starting-style], -.drawerPopup-bottom[data-ending-style] { - transform: translateY(100%); -} - .drawerPopup-bottom[data-ending-style] { - transition-duration: calc(var(--drawer-swipe-strength, 1) * 400ms); + transition-duration: calc(var(--drawer-swipe-strength, 1) * var(--rs-duration-drawer)); } .content { @@ -207,6 +194,28 @@ .drawerPopup-left, .drawerPopup-top, .drawerPopup-bottom { - transition: transform var(--rs-duration-drawer) var(--rs-ease-drawer); + transition: + transform var(--rs-duration-drawer) var(--rs-ease-drawer), + opacity var(--rs-duration-drawer) var(--rs-ease-drawer); + } + + .drawerPopup-right[data-starting-style], + .drawerPopup-right[data-ending-style] { + transform: translateX(100%); + } + + .drawerPopup-left[data-starting-style], + .drawerPopup-left[data-ending-style] { + transform: translateX(-100%); + } + + .drawerPopup-top[data-starting-style], + .drawerPopup-top[data-ending-style] { + transform: translateY(-100%); + } + + .drawerPopup-bottom[data-starting-style], + .drawerPopup-bottom[data-ending-style] { + transform: translateY(100%); } } From 77009c845b00ead127852cdc8decf6c63154528d Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:33:21 +0530 Subject: [PATCH 14/41] fix: reduced-motion and reduced-transparency fallbacks Four independent gaps: - Toast starting-style set a transform but no opacity: 0, so under reduced motion (where the transform never transitions) toasts popped in at full opacity instead of fading. - Navbar's hide-on-scroll transform had no reduced-motion fallback at all, so the bar teleported in/out; add an additive `reduce` block that fades it instead. - Spinner poles had no static opacity, so reduced motion (where the spin animation never applies) rendered a static asterisk instead of a graded ring that still reads as loading. - Dialog and command's backdrop blur had no prefers-reduced-transparency guard; add one with a heavier solid scrim (alert-dialog shares dialog's stylesheet, so it's covered too). Plan: plans/013-reduced-motion-and-transparency.md --- .../components/command/command.module.css | 8 ++++++++ .../raystack/components/dialog/dialog.module.css | 8 ++++++++ .../raystack/components/navbar/navbar.module.css | 15 +++++++++++++++ .../components/spinner/spinner.module.css | 8 ++++++++ .../raystack/components/toast/toast.module.css | 4 ++++ 5 files changed, 43 insertions(+) diff --git a/packages/raystack/components/command/command.module.css b/packages/raystack/components/command/command.module.css index e1e3285b1..e6c6a9f15 100644 --- a/packages/raystack/components/command/command.module.css +++ b/packages/raystack/components/command/command.module.css @@ -195,3 +195,11 @@ outline: none; isolation: isolate; } + +/* Reduced transparency: drop the blur, compensate with a heavier solid scrim */ +@media (prefers-reduced-transparency: reduce) { + .backdropBlur { + backdrop-filter: none; + background-color: var(--rs-color-overlay-black-a9); + } +} diff --git a/packages/raystack/components/dialog/dialog.module.css b/packages/raystack/components/dialog/dialog.module.css index 085c2eba8..1935b4212 100644 --- a/packages/raystack/components/dialog/dialog.module.css +++ b/packages/raystack/components/dialog/dialog.module.css @@ -124,3 +124,11 @@ .footer { padding: var(--rs-space-5) var(--rs-space-7); } + +/* Reduced transparency: drop the blur, compensate with a heavier solid scrim */ +@media (prefers-reduced-transparency: reduce) { + .overlayBlur { + backdrop-filter: none; + background-color: var(--rs-color-overlay-black-a9); + } +} diff --git a/packages/raystack/components/navbar/navbar.module.css b/packages/raystack/components/navbar/navbar.module.css index d7f7db202..940d8fba7 100644 --- a/packages/raystack/components/navbar/navbar.module.css +++ b/packages/raystack/components/navbar/navbar.module.css @@ -47,3 +47,18 @@ grid-column: 3; justify-self: end; } + +/* Reduced motion: hide-on-scroll degrades to a quick fade instead of a slide */ +@media (prefers-reduced-motion: reduce) { + .root { + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + visibility var(--rs-duration-fast) var(--rs-ease-out); + } + + .root[data-hidden="true"] { + transform: none; + opacity: 0; + visibility: hidden; + } +} diff --git a/packages/raystack/components/spinner/spinner.module.css b/packages/raystack/components/spinner/spinner.module.css index 029907e55..75b6fba20 100644 --- a/packages/raystack/components/spinner/spinner.module.css +++ b/packages/raystack/components/spinner/spinner.module.css @@ -16,41 +16,49 @@ .pole:nth-child(1) { transform: rotate(0deg); animation-delay: 0s; + opacity: 0.8; } .pole:nth-child(2) { transform: rotate(-45deg); animation-delay: -0.15s; + opacity: 0.7; } .pole:nth-child(3) { transform: rotate(-90deg); animation-delay: -0.3s; + opacity: 0.6; } .pole:nth-child(4) { transform: rotate(-135deg); animation-delay: -0.45s; + opacity: 0.5; } .pole:nth-child(5) { transform: rotate(-180deg); animation-delay: -0.6s; + opacity: 0.4; } .pole:nth-child(6) { transform: rotate(-225deg); animation-delay: -0.75s; + opacity: 0.3; } .pole:nth-child(7) { transform: rotate(-270deg); animation-delay: -0.9s; + opacity: 0.2; } .pole:nth-child(8) { transform: rotate(-315deg); animation-delay: -1.05s; + opacity: 0.1; } @keyframes spin { diff --git a/packages/raystack/components/toast/toast.module.css b/packages/raystack/components/toast/toast.module.css index 531c4723f..3c531dbdc 100644 --- a/packages/raystack/components/toast/toast.module.css +++ b/packages/raystack/components/toast/toast.module.css @@ -167,6 +167,10 @@ transform: translateY(calc(-100% - var(--rs-space-7))); } +.root[data-starting-style] { + opacity: 0; +} + /* ===== Limited state ===== */ .root[data-limited] { opacity: 0; From 1af66f0b58d0091446115c8b5f129fecc9188bcc Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:49:44 +0530 Subject: [PATCH 15/41] fix: rewrite tooltip motion as interruptible, symmetric transitions Tooltip animated with one-shot @keyframes: not interruptible (restarts from zero instead of retargeting), no exit at all (popup unmounted instantly), a dead unused fade keyframe, and a dead transform-origin (every keyframe was a pure translate, origin-independent). Replace with [data-starting-style]/[data-ending-style] transitions at --rs-duration-fast/--rs-ease-out, keeping the exact per-side and per-align 2px directional offsets the keyframes used. Enter and exit now mirror each other, rapid hover toggling retargets smoothly instead of restarting, data-instant still skips the animation via transition-duration: 0, and reduced motion gets an opacity-only fade (previously nothing, since every rule lived inside the no-preference media query). Plan: plans/014-tooltip-transitions.md --- .../components/tooltip/tooltip.module.css | 121 ++++++------------ 1 file changed, 41 insertions(+), 80 deletions(-) diff --git a/packages/raystack/components/tooltip/tooltip.module.css b/packages/raystack/components/tooltip/tooltip.module.css index d7cac3666..9639d967a 100644 --- a/packages/raystack/components/tooltip/tooltip.module.css +++ b/packages/raystack/components/tooltip/tooltip.module.css @@ -21,7 +21,7 @@ letter-spacing: var(--rs-letter-spacing-mini); width: fit-content; max-width: 400px; - transform-origin: var(--transform-origin); + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); } .arrow svg { @@ -55,107 +55,68 @@ transform: translateY(-50%) translateX(-100%) rotate(90deg); } -@keyframes fade { - from { - opacity: 0; - } - to { - opacity: 1; - } +.content[data-starting-style], +.content[data-ending-style] { + opacity: 0; } -@keyframes slideUpAndFade { - from { - opacity: 0; - transform: translateY(var(--rs-space-1)); - } + +/* Base UI sets data-instant (focus/click/dismiss and grouped-trigger moves): + skip the animation but keep the transition machinery. */ +.content[data-instant] { + transition-duration: 0ms; } -@keyframes slideRightAndFade { - from { - opacity: 0; - transform: translateX(calc(-1 * var(--rs-space-1))); + +@media (prefers-reduced-motion: no-preference) { + .content { + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + transform var(--rs-duration-fast) var(--rs-ease-out); } -} -@keyframes slideDownAndFade { - from { - opacity: 0; + /* Per-side directional offset — same vectors as the old keyframes */ + .content[data-side="top"][data-starting-style], + .content[data-side="top"][data-ending-style] { transform: translateY(calc(-1 * var(--rs-space-1))); } -} -@keyframes slideLeftAndFade { - from { - opacity: 0; + .content[data-side="right"][data-starting-style], + .content[data-side="right"][data-ending-style] { transform: translateX(var(--rs-space-1)); } -} -@keyframes slideDownRightAndFade { - from { - opacity: 0; + .content[data-side="bottom"][data-starting-style], + .content[data-side="bottom"][data-ending-style] { + transform: translateY(var(--rs-space-1)); + } + + .content[data-side="left"][data-starting-style], + .content[data-side="left"][data-ending-style] { + transform: translateX(calc(-1 * var(--rs-space-1))); + } + + /* Aligned variants get a diagonal offset — same vectors as before. + Higher specificity (0,4,0) than the side rules (0,3,0), so they win + when data-align is start/end. */ + .content[data-side="top"][data-align="start"][data-starting-style], + .content[data-side="top"][data-align="start"][data-ending-style] { transform: translate( calc(-1 * var(--rs-space-1)), calc(-1 * var(--rs-space-1)) ); } -} -@keyframes slideDownLeftAndFade { - from { - opacity: 0; + .content[data-side="top"][data-align="end"][data-starting-style], + .content[data-side="top"][data-align="end"][data-ending-style] { transform: translate(var(--rs-space-1), calc(-1 * var(--rs-space-1))); } -} -@keyframes slideUpRightAndFade { - from { - opacity: 0; + .content[data-side="bottom"][data-align="start"][data-starting-style], + .content[data-side="bottom"][data-align="start"][data-ending-style] { transform: translate(calc(-1 * var(--rs-space-1)), var(--rs-space-1)); } -} -@keyframes slideUpLeftAndFade { - from { - opacity: 0; + .content[data-side="bottom"][data-align="end"][data-starting-style], + .content[data-side="bottom"][data-align="end"][data-ending-style] { transform: translate(var(--rs-space-1), var(--rs-space-1)); } } - -@media (prefers-reduced-motion: no-preference) { - .content { - animation-duration: var(--rs-duration-fast); - animation-timing-function: var(--rs-ease-out); - } - - .content[data-open][data-side="top"]:not([data-instant]) { - animation-name: slideDownAndFade; - } - - .content[data-open][data-side="right"]:not([data-instant]) { - animation-name: slideLeftAndFade; - } - - .content[data-open][data-side="bottom"]:not([data-instant]) { - animation-name: slideUpAndFade; - } - - .content[data-open][data-side="left"]:not([data-instant]) { - animation-name: slideRightAndFade; - } - - .content[data-side="top"][data-align="start"]:not([data-instant]) { - animation-name: slideDownRightAndFade; - } - - .content[data-side="top"][data-align="end"]:not([data-instant]) { - animation-name: slideDownLeftAndFade; - } - - .content[data-side="bottom"][data-align="start"]:not([data-instant]) { - animation-name: slideUpRightAndFade; - } - - .content[data-side="bottom"][data-align="end"]:not([data-instant]) { - animation-name: slideUpLeftAndFade; - } -} From 202bf820928ce4f34151c2008e1d2f4d6034cf8b Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Sun, 12 Jul 2026 11:52:00 +0530 Subject: [PATCH 16/41] fix: animate callout enter and dismiss (fade + height collapse) Uncontrolled dismiss returned null on the next render, so the callout vanished in one frame and content below it snapped up by its full height. There was no enter transition either. Wrap the callout in a transition shell driven by a data-state attribute (open/closing/closed). Dismiss now plays a 200ms fade + grid-row height collapse before unmounting, so content below glides up instead of snapping. Mount fades in over the same duration. Reduced motion is instant both ways. The controlled API (onDismiss provided) is unchanged -- the consumer still owns removal. Plan: plans/015-callout-enter-exit.md --- .../callout/__tests__/callout.test.tsx | 30 +++++--- .../components/callout/callout.module.css | 33 ++++++++ .../raystack/components/callout/callout.tsx | 75 +++++++++++-------- 3 files changed, 99 insertions(+), 39 deletions(-) diff --git a/packages/raystack/components/callout/__tests__/callout.test.tsx b/packages/raystack/components/callout/__tests__/callout.test.tsx index f93e860eb..6d872bd06 100644 --- a/packages/raystack/components/callout/__tests__/callout.test.tsx +++ b/packages/raystack/components/callout/__tests__/callout.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { Callout } from '../callout'; import styles from '../callout.module.css'; @@ -141,14 +141,26 @@ describe('Callout', () => { }); it('hides itself after dismiss when onDismiss is omitted (uncontrolled)', () => { - render(Uncontrolled message); - - fireEvent.click(screen.getByRole('button', { name: 'Dismiss message' })); - - expect( - screen.queryByText('Uncontrolled message') - ).not.toBeInTheDocument(); - expect(screen.queryByRole('status')).not.toBeInTheDocument(); + vi.useFakeTimers(); + try { + render(Uncontrolled message); + + fireEvent.click( + screen.getByRole('button', { name: 'Dismiss message' }) + ); + + // Exit transition plays first; the callout unmounts after EXIT_MS. + act(() => { + vi.advanceTimersByTime(200); + }); + + expect( + screen.queryByText('Uncontrolled message') + ).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } }); it('renders both action and dismissible button', () => { diff --git a/packages/raystack/components/callout/callout.module.css b/packages/raystack/components/callout/callout.module.css index 1652e176c..330d14601 100644 --- a/packages/raystack/components/callout/callout.module.css +++ b/packages/raystack/components/callout/callout.module.css @@ -253,3 +253,36 @@ border: 0.5px solid var(--rs-color-border-base-tertiary); color: var(--rs-color-foreground-base-primary); } + +/* Enter/exit shell. Grid rows animate 1fr -> 0fr so the dismiss collapses the + callout's height without JS measurement; the inner wrapper clips the + shrinking row. */ +.transitionShell { + display: grid; + grid-template-rows: 1fr; +} + +.transitionInner { + min-height: 0; + overflow: hidden; +} + +.transitionShell[data-state="closing"] { + grid-template-rows: 0fr; + opacity: 0; +} + +@keyframes callout-enter { + from { + opacity: 0; + } +} + +@media (prefers-reduced-motion: no-preference) { + .transitionShell { + animation: callout-enter var(--rs-duration-normal) var(--rs-ease-out); + transition: + grid-template-rows var(--rs-duration-normal) var(--rs-ease-out), + opacity var(--rs-duration-normal) var(--rs-ease-out); + } +} diff --git a/packages/raystack/components/callout/callout.tsx b/packages/raystack/components/callout/callout.tsx index f5f587e47..9e6d4c422 100644 --- a/packages/raystack/components/callout/callout.tsx +++ b/packages/raystack/components/callout/callout.tsx @@ -6,12 +6,16 @@ import { type ComponentProps, type CSSProperties, type ReactNode, + useEffect, useState } from 'react'; import { IconButton } from '../icon-button'; import styles from './callout.module.css'; +/** Exit duration. Keep in sync with --rs-duration-normal (styles/effects.css:38). */ +const EXIT_MS = 200; + const callout = cva(styles.callout, { variants: { type: { @@ -65,45 +69,56 @@ export function Callout({ ...props }: CalloutProps) { // Dismissal is controlled when `onDismiss` is given; otherwise fall back to - // uncontrolled and hide the callout internally. - const [dismissed, setDismissed] = useState(false); + // uncontrolled: play the exit transition, then unmount. + const [state, setState] = useState<'open' | 'closing' | 'closed'>('open'); + + useEffect(() => { + if (state !== 'closing') return; + const timer = setTimeout(() => setState('closed'), EXIT_MS); + return () => clearTimeout(timer); + }, [state]); + const handleDismiss = () => { onDismiss?.(); - if (!onDismiss) setDismissed(true); + if (!onDismiss) setState('closing'); }; - if (dismissed) return null; + if (state === 'closed') return null; const role = type === 'alert' ? 'alert' : 'status'; return ( -
-
-
- {icon && ( -