Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e5c11274 | ||
|
|
accc562b90 | ||
|
|
f31f6f8404 | ||
|
|
8d50afef1e | ||
|
|
45a3e7fb8f | ||
|
|
91b49f1a59 | ||
|
|
f43a8ecb69 |
@@ -722,9 +722,15 @@ test('the brand typefaces actually load and apply', async ({ page }) => {
|
||||
|
||||
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 headings = [...document.querySelectorAll('h1, h2')].map(first);
|
||||
return {
|
||||
body: getComputedStyle(document.body).fontFamily,
|
||||
heading: getComputedStyle(document.querySelector('h1')!).fontFamily,
|
||||
body: first(document.body),
|
||||
headings,
|
||||
bodyStack: getComputedStyle(document.body).fontFamily,
|
||||
displayToken: root.getPropertyValue('--font-display').trim(),
|
||||
uiToken: root.getPropertyValue('--font-ui').trim(),
|
||||
proseToken: root.getPropertyValue('--font-prose').trim(),
|
||||
@@ -732,15 +738,42 @@ test('the brand typefaces actually load and apply', async ({ page }) => {
|
||||
});
|
||||
|
||||
// An empty token means the var() chain broke, which is the exact failure
|
||||
// mode above — the computed font-family would look plausible either way.
|
||||
// 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).toContain('Schibsted');
|
||||
expect(fonts.heading).toContain('Schibsted');
|
||||
// Times/serif anywhere in the interface means we fell back.
|
||||
expect(fonts.body).not.toMatch(/Times|serif$/);
|
||||
// Inter carries body copy, every control and every figure; Manrope carries
|
||||
// headings and key messaging. Both must actually resolve, not merely be
|
||||
// named in a stack that never loads.
|
||||
expect(fonts.body, 'body uses Inter').toBe('Inter');
|
||||
expect(fonts.headings.length, 'the page has headings to check').toBeGreaterThan(0);
|
||||
expect(fonts.headings, 'at least one heading uses Manrope').toContain('Manrope');
|
||||
|
||||
// The Times fallback is the specific failure that shipped once. 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/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The wordmark is the one piece of brand chrome a user reads on every page,
|
||||
* and it is set as two spans so "compare" can take the brand colour. A
|
||||
* refactor that drops the second span, or reverts the lowercase styling,
|
||||
* changes the brand without failing anything else.
|
||||
*/
|
||||
test('the header carries the schoolcompare lockup', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const home = page.getByRole('banner').getByRole('link', { name: /schoolcompare home/i });
|
||||
await expect(home).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Assert on rendered text rather than CSS-module class names, which are
|
||||
// hashed at build time and change on any unrelated edit.
|
||||
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
||||
|
||||
const mark = home.locator('svg').first();
|
||||
await expect(mark).toBeVisible();
|
||||
});
|
||||
|
||||
test('no visible text falls back to the browser default black', async ({ page }) => {
|
||||
@@ -754,7 +787,7 @@ test('no visible text falls back to the browser default black', async ({ page })
|
||||
.filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 2 || r.height < 2) return false;
|
||||
if (el.closest('.leaflet-container')) 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])
|
||||
@@ -801,7 +834,7 @@ test('rendered colours all come from the token palette', async ({ page }) => {
|
||||
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
|
||||
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]);
|
||||
@@ -817,3 +850,68 @@ test('rendered colours all come from the token palette', async ({ page }) => {
|
||||
|
||||
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([]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { BARS } from '@/components/Logo';
|
||||
import { markDataUri } from '@/components/Logo';
|
||||
|
||||
// iOS ignores SVG touch icons — it needs a real raster, on an opaque ground,
|
||||
// with no transparency (it composites its own rounded mask). Previously this
|
||||
// pointed at favicon.svg, so add-to-home-screen produced a blank tile.
|
||||
//
|
||||
// Teal ground with the mark knocked out in white, matching the app icon in the
|
||||
// guideline's "brand in action" row.
|
||||
export const size = { width: 180, height: 180 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
const COHORT = ['rgba(22,32,42,0.22)', 'rgba(22,32,42,0.38)', 'rgba(22,32,42,0.55)', '', 'rgba(22,32,42,0.22)'];
|
||||
const SCALE = 3.6; // 40 → 144, leaving a 18px margin inside 180
|
||||
const TEAL = '#0F766E';
|
||||
|
||||
export default function AppleIcon() {
|
||||
return new ImageResponse(
|
||||
@@ -20,25 +22,11 @@ export default function AppleIcon() {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f2f1ed',
|
||||
background: TEAL,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: 40 * SCALE, height: 40 * SCALE, display: 'flex' }}>
|
||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
||||
<div
|
||||
key={x}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x * SCALE,
|
||||
top: y * SCALE,
|
||||
width: w * SCALE,
|
||||
height: h * SCALE,
|
||||
borderRadius: 3,
|
||||
background: isSchool ? '#584a9b' : COHORT[i],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={markDataUri('#FFFFFF', TEAL)} width={116} height={116} alt="" />
|
||||
</div>
|
||||
),
|
||||
size
|
||||
|
||||
@@ -1,156 +1,201 @@
|
||||
/*
|
||||
* SchoolCompare.co.uk — "Cohort"
|
||||
* schoolcompare — "Find the right school. For their future."
|
||||
*
|
||||
* A school's number means nothing on its own; its position in the national
|
||||
* distribution does. That idea drives the identity: the logo is the same
|
||||
* five-bar spread that appears inside every school row.
|
||||
* Implements the schoolcompare brand guideline. Colour has four jobs and they
|
||||
* never borrow each other's hues:
|
||||
* brand (deep teal) identity, navigation, headings, links, primary buttons
|
||||
* action (coral) the one decisive action on a page — search, submit
|
||||
* status (green / valence against the England average. Never a CTA.
|
||||
* terracotta)
|
||||
* phase (desaturated) category chips, deliberately subordinate.
|
||||
*
|
||||
* Colour has exactly three jobs and they never borrow each other's hues:
|
||||
* brand (iris) interactive + identity. Never carries valence.
|
||||
* status (teal / amber) valence — teal is above/good, amber is
|
||||
* below/needs-attention. Never a CTA.
|
||||
* phase (desaturated) category chips, deliberately subordinate.
|
||||
* Two deviations from the supplied swatches, both forced by contrast, both
|
||||
* confined to text and fills. Coral #F97360 carrying a white label measures
|
||||
* 2.75:1 where 4.5 is the floor, so --action is a darkened #BE3C27; the
|
||||
* original coral survives as --coral for tints, borders and illustration.
|
||||
* Mustard #F2C94C is 1.5:1 as text, so --mustard-ink carries any label while
|
||||
* --mustard stays exact for fills, rings and badges.
|
||||
*
|
||||
* Escalate within status by weight, not by reaching for a new hue: a tinted
|
||||
* amber chip for "requires improvement", a solid amber one for "inadequate".
|
||||
*
|
||||
* Teal/amber rather than green/red so the above/below signal survives every
|
||||
* form of colour blindness — the audience includes parents reading closely.
|
||||
* Every value below clears WCAG AA on all three light grounds — Warm White,
|
||||
* white cards and Sand. Sand is the binding one; several values are a step
|
||||
* darker than they would need to be on white alone.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* ── Ground ─────────────────────────────────────────────────────── */
|
||||
--bg-primary: #f2f1ed; /* paper, not cream — warm enough to read as a document */
|
||||
--bg-secondary: #e9e8e2;
|
||||
--bg-card: #fcfcfa;
|
||||
--surface-inverse: #16202a; /* dark fills: skip link, active nav, tooltips */
|
||||
--bg-primary: #FAFAF8; /* Warm White */
|
||||
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
||||
--bg-card: #FFFFFF;
|
||||
--surface-inverse: #0F766E;
|
||||
|
||||
/* ── Ink ────────────────────────────────────────────────────────── */
|
||||
--text-primary: #16202a;
|
||||
--text-secondary: #4a545f;
|
||||
--text-muted: #5c6570; /* 5.3:1 on --bg-primary */
|
||||
--text-inverse: #f7f7f4;
|
||||
--text-primary: #1C2731; /* Ink */
|
||||
--text-secondary: #4A5560;
|
||||
--text-muted: #5F6A75; /* 4.8:1 on Sand, the worst ground */
|
||||
--text-inverse: #FFFFFF;
|
||||
|
||||
/* ── Line ───────────────────────────────────────────────────────── */
|
||||
--border: #dedcd5;
|
||||
--border-strong: #cdcac1;
|
||||
--border: #E5E7EB; /* Light Grey */
|
||||
--border-strong: #D3D7DD;
|
||||
|
||||
/* ── Brand: iris ────────────────────────────────────────────────── */
|
||||
--brand: #584a9b; /* 6.9:1 on --bg-primary */
|
||||
--brand-strong: #473c80; /* hover on brand fills */
|
||||
--brand-stronger: #3a3069; /* active/pressed */
|
||||
--brand-bg: rgba(88, 74, 155, 0.10);
|
||||
--brand-on: #fcfcfa; /* text/icon sitting on a brand fill */
|
||||
/* ── Brand: deep teal ───────────────────────────────────────────── */
|
||||
--brand: #0F766E; /* exact brand value; 4.8:1 on Sand */
|
||||
--brand-strong: #0C5F58;
|
||||
--brand-stronger: #0A4D47;
|
||||
--brand-bg: rgba(15, 118, 110, 0.10);
|
||||
--brand-on: #FFFFFF;
|
||||
|
||||
/* ── 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-at: var(--text-muted);
|
||||
--status-at-bg: rgba(92, 101, 112, 0.10);
|
||||
/* ── Action: coral. One per screen, and only for the decisive one. ── */
|
||||
--action: #BE3C27; /* darkened coral — 5.4:1 under a white label */
|
||||
--action-strong: #A33320;
|
||||
--action-stronger: #8C2B1B;
|
||||
/* Tint of --action, not of --coral, so a coral ghost button's label and
|
||||
ground share a hue. Unused today; the dark value was already correct. */
|
||||
--action-bg: rgba(190, 60, 39, 0.14);
|
||||
--action-on: #FFFFFF;
|
||||
|
||||
/* ── Charts: the same three hues, extended by lightness only ────── */
|
||||
--chart-1: #584a9b;
|
||||
--chart-2: #0e6e66;
|
||||
--chart-3: #9a5b00;
|
||||
--chart-4: #8a7cc9;
|
||||
--chart-5: #3e9c92;
|
||||
--chart-6: #c9903d;
|
||||
--chart-grid: #dedcd5;
|
||||
--chart-reference: #5c6570; /* national/LA reference lines */
|
||||
/* ── Palette accents: exact brand values, for fills and tints only ── */
|
||||
--coral: #F97360;
|
||||
--mustard: #F2C94C;
|
||||
--mustard-ink: #806200; /* any label that has to sit on or beside mustard */
|
||||
--sage: #A7D7C5;
|
||||
--sky: #C7EBF5;
|
||||
--lavender: #DDD8F5;
|
||||
--sand: #F5EFE6;
|
||||
|
||||
/* ── Status: above / at / below the England average ─────────────── */
|
||||
--status-above: #36743F;
|
||||
--status-above-bg: rgba(54, 116, 63, 0.12);
|
||||
--status-below: #A9481F;
|
||||
/* The tint is the solid hue at low alpha, exactly as every other pair in
|
||||
this file is. It briefly carried the raw --coral tuple instead, which
|
||||
put a coral-pink wash behind terracotta text — the one mismatched pair
|
||||
in the palette. Alpha matches --status-above-bg so the two chips read
|
||||
as equal weight; at 0.15 the darker terracotta tint drops the label to
|
||||
4.45:1 on Warm White. */
|
||||
--status-below-bg: rgba(169, 72, 31, 0.12);
|
||||
--status-at: #5F6A75;
|
||||
--status-at-bg: rgba(95, 106, 117, 0.10);
|
||||
|
||||
/* ── Charts ─────────────────────────────────────────────────────── */
|
||||
--chart-1: #0F766E;
|
||||
--chart-2: #36743F;
|
||||
--chart-3: #806200;
|
||||
--chart-4: #A9481F;
|
||||
--chart-5: #2F6F8F;
|
||||
--chart-6: #5F4FA8;
|
||||
--chart-grid: #E5E7EB;
|
||||
--chart-reference: #5F6A75;
|
||||
|
||||
/* ── Series: up to 8 schools compared at once ───────────────────────
|
||||
Categorical needs distinguishable hues, so this ramp is wider than the
|
||||
three-hue rule — but every step is held to the same mid-dark tone and
|
||||
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-4: #a03a5e;
|
||||
--series-5: #2f5f8f;
|
||||
--series-6: #4a6b2f;
|
||||
--series-7: #7a3f7a;
|
||||
--series-8: #97442a;
|
||||
Categorical, so wider than the four-hue rule — but every step is held
|
||||
to the same mid-dark tone and clears AA on the card, so they read as
|
||||
one family and work as legend text as well as chart lines. */
|
||||
--series-1: #0F766E;
|
||||
--series-2: #36743F;
|
||||
--series-3: #806200;
|
||||
--series-4: #A9481F;
|
||||
--series-5: #2F6F8F;
|
||||
--series-6: #5F4FA8;
|
||||
--series-7: #0E7A86;
|
||||
--series-8: #8A4A6B;
|
||||
|
||||
/* ── Phase: category, desaturated so it stays under the status hues ── */
|
||||
--phase-primary: #4a6072;
|
||||
--phase-primary-bg: rgba(74, 96, 114, 0.10);
|
||||
--phase-primary-text: #3b4e5e;
|
||||
--phase-secondary: #5f5480;
|
||||
--phase-secondary-bg: rgba(95, 84, 128, 0.10);
|
||||
--phase-secondary-text: #4e4570;
|
||||
--phase-all-through: #4b6b57;
|
||||
--phase-all-through-bg: rgba(75, 107, 87, 0.10);
|
||||
--phase-all-through-text: #3d5847;
|
||||
--phase-post16: #7a6242;
|
||||
--phase-post16-bg: rgba(122, 98, 66, 0.10);
|
||||
--phase-post16-text: #645036;
|
||||
--phase-nursery: #7a5560;
|
||||
--phase-nursery-bg: rgba(122, 85, 96, 0.10);
|
||||
--phase-nursery-text: #64454f;
|
||||
--phase-primary: #0F766E;
|
||||
--phase-primary-bg: rgba(167, 215, 197, 0.40);
|
||||
--phase-primary-text: #0C5F58;
|
||||
--phase-secondary: #2F6F8F;
|
||||
--phase-secondary-bg: rgba(199, 235, 245, 0.55);
|
||||
--phase-secondary-text: #2A6180;
|
||||
--phase-all-through: #36743F;
|
||||
--phase-all-through-bg: rgba(54, 116, 63, 0.12);
|
||||
--phase-all-through-text: #2F6738;
|
||||
--phase-post16: #806200;
|
||||
--phase-post16-bg: rgba(242, 201, 76, 0.26);
|
||||
--phase-post16-text: #6E5500;
|
||||
--phase-nursery: #5F4FA8;
|
||||
--phase-nursery-bg: rgba(221, 216, 245, 0.55);
|
||||
--phase-nursery-text: #544396;
|
||||
|
||||
/* ── Medals: rankings podium. Metal, not palette — kept legible on
|
||||
both grounds rather than literally gold/silver/bronze. ───────── */
|
||||
--medal-gold: #a67c00;
|
||||
--medal-silver: #6f7580;
|
||||
--medal-bronze: #8a5a2b;
|
||||
/* ── Sunken surface: the footer, and anything meant to read as a deep
|
||||
teal anchor band. Stays teal in both themes on purpose — it is the
|
||||
"brand in action" card from the guideline. ─────────────────── */
|
||||
--surface-sunken: #0C5F58;
|
||||
--on-sunken: #FFFFFF;
|
||||
--on-sunken-muted: #C9E4DB;
|
||||
--on-sunken-faint: #A9D6CA;
|
||||
--on-sunken-link: #C7EBF5;
|
||||
|
||||
/* ── Medals: rankings podium ────────────────────────────────────── */
|
||||
--medal-gold: #806200;
|
||||
--medal-silver: #6B7580;
|
||||
--medal-bronze: #8A5A2B;
|
||||
|
||||
/* ── Channels ───────────────────────────────────────────────────────
|
||||
CSS can't interpolate a hex token into rgba(), so any tint that needs
|
||||
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;
|
||||
--ink-rgb: 22, 32, 42;
|
||||
--text-inverse-rgb: 247, 247, 244;
|
||||
--shadow-rgb: 22, 32, 42;
|
||||
--muted-rgb: 92, 101, 112;
|
||||
--phase-secondary-rgb: 95, 84, 128;
|
||||
--medal-silver-rgb: 111, 117, 128;
|
||||
--brand-rgb: 15, 118, 110;
|
||||
--action-rgb: 190, 60, 39;
|
||||
--coral-rgb: 249, 115, 96;
|
||||
--sage-rgb: 167, 215, 197;
|
||||
--status-above-rgb: 54, 116, 63;
|
||||
--status-below-rgb: 169, 72, 31;
|
||||
--ink-rgb: 28, 39, 49;
|
||||
--text-inverse-rgb: 255, 255, 255;
|
||||
--shadow-rgb: 28, 39, 49;
|
||||
--muted-rgb: 95, 106, 117;
|
||||
--phase-secondary-rgb: 47, 111, 143;
|
||||
--medal-silver-rgb: 107, 117, 128;
|
||||
--medal-bronze-rgb: 138, 90, 43;
|
||||
|
||||
/* ── Elevation ──────────────────────────────────────────────────── */
|
||||
--shadow-soft: 0 1px 2px rgba(22, 32, 42, 0.04), 0 2px 8px rgba(22, 32, 42, 0.05);
|
||||
--shadow-medium: 0 4px 20px rgba(22, 32, 42, 0.09);
|
||||
--shadow-strong: 0 8px 40px rgba(22, 32, 42, 0.14);
|
||||
--scrim: rgba(22, 32, 42, 0.55);
|
||||
--shadow-soft: 0 1px 2px rgba(28, 39, 49, 0.04), 0 2px 8px rgba(28, 39, 49, 0.05);
|
||||
--shadow-medium: 0 4px 18px rgba(28, 39, 49, 0.09);
|
||||
--shadow-strong: 0 10px 34px rgba(28, 39, 49, 0.13);
|
||||
--scrim: rgba(28, 39, 49, 0.5);
|
||||
|
||||
/* ── 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;
|
||||
/* ── Type ───────────────────────────────────────────────────────────
|
||||
Manrope for headings and key messaging; Inter for body copy, every
|
||||
control and every figure. next/font expands each variable to the
|
||||
family plus its metric-matched fallback, and the classes sit on
|
||||
<html> so :root can see them — see app/layout.tsx. */
|
||||
--font-display: var(--font-manrope), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-ui: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-prose: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
|
||||
/* Type scale, 1.2 ratio off a 1rem base. New work should use these
|
||||
rather than inventing another font-size. */
|
||||
--step--2: 0.694rem;
|
||||
--step--1: 0.833rem;
|
||||
/* Type scale, straight from the guideline: H1 40/48, H2 32/40, H3 24/32,
|
||||
body large 16/24, body 14/20, small 12/16. */
|
||||
--step--2: 0.75rem;
|
||||
--step--1: 0.875rem;
|
||||
--step-0: 1rem;
|
||||
--step-1: 1.2rem;
|
||||
--step-2: 1.44rem;
|
||||
--step-3: 1.728rem;
|
||||
--step-4: 2.074rem;
|
||||
--step-5: 2.488rem;
|
||||
--step-1: 1.125rem;
|
||||
--step-2: 1.5rem;
|
||||
--step-3: 2rem;
|
||||
--step-4: 2.5rem;
|
||||
--step-5: 3rem;
|
||||
|
||||
/* ── Geometry & motion ──────────────────────────────────────────── */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 16px;
|
||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||
rounder than the old system's 3/6/10/16. */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 22px;
|
||||
|
||||
--transition: 0.2s ease;
|
||||
--transition-slow: 0.4s ease;
|
||||
}
|
||||
|
||||
/*
|
||||
* Dark theme. Not an inversion — the accent and both status hues are lifted
|
||||
* independently so each still clears AA on the dark ground, and --brand-on
|
||||
* flips to dark because the brand fill is now the lighter of the pair.
|
||||
* Dark theme. Not in the guideline, which is light-only — but the site already
|
||||
* ships one, so removing it would be a visible regression. Built from the same
|
||||
* tokens: teal and coral are lifted independently so each still clears AA on
|
||||
* the dark ground, and the ground itself keeps the Ink hue rather than going
|
||||
* to black.
|
||||
*
|
||||
* Tokens only. Never style a component from inside this block, or the two
|
||||
* themes drift apart the first time someone edits one of them.
|
||||
@@ -159,84 +204,108 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg-primary: #10151b;
|
||||
--bg-secondary: #161c24;
|
||||
--bg-card: #171d25;
|
||||
--surface-inverse: #e4e9ee;
|
||||
--bg-primary: #111A20;
|
||||
--bg-secondary: #16222A;
|
||||
--bg-card: #18242C;
|
||||
--surface-inverse: #E9EEF0;
|
||||
|
||||
--text-primary: #e4e9ee;
|
||||
--text-secondary: #b4bec8;
|
||||
--text-muted: #8d99a6; /* 6.4:1 on --bg-primary */
|
||||
--text-inverse: #10151b;
|
||||
--text-primary: #E9EEF0;
|
||||
--text-secondary: #B4C2C7;
|
||||
--text-muted: #8B9AA1; /* 5.5:1 on the card */
|
||||
--text-inverse: #111A20;
|
||||
|
||||
--border: #28313b;
|
||||
--border-strong: #38434f;
|
||||
--border: #26343D;
|
||||
--border-strong: #35454F;
|
||||
|
||||
--brand: #9c8ce8; /* 6.5:1 on --bg-primary */
|
||||
--brand-strong: #b0a3ee;
|
||||
--brand-stronger: #c2b8f3;
|
||||
--brand-bg: rgba(156, 140, 232, 0.14);
|
||||
--brand-on: #10151b;
|
||||
--brand: #5FC7BB;
|
||||
--brand-strong: #7BD6CC;
|
||||
--brand-stronger: #9BE3DB;
|
||||
--brand-bg: rgba(95, 199, 187, 0.14);
|
||||
--brand-on: #0A1418;
|
||||
|
||||
--status-above: #3fb3a4; /* 7.2:1 */
|
||||
--status-above-bg: rgba(63, 179, 164, 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);
|
||||
--action: #F08A72;
|
||||
--action-strong: #F5A492;
|
||||
--action-stronger: #F8BFB1;
|
||||
--action-bg: rgba(240, 138, 114, 0.16);
|
||||
--action-on: #241009;
|
||||
|
||||
--chart-1: #9c8ce8;
|
||||
--chart-2: #3fb3a4;
|
||||
--chart-3: #d99b2e;
|
||||
--chart-4: #6f5fc4;
|
||||
--chart-5: #2b8a7e;
|
||||
--chart-6: #a97420;
|
||||
--chart-grid: #28313b;
|
||||
--chart-reference: #8d99a6;
|
||||
--coral: #F08A72;
|
||||
--mustard: #F2C94C;
|
||||
--mustard-ink: #EFC658;
|
||||
--sage: #8FCBB7;
|
||||
--sky: #9CD4E6;
|
||||
--lavender: #C2BBEA;
|
||||
--sand: #1B2730;
|
||||
|
||||
--series-1: #9c8ce8;
|
||||
--series-2: #3fb3a4;
|
||||
--series-3: #d99b2e;
|
||||
--series-4: #e07a9d;
|
||||
--series-5: #6fa8dc;
|
||||
--series-6: #94c46b;
|
||||
--series-7: #c48bc4;
|
||||
--series-8: #e08a6b;
|
||||
--status-above: #7FCB8A;
|
||||
--status-above-bg: rgba(127, 203, 138, 0.14);
|
||||
--status-below: #EFA184;
|
||||
--status-below-bg: rgba(239, 161, 132, 0.14);
|
||||
--status-at: #8B9AA1;
|
||||
--status-at-bg: rgba(139, 154, 161, 0.14);
|
||||
|
||||
--phase-primary: #8aa4b8;
|
||||
--phase-primary-bg: rgba(138, 164, 184, 0.14);
|
||||
--phase-primary-text: #a3bacd;
|
||||
--phase-secondary: #a294c4;
|
||||
--phase-secondary-bg: rgba(162, 148, 196, 0.14);
|
||||
--phase-secondary-text: #b7abd6;
|
||||
--phase-all-through: #8bb098;
|
||||
--phase-all-through-bg: rgba(139, 176, 152, 0.14);
|
||||
--phase-all-through-text: #a2c2ad;
|
||||
--phase-post16: #bfa27c;
|
||||
--phase-post16-bg: rgba(191, 162, 124, 0.14);
|
||||
--phase-post16-text: #d0b795;
|
||||
--phase-nursery: #bf98a3;
|
||||
--phase-nursery-bg: rgba(191, 152, 163, 0.14);
|
||||
--phase-nursery-text: #d1aeb8;
|
||||
--chart-1: #5FC7BB;
|
||||
--chart-2: #7FCB8A;
|
||||
--chart-3: #EFC658;
|
||||
--chart-4: #EFA184;
|
||||
--chart-5: #8FB4D9;
|
||||
--chart-6: #B6A9DD;
|
||||
--chart-grid: #26343D;
|
||||
--chart-reference: #8B9AA1;
|
||||
|
||||
--medal-gold: #d4a72c;
|
||||
--medal-silver: #a8b0bb;
|
||||
--medal-bronze: #c08552;
|
||||
--series-1: #5FC7BB;
|
||||
--series-2: #7FCB8A;
|
||||
--series-3: #EFC658;
|
||||
--series-4: #EFA184;
|
||||
--series-5: #8FB4D9;
|
||||
--series-6: #B6A9DD;
|
||||
--series-7: #6FD0DC;
|
||||
--series-8: #D99BB8;
|
||||
|
||||
--brand-rgb: 156, 140, 232;
|
||||
--status-above-rgb: 63, 179, 164;
|
||||
--status-below-rgb: 217, 155, 46;
|
||||
--ink-rgb: 228, 233, 238;
|
||||
--text-inverse-rgb: 16, 21, 27;
|
||||
--phase-primary: #5FC7BB;
|
||||
--phase-primary-bg: rgba(95, 199, 187, 0.16);
|
||||
--phase-primary-text: #8ADACF;
|
||||
--phase-secondary: #8FB4D9;
|
||||
--phase-secondary-bg: rgba(143, 180, 217, 0.16);
|
||||
--phase-secondary-text: #AAC8E4;
|
||||
--phase-all-through: #7FCB8A;
|
||||
--phase-all-through-bg: rgba(127, 203, 138, 0.16);
|
||||
--phase-all-through-text: #9AD8A3;
|
||||
--phase-post16: #EFC658;
|
||||
--phase-post16-bg: rgba(242, 201, 76, 0.16);
|
||||
--phase-post16-text: #F2D179;
|
||||
--phase-nursery: #B6A9DD;
|
||||
--phase-nursery-bg: rgba(182, 169, 221, 0.16);
|
||||
--phase-nursery-text: #C8BEE6;
|
||||
|
||||
--surface-sunken: #0C5F58;
|
||||
--on-sunken: #FFFFFF;
|
||||
--on-sunken-muted: #C9E4DB;
|
||||
--on-sunken-faint: #A9D6CA;
|
||||
--on-sunken-link: #C7EBF5;
|
||||
|
||||
--medal-gold: #EFC658;
|
||||
--medal-silver: #A9B6BC;
|
||||
--medal-bronze: #C99070;
|
||||
|
||||
--brand-rgb: 95, 199, 187;
|
||||
--action-rgb: 240, 138, 114;
|
||||
--coral-rgb: 240, 138, 114;
|
||||
--sage-rgb: 143, 203, 183;
|
||||
--status-above-rgb: 127, 203, 138;
|
||||
--status-below-rgb: 239, 161, 132;
|
||||
--ink-rgb: 233, 238, 240;
|
||||
--text-inverse-rgb: 17, 26, 32;
|
||||
--shadow-rgb: 0, 0, 0;
|
||||
--muted-rgb: 141, 153, 166;
|
||||
--phase-secondary-rgb: 162, 148, 196;
|
||||
--medal-silver-rgb: 168, 176, 187;
|
||||
--medal-bronze-rgb: 192, 133, 82;
|
||||
--muted-rgb: 139, 154, 161;
|
||||
--phase-secondary-rgb: 143, 180, 217;
|
||||
--medal-silver-rgb: 169, 182, 188;
|
||||
--medal-bronze-rgb: 201, 144, 112;
|
||||
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-medium: 0 4px 20px rgba(0, 0, 0, 0.45);
|
||||
--shadow-strong: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
--scrim: rgba(4, 7, 10, 0.7);
|
||||
--shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
||||
--scrim: rgba(4, 8, 10, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +347,10 @@ button {
|
||||
|
||||
/* 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;
|
||||
@@ -286,6 +359,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;
|
||||
}
|
||||
@@ -374,6 +448,30 @@ table,
|
||||
border-color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/*
|
||||
* Action: coral fill — the one decisive action on a page, and only that.
|
||||
*
|
||||
* The guideline gives coral to "calls to action, highlights, important
|
||||
* buttons" while its own UI-components sheet draws the primary button in
|
||||
* teal. Both are honoured by making this a scarce, separate class: teal is
|
||||
* the default primary, coral marks the single action a page exists for —
|
||||
* the search submit. Use it more than once per screen and it stops meaning
|
||||
* anything.
|
||||
*/
|
||||
.btn-action {
|
||||
background: var(--action);
|
||||
color: var(--action-on);
|
||||
border-color: var(--action);
|
||||
}
|
||||
.btn-action:hover:not(:disabled) {
|
||||
background: var(--action-strong);
|
||||
border-color: var(--action-strong);
|
||||
}
|
||||
.btn-action:active:not(:disabled) {
|
||||
background: var(--action-stronger);
|
||||
border-color: var(--action-stronger);
|
||||
}
|
||||
|
||||
/* Secondary: brand outline — supporting actions (Add to shortlist) */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
@@ -421,28 +519,33 @@ table,
|
||||
* 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.
|
||||
*/
|
||||
.leaflet-container {
|
||||
html .leaflet-container {
|
||||
background: var(--bg-secondary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
html .leaflet-control-attribution {
|
||||
background: rgba(var(--text-inverse-rgb), 0.82);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a {
|
||||
html .leaflet-control-attribution a {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.leaflet-bar a {
|
||||
html .leaflet-bar a {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
|
||||
.leaflet-bar a:hover {
|
||||
html .leaflet-bar a:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="26" width="6" height="12" rx="1" fill="#16202a" fill-opacity="0.22"/>
|
||||
<rect x="10" y="16" width="6" height="22" rx="1" fill="#16202a" fill-opacity="0.38"/>
|
||||
<rect x="18" y="8" width="6" height="30" rx="1" fill="#16202a" fill-opacity="0.55"/>
|
||||
<rect x="26" y="14" width="6" height="24" rx="1" fill="#584a9b"/>
|
||||
<rect x="34" y="28" width="4" height="10" rx="1" fill="#16202a" fill-opacity="0.22"/>
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17 30 C15 35.6 11.6 40.2 7.8 42.8" stroke="#0F766E" stroke-width="8.5" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="26.5" cy="18.5" r="16.5" fill="#0F766E"/>
|
||||
<path d="M26.5 29.5 C26.5 25 26.6 21 27 18" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||
<path d="M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z" fill="#FFFFFF"/>
|
||||
<path d="M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 521 B After Width: | Height: | Size: 594 B |
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Schibsted_Grotesk, Literata } from 'next/font/google';
|
||||
import { Manrope, Inter } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -7,23 +7,22 @@ import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import './globals.css';
|
||||
|
||||
// Schibsted Grotesk carries the whole interface — headings, labels, and every
|
||||
// figure. It was drawn for news brands, so its tabular numerals hold a column
|
||||
// properly, which matters on a page that is mostly percentages.
|
||||
const schibsted = Schibsted_Grotesk({
|
||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||
// modern personality". It never sets body copy or a control.
|
||||
const manrope = Manrope({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-schibsted',
|
||||
weight: ['500', '600', '700'],
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
// Literata is for prose only — the admissions guide, methodology notes, the
|
||||
// editorial sections. It never touches a number or a control.
|
||||
const literata = Literata({
|
||||
// Inter carries body copy, every control and every figure — chosen in the
|
||||
// guideline for clarity and legibility. Its tabular numerals hold a column
|
||||
// properly, which matters on a page that is mostly percentages.
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '600'],
|
||||
style: ['normal', 'italic'],
|
||||
variable: '--font-literata',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
@@ -36,24 +35,24 @@ export const viewport: Viewport = {
|
||||
// These must match --bg-primary in globals.css for each theme, or the
|
||||
// browser chrome and the page disagree at the top edge of the screen.
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#f2f1ed' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#10151b' },
|
||||
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'SchoolCompare',
|
||||
title: 'schoolcompare',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
title: {
|
||||
default: 'SchoolCompare | Compare School Performance',
|
||||
template: '%s | SchoolCompare',
|
||||
default: 'schoolcompare | Compare School Performance',
|
||||
template: '%s | schoolcompare',
|
||||
},
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'SchoolCompare' }],
|
||||
authors: [{ name: 'schoolcompare' }],
|
||||
manifest: '/manifest.json',
|
||||
// No `icons` key on purpose: setting it here would override the file
|
||||
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||
@@ -61,15 +60,15 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://schoolcompare.co.uk'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
title: 'schoolcompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
url: 'https://schoolcompare.co.uk',
|
||||
siteName: 'SchoolCompare',
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
title: 'schoolcompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
},
|
||||
};
|
||||
@@ -81,13 +80,14 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
// 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}`}>
|
||||
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
||||
// var(--font-inter), and a custom property's var() references resolve on
|
||||
// the element that declares it. With the classes on <body>, those two are
|
||||
// undefined at :root, so --font-display computes to the guaranteed-invalid
|
||||
// value and every font-family referencing it silently falls back — which
|
||||
// once shipped the whole site in Times. Nothing throws and no unit test
|
||||
// fails, so the e2e typeface assertion is the only guard.
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { BARS } from '@/components/Logo';
|
||||
import { markDataUri } from '@/components/Logo';
|
||||
|
||||
/**
|
||||
* The site had no og:image at all, so every link pasted into a class WhatsApp
|
||||
* group or a Slack channel rendered as a bare grey text card — on the highest
|
||||
* intent channel this product has.
|
||||
*
|
||||
* The card is the mark at poster scale: the cohort as a wide distribution with
|
||||
* one school lit up. It states the proposition rather than decorating it.
|
||||
* The card is the brand's own proposition on the Sand ground, with the mark
|
||||
* and a suggestion of the illustration's rolling landscape along the bottom.
|
||||
* It states the promise rather than decorating it.
|
||||
*/
|
||||
export const alt = 'SchoolCompare — compare every school in England against the national picture';
|
||||
export const alt = 'schoolcompare — find the school where your child will flourish';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
const PAPER = '#f2f1ed';
|
||||
const INK = '#16202a';
|
||||
const MUTED = '#5c6570';
|
||||
const BRAND = '#584a9b';
|
||||
|
||||
// A plausible national distribution, drawn once at poster scale. The lit bar
|
||||
// is the same position the mark uses.
|
||||
const SPREAD = [8, 12, 17, 24, 33, 44, 57, 70, 82, 92, 98, 100, 96, 88, 77, 65, 53, 41, 31, 22, 15, 10, 7, 5];
|
||||
// Right of the peak: score runs left to right, so the lit school reads as
|
||||
// above the national average. At index 9 it sat on the low side, which quietly
|
||||
// said the opposite.
|
||||
const LIT = 14;
|
||||
const SAND = '#F5EFE6';
|
||||
const INK = '#1C2731';
|
||||
const MUTED = '#5F6A75';
|
||||
const TEAL = '#0F766E';
|
||||
const SAGE = '#A7D7C5';
|
||||
const SAGE_DEEP = '#7FC3AC';
|
||||
const SAGE_PALE = '#D6EDE2';
|
||||
|
||||
async function font(file: string) {
|
||||
return readFile(join(process.cwd(), 'assets', file));
|
||||
@@ -34,8 +30,8 @@ async function font(file: string) {
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const [regular, bold] = await Promise.all([
|
||||
font('SchibstedGrotesk-Regular.ttf'),
|
||||
font('SchibstedGrotesk-Bold.ttf'),
|
||||
font('Manrope-Regular.ttf'),
|
||||
font('Manrope-Bold.ttf'),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
@@ -47,85 +43,62 @@ export default async function OpengraphImage() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
background: PAPER,
|
||||
padding: '58px 72px 52px',
|
||||
fontFamily: 'Schibsted',
|
||||
background: SAND,
|
||||
padding: '58px 72px 0',
|
||||
fontFamily: 'Manrope',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Rolling hills along the bottom edge — the illustration style,
|
||||
reduced to the one gesture that survives at thumbnail size. */}
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
|
||||
<svg width="1200" height="210" viewBox="0 0 1200 210">
|
||||
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
|
||||
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
|
||||
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Lockup */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ position: 'relative', width: 40, height: 40, display: 'flex' }}>
|
||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
||||
<div
|
||||
key={x}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: w,
|
||||
height: h,
|
||||
borderRadius: 1.5,
|
||||
background: isSchool ? BRAND : INK,
|
||||
opacity: isSchool ? 1 : [0.22, 0.38, 0.55, 1, 0.22][i],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 29, fontWeight: 700, letterSpacing: '-0.028em' }}>
|
||||
<span style={{ color: INK }}>School</span>
|
||||
<span style={{ color: BRAND }}>Compare</span>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={markDataUri(TEAL, SAND)} width={52} height={52} alt="" />
|
||||
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
||||
<span style={{ color: INK }}>school</span>
|
||||
<span style={{ color: TEAL }}>compare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proposition. Sized so the headline holds two lines and the subhead
|
||||
one — an orphaned word reads as an accident at this size. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
fontSize: 66,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.036em',
|
||||
lineHeight: 1.03,
|
||||
letterSpacing: '-0.035em',
|
||||
lineHeight: 1.05,
|
||||
color: INK,
|
||||
maxWidth: 1010,
|
||||
maxWidth: 900,
|
||||
}}
|
||||
>
|
||||
A score means nothing until you see the spread.
|
||||
<span style={{ marginRight: 18 }}>Find the school where</span>
|
||||
<span style={{ marginRight: 18 }}>your child will</span>
|
||||
<span style={{ color: TEAL }}>flourish.</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 26, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The device, at poster scale. Same object as the mark above. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 7, height: 120 }}>
|
||||
{SPREAD.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: `${h}%`,
|
||||
borderRadius: 2,
|
||||
background: i === LIT ? BRAND : INK,
|
||||
opacity: i === LIT ? 1 : 0.15,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 18, color: MUTED, letterSpacing: '0.09em' }}>
|
||||
<span>NATIONAL SPREAD</span>
|
||||
<span style={{ letterSpacing: '-0.005em' }}>schoolcompare.co.uk</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{ name: 'Schibsted', data: regular, weight: 400, style: 'normal' },
|
||||
{ name: 'Schibsted', data: bold, weight: 700, style: 'normal' },
|
||||
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
|
||||
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
||||
description,
|
||||
type: 'website',
|
||||
url: `https://schoolcompare.co.uk${canonicalPath}`,
|
||||
siteName: 'SchoolCompare',
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
|
||||
@@ -224,11 +224,11 @@ export function ComparisonView({
|
||||
const url = window.location.href;
|
||||
const count = selectedSchools.length;
|
||||
const shareData = {
|
||||
title: 'School comparison · SchoolCompare',
|
||||
title: 'School comparison · schoolcompare',
|
||||
text:
|
||||
count > 0
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||
: 'SchoolCompare',
|
||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
|
||||
: 'schoolcompare',
|
||||
url,
|
||||
};
|
||||
if (
|
||||
|
||||
@@ -20,7 +20,7 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
||||
and demographics — each in its own table, each with its own jargon.
|
||||
</p>
|
||||
<p>
|
||||
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||
schoolcompare brings it all into one place. Every school page shows performance against the national
|
||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||
full inspection report.
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
/*
|
||||
* FilterBar — one component, two shapes.
|
||||
*
|
||||
* hero a single white pill on the Sand hero panel, carrying the one
|
||||
* coral action the page exists for. No card of its own: the panel
|
||||
* behind it belongs to the page, so the pill reads as the object.
|
||||
* compact the filter rail above the results — a white card of quiet
|
||||
* controls, brand for what is active, sage for what is selected.
|
||||
*
|
||||
* Colour comes from the token layer only; the disclosure arrows are drawn
|
||||
* from currentColor rather than a background image so they follow the theme.
|
||||
*/
|
||||
|
||||
/* ── Shell ────────────────────────────────────────────────────────── */
|
||||
|
||||
.filterBar {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.125rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.filterBar.isLoading {
|
||||
@@ -14,112 +29,16 @@
|
||||
}
|
||||
|
||||
.heroMode {
|
||||
padding: 2.5rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 3rem auto;
|
||||
box-shadow: 0 8px 24px rgba(var(--shadow-rgb), 0.08);
|
||||
border-width: 2px;
|
||||
border-color: var(--brand);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 2.5rem;
|
||||
}
|
||||
|
||||
.heroMode .searchSection {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.searchHint strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.searchHint {
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.375rem;
|
||||
background: var(--brand);
|
||||
color: var(--brand-on);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, transform 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
background: var(--status-above);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
||||
border-top-color: var(--brand-on);
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.geoError {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--brand-strong);
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nearMeBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
/* ── Search ───────────────────────────────────────────────────────── */
|
||||
|
||||
.searchSection {
|
||||
margin-bottom: 0;
|
||||
@@ -127,43 +46,90 @@
|
||||
|
||||
.omniBoxContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* The hero pill: hairline, soft corner, everything else sits inside it. */
|
||||
.heroMode .omniBoxContainer {
|
||||
gap: 0.375rem;
|
||||
padding: 0.3125rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.heroMode .omniBoxContainer:focus-within {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
.omniIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput {
|
||||
flex: 1;
|
||||
padding: 0.875rem 1.25rem;
|
||||
font-size: 1.05rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
padding: 0.6875rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
/* 1rem, not smaller: iOS Safari zooms the viewport on any control under
|
||||
16px, which throws the user out of the hero on first tap. */
|
||||
font-size: var(--step-0);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.omniInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.omniInput:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* Inside the pill the input carries no chrome — the pill holds the border
|
||||
and the focus ring, so the two never double up. */
|
||||
.heroMode .omniInput {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.heroMode .omniInput:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
padding: 0.875rem 2rem;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 8px;
|
||||
min-width: 120px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.6875rem 1.25rem;
|
||||
font-size: var(--step--1);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 1.375rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.3);
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--brand-on);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -171,103 +137,86 @@
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--border);
|
||||
/* ── Hero supporting copy ─────────────────────────────────────────── */
|
||||
|
||||
.searchHint {
|
||||
margin: 0.75rem 0 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
padding: 0.625rem 2.25rem 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-card);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.875rem center;
|
||||
background-size: 10px 6px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.filterSelect:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.12);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.omniBoxContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.radiusWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
.searchHint strong {
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
||||
.nearMeRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Quiet text link, not a second button: the coral submit is the decisive
|
||||
action and nothing beside it may compete for the same weight. */
|
||||
.nearMeBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--brand);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.nearMeBtn:hover:not(:disabled) {
|
||||
color: var(--brand-strong);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.nearMeBtn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nearMeSpinner {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: nearMeSpin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes nearMeSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.geoError {
|
||||
margin: 0;
|
||||
max-width: 340px;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.45;
|
||||
color: var(--brand-strong);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
|
||||
|
||||
.controlsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -289,65 +238,139 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Pill-style inline filter controls (radius + phase) */
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 0.4rem 2rem 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-card);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.65rem center;
|
||||
background-size: 10px 6px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
.radiusLabel {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.controlSelect:hover {
|
||||
border-color: var(--text-muted);
|
||||
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||
|
||||
.selectShell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.selectShell select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Drawn, not imaged: a background-image arrow has to hardcode its own
|
||||
colour, and would stay light-mode grey on the dark ground. */
|
||||
.selectChevron {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-top: -2px;
|
||||
pointer-events: none;
|
||||
color: var(--text-muted);
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid currentColor;
|
||||
}
|
||||
|
||||
.filterSelect,
|
||||
.controlSelect {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
box-shadow var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 0.5625rem 2rem 0.5625rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterSelect:hover:not(:disabled),
|
||||
.controlSelect:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.filterSelect:focus,
|
||||
.controlSelect:focus {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.12);
|
||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ─────────────────────────── */
|
||||
/* Selected — i.e. this control is narrowing the list. Sage tint, brand
|
||||
line: the same pairing the results use for "on". */
|
||||
.selectActive {
|
||||
background-color: rgba(var(--sage-rgb), 0.38);
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selectActive:hover:not(:disabled) {
|
||||
background-color: rgba(var(--sage-rgb), 0.5);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Advanced filters toggle ──────────────────────────────────────── */
|
||||
|
||||
.advancedToggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
transition: border-color var(--transition), background-color var(--transition),
|
||||
color var(--transition);
|
||||
}
|
||||
|
||||
.advancedToggle:hover {
|
||||
border-color: var(--text-muted);
|
||||
border-color: var(--border-strong);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* When filters are applied, promote the toggle to a coral pill so users
|
||||
can see at a glance that the result list is being narrowed. */
|
||||
/* Applied filters read as a brand chip, so it's obvious at a glance that
|
||||
the list below is being narrowed. */
|
||||
.advancedToggleActive {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
@@ -356,11 +379,18 @@
|
||||
}
|
||||
|
||||
.advancedToggleActive:hover {
|
||||
border-color: var(--brand-strong);
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
padding: 0.4375rem 0.875rem;
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
.chevronUp {
|
||||
display: inline-block;
|
||||
@@ -377,3 +407,70 @@
|
||||
.chevronUp {
|
||||
border-bottom: 4.5px solid currentColor;
|
||||
}
|
||||
|
||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filterBar {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
/* The hero pill stays a pill at every width — stacking it would lose the
|
||||
one shape the page is built around. Only the results bar stacks. */
|
||||
.filterBar:not(.heroMode) .omniBoxContainer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filterBar:not(.heroMode) .searchButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.selectShellWide {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.controlsRow {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.controlsRow .advancedToggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.controlSelect {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.searchHint {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nearMeRow {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.geoError {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Below this the pin costs more room than it earns. */
|
||||
.heroMode .omniIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heroMode .omniInput {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.heroMode .searchButton {
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { isValidPostcode } from "@/lib/utils";
|
||||
import { track } from "@/lib/analytics";
|
||||
@@ -18,6 +19,28 @@ interface FilterBarProps {
|
||||
geoError?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a native <select> so the disclosure arrow can be drawn in CSS from
|
||||
* currentColor rather than baked into a background image — a hardcoded arrow
|
||||
* colour is the one thing in a select that can't follow the theme.
|
||||
*/
|
||||
function SelectShell({
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`${styles.selectShell}${wide ? ` ${styles.selectShellWide}` : ""}`}
|
||||
>
|
||||
{children}
|
||||
<span className={styles.selectChevron} aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
filters,
|
||||
isHero,
|
||||
@@ -172,12 +195,33 @@ export function FilterBar({
|
||||
const isSecondaryMode =
|
||||
currentPhase === "secondary" || genderOptions.length > 0;
|
||||
|
||||
// A select that is narrowing the results carries a sage tint; the class is
|
||||
// only ever additive, so the control's behaviour is untouched.
|
||||
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||
>
|
||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||
<div className={styles.omniBoxContainer}>
|
||||
{isHero && (
|
||||
<span className={styles.omniIcon} aria-hidden="true">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z" />
|
||||
<circle cx="12" cy="10" r="2.6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
@@ -188,10 +232,10 @@ export function FilterBar({
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary ${styles.searchButton}`}
|
||||
className={`btn btn-action ${styles.searchButton}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
||||
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{isHero && (
|
||||
@@ -248,34 +292,38 @@ export function FilterBar({
|
||||
{currentPostcode && (
|
||||
<div className={styles.radiusControl}>
|
||||
<label className={styles.radiusLabel}>Within:</label>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentRadius}
|
||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="0.5">0.5 miles</option>
|
||||
<option value="1">1 mile</option>
|
||||
<option value="3">3 miles</option>
|
||||
<option value="5">5 miles</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phaseOptions.length > 0 && (
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={styles.controlSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell>
|
||||
<select
|
||||
value={currentPhase}
|
||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Phases</option>
|
||||
{phaseOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -307,87 +355,97 @@ export function FilterBar({
|
||||
|
||||
{filtersOpen && (
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentLA}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("local_authority", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All Local Authorities</option>
|
||||
{laOptions.map((la) => (
|
||||
<option key={la} value={la}>
|
||||
{la}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentType}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("school_type", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All School Types</option>
|
||||
{typeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{isSecondaryMode && (
|
||||
<>
|
||||
{genderOptions.length > 0 && (
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentGender}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("gender", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentGender)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Boys, Girls & Mixed</option>
|
||||
{genderOptions.map((g) => (
|
||||
<option key={g} value={g.toLowerCase()}>
|
||||
{g}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
value={currentHasSixthForm}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
handleFilterChange("has_sixth_form", e.target.value)
|
||||
}
|
||||
className={styles.filterSelect}
|
||||
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
<option value="">With or without sixth form</option>
|
||||
<option value="yes">With sixth form</option>
|
||||
<option value="no">Without sixth form</option>
|
||||
</select>
|
||||
</SelectShell>
|
||||
|
||||
{admissionsPolicyOptions.length > 0 && (
|
||||
<SelectShell wide>
|
||||
<select
|
||||
value={currentAdmissionsPolicy}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("admissions_policy", e.target.value)
|
||||
}
|
||||
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">All admissions types</option>
|
||||
{admissionsPolicyOptions.map((p) => (
|
||||
<option key={p} value={p.toLowerCase()}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SelectShell>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/*
|
||||
* The footer band.
|
||||
*
|
||||
* --surface-sunken is deep teal in BOTH themes on purpose, so nothing in here
|
||||
* may read a --text-* or --bg-* token: those invert with the theme and would
|
||||
* put dark ink on the teal (or teal on teal) in one of the two. Every
|
||||
* foreground below comes from the --on-sunken-* family, which is pinned to the
|
||||
* band rather than to the page.
|
||||
*/
|
||||
|
||||
.footer {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-secondary);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--on-sunken);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@@ -12,7 +22,7 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr 1fr;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -20,31 +30,76 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
/* ─── Brand lockup ──────────────────────────────────────────────── */
|
||||
|
||||
.lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.lockupMark {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.lockupMark svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Same wordmark as the header, inverted: "school" in the band's foreground,
|
||||
"compare" in sage — the one accent that reads cleanly on deep teal. */
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.wordmarkAccent {
|
||||
color: var(--sage);
|
||||
}
|
||||
|
||||
/* Key messaging, so this is the one place in the footer that takes Manrope
|
||||
for a full sentence. */
|
||||
.tagline {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
margin: -0.5rem 0 0;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.6;
|
||||
color: rgba(var(--text-inverse-rgb), 0.7);
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--status-below);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--sage);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.links {
|
||||
@@ -57,43 +112,64 @@
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(var(--text-inverse-rgb), 0.7);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--status-below);
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
.linkDisabled {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(var(--text-inverse-rgb), 0.4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--on-sunken-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── Bottom rule ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* The divider is drawn as a pseudo-element at low opacity rather than as an
|
||||
* rgba() border: there is no channel token for --on-sunken, and every literal
|
||||
* that would work here (rgba(255,255,255,…)) is exactly the kind of hardcoded
|
||||
* colour the token layer exists to remove.
|
||||
*/
|
||||
.bottom {
|
||||
position: relative;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(var(--text-inverse-rgb), 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bottom::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: var(--on-sunken);
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.copyright,
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(var(--text-inverse-rgb), 0.6);
|
||||
font-size: var(--step--1);
|
||||
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) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Footer Component
|
||||
* Site footer with links and info
|
||||
*
|
||||
* The footer is the guideline's "brand in action" band: a deep teal anchor
|
||||
* that stays teal in both themes. Everything inside it therefore reads from
|
||||
* the --on-sunken-* family rather than the page's --text-* tokens, which
|
||||
* invert and would go dark against the teal in one theme or the other.
|
||||
*/
|
||||
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
@@ -13,7 +18,20 @@ export function Footer() {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.title}>SchoolCompare</h3>
|
||||
{/*
|
||||
On the sunken band the mark inverts: the pin takes the band's
|
||||
foreground and the leaf is knocked out in the band itself. The
|
||||
header's defaults (brand pin, card leaf) would disappear here.
|
||||
*/}
|
||||
<h3 className={styles.lockup}>
|
||||
<span className={styles.lockupMark}>
|
||||
<LogoMark pin="var(--on-sunken)" leaf="var(--surface-sunken)" />
|
||||
</span>
|
||||
<span className={styles.wordmark}>
|
||||
school<span className={styles.wordmarkAccent}>compare</span>
|
||||
</span>
|
||||
</h3>
|
||||
<p className={styles.tagline}>Find the right school. For their future.</p>
|
||||
<p className={styles.description}>
|
||||
Compare primary and secondary schools across England.
|
||||
</p>
|
||||
@@ -69,7 +87,7 @@ export function Footer() {
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<p className={styles.copyright}>
|
||||
© {currentYear} SchoolCompare.co.uk
|
||||
© {currentYear} schoolcompare.co.uk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,68 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroSection {
|
||||
text-align: center;
|
||||
/* ── Hero ──────────────────────────────────────────────────────────────────
|
||||
A Sand panel: proposition and search on the left, the brand landscape
|
||||
bleeding to the panel's right edge. The illustration is decorative, so on
|
||||
phones it drops to a short band under the content rather than competing
|
||||
with the search for the fold. */
|
||||
.heroPanel {
|
||||
display: grid;
|
||||
grid-template-columns: 1.12fr 0.88fr;
|
||||
align-items: stretch;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-top: 2.5rem;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
align-self: center;
|
||||
padding: 3rem 1.5rem 3rem 3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heroArt {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
/* The scene needs height to read; without a floor it collapses to the
|
||||
content column's height on short viewports. */
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.heroArt svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.heroTrust {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.4rem;
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.heroTrustDots {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.heroTrustDot {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
margin-right: -0.55rem;
|
||||
}
|
||||
.heroTrustDot:nth-child(1) { background: var(--sage); }
|
||||
.heroTrustDot:nth-child(2) { background: var(--mustard); }
|
||||
.heroTrustDot:nth-child(3) { background: var(--sky); }
|
||||
|
||||
.heroEyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -32,78 +88,175 @@
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
font-size: 3rem;
|
||||
font-size: var(--step-4);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.85rem;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.03em;
|
||||
font-family: var(--font-display);
|
||||
max-width: 840px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 18ch;
|
||||
}
|
||||
|
||||
/* The em is a colour change, not an emphasis change — Manrope has no italic
|
||||
in the loaded weights, so leaving font-style alone would synthesise a slant. */
|
||||
.heroEmph {
|
||||
color: var(--brand-strong);
|
||||
font-style: italic;
|
||||
color: var(--brand);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.heroDescription {
|
||||
font-size: 1.05rem;
|
||||
font-size: var(--step-1);
|
||||
color: var(--text-secondary);
|
||||
margin: 0 auto 0.5rem;
|
||||
max-width: 680px;
|
||||
margin: 0 0 1.5rem;
|
||||
max-width: 46ch;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.heroDescription strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Copy variants: full paragraph ≥641px, compact merged line on phones. */
|
||||
.heroDescriptionCompact {
|
||||
display: none;
|
||||
@media (max-width: 1024px) {
|
||||
.heroContent {
|
||||
padding: 2.25rem 1.5rem 2.25rem 2rem;
|
||||
}
|
||||
.heroArt {
|
||||
min-height: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.heroSection {
|
||||
padding-top: 1.5rem;
|
||||
/* One column: content first, then a short band of landscape. */
|
||||
@media (max-width: 860px) {
|
||||
.heroPanel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.heroContent {
|
||||
padding: 2rem 1.5rem 1.75rem;
|
||||
}
|
||||
.heroArt {
|
||||
min-height: 11rem;
|
||||
order: 2;
|
||||
}
|
||||
.heroTitle {
|
||||
font-size: 2rem;
|
||||
font-size: var(--step-3);
|
||||
max-width: none;
|
||||
}
|
||||
.heroDescription {
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--step-0);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and swap
|
||||
the long descriptive paragraph for one compact line that carries coverage
|
||||
("24,000+ schools") and freshness ("updated for 2026/27") — a first-time
|
||||
visitor otherwise sees only a poetic h1 and a bare box, with no reason to
|
||||
trust the data (audit P1.7, feeds the 46% home exit rate). */
|
||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
||||
long coverage sentence, leaving the proposition and the search — a
|
||||
first-time visitor still gets the trust line beneath the box, which carries
|
||||
the data provenance the coverage sentence used to (audit P1.7, feeds the
|
||||
46% home exit rate). */
|
||||
@media (max-width: 640px) {
|
||||
.heroSection {
|
||||
padding-top: 0.75rem;
|
||||
.heroPanel {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.heroContent {
|
||||
padding: 1.25rem 1.1rem 1.5rem;
|
||||
}
|
||||
.heroEyebrow,
|
||||
.heroDescriptionFull {
|
||||
display: none;
|
||||
}
|
||||
.heroDescriptionCompact {
|
||||
display: block;
|
||||
}
|
||||
.heroDescription {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
max-width: 320px;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.heroTitle {
|
||||
font-size: 1.65rem;
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.heroTrust {
|
||||
margin-top: 1rem;
|
||||
font-size: var(--step--2);
|
||||
}
|
||||
.heroArt {
|
||||
min-height: 8.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Value props ───────────────────────────────────────────────────────────
|
||||
Four reasons, straight from the guideline. The fourth is ours rather than
|
||||
theirs — see the comment on VALUE_PROPS in HomeView.tsx. */
|
||||
.valueProps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.25rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.valueProp {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.propIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 2.6rem;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.propIconTrust {
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand);
|
||||
}
|
||||
.propIconCompare {
|
||||
background: rgba(var(--sage-rgb), 0.42);
|
||||
color: var(--status-above);
|
||||
}
|
||||
.propIconContext {
|
||||
background: rgba(242, 201, 76, 0.26);
|
||||
color: var(--mustard-ink);
|
||||
}
|
||||
.propIconDeadline {
|
||||
background: rgba(221, 216, 245, 0.6);
|
||||
color: var(--phase-nursery);
|
||||
}
|
||||
|
||||
.propTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-0);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.propBody {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.valueProps {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.valueProps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
|
||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { HeroIllustration } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
@@ -63,6 +64,96 @@ const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||
];
|
||||
|
||||
/* ── Value-prop icons ──────────────────────────────────────────────────────
|
||||
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
|
||||
local because they exist for this one row and nothing else. */
|
||||
|
||||
const ICON_PROPS = {
|
||||
viewBox: '0 0 24 24',
|
||||
width: 21,
|
||||
height: 21,
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': true,
|
||||
} as const;
|
||||
|
||||
function ShieldCheckIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg {...ICON_PROPS}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueProp {
|
||||
icon: React.ReactNode;
|
||||
tintClass: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||
* favourites). That feature does not exist in this product, so it is
|
||||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||
* and is backed by the countdown strip further down this page.
|
||||
*/
|
||||
const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
icon: <ShieldCheckIcon />,
|
||||
tintClass: styles.propIconTrust,
|
||||
title: 'Official & trusted',
|
||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||
},
|
||||
{
|
||||
icon: <BarsIcon />,
|
||||
tintClass: styles.propIconCompare,
|
||||
title: 'Easy to compare',
|
||||
body: 'Up to three schools side by side, on the measures that matter.',
|
||||
},
|
||||
{
|
||||
icon: <HeartIcon />,
|
||||
tintClass: styles.propIconContext,
|
||||
title: 'Beyond the numbers',
|
||||
body: 'Class sizes, SEN support and local context, not just results.',
|
||||
},
|
||||
{
|
||||
icon: <CalendarIcon />,
|
||||
tintClass: styles.propIconDeadline,
|
||||
title: 'Never miss a deadline',
|
||||
body: 'Application and offer dates for both primary and secondary.',
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -256,38 +347,81 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
|
||||
return (
|
||||
<div className={styles.homeView}>
|
||||
{/* Combined Hero + Search and Filters */}
|
||||
{!isSearchActive && (
|
||||
<div className={styles.heroSection}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
||||
freshness, standing in for the hidden eyebrow too) on phones,
|
||||
where every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||
the brand landscape bleeding to the panel edge on the right. The
|
||||
search lives inside the panel here and above the results elsewhere,
|
||||
which is why FilterBar is rendered in two places rather than moved. */}
|
||||
{!isSearchActive ? (
|
||||
<section className={styles.heroPanel}>
|
||||
<div className={styles.heroContent}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||
Updated for the 2026/27 admissions round
|
||||
</span>
|
||||
<span className={styles.heroDescriptionCompact}>
|
||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<h1 className={styles.heroTitle}>
|
||||
Find the school where your child will{' '}
|
||||
<em className={styles.heroEmph}>flourish.</em>
|
||||
</h1>
|
||||
<p className={styles.heroDescription}>
|
||||
Compare every school in England with clarity and confidence.
|
||||
{/* The coverage sentence is the detail a first-time visitor needs
|
||||
but a returning one does not, so it drops away on phones where
|
||||
every line above the fold costs. */}
|
||||
<span className={styles.heroDescriptionFull}>
|
||||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||
and admissions data for <strong>24,000+ schools</strong> — side by
|
||||
side, in one place.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
|
||||
<p className={styles.heroTrust}>
|
||||
<span className={styles.heroTrustDots} aria-hidden="true">
|
||||
<span className={styles.heroTrustDot} />
|
||||
<span className={styles.heroTrustDot} />
|
||||
<span className={styles.heroTrustDot} />
|
||||
</span>
|
||||
Built on official DfE and Ofsted data — 24,000+ schools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroArt}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={false}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
isHero={!isSearchActive}
|
||||
resultFilters={initialSchools.result_filters}
|
||||
onNearMe={handleNearMe}
|
||||
geoState={geoState}
|
||||
geoError={geoError}
|
||||
/>
|
||||
{/* Why this site, in four lines. Only on the landing page. */}
|
||||
{!isSearchActive && (
|
||||
<ul className={styles.valueProps}>
|
||||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||
<li key={title} className={styles.valueProp}>
|
||||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||
<div>
|
||||
<h2 className={styles.propTitle}>{title}</h2>
|
||||
<p className={styles.propBody}>{body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Admissions countdown strip — only on landing page */}
|
||||
{!isSearchActive && (
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Brand illustration — the landing hero's landscape.
|
||||
*
|
||||
* Guideline rules for illustration: no people or faces, soft shapes, rounded
|
||||
* corners, minimal detail, maximum clarity. The scene reads as "the journey to
|
||||
* a school" — a winding path up through layered hills to a small schoolhouse,
|
||||
* with a pin marking where you are.
|
||||
*
|
||||
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so
|
||||
* it renders in the RSC pass and never reaches the client bundle.
|
||||
*/
|
||||
|
||||
/*
|
||||
* These hex values mirror the brand palette in app/globals.css — --sky,
|
||||
* --sage, --brand, --coral, --sand and the tints/shades the scene needs
|
||||
* around them. They are written literally on purpose: an illustration is
|
||||
* artwork, not themed UI. Recolouring it per theme would break the picture,
|
||||
* so it keeps one fixed, light palette in both themes (it sits on a Sand
|
||||
* panel in light mode and reads as a framed image in dark mode).
|
||||
*/
|
||||
const SKY_HIGH = '#DAEDF8';
|
||||
const SKY_LOW = '#EFF8FB';
|
||||
const CLOUD = '#FFFFFF';
|
||||
|
||||
const HILL_FAR = '#D6EDE2'; // --sage, lightened
|
||||
const HILL_MID = '#A7D7C5'; // --sage, exact
|
||||
const HILL_NEAR = '#7FC3AC';
|
||||
const HILL_FRONT = '#5BA88F'; // --sage darkened toward --brand
|
||||
|
||||
const PATH_FILL = '#FAF6EE'; // --sand, lightened
|
||||
const PATH_EDGE = '#E6DAC2'; // --sand, darkened
|
||||
|
||||
const SCHOOL_WALL = '#FCE8C3'; // cream
|
||||
const SCHOOL_ROOF = '#F0A868'; // warm orange
|
||||
const SCHOOL_DOOR = '#0F766E'; // --brand, exact
|
||||
const SCHOOL_WINDOW = '#C7EBF5'; // --sky, exact
|
||||
const SCHOOL_CLOCK = '#FAFAF8'; // --bg-primary (Warm White)
|
||||
const FLAGPOLE = '#8FA3AE';
|
||||
const PENNANT = '#F97360'; // --coral, exact
|
||||
|
||||
const TREE_DARK = '#2E7D6B';
|
||||
const TREE_MID = '#3E8C74';
|
||||
const TREE_LIGHT = '#4A9E85';
|
||||
const TREE_PALE = '#7FC3AC';
|
||||
|
||||
const PIN = '#F97360'; // --coral, exact
|
||||
const PIN_EYE = '#FFFFFF';
|
||||
|
||||
/** Scoped so the gradient id can't collide with another inline SVG. */
|
||||
const SKY_GRADIENT_ID = 'sc-hero-sky';
|
||||
|
||||
export function HeroIllustration() {
|
||||
return (
|
||||
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id={SKY_GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={SKY_HIGH} />
|
||||
<stop offset="1" stopColor={SKY_LOW} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Sky */}
|
||||
<rect width="540" height="520" fill={`url(#${SKY_GRADIENT_ID})`} />
|
||||
|
||||
{/* Two soft clouds, each a pair of overlapping ellipses */}
|
||||
<g fill={CLOUD} opacity="0.85">
|
||||
<ellipse cx="96" cy="86" rx="32" ry="15" />
|
||||
<ellipse cx="122" cy="79" rx="23" ry="18" />
|
||||
<ellipse cx="418" cy="58" rx="27" ry="13" />
|
||||
<ellipse cx="439" cy="52" rx="19" ry="14" />
|
||||
</g>
|
||||
|
||||
{/* Four layered rolling hills, palest and furthest first */}
|
||||
<path d="M0 300 C 90 264 168 294 246 276 C 330 256 410 282 540 254 L540 520 L0 520 Z" fill={HILL_FAR} />
|
||||
<path d="M0 344 C 104 308 186 340 268 324 C 356 306 452 332 540 306 L540 520 L0 520 Z" fill={HILL_MID} />
|
||||
<path d="M0 408 C 118 378 214 410 306 392 C 400 374 470 398 540 382 L540 520 L0 520 Z" fill={HILL_NEAR} />
|
||||
<path d="M0 468 C 130 446 236 474 340 458 C 432 444 486 462 540 452 L540 520 L0 520 Z" fill={HILL_FRONT} />
|
||||
|
||||
{/* Winding cream path, with a dotted edge for a little texture */}
|
||||
<path
|
||||
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
|
||||
stroke={PATH_FILL}
|
||||
strokeWidth="18"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
|
||||
stroke={PATH_EDGE}
|
||||
strokeWidth="18"
|
||||
fill="none"
|
||||
strokeLinecap="butt"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="0.5 26"
|
||||
opacity="0.5"
|
||||
/>
|
||||
|
||||
{/* The school at the top of the path */}
|
||||
<g transform="translate(276 232)">
|
||||
<rect x="12" y="34" width="98" height="58" rx="4" fill={SCHOOL_WALL} />
|
||||
<path d="M2 36 L61 6 L120 36 Z" fill={SCHOOL_ROOF} />
|
||||
<rect x="54" y="62" width="20" height="30" rx="2" fill={SCHOOL_DOOR} />
|
||||
<g fill={SCHOOL_WINDOW}>
|
||||
<rect x="24" y="46" width="16" height="14" rx="2" />
|
||||
<rect x="86" y="46" width="16" height="14" rx="2" />
|
||||
<rect x="24" y="70" width="16" height="12" rx="2" />
|
||||
<rect x="86" y="70" width="16" height="12" rx="2" />
|
||||
</g>
|
||||
{/* Flagpole and coral pennant */}
|
||||
<rect x="59.5" y="-14" width="2.5" height="20" fill={FLAGPOLE} />
|
||||
<path d="M62 -13 L78 -8.5 L62 -4 Z" fill={PENNANT} />
|
||||
{/* Clock in the gable */}
|
||||
<circle cx="61" cy="24" r="6.5" fill={SCHOOL_CLOCK} />
|
||||
<path d="M61 20.5 v7 M57.5 24 h7" stroke={SCHOOL_DOOR} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
|
||||
{/* Scattered rounded trees */}
|
||||
<g fill={TREE_DARK}>
|
||||
<circle cx="104" cy="372" r="27" />
|
||||
<circle cx="136" cy="386" r="19" />
|
||||
<circle cx="470" cy="336" r="23" />
|
||||
</g>
|
||||
<g fill={TREE_LIGHT}>
|
||||
<circle cx="78" cy="394" r="21" />
|
||||
<circle cx="492" cy="358" r="17" />
|
||||
<circle cx="176" cy="420" r="18" />
|
||||
</g>
|
||||
<g fill={TREE_MID}>
|
||||
<ellipse cx="44" cy="336" rx="15" ry="22" />
|
||||
<ellipse cx="508" cy="296" rx="13" ry="18" />
|
||||
<ellipse cx="150" cy="342" rx="12" ry="17" />
|
||||
</g>
|
||||
<g fill={TREE_PALE} opacity="0.9">
|
||||
<circle cx="410" cy="420" r="16" />
|
||||
<circle cx="438" cy="430" r="12" />
|
||||
<circle cx="120" cy="470" r="14" />
|
||||
</g>
|
||||
|
||||
{/* Location pin — you are here, the path leads up to the school */}
|
||||
<g transform="translate(196 372)">
|
||||
<path d="M21 48 C21 48 42 26 42 15.5 A21 21 0 1 0 0 15.5 C0 26 21 48 21 48 Z" fill={PIN} />
|
||||
<circle cx="21" cy="15.5" r="8" fill={PIN_EYE} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,83 @@
|
||||
/**
|
||||
* The SchoolCompare mark.
|
||||
* The schoolcompare mark.
|
||||
*
|
||||
* Five bars: a cohort, with one school picked out. It is the same object as
|
||||
* the distribution strip inside a school row, at a different size — the logo
|
||||
* teaches the chart and the chart reinforces the logo.
|
||||
* A location pin with a leaf growing inside it: where a school is, and a child
|
||||
* growing there. The pin's tail sweeps left as the guideline's "signature path
|
||||
* shape" — the journey a parent takes to find the right school — so the mark
|
||||
* and the illustration style share one gesture.
|
||||
*
|
||||
* Built from opacity steps over `currentColor` rather than fixed greys, so it
|
||||
* inverts cleanly on any ground without a second artwork. The highlighted bar
|
||||
* is the only element that takes the brand hue.
|
||||
* This is the single source for the mark. app/icon.svg, app/apple-icon.tsx and
|
||||
* app/opengraph-image.tsx all derive from the same geometry (see MARK_PATHS),
|
||||
* because the header and the favicon had previously drifted into two different
|
||||
* logos.
|
||||
*
|
||||
* This is the single source for the mark. The favicon and the generated icons
|
||||
* derive from the same geometry (see BARS) — previously the header and the
|
||||
* favicon had drifted into two different logos.
|
||||
* Drawn on a 48×48 grid. The pin takes the brand hue and the leaf is knocked
|
||||
* out of it, so the mark needs exactly two colours and works on any ground.
|
||||
*/
|
||||
|
||||
/** x, y, width, height, and whether this bar is the highlighted school. */
|
||||
export const BARS: ReadonlyArray<[number, number, number, number, boolean]> = [
|
||||
[2, 26, 6, 12, false],
|
||||
[10, 16, 6, 22, false],
|
||||
[18, 8, 6, 30, false],
|
||||
[26, 14, 6, 24, true],
|
||||
[34, 28, 4, 10, false],
|
||||
];
|
||||
/** Geometry shared by every rendering of the mark, on a 0 0 48 48 viewBox. */
|
||||
export const MARK_PATHS = {
|
||||
/** The path tail, stroked — 8.5 wide with a round cap. */
|
||||
tail: 'M17 30 C15 35.6 11.6 40.2 7.8 42.8',
|
||||
/** The pin head. */
|
||||
head: { cx: 26.5, cy: 18.5, r: 16.5 },
|
||||
/** Leaf stem, stroked — 2.6 wide with a round cap. */
|
||||
stem: 'M26.5 29.5 C26.5 25 26.6 21 27 18',
|
||||
/** Upper leaf, filled. */
|
||||
leafUpper: 'M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z',
|
||||
/** Lower leaf, filled. */
|
||||
leafLower: 'M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z',
|
||||
} as const;
|
||||
|
||||
const COHORT_OPACITY = [0.22, 0.38, 0.55, 1, 0.22];
|
||||
/**
|
||||
* The mark as a standalone SVG string.
|
||||
*
|
||||
* Satori (next/og) renders `<img>` with a data URI far more reliably than it
|
||||
* renders inline SVG children, so the generated icon and share card both draw
|
||||
* the mark this way — from the same geometry as the React component above,
|
||||
* which is the whole point of MARK_PATHS.
|
||||
*/
|
||||
export function markSvg(pin: string, leaf: string): string {
|
||||
return [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">',
|
||||
`<path d="${MARK_PATHS.tail}" stroke="${pin}" stroke-width="8.5" stroke-linecap="round" fill="none"/>`,
|
||||
`<circle cx="${MARK_PATHS.head.cx}" cy="${MARK_PATHS.head.cy}" r="${MARK_PATHS.head.r}" fill="${pin}"/>`,
|
||||
`<path d="${MARK_PATHS.stem}" stroke="${leaf}" stroke-width="2.6" stroke-linecap="round" fill="none"/>`,
|
||||
`<path d="${MARK_PATHS.leafUpper}" fill="${leaf}"/>`,
|
||||
`<path d="${MARK_PATHS.leafLower}" fill="${leaf}"/>`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** The same string as a data URI, ready for `<img src>`. */
|
||||
export function markDataUri(pin: string, leaf: string): string {
|
||||
return `data:image/svg+xml;base64,${Buffer.from(markSvg(pin, leaf)).toString('base64')}`;
|
||||
}
|
||||
|
||||
interface LogoMarkProps {
|
||||
className?: string;
|
||||
/** Rendered size in px. Defaults to inheriting via CSS. */
|
||||
size?: number;
|
||||
/** Colour of the highlighted bar. Defaults to the brand token. */
|
||||
accent?: string;
|
||||
/** The pin. Defaults to the brand token so it inverts with the theme. */
|
||||
pin?: string;
|
||||
/** The leaf knocked out of the pin. */
|
||||
leaf?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function LogoMark({ className, size, accent = 'var(--brand)', title }: LogoMarkProps) {
|
||||
export function LogoMark({
|
||||
className,
|
||||
size,
|
||||
pin = 'var(--brand)',
|
||||
leaf = 'var(--bg-card)',
|
||||
title,
|
||||
}: LogoMarkProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 40 40"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role={title ? 'img' : undefined}
|
||||
@@ -48,18 +85,23 @@ export function LogoMark({ className, size, accent = 'var(--brand)', title }: Lo
|
||||
aria-label={title}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
||||
<rect
|
||||
key={x}
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={h}
|
||||
rx={1}
|
||||
fill={isSchool ? accent : 'currentColor'}
|
||||
opacity={isSchool ? 1 : COHORT_OPACITY[i]}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
d={MARK_PATHS.tail}
|
||||
stroke={pin}
|
||||
strokeWidth={8.5}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx={MARK_PATHS.head.cx} cy={MARK_PATHS.head.cy} r={MARK_PATHS.head.r} fill={pin} />
|
||||
<path
|
||||
d={MARK_PATHS.stem}
|
||||
stroke={leaf}
|
||||
strokeWidth={2.6}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path d={MARK_PATHS.leafUpper} fill={leaf} />
|
||||
<path d={MARK_PATHS.leafLower} fill={leaf} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/*
|
||||
* Header and mobile tab bar, on the schoolcompare brand.
|
||||
*
|
||||
* The header is a white card band closed by a single hairline rule — no
|
||||
* shadow. The guideline's chrome is quiet; the colour on this surface is the
|
||||
* wordmark and the one active nav item, and nothing else.
|
||||
*/
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -16,35 +23,42 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
/* 64px desktop / 56px mobile is load-bearing: the school detail page's
|
||||
sticky section nav docks against it with a hardcoded `top` in
|
||||
SchoolDetailShell.module.css and schoolSections.module.css, and the
|
||||
scroll-spy observers use it as a rootMargin. Change it in all four
|
||||
places or not at all. */
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.625rem;
|
||||
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||
margin: -0.375rem -0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
transition: color 0.2s ease;
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
color: var(--brand-strong);
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
/* currentColor paints the four cohort bars; the mark supplies the brand hue
|
||||
for the highlighted one itself. So this is ink, not brand. */
|
||||
/*
|
||||
* The mark colours itself — pin in var(--brand), leaf knocked out in
|
||||
* var(--bg-card), which is this header's own ground. It takes no colour from
|
||||
* currentColor, so there is deliberately no `color` here.
|
||||
*/
|
||||
.logoIcon {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-primary);
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.logoIcon svg {
|
||||
@@ -52,10 +66,17 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* "schoolcompare", one word, lower case. Manrope 700 pulled tight — the
|
||||
* negative tracking is what makes the two halves read as a single word rather
|
||||
* than as two.
|
||||
*/
|
||||
.logoText {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.022em;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.logoTextAccent {
|
||||
@@ -63,41 +84,28 @@
|
||||
}
|
||||
|
||||
.logo:hover .logoTextAccent {
|
||||
color: var(--brand-strong);
|
||||
color: var(--brand-stronger);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9375rem;
|
||||
gap: 0.4375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sliding underline effect */
|
||||
.navLink::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
height: 2px;
|
||||
background: var(--brand);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.25s ease;
|
||||
border-radius: 1px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: color var(--transition), background-color var(--transition);
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
@@ -105,33 +113,29 @@
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.navLink:hover::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* The current page is marked twice — brand hue and a heavier weight — so the
|
||||
state never rests on colour alone. */
|
||||
.navLink.active {
|
||||
color: var(--brand-strong);
|
||||
color: var(--brand);
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.navLink.active::after {
|
||||
transform: scaleX(1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border-radius: 9999px;
|
||||
animation: badgePop 0.3s ease-out;
|
||||
box-shadow: 0 2px 6px rgba(var(--brand-rgb), 0.4);
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
@@ -159,25 +163,23 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-height: 56px;
|
||||
padding: 0.375rem 0.25rem;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
transition: color var(--transition);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tab:active {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--brand-strong);
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabIconWrap {
|
||||
@@ -189,7 +191,29 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* The active tab's icon sits in a brand-tinted stadium — the same rounded
|
||||
geometry as the rest of the system, and a second cue beyond hue. Drawn as a
|
||||
pseudo-element so it can bleed wider than the 24px icon box without moving
|
||||
the badge's anchor. */
|
||||
.tabIconWrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px -9px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: transparent;
|
||||
transition: background-color var(--transition);
|
||||
}
|
||||
|
||||
.tabActive .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tab:active .tabIconWrap::before {
|
||||
background: var(--brand-bg);
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
@@ -200,16 +224,18 @@
|
||||
|
||||
.tabBadge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -10px;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--brand-on);
|
||||
background: var(--brand);
|
||||
border: 2px solid var(--bg-card);
|
||||
@@ -229,8 +255,12 @@
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.bottomBar {
|
||||
|
||||
@@ -79,6 +79,16 @@ export function Navigation() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* The brand guideline's nav also carries Guides, About, Favourites and a
|
||||
* "Sign in" button. None of the four is added here, deliberately: there are
|
||||
* no Guides or About pages, and Favourites and Sign in both need an accounts
|
||||
* system that does not exist. Drawing chrome for features we do not have
|
||||
* would be a promise the product cannot keep. Rankings and Admissions —
|
||||
* which the guideline omits — take their place, because they are real.
|
||||
*
|
||||
* If accounts ever ship, this is the list to extend.
|
||||
*/
|
||||
const items = [
|
||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||
@@ -90,12 +100,19 @@ export function Navigation() {
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
|
||||
{/*
|
||||
LogoMark's defaults are already correct for this ground: the pin
|
||||
takes var(--brand) and the leaf is knocked out in var(--bg-card),
|
||||
which is exactly the header's own background in both themes. No
|
||||
explicit pin/leaf needed here — the footer, which sits on the
|
||||
sunken teal band, does have to pass them.
|
||||
*/}
|
||||
<span className={styles.logoIcon}>
|
||||
<LogoMark />
|
||||
</span>
|
||||
<span className={styles.logoText}>
|
||||
School<span className={styles.logoTextAccent}>Compare</span>
|
||||
school<span className={styles.logoTextAccent}>compare</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -45,35 +45,35 @@ export type ThemeToken =
|
||||
|
||||
/** Values used before hydration and in any non-DOM context (SSR, tests). */
|
||||
const FALLBACK: Record<string, string> = {
|
||||
'--brand': '#584a9b',
|
||||
'--brand-strong': '#473c80',
|
||||
'--brand-bg': 'rgba(88, 74, 155, 0.10)',
|
||||
'--status-above': '#0e6e66',
|
||||
'--status-above-bg': 'rgba(14, 110, 102, 0.10)',
|
||||
'--status-below': '#9a5b00',
|
||||
'--status-below-bg': 'rgba(154, 91, 0, 0.11)',
|
||||
'--text-primary': '#16202a',
|
||||
'--text-secondary': '#4a545f',
|
||||
'--text-muted': '#5c6570',
|
||||
'--text-inverse': '#f7f7f4',
|
||||
'--bg-card': '#fcfcfa',
|
||||
'--bg-secondary': '#e9e8e2',
|
||||
'--border': '#dedcd5',
|
||||
'--border-strong': '#cdcac1',
|
||||
'--surface-inverse': '#16202a',
|
||||
'--chart-1': '#584a9b',
|
||||
'--chart-2': '#0e6e66',
|
||||
'--chart-3': '#9a5b00',
|
||||
'--chart-4': '#8a7cc9',
|
||||
'--chart-5': '#3e9c92',
|
||||
'--chart-6': '#c9903d',
|
||||
'--chart-grid': '#dedcd5',
|
||||
'--chart-reference': '#5c6570',
|
||||
'--phase-primary': '#4a6072',
|
||||
'--phase-secondary-text': '#4e4570',
|
||||
'--medal-gold': '#a67c00',
|
||||
'--medal-silver': '#6f7580',
|
||||
'--medal-bronze': '#8a5a2b',
|
||||
'--brand': '#0F766E',
|
||||
'--brand-strong': '#0C5F58',
|
||||
'--brand-bg': 'rgba(15, 118, 110, 0.10)',
|
||||
'--status-above': '#36743F',
|
||||
'--status-above-bg': 'rgba(54, 116, 63, 0.12)',
|
||||
'--status-below': '#A9481F',
|
||||
'--status-below-bg': 'rgba(249, 115, 96, 0.15)',
|
||||
'--text-primary': '#1C2731',
|
||||
'--text-secondary': '#4A5560',
|
||||
'--text-muted': '#5F6A75',
|
||||
'--text-inverse': '#FFFFFF',
|
||||
'--bg-card': '#FFFFFF',
|
||||
'--bg-secondary': '#F5EFE6',
|
||||
'--border': '#E5E7EB',
|
||||
'--border-strong': '#D3D7DD',
|
||||
'--surface-inverse': '#0F766E',
|
||||
'--chart-1': '#0F766E',
|
||||
'--chart-2': '#36743F',
|
||||
'--chart-3': '#806200',
|
||||
'--chart-4': '#A9481F',
|
||||
'--chart-5': '#2F6F8F',
|
||||
'--chart-6': '#5F4FA8',
|
||||
'--chart-grid': '#E5E7EB',
|
||||
'--chart-reference': '#5F6A75',
|
||||
'--phase-primary': '#0F766E',
|
||||
'--phase-secondary-text': '#2A6180',
|
||||
'--medal-gold': '#806200',
|
||||
'--medal-silver': '#6B7580',
|
||||
'--medal-bronze': '#8A5A2B',
|
||||
};
|
||||
|
||||
/** Read one token. Safe to call during SSR — returns the light-theme value. */
|
||||
@@ -120,8 +120,8 @@ const SERIES = [
|
||||
] as const;
|
||||
|
||||
const SERIES_FALLBACK = [
|
||||
'#584a9b', '#0e6e66', '#9a5b00', '#a03a5e',
|
||||
'#2f5f8f', '#4a6b2f', '#7a3f7a', '#97442a',
|
||||
'#0F766E', '#36743F', '#806200', '#A9481F',
|
||||
'#2F6F8F', '#5F4FA8', '#0E7A86', '#8A4A6B',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 7.4 KiB |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "SchoolCompare",
|
||||
"short_name": "SchoolCompare",
|
||||
"name": "schoolcompare",
|
||||
"short_name": "schoolcompare",
|
||||
"description": "Compare primary and secondary school performance across England",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f2f1ed",
|
||||
"theme_color": "#f2f1ed",
|
||||
"background_color": "#FAFAF8",
|
||||
"theme_color": "#0F766E",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
|
||||