From 26da018df939ffdc56fdcee1986024e5d9a5e09b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 9 Oct 2026 14:12:50 +0200 Subject: [PATCH] fix(frontend): meet WCAG AA contrast and show keyboard focus Darken the success, error, muted, subtle and warning text tokens, the GCP badge and the sort and filter icons so the measured failures pass 4.5:1 (3:1 for icons). Add a --cudly-focus-ring token, a zero-specificity default :focus-visible outline, and use the ring for chips, filters and the recommendation chevron instead of a near-invisible halo. The plan ramp radios are visually hidden rather than display: none, so they are in the tab order, and the card shows the ring on focus. Refs #696, #750 Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/styles/base.css | 23 ++++++++++++++------ frontend/src/styles/components.css | 21 ++++++++++-------- frontend/src/styles/forms.css | 18 ++++++++++++++-- frontend/src/styles/plans.css | 16 +++++++++++++- frontend/tests-e2e/ramp-focus.spec.ts | 31 +++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 frontend/tests-e2e/ramp-focus.spec.ts diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 8a8f4d33..74c206fb 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -23,22 +23,26 @@ /* Text */ --cudly-text: #111827; /* Primary text */ - --cudly-text-muted: #6b7280; /* Secondary / hint text */ - --cudly-text-subtle: #9ca3af; /* Placeholder / disabled labels */ + --cudly-text-muted: #4b5563; /* Secondary / hint text (7.5:1 on white) */ + --cudly-text-subtle: #6b7280; /* Placeholder / disabled labels (4.8:1 on white, AA) */ /* Status (semantic) — keep existing palette to avoid surprise drift */ - --cudly-success: #34a853; + --cudly-success: #15803d; /* 5.0:1 on white, and white text on it passes AA */ --cudly-success-bg: #e8f5e9; --cudly-success-fg: #2e7d32; --cudly-warn: #fbbc04; --cudly-warn-bg: #fff3e0; - --cudly-warn-fg: #e65100; - --cudly-error: #ea4335; + --cudly-warn-fg: #b45309; + --cudly-error: #d93025; /* white text on it is 4.8:1 */ --cudly-error-bg: #fce8e6; --cudly-error-fg: #c5221f; --cudly-info: #2563eb; --cudly-info-bg: #eff6ff; + /* Keyboard focus: a surface-coloured gap then a primary ring, visible on + * white and on tinted backgrounds. */ + --cudly-focus-ring: 0 0 0 2px var(--cudly-surface), 0 0 0 4px var(--cudly-primary); + /* Typography */ --cudly-font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --cudly-fs-xs: 0.75rem; /* 12px */ @@ -97,6 +101,13 @@ body { color: var(--cudly-text); } +/* Default keyboard focus ring. Zero specificity (:where) so a component that + * draws its own focus style keeps it; everything else gets a visible ring. */ +:where(a[href], button, input, select, textarea, summary, [tabindex]):focus-visible { + outline: 2px solid var(--cudly-primary); + outline-offset: 2px; +} + /* Generic utilities — prefer these over inline `style="..."` attributes * (parsed by HTML, blocked by CSP `style-src 'self'`) and over JS * `element.style.*` mutations (not blocked by CSP but harder to restyle). */ @@ -105,7 +116,7 @@ body { } .text-muted { - color: var(--cudly-text-subtle); + color: var(--cudly-text-muted); font-size: 0.85rem; } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index d9f1fe6f..27d794f2 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -142,7 +142,7 @@ button.success { } button.success:hover { - background: #2d9048; + background: #166534; } button.danger { @@ -150,7 +150,7 @@ button.danger { } button.danger:hover { - background: #d33426; + background: var(--cudly-error-fg); } /* Secondary button style */ @@ -215,8 +215,8 @@ button.danger:hover { } .btn-small.success:hover { - background: #2d9048; - border-color: #2d9048; + background: #166534; + border-color: #166534; } .btn-small.warning { @@ -600,7 +600,7 @@ th.sortable:hover { th.sortable .sort-indicator { margin-left: 0.35rem; - color: #bbb; + color: var(--cudly-text-subtle); font-size: 0.75em; } @@ -614,7 +614,7 @@ th .column-filter-btn { padding: 0 0.25rem; border: 0; background: transparent; - color: #bbb; + color: var(--cudly-text-subtle); cursor: pointer; font-size: 0.85em; line-height: 1; @@ -947,6 +947,9 @@ tr.rec-cell-summary-row:hover { background: var(--cudly-info-bg, #eaf0fb); outline: none; } +.rec-cell-chevron:focus-visible { + box-shadow: var(--cudly-focus-ring); +} .rec-cell-identity { font-weight: 500; font-size: 0.9em; @@ -1142,7 +1145,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .provider-badge.gcp { - background: #4285f4; + background: #1a73e8; color: white; } @@ -1527,7 +1530,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .chip-select:focus-visible { outline: none; border-color: var(--cudly-primary); - box-shadow: 0 0 0 3px var(--cudly-info-bg); + box-shadow: var(--cudly-focus-ring); } .chip-select[aria-expanded="true"] { @@ -1588,7 +1591,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { .chip-select-search:focus-visible { outline: none; border-color: var(--cudly-primary); - box-shadow: 0 0 0 2px var(--cudly-info-bg); + box-shadow: var(--cudly-focus-ring); } .chip-select-options { diff --git a/frontend/src/styles/forms.css b/frontend/src/styles/forms.css index 1ea2163d..4abe36ee 100644 --- a/frontend/src/styles/forms.css +++ b/frontend/src/styles/forms.css @@ -104,7 +104,7 @@ label { #history-controls input[type="date"]:focus-visible { outline: none; border-color: var(--cudly-primary); - box-shadow: 0 0 0 3px var(--cudly-info-bg); + box-shadow: var(--cudly-focus-ring); } /* Tone down the native calendar-picker indicator inside our pill- @@ -286,8 +286,22 @@ input:checked + .slider:before { border-color: var(--cudly-primary); } +.ramp-option { + position: relative; +} + +/* Visually hidden, not display:none, so the radio stays in the tab order. */ .ramp-option input[type="radio"] { - display: none; + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; + pointer-events: none; +} + +.ramp-option:has(input:focus-visible) { + box-shadow: var(--cudly-focus-ring); } .ramp-option:has(input:checked) { diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index fad59324..120fdba1 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -192,8 +192,22 @@ border-color: var(--cudly-primary); } +.ramp-option { + position: relative; +} + +/* Visually hidden, not display:none, so the radio stays in the tab order. */ .ramp-option input[type="radio"] { - display: none; + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; + pointer-events: none; +} + +.ramp-option:has(input:focus-visible) { + box-shadow: var(--cudly-focus-ring); } .ramp-option:has(input:checked) { diff --git a/frontend/tests-e2e/ramp-focus.spec.ts b/frontend/tests-e2e/ramp-focus.spec.ts new file mode 100644 index 00000000..f703370d --- /dev/null +++ b/frontend/tests-e2e/ramp-focus.spec.ts @@ -0,0 +1,31 @@ +/** + * Keyboard access to the plan ramp options (issue #750). + * + * The ramp radios were `display: none`, which removed them from the tab order + * so the cards could only be chosen with a mouse. jsdom has no layout or tab + * order, so this runs in Chromium: Tab must land on a ramp radio and the card + * must draw a visible focus ring. + */ + +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +test('ramp option radios are reachable by Tab and show a focus ring', async ({ page }) => { + await seedAuth(page); + await mockApi(page); + await page.goto('/plans'); + await page.getByRole('button', { name: 'New Plan' }).click(); + + const radio = page.locator('.ramp-option input[type="radio"]').first(); + await expect(radio).toBeAttached(); + for (let i = 0; i < 80; i++) { + if (await radio.evaluate((el) => el === document.activeElement)) break; + await page.keyboard.press('Tab'); + } + await expect(radio).toBeFocused(); + + // box-shadow animates with the card's transition; wait for the settled ring. + await expect + .poll(() => radio.evaluate((el) => getComputedStyle(el.closest('.ramp-option')!).boxShadow)) + .toContain('rgb(37, 99, 235)'); +});