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 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m29s
Implements the supplied brand guideline: the pin-and-leaf mark, the lowercase schoolcompare wordmark, Manrope for headings and Inter for everything a person reads a number from, the deep-teal/coral/mustard/sage palette, and the rounder geometry the guideline asks for. The landing hero is rebuilt to the concept: a Sand panel carrying the proposition, the search and a trust line, with the brand landscape bleeding to the panel's right edge, followed by the four value props. Concept vs reality — where the guideline describes something this product does not have, the real feature takes its place and the decision is recorded in a code comment next to it: - The Thriving Score is not implemented. Four of its five sub-scores (pastoral, enrichment, community, facilities) have no source in DfE or Ofsted data, and its band names are Ofsted's own, which would sit beside the real Ofsted grade and be indistinguishable from it. - Guides, About, Favourites and Sign in are not added to the nav — there are no such pages and no accounts system. Rankings and Admissions, which the guideline omits, keep their places because they exist. - The fourth value prop is "Never miss a deadline" rather than "Save & revisit", which would need shortlisting. Two deviations from the supplied swatches, both forced by contrast and both confined to text and fills. Coral #F97360 under a white label measures 2.75:1 against a 4.5 floor, so --action is a darkened #BE3C27 and the original coral survives as --coral for tints, borders and illustration. Mustard #F2C94C is 1.5:1 as text, so --mustard-ink carries labels while --mustard stays exact for fills and rings. Every token clears AA on all three light grounds; Sand is the binding one, so several values sit a step darker than white alone would need. The guideline is light-only, but the site already ships a dark theme, so it is rebuilt from the same tokens rather than dropped. Verified: tsc clean, 159/159 unit tests, production build green, and the generated share card and app icon render from the new mark. The hero and the site chrome were rendered from the compiled CSS and the prerendered markup to confirm Manrope and Inter actually resolve — a font token that silently falls back has shipped here before.
164 lines
5.2 KiB
TypeScript
164 lines
5.2 KiB
TypeScript
/**
|
|
* 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': '#0F766E',
|
|
'--brand-strong': '#0C5F58',
|
|
'--brand-bg': 'rgba(15, 118, 110, 0.10)',
|
|
'--status-above': '#36743F',
|
|
'--status-above-bg': 'rgba(54, 116, 63, 0.12)',
|
|
'--status-below': '#A9481F',
|
|
'--status-below-bg': 'rgba(249, 115, 96, 0.15)',
|
|
'--text-primary': '#1C2731',
|
|
'--text-secondary': '#4A5560',
|
|
'--text-muted': '#5F6A75',
|
|
'--text-inverse': '#FFFFFF',
|
|
'--bg-card': '#FFFFFF',
|
|
'--bg-secondary': '#F5EFE6',
|
|
'--border': '#E5E7EB',
|
|
'--border-strong': '#D3D7DD',
|
|
'--surface-inverse': '#0F766E',
|
|
'--chart-1': '#0F766E',
|
|
'--chart-2': '#36743F',
|
|
'--chart-3': '#806200',
|
|
'--chart-4': '#A9481F',
|
|
'--chart-5': '#2F6F8F',
|
|
'--chart-6': '#5F4FA8',
|
|
'--chart-grid': '#E5E7EB',
|
|
'--chart-reference': '#5F6A75',
|
|
'--phase-primary': '#0F766E',
|
|
'--phase-secondary-text': '#2A6180',
|
|
'--medal-gold': '#806200',
|
|
'--medal-silver': '#6B7580',
|
|
'--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 = [
|
|
'#0F766E', '#36743F', '#806200', '#A9481F',
|
|
'#2F6F8F', '#5F4FA8', '#0E7A86', '#8A4A6B',
|
|
];
|
|
|
|
/**
|
|
* 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})`;
|
|
}
|