Skip to content

fix(a11y): consistent high-contrast focus rings across all Button variants (WCAG 1.4.11)#3438

Merged
bilal-karim merged 2 commits into
mainfrom
a11y/1.4.11-button-focus-rings
May 22, 2026
Merged

fix(a11y): consistent high-contrast focus rings across all Button variants (WCAG 1.4.11)#3438
bilal-karim merged 2 commits into
mainfrom
a11y/1.4.11-button-focus-rings

Conversation

@bilal-karim

@bilal-karim bilal-karim commented May 22, 2026

Copy link
Copy Markdown
Member

Summary

Unifies focus-ring styling across all 5 Button variants to fix SC 1.4.11 Non-text Contrast. Previously every variant used a 70% opacity ring with no offset; the primary variant produced an effectively invisible indigo ring (~1:1) on its indigo background, and destructive produced a faint red-on-red ring (~2:1).

Final pattern (applied to all variants)

focus-visible:ring-{color} focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary
  • Ring color matches the button's color family (ring-primary for primary/secondary/ghost/table-header, ring-danger for destructive). Full opacity instead of /70.
  • Offset is surface-primary (white in light mode, black in dark mode) — puts a 2px gap between the button and the ring.
  • Result: button → page-color gap → ring → page. The gap separates the ring from the button; the colored ring contrasts against both light and dark page surfaces (~7:1 light, ~5:1 dark).

Variant changes

Variant Before After
primary ring-primary/70 (indigo on indigo, ~1:1) ring-primary + offset (~7:1 vs page)
destructive ring-danger/70 (red on red.300, ~2:1) ring-danger + offset (~6:1 vs page)
secondary ring-primary/70, no offset ring-primary + offset for consistency
ghost ring-primary/70, no offset ring-primary + offset for consistency
table-header ring-primary/70, no offset ring-primary + offset for consistency

Note on the first iteration

Initial commit used ring-white for primary per the audit's recommended Option A. That worked in dark mode but blended with the white page in light mode (button → white gap → white ring → white page). Switched to Option B (ring-primary with offset) which works in both themes.

Audit context

  • WCAG 2.2 SC 1.4.11 Non-text Contrast (Level AA) — Serious. Affects keyboard users on every focusable button across the product.
  • Cross-cutting with SC 2.4.7 Focus Visible.
  • Issue files: audit-output/issues/1.4.11-button-{primary,destructive}-focus-ring.md. The secondary/ghost/table-header consistency extension is beyond the audit's literal scope but follows directly from the same pattern.

Test plan

  • Tab to a primary button (Start Workflow, Save, Apply Filter) in both light and dark mode. Clear visible ring with a small page-colored gap.
  • Tab to a destructive button — only reachable via confirmation modals (Terminate, Cancel, Reset, Delete on workflows/activities/deployments). Red ring with gap.
  • Tab to secondary, ghost, and table-header buttons. Indigo ring with gap, consistent visual language.
  • DevTools contrast check: ring-vs-page and ring-vs-button both ≥ 3:1.

🤖 Generated with Claude Code

…ns (WCAG 1.4.11)

Two same-shape fixes for SC 1.4.11 Non-text Contrast on the Button
primitive. Both variants previously composited a ring color over a
same-or-similar button background, producing ratios that fail the
3:1 SC threshold:

- primary: indigo ring on indigo button (~1:1, effectively invisible)
  -> white ring with offset against the page surface (~7:1).
- destructive: red ring at 70% on red.300 button (~2:1)
  -> full-opacity red ring with offset against the page (~6:1).

The ring-offset-2 + ring-offset-surface-primary pattern puts the ring
in a 2px gap between the button and the page so it never blends with
either surface. Matches the convention used by Material, Primer, and
Atlassian Design System.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@bilal-karim
bilal-karim requested a review from a team as a code owner May 22, 2026 13:29
@vercel

vercel Bot commented May 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
holocene Ready Ready Preview, Comment May 22, 2026 1:45pm

Request Review

@temporal-cicd

temporal-cicd Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
Warnings
⚠️

📊 Strict Mode: 6 errors in 1 file (0.7% of 914 total)

src/lib/holocene/button.svelte (6)
  • L109:13: Type 'null' is not assignable to type '"search" | "link" | "success" | "error" | "action" | "activity" | "add-square" | "add" | "apple" | "archives" | "arrow-down" | "arrow-left" | "arrow-up" | "arrow-right" | "ascending" | ... 140 more ... | "xmark-square"'.
  • L110:13: Type 'null' is not assignable to type '"search" | "link" | "success" | "error" | "action" | "activity" | "add-square" | "add" | "apple" | "archives" | "arrow-down" | "arrow-left" | "arrow-up" | "arrow-right" | "ascending" | ... 140 more ... | "xmark-square"'.
  • L112:13: Type 'null' is not assignable to type 'string'.
  • L113:13: Type 'null' is not assignable to type 'string'.
  • L114:13: Type 'null' is not assignable to type 'string'.
  • L102:7: Argument of type '$$Props' is not assignable to parameter of type '{ variant?: "primary" | "secondary" | "ghost" | "destructive" | "table-header" | null | undefined; size?: "xs" | "sm" | "md" | "lg" | null | undefined; disabled?: boolean | undefined; ... 9 more ...; class?: string | undefined; }'.

Generated by 🚫 dangerJS against 821ed18

…ants

Follow-up to the primary + destructive focus-ring fix.

- Primary ring switched from ring-white to ring-primary. The white
  ring blended with the white page in light mode (button -> white
  gap -> white ring -> white page); the new full-opacity indigo
  ring is visible against both light and dark page surfaces.
- Secondary, ghost, and table-header variants now also use the
  ring-{color} + ring-offset-2 + ring-offset-surface-primary
  pattern for visual consistency. The offset puts the ring in a
  page-color gap separated from the button.

