fix(home): correct what the landing page claims, and give it one rhythm
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
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 2m25s

The homepage made four statements that were not true, carried elements that
asked nothing of anyone, and had a hero illustration that broke in both the
places it had to work.

Claims, all verified against the code or the API:

  * "24,000+ schools" (three places) against a real 27,230. The fact box meant
    to show the live figure rendered its own fallback on every request, because
    DataInfoResponse declared a `total_schools` field the API has never sent —
    it sends `unique_schools`. The fetch succeeded; only that field was
    undefined, so nothing threw and nothing failed. The interface, not the
    code, was the thing that was wrong.
  * "Up to three schools side by side" against MAX_SCHOOLS = 5, contradicting a
    card 400px below it that correctly said five.
  * "Class sizes" — data the codebase has never held. That copy line was the
    only hit in a full-repo grep.
  * Invented results and Ofsted grades attributed to two real, named schools
    in the compare preview.

Also one feature, three words: Compare (nav), shortlist (footer), pin (cards).
Settled on Compare everywhere. And <title> was the bare string "Home".

Cut: the trust line (repeated the coverage figure one paragraph after the hero
gave it, behind three decorative dots), the "Start exploring" row (three links
to two destinations already in the nav), the six-row coverage table, and three
of the four countdown cards — which gave the page's largest numeral to dates up
to 245 days away, two of them offer days, which cannot be missed. All four
dates remain, at proportionate weight. Value-prop titles drop from <h2> to <p>;
they were outranking the page's real headings in the document outline.

Rhythm: the gaps between the seven landing bands were 24/32/24/16/48/32/16px,
each band setting its own margin, with four different section-header
treatments between them. The page container now owns one gap, and there is one
header pattern. An e2e test asserts the gaps are identical.

Illustration: it kept a fixed light palette in both themes, which left a pale
sky slab as the brightest object on a near-black page, out-shouting the H1 and
the search box. It now reads from --ill-* tokens with a dark re-grade. And the
hero slot ranges from 1.34:1 to 4.9:1 across breakpoints, which no single
composition survives under `slice` — at 860x176 a 540x520 scene shows only its
bottom 110 units, so the schoolhouse was cropped away entirely on phones,
leaving hills and a pin pointing at nothing. There are now two compositions,
each drawn against the crop window its own breakpoint produces, with CSS
showing one. Both are static server-rendered SVG.

The deadline bar renders on the server rather than on hydrate. The effect-based
version needed a reserved height, and one guessed number cannot cover a block
whose supporting line wraps differently at every width — measured, it was short
at all four, shifting the page up to 108px on a phone.

Verified on the built output through an offline render harness (no local
server): real compiled CSS, real rendered markup, four widths, both themes.
tsc clean, 159/159 unit tests, build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5 committed 2026-08-14 17:29:47 +01:00
1 parent 6b117dd26c
commit dc22fd2853
11 files changed
+849 -569

No files matched your search

+126
View File
@@ -69,6 +69,132 @@ test('home hero offers a "use my location" shortcut beside the search box', asyn
await expect(nearMe).toBeVisible();
});
/*
* The landing page's claims must match the product and the data.
*
* Every failure this group guards against is silent: a hardcoded fallback that
* renders as if it were live, a number that contradicts a constant elsewhere in
* the codebase, illustrative copy that reads as fact. None of them throw, none
* fail a build, and none show up in a unit test.
*/
test('the coverage figure is live, not the hardcoded fallback', async ({ page }) => {
const info = await (await page.request.get('/api/data-info')).json();
test.skip(info.status !== 'loaded', 'environment has no data loaded');
const expected: number = info.unique_schools;
expect(expected).toBeGreaterThan(0);
await page.goto('/');
const body = await page.locator('body').innerText();
// The real count, formatted as the page formats it.
expect(body).toContain(expected.toLocaleString('en-GB'));
// The fallback that used to render in its place. `total_schools` is not a
// field this API returns, so reading it yielded undefined and the "24,000+"
// floor was shown on every request while the database held far more.
expect(body).not.toContain('24,000+');
});
test('the landing page states the real comparison limit', async ({ page }) => {
await page.goto('/');
const body = await page.locator('body').innerText();
// MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
// promise three while a card lower on the same page promised five.
expect(body).toMatch(/five schools/i);
expect(body).not.toMatch(/three schools/i);
});
test('the landing page invents no data about real schools', async ({ page }) => {
await page.goto('/');
const preview = page.locator('table, [class*="comparePreview"]').first();
const body = await page.locator('body').innerText();
// The compare preview's columns carried two real school names beside
// invented results and invented Ofsted grades.
expect(body).not.toMatch(/Our Lady Queen of Heaven|St Mary's Catholic Primary/i);
await expect(preview).toContainText(/School A/);
});
test('the home page has a real title', async ({ page }) => {
await page.goto('/');
// It was the bare string "Home" — the browser tab, the bookmark and the
// search result all read that.
expect(await page.title()).not.toBe('Home');
expect(await page.title()).toMatch(/schoolcompare/i);
});
test('the landing bands share one vertical rhythm', async ({ page }) => {
await page.goto('/');
const gaps = await page.evaluate(() => {
const landing = document.querySelector('main div[class*="landing"]');
if (!landing) return null;
const kids = [...landing.children].map(el => el.getBoundingClientRect());
const out: number[] = [];
for (let i = 1; i < kids.length; i++) out.push(Math.round(kids[i].top - kids[i - 1].bottom));
return out;
});
expect(gaps).not.toBeNull();
expect(gaps!.length).toBeGreaterThan(1);
// Every band gap comes from the page container's single `gap`, so they are
// identical. They used to be 24 / 32 / 24 / 16 / 48 / 32px — each band set
// its own margin, and the page read as a stack of unrelated strips.
expect(new Set(gaps!).size).toBe(1);
});
/*
* 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.
*/
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"]');
return {
wide: wide ? getComputedStyle(wide).display : 'missing',
band: band ? getComputedStyle(band).display : 'missing',
};
});
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
expect(await visible()).toEqual({ wide: 'block', band: 'none' });
// 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.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
expect(await visible()).toEqual({ wide: 'none', band: 'block' });
});
test('the hero illustration is re-graded for the dark theme', async ({ browser }) => {
const read = 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()
);
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);
});
test('searching by name returns school results', async ({ page }) => {
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });