feat(design): adopt the Cohort identity — new palette, type, mark and dark theme
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
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 3m13s

Implements the direction agreed from the identity board: Route C ("Cohort")
with the paper ground from C1, the Schibsted Grotesk / Literata pairing from
C2, and the iris accent from C3. Dark theme is in scope from the start rather
than retrofitted.

The audit found three things wrong beyond taste:

* No brand asset set. og:image was absent entirely, so every link shared into
  a class WhatsApp group rendered as a bare grey card. apple-touch-icon pointed
  at an SVG, which iOS ignores, and the manifest shipped no PNGs, so Android
  installs had no icon. The header mark and the favicon had also drifted into
  two different logos.
* No colour discipline. --primary and --trend-down were the same coral, so the
  main CTA and "below average" shared a hue. 58 distinct hex values were spread
  across component CSS, and the chart palette was still Chart.js's stock demo
  colours.
* A dark theme that was declared but never built — themeColor announced a dark
  variant with no dark styling behind it.

What changed:

Colour now has exactly three jobs that never borrow each other's hues: brand
(iris) for interactive and identity, status (teal/amber) for above/below a
comparison point, and phase for categories. Teal/amber rather than green/red
keeps the above/below signal readable for every form of colour blindness.
Every chromatic literal in component CSS is now a token, and the JS-painted
surfaces (Chart.js, Leaflet) read the tokens through lib/theme so they follow
the theme instead of ignoring it.

The mark is the five-bar cohort spread — the same object as the distribution
strip inside a school row, built from opacity steps so it inverts cleanly.
components/Logo.tsx is the single source; the favicon, apple-icon and share
card all derive from its geometry.

globals.css drops 123 dead global classes left over from the vanilla-JS app
(only the btn family, .skip-link and .main were still referenced), along with
the noise overlay. It also gains prefers-reduced-motion support, which was
missing entirely, and a type scale so the 54 ad-hoc font sizes have somewhere
to converge.

Verified: tsc clean, 159 unit tests pass, production build succeeds and
prerenders /icon.svg, /apple-icon and /opengraph-image. Three e2e journeys
added for the asset set, the themeColor/background match, and the dark theme
actually repainting — all silent failures that nothing on the page reveals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-08-06 12:13:04 +01:00
co-authored by Claude Opus 5
parent c298a29b61
commit 8ab0ac0a04
56 changed files with 2019 additions and 3073 deletions
+163
View File
@@ -0,0 +1,163 @@
/**
* Bridge between the CSS token layer and the bits of the UI that are painted
* from JavaScript — Chart.js datasets and Leaflet markers.
*
* Those two can't use var(), so before this they carried their own hardcoded
* hexes. That is how the chart palette drifted off-brand (a stray purple and
* Flat UI's stock blue) and why charts would have ignored the dark theme
* entirely. Reading the computed custom property keeps one source of truth.
*/
'use client';
import { useEffect, useState } from 'react';
export type ThemeToken =
| '--brand'
| '--brand-strong'
| '--brand-bg'
| '--status-above'
| '--status-above-bg'
| '--status-below'
| '--status-below-bg'
| '--text-primary'
| '--text-secondary'
| '--text-muted'
| '--text-inverse'
| '--bg-card'
| '--bg-secondary'
| '--border'
| '--border-strong'
| '--surface-inverse'
| '--chart-1'
| '--chart-2'
| '--chart-3'
| '--chart-4'
| '--chart-5'
| '--chart-6'
| '--chart-grid'
| '--chart-reference'
| '--phase-primary'
| '--phase-secondary-text'
| '--medal-gold'
| '--medal-silver'
| '--medal-bronze';
/** Values used before hydration and in any non-DOM context (SSR, tests). */
const FALLBACK: Record<string, string> = {
'--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',
};
/** Read one token. Safe to call during SSR — returns the light-theme value. */
export function token(name: ThemeToken): string {
if (typeof window === 'undefined') return FALLBACK[name];
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || FALLBACK[name];
}
/**
* Same as `token`, but the component re-renders when the OS theme flips so
* canvas-painted colours don't get stranded in the previous theme.
*/
export function useThemeTokens<T extends readonly ThemeToken[]>(
...names: T
): { [K in keyof T]: string } {
const read = () => names.map((n) => token(n)) as { [K in keyof T]: string };
const [values, setValues] = useState(read);
useEffect(() => {
// Only commit when something actually changed. In the light theme the
// computed values equal the fallbacks, so this is a no-op — which matters
// because a state update here re-renders the chart and makes Chart.js
// resize a canvas that may not be laid out yet.
const sync = () => setValues((prev) => (sameValues(prev, read()) ? prev : read()));
sync();
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
// `names` is a fixed-length literal tuple at every call site.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return values;
}
function sameValues(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
const SERIES = [
'--series-1', '--series-2', '--series-3', '--series-4',
'--series-5', '--series-6', '--series-7', '--series-8',
] as const;
const SERIES_FALLBACK = [
'#584a9b', '#0e6e66', '#9a5b00', '#a03a5e',
'#2f5f8f', '#4a6b2f', '#7a3f7a', '#97442a',
];
/**
* Resolved series colours for Chart.js datasets, in the same index order as
* CHART_COLORS — so a school keeps its colour between the canvas and the DOM
* swatch beside it. Re-reads when the OS theme flips.
*/
export function useSeriesColors(): string[] {
const read = () =>
typeof window === 'undefined'
? SERIES_FALLBACK
: SERIES.map((n, i) => {
const v = getComputedStyle(document.documentElement).getPropertyValue(n).trim();
return v || SERIES_FALLBACK[i];
});
const [colors, setColors] = useState(read);
useEffect(() => {
// See useThemeTokens: skip the commit when nothing changed, so mounting a
// chart doesn't cost an extra render.
const sync = () => setColors((prev) => (sameValues(prev, read()) ? prev : read()));
sync();
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
return colors;
}
/** rgba() built from a token's hex, for fills that need their own alpha. */
export function alpha(name: ThemeToken, a: number): string {
const hex = token(name);
const m = /^#([0-9a-f]{6})$/i.exec(hex);
if (!m) return hex;
const n = parseInt(m[1], 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
}
+40 -30
View File
@@ -273,17 +273,29 @@ export function debounce<T extends (...args: any[]) => any>(
// ============================================================================
/**
* Chart color palette (consistent with vanilla JS app)
* Series palette for comparing up to eight schools at once.
*
* These were Chart.js's stock demo colours, which is why the comparison view
* never looked like the rest of the site. They now point at the --series-*
* tokens, so the palette lives in one place and follows the theme.
*
* Every step clears WCAG AA on both grounds, so the same value works as a
* chart line and as the legend text keyed to it — there is no longer a
* separate text ramp to keep in sync.
*
* These are var() strings: correct anywhere the value lands in the DOM
* (inline styles, CSS custom properties). Canvas can't resolve var(), so
* Chart.js datasets must use `useSeriesColors()` from lib/theme instead.
*/
export const CHART_COLORS = [
'rgb(75, 192, 192)',
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 206, 86)',
'rgb(153, 102, 255)',
'rgb(255, 159, 64)',
'rgb(201, 203, 207)',
'rgb(255, 0, 255)',
'var(--series-1)',
'var(--series-2)',
'var(--series-3)',
'var(--series-4)',
'var(--series-5)',
'var(--series-6)',
'var(--series-7)',
'var(--series-8)',
];
/**
@@ -294,44 +306,42 @@ export function getChartColor(index: number): string {
}
/**
* Darker counterparts of CHART_COLORS for use as TEXT on the site's light
* backgrounds — the raw series colours fail WCAG AA (e.g. 1.9:1 on cream).
* Same hue per index so text still keys to its chart line. All ≥4.5:1 on
* #f3ede4 and lighter.
* @deprecated The series palette is now AA on both grounds, so line and text
* share one value. Kept as an alias so call sites can migrate incrementally.
*/
export const CHART_TEXT_COLORS = [
'#256868', // teal
'#c2255c', // pink/red
'#2069ad', // blue
'#8a5a00', // yellow → ochre
'#6d43c8', // purple
'#8f4e00', // orange → brown
'#5f5952', // grey
'#a300a3', // magenta
];
export const CHART_TEXT_COLORS = CHART_COLORS;
export function getChartTextColor(index: number): string {
return CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length];
}
/**
* Convert RGB color to RGBA with opacity
* Add opacity to a colour. Accepts `rgb(...)` or `#rrggbb` — the series
* palette resolves to hex now that it comes from the token layer, but the
* rgb() form is still used by callers passing Chart.js literals.
*/
export function rgbToRgba(rgb: string, alpha: number): string {
return rgb.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
export function rgbToRgba(color: string, alpha: number): string {
const hex = /^#([0-9a-f]{6})$/i.exec(color.trim());
if (hex) {
const n = parseInt(hex[1], 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
}
/**
* Get trend color based on direction
* Trend direction colour. Teal/amber rather than green/red so the signal
* survives redgreen colour blindness; `at` is deliberately neutral so a
* school sitting on the average doesn't read as a verdict.
*/
export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
switch (trend) {
case 'up':
return '#22c55e'; // green
return 'var(--status-above)';
case 'down':
return '#ef4444'; // red
return 'var(--status-below)';
case 'stable':
return '#6b7280'; // gray
return 'var(--status-at)';
}
}