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>
213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
/**
|
||
* Brand illustration — the landing hero's landscape.
|
||
*
|
||
* Guideline rules for illustration: no people or faces, soft shapes, rounded
|
||
* corners, minimal detail, maximum clarity. The scene reads as "the journey to
|
||
* a school" — a 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.
|
||
*/
|
||
|
||
/** 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.
|
||
*/
|
||
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>
|
||
);
|
||
}
|
||
|
||
/** 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>
|
||
<SkyGradient id={SKY_TALL_ID} />
|
||
</defs>
|
||
|
||
<rect width="540" height="520" fill={`url(#${SKY_TALL_ID})`} />
|
||
|
||
{/* 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="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)" />
|
||
|
||
{/* 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="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="var(--ill-path-edge)"
|
||
strokeWidth="18"
|
||
fill="none"
|
||
strokeLinecap="butt"
|
||
strokeLinejoin="round"
|
||
strokeDasharray="0.5 26"
|
||
opacity="0.5"
|
||
/>
|
||
|
||
<Schoolhouse x={276} y={232} />
|
||
|
||
{/* Scattered rounded trees */}
|
||
<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="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="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="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>
|
||
|
||
<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>
|
||
);
|
||
}
|