Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e373241d51 | ||
|
|
e65688d600 | ||
|
|
7f4dfa2748 | ||
|
|
bdaa05cd54 | ||
|
|
043506cb6b | ||
|
|
0466986195 | ||
|
|
3f0e05cc99 | ||
|
|
f1819e9c4d | ||
|
|
97ac5c9cef | ||
|
|
dc22fd2853 | ||
|
|
6b117dd26c |
No files matched your search
@@ -69,6 +69,146 @@ test('home hero offers a "use my location" shortcut beside the search box', asyn
|
|||||||
await expect(nearMe).toBeVisible();
|
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 artwork. Every assertion here covers a failure that renders as a
|
||||||
|
* perfectly valid page: a path that 404s leaves an empty box, a crop that
|
||||||
|
* stops switching silently decapitates the subject, and a format negotiation
|
||||||
|
* that regresses just serves a bigger file.
|
||||||
|
*/
|
||||||
|
function heroImageState(page: Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null;
|
||||||
|
if (!img) return null;
|
||||||
|
return {
|
||||||
|
// naturalWidth is 0 for an image that failed to load, whatever the src says.
|
||||||
|
loaded: img.complete && img.naturalWidth > 0,
|
||||||
|
file: img.currentSrc.split('/').pop() ?? '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto('/');
|
||||||
|
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 band up to 4.9:1. The
|
||||||
|
* desktop frame centre-cropped to that shape loses the schoolhouse, which is
|
||||||
|
* how the previous hero failed on phones — so <picture> must switch to the
|
||||||
|
* pre-cropped band file, not merely to a smaller version of the same frame.
|
||||||
|
*/
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/');
|
||||||
|
const band = await heroImageState(page);
|
||||||
|
expect(band!.loaded).toBe(true);
|
||||||
|
expect(band!.file).toContain('hero-band');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the hero artwork is served in a modern format', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto('/');
|
||||||
|
const state = await heroImageState(page);
|
||||||
|
// The JPEG exists only as the <img> fallback. Chromium takes AVIF, so seeing
|
||||||
|
// the fallback here means the <source> negotiation broke — which costs about
|
||||||
|
// 24kB extra on the LCP element and nothing visible.
|
||||||
|
expect(state!.file).toMatch(/\.(avif|webp)$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the hero artwork is dimmed for the dark theme', async ({ browser }) => {
|
||||||
|
const filterFor = async (colorScheme: 'light' | 'dark') => {
|
||||||
|
const ctx = await browser.newContext({ colorScheme });
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
await p.goto('/');
|
||||||
|
const value = await p.evaluate(() => {
|
||||||
|
const img = document.querySelector('[class*="heroArt"] img');
|
||||||
|
return img ? getComputedStyle(img).filter : 'missing';
|
||||||
|
});
|
||||||
|
await ctx.close();
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The artwork is a fixed raster, so it cannot be re-graded per theme the way
|
||||||
|
// the drawn hero was. Undimmed it is the brightest object on a near-black
|
||||||
|
// page and out-shouts the H1 and the search box — the exact regression the
|
||||||
|
// drawn version shipped with.
|
||||||
|
expect(await filterFor('light')).toBe('none');
|
||||||
|
expect(await filterFor('dark')).toMatch(/brightness/);
|
||||||
|
});
|
||||||
|
|
||||||
test('searching by name returns school results', async ({ page }) => {
|
test('searching by name returns school results', async ({ page }) => {
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
@@ -649,9 +789,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.
|
||||||
@@ -772,8 +924,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 }) => {
|
||||||
|
|||||||
@@ -178,6 +178,29 @@
|
|||||||
--step-4: 2.5rem;
|
--step-4: 2.5rem;
|
||||||
--step-5: 3rem;
|
--step-5: 3rem;
|
||||||
|
|
||||||
|
/* ── Section rhythm ─────────────────────────────────────────────────
|
||||||
|
The vertical gap between the bands of a page, and the gap between a
|
||||||
|
band's header and its content. Two values, not seven: the landing page
|
||||||
|
previously set its own margin on every band (24 / 32 / 16 / 48px, no
|
||||||
|
scale), which is what made a designed page read as a stack of unrelated
|
||||||
|
strips. Bands must not set their own vertical margins — the page
|
||||||
|
container owns the gap. */
|
||||||
|
--section-gap: 4rem;
|
||||||
|
--section-head-gap: 1.5rem;
|
||||||
|
|
||||||
|
/* ── Hero artwork ───────────────────────────────────────────────────
|
||||||
|
The landing hero is now a supplied raster illustration rather than a
|
||||||
|
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;
|
||||||
|
|
||||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||||
rounder than the old system's 3/6/10/16. */
|
rounder than the old system's 3/6/10/16. */
|
||||||
@@ -302,6 +325,13 @@
|
|||||||
--medal-silver-rgb: 169, 182, 188;
|
--medal-silver-rgb: 169, 182, 188;
|
||||||
--medal-bronze-rgb: 201, 144, 112;
|
--medal-bronze-rgb: 201, 144, 112;
|
||||||
|
|
||||||
|
/* The artwork is a fixed, bright raster — it cannot be re-graded by
|
||||||
|
token the way the drawn version was. The dark theme instead dims it
|
||||||
|
in CSS (see .heroArt in HomeView.module.css) and fades it into this
|
||||||
|
ground, which is the panel colour rather than the artwork's cream. */
|
||||||
|
--hero-ground: #16222A;
|
||||||
|
--hero-ground-rgb: 22, 34, 42;
|
||||||
|
|
||||||
--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);
|
||||||
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
||||||
@@ -472,7 +502,7 @@ table,
|
|||||||
border-color: var(--action-stronger);
|
border-color: var(--action-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary: brand outline — supporting actions (Add to shortlist) */
|
/* Secondary: brand outline — supporting actions (Add to compare) */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
@@ -493,7 +523,7 @@ table,
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active toggle state — the shortlist button once a school is on the list */
|
/* Active toggle state — the compare button once a school is on the list */
|
||||||
.btn-active {
|
.btn-active {
|
||||||
background: var(--brand-bg);
|
background: var(--brand-bg);
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
@@ -563,6 +593,12 @@ html .leaflet-bar a:hover {
|
|||||||
.main {
|
.main {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
/* Tighten the rhythm rather than abandon it — the ratio between the
|
||||||
|
section gap and the header gap stays the same. */
|
||||||
|
:root {
|
||||||
|
--section-gap: 2.75rem;
|
||||||
|
--section-head-gap: 1.15rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Honour the OS setting. Transitions collapse to near-instant rather than
|
/* Honour the OS setting. Transitions collapse to near-instant rather than
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* Main landing page with school search and browsing
|
* Main landing page with school search and browsing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||||
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
import { HomeView } from '@/components/HomeView';
|
import { HomeView } from '@/components/HomeView';
|
||||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||||
import { EditorialSection } from '@/components/EditorialSection';
|
import { EditorialSection } from '@/components/EditorialSection';
|
||||||
@@ -24,8 +26,14 @@ interface HomePageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
/*
|
||||||
title: 'Home',
|
* `title` was the bare string 'Home', which is what the browser tab, the
|
||||||
|
* bookmark and the search result all read. `absolute` opts out of the root
|
||||||
|
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
||||||
|
* saying the brand twice.
|
||||||
|
*/
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { absolute: 'schoolcompare | Compare every school in England' },
|
||||||
description: 'Search and compare school performance across England',
|
description: 'Search and compare school performance across England',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,14 +87,24 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||||
const total = dataInfo?.total_schools ?? null;
|
// `unique_schools`, not `total_schools` — the latter is not a field this
|
||||||
|
// endpoint returns, and reading it silently yielded null on every request.
|
||||||
|
const total = dataInfo?.unique_schools ?? null;
|
||||||
|
const years = dataInfo?.years_available ?? [];
|
||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={schoolsData}
|
initialSchools={schoolsData}
|
||||||
filters={resolvedFilters}
|
filters={resolvedFilters}
|
||||||
totalSchools={total}
|
totalSchools={total}
|
||||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
editorial={hasSearchParams ? null : (
|
||||||
|
<EditorialSection
|
||||||
|
totalSchools={total}
|
||||||
|
localAuthorityCount={resolvedFilters.local_authorities.length}
|
||||||
|
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
||||||
|
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -99,7 +117,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
filters={emptyFilters}
|
filters={emptyFilters}
|
||||||
totalSchools={null}
|
totalSchools={null}
|
||||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
editorial={hasSearchParams ? null : (
|
||||||
|
<EditorialSection
|
||||||
|
totalSchools={null}
|
||||||
|
localAuthorityCount={0}
|
||||||
|
earliestYearLabel={null}
|
||||||
|
latestYearLabel={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
date: 'September',
|
date: 'September',
|
||||||
title: 'Portal opens',
|
title: 'Portal opens',
|
||||||
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
||||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '31 October',
|
date: '31 October',
|
||||||
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
|
|||||||
date: 'September',
|
date: 'September',
|
||||||
title: 'Portal opens',
|
title: 'Portal opens',
|
||||||
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
||||||
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
tool: { label: 'Compare schools side by side', href: '/compare' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '15 January',
|
date: '15 January',
|
||||||
|
|||||||
@@ -5,15 +5,45 @@ import styles from './HomeView.module.css';
|
|||||||
interface EditorialSectionProps {
|
interface EditorialSectionProps {
|
||||||
totalSchools: number | null;
|
totalSchools: number | null;
|
||||||
localAuthorityCount: number;
|
localAuthorityCount: number;
|
||||||
|
latestYearLabel: string | null;
|
||||||
|
earliestYearLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
export function EditorialSection({
|
||||||
|
totalSchools,
|
||||||
|
localAuthorityCount,
|
||||||
|
latestYearLabel,
|
||||||
|
earliestYearLabel,
|
||||||
|
}: EditorialSectionProps) {
|
||||||
|
/*
|
||||||
|
* The coverage line replaces a six-row "Coverage at a glance" table.
|
||||||
|
*
|
||||||
|
* That table put six numbers on the page to say one thing, and the headline
|
||||||
|
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
|
||||||
|
* DataInfoResponse declared a `total_schools` field the API does not send
|
||||||
|
* (it sends `unique_schools`). The fetch had succeeded; only that field was
|
||||||
|
* undefined, so the fallback rendered and nothing failed. Every figure here
|
||||||
|
* is now live, and any figure that isn't available is dropped rather than
|
||||||
|
* replaced with a guess.
|
||||||
|
*/
|
||||||
|
const coverage = [
|
||||||
|
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
|
||||||
|
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
|
||||||
|
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.editorial}>
|
// No class on the section: the page container owns the vertical rhythm
|
||||||
<div className={styles.editorialGrid}>
|
// now, and this band carries its own ground on the card inside it.
|
||||||
|
<section>
|
||||||
|
<div className={styles.editorialCard}>
|
||||||
|
<div className={styles.sectionHead}>
|
||||||
|
<p className={styles.sectionKicker}>About school data</p>
|
||||||
|
<h2 className={styles.sectionHeading}>
|
||||||
|
Making England's school performance data actually readable
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<div className={styles.editorialText}>
|
<div className={styles.editorialText}>
|
||||||
<div className={styles.editorialKicker}>About school data</div>
|
|
||||||
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
|
||||||
<p>
|
<p>
|
||||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||||
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||||
@@ -21,38 +51,14 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
schoolcompare brings it all into one place. Every school page shows performance against the national
|
schoolcompare brings it all into one place. Every school page shows performance against the national
|
||||||
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
average, explains what the numbers mean, and lets you compare schools side by side. Built for
|
||||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
full inspection report.
|
full inspection report.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.factbox}>
|
{coverage.length > 0 && (
|
||||||
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
|
||||||
<div className={styles.factRow}>
|
)}
|
||||||
<span className={styles.factKey}>Schools covered</span>
|
|
||||||
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.factRow}>
|
|
||||||
<span className={styles.factKey}>Local authorities</span>
|
|
||||||
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.factRow}>
|
|
||||||
<span className={styles.factKey}>Phases</span>
|
|
||||||
<span className={styles.factVal}>Primary & Secondary</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.factRow}>
|
|
||||||
<span className={styles.factKey}>Latest results year</span>
|
|
||||||
<span className={styles.factVal}>2024/25</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.factRow}>
|
|
||||||
<span className={styles.factKey}>Historical data</span>
|
|
||||||
<span className={styles.factVal}>2016–2025</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.factRow}>
|
|
||||||
<span className={styles.factKey}>Metrics per school</span>
|
|
||||||
<span className={styles.factVal}>40+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -98,7 +98,11 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export function Footer() {
|
|||||||
<ul className={styles.links}>
|
<ul className={styles.links}>
|
||||||
<li><a href="/" className={styles.link}>Search schools</a></li>
|
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||||
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
||||||
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
|
{/* "Compare", not "shortlist" — the nav, this link and the
|
||||||
|
landing page all name the same feature the same way. */}
|
||||||
|
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
|
||||||
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,67 +2,192 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hero ──────────────────────────────────────────────────────────────────
|
/* ── Page rhythm ───────────────────────────────────────────────────────────
|
||||||
A Sand panel: proposition and search on the left, the brand landscape
|
The landing page owns the vertical gap between its bands; no band sets its
|
||||||
bleeding to the panel's right edge. The illustration is decorative, so on
|
own top or bottom margin. Previously each one did, and the gaps came out as
|
||||||
phones it drops to a short band under the content rather than competing
|
24 / 32 / 24 / 16 / 48 / 32 / 16px — no scale, which is most of the reason a
|
||||||
with the search for the fold. */
|
designed page read as a stack of unrelated strips. If you add a band here,
|
||||||
.heroPanel {
|
give it padding and a ground, never a margin. */
|
||||||
|
.landing {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--section-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero and the four reasons under it are one thought, so they sit closer
|
||||||
|
to each other than to the next band. This is the only place that overrides
|
||||||
|
the page gap, and it does it by grouping rather than by re-spacing. */
|
||||||
|
.heroGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section header ────────────────────────────────────────────────────────
|
||||||
|
One pattern for every band: kicker, heading, optional aside. */
|
||||||
|
.sectionHead {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.12fr 0.88fr;
|
grid-template-columns: 1fr auto;
|
||||||
align-items: stretch;
|
align-items: baseline;
|
||||||
background: var(--bg-secondary);
|
column-gap: 1.5rem;
|
||||||
|
margin-bottom: var(--section-head-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionKicker {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: var(--step--2);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--brand-strong);
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-3);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 24ch;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionAside {
|
||||||
|
font-size: var(--step--1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sectionHead {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sectionHeading {
|
||||||
|
font-size: var(--step-2);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
.sectionAside {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero ──────────────────────────────────────────────────────────────────
|
||||||
|
The artwork fills the panel and the proposition sits on top of it, in the
|
||||||
|
empty cream area the illustration reserves on its left. It is not a
|
||||||
|
two-column grid: splitting the panel would crop that reserved area off and
|
||||||
|
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 {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background: var(--hero-ground);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroArt svg {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroArt picture,
|
||||||
|
.heroArt img {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroTrust {
|
.heroArt img {
|
||||||
display: flex;
|
object-fit: cover;
|
||||||
align-items: center;
|
/* Centre keeps the schoolhouse in frame at every desktop panel ratio: the
|
||||||
gap: 0.7rem;
|
panel runs about 2.1:1 to 2.7:1 against a 1.78:1 image, so cover crops
|
||||||
margin-top: 1.4rem;
|
height only, and the school sits well inside the surviving band. */
|
||||||
font-size: var(--step--1);
|
object-position: center;
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroTrustDots {
|
/*
|
||||||
display: inline-flex;
|
* Contrast guarantee.
|
||||||
flex: 0 0 auto;
|
*
|
||||||
|
* 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%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroTrustDot {
|
/*
|
||||||
width: 1.6rem;
|
* Dark theme.
|
||||||
height: 1.6rem;
|
*
|
||||||
border-radius: 50%;
|
* The artwork is a fixed raster, so unlike the drawn hero it cannot be
|
||||||
border: 2px solid var(--bg-secondary);
|
* re-graded token by token — but the failure it would otherwise reproduce is
|
||||||
margin-right: -0.55rem;
|
* 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%
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.heroTrustDot:nth-child(1) { background: var(--sage); }
|
|
||||||
.heroTrustDot:nth-child(2) { background: var(--mustard); }
|
|
||||||
.heroTrustDot:nth-child(3) { background: var(--sky); }
|
|
||||||
|
|
||||||
.heroEyebrow {
|
.heroEyebrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -72,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;
|
||||||
@@ -120,24 +248,40 @@
|
|||||||
|
|
||||||
@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) {
|
||||||
.heroPanel {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.heroContent {
|
.heroContent {
|
||||||
padding: 2rem 1.5rem 1.75rem;
|
padding: 2rem 1.5rem 1.75rem;
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
.heroArt {
|
.heroArt {
|
||||||
min-height: 11rem;
|
position: static;
|
||||||
order: 2;
|
height: 11rem;
|
||||||
|
}
|
||||||
|
/* 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;
|
||||||
}
|
}
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: var(--step-3);
|
font-size: var(--step-3);
|
||||||
@@ -150,13 +294,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
||||||
long coverage sentence, leaving the proposition and the search — a
|
long coverage sentence, leaving the proposition and the search — the data
|
||||||
first-time visitor still gets the trust line beneath the box, which carries
|
provenance is carried by the "Official & trusted" prop immediately below
|
||||||
the data provenance the coverage sentence used to (audit P1.7, feeds the
|
the hero (audit P1.7, feeds the 46% home exit rate). */
|
||||||
46% home exit rate). */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.heroPanel {
|
.heroPanel {
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
.heroContent {
|
.heroContent {
|
||||||
@@ -175,12 +317,8 @@
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.heroTrust {
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-size: var(--step--2);
|
|
||||||
}
|
|
||||||
.heroArt {
|
.heroArt {
|
||||||
min-height: 8.5rem;
|
height: 8.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +331,7 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 0 2rem;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.valueProp {
|
.valueProp {
|
||||||
@@ -256,7 +394,6 @@
|
|||||||
.valueProps {
|
.valueProps {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,86 +826,15 @@
|
|||||||
border-color: var(--brand-strong);
|
border-color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.exploringRow {
|
/* ── How it works section ─────────────────────────────────────────────────
|
||||||
display: flex;
|
A Sand band, borrowing the hero panel's ground and radius so the page has
|
||||||
flex-direction: column;
|
two anchored bands rather than one designed element and a long tail of
|
||||||
align-items: center;
|
unstyled strips. */
|
||||||
gap: 0.6rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exploringLabel {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exploringChips {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exploringChip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.5rem 0.95rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exploringChip:hover {
|
|
||||||
border-color: var(--brand);
|
|
||||||
color: var(--brand-strong);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipDot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
opacity: 0.55;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── How it works section ─────────────────────────────── */
|
|
||||||
|
|
||||||
.howItWorks {
|
.howItWorks {
|
||||||
padding: 3rem 0 1rem;
|
background: var(--bg-secondary);
|
||||||
}
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
.hiwHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hiwHeading {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hiwSub {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hiwGrid {
|
.hiwGrid {
|
||||||
@@ -788,8 +854,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;
|
||||||
@@ -1135,7 +1212,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;
|
||||||
@@ -1183,12 +1263,9 @@
|
|||||||
.hiwGrid {
|
.hiwGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.hiwHeader {
|
.howItWorks {
|
||||||
flex-direction: column;
|
padding: 1.75rem 1.25rem;
|
||||||
align-items: flex-start;
|
border-radius: var(--radius-lg);
|
||||||
}
|
|
||||||
.hiwHeading {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,18 +1289,29 @@
|
|||||||
|
|
||||||
/* ── Editorial section ───────────────────────────────── */
|
/* ── Editorial section ───────────────────────────────── */
|
||||||
|
|
||||||
.editorial {
|
/* Heading beside the prose rather than above it. The fact box used to occupy
|
||||||
padding: 2rem 0 3rem;
|
the right-hand column; with it gone, a single column left the card half
|
||||||
}
|
empty, because the prose is capped at a 62ch reading measure and the card
|
||||||
|
is not. */
|
||||||
.editorialGrid {
|
.editorialCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.4fr 1fr;
|
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||||
gap: 2rem;
|
column-gap: 3rem;
|
||||||
padding: 1.75rem;
|
padding: 2rem 2.25rem;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The shared header pattern spaces itself for a header sitting above its
|
||||||
|
content; here it sits beside it. */
|
||||||
|
.editorialCard .sectionHead {
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorialCard .coverageLine {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorialText {
|
.editorialText {
|
||||||
@@ -1232,23 +1320,6 @@
|
|||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorialKicker {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
color: var(--brand-strong);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editorialHeading {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Running prose is the one place the serif appears. Slightly larger than the
|
/* Running prose is the one place the serif appears. Slightly larger than the
|
||||||
UI around it because Literata's x-height sits lower than the grotesque's. */
|
UI around it because Literata's x-height sits lower than the grotesque's. */
|
||||||
.editorialText p {
|
.editorialText p {
|
||||||
@@ -1260,53 +1331,25 @@
|
|||||||
max-width: 62ch;
|
max-width: 62ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.factbox {
|
/* One line of live coverage figures, replacing a six-row table whose headline
|
||||||
background: var(--bg-secondary);
|
number was its own hardcoded fallback. Quiet by design: it is provenance,
|
||||||
border-radius: 10px;
|
not a claim. */
|
||||||
padding: 1.25rem;
|
.coverageLine {
|
||||||
display: flex;
|
margin: 1.25rem 0 0;
|
||||||
flex-direction: column;
|
padding-top: 1rem;
|
||||||
gap: 0;
|
border-top: 1px solid var(--border);
|
||||||
}
|
font-size: var(--step--1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
.factboxHeading {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.factRow {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.factRow:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.factKey {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.factVal {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.editorialGrid {
|
.editorialCard {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
gap: 1.25rem;
|
}
|
||||||
|
.editorialCard .sectionHead {
|
||||||
|
margin-bottom: var(--section-head-gap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1385,189 +1428,137 @@
|
|||||||
.loadMoreButton {
|
.loadMoreButton {
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
Admissions Countdown Strip
|
Next admissions deadline
|
||||||
|
|
||||||
|
One bar, replacing four equally-weighted countdown cards. Those gave the
|
||||||
|
page's largest numeral to things up to 245 days away, and two of the four
|
||||||
|
counted down to offer days — dates you receive something on, which cannot
|
||||||
|
be missed. The headline is the next real deadline; the rest stay on the
|
||||||
|
page as one supporting line, so no fact was lost, only its weight.
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
||||||
.admissionsStrip {
|
.admissions {
|
||||||
padding: 1.5rem 0 2rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stripHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stripLabel {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stripCta {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--brand);
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stripCta:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdownRail {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdownChip {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1rem 1.1rem 0.9rem;
|
|
||||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nextDeadline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 1.15rem 1.5rem 1.15rem 1.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdownChip::before {
|
/* A brand rule down the leading edge — the same device the countdown cards
|
||||||
|
used along their top edge, kept so the band still reads as admissions. */
|
||||||
|
.nextDeadline::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
inset: 0 auto 0 0;
|
||||||
left: 0;
|
width: 5px;
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 12px 12px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdownChipDeadline::before {
|
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdownChipOffer::before {
|
.nextDeadline:hover {
|
||||||
background: var(--status-above);
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdownChipUrgent {
|
.nextDeadlineBody {
|
||||||
border-color: rgba(var(--status-below-rgb), 0.4);
|
|
||||||
background: rgba(var(--status-below-rgb), 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chipTrack {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.15rem;
|
||||||
font-size: 0.6rem;
|
min-width: 0;
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 0.15rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipTrackDeadline {
|
.nextDeadlineKicker {
|
||||||
|
font-size: var(--step--2);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--brand-strong);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipTrackOffer {
|
.nextDeadlineTitle {
|
||||||
color: var(--status-above);
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-1);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipTrackDot {
|
.nextDeadlineDate {
|
||||||
width: 5px;
|
font-size: var(--step--1);
|
||||||
height: 5px;
|
color: var(--text-muted);
|
||||||
border-radius: 50%;
|
}
|
||||||
background: currentColor;
|
|
||||||
|
.nextDeadlineCount {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipDays {
|
.nextDeadlineDays {
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 2.6rem;
|
font-size: var(--step-3);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdownChipDeadline .chipDays,
|
.nextDeadlineUnit {
|
||||||
.countdownChipUrgent .chipDays {
|
font-size: var(--step--1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inside a fortnight the count changes hue rather than size, so urgency is
|
||||||
|
legible without the bar growing. Terracotta is the status hue, not the
|
||||||
|
action hue — this is a state, not a button. */
|
||||||
|
.nextDeadlineUrgent .nextDeadlineDays {
|
||||||
color: var(--status-below);
|
color: var(--status-below);
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdownChipOffer .chipDays {
|
.laterDates {
|
||||||
color: var(--status-above);
|
font-size: var(--step--1);
|
||||||
}
|
|
||||||
|
|
||||||
.chipDaysUnit {
|
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: 0.2rem;
|
line-height: 1.6;
|
||||||
vertical-align: bottom;
|
margin: 0;
|
||||||
line-height: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipMilestone {
|
.laterDatesLink {
|
||||||
font-size: 0.85rem;
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
text-decoration: none;
|
||||||
line-height: 1.25;
|
white-space: nowrap;
|
||||||
margin-top: 0.1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chipDate {
|
.laterDatesLink:hover {
|
||||||
font-size: 0.75rem;
|
text-decoration: underline;
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 0.05rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.countdownRail {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* On phones the 2×2 grid cramped each chip so badly the "Secondary ·
|
|
||||||
Deadline" track label dropped to 9.6px. Switch to a horizontal
|
|
||||||
snap-scroller — each card is full-width-ish and stays readable,
|
|
||||||
and the rightmost card peeks past the edge to signal there's more. */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.countdownRail {
|
.nextDeadline {
|
||||||
display: flex;
|
align-items: flex-start;
|
||||||
grid-template-columns: none;
|
flex-direction: column;
|
||||||
overflow-x: auto;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
scrollbar-width: none;
|
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding-right: 1.25rem;
|
padding: 1rem 1.1rem 1rem 1.35rem;
|
||||||
margin-inline: -1rem;
|
|
||||||
padding-inline: 1rem;
|
|
||||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
|
||||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
|
||||||
}
|
}
|
||||||
.countdownRail::-webkit-scrollbar {
|
.nextDeadlineCount {
|
||||||
display: none;
|
align-items: baseline;
|
||||||
}
|
|
||||||
.countdownChip {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 78%;
|
|
||||||
min-width: 220px;
|
|
||||||
scroll-snap-align: start;
|
|
||||||
}
|
|
||||||
.chipTrack {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,13 +40,22 @@ function daysUntil(month: number, day: number): number {
|
|||||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCountdownDate(month: number, day: number): string {
|
function nextOccurrence(month: number, day: number): Date {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const y = today.getFullYear();
|
const y = today.getFullYear();
|
||||||
let target = new Date(y, month - 1, day);
|
const target = new Date(y, month - 1, day);
|
||||||
if (target < today) target = new Date(y + 1, month - 1, day);
|
return target < today ? new Date(y + 1, month - 1, day) : target;
|
||||||
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
}
|
||||||
|
|
||||||
|
function formatCountdownDate(month: number, day: number): string {
|
||||||
|
return nextOccurrence(month, day)
|
||||||
|
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(month: number, day: number): string {
|
||||||
|
return nextOccurrence(month, day)
|
||||||
|
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountdownChipData {
|
interface CountdownChipData {
|
||||||
@@ -57,6 +66,16 @@ interface CountdownChipData {
|
|||||||
day: number;
|
day: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AdmissionsMilestone {
|
||||||
|
chip: CountdownChipData;
|
||||||
|
/** Whole days from today. Always present — computed during render. */
|
||||||
|
days: number;
|
||||||
|
/** "Sat, 31 October 2026" — for the headline bar. */
|
||||||
|
dateLabel: string;
|
||||||
|
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
|
||||||
|
shortDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||||
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||||
@@ -125,7 +144,14 @@ interface ValueProp {
|
|||||||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||||
* favourites). That feature does not exist in this product, so it is
|
* favourites). That feature does not exist in this product, so it is
|
||||||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||||
* and is backed by the countdown strip further down this page.
|
* and is backed by the next-deadline bar further down this page.
|
||||||
|
*
|
||||||
|
* Every claim here must name something the product actually does. Two of the
|
||||||
|
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
|
||||||
|
* in context/ComparisonProvider.tsx (and the card further down the page, which
|
||||||
|
* correctly said five), and "class sizes" described data the codebase has never
|
||||||
|
* held — grep for it and this line was the only hit. Both are corrected below
|
||||||
|
* against the real fields, which live in components/school/InclusionSection.tsx.
|
||||||
*/
|
*/
|
||||||
const VALUE_PROPS: ValueProp[] = [
|
const VALUE_PROPS: ValueProp[] = [
|
||||||
{
|
{
|
||||||
@@ -138,13 +164,13 @@ const VALUE_PROPS: ValueProp[] = [
|
|||||||
icon: <BarsIcon />,
|
icon: <BarsIcon />,
|
||||||
tintClass: styles.propIconCompare,
|
tintClass: styles.propIconCompare,
|
||||||
title: 'Easy to compare',
|
title: 'Easy to compare',
|
||||||
body: 'Up to three schools side by side, on the measures that matter.',
|
body: 'Up to five schools side by side, on the measures that matter.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <HeartIcon />,
|
icon: <HeartIcon />,
|
||||||
tintClass: styles.propIconContext,
|
tintClass: styles.propIconContext,
|
||||||
title: 'Beyond the numbers',
|
title: 'Beyond the numbers',
|
||||||
body: 'Class sizes, SEN support and local context, not just results.',
|
body: 'SEN support, pupil premium and attendance — not just results.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <CalendarIcon />,
|
icon: <CalendarIcon />,
|
||||||
@@ -174,9 +200,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
const mapParamsRef = useRef<string>('');
|
const mapParamsRef = useRef<string>('');
|
||||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||||
const [geoError, setGeoError] = useState<string | null>(null);
|
const [geoError, setGeoError] = useState<string | null>(null);
|
||||||
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
/*
|
||||||
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
* Computed during render, on the server as well as the client, so the
|
||||||
);
|
* deadline bar is in the first HTML rather than appearing on hydrate.
|
||||||
|
*
|
||||||
|
* The previous version deferred this to an effect to dodge a hydration
|
||||||
|
* mismatch, which meant the section had to reserve its own height — and the
|
||||||
|
* reservation was a single guessed number for a block whose supporting line
|
||||||
|
* wraps to a different height at every width. Measured, it was short at
|
||||||
|
* every breakpoint, shifting the page by up to 108px on a phone.
|
||||||
|
*
|
||||||
|
* The mismatch it was dodging is real but tiny: a server rendering at
|
||||||
|
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
|
||||||
|
* confined to two text nodes, which carry suppressHydrationWarning below.
|
||||||
|
*/
|
||||||
|
const [milestones] = useState<AdmissionsMilestone[]>(() => {
|
||||||
|
const measured = ADMISSIONS_CHIPS.map(chip => ({
|
||||||
|
chip,
|
||||||
|
days: daysUntil(chip.month, chip.day),
|
||||||
|
dateLabel: formatCountdownDate(chip.month, chip.day),
|
||||||
|
shortDate: formatShortDate(chip.month, chip.day),
|
||||||
|
}));
|
||||||
|
measured.sort((a, b) => a.days - b.days);
|
||||||
|
return measured;
|
||||||
|
});
|
||||||
|
|
||||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||||
const isLocationSearch = !!searchParams.get('postcode');
|
const isLocationSearch = !!searchParams.get('postcode');
|
||||||
@@ -239,13 +286,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
|
||||||
useEffect(() => {
|
|
||||||
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
|
||||||
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
|
||||||
setSortedChips(withDays);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLoadMore = async () => {
|
const handleLoadMore = async () => {
|
||||||
if (isLoadingMore || !hasMore) return;
|
if (isLoadingMore || !hasMore) return;
|
||||||
track('results_load_more', { next_page: currentPage + 1 });
|
track('results_load_more', { next_page: currentPage + 1 });
|
||||||
@@ -334,6 +374,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
});
|
});
|
||||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The coverage figure, or nothing at all.
|
||||||
|
*
|
||||||
|
* This used to be the string "24,000+", hardcoded in three places while the
|
||||||
|
* database held 27,230 — and the fact box that was meant to show the live
|
||||||
|
* number silently rendered its fallback, because DataInfoResponse declared a
|
||||||
|
* `total_schools` field the API has never sent. Now there is one source: if
|
||||||
|
* the real count is unavailable the sentence simply omits it rather than
|
||||||
|
* inventing a floor.
|
||||||
|
*/
|
||||||
|
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The headline milestone is the next thing a parent can actually miss — an
|
||||||
|
* application deadline. Offer days are dates you receive something on, so
|
||||||
|
* they belong in the supporting line, not in the page's largest numeral.
|
||||||
|
* Four equally-sized cards counting down to things 245 days away was the
|
||||||
|
* loudest element on the page and asked nothing of anyone.
|
||||||
|
*/
|
||||||
|
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
|
||||||
|
const laterMilestones = milestones.filter(m => m !== nextDeadline);
|
||||||
|
|
||||||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||||
// split which surface drives compare adds.
|
// split which surface drives compare adds.
|
||||||
const addSchoolFromSearch = useCallback((school: School) => {
|
const addSchoolFromSearch = useCallback((school: School) => {
|
||||||
@@ -346,12 +408,18 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
}, [addSchool, selectedSchools.length]);
|
}, [addSchool, selectedSchools.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homeView}>
|
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
||||||
|
CSS); the search arrangement is a filter bar directly above its results
|
||||||
|
and wants none of it. */
|
||||||
|
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
||||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||||
the brand landscape bleeding to the panel edge on the right. The
|
the brand landscape bleeding to the panel edge on the right. The
|
||||||
search lives inside the panel here and above the results elsewhere,
|
search lives inside the panel here and above the results elsewhere,
|
||||||
which is why FilterBar is rendered in two places rather than moved. */}
|
which is why FilterBar is rendered in two places rather than moved.
|
||||||
|
The hero and the four value props are one group, so they sit closer
|
||||||
|
together than the page's section gap. */}
|
||||||
{!isSearchActive ? (
|
{!isSearchActive ? (
|
||||||
|
<div className={styles.heroGroup}>
|
||||||
<section className={styles.heroPanel}>
|
<section className={styles.heroPanel}>
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.heroContent}>
|
||||||
<span className={styles.heroEyebrow}>
|
<span className={styles.heroEyebrow}>
|
||||||
@@ -369,7 +437,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
every line above the fold costs. */}
|
every line above the fold costs. */}
|
||||||
<span className={styles.heroDescriptionFull}>
|
<span className={styles.heroDescriptionFull}>
|
||||||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||||
and admissions data for <strong>24,000+ schools</strong> — side by
|
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
|
||||||
side, in one place.
|
side, in one place.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -383,20 +451,35 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
geoError={geoError}
|
geoError={geoError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className={styles.heroTrust}>
|
|
||||||
<span className={styles.heroTrustDots} aria-hidden="true">
|
|
||||||
<span className={styles.heroTrustDot} />
|
|
||||||
<span className={styles.heroTrustDot} />
|
|
||||||
<span className={styles.heroTrustDot} />
|
|
||||||
</span>
|
|
||||||
Built on official DfE and Ofsted data — 24,000+ schools
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Rendered after the content on purpose. On desktop it is absolutely
|
||||||
|
positioned behind the panel, so order does not matter; below the
|
||||||
|
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}>
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Why this site, in four lines. These titles are labels on a list,
|
||||||
|
not section headings — as <h2> they outranked the page's real
|
||||||
|
headings in the document outline and gave a screen-reader user
|
||||||
|
four false landmarks before any content. */}
|
||||||
|
<ul className={styles.valueProps}>
|
||||||
|
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||||
|
<li key={title} className={styles.valueProp}>
|
||||||
|
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||||
|
<div>
|
||||||
|
<p className={styles.propTitle}>{title}</p>
|
||||||
|
<p className={styles.propBody}>{body}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -408,94 +491,55 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Why this site, in four lines. Only on the landing page. */}
|
{/* Next admissions deadline — one bar, not four cards. */}
|
||||||
{!isSearchActive && (
|
{!isSearchActive && (
|
||||||
<ul className={styles.valueProps}>
|
<section className={styles.admissions} aria-label="Admissions deadlines">
|
||||||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
{nextDeadline && (
|
||||||
<li key={title} className={styles.valueProp}>
|
<>
|
||||||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
<a
|
||||||
<div>
|
href="/admissions"
|
||||||
<h2 className={styles.propTitle}>{title}</h2>
|
className={
|
||||||
<p className={styles.propBody}>{body}</p>
|
nextDeadline.days <= 14
|
||||||
</div>
|
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
|
||||||
</li>
|
: styles.nextDeadline
|
||||||
))}
|
}
|
||||||
</ul>
|
>
|
||||||
)}
|
<span className={styles.nextDeadlineBody}>
|
||||||
|
<span className={styles.nextDeadlineKicker}>
|
||||||
{/* Admissions countdown strip — only on landing page */}
|
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
|
||||||
{!isSearchActive && (
|
|
||||||
<section className={styles.admissionsStrip}>
|
|
||||||
<div className={styles.stripHeader}>
|
|
||||||
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
|
||||||
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={styles.countdownRail}
|
|
||||||
style={{
|
|
||||||
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
|
||||||
transition: 'opacity 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sortedChips.map(({ chip, days }) => {
|
|
||||||
const isUrgent = days !== null && days <= 14;
|
|
||||||
const chipClass = [
|
|
||||||
styles.countdownChip,
|
|
||||||
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
|
||||||
isUrgent ? styles.countdownChipUrgent : '',
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
const trackClass = [
|
|
||||||
styles.chipTrack,
|
|
||||||
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
|
||||||
].join(' ');
|
|
||||||
return (
|
|
||||||
<div key={chip.milestone} className={chipClass}>
|
|
||||||
<span className={trackClass}>
|
|
||||||
<span className={styles.chipTrackDot} aria-hidden="true" />
|
|
||||||
{chip.track}
|
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
|
||||||
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
|
||||||
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
{nextDeadline.dateLabel}
|
||||||
</div>
|
</span>
|
||||||
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
</span>
|
||||||
<div className={styles.chipDate}>
|
<span className={styles.nextDeadlineCount}>
|
||||||
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
|
||||||
</div>
|
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
|
||||||
</div>
|
</span>
|
||||||
);
|
{nextDeadline.days !== 0 && (
|
||||||
})}
|
<span className={styles.nextDeadlineUnit}>days left</span>
|
||||||
</div>
|
)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p className={styles.laterDates} suppressHydrationWarning>
|
||||||
|
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
||||||
|
{' '}
|
||||||
|
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secondary discovery — moved below deadlines so the admissions
|
|
||||||
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
|
||||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
|
||||||
<div className={styles.exploringRow}>
|
|
||||||
<span className={styles.exploringLabel}>Start exploring</span>
|
|
||||||
<div className={styles.exploringChips}>
|
|
||||||
<a href="/rankings" className={styles.exploringChip}>
|
|
||||||
<span className={styles.chipDot} aria-hidden="true" />
|
|
||||||
Top-rated primary schools
|
|
||||||
</a>
|
|
||||||
<a href="/rankings" className={styles.exploringChip}>
|
|
||||||
<span className={styles.chipDot} aria-hidden="true" />
|
|
||||||
Top-rated secondary schools
|
|
||||||
</a>
|
|
||||||
<a href="/compare" className={styles.exploringChip}>
|
|
||||||
<span className={styles.chipDot} aria-hidden="true" />
|
|
||||||
Start a comparison
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||||
{!isSearchActive && howItWorks}
|
{!isSearchActive && howItWorks}
|
||||||
{!isSearchActive && editorial}
|
{!isSearchActive && editorial}
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section. Skipped entirely on the landing page when there is
|
||||||
|
nothing to list — an empty <section> is still a flex child, so it was
|
||||||
|
contributing a full section gap of blank space above the footer. */}
|
||||||
|
{(isSearchActive || initialSchools.schools.length > 0) && (
|
||||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
@@ -681,6 +725,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
|
|||||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||||
];
|
];
|
||||||
|
/*
|
||||||
|
* The columns are deliberately unnamed.
|
||||||
|
*
|
||||||
|
* They previously carried two real school names — "Our Lady Queen of Heaven"
|
||||||
|
* and "St Mary's Catholic Primary" — beside invented results and invented
|
||||||
|
* Ofsted grades. On a site whose entire proposition is official DfE and
|
||||||
|
* Ofsted data, publishing made-up figures against identifiable schools is
|
||||||
|
* the one thing it cannot do, and no illustrative intent survives being
|
||||||
|
* screenshotted. Generic labels make the layout point just as well.
|
||||||
|
*/
|
||||||
const compareRows = [
|
const compareRows = [
|
||||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||||
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.howItWorks}>
|
<section className={styles.howItWorks}>
|
||||||
<div className={styles.hiwHeader}>
|
{/* One header pattern across every band on this page: kicker, heading,
|
||||||
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
optional aside. The page previously ran four different treatments
|
||||||
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
(two uppercase micro-labels — one left, one centred — a large heading
|
||||||
|
with right-aligned grey text, and a kicker-plus-heading), which is
|
||||||
|
most of why a designed page read as a stack of unrelated strips. */}
|
||||||
|
<div className={styles.sectionHead}>
|
||||||
|
<p className={styles.sectionKicker}>Every school page</p>
|
||||||
|
<h2 className={styles.sectionHeading}>What you'll see on every school</h2>
|
||||||
|
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwGrid}>
|
<div className={styles.hiwGrid}>
|
||||||
{/* Card 1 — Performance */}
|
{/* Card 1 — Performance */}
|
||||||
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.comparePreview}>
|
<div className={styles.comparePreview}>
|
||||||
<div className={styles.compareHead}>
|
<div className={styles.compareHead}>
|
||||||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||||
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
<div className={styles.compareHeadCell}>School A</div>
|
||||||
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
<div className={styles.compareHeadCell}>School B</div>
|
||||||
</div>
|
</div>
|
||||||
{compareRows.map(({ label, a, b, aHi }) => (
|
{compareRows.map(({ label, a, b, aHi }) => (
|
||||||
<div key={label} className={styles.compareRow}>
|
<div key={label} className={styles.compareRow}>
|
||||||
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
|
|||||||
<span className={styles.compareRowVal}>{b}</span>
|
<span className={styles.compareRowVal}>{b}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwCardBody}>
|
<div className={styles.hiwCardBody}>
|
||||||
<div className={styles.hiwStep}>Compare</div>
|
<div className={styles.hiwStep}>Compare</div>
|
||||||
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
<div className={styles.hiwTitle}>Side by side</div>
|
||||||
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
{/* One verb for one feature. The site previously called this
|
||||||
|
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
||||||
|
which reads as three separate things it does not have. */}
|
||||||
|
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,146 +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 winding path up 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 CROPS, ONE <picture>
|
||||||
|
* ------------------------
|
||||||
|
* The slot is two different shapes. On desktop the artwork sits behind the
|
||||||
|
* whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
|
||||||
|
* area the illustration reserves on its left. Below the one-column breakpoint
|
||||||
|
* it becomes a band between 2.6:1 and 4.9:1, under the search rather than
|
||||||
|
* behind it.
|
||||||
|
*
|
||||||
|
* A single file under `object-fit: cover` centre-crops, and at the band's
|
||||||
|
* extreme that slices a strip through the middle of the scene and loses the
|
||||||
|
* schoolhouse — exactly how the previous SVG hero failed on phones. So the
|
||||||
|
* <source media> switches crop, not just resolution: `band` is pre-cropped
|
||||||
|
* around the school and is already near 2.6:1, so the narrow band barely
|
||||||
|
* crops it further.
|
||||||
|
*
|
||||||
|
* `object-position` is set in CSS per breakpoint and is load-bearing — see
|
||||||
|
* .heroArt in HomeView.module.css.
|
||||||
|
*
|
||||||
|
* The artwork is decorative: the proposition beside it carries the meaning, so
|
||||||
|
* alt is empty and it is hidden from assistive tech rather than described.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* These hex values mirror the brand palette in app/globals.css — --sky,
|
* The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
|
||||||
* --sage, --brand, --coral, --sand and the tints/shades the scene needs
|
* padding (1rem under 768px), so the artwork's box is that minus the padding —
|
||||||
* around them. They are written literally on purpose: an illustration is
|
* describing it as 100vw over-requests by ~48px worth of candidate at every
|
||||||
* artwork, not themed UI. Recolouring it per theme would break the picture,
|
* width, which on a slow connection is a larger file than needed for the LCP
|
||||||
* so it keeps one fixed, light palette in both themes (it sits on a Sand
|
* element.
|
||||||
* panel in light mode and reads as a framed image in dark mode).
|
|
||||||
*/
|
*/
|
||||||
const SKY_HIGH = '#DAEDF8';
|
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
||||||
const SKY_LOW = '#EFF8FB';
|
|
||||||
const CLOUD = '#FFFFFF';
|
|
||||||
|
|
||||||
const HILL_FAR = '#D6EDE2'; // --sage, lightened
|
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
||||||
const HILL_MID = '#A7D7C5'; // --sage, exact
|
const WIDE_W = 1672;
|
||||||
const HILL_NEAR = '#7FC3AC';
|
const WIDE_H = 941;
|
||||||
const HILL_FRONT = '#5BA88F'; // --sage darkened toward --brand
|
|
||||||
|
|
||||||
const PATH_FILL = '#FAF6EE'; // --sand, lightened
|
|
||||||
const PATH_EDGE = '#E6DAC2'; // --sand, darkened
|
|
||||||
|
|
||||||
const SCHOOL_WALL = '#FCE8C3'; // cream
|
|
||||||
const SCHOOL_ROOF = '#F0A868'; // warm orange
|
|
||||||
const SCHOOL_DOOR = '#0F766E'; // --brand, exact
|
|
||||||
const SCHOOL_WINDOW = '#C7EBF5'; // --sky, exact
|
|
||||||
const SCHOOL_CLOCK = '#FAFAF8'; // --bg-primary (Warm White)
|
|
||||||
const FLAGPOLE = '#8FA3AE';
|
|
||||||
const PENNANT = '#F97360'; // --coral, exact
|
|
||||||
|
|
||||||
const TREE_DARK = '#2E7D6B';
|
|
||||||
const TREE_MID = '#3E8C74';
|
|
||||||
const TREE_LIGHT = '#4A9E85';
|
|
||||||
const TREE_PALE = '#7FC3AC';
|
|
||||||
|
|
||||||
const PIN = '#F97360'; // --coral, exact
|
|
||||||
const PIN_EYE = '#FFFFFF';
|
|
||||||
|
|
||||||
/** Scoped so the gradient id can't collide with another inline SVG. */
|
|
||||||
const SKY_GRADIENT_ID = 'sc-hero-sky';
|
|
||||||
|
|
||||||
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
|
||||||
<linearGradient id={SKY_GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
|
rules have to precede the unconstrained desktop ones. */}
|
||||||
<stop offset="0" stopColor={SKY_HIGH} />
|
<source
|
||||||
<stop offset="1" stopColor={SKY_LOW} />
|
media="(max-width: 860px)"
|
||||||
</linearGradient>
|
type="image/avif"
|
||||||
</defs>
|
srcSet="/brand/hero-band-500.avif 500w, /brand/hero-band-700.avif 700w, /brand/hero-band-972.avif 972w"
|
||||||
|
sizes={SIZES}
|
||||||
{/* Sky */}
|
|
||||||
<rect width="540" height="520" fill={`url(#${SKY_GRADIENT_ID})`} />
|
|
||||||
|
|
||||||
{/* Two soft clouds, each a pair of overlapping ellipses */}
|
|
||||||
<g fill={CLOUD} opacity="0.85">
|
|
||||||
<ellipse cx="96" cy="86" rx="32" ry="15" />
|
|
||||||
<ellipse cx="122" cy="79" rx="23" ry="18" />
|
|
||||||
<ellipse cx="418" cy="58" rx="27" ry="13" />
|
|
||||||
<ellipse cx="439" cy="52" 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={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={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={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={HILL_FRONT} />
|
|
||||||
|
|
||||||
{/* Winding cream path, with a dotted edge for a little texture */}
|
|
||||||
<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={PATH_FILL}
|
|
||||||
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={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.
|
||||||
|
|
||||||
{/* The school at the top of the path */}
|
<img src> cannot vary by viewport, so it is always the wide crop. A
|
||||||
<g transform="translate(276 232)">
|
browser that takes neither AVIF nor WebP would therefore fall through
|
||||||
<rect x="12" y="34" width="98" height="58" rx="4" fill={SCHOOL_WALL} />
|
to the desktop frame on a phone and lose the schoolhouse — the exact
|
||||||
<path d="M2 36 L61 6 L120 36 Z" fill={SCHOOL_ROOF} />
|
failure this whole component is arranged to prevent, surviving in the
|
||||||
<rect x="54" y="62" width="20" height="30" rx="2" fill={SCHOOL_DOOR} />
|
one path nobody looks at. No `type` here, so it matches anywhere the
|
||||||
<g fill={SCHOOL_WINDOW}>
|
media query does, and it sits after the modern formats so they still
|
||||||
<rect x="24" y="46" width="16" height="14" rx="2" />
|
win where supported.
|
||||||
<rect x="86" y="46" width="16" height="14" rx="2" />
|
*/}
|
||||||
<rect x="24" y="70" width="16" height="12" rx="2" />
|
<source media="(max-width: 860px)" srcSet="/brand/hero-band-700.jpg" />
|
||||||
<rect x="86" y="70" width="16" height="12" rx="2" />
|
<source
|
||||||
</g>
|
type="image/avif"
|
||||||
{/* Flagpole and coral pennant */}
|
srcSet="/brand/hero-wide-900.avif 900w, /brand/hero-wide-1200.avif 1200w, /brand/hero-wide-1672.avif 1672w"
|
||||||
<rect x="59.5" y="-14" width="2.5" height="20" fill={FLAGPOLE} />
|
sizes={SIZES}
|
||||||
<path d="M62 -13 L78 -8.5 L62 -4 Z" fill={PENNANT} />
|
/>
|
||||||
{/* Clock in the gable */}
|
<source
|
||||||
<circle cx="61" cy="24" r="6.5" fill={SCHOOL_CLOCK} />
|
type="image/webp"
|
||||||
<path d="M61 20.5 v7 M57.5 24 h7" stroke={SCHOOL_DOOR} strokeWidth="1.5" strokeLinecap="round" />
|
srcSet="/brand/hero-wide-900.webp 900w, /brand/hero-wide-1200.webp 1200w, /brand/hero-wide-1672.webp 1672w"
|
||||||
</g>
|
sizes={SIZES}
|
||||||
|
/>
|
||||||
{/* Scattered rounded trees */}
|
{/*
|
||||||
<g fill={TREE_DARK}>
|
The hero is the largest thing above the fold, so it is almost certainly
|
||||||
<circle cx="104" cy="372" r="27" />
|
the LCP element: fetchPriority high, and never lazy. eslint's
|
||||||
<circle cx="136" cy="386" r="19" />
|
no-img-element wants next/image, which cannot art-direct between two
|
||||||
<circle cx="470" cy="336" r="23" />
|
different crops — that is the whole point of the <picture> above.
|
||||||
</g>
|
*/}
|
||||||
<g fill={TREE_LIGHT}>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<circle cx="78" cy="394" r="21" />
|
<img
|
||||||
<circle cx="492" cy="358" r="17" />
|
src="/brand/hero-wide-1200.jpg"
|
||||||
<circle cx="176" cy="420" r="18" />
|
alt=""
|
||||||
</g>
|
aria-hidden="true"
|
||||||
<g fill={TREE_MID}>
|
width={WIDE_W}
|
||||||
<ellipse cx="44" cy="336" rx="15" ry="22" />
|
height={WIDE_H}
|
||||||
<ellipse cx="508" cy="296" rx="13" ry="18" />
|
decoding="async"
|
||||||
<ellipse cx="150" cy="342" rx="12" ry="17" />
|
fetchPriority="high"
|
||||||
</g>
|
/>
|
||||||
<g fill={TREE_PALE} opacity="0.9">
|
</picture>
|
||||||
<circle cx="410" cy="420" r="16" />
|
|
||||||
<circle cx="438" cy="430" r="12" />
|
|
||||||
<circle cx="120" cy="470" r="14" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Location pin — you are here, the path leads up to the school */}
|
|
||||||
<g transform="translate(196 372)">
|
|
||||||
<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={PIN} />
|
|
||||||
<circle cx="21" cy="15.5" r="8" fill={PIN_EYE} />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -444,11 +444,27 @@ export interface MetricsResponse {
|
|||||||
metrics: MetricDefinition[];
|
metrics: MetricDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/data-info.
|
||||||
|
*
|
||||||
|
* The school count field is `unique_schools`. This interface declared
|
||||||
|
* `total_schools` instead — a field the API has never sent — so every read of
|
||||||
|
* it was `undefined` at runtime while type-checking cleanly, and the landing
|
||||||
|
* page silently fell back to a hardcoded "24,000+" against a real 27,230.
|
||||||
|
* Nothing threw and no test failed, because the interface, not the code, was
|
||||||
|
* the thing that was wrong. Keep these names matched to backend/app.py.
|
||||||
|
*/
|
||||||
export interface DataInfoResponse {
|
export interface DataInfoResponse {
|
||||||
total_schools: number;
|
status: 'loaded' | 'no_data';
|
||||||
years_available: number[];
|
data_source: string;
|
||||||
latest_year: number;
|
/** Absent on the `no_data` branch — see backend/app.py:1015. Optional here
|
||||||
total_records: number;
|
* so callers are forced to handle the case instead of reading undefined. */
|
||||||
|
unique_schools?: number;
|
||||||
|
years_available?: number[];
|
||||||
|
total_records?: number;
|
||||||
|
schools_per_year?: Record<string, number>;
|
||||||
|
local_authorities?: Record<string, number>;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||