Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 5 9cda1c1174 feat(home): use the supplied hero artwork instead of a drawn one
Replaces the hand-drawn SVG landscape with the illustration supplied by the
project owner. Master is assets/hero-source.png; everything under
public/brand/hero-* comes from scripts/build-hero-images.js and should never be
hand-edited.

LAYOUT

The artwork is composed as a full-bleed hero: it reserves an empty cream area
down its left side for the headline. The old hero was a two-column grid with
art in the right 0.88fr, which would have cropped that reserved area off and
shrunk the scene into a thumbnail — the one thing the picture is built not to
be. So the panel is now a single layered block: artwork behind, copy on top,
and below the one-column breakpoint the artwork leaves the background and
becomes a band under the search.

Overlaying does not survive down to phone widths — the panel gets too narrow
for the copy to stay inside the cream, so the scrim would have to cover nearly
the whole image and you would be left with a tinted rectangle. Hence the
switch at 860px rather than a single treatment stretched across every width.

CONTRAST

The copy sits on a gradient of --hero-ground, a new token sampled from the
artwork's own cream (#FDF9F3) rather than from Sand. Sand is eight to thirteen
points darker per channel, which leaves a visible seam straight down the hero.
The scrim exists because the artwork is a fixed image on a fluid panel: past
some width the headline would otherwise land on hillside green.

Measured on rendered pixels, sampling background up to 120px beyond the right
edge of each line, so a longer line still has margin:

  light  title 14.36:1   body 6.52:1
  dark   title 11.01:1   body 6.44:1

The worst light case is hillside green showing through the scrim at 6.52:1.

TWO CROPS

The slot is two shapes: roughly 2.1:1–2.7:1 behind the desktop panel, and
2.6:1–4.9:1 as the band. A single file under object-fit: cover centre-crops,
and at the band's extreme that slices a strip through the scene and loses the
schoolhouse — exactly how the drawn hero failed on phones. <picture> switches
crop, not just resolution: the band file is pre-cropped around the school and
is already near 2.6:1, with the subject at ~65% across so squeezing toward
4.9:1 crops the empty sides instead.

WEIGHT

1.6 MB PNG in, AVIF and WebP out; a browser fetches exactly one file — about
23–33 kB on desktop, 9–18 kB on a phone. The JPEG is only the <img> fallback.
`sizes` describes the real panel box (max-width 1400 minus padding) rather than
100vw, which over-requested a candidate at every width on the LCP element.

DARK THEME

A raster cannot be re-graded token by token the way the drawing was, but it
would reproduce the same failure — a bright illustration is the brightest
object on a near-black page. It is dimmed in CSS to read as dusk, and the
scrim fades it into the dark panel rather than into cream.

TESTS

The two illustration journeys are replaced by three, each covering a failure
that still renders a valid-looking page: the artwork actually loading
(naturalWidth, not src), the crop switching at the breakpoint, a modern format
winning negotiation, and the dark theme dimming it.

public/brand/CREDITS.md records provenance for every asset in that folder. The
hero's licence line is marked unconfirmed — that one needs the owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:35:10 +01:00
10 changed files with 15 additions and 181 deletions

No files matched your search

