Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 5 914b429a28 fix(home): art-direct the hero's fallback path, and declare sharp
Two findings from review on #93, both verified before fixing.

<img src> cannot vary by viewport, so it was always the wide desktop crop. A
browser taking neither AVIF nor WebP therefore fell through to the desktop
frame on a phone and lost the schoolhouse — the exact failure the two-crop
<picture> exists to prevent, surviving in the one path nobody looks at. The
band JPEG the build script already emitted was never referenced, which was the
tell. It now backs a <source media> placed after the modern formats, so they
still win wherever they are supported.

Verified by stripping the AVIF and WebP <source>s at runtime and letting
<picture> re-resolve, which is what an old browser actually sees:

  phone    hero-band-500.avif  →  hero-band-700.jpg   (band crop, school kept)
  desktop  hero-wide-1672.avif →  hero-wide-1200.jpg

sharp was not declared: it arrives transitively from next@16.1.6, so the
documented regeneration command works today and breaks on a Next upgrade or a
clean install that resolves differently. Declared in devDependencies for the
same reason next.config.js already declares its traced font files rather than
trusting the tracer to keep finding them.

The third finding — that the hero's licence is marked unconfirmed in
CREDITS.md while the artwork ships — is accurate and deliberate. It is the
owner's to answer; recording it as unknown is the point of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:46:04 +01:00
39 changed files with 40 additions and 259 deletions

No files matched your search

