Skip to content

Commit 94702c3

Browse files
authored
Merge pull request #1451 from Sundriveauto/feat/a11y-wcag-focus-targets-motion
feat(a11y): WCAG-compliant focus contrast, 44px targets, reduced motion
2 parents 0139e52 + 716e549 commit 94702c3

4 files changed

Lines changed: 270 additions & 8 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"test": "jest",
1313
"test:watch": "jest --watch",
1414
"test:coverage": "jest --coverage",
15-
"test:a11y": "jest --testPathPattern=accessibility",
15+
"test:a11y": "jest --testPathPatterns=accessibility",
1616
"test:ci": "jest --ci --coverage --maxWorkers=2",
1717
"test:e2e": "playwright test",
1818
"test:e2e:ui": "playwright test --ui",

frontend/src/styles/__tests__/accessibility.css.test.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import fs from 'fs';
22
import path from 'path';
33

44
const SRC_DIR = path.resolve(__dirname, '../../');
5+
const A11Y_CSS = path.resolve(SRC_DIR, 'styles/accessibility.css');
6+
const TOKENS_CSS = path.resolve(SRC_DIR, 'styles/tokens.css');
57

68
function findCssFiles(dir: string): string[] {
79
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
@@ -13,6 +15,157 @@ function findCssFiles(dir: string): string[] {
1315
});
1416
}
1517