Pattern is now uniform: button -> page-color gap -> ring (in
button's color family) -> page.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@bilal-karim bilal-karim changed the title fix(a11y): high-contrast focus rings on primary and destructive buttons (WCAG 1.4.11) fix(a11y): consistent high-contrast focus rings across all Button variants (WCAG 1.4.11) May 22, 2026
@bilal-karim

Copy link
Copy Markdown
Member Author

Ideally, this pattern should extend to all interactive elements.

@bilal-karim
bilal-karim merged commit e8ed8a4 into main May 22, 2026
17 checks passed
@bilal-karim
bilal-karim deleted the a11y/1.4.11-button-focus-rings branch May 22, 2026 13:57
bilal-karim pushed a commit that referenced this pull request Jun 3, 2026
…ing contrasts against the indigo checked background (#3477)

The Holocene Checkbox primitive's :checked state applied
peer-checked:bg-interactive (--color-interactive-surface = indigo.600 in
both modes) with a peer-focus-visible:ring-primary/70 focus ring, where
ring-primary maps to --color-border-focus-info (also indigo.600 in both
modes). The composited ring color equalled the checked background color
(ratio 1.00:1), so the focus indicator was invisible whenever a checkbox
was both checked and focused — the most common state for tab-traversing a
form.

This adds peer-focus-visible:ring-offset-2 + ring-offset-[var(--color-
surface-primary)] on the visual surrogate. The 2 px gap sits between the
indigo block and the indigo ring, and the gap is colored to match the
surrounding page surface (white in light mode, black in dark mode) so the
ring composites against canvas rather than against itself. Post-fix ratio
against the gap is ~3.58:1 in light mode (Pass).

Note: the existing Button-primary fix in #3438 used the class
ring-offset-surface-primary, which is a silent no-op in the current
Tailwind config (no surface-primary key in theme.ringOffsetColor or
theme.colors). It worked there only because Tailwind's default
--tw-ring-offset-color (#fff) happens to match light-mode surface-primary
and is visible-but-mis-colored in dark mode. The arbitrary-value form
used here, ring-offset-[var(--color-surface-primary)], passes the design
token through verbatim and generates a real CSS rule that is correct in
both modes. A separate follow-up may want to apply the same arbitrary-
value treatment to Button.

Cross-walks 2.4.7 Focus Visible (Level AA). Cascades to cloud-ui-main via
the @temporalio/ui tarball on next repack.

Co-authored-by: canditocheeto <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
rossedfort added a commit that referenced this pull request Jun 4, 2026
Auto-generated version bump from 2.50.0 to 2.51.0

Bump type: minor

Changes included:
- [`495e27f7`](495e27f) Use pageSize instead of maximumPageSize (#3422)
- [`42c58c45`](42c58c4) fix: use rem-based width for expanded side-nav (#3426)
- [`ce928ed5`](ce928ed) fix(a11y): meet 4.5:1 contrast for text-subtle and text-warning (WCAG 1.4.3) (#3437)
- [`e8ed8a49`](e8ed8a4) fix(a11y): consistent high-contrast focus rings across all Button variants (WCAG 1.4.11) (#3438)
- [`03918fb5`](03918fb) fix(a11y): prevent horizontal scroll on login page at 320px (WCAG 1.4.10) (#3440)
- [`a11555fb`](a11555f) fix(a11y): use rem-based font-size and unitless line-height in markdown reset (#3430)
- [`057ff323`](057ff32) fix: use empty alt on decorative SDK logo image (#3424)
- [`e435ffd7`](e435ffd) Standalone Activity details page UI updates (#3427)
- [`e09028f3`](e09028f) Make bottom-nav accept linksSnippet instead of sections (#3445)
- [`a22a1e21`](a22a1e2) Add tooltip for SDK version (#3462)
- [`c3b7820e`](c3b7820) [DT-4039] Workflow query builder doesn't work with numeric search attributes (#3435)
- [`5e53881f`](5e53881) Make Common Errors dismissable (#3471)
- [`2665e2f1`](2665e2f) [DT-4048] Add accessibility PR triage and notification helpers (#3465)
- [`8083cf4e`](8083cf4) feat(DT-4017): Schedules List UI Update (#3467)
- [`b5de30e3`](b5de30e) fix(markdown): allow nested lists to render as block (DT-4047) (#3463)
- [`8a6fd2de`](8a6fd2d) Add a prop to hide select/deselect controls (#3474)
- [`33ec1b3e`](33ec1b3) DT-4051: pre-populate input when starting standalone activity like this one (#3469)
- [`0d64fc20`](0d64fc2) fix(a11y): reveal Copyable's CopyButton on focus-within (WCAG 2.1.1) (#3452)
- [`321651c0`](321651c) fix: accept autocomplete prop on NumberInput, ChipInput, and Combobox (#3425)
- [`6567c222`](6567c22) fix: cap ZoomSvg container height to viewport (#3428)
- [`afd3a8ed`](afd3a8e) fix(a11y): accommodate text-spacing overrides on Badge and Chip (WCAG 1.4.12) (#3433)
- [`d264b876`](d264b87) fix(a11y): non-color signal for Label required indicator (WCAG 1.4.1) (#3439)
- [`73c61ea3`](73c61ea) fix(a11y): tabindex on <main> so the skip link moves focus reliably (WCAG 2.4.1) (#3451)
- [`34e582a8`](34e582a) fix(a11y): info-and-relationships compliance (WCAG 1.3.1) (#3432)
- [`f7be7b6c`](f7be7b6) fix(query): quote ExecutionDuration Go duration strings in visibility SQL (#3482)
- [`9f0c2631`](9f0c263) Passthrough goto params to link component (#3483)
- [`2f8c037b`](2f8c037) feat: add centerButton, menuButton, and linksContent snippets to BottomNavigation (#3485)
- [`7f258bac`](7f258ba) fix: add "for" to validate connection modal title (#3488)
- [`350397ab`](350397a) feat(DT-4069): Update modal backdrop to 50% opaque (#3486)
- [`f01baa49`](f01baa4) refactor: Input & DatePicker - Svelte 5 & afterLabel snippet (#3479)
- [`587c892f`](587c892) Fix decoded object payload summaries (#3491)
- [`2b85ef06`](2b85ef0) fix(DT-4044): only use browser codec endpoint when override option is selected (#3490)
- [`468893ba`](468893b) fix(a11y): improve 1.1.1 non-text content compliance (#3431)
- [`3e8c996d`](3e8c996) fix(a11y): make Tooltip keyboard-accessible, dismissible, and hoverable (#3429)
- [`47552538`](4755253) fix(a11y): name-role-value compliance, partial (WCAG 4.1.2) (#3434)
- [`e2c95d54`](e2c95d5) fix(a11y): align DOM order with visual order in mobile bottom-nav (WCAG 1.3.2) (#3441)
- [`634b08e1`](634b08e) a11y(1.4.11): focus rings — lighten dark-mode --color-border-focus-info to indigo.400 so ring-primary/70 meets 3:1 against surface-primary (#3478)
- [`039a555a`](039a555) a11y(1.4.11): Checkbox — add ring-offset so the checked-state focus ring contrasts against the indigo checked background (#3477)
- [`01161e2e`](01161e2) a11y(1.4.10): activity-options drawer — make width responsive so it reflows at 320 px (#3476)
- [`99f0a0a4`](99f0a0a) fix(a11y): filter hidden nav items in mobile bottom-nav (WCAG 4.1.2) (#3494)
- [`d12ebacc`](d12ebac) fix(a11y): add focusin/focusout to saved-query nav hover tooltip (WCAG 2.1.1) (#3453)
- [`c7206af9`](c7206af) fix(a11y): render a warning icon for Chip warning intent (WCAG 1.4.1) (#3450)
- [`b64ed713`](b64ed71) fix(a11y): pair Toast variant background with a severity icon (WCAG 1.4.1) (#3449)
- [`8e2ef708`](8e2ef70) upgrade temporal api version to latest (v1.62.13) (#3502)

Co-authored-by: rossedfort <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants