Implements the identity direction agreed across two rounds of design boards: 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 later.
Why
A design audit of the shipped app found three problems 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 — on the highest-intent channel this product has. apple-touch-icon pointed at an SVG, which iOS ignores, so add-to-home-screen produced a blank tile. 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, so a dark-mode phone painted near-black browser chrome above a cream page.
What changed
Colour has exactly three jobs and they never borrow each other's hues: brand (iris) for interactive and identity, status (teal/amber) for above/below a comparison point, phase (desaturated) for categories. Teal/amber rather than green/red keeps the signal readable for every form of colour blindness.
Every chromatic literal in component CSS is now a token — 52 hex values and 92 rgba() triples. 45 selector blocks were re-scoped where teal had been doing double duty as both "above average" and a decorative accent.
JS-painted surfaces follow the theme. Chart.js and Leaflet can't resolve var(), which is how the chart palette drifted off-brand and why charts would have ignored dark mode. lib/theme.ts bridges them, with an 8-step --series-* ramp for multi-school comparison — wider than the three-hue rule because categorical needs distinguishability, but every step held to one tone and AA on both grounds, so a line and its legend text share one value.
One mark, one source.components/Logo.tsx 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. The favicon, apple-icon and share card all derive from its geometry.
Housekeeping that came with it: 123 dead global classes dropped from globals.css (leftovers from the vanilla-JS app — only the btn family, .skip-link and .main were still referenced), the noise overlay removed, prefers-reduced-motion added where there was none, tabular numerals on 23 figure classes, and a type scale so the 54 ad-hoc font sizes have somewhere to converge.
Verification
tsc --noEmit clean
159/159 unit tests pass
next build succeeds and prerenders /icon.svg, /apple-icon and /opengraph-image
Generated share card and apple icon inspected as images; the first card draft crowded the lockup and placed the lit bar left of the distribution peak — which quietly read as "below average" on the card advertising the site. Both fixed.
Three e2e journeys added, since every one of these is a silent failure that nothing on the page reveals:
the brand asset set is served with the right content types
themeColor matches --bg-primary in both schemes
the dark theme actually repaints the page
Worth a look before merge
The colour re-scoping involved around 40 judgement calls made from selector names rather than from seeing them render. The ones most worth eyeballing on staging:
admissions countdown chips — deadline uses brand, offer uses status-above; arguably both should be categorical
deprivation dot scale — filled dots are brand, since "more deprived" is not a verdict
gender split bars — moved onto the desaturated phase ramp, because neither side is "better"
Implements the identity direction agreed across two rounds of design boards: 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 later.
## Why
A design audit of the shipped app found three problems 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 — on the highest-intent channel this product has. `apple-touch-icon` pointed at an SVG, which iOS ignores, so add-to-home-screen produced a blank tile. 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, so a dark-mode phone painted near-black browser chrome above a cream page.
## What changed
**Colour has exactly three jobs and they never borrow each other's hues:** brand (iris) for interactive and identity, status (teal/amber) for above/below a comparison point, phase (desaturated) for categories. Teal/amber rather than green/red keeps the signal readable for every form of colour blindness.
Every chromatic literal in component CSS is now a token — 52 hex values and 92 `rgba()` triples. 45 selector blocks were re-scoped where teal had been doing double duty as both "above average" and a decorative accent.
**JS-painted surfaces follow the theme.** Chart.js and Leaflet can't resolve `var()`, which is how the chart palette drifted off-brand and why charts would have ignored dark mode. `lib/theme.ts` bridges them, with an 8-step `--series-*` ramp for multi-school comparison — wider than the three-hue rule because categorical needs distinguishability, but every step held to one tone and AA on both grounds, so a line and its legend text share one value.
**One mark, one source.** `components/Logo.tsx` 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. The favicon, apple-icon and share card all derive from its geometry.
**Housekeeping that came with it:** 123 dead global classes dropped from `globals.css` (leftovers from the vanilla-JS app — only the `btn` family, `.skip-link` and `.main` were still referenced), the noise overlay removed, `prefers-reduced-motion` added where there was none, tabular numerals on 23 figure classes, and a type scale so the 54 ad-hoc font sizes have somewhere to converge.
## Verification
- `tsc --noEmit` clean
- 159/159 unit tests pass
- `next build` succeeds and prerenders `/icon.svg`, `/apple-icon` and `/opengraph-image`
- Generated share card and apple icon inspected as images; the first card draft crowded the lockup and placed the lit bar left of the distribution peak — which quietly read as "below average" on the card advertising the site. Both fixed.
Three e2e journeys added, since every one of these is a silent failure that nothing on the page reveals:
- the brand asset set is served with the right content types
- `themeColor` matches `--bg-primary` in both schemes
- the dark theme actually repaints the page
## Worth a look before merge
The colour re-scoping involved around 40 judgement calls made from selector names rather than from seeing them render. The ones most worth eyeballing on staging:
- **admissions countdown chips** — deadline uses brand, offer uses status-above; arguably both should be categorical
- **deprivation dot scale** — filled dots are brand, since "more deprived" is not a verdict
- **gender split bars** — moved onto the desaturated phase ramp, because neither side is "better"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 PR is a visual rebrand ('Cohort' identity): a new CSS token system with light/dark themes, new fonts (Schibsted Grotesk/Literata), a shared Logo component, generated favicon/apple-icon/opengraph-image routes, and broad component recoloring, backed by new Playwright e2e coverage for the brand assets and theming. The design-system work itself is thorough and mostly mechanical, but there's a concrete production breakage in how the new opengraph-image route's font assets are packaged for Docker.
🔴 Severe (blocks merge)
nextjs-app/Dockerfile: app/opengraph-image.tsx reads font files at runtime via readFile(join(process.cwd(), 'assets', file)) from the new nextjs-app/assets/ directory. The runner stage of the Dockerfile only copies public, .next/standalone, and .next/static from the builder — it never copies assets/, and Next's standalone output file tracing does not reliably capture dynamic fs.readFile calls outside public/. In the production container, process.cwd() is /app (no /app/assets), so every request to /opengraph-image will throw ENOENT and return a 500, breaking link previews in production — the exact regression this PR was written to fix, and the failure the new e2e test 'the brand asset set is complete and served' should have caught if run against a standalone-built image.
🟡 Minor
nextjs-app/components/DeltaChip.module.css: .bad now uses var(--brand-bg)/var(--brand-strong) (the iris interactive/identity colour) instead of a --status-below token, even though globals.css's own new design-system comment states status colours (teal/amber) signal above/below and brand 'never means status'. The same brand-for-negative substitution recurs in components/HomeView.tsx (compact item RWM delta and the ofsted4 'Inadequate' badge) and components/LeafletMapInner.tsx (delta colour logic), so negative/below-average indicators now share a hue with primary CTAs/links, diluting the colour-blind-safe above/below signal the redesign explicitly set out to guarantee.
## 🤖 AI Code Review (Claude Code)
This PR is a visual rebrand ('Cohort' identity): a new CSS token system with light/dark themes, new fonts (Schibsted Grotesk/Literata), a shared Logo component, generated favicon/apple-icon/opengraph-image routes, and broad component recoloring, backed by new Playwright e2e coverage for the brand assets and theming. The design-system work itself is thorough and mostly mechanical, but there's a concrete production breakage in how the new opengraph-image route's font assets are packaged for Docker.
### 🔴 Severe (blocks merge)
- **nextjs-app/Dockerfile**: app/opengraph-image.tsx reads font files at runtime via `readFile(join(process.cwd(), 'assets', file))` from the new nextjs-app/assets/ directory. The runner stage of the Dockerfile only copies `public`, `.next/standalone`, and `.next/static` from the builder — it never copies `assets/`, and Next's standalone output file tracing does not reliably capture dynamic `fs.readFile` calls outside `public/`. In the production container, `process.cwd()` is `/app` (no `/app/assets`), so every request to `/opengraph-image` will throw ENOENT and return a 500, breaking link previews in production — the exact regression this PR was written to fix, and the failure the new e2e test 'the brand asset set is complete and served' should have caught if run against a standalone-built image.
### 🟡 Minor
- **nextjs-app/components/DeltaChip.module.css**: `.bad` now uses `var(--brand-bg)`/`var(--brand-strong)` (the iris interactive/identity colour) instead of a `--status-below` token, even though globals.css's own new design-system comment states status colours (teal/amber) signal above/below and brand 'never means status'. The same brand-for-negative substitution recurs in components/HomeView.tsx (compact item RWM delta and the ofsted4 'Inadequate' badge) and components/LeafletMapInner.tsx (delta colour logic), so negative/below-average indicators now share a hue with primary CTAs/links, diluting the colour-blind-safe above/below signal the redesign explicitly set out to guarantee.
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>
tudor
merged commit f43a8ecb69 into main2026-08-06 11:36:02 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements the identity direction agreed across two rounds of design boards: 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 later.
Why
A design audit of the shipped app found three problems beyond taste:
og:imagewas absent entirely, so every link shared into a class WhatsApp group rendered as a bare grey card — on the highest-intent channel this product has.apple-touch-iconpointed at an SVG, which iOS ignores, so add-to-home-screen produced a blank tile. The manifest shipped no PNGs, so Android installs had no icon. The header mark and the favicon had also drifted into two different logos.--primaryand--trend-downwere 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.themeColorannounced a dark variant with no dark styling behind it, so a dark-mode phone painted near-black browser chrome above a cream page.What changed
Colour has exactly three jobs and they never borrow each other's hues: brand (iris) for interactive and identity, status (teal/amber) for above/below a comparison point, phase (desaturated) for categories. Teal/amber rather than green/red keeps the signal readable for every form of colour blindness.
Every chromatic literal in component CSS is now a token — 52 hex values and 92
rgba()triples. 45 selector blocks were re-scoped where teal had been doing double duty as both "above average" and a decorative accent.JS-painted surfaces follow the theme. Chart.js and Leaflet can't resolve
var(), which is how the chart palette drifted off-brand and why charts would have ignored dark mode.lib/theme.tsbridges them, with an 8-step--series-*ramp for multi-school comparison — wider than the three-hue rule because categorical needs distinguishability, but every step held to one tone and AA on both grounds, so a line and its legend text share one value.One mark, one source.
components/Logo.tsxis the five-bar cohort spread — the same object as the distribution strip inside a school row, built from opacity steps so it inverts cleanly. The favicon, apple-icon and share card all derive from its geometry.Housekeeping that came with it: 123 dead global classes dropped from
globals.css(leftovers from the vanilla-JS app — only thebtnfamily,.skip-linkand.mainwere still referenced), the noise overlay removed,prefers-reduced-motionadded where there was none, tabular numerals on 23 figure classes, and a type scale so the 54 ad-hoc font sizes have somewhere to converge.Verification
tsc --noEmitcleannext buildsucceeds and prerenders/icon.svg,/apple-iconand/opengraph-imageThree e2e journeys added, since every one of these is a silent failure that nothing on the page reveals:
themeColormatches--bg-primaryin both schemesWorth a look before merge
The colour re-scoping involved around 40 judgement calls made from selector names rather than from seeing them render. The ones most worth eyeballing on staging:
🤖 Generated with Claude Code
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>🤖 AI Code Review (Claude Code)
This PR is a visual rebrand ('Cohort' identity): a new CSS token system with light/dark themes, new fonts (Schibsted Grotesk/Literata), a shared Logo component, generated favicon/apple-icon/opengraph-image routes, and broad component recoloring, backed by new Playwright e2e coverage for the brand assets and theming. The design-system work itself is thorough and mostly mechanical, but there's a concrete production breakage in how the new opengraph-image route's font assets are packaged for Docker.
🔴 Severe (blocks merge)
readFile(join(process.cwd(), 'assets', file))from the new nextjs-app/assets/ directory. The runner stage of the Dockerfile only copiespublic,.next/standalone, and.next/staticfrom the builder — it never copiesassets/, and Next's standalone output file tracing does not reliably capture dynamicfs.readFilecalls outsidepublic/. In the production container,process.cwd()is/app(no/app/assets), so every request to/opengraph-imagewill throw ENOENT and return a 500, breaking link previews in production — the exact regression this PR was written to fix, and the failure the new e2e test 'the brand asset set is complete and served' should have caught if run against a standalone-built image.🟡 Minor
.badnow usesvar(--brand-bg)/var(--brand-strong)(the iris interactive/identity colour) instead of a--status-belowtoken, even though globals.css's own new design-system comment states status colours (teal/amber) signal above/below and brand 'never means status'. The same brand-for-negative substitution recurs in components/HomeView.tsx (compact item RWM delta and the ofsted4 'Inadequate' badge) and components/LeafletMapInner.tsx (delta colour logic), so negative/below-average indicators now share a hue with primary CTAs/links, diluting the colour-blind-safe above/below signal the redesign explicitly set out to guarantee.