Files
school_compare/nextjs-app/app/globals.css
T
TudorandClaude Opus 5 45a3e7fb8f
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m10s
fix(design): correct the font assertion and make the Leaflet overrides win
Two follow-ups from watching the staging E2E run and re-auditing the live site.

The typeface gate failed, but the site was right. Staging now serves
"Schibsted Grotesk", "Schibsted Grotesk Fallback", …, -apple-system, sans-serif
— exactly what it should. My assertion was /Times|serif$/, and `serif$` matches
the tail of `sans-serif`, so a healthy stack could never pass. It now checks
the FIRST family in the stack (the only one actually requested) and matches
Times as a whole word. Also asserts running prose resolves to Literata, which
the earlier version never covered.

The Leaflet overrides from the previous fix never took effect. leaflet.css is
imported from a client component, so its chunk loads after globals.css; at
equal specificity (both bare `.leaflet-container`) the later sheet wins.
Verified on the live page: my rule sat at stylesheet index 0, Leaflet's at
index 3, and the attribution link was still #0078A8. Prefixing the overrides
with `html` takes them to 0,1,1, which beats a bare class regardless of load
order — injecting that on the live page turned the background to
rgb(233,232,226) and the link to rgb(88,74,155).

The palette gate would have caught this, except it skipped everything inside
.leaflet-container to avoid flagging OSM tile imagery — which also skipped
Leaflet's own chrome. Narrowed the exemption to .leaflet-tile-pane so the map
controls are now covered.

Also dropped the duplicated family name from the font tokens: next/font
already expands the variable to the family plus its metric-matched fallback,
so the stack was naming Schibsted Grotesk twice. It looked like a safety net
but wasn't — an unresolvable var() with no fallback invalidates the whole
declaration, so the literal after it never gets a turn.

Verified against live staging: body and headings resolve to Schibsted Grotesk,
prose to Literata, the search input is --text-primary rather than pure black,
and the higher-specificity Leaflet rules win when injected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:04:36 +01:00

492 lines
17 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 ───────────────────────────────────────────────────────── */
/* next/font already expands --font-schibsted to the family plus its
metric-matched fallback, so naming the family again here only made the
stack say it twice. It read like a safety net but wasn't one: a var()
with no fallback that resolves to nothing invalidates the whole
declaration, so the literal after it never gets a turn. The real
safeguard is that these classes sit on <html>, where :root can see
them — see app/layout.tsx. */
--font-display: var(--font-schibsted), -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
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.
*
* Every selector here is prefixed with `html` on purpose. leaflet.css is
* imported from a client component, so its chunk loads AFTER globals.css; at
* equal specificity the later sheet wins and these overrides lose silently.
* The `html` prefix takes them to 0,1,1 so load order stops mattering.
*/
html .leaflet-container {
background: var(--bg-secondary);
font-family: var(--font-ui);
}
html .leaflet-control-attribution {
background: rgba(var(--text-inverse-rgb), 0.82);
color: var(--text-muted);
}
html .leaflet-control-attribution a {
color: var(--brand);
}
html .leaflet-bar a {
background: var(--bg-card);
color: var(--text-primary);
border-bottom-color: var(--border);
}
html .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;
}
}