feat(home): swap in the higher-fidelity hero artwork #98

Merged
tudor merged 1 commits from feat/hero-artwork-v2 into main 2026-08-15 08:56:33 +00:00
34 changed files with 58 additions and 33 deletions

No files matched your search

+11 -7
View File
@@ -193,13 +193,17 @@
drawn SVG, so its colours are in the file, not here. What is left is the drawn SVG, so its colours are in the file, not here. What is left is the
ground the artwork sits on and fades into. ground the artwork sits on and fades into.
--hero-ground is sampled from the artwork's own copy area (#FDF9F3), --hero-ground is sampled from the artwork's own copy area, not from
not from Sand. The artwork reserves an empty cream region on the left Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
for the headline, and the scrim that guarantees text contrast fades from seam straight down the hero.
this colour into it — a scrim in Sand (#F5EFE6, eight to thirteen points
darker per channel) leaves a visible seam straight down the hero. */ The artwork's copy area is not one flat colour: it runs from a peach
--hero-ground: #FDF9F3; #FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
--hero-ground-rgb: 253, 249, 243; the left edge is foliage rather than cream. This value is sampled from
the middle of that pale run, where the headline and search actually sit;
the scrim is what covers the foliage further down. */
--hero-ground: #FEF2E1;
--hero-ground-rgb: 254, 242, 225;
/* ── Geometry & motion ────────────────────────────────────────────── /* ── Geometry & motion ──────────────────────────────────────────────
"Soft shapes, rounded corners" — the guideline's geometry is markedly "Soft shapes, rounded corners" — the guideline's geometry is markedly
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

+15 -5
View File
@@ -323,15 +323,21 @@
padding: 1.75rem 1.5rem 1.75rem; padding: 1.75rem 1.5rem 1.75rem;
max-width: none; max-width: none;
} }
/* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
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 {
position: static; position: static;
order: -1; order: -1;
height: 11rem; height: 13rem;
} }
/* The band crop centres the schoolhouse at about 65% across, so squeezing /* The band crop puts the schoolhouse at 73% across — reported by
toward 4.9:1 crops the empty sides rather than the subject. */ 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 { .heroArt img {
object-position: 65% center; object-position: 73% center;
} }
/* The scrim exists to protect text sitting on the artwork. Nothing sits on /* The scrim exists to protect text sitting on the artwork. Nothing sits on
it here, and leaving it would wash the band out. */ it here, and leaving it would wash the band out. */
@@ -372,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 {
height: 8.5rem; height: 10rem;
} }
} }
+8 -8
View File
@@ -40,8 +40,8 @@
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)'; const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */ /** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
const WIDE_W = 1672; const WIDE_W = 2000;
const WIDE_H = 941; const WIDE_H = 1125;
export function HeroIllustration() { export function HeroIllustration() {
return ( return (
@@ -51,13 +51,13 @@ export function HeroIllustration() {
<source <source
media="(max-width: 860px)" media="(max-width: 860px)"
type="image/avif" type="image/avif"
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w" srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
sizes={SIZES} sizes={SIZES}
/> />
<source <source
media="(max-width: 860px)" media="(max-width: 860px)"
type="image/webp" type="image/webp"
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w" srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
sizes={SIZES} sizes={SIZES}
/> />
{/* {/*
@@ -71,15 +71,15 @@ export function HeroIllustration() {
media query does, and it sits after the modern formats so they still media query does, and it sits after the modern formats so they still
win where supported. win where supported.
*/} */}
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" /> <source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
<source <source
type="image/avif" type="image/avif"
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w" srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
sizes={SIZES} sizes={SIZES}
/> />
<source <source
type="image/webp" type="image/webp"
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w" srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
sizes={SIZES} sizes={SIZES}
/> />
{/* {/*
@@ -90,7 +90,7 @@ export function HeroIllustration() {
*/} */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src="/brand/hero-wide-1200.jpg" src="/brand/hero-wide-1400.jpg"
alt="" alt=""
aria-hidden="true" aria-hidden="true"
width={WIDE_W} width={WIDE_W}
+1 -1
View File
@@ -24,7 +24,7 @@ The landing hero illustration: a path climbing through hills to a schoolhouse,
with an empty cream area on the left reserved for the headline. with an empty cream area on the left reserved for the headline.
- **Source:** supplied by the project owner - **Source:** supplied by the project owner
- **Master:** `nextjs-app/assets/hero-source.png` (1672×941 PNG), committed so - **Master:** `nextjs-app/assets/hero-source.png` (2000×1125 PNG), committed so
the derivatives can be regenerated the derivatives can be regenerated
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these - **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
- **Licence:** **to be confirmed by the project owner** - **Licence:** **to be confirmed by the project owner**
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

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.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 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.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

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.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

+23 -12
View File
@@ -45,14 +45,24 @@ const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
const OUT = path.join(__dirname, '..', 'public', 'brand'); const OUT = path.join(__dirname, '..', 'public', 'brand');
/* /*
* Band crop, in source pixels. The schoolhouse occupies roughly x 1180–1540, * Band crop, in source pixels, measured against the artwork rather than
* y 300–520, so this keeps it whole with headroom above the flag and enough * guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
* hillside below to read as landscape rather than as a cutout. * 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: 700, top: 260, width: 972, height: 380 }; const BAND = { left: 656, top: 300, width: 1344, height: 420 };
const WIDE_WIDTHS = [1672, 1200, 900]; /** Where the schoolhouse lands in the band crop, for CSS object-position. */
const BAND_WIDTHS = [972, 700, 500]; 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) { async function emit(pipeline, name, width) {
const base = pipeline.clone().resize({ width, withoutEnlargement: true }); const base = pipeline.clone().resize({ width, withoutEnlargement: true });
@@ -67,25 +77,26 @@ async function main() {
const meta = await sharp(SRC).metadata(); const meta = await sharp(SRC).metadata();
console.log(`source ${meta.width}×${meta.height}`); 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); const wide = sharp(SRC);
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w); 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 // Fallback for the <img> itself. Flattened onto the artwork's own cream so a
// JPEG (no alpha) can never composite against black. // JPEG (no alpha) can never composite against black.
await sharp(SRC) await sharp(SRC)
.resize({ width: 1200 }) .resize({ width: 1400 })
.flatten({ background: '#FDF9F3' }) .flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true }) .jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-wide-1200.jpg')); .toFile(path.join(OUT, 'hero-wide-1400.jpg'));
const band = sharp(SRC).extract(BAND); const band = sharp(SRC).extract(BAND);
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w); for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
await sharp(SRC) await sharp(SRC)
.extract(BAND) .extract(BAND)
.resize({ width: 700 }) .resize({ width: 900 })
.flatten({ background: '#FDF9F3' }) .flatten({ background: '#FEF2E1' })
.jpeg({ quality: 82, mozjpeg: true }) .jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-band-700.jpg')); .toFile(path.join(OUT, 'hero-band-900.jpg'));
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort(); const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
let total = 0; let total = 0;