feat(home): use the supplied hero artwork instead of a drawn one
Replaces the hand-drawn SVG landscape with the illustration supplied by the project owner. Master is assets/hero-source.png; everything under public/brand/hero-* comes from scripts/build-hero-images.js and should never be hand-edited. LAYOUT The artwork is composed as a full-bleed hero: it reserves an empty cream area down its left side for the headline. The old hero was a two-column grid with art in the right 0.88fr, which would have cropped that reserved area off and shrunk the scene into a thumbnail — the one thing the picture is built not to be. So the panel is now a single layered block: artwork behind, copy on top, and below the one-column breakpoint the artwork leaves the background and becomes a band under the search. Overlaying does not survive down to phone widths — the panel gets too narrow for the copy to stay inside the cream, so the scrim would have to cover nearly the whole image and you would be left with a tinted rectangle. Hence the switch at 860px rather than a single treatment stretched across every width. CONTRAST The copy sits on a gradient of --hero-ground, a new token sampled from the artwork's own cream (#FDF9F3) rather than from Sand. Sand is eight to thirteen points darker per channel, which leaves a visible seam straight down the hero. The scrim exists because the artwork is a fixed image on a fluid panel: past some width the headline would otherwise land on hillside green. Measured on rendered pixels, sampling background up to 120px beyond the right edge of each line, so a longer line still has margin: light title 14.36:1 body 6.52:1 dark title 11.01:1 body 6.44:1 The worst light case is hillside green showing through the scrim at 6.52:1. TWO CROPS The slot is two shapes: roughly 2.1:1–2.7:1 behind the desktop panel, and 2.6:1–4.9:1 as the band. A single file under object-fit: cover centre-crops, and at the band's extreme that slices a strip through the scene and loses the schoolhouse — exactly how the drawn hero failed on phones. <picture> switches crop, not just resolution: the band file is pre-cropped around the school and is already near 2.6:1, with the subject at ~65% across so squeezing toward 4.9:1 crops the empty sides instead. WEIGHT 1.6 MB PNG in, AVIF and WebP out; a browser fetches exactly one file — about 23–33 kB on desktop, 9–18 kB on a phone. The JPEG is only the <img> fallback. `sizes` describes the real panel box (max-width 1400 minus padding) rather than 100vw, which over-requested a candidate at every width on the LCP element. DARK THEME A raster cannot be re-graded token by token the way the drawing was, but it would reproduce the same failure — a bright illustration is the brightest object on a near-black page. It is dimmed in CSS to read as dusk, and the scrim fades it into the dark panel rather than into cream. TESTS The two illustration journeys are replaced by three, each covering a failure that still renders a valid-looking page: the artwork actually loading (naturalWidth, not src), the crop switching at the breakpoint, a modern format winning negotiation, and the dark theme dimming it. public/brand/CREDITS.md records provenance for every asset in that folder. The hero's licence line is marked unconfirmed — that one needs the owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -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 <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.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 <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 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 }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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, <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) {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 +454,14 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
</div>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className={styles.heroArt}>
|
||||
<div className={styles.heroArtWide}>
|
||||
<HeroIllustration />
|
||||
</div>
|
||||
<div className={styles.heroArtBand}>
|
||||
<HeroIllustrationBand />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Why this site, in four lines. These titles are labels on a list,
|
||||
|
||||
@@ -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 <picture>
|
||||
* ------------------------
|
||||
* 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
|
||||
* <source media> 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 (
|
||||
<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.
|
||||
/*
|
||||
* 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
/** 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 (
|
||||
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<SkyGradient id={SKY_TALL_ID} />
|
||||
</defs>
|
||||
|
||||
<rect width="540" height="520" fill={`url(#${SKY_TALL_ID})`} />
|
||||
|
||||
{/* Two soft clouds. Low opacity so they never compete with the pin. */}
|
||||
<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"
|
||||
<picture>
|
||||
{/* Band crop first: <source> is first-match-wins, so the narrow-viewport
|
||||
rules have to precede the unconstrained desktop ones. */}
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
<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-edge)"
|
||||
strokeWidth="18"
|
||||
fill="none"
|
||||
strokeLinecap="butt"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="0.5 26"
|
||||
opacity="0.5"
|
||||
<source
|
||||
media="(max-width: 860px)"
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-band-500.webp 500w, /brand/hero-band-700.webp 700w, /brand/hero-band-972.webp 972w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
|
||||
<Schoolhouse x={276} y={232} />
|
||||
|
||||
{/* Scattered rounded trees */}
|
||||
<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
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
|
||||
<Schoolhouse x={186} y={30} scale={0.52} />
|
||||
|
||||
<g fill="var(--ill-tree-dark)">
|
||||
<circle cx="58" cy="86" r="15" />
|
||||
<circle cx="78" cy="94" r="11" />
|
||||
<circle cx="342" cy="80" r="13" />
|
||||
</g>
|
||||
<g fill="var(--ill-tree-light)">
|
||||
<circle cx="40" cy="98" r="12" />
|
||||
<circle cx="360" cy="92" r="10" />
|
||||
<circle cx="300" cy="100" r="11" />
|
||||
</g>
|
||||
<g fill="var(--ill-tree-pale)" opacity="0.9">
|
||||
<circle cx="112" cy="104" r="9" />
|
||||
<circle cx="266" cy="108" r="8" />
|
||||
</g>
|
||||
|
||||
<Pin x={124} y={70} scale={0.62} />
|
||||
</svg>
|
||||
<source
|
||||
type="image/webp"
|
||||
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
{/*
|
||||
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 <picture> above.
|
||||
*/}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/brand/hero-wide-1200.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={WIDE_W}
|
||||
height={WIDE_H}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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);
|
||||
});
|
||||