Files
sure/app/assets/tailwind/sure-design-system/components.css
Guillem Arias Fauste 77dda53ffb feat(ds): one canonical focus ring across primitives (#2140)
* feat(ds): one canonical focus ring across primitives (#2136)

Replaces the grab-bag of per-primitive focus indicators (neutral
ring-alpha-black/white, outline-gray-900/white, faint form-field ring-4)
with a single recipe — the #1737 accessibility follow-up.

- New --color-focus-ring token: blue-600 (light) / blue-500 (dark),
  >=4:1 against both surfaces.
- Canonical .focus-ring / .focus-ring-within in components.css: a 2px
  outline + 2px offset on :focus-visible only. Outline (not a box-shadow
  ring) so the offset gap is transparent on any surface with no layout
  shift; :focus-visible so it never shows for mouse/touch.
- Applied to every focusable DS primitive: Button (had none), Link,
  Disclosure summary, Tabs nav, MenuItem (replaces the browser-default
  box), SearchInput, Tooltip trigger, Popover trigger, Select panel
  (focus-within), Toggle (peer-driven outline-focus-ring). .form-field
  adopts it via :focus-within, replacing the ~1:1 ring-4.
- Dialog close button is a DS::Button icon variant, so it inherits the
  focus-visible-only ring and keeps no resting border (fixes "stuck ring").

Verified in-browser, light+dark: focus-visible ring on button, input, and
full-width menu row — consistent blue 2px+offset, legible on both surfaces.

Remaining follow-up: >=44px touch targets (disclosure trigger, composer
send); bespoke notification / account-new close buttons that still carry a
permanent border.

* fix(ds): #2136 interactive-state follow-ups — touch target + close-button chrome

- Disclosure default trigger: add min-h-11 (44px) so the standalone disclosure
  summary clears the touch-target minimum (was px-3 py-2 ~36px). Composer send +
  the coming-soon icons are already DS::Button icon/md (w-11 h-11).
- Notification close buttons (sync_toast, notice): drop the resting
  border-alpha-black-50 box ("frame shouts, glyph muted"); keep a bg-container +
  shadow-xs chip so the corner control stays visible over the page, and brighten
  the muted glyph on hover (text-subdued -> hover:text-primary).

* refactor(ds): focus ring -> neutral hugging box-shadow (was blue outline)

Per design feedback: the blue 2px outline + 2px offset read as a loud,
detached frame on the otherwise-neutral UI. Switch the canonical .focus-ring
to a soft box-shadow ring that hugs the control (follows border-radius, no
gap), in the theme-aware neutral focus-ring token (alpha-black/white-400).
Transparent outline kept as a forced-colors fallback; toggle peer-driver
switched from outline-* to ring-* to match. Still one token, :focus-visible
only. Strength is tunable (currently subtle ~1.5:1).

* fix(ds): focus ring vanished on shadowed controls — outline, not box-shadow

The neutral box-shadow ring lived in the components layer, so any utility-layer
shadow-* (or .form-field's focus-within:shadow-none) on the same element
overrode it and the ring silently disappeared on shadowed buttons/inputs. Draw
the same subtle neutral ring with a hugging `outline` (outline-offset: 0)
instead — a separate property with no box-shadow conflict, and it doubles as the
forced-colors indicator. Toggle peer-driver switched ring-* -> outline-* to
match. Look is unchanged (neutral, hugging, subtle); it just no longer vanishes.

* fix(a11y): enlarge sync-toast close-button touch target (p-0.5 -> p-1.5)

The hover-revealed close button had ~2px padding around a 20px icon (~24px
total), at the WCAG 2.5.8 AAA boundary. p-1.5 brings the interactive area to
~32px. Addresses CodeRabbit review on #2140.

* fix(ds): keep form-field's resting halo; stop the outline color flash

Two testing findings:

- .form-field reverts to its original always-on soft ring
  (focus-within ring-4 at low alpha, theme-aware) instead of adopting
  the keyboard-only outline. It's a resting decoration, not a focus
  indicator, and the lower-opacity halo was the better look. The
  canonical block's comment documents the deliberate opt-out.

- .focus-ring/.focus-ring-within now carry a base transparent 2px
  outline so consumers with transition-all (form-field had it) animate
  transparent -> token on focus instead of passing through
  currentColor, which flashed as a black border appearing and then
  fading out.

* feat(ds): focus-ring token clears WCAG 3:1 non-text contrast

alpha-black-400 (20%) measured ~1.6:1 against white — visible but below
the AA bar for focus indicators. Bump to the 700 stop (50%): ~3.95:1 on
light containers, ~4.6:1 on dark. Recipe unchanged; one token edit via
tokens:build.

* fix(ds): ring the hand-rolled privacy toggle too

The header pair showed two different focus treatments: panel-right (a
DS::Button) got the new token ring while the hand-rolled privacy
toggle next to it fell back to the browser-default ring — the sweep
covered DS primitives but not bespoke buttons. Both privacy toggles
(mobile + desktop) now carry .focus-ring.

Also documents the transition interplay on the focused-state rule:
consumers with transition-colors fade the ring in over 150ms because
Tailwind v4's color transition list includes outline-color. Verified
settled value at the intended 50% alpha via Playwright.

* fix(ds): ring the sidebar and settings nav links

The reshoot caught both nav species falling back to the browser's blue
default ring — main sidebar items and settings nav items are bespoke
link_to markup the primitive sweep missed, and they're the primary
keyboard path in the app. Both adopt .focus-ring (main nav adds
rounded-lg so the outline follows a shape).

* fix(ds): retire the legacy base-layer button ring for the canonical outline

The @layer base button rule still painted a ring-2 ring-offset-2
box-shadow on :focus-visible. Box-shadow and outline are independent
properties, so .focus-ring (an outline) could never clear it and every
button-tag primitive double-painted both indicators on keyboard focus.

Apply the canonical recipe to the base button rule itself: every
<button> now gets the transparent resting outline + focus-ring token on
:focus-visible by default.

Two bespoke buttons suppressed the outline with focus:outline-none and
relied on the base ring for their keyboard indicator (category dropdown
rows, the sign-up password toggle). Drop the suppression so they pick
up the canonical outline — :focus-visible keeps it keyboard-only, which
is what the suppression was protecting against anyway.

* fix(ds): segmented control adopts the canonical focus recipe

The segment rule inlined its own focus-visible outline (offset 2,
alpha-400 colors) with a comment noting it was temporary until the
canonical token landed — this branch is that token. Drop the inlined
utilities: button segments get the outline from the base button rule,
and link segments now carry .focus-ring.
2026-06-11 16:05:13 +02:00

285 lines
10 KiB
CSS

@layer components {
/*
Canonical keyboard-focus indicator (#2136). One recipe for every focusable
primitive: a neutral 2px `outline` that hugs the control (`outline-offset: 0`
so it sits right at the edge and follows the border-radius), in the
theme-aware `focus-ring` token.
`outline` — NOT box-shadow — on purpose: a components-layer box-shadow loses
to any utility-layer `shadow-*` (or `focus-within:shadow-none` on the field)
on the same element, so the ring silently vanished on shadowed controls.
`outline` is a separate property with no such conflict, and it doubles as the
forced-colors (Windows high-contrast) indicator. `:focus-visible` only, so it
never shows for mouse/touch. `.focus-ring-within` mirrors it onto a wrapper
whose focus lands on a child input. `.form-field` deliberately does NOT
adopt it: form fields keep their original always-on soft halo (ring-4 at
low alpha) — a resting decoration, not a keyboard-only indicator.
*/
/* Base: an always-present transparent outline so consumers that carry
`transition-all` (or any transition touching outline-color) animate
transparent -> token instead of passing through `currentColor` — the
latter reads as a black border flashing in and out. */
.focus-ring,
.focus-ring-within {
outline: 2px solid transparent;
outline-offset: 0;
}
.focus-ring:focus-visible,
.focus-ring-within:focus-within {
/* Note: consumers carrying `transition-colors` animate this in over
150ms — Tailwind v4's color transition list includes outline-color.
Combined with the transparent base above, that reads as a quick
fade-in rather than a pop, never a flash through currentColor. */
outline-color: var(--color-focus-ring);
}
/* Forms */
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
@apply transition-all duration-300;
@variant theme-dark {
@apply focus-within:ring-alpha-white-300;
}
/* Add styles for multiple select within form fields */
select[multiple] {
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
option {
@apply py-2 rounded-md;
}
option:checked {
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
}
option:active,
option:focus {
@apply bg-container-inset;
}
}
}
/* New form field structure components */
.form-field__header {
@apply flex items-center justify-between gap-2;
}
.form-field__body {
@apply flex flex-col gap-1;
}
.form-field__actions {
@apply flex items-center gap-1;
}
.form-field__label {
@apply block text-xs text-secondary peer-disabled:text-subdued;
}
.form-field__input {
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
@apply placeholder-shown:opacity-50;
@apply disabled:text-subdued;
@apply text-ellipsis overflow-hidden whitespace-nowrap;
@apply transition-opacity duration-300;
@apply placeholder:text-subdued;
@variant theme-dark {
&::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
}
}
}
textarea.form-field__input {
@apply whitespace-normal overflow-auto;
text-overflow: clip;
}
.form-field__input--multiselect-trigger {
@apply whitespace-normal overflow-visible;
text-overflow: clip;
}
select.form-field__input,
button.form-field__input {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right -0.15rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
text-align: left;
}
.form-field__radio {
@apply text-primary;
}
.form-field__submit {
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
}
/* Checkboxes */
.checkbox {
&[type='checkbox'] {
@apply rounded-sm;
@apply transition-colors duration-300;
}
}
.checkbox--light {
&[type='checkbox'] {
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-300 hover:bg-gray-300;
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
}
@variant theme-dark {
&[type='checkbox'] {
@apply ring-gray-900 border-alpha-white-300;
background-color: transparent;
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 border-transparent;
background-color: var(--color-gray-700);
}
&[type='checkbox']:checked,
&[type='checkbox']:indeterminate {
@apply border-transparent;
background-color: var(--color-gray-100);
}
&[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
&[type='checkbox']:indeterminate {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3crect x='3.5' y='7' width='9' height='2' rx='1'/%3e%3c/svg%3e");
}
}
}
.checkbox--dark {
&[type='checkbox'] {
@apply ring-gray-900 checked:text-white;
}
&[type='checkbox']:disabled {
@apply cursor-not-allowed opacity-80 ring-gray-600;
}
&[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
}
/* Tooltips */
.tooltip {
@apply hidden absolute;
}
.qrcode svg path {
fill: var(--color-black);
@variant theme-dark {
fill: var(--color-white);
}
}
/*
Segmented control (#2137) — track + equal-footprint segments. The selected
state is a single `--active` class so an optional Stimulus controller can
toggle it as one unit. Values mirror the DS tab tokens (tab-bg-group /
tab-item-active / tab-item-hover) but are inlined with `@variant theme-dark`
because `@apply`-ing a custom utility drops its dark override. The near-black
dark track (alpha-black-700) is what makes the gray-700 selected pill read —
the bespoke controls used a too-light `container-inset` track, hence the
audit's "selected pill invisible on dark." Focus comes from the canonical
recipe (`focus-ring` on link segments, the base button rule on button
segments), not from this block.
*/
.segmented-control {
@apply inline-flex items-center gap-0.5 p-1 rounded-lg bg-gray-50;
@variant theme-dark {
@apply bg-alpha-black-700;
}
}
.segmented-control__segment {
@apply inline-flex items-center justify-center px-2 py-1 rounded-md whitespace-nowrap;
@apply text-sm font-medium text-secondary cursor-pointer transition-colors duration-200;
@apply hover:bg-gray-200;
@variant theme-dark {
@apply hover:bg-gray-800;
}
}
.segmented-control__segment--active {
@apply bg-white text-primary shadow-sm hover:bg-white;
@variant theme-dark {
@apply bg-gray-700 hover:bg-gray-700;
}
}
/*
Horizontally scrollable table wrapper (#2137). `overflow-x: auto` so wide
tables scroll instead of clipping (the LLM-usage table was `overflow-hidden`
and pushed columns off-screen) or wrapping money mid-digit. The pure-CSS
"scroll shadow" gives the missing affordance: the cover gradients
(`--table-scroll-bg`, default container-inset) scroll WITH the content
(`background-attachment: local`) and hide the fixed shadow gradients at the
edges, so a soft edge-shadow only appears when there is more to scroll.
Theme-aware via `--color-shadow`. Set `--table-scroll-bg` to match the wrapper.
*/
.table-scroll {
--table-scroll-bg: var(--color-container-inset);
overflow-x: auto;
background:
linear-gradient(to right, var(--table-scroll-bg) 30%, transparent),
linear-gradient(to left, var(--table-scroll-bg) 30%, transparent) 100% 0,
radial-gradient(farthest-side at 0 50%, var(--color-shadow), transparent),
radial-gradient(farthest-side at 100% 50%, var(--color-shadow), transparent) 100% 0;
background-repeat: no-repeat;
background-color: var(--table-scroll-bg);
background-size: 32px 100%, 32px 100%, 12px 100%, 12px 100%;
background-attachment: local, local, scroll, scroll;
}
/*
Chart hover tooltip surface (see utils/chart_tooltip.js for the JS-side
contract). Matches the design reference exactly: hairline border ring
composed with a soft 8/24 drop shadow (Tailwind shadow utilities don't
compose, hence the component class), 10px radius, 10x12 padding. Dark
mode swaps the ring to alpha-white; the drop shadow is near-invisible
there, which is fine — the ring carries the edge.
No position transition here on purpose: cursor-following tooltips
(sankey, time-series) update left/top every mousemove and a transition
makes them trail the pointer. Snap-positioned tooltips (goal projection,
which jumps between dates) opt into the 80ms glide via inline style.
*/
.chart-tooltip {
background: var(--color-container);
border-radius: 10px;
padding: 10px 12px;
box-shadow:
0 0 0 1px var(--color-alpha-black-50),
0 8px 24px rgba(11, 11, 11, 0.12);
@variant theme-dark {
box-shadow:
0 0 0 1px var(--color-alpha-white-50),
0 8px 24px rgba(11, 11, 11, 0.12);
}
}
}