feat(brand): adopt the schoolcompare identity across the site
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m29s
Implements the supplied brand guideline: the pin-and-leaf mark, the lowercase schoolcompare wordmark, Manrope for headings and Inter for everything a person reads a number from, the deep-teal/coral/mustard/sage palette, and the rounder geometry the guideline asks for. The landing hero is rebuilt to the concept: a Sand panel carrying the proposition, the search and a trust line, with the brand landscape bleeding to the panel's right edge, followed by the four value props. Concept vs reality — where the guideline describes something this product does not have, the real feature takes its place and the decision is recorded in a code comment next to it: - The Thriving Score is not implemented. Four of its five sub-scores (pastoral, enrichment, community, facilities) have no source in DfE or Ofsted data, and its band names are Ofsted's own, which would sit beside the real Ofsted grade and be indistinguishable from it. - Guides, About, Favourites and Sign in are not added to the nav — there are no such pages and no accounts system. Rankings and Admissions, which the guideline omits, keep their places because they exist. - The fourth value prop is "Never miss a deadline" rather than "Save & revisit", which would need shortlisting. Two deviations from the supplied swatches, both forced by contrast and both confined to text and fills. Coral #F97360 under a white label measures 2.75:1 against a 4.5 floor, so --action is a darkened #BE3C27 and the original coral survives as --coral for tints, borders and illustration. Mustard #F2C94C is 1.5:1 as text, so --mustard-ink carries labels while --mustard stays exact for fills and rings. Every token clears AA on all three light grounds; Sand is the binding one, so several values sit a step darker than white alone would need. The guideline is light-only, but the site already ships a dark theme, so it is rebuilt from the same tokens rather than dropped. Verified: tsc clean, 159/159 unit tests, production build green, and the generated share card and app icon render from the new mark. The hero and the site chrome were rendered from the compiled CSS and the prerendered markup to confirm Manrope and Inter actually resolve — a font token that silently falls back has shipped here before.
@@ -726,11 +726,10 @@ test('the brand typefaces actually load and apply', async ({ page }) => {
|
|||||||
// rest are fallbacks and always end in a generic like sans-serif.
|
// rest are fallbacks and always end in a generic like sans-serif.
|
||||||
const first = (el: Element) =>
|
const first = (el: Element) =>
|
||||||
getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim();
|
getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim();
|
||||||
const prose = document.querySelector('[class*="editorialText"] p');
|
const headings = [...document.querySelectorAll('h1, h2')].map(first);
|
||||||
return {
|
return {
|
||||||
body: first(document.body),
|
body: first(document.body),
|
||||||
heading: first(document.querySelector('h1')!),
|
headings,
|
||||||
prose: prose ? first(prose) : null,
|
|
||||||
bodyStack: getComputedStyle(document.body).fontFamily,
|
bodyStack: getComputedStyle(document.body).fontFamily,
|
||||||
displayToken: root.getPropertyValue('--font-display').trim(),
|
displayToken: root.getPropertyValue('--font-display').trim(),
|
||||||
uiToken: root.getPropertyValue('--font-ui').trim(),
|
uiToken: root.getPropertyValue('--font-ui').trim(),
|
||||||
@@ -745,18 +744,38 @@ test('the brand typefaces actually load and apply', async ({ page }) => {
|
|||||||
expect(fonts.uiToken, '--font-ui resolved').not.toBe('');
|
expect(fonts.uiToken, '--font-ui resolved').not.toBe('');
|
||||||
expect(fonts.proseToken, '--font-prose resolved').not.toBe('');
|
expect(fonts.proseToken, '--font-prose resolved').not.toBe('');
|
||||||
|
|
||||||
expect(fonts.body, 'body uses the UI face').toBe('Schibsted Grotesk');
|
// Inter carries body copy, every control and every figure; Manrope carries
|
||||||
expect(fonts.heading, 'headings use the display face').toBe('Schibsted Grotesk');
|
// headings and key messaging. Both must actually resolve, not merely be
|
||||||
if (fonts.prose) {
|
// named in a stack that never loads.
|
||||||
expect(fonts.prose, 'running prose uses the serif').toBe('Literata');
|
expect(fonts.body, 'body uses Inter').toBe('Inter');
|
||||||
}
|
expect(fonts.headings.length, 'the page has headings to check').toBeGreaterThan(0);
|
||||||
|
expect(fonts.headings, 'at least one heading uses Manrope').toContain('Manrope');
|
||||||
|
|
||||||
// The Times fallback is the specific failure that shipped. Match the family
|
// The Times fallback is the specific failure that shipped once. Match the
|
||||||
// name only — a stack legitimately ends in sans-serif, so anchoring on
|
// family name only — a stack legitimately ends in sans-serif, so anchoring
|
||||||
// /serif$/ would flag a perfectly healthy page.
|
// on /serif$/ would flag a perfectly healthy page.
|
||||||
expect(fonts.bodyStack).not.toMatch(/\bTimes\b/);
|
expect(fonts.bodyStack).not.toMatch(/\bTimes\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wordmark is the one piece of brand chrome a user reads on every page,
|
||||||
|
* and it is set as two spans so "compare" can take the brand colour. A
|
||||||
|
* refactor that drops the second span, or reverts the lowercase styling,
|
||||||
|
* changes the brand without failing anything else.
|
||||||
|
*/
|
||||||
|
test('the header carries the schoolcompare lockup', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const home = page.getByRole('banner').getByRole('link', { name: /schoolcompare home/i });
|
||||||
|
await expect(home).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Assert on rendered text rather than CSS-module class names, which are
|
||||||
|
// hashed at build time and change on any unrelated edit.
|
||||||
|
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
||||||
|
|
||||||
|
const mark = home.locator('svg').first();
|
||||||
|
await expect(mark).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
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 }) => {
|
||||||
// Form controls don't inherit colour from their parent, so a missing
|
// Form controls don't inherit colour from their parent, so a missing
|
||||||
// declaration renders pure black — subtle in light mode, invisible in dark.
|
// declaration renders pure black — subtle in light mode, invisible in dark.
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { BARS } from '@/components/Logo';
|
import { markDataUri } from '@/components/Logo';
|
||||||
|
|
||||||
// iOS ignores SVG touch icons — it needs a real raster, on an opaque ground,
|
// iOS ignores SVG touch icons — it needs a real raster, on an opaque ground,
|
||||||
// with no transparency (it composites its own rounded mask). Previously this
|
// with no transparency (it composites its own rounded mask). Previously this
|
||||||
// pointed at favicon.svg, so add-to-home-screen produced a blank tile.
|
// pointed at favicon.svg, so add-to-home-screen produced a blank tile.
|
||||||
|
//
|
||||||
|
// Teal ground with the mark knocked out in white, matching the app icon in the
|
||||||
|
// guideline's "brand in action" row.
|
||||||
export const size = { width: 180, height: 180 };
|
export const size = { width: 180, height: 180 };
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
const COHORT = ['rgba(22,32,42,0.22)', 'rgba(22,32,42,0.38)', 'rgba(22,32,42,0.55)', '', 'rgba(22,32,42,0.22)'];
|
const TEAL = '#0F766E';
|
||||||
const SCALE = 3.6; // 40 → 144, leaving a 18px margin inside 180
|
|
||||||
|
|
||||||
export default function AppleIcon() {
|
export default function AppleIcon() {
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
@@ -20,25 +22,11 @@ export default function AppleIcon() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: '#f2f1ed',
|
background: TEAL,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', width: 40 * SCALE, height: 40 * SCALE, display: 'flex' }}>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
<img src={markDataUri('#FFFFFF', TEAL)} width={116} height={116} alt="" />
|
||||||
<div
|
|
||||||
key={x}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: x * SCALE,
|
|
||||||
top: y * SCALE,
|
|
||||||
width: w * SCALE,
|
|
||||||
height: h * SCALE,
|
|
||||||
borderRadius: 3,
|
|
||||||
background: isSchool ? '#584a9b' : COHORT[i],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
size
|
size
|
||||||
|
|||||||
@@ -1,173 +1,193 @@
|
|||||||
/*
|
/*
|
||||||
* SchoolCompare.co.uk — "Cohort"
|
* SchoolCompare — "Find the right school. For their future."
|
||||||
*
|
*
|
||||||
* A school's number means nothing on its own; its position in the national
|
* Implements the schoolcompare brand guideline. Colour has four jobs and they
|
||||||
* distribution does. That idea drives the identity: the logo is the same
|
* never borrow each other's hues:
|
||||||
* five-bar spread that appears inside every school row.
|
* brand (deep teal) identity, navigation, headings, links, primary buttons
|
||||||
|
* action (coral) the one decisive action on a page — search, submit
|
||||||
|
* status (green / valence against the England average. Never a CTA.
|
||||||
|
* terracotta)
|
||||||
|
* phase (desaturated) category chips, deliberately subordinate.
|
||||||
*
|
*
|
||||||
* Colour has exactly three jobs and they never borrow each other's hues:
|
* Two deviations from the supplied swatches, both forced by contrast, both
|
||||||
* brand (iris) interactive + identity. Never carries valence.
|
* confined to text and fills. Coral #F97360 carrying a white label measures
|
||||||
* status (teal / amber) valence — teal is above/good, amber is
|
* 2.75:1 where 4.5 is the floor, so --action is a darkened #BE3C27; the
|
||||||
* below/needs-attention. Never a CTA.
|
* original coral survives as --coral for tints, borders and illustration.
|
||||||
* phase (desaturated) category chips, deliberately subordinate.
|
* Mustard #F2C94C is 1.5:1 as text, so --mustard-ink carries any label while
|
||||||
|
* --mustard stays exact for fills, rings and badges.
|
||||||
*
|
*
|
||||||
* Escalate within status by weight, not by reaching for a new hue: a tinted
|
* Every value below clears WCAG AA on all three light grounds — Warm White,
|
||||||
* amber chip for "requires improvement", a solid amber one for "inadequate".
|
* white cards and Sand. Sand is the binding one; several values are a step
|
||||||
*
|
* darker than they would need to be on white alone.
|
||||||
* Teal/amber rather than green/red so the above/below signal survives every
|
|
||||||
* form of colour blindness — the audience includes parents reading closely.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/* ── Ground ─────────────────────────────────────────────────────── */
|
/* ── Ground ─────────────────────────────────────────────────────── */
|
||||||
--bg-primary: #f2f1ed; /* paper, not cream — warm enough to read as a document */
|
--bg-primary: #FAFAF8; /* Warm White */
|
||||||
--bg-secondary: #e9e8e2;
|
--bg-secondary: #F5EFE6; /* Sand — hero panels, sunken rows */
|
||||||
--bg-card: #fcfcfa;
|
--bg-card: #FFFFFF;
|
||||||
--surface-inverse: #16202a; /* dark fills: skip link, active nav, tooltips */
|
--surface-inverse: #0F766E;
|
||||||
|
|
||||||
/* ── Ink ────────────────────────────────────────────────────────── */
|
/* ── Ink ────────────────────────────────────────────────────────── */
|
||||||
--text-primary: #16202a;
|
--text-primary: #1C2731; /* Ink */
|
||||||
--text-secondary: #4a545f;
|
--text-secondary: #4A5560;
|
||||||
--text-muted: #5c6570; /* 5.3:1 on --bg-primary */
|
--text-muted: #5F6A75; /* 4.8:1 on Sand, the worst ground */
|
||||||
--text-inverse: #f7f7f4;
|
--text-inverse: #FFFFFF;
|
||||||
|
|
||||||
/* ── Line ───────────────────────────────────────────────────────── */
|
/* ── Line ───────────────────────────────────────────────────────── */
|
||||||
--border: #dedcd5;
|
--border: #E5E7EB; /* Light Grey */
|
||||||
--border-strong: #cdcac1;
|
--border-strong: #D3D7DD;
|
||||||
|
|
||||||
/* ── Brand: iris ────────────────────────────────────────────────── */
|
/* ── Brand: deep teal ───────────────────────────────────────────── */
|
||||||
--brand: #584a9b; /* 6.9:1 on --bg-primary */
|
--brand: #0F766E; /* exact brand value; 4.8:1 on Sand */
|
||||||
--brand-strong: #473c80; /* hover on brand fills */
|
--brand-strong: #0C5F58;
|
||||||
--brand-stronger: #3a3069; /* active/pressed */
|
--brand-stronger: #0A4D47;
|
||||||
--brand-bg: rgba(88, 74, 155, 0.10);
|
--brand-bg: rgba(15, 118, 110, 0.10);
|
||||||
--brand-on: #fcfcfa; /* text/icon sitting on a brand fill */
|
--brand-on: #FFFFFF;
|
||||||
|
|
||||||
/* ── Status: above / at / below the comparison point ────────────── */
|
/* ── Action: coral. One per screen, and only for the decisive one. ── */
|
||||||
--status-above: #0b625a; /* 6.6:1 on --bg-primary, 5.1:1 on its own tint */
|
--action: #BE3C27; /* darkened coral — 5.4:1 under a white label */
|
||||||
--status-above-bg: rgba(11, 98, 90, 0.10);
|
--action-strong: #A33320;
|
||||||
--status-below: #7f4a00; /* 6.3:1 on --bg-primary, 5.1:1 on its own tint */
|
--action-stronger: #8C2B1B;
|
||||||
--status-below-bg: rgba(127, 74, 0, 0.11);
|
--action-bg: rgba(249, 115, 96, 0.14);
|
||||||
--status-at: var(--text-muted);
|
--action-on: #FFFFFF;
|
||||||
--status-at-bg: rgba(92, 101, 112, 0.10);
|
|
||||||
|
|
||||||
/* ── Charts: the same three hues, extended by lightness only ────── */
|
/* ── Palette accents: exact brand values, for fills and tints only ── */
|
||||||
--chart-1: #584a9b;
|
--coral: #F97360;
|
||||||
--chart-2: #0b625a;
|
--mustard: #F2C94C;
|
||||||
--chart-3: #7f4a00;
|
--mustard-ink: #806200; /* any label that has to sit on or beside mustard */
|
||||||
--chart-4: #8a7cc9;
|
--sage: #A7D7C5;
|
||||||
--chart-5: #3e9c92;
|
--sky: #C7EBF5;
|
||||||
--chart-6: #c9903d;
|
--lavender: #DDD8F5;
|
||||||
--chart-grid: #dedcd5;
|
--sand: #F5EFE6;
|
||||||
--chart-reference: #5c6570; /* national/LA reference lines */
|
|
||||||
|
/* ── Status: above / at / below the England average ─────────────── */
|
||||||
|
--status-above: #36743F;
|
||||||
|
--status-above-bg: rgba(54, 116, 63, 0.12);
|
||||||
|
--status-below: #A9481F;
|
||||||
|
--status-below-bg: rgba(249, 115, 96, 0.15);
|
||||||
|
--status-at: #5F6A75;
|
||||||
|
--status-at-bg: rgba(95, 106, 117, 0.10);
|
||||||
|
|
||||||
|
/* ── Charts ─────────────────────────────────────────────────────── */
|
||||||
|
--chart-1: #0F766E;
|
||||||
|
--chart-2: #36743F;
|
||||||
|
--chart-3: #806200;
|
||||||
|
--chart-4: #A9481F;
|
||||||
|
--chart-5: #2F6F8F;
|
||||||
|
--chart-6: #5F4FA8;
|
||||||
|
--chart-grid: #E5E7EB;
|
||||||
|
--chart-reference: #5F6A75;
|
||||||
|
|
||||||
/* ── Series: up to 8 schools compared at once ───────────────────────
|
/* ── Series: up to 8 schools compared at once ───────────────────────
|
||||||
Categorical needs distinguishable hues, so this ramp is wider than the
|
Categorical, so wider than the four-hue rule — but every step is held
|
||||||
three-hue rule — but every step is held to the same mid-dark tone and
|
to the same mid-dark tone and clears AA on the card, so they read as
|
||||||
moderate saturation, and every one clears AA on --bg-primary, so they
|
one family and work as legend text as well as chart lines. */
|
||||||
read as one family and work as legend text as well as chart lines. */
|
--series-1: #0F766E;
|
||||||
--series-1: #584a9b;
|
--series-2: #36743F;
|
||||||
--series-2: #0b625a;
|
--series-3: #806200;
|
||||||
--series-3: #7f4a00;
|
--series-4: #A9481F;
|
||||||
--series-4: #a03a5e;
|
--series-5: #2F6F8F;
|
||||||
--series-5: #2f5f8f;
|
--series-6: #5F4FA8;
|
||||||
--series-6: #4a6b2f;
|
--series-7: #0E7A86;
|
||||||
--series-7: #7a3f7a;
|
--series-8: #8A4A6B;
|
||||||
--series-8: #97442a;
|
|
||||||
|
|
||||||
/* ── Phase: category, desaturated so it stays under the status hues ── */
|
/* ── Phase: category, desaturated so it stays under the status hues ── */
|
||||||
--phase-primary: #4a6072;
|
--phase-primary: #0F766E;
|
||||||
--phase-primary-bg: rgba(74, 96, 114, 0.10);
|
--phase-primary-bg: rgba(167, 215, 197, 0.40);
|
||||||
--phase-primary-text: #3b4e5e;
|
--phase-primary-text: #0C5F58;
|
||||||
--phase-secondary: #5f5480;
|
--phase-secondary: #2F6F8F;
|
||||||
--phase-secondary-bg: rgba(95, 84, 128, 0.10);
|
--phase-secondary-bg: rgba(199, 235, 245, 0.55);
|
||||||
--phase-secondary-text: #4e4570;
|
--phase-secondary-text: #2A6180;
|
||||||
--phase-all-through: #4b6b57;
|
--phase-all-through: #36743F;
|
||||||
--phase-all-through-bg: rgba(75, 107, 87, 0.10);
|
--phase-all-through-bg: rgba(54, 116, 63, 0.12);
|
||||||
--phase-all-through-text: #3d5847;
|
--phase-all-through-text: #2F6738;
|
||||||
--phase-post16: #7a6242;
|
--phase-post16: #806200;
|
||||||
--phase-post16-bg: rgba(122, 98, 66, 0.10);
|
--phase-post16-bg: rgba(242, 201, 76, 0.26);
|
||||||
--phase-post16-text: #645036;
|
--phase-post16-text: #6E5500;
|
||||||
--phase-nursery: #7a5560;
|
--phase-nursery: #5F4FA8;
|
||||||
--phase-nursery-bg: rgba(122, 85, 96, 0.10);
|
--phase-nursery-bg: rgba(221, 216, 245, 0.55);
|
||||||
--phase-nursery-text: #64454f;
|
--phase-nursery-text: #544396;
|
||||||
|
|
||||||
/* ── Sunken surface: the footer, and anything meant to read as a dark
|
/* ── Sunken surface: the footer, and anything meant to read as a deep
|
||||||
anchor band. Deliberately NOT --surface-inverse: "inverse" flips
|
teal anchor band. Stays teal in both themes on purpose — it is the
|
||||||
with the theme, which turned the footer into a light slab at the
|
"brand in action" card from the guideline. ─────────────────── */
|
||||||
bottom of a dark page. This stays dark in both. ──────────────── */
|
--surface-sunken: #0C5F58;
|
||||||
--surface-sunken: #16202a;
|
--on-sunken: #FFFFFF;
|
||||||
--on-sunken: #e4e2dc;
|
--on-sunken-muted: #C9E4DB;
|
||||||
--on-sunken-muted: #b3b8bd;
|
--on-sunken-faint: #A9D6CA;
|
||||||
--on-sunken-faint: #7f878f;
|
--on-sunken-link: #C7EBF5;
|
||||||
--on-sunken-link: #b9aeef;
|
|
||||||
|
|
||||||
/* ── Medals: rankings podium. Metal, not palette — kept legible on
|
/* ── Medals: rankings podium ────────────────────────────────────── */
|
||||||
both grounds rather than literally gold/silver/bronze. ───────── */
|
--medal-gold: #806200;
|
||||||
--medal-gold: #a67c00;
|
--medal-silver: #6B7580;
|
||||||
--medal-silver: #6f7580;
|
--medal-bronze: #8A5A2B;
|
||||||
--medal-bronze: #8a5a2b;
|
|
||||||
|
|
||||||
/* ── Channels ───────────────────────────────────────────────────────
|
/* ── Channels ───────────────────────────────────────────────────────
|
||||||
CSS can't interpolate a hex token into rgba(), so any tint that needs
|
CSS can't interpolate a hex token into rgba(), so any tint that needs
|
||||||
an arbitrary alpha reads the raw channels from here. Keep these in
|
an arbitrary alpha reads the raw channels from here. Keep these in
|
||||||
sync with the hex tokens above — they are the same colours. */
|
sync with the hex tokens above — they are the same colours. */
|
||||||
--brand-rgb: 88, 74, 155;
|
--brand-rgb: 15, 118, 110;
|
||||||
--status-above-rgb: 11, 98, 90;
|
--action-rgb: 190, 60, 39;
|
||||||
--status-below-rgb: 127, 74, 0;
|
--coral-rgb: 249, 115, 96;
|
||||||
--ink-rgb: 22, 32, 42;
|
--sage-rgb: 167, 215, 197;
|
||||||
--text-inverse-rgb: 247, 247, 244;
|
--status-above-rgb: 54, 116, 63;
|
||||||
--shadow-rgb: 22, 32, 42;
|
--status-below-rgb: 169, 72, 31;
|
||||||
--muted-rgb: 92, 101, 112;
|
--ink-rgb: 28, 39, 49;
|
||||||
--phase-secondary-rgb: 95, 84, 128;
|
--text-inverse-rgb: 255, 255, 255;
|
||||||
--medal-silver-rgb: 111, 117, 128;
|
--shadow-rgb: 28, 39, 49;
|
||||||
|
--muted-rgb: 95, 106, 117;
|
||||||
|
--phase-secondary-rgb: 47, 111, 143;
|
||||||
|
--medal-silver-rgb: 107, 117, 128;
|
||||||
--medal-bronze-rgb: 138, 90, 43;
|
--medal-bronze-rgb: 138, 90, 43;
|
||||||
|
|
||||||
/* ── Elevation ──────────────────────────────────────────────────── */
|
/* ── Elevation ──────────────────────────────────────────────────── */
|
||||||
--shadow-soft: 0 1px 2px rgba(22, 32, 42, 0.04), 0 2px 8px rgba(22, 32, 42, 0.05);
|
--shadow-soft: 0 1px 2px rgba(28, 39, 49, 0.04), 0 2px 8px rgba(28, 39, 49, 0.05);
|
||||||
--shadow-medium: 0 4px 20px rgba(22, 32, 42, 0.09);
|
--shadow-medium: 0 4px 18px rgba(28, 39, 49, 0.09);
|
||||||
--shadow-strong: 0 8px 40px rgba(22, 32, 42, 0.14);
|
--shadow-strong: 0 10px 34px rgba(28, 39, 49, 0.13);
|
||||||
--scrim: rgba(22, 32, 42, 0.55);
|
--scrim: rgba(28, 39, 49, 0.5);
|
||||||
|
|
||||||
/* ── Type ───────────────────────────────────────────────────────── */
|
/* ── Type ───────────────────────────────────────────────────────────
|
||||||
/* next/font already expands --font-schibsted to the family plus its
|
Manrope for headings and key messaging; Inter for body copy, every
|
||||||
metric-matched fallback, so naming the family again here only made the
|
control and every figure. next/font expands each variable to the
|
||||||
stack say it twice. It read like a safety net but wasn't one: a var()
|
family plus its metric-matched fallback, and the classes sit on
|
||||||
with no fallback that resolves to nothing invalidates the whole
|
<html> so :root can see them — see app/layout.tsx. */
|
||||||
declaration, so the literal after it never gets a turn. The real
|
--font-display: var(--font-manrope), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
safeguard is that these classes sit on <html>, where :root can see
|
--font-ui: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
them — see app/layout.tsx. */
|
--font-data: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
--font-display: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif;
|
--font-prose: var(--font-inter), -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
--font-ui: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
--font-data: var(--font-schibsted), -apple-system, BlinkMacSystemFont, sans-serif;
|
|
||||||
--font-prose: var(--font-literata), Georgia, serif;
|
|
||||||
|
|
||||||
/* Type scale, 1.2 ratio off a 1rem base. New work should use these
|
/* Type scale, straight from the guideline: H1 40/48, H2 32/40, H3 24/32,
|
||||||
rather than inventing another font-size. */
|
body large 16/24, body 14/20, small 12/16. */
|
||||||
--step--2: 0.694rem;
|
--step--2: 0.75rem;
|
||||||
--step--1: 0.833rem;
|
--step--1: 0.875rem;
|
||||||
--step-0: 1rem;
|
--step-0: 1rem;
|
||||||
--step-1: 1.2rem;
|
--step-1: 1.125rem;
|
||||||
--step-2: 1.44rem;
|
--step-2: 1.5rem;
|
||||||
--step-3: 1.728rem;
|
--step-3: 2rem;
|
||||||
--step-4: 2.074rem;
|
--step-4: 2.5rem;
|
||||||
--step-5: 2.488rem;
|
--step-5: 3rem;
|
||||||
|
|
||||||
/* ── Geometry & motion ──────────────────────────────────────────── */
|
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||||
--radius-sm: 3px;
|
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||||
--radius-md: 6px;
|
rounder than the old system's 3/6/10/16. */
|
||||||
--radius-lg: 10px;
|
--radius-sm: 8px;
|
||||||
--radius-xl: 16px;
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 22px;
|
||||||
|
|
||||||
--transition: 0.2s ease;
|
--transition: 0.2s ease;
|
||||||
--transition-slow: 0.4s ease;
|
--transition-slow: 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Dark theme. Not an inversion — the accent and both status hues are lifted
|
* Dark theme. Not in the guideline, which is light-only — but the site already
|
||||||
* independently so each still clears AA on the dark ground, and --brand-on
|
* ships one, so removing it would be a visible regression. Built from the same
|
||||||
* flips to dark because the brand fill is now the lighter of the pair.
|
* tokens: teal and coral are lifted independently so each still clears AA on
|
||||||
|
* the dark ground, and the ground itself keeps the Ink hue rather than going
|
||||||
|
* to black.
|
||||||
*
|
*
|
||||||
* Tokens only. Never style a component from inside this block, or the two
|
* Tokens only. Never style a component from inside this block, or the two
|
||||||
* themes drift apart the first time someone edits one of them.
|
* themes drift apart the first time someone edits one of them.
|
||||||
@@ -176,90 +196,108 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--bg-primary: #10151b;
|
--bg-primary: #111A20;
|
||||||
--bg-secondary: #161c24;
|
--bg-secondary: #16222A;
|
||||||
--bg-card: #171d25;
|
--bg-card: #18242C;
|
||||||
--surface-inverse: #e4e9ee;
|
--surface-inverse: #E9EEF0;
|
||||||
|
|
||||||
--text-primary: #e4e9ee;
|
--text-primary: #E9EEF0;
|
||||||
--text-secondary: #b4bec8;
|
--text-secondary: #B4C2C7;
|
||||||
--text-muted: #8d99a6; /* 6.4:1 on --bg-primary */
|
--text-muted: #8B9AA1; /* 5.5:1 on the card */
|
||||||
--text-inverse: #10151b;
|
--text-inverse: #111A20;
|
||||||
|
|
||||||
--border: #28313b;
|
--border: #26343D;
|
||||||
--border-strong: #38434f;
|
--border-strong: #35454F;
|
||||||
|
|
||||||
--brand: #9c8ce8; /* 6.5:1 on --bg-primary */
|
--brand: #5FC7BB;
|
||||||
--brand-strong: #b0a3ee;
|
--brand-strong: #7BD6CC;
|
||||||
--brand-stronger: #c2b8f3;
|
--brand-stronger: #9BE3DB;
|
||||||
--brand-bg: rgba(156, 140, 232, 0.14);
|
--brand-bg: rgba(95, 199, 187, 0.14);
|
||||||
--brand-on: #10151b;
|
--brand-on: #0A1418;
|
||||||
|
|
||||||
--status-above: #4fc0b0; /* 8.4:1 on --bg-primary, 4.8:1 on its own tint */
|
--action: #F08A72;
|
||||||
--status-above-bg: rgba(79, 192, 176, 0.14);
|
--action-strong: #F5A492;
|
||||||
--status-below: #d99b2e; /* 7.7:1 */
|
--action-stronger: #F8BFB1;
|
||||||
--status-below-bg: rgba(217, 155, 46, 0.14);
|
--action-bg: rgba(240, 138, 114, 0.16);
|
||||||
--status-at-bg: rgba(141, 153, 166, 0.14);
|
--action-on: #241009;
|
||||||
|
|
||||||
--chart-1: #9c8ce8;
|
--coral: #F08A72;
|
||||||
--chart-2: #4fc0b0;
|
--mustard: #F2C94C;
|
||||||
--chart-3: #d99b2e;
|
--mustard-ink: #EFC658;
|
||||||
--chart-4: #6f5fc4;
|
--sage: #8FCBB7;
|
||||||
--chart-5: #2b8a7e;
|
--sky: #9CD4E6;
|
||||||
--chart-6: #a97420;
|
--lavender: #C2BBEA;
|
||||||
--chart-grid: #28313b;
|
--sand: #1B2730;
|
||||||
--chart-reference: #8d99a6;
|
|
||||||
|
|
||||||
--series-1: #9c8ce8;
|
--status-above: #7FCB8A;
|
||||||
--series-2: #4fc0b0;
|
--status-above-bg: rgba(127, 203, 138, 0.14);
|
||||||
--series-3: #d99b2e;
|
--status-below: #EFA184;
|
||||||
--series-4: #e07a9d;
|
--status-below-bg: rgba(239, 161, 132, 0.14);
|
||||||
--series-5: #6fa8dc;
|
--status-at: #8B9AA1;
|
||||||
--series-6: #94c46b;
|
--status-at-bg: rgba(139, 154, 161, 0.14);
|
||||||
--series-7: #c48bc4;
|
|
||||||
--series-8: #e08a6b;
|
|
||||||
|
|
||||||
--phase-primary: #8aa4b8;
|
--chart-1: #5FC7BB;
|
||||||
--phase-primary-bg: rgba(138, 164, 184, 0.14);
|
--chart-2: #7FCB8A;
|
||||||
--phase-primary-text: #a3bacd;
|
--chart-3: #EFC658;
|
||||||
--phase-secondary: #a294c4;
|
--chart-4: #EFA184;
|
||||||
--phase-secondary-bg: rgba(162, 148, 196, 0.14);
|
--chart-5: #8FB4D9;
|
||||||
--phase-secondary-text: #b7abd6;
|
--chart-6: #B6A9DD;
|
||||||
--phase-all-through: #8bb098;
|
--chart-grid: #26343D;
|
||||||
--phase-all-through-bg: rgba(139, 176, 152, 0.14);
|
--chart-reference: #8B9AA1;
|
||||||
--phase-all-through-text: #a2c2ad;
|
|
||||||
--phase-post16: #bfa27c;
|
|
||||||
--phase-post16-bg: rgba(191, 162, 124, 0.14);
|
|
||||||
--phase-post16-text: #d0b795;
|
|
||||||
--phase-nursery: #bf98a3;
|
|
||||||
--phase-nursery-bg: rgba(191, 152, 163, 0.14);
|
|
||||||
--phase-nursery-text: #d1aeb8;
|
|
||||||
|
|
||||||
--surface-sunken: #0a0e13;
|
--series-1: #5FC7BB;
|
||||||
--on-sunken: #d8dde2;
|
--series-2: #7FCB8A;
|
||||||
--on-sunken-muted: #9aa4ae;
|
--series-3: #EFC658;
|
||||||
--on-sunken-faint: #6d777f;
|
--series-4: #EFA184;
|
||||||
--on-sunken-link: #b0a3ee;
|
--series-5: #8FB4D9;
|
||||||
|
--series-6: #B6A9DD;
|
||||||
|
--series-7: #6FD0DC;
|
||||||
|
--series-8: #D99BB8;
|
||||||
|
|
||||||
--medal-gold: #d4a72c;
|
--phase-primary: #5FC7BB;
|
||||||
--medal-silver: #a8b0bb;
|
--phase-primary-bg: rgba(95, 199, 187, 0.16);
|
||||||
--medal-bronze: #c08552;
|
--phase-primary-text: #8ADACF;
|
||||||
|
--phase-secondary: #8FB4D9;
|
||||||
|
--phase-secondary-bg: rgba(143, 180, 217, 0.16);
|
||||||
|
--phase-secondary-text: #AAC8E4;
|
||||||
|
--phase-all-through: #7FCB8A;
|
||||||
|
--phase-all-through-bg: rgba(127, 203, 138, 0.16);
|
||||||
|
--phase-all-through-text: #9AD8A3;
|
||||||
|
--phase-post16: #EFC658;
|
||||||
|
--phase-post16-bg: rgba(242, 201, 76, 0.16);
|
||||||
|
--phase-post16-text: #F2D179;
|
||||||
|
--phase-nursery: #B6A9DD;
|
||||||
|
--phase-nursery-bg: rgba(182, 169, 221, 0.16);
|
||||||
|
--phase-nursery-text: #C8BEE6;
|
||||||
|
|
||||||
--brand-rgb: 156, 140, 232;
|
--surface-sunken: #0C5F58;
|
||||||
--status-above-rgb: 79, 192, 176;
|
--on-sunken: #FFFFFF;
|
||||||
--status-below-rgb: 217, 155, 46;
|
--on-sunken-muted: #C9E4DB;
|
||||||
--ink-rgb: 228, 233, 238;
|
--on-sunken-faint: #A9D6CA;
|
||||||
--text-inverse-rgb: 16, 21, 27;
|
--on-sunken-link: #C7EBF5;
|
||||||
|
|
||||||
|
--medal-gold: #EFC658;
|
||||||
|
--medal-silver: #A9B6BC;
|
||||||
|
--medal-bronze: #C99070;
|
||||||
|
|
||||||
|
--brand-rgb: 95, 199, 187;
|
||||||
|
--action-rgb: 240, 138, 114;
|
||||||
|
--coral-rgb: 240, 138, 114;
|
||||||
|
--sage-rgb: 143, 203, 183;
|
||||||
|
--status-above-rgb: 127, 203, 138;
|
||||||
|
--status-below-rgb: 239, 161, 132;
|
||||||
|
--ink-rgb: 233, 238, 240;
|
||||||
|
--text-inverse-rgb: 17, 26, 32;
|
||||||
--shadow-rgb: 0, 0, 0;
|
--shadow-rgb: 0, 0, 0;
|
||||||
--muted-rgb: 141, 153, 166;
|
--muted-rgb: 139, 154, 161;
|
||||||
--phase-secondary-rgb: 162, 148, 196;
|
--phase-secondary-rgb: 143, 180, 217;
|
||||||
--medal-silver-rgb: 168, 176, 187;
|
--medal-silver-rgb: 169, 182, 188;
|
||||||
--medal-bronze-rgb: 192, 133, 82;
|
--medal-bronze-rgb: 201, 144, 112;
|
||||||
|
|
||||||
--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 20px rgba(0, 0, 0, 0.45);
|
--shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||||
--shadow-strong: 0 8px 40px rgba(0, 0, 0, 0.6);
|
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
||||||
--scrim: rgba(4, 7, 10, 0.7);
|
--scrim: rgba(4, 8, 10, 0.65);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +440,30 @@ table,
|
|||||||
border-color: var(--brand-stronger);
|
border-color: var(--brand-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Action: coral fill — the one decisive action on a page, and only that.
|
||||||
|
*
|
||||||
|
* The guideline gives coral to "calls to action, highlights, important
|
||||||
|
* buttons" while its own UI-components sheet draws the primary button in
|
||||||
|
* teal. Both are honoured by making this a scarce, separate class: teal is
|
||||||
|
* the default primary, coral marks the single action a page exists for —
|
||||||
|
* the search submit. Use it more than once per screen and it stops meaning
|
||||||
|
* anything.
|
||||||
|
*/
|
||||||
|
.btn-action {
|
||||||
|
background: var(--action);
|
||||||
|
color: var(--action-on);
|
||||||
|
border-color: var(--action);
|
||||||
|
}
|
||||||
|
.btn-action:hover:not(:disabled) {
|
||||||
|
background: var(--action-strong);
|
||||||
|
border-color: var(--action-strong);
|
||||||
|
}
|
||||||
|
.btn-action:active:not(:disabled) {
|
||||||
|
background: var(--action-stronger);
|
||||||
|
border-color: var(--action-stronger);
|
||||||
|
}
|
||||||
|
|
||||||
/* Secondary: brand outline — supporting actions (Add to shortlist) */
|
/* Secondary: brand outline — supporting actions (Add to shortlist) */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="2" y="26" width="6" height="12" rx="1" fill="#16202a" fill-opacity="0.22"/>
|
<path d="M17 30 C15 35.6 11.6 40.2 7.8 42.8" stroke="#0F766E" stroke-width="8.5" stroke-linecap="round" fill="none"/>
|
||||||
<rect x="10" y="16" width="6" height="22" rx="1" fill="#16202a" fill-opacity="0.38"/>
|
<circle cx="26.5" cy="18.5" r="16.5" fill="#0F766E"/>
|
||||||
<rect x="18" y="8" width="6" height="30" rx="1" fill="#16202a" fill-opacity="0.55"/>
|
<path d="M26.5 29.5 C26.5 25 26.6 21 27 18" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" fill="none"/>
|
||||||
<rect x="26" y="14" width="6" height="24" rx="1" fill="#584a9b"/>
|
<path d="M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z" fill="#FFFFFF"/>
|
||||||
<rect x="34" y="28" width="4" height="10" rx="1" fill="#16202a" fill-opacity="0.22"/>
|
<path d="M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z" fill="#FFFFFF"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 521 B After Width: | Height: | Size: 594 B |
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Schibsted_Grotesk, Literata } from 'next/font/google';
|
import { Manrope, Inter } from 'next/font/google';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import { Navigation } from '@/components/Navigation';
|
import { Navigation } from '@/components/Navigation';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
@@ -7,23 +7,22 @@ import { ComparisonToast } from '@/components/ComparisonToast';
|
|||||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
// Schibsted Grotesk carries the whole interface — headings, labels, and every
|
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||||
// figure. It was drawn for news brands, so its tabular numerals hold a column
|
// modern personality". It never sets body copy or a control.
|
||||||
// properly, which matters on a page that is mostly percentages.
|
const manrope = Manrope({
|
||||||
const schibsted = Schibsted_Grotesk({
|
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '500', '600', '700'],
|
weight: ['500', '600', '700'],
|
||||||
variable: '--font-schibsted',
|
variable: '--font-manrope',
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Literata is for prose only — the admissions guide, methodology notes, the
|
// Inter carries body copy, every control and every figure — chosen in the
|
||||||
// editorial sections. It never touches a number or a control.
|
// guideline for clarity and legibility. Its tabular numerals hold a column
|
||||||
const literata = Literata({
|
// properly, which matters on a page that is mostly percentages.
|
||||||
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
weight: ['400', '600'],
|
weight: ['400', '500', '600', '700'],
|
||||||
style: ['normal', 'italic'],
|
variable: '--font-inter',
|
||||||
variable: '--font-literata',
|
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,8 +35,8 @@ export const viewport: Viewport = {
|
|||||||
// These must match --bg-primary in globals.css for each theme, or the
|
// These must match --bg-primary in globals.css for each theme, or the
|
||||||
// browser chrome and the page disagree at the top edge of the screen.
|
// browser chrome and the page disagree at the top edge of the screen.
|
||||||
themeColor: [
|
themeColor: [
|
||||||
{ media: '(prefers-color-scheme: light)', color: '#f2f1ed' },
|
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
|
||||||
{ media: '(prefers-color-scheme: dark)', color: '#10151b' },
|
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ export default function RootLayout({
|
|||||||
// at :root, so --font-display computed to the guaranteed-invalid value and
|
// at :root, so --font-display computed to the guaranteed-invalid value and
|
||||||
// every font-family that referenced it silently fell back — the whole site
|
// every font-family that referenced it silently fell back — the whole site
|
||||||
// rendered in Times.
|
// rendered in Times.
|
||||||
<html lang="en" className={`${schibsted.variable} ${literata.variable}`}>
|
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||||
<link rel="preconnect" href="https://api.postcodes.io" />
|
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||||
|
|||||||
@@ -1,32 +1,28 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { ImageResponse } from 'next/og';
|
import { ImageResponse } from 'next/og';
|
||||||
import { BARS } from '@/components/Logo';
|
import { markDataUri } from '@/components/Logo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The site had no og:image at all, so every link pasted into a class WhatsApp
|
* The site had no og:image at all, so every link pasted into a class WhatsApp
|
||||||
* group or a Slack channel rendered as a bare grey text card — on the highest
|
* group or a Slack channel rendered as a bare grey text card — on the highest
|
||||||
* intent channel this product has.
|
* intent channel this product has.
|
||||||
*
|
*
|
||||||
* The card is the mark at poster scale: the cohort as a wide distribution with
|
* The card is the brand's own proposition on the Sand ground, with the mark
|
||||||
* one school lit up. It states the proposition rather than decorating it.
|
* and a suggestion of the illustration's rolling landscape along the bottom.
|
||||||
|
* It states the promise rather than decorating it.
|
||||||
*/
|
*/
|
||||||
export const alt = 'SchoolCompare — compare every school in England against the national picture';
|
export const alt = 'schoolcompare — find the school where your child will flourish';
|
||||||
export const size = { width: 1200, height: 630 };
|
export const size = { width: 1200, height: 630 };
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
const PAPER = '#f2f1ed';
|
const SAND = '#F5EFE6';
|
||||||
const INK = '#16202a';
|
const INK = '#1C2731';
|
||||||
const MUTED = '#5c6570';
|
const MUTED = '#5F6A75';
|
||||||
const BRAND = '#584a9b';
|
const TEAL = '#0F766E';
|
||||||
|
const SAGE = '#A7D7C5';
|
||||||
// A plausible national distribution, drawn once at poster scale. The lit bar
|
const SAGE_DEEP = '#7FC3AC';
|
||||||
// is the same position the mark uses.
|
const SAGE_PALE = '#D6EDE2';
|
||||||
const SPREAD = [8, 12, 17, 24, 33, 44, 57, 70, 82, 92, 98, 100, 96, 88, 77, 65, 53, 41, 31, 22, 15, 10, 7, 5];
|
|
||||||
// Right of the peak: score runs left to right, so the lit school reads as
|
|
||||||
// above the national average. At index 9 it sat on the low side, which quietly
|
|
||||||
// said the opposite.
|
|
||||||
const LIT = 14;
|
|
||||||
|
|
||||||
async function font(file: string) {
|
async function font(file: string) {
|
||||||
return readFile(join(process.cwd(), 'assets', file));
|
return readFile(join(process.cwd(), 'assets', file));
|
||||||
@@ -34,8 +30,8 @@ async function font(file: string) {
|
|||||||
|
|
||||||
export default async function OpengraphImage() {
|
export default async function OpengraphImage() {
|
||||||
const [regular, bold] = await Promise.all([
|
const [regular, bold] = await Promise.all([
|
||||||
font('SchibstedGrotesk-Regular.ttf'),
|
font('Manrope-Regular.ttf'),
|
||||||
font('SchibstedGrotesk-Bold.ttf'),
|
font('Manrope-Bold.ttf'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
@@ -47,85 +43,62 @@ export default async function OpengraphImage() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
background: PAPER,
|
background: SAND,
|
||||||
padding: '58px 72px 52px',
|
padding: '58px 72px 0',
|
||||||
fontFamily: 'Schibsted',
|
fontFamily: 'Manrope',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Rolling hills along the bottom edge — the illustration style,
|
||||||
|
reduced to the one gesture that survives at thumbnail size. */}
|
||||||
|
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
|
||||||
|
<svg width="1200" height="210" viewBox="0 0 1200 210">
|
||||||
|
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
|
||||||
|
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
|
||||||
|
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Lockup */}
|
{/* Lockup */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
<div style={{ position: 'relative', width: 40, height: 40, display: 'flex' }}>
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
<img src={markDataUri(TEAL, SAND)} width={52} height={52} alt="" />
|
||||||
<div
|
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
||||||
key={x}
|
<span style={{ color: INK }}>school</span>
|
||||||
style={{
|
<span style={{ color: TEAL }}>compare</span>
|
||||||
position: 'absolute',
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
background: isSchool ? BRAND : INK,
|
|
||||||
opacity: isSchool ? 1 : [0.22, 0.38, 0.55, 1, 0.22][i],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', fontSize: 29, fontWeight: 700, letterSpacing: '-0.028em' }}>
|
|
||||||
<span style={{ color: INK }}>School</span>
|
|
||||||
<span style={{ color: BRAND }}>Compare</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Proposition. Sized so the headline holds two lines and the subhead
|
{/* Proposition. Sized so the headline holds two lines and the subhead
|
||||||
one — an orphaned word reads as an accident at this size. */}
|
one — an orphaned word reads as an accident at this size. */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
fontSize: 66,
|
flexWrap: 'wrap',
|
||||||
|
fontSize: 72,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '-0.036em',
|
letterSpacing: '-0.035em',
|
||||||
lineHeight: 1.03,
|
lineHeight: 1.05,
|
||||||
color: INK,
|
color: INK,
|
||||||
maxWidth: 1010,
|
maxWidth: 900,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
A score means nothing until you see the spread.
|
<span style={{ marginRight: 18 }}>Find the school where</span>
|
||||||
|
<span style={{ marginRight: 18 }}>your child will</span>
|
||||||
|
<span style={{ color: TEAL }}>flourish.</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', fontSize: 26, color: MUTED, letterSpacing: '-0.005em' }}>
|
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||||
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The device, at poster scale. Same object as the mark above. */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 7, height: 120 }}>
|
|
||||||
{SPREAD.map((h, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: `${h}%`,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: i === LIT ? BRAND : INK,
|
|
||||||
opacity: i === LIT ? 1 : 0.15,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 18, color: MUTED, letterSpacing: '0.09em' }}>
|
|
||||||
<span>NATIONAL SPREAD</span>
|
|
||||||
<span style={{ letterSpacing: '-0.005em' }}>schoolcompare.co.uk</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
...size,
|
...size,
|
||||||
fonts: [
|
fonts: [
|
||||||
{ name: 'Schibsted', data: regular, weight: 400, style: 'normal' },
|
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
|
||||||
{ name: 'Schibsted', data: bold, weight: 700, style: 'normal' },
|
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* FilterBar — one component, two shapes.
|
||||||
|
*
|
||||||
|
* hero a single white pill on the Sand hero panel, carrying the one
|
||||||
|
* coral action the page exists for. No card of its own: the panel
|
||||||
|
* behind it belongs to the page, so the pill reads as the object.
|
||||||
|
* compact the filter rail above the results — a white card of quiet
|
||||||
|
* controls, brand for what is active, sage for what is selected.
|
||||||
|
*
|
||||||
|
* Colour comes from the token layer only; the disclosure arrows are drawn
|
||||||
|
* from currentColor rather than a background image so they follow the theme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Shell ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.filterBar {
|
.filterBar {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1rem 1.125rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
|
box-shadow: var(--shadow-soft);
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterBar.isLoading {
|
.filterBar.isLoading {
|
||||||
@@ -14,112 +29,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heroMode {
|
.heroMode {
|
||||||
padding: 2.5rem;
|
background: none;
|
||||||
max-width: 800px;
|
border: 0;
|
||||||
margin: 0 auto 3rem auto;
|
border-radius: 0;
|
||||||
box-shadow: 0 8px 24px rgba(var(--shadow-rgb), 0.08);
|
box-shadow: none;
|
||||||
border-width: 2px;
|
padding: 0;
|
||||||
border-color: var(--brand);
|
max-width: 640px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroMode .omniInput {
|
/* ── Search ───────────────────────────────────────────────────────── */
|
||||||
font-size: 1.25rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroMode .searchButton {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
padding: 1.25rem 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heroMode .searchSection {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchHint {
|
|
||||||
margin: 0.875rem 0 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchHint strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.searchHint {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeRow {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeBtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem 1.375rem;
|
|
||||||
background: var(--brand);
|
|
||||||
color: var(--brand-on);
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s ease, transform 0.15s ease;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeBtn:hover:not(:disabled) {
|
|
||||||
background: var(--status-above);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeBtn:disabled {
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nearMeSpinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
|
||||||
border-top-color: var(--brand-on);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: nearMeSpin 0.7s linear infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes nearMeSpin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.geoError {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--brand-strong);
|
|
||||||
margin: 0;
|
|
||||||
max-width: 340px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.nearMeBtn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchSection {
|
.searchSection {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -127,43 +46,90 @@
|
|||||||
|
|
||||||
.omniBoxContainer {
|
.omniBoxContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The hero pill: hairline, soft corner, everything else sits inside it. */
|
||||||
|
.heroMode .omniBoxContainer {
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.3125rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMode .omniBoxContainer:focus-within {
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.omniIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-left: 0.625rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.omniInput {
|
.omniInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.875rem 1.25rem;
|
min-width: 0;
|
||||||
font-size: 1.05rem;
|
padding: 0.6875rem 0.875rem;
|
||||||
border: 2px solid var(--border);
|
font-family: var(--font-ui);
|
||||||
border-radius: 8px;
|
/* 1rem, not smaller: iOS Safari zooms the viewport on any control under
|
||||||
outline: none;
|
16px, which throws the user out of the hero on first tap. */
|
||||||
transition: all 0.2s ease;
|
font-size: var(--step-0);
|
||||||
|
color: var(--text-primary);
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
font-family: inherit;
|
border: 1px solid var(--border);
|
||||||
}
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
.omniInput:focus {
|
transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
border-color: var(--brand);
|
|
||||||
box-shadow: 0 0 0 3px var(--brand-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.omniInput::placeholder {
|
.omniInput::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.omniInput:focus {
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inside the pill the input carries no chrome — the pill holds the border
|
||||||
|
and the focus ring, so the two never double up. */
|
||||||
|
.heroMode .omniInput {
|
||||||
|
background: none;
|
||||||
|
border-color: transparent;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMode .omniInput:focus {
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.searchButton {
|
.searchButton {
|
||||||
padding: 0.875rem 2rem;
|
flex: 0 0 auto;
|
||||||
font-size: 1.05rem;
|
padding: 0.6875rem 1.25rem;
|
||||||
border-radius: 8px;
|
font-size: var(--step--1);
|
||||||
min-width: 120px;
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMode .searchButton {
|
||||||
|
padding: 0.75rem 1.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 20px;
|
width: 1.0625rem;
|
||||||
height: 20px;
|
height: 1.0625rem;
|
||||||
border: 2px solid rgba(var(--text-inverse-rgb), 0.3);
|
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
|
||||||
|
border-top-color: currentColor;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: var(--brand-on);
|
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,103 +137,86 @@
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
/* ── Hero supporting copy ─────────────────────────────────────────── */
|
||||||
display: flex;
|
|
||||||
gap: 0.625rem;
|
.searchHint {
|
||||||
flex-wrap: wrap;
|
margin: 0.75rem 0 0;
|
||||||
margin-top: 0.625rem;
|
font-family: var(--font-ui);
|
||||||
padding-top: 0.625rem;
|
font-size: var(--step--1);
|
||||||
border-top: 1px solid var(--border);
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterSelect {
|
.searchHint strong {
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 180px;
|
|
||||||
padding: 0.625rem 2.25rem 0.625rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
border: 1.5px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 0.875rem center;
|
|
||||||
background-size: 10px 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterSelect:hover {
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterSelect:focus {
|
|
||||||
border-color: var(--brand);
|
|
||||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clearButton {
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.filterBar {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.omniBoxContainer {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchButton {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filterSelect {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlsRow {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlsRow .advancedToggle {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlSelect {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.radiusWrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radiusLabel {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Controls row (radius + phase + advanced toggle) ─── */
|
.nearMeRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quiet text link, not a second button: the coral submit is the decisive
|
||||||
|
action and nothing beside it may compete for the same weight. */
|
||||||
|
.nearMeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.375rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--brand);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:hover:not(:disabled) {
|
||||||
|
color: var(--brand-strong);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeSpinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border: 2px solid rgba(var(--brand-rgb), 0.3);
|
||||||
|
border-top-color: var(--brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: nearMeSpin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nearMeSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.geoError {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 340px;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--brand-strong);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
|
||||||
|
|
||||||
.controlsRow {
|
.controlsRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -289,65 +238,139 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pill-style inline filter controls (radius + phase) */
|
.radiusLabel {
|
||||||
.controlSelect {
|
font-size: var(--step--2);
|
||||||
appearance: none;
|
font-weight: 600;
|
||||||
-webkit-appearance: none;
|
color: var(--text-secondary);
|
||||||
padding: 0.4rem 2rem 0.4rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
border: 1.5px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 0.65rem center;
|
|
||||||
background-size: 10px 6px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlSelect:hover {
|
/* ── Selects ──────────────────────────────────────────────────────── */
|
||||||
border-color: var(--text-muted);
|
|
||||||
|
.selectShell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectShellWide {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectShell select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawn, not imaged: a background-image arrow has to hardcode its own
|
||||||
|
colour, and would stay light-mode grey on the dark ground. */
|
||||||
|
.selectChevron {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-top: -2px;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 5px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSelect,
|
||||||
|
.controlSelect {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition), background-color var(--transition),
|
||||||
|
box-shadow var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSelect {
|
||||||
|
padding: 0.5625rem 2rem 0.5625rem 0.75rem;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlSelect {
|
||||||
|
padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterSelect:hover:not(:disabled),
|
||||||
|
.controlSelect:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-strong);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filterSelect:focus,
|
||||||
.controlSelect:focus {
|
.controlSelect:focus {
|
||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.12);
|
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Advanced filters toggle ─────────────────────────── */
|
/* Selected — i.e. this control is narrowing the list. Sage tint, brand
|
||||||
|
line: the same pairing the results use for "on". */
|
||||||
|
.selectActive {
|
||||||
|
background-color: rgba(var(--sage-rgb), 0.38);
|
||||||
|
border-color: var(--brand);
|
||||||
|
color: var(--brand-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectActive:hover:not(:disabled) {
|
||||||
|
background-color: rgba(var(--sage-rgb), 0.5);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Advanced filters toggle ──────────────────────────────────────── */
|
||||||
|
|
||||||
.advancedToggle {
|
.advancedToggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1.5px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.4rem 0.875rem;
|
font-family: var(--font-ui);
|
||||||
font-size: 0.8125rem;
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
transition: border-color var(--transition), background-color var(--transition),
|
||||||
|
color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.advancedToggle:hover {
|
.advancedToggle:hover {
|
||||||
border-color: var(--text-muted);
|
border-color: var(--border-strong);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When filters are applied, promote the toggle to a coral pill so users
|
/* Applied filters read as a brand chip, so it's obvious at a glance that
|
||||||
can see at a glance that the result list is being narrowed. */
|
the list below is being narrowed. */
|
||||||
.advancedToggleActive {
|
.advancedToggleActive {
|
||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
background: var(--brand-bg);
|
background: var(--brand-bg);
|
||||||
@@ -356,11 +379,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.advancedToggleActive:hover {
|
.advancedToggleActive:hover {
|
||||||
border-color: var(--brand-strong);
|
border-color: var(--brand);
|
||||||
background: var(--brand-bg);
|
background: var(--brand-bg);
|
||||||
color: var(--brand-strong);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
padding: 0.4375rem 0.875rem;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.chevronDown,
|
.chevronDown,
|
||||||
.chevronUp {
|
.chevronUp {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -377,3 +407,70 @@
|
|||||||
.chevronUp {
|
.chevronUp {
|
||||||
border-bottom: 4.5px solid currentColor;
|
border-bottom: 4.5px solid currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filterBar {
|
||||||
|
padding: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero pill stays a pill at every width — stacking it would lose the
|
||||||
|
one shape the page is built around. Only the results bar stacks. */
|
||||||
|
.filterBar:not(.heroMode) .omniBoxContainer {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBar:not(.heroMode) .searchButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectShellWide {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsRow {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsRow .advancedToggle {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlSelect {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchHint {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeRow {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geoError {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
/* Below this the pin costs more room than it earns. */
|
||||||
|
.heroMode .omniIcon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMode .omniInput {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroMode .searchButton {
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { isValidPostcode } from "@/lib/utils";
|
import { isValidPostcode } from "@/lib/utils";
|
||||||
import { track } from "@/lib/analytics";
|
import { track } from "@/lib/analytics";
|
||||||
@@ -18,6 +19,28 @@ interface FilterBarProps {
|
|||||||
geoError?: string | null;
|
geoError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a native <select> so the disclosure arrow can be drawn in CSS from
|
||||||
|
* currentColor rather than baked into a background image — a hardcoded arrow
|
||||||
|
* colour is the one thing in a select that can't follow the theme.
|
||||||
|
*/
|
||||||
|
function SelectShell({
|
||||||
|
children,
|
||||||
|
wide,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${styles.selectShell}${wide ? ` ${styles.selectShellWide}` : ""}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span className={styles.selectChevron} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FilterBar({
|
export function FilterBar({
|
||||||
filters,
|
filters,
|
||||||
isHero,
|
isHero,
|
||||||
@@ -172,12 +195,33 @@ export function FilterBar({
|
|||||||
const isSecondaryMode =
|
const isSecondaryMode =
|
||||||
currentPhase === "secondary" || genderOptions.length > 0;
|
currentPhase === "secondary" || genderOptions.length > 0;
|
||||||
|
|
||||||
|
// A select that is narrowing the results carries a sage tint; the class is
|
||||||
|
// only ever additive, so the control's behaviour is untouched.
|
||||||
|
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
|
||||||
<div className={styles.omniBoxContainer}>
|
<div className={styles.omniBoxContainer}>
|
||||||
|
{isHero && (
|
||||||
|
<span className={styles.omniIcon} aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z" />
|
||||||
|
<circle cx="12" cy="10" r="2.6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
@@ -188,10 +232,10 @@ export function FilterBar({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`btn btn-primary ${styles.searchButton}`}
|
className={`btn btn-action ${styles.searchButton}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
{isPending ? <div className={styles.spinner}></div> : "Search"}
|
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isHero && (
|
{isHero && (
|
||||||
@@ -248,34 +292,38 @@ export function FilterBar({
|
|||||||
{currentPostcode && (
|
{currentPostcode && (
|
||||||
<div className={styles.radiusControl}>
|
<div className={styles.radiusControl}>
|
||||||
<label className={styles.radiusLabel}>Within:</label>
|
<label className={styles.radiusLabel}>Within:</label>
|
||||||
<select
|
<SelectShell>
|
||||||
value={currentRadius}
|
<select
|
||||||
onChange={(e) => updateURL({ radius: e.target.value })}
|
value={currentRadius}
|
||||||
className={styles.controlSelect}
|
onChange={(e) => updateURL({ radius: e.target.value })}
|
||||||
disabled={isPending}
|
className={styles.controlSelect}
|
||||||
>
|
disabled={isPending}
|
||||||
<option value="0.5">0.5 miles</option>
|
>
|
||||||
<option value="1">1 mile</option>
|
<option value="0.5">0.5 miles</option>
|
||||||
<option value="3">3 miles</option>
|
<option value="1">1 mile</option>
|
||||||
<option value="5">5 miles</option>
|
<option value="3">3 miles</option>
|
||||||
</select>
|
<option value="5">5 miles</option>
|
||||||
|
</select>
|
||||||
|
</SelectShell>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phaseOptions.length > 0 && (
|
{phaseOptions.length > 0 && (
|
||||||
<select
|
<SelectShell>
|
||||||
value={currentPhase}
|
<select
|
||||||
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
value={currentPhase}
|
||||||
className={styles.controlSelect}
|
onChange={(e) => handleFilterChange("phase", e.target.value)}
|
||||||
disabled={isPending}
|
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
|
||||||
>
|
disabled={isPending}
|
||||||
<option value="">All Phases</option>
|
>
|
||||||
{phaseOptions.map((p) => (
|
<option value="">All Phases</option>
|
||||||
<option key={p} value={p.toLowerCase()}>
|
{phaseOptions.map((p) => (
|
||||||
{p}
|
<option key={p} value={p.toLowerCase()}>
|
||||||
</option>
|
{p}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -307,87 +355,97 @@ export function FilterBar({
|
|||||||
|
|
||||||
{filtersOpen && (
|
{filtersOpen && (
|
||||||
<div className={styles.filters}>
|
<div className={styles.filters}>
|
||||||
<select
|
<SelectShell wide>
|
||||||
value={currentLA}
|
<select
|
||||||
onChange={(e) =>
|
value={currentLA}
|
||||||
handleFilterChange("local_authority", e.target.value)
|
onChange={(e) =>
|
||||||
}
|
handleFilterChange("local_authority", e.target.value)
|
||||||
className={styles.filterSelect}
|
}
|
||||||
disabled={isPending}
|
className={`${styles.filterSelect}${activeIf(currentLA)}`}
|
||||||
>
|
disabled={isPending}
|
||||||
<option value="">All Local Authorities</option>
|
>
|
||||||
{laOptions.map((la) => (
|
<option value="">All Local Authorities</option>
|
||||||
<option key={la} value={la}>
|
{laOptions.map((la) => (
|
||||||
{la}
|
<option key={la} value={la}>
|
||||||
</option>
|
{la}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectShell>
|
||||||
|
|
||||||
<select
|
<SelectShell wide>
|
||||||
value={currentType}
|
<select
|
||||||
onChange={(e) =>
|
value={currentType}
|
||||||
handleFilterChange("school_type", e.target.value)
|
onChange={(e) =>
|
||||||
}
|
handleFilterChange("school_type", e.target.value)
|
||||||
className={styles.filterSelect}
|
}
|
||||||
disabled={isPending}
|
className={`${styles.filterSelect}${activeIf(currentType)}`}
|
||||||
>
|
disabled={isPending}
|
||||||
<option value="">All School Types</option>
|
>
|
||||||
{typeOptions.map((type) => (
|
<option value="">All School Types</option>
|
||||||
<option key={type} value={type}>
|
{typeOptions.map((type) => (
|
||||||
{type}
|
<option key={type} value={type}>
|
||||||
</option>
|
{type}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectShell>
|
||||||
|
|
||||||
{isSecondaryMode && (
|
{isSecondaryMode && (
|
||||||
<>
|
<>
|
||||||
{genderOptions.length > 0 && (
|
{genderOptions.length > 0 && (
|
||||||
<select
|
<SelectShell wide>
|
||||||
value={currentGender}
|
<select
|
||||||
onChange={(e) =>
|
value={currentGender}
|
||||||
handleFilterChange("gender", e.target.value)
|
onChange={(e) =>
|
||||||
}
|
handleFilterChange("gender", e.target.value)
|
||||||
className={styles.filterSelect}
|
}
|
||||||
disabled={isPending}
|
className={`${styles.filterSelect}${activeIf(currentGender)}`}
|
||||||
>
|
disabled={isPending}
|
||||||
<option value="">Boys, Girls & Mixed</option>
|
>
|
||||||
{genderOptions.map((g) => (
|
<option value="">Boys, Girls & Mixed</option>
|
||||||
<option key={g} value={g.toLowerCase()}>
|
{genderOptions.map((g) => (
|
||||||
{g}
|
<option key={g} value={g.toLowerCase()}>
|
||||||
</option>
|
{g}
|
||||||
))}
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectShell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<select
|
<SelectShell wide>
|
||||||
value={currentHasSixthForm}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleFilterChange("has_sixth_form", e.target.value)
|
|
||||||
}
|
|
||||||
className={styles.filterSelect}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<option value="">With or without sixth form</option>
|
|
||||||
<option value="yes">With sixth form</option>
|
|
||||||
<option value="no">Without sixth form</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{admissionsPolicyOptions.length > 0 && (
|
|
||||||
<select
|
<select
|
||||||
value={currentAdmissionsPolicy}
|
value={currentHasSixthForm}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleFilterChange("admissions_policy", e.target.value)
|
handleFilterChange("has_sixth_form", e.target.value)
|
||||||
}
|
}
|
||||||
className={styles.filterSelect}
|
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<option value="">All admissions types</option>
|
<option value="">With or without sixth form</option>
|
||||||
{admissionsPolicyOptions.map((p) => (
|
<option value="yes">With sixth form</option>
|
||||||
<option key={p} value={p.toLowerCase()}>
|
<option value="no">Without sixth form</option>
|
||||||
{p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
|
</SelectShell>
|
||||||
|
|
||||||
|
{admissionsPolicyOptions.length > 0 && (
|
||||||
|
<SelectShell wide>
|
||||||
|
<select
|
||||||
|
value={currentAdmissionsPolicy}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("admissions_policy", e.target.value)
|
||||||
|
}
|
||||||
|
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All admissions types</option>
|
||||||
|
{admissionsPolicyOptions.map((p) => (
|
||||||
|
<option key={p} value={p.toLowerCase()}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectShell>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
/*
|
||||||
|
* The footer band.
|
||||||
|
*
|
||||||
|
* --surface-sunken is deep teal in BOTH themes on purpose, so nothing in here
|
||||||
|
* may read a --text-* or --bg-* token: those invert with the theme and would
|
||||||
|
* put dark ink on the teal (or teal on teal) in one of the two. Every
|
||||||
|
* foreground below comes from the --on-sunken-* family, which is pinned to the
|
||||||
|
* band rather than to the page.
|
||||||
|
*/
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
color: var(--on-sunken);
|
color: var(--on-sunken);
|
||||||
@@ -12,7 +22,7 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.5fr 1fr 1fr;
|
grid-template-columns: 1.6fr 1fr 1fr;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
@@ -20,31 +30,76 @@
|
|||||||
.section {
|
.section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
/* ─── Brand lockup ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.lockup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--on-sunken);
|
}
|
||||||
|
|
||||||
|
.lockupMark {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lockupMark svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same wordmark as the header, inverted: "school" in the band's foreground,
|
||||||
|
"compare" in sage — the one accent that reads cleanly on deep teal. */
|
||||||
|
.wordmark {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-2);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
color: var(--on-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmarkAccent {
|
||||||
|
color: var(--sage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key messaging, so this is the one place in the footer that takes Manrope
|
||||||
|
for a full sentence. */
|
||||||
|
.tagline {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-1);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--on-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
margin: 0;
|
margin: -0.5rem 0 0;
|
||||||
font-size: 0.875rem;
|
font-size: var(--step--1);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--on-sunken-muted);
|
color: var(--on-sunken-muted);
|
||||||
|
max-width: 34ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-family: var(--font-display);
|
||||||
font-weight: 600;
|
font-size: var(--step--1);
|
||||||
color: var(--on-sunken-muted);
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--sage);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.links {
|
.links {
|
||||||
@@ -57,10 +112,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
font-size: 0.875rem;
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--step--1);
|
||||||
color: var(--on-sunken-muted);
|
color: var(--on-sunken-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link:hover {
|
.link:hover {
|
||||||
@@ -68,23 +125,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.linkDisabled {
|
.linkDisabled {
|
||||||
font-size: 0.875rem;
|
font-size: var(--step--1);
|
||||||
color: var(--on-sunken-faint);
|
color: var(--on-sunken-faint);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Bottom rule ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The divider is drawn as a pseudo-element at low opacity rather than as an
|
||||||
|
* rgba() border: there is no channel token for --on-sunken, and every literal
|
||||||
|
* that would work here (rgba(255,255,255,…)) is exactly the kind of hardcoded
|
||||||
|
* colour the token layer exists to remove.
|
||||||
|
*/
|
||||||
.bottom {
|
.bottom {
|
||||||
|
position: relative;
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--on-sunken);
|
||||||
|
opacity: 0.16;
|
||||||
|
}
|
||||||
|
|
||||||
.copyright,
|
.copyright,
|
||||||
.disclaimer {
|
.disclaimer {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: var(--step--1);
|
||||||
color: var(--on-sunken-muted);
|
color: var(--on-sunken-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Footer Component
|
* Footer Component
|
||||||
* Site footer with links and info
|
*
|
||||||
|
* The footer is the guideline's "brand in action" band: a deep teal anchor
|
||||||
|
* that stays teal in both themes. Everything inside it therefore reads from
|
||||||
|
* the --on-sunken-* family rather than the page's --text-* tokens, which
|
||||||
|
* invert and would go dark against the teal in one theme or the other.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { LogoMark } from './Logo';
|
||||||
import styles from './Footer.module.css';
|
import styles from './Footer.module.css';
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
@@ -13,7 +18,20 @@ export function Footer() {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.title}>SchoolCompare</h3>
|
{/*
|
||||||
|
On the sunken band the mark inverts: the pin takes the band's
|
||||||
|
foreground and the leaf is knocked out in the band itself. The
|
||||||
|
header's defaults (brand pin, card leaf) would disappear here.
|
||||||
|
*/}
|
||||||
|
<h3 className={styles.lockup}>
|
||||||
|
<span className={styles.lockupMark}>
|
||||||
|
<LogoMark pin="var(--on-sunken)" leaf="var(--surface-sunken)" />
|
||||||
|
</span>
|
||||||
|
<span className={styles.wordmark}>
|
||||||
|
school<span className={styles.wordmarkAccent}>compare</span>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className={styles.tagline}>Find the right school. For their future.</p>
|
||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
Compare primary and secondary schools across England.
|
Compare primary and secondary schools across England.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,12 +2,68 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroSection {
|
/* ── Hero ──────────────────────────────────────────────────────────────────
|
||||||
text-align: center;
|
A Sand panel: proposition and search on the left, the brand landscape
|
||||||
|
bleeding to the panel's right edge. The illustration is decorative, so on
|
||||||
|
phones it drops to a short band under the content rather than competing
|
||||||
|
with the search for the fold. */
|
||||||
|
.heroPanel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.12fr 0.88fr;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding-top: 2.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroContent {
|
||||||
|
align-self: center;
|
||||||
|
padding: 3rem 1.5rem 3rem 3rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTrust {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTrustDots {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTrustDot {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
margin-right: -0.55rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -32,78 +88,175 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: 3rem;
|
font-size: var(--step-4);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.75rem;
|
||||||
line-height: 1.08;
|
line-height: 1.12;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.03em;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
max-width: 840px;
|
max-width: 18ch;
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The em is a colour change, not an emphasis change — Manrope has no italic
|
||||||
|
in the loaded weights, so leaving font-style alone would synthesise a slant. */
|
||||||
.heroEmph {
|
.heroEmph {
|
||||||
color: var(--brand-strong);
|
color: var(--brand);
|
||||||
font-style: italic;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroDescription {
|
.heroDescription {
|
||||||
font-size: 1.05rem;
|
font-size: var(--step-1);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0 auto 0.5rem;
|
margin: 0 0 1.5rem;
|
||||||
max-width: 680px;
|
max-width: 46ch;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroDescription strong {
|
.heroDescription strong {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Copy variants: full paragraph ≥641px, compact merged line on phones. */
|
@media (max-width: 1024px) {
|
||||||
.heroDescriptionCompact {
|
.heroContent {
|
||||||
display: none;
|
padding: 2.25rem 1.5rem 2.25rem 2rem;
|
||||||
|
}
|
||||||
|
.heroArt {
|
||||||
|
min-height: 20rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
/* One column: content first, then a short band of landscape. */
|
||||||
.heroSection {
|
@media (max-width: 860px) {
|
||||||
padding-top: 1.5rem;
|
.heroPanel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.heroContent {
|
||||||
|
padding: 2rem 1.5rem 1.75rem;
|
||||||
|
}
|
||||||
|
.heroArt {
|
||||||
|
min-height: 11rem;
|
||||||
|
order: 2;
|
||||||
}
|
}
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: 2rem;
|
font-size: var(--step-3);
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
.heroDescription {
|
.heroDescription {
|
||||||
font-size: 0.95rem;
|
font-size: var(--step-0);
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and swap
|
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
||||||
the long descriptive paragraph for one compact line that carries coverage
|
long coverage sentence, leaving the proposition and the search — a
|
||||||
("24,000+ schools") and freshness ("updated for 2026/27") — a first-time
|
first-time visitor still gets the trust line beneath the box, which carries
|
||||||
visitor otherwise sees only a poetic h1 and a bare box, with no reason to
|
the data provenance the coverage sentence used to (audit P1.7, feeds the
|
||||||
trust the data (audit P1.7, feeds the 46% home exit rate). */
|
46% home exit rate). */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.heroSection {
|
.heroPanel {
|
||||||
padding-top: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.heroContent {
|
||||||
|
padding: 1.25rem 1.1rem 1.5rem;
|
||||||
}
|
}
|
||||||
.heroEyebrow,
|
.heroEyebrow,
|
||||||
.heroDescriptionFull {
|
.heroDescriptionFull {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.heroDescriptionCompact {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.heroDescription {
|
.heroDescription {
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.4;
|
line-height: 1.45;
|
||||||
max-width: 320px;
|
margin-bottom: 1rem;
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: 1.65rem;
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.heroTrust {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: var(--step--2);
|
||||||
|
}
|
||||||
|
.heroArt {
|
||||||
|
min-height: 8.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Value props ───────────────────────────────────────────────────────────
|
||||||
|
Four reasons, straight from the guideline. The fourth is ours rather than
|
||||||
|
theirs — see the comment on VALUE_PROPS in HomeView.tsx. */
|
||||||
|
.valueProps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.valueProp {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 2.6rem;
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propIconTrust {
|
||||||
|
background: var(--brand-bg);
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
.propIconCompare {
|
||||||
|
background: rgba(var(--sage-rgb), 0.42);
|
||||||
|
color: var(--status-above);
|
||||||
|
}
|
||||||
|
.propIconContext {
|
||||||
|
background: rgba(242, 201, 76, 0.26);
|
||||||
|
color: var(--mustard-ink);
|
||||||
|
}
|
||||||
|
.propIconDeadline {
|
||||||
|
background: rgba(221, 216, 245, 0.6);
|
||||||
|
color: var(--phase-nursery);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propTitle {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-0);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 0.15rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propBody {
|
||||||
|
font-size: var(--step--1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.valueProps {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.valueProps {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
|
|||||||
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||||
import { SchoolMap } from './SchoolMap';
|
import { SchoolMap } from './SchoolMap';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
|
import { HeroIllustration } from './Illustration';
|
||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||||
@@ -63,6 +64,96 @@ const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
|||||||
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* ── Value-prop icons ──────────────────────────────────────────────────────
|
||||||
|
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
|
||||||
|
local because they exist for this one row and nothing else. */
|
||||||
|
|
||||||
|
const ICON_PROPS = {
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
width: 21,
|
||||||
|
height: 21,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
'aria-hidden': true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ShieldCheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarsIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeartIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||||
|
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValueProp {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
tintClass: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||||
|
* favourites). That feature does not exist in this product, so it is
|
||||||
|
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||||
|
* and is backed by the countdown strip further down this page.
|
||||||
|
*/
|
||||||
|
const VALUE_PROPS: ValueProp[] = [
|
||||||
|
{
|
||||||
|
icon: <ShieldCheckIcon />,
|
||||||
|
tintClass: styles.propIconTrust,
|
||||||
|
title: 'Official & trusted',
|
||||||
|
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarsIcon />,
|
||||||
|
tintClass: styles.propIconCompare,
|
||||||
|
title: 'Easy to compare',
|
||||||
|
body: 'Up to three schools side by side, on the measures that matter.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <HeartIcon />,
|
||||||
|
tintClass: styles.propIconContext,
|
||||||
|
title: 'Beyond the numbers',
|
||||||
|
body: 'Class sizes, SEN support and local context, not just results.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <CalendarIcon />,
|
||||||
|
tintClass: styles.propIconDeadline,
|
||||||
|
title: 'Never miss a deadline',
|
||||||
|
body: 'Application and offer dates for both primary and secondary.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -256,38 +347,81 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homeView}>
|
<div className={styles.homeView}>
|
||||||
{/* Combined Hero + Search and Filters */}
|
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||||
{!isSearchActive && (
|
the brand landscape bleeding to the panel edge on the right. The
|
||||||
<div className={styles.heroSection}>
|
search lives inside the panel here and above the results elsewhere,
|
||||||
<span className={styles.heroEyebrow}>
|
which is why FilterBar is rendered in two places rather than moved. */}
|
||||||
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
{!isSearchActive ? (
|
||||||
Updated for the 2026/27 admissions round
|
<section className={styles.heroPanel}>
|
||||||
</span>
|
<div className={styles.heroContent}>
|
||||||
<h1 className={styles.heroTitle}>
|
<span className={styles.heroEyebrow}>
|
||||||
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||||
</h1>
|
Updated for the 2026/27 admissions round
|
||||||
<p className={styles.heroDescription}>
|
|
||||||
{/* Full copy for larger screens; a compact merged line (coverage +
|
|
||||||
freshness, standing in for the hidden eyebrow too) on phones,
|
|
||||||
where every line above the fold costs. */}
|
|
||||||
<span className={styles.heroDescriptionFull}>
|
|
||||||
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.heroDescriptionCompact}>
|
<h1 className={styles.heroTitle}>
|
||||||
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
|
Find the school where your child will{' '}
|
||||||
</span>
|
<em className={styles.heroEmph}>flourish.</em>
|
||||||
</p>
|
</h1>
|
||||||
</div>
|
<p className={styles.heroDescription}>
|
||||||
|
Compare every school in England with clarity and confidence.
|
||||||
|
{/* The coverage sentence is the detail a first-time visitor needs
|
||||||
|
but a returning one does not, so it drops away on phones where
|
||||||
|
every line above the fold costs. */}
|
||||||
|
<span className={styles.heroDescriptionFull}>
|
||||||
|
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||||
|
and admissions data for <strong>24,000+ schools</strong> — side by
|
||||||
|
side, in one place.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
filters={filters}
|
||||||
|
isHero
|
||||||
|
resultFilters={initialSchools.result_filters}
|
||||||
|
onNearMe={handleNearMe}
|
||||||
|
geoState={geoState}
|
||||||
|
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 className={styles.heroArt}>
|
||||||
|
<HeroIllustration />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<FilterBar
|
||||||
|
filters={filters}
|
||||||
|
isHero={false}
|
||||||
|
resultFilters={initialSchools.result_filters}
|
||||||
|
onNearMe={handleNearMe}
|
||||||
|
geoState={geoState}
|
||||||
|
geoError={geoError}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FilterBar
|
{/* Why this site, in four lines. Only on the landing page. */}
|
||||||
filters={filters}
|
{!isSearchActive && (
|
||||||
isHero={!isSearchActive}
|
<ul className={styles.valueProps}>
|
||||||
resultFilters={initialSchools.result_filters}
|
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||||
onNearMe={handleNearMe}
|
<li key={title} className={styles.valueProp}>
|
||||||
geoState={geoState}
|
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||||
geoError={geoError}
|
<div>
|
||||||
/>
|
<h2 className={styles.propTitle}>{title}</h2>
|
||||||
|
<p className={styles.propBody}>{body}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Admissions countdown strip — only on landing page */}
|
{/* Admissions countdown strip — only on landing page */}
|
||||||
{!isSearchActive && (
|
{!isSearchActive && (
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Brand illustration — the landing hero's landscape.
|
||||||
|
*
|
||||||
|
* Guideline rules for illustration: no people or faces, soft shapes, rounded
|
||||||
|
* corners, minimal detail, maximum clarity. The scene reads as "the journey to
|
||||||
|
* a school" — a winding path up through layered hills to a small schoolhouse,
|
||||||
|
* with a pin marking where you are.
|
||||||
|
*
|
||||||
|
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so
|
||||||
|
* it renders in the RSC pass and never reaches the client bundle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These hex values mirror the brand palette in app/globals.css — --sky,
|
||||||
|
* --sage, --brand, --coral, --sand and the tints/shades the scene needs
|
||||||
|
* around them. They are written literally on purpose: an illustration is
|
||||||
|
* artwork, not themed UI. Recolouring it per theme would break the picture,
|
||||||
|
* so it keeps one fixed, light palette in both themes (it sits on a Sand
|
||||||
|
* panel in light mode and reads as a framed image in dark mode).
|
||||||
|
*/
|
||||||
|
const SKY_HIGH = '#DAEDF8';
|
||||||
|
const SKY_LOW = '#EFF8FB';
|
||||||
|
const CLOUD = '#FFFFFF';
|
||||||
|
|
||||||
|
const HILL_FAR = '#D6EDE2'; // --sage, lightened
|
||||||
|
const HILL_MID = '#A7D7C5'; // --sage, exact
|
||||||
|
const HILL_NEAR = '#7FC3AC';
|
||||||
|
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() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={SKY_GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stopColor={SKY_HIGH} />
|
||||||
|
<stop offset="1" stopColor={SKY_LOW} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
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_EDGE}
|
||||||
|
strokeWidth="18"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeDasharray="0.5 26"
|
||||||
|
opacity="0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The school at the top of the path */}
|
||||||
|
<g transform="translate(276 232)">
|
||||||
|
<rect x="12" y="34" width="98" height="58" rx="4" fill={SCHOOL_WALL} />
|
||||||
|
<path d="M2 36 L61 6 L120 36 Z" fill={SCHOOL_ROOF} />
|
||||||
|
<rect x="54" y="62" width="20" height="30" rx="2" fill={SCHOOL_DOOR} />
|
||||||
|
<g fill={SCHOOL_WINDOW}>
|
||||||
|
<rect x="24" y="46" width="16" height="14" rx="2" />
|
||||||
|
<rect x="86" y="46" width="16" height="14" rx="2" />
|
||||||
|
<rect x="24" y="70" width="16" height="12" rx="2" />
|
||||||
|
<rect x="86" y="70" width="16" height="12" rx="2" />
|
||||||
|
</g>
|
||||||
|
{/* Flagpole and coral pennant */}
|
||||||
|
<rect x="59.5" y="-14" width="2.5" height="20" fill={FLAGPOLE} />
|
||||||
|
<path d="M62 -13 L78 -8.5 L62 -4 Z" fill={PENNANT} />
|
||||||
|
{/* Clock in the gable */}
|
||||||
|
<circle cx="61" cy="24" r="6.5" fill={SCHOOL_CLOCK} />
|
||||||
|
<path d="M61 20.5 v7 M57.5 24 h7" stroke={SCHOOL_DOOR} strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Scattered rounded trees */}
|
||||||
|
<g fill={TREE_DARK}>
|
||||||
|
<circle cx="104" cy="372" r="27" />
|
||||||
|
<circle cx="136" cy="386" r="19" />
|
||||||
|
<circle cx="470" cy="336" r="23" />
|
||||||
|
</g>
|
||||||
|
<g fill={TREE_LIGHT}>
|
||||||
|
<circle cx="78" cy="394" r="21" />
|
||||||
|
<circle cx="492" cy="358" r="17" />
|
||||||
|
<circle cx="176" cy="420" r="18" />
|
||||||
|
</g>
|
||||||
|
<g fill={TREE_MID}>
|
||||||
|
<ellipse cx="44" cy="336" rx="15" ry="22" />
|
||||||
|
<ellipse cx="508" cy="296" rx="13" ry="18" />
|
||||||
|
<ellipse cx="150" cy="342" rx="12" ry="17" />
|
||||||
|
</g>
|
||||||
|
<g fill={TREE_PALE} opacity="0.9">
|
||||||
|
<circle cx="410" cy="420" r="16" />
|
||||||
|
<circle cx="438" cy="430" r="12" />
|
||||||
|
<circle cx="120" cy="470" r="14" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,46 +1,83 @@
|
|||||||
/**
|
/**
|
||||||
* The SchoolCompare mark.
|
* The schoolcompare mark.
|
||||||
*
|
*
|
||||||
* Five bars: a cohort, with one school picked out. It is the same object as
|
* A location pin with a leaf growing inside it: where a school is, and a child
|
||||||
* the distribution strip inside a school row, at a different size — the logo
|
* growing there. The pin's tail sweeps left as the guideline's "signature path
|
||||||
* teaches the chart and the chart reinforces the logo.
|
* shape" — the journey a parent takes to find the right school — so the mark
|
||||||
|
* and the illustration style share one gesture.
|
||||||
*
|
*
|
||||||
* Built from opacity steps over `currentColor` rather than fixed greys, so it
|
* This is the single source for the mark. app/icon.svg, app/apple-icon.tsx and
|
||||||
* inverts cleanly on any ground without a second artwork. The highlighted bar
|
* app/opengraph-image.tsx all derive from the same geometry (see MARK_PATHS),
|
||||||
* is the only element that takes the brand hue.
|
* because the header and the favicon had previously drifted into two different
|
||||||
|
* logos.
|
||||||
*
|
*
|
||||||
* This is the single source for the mark. The favicon and the generated icons
|
* Drawn on a 48×48 grid. The pin takes the brand hue and the leaf is knocked
|
||||||
* derive from the same geometry (see BARS) — previously the header and the
|
* out of it, so the mark needs exactly two colours and works on any ground.
|
||||||
* favicon had drifted into two different logos.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** x, y, width, height, and whether this bar is the highlighted school. */
|
/** Geometry shared by every rendering of the mark, on a 0 0 48 48 viewBox. */
|
||||||
export const BARS: ReadonlyArray<[number, number, number, number, boolean]> = [
|
export const MARK_PATHS = {
|
||||||
[2, 26, 6, 12, false],
|
/** The path tail, stroked — 8.5 wide with a round cap. */
|
||||||
[10, 16, 6, 22, false],
|
tail: 'M17 30 C15 35.6 11.6 40.2 7.8 42.8',
|
||||||
[18, 8, 6, 30, false],
|
/** The pin head. */
|
||||||
[26, 14, 6, 24, true],
|
head: { cx: 26.5, cy: 18.5, r: 16.5 },
|
||||||
[34, 28, 4, 10, false],
|
/** Leaf stem, stroked — 2.6 wide with a round cap. */
|
||||||
];
|
stem: 'M26.5 29.5 C26.5 25 26.6 21 27 18',
|
||||||
|
/** Upper leaf, filled. */
|
||||||
|
leafUpper: 'M27 19.6 C28.2 13.8 32.4 9.8 37.6 9.4 C38 15.6 33.8 20.8 27 19.6 Z',
|
||||||
|
/** Lower leaf, filled. */
|
||||||
|
leafLower: 'M26.4 24.6 C25.2 20 21.2 17 16.4 17.1 C16.2 21.8 19.8 25.8 26.4 24.6 Z',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const COHORT_OPACITY = [0.22, 0.38, 0.55, 1, 0.22];
|
/**
|
||||||
|
* The mark as a standalone SVG string.
|
||||||
|
*
|
||||||
|
* Satori (next/og) renders `<img>` with a data URI far more reliably than it
|
||||||
|
* renders inline SVG children, so the generated icon and share card both draw
|
||||||
|
* the mark this way — from the same geometry as the React component above,
|
||||||
|
* which is the whole point of MARK_PATHS.
|
||||||
|
*/
|
||||||
|
export function markSvg(pin: string, leaf: string): string {
|
||||||
|
return [
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">',
|
||||||
|
`<path d="${MARK_PATHS.tail}" stroke="${pin}" stroke-width="8.5" stroke-linecap="round" fill="none"/>`,
|
||||||
|
`<circle cx="${MARK_PATHS.head.cx}" cy="${MARK_PATHS.head.cy}" r="${MARK_PATHS.head.r}" fill="${pin}"/>`,
|
||||||
|
`<path d="${MARK_PATHS.stem}" stroke="${leaf}" stroke-width="2.6" stroke-linecap="round" fill="none"/>`,
|
||||||
|
`<path d="${MARK_PATHS.leafUpper}" fill="${leaf}"/>`,
|
||||||
|
`<path d="${MARK_PATHS.leafLower}" fill="${leaf}"/>`,
|
||||||
|
'</svg>',
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same string as a data URI, ready for `<img src>`. */
|
||||||
|
export function markDataUri(pin: string, leaf: string): string {
|
||||||
|
return `data:image/svg+xml;base64,${Buffer.from(markSvg(pin, leaf)).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface LogoMarkProps {
|
interface LogoMarkProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Rendered size in px. Defaults to inheriting via CSS. */
|
/** Rendered size in px. Defaults to inheriting via CSS. */
|
||||||
size?: number;
|
size?: number;
|
||||||
/** Colour of the highlighted bar. Defaults to the brand token. */
|
/** The pin. Defaults to the brand token so it inverts with the theme. */
|
||||||
accent?: string;
|
pin?: string;
|
||||||
|
/** The leaf knocked out of the pin. */
|
||||||
|
leaf?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogoMark({ className, size, accent = 'var(--brand)', title }: LogoMarkProps) {
|
export function LogoMark({
|
||||||
|
className,
|
||||||
|
size,
|
||||||
|
pin = 'var(--brand)',
|
||||||
|
leaf = 'var(--bg-card)',
|
||||||
|
title,
|
||||||
|
}: LogoMarkProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={className}
|
className={className}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 40 40"
|
viewBox="0 0 48 48"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
role={title ? 'img' : undefined}
|
role={title ? 'img' : undefined}
|
||||||
@@ -48,18 +85,23 @@ export function LogoMark({ className, size, accent = 'var(--brand)', title }: Lo
|
|||||||
aria-label={title}
|
aria-label={title}
|
||||||
>
|
>
|
||||||
{title ? <title>{title}</title> : null}
|
{title ? <title>{title}</title> : null}
|
||||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
<path
|
||||||
<rect
|
d={MARK_PATHS.tail}
|
||||||
key={x}
|
stroke={pin}
|
||||||
x={x}
|
strokeWidth={8.5}
|
||||||
y={y}
|
strokeLinecap="round"
|
||||||
width={w}
|
fill="none"
|
||||||
height={h}
|
/>
|
||||||
rx={1}
|
<circle cx={MARK_PATHS.head.cx} cy={MARK_PATHS.head.cy} r={MARK_PATHS.head.r} fill={pin} />
|
||||||
fill={isSchool ? accent : 'currentColor'}
|
<path
|
||||||
opacity={isSchool ? 1 : COHORT_OPACITY[i]}
|
d={MARK_PATHS.stem}
|
||||||
/>
|
stroke={leaf}
|
||||||
))}
|
strokeWidth={2.6}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path d={MARK_PATHS.leafUpper} fill={leaf} />
|
||||||
|
<path d={MARK_PATHS.leafLower} fill={leaf} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* Header and mobile tab bar, on the schoolcompare brand.
|
||||||
|
*
|
||||||
|
* The header is a white card band closed by a single hairline rule — no
|
||||||
|
* shadow. The guideline's chrome is quiet; the colour on this surface is the
|
||||||
|
* wordmark and the one active nav item, and nothing else.
|
||||||
|
*/
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -16,35 +23,42 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
/* 64px desktop / 56px mobile is load-bearing: the school detail page's
|
||||||
|
sticky section nav docks against it with a hardcoded `top` in
|
||||||
|
SchoolDetailShell.module.css and schoolSections.module.css, and the
|
||||||
|
scroll-spy observers use it as a rootMargin. Change it in all four
|
||||||
|
places or not at all. */
|
||||||
height: 64px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.625rem;
|
||||||
/* Padded hit area so the logo link is ≥44×44 on touch */
|
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||||
margin: -0.375rem -0.5rem;
|
margin: -0.375rem -0.5rem;
|
||||||
padding: 0.375rem 0.5rem;
|
padding: 0.375rem 0.5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1.25rem;
|
transition: color var(--transition);
|
||||||
font-weight: 700;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo:hover {
|
.logo:hover {
|
||||||
color: var(--brand-strong);
|
color: var(--brand-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* currentColor paints the four cohort bars; the mark supplies the brand hue
|
/*
|
||||||
for the highlighted one itself. So this is ink, not brand. */
|
* The mark colours itself — pin in var(--brand), leaf knocked out in
|
||||||
|
* var(--bg-card), which is this header's own ground. It takes no colour from
|
||||||
|
* currentColor, so there is deliberately no `color` here.
|
||||||
|
*/
|
||||||
.logoIcon {
|
.logoIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 32px;
|
flex: 0 0 auto;
|
||||||
height: 32px;
|
width: 36px;
|
||||||
color: var(--text-primary);
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoIcon svg {
|
.logoIcon svg {
|
||||||
@@ -52,10 +66,17 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "schoolcompare", one word, lower case. Manrope 700 pulled tight — the
|
||||||
|
* negative tracking is what makes the two halves read as a single word rather
|
||||||
|
* than as two.
|
||||||
|
*/
|
||||||
.logoText {
|
.logoText {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-2);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.022em;
|
line-height: 1;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoTextAccent {
|
.logoTextAccent {
|
||||||
@@ -63,41 +84,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo:hover .logoTextAccent {
|
.logo:hover .logoTextAccent {
|
||||||
color: var(--brand-strong);
|
color: var(--brand-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
|
gap: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink {
|
.navLink {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4375rem;
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.5rem 0.875rem;
|
||||||
font-size: 0.9375rem;
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
transition: all 0.2s ease;
|
transition: color var(--transition), background-color var(--transition);
|
||||||
}
|
|
||||||
|
|
||||||
/* Sliding underline effect */
|
|
||||||
.navLink::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 4px;
|
|
||||||
left: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
height: 2px;
|
|
||||||
background: var(--brand);
|
|
||||||
transform: scaleX(0);
|
|
||||||
transform-origin: left;
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
border-radius: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink:hover {
|
.navLink:hover {
|
||||||
@@ -105,33 +113,29 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink:hover::after {
|
/* The current page is marked twice — brand hue and a heavier weight — so the
|
||||||
transform: scaleX(1);
|
state never rests on colour alone. */
|
||||||
}
|
|
||||||
|
|
||||||
.navLink.active {
|
.navLink.active {
|
||||||
color: var(--brand-strong);
|
color: var(--brand);
|
||||||
background: var(--brand-bg);
|
background: var(--brand-bg);
|
||||||
}
|
font-weight: 600;
|
||||||
|
|
||||||
.navLink.active::after {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 1.25rem;
|
min-width: 1.125rem;
|
||||||
height: 1.25rem;
|
height: 1.125rem;
|
||||||
padding: 0 0.375rem;
|
padding: 0 0.375rem;
|
||||||
font-size: 0.75rem;
|
font-family: var(--font-ui);
|
||||||
font-weight: 600;
|
font-size: var(--step--2);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
color: var(--brand-on);
|
color: var(--brand-on);
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
animation: badgePop 0.3s ease-out;
|
animation: badgePop 0.3s ease-out;
|
||||||
box-shadow: 0 2px 6px rgba(var(--brand-rgb), 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes badgePop {
|
@keyframes badgePop {
|
||||||
@@ -159,25 +163,23 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.2rem;
|
gap: 0.25rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
padding: 0.375rem 0.25rem;
|
padding: 0.375rem 0.25rem;
|
||||||
|
font-family: var(--font-ui);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
transition: color 0.15s ease, background-color 0.15s ease;
|
transition: color var(--transition);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:active {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
color: var(--brand-strong);
|
color: var(--brand);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabIconWrap {
|
.tabIconWrap {
|
||||||
@@ -189,7 +191,29 @@
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The active tab's icon sits in a brand-tinted stadium — the same rounded
|
||||||
|
geometry as the rest of the system, and a second cue beyond hue. Drawn as a
|
||||||
|
pseudo-element so it can bleed wider than the 24px icon box without moving
|
||||||
|
the badge's anchor. */
|
||||||
|
.tabIconWrap::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px -9px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive .tabIconWrap::before {
|
||||||
|
background: var(--brand-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:active .tabIconWrap::before {
|
||||||
|
background: var(--brand-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.tabIcon {
|
.tabIcon {
|
||||||
|
position: relative;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
@@ -200,16 +224,18 @@
|
|||||||
|
|
||||||
.tabBadge {
|
.tabBadge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -6px;
|
top: -7px;
|
||||||
right: -10px;
|
right: -7px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
color: var(--brand-on);
|
color: var(--brand-on);
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
border: 2px solid var(--bg-card);
|
border: 2px solid var(--bg-card);
|
||||||
@@ -229,8 +255,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logoIcon {
|
.logoIcon {
|
||||||
width: 32px;
|
width: 34px;
|
||||||
height: 32px;
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoText {
|
||||||
|
font-size: var(--step-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomBar {
|
.bottomBar {
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ export function Navigation() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The brand guideline's nav also carries Guides, About, Favourites and a
|
||||||
|
* "Sign in" button. None of the four is added here, deliberately: there are
|
||||||
|
* no Guides or About pages, and Favourites and Sign in both need an accounts
|
||||||
|
* system that does not exist. Drawing chrome for features we do not have
|
||||||
|
* would be a promise the product cannot keep. Rankings and Admissions —
|
||||||
|
* which the guideline omits — take their place, because they are real.
|
||||||
|
*
|
||||||
|
* If accounts ever ship, this is the list to extend.
|
||||||
|
*/
|
||||||
const items = [
|
const items = [
|
||||||
{ href: '/', label: 'Search', Icon: SearchIcon },
|
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||||
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||||
@@ -91,11 +101,18 @@ export function Navigation() {
|
|||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||||
|
{/*
|
||||||
|
LogoMark's defaults are already correct for this ground: the pin
|
||||||
|
takes var(--brand) and the leaf is knocked out in var(--bg-card),
|
||||||
|
which is exactly the header's own background in both themes. No
|
||||||
|
explicit pin/leaf needed here — the footer, which sits on the
|
||||||
|
sunken teal band, does have to pass them.
|
||||||
|
*/}
|
||||||
<span className={styles.logoIcon}>
|
<span className={styles.logoIcon}>
|
||||||
<LogoMark />
|
<LogoMark />
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.logoText}>
|
<span className={styles.logoText}>
|
||||||
School<span className={styles.logoTextAccent}>Compare</span>
|
school<span className={styles.logoTextAccent}>compare</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -45,35 +45,35 @@ export type ThemeToken =
|
|||||||
|
|
||||||
/** Values used before hydration and in any non-DOM context (SSR, tests). */
|
/** Values used before hydration and in any non-DOM context (SSR, tests). */
|
||||||
const FALLBACK: Record<string, string> = {
|
const FALLBACK: Record<string, string> = {
|
||||||
'--brand': '#584a9b',
|
'--brand': '#0F766E',
|
||||||
'--brand-strong': '#473c80',
|
'--brand-strong': '#0C5F58',
|
||||||
'--brand-bg': 'rgba(88, 74, 155, 0.10)',
|
'--brand-bg': 'rgba(15, 118, 110, 0.10)',
|
||||||
'--status-above': '#0e6e66',
|
'--status-above': '#36743F',
|
||||||
'--status-above-bg': 'rgba(14, 110, 102, 0.10)',
|
'--status-above-bg': 'rgba(54, 116, 63, 0.12)',
|
||||||
'--status-below': '#9a5b00',
|
'--status-below': '#A9481F',
|
||||||
'--status-below-bg': 'rgba(154, 91, 0, 0.11)',
|
'--status-below-bg': 'rgba(249, 115, 96, 0.15)',
|
||||||
'--text-primary': '#16202a',
|
'--text-primary': '#1C2731',
|
||||||
'--text-secondary': '#4a545f',
|
'--text-secondary': '#4A5560',
|
||||||
'--text-muted': '#5c6570',
|
'--text-muted': '#5F6A75',
|
||||||
'--text-inverse': '#f7f7f4',
|
'--text-inverse': '#FFFFFF',
|
||||||
'--bg-card': '#fcfcfa',
|
'--bg-card': '#FFFFFF',
|
||||||
'--bg-secondary': '#e9e8e2',
|
'--bg-secondary': '#F5EFE6',
|
||||||
'--border': '#dedcd5',
|
'--border': '#E5E7EB',
|
||||||
'--border-strong': '#cdcac1',
|
'--border-strong': '#D3D7DD',
|
||||||
'--surface-inverse': '#16202a',
|
'--surface-inverse': '#0F766E',
|
||||||
'--chart-1': '#584a9b',
|
'--chart-1': '#0F766E',
|
||||||
'--chart-2': '#0e6e66',
|
'--chart-2': '#36743F',
|
||||||
'--chart-3': '#9a5b00',
|
'--chart-3': '#806200',
|
||||||
'--chart-4': '#8a7cc9',
|
'--chart-4': '#A9481F',
|
||||||
'--chart-5': '#3e9c92',
|
'--chart-5': '#2F6F8F',
|
||||||
'--chart-6': '#c9903d',
|
'--chart-6': '#5F4FA8',
|
||||||
'--chart-grid': '#dedcd5',
|
'--chart-grid': '#E5E7EB',
|
||||||
'--chart-reference': '#5c6570',
|
'--chart-reference': '#5F6A75',
|
||||||
'--phase-primary': '#4a6072',
|
'--phase-primary': '#0F766E',
|
||||||
'--phase-secondary-text': '#4e4570',
|
'--phase-secondary-text': '#2A6180',
|
||||||
'--medal-gold': '#a67c00',
|
'--medal-gold': '#806200',
|
||||||
'--medal-silver': '#6f7580',
|
'--medal-silver': '#6B7580',
|
||||||
'--medal-bronze': '#8a5a2b',
|
'--medal-bronze': '#8A5A2B',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Read one token. Safe to call during SSR — returns the light-theme value. */
|
/** Read one token. Safe to call during SSR — returns the light-theme value. */
|
||||||
@@ -120,8 +120,8 @@ const SERIES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const SERIES_FALLBACK = [
|
const SERIES_FALLBACK = [
|
||||||
'#584a9b', '#0e6e66', '#9a5b00', '#a03a5e',
|
'#0F766E', '#36743F', '#806200', '#A9481F',
|
||||||
'#2f5f8f', '#4a6b2f', '#7a3f7a', '#97442a',
|
'#2F6F8F', '#5F4FA8', '#0E7A86', '#8A4A6B',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 7.4 KiB |
@@ -4,8 +4,8 @@
|
|||||||
"description": "Compare primary and secondary school performance across England",
|
"description": "Compare primary and secondary school performance across England",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#f2f1ed",
|
"background_color": "#FAFAF8",
|
||||||
"theme_color": "#f2f1ed",
|
"theme_color": "#0F766E",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "/icon.svg",
|
||||||
|
|||||||