diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index 6c4fbf2..0c8fea2 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -642,7 +642,10 @@ test('the brand asset set is complete and served', async ({ page }) => {
await expect(ogImage).toHaveCount(1);
const ogUrl = await ogImage.getAttribute('content');
expect(ogUrl).toBeTruthy();
- const og = await page.request.get(ogUrl!);
+ // metadataBase pins canonical URLs to the production host, which is correct
+ // for prod but means the absolute URL points off-environment on staging.
+ // Fetch the path against whichever environment we're actually testing.
+ const og = await page.request.get(new URL(ogUrl!).pathname + new URL(ogUrl!).search);
expect(og.ok()).toBe(true);
expect(og.headers()['content-type']).toContain('image/png');
@@ -702,3 +705,115 @@ test('the dark theme actually repaints the page', async ({ browser }) => {
expect(dark.bg).not.toBe(light.bg);
expect(dark.fg).not.toBe(light.fg);
});
+
+/**
+ * Typography and palette integrity.
+ *
+ * The identity PR shipped with every font-family silently falling back to
+ * Times: the font variables landed on
while the tokens referencing
+ * them were declared on :root, so --font-display computed to the
+ * guaranteed-invalid value. Nothing threw, no test failed, and the build was
+ * green — the only symptom was visual. These assertions make that class of
+ * failure loud.
+ */
+test('the brand typefaces actually load and apply', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
+
+ const fonts = await page.evaluate(() => {
+ const root = getComputedStyle(document.documentElement);
+ return {
+ body: getComputedStyle(document.body).fontFamily,
+ heading: getComputedStyle(document.querySelector('h1')!).fontFamily,
+ displayToken: root.getPropertyValue('--font-display').trim(),
+ uiToken: root.getPropertyValue('--font-ui').trim(),
+ proseToken: root.getPropertyValue('--font-prose').trim(),
+ };
+ });
+
+ // An empty token means the var() chain broke, which is the exact failure
+ // mode above — the computed font-family would look plausible either way.
+ expect(fonts.displayToken, '--font-display resolved').not.toBe('');
+ expect(fonts.uiToken, '--font-ui resolved').not.toBe('');
+ expect(fonts.proseToken, '--font-prose resolved').not.toBe('');
+
+ expect(fonts.body).toContain('Schibsted');
+ expect(fonts.heading).toContain('Schibsted');
+ // Times/serif anywhere in the interface means we fell back.
+ expect(fonts.body).not.toMatch(/Times|serif$/);
+});
+
+test('no visible text falls back to the browser default black', async ({ page }) => {
+ // Form controls don't inherit colour from their parent, so a missing
+ // declaration renders pure black — subtle in light mode, invisible in dark.
+ await page.goto('/');
+ await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
+
+ const blacks = await page.evaluate(() =>
+ [...document.querySelectorAll('body *')]
+ .filter((el) => {
+ const r = el.getBoundingClientRect();
+ if (r.width < 2 || r.height < 2) return false;
+ if (el.closest('.leaflet-container')) return false;
+ return getComputedStyle(el).color === 'rgb(0, 0, 0)';
+ })
+ .map((el) => el.tagName.toLowerCase() + '.' + (el.getAttribute('class') || '').split(' ')[0])
+ .slice(0, 10)
+ );
+
+ expect(blacks, `elements rendering pure black: ${blacks.join(', ')}`).toEqual([]);
+});
+
+test('rendered colours all come from the token palette', async ({ page }) => {
+ // Turns the manual design audit into a gate: anything painted with a colour
+ // the token layer doesn't define has escaped the system, and will not
+ // follow the dark theme.
+ await page.goto('/rankings');
+ await expect(page.locator('table, [class*="rankings"]').first()).toBeVisible({ timeout: 15_000 });
+
+ const strays = await page.evaluate(() => {
+ const root = getComputedStyle(document.documentElement);
+ const palette = new Set();
+ for (const sheet of document.styleSheets) {
+ let rules: CSSRuleList;
+ try { rules = sheet.cssRules; } catch { continue; }
+ for (const rule of rules) {
+ const r = rule as CSSStyleRule;
+ if (r.selectorText !== ':root' || !r.style) continue;
+ for (const prop of r.style) {
+ if (!prop.startsWith('--')) continue;
+ const v = root.getPropertyValue(prop).trim();
+ if (v) palette.add(v.toLowerCase());
+ }
+ }
+ }
+ const norm = (c: string) => {
+ const d = document.createElement('div');
+ d.style.color = c;
+ document.body.appendChild(d);
+ const v = getComputedStyle(d).color;
+ d.remove();
+ return v;
+ };
+ const allowed = new Set([...palette].filter((v) => /^#|^rgb/.test(v)).map(norm));
+
+ const found: string[] = [];
+ for (const el of document.querySelectorAll('body *')) {
+ const box = el.getBoundingClientRect();
+ if (box.width < 2 || box.height < 2) continue;
+ if (el.closest('.leaflet-container')) continue; // OSM tiles are imagery
+ const s = getComputedStyle(el);
+ const checks: Array<[string, string]> = [['color', s.color]];
+ if (s.backgroundColor !== 'rgba(0, 0, 0, 0)') checks.push(['background', s.backgroundColor]);
+ for (const [prop, value] of checks) {
+ if (!value || value.startsWith('rgba(') || allowed.has(value)) continue;
+ const cls = (el.getAttribute('class') || '(none)').split(' ')[0];
+ const entry = `${value} as ${prop} on ${cls}`;
+ if (!found.includes(entry)) found.push(entry);
+ }
+ }
+ return found.slice(0, 12);
+ });
+
+ expect(strays, `off-palette colours: ${strays.join('; ')}`).toEqual([]);
+});
diff --git a/nextjs-app/app/globals.css b/nextjs-app/app/globals.css
index 7d4e77f..86bd1dc 100644
--- a/nextjs-app/app/globals.css
+++ b/nextjs-app/app/globals.css
@@ -264,6 +264,18 @@ body {
-webkit-tap-highlight-color: transparent;
}
+/* Form controls don't inherit font or colour from their parent — the UA
+ supplies its own. Without this they render in the system font at pure
+ black, which is nearly invisible against --text-primary in light mode and
+ completely invisible on the dark ground. */
+input,
+select,
+textarea,
+button {
+ font-family: inherit;
+ color: inherit;
+}
+
/* Every digit that could line up in a column does. This is a data product;
proportional numerals in a results table read as amateur. */
table,
@@ -401,6 +413,40 @@ table,
font-size: 0.8125rem;
}
+/*
+ * Leaflet ships its own palette — a #ddd tile backdrop, #333 attribution text
+ * and a #0078A8 link blue that is the most saturated colour anywhere on the
+ * site and belongs to no part of this system. Pull the map chrome onto the
+ * tokens. This matters most in dark mode, where Leaflet's white attribution
+ * bar would otherwise sit on a near-black page.
+ *
+ * The tiles themselves stay as OSM renders them; only the chrome is ours.
+ */
+.leaflet-container {
+ background: var(--bg-secondary);
+ font-family: var(--font-ui);
+}
+
+.leaflet-control-attribution {
+ background: rgba(var(--text-inverse-rgb), 0.82);
+ color: var(--text-muted);
+}
+
+.leaflet-control-attribution a {
+ color: var(--brand);
+}
+
+.leaflet-bar a {
+ background: var(--bg-card);
+ color: var(--text-primary);
+ border-bottom-color: var(--border);
+}
+
+.leaflet-bar a:hover {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+}
+
/* Main content column */
.main {
max-width: 1400px;
diff --git a/nextjs-app/app/layout.tsx b/nextjs-app/app/layout.tsx
index 1d979ec..8cc4cdd 100644
--- a/nextjs-app/app/layout.tsx
+++ b/nextjs-app/app/layout.tsx
@@ -80,7 +80,14 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+ // The font variable classes must sit on , not . globals.css
+ // declares --font-display/--font-ui on :root as var(--font-schibsted),
+ // and a custom property's var() references resolve on the element that
+ // declares it. With the classes on , --font-schibsted was undefined
+ // at :root, so --font-display computed to the guaranteed-invalid value and
+ // every font-family that referenced it silently fell back — the whole site
+ // rendered in Times.
+
@@ -102,7 +109,7 @@ export default function RootLayout({
strategy="afterInteractive"
/>
-
+
Skip to main content