Files
school_compare/nextjs-app/components/RankingsView.module.css
T
TudorandClaude Opus 5 8ab0ac0a04
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
feat(design): adopt the Cohort identity — new palette, type, mark and dark theme
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>
2026-08-06 12:13:04 +01:00

464 lines
8.5 KiB
CSS

.container {
width: 100%;
min-width: 0;
}
/* Header */
.header {
margin-bottom: 2rem;
}
.header h1 {
font-size: 2.25rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-family: var(--font-display);
}
.subtitle {
font-size: 1rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.6;
}
/* Phase Tabs */
.phaseTabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
}
.phaseTab {
padding: 0.625rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
background: var(--bg-card);
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.phaseTab:not(:last-child) {
border-right: 1px solid var(--border);
}
.phaseTab:hover {
background: var(--bg-secondary);
}
.phaseTabActive {
background: var(--brand-strong);
color: var(--brand-on);
font-weight: 600;
}
.phaseTabActive:hover {
background: var(--brand-stronger);
}
/* Filters */
.filters {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 2rem;
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.filterGroup {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 200px;
}
.filterLabel {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.filterSelect {
flex: 1;
min-width: 0;
max-width: 100%;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.filterSelect:hover {
border-color: var(--brand);
}
.filterSelect:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-bg);
}
.filterSelect optgroup {
font-weight: 700;
color: var(--text-primary);
background: var(--bg-secondary);
padding: 0.5rem 0;
}
.filterSelect option {
font-weight: 400;
color: var(--text-secondary);
padding: 0.375rem 1rem;
}
/* Rankings Section */
.rankingsSection {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.tableWrapper {
overflow-x: auto;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
.rankingsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
}
.rankingsTable thead {
background: var(--bg-secondary);
}
.rankingsTable th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary);
border-bottom: 2px solid var(--border);
white-space: nowrap;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.rankHeader {
width: 80px;
}
.schoolHeader {
min-width: 250px;
}
.areaHeader {
min-width: 150px;
}
.typeHeader {
min-width: 120px;
}
.valueHeader {
width: 120px;
text-align: center;
}
/* Table auto-layout sizes columns by the unwrapped text, so a long metric
label ("Reading, Writing & Maths Combined Higher %") widened the table
past the viewport. A block inner span is what actually caps the measured
width and forces the label onto multiple lines. */
.valueHeaderText {
display: block;
max-width: 110px;
margin: 0 auto;
white-space: normal;
line-height: 1.2;
}
.actionHeader {
width: 120px;
text-align: center;
}
.rankingsTable td {
padding: 1rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
.rankingsTable tbody tr:last-child td {
border-bottom: none;
}
/* Alternating row backgrounds for visual rhythm */
.rankingsTable tbody tr:nth-child(even) {
background: rgba(var(--text-inverse-rgb), 0.5);
}
.rankingsTable tbody tr:hover {
background: var(--bg-secondary);
}
/* Top 3 Highlighting with Gold */
.rank1 {
background: linear-gradient(90deg, rgba(var(--status-below-rgb), 0.15) 0%, transparent 100%) !important;
}
.rank2 {
background: linear-gradient(90deg, rgba(var(--medal-silver-rgb), 0.15) 0%, transparent 100%) !important;
}
.rank3 {
background: linear-gradient(90deg, rgba(var(--medal-bronze-rgb), 0.15) 0%, transparent 100%) !important;
}
.rankCell {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
color: var(--text-primary);
}
/* Styled rank badges for top 3 */
.rankBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1rem;
font-weight: 700;
color: var(--brand-on);
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.15);
position: relative;
}
.rankBadge::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 50%;
border: 2px solid transparent;
background: linear-gradient(135deg, rgba(var(--text-inverse-rgb), 0.4), transparent) border-box;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
}
.rankBadge1 {
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-gold) 50%, var(--status-below) 100%);
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
}
.rankBadge2 {
background: linear-gradient(135deg, var(--text-muted) 0%, var(--medal-silver) 50%, var(--text-muted) 100%);
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
}
.rankBadge3 {
background: linear-gradient(135deg, var(--status-below) 0%, var(--medal-bronze) 50%, var(--status-below) 100%);
text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.2);
}
.rankNumber {
font-variant-numeric: tabular-nums;
font-size: 1rem;
color: var(--text-secondary);
}
.schoolCell {
font-weight: 500;
}
.schoolLink {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.schoolLink:hover {
color: var(--brand-strong);
}
.areaCell,
.typeCell {
color: var(--text-secondary);
}
/* LA subline under the school name — mobile only (Area column hidden there). */
.schoolCellArea {
display: none;
font-size: 0.75rem;
font-weight: 400;
color: var(--text-muted);
}
.valueCell {
text-align: center;
font-size: 1rem;
}
.valueCell strong {
color: var(--status-above);
font-weight: 700;
}
.actionCell {
text-align: center;
}
/* Equalise <a> and <button> rendering */
.actionCell > * {
height: 2rem;
line-height: 1;
font-family: inherit;
box-sizing: border-box;
vertical-align: middle;
}
/* No Results */
.noResults {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.noResults p {
font-size: 1rem;
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.header h1 {
font-size: 1.75rem;
}
.filters {
flex-direction: column;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
}
.filterGroup {
flex-direction: column;
align-items: stretch;
min-width: 0;
width: 100%;
}
.rankingsSection {
padding: 1rem;
border-radius: 8px;
}
.rankingsTable {
font-size: 0.875rem;
}
.rankingsTable th,
.rankingsTable td {
padding: 0.75rem 0.5rem;
}
.rankBadge {
width: 30px;
height: 30px;
font-size: 0.875rem;
}
/* Hide less-critical columns on mobile so the metric value stays visible.
Area moves to a subline under the school name (.schoolCellArea) — with a
four-column layout the value still overflowed a 390px viewport and the
whole point of the page (the score) needed a sideways swipe to see. */
.typeHeader,
.typeCell,
.actionHeader,
.actionCell,
.areaHeader,
.areaCell {
display: none;
}
.schoolCellArea {
display: block;
margin-top: 0.15rem;
}
.schoolHeader {
min-width: 0;
}
.valueHeader,
.valueCell {
width: auto;
min-width: 60px;
}
/* Long metric labels like "Reading, Writing & Maths Combined %" used to
force the whole column wide; let them wrap onto short lines with a
tighter font so the value cell can stay compact. */
.valueHeader {
font-size: 0.625rem;
white-space: normal;
line-height: 1.15;
letter-spacing: 0.03em;
}
.valueHeaderText {
max-width: 84px;
}
.rankHeader {
width: 40px;
}
}
.limitNote {
color: var(--text-muted);
font-weight: 400;
}
.metricDescription {
font-size: 0.875rem;
color: var(--text-secondary);
margin: -1rem 0 1.5rem;
max-width: 700px;
}
.progressHint {
font-size: 0.8rem;
color: var(--text-muted);
margin: -1rem 0 1.5rem;
font-style: italic;
}