Compare commits

...
Author SHA1 Message Date
tudor f31f6f8404 Merge pull request 'fix(design): correct the font assertion (serif$ matched sans-serif) and make the Leaflet overrides win' (#88) from fix/leaflet-specificity-and-font-assertion into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 57s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m8s
Reviewed-on: #88
2026-08-06 14:47:05 +00:00
TudorandClaude Opus 5 8d50afef1e fix(design): meet AA on tinted surfaces, and stop the footer inverting in dark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m6s
Full audit across home, search, rankings, admissions, compare, and primary /
secondary / special / no-data school pages, in both themes, measuring computed
styles rather than reading CSS.

Contrast: the status hues were specced against --bg-primary, but they are used
as chip text on their own tint, which sits on cards and secondary surfaces
rather than the page ground. Measured in the wild they were 3.85–4.44:1 —
under AA — on Ofsted badges, delta chips, report-card chips and metric values,
i.e. most of the product's actual signal. Darkened the light hues
(#0e6e66 → #0b625a, #9a5b00 → #7f4a00) and lifted the dark teal
(#3fb3a4 → #4fc0b0) so each clears AA on its own tint, which is the worst case
rather than the easy one. Chart and series ramps follow.

The footer was painting itself with a text token and lettering itself with a
background token: `background: var(--text-primary); color: var(--bg-secondary)`.
That reads correctly in one theme and inverts in the other — in dark mode it
became a light slab at the bottom of a dark page, with amber section headings
at 1.98:1. Added --surface-sunken and its on-* companions, which stay dark in
BOTH themes (deliberately not --surface-inverse, whose whole job is to flip),
and moved the footer onto them. Section headings were also using a status hue
purely as decoration; they are now a muted on-surface token.

The "Open full map" pill was a fixed white background with a themed text
colour, so in dark mode it rendered light violet on white at 1.84:1. Both
sides are token-driven now.

Tabular numerals are now the default for .main rather than per-component
opt-in, with prose opting back out — a handful of figures (miniNatPill,
compareRowVal, factVal) had been missed by the class-name-based pass.

Added a two-theme AA gate over home, rankings and admissions. It waits for
`transition: color` to settle first: an earlier measurement pass read
mid-transition values and reported seven failures that did not exist at rest.
Worth stating plainly — most of what a naive audit flags here is its own
artifact, and the check has to account for that to be worth having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:38:31 +01:00
TudorandClaude Opus 5 45a3e7fb8f fix(design): correct the font assertion and make the Leaflet overrides win
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m10s
Two follow-ups from watching the staging E2E run and re-auditing the live site.

The typeface gate failed, but the site was right. Staging now serves
"Schibsted Grotesk", "Schibsted Grotesk Fallback", …, -apple-system, sans-serif
— exactly what it should. My assertion was /Times|serif$/, and `serif$` matches
the tail of `sans-serif`, so a healthy stack could never pass. It now checks
the FIRST family in the stack (the only one actually requested) and matches
Times as a whole word. Also asserts running prose resolves to Literata, which
the earlier version never covered.

The Leaflet overrides from the previous fix never took effect. leaflet.css is
imported from a client component, so its chunk loads after globals.css; at
equal specificity (both bare `.leaflet-container`) the later sheet wins.
Verified on the live page: my rule sat at stylesheet index 0, Leaflet's at
index 3, and the attribution link was still #0078A8. Prefixing the overrides
with `html` takes them to 0,1,1, which beats a bare class regardless of load
order — injecting that on the live page turned the background to
rgb(233,232,226) and the link to rgb(88,74,155).

The palette gate would have caught this, except it skipped everything inside
.leaflet-container to avoid flagging OSM tile imagery — which also skipped
Leaflet's own chrome. Narrowed the exemption to .leaflet-tile-pane so the map
controls are now covered.

Also dropped the duplicated family name from the font tokens: next/font
already expands the variable to the family plus its metric-matched fallback,
so the stack was naming Schibsted Grotesk twice. It looked like a safety net
but wasn't — an unresolvable var() with no fallback invalidates the whole
declaration, so the literal after it never gets a turn.

Verified against live staging: body and headings resolve to Schibsted Grotesk,
prose to Literata, the search input is --text-primary rather than pure black,
and the higher-specificity Leaflet rules win when injected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:04:36 +01:00
tudor 91b49f1a59 Merge pull request 'fix(design): resolve the font tokens — staging was rendering entirely in Times' (#87) from fix/font-cascade-and-map-palette into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 58s
Reviewed-on: #87
2026-08-06 12:52:34 +00:00
TudorandClaude Opus 5 2433101fa0 fix(design): resolve the font tokens, and pull form controls and map chrome onto the palette
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m22s
Staging audit of the Cohort identity found the whole site rendering in Times.

Root cause: next/font's variable classes were on <body>, while globals.css
declares --font-display/--font-ui/--font-prose on :root as
`var(--font-schibsted), ...`. A custom property's var() references resolve on
the element that declares it, so at :root --font-schibsted was undefined,
--font-display computed to the guaranteed-invalid value, and every
font-family referencing it fell back. Nothing threw and the build was green —
the only symptom was visual.

Verified the mechanism in a browser both ways round: class on <body> gives an
empty token and a Times body font; class on <html> resolves to Schibsted
Grotesk. The classes now sit on <html>.

Two colour escapes from the same audit:

* Form controls don't inherit font or colour from their parent, so the omni
  search input and the map's "Open full map" button rendered in the system
  font at pure black. Nearly invisible against --text-primary in light mode
  and completely invisible on the dark ground. Added a base inherit rule.
* Leaflet ships its own palette — a #ddd tile backdrop, #333 attribution text
  and a #0078A8 link blue that was the most saturated colour anywhere on the
  site. The map chrome now uses tokens; the tiles stay as OSM renders them.

Three e2e gates added, because the existing suite passed while the site was
entirely in Times:

* the font tokens resolve to a non-empty value and the applied family is
  Schibsted, not a serif fallback
* no visible element renders in the browser's default black
* every rendered colour comes from the token palette — the manual audit,
  turned into a gate

Also made the og:image check environment-relative: metadataBase pins canonical
URLs to the production host, so the absolute URL pointed off-environment when
the suite ran against staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:38:17 +01:00
tudor f43a8ecb69 Merge pull request 'feat(design): adopt the Cohort identity — palette, type, mark and dark theme' (#86) from design/cohort-identity into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m1s
Reviewed-on: #86
2026-08-06 11:36:02 +00:00
6 changed files with 316 additions and 38 deletions
+195 -1
View File
@@ -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,194 @@ 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 <body> 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);
// Only the FIRST family in the stack is the one actually asked for; the
// rest are fallbacks and always end in a generic like sans-serif.
const first = (el: Element) =>
getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim();
const prose = document.querySelector('[class*="editorialText"] p');
return {
body: first(document.body),
heading: first(document.querySelector('h1')!),
prose: prose ? first(prose) : null,
bodyStack: getComputedStyle(document.body).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 this guards — the computed font-family would look plausible either
// way, because an invalid font-family just inherits.
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, 'body uses the UI face').toBe('Schibsted Grotesk');
expect(fonts.heading, 'headings use the display face').toBe('Schibsted Grotesk');
if (fonts.prose) {
expect(fonts.prose, 'running prose uses the serif').toBe('Literata');
}
// The Times fallback is the specific failure that shipped. Match the family
// name only — a stack legitimately ends in sans-serif, so anchoring on
// /serif$/ would flag a perfectly healthy page.
expect(fonts.bodyStack).not.toMatch(/\bTimes\b/);
});
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-tile-pane')) 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<string>();
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-tile-pane')) continue; // OSM tiles are imagery, not palette
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([]);
});
/**
* Contrast, in both themes.
*
* The status hues were originally specced against --bg-primary, but they are
* used as chip text on their own tint, which sits on darker surfaces — so the
* real ratios were 3.854.44:1, under AA, on every school row and result card.
* The page ground is the easy case; the tinted chip is the one that fails.
*
* Waits for transitions to settle before measuring: several components carry
* `transition: color`, and reading mid-transition reports colours that were
* never on screen at rest.
*/
const CONTRAST_PROBE = `(() => {
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
const BG = el => { const ls=[]; let n=el;
while(n && n!==document.documentElement){ const c=ps(getComputedStyle(n).backgroundColor);
if(c && c.a>0){ ls.push(c); if(c.a===1) break; } n=n.parentElement; }
const base = ps(getComputedStyle(document.documentElement).backgroundColor)||{r:255,g:255,b:255,a:1};
let acc = ls.length && ls[ls.length-1].a===1 ? ls.pop() : base;
for(let i=ls.length-1;i>=0;i--) acc=ov(ls[i],acc); return acc; };
const out=[], seen=new Set();
for (const el of document.querySelectorAll('body *')) {
if (el.closest('.leaflet-container')) continue;
const r=el.getBoundingClientRect(), s=getComputedStyle(el);
if (r.width<2 || r.height<2 || s.visibility==='hidden' || s.opacity==='0') continue;
if (![...el.childNodes].some(n=>n.nodeType===3 && n.textContent.trim().length>1)) continue;
const fc=ps(s.color), bc=BG(el); if(!fc||!bc) continue;
const fg = fc.a<1?ov(fc,bc):fc, ratio=RT(fg,bc);
const px=parseFloat(s.fontSize), bold=parseInt(s.fontWeight,10)>=700;
const need=(px>=24||(px>=18.66&&bold))?3:4.5;
if (ratio >= need) continue;
const key=(el.getAttribute('class')||'')+s.color;
if (seen.has(key)) continue; seen.add(key);
out.push(((el.getAttribute('class')||'?').split(' ')[0])+' '+ratio.toFixed(2)+':1 (needs '+need+
') '+s.color+' on rgb('+Math.round(bc.r)+','+Math.round(bc.g)+','+Math.round(bc.b)+') "'+
el.textContent.trim().slice(0,28)+'"');
}
return out.slice(0, 12);
})()`;
for (const scheme of ['light', 'dark'] as const) {
test(`text meets WCAG AA in the ${scheme} theme`, async ({ browser }) => {
const context = await browser.newContext({ colorScheme: scheme });
const page = await context.newPage();
const failures: string[] = [];
for (const path of ['/', '/rankings', '/admissions']) {
await page.goto(path);
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 15_000 });
// Let `transition: color` settle — the longest in the app is 0.4s.
await page.waitForTimeout(700);
const found = (await page.evaluate(CONTRAST_PROBE)) as string[];
failures.push(...found.map((f) => `${path}${f}`));
}
await context.close();
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
});
}
+98 -19
View File
@@ -45,17 +45,17 @@
--brand-on: #fcfcfa; /* text/icon sitting on a brand fill */
/* ── Status: above / at / below the comparison point ────────────── */
--status-above: #0e6e66; /* 5.2:1 on --bg-primary */
--status-above-bg: rgba(14, 110, 102, 0.10);
--status-below: #9a5b00; /* 4.8:1 on --bg-primary */
--status-below-bg: rgba(154, 91, 0, 0.11);
--status-above: #0b625a; /* 6.6:1 on --bg-primary, 5.1:1 on its own tint */
--status-above-bg: rgba(11, 98, 90, 0.10);
--status-below: #7f4a00; /* 6.3:1 on --bg-primary, 5.1:1 on its own tint */
--status-below-bg: rgba(127, 74, 0, 0.11);
--status-at: var(--text-muted);
--status-at-bg: rgba(92, 101, 112, 0.10);
/* ── Charts: the same three hues, extended by lightness only ────── */
--chart-1: #584a9b;
--chart-2: #0e6e66;
--chart-3: #9a5b00;
--chart-2: #0b625a;
--chart-3: #7f4a00;
--chart-4: #8a7cc9;
--chart-5: #3e9c92;
--chart-6: #c9903d;
@@ -68,8 +68,8 @@
moderate saturation, and every one clears AA on --bg-primary, so they
read as one family and work as legend text as well as chart lines. */
--series-1: #584a9b;
--series-2: #0e6e66;
--series-3: #9a5b00;
--series-2: #0b625a;
--series-3: #7f4a00;
--series-4: #a03a5e;
--series-5: #2f5f8f;
--series-6: #4a6b2f;
@@ -93,6 +93,16 @@
--phase-nursery-bg: rgba(122, 85, 96, 0.10);
--phase-nursery-text: #64454f;
/* ── Sunken surface: the footer, and anything meant to read as a dark
anchor band. Deliberately NOT --surface-inverse: "inverse" flips
with the theme, which turned the footer into a light slab at the
bottom of a dark page. This stays dark in both. ──────────────── */
--surface-sunken: #16202a;
--on-sunken: #e4e2dc;
--on-sunken-muted: #b3b8bd;
--on-sunken-faint: #7f878f;
--on-sunken-link: #b9aeef;
/* ── Medals: rankings podium. Metal, not palette — kept legible on
both grounds rather than literally gold/silver/bronze. ───────── */
--medal-gold: #a67c00;
@@ -104,8 +114,8 @@
an arbitrary alpha reads the raw channels from here. Keep these in
sync with the hex tokens above — they are the same colours. */
--brand-rgb: 88, 74, 155;
--status-above-rgb: 14, 110, 102;
--status-below-rgb: 154, 91, 0;
--status-above-rgb: 11, 98, 90;
--status-below-rgb: 127, 74, 0;
--ink-rgb: 22, 32, 42;
--text-inverse-rgb: 247, 247, 244;
--shadow-rgb: 22, 32, 42;
@@ -121,10 +131,17 @@
--scrim: rgba(22, 32, 42, 0.55);
/* ── Type ───────────────────────────────────────────────────────── */
--font-display: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
--font-ui: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
--font-data: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
--font-prose: var(--font-literata), 'Literata', Georgia, serif;
/* next/font already expands --font-schibsted to the family plus its
metric-matched fallback, so naming the family again here only made the
stack say it twice. It read like a safety net but wasn't one: a var()
with no fallback that resolves to nothing invalidates the whole
declaration, so the literal after it never gets a turn. The real
safeguard is that these classes sit on <html>, where :root can see
them — see app/layout.tsx. */
--font-display: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif;
--font-ui: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif;
--font-data: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif;
--font-prose: var(--font-literata), Georgia, serif;
/* Type scale, 1.2 ratio off a 1rem base. New work should use these
rather than inventing another font-size. */
@@ -178,14 +195,14 @@
--brand-bg: rgba(156, 140, 232, 0.14);
--brand-on: #10151b;
--status-above: #3fb3a4; /* 7.2:1 */
--status-above-bg: rgba(63, 179, 164, 0.14);
--status-above: #4fc0b0; /* 8.4:1 on --bg-primary, 4.8:1 on its own tint */
--status-above-bg: rgba(79, 192, 176, 0.14);
--status-below: #d99b2e; /* 7.7:1 */
--status-below-bg: rgba(217, 155, 46, 0.14);
--status-at-bg: rgba(141, 153, 166, 0.14);
--chart-1: #9c8ce8;
--chart-2: #3fb3a4;
--chart-2: #4fc0b0;
--chart-3: #d99b2e;
--chart-4: #6f5fc4;
--chart-5: #2b8a7e;
@@ -194,7 +211,7 @@
--chart-reference: #8d99a6;
--series-1: #9c8ce8;
--series-2: #3fb3a4;
--series-2: #4fc0b0;
--series-3: #d99b2e;
--series-4: #e07a9d;
--series-5: #6fa8dc;
@@ -218,12 +235,18 @@
--phase-nursery-bg: rgba(191, 152, 163, 0.14);
--phase-nursery-text: #d1aeb8;
--surface-sunken: #0a0e13;
--on-sunken: #d8dde2;
--on-sunken-muted: #9aa4ae;
--on-sunken-faint: #6d777f;
--on-sunken-link: #b0a3ee;
--medal-gold: #d4a72c;
--medal-silver: #a8b0bb;
--medal-bronze: #c08552;
--brand-rgb: 156, 140, 232;
--status-above-rgb: 63, 179, 164;
--status-above-rgb: 79, 192, 176;
--status-below-rgb: 217, 155, 46;
--ink-rgb: 228, 233, 238;
--text-inverse-rgb: 16, 21, 27;
@@ -264,8 +287,24 @@ 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. */
/* The interface is mostly figures, so tabular is the default here rather
than something each component has to remember. Running prose opts back out
below — proportional figures read better in a sentence. */
.main,
table,
[data-numeric] {
font-variant-numeric: tabular-nums;
@@ -274,6 +313,7 @@ table,
/* Prose gets the serif; the interface never does. */
.prose {
font-family: var(--font-prose);
font-variant-numeric: normal;
font-size: var(--step-0);
line-height: 1.65;
}
@@ -401,6 +441,45 @@ 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.
*
* Every selector here is prefixed with `html` on purpose. leaflet.css is
* imported from a client component, so its chunk loads AFTER globals.css; at
* equal specificity the later sheet wins and these overrides lose silently.
* The `html` prefix takes them to 0,1,1 so load order stops mattering.
*/
html .leaflet-container {
background: var(--bg-secondary);
font-family: var(--font-ui);
}
html .leaflet-control-attribution {
background: rgba(var(--text-inverse-rgb), 0.82);
color: var(--text-muted);
}
html .leaflet-control-attribution a {
color: var(--brand);
}
html .leaflet-bar a {
background: var(--bg-card);
color: var(--text-primary);
border-bottom-color: var(--border);
}
html .leaflet-bar a:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Main content column */
.main {
max-width: 1400px;
+9 -2
View File
@@ -80,7 +80,14 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
// The font variable classes must sit on <html>, not <body>. 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 <body>, --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.
<html lang="en" className={`${schibsted.variable} ${literata.variable}`}>
<head>
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
<link rel="preconnect" href="https://api.postcodes.io" />
@@ -102,7 +109,7 @@ export default function RootLayout({
strategy="afterInteractive"
/>
</head>
<body className={`${schibsted.variable} ${literata.variable}`}>
<body>
<ComparisonProvider>
<a href="#main-content" className="skip-link">Skip to main content</a>
<Navigation />
+12 -12
View File
@@ -1,6 +1,6 @@
.footer {
background: var(--text-primary);
color: var(--bg-secondary);
background: var(--surface-sunken);
color: var(--on-sunken);
margin-top: auto;
}
@@ -27,7 +27,7 @@
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--bg-primary);
color: var(--on-sunken);
font-family: var(--font-display);
}
@@ -35,14 +35,14 @@
margin: 0;
font-size: 0.875rem;
line-height: 1.6;
color: rgba(var(--text-inverse-rgb), 0.7);
color: var(--on-sunken-muted);
}
.sectionTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--status-below);
color: var(--on-sunken-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -58,24 +58,24 @@
.link {
font-size: 0.875rem;
color: rgba(var(--text-inverse-rgb), 0.7);
color: var(--on-sunken-muted);
text-decoration: none;
transition: color 0.2s ease;
}
.link:hover {
color: var(--status-below);
color: var(--on-sunken);
}
.linkDisabled {
font-size: 0.875rem;
color: rgba(var(--text-inverse-rgb), 0.4);
color: var(--on-sunken-faint);
cursor: not-allowed;
}
.bottom {
padding-top: 2rem;
border-top: 1px solid rgba(var(--text-inverse-rgb), 0.15);
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -85,15 +85,15 @@
.disclaimer {
margin: 0;
font-size: 0.875rem;
color: rgba(var(--text-inverse-rgb), 0.6);
color: var(--on-sunken-muted);
}
.disclaimer .link {
color: var(--brand);
color: var(--on-sunken-link);
}
.disclaimer .link:hover {
color: var(--status-below);
color: var(--on-sunken);
}
@media (max-width: 768px) {
@@ -88,8 +88,7 @@
.headerHasMap .actions .btnAdd {
/* Sits on the map hero, which renders light in both themes. */
background: rgba(255, 255, 255, 0.9);
background: var(--bg-card);
color: var(--brand-strong);
border-color: transparent;
-webkit-backdrop-filter: blur(6px);
@@ -75,8 +75,7 @@
}
.headerHasMap .actions .btnAdd {
/* Sits on the map hero, which renders light in both themes. */
background: rgba(255, 255, 255, 0.9);
background: var(--bg-card);
color: var(--brand-strong);
border-color: transparent;
-webkit-backdrop-filter: blur(6px);