+ {/*
+ 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.
+ */}
+
Find the right school. For their future.
Compare primary and secondary schools across England.
diff --git a/nextjs-app/components/HomeView.module.css b/nextjs-app/components/HomeView.module.css
index aec98f9..f0db753 100644
--- a/nextjs-app/components/HomeView.module.css
+++ b/nextjs-app/components/HomeView.module.css
@@ -2,12 +2,68 @@
width: 100%;
}
-.heroSection {
- text-align: center;
+/* ── Hero ──────────────────────────────────────────────────────────────────
+ 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;
- 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 {
display: inline-flex;
align-items: center;
@@ -32,78 +88,175 @@
}
.heroTitle {
- font-size: 3rem;
+ font-size: var(--step-4);
font-weight: 700;
color: var(--text-primary);
- margin-bottom: 0.85rem;
- line-height: 1.08;
- letter-spacing: -0.015em;
+ margin-bottom: 0.75rem;
+ line-height: 1.12;
+ letter-spacing: -0.03em;
font-family: var(--font-display);
- max-width: 840px;
- margin-left: auto;
- margin-right: auto;
+ max-width: 18ch;
}
+/* 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 {
- color: var(--brand-strong);
- font-style: italic;
+ color: var(--brand);
+ font-style: normal;
}
.heroDescription {
- font-size: 1.05rem;
+ font-size: var(--step-1);
color: var(--text-secondary);
- margin: 0 auto 0.5rem;
- max-width: 680px;
+ margin: 0 0 1.5rem;
+ max-width: 46ch;
line-height: 1.55;
}
.heroDescription strong {
color: var(--text-primary);
- font-weight: 700;
+ font-weight: 600;
}
-/* Copy variants: full paragraph ≥641px, compact merged line on phones. */
-.heroDescriptionCompact {
- display: none;
+@media (max-width: 1024px) {
+ .heroContent {
+ padding: 2.25rem 1.5rem 2.25rem 2rem;
+ }
+ .heroArt {
+ min-height: 20rem;
+ }
}
-@media (max-width: 768px) {
- .heroSection {
- padding-top: 1.5rem;
+/* One column: content first, then a short band of landscape. */
+@media (max-width: 860px) {
+ .heroPanel {
+ grid-template-columns: 1fr;
+ }
+ .heroContent {
+ padding: 2rem 1.5rem 1.75rem;
+ }
+ .heroArt {
+ min-height: 11rem;
+ order: 2;
}
.heroTitle {
- font-size: 2rem;
+ font-size: var(--step-3);
+ max-width: none;
}
.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
- the long descriptive paragraph for one compact line that carries coverage
- ("24,000+ schools") and freshness ("updated for 2026/27") — a first-time
- visitor otherwise sees only a poetic h1 and a bare box, with no reason to
- trust the data (audit P1.7, feeds the 46% home exit rate). */
+/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
+ long coverage sentence, leaving the proposition and the search — a
+ first-time visitor still gets the trust line beneath the box, which carries
+ the data provenance the coverage sentence used to (audit P1.7, feeds the
+ 46% home exit rate). */
@media (max-width: 640px) {
- .heroSection {
- padding-top: 0.75rem;
+ .heroPanel {
margin-bottom: 1rem;
+ border-radius: var(--radius-lg);
+ }
+ .heroContent {
+ padding: 1.25rem 1.1rem 1.5rem;
}
.heroEyebrow,
.heroDescriptionFull {
display: none;
}
- .heroDescriptionCompact {
- display: block;
- }
.heroDescription {
- font-size: 0.85rem;
- line-height: 1.4;
- max-width: 320px;
- margin-bottom: 0.25rem;
+ font-size: 0.9rem;
+ line-height: 1.45;
+ margin-bottom: 1rem;
}
.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;
}
}
diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx
index 22a56c7..20c0537 100644
--- a/nextjs-app/components/HomeView.tsx
+++ b/nextjs-app/components/HomeView.tsx
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap';
import { EmptyState } from './EmptyState';
+import { HeroIllustration } from './Illustration';
import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
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 },
];
+/* ── 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 (
+
+ );
+}
+
+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:
,
+ tintClass: styles.propIconTrust,
+ title: 'Official & trusted',
+ body: 'Every figure comes from DfE performance tables and Ofsted.',
+ },
+ {
+ icon:
,
+ tintClass: styles.propIconCompare,
+ title: 'Easy to compare',
+ body: 'Up to three schools side by side, on the measures that matter.',
+ },
+ {
+ icon:
,
+ tintClass: styles.propIconContext,
+ title: 'Beyond the numbers',
+ body: 'Class sizes, SEN support and local context, not just results.',
+ },
+ {
+ icon:
,
+ 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) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -256,38 +347,81 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
return (
- {/* Combined Hero + Search and Filters */}
- {!isSearchActive && (
-
-
-
- Updated for the 2026/27 admissions round
-
-
- Every school in England, compared.
-
-
- {/* 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. */}
-
- 24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
+ {/* Hero: a Sand panel with the proposition and the search on the left and
+ the brand landscape bleeding to the panel edge on the right. The
+ search lives inside the panel here and above the results elsewhere,
+ which is why FilterBar is rendered in two places rather than moved. */}
+ {!isSearchActive ? (
+
+
+
+
+ Updated for the 2026/27 admissions round
-
- 24,000+ English schools — SATs, GCSEs, Ofsted & admissions, side by side. Updated for 2026/27.
-
-
-
+
+ Find the school where your child will{' '}
+ flourish.
+
+
+ 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. */}
+
+ {' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
+ and admissions data for 24,000+ schools — side by
+ side, in one place.
+
+
+
+
+
+
+
+
+
+
+
+ Built on official DfE and Ofsted data — 24,000+ schools
+
+
+
+
+
+
+
+ ) : (
+
)}
-
+ {/* Why this site, in four lines. Only on the landing page. */}
+ {!isSearchActive && (
+
+ {VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
+
+ {icon}
+
+
+ ))}
+
+ )}
{/* Admissions countdown strip — only on landing page */}
{!isSearchActive && (
diff --git a/nextjs-app/components/Illustration.tsx b/nextjs-app/components/Illustration.tsx
new file mode 100644
index 0000000..e268e87
--- /dev/null
+++ b/nextjs-app/components/Illustration.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {/* Sky */}
+
+
+ {/* Two soft clouds, each a pair of overlapping ellipses */}
+
+
+
+
+
+
+
+ {/* Four layered rolling hills, palest and furthest first */}
+
+
+
+
+
+ {/* Winding cream path, with a dotted edge for a little texture */}
+
+
+
+ {/* The school at the top of the path */}
+
+
+
+
+
+
+
+
+
+
+ {/* Flagpole and coral pennant */}
+
+
+ {/* Clock in the gable */}
+
+
+
+
+ {/* Scattered rounded trees */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Location pin — you are here, the path leads up to the school */}
+
+
+
+
+
+ );
+}
diff --git a/nextjs-app/components/Logo.tsx b/nextjs-app/components/Logo.tsx
index bd2adaf..7809df0 100644
--- a/nextjs-app/components/Logo.tsx
+++ b/nextjs-app/components/Logo.tsx
@@ -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
- * the distribution strip inside a school row, at a different size — the logo
- * teaches the chart and the chart reinforces the logo.
+ * A location pin with a leaf growing inside it: where a school is, and a child
+ * growing there. The pin's tail sweeps left as the guideline's "signature path
+ * 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
- * inverts cleanly on any ground without a second artwork. The highlighted bar
- * is the only element that takes the brand hue.
+ * This is the single source for the mark. app/icon.svg, app/apple-icon.tsx and
+ * app/opengraph-image.tsx all derive from the same geometry (see MARK_PATHS),
+ * 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
- * derive from the same geometry (see BARS) — previously the header and the
- * favicon had drifted into two different logos.
+ * Drawn on a 48×48 grid. The pin takes the brand hue and the leaf is knocked
+ * out of it, so the mark needs exactly two colours and works on any ground.
*/
-/** x, y, width, height, and whether this bar is the highlighted school. */
-export const BARS: ReadonlyArray<[number, number, number, number, boolean]> = [
- [2, 26, 6, 12, false],
- [10, 16, 6, 22, false],
- [18, 8, 6, 30, false],
- [26, 14, 6, 24, true],
- [34, 28, 4, 10, false],
-];
+/** Geometry shared by every rendering of the mark, on a 0 0 48 48 viewBox. */
+export const MARK_PATHS = {
+ /** The path tail, stroked — 8.5 wide with a round cap. */
+ tail: 'M17 30 C15 35.6 11.6 40.2 7.8 42.8',
+ /** The pin head. */
+ head: { cx: 26.5, cy: 18.5, r: 16.5 },
+ /** 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 `
` 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 [
+ '
',
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ` `,
+ ' ',
+ ].join('');
+}
+
+/** The same string as a data URI, ready for `
`. */
+export function markDataUri(pin: string, leaf: string): string {
+ return `data:image/svg+xml;base64,${Buffer.from(markSvg(pin, leaf)).toString('base64')}`;
+}
interface LogoMarkProps {
className?: string;
/** Rendered size in px. Defaults to inheriting via CSS. */
size?: number;
- /** Colour of the highlighted bar. Defaults to the brand token. */
- accent?: string;
+ /** The pin. Defaults to the brand token so it inverts with the theme. */
+ pin?: string;
+ /** The leaf knocked out of the pin. */
+ leaf?: 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 (
{title ? {title} : null}
- {BARS.map(([x, y, w, h, isSchool], i) => (
-
- ))}
+
+
+
+
+
);
}
diff --git a/nextjs-app/components/Navigation.module.css b/nextjs-app/components/Navigation.module.css
index 2d0073b..3bea92b 100644
--- a/nextjs-app/components/Navigation.module.css
+++ b/nextjs-app/components/Navigation.module.css
@@ -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 {
position: sticky;
top: 0;
z-index: 1000;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
- box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
}
.container {
@@ -16,35 +23,42 @@
display: flex;
justify-content: space-between;
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;
}
.logo {
display: flex;
align-items: center;
- gap: 0.75rem;
+ gap: 0.625rem;
/* Padded hit area so the logo link is ≥44×44 on touch */
margin: -0.375rem -0.5rem;
padding: 0.375rem 0.5rem;
text-decoration: none;
color: var(--text-primary);
- font-size: 1.25rem;
- font-weight: 700;
- transition: color 0.2s ease;
+ transition: color var(--transition);
-webkit-tap-highlight-color: transparent;
}
.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 {
display: inline-flex;
- width: 32px;
- height: 32px;
- color: var(--text-primary);
+ flex: 0 0 auto;
+ width: 36px;
+ height: 36px;
}
.logoIcon svg {
@@ -52,10 +66,17 @@
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 {
font-family: var(--font-display);
+ font-size: var(--step-2);
font-weight: 700;
- letter-spacing: -0.022em;
+ line-height: 1;
+ letter-spacing: -0.035em;
}
.logoTextAccent {
@@ -63,41 +84,28 @@
}
.logo:hover .logoTextAccent {
- color: var(--brand-strong);
+ color: var(--brand-stronger);
}
.nav {
display: flex;
- gap: 0.5rem;
+ align-items: center;
+ gap: 0.125rem;
}
.navLink {
position: relative;
display: flex;
align-items: center;
- gap: 0.5rem;
- padding: 0.625rem 1rem;
- font-size: 0.9375rem;
+ gap: 0.4375rem;
+ padding: 0.5rem 0.875rem;
+ font-family: var(--font-ui);
+ font-size: var(--step--1);
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
- border-radius: 6px;
- transition: all 0.2s ease;
-}
-
-/* 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;
+ border-radius: var(--radius-md);
+ transition: color var(--transition), background-color var(--transition);
}
.navLink:hover {
@@ -105,33 +113,29 @@
background: var(--bg-secondary);
}
-.navLink:hover::after {
- transform: scaleX(1);
-}
-
+/* The current page is marked twice — brand hue and a heavier weight — so the
+ state never rests on colour alone. */
.navLink.active {
- color: var(--brand-strong);
+ color: var(--brand);
background: var(--brand-bg);
-}
-
-.navLink.active::after {
- transform: scaleX(1);
+ font-weight: 600;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
- min-width: 1.25rem;
- height: 1.25rem;
+ min-width: 1.125rem;
+ height: 1.125rem;
padding: 0 0.375rem;
- font-size: 0.75rem;
- font-weight: 600;
+ font-family: var(--font-ui);
+ font-size: var(--step--2);
+ font-weight: 700;
+ line-height: 1;
color: var(--brand-on);
background: var(--brand);
border-radius: 9999px;
animation: badgePop 0.3s ease-out;
- box-shadow: 0 2px 6px rgba(var(--brand-rgb), 0.4);
}
@keyframes badgePop {
@@ -159,25 +163,23 @@
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 0.2rem;
+ gap: 0.25rem;
flex: 1;
min-height: 56px;
padding: 0.375rem 0.25rem;
+ font-family: var(--font-ui);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.01em;
- transition: color 0.15s ease, background-color 0.15s ease;
+ transition: color var(--transition);
-webkit-tap-highlight-color: transparent;
}
-.tab:active {
- background: var(--bg-secondary);
-}
-
.tabActive {
- color: var(--brand-strong);
+ color: var(--brand);
+ font-weight: 600;
}
.tabIconWrap {
@@ -189,7 +191,29 @@
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 {
+ position: relative;
width: 22px;
height: 22px;
}
@@ -200,16 +224,18 @@
.tabBadge {
position: absolute;
- top: -6px;
- right: -10px;
+ top: -7px;
+ right: -7px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
+ font-family: var(--font-ui);
font-size: 0.6875rem;
font-weight: 700;
+ line-height: 1;
color: var(--brand-on);
background: var(--brand);
border: 2px solid var(--bg-card);
@@ -229,8 +255,12 @@
}
.logoIcon {
- width: 32px;
- height: 32px;
+ width: 34px;
+ height: 34px;
+ }
+
+ .logoText {
+ font-size: var(--step-1);
}
.bottomBar {
diff --git a/nextjs-app/components/Navigation.tsx b/nextjs-app/components/Navigation.tsx
index 407261a..1b00fae 100644
--- a/nextjs-app/components/Navigation.tsx
+++ b/nextjs-app/components/Navigation.tsx
@@ -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 = [
{ href: '/', label: 'Search', Icon: SearchIcon },
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
@@ -91,11 +101,18 @@ export function Navigation() {
+ {/*
+ 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.
+ */}
- SchoolCompare
+ schoolcompare
diff --git a/nextjs-app/lib/theme.ts b/nextjs-app/lib/theme.ts
index 31032c9..1566fa6 100644
--- a/nextjs-app/lib/theme.ts
+++ b/nextjs-app/lib/theme.ts
@@ -45,35 +45,35 @@ export type ThemeToken =
/** Values used before hydration and in any non-DOM context (SSR, tests). */
const FALLBACK: Record = {
- '--brand': '#584a9b',
- '--brand-strong': '#473c80',
- '--brand-bg': 'rgba(88, 74, 155, 0.10)',
- '--status-above': '#0e6e66',
- '--status-above-bg': 'rgba(14, 110, 102, 0.10)',
- '--status-below': '#9a5b00',
- '--status-below-bg': 'rgba(154, 91, 0, 0.11)',
- '--text-primary': '#16202a',
- '--text-secondary': '#4a545f',
- '--text-muted': '#5c6570',
- '--text-inverse': '#f7f7f4',
- '--bg-card': '#fcfcfa',
- '--bg-secondary': '#e9e8e2',
- '--border': '#dedcd5',
- '--border-strong': '#cdcac1',
- '--surface-inverse': '#16202a',
- '--chart-1': '#584a9b',
- '--chart-2': '#0e6e66',
- '--chart-3': '#9a5b00',
- '--chart-4': '#8a7cc9',
- '--chart-5': '#3e9c92',
- '--chart-6': '#c9903d',
- '--chart-grid': '#dedcd5',
- '--chart-reference': '#5c6570',
- '--phase-primary': '#4a6072',
- '--phase-secondary-text': '#4e4570',
- '--medal-gold': '#a67c00',
- '--medal-silver': '#6f7580',
- '--medal-bronze': '#8a5a2b',
+ '--brand': '#0F766E',
+ '--brand-strong': '#0C5F58',
+ '--brand-bg': 'rgba(15, 118, 110, 0.10)',
+ '--status-above': '#36743F',
+ '--status-above-bg': 'rgba(54, 116, 63, 0.12)',
+ '--status-below': '#A9481F',
+ '--status-below-bg': 'rgba(249, 115, 96, 0.15)',
+ '--text-primary': '#1C2731',
+ '--text-secondary': '#4A5560',
+ '--text-muted': '#5F6A75',
+ '--text-inverse': '#FFFFFF',
+ '--bg-card': '#FFFFFF',
+ '--bg-secondary': '#F5EFE6',
+ '--border': '#E5E7EB',
+ '--border-strong': '#D3D7DD',
+ '--surface-inverse': '#0F766E',
+ '--chart-1': '#0F766E',
+ '--chart-2': '#36743F',
+ '--chart-3': '#806200',
+ '--chart-4': '#A9481F',
+ '--chart-5': '#2F6F8F',
+ '--chart-6': '#5F4FA8',
+ '--chart-grid': '#E5E7EB',
+ '--chart-reference': '#5F6A75',
+ '--phase-primary': '#0F766E',
+ '--phase-secondary-text': '#2A6180',
+ '--medal-gold': '#806200',
+ '--medal-silver': '#6B7580',
+ '--medal-bronze': '#8A5A2B',
};
/** Read one token. Safe to call during SSR — returns the light-theme value. */
@@ -120,8 +120,8 @@ const SERIES = [
] as const;
const SERIES_FALLBACK = [
- '#584a9b', '#0e6e66', '#9a5b00', '#a03a5e',
- '#2f5f8f', '#4a6b2f', '#7a3f7a', '#97442a',
+ '#0F766E', '#36743F', '#806200', '#A9481F',
+ '#2F6F8F', '#5F4FA8', '#0E7A86', '#8A4A6B',
];
/**
diff --git a/nextjs-app/public/icon-192.png b/nextjs-app/public/icon-192.png
index 3aeabc2..db38a01 100644
Binary files a/nextjs-app/public/icon-192.png and b/nextjs-app/public/icon-192.png differ
diff --git a/nextjs-app/public/icon-512.png b/nextjs-app/public/icon-512.png
index 969395c..5fd5ab3 100644
Binary files a/nextjs-app/public/icon-512.png and b/nextjs-app/public/icon-512.png differ
diff --git a/nextjs-app/public/icon-maskable-512.png b/nextjs-app/public/icon-maskable-512.png
index 612182a..82a913f 100644
Binary files a/nextjs-app/public/icon-maskable-512.png and b/nextjs-app/public/icon-maskable-512.png differ
diff --git a/nextjs-app/public/manifest.json b/nextjs-app/public/manifest.json
index 413af82..d6a4c81 100644
--- a/nextjs-app/public/manifest.json
+++ b/nextjs-app/public/manifest.json
@@ -4,8 +4,8 @@
"description": "Compare primary and secondary school performance across England",
"start_url": "/",
"display": "standalone",
- "background_color": "#f2f1ed",
- "theme_color": "#f2f1ed",
+ "background_color": "#FAFAF8",
+ "theme_color": "#0F766E",
"icons": [
{
"src": "/icon.svg",