import fs from 'fs'; import path from 'path'; /** * Guards against light-theme-only CSS. * * The site themes entirely through tokens redefined under * `@media (prefers-color-scheme: dark)`. A hardcoded colour therefore does not * fail loudly — it renders perfectly in the theme it was written for and * quietly wrongly in the other, which nobody sees unless they happen to be in * dark mode when they look. * * Both rules below are drawn from real defects in SchoolHeroMap.module.css, * found by eye rather than by any test: * * - the map's fade to the header ramped through hardcoded white and landed on * `var(--bg-card)`. Invisible in light; a bright band across the full width * of a near-black card in dark. * - the controls floating over the map paired a hardcoded white background * with `color: var(--text-primary)`, which resolves to #E9EEF0 in dark — * near-white text on a near-white button. */ const COMPONENTS = path.join(__dirname, '..', '..', 'components'); function stylesheets(dir: string): string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); if (entry.isDirectory()) return stylesheets(full); return entry.name.endsWith('.module.css') ? [full] : []; }); } /** Innermost `selector { body }` pairs. Nested at-rules never match as rules, * because their body contains braces. * * Comments are stripped before matching rather than after, so that the whole * selector survives. Taking only its last line — which is what stripping a * leading comment used to require — silently discarded every selector in a * grouped rule but the final one, and a safety guard that cannot see half its * input fails open. */ function rules(css: string): Array<{ selector: string; body: string }> { const bare = css.replace(/\/\*[\s\S]*?\*\//g, ''); return Array.from(bare.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({ selector: m[1].trim().replace(/\s*\n\s*/g, ' '), body: m[2], })); } const HARDCODED_WHITE_BG = /background[^;]*(?:255,\s*255,\s*255|#fff\b|#ffffff\b)/i; const THEMED_COLOR = /(?:^|[^-])color:\s*var\(--/; const files = stylesheets(COMPONENTS); /** Component sources, for the third-party-surface rule below. */ function sources(dir: string): string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); if (entry.isDirectory()) return sources(full); return entry.name.endsWith('.tsx') ? [full] : []; }); } describe('dark-theme safety', () => { it('finds stylesheets to check', () => { expect(files.length).toBeGreaterThan(0); }); it('never pairs a hardcoded white background with a themed text colour', () => { const offenders = files.flatMap((file) => rules(fs.readFileSync(file, 'utf8')) .filter((r) => HARDCODED_WHITE_BG.test(r.body) && THEMED_COLOR.test(r.body)) .map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`)); // Either the surface follows the theme and so should the text, or it does // not and the text must be literal too. Mixing them is how near-white text // ends up on a near-white button. expect(offenders).toEqual([]); }); it('never fades to a themed colour through a hardcoded one', () => { const offenders = files.flatMap((file) => rules(fs.readFileSync(file, 'utf8')) .filter((r) => /linear-gradient/.test(r.body) && /var\(--bg-(card|primary|secondary)\)/.test(r.body) && /255,\s*255,\s*255|#fff\b/i.test(r.body)) .map((r) => `${path.relative(COMPONENTS, file)} ${r.selector}`)); // A gradient that lands on a token has to be made of that token, or the // ramp and its destination disagree in one theme. Use the matching // `--*-rgb` token for the transparent stops. expect(offenders).toEqual([]); }); }); /** * The same defect one stylesheet further out. * * The rules above scan our own CSS modules. They cannot see a surface painted * by a third-party sheet: leaflet.css hardcodes `background: white` on * `.leaflet-popup-content-wrapper` and `.leaflet-popup-tip`, and * LeafletMapInner builds its popup as an HTML string with inline * `color: var(--text-primary)`. Neither half lives in a .module.css, so the * module scan passed while dark mode rendered #E9EEF0 on #FFFFFF — 1.17:1, * with the school name and the headline figure effectively invisible. * * globals.css already pulls the rest of Leaflet's chrome onto the tokens (the * attribution bar, the zoom controls) for exactly this reason. The popup was * simply missed. */ describe('third-party surfaces under themed text', () => { const GLOBALS = path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css'); /** Leaflet surfaces our own code writes token-coloured text onto. */ const LEAFLET_POPUP_SURFACES = [ '.leaflet-popup-content-wrapper', '.leaflet-popup-tip', ]; it('still finds a component painting themed text into a Leaflet popup', () => { // Guards the rule below against passing vacuously if the popups are ever // rewritten as React components rather than HTML strings. const themed = sources(COMPONENTS).filter((file) => { const src = fs.readFileSync(file, 'utf8'); return /bindPopup\(/.test(src) && /color:var\(--|color: var\(--/.test(src); }); expect(themed.length).toBeGreaterThan(0); }); it('themes the Leaflet popup surface, because the text on it is themed', () => { const globals = rules(fs.readFileSync(GLOBALS, 'utf8')); const unthemed = LEAFLET_POPUP_SURFACES.filter((surface) => { const rule = globals.find((r) => r.selector.includes(surface)); return !rule || !/background[^;]*var\(--/.test(rule.body); }); // Leaflet's white is not a colour this site owns. Either the surface // follows the theme or the text on it must be literal — and the text is // already themed. expect(unthemed).toEqual([]); }); it('never puts a literal white label on a themed fill', () => { /* * The mirror image of the module-CSS rule above, and the half of the popup * that theming the card does not reach. "View Details" is * `background:var(--status-above);color:white`; --status-above is #36743F * in light but #7FCB8A in dark, so the label went from 5.63:1 to 1.94:1. * * --text-inverse is the token for ink on a saturated fill — #FFFFFF in * light, #111A20 in dark — and the popup's Ofsted badge already uses it. */ const offenders = sources(COMPONENTS).flatMap((file) => { const src = fs.readFileSync(file, 'utf8'); return Array.from( src.matchAll(/background:\s*var\(--[^;"']*;[^"']*?color:\s*(white|#fff\b|#ffffff\b)/gi), () => path.relative(COMPONENTS, file)); }); expect(offenders).toEqual([]); }); }); /** * Destination measures add the first new colour family since the palette was * set. The tokens have to exist in both blocks or the section renders one * theme's fills on the other theme's ground — the exact failure the suite * above exists to catch, but for tokens rather than literals. */ describe('destination tokens', () => { const css = fs.readFileSync( path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css'), 'utf8'); const TOKENS = [ '--dest-sixthform', '--dest-sfcollege', '--dest-fecollege', '--dest-apprentice', '--dest-employment', '--dest-none', '--dest-none-hatch', ]; const DARK_AT = css.indexOf('@media (prefers-color-scheme: dark)'); it('defines every destination token in the light palette', () => { const light = css.slice(0, DARK_AT); expect(TOKENS.filter((t) => !light.includes(`${t}:`))).toEqual([]); }); it('redefines every destination token for dark', () => { const dark = css.slice(DARK_AT); expect(TOKENS.filter((t) => !dark.includes(`${t}:`))).toEqual([]); }); });