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 b63393a2..5d76ec88 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -157,8 +157,8 @@ button.success { } button.success:hover { - background: #2d9048; - border-color: #2d9048; + background: #166534; + border-color: #166534; } button.danger { @@ -168,8 +168,8 @@ button.danger { } button.danger:hover { - background: #d33426; - border-color: #d33426; + background: var(--cudly-error-fg); + border-color: var(--cudly-error-fg); } /* Secondary button style */ @@ -238,8 +238,8 @@ button.secondary:hover { } .btn-small.success:hover { - background: #2d9048; - border-color: #2d9048; + background: #166534; + border-color: #166534; } .btn-small.warning { @@ -664,7 +664,7 @@ th.sortable:hover { th.sortable .sort-indicator { margin-left: 0.35rem; - color: #bbb; + color: var(--cudly-text-subtle); font-size: 0.75em; } @@ -678,7 +678,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; @@ -1013,6 +1013,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; @@ -1208,7 +1211,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .provider-badge.gcp { - background: #4285f4; + background: #1a73e8; color: white; } @@ -1593,7 +1596,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"] { @@ -1654,7 +1657,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 ac372bb4..e965e1da 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)'); +});