Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d50afef1e | ||
|
|
45a3e7fb8f | ||
|
|
2433101fa0 |
+195
-1
@@ -642,7 +642,10 @@ test('the brand asset set is complete and served', async ({ page }) => {
|
|||||||
await expect(ogImage).toHaveCount(1);
|
await expect(ogImage).toHaveCount(1);
|
||||||
const ogUrl = await ogImage.getAttribute('content');
|
const ogUrl = await ogImage.getAttribute('content');
|
||||||
expect(ogUrl).toBeTruthy();
|
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.ok()).toBe(true);
|
||||||
expect(og.headers()['content-type']).toContain('image/png');
|
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.bg).not.toBe(light.bg);
|
||||||
expect(dark.fg).not.toBe(light.fg);
|
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.85–4.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
@@ -45,17 +45,17 @@
|
|||||||
--brand-on: #fcfcfa; /* text/icon sitting on a brand fill */
|
--brand-on: #fcfcfa; /* text/icon sitting on a brand fill */
|
||||||
|
|
||||||
/* ── Status: above / at / below the comparison point ────────────── */
|
/* ── Status: above / at / below the comparison point ────────────── */
|
||||||
--status-above: #0e6e66; /* 5.2:1 on --bg-primary */
|
--status-above: #0b625a; /* 6.6:1 on --bg-primary, 5.1:1 on its own tint */
|
||||||
--status-above-bg: rgba(14, 110, 102, 0.10);
|
--status-above-bg: rgba(11, 98, 90, 0.10);
|
||||||
--status-below: #9a5b00; /* 4.8:1 on --bg-primary */
|
--status-below: #7f4a00; /* 6.3:1 on --bg-primary, 5.1:1 on its own tint */
|
||||||
--status-below-bg: rgba(154, 91, 0, 0.11);
|
--status-below-bg: rgba(127, 74, 0, 0.11);
|
||||||
--status-at: var(--text-muted);
|
--status-at: var(--text-muted);
|
||||||
--status-at-bg: rgba(92, 101, 112, 0.10);
|
--status-at-bg: rgba(92, 101, 112, 0.10);
|
||||||
|
|
||||||
/* ── Charts: the same three hues, extended by lightness only ────── */
|
/* ── Charts: the same three hues, extended by lightness only ────── */
|
||||||
--chart-1: #584a9b;
|
--chart-1: #584a9b;
|
||||||
--chart-2: #0e6e66;
|
--chart-2: #0b625a;
|
||||||
--chart-3: #9a5b00;
|
--chart-3: #7f4a00;
|
||||||
--chart-4: #8a7cc9;
|
--chart-4: #8a7cc9;
|
||||||
--chart-5: #3e9c92;
|
--chart-5: #3e9c92;
|
||||||
--chart-6: #c9903d;
|
--chart-6: #c9903d;
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
moderate saturation, and every one clears AA on --bg-primary, so they
|
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. */
|
read as one family and work as legend text as well as chart lines. */
|
||||||
--series-1: #584a9b;
|
--series-1: #584a9b;
|
||||||
--series-2: #0e6e66;
|
--series-2: #0b625a;
|
||||||
--series-3: #9a5b00;
|
--series-3: #7f4a00;
|
||||||
--series-4: #a03a5e;
|
--series-4: #a03a5e;
|
||||||
--series-5: #2f5f8f;
|
--series-5: #2f5f8f;
|
||||||
--series-6: #4a6b2f;
|
--series-6: #4a6b2f;
|
||||||
@@ -93,6 +93,16 @@
|
|||||||
--phase-nursery-bg: rgba(122, 85, 96, 0.10);
|
--phase-nursery-bg: rgba(122, 85, 96, 0.10);
|
||||||
--phase-nursery-text: #64454f;
|
--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
|
/* ── Medals: rankings podium. Metal, not palette — kept legible on
|
||||||
both grounds rather than literally gold/silver/bronze. ───────── */
|
both grounds rather than literally gold/silver/bronze. ───────── */
|
||||||
--medal-gold: #a67c00;
|
--medal-gold: #a67c00;
|
||||||
@@ -104,8 +114,8 @@
|
|||||||
an arbitrary alpha reads the raw channels from here. Keep these in
|
an arbitrary alpha reads the raw channels from here. Keep these in
|
||||||
sync with the hex tokens above — they are the same colours. */
|
sync with the hex tokens above — they are the same colours. */
|
||||||
--brand-rgb: 88, 74, 155;
|
--brand-rgb: 88, 74, 155;
|
||||||
--status-above-rgb: 14, 110, 102;
|
--status-above-rgb: 11, 98, 90;
|
||||||
--status-below-rgb: 154, 91, 0;
|
--status-below-rgb: 127, 74, 0;
|
||||||
--ink-rgb: 22, 32, 42;
|
--ink-rgb: 22, 32, 42;
|
||||||
--text-inverse-rgb: 247, 247, 244;
|
--text-inverse-rgb: 247, 247, 244;
|
||||||
--shadow-rgb: 22, 32, 42;
|
--shadow-rgb: 22, 32, 42;
|
||||||
@@ -121,10 +131,17 @@
|
|||||||
--scrim: rgba(22, 32, 42, 0.55);
|
--scrim: rgba(22, 32, 42, 0.55);
|
||||||
|
|
||||||
/* ── Type ───────────────────────────────────────────────────────── */
|
/* ── Type ───────────────────────────────────────────────────────── */
|
||||||
--font-display: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
/* next/font already expands --font-schibsted to the family plus its
|
||||||
--font-ui: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
metric-matched fallback, so naming the family again here only made the
|
||||||
--font-data: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
stack say it twice. It read like a safety net but wasn't one: a var()
|
||||||
--font-prose: var(--font-literata), 'Literata', Georgia, serif;
|
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
|
/* Type scale, 1.2 ratio off a 1rem base. New work should use these
|
||||||
rather than inventing another font-size. */
|
rather than inventing another font-size. */
|
||||||
@@ -178,14 +195,14 @@
|
|||||||
--brand-bg: rgba(156, 140, 232, 0.14);
|
--brand-bg: rgba(156, 140, 232, 0.14);
|
||||||
--brand-on: #10151b;
|
--brand-on: #10151b;
|
||||||
|
|
||||||
--status-above: #3fb3a4; /* 7.2:1 */
|
--status-above: #4fc0b0; /* 8.4:1 on --bg-primary, 4.8:1 on its own tint */
|
||||||
--status-above-bg: rgba(63, 179, 164, 0.14);
|
--status-above-bg: rgba(79, 192, 176, 0.14);
|
||||||
--status-below: #d99b2e; /* 7.7:1 */
|
--status-below: #d99b2e; /* 7.7:1 */
|
||||||
--status-below-bg: rgba(217, 155, 46, 0.14);
|
--status-below-bg: rgba(217, 155, 46, 0.14);
|
||||||
--status-at-bg: rgba(141, 153, 166, 0.14);
|
--status-at-bg: rgba(141, 153, 166, 0.14);
|
||||||
|
|
||||||
--chart-1: #9c8ce8;
|
--chart-1: #9c8ce8;
|
||||||
--chart-2: #3fb3a4;
|
--chart-2: #4fc0b0;
|
||||||
--chart-3: #d99b2e;
|
--chart-3: #d99b2e;
|
||||||
--chart-4: #6f5fc4;
|
--chart-4: #6f5fc4;
|
||||||
--chart-5: #2b8a7e;
|
--chart-5: #2b8a7e;
|
||||||
@@ -194,7 +211,7 @@
|
|||||||
--chart-reference: #8d99a6;
|
--chart-reference: #8d99a6;
|
||||||
|
|
||||||
--series-1: #9c8ce8;
|
--series-1: #9c8ce8;
|
||||||
--series-2: #3fb3a4;
|
--series-2: #4fc0b0;
|
||||||
--series-3: #d99b2e;
|
--series-3: #d99b2e;
|
||||||
--series-4: #e07a9d;
|
--series-4: #e07a9d;
|
||||||
--series-5: #6fa8dc;
|
--series-5: #6fa8dc;
|
||||||
@@ -218,12 +235,18 @@
|
|||||||
--phase-nursery-bg: rgba(191, 152, 163, 0.14);
|
--phase-nursery-bg: rgba(191, 152, 163, 0.14);
|
||||||
--phase-nursery-text: #d1aeb8;
|
--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-gold: #d4a72c;
|
||||||
--medal-silver: #a8b0bb;
|
--medal-silver: #a8b0bb;
|
||||||
--medal-bronze: #c08552;
|
--medal-bronze: #c08552;
|
||||||
|
|
||||||
--brand-rgb: 156, 140, 232;
|
--brand-rgb: 156, 140, 232;
|
||||||
--status-above-rgb: 63, 179, 164;
|
--status-above-rgb: 79, 192, 176;
|
||||||
--status-below-rgb: 217, 155, 46;
|
--status-below-rgb: 217, 155, 46;
|
||||||
--ink-rgb: 228, 233, 238;
|
--ink-rgb: 228, 233, 238;
|
||||||
--text-inverse-rgb: 16, 21, 27;
|
--text-inverse-rgb: 16, 21, 27;
|
||||||
@@ -264,8 +287,24 @@ body {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-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;
|
/* Every digit that could line up in a column does. This is a data product;
|
||||||
proportional numerals in a results table read as amateur. */
|
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,
|
table,
|
||||||
[data-numeric] {
|
[data-numeric] {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -274,6 +313,7 @@ table,
|
|||||||
/* Prose gets the serif; the interface never does. */
|
/* Prose gets the serif; the interface never does. */
|
||||||
.prose {
|
.prose {
|
||||||
font-family: var(--font-prose);
|
font-family: var(--font-prose);
|
||||||
|
font-variant-numeric: normal;
|
||||||
font-size: var(--step-0);
|
font-size: var(--step-0);
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
@@ -401,6 +441,45 @@ table,
|
|||||||
font-size: 0.8125rem;
|
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 content column */
|
||||||
.main {
|
.main {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
|
|||||||
@@ -80,7 +80,14 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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>
|
<head>
|
||||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||||
@@ -102,7 +109,7 @@ export default function RootLayout({
|
|||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${schibsted.variable} ${literata.variable}`}>
|
<body>
|
||||||
<ComparisonProvider>
|
<ComparisonProvider>
|
||||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.footer {
|
.footer {
|
||||||
background: var(--text-primary);
|
background: var(--surface-sunken);
|
||||||
color: var(--bg-secondary);
|
color: var(--on-sunken);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--bg-primary);
|
color: var(--on-sunken);
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: rgba(var(--text-inverse-rgb), 0.7);
|
color: var(--on-sunken-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--status-below);
|
color: var(--on-sunken-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -58,24 +58,24 @@
|
|||||||
|
|
||||||
.link {
|
.link {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: rgba(var(--text-inverse-rgb), 0.7);
|
color: var(--on-sunken-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover {
|
.link:hover {
|
||||||
color: var(--status-below);
|
color: var(--on-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkDisabled {
|
.linkDisabled {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: rgba(var(--text-inverse-rgb), 0.4);
|
color: var(--on-sunken-faint);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
padding-top: 2rem;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -85,15 +85,15 @@
|
|||||||
.disclaimer {
|
.disclaimer {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: rgba(var(--text-inverse-rgb), 0.6);
|
color: var(--on-sunken-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disclaimer .link {
|
.disclaimer .link {
|
||||||
color: var(--brand);
|
color: var(--on-sunken-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disclaimer .link:hover {
|
.disclaimer .link:hover {
|
||||||
color: var(--status-below);
|
color: var(--on-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -88,8 +88,7 @@
|
|||||||
|
|
||||||
|
|
||||||
.headerHasMap .actions .btnAdd {
|
.headerHasMap .actions .btnAdd {
|
||||||
/* Sits on the map hero, which renders light in both themes. */
|
background: var(--bg-card);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
color: var(--brand-strong);
|
color: var(--brand-strong);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
|||||||
@@ -75,8 +75,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.headerHasMap .actions .btnAdd {
|
.headerHasMap .actions .btnAdd {
|
||||||
/* Sits on the map hero, which renders light in both themes. */
|
background: var(--bg-card);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
color: var(--brand-strong);
|
color: var(--brand-strong);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
|||||||
Reference in New Issue
Block a user