Compare commits

..
Author SHA1 Message Date
tudor 8636c96d35 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
2026-08-14 16:16:25 +00:00
TudorandClaude Opus 5 7fa2a76c05 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>
2026-08-14 17:15:09 +01:00
25 changed files with 347 additions and 436 deletions

No files matched your search

+34 -48
View File
@@ -140,73 +140,59 @@ test('the landing bands share one vertical rhythm', async ({ page }) => {
});
/*
* The hero artwork. Every assertion here covers a failure that renders as a
* perfectly valid page: a path that 404s leaves an empty box, a crop that
* stops switching silently decapitates the subject, and a format negotiation
* that regresses just serves a bigger file.
* 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.
*/
function heroImageState(page: Page) {
return page.evaluate(() => {
const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null;
if (!img) return null;
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 {
// naturalWidth is 0 for an image that failed to load, whatever the src says.
loaded: img.complete && img.naturalWidth > 0,
file: img.currentSrc.split('/').pop() ?? '',
wide: wide ? getComputedStyle(wide).display : 'missing',
band: band ? getComputedStyle(band).display : 'missing',
};
});
}
test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
const wide = await heroImageState(page);
expect(wide).not.toBeNull();
expect(wide!.loaded).toBe(true);
expect(wide!.file).toContain('hero-wide');
expect(await visible()).toEqual({ wide: 'block', band: 'none' });
/*
* Below the one-column breakpoint the slot becomes a band up to 4.9:1. The
* desktop frame centre-cropped to that shape loses the schoolhouse, which is
* how the previous hero failed on phones — so <picture> must switch to the
* pre-cropped band file, not merely to a smaller version of the same frame.
*/
// 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('/');
const band = await heroImageState(page);
expect(band!.loaded).toBe(true);
expect(band!.file).toContain('hero-band');
expect(await visible()).toEqual({ wide: 'none', band: 'block' });
});
test('the hero artwork is served in a modern format', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
const state = await heroImageState(page);
// The JPEG exists only as the <img> fallback. Chromium takes AVIF, so seeing
// the fallback here means the <source> negotiation broke — which costs about
// 24kB extra on the LCP element and nothing visible.
expect(state!.file).toMatch(/\.(avif|webp)$/);
});
test('the hero artwork is dimmed for the dark theme', async ({ browser }) => {
const filterFor = async (colorScheme: 'light' | 'dark') => {
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(() => {
const img = document.querySelector('[class*="heroArt"] img');
return img ? getComputedStyle(img).filter : 'missing';
});
const value = await p.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--ill-sky-high').trim()
);
await ctx.close();
return value;
};
// The artwork is a fixed raster, so it cannot be re-graded per theme the way
// the drawn hero was. Undimmed it is the brightest object on a near-black
// page and out-shouts the H1 and the search box — the exact regression the
// drawn version shipped with.
expect(await filterFor('light')).toBe('none');
expect(await filterFor('dark')).toMatch(/brightness/);
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 }) => {
+51 -18
View File
@@ -188,18 +188,33 @@
--section-gap: 4rem;
--section-head-gap: 1.5rem;
/* ── Hero artwork ───────────────────────────────────────────────────
The landing hero is now a supplied raster illustration rather than a
drawn SVG, so its colours are in the file, not here. What is left is the
ground the artwork sits on and fades into.
--hero-ground is sampled from the artwork's own copy area (#FDF9F3),
not from Sand. The artwork reserves an empty cream region on the left
for the headline, and the scrim that guarantees text contrast fades from
this colour into it — a scrim in Sand (#F5EFE6, eight to thirteen points
darker per channel) leaves a visible seam straight down the hero. */
--hero-ground: #FDF9F3;
--hero-ground-rgb: 253, 249, 243;
/* ── 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
@@ -325,12 +340,30 @@
--medal-silver-rgb: 169, 182, 188;
--medal-bronze-rgb: 201, 144, 112;
/* The artwork is a fixed, bright raster — it cannot be re-graded by
token the way the drawn version was. The dark theme instead dims it
in CSS (see .heroArt in HomeView.module.css) and fades it into this
ground, which is the panel colour rather than the artwork's cream. */
--hero-ground: #16222A;
--hero-ground-rgb: 22, 34, 42;
/* 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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

+1 -5
View File
@@ -98,11 +98,7 @@
font-size: var(--step--1);
font-weight: 700;
line-height: 1;
/* --sage flips with the theme; this band does not (it is teal in both), so
the pairing only held in one of them — the dark sage measured 4.08:1 here
against a 4.5 floor, on every page. The --on-sunken-* family exists
precisely because this surface is theme-invariant. */
color: var(--on-sunken-muted);
color: var(--sage);
text-transform: uppercase;
letter-spacing: 0.1em;
}
+50 -127
View File
@@ -77,104 +77,52 @@
}
/* ── Hero ──────────────────────────────────────────────────────────────────
The artwork fills the panel and the proposition sits on top of it, in the
empty cream area the illustration reserves on its left. It is not a
two-column grid: splitting the panel would crop that reserved area off and
shrink the scene into a thumbnail, which is the one thing the artwork is
composed not to be.
Below the one-column breakpoint the artwork leaves the background and
becomes a band under the search — see the 860px block. */
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
phones it drops to a short band under the content rather than competing
with the search for the fold. */
.heroPanel {
position: relative;
isolation: isolate;
background: var(--hero-ground);
display: grid;
grid-template-columns: 1.12fr 0.88fr;
align-items: stretch;
background: var(--bg-secondary);
border-radius: var(--radius-xl);
overflow: hidden;
}
.heroContent {
position: relative;
z-index: 2;
padding: 3.5rem 1.5rem 3.5rem 3rem;
/* Held inside the artwork's cream area. Beyond roughly this width the text
runs onto the hillside, where the scrim below is doing all the work. */
max-width: min(56%, 40rem);
align-self: center;
padding: 3rem 1.5rem 3rem 3rem;
min-width: 0;
}
.heroArt {
position: absolute;
inset: 0;
z-index: 0;
position: relative;
min-width: 0;
/* The scene needs height to read; without a floor it collapses to the
content column's height on short viewports. */
min-height: 24rem;
}
.heroArt picture,
.heroArt img {
display: block;
/* Both compositions fill the slot; the breakpoint below picks one. */
.heroArtWide,
.heroArtBand {
position: absolute;
inset: 0;
}
.heroArt svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.heroArt img {
object-fit: cover;
/* Centre keeps the schoolhouse in frame at every desktop panel ratio: the
panel runs about 2.1:1 to 2.7:1 against a 1.78:1 image, so cover crops
height only, and the school sits well inside the surviving band. */
object-position: center;
}
/*
* Contrast guarantee.
*
* The artwork's cream area is generous but it is a fixed image on a fluid
* panel — as the panel widens the text block and the cream drift apart, and at
* some width the headline would sit on hillside green. Rather than tune the
* text width per breakpoint and hope, the panel fades its own ground across
* the left of the artwork, so the copy always has a known surface under it.
*
* The gradient starts from --hero-ground, which is sampled from the artwork
* itself, so in the light theme this is invisible — it reads as the
* illustration, not as an overlay on it.
*/
.heroPanel::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(
to right,
var(--hero-ground) 0%,
rgba(var(--hero-ground-rgb), 0.97) 26%,
rgba(var(--hero-ground-rgb), 0.72) 44%,
rgba(var(--hero-ground-rgb), 0) 62%
);
}
/*
* Dark theme.
*
* The artwork is a fixed raster, so unlike the drawn hero it cannot be
* re-graded token by token — but the failure it would otherwise reproduce is
* the same one: a bright illustration is the brightest object on a near-black
* page and out-shouts the H1 and the search box. Dimming it in CSS is the
* whole treatment, and the scrim then fades it into the dark panel instead of
* into cream.
*/
@media (prefers-color-scheme: dark) {
.heroArt img {
filter: brightness(0.52) saturate(0.72) contrast(1.02);
}
.heroPanel::before {
background: linear-gradient(
to right,
var(--hero-ground) 0%,
rgba(var(--hero-ground-rgb), 0.96) 30%,
rgba(var(--hero-ground-rgb), 0.66) 50%,
rgba(var(--hero-ground-rgb), 0.15) 78%
);
}
/* 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;
}
.heroEyebrow {
@@ -185,10 +133,7 @@
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
/* --brand on its own 10% tint over Sand measures 4.19:1 — the tint darkens
the ground under the label, and the token was specced against the ground,
not against the tint. --brand-strong on the same composite is 5.85:1. */
color: var(--brand-strong);
color: var(--brand);
background: rgba(var(--brand-rgb), 0.1);
padding: 0.3rem 0.7rem;
border-radius: 999px;
@@ -236,41 +181,33 @@
@media (max-width: 1024px) {
.heroContent {
padding: 2.75rem 1.5rem 2.75rem 2rem;
max-width: min(64%, 34rem);
padding: 2.25rem 1.5rem 2.25rem 2rem;
}
.heroArt {
min-height: 20rem;
}
}
/*
* One column: the artwork stops being a background and becomes a band under
* the search.
*
* Overlaying at this width does not work — the panel is too narrow for the
* copy to stay inside the artwork's cream area, so the scrim would have to
* cover nearly the whole image, which is just a tinted rectangle with a
* picture faintly behind it. Below the band, <picture> has already switched
* to the pre-cropped file (see Illustration.tsx); this is the layout half of
* the same decision.
*/
/* One column: content first, then a short band of landscape. */
@media (max-width: 860px) {
.heroPanel {
grid-template-columns: 1fr;
}
.heroContent {
padding: 2rem 1.5rem 1.75rem;
max-width: none;
}
/* 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 {
position: static;
height: 11rem;
min-height: 11rem;
order: 2;
}
/* The band crop centres the schoolhouse at about 65% across, so squeezing
toward 4.9:1 crops the empty sides rather than the subject. */
.heroArt img {
object-position: 65% center;
}
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
it here, and leaving it would wash the band out. */
.heroPanel::before {
.heroArtWide {
display: none;
}
.heroArtBand {
display: block;
}
.heroTitle {
font-size: var(--step-3);
max-width: none;
@@ -306,7 +243,7 @@
margin-bottom: 0.5rem;
}
.heroArt {
height: 8.5rem;
min-height: 8.5rem;
}
}
@@ -842,19 +779,8 @@
gap: 0.9rem;
}
/*
* The preview panel sits on the card surface, not on Sand.
*
* Everything inside it is a translucent status or brand tint, and those tokens
* are specced to clear AA over --bg-card. Over Sand the same tints composite
* darker, which put the report-card chips at 4.22:1 and 4.29:1 against a 4.5
* floor — the ratio depended on a ground two levels up. On the card surface
* they are 5.69:1 and 5.81:1. The border keeps it reading as an inset frame
* now that panel and card share a colour.
*/
.hiwVisual {
background: var(--bg-card);
border: 1px solid var(--border);
background: var(--bg-secondary);
border-radius: 10px;
padding: 0.9rem;
min-height: 180px;
@@ -1200,10 +1126,7 @@
.compareHeadLabel {
font-family: inherit;
font-size: 0.48rem;
/* --text-muted on the header row's tint is 4.45:1 in the dark theme —
under the floor by a hair, because the tint darkens the ground beneath
the label. --text-secondary clears it on both grounds. */
color: var(--text-secondary);
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
+10 -6
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';
@@ -454,13 +454,17 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</div>
{/*
Rendered after the content on purpose. On desktop it is absolutely
positioned behind the panel, so order does not matter; below the
one-column breakpoint it returns to normal flow, where DOM order is
what puts the band under the search rather than above the headline.
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>
+195 -86
View File
@@ -1,103 +1,212 @@
/**
* The landing hero artwork.
* Brand illustration — the landing hero's landscape.
*
* This is the supplied illustration, not a drawing of one — see
* assets/hero-source.png and scripts/build-hero-images.js, which produces
* everything under public/brand/hero-*. Regenerate rather than hand-editing.
* 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.
*
* Server-safe by construction: no 'use client', no hooks, no handlers, so it
* renders in the RSC pass and never reaches the client bundle.
* 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 CROPS, ONE <picture>
* ------------------------
* The slot is two different shapes. On desktop the artwork sits behind the
* whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
* area the illustration reserves on its left. Below the one-column breakpoint
* it becomes a band between 2.6:1 and 4.9:1, under the search rather than
* behind it.
* 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 file under `object-fit: cover` centre-crops, and at the band's
* extreme that slices a strip through the middle of the scene and loses the
* schoolhouse — exactly how the previous SVG hero failed on phones. So the
* <source media> switches crop, not just resolution: `band` is pre-cropped
* around the school and is already near 2.6:1, so the narrow band barely
* crops it further.
* 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.
*
* `object-position` is set in CSS per breakpoint and is load-bearing — see
* .heroArt in HomeView.module.css.
* 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.
*
* The artwork is decorative: the proposition beside it carries the meaning, so
* alt is empty and it is hidden from assistive tech rather than described.
* 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.
*/
/*
* The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
* padding (1rem under 768px), so the artwork's box is that minus the padding —
* describing it as 100vw over-requests by ~48px worth of candidate at every
* width, which on a slow connection is a larger file than needed for the LCP
* element.
*/
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
/** 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';
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
const WIDE_W = 1672;
const WIDE_H = 941;
export function HeroIllustration() {
function SkyGradient({ id }: { id: string }) {
return (
<picture>
{/* Band crop first: <source> is first-match-wins, so the narrow-viewport
rules have to precede the unconstrained desktop ones. */}
<source
media="(max-width: 860px)"
type="image/avif"
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
sizes={SIZES}
<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)"
/>
<source
media="(max-width: 860px)"
type="image/webp"
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
sizes={SIZES}
<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"
/>
{/*
The band's JPEG, and the reason it exists.
<img src> cannot vary by viewport, so it is always the wide crop. A
browser that takes neither AVIF nor WebP would therefore fall through
to the desktop frame on a phone and lose the schoolhouse — the exact
failure this whole component is arranged to prevent, surviving in the
one path nobody looks at. No `type` here, so it matches anywhere the
media query does, and it sits after the modern formats so they still
win where supported.
*/}
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
<source
type="image/avif"
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
sizes={SIZES}
<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"
/>
<source
type="image/webp"
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
sizes={SIZES}
<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"
/>
{/*
The hero is the largest thing above the fold, so it is almost certainly
the LCP element: fetchPriority high, and never lazy. eslint's
no-img-element wants next/image, which cannot art-direct between two
different crops — that is the whole point of the <picture> above.
*/}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/brand/hero-wide-1200.jpg"
alt=""
aria-hidden="true"
width={WIDE_W}
height={WIDE_H}
decoding="async"
fetchPriority="high"
/>
</picture>
<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>
);
}
+5 -6
View File
@@ -31,8 +31,7 @@
"@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.21",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"sharp": "^0.34.5"
"jest-environment-jsdom": "^30.2.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -938,8 +937,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"devOptional": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -4161,8 +4160,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -8585,9 +8584,9 @@
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -8630,8 +8629,8 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"devOptional": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
+1 -2
View File
@@ -36,7 +36,6 @@
"@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.21",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"sharp": "^0.34.5"
"jest-environment-jsdom": "^30.2.0"
}
}
-35
View File
@@ -1,35 +0,0 @@
# Brand asset provenance
Every image shipped in this folder, where it came from, and what we are allowed
to do with it. Add a row before adding a file — in two years nobody will
remember, and "we think it was fine" is not a licence.
## `mark.png`, `mark-on-dark.png`
The schoolcompare mark, extracted from the logo sheet supplied by the project
owner. Two colourways on one 176×208 canvas — see `components/Logo.tsx` for why
they must share a canvas.
- **Source:** supplied by the project owner
- **Licence:** owned by the project
> Only a 153×189 instance exists on the supplied sheet, so the 512px PWA icon is
> upscaled and slightly soft. A vector (SVG/AI/EPS) dropped in here and
> regenerated fixes it; every consumer goes through `components/Logo.tsx` or
> this folder, so it is one swap.
## `hero-wide-*`, `hero-band-*`
The landing hero illustration: a path climbing through hills to a schoolhouse,
with an empty cream area on the left reserved for the headline.
- **Source:** supplied by the project owner
- **Master:** `nextjs-app/assets/hero-source.png` (1672×941 PNG), committed so
the derivatives can be regenerated
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
- **Licence:** **to be confirmed by the project owner**
> ⚠️ The licence line above is the one thing here that is not verified. If this
> artwork was bought, generated, or commissioned, record which — and keep the
> receipt or licence key alongside it. It is the most prominent image on the
> site.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-103
View File
@@ -1,103 +0,0 @@
#!/usr/bin/env node
/**
* Derivatives for the landing hero artwork.
*
* node scripts/build-hero-images.js
*
* Source of truth is assets/hero-source.png (1672×941). It is committed so
* these can be regenerated: never hand-edit anything in public/brand/hero-*,
* change the source or the numbers here and re-run.
*
* TWO CROPS, ON PURPOSE
* ---------------------
* The hero slot is not one shape. On desktop the artwork sits behind the whole
* panel at roughly 2.1:1 → 2.7:1; below the one-column breakpoint it collapses
* to a band between 2.6:1 and 4.9:1. A single file under `object-fit: cover`
* centre-crops, which at the extremes slices a strip through the middle of the
* scene and loses the schoolhouse — the same failure the previous SVG hero had.
*
* So:
* wide the full frame, including the empty cream area the artwork reserves
* on the left for the headline. Used behind the desktop panel.
* band a crop around the schoolhouse and the path, already near 2.6:1, so
* the narrow band barely crops it at all. The school sits at ~65%
* across, which is why the CSS pairs this with object-position: 65%:
* squeezing further toward 4.9:1 crops the empty sides, not the subject.
*
* AVIF and WebP are both emitted. This is flat-shaded vector-style artwork, so
* AVIF lands roughly an order of magnitude under the source PNG. Both crops
* also get a JPEG: the wide one backs the <img src>, and the band one backs a
* <source media> — without it a browser taking neither modern format would
* fall through to the desktop frame on a phone, since <img src> cannot vary by
* viewport.
*
* sharp is declared in devDependencies rather than leaned on as a transitive
* dependency of next. It does currently arrive that way, but relying on that
* means a Next upgrade can silently break this script — the same reasoning as
* the outputFileTracingIncludes note in next.config.js.
*/
const path = require('path');
const fs = require('fs');
const sharp = require('sharp');
const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
const OUT = path.join(__dirname, '..', 'public', 'brand');
/*
* Band crop, in source pixels. The schoolhouse occupies roughly x 1180–1540,
* y 300–520, so this keeps it whole with headroom above the flag and enough
* hillside below to read as landscape rather than as a cutout.
*/
const BAND = { left: 700, top: 260, width: 972, height: 380 };
const WIDE_WIDTHS = [1672, 1200, 900];
const BAND_WIDTHS = [972, 700, 500];
async function emit(pipeline, name, width) {
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
await base.clone().avif({ quality: 62, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.avif`));
await base.clone().webp({ quality: 80, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.webp`));
return width;
}
async function main() {
if (!fs.existsSync(SRC)) throw new Error(`missing source: ${SRC}`);
fs.mkdirSync(OUT, { recursive: true });
const meta = await sharp(SRC).metadata();
console.log(`source ${meta.width}×${meta.height}`);
const wide = sharp(SRC);
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
// JPEG (no alpha) can never composite against black.
await sharp(SRC)
.resize({ width: 1200 })
.flatten({ background: '#FDF9F3' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-wide-1200.jpg'));
const band = sharp(SRC).extract(BAND);
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
await sharp(SRC)
.extract(BAND)
.resize({ width: 700 })
.flatten({ background: '#FDF9F3' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-band-700.jpg'));
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
let total = 0;
for (const f of files) {
const { size } = fs.statSync(path.join(OUT, f));
total += size;
console.log(` ${f.padEnd(24)} ${(size / 1024).toFixed(1)} kB`);
}
console.log(`total ${(total / 1024).toFixed(1)} kB across ${files.length} files`);
}
main().catch(err => {
console.error(err);
process.exit(1);
});