Compare commits

...
Author SHA1 Message Date
tudor c7e0c2eac7 Merge pull request 'feat(home): swap in the higher-fidelity hero artwork' (#98) from feat/hero-artwork-v2 into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 59s
Reviewed-on: #98
2026-08-15 08:56:33 +00:00
TudorandClaude Opus 5 eddf74745f feat(home): swap in the higher-fidelity hero artwork
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Same 16:9 frame, but not a drop-in replacement — the composition, palette and
copy area all moved, and each one drives a change here.

COPY AREA

The old artwork was a flat #FDF9F3 down its whole left side. This one runs
peach #FEE8D2 at the top to cream #FDF3E7 around 43% height, and below ~48%
the left edge is foliage rather than cream at all. --hero-ground is resampled
to #FEF2E1, the middle of the pale run where the headline and search actually
sit; the scrim covers the foliage further down.

Measured on rendered pixels with the text hidden, sampling the real glyph runs
(via Range, not the element boxes) plus a 120px growth margin:

           title      body      byline
  light    13.61:1    5.74:1    6.66:1
  dark      9.49:1    5.45:1    8.35:1

Body drops from 6.52:1 to 5.74:1 as the foliage comes closer, still clear.

BAND CROP

The schoolhouse moved to ~82% across the frame, leaving only ~140px of artwork
to its right, so the band crop is anchored to the right edge and takes 1344px
back — putting the school at 73%, which the build script now derives and
prints rather than leaving it to drift from the CSS.

The crop is also 3.2:1 rather than matching the phone. The band is not one
ratio: it runs 2.4:1 on a phone to about 3.8:1 under the one-column
breakpoint. Cropping at the narrow end means the wide end throws away height,
which cut the flag off the roof and the base off the building. Sitting above
the middle costs a little width on phones — where the crop's left is hillside —
and keeps the building whole where it matters.

BAND HEIGHTS

Raised to 13rem (861–860px) and 10rem (≤640px). The band was widest-per-height
at exactly 640px, where an 8.5rem band measured 4.2:1 — worse than any wider
viewport, because the height steps down at that breakpoint while the width does
not. Ratios across the range are now 2.0 / 2.6 / 3.6 / 3.1 / 3.8 rather than
spiking. The search still lands at 442px on a 667px viewport.

WEIGHT

Source is 3.1MB; a browser fetches one file — 29–59kB on desktop, 10–14kB on a
phone. Widths re-cut for the larger master: 2000/1400/1000 wide, 1344/900/600
band.

Verified: 0 AA failures across both themes at 1440 / 860 / 390, every srcset
entry present on disk with no orphans, tsc clean, 159/159, build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:22:37 +01:00
tudor d5f3fdf0f9 Merge pull request 'feat(home): add a first-person byline under the hero search' (#97) from feat/hero-byline into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m1s
Reviewed-on: #97
2026-08-15 08:00:47 +00:00
TudorandClaude Opus 5 3015c37bac feat(home): add a first-person byline under the hero search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
"Built for parents, by a parent." — the one line on the page written by a
person rather than by a product.

Placed under the search rather than in the footer, which is where it would
have been true and unread. It does its work at the moment someone is deciding
whether to trust a page full of government statistics, which is the moment
they are looking at the search box.

Set apart without shouting: display face, a step down in size, and a short
brand rule in place of a bullet. No italic — Manrope ships none in the loaded
weights, so font-style would be synthesised into a slant, the same reason
.heroEmph resets it.

Deliberately the only claim of its kind on the page. Every other trust signal
here is about the data's provenance and is checkable against the DfE and
Ofsted; this one is about who built it, and is not. So it is stated once,
plainly, and never repeated — the opposite of the "Official & trusted" line it
now sits near, which claimed something about the site that was not true.

Measured 7.26:1 in the light theme and 8.86:1 in dark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 08:59:43 +01:00
tudor 153b26a32f Merge pull request 'fix(home): stop implying we are official, and fix the mobile hero and search' (#96) from fix/hero-mobile-and-wording into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 56s
Reviewed-on: #96
2026-08-14 22:28:33 +00:00
tudor e373241d51 Merge pull request 'fix(e2e): assert the brand lockup and touch icon as they are actually built' (#95) from fix/e2e-brand-assertions into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m2s
Reviewed-on: #95
2026-08-14 22:28:10 +00:00
TudorandClaude Opus 5 4043270a77 fix(home): stop implying we are official, and fix the mobile hero and search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
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 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
Three things, all reported from the live mobile view.

WE ARE NOT AN OFFICIAL SERVICE

The value prop read "Official & trusted", whose grammatical subject is this
site — it reads as a claim that schoolcompare is an official service. It is
not; it is an independent site that republishes official figures. Now "Built
on official data", which describes the data instead.

Checked every other use of the word: all nine describe the data ("official DfE
figures", "the official figure isn't in") and are correct as they stand. That
one title was the only place the site described itself.

The footer now says it outright — "An independent site. Not affiliated with
the Department for Education or Ofsted." — so it appears on every page rather
than being left to inference. An e2e test asserts both halves: that the
statement is present, and that no text presents the site itself as official.

The disclaimer first measured 4.71:1 against a 4.5 floor. That is a fine
margin for decoration and the wrong one for a line whose job is to be legible
to someone checking whether this is a government site; it is now 5.59:1,
quieter than the copy around it by size rather than by contrast.

THE ARTWORK SAT UNDER THE SEARCH ON PHONES

The DOM keeps .heroContent first so the desktop overlay does not depend on
source order, which left the band stranded at the bottom of the panel, reading
as a strip stuck on the end rather than a hero image. `order` moves it above
the copy on phones; it is decorative and aria-hidden, so no reading order
changes. Measured on a 667px viewport — the shortest phone still in use — the
search button lands at 418px, comfortably inside the fold.

THE SEARCH INPUT WAS UNUSABLE ON PHONES

"Search schools" is a fixed 134px with white-space: nowrap, so it took 48% of
the row. Measured on staging:

  390px   input text space 102px   placeholder needs 194px
  360px                     72px
  320px                     32px

At 320px you could not see what you were typing. The button now wraps to its
own full-width row below 480px, which fixes 360px and up — 390px goes from
102px to 226px of text space. The pill stays a single element, so it keeps its
border, shadow and :focus-within ring, and the button gains a full-width tap
target.

320px is still ~30px short. Closing it needs a shorter placeholder, which three
other tests match on by exact string; left alone deliberately rather than
churn them for a width that is effectively gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:59:12 +01:00
TudorandClaude Opus 5 e65688d600 fix(e2e): assert the brand lockup and touch icon as they are actually built
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
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 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
The staging gate has been failing these two since the supplied logo artwork
replaced the reconstruction. Both tests were still asserting the previous
implementation, and both were right to fail — they were just describing
something the site no longer does.

  the header carries the schoolcompare lockup
    Looked for an <svg> inside the header link. The mark is raster now:
    <picture><source><img src="/brand/mark.png">. Nothing matched, so the
    locator timed out.

  the brand asset set is complete and served
    Requested /apple-icon, which 404s. The route moved when the generated
    app/apple-icon.tsx became a static app/apple-icon.png — generated icons
    serve at /apple-icon, static ones at /apple-icon.png with a content hash.
    The icon was present and correctly linked the whole time.

Both now read from the page instead of hardcoding the shape of the answer: the
touch icon is fetched from its own <link rel="apple-touch-icon"> href, the way
this test already handles og:image, so it follows whatever Next emits.

The lockup assertion also got stronger rather than merely corrected. A
<picture> whose sources all 404 still lays out and still satisfies
toBeVisible(), so that alone would go green on a broken lockup; it now asserts
naturalWidth, which only a decoded image can satisfy.

Verified against staging directly: 41/41 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:34:22 +01:00
tudor 7f4dfa2748 Merge pull request 'fix(home): art-direct the hero's fallback path, and declare sharp' (#94) from fix/hero-fallback-and-sharp into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m12s
Reviewed-on: #94
2026-08-14 21:28:13 +00:00
TudorandClaude Opus 5 bdaa05cd54 style(home): lift the hero artwork's dark-theme brightness to 0.75
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
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 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
At 0.52 the scene was legible but heavily suppressed; 0.75 lets the hills,
path and schoolhouse read while the white H1 stays comfortably the brightest
thing on the panel.

Re-measured rather than assumed, because in the dark theme the text is light
and the artwork is behind it — brightening the image lowers text contrast
rather than raising it. Off rendered pixels, sampling background up to 120px
past each line's right edge:

  brightness   title      body
  0.52         11.01:1    6.44:1
  0.75          9.48:1    5.21:1

Both still clear the 4.5:1 floor, body being the binding one. The trade is
recorded next to the value so the next person to reach for it knows it has a
floor and not just a taste range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:26:23 +01:00
TudorandClaude Opus 5 043506cb6b fix(home): art-direct the hero's fallback path, and declare sharp
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m32s
Two findings from review on #93, both verified before fixing.

<img src> cannot vary by viewport, so it was always the wide desktop crop. A
browser taking neither AVIF nor WebP therefore fell through to the desktop
frame on a phone and lost the schoolhouse — the exact failure the two-crop
<picture> exists to prevent, surviving in the one path nobody looks at. The
band JPEG the build script already emitted was never referenced, which was the
tell. It now backs a <source media> placed after the modern formats, so they
still win wherever they are supported.

Verified by stripping the AVIF and WebP <source>s at runtime and letting
<picture> re-resolve, which is what an old browser actually sees:

  phone    hero-band-500.avif  →  hero-band-700.jpg   (band crop, school kept)
  desktop  hero-wide-1672.avif →  hero-wide-1200.jpg

sharp was not declared: it arrives transitively from next@16.1.6, so the
documented regeneration command works today and breaks on a Next upgrade or a
clean install that resolves differently. Declared in devDependencies for the
same reason next.config.js already declares its traced font files rather than
trusting the tracer to keep finding them.

The third finding — that the hero's licence is marked unconfirmed in
CREDITS.md while the artwork ships — is accurate and deliberate. It is the
owner's to answer; recording it as unknown is the point of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:07:01 +01:00
tudor 0466986195 Merge pull request 'feat(home): use the supplied hero artwork instead of a drawn one' (#93) from feat/hero-artwork into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m14s
Reviewed-on: #93
2026-08-14 20:41:46 +00:00
TudorandClaude Opus 5 3f0e05cc99 feat(home): use the supplied hero artwork instead of a drawn one
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
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 2m29s
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:36:40 +01:00
tudor f1819e9c4d Merge pull request 'fix(home): correct what the landing page claims, and give it one rhythm' (#92) from fix/homepage-truth-and-rhythm-main into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m13s
Reviewed-on: #92
2026-08-14 18:18:53 +00:00
27 changed files with 629 additions and 342 deletions

No files matched your search

+105 -38
View File
@@ -121,6 +121,33 @@ test('the home page has a real title', async ({ page }) => {
expect(await page.title()).toMatch(/schoolcompare/i); 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 }) => { test('the landing bands share one vertical rhythm', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const gaps = await page.evaluate(() => { const gaps = await page.evaluate(() => {
@@ -140,59 +167,73 @@ test('the landing bands share one vertical rhythm', async ({ page }) => {
}); });
/* /*
* The hero illustration. Two separate regressions are guarded here, both of * The hero artwork. Every assertion here covers a failure that renders as a
* which shipped: the drawing being cropped past its own subject on phones, and * perfectly valid page: a path that 404s leaves an empty box, a crop that
* the drawing keeping a fixed light palette on a near-black page. * stops switching silently decapitates the subject, and a format negotiation
* that regresses just serves a bigger file.
*/ */
test('the hero illustration swaps composition rather than cropping its subject', async ({ page }) => { function heroImageState(page: Page) {
const visible = () => page.evaluate(() => { return page.evaluate(() => {
const wide = document.querySelector('[class*="heroArtWide"]'); const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null;
const band = document.querySelector('[class*="heroArtBand"]'); if (!img) return null;
return { return {
wide: wide ? getComputedStyle(wide).display : 'missing', // naturalWidth is 0 for an image that failed to load, whatever the src says.
band: band ? getComputedStyle(band).display : 'missing', loaded: img.complete && img.naturalWidth > 0,
file: img.currentSrc.split('/').pop() ?? '',
}; };
}); });
}
test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/'); await page.goto('/');
expect(await visible()).toEqual({ wide: 'block', band: 'none' }); const wide = await heroImageState(page);
expect(wide).not.toBeNull();
expect(wide!.loaded).toBe(true);
expect(wide!.file).toContain('hero-wide');
// Below the one-column breakpoint the slot becomes a wide, short band. The /*
// tall composition sliced to that shape cropped the schoolhouse away * Below the one-column breakpoint the slot becomes a band up to 4.9:1. The
// entirely, leaving hills and a pin pointing at nothing. * 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.
*/
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/'); await page.goto('/');
expect(await visible()).toEqual({ wide: 'none', band: 'block' }); const band = await heroImageState(page);
expect(band!.loaded).toBe(true);
expect(band!.file).toContain('hero-band');
}); });
test('the hero illustration is re-graded for the dark theme', async ({ browser }) => { test('the hero artwork is served in a modern format', async ({ page }) => {
const read = async (colorScheme: 'light' | 'dark') => { 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') => {
const ctx = await browser.newContext({ colorScheme }); const ctx = await browser.newContext({ colorScheme });
const p = await ctx.newPage(); const p = await ctx.newPage();
await p.goto('/'); await p.goto('/');
const value = await p.evaluate(() => const value = await p.evaluate(() => {
getComputedStyle(document.documentElement).getPropertyValue('--ill-sky-high').trim() const img = document.querySelector('[class*="heroArt"] img');
); return img ? getComputedStyle(img).filter : 'missing';
});
await ctx.close(); await ctx.close();
return value; return value;
}; };
const toLuminance = (hex: string) => { // The artwork is a fixed raster, so it cannot be re-graded per theme the way
const m = hex.replace('#', ''); // the drawn hero was. Undimmed it is the brightest object on a near-black
const [r, g, b] = [0, 2, 4].map(i => parseInt(m.slice(i, i + 2), 16) / 255); // page and out-shouts the H1 and the search box — the exact regression the
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); // drawn version shipped with.
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); expect(await filterFor('light')).toBe('none');
}; expect(await filterFor('dark')).toMatch(/brightness/);
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 }) => { test('searching by name returns school results', async ({ page }) => {
@@ -775,9 +816,21 @@ test('the brand asset set is complete and served', async ({ page }) => {
expect(og.ok()).toBe(true); expect(og.ok()).toBe(true);
expect(og.headers()['content-type']).toContain('image/png'); expect(og.headers()['content-type']).toContain('image/png');
// iOS ignores SVG touch icons, so this must be a real raster. /*
const apple = await page.request.get('/apple-icon'); * iOS ignores SVG touch icons, so this must be a real raster.
expect(apple.ok()).toBe(true); *
* 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);
expect(apple.headers()['content-type']).toContain('image/png'); expect(apple.headers()['content-type']).toContain('image/png');
// Android needs a maskable PNG or the install prompt has no icon. // Android needs a maskable PNG or the install prompt has no icon.
@@ -898,8 +951,22 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
// hashed at build time and change on any unrelated edit. // hashed at build time and change on any unrelated edit.
await expect(home).toHaveText(/^\s*schoolcompare\s*$/); await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
const mark = home.locator('svg').first(); /*
* 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();
await expect(mark).toBeVisible(); 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 }) => { test('no visible text falls back to the browser default black', async ({ page }) => {
+22 -51
View File
@@ -188,33 +188,22 @@
--section-gap: 4rem; --section-gap: 4rem;
--section-head-gap: 1.5rem; --section-head-gap: 1.5rem;
/* ── Illustration ─────────────────────────────────────────────────── /* ── Hero artwork ───────────────────────────────────────────────────
The hero landscape's palette. These are tokens rather than literals in The landing hero is now a supplied raster illustration rather than a
the SVG for one reason: the drawing previously kept a fixed light drawn SVG, so its colours are in the file, not here. What is left is the
palette in both themes, which left a pale sky slab as the brightest ground the artwork sits on and fades into.
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 --hero-ground is sampled from the artwork's own copy area, not from
values below are a re-grade, not an inversion: the same scene, lit at Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
dusk, sitting a step above the panel it lives on. */ seam straight down the hero.
--ill-sky-high: #DAEDF8;
--ill-sky-low: #EFF8FB; The artwork's copy area is not one flat colour: it runs from a peach
--ill-cloud: #FFFFFF; #FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
--ill-hill-far: #D6EDE2; the left edge is foliage rather than cream. This value is sampled from
--ill-hill-mid: #A7D7C5; the middle of that pale run, where the headline and search actually sit;
--ill-hill-near: #7FC3AC; the scrim is what covers the foliage further down. */
--ill-hill-front: #5BA88F; --hero-ground: #FEF2E1;
--ill-path: #FAF6EE; --hero-ground-rgb: 254, 242, 225;
--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 ────────────────────────────────────────────── /* ── Geometry & motion ──────────────────────────────────────────────
"Soft shapes, rounded corners" — the guideline's geometry is markedly "Soft shapes, rounded corners" — the guideline's geometry is markedly
@@ -340,30 +329,12 @@
--medal-silver-rgb: 169, 182, 188; --medal-silver-rgb: 169, 182, 188;
--medal-bronze-rgb: 201, 144, 112; --medal-bronze-rgb: 201, 144, 112;
/* Dusk re-grade of the hero landscape. The sky sits one step above the /* The artwork is a fixed, bright raster — it cannot be re-graded by
Sand panel (#16222A) rather than 60 points above it, so the picture token the way the drawn version was. The dark theme instead dims it
reads as a framed vignette instead of a light leak. The pin keeps in CSS (see .heroArt in HomeView.module.css) and fades it into this
the theme's coral and is the only bright thing left in the scene — ground, which is the panel colour rather than the artwork's cream. */
which is the point, since it marks "you are here". */ --hero-ground: #16222A;
--ill-sky-high: #1B2C36; --hero-ground-rgb: 22, 34, 42;
--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-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-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

@@ -460,6 +460,30 @@
} }
} }
/*
* 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) { @media (max-width: 420px) {
/* Below this the pin costs more room than it earns. */ /* Below this the pin costs more room than it earns. */
.heroMode .omniIcon { .heroMode .omniIcon {
+16
View File
@@ -92,6 +92,22 @@
max-width: 34ch; 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 { .sectionTitle {
margin: 0; margin: 0;
font-family: var(--font-display); font-family: var(--font-display);
+8
View File
@@ -35,6 +35,14 @@ export function Footer() {
<p className={styles.description}> <p className={styles.description}>
Compare primary and secondary schools across England. Compare primary and secondary schools across England.
</p> </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 <a
href="mailto:contact@schoolcompare.co.uk" href="mailto:contact@schoolcompare.co.uk"
className={styles.link} className={styles.link}
+181 -44
View File
@@ -77,52 +77,153 @@
} }
/* ── Hero ────────────────────────────────────────────────────────────────── /* ── Hero ──────────────────────────────────────────────────────────────────
A Sand panel: proposition and search on the left, the brand landscape The artwork fills the panel and the proposition sits on top of it, in the
bleeding to the panel's right edge. The illustration is decorative, so on empty cream area the illustration reserves on its left. It is not a
phones it drops to a short band under the content rather than competing two-column grid: splitting the panel would crop that reserved area off and
with the search for the fold. */ 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. */
.heroPanel { .heroPanel {
display: grid; position: relative;
grid-template-columns: 1.12fr 0.88fr; isolation: isolate;
align-items: stretch; background: var(--hero-ground);
background: var(--bg-secondary);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
overflow: hidden; overflow: hidden;
} }
.heroContent { .heroContent {
align-self: center; position: relative;
padding: 3rem 1.5rem 3rem 3rem; 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);
min-width: 0; min-width: 0;
} }
.heroArt { .heroArt {
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;
}
/* Both compositions fill the slot; the breakpoint below picks one. */
.heroArtWide,
.heroArtBand {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 0;
} }
.heroArt svg { .heroArt picture,
position: absolute; .heroArt img {
inset: 0; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block;
} }
/* The band composition only exists below the one-column breakpoint — above .heroArt img {
it the slot is tall and the wide drawing is the right picture. */ object-fit: cover;
.heroArtBand { /* Centre keeps the schoolhouse in frame at every desktop panel ratio: the
display: none; 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.
*
* 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);
}
.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 one line in the first person, so it is set apart from the marketing copy
* around it without shouting: display face, a step down in size, and a short
* brand rule instead of a bullet or an emoji.
*
* No italic — Manrope ships no italic in the loaded weights, so font-style
* would be synthesised into a slant. Same reason .heroEmph sets font-style
* back to normal.
*/
.heroByline {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 1.25rem 0 0;
font-family: var(--font-display);
font-size: var(--step--1);
font-weight: 500;
font-style: normal;
color: var(--text-secondary);
}
.heroByline::before {
content: '';
flex: 0 0 auto;
width: 1.25rem;
height: 2px;
border-radius: 1px;
background: var(--brand);
}
@media (max-width: 640px) {
.heroByline {
margin-top: 1rem;
font-size: var(--step--2);
}
} }
.heroEyebrow { .heroEyebrow {
@@ -184,33 +285,65 @@
@media (max-width: 1024px) { @media (max-width: 1024px) {
.heroContent { .heroContent {
padding: 2.25rem 1.5rem 2.25rem 2rem; padding: 2.75rem 1.5rem 2.75rem 2rem;
} max-width: min(64%, 34rem);
.heroArt {
min-height: 20rem;
} }
} }
/* One column: content first, then a short band of landscape. */ /*
* 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.
*/
@media (max-width: 860px) { @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 { .heroPanel {
grid-template-columns: 1fr; display: flex;
flex-direction: column;
} }
.heroContent { .heroContent {
padding: 2rem 1.5rem 1.75rem; padding: 1.75rem 1.5rem 1.75rem;
max-width: none;
} }
/* One column: the slot becomes a wide, short band, so swap in the drawing /* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
composed for that crop. See the note at the top of Illustration.tsx. */ crop into that throws away enough height to cut the flag off the roof and
the base off the building. 13rem holds it at about 3.8:1, where the
building stays whole. */
.heroArt { .heroArt {
min-height: 11rem; position: static;
order: 2; order: -1;
height: 13rem;
} }
.heroArtWide { /* The band crop puts the schoolhouse at 73% across — reported by
scripts/build-hero-images.js, which derives it from the crop box rather
than leaving the two to drift. Squeezing narrower then crops the emptier
left side rather than the subject. */
.heroArt img {
object-position: 73% 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 {
display: none; display: none;
} }
.heroArtBand {
display: block;
}
.heroTitle { .heroTitle {
font-size: var(--step-3); font-size: var(--step-3);
max-width: none; max-width: none;
@@ -245,8 +378,12 @@
font-size: 1.75rem; font-size: 1.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
/* 10rem, not 8.5: the band is widest-per-height right at this breakpoint —
at 640px an 8.5rem band is 4.2:1, worse than anything above it, because
the height steps down here while the width does not. 10rem keeps it near
3.6:1, in line with the rest of the range. */
.heroArt { .heroArt {
min-height: 8.5rem; height: 10rem;
} }
} }
+33 -11
View File
@@ -12,7 +12,7 @@ import { SchoolRow } from './SchoolRow';
import { SecondarySchoolRow } from './SecondarySchoolRow'; import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap'; import { SchoolMap } from './SchoolMap';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { HeroIllustration, HeroIllustrationBand } from './Illustration'; import { HeroIllustration } from './Illustration';
import { useComparisonContext } from '@/context/ComparisonContext'; import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api'; import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types'; import type { SchoolsResponse, Filters, School } from '@/lib/types';
@@ -157,7 +157,20 @@ const VALUE_PROPS: ValueProp[] = [
{ {
icon: <ShieldCheckIcon />, icon: <ShieldCheckIcon />,
tintClass: styles.propIconTrust, tintClass: styles.propIconTrust,
title: 'Official & trusted', /*
* "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',
body: 'Every figure comes from DfE performance tables and Ofsted.', body: 'Every figure comes from DfE performance tables and Ofsted.',
}, },
{ {
@@ -451,20 +464,29 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
geoError={geoError} geoError={geoError}
/> />
{/*
Sits under the search rather than in the footer on purpose: this
is the one line on the page written in the first person, and it
earns its place at the moment someone is deciding whether to
trust a site full of government statistics. In the footer it
would be true and unread.
It is deliberately the only such claim — the page's other trust
signals are all about the data's provenance, which is checkable.
This one is about who made it, which is not, so it is stated
once, plainly, and never repeated.
*/}
<p className={styles.heroByline}>Built for parents, by a parent.</p>
</div> </div>
{/* {/*
Two drawings, one visible — see the note at the top of Rendered after the content on purpose. On desktop it is absolutely
Illustration.tsx. The hero art slot ranges from about 1.34:1 to positioned behind the panel, so order does not matter; below the
4.9:1 across the breakpoints, which no single composition survives. one-column breakpoint it returns to normal flow, where DOM order is
what puts the band under the search rather than above the headline.
*/} */}
<div className={styles.heroArt}> <div className={styles.heroArt}>
<div className={styles.heroArtWide}> <HeroIllustration />
<HeroIllustration />
</div>
<div className={styles.heroArtBand}>
<HeroIllustrationBand />
</div>
</div> </div>
</section> </section>
+83 -192
View File
@@ -1,212 +1,103 @@
/** /**
* Brand illustration — the landing hero's landscape. * The landing hero artwork.
* *
* Guideline rules for illustration: no people or faces, soft shapes, rounded * This is the supplied illustration, not a drawing of one — see
* corners, minimal detail, maximum clarity. The scene reads as "the journey to * assets/hero-source.png and scripts/build-hero-images.js, which produces
* a school" — a path climbing through layered hills to a small schoolhouse, * everything under public/brand/hero-*. Regenerate rather than hand-editing.
* with a pin marking where you are.
* *
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so * Server-safe by construction: no 'use client', no hooks, no handlers, so it
* it renders in the RSC pass and never reaches the client bundle. * renders in the RSC pass and never reaches the client bundle.
* *
* TWO COMPOSITIONS, ON PURPOSE * TWO CROPS, ONE <picture>
* ---------------------------- * ------------------------
* The hero art slot is not one shape. On a wide desktop it is roughly 1.5:1; * The slot is two different shapes. On desktop the artwork sits behind the
* at the 1024px breakpoint about 1.34:1; on a phone it collapses to a band * whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
* near 2.6:1, and just above the one-column breakpoint it is nearly 5:1. * 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.
* *
* A single drawing cannot cover 1.34:1 → 4.9:1. With `slice`, the SVG scales * A single file under `object-fit: cover` centre-crops, and at the band's
* to cover and crops the overflow, anchored here to the bottom edge — so at * extreme that slices a strip through the middle of the scene and loses the
* 860×176 a 540×520 scene shows only its bottom 110 user units. That is what * schoolhouse — exactly how the previous SVG hero failed on phones. So the
* used to happen: the schoolhouse sat at y 218–324 and was cropped away * <source media> switches crop, not just resolution: `band` is pre-cropped
* entirely on phones, leaving bare hills and a pin pointing at nothing. * around the school and is already near 2.6:1, so the narrow band barely
* crops it further.
* *
* So there are two drawings and CSS shows exactly one. They are both static * `object-position` is set in CSS per breakpoint and is load-bearing — see
* server-rendered SVG, so this costs markup and nothing else — no JS, no * .heroArt in HomeView.module.css.
* 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 artwork is decorative: the proposition beside it carries the meaning, so
* the dark theme. Everything else about the scene is identical in both themes. * alt is empty and it is hidden from assistive tech rather than described.
*/ */
/** Scoped so the gradient ids can't collide with another inline SVG. */ /*
const SKY_TALL_ID = 'sc-hero-sky-tall'; * The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
const SKY_BAND_ID = 'sc-hero-sky-band'; * 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
function SkyGradient({ id }: { id: string }) { * width, which on a slow connection is a larger file than needed for the LCP
return ( * element.
<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 }) { const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
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. */ /** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
function Pin({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) { const WIDE_W = 2000;
return ( const WIDE_H = 1125;
<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() { export function HeroIllustration() {
return ( return (
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false"> <picture>
<defs> {/* Band crop first: <source> is first-match-wins, so the narrow-viewport
<SkyGradient id={SKY_TALL_ID} /> rules have to precede the unconstrained desktop ones. */}
</defs> <source
media="(max-width: 860px)"
<rect width="540" height="520" fill={`url(#${SKY_TALL_ID})`} /> type="image/avif"
srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
{/* Two soft clouds. Low opacity so they never compete with the pin. */} sizes={SIZES}
<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 <source
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316" media="(max-width: 860px)"
stroke="var(--ill-path-edge)" type="image/webp"
strokeWidth="18" srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
fill="none" sizes={SIZES}
strokeLinecap="butt"
strokeLinejoin="round"
strokeDasharray="0.5 26"
opacity="0.5"
/> />
{/*
The band's JPEG, and the reason it exists.
<Schoolhouse x={276} y={232} /> <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
{/* Scattered rounded trees */} to the desktop frame on a phone and lose the schoolhouse — the exact
<g fill="var(--ill-tree-dark)"> failure this whole component is arranged to prevent, surviving in the
<circle cx="104" cy="372" r="27" /> one path nobody looks at. No `type` here, so it matches anywhere the
<circle cx="136" cy="386" r="19" /> media query does, and it sits after the modern formats so they still
<circle cx="470" cy="336" r="23" /> win where supported.
</g> */}
<g fill="var(--ill-tree-light)"> <source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
<circle cx="78" cy="394" r="21" /> <source
<circle cx="492" cy="358" r="17" /> type="image/avif"
<circle cx="176" cy="420" r="18" /> srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
</g> sizes={SIZES}
<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"
/> />
<source
<Schoolhouse x={186} y={30} scale={0.52} /> type="image/webp"
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
<g fill="var(--ill-tree-dark)"> sizes={SIZES}
<circle cx="58" cy="86" r="15" /> />
<circle cx="78" cy="94" r="11" /> {/*
<circle cx="342" cy="80" r="13" /> The hero is the largest thing above the fold, so it is almost certainly
</g> the LCP element: fetchPriority high, and never lazy. eslint's
<g fill="var(--ill-tree-light)"> no-img-element wants next/image, which cannot art-direct between two
<circle cx="40" cy="98" r="12" /> different crops — that is the whole point of the <picture> above.
<circle cx="360" cy="92" r="10" /> */}
<circle cx="300" cy="100" r="11" /> {/* eslint-disable-next-line @next/next/no-img-element */}
</g> <img
<g fill="var(--ill-tree-pale)" opacity="0.9"> src="/brand/hero-wide-1400.jpg"
<circle cx="112" cy="104" r="9" /> alt=""
<circle cx="266" cy="108" r="8" /> aria-hidden="true"
</g> width={WIDE_W}
height={WIDE_H}
<Pin x={124} y={70} scale={0.62} /> decoding="async"
</svg> fetchPriority="high"
/>
</picture>
); );
} }
+6 -5
View File
@@ -31,7 +31,8 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0" "jest-environment-jsdom": "^30.2.0",
"sharp": "^0.34.5"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
@@ -937,8 +938,8 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -4160,8 +4161,8 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -8584,9 +8585,9 @@
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -8629,8 +8630,8 @@
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
+2 -1
View File
@@ -36,6 +36,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0" "jest-environment-jsdom": "^30.2.0",
"sharp": "^0.34.5"
} }
} }
+35
View File
@@ -0,0 +1,35 @@
# 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` (2000×1125 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.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+114
View File
@@ -0,0 +1,114 @@
#!/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, measured against the artwork rather than
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
* up at y 330.
*
* The crop is flush to the right edge because it has to be. The school sits at
* about 82% across the frame, so there is only ~140px of artwork to its right
* — not enough to centre it. Anchoring right and taking 1050px back puts the
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
* the CSS so the narrowest band crops the empty left side rather than the
* subject.
*/
const BAND = { left: 656, top: 300, width: 1344, height: 420 };
/** Where the schoolhouse lands in the band crop, for CSS object-position. */
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
const WIDE_WIDTHS = [2000, 1400, 1000];
const BAND_WIDTHS = [1344, 900, 600];
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}`);
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
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: 1400 })
.flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-wide-1400.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: 900 })
.flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-band-900.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);
});