264edd2e3a6681a4ad47f42ddb3de2021adb6b5c
349
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a72323874f |
feat(admissions): add the cut-off history, map and postcode check
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 17s
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 2m39s
Completes the last-distance-offered feature against the mockup: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them. Serving the history The first cut deliberately served only the latest year, because a plain series would draw a trend line straight through gaps that are absences of publication, not of a cut-off. That reasoning is answered rather than abandoned: cutoffYearRows classifies every year in the span, and the chart breaks the line rather than interpolating across it. A missing year is not one fact but three. It may be unpublished; it may be a year the school was not oversubscribed; or there may be no record at all. Collapsing them into "no data" throws away the reassuring case and hides the important caveat, so each is stated in words in the table. The claim is held to what the data supports. fact_admissions.oversubscribed compares FIRST PREFERENCES against places, which does not establish that every applicant was offered one — so the copy says "places available on first preferences" and a test asserts the stronger claim never appears. The trend summary is not a verdict It names both endpoints and their years and lets the reader conclude. It is withheld below four published points, and a swing under a tenth of the earlier figure is reported as "broadly the same" rather than dressed up as a direction. The postcode check This is the only place on the site that answers a question about a family rather than a school, so most of the care went into what it refuses to say. postcodes.io returns a centroid covering roughly fifteen addresses, which against a 500 m cut-off is a fifth of the whole distance — so a margin inside 100 m returns "too close to call" rather than a place a family does not have. Unpublished years count as unknown, never as a pass. The limits are stated before the check is used, not revealed with the answer. The postcode is geocoded in the browser and never stored. Both templates Banded and selective secondaries are exactly where this matters most, so the detail is shared. The primary page gives it a third tab; the secondary page is one flat panel by design and renders it inline. Absence is explained rather than reported. A selective school's missing figure is explained by how it admits; a consistently undersubscribed school reads as good news. Also makes the batch loader's test double honour ORDER BY. It was a no-op, so "latest row per URN" was really "first row in the fixture" and the test would have passed with the sort reversed or removed. Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging (the 8 cut-off journeys skip until the DAG runs). Rendered offline against the real compiled CSS in both themes and at 390px; every new surface clears WCAG AA, measured on composited pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE |
||
|
|
88c653215d |
feat(admissions): show the last distance offered where councils publish it
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 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s
Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.
There is no national source for this. Each LA publishes its own cut-offs in
its own format, and the collected CSV is transcribed from PDFs, spreadsheets
and web pages — so most of the work here is deciding what is safe to show.
Data
* tap-uk-school-distance loads the CSV verbatim into raw. Keyed on
(urn, year, school_name), because school_name carries the admission
route: (urn, year) alone collides on 118 keys and a reload would have
silently dropped every band but one.
* stg_school_distance applies a 25 m – 25 km plausibility band. The source
contains 0.0-mile rows (published where a school filled on a higher
criterion), 1-metre cut-offs, and one reading 533 miles — ~4% of rows,
all of which would put a visibly wrong number on a live page.
* fact_admission_distance collapses routes to one row per school per year
using the furthest, and keeps route_count so the page can say the figure
is the widest of several bands rather than the one for a given child.
Serving
* Kept out of fact_admissions: that mart is EES-derived and near-complete
for England, this one covers 57 LAs, and the two refresh independently.
* Latest year only. Coverage is ragged — a school may have 2021 and 2026
and nothing between — so a history array would invite a trend line drawn
through gaps that are absences of publication, not of a cut-off.
* The Admissions section now renders on either source. 3% of the schools
that render have a cut-off and no EES admissions row, and gating on
admissions alone would have hidden the figure on those pages.
Interface
* The year travels with the figure everywhere it appears; a cut-off
detached from its admissions round is not a fact about anything.
* "Not a fixed catchment — it moves every year" sits under every instance,
because that is the inference a parent will otherwise draw.
* Replaces a hardcoded "Historical distance cut-off data is not available
for this school" that appeared on every secondary page, including the
ones whose council does publish it. The absence is now stated only when
it is real, and names the authority that would hold it.
The tint costs the muted tokens their AA margin: measured on the composited
backdrop (not the computed one, which reports the untinted card), --text-muted
falls to 4.09:1 in dark theme. The tile uses --text-secondary instead — 6.50:1
dark, 6.60:1 light.
The DAG is manual, like the other annual ones: councils publish on allocation
day, each on its own timetable, so there is no date worth scheduling against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
|
||
|
|
fa1abff642 |
chore: drop the hero byline, and refresh a figure in the UX audit notes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
Two unrelated working-tree changes, committed as one at the owner's request. Removes "Built for parents, by a parent." and its comment from the hero. It was added two commits ago; taking it out is the owner's call, and the reasons it was placed under the search rather than in the footer no longer apply. The .heroByline rules in HomeView.module.css are deliberately left in place. They are now unreferenced, but the class was purpose-built for this one line and keeping it makes restoring the byline a one-line change. If the removal is permanent, that block (and its 640px media query) should go with it. Also updates two quoted figures in the 2026-07-02 UX audit notes from "24,000+" to "27,000+". Worth noting for the record: that file documents what the page said when it was audited, and at that time it genuinely did say "24,000+" — which was itself the bug later fixed by reading unique_schools instead of a field the API never sent. Editing the quoted evidence makes the note read consistently with the current site, at the cost of no longer being a verbatim record of what was observed. Left as the owner edited it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
38acc76555 |
Merge pull request 'fix(charts): make the national-average marker visible on both templates' (#99) from fix/chart-marker-contrast into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 16s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m3s
Reviewed-on: #99 |
||
|
|
59ac9c10b9 |
fix(charts): make the national-average marker visible on both templates
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m5s
The marker was var(--brand) — the identical value to the bar fill it sits on.
Measured by pixel-sampling every bar on both templates, in both themes:
primary SatsChart .natTick 1.00:1 (6 bars)
secondary .att8VizNatLine 1.00:1
Not low contrast. The same colour. It scored 5.47:1 only against the empty
track, which means it was visible precisely when a school was BELOW the
national average and vanished for every school at or above it — failing for
exactly the schools people are looking for.
No single colour fixes this, because the marker's position is data-driven: it
can land on the bar, on the empty track, or across the boundary. It is now a
knockout — a light core carrying a dark edge, both from tokens that flip with
the theme, so one part or the other always separates:
on the bar on the track
light 5.47 / 9.70:1 15.17:1
dark 7.81 / 10.85:1 13.52:1
THE LEGEND DESCRIBED A CHART THAT DID NOT EXIST
Both data swatches were var(--status-above) green while their bars were
var(--brand) teal, and the two were identical to each other — one swatch for
two series. Worse, the only swatch matching the bar colour was the one
labelled "National average", so reading the chart by matching colours told you
the teal bars were the benchmark. Each swatch now carries its bar's exact
value, and the marker swatch mirrors the knockout.
TWO SERIES, ONE COLOUR
Expected and Exceeding were both var(--brand), distinguished only by row.
They are a sequential pair — exceeding is the same cohort at a harder bar — so
they take two steps of one hue, the harder measure being the step further from
the ground in each theme.
They measure 1.77:1 (light) and 1.39:1 (dark) against each other, and that is
accepted rather than overlooked: two fills that must EACH clear 3:1 against
the same white track are geometrically forced close together. The distinction
is carried by the row labels and printed values; colour is redundant here, not
load-bearing.
THE TEST THAT SHOULD HAVE CAUGHT THIS
The WCAG journey composites backgrounds by walking the ancestor chain, but
these markers are absolutely positioned over a sibling — ancestor-walking is
structurally blind to overlap, which is why this shipped in two templates and
passed every gate. The new journey asks the stacking order instead, via
document.elementsFromPoint.
Writing it surfaced a second trap worth recording: elementsFromPoint takes
viewport coordinates and returns an empty stack off-screen, and these markers
sit ~1200px down. The first version defaulted an empty stack to white, which
made teal-on-teal look like teal-on-white and PASS in the light theme. It now
scrolls each marker into view and counts any marker it cannot resolve a
backdrop for as a failure rather than a pass.
Verified both directions: the new journey fails against current staging with
"1.00:1 over rgb(15,118,110)" in both themes, and passes against this build
with 6/6 markers measured at 5.47–15.17:1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
eddf74745f |
feat(home): swap in the higher-fidelity hero artwork
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
Same 16:9 frame, but not a drop-in replacement — the composition, palette and
copy area all moved, and each one drives a change here.
COPY AREA
The old artwork was a flat #FDF9F3 down its whole left side. This one runs
peach #FEE8D2 at the top to cream #FDF3E7 around 43% height, and below ~48%
the left edge is foliage rather than cream at all. --hero-ground is resampled
to #FEF2E1, the middle of the pale run where the headline and search actually
sit; the scrim covers the foliage further down.
Measured on rendered pixels with the text hidden, sampling the real glyph runs
(via Range, not the element boxes) plus a 120px growth margin:
title body byline
light 13.61:1 5.74:1 6.66:1
dark 9.49:1 5.45:1 8.35:1
Body drops from 6.52:1 to 5.74:1 as the foliage comes closer, still clear.
BAND CROP
The schoolhouse moved to ~82% across the frame, leaving only ~140px of artwork
to its right, so the band crop is anchored to the right edge and takes 1344px
back — putting the school at 73%, which the build script now derives and
prints rather than leaving it to drift from the CSS.
The crop is also 3.2:1 rather than matching the phone. The band is not one
ratio: it runs 2.4:1 on a phone to about 3.8:1 under the one-column
breakpoint. Cropping at the narrow end means the wide end throws away height,
which cut the flag off the roof and the base off the building. Sitting above
the middle costs a little width on phones — where the crop's left is hillside —
and keeps the building whole where it matters.
BAND HEIGHTS
Raised to 13rem (861–860px) and 10rem (≤640px). The band was widest-per-height
at exactly 640px, where an 8.5rem band measured 4.2:1 — worse than any wider
viewport, because the height steps down at that breakpoint while the width does
not. Ratios across the range are now 2.0 / 2.6 / 3.6 / 3.1 / 3.8 rather than
spiking. The search still lands at 442px on a 667px viewport.
WEIGHT
Source is 3.1MB; a browser fetches one file — 29–59kB on desktop, 10–14kB on a
phone. Widths re-cut for the larger master: 2000/1400/1000 wide, 1344/900/600
band.
Verified: 0 AA failures across both themes at 1440 / 860 / 390, every srcset
entry present on disk with no orphans, tsc clean, 159/159, build green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3015c37bac |
feat(home): add a first-person byline under the hero search
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 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
"Built for parents, by a parent." — the one line on the page written by a person rather than by a product. Placed under the search rather than in the footer, which is where it would have been true and unread. It does its work at the moment someone is deciding whether to trust a page full of government statistics, which is the moment they are looking at the search box. Set apart without shouting: display face, a step down in size, and a short brand rule in place of a bullet. No italic — Manrope ships none in the loaded weights, so font-style would be synthesised into a slant, the same reason .heroEmph resets it. Deliberately the only claim of its kind on the page. Every other trust signal here is about the data's provenance and is checkable against the DfE and Ofsted; this one is about who built it, and is not. So it is stated once, plainly, and never repeated — the opposite of the "Official & trusted" line it now sits near, which claimed something about the site that was not true. Measured 7.26:1 in the light theme and 8.86:1 in dark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4043270a77 |
fix(home): stop implying we are official, and fix the mobile hero and search
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
Three things, all reported from the live mobile view.
WE ARE NOT AN OFFICIAL SERVICE
The value prop read "Official & trusted", whose grammatical subject is this
site — it reads as a claim that schoolcompare is an official service. It is
not; it is an independent site that republishes official figures. Now "Built
on official data", which describes the data instead.
Checked every other use of the word: all nine describe the data ("official DfE
figures", "the official figure isn't in") and are correct as they stand. That
one title was the only place the site described itself.
The footer now says it outright — "An independent site. Not affiliated with
the Department for Education or Ofsted." — so it appears on every page rather
than being left to inference. An e2e test asserts both halves: that the
statement is present, and that no text presents the site itself as official.
The disclaimer first measured 4.71:1 against a 4.5 floor. That is a fine
margin for decoration and the wrong one for a line whose job is to be legible
to someone checking whether this is a government site; it is now 5.59:1,
quieter than the copy around it by size rather than by contrast.
THE ARTWORK SAT UNDER THE SEARCH ON PHONES
The DOM keeps .heroContent first so the desktop overlay does not depend on
source order, which left the band stranded at the bottom of the panel, reading
as a strip stuck on the end rather than a hero image. `order` moves it above
the copy on phones; it is decorative and aria-hidden, so no reading order
changes. Measured on a 667px viewport — the shortest phone still in use — the
search button lands at 418px, comfortably inside the fold.
THE SEARCH INPUT WAS UNUSABLE ON PHONES
"Search schools" is a fixed 134px with white-space: nowrap, so it took 48% of
the row. Measured on staging:
390px input text space 102px placeholder needs 194px
360px 72px
320px 32px
At 320px you could not see what you were typing. The button now wraps to its
own full-width row below 480px, which fixes 360px and up — 390px goes from
102px to 226px of text space. The pill stays a single element, so it keeps its
border, shadow and :focus-within ring, and the button gains a full-width tap
target.
320px is still ~30px short. Closing it needs a shorter placeholder, which three
other tests match on by exact string; left alone deliberately rather than
churn them for a width that is effectively gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
bdaa05cd54 |
style(home): lift the hero artwork's dark-theme brightness to 0.75
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
At 0.52 the scene was legible but heavily suppressed; 0.75 lets the hills, path and schoolhouse read while the white H1 stays comfortably the brightest thing on the panel. Re-measured rather than assumed, because in the dark theme the text is light and the artwork is behind it — brightening the image lowers text contrast rather than raising it. Off rendered pixels, sampling background up to 120px past each line's right edge: brightness title body 0.52 11.01:1 6.44:1 0.75 9.48:1 5.21:1 Both still clear the 4.5:1 floor, body being the binding one. The trade is recorded next to the value so the next person to reach for it knows it has a floor and not just a taste range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
043506cb6b |
fix(home): art-direct the hero's fallback path, and declare sharp
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m32s
Two findings from review on #93, both verified before fixing. <img src> cannot vary by viewport, so it was always the wide desktop crop. A browser taking neither AVIF nor WebP therefore fell through to the desktop frame on a phone and lost the schoolhouse — the exact failure the two-crop <picture> exists to prevent, surviving in the one path nobody looks at. The band JPEG the build script already emitted was never referenced, which was the tell. It now backs a <source media> placed after the modern formats, so they still win wherever they are supported. Verified by stripping the AVIF and WebP <source>s at runtime and letting <picture> re-resolve, which is what an old browser actually sees: phone hero-band-500.avif → hero-band-700.jpg (band crop, school kept) desktop hero-wide-1672.avif → hero-wide-1200.jpg sharp was not declared: it arrives transitively from next@16.1.6, so the documented regeneration command works today and breaks on a Next upgrade or a clean install that resolves differently. Declared in devDependencies for the same reason next.config.js already declares its traced font files rather than trusting the tracer to keep finding them. The third finding — that the hero's licence is marked unconfirmed in CREDITS.md while the artwork ships — is accurate and deliberate. It is the owner's to answer; recording it as unknown is the point of the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3f0e05cc99 |
feat(home): use the supplied hero artwork instead of a drawn one
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 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m29s
Replaces the hand-drawn SVG landscape with the illustration supplied by the project owner. Master is assets/hero-source.png; everything under public/brand/hero-* comes from scripts/build-hero-images.js and should never be hand-edited. LAYOUT The artwork is composed as a full-bleed hero: it reserves an empty cream area down its left side for the headline. The old hero was a two-column grid with art in the right 0.88fr, which would have cropped that reserved area off and shrunk the scene into a thumbnail — the one thing the picture is built not to be. So the panel is now a single layered block: artwork behind, copy on top, and below the one-column breakpoint the artwork leaves the background and becomes a band under the search. Overlaying does not survive down to phone widths — the panel gets too narrow for the copy to stay inside the cream, so the scrim would have to cover nearly the whole image and you would be left with a tinted rectangle. Hence the switch at 860px rather than a single treatment stretched across every width. CONTRAST The copy sits on a gradient of --hero-ground, a new token sampled from the artwork's own cream (#FDF9F3) rather than from Sand. Sand is eight to thirteen points darker per channel, which leaves a visible seam straight down the hero. The scrim exists because the artwork is a fixed image on a fluid panel: past some width the headline would otherwise land on hillside green. Measured on rendered pixels, sampling background up to 120px beyond the right edge of each line, so a longer line still has margin: light title 14.36:1 body 6.52:1 dark title 11.01:1 body 6.44:1 The worst light case is hillside green showing through the scrim at 6.52:1. TWO CROPS The slot is two shapes: roughly 2.1:1–2.7:1 behind the desktop panel, and 2.6:1–4.9:1 as the band. A single file under object-fit: cover centre-crops, and at the band's extreme that slices a strip through the scene and loses the schoolhouse — exactly how the drawn hero failed on phones. <picture> switches crop, not just resolution: the band file is pre-cropped around the school and is already near 2.6:1, with the subject at ~65% across so squeezing toward 4.9:1 crops the empty sides instead. WEIGHT 1.6 MB PNG in, AVIF and WebP out; a browser fetches exactly one file — about 23–33 kB on desktop, 9–18 kB on a phone. The JPEG is only the <img> fallback. `sizes` describes the real panel box (max-width 1400 minus padding) rather than 100vw, which over-requested a candidate at every width on the LCP element. DARK THEME A raster cannot be re-graded token by token the way the drawing was, but it would reproduce the same failure — a bright illustration is the brightest object on a near-black page. It is dimmed in CSS to read as dusk, and the scrim fades it into the dark panel rather than into cream. TESTS The two illustration journeys are replaced by three, each covering a failure that still renders a valid-looking page: the artwork actually loading (naturalWidth, not src), the crop switching at the breakpoint, a modern format winning negotiation, and the dark theme dimming it. public/brand/CREDITS.md records provenance for every asset in that folder. The hero's licence line is marked unconfirmed — that one needs the owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
97ac5c9cef |
fix(a11y): clear the four AA failures blocking the staging E2E gate
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 11s
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) Successful in 5m29s
Main's staging E2E has been red since #90. Two of the four failures were the staging container serving a stale image at the moment the gate ran — apple-icon and the header lockup both check out against staging now. The other two were real, and both are the same mistake: a colour pairing verified against one ground while the element sits on another. * .heroEyebrow --brand on its own 10% tint over Sand is 4.19:1. The token clears AA on Sand, but the tint darkens the ground under the label, and that composite was never the thing measured. --brand-strong is 5.85:1. * .hiwVisual The preview panel was grounded in Sand while everything inside it is a translucent status tint — and those tints are specced to clear AA over --bg-card. Over Sand the report-card chips landed at 4.22:1 and 4.29:1. The ratio depended on a background two levels up. Moving the panel to the card surface puts them at 5.69:1 and 5.81:1; a border keeps it reading as an inset frame now that panel and card share a colour. * Footer .sectionTitle --sage flips with the theme; the footer band does not (it is teal in both). So the pairing only held in one of them — the dark sage measured 4.08:1, on every page. Now --on-sunken-muted, which is what the --on-sunken-* family exists for, as Footer.tsx's own header comment already says. * .compareHeadLabel --text-muted on the header row's tint is 4.45:1 in the dark theme. Under by a hair, same cause. Now --text-secondary. The harness gained the footer, because it had no footer and so could not have caught the one failure that appeared on all three pages. Rewriting fragments now uses each CSS module's own hash — the footer rewritten with HomeView's prefix renders unstyled, which would have made any contrast measured on it meaningless while still reporting a number. Verified: 0 AA failures in both themes across the assembled page, measured with the same probe the e2e test uses, against the real compiled CSS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dc22fd2853 |
fix(home): correct what the landing page claims, and give it one rhythm
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m25s
The homepage made four statements that were not true, carried elements that
asked nothing of anyone, and had a hero illustration that broke in both the
places it had to work.
Claims, all verified against the code or the API:
* "24,000+ schools" (three places) against a real 27,230. The fact box meant
to show the live figure rendered its own fallback on every request, because
DataInfoResponse declared a `total_schools` field the API has never sent —
it sends `unique_schools`. The fetch succeeded; only that field was
undefined, so nothing threw and nothing failed. The interface, not the
code, was the thing that was wrong.
* "Up to three schools side by side" against MAX_SCHOOLS = 5, contradicting a
card 400px below it that correctly said five.
* "Class sizes" — data the codebase has never held. That copy line was the
only hit in a full-repo grep.
* Invented results and Ofsted grades attributed to two real, named schools
in the compare preview.
Also one feature, three words: Compare (nav), shortlist (footer), pin (cards).
Settled on Compare everywhere. And <title> was the bare string "Home".
Cut: the trust line (repeated the coverage figure one paragraph after the hero
gave it, behind three decorative dots), the "Start exploring" row (three links
to two destinations already in the nav), the six-row coverage table, and three
of the four countdown cards — which gave the page's largest numeral to dates up
to 245 days away, two of them offer days, which cannot be missed. All four
dates remain, at proportionate weight. Value-prop titles drop from <h2> to <p>;
they were outranking the page's real headings in the document outline.
Rhythm: the gaps between the seven landing bands were 24/32/24/16/48/32/16px,
each band setting its own margin, with four different section-header
treatments between them. The page container now owns one gap, and there is one
header pattern. An e2e test asserts the gaps are identical.
Illustration: it kept a fixed light palette in both themes, which left a pale
sky slab as the brightest object on a near-black page, out-shouting the H1 and
the search box. It now reads from --ill-* tokens with a dark re-grade. And the
hero slot ranges from 1.34:1 to 4.9:1 across breakpoints, which no single
composition survives under `slice` — at 860x176 a 540x520 scene shows only its
bottom 110 units, so the schoolhouse was cropped away entirely on phones,
leaving hills and a pin pointing at nothing. There are now two compositions,
each drawn against the crop window its own breakpoint produces, with CSS
showing one. Both are static server-rendered SVG.
The deadline bar renders on the server rather than on hydrate. The effect-based
version needed a reserved height, and one guessed number cannot cover a block
whose supporting line wraps differently at every width — measured, it was short
at all four, shifting the page up to 108px on a phone.
Verified on the built output through an offline render harness (no local
server): real compiled CSS, real rendered markup, four widths, both themes.
tsc clean, 159/159 unit tests, build green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9ee4d45a94 |
fix(brand): give both logo colourways one canvas so the aspect hint is right
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 10s
LogoMark derived width from a single hardcoded 153/189, taken from mark.png, while mark-on-dark.png was 98×112. Both call sites size the mark with height:100%/width:auto, so the rendered size came out right in the end — but the width/height attributes are the pre-load aspect hint, so the on-dark artwork reserved the wrong box and reflowed on load. Any future call site without that CSS would have rendered it stretched outright. Per-variant attributes would not have fixed it. variant="auto" is a single <img> whose srcset swaps the file underneath it, so one set of attributes has to serve both colourways; the only correct fix is for the two files to share a ratio. They also had different artwork ratios (0.810 against 0.833) and different padding, which the review did not reach: the logo changed size slightly whenever the OS theme flipped. Both are now written onto one 176×208 canvas with the artwork at the same height, centred, so they are drop-in swappable. Measured on the built output, header mark, before → after: light attr 31×38, rendered 30.75×38 → attr 32×38, rendered 32.15×38 dark attr 31×38, rendered 33.25×38 → attr 32×38, rendered 32.15×38 A 2.25px reflow and a cross-theme size jump both become 0.15px of rounding. The share card reads the same file and had its own hardcoded 45×56, which the re-crop would have stretched; it is now 47×56, with a comment tying it to the canvas. The comment on ASPECT records that re-cropping either file means re-normalising both. Verified: tsc clean, 159/159 tests, build green, and both files report naturalWidth 176 × naturalHeight 208 in the browser. |
||
|
|
b11ee3c8de |
feat(brand): use the supplied logo artwork instead of a reconstruction
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) Successful in 2m7s
The mark shipped so far was my SVG approximation, and it was wrong: the real mark is a teardrop pin with a white window and a path flowing out of its base, carrying three leaves — not a circle with a separate tail. Both colourways are extracted from the supplied sheet, which has a genuinely transparent background, so these are the artwork rather than a trace: public/brand/mark.png teal pin, white window and path, green leaves public/brand/mark-on-dark.png white pin with the counter knocked through Two colourways are needed, not one. Rendered against every real ground, the teal pin holds up on Warm White, white cards, Sand and both dark-theme grounds — but it vanishes on the teal footer band, where only the white window survives. The header therefore serves the teal artwork and swaps to the on-dark artwork for dark-theme viewers through a <picture> source, needing no JavaScript; the footer forces on-dark, because its band is teal in both themes. Everything downstream now derives from those two files: the favicon (app/icon.png, replacing icon.svg), the touch icon (app/apple-icon.png, replacing the generated apple-icon.tsx), the three PWA rasters, and the share card, which reads public/brand/mark.png off disk so it can never drift from the header. The logo sizing CSS keyed off a square box, which would have squashed a 153:189 artwork — height now drives and width follows. The wordmark stays live text in Manrope. The sheet's wordmark is a raster with visible edge fringing, and the written style guide specifies Manrope; live text also stays selectable, scales cleanly and recolours with the theme. KNOWN LIMITATION: the largest instance on the sheet is 153×189. That is ample for the header at 38px, the favicon and the share card, but short of the 512px PWA icon, which is upscaled and slightly soft. A vector would fix it and is a single swap — every consumer goes through components/Logo.tsx or public/brand. Verified: tsc clean, 159/159 tests, build green, /icon.png and /apple-icon.png emit as static routes, and the header, footer, share card and all five icons were rendered and inspected in both themes. |
||
|
|
39e5c11274 |
fix(brand): match the below-average tint to its own hue, and finish the casing
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m19s
Three review findings. --status-below-bg carried the raw --coral tuple (249,115,96) instead of the hue it belongs to (#A9481F → 169,72,31), so "below average" chips rendered a coral-pink wash behind terracotta text — the one solid/tint pair in the file whose hues disagreed. Alpha drops 0.15 → 0.12 to match --status-above-bg, so the two chips read as equal weight; left at 0.15 the darker terracotta tint takes the label to 4.45:1 on Warm White, under the 4.5 floor. At 0.12 it is 4.86:1 on cards and 4.67:1 on Warm White. Auditing the whole file for the same class of error turned up one more: --action-bg was also a --coral tint while --action is the darkened #BE3C27. It is unused today and the dark-theme value was already correct, but it is aligned so a future coral ghost button gets a matching label and ground. The five phase pairs also "mismatch" by the same test and are deliberately left alone: those pair a pastel ground (sage, sky, mustard, lavender) with a deeper text hue, which is exactly what the guideline's Chips/Tags show. The lowercase wordmark had only reached the visual chrome. Every user-visible occurrence now matches: page title and template, appleWebApp title, authors, OpenGraph and Twitter titles and siteName, the school page's siteName, the PWA manifest name and short_name, the footer copyright line, the nav's aria-label, the Web Share API strings, and the editorial paragraph. Comments in lib/*.ts and AdmissionsView still say "SchoolCompare"; they describe the codebase rather than the mark, so they are left to keep this diff to the reported problem. The comment explaining why the font classes belong on <html> still described --font-schibsted, which no longer exists. It now names --font-manrope and --font-inter, and records that the e2e typeface assertion is the only guard — this failure mode throws nothing and fails no unit test. Verified: tsc clean, 159/159 tests, build green, and the rendered title is now "School Admissions Guide | schoolcompare". Status and phase labels re-checked on their own composited tints over both light grounds — all clear AA. |
||
|
|
accc562b90 |
feat(brand): adopt the schoolcompare identity across the site
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. |
||
|
|
8d50afef1e |
fix(design): meet AA on tinted surfaces, and stop the footer inverting in dark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m11s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m6s
Full audit across home, search, rankings, admissions, compare, and primary / secondary / special / no-data school pages, in both themes, measuring computed styles rather than reading CSS. Contrast: the status hues were specced against --bg-primary, but they are used as chip text on their own tint, which sits on cards and secondary surfaces rather than the page ground. Measured in the wild they were 3.85–4.44:1 — under AA — on Ofsted badges, delta chips, report-card chips and metric values, i.e. most of the product's actual signal. Darkened the light hues (#0e6e66 → #0b625a, #9a5b00 → #7f4a00) and lifted the dark teal (#3fb3a4 → #4fc0b0) so each clears AA on its own tint, which is the worst case rather than the easy one. Chart and series ramps follow. The footer was painting itself with a text token and lettering itself with a background token: `background: var(--text-primary); color: var(--bg-secondary)`. That reads correctly in one theme and inverts in the other — in dark mode it became a light slab at the bottom of a dark page, with amber section headings at 1.98:1. Added --surface-sunken and its on-* companions, which stay dark in BOTH themes (deliberately not --surface-inverse, whose whole job is to flip), and moved the footer onto them. Section headings were also using a status hue purely as decoration; they are now a muted on-surface token. The "Open full map" pill was a fixed white background with a themed text colour, so in dark mode it rendered light violet on white at 1.84:1. Both sides are token-driven now. Tabular numerals are now the default for .main rather than per-component opt-in, with prose opting back out — a handful of figures (miniNatPill, compareRowVal, factVal) had been missed by the class-name-based pass. Added a two-theme AA gate over home, rankings and admissions. It waits for `transition: color` to settle first: an earlier measurement pass read mid-transition values and reported seven failures that did not exist at rest. Worth stating plainly — most of what a naive audit flags here is its own artifact, and the check has to account for that to be worth having. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
45a3e7fb8f |
fix(design): correct the font assertion and make the Leaflet overrides win
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
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) Successful in 2m10s
Two follow-ups from watching the staging E2E run and re-auditing the live site. The typeface gate failed, but the site was right. Staging now serves "Schibsted Grotesk", "Schibsted Grotesk Fallback", …, -apple-system, sans-serif — exactly what it should. My assertion was /Times|serif$/, and `serif$` matches the tail of `sans-serif`, so a healthy stack could never pass. It now checks the FIRST family in the stack (the only one actually requested) and matches Times as a whole word. Also asserts running prose resolves to Literata, which the earlier version never covered. The Leaflet overrides from the previous fix never took effect. leaflet.css is imported from a client component, so its chunk loads after globals.css; at equal specificity (both bare `.leaflet-container`) the later sheet wins. Verified on the live page: my rule sat at stylesheet index 0, Leaflet's at index 3, and the attribution link was still #0078A8. Prefixing the overrides with `html` takes them to 0,1,1, which beats a bare class regardless of load order — injecting that on the live page turned the background to rgb(233,232,226) and the link to rgb(88,74,155). The palette gate would have caught this, except it skipped everything inside .leaflet-container to avoid flagging OSM tile imagery — which also skipped Leaflet's own chrome. Narrowed the exemption to .leaflet-tile-pane so the map controls are now covered. Also dropped the duplicated family name from the font tokens: next/font already expands the variable to the family plus its metric-matched fallback, so the stack was naming Schibsted Grotesk twice. It looked like a safety net but wasn't — an unresolvable var() with no fallback invalidates the whole declaration, so the literal after it never gets a turn. Verified against live staging: body and headings resolve to Schibsted Grotesk, prose to Literata, the search input is --text-primary rather than pure black, and the higher-specificity Leaflet rules win when injected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2433101fa0 |
fix(design): resolve the font tokens, and pull form controls and map chrome onto the palette
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 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m22s
Staging audit of the Cohort identity found the whole site rendering in Times. Root cause: next/font's variable classes were on <body>, while globals.css declares --font-display/--font-ui/--font-prose on :root as `var(--font-schibsted), ...`. A custom property's var() references resolve on the element that declares it, so at :root --font-schibsted was undefined, --font-display computed to the guaranteed-invalid value, and every font-family referencing it fell back. Nothing threw and the build was green — the only symptom was visual. Verified the mechanism in a browser both ways round: class on <body> gives an empty token and a Times body font; class on <html> resolves to Schibsted Grotesk. The classes now sit on <html>. Two colour escapes from the same audit: * Form controls don't inherit font or colour from their parent, so the omni search input and the map's "Open full map" button rendered in the system font at pure black. Nearly invisible against --text-primary in light mode and completely invisible on the dark ground. Added a base inherit rule. * Leaflet ships its own palette — a #ddd tile backdrop, #333 attribution text and a #0078A8 link blue that was the most saturated colour anywhere on the site. The map chrome now uses tokens; the tiles stay as OSM renders them. Three e2e gates added, because the existing suite passed while the site was entirely in Times: * the font tokens resolve to a non-empty value and the applied family is Schibsted, not a serif fallback * no visible element renders in the browser's default black * every rendered colour comes from the token palette — the manual audit, turned into a gate Also made the og:image check environment-relative: metadataBase pins canonical URLs to the production host, so the absolute URL pointed off-environment when the suite ran against staging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c3a5cc4e9 |
fix(design): keep below/attention off the brand hue, harden the share-card fonts
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
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 1m8s
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> |
||
|
|
8ab0ac0a04 |
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>
|
||
|
|
e098ad4bd1 |
fix(detail): restore the shell's mobile header/nav CSS
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 36s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m19s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The server/client split moved the header and sticky nav markup into
SchoolDetailShell, but their responsive rules stayed behind in
schoolSections.module.css. CSS Modules hash class names per file, so those
rules stopped matching entirely: on mobile the header details block never
collapsed ("Show all details" e2e journey failed), the toggle itself never
appeared, and the section-nav mobile swap (menu button in, link row out)
never applied.
Ports the stranded blocks into SchoolDetailShell.module.css verbatim: the
768px header block (stacked header, wrapped meta pills, details toggle +
collapse) and the 640px section-nav blocks (scroll-fade mask, bottom-sheet
panel, control swap). Adds a jest guard that every class a components/school
component references is defined in the stylesheet that component imports —
it fails on the three classes that had no rule at all here
(headerDetailsOpen, atEnd, sectionNavBackLabel).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
803e68970c |
refactor(detail): drop dead derived state from the shell
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 9m28s
Leftover from the mechanical extraction: the shell still called computeSchoolFlags() and derived isReportCard / ofstedInspectedDate / oeifAreas / oeifAllSameGrade / deprivationDesc / primaryAvg / secondaryAvg on every render, duplicating work page.tsx already does. None of those values were referenced in its JSX anymore -- that logic moved to the section composers. The chrome needs only four locally-derived values (latestResults, phase, isAllThrough, hasLocation), all one-liners over props it already owns. Removing them made seven props dead, which TypeScript caught at both call sites: absenceData, ofsted, admissions, admissionsHistory, deprivation, finance and nationalAvg now go straight to the section composers and never reach the client component. The shell's surface is down to schoolInfo, yearlyData, census, navItems and children. No behaviour change: 155 tests pass and the characterization tests remain byte-identical to the commit that introduced them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4c229aec6e |
chore: drop unused SWR hooks and dependency
useSchools, useFilters, useMetrics and useSchoolDetails were imported by nothing and were the only consumers of swr. All data fetching goes through lib/api.ts on the server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
752eb07310 |
refactor(detail): render sections on the server behind a client shell
page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.
The scroll-spy already located sections via document.getElementById, so it
works unchanged against server-rendered children.
Charts needed a client wrapper: next/dynamic with ssr:false is illegal in a
Server Component, so components/school/charts.tsx is the boundary that keeps
Chart.js (64 KB gz) lazy and browser-only.
Measured on this build:
- school route client chunk: 8 KB gz (33 KB raw)
- total static JS across all chunks: 380.6 -> 350.7 KB gz
- section markup is absent from every client chunk ("Got their first choice",
"Ofsted reports", "Most deprived" etc. all return 0 hits); shell strings
still present, as expected
- shared baseline unchanged at 172 KB gz -- out of scope, as designed
The 14 characterization tests pass byte-identical to the commit that
introduced them. Only the render helper changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a7f6ff4035 |
refactor(detail): extract sections as server components
Moves ~1,300 lines of section markup out of the two client views into components/school/, mirroring the components/compare/ layout. Twelve section components plus shared primitives, all server components. The only client file is AdmissionsViewToggle, which owns the hidden/aria-pressed state and receives both views as server-rendered children. JSX was extracted mechanically rather than retyped, so the markup the CSS modules depend on is verbatim. Sharing follows measured similarity, not assumption: - Finances (91%) shared. The secondary premises-cost card is gated behind a prop so primary pages are unchanged; enabling it is a one-line follow-up. - Ofsted (80%) shared, but behind a variant prop. The headline similarity hid a real fork: on a school with no overall grade the primary page shows a "Not rated" badge while the secondary shows a four-area OEIF panel, and the disclaimer copy differs. Both preserved exactly; reconciling them is a human decision, not a side effect of a move. - Admissions (14%) and History (40%) kept separate. Not yet wired up -- the old views still render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f786787b9 |
style(detail): merge the two detail stylesheets
Builds components/school/schoolSections.module.css from the primary module, plus 35 secondary-only rules, so shared section components have one stylesheet. Three categories of difference, handled separately so nothing changes visually: - 7 incidental-drift properties unioned in (overflow-wrap, word-break, min-width, user-select) -- fixes that landed on one page and were never back-ported. Also fixes latent long-school-name overflow on the page that lacked them. - 4 genuinely visual class differences kept as explicit variants: genderBar, heroStatValue, genderSplitBoys, genderSplitGirls. font-weight on the genderSplit pair was reclassified from drift to visual -- unioning it would have bolded the primary page. - 17 rules that exist only in the secondary stylesheet but target shared class names (.card, .sectionTitle, .metricCard). Applied flat these would restyle the primary page, so they are scoped under .secondaryScope, which only SecondarySchoolSections will carry. Audited: every rule in both original stylesheets is a subset of the merged one (0 MISSING, 0 LOST), excluding the documented variants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5044c24895 |
refactor(detail): extract derived flags and nav items to lib
Pure data-shape logic moves out of the client components so page.tsx can compute the section list on the server without importing them. The secondary page is not a variant of the primary one -- different section ids (gcse, wellbeing), different flags, and History gated on more than one year rather than at least one -- so it gets its own computeSecondaryFlags and buildSecondaryNavItems rather than bending a shared function. Adds 16 unit tests covering the all-through and special-school branches, previously reachable only through a full component render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d74cc95034 |
refactor(detail): fetch national averages on the server
Both detail views fetched /api/national-averages in a useEffect, so the England-comparison deltas popped in after hydration and every section that uses them was pinned to the client. The page now fetches it in parallel with the school details (backend-cached 1h, degrades to null) and passes it down. Removes one client round-trip per detail page and unblocks the section extraction. Characterization tests pass unmodified; only the render helper changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7c43a1baaf |
test: pin school detail behaviour before refactor
14 characterization tests covering Ofsted (both OEIF and Report Card layouts), KS2 results with the England delta, KS4 Attainment 8 / Progress 8 / EBacc, all-through dual rendering, special-school comparison suppression (PR #70), the admissions year/trend toggle, and conditional section rendering. All rendering goes through renderSchoolDetail(), the single seam the server/client split is allowed to change. The assertions must survive the refactor unmodified. Excludes __tests__/support from testMatch: it holds fixtures and helpers, not suites, and the glob was failing them as empty test files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bcf3498086 |
test: add jsdom globals and school detail fixtures
jsdom provides neither IntersectionObserver nor scrollTo; both detail views use them. Adds four fixtures (primary, secondary, all-through, special) covering the branches the characterization tests will pin. SchoolResult carries 57 required nullable fields, so fixtures are built from an all-null base row and override only what they exercise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
96d5fcf5b0 |
feat(detail): move religious character into header details + mobile reveal
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 52s
Religious character now sits with the other labelled header details (near
County / Constituency) as "Religious character: <denomination>" ("None" for
Does not apply / None), instead of as an identity chip.
On mobile/tablet (≤768px) the header details block was hidden outright; it's
now collapsed behind a "Show all details" toggle that reveals the full block
(headteacher, website, pupils, trust, phone, religious character, county,
constituency). Applied to both the primary and secondary detail views for
parity. Extends the e2e journey with the mobile toggle behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
8cf2ac47c1 |
Merge pull request 'feat(detail): surface GIAS identity/contact details, drop unwired sections' (#82) from feat/detail-gias-fields-remove-unwired into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 23s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m37s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m33s
Reviewed-on: #82 |
||
|
|
684da611ff |
feat(detail): always show religious character, "None" for no religious character
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 22s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m56s
Previously the chip was hidden for "Does not apply"/"None". Show it in all cases, rendering "None" for those two sentinel values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
0186227ced |
feat(detail): surface GIAS identity/contact details, drop unwired sections
PR Checks / Frontend Typecheck + Tests (pull_request) Canceled after 58s
PR Checks / Backend Smoke (pull_request) Canceled after 0s
PR Checks / Build Backend (no push) (pull_request) Canceled after 0s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 0s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Add seven school-identity fields to the detail header (both primary and secondary views): age range, religious character, nursery and sixth-form indicators as chips; telephone (tel: link), county and parliamentary constituency as header details. religious_denomination, age_range and has_sixth_form were already served; telephone, nursery_provision, county and parliamentary_constituency are newly wired through the marts query (with a NULL fallback for un-rebuilt marts, mirroring has_sixth_form) and the school_info API response. Remove three UI sections the backend never populated (always null): Year 1 Phonics, the SEN "types of additional needs" breakdown, and the average class-size card — along with their now-dead props, route plumbing, and the SenDetail/Phonics types + class_size_avg field. Extend the e2e detail journey to assert the Phonics section is gone and the new header fields render when the record carries them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
59b5f3f163 |
Merge pull request 'feat(ui): viewport-aware InfoPopover replaces broken metric tooltips' (#81) from feat/info-popover-tooltip into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 5m2s
Reviewed-on: #81 |
||
|
|
84bca53c7e |
refactor(compare): row-label help uses InfoPopover, not native title
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
15b7493b85 |
refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
1d9d2eb5ae |
feat(ui): add InfoPopover — viewport-aware metric help via Floating UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ddb42badb6 |
build(deps): add @floating-ui/react for the info popover
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
598ba9af4e |
Merge pull request 'fix(compare): a no-results school no longer blanks the trend chart' (#80) from fix/compare-null-year-blanks-chart into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 58s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m49s
Reviewed-on: #80 |
||
|
|
dc85254ad2 |
fix(compare): a no-results school no longer blanks the trend chart
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
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 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 54s
Adding a school with no performance data to a comparison made every school's trend line disappear until that school was removed. Root cause: /api/compare returns such a school with a single phantom yearly_data row (the dim_school LEFT JOIN) whose year is null. In buildCompareChart, Math.trunc(null) is 0, so the axis was seeded at year 0; fillAcademicYears then walked 0, 101, 202, … and hit its 50-step cap long before reaching the real years, leaving every school's series mapped entirely to null. Fix: ignore yearly rows without a real numeric year when building the axis and the per-school year map. Regression test added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
9556595800 |
feat(security): allow analytics subdomain to iframe the site
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
Replace X-Frame-Options: SAMEORIGIN with a CSP frame-ancestors directive so analytics.schoolcompare.co.uk (Umami heatmap/recorder) can embed the site while all other origins stay blocked. X-Frame-Options cannot allow a specific sibling subdomain (ALLOW-FROM is deprecated/ignored by modern browsers), so frame-ancestors is the correct replacement. Also update the nginx snippet in DEPLOYMENT.md to match, so the reverse proxy doesn't re-inject a conflicting X-Frame-Options header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
32f8a02862 |
fix(detail): give Results Over Time chart more height on desktop
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 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
The trend chart was capped at 280px tall on desktop, squishing the lines together and making them hard to read. Raise the base .chartContainer height to 380px in both the primary and secondary detail views. Mobile is unaffected: the base value is already overridden to height:auto by the existing max-width:768px query, where PerformanceChart's own .chartWrapper carries the canvas height. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
1f80571b1f |
feat(analytics): load Umami heatmap recorder
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 42s
Adds the Umami session recorder (recorder.js) that powers heatmaps, alongside the existing analytics tracker. Loaded via next/script with the same data-domains guard as the main tracker so it only fires on the production hostnames — staging runs the same image under a different host and must not feed the heatmap. Cookieless: recorder.js uses Umami's server-side session model (no cookie or local storage on the device), so it adds no new PECR consent trigger. Verified with tsc --noEmit and next build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
8a9ba30cc2 |
fix(detail): compare each SATs bar to its own national benchmark
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The KS2 SATs chart drew a single national-average line spanning the full height of each subject's chart area, positioned at the national *expected* value. But the area stacks two bars — Expected and Exceeding — and the higher-standard/greater-depth national is a very different, much lower figure (e.g. reading higher standard ~29% vs expected ~75%). So the line crossed the Exceeding bar at the wrong place, making every school's exceeding result look far below national when it wasn't. The per-subject higher-standard nationals were already computed in the fact_ks2_national_averages mart; they just weren't serialized. Fix: - backend: add reading_high_pct, writing_gd_pct (writing = greater depth) and maths_high_pct to the national-averages payload. - SchoolDetailView: pass a nationalExceedingPct per subject, mapping writing to the greater-depth figure. - SatsChart: replace the single full-height line with a national marker on each bar's own track (coral tick + "nat X%" in the bar header), so Expected and Exceeding each sit against the correct benchmark. KS2 only; the secondary Attainment 8 chart already uses one line for one measure and is untouched. Verified: tsc --noEmit, next build, and backend pytest (national averages marts, incl. a new test guarding the per-subject nationals). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
15da060e4a |
feat(admissions): surface easy-to-miss deadlines as callout boxes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The selective-school registration and faith-school SIF warnings were added as prose in the criteria steps, where they read as ordinary sentences and were easy to skim past. Pulled them into structured callout boxes with an amber "note" treatment (using the page's own gold accent tokens), a warning glyph, and a bold lead-in — so the two deadlines parents most often miss actually stand out. - Added a `callouts` field to Step; render as a callout group between the body and the tool link (both primary and secondary timelines). - Reverted the warning text back out of the step bodies so it isn't duplicated. Verified with tsc --noEmit and next build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
a5b2296240 |
fix(rankings,admissions): correct accuracy issues from education audit
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 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m11s
Follow-up audit of the rankings and admissions pages. Admissions (AdmissionsView.tsx): - National Offer Day no longer claims offers publish "from 12:01 am"; release times are set per-council (often late afternoon, some overnight), so it now tells parents to check their council's page. - Secondary preference count no longer states a flat "up to six" (that's London/Pan-London); most LAs allow three to six. Mirrors the hedge the primary step already used. - Added the two deadlines that most often catch parents out: selective schools' separate entrance-test registration (months earlier), and faith schools' supplementary information form sent direct to the school. Covered in both the primary and secondary criteria steps. - Reworded the equal-preference tip so it's precise: order is the tie-break among schools you qualify for (you get the highest-ranked one), not irrelevant. Rankings (RankingsView.tsx): - Subtitle "Top-performing schools by X" -> "Schools ranked by X", so it isn't nonsensical for context/equity/absence metrics. - KS2 progress isn't published for 2023/24 or 2024/25 (no KS1 baseline). Selecting a primary progress metric on a recent year used to dead-end on a generic "No rankings found". Added a scoped caveat to the progress hint and an explanatory empty-state (primary only — secondary Progress 8 is published). Added an admissions smoke journey (static content, stable milestones). Verified with tsc --noEmit and next build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |
||
|
|
22e9eb2d48 |
fix(home): make Att8 bar match its numbers and separate safeguarding
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m6s
Two internal-consistency fixes on the "What you'll see" mock: - The Attainment 8 bar had drifted from the numbers beside it (fill 68% / line 57% vs a 54.8 score and 45.9 national avg). Restored the mock's original 1:1 value-to-percent mapping: fill 54.8%, national line 45.9%. - The report card grouped "Safeguarding: Met" in the same five-point list as the graded areas, but safeguarding is a separate binary met/not-met judgement under the real framework. Moved it below the list behind a divider, with an outlined chip and a "separate check" label so it no longer reads as a graded area. Verified with tsc --noEmit and next build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB |