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.
This commit is contained in:
Guillem Arias Fauste
2026-06-11 16:05:13 +02:00
committed by GitHub
parent 5f391f7fff
commit 77dda53ffb
21 changed files with 86 additions and 43 deletions

View File

@@ -15,6 +15,7 @@
--color-info: var(--color-blue-600);
--color-shadow: --alpha(var(--color-black) / 6%);
--color-link: var(--color-blue-600);
--color-focus-ring: var(--color-alpha-black-700);
--color-tertiary: var(--color-alpha-black-100);
--color-surface: var(--color-gray-50);
--color-surface-hover: var(--color-gray-100);
@@ -216,6 +217,7 @@
--color-info: var(--color-blue-500);
--color-shadow: --alpha(var(--color-white) / 8%);
--color-link: var(--color-blue-500);
--color-focus-ring: var(--color-alpha-white-700);
--color-tertiary: var(--color-alpha-white-200);
--color-surface: var(--color-black);
--color-surface-hover: var(--color-gray-800);

View File

@@ -11,10 +11,21 @@
}
button {
@apply cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-alpha-black-300;
@apply cursor-pointer;
@variant theme-dark {
@apply focus-visible:ring-alpha-white-300;
/* Canonical keyboard-focus indicator (#2136) — the same recipe as
`.focus-ring` in components.css, applied here so every <button> gets
it by default. This used to be a `ring-2 ring-offset-2` box-shadow,
which double-painted alongside the `.focus-ring` outline on DS
primitives (Buttonish, tabs, popover, tooltip): box-shadow and
outline are independent properties, so the components-layer class
could never clear it. Transparent resting outline for the same
transition reasoning as `.focus-ring`. */
outline: 2px solid transparent;
outline-offset: 0;
&:focus-visible {
outline-color: var(--color-focus-ring);
}
}

View File

@@ -1,4 +1,39 @@
@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;
@@ -168,8 +203,9 @@
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 = neutral outline (matches
the canonical focus ring; inlined until that token lands on main).
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;
@@ -182,8 +218,6 @@
@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;
@apply focus-visible:outline-2 focus-visible:outline-offset-2;
@apply focus-visible:outline-alpha-black-400 theme-dark:focus-visible:outline-alpha-white-400;
@variant theme-dark {
@apply hover:bg-gray-800;
}

View File

@@ -92,7 +92,7 @@ class DS::Buttonish < DesignSystemComponent
# `aria-disabled` to stay clickable/focusable (e.g. submit buttons whose
# click handler surfaces validation errors — a truly disabled default
# submit would also swallow Enter-key implicit submission).
"font-medium whitespace-nowrap disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
"font-medium whitespace-nowrap focus-ring disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
merged_base_classes,
full_width ? "w-full justify-center" : nil,
container_size_classes,

View File

@@ -68,15 +68,15 @@ class DS::Disclosure < DesignSystemComponent
# surface. Keep cursor + focus-visible ring + flex baseline.
# Ring token matches `settings/provider_card.html.erb` (the
# established focus pattern on container cards).
"list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-xl"
"list-none cursor-pointer focus-ring rounded-xl"
when :inline
# Inline variant: no surface, no padding — the summary reads as
# plain text-link copy. Caller markup (text + optional chevron)
# provides the visual. Keep cursor + focus-visible ring + matching
# alpha-black-300 token used by the card variants for consistency.
"list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-sm"
"list-none cursor-pointer focus-ring rounded-sm"
else
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300"
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-ring min-h-11"
end
end
end

View File

@@ -6,12 +6,9 @@ class DS::Link < DS::Buttonish
VARIANTS = VARIANTS.reverse_merge(
default: {
# Underline + `text-link` so the link is distinguishable by more
# than color alone (WCAG 1.4.1). Focus ring uses the established
# alpha-ring DS pattern (also used by DS::Toggle, DS::Tooltip,
# provider_card, form-field) so theming stays centralized.
container_classes: "text-link underline underline-offset-2 hover:no-underline " \
"focus-visible:ring-2 focus-visible:ring-alpha-black-300 " \
"theme-dark:focus-visible:ring-alpha-white-300",
# than color alone (WCAG 1.4.1). Keyboard focus uses the canonical
# `.focus-ring` (#2136) so every primitive shares one indicator.
container_classes: "text-link underline underline-offset-2 hover:no-underline focus-ring",
icon_classes: "text-secondary"
}
).freeze

View File

@@ -81,7 +81,7 @@ class DS::MenuItem < DesignSystemComponent
def container_classes
[
"flex items-center gap-2 p-2 rounded-md w-full",
"flex items-center gap-2 p-2 rounded-md w-full focus-ring",
destructive? ? "hover:bg-red-tint-5 theme-dark:hover:bg-red-tint-10" : "hover:bg-container-hover"
].join(" ")
end

View File

@@ -9,7 +9,7 @@
the fallback `ds.popover.avatar_default_label` is used. %>
<button type="button"
data-DS--popover-target="button"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 cursor-pointer rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 theme-dark:focus-visible:outline-white"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 cursor-pointer rounded-full focus-ring"
aria-label="<%= trigger_aria_label %>"
aria-haspopup="dialog"
aria-expanded="false"

View File

@@ -53,15 +53,11 @@ class DS::SearchInput < DesignSystemComponent
"h-10 pl-10 w-full border-none rounded-lg " \
"focus:outline-hidden focus:ring-0"
else
# `focus-visible:outline-*` matches the focus-ring pattern from
# DS::Button (base.css) so every interactive surface in the design
# system uses the same ring token. Replaces the broken
# `focus:ring-gray-500` from the inline callsites — that utility
# had no backing token and rendered invisibly on the bordered
# bg-container surface.
# Canonical `.focus-ring` (#2136) — one shared keyboard-focus
# indicator across every DS primitive. Replaces the earlier neutral
# `outline-gray-900 / theme-dark:outline-white` pair.
"block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container text-base sm:text-sm " \
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 " \
"theme-dark:focus-visible:outline-white"
"focus-ring"
end
end

View File

@@ -10,8 +10,11 @@ class DS::SegmentedControl < DesignSystemComponent
# `full_width: true` stretches segments to equal width (the "equal-footprint"
# the #2137 audit asked for); default is content width.
renders_many :segments, ->(label, active: false, href: nil, **opts) do
# `focus-ring` is for link segments — button segments already get the
# canonical outline from the base-layer button rule (it's an idempotent
# duplicate there).
classes = class_names(
"segmented-control__segment",
"segmented-control__segment focus-ring",
("flex-1" if full_width),
("segmented-control__segment--active" if active),
opts.delete(:class)

View File

@@ -33,7 +33,7 @@
</div>
<div class="absolute z-50 p-1.5 w-full min-w-32 rounded-lg shadow-border-lg bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden" data-select-target="menu">
<% if searchable %>
<div class="flex items-center bg-container border border-secondary rounded-lg mb-1 focus-within:ring-4 focus-within:ring-alpha-black-200 theme-dark:focus-within:ring-alpha-white-300 transition-shadow">
<div class="flex items-center bg-container border border-secondary rounded-lg mb-1 focus-ring-within transition-shadow">
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t("helpers.select.search_placeholder"),

View File

@@ -22,7 +22,7 @@ class DS::Tabs::Nav < DesignSystemComponent
content_tag(
:button, label, id: "#{dom_prefix}-tab-#{id}",
type: "button",
class: class_names(btn_classes, is_active ? active_btn_classes : inactive_btn_classes, classes),
class: class_names("focus-ring", btn_classes, is_active ? active_btn_classes : inactive_btn_classes, classes),
role: "tab",
"aria-selected": is_active.to_s,
"aria-controls": "#{dom_prefix}-panel-#{id}",

View File

@@ -27,11 +27,10 @@ class DS::Toggle < DesignSystemComponent
"after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:shadow-sm",
"motion-safe:after:transition-transform motion-safe:after:duration-300 motion-safe:after:ease-in-out",
"peer-checked:bg-success peer-checked:after:translate-x-4",
# Focus ring driven from the sr-only input via `peer-focus-visible:`.
# Offset places the ring outside the track so it lands on the
# surrounding chrome regardless of theme.
"peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2",
"peer-focus-visible:ring-alpha-black-300 theme-dark:peer-focus-visible:ring-alpha-white-300",
# Canonical focus ring (#2136), driven from the sr-only input via
# `peer-focus-visible:`. outline-offset places it just outside the track
# so it lands on surrounding chrome in either theme.
"peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-focus-ring",
"peer-disabled:opacity-70 peer-disabled:cursor-not-allowed"
)
end

View File

@@ -6,7 +6,7 @@
name comes from `aria-label`, and the tooltip text is exposed
via `aria-describedby`. %>
<button type="button"
class="inline-flex items-center cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 theme-dark:focus-visible:ring-alpha-white-300 rounded"
class="inline-flex items-center cursor-default focus-ring rounded"
aria-describedby="<%= tooltip_id %>"
aria-label="<%= t("ds.tooltip.trigger_label", default: "More info") %>">
<%= helpers.icon icon_name, size: size, color: color %>

View File

@@ -18,7 +18,7 @@
),
method: :patch,
data: { turbo_frame: "category_dropdown" },
class: "flex w-full items-center gap-1.5 cursor-pointer focus:outline-none" do %>
class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<span class="flex items-center justify-center w-5 h-5 shrink-0">
<% if is_selected %><%= icon("check") %><% end %>

View File

@@ -60,7 +60,7 @@ end %>
<div class="flex items-center gap-1">
<button type="button"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
data-action="click->privacy-mode#toggle"
data-privacy-mode-target="toggle"
title="<%= t("layouts.application.privacy_mode") %>"
@@ -158,7 +158,7 @@ end %>
<div class="flex items-center gap-2">
<button type="button"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
data-action="click->privacy-mode#toggle"
data-privacy-mode-target="toggle"
title="<%= t("layouts.application.privacy_mode") %>"

View File

@@ -1,6 +1,6 @@
<%# locals: (name:, path:, icon:, icon_custom:, active:, mobile_only: false, preview: false) %>
<%= link_to path, class: "space-y-1 group block relative pb-1", aria: { current: ("page" if active) } do %>
<%= link_to path, class: "space-y-1 group block relative pb-1 focus-ring rounded-lg", aria: { current: ("page" if active) } do %>
<div class="grow flex flex-col lg:flex-row gap-1 items-center">
<%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => active) %>

View File

@@ -53,7 +53,7 @@
action: "input->password-validator#validate"
} %>
<button type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-primary focus:outline-none"
class="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-primary"
data-action="click->password-visibility#toggle">
<div data-password-visibility-target="showIcon">
<%= icon("eye") %>

View File

@@ -1,7 +1,7 @@
<%# locals: (name:, path:, icon:) %>
<%= link_to path, class: class_names(
"flex items-center gap-2 whitespace-nowrap px-3 py-2 rounded-lg text-sm",
"flex items-center gap-2 whitespace-nowrap px-3 py-2 rounded-lg text-sm focus-ring",
page_active?(path) ? "text-primary bg-container shadow-border-xs" : "text-secondary hover:bg-surface-hover border-transparent"
), aria: { current: ("page" if page_active?(path)) } do %>
<%= icon(icon) if icon %>

View File

@@ -31,6 +31,6 @@
</div>
<div class="absolute -top-2 -right-2">
<%= icon "x", class: "p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-container text-subdued cursor-pointer", data: { action: "click->element-removal#remove" } %>
<%= icon "x", class: "p-0.5 hidden group-hover:inline-block rounded-lg bg-container shadow-xs text-subdued hover:text-primary cursor-pointer", data: { action: "click->element-removal#remove" } %>
</div>
<% end %>

View File

@@ -25,6 +25,7 @@
"shadow": { "$value": "{color.black|6%}", "$type": "color", "$extensions": { "sure.dark": "{color.white|8%}" } },
"link": { "$value": "{color.blue.600}", "$type": "color", "$extensions": { "sure.dark": "{color.blue.500}" } },
"focus-ring": { "$value": "{color.alpha-black.700}", "$type": "color", "$extensions": { "sure.dark": "{color.alpha-white.700}" } },
"tertiary": { "$value": "{color.alpha-black.100}", "$type": "color", "$extensions": { "sure.dark": "{color.alpha-white.200}" } },
"surface": { "$value": "{color.gray.50}", "$type": "color", "$extensions": { "sure.dark": "{color.black}" } },
"surface-hover": { "$value": "{color.gray.100}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.800}" } },