Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
153b26a32f | ||
|
|
e373241d51 | ||
|
|
4043270a77 | ||
|
|
e65688d600 | ||
|
|
7f4dfa2748 | ||
|
|
bdaa05cd54 | ||
|
|
043506cb6b | ||
|
|
0466986195 | ||
|
|
3f0e05cc99 | ||
|
|
f1819e9c4d | ||
|
|
97ac5c9cef | ||
|
|
dc22fd2853 | ||
|
|
6b117dd26c |
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);
|
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 }) => {
|
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const gaps = await page.evaluate(() => {
|
const gaps = await page.evaluate(() => {
|
||||||
@@ -140,59 +167,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 }) => {
|
||||||
@@ -775,9 +816,21 @@ test('the brand asset set is complete and served', async ({ page }) => {
|
|||||||
expect(og.ok()).toBe(true);
|
expect(og.ok()).toBe(true);
|
||||||
expect(og.headers()['content-type']).toContain('image/png');
|
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');
|
* iOS ignores SVG touch icons, so this must be a real raster.
|
||||||
expect(apple.ok()).toBe(true);
|
*
|
||||||
|
* 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');
|
expect(apple.headers()['content-type']).toContain('image/png');
|
||||||
|
|
||||||
// Android needs a maskable PNG or the install prompt has no icon.
|
// Android needs a maskable PNG or the install prompt has no icon.
|
||||||
@@ -898,8 +951,22 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
|
|||||||
// hashed at build time and change on any unrelated edit.
|
// hashed at build time and change on any unrelated edit.
|
||||||
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
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();
|
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 }) => {
|
test('no visible text falls back to the browser default black', async ({ page }) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.6 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) {
|
@media (max-width: 420px) {
|
||||||
/* Below this the pin costs more room than it earns. */
|
/* Below this the pin costs more room than it earns. */
|
||||||
.heroMode .omniIcon {
|
.heroMode .omniIcon {
|
||||||
|
|||||||
@@ -92,13 +92,33 @@
|
|||||||
max-width: 34ch;
|
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 {
|
.sectionTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--sage);
|
/* --sage flips with the theme; this band does not (it is teal in both), so
|
||||||
|
the pairing only held in one of them — the dark sage measured 4.08:1 here
|
||||||
|
against a 4.5 floor, on every page. The --on-sunken-* family exists
|
||||||
|
precisely because this surface is theme-invariant. */
|
||||||
|
color: var(--on-sunken-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ export function Footer() {
|
|||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
Compare primary and secondary schools across England.
|
Compare primary and secondary schools across England.
|
||||||
</p>
|
</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
|
<a
|
||||||
href="mailto:contact@schoolcompare.co.uk"
|
href="mailto:contact@schoolcompare.co.uk"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
|
|||||||
@@ -77,52 +77,116 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── 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.
|
||||||
|
*
|
||||||
|
* 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.75) 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 {
|
||||||
@@ -133,7 +197,10 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--brand);
|
/* --brand on its own 10% tint over Sand measures 4.19:1 — the tint darkens
|
||||||
|
the ground under the label, and the token was specced against the ground,
|
||||||
|
not against the tint. --brand-strong on the same composite is 5.85:1. */
|
||||||
|
color: var(--brand-strong);
|
||||||
background: rgba(var(--brand-rgb), 0.1);
|
background: rgba(var(--brand-rgb), 0.1);
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -181,33 +248,59 @@
|
|||||||
|
|
||||||
@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) {
|
||||||
|
/*
|
||||||
|
* 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 {
|
.heroPanel {
|
||||||
grid-template-columns: 1fr;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.heroContent {
|
.heroContent {
|
||||||
padding: 2rem 1.5rem 1.75rem;
|
padding: 1.75rem 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;
|
order: -1;
|
||||||
|
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;
|
||||||
@@ -243,7 +336,7 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.heroArt {
|
.heroArt {
|
||||||
min-height: 8.5rem;
|
height: 8.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,8 +872,19 @@
|
|||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The preview panel sits on the card surface, not on Sand.
|
||||||
|
*
|
||||||
|
* Everything inside it is a translucent status or brand tint, and those tokens
|
||||||
|
* are specced to clear AA over --bg-card. Over Sand the same tints composite
|
||||||
|
* darker, which put the report-card chips at 4.22:1 and 4.29:1 against a 4.5
|
||||||
|
* floor — the ratio depended on a ground two levels up. On the card surface
|
||||||
|
* they are 5.69:1 and 5.81:1. The border keeps it reading as an inset frame
|
||||||
|
* now that panel and card share a colour.
|
||||||
|
*/
|
||||||
.hiwVisual {
|
.hiwVisual {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
@@ -1126,7 +1230,10 @@
|
|||||||
.compareHeadLabel {
|
.compareHeadLabel {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.48rem;
|
font-size: 0.48rem;
|
||||||
color: var(--text-muted);
|
/* --text-muted on the header row's tint is 4.45:1 in the dark theme —
|
||||||
|
under the floor by a hair, because the tint darkens the ground beneath
|
||||||
|
the label. --text-secondary clears it on both grounds. */
|
||||||
|
color: var(--text-secondary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -157,7 +157,20 @@ const VALUE_PROPS: ValueProp[] = [
|
|||||||
{
|
{
|
||||||
icon: <ShieldCheckIcon />,
|
icon: <ShieldCheckIcon />,
|
||||||
tintClass: styles.propIconTrust,
|
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.',
|
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -454,18 +467,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,
|
||||||
|
|||||||
@@ -1,212 +1,103 @@
|
|||||||
/**
|
/**
|
||||||
* 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"
|
|
||||||
/>
|
/>
|
||||||
|
{/*
|
||||||
|
The band's JPEG, and the reason it exists.
|
||||||
|
|
||||||
<Schoolhouse x={276} y={232} />
|
<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
|
||||||
{/* Scattered rounded trees */}
|
to the desktop frame on a phone and lose the schoolhouse — the exact
|
||||||
<g fill="var(--ill-tree-dark)">
|
failure this whole component is arranged to prevent, surviving in the
|
||||||
<circle cx="104" cy="372" r="27" />
|
one path nobody looks at. No `type` here, so it matches anywhere the
|
||||||
<circle cx="136" cy="386" r="19" />
|
media query does, and it sits after the modern formats so they still
|
||||||
<circle cx="470" cy="336" r="23" />
|
win where supported.
|
||||||
</g>
|
*/}
|
||||||
<g fill="var(--ill-tree-light)">
|
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
|
||||||
<circle cx="78" cy="394" r="21" />
|
<source
|
||||||
<circle cx="492" cy="358" r="17" />
|
type="image/avif"
|
||||||
<circle cx="176" cy="420" r="18" />
|
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
|
||||||
</g>
|
sizes={SIZES}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"jest": "^30.2.0",
|
"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": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -937,8 +938,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -4160,8 +4161,8 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -8584,9 +8585,9 @@
|
|||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
|
"devOptional": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -8629,8 +8630,8 @@
|
|||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0"
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,103 @@
|
|||||||
|
#!/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. 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');
|
||||||
|
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);
|
||||||
|
});
|
||||||