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>
This commit is contained in:
TudorandClaude Opus 5 committed 2026-08-14 21:36:40 +01:00
1 parent f1819e9c4d
commit 3f0e05cc99
22 files changed
+379 -333

No files matched your search

+105 -45
View File
@@ -77,52 +77,104 @@
}
/* ── Hero ──────────────────────────────────────────────────────────────────
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. */
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. */
.heroPanel {
display: grid;
grid-template-columns: 1.12fr 0.88fr;
align-items: stretch;
background: var(--bg-secondary);
position: relative;
isolation: isolate;
background: var(--hero-ground);
border-radius: var(--radius-xl);
overflow: hidden;
}
.heroContent {
align-self: center;
padding: 3rem 1.5rem 3rem 3rem;
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);
min-width: 0;
}
.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;
inset: 0;
z-index: 0;
}
.heroArt svg {
position: absolute;
inset: 0;
.heroArt picture,
.heroArt img {
display: block;
width: 100%;
height: 100%;
display: block;
}
/* 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;
.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%
);
}
}
.heroEyebrow {
@@ -184,33 +236,41 @@
@media (max-width: 1024px) {
.heroContent {
padding: 2.25rem 1.5rem 2.25rem 2rem;
}
.heroArt {
min-height: 20rem;
padding: 2.75rem 1.5rem 2.75rem 2rem;
max-width: min(64%, 34rem);
}
}
/* 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) {
.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 {
min-height: 11rem;
order: 2;
position: static;
height: 11rem;
}
.heroArtWide {
/* 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 {
display: none;
}
.heroArtBand {
display: block;
}
.heroTitle {
font-size: var(--step-3);
max-width: none;
@@ -246,7 +306,7 @@
margin-bottom: 0.5rem;
}
.heroArt {
min-height: 8.5rem;
height: 8.5rem;
}
}
+6 -10
View File
@@ -12,7 +12,7 @@ import { SchoolRow } from './SchoolRow';
import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap';
import { EmptyState } from './EmptyState';
import { HeroIllustration, HeroIllustrationBand } from './Illustration';
import { HeroIllustration } from './Illustration';
import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
@@ -454,17 +454,13 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</div>
{/*
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.
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.
*/}
<div className={styles.heroArt}>
<div className={styles.heroArtWide}>
<HeroIllustration />
</div>
<div className={styles.heroArtBand}>
<HeroIllustrationBand />
</div>
<HeroIllustration />
</div>
</section>
+72 -193
View File
@@ -1,212 +1,91 @@
/**
* Brand illustration — the landing hero's landscape.
* The landing hero artwork.
*
* 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.
* 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.
*
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so
* it renders in the RSC pass and never reaches the client bundle.
* Server-safe by construction: no 'use client', no hooks, no handlers, so it
* renders in the RSC pass and never reaches the client bundle.
*
* TWO COMPOSITIONS, ON PURPOSE
* ----------------------------
* The hero art slot is not one shape. On a wide desktop it is roughly 1.5:1;
* at the 1024px breakpoint about 1.34:1; on a phone it collapses to a band
* near 2.6:1, and just above the one-column breakpoint it is nearly 5:1.
* 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.
*
* 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.
* 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.
*
* 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.
* `object-position` is set in CSS per breakpoint and is load-bearing — see
* .heroArt in HomeView.module.css.
*
* 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 artwork is decorative: the proposition beside it carries the meaning, so
* 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';
const SKY_BAND_ID = 'sc-hero-sky-band';
function SkyGradient({ id }: { id: string }) {
return (
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--ill-sky-high)" />
<stop offset="1" stopColor="var(--ill-sky-low)" />
</linearGradient>
);
}
/**
* The schoolhouse. Drawn once, placed by each composition.
*
* Detail deliberately below what it was: the gable clock, the flagpole
* pennant and the four-window grid are gone. At the sizes this actually
* renders they read as noise, and they were what tipped the drawing from
* "soft brand illustration" into clip art.
/*
* 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.
*/
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>
);
}
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
/** The "you are here" pin. The only saturated thing in either composition. */
function Pin({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) {
return (
<g transform={`translate(${x} ${y}) scale(${scale})`}>
<path
d="M21 48 C21 48 42 26 42 15.5 A21 21 0 1 0 0 15.5 C0 26 21 48 21 48 Z"
fill="var(--ill-pin)"
/>
<circle cx="21" cy="15.5" r="8" fill="var(--ill-pin-eye)" />
</g>
);
}
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
const WIDE_W = 1672;
const WIDE_H = 941;
/**
* 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"
<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}
/>
<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
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}
/>
<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"
<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}
/>
<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>
<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}
/>
{/*
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>
);
}