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>
115 lines
4.8 KiB
JavaScript
115 lines
4.8 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. 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);
|
||
});
|