18+
/* ============================================================
19+
CSS token / block extraction helpers
20+
============================================================ */
21+
22+
/** Extract the body of the first top-level block whose selector line matches. */
23+
function readBlock(css: string, selector: RegExp): string {
24+
const lines = css.split('\n');
25+
const body: string[] = [];
26+
let inBlock = false;
27+
let started = false;
28+
let depth = 0;
29+
30+
for (const line of lines) {
31+
if (!inBlock) {
32+
if (selector.test(line)) {
33+
inBlock = true;
34+
const brace = (line.match(/\{/g) || []).length;
35+
if (brace > 0) {
36+
started = true;
37+
depth = brace;
38+
}
39+
}
40+
continue;
41+
}
42+
if (!started) {
43+
const brace = (line.match(/\{/g) || []).length;
44+
if (brace > 0) {
45+
started = true;
46+
depth = brace;
47+
}
48+
} else {
49+
depth += (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length;
50+
}
51+
body.push(line.replace(/^[ \t]+/, ''));
52+
if (started && depth === 0) break;
53+
}
54+
return body.join('\n');
55+
}
56+
57+
/** Extract a CSS custom property value from a block body. */
58+
function varValue(block: string, name: string): string | null {
59+
// `name` already includes the leading `--` (e.g. "--ring-strong").
60+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61+
const m = block.match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`));
62+
return m ? m[1].trim() : null;
63+
}
64+
65+
/* ============================================================
66+
Color / contrast helpers (WCAG 2.x APCA-free ratio math)
67+
============================================================ */
68+
69+
type RGB = [number, number, number]; // 0..1 floats
70+
71+
function channelToLinear(c: number): number {
72+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
73+
}
74+
75+
function hexToRgb(hex: string): RGB | null {
76+
const m = hex.trim().replace('#', '').match(/^([0-9a-f]{6})$/i);
77+
if (!m) return null;
78+
return [
79+
parseInt(m[1].slice(0, 2), 16) / 255,
80+
parseInt(m[1].slice(2, 4), 16) / 255,
81+
parseInt(m[1].slice(4, 6), 16) / 255,
82+
];
83+
}
84+
85+
/** Parse `rgb(r,g,b)` / `rgba(r,g,b,a)` into floats. */
86+
function rgbaToRgb(value: string): RGB | null {
87+
const m = value.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i);
88+
if (!m) return null;
89+
return [Number(m[1]) / 255, Number(m[2]) / 255, Number(m[3]) / 255];
90+
}
91+
92+
function luminance(rgb: RGB): number {
93+
return (
94+
0.2126 * channelToLinear(rgb[0]) +
95+
0.7152 * channelToLinear(rgb[1]) +
96+
0.0722 * channelToLinear(rgb[2])
97+
);
98+
}
99+
100+
function contrastRatio(a: RGB, b: RGB): number {
101+
const l1 = luminance(a);
102+
const l2 = luminance(b);
103+
const [hi, lo] = [Math.max(l1, l2), Math.min(l1, l2)];
104+
return (hi + 0.05) / (lo + 0.05);
105+
}
106+
107+
/** Resolve a token value (hex or rgba) to RGB. r,g,b are 0..255 ints. */
108+
function parseColor(value: string): RGB | null {
109+
const trimmed = value.trim();
110+
if (/^#/.test(trimmed)) return hexToRgb(trimmed);
111+
if (/^rgba?\(/.test(trimmed)) return rgbaToRgb(trimmed);
112+
return null;
113+
}
114+
115+
/**
116+
* Composite a translucent foreground over an opaque background, returning the
117+
* resulting opaque RGB. Used to resolve `--surface-glass` tokens.
118+
*/
119+
function composite(fg: RGB, base: RGB, alpha: number): RGB {
120+
return [
121+
fg[0] * alpha + base[0] * (1 - alpha),
122+
fg[1] * alpha + base[1] * (1 - alpha),
123+
fg[2] * alpha + base[2] * (1 - alpha),
124+
];
125+
}
126+
127+
function alphaOf(value: string): number | null {
128+
const m = value.match(/,?\s*([\d.]+)\)\s*$/);
129+
return m ? Number(m[1]) : null;
130+
}
131+
132+
/* ============================================================
133+
Shared fixtures extracted from tokens.css
134+
============================================================ */
135+
136+
const tokensCss = fs.readFileSync(TOKENS_CSS, 'utf-8');
137+
const a11yCss = fs.readFileSync(A11Y_CSS, 'utf-8');
138+
139+
// Scopes define where each color scheme's tokens live in tokens.css.
140+
const COLOR_SCOPES: Record<'dark' | 'light', RegExp> = {
141+
dark: /^:root\s*\{/,
142+
light: /html:not\(\.dark-mode\)\s*\{/,
143+
};
144+
145+
/** The opaque background tokens by name — shared across schemes. */
146+
const BG_TOKEN_NAMES = ['--bg', '--bg-deep', '--surface', '--surface-2'];
147+
148+
/** For each scheme, the RGB background tokens the focus ring must contrast with. */
149+
function backgroundTokens(scheme: 'dark' | 'light', baseForGlass: RGB): { name: string; rgb: RGB }[] {
150+
const block = readBlock(tokensCss, COLOR_SCOPES[scheme]);
151+
const tokens: { name: string; rgb: RGB }[] = [];
152+
153+
for (const name of BG_TOKEN_NAMES) {
154+
const raw = varValue(block, name);
155+
const rgb = raw ? parseColor(raw) : null;
156+
if (rgb) tokens.push({ name, rgb });
157+
}
158+
159+
const glassRaw = varValue(block, '--surface-glass');
160+
const alpha = glassRaw ? alphaOf(glassRaw) : null;
161+
const glassRgb = glassRaw ? rgbaToRgb(glassRaw) : null;
162+
if (glassRaw && alpha !== null && glassRgb) {
163+
tokens.push({ name: '--surface-glass', rgb: composite(glassRgb, baseForGlass, alpha) });
164+
}
165+
166+
return tokens;
167+
}
168+
16169
describe('.visually-hidden utility class', () => {
17170
it('is defined exactly once across the stylesheet tree', () => {
18171
const cssFiles = findCssFiles(SRC_DIR);
@@ -23,3 +176,86 @@ describe('.visually-hidden utility class', () => {
23176
expect(definitions).toEqual([path.resolve(SRC_DIR, 'styles/accessibility.css')]);
24177
});
25178
});
179+
180+
/* ============================================================
181+
Requirement 1 — :focus-visible contrast vs every bg token
182+
============================================================ */
183+
184+
describe(':focus-visible outlines', () => {
185+
const fork = readBlock(a11yCss, /\*:focus-visible/);
186+
187+
it('uses the mode-aware --ring-strong token (with gold fallback)', () => {
188+
expect(fork).toContain('var(--ring-strong, var(--ring, #f59e0b))');
189+
});
190+
191+
test.each(['dark', 'light'] as const)(
192+
'%s scheme: --ring-strong holds >= 3:1 against every background token',
193+
(scheme) => {
194+
const block = readBlock(tokensCss, COLOR_SCOPES[scheme]);
195+
const ring = varValue(block, '--ring-strong');
196+
expect(ring).toBeTruthy();
197+
198+
const ringRgb = parseColor(ring as string);
199+
expect(ringRgb).not.toBeNull();
200+
201+
// --surface-glass renders over the deepest page background.
202+
const baseForGlass = scheme === 'dark' ? (hexToRgb('#070b16') as RGB) : (hexToRgb('#f7f9fc') as RGB);
203+
const bgs = backgroundTokens(scheme, baseForGlass);
204+
expect(bgs.length).toBeGreaterThan(0);
205+
206+
for (const bg of bgs) {
207+
const ratio = contrastRatio(ringRgb as RGB, bg.rgb);
208+
if (ratio < 3) {
209+
throw new Error(
210+
`${scheme} ring ${ring} vs ${bg.name} = ${ratio.toFixed(2)}:1 (< 3:1 minimum)`
211+
);
212+
}
213+
}
214+
}
215+
);
216+
});
217+
218+
/* ============================================================
219+
Requirement 2 — minimum interactive target size (44 x 44)
220+
============================================================ */
221+
222+
describe('interactive target size (WCAG 2.5.5)', () => {
223+
const block = readBlock(a11yCss, /INTERACTIVE TARGET SIZE/);
224+
225+
test('targets buttons, links, and form controls', () => {
226+
expect(block).toContain('button');
227+
expect(block).toContain("a[href]");
228+
expect(block).toContain('input');
229+
expect(block).toContain('select');
230+
expect(block).toContain('textarea');
231+
});
232+
233+
it('enforces at least 44 x 44 CSS px', () => {
234+
expect(block).toMatch(/min-height:\s*44px/);
235+
expect(block).toMatch(/min-width:\s*44px/);
236+
});
237+
});
238+
239+
/* ============================================================
240+
Requirement 3 — prefers-reduced-motion disables animation
241+
============================================================ */
242+
243+
describe('prefers-reduced-motion', () => {
244+
const block = readBlock(a11yCss, /prefers-reduced-motion: reduce/);
245+
246+
it('declares the @media (prefers-reduced-motion: reduce) query', () => {
247+
expect(a11yCss).toContain('@media (prefers-reduced-motion: reduce) {');
248+
});
249+
250+
it('disables non-essential animation globally', () => {
251+
expect(block).toMatch(/animation-\s*duration/);
252+
});
253+
254+
it('explicitly disables spinners, skeleton shimmer, and toast transitions', () => {
255+
expect(block).toContain('.spinner');
256+
expect(block).toContain('.loading-spinner .spinner');
257+
expect(block).toContain('.skeleton');
258+
// Toast / transition-driven UI is covered by the universal transition kill.
259+
expect(block).toMatch(/transition-duration/);
260+
});
261+
});

frontend/src/styles/accessibility.css

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
}
2323
.skip-link:focus {
2424
top: 1rem;
25-
outline: 3px solid var(--ring, #f59e0b);
25+
outline: 3px solid var(--ring-strong, var(--ring, #f59e0b));
2626
outline-offset: 2px;
2727
}
2828

@@ -53,9 +53,13 @@
5353

5454
/* ============================================
5555
FOCUS INDICATORS
56+
57+
--ring-strong is a mode-aware token (see tokens.css) that keeps >= 3:1
58+
non-text contrast against EVERY background token in both light and dark
59+
schemes (WCAG 2.1 AA non-text contrast / visibility of focus).
5660
============================================ */
5761
*:focus-visible {
58-
outline: 3px solid var(--ring, #f59e0b);
62+
outline: 3px solid var(--ring-strong, var(--ring, #f59e0b));
5963
outline-offset: 2px;
6064
border-radius: 4px;
6165
}
@@ -117,14 +121,20 @@ textarea[aria-invalid='true'] {
117121
}
118122

119123
/* ============================================
120-
TOUCH TARGETS (minimum 44x44)
124+
INTERACTIVE TARGET SIZE — WCAG 2.5.5 (44 x 44 CSS px)
125+
126+
Every button, link, and form control gets a hit target of at least
127+
44 x 44px so low-motion / touch users can reliably activate it.
121128
============================================ */
122129
button,
123-
a[role='menuitem'],
124-
input[type='checkbox'],
125-
input[type='radio'],
126-
select {
130+
[role='button'],
131+
a[href],
132+
input,
133+
select,
134+
textarea,
135+
summary {
127136
min-height: 44px;
137+
min-width: 44px;
128138
}
129139

130140
/* ============================================
@@ -158,6 +168,10 @@ html {
158168

159169
/* ============================================
160170
REDUCED MOTION
171+
172+
Disables non-essential animation for users who opt out of motion, covering
173+
the loading spinner (spin), skeleton shimmer, toast in/out transitions, and
174+
any other keyframe/transition. Essential functionality is unaffected.
161175
============================================ */
162176
@media (prefers-reduced-motion: reduce) {
163177
html {
@@ -166,11 +180,17 @@ html {
166180
*,
167181
*::before,
168182
*::after {
183+
animation-name: none !important;
169184
animation-duration: 0.01ms !important;
170185
animation-iteration-count: 1 !important;
171186
transition-duration: 0.01ms !important;
172187
scroll-behavior: auto !important;
173188
}
189+
.spinner,
190+
.loading-spinner .spinner,
191+
.skeleton {
192+
animation: none !important;
193+
}
174194
}
175195

176196
/* ============================================

frontend/src/styles/tokens.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
--destructive: #f87171;
5757
--success: #34d399;
5858
--ring: var(--gold);
59+
/* Focus indicator that holds >= 3:1 contrast against every dark bg token. */
60+
--ring-strong: #fbbf24;
5961

6062
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
6163
--shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.7);
@@ -82,6 +84,8 @@ html.light-mode {
8284
--on-primary: #1a1204;
8385
--destructive: #dc2626;
8486
--success: #059669;
87+
/* Focus indicator that holds >= 3:1 contrast against every light bg token. */
88+
--ring-strong: #1e3a8a;
8589

8690
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.08);
8791
--shadow: 0 24px 48px -24px rgba(15, 23, 42, 0.25);
@@ -106,6 +110,8 @@ html.light-mode {
106110
--on-primary: #1a1204;
107111
--destructive: #dc2626;
108112
--success: #059669;
113+
/* Focus indicator that holds >= 3:1 contrast against every light bg token. */
114+
--ring-strong: #1e3a8a;
109115

110116
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.08);
111117
--shadow: 0 24px 48px -24px rgba(15, 23, 42, 0.25);

0 commit comments

Comments
 (0)