feat(home): use the supplied hero artwork instead of a drawn one
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m29s
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m29s
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>
This commit is contained in:
1 parent
f1819e9c4d
commit
3f0e05cc99
22 files changed
+379
-333
No files matched your search
+48
-34
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user