Files
school_compare/nextjs-app/app/globals.css
TudorandClaude Opus 5 8c3a5cc4e9
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
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 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 1m8s
fix(design): keep below/attention off the brand hue, harden the share-card fonts
Review follow-up on #86.

The blind coral -> brand rename recreated the exact collision this PR set out
to remove: coral had been both the primary CTA and the "below average" signal,
so every negative indicator followed --primary onto iris. Sixteen rules moved
back onto the status ramp — delta chips, trend-down arrows, progress-negative
values, statusBad, chipBad/badgeBad, and the urgent deadline chips.

The Ofsted scale had also lost its worst step, with grade 4 landing on brand
while 1-2 were teal and 3 was amber. It now escalates by weight rather than by
reaching for another hue: a tinted amber chip for "requires improvement", a
solid amber one for "inadequate" (5.1:1 light, 7.7:1 dark). Report-card grade 5
follows the same rule.

globals.css now describes status as valence — teal above/good, amber
below/needs-attention — which is what it has to mean for an urgent deadline,
rather than the narrower "comparison point only" the first draft claimed.

On the share-card fonts: /opengraph-image is prerendered, so the font read
happens in the builder stage where assets/ exists, and the baked PNG ships
inside .next/standalone/.next/server/app/. File tracing independently places
the fonts at .next/standalone/assets, which the existing standalone COPY
carries to /app/assets. So the reported ENOENT doesn't occur — but it depends
on the tracer resolving a runtime join(), and a miss would be a silent 500
rather than a build failure. Declared outputFileTracingIncludes for the route
and made the Dockerfile COPY explicit so neither is left to inference.

Also repointed the immutable Cache-Control rule from the deleted favicon.svg
to app/icon.svg, where it was caching a 404.

Verified: tsc clean, 159/159 tests, clean rebuild prerenders all three image
routes with the fonts present in standalone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:29:53 +01:00

434 lines
15 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;
}
/* 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;
}
/* 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;
}
}