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. */ function rules(css: string): Array<{ selector: string; body: string }> { return Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({ selector: m[1].trim().split('\n').pop()!.trim(), 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); 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([]); }); }); /** * 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', '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([]); }); });