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

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:
TudorandClaude Opus 5 committed 2026-08-14 17:29:47 +01:00
1 parent 6b117dd26c
commit dc22fd2853
11 files changed
+849 -569

No files matched your search

+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>
);
}