From 8d50afef1e8a2b621b7344eadf475b0d609ad7a2 Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 6 Aug 2026 15:38:31 +0100 Subject: [PATCH] fix(design): meet AA on tinted surfaces, and stop the footer inverting in dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/tests/journeys.spec.ts | 65 +++++++++++++++++++ nextjs-app/app/globals.css | 51 ++++++++++----- nextjs-app/components/Footer.module.css | 24 +++---- .../school/SchoolDetailShell.module.css | 3 +- .../school/schoolSections.module.css | 3 +- 5 files changed, 115 insertions(+), 31 deletions(-) diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 467f222..5389bf4 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -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([]); + }); +} diff --git a/nextjs-app/app/globals.css b/nextjs-app/app/globals.css index fc1d7b8..f2be1d1 100644 --- a/nextjs-app/app/globals.css +++ b/nextjs-app/app/globals.css @@ -45,17 +45,17 @@ --brand-on: #fcfcfa; /* text/icon sitting on a brand fill */ /* ── Status: above / at / below the comparison point ────────────── */ - --status-above: #0e6e66; /* 5.2:1 on --bg-primary */ - --status-above-bg: rgba(14, 110, 102, 0.10); - --status-below: #9a5b00; /* 4.8:1 on --bg-primary */ - --status-below-bg: rgba(154, 91, 0, 0.11); + --status-above: #0b625a; /* 6.6:1 on --bg-primary, 5.1:1 on its own tint */ + --status-above-bg: rgba(11, 98, 90, 0.10); + --status-below: #7f4a00; /* 6.3:1 on --bg-primary, 5.1:1 on its own tint */ + --status-below-bg: rgba(127, 74, 0, 0.11); --status-at: var(--text-muted); --status-at-bg: rgba(92, 101, 112, 0.10); /* ── Charts: the same three hues, extended by lightness only ────── */ --chart-1: #584a9b; - --chart-2: #0e6e66; - --chart-3: #9a5b00; + --chart-2: #0b625a; + --chart-3: #7f4a00; --chart-4: #8a7cc9; --chart-5: #3e9c92; --chart-6: #c9903d; @@ -68,8 +68,8 @@ moderate saturation, and every one clears AA on --bg-primary, so they read as one family and work as legend text as well as chart lines. */ --series-1: #584a9b; - --series-2: #0e6e66; - --series-3: #9a5b00; + --series-2: #0b625a; + --series-3: #7f4a00; --series-4: #a03a5e; --series-5: #2f5f8f; --series-6: #4a6b2f; @@ -93,6 +93,16 @@ --phase-nursery-bg: rgba(122, 85, 96, 0.10); --phase-nursery-text: #64454f; + /* ── Sunken surface: the footer, and anything meant to read as a dark + anchor band. Deliberately NOT --surface-inverse: "inverse" flips + with the theme, which turned the footer into a light slab at the + bottom of a dark page. This stays dark in both. ──────────────── */ + --surface-sunken: #16202a; + --on-sunken: #e4e2dc; + --on-sunken-muted: #b3b8bd; + --on-sunken-faint: #7f878f; + --on-sunken-link: #b9aeef; + /* ── Medals: rankings podium. Metal, not palette — kept legible on both grounds rather than literally gold/silver/bronze. ───────── */ --medal-gold: #a67c00; @@ -104,8 +114,8 @@ an arbitrary alpha reads the raw channels from here. Keep these in sync with the hex tokens above — they are the same colours. */ --brand-rgb: 88, 74, 155; - --status-above-rgb: 14, 110, 102; - --status-below-rgb: 154, 91, 0; + --status-above-rgb: 11, 98, 90; + --status-below-rgb: 127, 74, 0; --ink-rgb: 22, 32, 42; --text-inverse-rgb: 247, 247, 244; --shadow-rgb: 22, 32, 42; @@ -185,14 +195,14 @@ --brand-bg: rgba(156, 140, 232, 0.14); --brand-on: #10151b; - --status-above: #3fb3a4; /* 7.2:1 */ - --status-above-bg: rgba(63, 179, 164, 0.14); + --status-above: #4fc0b0; /* 8.4:1 on --bg-primary, 4.8:1 on its own tint */ + --status-above-bg: rgba(79, 192, 176, 0.14); --status-below: #d99b2e; /* 7.7:1 */ --status-below-bg: rgba(217, 155, 46, 0.14); --status-at-bg: rgba(141, 153, 166, 0.14); --chart-1: #9c8ce8; - --chart-2: #3fb3a4; + --chart-2: #4fc0b0; --chart-3: #d99b2e; --chart-4: #6f5fc4; --chart-5: #2b8a7e; @@ -201,7 +211,7 @@ --chart-reference: #8d99a6; --series-1: #9c8ce8; - --series-2: #3fb3a4; + --series-2: #4fc0b0; --series-3: #d99b2e; --series-4: #e07a9d; --series-5: #6fa8dc; @@ -225,12 +235,18 @@ --phase-nursery-bg: rgba(191, 152, 163, 0.14); --phase-nursery-text: #d1aeb8; + --surface-sunken: #0a0e13; + --on-sunken: #d8dde2; + --on-sunken-muted: #9aa4ae; + --on-sunken-faint: #6d777f; + --on-sunken-link: #b0a3ee; + --medal-gold: #d4a72c; --medal-silver: #a8b0bb; --medal-bronze: #c08552; --brand-rgb: 156, 140, 232; - --status-above-rgb: 63, 179, 164; + --status-above-rgb: 79, 192, 176; --status-below-rgb: 217, 155, 46; --ink-rgb: 228, 233, 238; --text-inverse-rgb: 16, 21, 27; @@ -285,6 +301,10 @@ button { /* Every digit that could line up in a column does. This is a data product; proportional numerals in a results table read as amateur. */ +/* The interface is mostly figures, so tabular is the default here rather + than something each component has to remember. Running prose opts back out + below — proportional figures read better in a sentence. */ +.main, table, [data-numeric] { font-variant-numeric: tabular-nums; @@ -293,6 +313,7 @@ table, /* Prose gets the serif; the interface never does. */ .prose { font-family: var(--font-prose); + font-variant-numeric: normal; font-size: var(--step-0); line-height: 1.65; } diff --git a/nextjs-app/components/Footer.module.css b/nextjs-app/components/Footer.module.css index 8380a98..228853f 100644 --- a/nextjs-app/components/Footer.module.css +++ b/nextjs-app/components/Footer.module.css @@ -1,6 +1,6 @@ .footer { - background: var(--text-primary); - color: var(--bg-secondary); + background: var(--surface-sunken); + color: var(--on-sunken); margin-top: auto; } @@ -27,7 +27,7 @@ margin: 0; font-size: 1.25rem; font-weight: 700; - color: var(--bg-primary); + color: var(--on-sunken); font-family: var(--font-display); } @@ -35,14 +35,14 @@ margin: 0; font-size: 0.875rem; line-height: 1.6; - color: rgba(var(--text-inverse-rgb), 0.7); + color: var(--on-sunken-muted); } .sectionTitle { margin: 0; font-size: 0.875rem; font-weight: 600; - color: var(--status-below); + color: var(--on-sunken-muted); text-transform: uppercase; letter-spacing: 0.05em; } @@ -58,24 +58,24 @@ .link { font-size: 0.875rem; - color: rgba(var(--text-inverse-rgb), 0.7); + color: var(--on-sunken-muted); text-decoration: none; transition: color 0.2s ease; } .link:hover { - color: var(--status-below); + color: var(--on-sunken); } .linkDisabled { font-size: 0.875rem; - color: rgba(var(--text-inverse-rgb), 0.4); + color: var(--on-sunken-faint); cursor: not-allowed; } .bottom { padding-top: 2rem; - border-top: 1px solid rgba(var(--text-inverse-rgb), 0.15); + border-top: 1px solid rgba(255, 255, 255, 0.12); display: flex; flex-direction: column; gap: 0.5rem; @@ -85,15 +85,15 @@ .disclaimer { margin: 0; font-size: 0.875rem; - color: rgba(var(--text-inverse-rgb), 0.6); + color: var(--on-sunken-muted); } .disclaimer .link { - color: var(--brand); + color: var(--on-sunken-link); } .disclaimer .link:hover { - color: var(--status-below); + color: var(--on-sunken); } @media (max-width: 768px) { diff --git a/nextjs-app/components/school/SchoolDetailShell.module.css b/nextjs-app/components/school/SchoolDetailShell.module.css index 3286ded..1e0ee6f 100644 --- a/nextjs-app/components/school/SchoolDetailShell.module.css +++ b/nextjs-app/components/school/SchoolDetailShell.module.css @@ -88,8 +88,7 @@ .headerHasMap .actions .btnAdd { - /* Sits on the map hero, which renders light in both themes. */ - background: rgba(255, 255, 255, 0.9); + background: var(--bg-card); color: var(--brand-strong); border-color: transparent; -webkit-backdrop-filter: blur(6px); diff --git a/nextjs-app/components/school/schoolSections.module.css b/nextjs-app/components/school/schoolSections.module.css index d863e09..b43c087 100644 --- a/nextjs-app/components/school/schoolSections.module.css +++ b/nextjs-app/components/school/schoolSections.module.css @@ -75,8 +75,7 @@ } .headerHasMap .actions .btnAdd { - /* Sits on the map hero, which renders light in both themes. */ - background: rgba(255, 255, 255, 0.9); + background: var(--bg-card); color: var(--brand-strong); border-color: transparent; -webkit-backdrop-filter: blur(6px);