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>
96 lines
3.7 KiB
JavaScript
96 lines
3.7 KiB
JavaScript
#!/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; the JPEG is
|
||
* only the <img> fallback and is never served to a current browser.
|
||
*/
|
||
|
||
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. The schoolhouse occupies roughly x 1180–1540,
|
||
* y 300–520, so this keeps it whole with headroom above the flag and enough
|
||
* hillside below to read as landscape rather than as a cutout.
|
||
*/
|
||
const BAND = { left: 700, top: 260, width: 972, height: 380 };
|
||
|
||
const WIDE_WIDTHS = [1672, 1200, 900];
|
||
const BAND_WIDTHS = [972, 700, 500];
|
||
|
||
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}`);
|
||
|
||
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: 1200 })
|
||
.flatten({ background: '#FDF9F3' })
|
||
.jpeg({ quality: 82, mozjpeg: true })
|
||
.toFile(path.join(OUT, 'hero-wide-1200.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: 700 })
|
||
.flatten({ background: '#FDF9F3' })
|
||
.jpeg({ quality: 82, mozjpeg: true })
|
||
.toFile(path.join(OUT, 'hero-band-700.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);
|
||
});
|