+4 -57
View File
@@ -121,33 +121,6 @@ test('the home page has a real title', async ({ page }) => {
expect(await page.title()).toMatch(/schoolcompare/i);
});
test('the site states its independence and never claims to be official', async ({ page }) => {
await page.goto('/');
// Carried in the footer, so it appears on every page rather than only here.
await expect(
page.getByText(/an independent site\.\s*not affiliated with the department for education/i)
).toBeVisible();
/*
* Describing the DATA as official is accurate and appears throughout
* ("official DfE figures", "the official figure isn't in"). Describing the
* SITE as official is not: schoolcompare republishes government data, it is
* not a government service. The difference is the grammatical subject, so
* this looks for text that opens with "Official" as a standalone label —
* the shape a value-prop title or heading takes, and the shape the old
* "Official & trusted" took.
*/
const claims = await page.evaluate(() =>
[...document.querySelectorAll('body *')]
.flatMap(el => [...el.childNodes]
.filter(n => n.nodeType === 3)
.map(n => (n.textContent ?? '').trim()))
.filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t))
);
expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]);
});
test('the landing bands share one vertical rhythm', async ({ page }) => {
await page.goto('/');
const gaps = await page.evaluate(() => {
@@ -816,21 +789,9 @@ test('the brand asset set is complete and served', async ({ page }) => {
expect(og.ok()).toBe(true);
expect(og.headers()['content-type']).toContain('image/png');
/*
* iOS ignores SVG touch icons, so this must be a real raster.
*
* Read the href off the page rather than hardcoding a path. The route moved
* when the generated app/apple-icon.tsx became a static app/apple-icon.png:
* a generated icon is served at /apple-icon, a static one at /apple-icon.png
* with a content hash. This test kept asking for /apple-icon and got a 404 —
* reporting a missing icon while the icon was present and correctly linked.
*/
const appleLink = page.locator('link[rel="apple-touch-icon"]');
await expect(appleLink).toHaveCount(1);
const appleHref = await appleLink.getAttribute('href');
expect(appleHref).toBeTruthy();
const apple = await page.request.get(appleHref!);
expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true);
// iOS ignores SVG touch icons, so this must be a real raster.
const apple = await page.request.get('/apple-icon');
expect(apple.ok()).toBe(true);
expect(apple.headers()['content-type']).toContain('image/png');
// Android needs a maskable PNG or the install prompt has no icon.
@@ -951,22 +912,8 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
// hashed at build time and change on any unrelated edit.
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
/*
* The mark is raster artwork, not an inline SVG — it has been since the
* supplied logo replaced the reconstruction, and this locator went on
* looking for an <svg> that no longer exists.
*
* Assert on naturalWidth rather than visibility: a <picture> whose sources
* all 404 still lays out and still passes toBeVisible(), so the weaker
* assertion would go green on a broken lockup.
*/
const mark = home.locator('img').first();
const mark = home.locator('svg').first();
await expect(mark).toBeVisible();
const markState = await mark.evaluate((el: HTMLImageElement) => ({
loaded: el.complete && el.naturalWidth > 0,
src: el.currentSrc,
}));
expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true);
});
test('no visible text falls back to the browser default black', async ({ page }) => {
@@ -460,30 +460,6 @@
}
}
/*
* Narrow phones: the button drops below the input instead of sharing the row.
*
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
* screen it took 48% of the row and left the input 102px of text space for a
* 194px placeholder — the field showed "School name or" and stopped. At 320px
* the input was down to 32px, which is too narrow to read what you are typing.
*
* Wrapping rather than restructuring: the pill stays one element, so it keeps
* its border, shadow and :focus-within ring, and the button gets a full-width
* tap target on the way. Giving the button a 100% flex-basis is what forces
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
*/
@media (max-width: 480px) {
.heroMode .omniBoxContainer {
flex-wrap: wrap;
gap: 0.5rem;
}
.heroMode .searchButton {
flex: 1 1 100%;
width: 100%;
}
}
@media (max-width: 420px) {
/* Below this the pin costs more room than it earns. */
.heroMode .omniIcon {
-16
View File
@@ -92,22 +92,6 @@
max-width: 34ch;
}
/*
* Quieter than .description, but by size only — not by contrast.
*
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
* fine margin for decorative text and the wrong one for a disclaimer, whose
* whole job is to be legible to someone checking whether this is a government
* site. --on-sunken-muted is 5.66:1 on the same band.
*/
.independence {
margin: 0;
font-size: var(--step--2);
line-height: 1.6;
color: var(--on-sunken-muted);
max-width: 38ch;
}
.sectionTitle {
margin: 0;
font-family: var(--font-display);
-8
View File
@@ -35,14 +35,6 @@ export function Footer() {
<p className={styles.description}>
Compare primary and secondary schools across England.
</p>
{/*
Says once, plainly, what the landing page's value props only
imply: we publish official data, we are not an official body.
Cheap to state and expensive to be wrong about.
*/}
<p className={styles.independence}>
An independent site. Not affiliated with the Department for Education or Ofsted.
</p>
<a
href="mailto:contact@schoolcompare.co.uk"
className={styles.link}
+2 -32
View File
@@ -161,22 +161,10 @@
* 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.
*
* Raising the brightness raises the background under light dark-theme text, so
* this value has a contrast floor, not just a taste range. Measured off
* rendered pixels, sampling background up to 120px past each line:
*
* brightness title body
* 0.52 11.01:1 6.44:1
* 0.75 9.48:1 5.21:1 ← current
*
* Body text is the binding one. Going much above 0.75 walks it toward the
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
* than the artwork being dimmed less.
*/
@media (prefers-color-scheme: dark) {
.heroArt img {
filter: brightness(0.75) saturate(0.72) contrast(1.02);
filter: brightness(0.52) saturate(0.72) contrast(1.02);
}
.heroPanel::before {
background: linear-gradient(
@@ -265,30 +253,12 @@
* the same decision.
*/
@media (max-width: 860px) {
/*
* Artwork above the copy, not below it.
*
* The DOM keeps .heroContent first so the desktop overlay does not depend on
* source order, which left the band stranded under the search on phones —
* reading as a strip stuck on the end rather than as a hero image. `order`
* moves it visually only; it is decorative and aria-hidden, so there is no
* reading order to disturb.
*
* It costs about 136px above the search. Measured on a 667px viewport (the
* shortest phone still in use) the search still lands near 400px, well
* inside the fold.
*/
.heroPanel {
display: flex;
flex-direction: column;
}
.heroContent {
padding: 1.75rem 1.5rem 1.75rem;
padding: 2rem 1.5rem 1.75rem;
max-width: none;
}
.heroArt {
position: static;
order: -1;
height: 11rem;
}
/* The band crop centres the schoolhouse at about 65% across, so squeezing
+1 -14
View File
@@ -157,20 +157,7 @@ const VALUE_PROPS: ValueProp[] = [
{
icon: <ShieldCheckIcon />,
tintClass: styles.propIconTrust,
/*
* "Built on official data", never "Official".
*
* The previous title was "Official & trusted", whose grammatical subject
* is this site — it reads as a claim that schoolcompare is itself an
* official service. It is not: it is an independent site that republishes
* official figures. The distinction is the difference between describing
* the data and describing ourselves, and only the first is true.
*
* Everywhere else the word appears ("official DfE figures", "the official
* figure isn't in") it already qualifies the data, which is correct and
* should stay.
*/
title: 'Built on official data',
title: 'Official & trusted',
body: 'Every figure comes from DfE performance tables and Ofsted.',
},
{
-12
View File
@@ -60,18 +60,6 @@ export function HeroIllustration() {
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
sizes={SIZES}
/>
{/*
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"
+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"
}
}
+2 -10
View File
@@ -25,16 +25,8 @@
* 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.
* AVIF lands roughly an order of magnitude under the source PNG; the JPEG is
* only the <img> fallback and is never served to a current browser.
*/
const path = require('path');