fix(home): correct what the landing page claims, and give it one rhythm
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m25s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m25s
The homepage made four statements that were not true, carried elements that
asked nothing of anyone, and had a hero illustration that broke in both the
places it had to work.
Claims, all verified against the code or the API:
* "24,000+ schools" (three places) against a real 27,230. The fact box meant
to show the live figure rendered its own fallback on every request, because
DataInfoResponse declared a `total_schools` field the API has never sent —
it sends `unique_schools`. The fetch succeeded; only that field was
undefined, so nothing threw and nothing failed. The interface, not the
code, was the thing that was wrong.
* "Up to three schools side by side" against MAX_SCHOOLS = 5, contradicting a
card 400px below it that correctly said five.
* "Class sizes" — data the codebase has never held. That copy line was the
only hit in a full-repo grep.
* Invented results and Ofsted grades attributed to two real, named schools
in the compare preview.
Also one feature, three words: Compare (nav), shortlist (footer), pin (cards).
Settled on Compare everywhere. And <title> was the bare string "Home".
Cut: the trust line (repeated the coverage figure one paragraph after the hero
gave it, behind three decorative dots), the "Start exploring" row (three links
to two destinations already in the nav), the six-row coverage table, and three
of the four countdown cards — which gave the page's largest numeral to dates up
to 245 days away, two of them offer days, which cannot be missed. All four
dates remain, at proportionate weight. Value-prop titles drop from <h2> to <p>;
they were outranking the page's real headings in the document outline.
Rhythm: the gaps between the seven landing bands were 24/32/24/16/48/32/16px,
each band setting its own margin, with four different section-header
treatments between them. The page container now owns one gap, and there is one
header pattern. An e2e test asserts the gaps are identical.
Illustration: it kept a fixed light palette in both themes, which left a pale
sky slab as the brightest object on a near-black page, out-shouting the H1 and
the search box. It now reads from --ill-* tokens with a dark re-grade. And the
hero slot ranges from 1.34:1 to 4.9:1 across breakpoints, which no single
composition survives under `slice` — at 860x176 a 540x520 scene shows only its
bottom 110 units, so the schoolhouse was cropped away entirely on phones,
leaving hills and a pin pointing at nothing. There are now two compositions,
each drawn against the crop window its own breakpoint produces, with CSS
showing one. Both are static server-rendered SVG.
The deadline bar renders on the server rather than on hydrate. The effect-based
version needed a reserved height, and one guessed number cannot cover a block
whose supporting line wraps differently at every width — measured, it was short
at all four, shifting the page up to 108px on a phone.
Verified on the built output through an offline render harness (no local
server): real compiled CSS, real rendered markup, four widths, both themes.
tsc clean, 159/159 unit tests, build green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b117dd26c
commit
dc22fd2853
11 files changed
+849
-569
No files matched your search
@@ -69,6 +69,132 @@ test('home hero offers a "use my location" shortcut beside the search box', asyn
|
||||
await expect(nearMe).toBeVisible();
|
||||
});
|
||||
|
||||
/*
|
||||
* The landing page's claims must match the product and the data.
|
||||
*
|
||||
* Every failure this group guards against is silent: a hardcoded fallback that
|
||||
* renders as if it were live, a number that contradicts a constant elsewhere in
|
||||
* the codebase, illustrative copy that reads as fact. None of them throw, none
|
||||
* fail a build, and none show up in a unit test.
|
||||
*/
|
||||
test('the coverage figure is live, not the hardcoded fallback', async ({ page }) => {
|
||||
const info = await (await page.request.get('/api/data-info')).json();
|
||||
test.skip(info.status !== 'loaded', 'environment has no data loaded');
|
||||
const expected: number = info.unique_schools;
|
||||
expect(expected).toBeGreaterThan(0);
|
||||
|
||||
await page.goto('/');
|
||||
const body = await page.locator('body').innerText();
|
||||
|
||||
// The real count, formatted as the page formats it.
|
||||
expect(body).toContain(expected.toLocaleString('en-GB'));
|
||||
// The fallback that used to render in its place. `total_schools` is not a
|
||||
// field this API returns, so reading it yielded undefined and the "24,000+"
|
||||
// floor was shown on every request while the database held far more.
|
||||
expect(body).not.toContain('24,000+');
|
||||
});
|
||||
|
||||
test('the landing page states the real comparison limit', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const body = await page.locator('body').innerText();
|
||||
// MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
|
||||
// promise three while a card lower on the same page promised five.
|
||||
expect(body).toMatch(/five schools/i);
|
||||
expect(body).not.toMatch(/three schools/i);
|
||||
});
|
||||
|
||||
test('the landing page invents no data about real schools', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const preview = page.locator('table, [class*="comparePreview"]').first();
|
||||
const body = await page.locator('body').innerText();
|
||||
// The compare preview's columns carried two real school names beside
|
||||
// invented results and invented Ofsted grades.
|
||||
expect(body).not.toMatch(/Our Lady Queen of Heaven|St Mary's Catholic Primary/i);
|
||||
await expect(preview).toContainText(/School A/);
|
||||
});
|
||||
|
||||
test('the home page has a real title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// It was the bare string "Home" — the browser tab, the bookmark and the
|
||||
// search result all read that.
|
||||
expect(await page.title()).not.toBe('Home');
|
||||
expect(await page.title()).toMatch(/schoolcompare/i);
|
||||
});
|
||||
|
||||
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const gaps = await page.evaluate(() => {
|
||||
const landing = document.querySelector('main div[class*="landing"]');
|
||||
if (!landing) return null;
|
||||
const kids = [...landing.children].map(el => el.getBoundingClientRect());
|
||||
const out: number[] = [];
|
||||
for (let i = 1; i < kids.length; i++) out.push(Math.round(kids[i].top - kids[i - 1].bottom));
|
||||
return out;
|
||||
});
|
||||
expect(gaps).not.toBeNull();
|
||||
expect(gaps!.length).toBeGreaterThan(1);
|
||||
// Every band gap comes from the page container's single `gap`, so they are
|
||||
// identical. They used to be 24 / 32 / 24 / 16 / 48 / 32px — each band set
|
||||
// its own margin, and the page read as a stack of unrelated strips.
|
||||
expect(new Set(gaps!).size).toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* The hero illustration. Two separate regressions are guarded here, both of
|
||||
* which shipped: the drawing being cropped past its own subject on phones, and
|
||||
* the drawing keeping a fixed light palette on a near-black page.
|
||||
*/
|
||||
test('the hero illustration swaps composition rather than cropping its subject', async ({ page }) => {
|
||||
const visible = () => page.evaluate(() => {
|
||||
const wide = document.querySelector('[class*="heroArtWide"]');
|
||||
const band = document.querySelector('[class*="heroArtBand"]');
|
||||
return {
|
||||
wide: wide ? getComputedStyle(wide).display : 'missing',
|
||||
band: band ? getComputedStyle(band).display : 'missing',
|
||||
};
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/');
|
||||
expect(await visible()).toEqual({ wide: 'block', band: 'none' });
|
||||
|
||||
// Below the one-column breakpoint the slot becomes a wide, short band. The
|
||||
// tall composition sliced to that shape cropped the schoolhouse away
|
||||
// entirely, leaving hills and a pin pointing at nothing.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
expect(await visible()).toEqual({ wide: 'none', band: 'block' });
|
||||
});
|
||||
|
||||
test('the hero illustration is re-graded for the dark theme', async ({ browser }) => {
|
||||
const read = async (colorScheme: 'light' | 'dark') => {
|
||||
const ctx = await browser.newContext({ colorScheme });
|
||||
const p = await ctx.newPage();
|
||||
await p.goto('/');
|
||||
const value = await p.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--ill-sky-high').trim()
|
||||
);
|
||||
await ctx.close();
|
||||
return value;
|
||||
};
|
||||
|
||||
const toLuminance = (hex: string) => {
|
||||
const m = hex.replace('#', '');
|
||||
const [r, g, b] = [0, 2, 4].map(i => parseInt(m.slice(i, i + 2), 16) / 255);
|
||||
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
};
|
||||
|
||||
const light = await read('light');
|
||||
const dark = await read('dark');
|
||||
expect(light).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
expect(dark).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
// The sky used to be the same pale blue in both themes, which made it the
|
||||
// brightest object on the page in dark mode — it out-shouted the H1 and the
|
||||
// search box. It must now be substantially darker than its light value.
|
||||
expect(toLuminance(dark)).toBeLessThan(toLuminance(light) / 4);
|
||||
});
|
||||
|
||||
test('searching by name returns school results', async ({ page }) => {
|
||||
await searchByName(page, 'primary');
|
||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -178,6 +178,44 @@
|
||||
--step-4: 2.5rem;
|
||||
--step-5: 3rem;
|
||||
|
||||
/* ── Section rhythm ─────────────────────────────────────────────────
|
||||
The vertical gap between the bands of a page, and the gap between a
|
||||
band's header and its content. Two values, not seven: the landing page
|
||||
previously set its own margin on every band (24 / 32 / 16 / 48px, no
|
||||
scale), which is what made a designed page read as a stack of unrelated
|
||||
strips. Bands must not set their own vertical margins — the page
|
||||
container owns the gap. */
|
||||
--section-gap: 4rem;
|
||||
--section-head-gap: 1.5rem;
|
||||
|
||||
/* ── Illustration ───────────────────────────────────────────────────
|
||||
The hero landscape's palette. These are tokens rather than literals in
|
||||
the SVG for one reason: the drawing previously kept a fixed light
|
||||
palette in both themes, which left a pale sky slab as the brightest
|
||||
object on a near-black page — it out-shouted the H1 and the search box.
|
||||
Artwork still shouldn't invert (that breaks the picture), so the dark
|
||||
values below are a re-grade, not an inversion: the same scene, lit at
|
||||
dusk, sitting a step above the panel it lives on. */
|
||||
--ill-sky-high: #DAEDF8;
|
||||
--ill-sky-low: #EFF8FB;
|
||||
--ill-cloud: #FFFFFF;
|
||||
--ill-hill-far: #D6EDE2;
|
||||
--ill-hill-mid: #A7D7C5;
|
||||
--ill-hill-near: #7FC3AC;
|
||||
--ill-hill-front: #5BA88F;
|
||||
--ill-path: #FAF6EE;
|
||||
--ill-path-edge: #E6DAC2;
|
||||
--ill-wall: #FCE8C3;
|
||||
--ill-roof: #F0A868;
|
||||
--ill-door: #0F766E;
|
||||
--ill-window: #C7EBF5;
|
||||
--ill-tree-dark: #2E7D6B;
|
||||
--ill-tree-mid: #3E8C74;
|
||||
--ill-tree-light: #4A9E85;
|
||||
--ill-tree-pale: #7FC3AC;
|
||||
--ill-pin: #F97360;
|
||||
--ill-pin-eye: #FFFFFF;
|
||||
|
||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||
rounder than the old system's 3/6/10/16. */
|
||||
@@ -302,6 +340,31 @@
|
||||
--medal-silver-rgb: 169, 182, 188;
|
||||
--medal-bronze-rgb: 201, 144, 112;
|
||||
|
||||
/* Dusk re-grade of the hero landscape. The sky sits one step above the
|
||||
Sand panel (#16222A) rather than 60 points above it, so the picture
|
||||
reads as a framed vignette instead of a light leak. The pin keeps
|
||||
the theme's coral and is the only bright thing left in the scene —
|
||||
which is the point, since it marks "you are here". */
|
||||
--ill-sky-high: #1B2C36;
|
||||
--ill-sky-low: #16242C;
|
||||
--ill-cloud: #2A3F4A;
|
||||
--ill-hill-far: #1E3A38;
|
||||
--ill-hill-mid: #234944;
|
||||
--ill-hill-near: #2A5A51;
|
||||
--ill-hill-front: #326B5E;
|
||||
--ill-path: #4A5A52;
|
||||
--ill-path-edge: #3A4842;
|
||||
--ill-wall: #45423A;
|
||||
--ill-roof: #8A5A3E;
|
||||
--ill-door: #5FC7BB;
|
||||
--ill-window: #2F5966;
|
||||
--ill-tree-dark: #16302C;
|
||||
--ill-tree-mid: #1B3A34;
|
||||
--ill-tree-light: #21453D;
|
||||
--ill-tree-pale: #2A5A51;
|
||||
--ill-pin: #F08A72;
|
||||
--ill-pin-eye: #16242C;
|
||||
|
||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
||||
@@ -472,7 +535,7 @@ table,
|
||||
border-color: var(--action-stronger);
|
||||
}
|
||||
|
||||
/* Secondary: brand outline — supporting actions (Add to shortlist) */
|
||||
/* Secondary: brand outline — supporting actions (Add to compare) */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--brand);
|
||||
@@ -493,7 +556,7 @@ table,
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active toggle state — the shortlist button once a school is on the list */
|
||||
/* Active toggle state — the compare button once a school is on the list */
|
||||
.btn-active {
|
||||
background: var(--brand-bg);
|
||||
color: var(--brand);
|
||||
@@ -563,6 +626,12 @@ html .leaflet-bar a:hover {
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
/* Tighten the rhythm rather than abandon it — the ratio between the
|
||||
section gap and the header gap stays the same. */
|
||||
:root {
|
||||
--section-gap: 2.75rem;
|
||||
--section-head-gap: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Honour the OS setting. Transitions collapse to near-instant rather than
|
||||
|
||||
+30
-5
@@ -3,7 +3,9 @@
|
||||
* Main landing page with school search and browsing
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||
import { formatAcademicYear } from '@/lib/utils';
|
||||
import { HomeView } from '@/components/HomeView';
|
||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||
import { EditorialSection } from '@/components/EditorialSection';
|
||||
@@ -24,8 +26,14 @@ interface HomePageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Home',
|
||||
/*
|
||||
* `title` was the bare string 'Home', which is what the browser tab, the
|
||||
* bookmark and the search result all read. `absolute` opts out of the root
|
||||
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
||||
* saying the brand twice.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: 'schoolcompare | Compare every school in England' },
|
||||
description: 'Search and compare school performance across England',
|
||||
};
|
||||
|
||||
@@ -79,14 +87,24 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
}
|
||||
|
||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||
const total = dataInfo?.total_schools ?? null;
|
||||
// `unique_schools`, not `total_schools` — the latter is not a field this
|
||||
// endpoint returns, and reading it silently yielded null on every request.
|
||||
const total = dataInfo?.unique_schools ?? null;
|
||||
const years = dataInfo?.years_available ?? [];
|
||||
return (
|
||||
<HomeView
|
||||
initialSchools={schoolsData}
|
||||
filters={resolvedFilters}
|
||||
totalSchools={total}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={total}
|
||||
localAuthorityCount={resolvedFilters.local_authorities.length}
|
||||
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
||||
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -99,7 +117,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
filters={emptyFilters}
|
||||
totalSchools={null}
|
||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||
editorial={hasSearchParams ? null : (
|
||||
<EditorialSection
|
||||
totalSchools={null}
|
||||
localAuthorityCount={0}
|
||||
earliestYearLabel={null}
|
||||
latestYearLabel={null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '31 October',
|
||||
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
|
||||
date: 'September',
|
||||
title: 'Portal opens',
|
||||
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||
},
|
||||
{
|
||||
date: '15 January',
|
||||
|
||||
@@ -5,15 +5,45 @@ import styles from './HomeView.module.css';
|
||||
interface EditorialSectionProps {
|
||||
totalSchools: number | null;
|
||||
localAuthorityCount: number;
|
||||
latestYearLabel: string | null;
|
||||
earliestYearLabel: string | null;
|
||||
}
|
||||
|
||||
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||
export function EditorialSection({
|
||||
totalSchools,
|
||||
localAuthorityCount,
|
||||
latestYearLabel,
|
||||
earliestYearLabel,
|
||||
}: EditorialSectionProps) {
|
||||
/*
|
||||
* The coverage line replaces a six-row "Coverage at a glance" table.
|
||||
*
|
||||
* That table put six numbers on the page to say one thing, and the headline
|
||||
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
|
||||
* DataInfoResponse declared a `total_schools` field the API does not send
|
||||
* (it sends `unique_schools`). The fetch had succeeded; only that field was
|
||||
* undefined, so the fallback rendered and nothing failed. Every figure here
|
||||
* is now live, and any figure that isn't available is dropped rather than
|
||||
* replaced with a guess.
|
||||
*/
|
||||
const coverage = [
|
||||
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
|
||||
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
|
||||
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className={styles.editorial}>
|
||||
<div className={styles.editorialGrid}>
|
||||
// No class on the section: the page container owns the vertical rhythm
|
||||
// now, and this band carries its own ground on the card inside it.
|
||||
<section>
|
||||
<div className={styles.editorialCard}>
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>About school data</p>
|
||||
<h2 className={styles.sectionHeading}>
|
||||
Making England's school performance data actually readable
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.editorialText}>
|
||||
<div className={styles.editorialKicker}>About school data</div>
|
||||
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||
<p>
|
||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||
@@ -21,38 +51,14 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
||||
</p>
|
||||
<p>
|
||||
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
|
||||
average, explains what the numbers mean, and lets you compare schools side by side. Built for
|
||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||
full inspection report.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.factbox}>
|
||||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Schools covered</span>
|
||||
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Local authorities</span>
|
||||
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Phases</span>
|
||||
<span className={styles.factVal}>Primary & Secondary</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Latest results year</span>
|
||||
<span className={styles.factVal}>2024/25</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Historical data</span>
|
||||
<span className={styles.factVal}>2016–2025</span>
|
||||
</div>
|
||||
<div className={styles.factRow}>
|
||||
<span className={styles.factKey}>Metrics per school</span>
|
||||
<span className={styles.factVal}>40+</span>
|
||||
</div>
|
||||
</div>
|
||||
{coverage.length > 0 && (
|
||||
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,9 @@ export function Footer() {
|
||||
<ul className={styles.links}>
|
||||
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
||||
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
|
||||
{/* "Compare", not "shortlist" — the nav, this link and the
|
||||
landing page all name the same feature the same way. */}
|
||||
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
|
||||
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,80 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Page rhythm ───────────────────────────────────────────────────────────
|
||||
The landing page owns the vertical gap between its bands; no band sets its
|
||||
own top or bottom margin. Previously each one did, and the gaps came out as
|
||||
24 / 32 / 24 / 16 / 48 / 32 / 16px — no scale, which is most of the reason a
|
||||
designed page read as a stack of unrelated strips. If you add a band here,
|
||||
give it padding and a ground, never a margin. */
|
||||
.landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--section-gap);
|
||||
}
|
||||
|
||||
/* The hero and the four reasons under it are one thought, so they sit closer
|
||||
to each other than to the next band. This is the only place that overrides
|
||||
the page gap, and it does it by grouping rather than by re-spacing. */
|
||||
.heroGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* ── Section header ────────────────────────────────────────────────────────
|
||||
One pattern for every band: kicker, heading, optional aside. */
|
||||
.sectionHead {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: baseline;
|
||||
column-gap: 1.5rem;
|
||||
margin-bottom: var(--section-head-gap);
|
||||
}
|
||||
|
||||
.sectionKicker {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-strong);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-3);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
max-width: 24ch;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.sectionAside {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sectionHead {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sectionHeading {
|
||||
font-size: var(--step-2);
|
||||
max-width: none;
|
||||
}
|
||||
.sectionAside {
|
||||
text-align: left;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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
|
||||
@@ -14,7 +88,6 @@
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
@@ -31,6 +104,13 @@
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
/* Both compositions fill the slot; the breakpoint below picks one. */
|
||||
.heroArtWide,
|
||||
.heroArtBand {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.heroArt svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -39,31 +119,12 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.heroTrust {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.4rem;
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-secondary);
|
||||
/* The band composition only exists below the one-column breakpoint — above
|
||||
it the slot is tall and the wide drawing is the right picture. */
|
||||
.heroArtBand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -135,10 +196,18 @@
|
||||
.heroContent {
|
||||
padding: 2rem 1.5rem 1.75rem;
|
||||
}
|
||||
/* One column: the slot becomes a wide, short band, so swap in the drawing
|
||||
composed for that crop. See the note at the top of Illustration.tsx. */
|
||||
.heroArt {
|
||||
min-height: 11rem;
|
||||
order: 2;
|
||||
}
|
||||
.heroArtWide {
|
||||
display: none;
|
||||
}
|
||||
.heroArtBand {
|
||||
display: block;
|
||||
}
|
||||
.heroTitle {
|
||||
font-size: var(--step-3);
|
||||
max-width: none;
|
||||
@@ -150,13 +219,11 @@
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
long coverage sentence, leaving the proposition and the search — the data
|
||||
provenance is carried by the "Official & trusted" prop immediately below
|
||||
the hero (audit P1.7, feeds the 46% home exit rate). */
|
||||
@media (max-width: 640px) {
|
||||
.heroPanel {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.heroContent {
|
||||
@@ -175,10 +242,6 @@
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.heroTrust {
|
||||
margin-top: 1rem;
|
||||
font-size: var(--step--2);
|
||||
}
|
||||
.heroArt {
|
||||
min-height: 8.5rem;
|
||||
}
|
||||
@@ -193,7 +256,7 @@
|
||||
gap: 1.25rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.valueProp {
|
||||
@@ -256,7 +319,6 @@
|
||||
.valueProps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,86 +751,15 @@
|
||||
border-color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.exploringRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.exploringLabel {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.exploringChips {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exploringChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.95rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.exploringChip:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chipDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── How it works section ─────────────────────────────── */
|
||||
/* ── How it works section ─────────────────────────────────────────────────
|
||||
A Sand band, borrowing the hero panel's ground and radius so the page has
|
||||
two anchored bands rather than one designed element and a long tail of
|
||||
unstyled strips. */
|
||||
|
||||
.howItWorks {
|
||||
padding: 3rem 0 1rem;
|
||||
}
|
||||
|
||||
.hiwHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hiwHeading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hiwSub {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
.hiwGrid {
|
||||
@@ -1183,12 +1174,9 @@
|
||||
.hiwGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hiwHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.hiwHeading {
|
||||
font-size: 1.4rem;
|
||||
.howItWorks {
|
||||
padding: 1.75rem 1.25rem;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1212,18 +1200,29 @@
|
||||
|
||||
/* ── Editorial section ───────────────────────────────── */
|
||||
|
||||
.editorial {
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.editorialGrid {
|
||||
/* Heading beside the prose rather than above it. The fact box used to occupy
|
||||
the right-hand column; with it gone, a single column left the card half
|
||||
empty, because the prose is capped at a 62ch reading measure and the card
|
||||
is not. */
|
||||
.editorialCard {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 2rem;
|
||||
padding: 1.75rem;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
column-gap: 3rem;
|
||||
padding: 2rem 2.25rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* The shared header pattern spaces itself for a header sitting above its
|
||||
content; here it sits beside it. */
|
||||
.editorialCard .sectionHead {
|
||||
margin-bottom: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editorialCard .coverageLine {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.editorialText {
|
||||
@@ -1232,23 +1231,6 @@
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.editorialKicker {
|
||||
font-size: 0.68rem;
|
||||
color: var(--brand-strong);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editorialHeading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Running prose is the one place the serif appears. Slightly larger than the
|
||||
UI around it because Literata's x-height sits lower than the grotesque's. */
|
||||
.editorialText p {
|
||||
@@ -1260,53 +1242,25 @@
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.factbox {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.factboxHeading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.factRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.factRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.factKey {
|
||||
/* One line of live coverage figures, replacing a six-row table whose headline
|
||||
number was its own hardcoded fallback. Quiet by design: it is provenance,
|
||||
not a claim. */
|
||||
.coverageLine {
|
||||
margin: 1.25rem 0 0;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: var(--step--1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.factVal {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editorialGrid {
|
||||
.editorialCard {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1.25rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.editorialCard .sectionHead {
|
||||
margin-bottom: var(--section-head-gap);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,189 +1339,137 @@
|
||||
.loadMoreButton {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Admissions Countdown Strip
|
||||
Next admissions deadline
|
||||
|
||||
One bar, replacing four equally-weighted countdown cards. Those gave the
|
||||
page's largest numeral to things up to 245 days away, and two of the four
|
||||
counted down to offer days — dates you receive something on, which cannot
|
||||
be missed. The headline is the next real deadline; the rest stay on the
|
||||
page as one supporting line, so no fact was lost, only its weight.
|
||||
========================================================= */
|
||||
|
||||
.admissionsStrip {
|
||||
padding: 1.5rem 0 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stripHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stripLabel {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stripCta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.stripCta:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.countdownRail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.countdownChip {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||
.admissions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nextDeadline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 1.15rem 1.5rem 1.15rem 1.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.countdownChip::before {
|
||||
/* A brand rule down the leading edge — the same device the countdown cards
|
||||
used along their top edge, kept so the band still reads as admissions. */
|
||||
.nextDeadline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.countdownChipDeadline::before {
|
||||
inset: 0 auto 0 0;
|
||||
width: 5px;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.countdownChipOffer::before {
|
||||
background: var(--status-above);
|
||||
.nextDeadline:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-medium);
|
||||
}
|
||||
|
||||
.countdownChipUrgent {
|
||||
border-color: rgba(var(--status-below-rgb), 0.4);
|
||||
background: rgba(var(--status-below-rgb), 0.04);
|
||||
}
|
||||
|
||||
.chipTrack {
|
||||
.nextDeadlineBody {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.15rem;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chipTrackDeadline {
|
||||
.nextDeadlineKicker {
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.chipTrackOffer {
|
||||
color: var(--status-above);
|
||||
.nextDeadlineTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.chipTrackDot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
.nextDeadlineDate {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nextDeadlineCount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipDays {
|
||||
font-variant-numeric: tabular-nums;
|
||||
.nextDeadlineDays {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.6rem;
|
||||
font-size: var(--step-3);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.countdownChipDeadline .chipDays,
|
||||
.countdownChipUrgent .chipDays {
|
||||
.nextDeadlineUnit {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Inside a fortnight the count changes hue rather than size, so urgency is
|
||||
legible without the bar growing. Terracotta is the status hue, not the
|
||||
action hue — this is a state, not a button. */
|
||||
.nextDeadlineUrgent .nextDeadlineDays {
|
||||
color: var(--status-below);
|
||||
}
|
||||
|
||||
.countdownChipOffer .chipDays {
|
||||
color: var(--status-above);
|
||||
}
|
||||
|
||||
.chipDaysUnit {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
.laterDates {
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: bottom;
|
||||
line-height: 2;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chipMilestone {
|
||||
font-size: 0.85rem;
|
||||
.laterDatesLink {
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
margin-top: 0.1rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chipDate {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.05rem;
|
||||
.laterDatesLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.countdownRail {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* On phones the 2×2 grid cramped each chip so badly the "Secondary ·
|
||||
Deadline" track label dropped to 9.6px. Switch to a horizontal
|
||||
snap-scroller — each card is full-width-ish and stays readable,
|
||||
and the rightmost card peeks past the edge to signal there's more. */
|
||||
@media (max-width: 640px) {
|
||||
.countdownRail {
|
||||
display: flex;
|
||||
grid-template-columns: none;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
.nextDeadline {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-right: 1.25rem;
|
||||
margin-inline: -1rem;
|
||||
padding-inline: 1rem;
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||
padding: 1rem 1.1rem 1rem 1.35rem;
|
||||
}
|
||||
.countdownRail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.countdownChip {
|
||||
flex: 0 0 auto;
|
||||
width: 78%;
|
||||
min-width: 220px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.chipTrack {
|
||||
font-size: 0.7rem;
|
||||
.nextDeadlineCount {
|
||||
align-items: baseline;
|
||||
}
|
||||
}
|
||||
+158
-109
@@ -12,7 +12,7 @@ import { SchoolRow } from './SchoolRow';
|
||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||
import { SchoolMap } from './SchoolMap';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { HeroIllustration } from './Illustration';
|
||||
import { HeroIllustration, HeroIllustrationBand } from './Illustration';
|
||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||
@@ -40,13 +40,22 @@ function daysUntil(month: number, day: number): number {
|
||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
function nextOccurrence(month: number, day: number): Date {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const y = today.getFullYear();
|
||||
let target = new Date(y, month - 1, day);
|
||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const target = new Date(y, month - 1, day);
|
||||
return target < today ? new Date(y + 1, month - 1, day) : target;
|
||||
}
|
||||
|
||||
function formatCountdownDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function formatShortDate(month: number, day: number): string {
|
||||
return nextOccurrence(month, day)
|
||||
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
interface CountdownChipData {
|
||||
@@ -57,6 +66,16 @@ interface CountdownChipData {
|
||||
day: number;
|
||||
}
|
||||
|
||||
interface AdmissionsMilestone {
|
||||
chip: CountdownChipData;
|
||||
/** Whole days from today. Always present — computed during render. */
|
||||
days: number;
|
||||
/** "Sat, 31 October 2026" — for the headline bar. */
|
||||
dateLabel: string;
|
||||
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
|
||||
shortDate: string;
|
||||
}
|
||||
|
||||
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||
@@ -125,7 +144,14 @@ interface ValueProp {
|
||||
* 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.
|
||||
* and is backed by the next-deadline bar further down this page.
|
||||
*
|
||||
* Every claim here must name something the product actually does. Two of the
|
||||
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
|
||||
* in context/ComparisonProvider.tsx (and the card further down the page, which
|
||||
* correctly said five), and "class sizes" described data the codebase has never
|
||||
* held — grep for it and this line was the only hit. Both are corrected below
|
||||
* against the real fields, which live in components/school/InclusionSection.tsx.
|
||||
*/
|
||||
const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
@@ -138,13 +164,13 @@ const VALUE_PROPS: ValueProp[] = [
|
||||
icon: <BarsIcon />,
|
||||
tintClass: styles.propIconCompare,
|
||||
title: 'Easy to compare',
|
||||
body: 'Up to three schools side by side, on the measures that matter.',
|
||||
body: 'Up to five 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.',
|
||||
body: 'SEN support, pupil premium and attendance — not just results.',
|
||||
},
|
||||
{
|
||||
icon: <CalendarIcon />,
|
||||
@@ -174,9 +200,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
const mapParamsRef = useRef<string>('');
|
||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||
const [geoError, setGeoError] = useState<string | null>(null);
|
||||
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
||||
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
||||
);
|
||||
/*
|
||||
* Computed during render, on the server as well as the client, so the
|
||||
* deadline bar is in the first HTML rather than appearing on hydrate.
|
||||
*
|
||||
* The previous version deferred this to an effect to dodge a hydration
|
||||
* mismatch, which meant the section had to reserve its own height — and the
|
||||
* reservation was a single guessed number for a block whose supporting line
|
||||
* wraps to a different height at every width. Measured, it was short at
|
||||
* every breakpoint, shifting the page by up to 108px on a phone.
|
||||
*
|
||||
* The mismatch it was dodging is real but tiny: a server rendering at
|
||||
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
|
||||
* confined to two text nodes, which carry suppressHydrationWarning below.
|
||||
*/
|
||||
const [milestones] = useState<AdmissionsMilestone[]>(() => {
|
||||
const measured = ADMISSIONS_CHIPS.map(chip => ({
|
||||
chip,
|
||||
days: daysUntil(chip.month, chip.day),
|
||||
dateLabel: formatCountdownDate(chip.month, chip.day),
|
||||
shortDate: formatShortDate(chip.month, chip.day),
|
||||
}));
|
||||
measured.sort((a, b) => a.days - b.days);
|
||||
return measured;
|
||||
});
|
||||
|
||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||
const isLocationSearch = !!searchParams.get('postcode');
|
||||
@@ -239,13 +286,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
||||
useEffect(() => {
|
||||
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
||||
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
||||
setSortedChips(withDays);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
track('results_load_more', { next_page: currentPage + 1 });
|
||||
@@ -334,6 +374,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
});
|
||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||
|
||||
/*
|
||||
* The coverage figure, or nothing at all.
|
||||
*
|
||||
* This used to be the string "24,000+", hardcoded in three places while the
|
||||
* database held 27,230 — and the fact box that was meant to show the live
|
||||
* number silently rendered its fallback, because DataInfoResponse declared a
|
||||
* `total_schools` field the API has never sent. Now there is one source: if
|
||||
* the real count is unavailable the sentence simply omits it rather than
|
||||
* inventing a floor.
|
||||
*/
|
||||
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
|
||||
|
||||
/*
|
||||
* The headline milestone is the next thing a parent can actually miss — an
|
||||
* application deadline. Offer days are dates you receive something on, so
|
||||
* they belong in the supporting line, not in the page's largest numeral.
|
||||
* Four equally-sized cards counting down to things 245 days away was the
|
||||
* loudest element on the page and asked nothing of anyone.
|
||||
*/
|
||||
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
|
||||
const laterMilestones = milestones.filter(m => m !== nextDeadline);
|
||||
|
||||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||
// split which surface drives compare adds.
|
||||
const addSchoolFromSearch = useCallback((school: School) => {
|
||||
@@ -346,12 +408,18 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
}, [addSchool, selectedSchools.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.homeView}>
|
||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||
CSS); the search arrangement is a filter bar directly above its results
|
||||
and wants none of it. */
|
||||
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
||||
{/* 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. */}
|
||||
which is why FilterBar is rendered in two places rather than moved.
|
||||
The hero and the four value props are one group, so they sit closer
|
||||
together than the page's section gap. */}
|
||||
{!isSearchActive ? (
|
||||
<div className={styles.heroGroup}>
|
||||
<section className={styles.heroPanel}>
|
||||
<div className={styles.heroContent}>
|
||||
<span className={styles.heroEyebrow}>
|
||||
@@ -369,7 +437,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
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
|
||||
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
|
||||
side, in one place.
|
||||
</span>
|
||||
</p>
|
||||
@@ -383,20 +451,39 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
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>
|
||||
|
||||
{/*
|
||||
Two drawings, one visible — see the note at the top of
|
||||
Illustration.tsx. The hero art slot ranges from about 1.34:1 to
|
||||
4.9:1 across the breakpoints, which no single composition survives.
|
||||
*/}
|
||||
<div className={styles.heroArt}>
|
||||
<HeroIllustration />
|
||||
<div className={styles.heroArtWide}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
<div className={styles.heroArtBand}>
|
||||
<HeroIllustrationBand />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why this site, in four lines. These titles are labels on a list,
|
||||
not section headings — as <h2> they outranked the page's real
|
||||
headings in the document outline and gave a screen-reader user
|
||||
four false landmarks before any content. */}
|
||||
<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>
|
||||
<p className={styles.propTitle}>{title}</p>
|
||||
<p className={styles.propBody}>{body}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
@@ -408,94 +495,55 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Why this site, in four lines. Only on the landing page. */}
|
||||
{/* Next admissions deadline — one bar, not four cards. */}
|
||||
{!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 && (
|
||||
<section className={styles.admissionsStrip}>
|
||||
<div className={styles.stripHeader}>
|
||||
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
||||
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
||||
</div>
|
||||
<div
|
||||
className={styles.countdownRail}
|
||||
style={{
|
||||
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{sortedChips.map(({ chip, days }) => {
|
||||
const isUrgent = days !== null && days <= 14;
|
||||
const chipClass = [
|
||||
styles.countdownChip,
|
||||
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
||||
isUrgent ? styles.countdownChipUrgent : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const trackClass = [
|
||||
styles.chipTrack,
|
||||
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
||||
].join(' ');
|
||||
return (
|
||||
<div key={chip.milestone} className={chipClass}>
|
||||
<span className={trackClass}>
|
||||
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||
{chip.track}
|
||||
<section className={styles.admissions} aria-label="Admissions deadlines">
|
||||
{nextDeadline && (
|
||||
<>
|
||||
<a
|
||||
href="/admissions"
|
||||
className={
|
||||
nextDeadline.days <= 14
|
||||
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
|
||||
: styles.nextDeadline
|
||||
}
|
||||
>
|
||||
<span className={styles.nextDeadlineBody}>
|
||||
<span className={styles.nextDeadlineKicker}>
|
||||
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
|
||||
</span>
|
||||
<div>
|
||||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||
</div>
|
||||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||
<div className={styles.chipDate}>
|
||||
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
|
||||
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
|
||||
{nextDeadline.dateLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.nextDeadlineCount}>
|
||||
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
|
||||
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
|
||||
</span>
|
||||
{nextDeadline.days !== 0 && (
|
||||
<span className={styles.nextDeadlineUnit}>days left</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
<p className={styles.laterDates} suppressHydrationWarning>
|
||||
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
||||
{' '}
|
||||
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Secondary discovery — moved below deadlines so the admissions
|
||||
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||
<div className={styles.exploringRow}>
|
||||
<span className={styles.exploringLabel}>Start exploring</span>
|
||||
<div className={styles.exploringChips}>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated primary schools
|
||||
</a>
|
||||
<a href="/rankings" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Top-rated secondary schools
|
||||
</a>
|
||||
<a href="/compare" className={styles.exploringChip}>
|
||||
<span className={styles.chipDot} aria-hidden="true" />
|
||||
Start a comparison
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||
{!isSearchActive && howItWorks}
|
||||
{!isSearchActive && editorial}
|
||||
|
||||
{/* Results Section */}
|
||||
{/* Results Section. Skipped entirely on the landing page when there is
|
||||
nothing to list — an empty <section> is still a flex child, so it was
|
||||
contributing a full section gap of blank space above the footer. */}
|
||||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||
<div className={styles.sectionHeader}>
|
||||
@@ -681,6 +729,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
|
||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||
];
|
||||
/*
|
||||
* The columns are deliberately unnamed.
|
||||
*
|
||||
* They previously carried two real school names — "Our Lady Queen of Heaven"
|
||||
* and "St Mary's Catholic Primary" — beside invented results and invented
|
||||
* Ofsted grades. On a site whose entire proposition is official DfE and
|
||||
* Ofsted data, publishing made-up figures against identifiable schools is
|
||||
* the one thing it cannot do, and no illustrative intent survives being
|
||||
* screenshotted. Generic labels make the layout point just as well.
|
||||
*/
|
||||
const compareRows = [
|
||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
|
||||
|
||||
return (
|
||||
<section className={styles.howItWorks}>
|
||||
<div className={styles.hiwHeader}>
|
||||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||||
{/* One header pattern across every band on this page: kicker, heading,
|
||||
optional aside. The page previously ran four different treatments
|
||||
(two uppercase micro-labels — one left, one centred — a large heading
|
||||
with right-aligned grey text, and a kicker-plus-heading), which is
|
||||
most of why a designed page read as a stack of unrelated strips. */}
|
||||
<div className={styles.sectionHead}>
|
||||
<p className={styles.sectionKicker}>Every school page</p>
|
||||
<h2 className={styles.sectionHeading}>What you'll see on every school</h2>
|
||||
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
|
||||
</div>
|
||||
<div className={styles.hiwGrid}>
|
||||
{/* Card 1 — Performance */}
|
||||
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
|
||||
<div className={styles.comparePreview}>
|
||||
<div className={styles.compareHead}>
|
||||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||||
<div className={styles.compareHeadCell}>School A</div>
|
||||
<div className={styles.compareHeadCell}>School B</div>
|
||||
</div>
|
||||
{compareRows.map(({ label, a, b, aHi }) => (
|
||||
<div key={label} className={styles.compareRow}>
|
||||
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
|
||||
<span className={styles.compareRowVal}>{b}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||||
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hiwCardBody}>
|
||||
<div className={styles.hiwStep}>Compare</div>
|
||||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||||
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
<div className={styles.hiwTitle}>Side by side</div>
|
||||
{/* One verb for one feature. The site previously called this
|
||||
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
||||
which reads as three separate things it does not have. */}
|
||||
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,90 +3,121 @@
|
||||
*
|
||||
* 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,
|
||||
* a school" — a path climbing 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.
|
||||
*
|
||||
* TWO COMPOSITIONS, ON PURPOSE
|
||||
* ----------------------------
|
||||
* The hero art slot is not one shape. On a wide desktop it is roughly 1.5:1;
|
||||
* at the 1024px breakpoint about 1.34:1; on a phone it collapses to a band
|
||||
* near 2.6:1, and just above the one-column breakpoint it is nearly 5:1.
|
||||
*
|
||||
* A single drawing cannot cover 1.34:1 → 4.9:1. With `slice`, the SVG scales
|
||||
* to cover and crops the overflow, anchored here to the bottom edge — so at
|
||||
* 860×176 a 540×520 scene shows only its bottom 110 user units. That is what
|
||||
* used to happen: the schoolhouse sat at y 218–324 and was cropped away
|
||||
* entirely on phones, leaving bare hills and a pin pointing at nothing.
|
||||
*
|
||||
* So there are two drawings and CSS shows exactly one. They are both static
|
||||
* server-rendered SVG, so this costs markup and nothing else — no JS, no
|
||||
* second request, no layout shift. Each is composed against the crop window
|
||||
* its own breakpoint actually produces; see the safe-zone note on each.
|
||||
*
|
||||
* Colour comes from the --ill-* tokens in globals.css, which are re-graded for
|
||||
* the dark theme. Everything else about the scene is identical in both themes.
|
||||
*/
|
||||
|
||||
/*
|
||||
* 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).
|
||||
/** Scoped so the gradient ids can't collide with another inline SVG. */
|
||||
const SKY_TALL_ID = 'sc-hero-sky-tall';
|
||||
const SKY_BAND_ID = 'sc-hero-sky-band';
|
||||
|
||||
function SkyGradient({ id }: { id: string }) {
|
||||
return (
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="var(--ill-sky-high)" />
|
||||
<stop offset="1" stopColor="var(--ill-sky-low)" />
|
||||
</linearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schoolhouse. Drawn once, placed by each composition.
|
||||
*
|
||||
* Detail deliberately below what it was: the gable clock, the flagpole
|
||||
* pennant and the four-window grid are gone. At the sizes this actually
|
||||
* renders they read as noise, and they were what tipped the drawing from
|
||||
* "soft brand illustration" into clip art.
|
||||
*/
|
||||
const SKY_HIGH = '#DAEDF8';
|
||||
const SKY_LOW = '#EFF8FB';
|
||||
const CLOUD = '#FFFFFF';
|
||||
function Schoolhouse({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y}) scale(${scale})`}>
|
||||
<rect x="12" y="34" width="98" height="58" rx="6" fill="var(--ill-wall)" />
|
||||
<path d="M2 36 L61 6 L120 36 Z" fill="var(--ill-roof)" />
|
||||
<rect x="52" y="60" width="20" height="32" rx="3" fill="var(--ill-door)" />
|
||||
<rect x="26" y="48" width="18" height="16" rx="4" fill="var(--ill-window)" />
|
||||
<rect x="80" y="48" width="18" height="16" rx="4" fill="var(--ill-window)" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
/** The "you are here" pin. The only saturated thing in either composition. */
|
||||
function Pin({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y}) scale(${scale})`}>
|
||||
<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="var(--ill-pin)"
|
||||
/>
|
||||
<circle cx="21" cy="15.5" r="8" fill="var(--ill-pin-eye)" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop composition — the two-column hero panel.
|
||||
*
|
||||
* Crop window: `slice` anchored to the bottom, so the widest slot (about
|
||||
* 590×384) reveals y ∈ [169, 520] and the narrowest (about 430×320) reveals
|
||||
* y ∈ [118, 520]. Everything that carries meaning is therefore kept at
|
||||
* y ≥ 200; the sky above it exists only to be cropped.
|
||||
*/
|
||||
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>
|
||||
<SkyGradient id={SKY_TALL_ID} />
|
||||
</defs>
|
||||
|
||||
{/* Sky */}
|
||||
<rect width="540" height="520" fill={`url(#${SKY_GRADIENT_ID})`} />
|
||||
<rect width="540" height="520" fill={`url(#${SKY_TALL_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" />
|
||||
{/* Two soft clouds. Low opacity so they never compete with the pin. */}
|
||||
<g fill="var(--ill-cloud)" opacity="0.7">
|
||||
<ellipse cx="96" cy="196" rx="34" ry="14" />
|
||||
<ellipse cx="124" cy="189" rx="24" ry="17" />
|
||||
<ellipse cx="424" cy="168" rx="28" ry="12" />
|
||||
<ellipse cx="445" cy="162" 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} />
|
||||
<path d="M0 300 C 90 264 168 294 246 276 C 330 256 410 282 540 254 L540 520 L0 520 Z" fill="var(--ill-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="var(--ill-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="var(--ill-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="var(--ill-hill-front)" />
|
||||
|
||||
{/* Winding cream path, with a dotted edge for a little texture */}
|
||||
{/* The path climbs from the near edge up to the school */}
|
||||
<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}
|
||||
stroke="var(--ill-path)"
|
||||
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}
|
||||
stroke="var(--ill-path-edge)"
|
||||
strokeWidth="18"
|
||||
fill="none"
|
||||
strokeLinecap="butt"
|
||||
@@ -95,52 +126,87 @@ export function HeroIllustration() {
|
||||
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>
|
||||
<Schoolhouse x={276} y={232} />
|
||||
|
||||
{/* Scattered rounded trees */}
|
||||
<g fill={TREE_DARK}>
|
||||
<g fill="var(--ill-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}>
|
||||
<g fill="var(--ill-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}>
|
||||
<g fill="var(--ill-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">
|
||||
<g fill="var(--ill-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} />
|
||||
<Pin x={196} y={372} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Band composition — the one-column hero, where the art becomes a short strip
|
||||
* beneath the search.
|
||||
*
|
||||
* Crop window: at 358×136 (a phone) `slice` reveals x ∈ [42, 358] of the
|
||||
* 400-unit width; at 860×176 (just under the one-column breakpoint) it reveals
|
||||
* the full width but only y ∈ [38, 120]. The intersection — x ∈ [70, 330],
|
||||
* y ∈ [38, 120] — is where the school and the pin sit. Hills and trees run to
|
||||
* the edges precisely because they are the parts that may be cropped.
|
||||
*/
|
||||
export function HeroIllustrationBand() {
|
||||
return (
|
||||
<svg viewBox="0 0 400 120" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<SkyGradient id={SKY_BAND_ID} />
|
||||
</defs>
|
||||
|
||||
<rect width="400" height="120" fill={`url(#${SKY_BAND_ID})`} />
|
||||
|
||||
{/* Hills sit low so the safe zone stays clear for the building */}
|
||||
<path d="M0 74 C 70 62 120 76 176 70 C 244 62 318 74 400 62 L400 120 L0 120 Z" fill="var(--ill-hill-far)" />
|
||||
<path d="M0 92 C 84 82 140 94 208 88 C 286 80 340 92 400 84 L400 120 L0 120 Z" fill="var(--ill-hill-mid)" />
|
||||
<path d="M0 108 C 96 100 168 110 248 104 C 322 98 356 106 400 102 L400 120 L0 120 Z" fill="var(--ill-hill-near)" />
|
||||
|
||||
{/* A shorter path — the same idea at band scale, without the switchback,
|
||||
which turns to mush below about 40 user units of height. */}
|
||||
<path
|
||||
d="M150 120 C 156 104 186 100 202 92 C 216 84 210 76 216 70"
|
||||
stroke="var(--ill-path)"
|
||||
strokeWidth="9"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
<Schoolhouse x={186} y={30} scale={0.52} />
|
||||
|
||||
<g fill="var(--ill-tree-dark)">
|
||||
<circle cx="58" cy="86" r="15" />
|
||||
<circle cx="78" cy="94" r="11" />
|
||||
<circle cx="342" cy="80" r="13" />
|
||||
</g>
|
||||
<g fill="var(--ill-tree-light)">
|
||||
<circle cx="40" cy="98" r="12" />
|
||||
<circle cx="360" cy="92" r="10" />
|
||||
<circle cx="300" cy="100" r="11" />
|
||||
</g>
|
||||
<g fill="var(--ill-tree-pale)" opacity="0.9">
|
||||
<circle cx="112" cy="104" r="9" />
|
||||
<circle cx="266" cy="108" r="8" />
|
||||
</g>
|
||||
|
||||
<Pin x={124} y={70} scale={0.62} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+20
-4
@@ -444,11 +444,27 @@ export interface MetricsResponse {
|
||||
metrics: MetricDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/data-info.
|
||||
*
|
||||
* The school count field is `unique_schools`. This interface declared
|
||||
* `total_schools` instead — a field the API has never sent — so every read of
|
||||
* it was `undefined` at runtime while type-checking cleanly, and the landing
|
||||
* page silently fell back to a hardcoded "24,000+" against a real 27,230.
|
||||
* Nothing threw and no test failed, because the interface, not the code, was
|
||||
* the thing that was wrong. Keep these names matched to backend/app.py.
|
||||
*/
|
||||
export interface DataInfoResponse {
|
||||
total_schools: number;
|
||||
years_available: number[];
|
||||
latest_year: number;
|
||||
total_records: number;
|
||||
status: 'loaded' | 'no_data';
|
||||
data_source: string;
|
||||
/** Absent on the `no_data` branch — see backend/app.py:1015. Optional here
|
||||
* so callers are forced to handle the case instead of reading undefined. */
|
||||
unique_schools?: number;
|
||||
years_available?: number[];
|
||||
total_records?: number;
|
||||
schools_per_year?: Record<string, number>;
|
||||
local_authorities?: Record<string, number>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user