fix(home): correct what the landing page claims, and give it one rhythm
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
9ee4d45a94
commit
7fa2a76c05
11 files changed
+849
-569
No files matched your search
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user