fix(map): the hero map's fade to the header was hardcoded white #129
No files matched your search
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,9 @@
|
|||||||
--bg-primary: #FAFAF8; /* Warm White */
|
--bg-primary: #FAFAF8; /* Warm White */
|
||||||
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
||||||
--bg-card: #FFFFFF;
|
--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;
|
--surface-inverse: #0F766E;
|
||||||
|
|
||||||
/* ── Ink ────────────────────────────────────────────────────────── */
|
/* ── Ink ────────────────────────────────────────────────────────── */
|
||||||
@@ -234,6 +237,7 @@
|
|||||||
--bg-primary: #111A20;
|
--bg-primary: #111A20;
|
||||||
--bg-secondary: #16222A;
|
--bg-secondary: #16222A;
|
||||||
--bg-card: #18242C;
|
--bg-card: #18242C;
|
||||||
|
--bg-card-rgb: 24, 36, 44;
|
||||||
--surface-inverse: #E9EEF0;
|
--surface-inverse: #E9EEF0;
|
||||||
|
|
||||||
--text-primary: #E9EEF0;
|
--text-primary: #E9EEF0;
|
||||||
|
|||||||
@@ -47,7 +47,13 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
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%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.4s infinite;
|
animation: shimmer 1.4s infinite;
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,15 @@
|
|||||||
justify-content: center;
|
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 {
|
.openHint {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -85,7 +100,8 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
/* See "Controls that float ON the map" above. */
|
||||||
|
color: #1C2731;
|
||||||
background: rgba(255, 255, 255, .85);
|
background: rgba(255, 255, 255, .85);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
@@ -113,11 +129,18 @@
|
|||||||
on top of the blend. */
|
on top of the blend. */
|
||||||
z-index: 450;
|
z-index: 450;
|
||||||
pointer-events: none;
|
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,
|
background: linear-gradient(to bottom,
|
||||||
rgba(255, 255, 255, 0) 0%,
|
rgba(var(--bg-card-rgb), 0) 0%,
|
||||||
rgba(255, 255, 255, .35) 35%,
|
rgba(var(--bg-card-rgb), .35) 35%,
|
||||||
rgba(255, 255, 255, .75) 62%,
|
rgba(var(--bg-card-rgb), .75) 62%,
|
||||||
rgba(255, 255, 255, .95) 82%,
|
rgba(var(--bg-card-rgb), .95) 82%,
|
||||||
var(--bg-card) 100%);
|
var(--bg-card) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +157,8 @@
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, .92);
|
background: rgba(255, 255, 255, .92);
|
||||||
color: var(--text-primary);
|
/* See "Controls that float ON the map" above. */
|
||||||
|
color: #1C2731;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user