feat(home): use the supplied hero artwork instead of a drawn one #93

Merged
tudor merged 1 commits from feat/hero-artwork into main 2026-08-14 20:41:46 +00:00
22 changed files with 378 additions and 332 deletions

No files matched your search

+48 -34
View File
@@ -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 * The hero artwork. Every assertion here covers a failure that renders as a
* which shipped: the drawing being cropped past its own subject on phones, and * perfectly valid page: a path that 404s leaves an empty box, a crop that
* the drawing keeping a fixed light palette on a near-black page. * 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 }) => { function heroImageState(page: Page) {
const visible = () => page.evaluate(() => { return page.evaluate(() => {
const wide = document.querySelector('[class*="heroArtWide"]'); const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null;
const band = document.querySelector('[class*="heroArtBand"]'); if (!img) return null;
return { return {
wide: wide ? getComputedStyle(wide).display : 'missing', // naturalWidth is 0 for an image that failed to load, whatever the src says.
band: band ? getComputedStyle(band).display : 'missing', 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.setViewportSize({ width: 1440, height: 900 });
await page.goto('/'); 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 * Below the one-column breakpoint the slot becomes a band up to 4.9:1. The
// entirely, leaving hills and a pin pointing at nothing. * desktop frame centre-cropped to that shape loses the schoolhouse, which is
* how the previous hero failed on phones — so <picture> 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.setViewportSize({ width: 390, height: 844 });
await page.goto('/'); 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 }) => { test('the hero artwork is served in a modern format', async ({ page }) => {
const read = async (colorScheme: 'light' | 'dark') => { await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
const state = await heroImageState(page);
// The JPEG exists only as the <img> fallback. Chromium takes AVIF, so seeing
// the fallback here means the <source> 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 ctx = await browser.newContext({ colorScheme });
const p = await ctx.newPage(); const p = await ctx.newPage();
await p.goto('/'); await p.goto('/');
const value = await p.evaluate(() => const value = await p.evaluate(() => {
getComputedStyle(document.documentElement).getPropertyValue('--ill-sky-high').trim() const img = document.querySelector('[class*="heroArt"] img');
); return img ? getComputedStyle(img).filter : 'missing';
});
await ctx.close(); await ctx.close();
return value; return value;
}; };
const toLuminance = (hex: string) => { // The artwork is a fixed raster, so it cannot be re-graded per theme the way
const m = hex.replace('#', ''); // the drawn hero was. Undimmed it is the brightest object on a near-black
const [r, g, b] = [0, 2, 4].map(i => parseInt(m.slice(i, i + 2), 16) / 255); // page and out-shouts the H1 and the search box — the exact regression the
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); // drawn version shipped with.
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); expect(await filterFor('light')).toBe('none');
}; expect(await filterFor('dark')).toMatch(/brightness/);
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);
}); });
test('searching by name returns school results', async ({ page }) => { test('searching by name returns school results', async ({ page }) => {
+18 -51
View File
@@ -188,33 +188,18 @@
--section-gap: 4rem; --section-gap: 4rem;
--section-head-gap: 1.5rem; --section-head-gap: 1.5rem;
/* ── Illustration ─────────────────────────────────────────────────── /* ── Hero artwork ───────────────────────────────────────────────────
The hero landscape's palette. These are tokens rather than literals in The landing hero is now a supplied raster illustration rather than a
the SVG for one reason: the drawing previously kept a fixed light drawn SVG, so its colours are in the file, not here. What is left is the
palette in both themes, which left a pale sky slab as the brightest ground the artwork sits on and fades into.
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 --hero-ground is sampled from the artwork's own copy area (#FDF9F3),
values below are a re-grade, not an inversion: the same scene, lit at not from Sand. The artwork reserves an empty cream region on the left
dusk, sitting a step above the panel it lives on. */ for the headline, and the scrim that guarantees text contrast fades from
--ill-sky-high: #DAEDF8; this colour into it — a scrim in Sand (#F5EFE6, eight to thirteen points
--ill-sky-low: #EFF8FB; darker per channel) leaves a visible seam straight down the hero. */
--ill-cloud: #FFFFFF; --hero-ground: #FDF9F3;
--ill-hill-far: #D6EDE2; --hero-ground-rgb: 253, 249, 243;
--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;
/* ── Geometry & motion ────────────────────────────────────────────── /* ── Geometry & motion ──────────────────────────────────────────────
"Soft shapes, rounded corners" — the guideline's geometry is markedly "Soft shapes, rounded corners" — the guideline's geometry is markedly
@@ -340,30 +325,12 @@
--medal-silver-rgb: 169, 182, 188; --medal-silver-rgb: 169, 182, 188;
--medal-bronze-rgb: 201, 144, 112; --medal-bronze-rgb: 201, 144, 112;
/* Dusk re-grade of the hero landscape. The sky sits one step above the /* The artwork is a fixed, bright raster — it cannot be re-graded by
Sand panel (#16222A) rather than 60 points above it, so the picture token the way the drawn version was. The dark theme instead dims it
reads as a framed vignette instead of a light leak. The pin keeps in CSS (see .heroArt in HomeView.module.css) and fades it into this
the theme's coral and is the only bright thing left in the scene — ground, which is the panel colour rather than the artwork's cream. */
which is the point, since it marks "you are here". */ --hero-ground: #16222A;
--ill-sky-high: #1B2C36; --hero-ground-rgb: 22, 34, 42;
--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;
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); --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); --shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+105 -45
View File
@@ -77,52 +77,104 @@
} }
/* ── Hero ────────────────────────────────────────────────────────────────── /* ── Hero ──────────────────────────────────────────────────────────────────
A Sand panel: proposition and search on the left, the brand landscape The artwork fills the panel and the proposition sits on top of it, in the
bleeding to the panel's right edge. The illustration is decorative, so on empty cream area the illustration reserves on its left. It is not a
phones it drops to a short band under the content rather than competing two-column grid: splitting the panel would crop that reserved area off and
with the search for the fold. */ 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 { .heroPanel {
display: grid; position: relative;
grid-template-columns: 1.12fr 0.88fr; isolation: isolate;
align-items: stretch; background: var(--hero-ground);
background: var(--bg-secondary);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
overflow: hidden; overflow: hidden;
} }
.heroContent { .heroContent {
align-self: center; position: relative;
padding: 3rem 1.5rem 3rem 3rem; 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; min-width: 0;
} }
.heroArt { .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; position: absolute;
inset: 0; inset: 0;
z-index: 0;
} }
.heroArt svg { .heroArt picture,
position: absolute; .heroArt img {
inset: 0; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block;
} }
/* The band composition only exists below the one-column breakpoint — above .heroArt img {
it the slot is tall and the wide drawing is the right picture. */ object-fit: cover;
.heroArtBand { /* Centre keeps the schoolhouse in frame at every desktop panel ratio: the
display: none; 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 { .heroEyebrow {
@@ -184,33 +236,41 @@
@media (max-width: 1024px) { @media (max-width: 1024px) {
.heroContent { .heroContent {
padding: 2.25rem 1.5rem 2.25rem 2rem; padding: 2.75rem 1.5rem 2.75rem 2rem;
} max-width: min(64%, 34rem);
.heroArt {
min-height: 20rem;
} }
} }
/* 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, <picture> has already switched
* to the pre-cropped file (see Illustration.tsx); this is the layout half of
* the same decision.
*/
@media (max-width: 860px) { @media (max-width: 860px) {
.heroPanel {
grid-template-columns: 1fr;
}
.heroContent { .heroContent {
padding: 2rem 1.5rem 1.75rem; 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 { .heroArt {
min-height: 11rem; position: static;
order: 2; 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; display: none;
} }
.heroArtBand {
display: block;
}
.heroTitle { .heroTitle {
font-size: var(--step-3); font-size: var(--step-3);
max-width: none; max-width: none;
@@ -246,7 +306,7 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.heroArt { .heroArt {
min-height: 8.5rem; height: 8.5rem;
} }
} }
+5 -9
View File
@@ -12,7 +12,7 @@ import { SchoolRow } from './SchoolRow';
import { SecondarySchoolRow } from './SecondarySchoolRow'; import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap'; import { SchoolMap } from './SchoolMap';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { HeroIllustration, HeroIllustrationBand } from './Illustration'; import { HeroIllustration } from './Illustration';
import { useComparisonContext } from '@/context/ComparisonContext'; import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api'; import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types'; import type { SchoolsResponse, Filters, School } from '@/lib/types';
@@ -454,18 +454,14 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</div> </div>
{/* {/*
Two drawings, one visible — see the note at the top of Rendered after the content on purpose. On desktop it is absolutely
Illustration.tsx. The hero art slot ranges from about 1.34:1 to positioned behind the panel, so order does not matter; below the
4.9:1 across the breakpoints, which no single composition survives. one-column breakpoint it returns to normal flow, where DOM order is
what puts the band under the search rather than above the headline.
*/} */}
<div className={styles.heroArt}> <div className={styles.heroArt}>
<div className={styles.heroArtWide}>
<HeroIllustration /> <HeroIllustration />
</div> </div>
<div className={styles.heroArtBand}>
<HeroIllustrationBand />
</div>
</div>
</section> </section>
{/* Why this site, in four lines. These titles are labels on a list, {/* Why this site, in four lines. These titles are labels on a list,
+72 -193
View File
@@ -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 * This is the supplied illustration, not a drawing of one — see
* corners, minimal detail, maximum clarity. The scene reads as "the journey to * assets/hero-source.png and scripts/build-hero-images.js, which produces
* a school" — a path climbing through layered hills to a small schoolhouse, * everything under public/brand/hero-*. Regenerate rather than hand-editing.
* with a pin marking where you are.
* *
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so * Server-safe by construction: no 'use client', no hooks, no handlers, so it
* it renders in the RSC pass and never reaches the client bundle. * renders in the RSC pass and never reaches the client bundle.
* *
* TWO COMPOSITIONS, ON PURPOSE * TWO CROPS, ONE <picture>
* ---------------------------- * ------------------------
* The hero art slot is not one shape. On a wide desktop it is roughly 1.5:1; * The slot is two different shapes. On desktop the artwork sits behind the
* at the 1024px breakpoint about 1.34:1; on a phone it collapses to a band * whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
* near 2.6:1, and just above the one-column breakpoint it is nearly 5:1. * 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 * A single file under `object-fit: cover` centre-crops, and at the band's
* to cover and crops the overflow, anchored here to the bottom edge — so at * extreme that slices a strip through the middle of the scene and loses the
* 860×176 a 540×520 scene shows only its bottom 110 user units. That is what * schoolhouse — exactly how the previous SVG hero failed on phones. So the
* used to happen: the schoolhouse sat at y 218–324 and was cropped away * <source media> switches crop, not just resolution: `band` is pre-cropped
* entirely on phones, leaving bare hills and a pin pointing at nothing. * 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 * `object-position` is set in CSS per breakpoint and is load-bearing — see
* server-rendered SVG, so this costs markup and nothing else — no JS, no * .heroArt in HomeView.module.css.
* second request, no layout shift. Each is composed against the crop window
* its own breakpoint actually produces; see the safe-zone note on each.
* *
* Colour comes from the --ill-* tokens in globals.css, which are re-graded for * The artwork is decorative: the proposition beside it carries the meaning, so
* the dark theme. Everything else about the scene is identical in both themes. * 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'; * The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
const SKY_BAND_ID = 'sc-hero-sky-band'; * 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
function SkyGradient({ id }: { id: string }) { * width, which on a slow connection is a larger file than needed for the LCP
return ( * element.
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--ill-sky-high)" />
<stop offset="1" stopColor="var(--ill-sky-low)" />
</linearGradient>
);
}
/**
* 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.
*/ */
function Schoolhouse({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) { const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
return (
<g transform={`translate(${x} ${y}) scale(${scale})`}>
<rect x="12" y="34" width="98" height="58" rx="6" fill="var(--ill-wall)" />
<path d="M2 36 L61 6 L120 36 Z" fill="var(--ill-roof)" />
<rect x="52" y="60" width="20" height="32" rx="3" fill="var(--ill-door)" />
<rect x="26" y="48" width="18" height="16" rx="4" fill="var(--ill-window)" />
<rect x="80" y="48" width="18" height="16" rx="4" fill="var(--ill-window)" />
</g>
);
}
/** The "you are here" pin. The only saturated thing in either composition. */ /** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
function Pin({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) { const WIDE_W = 1672;
return ( const WIDE_H = 941;
<g transform={`translate(${x} ${y}) scale(${scale})`}>
<path
d="M21 48 C21 48 42 26 42 15.5 A21 21 0 1 0 0 15.5 C0 26 21 48 21 48 Z"
fill="var(--ill-pin)"
/>
<circle cx="21" cy="15.5" r="8" fill="var(--ill-pin-eye)" />
</g>
);
}
/**
* 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() { export function HeroIllustration() {
return ( return (
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false"> <picture>
<defs> {/* Band crop first: <source> is first-match-wins, so the narrow-viewport
<SkyGradient id={SKY_TALL_ID} /> rules have to precede the unconstrained desktop ones. */}
</defs> <source
media="(max-width: 860px)"
<rect width="540" height="520" fill={`url(#${SKY_TALL_ID})`} /> type="image/avif"
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
{/* Two soft clouds. Low opacity so they never compete with the pin. */} sizes={SIZES}
<g fill="var(--ill-cloud)" opacity="0.7">
<ellipse cx="96" cy="196" rx="34" ry="14" />
<ellipse cx="124" cy="189" rx="24" ry="17" />
<ellipse cx="424" cy="168" rx="28" ry="12" />
<ellipse cx="445" cy="162" rx="19" ry="14" />
</g>
{/* Four layered rolling hills, palest and furthest first */}
<path d="M0 300 C 90 264 168 294 246 276 C 330 256 410 282 540 254 L540 520 L0 520 Z" fill="var(--ill-hill-far)" />
<path d="M0 344 C 104 308 186 340 268 324 C 356 306 452 332 540 306 L540 520 L0 520 Z" fill="var(--ill-hill-mid)" />
<path d="M0 408 C 118 378 214 410 306 392 C 400 374 470 398 540 382 L540 520 L0 520 Z" fill="var(--ill-hill-near)" />
<path d="M0 468 C 130 446 236 474 340 458 C 432 444 486 462 540 452 L540 520 L0 520 Z" fill="var(--ill-hill-front)" />
{/* The path climbs from the near edge up to the school */}
<path
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
stroke="var(--ill-path)"
strokeWidth="18"
fill="none"
strokeLinecap="round"
/> />
<path <source
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316" media="(max-width: 860px)"
stroke="var(--ill-path-edge)" type="image/webp"
strokeWidth="18" srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
fill="none" sizes={SIZES}
strokeLinecap="butt"
strokeLinejoin="round"
strokeDasharray="0.5 26"
opacity="0.5"
/> />
<source
<Schoolhouse x={276} y={232} /> type="image/avif"
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
{/* Scattered rounded trees */} sizes={SIZES}
<g fill="var(--ill-tree-dark)">
<circle cx="104" cy="372" r="27" />
<circle cx="136" cy="386" r="19" />
<circle cx="470" cy="336" r="23" />
</g>
<g fill="var(--ill-tree-light)">
<circle cx="78" cy="394" r="21" />
<circle cx="492" cy="358" r="17" />
<circle cx="176" cy="420" r="18" />
</g>
<g fill="var(--ill-tree-mid)">
<ellipse cx="44" cy="336" rx="15" ry="22" />
<ellipse cx="508" cy="296" rx="13" ry="18" />
<ellipse cx="150" cy="342" rx="12" ry="17" />
</g>
<g fill="var(--ill-tree-pale)" opacity="0.9">
<circle cx="410" cy="420" r="16" />
<circle cx="438" cy="430" r="12" />
<circle cx="120" cy="470" r="14" />
</g>
<Pin x={196} y={372} />
</svg>
);
}
/**
* 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 (
<svg viewBox="0 0 400 120" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
<defs>
<SkyGradient id={SKY_BAND_ID} />
</defs>
<rect width="400" height="120" fill={`url(#${SKY_BAND_ID})`} />
{/* Hills sit low so the safe zone stays clear for the building */}
<path d="M0 74 C 70 62 120 76 176 70 C 244 62 318 74 400 62 L400 120 L0 120 Z" fill="var(--ill-hill-far)" />
<path d="M0 92 C 84 82 140 94 208 88 C 286 80 340 92 400 84 L400 120 L0 120 Z" fill="var(--ill-hill-mid)" />
<path d="M0 108 C 96 100 168 110 248 104 C 322 98 356 106 400 102 L400 120 L0 120 Z" fill="var(--ill-hill-near)" />
{/* A shorter path — the same idea at band scale, without the switchback,
which turns to mush below about 40 user units of height. */}
<path
d="M150 120 C 156 104 186 100 202 92 C 216 84 210 76 216 70"
stroke="var(--ill-path)"
strokeWidth="9"
fill="none"
strokeLinecap="round"
/> />
<source
<Schoolhouse x={186} y={30} scale={0.52} /> type="image/webp"
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
<g fill="var(--ill-tree-dark)"> sizes={SIZES}
<circle cx="58" cy="86" r="15" /> />
<circle cx="78" cy="94" r="11" /> {/*
<circle cx="342" cy="80" r="13" /> The hero is the largest thing above the fold, so it is almost certainly
</g> the LCP element: fetchPriority high, and never lazy. eslint's
<g fill="var(--ill-tree-light)"> no-img-element wants next/image, which cannot art-direct between two
<circle cx="40" cy="98" r="12" /> different crops — that is the whole point of the <picture> above.
<circle cx="360" cy="92" r="10" /> */}
<circle cx="300" cy="100" r="11" /> {/* eslint-disable-next-line @next/next/no-img-element */}
</g> <img
<g fill="var(--ill-tree-pale)" opacity="0.9"> src="/brand/hero-wide-1200.jpg"
<circle cx="112" cy="104" r="9" /> alt=""
<circle cx="266" cy="108" r="8" /> aria-hidden="true"
</g> width={WIDE_W}
height={WIDE_H}
<Pin x={124} y={70} scale={0.62} /> decoding="async"
</svg> fetchPriority="high"
/>
</picture>
); );
} }
+35
View File
@@ -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.
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.

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.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

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

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+95
View File
@@ -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 <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);
});