Files
school_compare/nextjs-app/components/Illustration.tsx
T

103 lines
4.2 KiB
TypeScript
Raw Normal View History

/**
* The landing hero artwork.
*
* 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.
*
* 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 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 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.
*
* `object-position` is set in CSS per breakpoint and is load-bearing — see
* .heroArt in HomeView.module.css.
*
* 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.
*/
/*
* 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.
*/
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
const WIDE_W = 1672;
const WIDE_H = 941;
export function HeroIllustration() {
return (
<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}
/>
<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}
/>
{/*
The band's JPEG, and the reason it exists.
<img src> cannot vary by viewport, so it is always the wide crop. A
browser that takes neither AVIF nor WebP would therefore fall through
to the desktop frame on a phone and lose the schoolhouse — the exact
failure this whole component is arranged to prevent, surviving in the
one path nobody looks at. No `type` here, so it matches anywhere the
media query does, and it sits after the modern formats so they still
win where supported.
*/}
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
<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}
/>
<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>
);
}