PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 4m4s
# Conflicts: # nextjs-app/__tests__/components/darkThemeSafety.test.ts
193 lines
7.8 KiB
TypeScript
193 lines
7.8 KiB
TypeScript
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', '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', '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([]);
|
|
});
|
|
});
|