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
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:
@@ -0,0 +1,46 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { BARS } from '@/components/Logo';
|
||||
|
||||
// iOS ignores SVG touch icons — it needs a real raster, on an opaque ground,
|
||||
// with no transparency (it composites its own rounded mask). Previously this
|
||||
// pointed at favicon.svg, so add-to-home-screen produced a blank tile.
|
||||
export const size = { width: 180, height: 180 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
const COHORT = ['rgba(22,32,42,0.22)', 'rgba(22,32,42,0.38)', 'rgba(22,32,42,0.55)', '', 'rgba(22,32,42,0.22)'];
|
||||
const SCALE = 3.6; // 40 → 144, leaving a 18px margin inside 180
|
||||
|
||||
export default function AppleIcon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f2f1ed',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: 40 * SCALE, height: 40 * SCALE, display: 'flex' }}>
|
||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
||||
<div
|
||||
key={x}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x * SCALE,
|
||||
top: y * SCALE,
|
||||
width: w * SCALE,
|
||||
height: h * SCALE,
|
||||
borderRadius: 3,
|
||||
background: isSchool ? '#584a9b' : COHORT[i],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size
|
||||
);
|
||||
}
|
||||
+285
-1926
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="26" width="6" height="12" rx="1" fill="#16202a" fill-opacity="0.22"/>
|
||||
<rect x="10" y="16" width="6" height="22" rx="1" fill="#16202a" fill-opacity="0.38"/>
|
||||
<rect x="18" y="8" width="6" height="30" rx="1" fill="#16202a" fill-opacity="0.55"/>
|
||||
<rect x="26" y="14" width="6" height="24" rx="1" fill="#584a9b"/>
|
||||
<rect x="34" y="28" width="4" height="10" rx="1" fill="#16202a" fill-opacity="0.22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 B |
+23
-16
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||
import { Schibsted_Grotesk, Literata } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
@@ -7,17 +7,23 @@ import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import './globals.css';
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
// Schibsted Grotesk carries the whole interface — headings, labels, and every
|
||||
// figure. It was drawn for news brands, so its tabular numerals hold a column
|
||||
// properly, which matters on a page that is mostly percentages.
|
||||
const schibsted = Schibsted_Grotesk({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-dm-sans',
|
||||
variable: '--font-schibsted',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const playfairDisplay = Playfair_Display({
|
||||
// Literata is for prose only — the admissions guide, methodology notes, the
|
||||
// editorial sections. It never touches a number or a control.
|
||||
const literata = Literata({
|
||||
subsets: ['latin'],
|
||||
weight: ['600', '700'],
|
||||
variable: '--font-playfair',
|
||||
weight: ['400', '600'],
|
||||
style: ['normal', 'italic'],
|
||||
variable: '--font-literata',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
@@ -27,9 +33,11 @@ export const viewport: Viewport = {
|
||||
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||
viewportFit: 'cover',
|
||||
// These must match --bg-primary in globals.css for each theme, or the
|
||||
// browser chrome and the page disagree at the top edge of the screen.
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||
{ media: '(prefers-color-scheme: light)', color: '#f2f1ed' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#10151b' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -47,11 +55,10 @@ export const metadata: Metadata = {
|
||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||
authors: [{ name: 'SchoolCompare' }],
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.svg',
|
||||
shortcut: '/favicon.svg',
|
||||
apple: '/favicon.svg',
|
||||
},
|
||||
// No `icons` key on purpose: setting it here would override the file
|
||||
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||
metadataBase: new URL('https://schoolcompare.co.uk'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
@@ -60,7 +67,8 @@ export const metadata: Metadata = {
|
||||
siteName: 'SchoolCompare',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
// summary_large_image now that there is an image worth showing.
|
||||
card: 'summary_large_image',
|
||||
title: 'SchoolCompare | Compare School Performance',
|
||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
||||
},
|
||||
@@ -94,8 +102,7 @@ export default function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||
<div className="noise-overlay" />
|
||||
<body className={`${schibsted.variable} ${literata.variable}`}>
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { BARS } from '@/components/Logo';
|
||||
|
||||
/**
|
||||
* The site had no og:image at all, so every link pasted into a class WhatsApp
|
||||
* group or a Slack channel rendered as a bare grey text card — on the highest
|
||||
* intent channel this product has.
|
||||
*
|
||||
* The card is the mark at poster scale: the cohort as a wide distribution with
|
||||
* one school lit up. It states the proposition rather than decorating it.
|
||||
*/
|
||||
export const alt = 'SchoolCompare — compare every school in England against the national picture';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
const PAPER = '#f2f1ed';
|
||||
const INK = '#16202a';
|
||||
const MUTED = '#5c6570';
|
||||
const BRAND = '#584a9b';
|
||||
|
||||
// A plausible national distribution, drawn once at poster scale. The lit bar
|
||||
// is the same position the mark uses.
|
||||
const SPREAD = [8, 12, 17, 24, 33, 44, 57, 70, 82, 92, 98, 100, 96, 88, 77, 65, 53, 41, 31, 22, 15, 10, 7, 5];
|
||||
// Right of the peak: score runs left to right, so the lit school reads as
|
||||
// above the national average. At index 9 it sat on the low side, which quietly
|
||||
// said the opposite.
|
||||
const LIT = 14;
|
||||
|
||||
async function font(file: string) {
|
||||
return readFile(join(process.cwd(), 'assets', file));
|
||||
}
|
||||
|
||||
export default async function OpengraphImage() {
|
||||
const [regular, bold] = await Promise.all([
|
||||
font('SchibstedGrotesk-Regular.ttf'),
|
||||
font('SchibstedGrotesk-Bold.ttf'),
|
||||
]);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
background: PAPER,
|
||||
padding: '58px 72px 52px',
|
||||
fontFamily: 'Schibsted',
|
||||
}}
|
||||
>
|
||||
{/* Lockup */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ position: 'relative', width: 40, height: 40, display: 'flex' }}>
|
||||
{BARS.map(([x, y, w, h, isSchool], i) => (
|
||||
<div
|
||||
key={x}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
width: w,
|
||||
height: h,
|
||||
borderRadius: 1.5,
|
||||
background: isSchool ? BRAND : INK,
|
||||
opacity: isSchool ? 1 : [0.22, 0.38, 0.55, 1, 0.22][i],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 29, fontWeight: 700, letterSpacing: '-0.028em' }}>
|
||||
<span style={{ color: INK }}>School</span>
|
||||
<span style={{ color: BRAND }}>Compare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proposition. Sized so the headline holds two lines and the subhead
|
||||
one — an orphaned word reads as an accident at this size. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
fontSize: 66,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.036em',
|
||||
lineHeight: 1.03,
|
||||
color: INK,
|
||||
maxWidth: 1010,
|
||||
}}
|
||||
>
|
||||
A score means nothing until you see the spread.
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 26, color: MUTED, letterSpacing: '-0.005em' }}>
|
||||
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The device, at poster scale. Same object as the mark above. */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 7, height: 120 }}>
|
||||
{SPREAD.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: `${h}%`,
|
||||
borderRadius: 2,
|
||||
background: i === LIT ? BRAND : INK,
|
||||
opacity: i === LIT ? 1 : 0.15,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 18, color: MUTED, letterSpacing: '0.09em' }}>
|
||||
<span>NATIONAL SPREAD</span>
|
||||
<span style={{ letterSpacing: '-0.005em' }}>schoolcompare.co.uk</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
fonts: [
|
||||
{ name: 'Schibsted', data: regular, weight: 400, style: 'normal' },
|
||||
{ name: 'Schibsted', data: bold, weight: 700, style: 'normal' },
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user