),
{
...size,
fonts: [
- { name: 'Schibsted', data: regular, weight: 400, style: 'normal' },
- { name: 'Schibsted', data: bold, weight: 700, style: 'normal' },
+ { name: 'Manrope', data: regular, weight: 400, style: 'normal' },
+ { name: 'Manrope', data: bold, weight: 700, style: 'normal' },
],
}
);
diff --git a/nextjs-app/app/school/[slug]/page.tsx b/nextjs-app/app/school/[slug]/page.tsx
index 56a3daf..538ee3d 100644
--- a/nextjs-app/app/school/[slug]/page.tsx
+++ b/nextjs-app/app/school/[slug]/page.tsx
@@ -98,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise
0
- ? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
- : 'SchoolCompare',
+ ? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
+ : 'schoolcompare',
url,
};
if (
diff --git a/nextjs-app/components/EditorialSection.tsx b/nextjs-app/components/EditorialSection.tsx
index 0cbc4af..f875e99 100644
--- a/nextjs-app/components/EditorialSection.tsx
+++ b/nextjs-app/components/EditorialSection.tsx
@@ -20,7 +20,7 @@ export function EditorialSection({ totalSchools, localAuthorityCount }: Editoria
and demographics — each in its own table, each with its own jargon.
- SchoolCompare brings it all into one place. Every school page shows performance against the national
+ schoolcompare brings it all into one place. Every school page shows performance against the national
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
parents, governors, journalists, and anyone who wants to understand a school without reading a
full inspection report.
diff --git a/nextjs-app/components/FilterBar.module.css b/nextjs-app/components/FilterBar.module.css
index 50772b4..8a085b2 100644
--- a/nextjs-app/components/FilterBar.module.css
+++ b/nextjs-app/components/FilterBar.module.css
@@ -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 {
background: var(--bg-card);
border: 1px solid var(--border);
- border-radius: 10px;
- padding: 1.5rem;
- margin-bottom: 2rem;
- box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
- transition: opacity 0.2s ease;
+ border-radius: var(--radius-lg);
+ padding: 1rem 1.125rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--shadow-soft);
+ transition: opacity var(--transition);
}
.filterBar.isLoading {
@@ -14,112 +29,16 @@
}
.heroMode {
- padding: 2.5rem;
- max-width: 800px;
- margin: 0 auto 3rem auto;
- box-shadow: 0 8px 24px rgba(var(--shadow-rgb), 0.08);
- border-width: 2px;
- border-color: var(--brand);
+ background: none;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ padding: 0;
+ max-width: 640px;
+ margin: 0 auto 1.5rem;
}
-.heroMode .omniInput {
- 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;
- }
-}
+/* ── Search ───────────────────────────────────────────────────────── */
.searchSection {
margin-bottom: 0;
@@ -127,43 +46,90 @@
.omniBoxContainer {
display: flex;
+ align-items: center;
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 {
flex: 1;
- padding: 0.875rem 1.25rem;
- font-size: 1.05rem;
- border: 2px solid var(--border);
- border-radius: 8px;
- outline: none;
- transition: all 0.2s ease;
+ min-width: 0;
+ padding: 0.6875rem 0.875rem;
+ font-family: var(--font-ui);
+ /* 1rem, not smaller: iOS Safari zooms the viewport on any control under
+ 16px, which throws the user out of the hero on first tap. */
+ font-size: var(--step-0);
+ color: var(--text-primary);
background: var(--bg-card);
- font-family: inherit;
-}
-
-.omniInput:focus {
- border-color: var(--brand);
- box-shadow: 0 0 0 3px var(--brand-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ outline: none;
+ transition: border-color var(--transition), box-shadow var(--transition);
}
.omniInput::placeholder {
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 {
- padding: 0.875rem 2rem;
- font-size: 1.05rem;
- border-radius: 8px;
- min-width: 120px;
+ flex: 0 0 auto;
+ padding: 0.6875rem 1.25rem;
+ font-size: var(--step--1);
+ border-radius: var(--radius-sm);
+}
+
+.heroMode .searchButton {
+ padding: 0.75rem 1.375rem;
}
.spinner {
- width: 20px;
- height: 20px;
- border: 2px solid rgba(var(--text-inverse-rgb), 0.3);
+ width: 1.0625rem;
+ height: 1.0625rem;
+ border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
+ border-top-color: currentColor;
border-radius: 50%;
- border-top-color: var(--brand-on);
animation: spin 0.8s linear infinite;
}
@@ -171,103 +137,86 @@
to { transform: rotate(360deg); }
}
-.filters {
- display: flex;
- gap: 0.625rem;
- flex-wrap: wrap;
- margin-top: 0.625rem;
- padding-top: 0.625rem;
- border-top: 1px solid var(--border);
+/* ── Hero supporting copy ─────────────────────────────────────────── */
+
+.searchHint {
+ margin: 0.75rem 0 0;
+ font-family: var(--font-ui);
+ font-size: var(--step--1);
+ line-height: 1.5;
+ color: var(--text-muted);
+ text-align: center;
}
-.filterSelect {
- 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;
+.searchHint strong {
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 {
display: flex;
align-items: center;
@@ -289,65 +238,139 @@
flex-shrink: 0;
}
-/* Pill-style inline filter controls (radius + phase) */
-.controlSelect {
- appearance: none;
- -webkit-appearance: none;
- 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;
+.radiusLabel {
+ font-size: var(--step--2);
+ font-weight: 600;
+ color: var(--text-secondary);
white-space: nowrap;
}
-.controlSelect:hover {
- border-color: var(--text-muted);
+/* ── Selects ──────────────────────────────────────────────────────── */
+
+.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);
}
+.filterSelect:focus,
.controlSelect:focus {
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 {
display: inline-flex;
align-items: center;
gap: 0.375rem;
+ padding: 0.4375rem 0.75rem;
background: none;
- border: 1.5px solid var(--border);
- border-radius: 999px;
- padding: 0.4rem 0.875rem;
- font-size: 0.8125rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-ui);
+ font-size: var(--step--1);
font-weight: 500;
+ line-height: 1;
color: var(--text-secondary);
cursor: pointer;
- font-family: inherit;
- transition: all 0.15s ease;
white-space: nowrap;
+ transition: border-color var(--transition), background-color var(--transition),
+ color var(--transition);
}
.advancedToggle:hover {
- border-color: var(--text-muted);
+ border-color: var(--border-strong);
background-color: var(--bg-secondary);
color: var(--text-primary);
}
-/* When filters are applied, promote the toggle to a coral pill so users
- can see at a glance that the result list is being narrowed. */
+/* Applied filters read as a brand chip, so it's obvious at a glance that
+ the list below is being narrowed. */
.advancedToggleActive {
border-color: var(--brand);
background: var(--brand-bg);
@@ -356,11 +379,18 @@
}
.advancedToggleActive:hover {
- border-color: var(--brand-strong);
+ border-color: var(--brand);
background: var(--brand-bg);
color: var(--brand-strong);
}
+.clearButton {
+ padding: 0.4375rem 0.875rem;
+ font-size: var(--step--1);
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+}
+
.chevronDown,
.chevronUp {
display: inline-block;
@@ -377,3 +407,70 @@
.chevronUp {
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;
+ }
+}
diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx
index 91abf00..0b542e5 100644
--- a/nextjs-app/components/FilterBar.tsx
+++ b/nextjs-app/components/FilterBar.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
+import type { ReactNode } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode } from "@/lib/utils";
import { track } from "@/lib/analytics";
@@ -18,6 +19,28 @@ interface FilterBarProps {
geoError?: string | null;
}
+/**
+ * Wraps a native 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 (
+
+ {children}
+
+
+ );
+}
+
export function FilterBar({
filters,
isHero,
@@ -172,12 +195,33 @@ export function FilterBar({
const isSecondaryMode =
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 (