+4 -57
View File
@@ -121,33 +121,6 @@ test('the home page has a real title', async ({ page }) => {
expect(await page.title()).toMatch(/schoolcompare/i); expect(await page.title()).toMatch(/schoolcompare/i);
}); });
test('the site states its independence and never claims to be official', async ({ page }) => {
await page.goto('/');
// Carried in the footer, so it appears on every page rather than only here.
await expect(
page.getByText(/an independent site\.\s*not affiliated with the department for education/i)
).toBeVisible();
/*
* Describing the DATA as official is accurate and appears throughout
* ("official DfE figures", "the official figure isn't in"). Describing the
* SITE as official is not: schoolcompare republishes government data, it is
* not a government service. The difference is the grammatical subject, so
* this looks for text that opens with "Official" as a standalone label —
* the shape a value-prop title or heading takes, and the shape the old
* "Official & trusted" took.
*/
const claims = await page.evaluate(() =>
[...document.querySelectorAll('body *')]
.flatMap(el => [...el.childNodes]
.filter(n => n.nodeType === 3)
.map(n => (n.textContent ?? '').trim()))
.filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t))
);
expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]);
});
test('the landing bands share one vertical rhythm', async ({ page }) => { test('the landing bands share one vertical rhythm', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const gaps = await page.evaluate(() => { const gaps = await page.evaluate(() => {
@@ -816,21 +789,9 @@ test('the brand asset set is complete and served', async ({ page }) => {
expect(og.ok()).toBe(true); expect(og.ok()).toBe(true);
expect(og.headers()['content-type']).toContain('image/png'); expect(og.headers()['content-type']).toContain('image/png');
/* // iOS ignores SVG touch icons, so this must be a real raster.
* iOS ignores SVG touch icons, so this must be a real raster. const apple = await page.request.get('/apple-icon');
* expect(apple.ok()).toBe(true);
* Read the href off the page rather than hardcoding a path. The route moved
* when the generated app/apple-icon.tsx became a static app/apple-icon.png:
* a generated icon is served at /apple-icon, a static one at /apple-icon.png
* with a content hash. This test kept asking for /apple-icon and got a 404 —
* reporting a missing icon while the icon was present and correctly linked.
*/
const appleLink = page.locator('link[rel="apple-touch-icon"]');
await expect(appleLink).toHaveCount(1);
const appleHref = await appleLink.getAttribute('href');
expect(appleHref).toBeTruthy();
const apple = await page.request.get(appleHref!);
expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true);
expect(apple.headers()['content-type']).toContain('image/png'); expect(apple.headers()['content-type']).toContain('image/png');
// Android needs a maskable PNG or the install prompt has no icon. // Android needs a maskable PNG or the install prompt has no icon.
@@ -951,22 +912,8 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
// hashed at build time and change on any unrelated edit. // hashed at build time and change on any unrelated edit.
await expect(home).toHaveText(/^\s*schoolcompare\s*$/); await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
/* const mark = home.locator('svg').first();
* The mark is raster artwork, not an inline SVG — it has been since the
* supplied logo replaced the reconstruction, and this locator went on
* looking for an <svg> that no longer exists.
*
* Assert on naturalWidth rather than visibility: a <picture> whose sources
* all 404 still lays out and still passes toBeVisible(), so the weaker
* assertion would go green on a broken lockup.
*/
const mark = home.locator('img').first();
await expect(mark).toBeVisible(); await expect(mark).toBeVisible();
const markState = await mark.evaluate((el: HTMLImageElement) => ({
loaded: el.complete && el.naturalWidth > 0,
src: el.currentSrc,
}));
expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true);
}); });
test('no visible text falls back to the browser default black', async ({ page }) => { test('no visible text falls back to the browser default black', async ({ page }) => {
+7 -11
View File
@@ -193,17 +193,13 @@
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, not from --hero-ground is sampled from the artwork's own copy area (#FDF9F3),
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible not from Sand. The artwork reserves an empty cream region on the left
seam straight down the hero. for the headline, and the scrim that guarantees text contrast fades from
this colour into it — a scrim in Sand (#F5EFE6, eight to thirteen points
The artwork's copy area is not one flat colour: it runs from a peach darker per channel) leaves a visible seam straight down the hero. */
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48% --hero-ground: #FDF9F3;
the left edge is foliage rather than cream. This value is sampled from --hero-ground-rgb: 253, 249, 243;
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: 3.0 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -460,30 +460,6 @@
} }
} }
/*
* Narrow phones: the button drops below the input instead of sharing the row.
*
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
* screen it took 48% of the row and left the input 102px of text space for a
* 194px placeholder — the field showed "School name or" and stopped. At 320px
* the input was down to 32px, which is too narrow to read what you are typing.
*
* Wrapping rather than restructuring: the pill stays one element, so it keeps
* its border, shadow and :focus-within ring, and the button gets a full-width
* tap target on the way. Giving the button a 100% flex-basis is what forces
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
*/
@media (max-width: 480px) {
.heroMode .omniBoxContainer {
flex-wrap: wrap;
gap: 0.5rem;
}
.heroMode .searchButton {
flex: 1 1 100%;
width: 100%;
}
}
@media (max-width: 420px) { @media (max-width: 420px) {
/* Below this the pin costs more room than it earns. */ /* Below this the pin costs more room than it earns. */
.heroMode .omniIcon { .heroMode .omniIcon {
-16
View File
@@ -92,22 +92,6 @@
max-width: 34ch; max-width: 34ch;
} }
/*
* Quieter than .description, but by size only — not by contrast.
*
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
* fine margin for decorative text and the wrong one for a disclaimer, whose
* whole job is to be legible to someone checking whether this is a government
* site. --on-sunken-muted is 5.66:1 on the same band.
*/
.independence {
margin: 0;
font-size: var(--step--2);
line-height: 1.6;
color: var(--on-sunken-muted);
max-width: 38ch;
}
.sectionTitle { .sectionTitle {
margin: 0; margin: 0;
font-family: var(--font-display); font-family: var(--font-display);
-8
View File
@@ -35,14 +35,6 @@ export function Footer() {
<p className={styles.description}> <p className={styles.description}>
Compare primary and secondary schools across England. Compare primary and secondary schools across England.
</p> </p>
{/*
Says once, plainly, what the landing page's value props only
imply: we publish official data, we are not an official body.
Cheap to state and expensive to be wrong about.
*/}
<p className={styles.independence}>
An independent site. Not affiliated with the Department for Education or Ofsted.
</p>
<a <a
href="mailto:contact@schoolcompare.co.uk" href="mailto:contact@schoolcompare.co.uk"
className={styles.link} className={styles.link}
+7 -84
View File
@@ -161,22 +161,10 @@
* page and out-shouts the H1 and the search box. Dimming it in CSS is the * 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 * whole treatment, and the scrim then fades it into the dark panel instead of
* into cream. * into cream.
*
* Raising the brightness raises the background under light dark-theme text, so
* this value has a contrast floor, not just a taste range. Measured off
* rendered pixels, sampling background up to 120px past each line:
*
* brightness title body
* 0.52 11.01:1 6.44:1
* 0.75 9.48:1 5.21:1 ← current
*
* Body text is the binding one. Going much above 0.75 walks it toward the
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
* than the artwork being dimmed less.
*/ */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.heroArt img { .heroArt img {
filter: brightness(0.75) saturate(0.72) contrast(1.02); filter: brightness(0.52) saturate(0.72) contrast(1.02);
} }
.heroPanel::before { .heroPanel::before {
background: linear-gradient( background: linear-gradient(
@@ -189,43 +177,6 @@
} }
} }
/*
* The one line in the first person, so it is set apart from the marketing copy
* around it without shouting: display face, a step down in size, and a short
* brand rule instead of a bullet or an emoji.
*
* No italic — Manrope ships no italic in the loaded weights, so font-style
* would be synthesised into a slant. Same reason .heroEmph sets font-style
* back to normal.
*/
.heroByline {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 1.25rem 0 0;
font-family: var(--font-display);
font-size: var(--step--1);
font-weight: 500;
font-style: normal;
color: var(--text-secondary);
}
.heroByline::before {
content: '';
flex: 0 0 auto;
width: 1.25rem;
height: 2px;
border-radius: 1px;
background: var(--brand);
}
@media (max-width: 640px) {
.heroByline {
margin-top: 1rem;
font-size: var(--step--2);
}
}
.heroEyebrow { .heroEyebrow {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -302,42 +253,18 @@
* the same decision. * the same decision.
*/ */
@media (max-width: 860px) { @media (max-width: 860px) {
/*
* Artwork above the copy, not below it.
*
* The DOM keeps .heroContent first so the desktop overlay does not depend on
* source order, which left the band stranded under the search on phones —
* reading as a strip stuck on the end rather than as a hero image. `order`
* moves it visually only; it is decorative and aria-hidden, so there is no
* reading order to disturb.
*
* It costs about 136px above the search. Measured on a 667px viewport (the
* shortest phone still in use) the search still lands near 400px, well
* inside the fold.
*/
.heroPanel {
display: flex;
flex-direction: column;
}
.heroContent { .heroContent {
padding: 1.75rem 1.5rem 1.75rem; padding: 2rem 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; height: 11rem;
height: 13rem;
} }
/* The band crop puts the schoolhouse at 73% across — reported by /* The band crop centres the schoolhouse at about 65% across, so squeezing
scripts/build-hero-images.js, which derives it from the crop box rather toward 4.9:1 crops the empty sides rather than the subject. */
than leaving the two to drift. Squeezing narrower then crops the emptier
left side rather than the subject. */
.heroArt img { .heroArt img {
object-position: 73% center; object-position: 65% 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. */
@@ -378,12 +305,8 @@
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: 10rem; height: 8.5rem;
} }
} }
+1 -27
View File
@@ -157,20 +157,7 @@ const VALUE_PROPS: ValueProp[] = [
{ {
icon: <ShieldCheckIcon />, icon: <ShieldCheckIcon />,
tintClass: styles.propIconTrust, tintClass: styles.propIconTrust,
/* title: 'Official & trusted',
* "Built on official data", never "Official".
*
* The previous title was "Official & trusted", whose grammatical subject
* is this site — it reads as a claim that schoolcompare is itself an
* official service. It is not: it is an independent site that republishes
* official figures. The distinction is the difference between describing
* the data and describing ourselves, and only the first is true.
*
* Everywhere else the word appears ("official DfE figures", "the official
* figure isn't in") it already qualifies the data, which is correct and
* should stay.
*/
title: 'Built on official data',
body: 'Every figure comes from DfE performance tables and Ofsted.', body: 'Every figure comes from DfE performance tables and Ofsted.',
}, },
{ {
@@ -464,19 +451,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
geoError={geoError} geoError={geoError}
/> />
{/*
Sits under the search rather than in the footer on purpose: this
is the one line on the page written in the first person, and it
earns its place at the moment someone is deciding whether to
trust a site full of government statistics. In the footer it
would be true and unread.
It is deliberately the only such claim — the page's other trust
signals are all about the data's provenance, which is checkable.
This one is about who made it, which is not, so it is stated
once, plainly, and never repeated.
*/}
<p className={styles.heroByline}>Built for parents, by a parent.</p>
</div> </div>
{/* {/*
+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 = 2000; const WIDE_W = 1672;
const WIDE_H = 1125; const WIDE_H = 941;
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-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w" srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
sizes={SIZES} sizes={SIZES}
/> />
<source <source
media="(max-width: 860px)" media="(max-width: 860px)"
type="image/webp" type="image/webp"
srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w" srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
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-900.jpg" /> <source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
<source <source
type="image/avif" type="image/avif"
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w" srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
sizes={SIZES} sizes={SIZES}
/> />
<source <source
type="image/webp" type="image/webp"
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w" srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
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-1400.jpg" src="/brand/hero-wide-1200.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` (2000×1125 PNG), committed so - **Master:** `nextjs-app/assets/hero-source.png` (1672×941 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.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+12 -23
View File
@@ -45,24 +45,14 @@ 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, measured against the artwork rather than * Band crop, in source pixels. The schoolhouse occupies roughly x 1180–1540,
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag * y 300–520, so this keeps it whole with headroom above the flag and enough
* up at y 330. * hillside below to read as landscape rather than as a cutout.
*
* 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 }; const BAND = { left: 700, top: 260, width: 972, height: 380 };
/** Where the schoolhouse lands in the band crop, for CSS object-position. */ const WIDE_WIDTHS = [1672, 1200, 900];
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100); const BAND_WIDTHS = [972, 700, 500];
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 });
@@ -77,26 +67,25 @@ 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: 1400 }) .resize({ width: 1200 })
.flatten({ background: '#FEF2E1' }) .flatten({ background: '#FDF9F3' })
.jpeg({ quality: 82, mozjpeg: true }) .jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-wide-1400.jpg')); .toFile(path.join(OUT, 'hero-wide-1200.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: 900 }) .resize({ width: 700 })
.flatten({ background: '#FEF2E1' }) .flatten({ background: '#FDF9F3' })
.jpeg({ quality: 82, mozjpeg: true }) .jpeg({ quality: 82, mozjpeg: true })
.toFile(path.join(OUT, 'hero-band-900.jpg')); .toFile(path.join(OUT, 'hero-band-700.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;