From 3236efa8465eb0c38d7a1639b002e27a8aea41c5 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 26 Aug 2026 21:20:57 +0100 Subject: [PATCH] fix(map): the hero map's fade to the header was hardcoded white MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fade between the map band and the school header ramped through rgba(255,255,255,...) and landed on var(--bg-card). In the light theme that is white into white and invisible, as designed. In the dark theme it climbed to 95% WHITE and then met a near-black card, putting a bright band across the full width exactly where the map should dissolve into the title. Fading to the colour the gradient lands on is the whole trick, and it only works if that colour is a token — so --bg-card-rgb now exists in both theme blocks, matching the --hero-ground-rgb precedent. Two more defects in the same file, same cause, found while in there: 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. These deliberately do NOT follow the theme, because the map tiles are light in both, so the ink is now literal too and says why. A themed token is the wrong tool for a surface that never changes. The loading skeleton swept 50% white across var(--bg-secondary), which is a bright flash every 1.4s on a dark page. It now sweeps toward the card colour, a shade lighter than the ground in both themes. The guard is a stylesheet test rather than a render test, because the bug is invisible in the theme it was written for. Verified by reverting each fix in turn: it names .fade and .openHint exactly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- .../components/darkThemeSafety.test.ts | 78 +++++++++++++++++++ nextjs-app/app/globals.css | 4 + .../components/SchoolHeroMap.module.css | 38 +++++++-- 3 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 nextjs-app/__tests__/components/darkThemeSafety.test.ts 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); } -- 2.54.0