Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e0c2eac7 | ||
|
|
eddf74745f | ||
|
|
d5f3fdf0f9 | ||
|
|
3015c37bac | ||
|
|
153b26a32f | ||
|
|
e373241d51 | ||
|
|
4043270a77 | ||
|
|
e65688d600 | ||
|
|
7f4dfa2748 | ||
|
|
bdaa05cd54 | ||
|
|
043506cb6b | ||
|
|
0466986195 | ||
|
|
3f0e05cc99 | ||
|
|
f1819e9c4d |
No files matched your search
@@ -121,6 +121,33 @@ test('the home page has a real title', async ({ page }) => {
|
||||
expect(await page.title()).toMatch(/schoolcompare/i);
|
||||
});
|
||||
|
||||
test('the site states its independence and never claims to be official', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Carried in the footer, so it appears on every page rather than only here.
|
||||
await expect(
|
||||
page.getByText(/an independent site\.\s*not affiliated with the department for education/i)
|
||||
).toBeVisible();
|
||||
|
||||
/*
|
||||
* Describing the DATA as official is accurate and appears throughout
|
||||
* ("official DfE figures", "the official figure isn't in"). Describing the
|
||||
* SITE as official is not: schoolcompare republishes government data, it is
|
||||
* not a government service. The difference is the grammatical subject, so
|
||||
* this looks for text that opens with "Official" as a standalone label —
|
||||
* the shape a value-prop title or heading takes, and the shape the old
|
||||
* "Official & trusted" took.
|
||||
*/
|
||||
const claims = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('body *')]
|
||||
.flatMap(el => [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => (n.textContent ?? '').trim()))
|
||||
.filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t))
|
||||
);
|
||||
expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const gaps = await page.evaluate(() => {
|
||||
@@ -789,9 +816,21 @@ test('the brand asset set is complete and served', async ({ page }) => {
|
||||
expect(og.ok()).toBe(true);
|
||||
expect(og.headers()['content-type']).toContain('image/png');
|
||||
|
||||
// iOS ignores SVG touch icons, so this must be a real raster.
|
||||
const apple = await page.request.get('/apple-icon');
|
||||
expect(apple.ok()).toBe(true);
|
||||
/*
|
||||
* iOS ignores SVG touch icons, so this must be a real raster.
|
||||
*
|
||||
* Read the href off the page rather than hardcoding a path. The route moved
|
||||
* when the generated app/apple-icon.tsx became a static app/apple-icon.png:
|
||||
* a generated icon is served at /apple-icon, a static one at /apple-icon.png
|
||||
* with a content hash. This test kept asking for /apple-icon and got a 404 —
|
||||
* reporting a missing icon while the icon was present and correctly linked.
|
||||
*/
|
||||
const appleLink = page.locator('link[rel="apple-touch-icon"]');
|
||||
await expect(appleLink).toHaveCount(1);
|
||||
const appleHref = await appleLink.getAttribute('href');
|
||||
expect(appleHref).toBeTruthy();
|
||||
const apple = await page.request.get(appleHref!);
|
||||
expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true);
|
||||
expect(apple.headers()['content-type']).toContain('image/png');
|
||||
|
||||
// Android needs a maskable PNG or the install prompt has no icon.
|
||||
@@ -912,8 +951,22 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
|
||||
// hashed at build time and change on any unrelated edit.
|
||||
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
||||
|
||||
const mark = home.locator('svg').first();
|
||||
/*
|
||||
* The mark is raster artwork, not an inline SVG — it has been since the
|
||||
* supplied logo replaced the reconstruction, and this locator went on
|
||||
* looking for an <svg> that no longer exists.
|
||||
*
|
||||
* Assert on naturalWidth rather than visibility: a <picture> whose sources
|
||||
* all 404 still lays out and still passes toBeVisible(), so the weaker
|
||||
* assertion would go green on a broken lockup.
|
||||
*/
|
||||
const mark = home.locator('img').first();
|
||||
await expect(mark).toBeVisible();
|
||||
const markState = await mark.evaluate((el: HTMLImageElement) => ({
|
||||
loaded: el.complete && el.naturalWidth > 0,
|
||||
src: el.currentSrc,
|
||||
}));
|
||||
expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true);
|
||||
});
|
||||
|
||||
test('no visible text falls back to the browser default black', async ({ page }) => {
|
||||
|
||||
@@ -193,13 +193,17 @@
|
||||
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;
|
||||
--hero-ground is sampled from the artwork's own copy area, not from
|
||||
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
|
||||
seam straight down the hero.
|
||||
|
||||
The artwork's copy area is not one flat colour: it runs from a peach
|
||||
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
|
||||
the left edge is foliage rather than cream. This value is sampled from
|
||||
the middle of that pale run, where the headline and search actually sit;
|
||||
the scrim is what covers the foliage further down. */
|
||||
--hero-ground: #FEF2E1;
|
||||
--hero-ground-rgb: 254, 242, 225;
|
||||
|
||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 3.0 MiB |
@@ -460,6 +460,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Narrow phones: the button drops below the input instead of sharing the row.
|
||||
*
|
||||
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
|
||||
* screen it took 48% of the row and left the input 102px of text space for a
|
||||
* 194px placeholder — the field showed "School name or" and stopped. At 320px
|
||||
* the input was down to 32px, which is too narrow to read what you are typing.
|
||||
*
|
||||
* Wrapping rather than restructuring: the pill stays one element, so it keeps
|
||||
* its border, shadow and :focus-within ring, and the button gets a full-width
|
||||
* tap target on the way. Giving the button a 100% flex-basis is what forces
|
||||
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
|
||||
*/
|
||||
@media (max-width: 480px) {
|
||||
.heroMode .omniBoxContainer {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.heroMode .searchButton {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
/* Below this the pin costs more room than it earns. */
|
||||
.heroMode .omniIcon {
|
||||
|
||||
@@ -92,6 +92,22 @@
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
/*
|
||||
* Quieter than .description, but by size only — not by contrast.
|
||||
*
|
||||
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
|
||||
* fine margin for decorative text and the wrong one for a disclaimer, whose
|
||||
* whole job is to be legible to someone checking whether this is a government
|
||||
* site. --on-sunken-muted is 5.66:1 on the same band.
|
||||
*/
|
||||
.independence {
|
||||
margin: 0;
|
||||
font-size: var(--step--2);
|
||||
line-height: 1.6;
|
||||
color: var(--on-sunken-muted);
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
|
||||
@@ -35,6 +35,14 @@ export function Footer() {
|
||||
<p className={styles.description}>
|
||||
Compare primary and secondary schools across England.
|
||||
</p>
|
||||
{/*
|
||||
Says once, plainly, what the landing page's value props only
|
||||
imply: we publish official data, we are not an official body.
|
||||
Cheap to state and expensive to be wrong about.
|
||||
*/}
|
||||
<p className={styles.independence}>
|
||||
An independent site. Not affiliated with the Department for Education or Ofsted.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:contact@schoolcompare.co.uk"
|
||||
className={styles.link}
|
||||
|
||||
@@ -161,10 +161,22 @@
|
||||
* 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.
|
||||
*
|
||||
* Raising the brightness raises the background under light dark-theme text, so
|
||||
* this value has a contrast floor, not just a taste range. Measured off
|
||||
* rendered pixels, sampling background up to 120px past each line:
|
||||
*
|
||||
* brightness title body
|
||||
* 0.52 11.01:1 6.44:1
|
||||
* 0.75 9.48:1 5.21:1 ← current
|
||||
*
|
||||
* Body text is the binding one. Going much above 0.75 walks it toward the
|
||||
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
|
||||
* than the artwork being dimmed less.
|
||||
*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.heroArt img {
|
||||
filter: brightness(0.52) saturate(0.72) contrast(1.02);
|
||||
filter: brightness(0.75) saturate(0.72) contrast(1.02);
|
||||
}
|
||||
.heroPanel::before {
|
||||
background: linear-gradient(
|
||||
@@ -177,6 +189,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The one line in the first person, so it is set apart from the marketing copy
|
||||
* around it without shouting: display face, a step down in size, and a short
|
||||
* brand rule instead of a bullet or an emoji.
|
||||
*
|
||||
* No italic — Manrope ships no italic in the loaded weights, so font-style
|
||||
* would be synthesised into a slant. Same reason .heroEmph sets font-style
|
||||
* back to normal.
|
||||
*/
|
||||
.heroByline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 1.25rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.heroByline::before {
|
||||
content: '';
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.heroByline {
|
||||
margin-top: 1rem;
|
||||
font-size: var(--step--2);
|
||||
}
|
||||
}
|
||||
|
||||
.heroEyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -253,18 +302,42 @@
|
||||
* the same decision.
|
||||
*/
|
||||
@media (max-width: 860px) {
|
||||
/*
|
||||
* Artwork above the copy, not below it.
|
||||
*
|
||||
* The DOM keeps .heroContent first so the desktop overlay does not depend on
|
||||
* source order, which left the band stranded under the search on phones —
|
||||
* reading as a strip stuck on the end rather than as a hero image. `order`
|
||||
* moves it visually only; it is decorative and aria-hidden, so there is no
|
||||
* reading order to disturb.
|
||||
*
|
||||
* It costs about 136px above the search. Measured on a 667px viewport (the
|
||||
* shortest phone still in use) the search still lands near 400px, well
|
||||
* inside the fold.
|
||||
*/
|
||||
.heroPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.heroContent {
|
||||
padding: 2rem 1.5rem 1.75rem;
|
||||
padding: 1.75rem 1.5rem 1.75rem;
|
||||
max-width: none;
|
||||
}
|
||||
/* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
|
||||
crop into that throws away enough height to cut the flag off the roof and
|
||||
the base off the building. 13rem holds it at about 3.8:1, where the
|
||||
building stays whole. */
|
||||
.heroArt {
|
||||
position: static;
|
||||
height: 11rem;
|
||||
order: -1;
|
||||
height: 13rem;
|
||||
}
|
||||
/* The band crop centres the schoolhouse at about 65% across, so squeezing
|
||||
toward 4.9:1 crops the empty sides rather than the subject. */
|
||||
/* The band crop puts the schoolhouse at 73% across — reported by
|
||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
||||
than leaving the two to drift. Squeezing narrower then crops the emptier
|
||||
left side rather than the subject. */
|
||||
.heroArt img {
|
||||
object-position: 65% center;
|
||||
object-position: 73% center;
|
||||
}
|
||||
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
|
||||
it here, and leaving it would wash the band out. */
|
||||
@@ -305,8 +378,12 @@
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
/* 10rem, not 8.5: the band is widest-per-height right at this breakpoint —
|
||||
at 640px an 8.5rem band is 4.2:1, worse than anything above it, because
|
||||
the height steps down here while the width does not. 10rem keeps it near
|
||||
3.6:1, in line with the rest of the range. */
|
||||
.heroArt {
|
||||
height: 8.5rem;
|
||||
height: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,20 @@ const VALUE_PROPS: ValueProp[] = [
|
||||
{
|
||||
icon: <ShieldCheckIcon />,
|
||||
tintClass: styles.propIconTrust,
|
||||
title: 'Official & trusted',
|
||||
/*
|
||||
* "Built on official data", never "Official".
|
||||
*
|
||||
* The previous title was "Official & trusted", whose grammatical subject
|
||||
* is this site — it reads as a claim that schoolcompare is itself an
|
||||
* official service. It is not: it is an independent site that republishes
|
||||
* official figures. The distinction is the difference between describing
|
||||
* the data and describing ourselves, and only the first is true.
|
||||
*
|
||||
* Everywhere else the word appears ("official DfE figures", "the official
|
||||
* figure isn't in") it already qualifies the data, which is correct and
|
||||
* should stay.
|
||||
*/
|
||||
title: 'Built on official data',
|
||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||
},
|
||||
{
|
||||
@@ -451,6 +464,19 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
||||
geoError={geoError}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Sits under the search rather than in the footer on purpose: this
|
||||
is the one line on the page written in the first person, and it
|
||||
earns its place at the moment someone is deciding whether to
|
||||
trust a site full of government statistics. In the footer it
|
||||
would be true and unread.
|
||||
|
||||
It is deliberately the only such claim — the page's other trust
|
||||
signals are all about the data's provenance, which is checkable.
|
||||
This one is about who made it, which is not, so it is stated
|
||||
once, plainly, and never repeated.
|
||||
*/}
|
||||
<p className={styles.heroByline}>Built for parents, by a parent.</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
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;
|
||||
const WIDE_W = 2000;
|
||||
const WIDE_H = 1125;
|
||||
|
||||
export function HeroIllustration() {
|
||||
return (
|
||||
@@ -51,23 +51,35 @@ export function HeroIllustration() {
|
||||
<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"
|
||||
srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
|
||||
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"
|
||||
srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
|
||||
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-900.jpg" />
|
||||
<source
|
||||
type="image/avif"
|
||||
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
|
||||
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
|
||||
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"
|
||||
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
|
||||
sizes={SIZES}
|
||||
/>
|
||||
{/*
|
||||
@@ -78,7 +90,7 @@ export function HeroIllustration() {
|
||||
*/}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/brand/hero-wide-1200.jpg"
|
||||
src="/brand/hero-wide-1400.jpg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={WIDE_W}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0"
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -937,8 +938,8 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -4160,8 +4161,8 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -8584,9 +8585,9 @@
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -8629,8 +8630,8 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0"
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ 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
|
||||
- **Master:** `nextjs-app/assets/hero-source.png` (2000×1125 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**
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -25,8 +25,16 @@
|
||||
* 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.
|
||||
* AVIF lands roughly an order of magnitude under the source PNG. Both crops
|
||||
* also get a JPEG: the wide one backs the <img src>, and the band one backs a
|
||||
* <source media> — without it a browser taking neither modern format would
|
||||
* fall through to the desktop frame on a phone, since <img src> cannot vary by
|
||||
* viewport.
|
||||
*
|
||||
* sharp is declared in devDependencies rather than leaned on as a transitive
|
||||
* dependency of next. It does currently arrive that way, but relying on that
|
||||
* means a Next upgrade can silently break this script — the same reasoning as
|
||||
* the outputFileTracingIncludes note in next.config.js.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
@@ -37,14 +45,24 @@ 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.
|
||||
* Band crop, in source pixels, measured against the artwork rather than
|
||||
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
|
||||
* up at y 330.
|
||||
*
|
||||
* The crop is flush to the right edge because it has to be. The school sits at
|
||||
* about 82% across the frame, so there is only ~140px of artwork to its right
|
||||
* — not enough to centre it. Anchoring right and taking 1050px back puts the
|
||||
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
|
||||
* the CSS so the narrowest band crops the empty left side rather than the
|
||||
* subject.
|
||||
*/
|
||||
const BAND = { left: 700, top: 260, width: 972, height: 380 };
|
||||
const BAND = { left: 656, top: 300, width: 1344, height: 420 };
|
||||
|
||||
const WIDE_WIDTHS = [1672, 1200, 900];
|
||||
const BAND_WIDTHS = [972, 700, 500];
|
||||
/** Where the schoolhouse lands in the band crop, for CSS object-position. */
|
||||
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
|
||||
|
||||
const WIDE_WIDTHS = [2000, 1400, 1000];
|
||||
const BAND_WIDTHS = [1344, 900, 600];
|
||||
|
||||
async function emit(pipeline, name, width) {
|
||||
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
|
||||
@@ -59,25 +77,26 @@ async function main() {
|
||||
|
||||
const meta = await sharp(SRC).metadata();
|
||||
console.log(`source ${meta.width}×${meta.height}`);
|
||||
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
|
||||
|
||||
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' })
|
||||
.resize({ width: 1400 })
|
||||
.flatten({ background: '#FEF2E1' })
|
||||
.jpeg({ quality: 82, mozjpeg: true })
|
||||
.toFile(path.join(OUT, 'hero-wide-1200.jpg'));
|
||||
.toFile(path.join(OUT, 'hero-wide-1400.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' })
|
||||
.resize({ width: 900 })
|
||||
.flatten({ background: '#FEF2E1' })
|
||||
.jpeg({ quality: 82, mozjpeg: true })
|
||||
.toFile(path.join(OUT, 'hero-band-700.jpg'));
|
||||
.toFile(path.join(OUT, 'hero-band-900.jpg'));
|
||||
|
||||
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
|
||||
let total = 0;
|
||||
|
||||