diff --git a/nextjs-app/__tests__/components/darkThemeSafety.test.ts b/nextjs-app/__tests__/components/darkThemeSafety.test.ts new file mode 100644 index 0000000..60445ee --- /dev/null +++ b/nextjs-app/__tests__/components/darkThemeSafety.test.ts @@ -0,0 +1,78 @@ +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([]); + }); +}); diff --git a/nextjs-app/app/globals.css b/nextjs-app/app/globals.css index a83b438..096e4f6 100644 --- a/nextjs-app/app/globals.css +++ b/nextjs-app/app/globals.css @@ -28,6 +28,9 @@ --bg-primary: #FAFAF8; /* Warm White */ --bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */ --bg-card: #FFFFFF; + /* For gradients that have to fade to the card colour. A hardcoded white + ramp reads as a bright band against a dark card. */ + --bg-card-rgb: 255, 255, 255; --surface-inverse: #0F766E; /* ── Ink ────────────────────────────────────────────────────────── */ @@ -234,6 +237,7 @@ --bg-primary: #111A20; --bg-secondary: #16222A; --bg-card: #18242C; + --bg-card-rgb: 24, 36, 44; --surface-inverse: #E9EEF0; --text-primary: #E9EEF0; diff --git a/nextjs-app/components/SchoolHeroMap.module.css b/nextjs-app/components/SchoolHeroMap.module.css index 8904521..e71369c 100644 --- a/nextjs-app/components/SchoolHeroMap.module.css +++ b/nextjs-app/components/SchoolHeroMap.module.css @@ -47,7 +47,13 @@ width: 100%; height: 100%; background: - linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary); + /* Sweeps toward the card colour, which is a shade lighter than this + ground in both themes. Hardcoded white was a bright flash across a + dark page every 1.4s while the tiles loaded. */ + linear-gradient(100deg, + rgba(var(--bg-card-rgb), 0) 40%, + rgba(var(--bg-card-rgb), .5) 50%, + rgba(var(--bg-card-rgb), 0) 60%) var(--bg-secondary); background-size: 200% 100%; animation: shimmer 1.4s infinite; } @@ -76,6 +82,15 @@ justify-content: center; } +/* + * Controls that float ON the map. + * + * The map tiles are light in both themes, so these deliberately do NOT follow + * the theme — they follow the map. The literal ink below is the point: paired + * with a hardcoded white background, `color: var(--text-primary)` resolved to + * #E9EEF0 in the dark theme and put near-white text on a near-white button. + * A themed token is the wrong tool for a surface that never changes. + */ .openHint { display: inline-flex; align-items: center; @@ -85,7 +100,8 @@ border-radius: 999px; font-size: 13px; font-weight: 600; - color: var(--text-primary); + /* See "Controls that float ON the map" above. */ + color: #1C2731; background: rgba(255, 255, 255, .85); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); @@ -113,11 +129,18 @@ on top of the blend. */ z-index: 450; pointer-events: none; + /* The card colour, not white. + This ramp was hardcoded white and ended at var(--bg-card). In the light + theme that is white into white and invisible, as intended. In the dark + theme it climbed to 95% WHITE and then met a near-black card — a bright + band across the full width, right where the map is supposed to dissolve + into the header. Fading to the same colour the gradient lands on is the + whole trick, and it only works if that colour is a token. */ background: linear-gradient(to bottom, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, .35) 35%, - rgba(255, 255, 255, .75) 62%, - rgba(255, 255, 255, .95) 82%, + rgba(var(--bg-card-rgb), 0) 0%, + rgba(var(--bg-card-rgb), .35) 35%, + rgba(var(--bg-card-rgb), .75) 62%, + rgba(var(--bg-card-rgb), .95) 82%, var(--bg-card) 100%); } @@ -134,7 +157,8 @@ border: none; border-radius: 8px; background: rgba(255, 255, 255, .92); - color: var(--text-primary); + /* See "Controls that float ON the map" above. */ + color: #1C2731; cursor: pointer; box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2); }