diff --git a/frontend/package.json b/frontend/package.json index f11a63d4..e22e2287 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "test:a11y": "jest --testPathPattern=accessibility", + "test:a11y": "jest --testPathPatterns=accessibility", "test:ci": "jest --ci --coverage --maxWorkers=2", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/frontend/src/styles/__tests__/accessibility.css.test.ts b/frontend/src/styles/__tests__/accessibility.css.test.ts index a2f66550..2f3b572d 100644 --- a/frontend/src/styles/__tests__/accessibility.css.test.ts +++ b/frontend/src/styles/__tests__/accessibility.css.test.ts @@ -2,6 +2,8 @@ import fs from 'fs'; import path from 'path'; const SRC_DIR = path.resolve(__dirname, '../../'); +const A11Y_CSS = path.resolve(SRC_DIR, 'styles/accessibility.css'); +const TOKENS_CSS = path.resolve(SRC_DIR, 'styles/tokens.css'); function findCssFiles(dir: string): string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -13,6 +15,157 @@ function findCssFiles(dir: string): string[] { }); } +/* ============================================================ + CSS token / block extraction helpers + ============================================================ */ + +/** Extract the body of the first top-level block whose selector line matches. */ +function readBlock(css: string, selector: RegExp): string { + const lines = css.split('\n'); + const body: string[] = []; + let inBlock = false; + let started = false; + let depth = 0; + + for (const line of lines) { + if (!inBlock) { + if (selector.test(line)) { + inBlock = true; + const brace = (line.match(/\{/g) || []).length; + if (brace > 0) { + started = true; + depth = brace; + } + } + continue; + } + if (!started) { + const brace = (line.match(/\{/g) || []).length; + if (brace > 0) { + started = true; + depth = brace; + } + } else { + depth += (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length; + } + body.push(line.replace(/^[ \t]+/, '')); + if (started && depth === 0) break; + } + return body.join('\n'); +} + +/** Extract a CSS custom property value from a block body. */ +function varValue(block: string, name: string): string | null { + // `name` already includes the leading `--` (e.g. "--ring-strong"). + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const m = block.match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`)); + return m ? m[1].trim() : null; +} + +/* ============================================================ + Color / contrast helpers (WCAG 2.x APCA-free ratio math) + ============================================================ */ + +type RGB = [number, number, number]; // 0..1 floats + +function channelToLinear(c: number): number { + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +function hexToRgb(hex: string): RGB | null { + const m = hex.trim().replace('#', '').match(/^([0-9a-f]{6})$/i); + if (!m) return null; + return [ + parseInt(m[1].slice(0, 2), 16) / 255, + parseInt(m[1].slice(2, 4), 16) / 255, + parseInt(m[1].slice(4, 6), 16) / 255, + ]; +} + +/** Parse `rgb(r,g,b)` / `rgba(r,g,b,a)` into floats. */ +function rgbaToRgb(value: string): RGB | null { + const m = value.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i); + if (!m) return null; + return [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255]; +} + +function luminance(rgb: RGB): number { + return ( + 0.2126 * channelToLinear(rgb[0]) + + 0.7152 * channelToLinear(rgb[1]) + + 0.0722 * channelToLinear(rgb[2]) + ); +} + +function contrastRatio(a: RGB, b: RGB): number { + const l1 = luminance(a); + const l2 = luminance(b); + const [hi, lo] = [Math.max(l1, l2), Math.min(l1, l2)]; + return (hi + 0.05) / (lo + 0.05); +} + +/** Resolve a token value (hex or rgba) to RGB. r,g,b are 0..255 ints. */ +function parseColor(value: string): RGB | null { + const trimmed = value.trim(); + if (/^#/.test(trimmed)) return hexToRgb(trimmed); + if (/^rgba?\(/.test(trimmed)) return rgbaToRgb(trimmed); + return null; +} + +/** + * Composite a translucent foreground over an opaque background, returning the + * resulting opaque RGB. Used to resolve `--surface-glass` tokens. + */ +function composite(fg: RGB, base: RGB, alpha: number): RGB { + return [ + fg[0] * alpha + base[0] * (1 - alpha), + fg[1] * alpha + base[1] * (1 - alpha), + fg[2] * alpha + base[2] * (1 - alpha), + ]; +} + +function alphaOf(value: string): number | null { + const m = value.match(/,?\s*([\d.]+)\)\s*$/); + return m ? Number(m[1]) : null; +} + +/* ============================================================ + Shared fixtures extracted from tokens.css + ============================================================ */ + +const tokensCss = fs.readFileSync(TOKENS_CSS, 'utf-8'); +const a11yCss = fs.readFileSync(A11Y_CSS, 'utf-8'); + +// Scopes define where each color scheme's tokens live in tokens.css. +const COLOR_SCOPES: Record<'dark' | 'light', RegExp> = { + dark: /^:root\s*\{/, + light: /html:not\(\.dark-mode\)\s*\{/, +}; + +/** The opaque background tokens by name — shared across schemes. */ +const BG_TOKEN_NAMES = ['--bg', '--bg-deep', '--surface', '--surface-2']; + +/** For each scheme, the RGB background tokens the focus ring must contrast with. */ +function backgroundTokens(scheme: 'dark' | 'light', baseForGlass: RGB): { name: string; rgb: RGB }[] { + const block = readBlock(tokensCss, COLOR_SCOPES[scheme]); + const tokens: { name: string; rgb: RGB }[] = []; + + for (const name of BG_TOKEN_NAMES) { + const raw = varValue(block, name); + const rgb = raw ? parseColor(raw) : null; + if (rgb) tokens.push({ name, rgb }); + } + + const glassRaw = varValue(block, '--surface-glass'); + const alpha = glassRaw ? alphaOf(glassRaw) : null; + const glassRgb = glassRaw ? rgbaToRgb(glassRaw) : null; + if (glassRaw && alpha !== null && glassRgb) { + tokens.push({ name: '--surface-glass', rgb: composite(glassRgb, baseForGlass, alpha) }); + } + + return tokens; +} + describe('.visually-hidden utility class', () => { it('is defined exactly once across the stylesheet tree', () => { const cssFiles = findCssFiles(SRC_DIR); @@ -23,3 +176,86 @@ describe('.visually-hidden utility class', () => { expect(definitions).toEqual([path.resolve(SRC_DIR, 'styles/accessibility.css')]); }); }); + +/* ============================================================ + Requirement 1 — :focus-visible contrast vs every bg token + ============================================================ */ + +describe(':focus-visible outlines', () => { + const fork = readBlock(a11yCss, /\*:focus-visible/); + + it('uses the mode-aware --ring-strong token (with gold fallback)', () => { + expect(fork).toContain('var(--ring-strong, var(--ring, #f59e0b))'); + }); + + test.each(['dark', 'light'] as const)( + '%s scheme: --ring-strong holds >= 3:1 against every background token', + (scheme) => { + const block = readBlock(tokensCss, COLOR_SCOPES[scheme]); + const ring = varValue(block, '--ring-strong'); + expect(ring).toBeTruthy(); + + const ringRgb = parseColor(ring as string); + expect(ringRgb).not.toBeNull(); + + // --surface-glass renders over the deepest page background. + const baseForGlass = scheme === 'dark' ? (hexToRgb('#070b16') as RGB) : (hexToRgb('#f7f9fc') as RGB); + const bgs = backgroundTokens(scheme, baseForGlass); + expect(bgs.length).toBeGreaterThan(0); + + for (const bg of bgs) { + const ratio = contrastRatio(ringRgb as RGB, bg.rgb); + if (ratio < 3) { + throw new Error( + `${scheme} ring ${ring} vs ${bg.name} = ${ratio.toFixed(2)}:1 (< 3:1 minimum)` + ); + } + } + } + ); +}); + +/* ============================================================ + Requirement 2 — minimum interactive target size (44 x 44) + ============================================================ */ + +describe('interactive target size (WCAG 2.5.5)', () => { + const block = readBlock(a11yCss, /INTERACTIVE TARGET SIZE/); + + test('targets buttons, links, and form controls', () => { + expect(block).toContain('button'); + expect(block).toContain("a[href]"); + expect(block).toContain('input'); + expect(block).toContain('select'); + expect(block).toContain('textarea'); + }); + + it('enforces at least 44 x 44 CSS px', () => { + expect(block).toMatch(/min-height:\s*44px/); + expect(block).toMatch(/min-width:\s*44px/); + }); +}); + +/* ============================================================ + Requirement 3 — prefers-reduced-motion disables animation + ============================================================ */ + +describe('prefers-reduced-motion', () => { + const block = readBlock(a11yCss, /prefers-reduced-motion: reduce/); + + it('declares the @media (prefers-reduced-motion: reduce) query', () => { + expect(a11yCss).toContain('@media (prefers-reduced-motion: reduce) {'); + }); + + it('disables non-essential animation globally', () => { + expect(block).toMatch(/animation-\s*duration/); + }); + + it('explicitly disables spinners, skeleton shimmer, and toast transitions', () => { + expect(block).toContain('.spinner'); + expect(block).toContain('.loading-spinner .spinner'); + expect(block).toContain('.skeleton'); + // Toast / transition-driven UI is covered by the universal transition kill. + expect(block).toMatch(/transition-duration/); + }); +}); \ No newline at end of file diff --git a/frontend/src/styles/accessibility.css b/frontend/src/styles/accessibility.css index 97e24bb9..4c3ce524 100644 --- a/frontend/src/styles/accessibility.css +++ b/frontend/src/styles/accessibility.css @@ -22,7 +22,7 @@ } .skip-link:focus { top: 1rem; - outline: 3px solid var(--ring, #f59e0b); + outline: 3px solid var(--ring-strong, var(--ring, #f59e0b)); outline-offset: 2px; } @@ -53,9 +53,13 @@ /* ============================================ FOCUS INDICATORS + + --ring-strong is a mode-aware token (see tokens.css) that keeps >= 3:1 + non-text contrast against EVERY background token in both light and dark + schemes (WCAG 2.1 AA non-text contrast / visibility of focus). ============================================ */ *:focus-visible { - outline: 3px solid var(--ring, #f59e0b); + outline: 3px solid var(--ring-strong, var(--ring, #f59e0b)); outline-offset: 2px; border-radius: 4px; } @@ -117,14 +121,20 @@ textarea[aria-invalid='true'] { } /* ============================================ - TOUCH TARGETS (minimum 44x44) + INTERACTIVE TARGET SIZE — WCAG 2.5.5 (44 x 44 CSS px) + + Every button, link, and form control gets a hit target of at least + 44 x 44px so low-motion / touch users can reliably activate it. ============================================ */ button, -a[role='menuitem'], -input[type='checkbox'], -input[type='radio'], -select { +[role='button'], +a[href], +input, +select, +textarea, +summary { min-height: 44px; + min-width: 44px; } /* ============================================ @@ -158,6 +168,10 @@ html { /* ============================================ REDUCED MOTION + + Disables non-essential animation for users who opt out of motion, covering + the loading spinner (spin), skeleton shimmer, toast in/out transitions, and + any other keyframe/transition. Essential functionality is unaffected. ============================================ */ @media (prefers-reduced-motion: reduce) { html { @@ -166,11 +180,17 @@ html { *, *::before, *::after { + animation-name: none !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } + .spinner, + .loading-spinner .spinner, + .skeleton { + animation: none !important; + } } /* ============================================ diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index f595eafc..03b3c8d8 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -56,6 +56,8 @@ --destructive: #f87171; --success: #34d399; --ring: var(--gold); + /* Focus indicator that holds >= 3:1 contrast against every dark bg token. */ + --ring-strong: #fbbf24; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.7); @@ -82,6 +84,8 @@ html.light-mode { --on-primary: #1a1204; --destructive: #dc2626; --success: #059669; + /* Focus indicator that holds >= 3:1 contrast against every light bg token. */ + --ring-strong: #1e3a8a; --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.08); --shadow: 0 24px 48px -24px rgba(15, 23, 42, 0.25); @@ -106,6 +110,8 @@ html.light-mode { --on-primary: #1a1204; --destructive: #dc2626; --success: #059669; + /* Focus indicator that holds >= 3:1 contrast against every light bg token. */ + --ring-strong: #1e3a8a; --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.08); --shadow: 0 24px 48px -24px rgba(15, 23, 42, 0.25);