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 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m22s
Staging audit of the Cohort identity found the whole site rendering in Times. Root cause: next/font's variable classes were on <body>, while globals.css declares --font-display/--font-ui/--font-prose on :root as `var(--font-schibsted), ...`. A custom property's var() references resolve on the element that declares it, so at :root --font-schibsted was undefined, --font-display computed to the guaranteed-invalid value, and every font-family referencing it fell back. Nothing threw and the build was green — the only symptom was visual. Verified the mechanism in a browser both ways round: class on <body> gives an empty token and a Times body font; class on <html> resolves to Schibsted Grotesk. The classes now sit on <html>. Two colour escapes from the same audit: * Form controls don't inherit font or colour from their parent, so the omni search input and the map's "Open full map" button rendered in the system font at pure black. Nearly invisible against --text-primary in light mode and completely invisible on the dark ground. Added a base inherit rule. * Leaflet ships its own palette — a #ddd tile backdrop, #333 attribution text and a #0078A8 link blue that was the most saturated colour anywhere on the site. The map chrome now uses tokens; the tiles stay as OSM renders them. Three e2e gates added, because the existing suite passed while the site was entirely in Times: * the font tokens resolve to a non-empty value and the applied family is Schibsted, not a serif fallback * no visible element renders in the browser's default black * every rendered colour comes from the token palette — the manual audit, turned into a gate Also made the og:image check environment-relative: metadataBase pins canonical URLs to the production host, so the absolute URL pointed off-environment when the suite ran against staging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
480 lines
16 KiB
CSS
480 lines
16 KiB
CSS
/*
|
|
* SchoolCompare.co.uk — "Cohort"
|
|
*
|
|
* A school's number means nothing on its own; its position in the national
|
|
* distribution does. That idea drives the identity: the logo is the same
|
|
* five-bar spread that appears inside every school row.
|
|
*
|
|
* Colour has exactly three jobs and they never borrow each other's hues:
|
|
* brand (iris) interactive + identity. Never carries valence.
|
|
* status (teal / amber) valence — teal is above/good, amber is
|
|
* below/needs-attention. Never a CTA.
|
|
* phase (desaturated) category chips, deliberately subordinate.
|
|
*
|
|
* Escalate within status by weight, not by reaching for a new hue: a tinted
|
|
* amber chip for "requires improvement", a solid amber one for "inadequate".
|
|
*
|
|
* Teal/amber rather than green/red so the above/below signal survives every
|
|
* form of colour blindness — the audience includes parents reading closely.
|
|
*/
|
|
|
|
:root {
|
|
color-scheme: light;
|
|
|
|
/* ── Ground ─────────────────────────────────────────────────────── */
|
|
--bg-primary: #f2f1ed; /* paper, not cream — warm enough to read as a document */
|
|
--bg-secondary: #e9e8e2;
|
|
--bg-card: #fcfcfa;
|
|
--surface-inverse: #16202a; /* dark fills: skip link, active nav, tooltips */
|
|
|
|
/* ── Ink ────────────────────────────────────────────────────────── */
|
|
--text-primary: #16202a;
|
|
--text-secondary: #4a545f;
|
|
--text-muted: #5c6570; /* 5.3:1 on --bg-primary */
|
|
--text-inverse: #f7f7f4;
|
|
|
|
/* ── Line ───────────────────────────────────────────────────────── */
|
|
--border: #dedcd5;
|
|
--border-strong: #cdcac1;
|
|
|
|
/* ── Brand: iris ────────────────────────────────────────────────── */
|
|
--brand: #584a9b; /* 6.9:1 on --bg-primary */
|
|
--brand-strong: #473c80; /* hover on brand fills */
|
|
--brand-stronger: #3a3069; /* active/pressed */
|
|
--brand-bg: rgba(88, 74, 155, 0.10);
|
|
--brand-on: #fcfcfa; /* text/icon sitting on a brand fill */
|
|
|
|
/* ── Status: above / at / below the comparison point ────────────── */
|
|
--status-above: #0e6e66; /* 5.2:1 on --bg-primary */
|
|
--status-above-bg: rgba(14, 110, 102, 0.10);
|
|
--status-below: #9a5b00; /* 4.8:1 on --bg-primary */
|
|
--status-below-bg: rgba(154, 91, 0, 0.11);
|
|
--status-at: var(--text-muted);
|
|
--status-at-bg: rgba(92, 101, 112, 0.10);
|
|
|
|
/* ── Charts: the same three hues, extended by lightness only ────── */
|
|
--chart-1: #584a9b;
|
|
--chart-2: #0e6e66;
|
|
--chart-3: #9a5b00;
|
|
--chart-4: #8a7cc9;
|
|
--chart-5: #3e9c92;
|
|
--chart-6: #c9903d;
|
|
--chart-grid: #dedcd5;
|
|
--chart-reference: #5c6570; /* national/LA reference lines */
|
|
|
|
/* ── Series: up to 8 schools compared at once ───────────────────────
|
|
Categorical needs distinguishable hues, so this ramp is wider than the
|
|
three-hue rule — but every step is held to the same mid-dark tone and
|
|
moderate saturation, and every one clears AA on --bg-primary, so they
|
|
read as one family and work as legend text as well as chart lines. */
|
|
--series-1: #584a9b;
|
|
--series-2: #0e6e66;
|
|
--series-3: #9a5b00;
|
|
--series-4: #a03a5e;
|
|
--series-5: #2f5f8f;
|
|
--series-6: #4a6b2f;
|
|
--series-7: #7a3f7a;
|
|
--series-8: #97442a;
|
|
|
|
/* ── Phase: category, desaturated so it stays under the status hues ── */
|
|
--phase-primary: #4a6072;
|
|
--phase-primary-bg: rgba(74, 96, 114, 0.10);
|
|
--phase-primary-text: #3b4e5e;
|
|
--phase-secondary: #5f5480;
|
|
--phase-secondary-bg: rgba(95, 84, 128, 0.10);
|
|
--phase-secondary-text: #4e4570;
|
|
--phase-all-through: #4b6b57;
|
|
--phase-all-through-bg: rgba(75, 107, 87, 0.10);
|
|
--phase-all-through-text: #3d5847;
|
|
--phase-post16: #7a6242;
|
|
--phase-post16-bg: rgba(122, 98, 66, 0.10);
|
|
--phase-post16-text: #645036;
|
|
--phase-nursery: #7a5560;
|
|
--phase-nursery-bg: rgba(122, 85, 96, 0.10);
|
|
--phase-nursery-text: #64454f;
|
|
|
|
/* ── Medals: rankings podium. Metal, not palette — kept legible on
|
|
both grounds rather than literally gold/silver/bronze. ───────── */
|
|
--medal-gold: #a67c00;
|
|
--medal-silver: #6f7580;
|
|
--medal-bronze: #8a5a2b;
|
|
|
|
/* ── Channels ───────────────────────────────────────────────────────
|
|
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
|
|
sync with the hex tokens above — they are the same colours. */
|
|
--brand-rgb: 88, 74, 155;
|
|
--status-above-rgb: 14, 110, 102;
|
|
--status-below-rgb: 154, 91, 0;
|
|
--ink-rgb: 22, 32, 42;
|
|
--text-inverse-rgb: 247, 247, 244;
|
|
--shadow-rgb: 22, 32, 42;
|
|
--muted-rgb: 92, 101, 112;
|
|
--phase-secondary-rgb: 95, 84, 128;
|
|
--medal-silver-rgb: 111, 117, 128;
|
|
--medal-bronze-rgb: 138, 90, 43;
|
|
|
|
/* ── Elevation ──────────────────────────────────────────────────── */
|
|
--shadow-soft: 0 1px 2px rgba(22, 32, 42, 0.04), 0 2px 8px rgba(22, 32, 42, 0.05);
|
|
--shadow-medium: 0 4px 20px rgba(22, 32, 42, 0.09);
|
|
--shadow-strong: 0 8px 40px rgba(22, 32, 42, 0.14);
|
|
--scrim: rgba(22, 32, 42, 0.55);
|
|
|
|
/* ── Type ───────────────────────────────────────────────────────── */
|
|
--font-display: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
--font-ui: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
--font-data: var(--font-schibsted), 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
--font-prose: var(--font-literata), 'Literata', Georgia, serif;
|
|
|
|
/* Type scale, 1.2 ratio off a 1rem base. New work should use these
|
|
rather than inventing another font-size. */
|
|
--step--2: 0.694rem;
|
|
--step--1: 0.833rem;
|
|
--step-0: 1rem;
|
|
--step-1: 1.2rem;
|
|
--step-2: 1.44rem;
|
|
--step-3: 1.728rem;
|
|
--step-4: 2.074rem;
|
|
--step-5: 2.488rem;
|
|
|
|
/* ── Geometry & motion ──────────────────────────────────────────── */
|
|
--radius-sm: 3px;
|
|
--radius-md: 6px;
|
|
--radius-lg: 10px;
|
|
--radius-xl: 16px;
|
|
|
|
--transition: 0.2s ease;
|
|
--transition-slow: 0.4s ease;
|
|
}
|
|
|
|
/*
|
|
* Dark theme. Not an inversion — the accent and both status hues are lifted
|
|
* independently so each still clears AA on the dark ground, and --brand-on
|
|
* flips to dark because the brand fill is now the lighter of the pair.
|
|
*
|
|
* Tokens only. Never style a component from inside this block, or the two
|
|
* themes drift apart the first time someone edits one of them.
|
|
*/
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
color-scheme: dark;
|
|
|
|
--bg-primary: #10151b;
|
|
--bg-secondary: #161c24;
|
|
--bg-card: #171d25;
|
|
--surface-inverse: #e4e9ee;
|
|
|
|
--text-primary: #e4e9ee;
|
|
--text-secondary: #b4bec8;
|
|
--text-muted: #8d99a6; /* 6.4:1 on --bg-primary */
|
|
--text-inverse: #10151b;
|
|
|
|
--border: #28313b;
|
|
--border-strong: #38434f;
|
|
|
|
--brand: #9c8ce8; /* 6.5:1 on --bg-primary */
|
|
--brand-strong: #b0a3ee;
|
|
--brand-stronger: #c2b8f3;
|
|
--brand-bg: rgba(156, 140, 232, 0.14);
|
|
--brand-on: #10151b;
|
|
|
|
--status-above: #3fb3a4; /* 7.2:1 */
|
|
--status-above-bg: rgba(63, 179, 164, 0.14);
|
|
--status-below: #d99b2e; /* 7.7:1 */
|
|
--status-below-bg: rgba(217, 155, 46, 0.14);
|
|
--status-at-bg: rgba(141, 153, 166, 0.14);
|
|
|
|
--chart-1: #9c8ce8;
|
|
--chart-2: #3fb3a4;
|
|
--chart-3: #d99b2e;
|
|
--chart-4: #6f5fc4;
|
|
--chart-5: #2b8a7e;
|
|
--chart-6: #a97420;
|
|
--chart-grid: #28313b;
|
|
--chart-reference: #8d99a6;
|
|
|
|
--series-1: #9c8ce8;
|
|
--series-2: #3fb3a4;
|
|
--series-3: #d99b2e;
|
|
--series-4: #e07a9d;
|
|
--series-5: #6fa8dc;
|
|
--series-6: #94c46b;
|
|
--series-7: #c48bc4;
|
|
--series-8: #e08a6b;
|
|
|
|
--phase-primary: #8aa4b8;
|
|
--phase-primary-bg: rgba(138, 164, 184, 0.14);
|
|
--phase-primary-text: #a3bacd;
|
|
--phase-secondary: #a294c4;
|
|
--phase-secondary-bg: rgba(162, 148, 196, 0.14);
|
|
--phase-secondary-text: #b7abd6;
|
|
--phase-all-through: #8bb098;
|
|
--phase-all-through-bg: rgba(139, 176, 152, 0.14);
|
|
--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;
|
|
|
|
--medal-gold: #d4a72c;
|
|
--medal-silver: #a8b0bb;
|
|
--medal-bronze: #c08552;
|
|
|
|
--brand-rgb: 156, 140, 232;
|
|
--status-above-rgb: 63, 179, 164;
|
|
--status-below-rgb: 217, 155, 46;
|
|
--ink-rgb: 228, 233, 238;
|
|
--text-inverse-rgb: 16, 21, 27;
|
|
--shadow-rgb: 0, 0, 0;
|
|
--muted-rgb: 141, 153, 166;
|
|
--phase-secondary-rgb: 162, 148, 196;
|
|
--medal-silver-rgb: 168, 176, 187;
|
|
--medal-bronze-rgb: 192, 133, 82;
|
|
|
|
--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-strong: 0 8px 40px rgba(0, 0, 0, 0.6);
|
|
--scrim: rgba(4, 7, 10, 0.7);
|
|
}
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html {
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
body {
|
|
font-family: var(--font-ui);
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
line-height: 1.6;
|
|
/* dvh (dynamic viewport) accounts for iOS Safari's collapsing toolbar;
|
|
fall back to vh on older engines that don't recognise dvh. */
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
/* Suppress the iOS Safari grey tap flash — explicit :active states
|
|
below carry the press feedback instead. */
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
/* Form controls don't inherit font or colour from their parent — the UA
|
|
supplies its own. Without this they render in the system font at pure
|
|
black, which is nearly invisible against --text-primary in light mode and
|
|
completely invisible on the dark ground. */
|
|
input,
|
|
select,
|
|
textarea,
|
|
button {
|
|
font-family: inherit;
|
|
color: inherit;
|
|
}
|
|
|
|
/* Every digit that could line up in a column does. This is a data product;
|
|
proportional numerals in a results table read as amateur. */
|
|
table,
|
|
[data-numeric] {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Prose gets the serif; the interface never does. */
|
|
.prose {
|
|
font-family: var(--font-prose);
|
|
font-size: var(--step-0);
|
|
line-height: 1.65;
|
|
}
|
|
|
|
/* Provide a baseline press feedback for the most common interactive
|
|
elements — replaces the suppressed default tap highlight. Buttons and
|
|
.btn-* classes carry their own :active states already; this handles
|
|
plain anchors used as inline links and bare button elements. */
|
|
@media (hover: none) and (pointer: coarse) {
|
|
a:active,
|
|
button:active {
|
|
opacity: 0.7;
|
|
}
|
|
}
|
|
|
|
/* Reserve space for the fixed mobile bottom tab bar (56px + safe-area inset). */
|
|
@media (max-width: 640px) {
|
|
body {
|
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
|
}
|
|
}
|
|
|
|
/* Skip link — visible only on focus for keyboard users */
|
|
.skip-link {
|
|
position: absolute;
|
|
top: -100px;
|
|
left: 1rem;
|
|
z-index: 10000;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--surface-inverse);
|
|
color: var(--text-inverse);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
border-radius: var(--radius-md);
|
|
text-decoration: none;
|
|
transition: top 0.15s ease;
|
|
}
|
|
.skip-link:focus {
|
|
top: 0.5rem;
|
|
}
|
|
|
|
/* Focus indicators — brand hue, visible on both grounds */
|
|
:focus-visible {
|
|
outline: 2px solid var(--brand);
|
|
outline-offset: 2px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
/* ================================================================
|
|
Shared button classes — use these across all components
|
|
================================================================ */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.375rem;
|
|
padding: 0.5rem 1rem;
|
|
font-family: var(--font-ui);
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid transparent;
|
|
cursor: pointer;
|
|
transition: background var(--transition), border-color var(--transition), color var(--transition);
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Primary: brand fill — main CTAs (Search, Compare now) */
|
|
.btn-primary {
|
|
background: var(--brand);
|
|
color: var(--brand-on);
|
|
border-color: var(--brand);
|
|
}
|
|
.btn-primary:hover:not(:disabled) {
|
|
background: var(--brand-strong);
|
|
border-color: var(--brand-strong);
|
|
}
|
|
.btn-primary:active:not(:disabled) {
|
|
background: var(--brand-stronger);
|
|
border-color: var(--brand-stronger);
|
|
}
|
|
|
|
/* Secondary: brand outline — supporting actions (Add to shortlist) */
|
|
.btn-secondary {
|
|
background: transparent;
|
|
color: var(--brand);
|
|
border: 1px solid var(--brand);
|
|
}
|
|
.btn-secondary:hover:not(:disabled) {
|
|
background: var(--brand-bg);
|
|
}
|
|
|
|
/* Tertiary: quiet — low-emphasis (View, Clear) */
|
|
.btn-tertiary {
|
|
background: var(--bg-secondary);
|
|
color: var(--text-secondary);
|
|
border: 1px solid var(--border);
|
|
}
|
|
.btn-tertiary:hover:not(:disabled) {
|
|
background: var(--border);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Active toggle state — the shortlist button once a school is on the list */
|
|
.btn-active {
|
|
background: var(--brand-bg);
|
|
color: var(--brand);
|
|
border: 1px solid var(--brand);
|
|
}
|
|
.btn-active:hover:not(:disabled) {
|
|
background: transparent;
|
|
color: var(--text-secondary);
|
|
border-color: var(--border-strong);
|
|
}
|
|
|
|
/* Small variant */
|
|
.btn-sm {
|
|
padding: 0.3rem 0.625rem;
|
|
font-size: 0.8125rem;
|
|
}
|
|
|
|
/*
|
|
* Leaflet ships its own palette — a #ddd tile backdrop, #333 attribution text
|
|
* and a #0078A8 link blue that is the most saturated colour anywhere on the
|
|
* site and belongs to no part of this system. Pull the map chrome onto the
|
|
* tokens. This matters most in dark mode, where Leaflet's white attribution
|
|
* bar would otherwise sit on a near-black page.
|
|
*
|
|
* The tiles themselves stay as OSM renders them; only the chrome is ours.
|
|
*/
|
|
.leaflet-container {
|
|
background: var(--bg-secondary);
|
|
font-family: var(--font-ui);
|
|
}
|
|
|
|
.leaflet-control-attribution {
|
|
background: rgba(var(--text-inverse-rgb), 0.82);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.leaflet-control-attribution a {
|
|
color: var(--brand);
|
|
}
|
|
|
|
.leaflet-bar a {
|
|
background: var(--bg-card);
|
|
color: var(--text-primary);
|
|
border-bottom-color: var(--border);
|
|
}
|
|
|
|
.leaflet-bar a:hover {
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Main content column */
|
|
.main {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 1.25rem 1.5rem;
|
|
min-width: 0;
|
|
overflow-x: clip;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.main {
|
|
padding: 1rem;
|
|
}
|
|
}
|
|
|
|
/* Honour the OS setting. Transitions collapse to near-instant rather than
|
|
zero so state changes still register as changes. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
html {
|
|
scroll-behavior: auto;
|
|
}
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
scroll-behavior: auto !important;
|
|
}
|
|
}
|