diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 6e9e97d..07dfe10 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -140,59 +140,73 @@ test('the landing bands share one vertical rhythm', async ({ page }) => { }); /* - * The hero illustration. Two separate regressions are guarded here, both of - * which shipped: the drawing being cropped past its own subject on phones, and - * the drawing keeping a fixed light palette on a near-black page. + * The hero artwork. Every assertion here covers a failure that renders as a + * perfectly valid page: a path that 404s leaves an empty box, a crop that + * stops switching silently decapitates the subject, and a format negotiation + * that regresses just serves a bigger file. */ -test('the hero illustration swaps composition rather than cropping its subject', async ({ page }) => { - const visible = () => page.evaluate(() => { - const wide = document.querySelector('[class*="heroArtWide"]'); - const band = document.querySelector('[class*="heroArtBand"]'); +function heroImageState(page: Page) { + return page.evaluate(() => { + const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null; + if (!img) return null; return { - wide: wide ? getComputedStyle(wide).display : 'missing', - band: band ? getComputedStyle(band).display : 'missing', + // naturalWidth is 0 for an image that failed to load, whatever the src says. + loaded: img.complete && img.naturalWidth > 0, + file: img.currentSrc.split('/').pop() ?? '', }; }); +} +test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/'); - expect(await visible()).toEqual({ wide: 'block', band: 'none' }); + const wide = await heroImageState(page); + expect(wide).not.toBeNull(); + expect(wide!.loaded).toBe(true); + expect(wide!.file).toContain('hero-wide'); - // Below the one-column breakpoint the slot becomes a wide, short band. The - // tall composition sliced to that shape cropped the schoolhouse away - // entirely, leaving hills and a pin pointing at nothing. + /* + * Below the one-column breakpoint the slot becomes a band up to 4.9:1. The + * desktop frame centre-cropped to that shape loses the schoolhouse, which is + * how the previous hero failed on phones — so must switch to the + * pre-cropped band file, not merely to a smaller version of the same frame. + */ await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/'); - expect(await visible()).toEqual({ wide: 'none', band: 'block' }); + const band = await heroImageState(page); + expect(band!.loaded).toBe(true); + expect(band!.file).toContain('hero-band'); }); -test('the hero illustration is re-graded for the dark theme', async ({ browser }) => { - const read = async (colorScheme: 'light' | 'dark') => { +test('the hero artwork is served in a modern format', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto('/'); + const state = await heroImageState(page); + // The JPEG exists only as the fallback. Chromium takes AVIF, so seeing + // the fallback here means the negotiation broke — which costs about + // 24kB extra on the LCP element and nothing visible. + expect(state!.file).toMatch(/\.(avif|webp)$/); +}); + +test('the hero artwork is dimmed for the dark theme', async ({ browser }) => { + const filterFor = async (colorScheme: 'light' | 'dark') => { const ctx = await browser.newContext({ colorScheme }); const p = await ctx.newPage(); await p.goto('/'); - const value = await p.evaluate(() => - getComputedStyle(document.documentElement).getPropertyValue('--ill-sky-high').trim() - ); + const value = await p.evaluate(() => { + const img = document.querySelector('[class*="heroArt"] img'); + return img ? getComputedStyle(img).filter : 'missing'; + }); await ctx.close(); return value; }; - const toLuminance = (hex: string) => { - const m = hex.replace('#', ''); - const [r, g, b] = [0, 2, 4].map(i => parseInt(m.slice(i, i + 2), 16) / 255); - const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); - return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); - }; - - const light = await read('light'); - const dark = await read('dark'); - expect(light).toMatch(/^#[0-9a-f]{6}$/i); - expect(dark).toMatch(/^#[0-9a-f]{6}$/i); - // The sky used to be the same pale blue in both themes, which made it the - // brightest object on the page in dark mode — it out-shouted the H1 and the - // search box. It must now be substantially darker than its light value. - expect(toLuminance(dark)).toBeLessThan(toLuminance(light) / 4); + // The artwork is a fixed raster, so it cannot be re-graded per theme the way + // the drawn hero was. Undimmed it is the brightest object on a near-black + // page and out-shouts the H1 and the search box — the exact regression the + // drawn version shipped with. + expect(await filterFor('light')).toBe('none'); + expect(await filterFor('dark')).toMatch(/brightness/); }); test('searching by name returns school results', async ({ page }) => { diff --git a/nextjs-app/app/globals.css b/nextjs-app/app/globals.css index 03f54a3..70ebdea 100644 --- a/nextjs-app/app/globals.css +++ b/nextjs-app/app/globals.css @@ -188,33 +188,18 @@ --section-gap: 4rem; --section-head-gap: 1.5rem; - /* ── Illustration ─────────────────────────────────────────────────── - The hero landscape's palette. These are tokens rather than literals in - the SVG for one reason: the drawing previously kept a fixed light - palette in both themes, which left a pale sky slab as the brightest - object on a near-black page — it out-shouted the H1 and the search box. - Artwork still shouldn't invert (that breaks the picture), so the dark - values below are a re-grade, not an inversion: the same scene, lit at - dusk, sitting a step above the panel it lives on. */ - --ill-sky-high: #DAEDF8; - --ill-sky-low: #EFF8FB; - --ill-cloud: #FFFFFF; - --ill-hill-far: #D6EDE2; - --ill-hill-mid: #A7D7C5; - --ill-hill-near: #7FC3AC; - --ill-hill-front: #5BA88F; - --ill-path: #FAF6EE; - --ill-path-edge: #E6DAC2; - --ill-wall: #FCE8C3; - --ill-roof: #F0A868; - --ill-door: #0F766E; - --ill-window: #C7EBF5; - --ill-tree-dark: #2E7D6B; - --ill-tree-mid: #3E8C74; - --ill-tree-light: #4A9E85; - --ill-tree-pale: #7FC3AC; - --ill-pin: #F97360; - --ill-pin-eye: #FFFFFF; + /* ── Hero artwork ─────────────────────────────────────────────────── + The landing hero is now a supplied raster illustration rather than a + drawn SVG, so its colours are in the file, not here. What is left is the + ground the artwork sits on and fades into. + + --hero-ground is sampled from the artwork's own copy area (#FDF9F3), + not from Sand. The artwork reserves an empty cream region on the left + 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 + darker per channel) leaves a visible seam straight down the hero. */ + --hero-ground: #FDF9F3; + --hero-ground-rgb: 253, 249, 243; /* ── Geometry & motion ────────────────────────────────────────────── "Soft shapes, rounded corners" — the guideline's geometry is markedly @@ -340,30 +325,12 @@ --medal-silver-rgb: 169, 182, 188; --medal-bronze-rgb: 201, 144, 112; - /* Dusk re-grade of the hero landscape. The sky sits one step above the - Sand panel (#16222A) rather than 60 points above it, so the picture - reads as a framed vignette instead of a light leak. The pin keeps - the theme's coral and is the only bright thing left in the scene — - which is the point, since it marks "you are here". */ - --ill-sky-high: #1B2C36; - --ill-sky-low: #16242C; - --ill-cloud: #2A3F4A; - --ill-hill-far: #1E3A38; - --ill-hill-mid: #234944; - --ill-hill-near: #2A5A51; - --ill-hill-front: #326B5E; - --ill-path: #4A5A52; - --ill-path-edge: #3A4842; - --ill-wall: #45423A; - --ill-roof: #8A5A3E; - --ill-door: #5FC7BB; - --ill-window: #2F5966; - --ill-tree-dark: #16302C; - --ill-tree-mid: #1B3A34; - --ill-tree-light: #21453D; - --ill-tree-pale: #2A5A51; - --ill-pin: #F08A72; - --ill-pin-eye: #16242C; + /* The artwork is a fixed, bright raster — it cannot be re-graded by + token the way the drawn version was. The dark theme instead dims it + in CSS (see .heroArt in HomeView.module.css) and fades it into this + ground, which is the panel colour rather than the artwork's cream. */ + --hero-ground: #16222A; + --hero-ground-rgb: 22, 34, 42; --shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); --shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45); diff --git a/nextjs-app/assets/hero-source.png b/nextjs-app/assets/hero-source.png new file mode 100644 index 0000000..c010296 Binary files /dev/null and b/nextjs-app/assets/hero-source.png differ diff --git a/nextjs-app/components/HomeView.module.css b/nextjs-app/components/HomeView.module.css index 04bb74c..a964d3e 100644 --- a/nextjs-app/components/HomeView.module.css +++ b/nextjs-app/components/HomeView.module.css @@ -77,52 +77,104 @@ } /* ── Hero ────────────────────────────────────────────────────────────────── - A Sand panel: proposition and search on the left, the brand landscape - bleeding to the panel's right edge. The illustration is decorative, so on - phones it drops to a short band under the content rather than competing - with the search for the fold. */ + The artwork fills the panel and the proposition sits on top of it, in the + empty cream area the illustration reserves on its left. It is not a + two-column grid: splitting the panel would crop that reserved area off and + shrink the scene into a thumbnail, which is the one thing the artwork is + composed not to be. + + Below the one-column breakpoint the artwork leaves the background and + becomes a band under the search — see the 860px block. */ .heroPanel { - display: grid; - grid-template-columns: 1.12fr 0.88fr; - align-items: stretch; - background: var(--bg-secondary); + position: relative; + isolation: isolate; + background: var(--hero-ground); border-radius: var(--radius-xl); overflow: hidden; } .heroContent { - align-self: center; - padding: 3rem 1.5rem 3rem 3rem; + position: relative; + z-index: 2; + padding: 3.5rem 1.5rem 3.5rem 3rem; + /* Held inside the artwork's cream area. Beyond roughly this width the text + runs onto the hillside, where the scrim below is doing all the work. */ + max-width: min(56%, 40rem); min-width: 0; } .heroArt { - position: relative; - min-width: 0; - /* The scene needs height to read; without a floor it collapses to the - content column's height on short viewports. */ - min-height: 24rem; -} - -/* Both compositions fill the slot; the breakpoint below picks one. */ -.heroArtWide, -.heroArtBand { position: absolute; inset: 0; + z-index: 0; } -.heroArt svg { - position: absolute; - inset: 0; +.heroArt picture, +.heroArt img { + display: block; width: 100%; height: 100%; - display: block; } -/* The band composition only exists below the one-column breakpoint — above - it the slot is tall and the wide drawing is the right picture. */ -.heroArtBand { - display: none; +.heroArt img { + object-fit: cover; + /* Centre keeps the schoolhouse in frame at every desktop panel ratio: the + panel runs about 2.1:1 to 2.7:1 against a 1.78:1 image, so cover crops + height only, and the school sits well inside the surviving band. */ + object-position: center; +} + +/* + * Contrast guarantee. + * + * The artwork's cream area is generous but it is a fixed image on a fluid + * panel — as the panel widens the text block and the cream drift apart, and at + * some width the headline would sit on hillside green. Rather than tune the + * text width per breakpoint and hope, the panel fades its own ground across + * the left of the artwork, so the copy always has a known surface under it. + * + * The gradient starts from --hero-ground, which is sampled from the artwork + * itself, so in the light theme this is invisible — it reads as the + * illustration, not as an overlay on it. + */ +.heroPanel::before { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: linear-gradient( + to right, + var(--hero-ground) 0%, + rgba(var(--hero-ground-rgb), 0.97) 26%, + rgba(var(--hero-ground-rgb), 0.72) 44%, + rgba(var(--hero-ground-rgb), 0) 62% + ); +} + +/* + * Dark theme. + * + * The artwork is a fixed raster, so unlike the drawn hero it cannot be + * re-graded token by token — but the failure it would otherwise reproduce is + * the same one: a bright illustration is the brightest object on a near-black + * 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 + * into cream. + */ +@media (prefers-color-scheme: dark) { + .heroArt img { + filter: brightness(0.52) saturate(0.72) contrast(1.02); + } + .heroPanel::before { + background: linear-gradient( + to right, + var(--hero-ground) 0%, + rgba(var(--hero-ground-rgb), 0.96) 30%, + rgba(var(--hero-ground-rgb), 0.66) 50%, + rgba(var(--hero-ground-rgb), 0.15) 78% + ); + } } .heroEyebrow { @@ -184,33 +236,41 @@ @media (max-width: 1024px) { .heroContent { - padding: 2.25rem 1.5rem 2.25rem 2rem; - } - .heroArt { - min-height: 20rem; + padding: 2.75rem 1.5rem 2.75rem 2rem; + max-width: min(64%, 34rem); } } -/* One column: content first, then a short band of landscape. */ +/* + * One column: the artwork stops being a background and becomes a band under + * the search. + * + * Overlaying at this width does not work — the panel is too narrow for the + * copy to stay inside the artwork's cream area, so the scrim would have to + * cover nearly the whole image, which is just a tinted rectangle with a + * picture faintly behind it. Below the band, has already switched + * to the pre-cropped file (see Illustration.tsx); this is the layout half of + * the same decision. + */ @media (max-width: 860px) { - .heroPanel { - grid-template-columns: 1fr; - } .heroContent { padding: 2rem 1.5rem 1.75rem; + max-width: none; } - /* One column: the slot becomes a wide, short band, so swap in the drawing - composed for that crop. See the note at the top of Illustration.tsx. */ .heroArt { - min-height: 11rem; - order: 2; + position: static; + height: 11rem; } - .heroArtWide { + /* The band crop centres the schoolhouse at about 65% across, so squeezing + toward 4.9:1 crops the empty sides rather than the subject. */ + .heroArt img { + object-position: 65% center; + } + /* The scrim exists to protect text sitting on the artwork. Nothing sits on + it here, and leaving it would wash the band out. */ + .heroPanel::before { display: none; } - .heroArtBand { - display: block; - } .heroTitle { font-size: var(--step-3); max-width: none; @@ -246,7 +306,7 @@ margin-bottom: 0.5rem; } .heroArt { - min-height: 8.5rem; + height: 8.5rem; } } diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index a1e73c6..e3c459c 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -12,7 +12,7 @@ import { SchoolRow } from './SchoolRow'; import { SecondarySchoolRow } from './SecondarySchoolRow'; import { SchoolMap } from './SchoolMap'; import { EmptyState } from './EmptyState'; -import { HeroIllustration, HeroIllustrationBand } from './Illustration'; +import { HeroIllustration } from './Illustration'; import { useComparisonContext } from '@/context/ComparisonContext'; import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api'; import type { SchoolsResponse, Filters, School } from '@/lib/types'; @@ -454,17 +454,13 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed {/* - Two drawings, one visible — see the note at the top of - Illustration.tsx. The hero art slot ranges from about 1.34:1 to - 4.9:1 across the breakpoints, which no single composition survives. + Rendered after the content on purpose. On desktop it is absolutely + positioned behind the panel, so order does not matter; below the + one-column breakpoint it returns to normal flow, where DOM order is + what puts the band under the search rather than above the headline. */}
-
- -
-
- -
+
diff --git a/nextjs-app/components/Illustration.tsx b/nextjs-app/components/Illustration.tsx index aadeadc..cc3963c 100644 --- a/nextjs-app/components/Illustration.tsx +++ b/nextjs-app/components/Illustration.tsx @@ -1,212 +1,91 @@ /** - * Brand illustration — the landing hero's landscape. + * The landing hero artwork. * - * Guideline rules for illustration: no people or faces, soft shapes, rounded - * corners, minimal detail, maximum clarity. The scene reads as "the journey to - * a school" — a path climbing through layered hills to a small schoolhouse, - * with a pin marking where you are. + * This is the supplied illustration, not a drawing of one — see + * assets/hero-source.png and scripts/build-hero-images.js, which produces + * everything under public/brand/hero-*. Regenerate rather than hand-editing. * - * Deliberately server-safe: no 'use client', no hooks, no event handlers, so - * it renders in the RSC pass and never reaches the client bundle. + * Server-safe by construction: no 'use client', no hooks, no handlers, so it + * renders in the RSC pass and never reaches the client bundle. * - * TWO COMPOSITIONS, ON PURPOSE - * ---------------------------- - * The hero art slot is not one shape. On a wide desktop it is roughly 1.5:1; - * at the 1024px breakpoint about 1.34:1; on a phone it collapses to a band - * near 2.6:1, and just above the one-column breakpoint it is nearly 5:1. + * TWO CROPS, ONE + * ------------------------ + * The slot is two different shapes. On desktop the artwork sits behind the + * whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream + * area the illustration reserves on its left. Below the one-column breakpoint + * it becomes a band between 2.6:1 and 4.9:1, under the search rather than + * behind it. * - * A single drawing cannot cover 1.34:1 → 4.9:1. With `slice`, the SVG scales - * to cover and crops the overflow, anchored here to the bottom edge — so at - * 860×176 a 540×520 scene shows only its bottom 110 user units. That is what - * used to happen: the schoolhouse sat at y 218–324 and was cropped away - * entirely on phones, leaving bare hills and a pin pointing at nothing. + * A single file under `object-fit: cover` centre-crops, and at the band's + * extreme that slices a strip through the middle of the scene and loses the + * schoolhouse — exactly how the previous SVG hero failed on phones. So the + * switches crop, not just resolution: `band` is pre-cropped + * around the school and is already near 2.6:1, so the narrow band barely + * crops it further. * - * So there are two drawings and CSS shows exactly one. They are both static - * server-rendered SVG, so this costs markup and nothing else — no JS, no - * second request, no layout shift. Each is composed against the crop window - * its own breakpoint actually produces; see the safe-zone note on each. + * `object-position` is set in CSS per breakpoint and is load-bearing — see + * .heroArt in HomeView.module.css. * - * Colour comes from the --ill-* tokens in globals.css, which are re-graded for - * the dark theme. Everything else about the scene is identical in both themes. + * The artwork is decorative: the proposition beside it carries the meaning, so + * alt is empty and it is hidden from assistive tech rather than described. */ -/** Scoped so the gradient ids can't collide with another inline SVG. */ -const SKY_TALL_ID = 'sc-hero-sky-tall'; -const SKY_BAND_ID = 'sc-hero-sky-band'; - -function SkyGradient({ id }: { id: string }) { - return ( - - - - - ); -} - -/** - * The schoolhouse. Drawn once, placed by each composition. - * - * Detail deliberately below what it was: the gable clock, the flagpole - * pennant and the four-window grid are gone. At the sizes this actually - * renders they read as noise, and they were what tipped the drawing from - * "soft brand illustration" into clip art. +/* + * The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of + * padding (1rem under 768px), so the artwork's box is that minus the padding — + * describing it as 100vw over-requests by ~48px worth of candidate at every + * width, which on a slow connection is a larger file than needed for the LCP + * element. */ -function Schoolhouse({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) { - return ( - - - - - - - - ); -} +const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)'; -/** The "you are here" pin. The only saturated thing in either composition. */ -function Pin({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) { - return ( - - - - - ); -} +/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */ +const WIDE_W = 1672; +const WIDE_H = 941; -/** - * Desktop composition — the two-column hero panel. - * - * Crop window: `slice` anchored to the bottom, so the widest slot (about - * 590×384) reveals y ∈ [169, 520] and the narrowest (about 430×320) reveals - * y ∈ [118, 520]. Everything that carries meaning is therefore kept at - * y ≥ 200; the sky above it exists only to be cropped. - */ export function HeroIllustration() { return ( - - ); -} - -/** - * Band composition — the one-column hero, where the art becomes a short strip - * beneath the search. - * - * Crop window: at 358×136 (a phone) `slice` reveals x ∈ [42, 358] of the - * 400-unit width; at 860×176 (just under the one-column breakpoint) it reveals - * the full width but only y ∈ [38, 120]. The intersection — x ∈ [70, 330], - * y ∈ [38, 120] — is where the school and the pin sit. Hills and trees run to - * the edges precisely because they are the parts that may be cropped. - */ -export function HeroIllustrationBand() { - return ( - + + {/* + The hero is the largest thing above the fold, so it is almost certainly + the LCP element: fetchPriority high, and never lazy. eslint's + no-img-element wants next/image, which cannot art-direct between two + different crops — that is the whole point of the above. + */} + {/* eslint-disable-next-line @next/next/no-img-element */} + + ); } diff --git a/nextjs-app/public/brand/CREDITS.md b/nextjs-app/public/brand/CREDITS.md new file mode 100644 index 0000000..138d543 --- /dev/null +++ b/nextjs-app/public/brand/CREDITS.md @@ -0,0 +1,35 @@ +# Brand asset provenance + +Every image shipped in this folder, where it came from, and what we are allowed +to do with it. Add a row before adding a file — in two years nobody will +remember, and "we think it was fine" is not a licence. + +## `mark.png`, `mark-on-dark.png` + +The schoolcompare mark, extracted from the logo sheet supplied by the project +owner. Two colourways on one 176×208 canvas — see `components/Logo.tsx` for why +they must share a canvas. + +- **Source:** supplied by the project owner +- **Licence:** owned by the project + +> Only a 153×189 instance exists on the supplied sheet, so the 512px PWA icon is +> upscaled and slightly soft. A vector (SVG/AI/EPS) dropped in here and +> regenerated fixes it; every consumer goes through `components/Logo.tsx` or +> this folder, so it is one swap. + +## `hero-wide-*`, `hero-band-*` + +The landing hero illustration: a path climbing through hills to a schoolhouse, +with an empty cream area on the left reserved for the headline. + +- **Source:** supplied by the project owner +- **Master:** `nextjs-app/assets/hero-source.png` (1672×941 PNG), committed so + the derivatives can be regenerated +- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these +- **Licence:** **to be confirmed by the project owner** + +> ⚠️ The licence line above is the one thing here that is not verified. If this +> artwork was bought, generated, or commissioned, record which — and keep the +> receipt or licence key alongside it. It is the most prominent image on the +> site. diff --git a/nextjs-app/public/brand/hero-band-500.avif b/nextjs-app/public/brand/hero-band-500.avif new file mode 100644 index 0000000..1c5c26c Binary files /dev/null and b/nextjs-app/public/brand/hero-band-500.avif differ diff --git a/nextjs-app/public/brand/hero-band-500.webp b/nextjs-app/public/brand/hero-band-500.webp new file mode 100644 index 0000000..12bfdc6 Binary files /dev/null and b/nextjs-app/public/brand/hero-band-500.webp differ diff --git a/nextjs-app/public/brand/hero-band-700.avif b/nextjs-app/public/brand/hero-band-700.avif new file mode 100644 index 0000000..8c4b6f2 Binary files /dev/null and b/nextjs-app/public/brand/hero-band-700.avif differ diff --git a/nextjs-app/public/brand/hero-band-700.jpg b/nextjs-app/public/brand/hero-band-700.jpg new file mode 100644 index 0000000..f4f9acc Binary files /dev/null and b/nextjs-app/public/brand/hero-band-700.jpg differ diff --git a/nextjs-app/public/brand/hero-band-700.webp b/nextjs-app/public/brand/hero-band-700.webp new file mode 100644 index 0000000..a4232be Binary files /dev/null and b/nextjs-app/public/brand/hero-band-700.webp differ diff --git a/nextjs-app/public/brand/hero-band-972.avif b/nextjs-app/public/brand/hero-band-972.avif new file mode 100644 index 0000000..65e7998 Binary files /dev/null and b/nextjs-app/public/brand/hero-band-972.avif differ diff --git a/nextjs-app/public/brand/hero-band-972.webp b/nextjs-app/public/brand/hero-band-972.webp new file mode 100644 index 0000000..b84a84d Binary files /dev/null and b/nextjs-app/public/brand/hero-band-972.webp differ diff --git a/nextjs-app/public/brand/hero-wide-1200.avif b/nextjs-app/public/brand/hero-wide-1200.avif new file mode 100644 index 0000000..5b5b673 Binary files /dev/null and b/nextjs-app/public/brand/hero-wide-1200.avif differ diff --git a/nextjs-app/public/brand/hero-wide-1200.jpg b/nextjs-app/public/brand/hero-wide-1200.jpg new file mode 100644 index 0000000..646b258 Binary files /dev/null and b/nextjs-app/public/brand/hero-wide-1200.jpg differ diff --git a/nextjs-app/public/brand/hero-wide-1200.webp b/nextjs-app/public/brand/hero-wide-1200.webp new file mode 100644 index 0000000..201f1a4 Binary files /dev/null and b/nextjs-app/public/brand/hero-wide-1200.webp differ diff --git a/nextjs-app/public/brand/hero-wide-1672.avif b/nextjs-app/public/brand/hero-wide-1672.avif new file mode 100644 index 0000000..583efcf Binary files /dev/null and b/nextjs-app/public/brand/hero-wide-1672.avif differ diff --git a/nextjs-app/public/brand/hero-wide-1672.webp b/nextjs-app/public/brand/hero-wide-1672.webp new file mode 100644 index 0000000..4608bdc Binary files /dev/null and b/nextjs-app/public/brand/hero-wide-1672.webp differ diff --git a/nextjs-app/public/brand/hero-wide-900.avif b/nextjs-app/public/brand/hero-wide-900.avif new file mode 100644 index 0000000..8b658e4 Binary files /dev/null and b/nextjs-app/public/brand/hero-wide-900.avif differ diff --git a/nextjs-app/public/brand/hero-wide-900.webp b/nextjs-app/public/brand/hero-wide-900.webp new file mode 100644 index 0000000..1c8f986 Binary files /dev/null and b/nextjs-app/public/brand/hero-wide-900.webp differ diff --git a/nextjs-app/scripts/build-hero-images.js b/nextjs-app/scripts/build-hero-images.js new file mode 100644 index 0000000..91b7043 --- /dev/null +++ b/nextjs-app/scripts/build-hero-images.js @@ -0,0 +1,95 @@ +#!/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 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 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); +});