Compare commits

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

LAYOUT

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

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

CONTRAST

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

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

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

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

TWO CROPS

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

WEIGHT

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

DARK THEME

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

TESTS

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

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

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

No files matched your search

-12
View File
@@ -60,18 +60,6 @@ export function HeroIllustration() {
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
sizes={SIZES}
/>
{/*
The band's JPEG, and the reason it exists.
<img src> cannot vary by viewport, so it is always the wide crop. A
browser that takes neither AVIF nor WebP would therefore fall through
to the desktop frame on a phone and lose the schoolhouse — the exact
failure this whole component is arranged to prevent, surviving in the
one path nobody looks at. No `type` here, so it matches anywhere the
media query does, and it sits after the modern formats so they still
win where supported.
*/}
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
<source
type="image/avif"
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
+5 -6
View File
@@ -31,8 +31,7 @@
"@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.21",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"sharp": "^0.34.5"
"jest-environment-jsdom": "^30.2.0"
}
},
"node_modules/@adobe/css-tools": {
@@ -938,8 +937,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"devOptional": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -4161,8 +4160,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -8585,9 +8584,9 @@
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -8630,8 +8629,8 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"devOptional": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
+1 -2
View File
@@ -36,7 +36,6 @@
"@types/jest": "^30.0.0",
"@types/leaflet": "^1.9.21",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"sharp": "^0.34.5"
"jest-environment-jsdom": "^30.2.0"
}
}
+2 -10
View File
@@ -25,16 +25,8 @@
* squeezing further toward 4.9:1 crops the empty sides, not the subject.
*
* AVIF and WebP are both emitted. This is flat-shaded vector-style artwork, so
* AVIF lands roughly an order of magnitude under the source PNG. Both crops
* also get a JPEG: the wide one backs the <img src>, and the band one backs a
* <source media> — without it a browser taking neither modern format would
* fall through to the desktop frame on a phone, since <img src> cannot vary by
* viewport.
*
* sharp is declared in devDependencies rather than leaned on as a transitive
* dependency of next. It does currently arrive that way, but relying on that
* means a Next upgrade can silently break this script — the same reasoning as
* the outputFileTracingIncludes note in next.config.js.
* AVIF lands roughly an order of magnitude under the source PNG; the JPEG is
* only the <img> fallback and is never served to a current browser.
*/
const path = require('path');