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