Merge pull request 'fix(home): correct what the landing page claims, and give it one rhythm' (#91) from fix/homepage-truth-and-rhythm into feat/brand-logo-artwork

Reviewed-on: #91
This commit was merged in pull request #91.
This commit is contained in:
tudor committed 2026-08-14 16:16:25 +00:00
commit 8636c96d35
11 files changed
+849 -569

No files matched your search

+126
View File
@@ -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 });
+71 -2
View File
@@ -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
View File
@@ -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}
/>
)}
/>
);
}
+2 -2
View File
@@ -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',
+39 -33
View File
@@ -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&apos;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&apos;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 &amp; 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>
);
+3 -1
View File
@@ -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>
+225 -323
View File
@@ -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
View File
@@ -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>
);
}
+27 -8
View File
@@ -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&apos;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&apos;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&apos;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>
+148 -82
View File
@@ -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
View File
@@ -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;
}
// ============================================================================