fix(design): meet AA on tinted surfaces, and stop the footer inverting in dark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m6s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m6s
Full audit across home, search, rankings, admissions, compare, and primary / secondary / special / no-data school pages, in both themes, measuring computed styles rather than reading CSS. Contrast: the status hues were specced against --bg-primary, but they are used as chip text on their own tint, which sits on cards and secondary surfaces rather than the page ground. Measured in the wild they were 3.85–4.44:1 — under AA — on Ofsted badges, delta chips, report-card chips and metric values, i.e. most of the product's actual signal. Darkened the light hues (#0e6e66 → #0b625a, #9a5b00 → #7f4a00) and lifted the dark teal (#3fb3a4 → #4fc0b0) so each clears AA on its own tint, which is the worst case rather than the easy one. Chart and series ramps follow. The footer was painting itself with a text token and lettering itself with a background token: `background: var(--text-primary); color: var(--bg-secondary)`. That reads correctly in one theme and inverts in the other — in dark mode it became a light slab at the bottom of a dark page, with amber section headings at 1.98:1. Added --surface-sunken and its on-* companions, which stay dark in BOTH themes (deliberately not --surface-inverse, whose whole job is to flip), and moved the footer onto them. Section headings were also using a status hue purely as decoration; they are now a muted on-surface token. The "Open full map" pill was a fixed white background with a themed text colour, so in dark mode it rendered light violet on white at 1.84:1. Both sides are token-driven now. Tabular numerals are now the default for .main rather than per-component opt-in, with prose opting back out — a handful of figures (miniNatPill, compareRowVal, factVal) had been missed by the class-name-based pass. Added a two-theme AA gate over home, rankings and admissions. It waits for `transition: color` to settle first: an earlier measurement pass read mid-transition values and reported seven failures that did not exist at rest. Worth stating plainly — most of what a naive audit flags here is its own artifact, and the check has to account for that to be worth having. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -831,3 +831,68 @@ test('rendered colours all come from the token palette', async ({ page }) => {
|
||||
|
||||
expect(strays, `off-palette colours: ${strays.join('; ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Contrast, in both themes.
|
||||
*
|
||||
* The status hues were originally specced against --bg-primary, but they are
|
||||
* used as chip text on their own tint, which sits on darker surfaces — so the
|
||||
* real ratios were 3.85–4.44:1, under AA, on every school row and result card.
|
||||
* The page ground is the easy case; the tinted chip is the one that fails.
|
||||
*
|
||||
* Waits for transitions to settle before measuring: several components carry
|
||||
* `transition: color`, and reading mid-transition reports colours that were
|
||||
* never on screen at rest.
|
||||
*/
|
||||
const CONTRAST_PROBE = `(() => {
|
||||
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
|
||||
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
|
||||
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
|
||||
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
|
||||
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
|
||||
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
|
||||
const BG = el => { const ls=[]; let n=el;
|
||||
while(n && n!==document.documentElement){ const c=ps(getComputedStyle(n).backgroundColor);
|
||||
if(c && c.a>0){ ls.push(c); if(c.a===1) break; } n=n.parentElement; }
|
||||
const base = ps(getComputedStyle(document.documentElement).backgroundColor)||{r:255,g:255,b:255,a:1};
|
||||
let acc = ls.length && ls[ls.length-1].a===1 ? ls.pop() : base;
|
||||
for(let i=ls.length-1;i>=0;i--) acc=ov(ls[i],acc); return acc; };
|
||||
const out=[], seen=new Set();
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
if (el.closest('.leaflet-container')) continue;
|
||||
const r=el.getBoundingClientRect(), s=getComputedStyle(el);
|
||||
if (r.width<2 || r.height<2 || s.visibility==='hidden' || s.opacity==='0') continue;
|
||||
if (![...el.childNodes].some(n=>n.nodeType===3 && n.textContent.trim().length>1)) continue;
|
||||
const fc=ps(s.color), bc=BG(el); if(!fc||!bc) continue;
|
||||
const fg = fc.a<1?ov(fc,bc):fc, ratio=RT(fg,bc);
|
||||
const px=parseFloat(s.fontSize), bold=parseInt(s.fontWeight,10)>=700;
|
||||
const need=(px>=24||(px>=18.66&&bold))?3:4.5;
|
||||
if (ratio >= need) continue;
|
||||
const key=(el.getAttribute('class')||'')+s.color;
|
||||
if (seen.has(key)) continue; seen.add(key);
|
||||
out.push(((el.getAttribute('class')||'?').split(' ')[0])+' '+ratio.toFixed(2)+':1 (needs '+need+
|
||||
') '+s.color+' on rgb('+Math.round(bc.r)+','+Math.round(bc.g)+','+Math.round(bc.b)+') "'+
|
||||
el.textContent.trim().slice(0,28)+'"');
|
||||
}
|
||||
return out.slice(0, 12);
|
||||
})()`;
|
||||
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`text meets WCAG AA in the ${scheme} theme`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ colorScheme: scheme });
|
||||
const page = await context.newPage();
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const path of ['/', '/rankings', '/admissions']) {
|
||||
await page.goto(path);
|
||||
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 15_000 });
|
||||
// Let `transition: color` settle — the longest in the app is 0.4s.
|
||||
await page.waitForTimeout(700);
|
||||
const found = (await page.evaluate(CONTRAST_PROBE)) as string[];
|
||||
failures.push(...found.map((f) => `${path} → ${f}`));
|
||||
}
|
||||
|
||||
await context.close();
|
||||
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user