Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5 7fa2a76c05 fix(home): correct what the landing page claims, and give it one rhythm
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>
2026-08-14 17:15:09 +01:00
Tudor 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.
2026-08-08 22:39:28 +01:00
Tudor 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.
2026-08-08 22:22:58 +01:00
tudor eecb84fb35 Merge pull request 'feat(brand): adopt the schoolcompare identity across the site' (#89) from feat/schoolcompare-brand into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
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 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m6s
Reviewed-on: #89
2026-08-07 17:58:55 +00:00
Tudor 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.
2026-08-07 18:33:30 +01:00
Tudor 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.
2026-08-07 18:05:49 +01:00
tudor f31f6f8404 Merge pull request 'fix(design): correct the font assertion (serif$ matched sans-serif) and make the Leaflet overrides win' (#88) from fix/leaflet-specificity-and-font-assertion into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 57s
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 1m8s
Reviewed-on: #88
2026-08-06 14:47:05 +00:00
TudorandClaude Opus 5 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>
2026-08-06 15:38:31 +01:00
TudorandClaude Opus 5 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>
2026-08-06 14:04:36 +01:00
tudor 91b49f1a59 Merge pull request 'fix(design): resolve the font tokens — staging was rendering entirely in Times' (#87) from fix/font-cascade-and-map-palette 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 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 58s
Reviewed-on: #87
2026-08-06 12:52:34 +00:00
TudorandClaude Opus 5 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>
2026-08-06 13:38:17 +01:00
tudor f43a8ecb69 Merge pull request 'feat(design): adopt the Cohort identity — palette, type, mark and dark theme' (#86) from design/cohort-identity into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m1s
Reviewed-on: #86
2026-08-06 11:36:02 +00:00
TudorandClaude Opus 5 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>
2026-08-06 12:29:53 +01:00
TudorandClaude Opus 5 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>
2026-08-06 12:13:04 +01:00
70 changed files with 4234 additions and 3918 deletions

No files matched your search

+415
View File
@@ -69,6 +69,132 @@ test('home hero offers a "use my location" shortcut beside the search box', asyn
await expect(nearMe).toBeVisible();
});
/*
* The landing page's claims must match the product and the data.
*
* Every failure this group guards against is silent: a hardcoded fallback that
* renders as if it were live, a number that contradicts a constant elsewhere in
* the codebase, illustrative copy that reads as fact. None of them throw, none
* fail a build, and none show up in a unit test.
*/
test('the coverage figure is live, not the hardcoded fallback', async ({ page }) => {
const info = await (await page.request.get('/api/data-info')).json();
test.skip(info.status !== 'loaded', 'environment has no data loaded');
const expected: number = info.unique_schools;
expect(expected).toBeGreaterThan(0);
await page.goto('/');
const body = await page.locator('body').innerText();
// The real count, formatted as the page formats it.
expect(body).toContain(expected.toLocaleString('en-GB'));
// The fallback that used to render in its place. `total_schools` is not a
// field this API returns, so reading it yielded undefined and the "24,000+"
// floor was shown on every request while the database held far more.
expect(body).not.toContain('24,000+');
});
test('the landing page states the real comparison limit', async ({ page }) => {
await page.goto('/');
const body = await page.locator('body').innerText();
// MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
// promise three while a card lower on the same page promised five.
expect(body).toMatch(/five schools/i);
expect(body).not.toMatch(/three schools/i);
});
test('the landing page invents no data about real schools', async ({ page }) => {
await page.goto('/');
const preview = page.locator('table, [class*="comparePreview"]').first();
const body = await page.locator('body').innerText();
// The compare preview's columns carried two real school names beside
// invented results and invented Ofsted grades.
expect(body).not.toMatch(/Our Lady Queen of Heaven|St Mary's Catholic Primary/i);
await expect(preview).toContainText(/School A/);
});
test('the home page has a real title', async ({ page }) => {
await page.goto('/');
// It was the bare string "Home" — the browser tab, the bookmark and the
// search result all read that.
expect(await page.title()).not.toBe('Home');
expect(await page.title()).toMatch(/schoolcompare/i);
});
test('the landing bands share one vertical rhythm', async ({ page }) => {
await page.goto('/');
const gaps = await page.evaluate(() => {
const landing = document.querySelector('main div[class*="landing"]');
if (!landing) return null;
const kids = [...landing.children].map(el => el.getBoundingClientRect());
const out: number[] = [];
for (let i = 1; i < kids.length; i++) out.push(Math.round(kids[i].top - kids[i - 1].bottom));
return out;
});
expect(gaps).not.toBeNull();
expect(gaps!.length).toBeGreaterThan(1);
// Every band gap comes from the page container's single `gap`, so they are
// identical. They used to be 24 / 32 / 24 / 16 / 48 / 32px — each band set
// its own margin, and the page read as a stack of unrelated strips.
expect(new Set(gaps!).size).toBe(1);
});
/*
* The hero illustration. Two separate regressions are guarded here, both of
* which shipped: the drawing being cropped past its own subject on phones, and
* the drawing keeping a fixed light palette on a near-black page.
*/
test('the hero illustration swaps composition rather than cropping its subject', async ({ page }) => {
const visible = () => page.evaluate(() => {
const wide = document.querySelector('[class*="heroArtWide"]');
const band = document.querySelector('[class*="heroArtBand"]');
return {
wide: wide ? getComputedStyle(wide).display : 'missing',
band: band ? getComputedStyle(band).display : 'missing',
};
});
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
expect(await visible()).toEqual({ wide: 'block', band: 'none' });
// Below the one-column breakpoint the slot becomes a wide, short band. The
// tall composition sliced to that shape cropped the schoolhouse away
// entirely, leaving hills and a pin pointing at nothing.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
expect(await visible()).toEqual({ wide: 'none', band: 'block' });
});
test('the hero illustration is re-graded for the dark theme', async ({ browser }) => {
const read = async (colorScheme: 'light' | 'dark') => {
const ctx = await browser.newContext({ colorScheme });
const p = await ctx.newPage();
await p.goto('/');
const value = await p.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--ill-sky-high').trim()
);
await ctx.close();
return value;
};
const toLuminance = (hex: string) => {
const m = hex.replace('#', '');
const [r, g, b] = [0, 2, 4].map(i => parseInt(m.slice(i, i + 2), 16) / 255);
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
};
const light = await read('light');
const dark = await read('dark');
expect(light).toMatch(/^#[0-9a-f]{6}$/i);
expect(dark).toMatch(/^#[0-9a-f]{6}$/i);
// The sky used to be the same pale blue in both themes, which made it the
// brightest object on the page in dark mode — it out-shouted the H1 and the
// search box. It must now be substantially darker than its light value.
expect(toLuminance(dark)).toBeLessThan(toLuminance(light) / 4);
});
test('searching by name returns school results', async ({ page }) => {
await searchByName(page, 'primary');
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
@@ -626,3 +752,292 @@ test('sticky section nav jumps to server-rendered sections', async ({ page }) =>
const after = await page.evaluate(() => window.scrollY);
expect(after).toBeGreaterThan(before);
});
/**
* Brand surface. The identity work replaced a favicon-only asset set that
* broke iOS home-screen icons, Android PWA installs, and every link preview.
* These are silent failures — nothing on the page looks wrong — so they need
* a gate.
*/
test('the brand asset set is complete and served', async ({ page }) => {
const response = await page.goto('/');
expect(response?.ok()).toBe(true);
// The share card: without it, every link pasted into a chat renders bare.
const ogImage = page.locator('meta[property="og:image"]');
await expect(ogImage).toHaveCount(1);
const ogUrl = await ogImage.getAttribute('content');
expect(ogUrl).toBeTruthy();
// metadataBase pins canonical URLs to the production host, which is correct
// for prod but means the absolute URL points off-environment on staging.
// Fetch the path against whichever environment we're actually testing.
const og = await page.request.get(new URL(ogUrl!).pathname + new URL(ogUrl!).search);
expect(og.ok()).toBe(true);
expect(og.headers()['content-type']).toContain('image/png');
// iOS ignores SVG touch icons, so this must be a real raster.
const apple = await page.request.get('/apple-icon');
expect(apple.ok()).toBe(true);
expect(apple.headers()['content-type']).toContain('image/png');
// Android needs a maskable PNG or the install prompt has no icon.
for (const icon of ['/icon-192.png', '/icon-512.png', '/icon-maskable-512.png']) {
const res = await page.request.get(icon);
expect(res.ok(), `${icon} should be served`).toBe(true);
}
});
test('theme colour matches the page background in both themes', async ({ browser }) => {
// A mismatch here paints a stripe of the wrong colour across the top of the
// screen on mobile. Previously the dark themeColor was declared with no dark
// styling behind it at all.
for (const colorScheme of ['light', 'dark'] as const) {
const context = await browser.newContext({ colorScheme });
const page = await context.newPage();
await page.goto('/');
const declared = await page
.locator(`meta[name="theme-color"][media*="${colorScheme}"]`)
.getAttribute('content');
expect(declared, `theme-color declared for ${colorScheme}`).toBeTruthy();
const painted = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
);
expect(painted.toLowerCase()).toBe(declared!.toLowerCase());
await context.close();
}
});
test('the dark theme actually repaints the page', async ({ browser }) => {
// Guards the token layer: if a component reintroduces a hardcoded colour,
// the surface below stays light while everything around it flips.
const read = async (colorScheme: 'light' | 'dark') => {
const context = await browser.newContext({ colorScheme });
const page = await context.newPage();
await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
const values = await page.evaluate(() => {
const s = getComputedStyle(document.body);
return { bg: s.backgroundColor, fg: s.color };
});
await context.close();
return values;
};
const light = await read('light');
const dark = await read('dark');
expect(dark.bg).not.toBe(light.bg);
expect(dark.fg).not.toBe(light.fg);
});
/**
* Typography and palette integrity.
*
* The identity PR shipped with every font-family silently falling back to
* Times: the font variables landed on <body> while the tokens referencing
* them were declared on :root, so --font-display computed to the
* guaranteed-invalid value. Nothing threw, no test failed, and the build was
* green — the only symptom was visual. These assertions make that class of
* failure loud.
*/
test('the brand typefaces actually load and apply', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
const fonts = await page.evaluate(() => {
const root = getComputedStyle(document.documentElement);
// Only the FIRST family in the stack is the one actually asked for; the
// rest are fallbacks and always end in a generic like sans-serif.
const first = (el: Element) =>
getComputedStyle(el).fontFamily.split(',')[0].replace(/["']/g, '').trim();
const headings = [...document.querySelectorAll('h1, h2')].map(first);
return {
body: first(document.body),
headings,
bodyStack: getComputedStyle(document.body).fontFamily,
displayToken: root.getPropertyValue('--font-display').trim(),
uiToken: root.getPropertyValue('--font-ui').trim(),
proseToken: root.getPropertyValue('--font-prose').trim(),
};
});
// An empty token means the var() chain broke, which is the exact failure
// mode this guards — the computed font-family would look plausible either
// way, because an invalid font-family just inherits.
expect(fonts.displayToken, '--font-display resolved').not.toBe('');
expect(fonts.uiToken, '--font-ui resolved').not.toBe('');
expect(fonts.proseToken, '--font-prose resolved').not.toBe('');
// Inter carries body copy, every control and every figure; Manrope carries
// headings and key messaging. Both must actually resolve, not merely be
// named in a stack that never loads.
expect(fonts.body, 'body uses Inter').toBe('Inter');
expect(fonts.headings.length, 'the page has headings to check').toBeGreaterThan(0);
expect(fonts.headings, 'at least one heading uses Manrope').toContain('Manrope');
// The Times fallback is the specific failure that shipped once. Match the
// family name only — a stack legitimately ends in sans-serif, so anchoring
// on /serif$/ would flag a perfectly healthy page.
expect(fonts.bodyStack).not.toMatch(/\bTimes\b/);
});
/**
* The wordmark is the one piece of brand chrome a user reads on every page,
* and it is set as two spans so "compare" can take the brand colour. A
* refactor that drops the second span, or reverts the lowercase styling,
* changes the brand without failing anything else.
*/
test('the header carries the schoolcompare lockup', async ({ page }) => {
await page.goto('/');
const home = page.getByRole('banner').getByRole('link', { name: /schoolcompare home/i });
await expect(home).toBeVisible({ timeout: 15_000 });
// Assert on rendered text rather than CSS-module class names, which are
// hashed at build time and change on any unrelated edit.
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
const mark = home.locator('svg').first();
await expect(mark).toBeVisible();
});
test('no visible text falls back to the browser default black', async ({ page }) => {
// Form controls don't inherit colour from their parent, so a missing
// declaration renders pure black — subtle in light mode, invisible in dark.
await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
const blacks = await page.evaluate(() =>
[...document.querySelectorAll('body *')]
.filter((el) => {
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return false;
if (el.closest('.leaflet-tile-pane')) return false;
return getComputedStyle(el).color === 'rgb(0, 0, 0)';
})
.map((el) => el.tagName.toLowerCase() + '.' + (el.getAttribute('class') || '').split(' ')[0])
.slice(0, 10)
);
expect(blacks, `elements rendering pure black: ${blacks.join(', ')}`).toEqual([]);
});
test('rendered colours all come from the token palette', async ({ page }) => {
// Turns the manual design audit into a gate: anything painted with a colour
// the token layer doesn't define has escaped the system, and will not
// follow the dark theme.
await page.goto('/rankings');
await expect(page.locator('table, [class*="rankings"]').first()).toBeVisible({ timeout: 15_000 });
const strays = await page.evaluate(() => {
const root = getComputedStyle(document.documentElement);
const palette = new Set<string>();
for (const sheet of document.styleSheets) {
let rules: CSSRuleList;
try { rules = sheet.cssRules; } catch { continue; }
for (const rule of rules) {
const r = rule as CSSStyleRule;
if (r.selectorText !== ':root' || !r.style) continue;
for (const prop of r.style) {
if (!prop.startsWith('--')) continue;
const v = root.getPropertyValue(prop).trim();
if (v) palette.add(v.toLowerCase());
}
}
}
const norm = (c: string) => {
const d = document.createElement('div');
d.style.color = c;
document.body.appendChild(d);
const v = getComputedStyle(d).color;
d.remove();
return v;
};
const allowed = new Set([...palette].filter((v) => /^#|^rgb/.test(v)).map(norm));
const found: string[] = [];
for (const el of document.querySelectorAll('body *')) {
const box = el.getBoundingClientRect();
if (box.width < 2 || box.height < 2) continue;
if (el.closest('.leaflet-tile-pane')) continue; // OSM tiles are imagery, not palette
const s = getComputedStyle(el);
const checks: Array<[string, string]> = [['color', s.color]];
if (s.backgroundColor !== 'rgba(0, 0, 0, 0)') checks.push(['background', s.backgroundColor]);
for (const [prop, value] of checks) {
if (!value || value.startsWith('rgba(') || allowed.has(value)) continue;
const cls = (el.getAttribute('class') || '(none)').split(' ')[0];
const entry = `${value} as ${prop} on ${cls}`;
if (!found.includes(entry)) found.push(entry);
}
}
return found.slice(0, 12);
});
expect(strays, `off-palette colours: ${strays.join('; ')}`).toEqual([]);
});
/**
* Contrast, in both themes.
*
* The status hues were originally specced against --bg-primary, but they are
* used as chip text on their own tint, which sits on darker surfaces — so the
* real ratios were 3.85–4.44:1, under AA, on every school row and result card.
* The page ground is the easy case; the tinted chip is the one that fails.
*
* Waits for transitions to settle before measuring: several components carry
* `transition: color`, and reading mid-transition reports colours that were
* never on screen at rest.
*/
const CONTRAST_PROBE = `(() => {
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
const BG = el => { const ls=[]; let n=el;
while(n && n!==document.documentElement){ const c=ps(getComputedStyle(n).backgroundColor);
if(c && c.a>0){ ls.push(c); if(c.a===1) break; } n=n.parentElement; }
const base = ps(getComputedStyle(document.documentElement).backgroundColor)||{r:255,g:255,b:255,a:1};
let acc = ls.length && ls[ls.length-1].a===1 ? ls.pop() : base;
for(let i=ls.length-1;i>=0;i--) acc=ov(ls[i],acc); return acc; };
const out=[], seen=new Set();
for (const el of document.querySelectorAll('body *')) {
if (el.closest('.leaflet-container')) continue;
const r=el.getBoundingClientRect(), s=getComputedStyle(el);
if (r.width<2 || r.height<2 || s.visibility==='hidden' || s.opacity==='0') continue;
if (![...el.childNodes].some(n=>n.nodeType===3 && n.textContent.trim().length>1)) continue;
const fc=ps(s.color), bc=BG(el); if(!fc||!bc) continue;
const fg = fc.a<1?ov(fc,bc):fc, ratio=RT(fg,bc);
const px=parseFloat(s.fontSize), bold=parseInt(s.fontWeight,10)>=700;
const need=(px>=24||(px>=18.66&&bold))?3:4.5;
if (ratio >= need) continue;
const key=(el.getAttribute('class')||'')+s.color;
if (seen.has(key)) continue; seen.add(key);
out.push(((el.getAttribute('class')||'?').split(' ')[0])+' '+ratio.toFixed(2)+':1 (needs '+need+
') '+s.color+' on rgb('+Math.round(bc.r)+','+Math.round(bc.g)+','+Math.round(bc.b)+') "'+
el.textContent.trim().slice(0,28)+'"');
}
return out.slice(0, 12);
})()`;
for (const scheme of ['light', 'dark'] as const) {
test(`text meets WCAG AA in the ${scheme} theme`, async ({ browser }) => {
const context = await browser.newContext({ colorScheme: scheme });
const page = await context.newPage();
const failures: string[] = [];
for (const path of ['/', '/rankings', '/admissions']) {
await page.goto(path);
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 15_000 });
// Let `transition: color` settle — the longest in the app is 0.4s.
await page.waitForTimeout(700);
const found = (await page.evaluate(CONTRAST_PROBE)) as string[];
failures.push(...found.map((f) => `${path} → ${f}`));
}
await context.close();
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
});
}
+6
View File
@@ -47,6 +47,12 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Fonts for the generated share card. File tracing already places these in
# .next/standalone/assets, so the COPY above carries them — this makes the
# dependency explicit rather than implicit in the tracer's behaviour, because
# a miss here is a silent 500 on /opengraph-image, not a build failure.
COPY --from=builder /app/assets ./assets
# Set correct permissions
RUN chown -R nextjs:nodejs /app
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+500 -1919
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+39 -25
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from 'next';
import { DM_Sans, Playfair_Display } from 'next/font/google';
import { Manrope, Inter } from 'next/font/google';
import Script from 'next/script';
import { Navigation } from '@/components/Navigation';
import { Footer } from '@/components/Footer';
@@ -7,17 +7,22 @@ import { ComparisonToast } from '@/components/ComparisonToast';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import './globals.css';
const dmSans = DM_Sans({
// Manrope carries headings and key messaging — the guideline's "friendly,
// modern personality". It never sets body copy or a control.
const manrope = Manrope({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-dm-sans',
weight: ['500', '600', '700'],
variable: '--font-manrope',
display: 'swap',
});
const playfairDisplay = Playfair_Display({
// Inter carries body copy, every control and every figure — chosen in the
// guideline for clarity and legibility. Its tabular numerals hold a column
// properly, which matters on a page that is mostly percentages.
const inter = Inter({
subsets: ['latin'],
weight: ['600', '700'],
variable: '--font-playfair',
weight: ['400', '500', '600', '700'],
variable: '--font-inter',
display: 'swap',
});
@@ -27,41 +32,43 @@ export const viewport: Viewport = {
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
viewportFit: 'cover',
// These must match --bg-primary in globals.css for each theme, or the
// browser chrome and the page disagree at the top edge of the screen.
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
{ media: '(prefers-color-scheme: light)', color: '#FAFAF8' },
{ media: '(prefers-color-scheme: dark)', color: '#111A20' },
],
};
export const metadata: Metadata = {
appleWebApp: {
capable: true,
title: 'SchoolCompare',
title: 'schoolcompare',
statusBarStyle: 'default',
},
title: {
default: 'SchoolCompare | Compare School Performance',
template: '%s | SchoolCompare',
default: 'schoolcompare | Compare School Performance',
template: '%s | schoolcompare',
},
description: 'Compare primary and secondary school SATs and GCSE performance across England',
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
authors: [{ name: 'SchoolCompare' }],
authors: [{ name: 'schoolcompare' }],
manifest: '/manifest.json',
icons: {
icon: '/favicon.svg',
shortcut: '/favicon.svg',
apple: '/favicon.svg',
},
// No `icons` key on purpose: setting it here would override the file
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
// app/opengraph-image.tsx supplies og:image and twitter:image.
metadataBase: new URL('https://schoolcompare.co.uk'),
openGraph: {
type: 'website',
title: 'SchoolCompare | Compare School Performance',
title: 'schoolcompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
url: 'https://schoolcompare.co.uk',
siteName: 'SchoolCompare',
siteName: 'schoolcompare',
},
twitter: {
card: 'summary',
title: 'SchoolCompare | Compare School Performance',
// summary_large_image now that there is an image worth showing.
card: 'summary_large_image',
title: 'schoolcompare | Compare School Performance',
description: 'Compare primary and secondary school SATs and GCSE performance across England',
},
};
@@ -72,7 +79,15 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
// The font variable classes must sit on <html>, not <body>. globals.css
// declares --font-display on :root as var(--font-manrope) and --font-ui as
// var(--font-inter), and a custom property's var() references resolve on
// the element that declares it. With the classes on <body>, those two are
// undefined at :root, so --font-display computes to the guaranteed-invalid
// value and every font-family referencing it silently falls back — which
// once shipped the whole site in Times. Nothing throws and no unit test
// fails, so the e2e typeface assertion is the only guard.
<html lang="en" className={`${manrope.variable} ${inter.variable}`}>
<head>
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
<link rel="preconnect" href="https://api.postcodes.io" />
@@ -94,8 +109,7 @@ export default function RootLayout({
strategy="afterInteractive"
/>
</head>
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
<div className="noise-overlay" />
<body>
<ComparisonProvider>
<a href="#main-content" className="skip-link">Skip to main content</a>
<Navigation />
+117
View File
@@ -0,0 +1,117 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { ImageResponse } from 'next/og';
/**
* The site had no og:image at all, so every link pasted into a class WhatsApp
* group or a Slack channel rendered as a bare grey text card — on the highest
* intent channel this product has.
*
* The card is the brand's own proposition on the Sand ground, with the mark
* and a suggestion of the illustration's rolling landscape along the bottom.
* It states the promise rather than decorating it.
*/
export const alt = 'schoolcompare — find the school where your child will flourish';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
const SAND = '#F5EFE6';
const INK = '#1C2731';
const MUTED = '#5F6A75';
const TEAL = '#0F766E';
const SAGE = '#A7D7C5';
const SAGE_DEEP = '#7FC3AC';
const SAGE_PALE = '#D6EDE2';
async function font(file: string) {
return readFile(join(process.cwd(), 'assets', file));
}
/**
* The mark, as a data URI. Satori has no access to the public/ URL space, so
* the artwork is read off disk and inlined. This is the same file the header
* serves — public/brand/mark.png — so the card can never drift from the site.
* The 47×56 box below is that file's 176:208 ratio; re-crop the artwork and
* this has to move with it, or Satori will stretch it.
*/
async function markDataUri() {
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
return `data:image/png;base64,${png.toString('base64')}`;
}
export default async function OpengraphImage() {
const [regular, bold, mark] = await Promise.all([
font('Manrope-Regular.ttf'),
font('Manrope-Bold.ttf'),
markDataUri(),
]);
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
background: SAND,
padding: '58px 72px 0',
fontFamily: 'Manrope',
position: 'relative',
}}
>
{/* Rolling hills along the bottom edge — the illustration style,
reduced to the one gesture that survives at thumbnail size. */}
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 210, display: 'flex' }}>
<svg width="1200" height="210" viewBox="0 0 1200 210">
<path d="M0 92 C 200 56 380 100 560 78 C 760 54 940 92 1200 62 L1200 210 L0 210 Z" fill={SAGE_PALE} />
<path d="M0 132 C 230 96 420 140 620 118 C 830 94 1000 130 1200 106 L1200 210 L0 210 Z" fill={SAGE} />
<path d="M0 176 C 260 146 470 186 690 166 C 900 146 1040 174 1200 158 L1200 210 L0 210 Z" fill={SAGE_DEEP} />
</svg>
</div>
{/* Lockup */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={mark} width={47} height={56} alt="" />
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
<span style={{ color: INK }}>school</span>
<span style={{ color: TEAL }}>compare</span>
</div>
</div>
{/* Proposition. Sized so the headline holds two lines and the subhead
one — an orphaned word reads as an accident at this size. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, paddingBottom: 236 }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
fontSize: 72,
fontWeight: 700,
letterSpacing: '-0.035em',
lineHeight: 1.05,
color: INK,
maxWidth: 900,
}}
>
<span style={{ marginRight: 18 }}>Find the school where</span>
<span style={{ marginRight: 18 }}>your child will</span>
<span style={{ color: TEAL }}>flourish.</span>
</div>
<div style={{ display: 'flex', fontSize: 27, color: MUTED, letterSpacing: '-0.005em' }}>
24,000+ schools in England · SATs, GCSEs, Ofsted and admissions
</div>
</div>
</div>
),
{
...size,
fonts: [
{ name: 'Manrope', data: regular, weight: 400, style: 'normal' },
{ name: 'Manrope', data: bold, weight: 700, style: 'normal' },
],
}
);
}
+30 -5
View File
@@ -3,7 +3,9 @@
* Main landing page with school search and browsing
*/
import type { Metadata } from 'next';
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
import { formatAcademicYear } from '@/lib/utils';
import { HomeView } from '@/components/HomeView';
import { HowItWorksSection } from '@/components/HowItWorksSection';
import { EditorialSection } from '@/components/EditorialSection';
@@ -24,8 +26,14 @@ interface HomePageProps {
}>;
}
export const metadata = {
title: 'Home',
/*
* `title` was the bare string 'Home', which is what the browser tab, the
* bookmark and the search result all read. `absolute` opts out of the root
* layout's "%s | schoolcompare" template so the homepage doesn't end up
* saying the brand twice.
*/
export const metadata: Metadata = {
title: { absolute: 'schoolcompare | Compare every school in England' },
description: 'Search and compare school performance across England',
};
@@ -79,14 +87,24 @@ export default async function HomePage({ searchParams }: HomePageProps) {
}
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
const total = dataInfo?.total_schools ?? null;
// `unique_schools`, not `total_schools` — the latter is not a field this
// endpoint returns, and reading it silently yielded null on every request.
const total = dataInfo?.unique_schools ?? null;
const years = dataInfo?.years_available ?? [];
return (
<HomeView
initialSchools={schoolsData}
filters={resolvedFilters}
totalSchools={total}
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
editorial={hasSearchParams ? null : (
<EditorialSection
totalSchools={total}
localAuthorityCount={resolvedFilters.local_authorities.length}
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
/>
)}
/>
);
} catch (error) {
@@ -99,7 +117,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
filters={emptyFilters}
totalSchools={null}
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
editorial={hasSearchParams ? null : (
<EditorialSection
totalSchools={null}
localAuthorityCount={0}
earliestYearLabel={null}
latestYearLabel={null}
/>
)}
/>
);
}
+1 -1
View File
@@ -98,7 +98,7 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
description,
type: 'website',
url: `https://schoolcompare.co.uk${canonicalPath}`,
siteName: 'SchoolCompare',
siteName: 'schoolcompare',
},
twitter: {
card: 'summary',
Binary file not shown.
Binary file not shown.
+14 -6
View File
@@ -9,6 +9,7 @@
import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup';
import { useThemeTokens, alpha } from '@/lib/theme';
import { formatAcademicYear } from '@/lib/utils';
import { fillAcademicYears } from '@/lib/compareChartData';
import type { SchoolAdmissions } from '@/lib/types';
@@ -38,6 +39,11 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5);
const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5);
// Canvas can't read var(), so pull the resolved tokens through the bridge.
const [cBrand, cGrid, cInverse, cInverseText, cCard] = useThemeTokens(
'--brand', '--chart-grid', '--surface-inverse', '--text-inverse', '--bg-card'
);
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
@@ -49,7 +55,9 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: 'rgba(26,22,18,0.92)',
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
padding: 10,
titleFont: { size: 12 },
bodyFont: { size: 12 },
@@ -62,7 +70,7 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
y: {
min: yMin,
max: yMax,
grid: { color: 'rgba(0,0,0,0.05)' },
grid: { color: cGrid },
ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` },
},
x: {
@@ -80,14 +88,14 @@ export default function AdmissionsTrendChart({ history }: { history: SchoolAdmis
data: values,
clip: false as const,
spanGaps: false,
borderColor: '#e07256',
backgroundColor: 'rgba(224,114,86,0.10)',
borderColor: cBrand,
backgroundColor: alpha('--brand', 0.10),
borderWidth: 2.5,
tension: 0.3,
fill: true,
pointRadius: values.map((_, i) => (i === lastIdx ? 5 : 3)),
pointBackgroundColor: '#e07256',
pointBorderColor: '#fff',
pointBackgroundColor: cBrand,
pointBorderColor: cCard,
pointBorderWidth: values.map((_, i) => (i === lastIdx ? 2 : 0)),
pointHoverRadius: 6,
},
+79 -74
View File
@@ -27,7 +27,7 @@
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
margin-bottom: 0.85rem;
padding-left: 0.85rem;
}
@@ -39,7 +39,7 @@
display: flex;
flex-direction: column;
gap: 0.1rem;
border-left: 2px solid var(--border-color, #e5dfd5);
border-left: 2px solid var(--border);
}
.navLink {
@@ -51,33 +51,33 @@
border-left: 2px solid transparent;
font-size: 0.88rem;
font-weight: 500;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
text-decoration: none;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
}
.navLink:hover {
color: var(--text-primary, #1a1612);
color: var(--text-primary);
}
.navLinkActive {
color: var(--accent-teal, #2d7d7d);
color: var(--brand);
font-weight: 700;
border-left-color: var(--accent-teal, #2d7d7d);
border-left-color: var(--brand);
}
.navDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border-color, #e5dfd5);
background: var(--border);
flex-shrink: 0;
transition: background 0.15s ease;
}
.navLinkActive .navDot {
background: var(--accent-teal, #2d7d7d);
background: var(--brand);
}
/* ─── Hero ───────────────────────────────────────────── */
@@ -95,8 +95,8 @@
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-teal, #2d7d7d);
background: rgba(45, 125, 125, 0.1);
color: var(--brand);
background: rgba(var(--brand-rgb), 0.1);
padding: 0.3rem 0.7rem;
border-radius: 999px;
margin-bottom: 1rem;
@@ -107,22 +107,22 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-teal, #2d7d7d);
background: var(--brand);
}
.heroTitle {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-family: var(--font-display);
font-size: 2.75rem;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.015em;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.85rem;
}
.heroSub {
font-size: 1.05rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
max-width: 640px;
margin: 0 auto;
line-height: 1.55;
@@ -132,7 +132,7 @@
.countdownSection {
padding: 0 0 2.5rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
border-bottom: 1px solid var(--border);
margin-bottom: 2.5rem;
}
@@ -145,7 +145,7 @@
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
}
.countdownRail {
@@ -155,11 +155,11 @@
}
.chip {
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.1rem 0.9rem;
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
display: flex;
flex-direction: column;
gap: 0.2rem;
@@ -177,12 +177,12 @@
border-radius: 12px 12px 0 0;
}
.chipDeadline::before { background: var(--accent-coral, #e07256); }
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
.chipDeadline::before { background: var(--brand); }
.chipOffer::before { background: var(--status-above); }
.chipUrgent {
border-color: rgba(224, 114, 86, 0.4);
background: rgba(224, 114, 86, 0.04);
border-color: rgba(var(--status-below-rgb), 0.4);
background: rgba(var(--status-below-rgb), 0.04);
}
.chipTrack {
@@ -196,8 +196,8 @@
margin-bottom: 0.15rem;
}
.chipTrackDeadline { color: var(--accent-coral-dark, #b04a2e); }
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
.chipTrackDeadline { color: var(--brand-strong); }
.chipTrackOffer { color: var(--status-above); }
.chipTrackDot {
width: 5px;
@@ -208,7 +208,8 @@
}
.chipDays {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-variant-numeric: tabular-nums;
font-family: var(--font-display);
font-size: 2.6rem;
font-weight: 700;
line-height: 1;
@@ -217,17 +218,17 @@
.chipDeadline .chipDays,
.chipUrgent .chipDays {
color: var(--accent-coral-dark, #c45a3f);
color: var(--status-below);
}
.chipOffer .chipDays {
color: var(--accent-teal, #2d7d7d);
color: var(--status-above);
}
.chipDaysUnit {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
margin-left: 0.2rem;
vertical-align: bottom;
line-height: 2;
@@ -236,14 +237,14 @@
.chipMilestone {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
line-height: 1.25;
margin-top: 0.1rem;
}
.chipDate {
font-size: 0.75rem;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
margin-top: 0.05rem;
}
@@ -251,8 +252,8 @@
.track {
margin-bottom: 3rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
}
@@ -263,7 +264,7 @@
justify-content: space-between;
gap: 1.5rem;
padding: 1.75rem 2rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
@@ -278,22 +279,22 @@
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
margin-bottom: 0.35rem;
}
.trackTitle {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.4rem;
line-height: 1.2;
}
.trackSub {
font-size: 0.9rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
@@ -317,13 +318,13 @@
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
}
.trackDateVal {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
}
/* ─── Timeline ───────────────────────────────────────── */
@@ -356,27 +357,27 @@
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--bg-card, #fff);
border: 2px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 2px solid var(--border);
flex-shrink: 0;
transition: border-color 0.2s ease, background 0.2s ease;
}
.stepDeadline .stepDot {
background: rgba(224, 114, 86, 0.15);
border-color: var(--accent-coral, #e07256);
background: rgba(var(--brand-rgb), 0.15);
border-color: var(--brand);
}
.stepOffer .stepDot {
background: rgba(45, 125, 125, 0.15);
border-color: var(--accent-teal, #2d7d7d);
background: rgba(var(--status-above-rgb), 0.15);
border-color: var(--status-above);
}
.stepLine {
width: 2px;
flex: 1;
min-height: 24px;
background: var(--border-color, #e5dfd5);
background: var(--border);
margin: 4px 0;
}
@@ -395,27 +396,28 @@
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
margin-bottom: 0.2rem;
}
.stepDeadline .stepDate { color: var(--accent-coral-dark, #b04a2e); }
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
.stepDeadline .stepDate { color: var(--brand-strong); }
.stepOffer .stepDate { color: var(--status-above); }
.stepTitle {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.3rem;
line-height: 1.3;
}
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
.stepDeadline .stepTitle { color: var(--brand-strong); }
.stepOffer .stepTitle { color: var(--status-above); }
.stepBody {
font-family: var(--font-prose);
font-size: 0.88rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
line-height: 1.55;
margin: 0;
}
@@ -427,13 +429,13 @@
margin-top: 0.6rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--accent-teal, #2d7d7d);
color: var(--status-above);
text-decoration: none;
transition: gap 0.2s ease, color 0.2s ease;
}
.stepTool:hover {
color: var(--accent-coral-dark, #c45a3f);
color: var(--brand-strong);
gap: 0.6rem;
}
@@ -455,9 +457,9 @@
display: flex;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.12));
border: 1px solid rgba(201, 162, 39, 0.28);
border-left: 3px solid var(--accent-gold, #c9a227);
background: var(--status-below-bg);
border: 1px solid rgba(var(--status-below-rgb), 0.28);
border-left: 3px solid var(--status-below);
border-radius: 8px;
}
@@ -467,8 +469,8 @@
height: 1.05rem;
margin-top: 0.1rem;
border-radius: 50%;
background: var(--accent-gold, #c9a227);
color: #fff;
background: var(--status-below);
color: var(--text-inverse);
font-size: 0.72rem;
font-weight: 800;
line-height: 1.05rem;
@@ -482,14 +484,15 @@
.calloutTitle {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-gold-text, #7a6800);
color: var(--status-below);
line-height: 1.3;
margin-bottom: 0.15rem;
}
.calloutBody {
font-family: var(--font-prose);
font-size: 0.82rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
@@ -501,10 +504,10 @@
}
.tipsHeading {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 1.25rem;
}
@@ -515,16 +518,17 @@
}
.tipCard {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
border-radius: 12px;
padding: 1.25rem;
}
.tipNumber {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-variant-numeric: tabular-nums;
font-family: var(--font-display);
font-size: 1.75rem;
font-weight: 700;
color: #7d766b; /* 3.8:1 on cream — AA large-text for this 28px numeral */
color: var(--text-muted); /* 3.8:1 on cream — AA large-text for this 28px numeral */
line-height: 1;
margin-bottom: 0.6rem;
}
@@ -532,14 +536,15 @@
.tipHeading {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.4rem;
line-height: 1.3;
}
.tipBody {
font-family: var(--font-prose);
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
line-height: 1.55;
margin: 0;
}
@@ -557,8 +562,8 @@
position: sticky;
top: 0;
padding-top: 0;
background: var(--bg-primary, #faf8f3);
border-bottom: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
margin: 0 -1.25rem 1rem;
padding: 0.6rem 1.25rem;
z-index: 10;
@@ -590,8 +595,8 @@
}
.navLinkActive {
background: var(--text-primary, #1a1612);
color: #fff;
background: var(--text-primary);
color: var(--text-inverse);
border-left: none;
}
+2 -2
View File
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
date: 'September',
title: 'Portal opens',
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
tool: { label: 'Build and compare your shortlist', href: '/compare' },
tool: { label: 'Compare schools side by side', href: '/compare' },
},
{
date: '31 October',
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
date: 'September',
title: 'Portal opens',
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
tool: { label: 'Build and compare your shortlist', href: '/compare' },
tool: { label: 'Compare schools side by side', href: '/compare' },
},
{
date: '15 January',
@@ -43,7 +43,7 @@
min-height: 40px;
min-width: 0;
padding: 4px 10px;
border: 1px solid rgba(0, 0, 0, .12);
border: 1px solid var(--border);
border-radius: 999px;
background: transparent;
cursor: pointer;
@@ -52,8 +52,8 @@
}
.chip[aria-pressed="true"] {
background: rgba(0, 0, 0, .06);
border-color: rgba(0, 0, 0, .35);
background: var(--bg-secondary);
border-color: var(--border-strong);
}
.chipDot {
+14 -4
View File
@@ -15,6 +15,7 @@ import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup';
import { useSeriesColors, useThemeTokens } from '@/lib/theme';
import { buildCompareChart } from '@/lib/compareChartData';
import type { ComparisonData } from '@/lib/types';
import {
@@ -48,6 +49,13 @@ const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'st
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
const isMobile = useIsMobile();
// Canvas can't resolve var(), so the series come through the token bridge.
// Same index order as CHART_COLORS, so a school's line and its DOM swatch
// stay the same colour.
const seriesColors = useSeriesColors();
const [cRef, cGrid, cInverse, cInverseText] = useThemeTokens(
'--chart-reference', '--chart-grid', '--surface-inverse', '--text-inverse'
);
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
// A focused school that leaves the basket must not linger.
@@ -67,7 +75,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
const school = schools[series.schoolIndex];
const color = CHART_COLORS[series.schoolIndex % CHART_COLORS.length];
const color = seriesColors[series.schoolIndex % seriesColors.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return {
@@ -90,7 +98,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
datasets.push({
label: built.englandDataset.label,
data: built.englandDataset.data,
borderColor: 'rgba(109, 104, 95, 0.9)',
borderColor: cRef,
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: built.englandDataset.borderDash,
@@ -138,7 +146,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
padding: isMobile ? 10 : 12,
titleFont: {
size: isMobile ? 12 : 14,
@@ -182,7 +192,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
...(isMobile && { maxTicksLimit: 5 }),
},
grid: {
color: 'rgba(0, 0, 0, 0.05)',
color: cGrid,
},
},
x: {
@@ -23,11 +23,11 @@
flex-direction: column;
gap: 0;
padding: 1rem 1.25rem;
background: var(--bg-primary, #faf7f2);
color: var(--text-primary, #2c2420);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(44, 36, 32, 0.18), 0 2px 8px rgba(44, 36, 32, 0.08);
border: 1px solid var(--border-color, #e8ddd4);
box-shadow: 0 8px 32px rgba(var(--shadow-rgb), 0.18), 0 2px 8px rgba(var(--shadow-rgb), 0.08);
border: 1px solid var(--border);
min-width: 280px;
}
@@ -37,8 +37,8 @@
justify-content: center;
width: 28px;
height: 28px;
background: var(--accent-coral, #e07256);
color: white;
background: var(--brand);
color: var(--brand-on);
border-radius: 50%;
font-weight: 700;
font-size: 0.9rem;
@@ -62,13 +62,13 @@
gap: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary, #2c2420);
color: var(--text-primary);
}
.collapseBtn {
background: none;
border: none;
color: var(--text-muted, #8a7a72);
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
@@ -79,7 +79,7 @@
}
.collapseBtn:hover {
color: var(--text-primary, #2c2420);
color: var(--text-primary);
}
.schoolList {
@@ -95,13 +95,13 @@
justify-content: space-between;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
border-radius: var(--radius-sm, 4px);
}
.schoolName {
font-size: 0.8rem;
color: var(--text-primary, #2c2420);
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -111,7 +111,7 @@
.removeSchoolBtn {
background: none;
border: none;
color: var(--text-muted, #8a7a72);
color: var(--text-muted);
cursor: pointer;
font-size: 1rem;
padding: 0 0.25rem;
@@ -121,7 +121,7 @@
}
.removeSchoolBtn:hover {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
}
.toastActions {
@@ -129,13 +129,13 @@
align-items: center;
gap: 0.75rem;
padding-top: 0.625rem;
border-top: 1px solid var(--border-color, #e8ddd4);
border-top: 1px solid var(--border);
}
.btnClearAll {
background: none;
border: none;
color: var(--text-muted, #8a7a72);
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
@@ -145,13 +145,13 @@
}
.btnClearAll:hover {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
}
.btnCompare {
flex: 1;
background: var(--accent-coral, #e07256);
color: white;
background: var(--brand);
color: var(--brand-on);
padding: 0.6rem 1.25rem;
border-radius: 25px;
font-weight: 600;
@@ -164,7 +164,7 @@
.btnCompare:hover {
transform: translateY(-1px);
background: var(--accent-coral-dark, #c9614a);
background: var(--brand-strong);
}
/* Hidden on phones — the bottom tab bar's Compare badge already
+26 -26
View File
@@ -18,14 +18,14 @@
.header h1 {
font-size: 2.25rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.5rem;
font-family: var(--font-playfair), 'Playfair Display', serif;
font-family: var(--font-display);
}
.subtitle {
font-size: 1rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
margin: 0;
line-height: 1.6;
max-width: 60ch;
@@ -43,7 +43,7 @@
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color, #e5dfd5);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
@@ -53,8 +53,8 @@
padding: 0.625rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
background: var(--bg-card, white);
color: var(--text-secondary, #5c564d);
background: var(--bg-card);
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.15s ease;
@@ -62,21 +62,21 @@
}
.phaseTab:not(:last-child) {
border-right: 1px solid var(--border-color, #e5dfd5);
border-right: 1px solid var(--border);
}
.phaseTab:hover {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
}
.phaseTabActive {
background: var(--accent-coral-dark, #b04a2e);
color: white;
background: var(--brand-strong);
color: var(--brand-on);
font-weight: 600;
}
.phaseTabActive:hover {
background: var(--accent-coral-darker, #9c3f26);
background: var(--brand-stronger);
}
/* Sticky school bar — column identity while scrolling; horizontal scroll on
@@ -87,23 +87,23 @@
position: sticky;
top: 65px;
z-index: 10;
background: var(--bg-primary, #faf7f2);
background: var(--bg-primary);
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light, #e5dfd5);
border-bottom: 1px solid var(--border);
-webkit-overflow-scrolling: touch;
}
.schoolChip {
flex: 1 1 0;
min-width: 180px;
background: var(--bg-card, white);
border: 1px solid var(--border-light, #e5dfd5);
border-top: 3px solid var(--accent-coral, #e07256);
background: var(--bg-card);
border: 1px solid var(--border);
border-top: 3px solid var(--brand);
border-radius: 8px;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
padding: 0.55rem 0.75rem;
display: flex;
gap: 0.55rem;
@@ -126,12 +126,12 @@
font-weight: 600;
font-size: 0.92rem;
line-height: 1.25;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
text-decoration: none;
}
.chipName:hover {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
}
/* Full name on desktop, short name on the compact mobile pills. */
@@ -142,7 +142,7 @@
.chipMeta {
display: block;
font-size: 0.78rem;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -185,22 +185,22 @@
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
}
.barCaptionCount {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
}
}
.chipRemove {
margin-left: auto;
border: none;
background: var(--bg-secondary, #f3ede4);
color: var(--text-muted, #6d685f);
background: var(--bg-secondary);
color: var(--text-muted);
border-radius: 50%;
width: 22px;
height: 22px;
@@ -212,9 +212,9 @@
.footnote {
font-size: 0.78rem;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
margin-top: 2.5rem;
border-top: 1px solid var(--border-light, #e5dfd5);
border-top: 1px solid var(--border);
padding-top: 1rem;
max-width: 75ch;
}
+3 -3
View File
@@ -224,11 +224,11 @@ export function ComparisonView({
const url = window.location.href;
const count = selectedSchools.length;
const shareData = {
title: 'School comparison · SchoolCompare',
title: 'School comparison · schoolcompare',
text:
count > 0
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
: 'SchoolCompare',
? `Comparing ${count} school${count === 1 ? '' : 's'} on schoolcompare`
: 'schoolcompare',
url,
};
if (
+6 -6
View File
@@ -23,16 +23,16 @@
}
.good {
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
color: var(--accent-teal, #2d7d7d);
background: var(--status-above-bg);
color: var(--status-above);
}
.bad {
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
color: var(--accent-coral-dark, #b04a2e);
background: var(--status-below-bg);
color: var(--status-below);
}
.neutral {
background: var(--bg-secondary, #f3ede4);
color: var(--text-muted, #8a847a);
background: var(--bg-secondary);
color: var(--text-muted);
}
+1 -1
View File
@@ -61,7 +61,7 @@
border-radius: 50%;
transform: translateX(-50%);
border: 2px solid var(--bg-card);
box-shadow: 0 0 0 1px rgba(26, 22, 18, 0.08);
box-shadow: 0 0 0 1px rgba(var(--shadow-rgb), 0.08);
}
.pointLabel {
+40 -34
View File
@@ -5,54 +5,60 @@ import styles from './HomeView.module.css';
interface EditorialSectionProps {
totalSchools: number | null;
localAuthorityCount: number;
latestYearLabel: string | null;
earliestYearLabel: string | null;
}
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
export function EditorialSection({
totalSchools,
localAuthorityCount,
latestYearLabel,
earliestYearLabel,
}: EditorialSectionProps) {
/*
* The coverage line replaces a six-row "Coverage at a glance" table.
*
* That table put six numbers on the page to say one thing, and the headline
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
* DataInfoResponse declared a `total_schools` field the API does not send
* (it sends `unique_schools`). The fetch had succeeded; only that field was
* undefined, so the fallback rendered and nothing failed. Every figure here
* is now live, and any figure that isn't available is dropped rather than
* replaced with a guess.
*/
const coverage = [
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
].filter(Boolean);
return (
<section className={styles.editorial}>
<div className={styles.editorialGrid}>
// No class on the section: the page container owns the vertical rhythm
// now, and this band carries its own ground on the card inside it.
<section>
<div className={styles.editorialCard}>
<div className={styles.sectionHead}>
<p className={styles.sectionKicker}>About school data</p>
<h2 className={styles.sectionHeading}>
Making England&apos;s school performance data actually readable
</h2>
</div>
<div className={styles.editorialText}>
<div className={styles.editorialKicker}>About school data</div>
<h2 className={styles.editorialHeading}>Making England&apos;s school performance data actually readable</h2>
<p>
School performance data in England is rich but fragmented. The Department for Education and Ofsted
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
and demographics — each in its own table, each with its own jargon.
</p>
<p>
SchoolCompare brings it all into one place. Every school page shows performance against the national
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
schoolcompare brings it all into one place. Every school page shows performance against the national
average, explains what the numbers mean, and lets you compare schools side by side. Built for
parents, governors, journalists, and anyone who wants to understand a school without reading a
full inspection report.
</p>
</div>
<div className={styles.factbox}>
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
<div className={styles.factRow}>
<span className={styles.factKey}>Schools covered</span>
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Local authorities</span>
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Phases</span>
<span className={styles.factVal}>Primary &amp; Secondary</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Latest results year</span>
<span className={styles.factVal}>2024/25</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Historical data</span>
<span className={styles.factVal}>2016–2025</span>
</div>
<div className={styles.factRow}>
<span className={styles.factKey}>Metrics per school</span>
<span className={styles.factVal}>40+</span>
</div>
</div>
{coverage.length > 0 && (
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
)}
</div>
</section>
);
+11 -11
View File
@@ -5,15 +5,15 @@
justify-content: center;
padding: 4rem 2rem;
text-align: center;
background: var(--bg-card, white);
border: 2px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 2px solid var(--border);
border-radius: 16px;
min-height: 400px;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.icon {
color: var(--text-muted, #8a847a);
color: var(--text-muted);
margin-bottom: 1.5rem;
opacity: 0.7;
}
@@ -22,14 +22,14 @@
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
font-family: var(--font-playfair), 'Playfair Display', serif;
color: var(--text-primary);
font-family: var(--font-display);
}
.message {
margin: 0 0 2rem 0;
font-size: 1rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
max-width: 500px;
line-height: 1.6;
}
@@ -38,8 +38,8 @@
padding: 0.875rem 2rem;
font-size: 1rem;
font-weight: 600;
background: var(--accent-coral, #e07256);
color: white;
background: var(--brand);
color: var(--brand-on);
border: none;
border-radius: 8px;
cursor: pointer;
@@ -47,9 +47,9 @@
}
.button:hover {
background: var(--accent-coral-dark, #c45a3f);
background: var(--brand-strong);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
box-shadow: 0 4px 12px rgba(var(--brand-rgb), 0.3);
}
.button:active {
+360 -263
View File
@@ -1,11 +1,26 @@
/*
* FilterBar — one component, two shapes.
*
* hero a single white pill on the Sand hero panel, carrying the one
* coral action the page exists for. No card of its own: the panel
* behind it belongs to the page, so the pill reads as the object.
* compact the filter rail above the results — a white card of quiet
* controls, brand for what is active, sage for what is selected.
*
* Colour comes from the token layer only; the disclosure arrows are drawn
* from currentColor rather than a background image so they follow the theme.
*/
/* ── Shell ────────────────────────────────────────────────────────── */
.filterBar {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
transition: opacity 0.2s ease;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem 1.125rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow-soft);
transition: opacity var(--transition);
}
.filterBar.isLoading {
@@ -14,112 +29,16 @@
}
.heroMode {
padding: 2.5rem;
max-width: 800px;
margin: 0 auto 3rem auto;
box-shadow: 0 8px 24px rgba(26, 22, 18, 0.08);
border-width: 2px;
border-color: var(--accent-coral, #e07256);
background: none;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
max-width: 640px;
margin: 0 auto 1.5rem;
}
.heroMode .omniInput {
font-size: 1.25rem;
padding: 1.25rem 1.5rem;
}
.heroMode .searchButton {
font-size: 1.25rem;
padding: 1.25rem 2.5rem;
}
.heroMode .searchSection {
margin-bottom: 0;
}
.searchHint {
margin: 0.875rem 0 0;
font-size: 0.95rem;
color: var(--text-secondary, #5a554d);
text-align: center;
}
.searchHint strong {
color: var(--text-primary, #1a1612);
font-weight: 600;
}
@media (max-width: 600px) {
.searchHint {
font-size: 0.85rem;
text-align: left;
}
}
.nearMeRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.nearMeBtn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.375rem;
background: var(--accent-teal, #2d7d7d);
color: #fff;
border: none;
border-radius: 999px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
font-family: inherit;
}
.nearMeBtn:hover:not(:disabled) {
background: #235f5f;
transform: translateY(-1px);
}
.nearMeBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.nearMeSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: nearMeSpin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes nearMeSpin {
to {
transform: rotate(360deg);
}
}
.geoError {
font-size: 0.8125rem;
color: var(--accent-coral-dark, #b04a2e);
margin: 0;
max-width: 340px;
text-align: center;
}
@media (max-width: 600px) {
.nearMeBtn {
width: 100%;
justify-content: center;
}
}
/* ── Search ───────────────────────────────────────────────────────── */
.searchSection {
margin-bottom: 0;
@@ -127,43 +46,90 @@
.omniBoxContainer {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* The hero pill: hairline, soft corner, everything else sits inside it. */
.heroMode .omniBoxContainer {
gap: 0.375rem;
padding: 0.3125rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
transition: border-color var(--transition), box-shadow var(--transition);
}
.heroMode .omniBoxContainer:focus-within {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
}
.omniIcon {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding-left: 0.625rem;
color: var(--text-muted);
}
.omniInput {
flex: 1;
padding: 0.875rem 1.25rem;
font-size: 1.05rem;
border: 2px solid var(--border-color, #e5dfd5);
border-radius: 8px;
min-width: 0;
padding: 0.6875rem 0.875rem;
font-family: var(--font-ui);
/* 1rem, not smaller: iOS Safari zooms the viewport on any control under
16px, which throws the user out of the hero on first tap. */
font-size: var(--step-0);
color: var(--text-primary);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: all 0.2s ease;
background: var(--bg-card, white);
font-family: inherit;
}
.omniInput:focus {
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px var(--accent-coral-bg);
transition: border-color var(--transition), box-shadow var(--transition);
}
.omniInput::placeholder {
color: var(--text-muted, #8a847a);
color: var(--text-muted);
}
.omniInput:focus {
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
}
/* Inside the pill the input carries no chrome — the pill holds the border
and the focus ring, so the two never double up. */
.heroMode .omniInput {
background: none;
border-color: transparent;
padding-left: 0.5rem;
}
.heroMode .omniInput:focus {
border-color: transparent;
box-shadow: none;
}
.searchButton {
padding: 0.875rem 2rem;
font-size: 1.05rem;
border-radius: 8px;
min-width: 120px;
flex: 0 0 auto;
padding: 0.6875rem 1.25rem;
font-size: var(--step--1);
border-radius: var(--radius-sm);
}
.heroMode .searchButton {
padding: 0.75rem 1.375rem;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
width: 1.0625rem;
height: 1.0625rem;
border: 2px solid rgba(var(--text-inverse-rgb), 0.35);
border-top-color: currentColor;
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
}
@@ -171,103 +137,86 @@
to { transform: rotate(360deg); }
}
.filters {
display: flex;
gap: 0.625rem;
flex-wrap: wrap;
margin-top: 0.625rem;
padding-top: 0.625rem;
border-top: 1px solid var(--border-color, #e5dfd5);
/* ── Hero supporting copy ─────────────────────────────────────────── */
.searchHint {
margin: 0.75rem 0 0;
font-family: var(--font-ui);
font-size: var(--step--1);
line-height: 1.5;
color: var(--text-muted);
text-align: center;
}
.filterSelect {
appearance: none;
-webkit-appearance: none;
flex: 1;
min-width: 180px;
padding: 0.625rem 2.25rem 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
border: 1.5px solid var(--border-color, #e5dfd5);
border-radius: 8px;
background-color: var(--bg-card, white);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.875rem center;
background-size: 10px 6px;
cursor: pointer;
outline: none;
color: var(--text-primary, #1a1612);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
.searchHint strong {
color: var(--text-secondary);
font-weight: 600;
}
.filterSelect:hover {
border-color: var(--text-muted, #8a847a);
}
.filterSelect:focus {
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
}
.clearButton {
padding: 0.4rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 999px;
}
@media (max-width: 768px) {
.filterBar {
padding: 1rem;
}
.omniBoxContainer {
flex-direction: column;
}
.searchButton {
width: 100%;
}
.filters {
flex-direction: column;
}
.filterSelect {
min-width: 100%;
}
.controlsRow {
gap: 0.5rem;
}
.controlsRow .advancedToggle {
margin-left: 0;
}
.controlSelect {
flex: 1;
min-width: 140px;
}
}
.radiusWrapper {
.nearMeRow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
gap: 0.375rem;
margin-top: 0.25rem;
}
.radiusLabel {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #5a554d);
white-space: nowrap;
/* Quiet text link, not a second button: the coral submit is the decisive
action and nothing beside it may compete for the same weight. */
.nearMeBtn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.375rem;
background: none;
border: 0;
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 600;
line-height: 1.4;
color: var(--brand);
cursor: pointer;
transition: color var(--transition);
}
/* ── Controls row (radius + phase + advanced toggle) ─── */
.nearMeBtn:hover:not(:disabled) {
color: var(--brand-strong);
text-decoration: underline;
text-underline-offset: 3px;
}
.nearMeBtn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.nearMeSpinner {
display: inline-block;
width: 13px;
height: 13px;
border: 2px solid rgba(var(--brand-rgb), 0.3);
border-top-color: var(--brand);
border-radius: 50%;
animation: nearMeSpin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes nearMeSpin {
to { transform: rotate(360deg); }
}
.geoError {
margin: 0;
max-width: 340px;
font-size: var(--step--1);
line-height: 1.45;
color: var(--brand-strong);
text-align: center;
}
/* ── Controls row (radius + phase + advanced toggle) ──────────────── */
.controlsRow {
display: flex;
align-items: center;
@@ -275,7 +224,7 @@
flex-wrap: wrap;
margin-top: 0.875rem;
padding-top: 0.875rem;
border-top: 1px solid var(--border-color, #e5dfd5);
border-top: 1px solid var(--border);
}
.controlsRow .advancedToggle {
@@ -289,76 +238,157 @@
flex-shrink: 0;
}
/* Pill-style inline filter controls (radius + phase) */
.controlSelect {
appearance: none;
-webkit-appearance: none;
padding: 0.4rem 2rem 0.4rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
border: 1.5px solid var(--border-color, #e5dfd5);
border-radius: 999px;
background-color: var(--bg-card, white);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a847a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.65rem center;
background-size: 10px 6px;
color: var(--text-primary, #1a1612);
cursor: pointer;
outline: none;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
.radiusLabel {
font-size: var(--step--2);
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.controlSelect:hover {
border-color: var(--text-muted, #8a847a);
background-color: var(--bg-secondary, #f8f4ef);
/* ── Selects ──────────────────────────────────────────────────────── */
.selectShell {
position: relative;
display: inline-flex;
align-items: center;
min-width: 0;
}
.selectShellWide {
flex: 1;
min-width: 180px;
}
.selectShell select {
width: 100%;
}
/* Drawn, not imaged: a background-image arrow has to hardcode its own
colour, and would stay light-mode grey on the dark ground. */
.selectChevron {
position: absolute;
right: 0.75rem;
top: 50%;
width: 0;
height: 0;
margin-top: -2px;
pointer-events: none;
color: var(--text-muted);
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
}
.filterSelect,
.controlSelect {
appearance: none;
-webkit-appearance: none;
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
outline: none;
transition: border-color var(--transition), background-color var(--transition),
box-shadow var(--transition), color var(--transition);
}
.filterSelect {
padding: 0.5625rem 2rem 0.5625rem 0.75rem;
font-size: var(--step--1);
font-weight: 500;
}
.controlSelect {
padding: 0.4375rem 1.875rem 0.4375rem 0.75rem;
font-size: var(--step--1);
font-weight: 500;
white-space: nowrap;
}
.filterSelect:hover:not(:disabled),
.controlSelect:hover:not(:disabled) {
border-color: var(--border-strong);
background-color: var(--bg-secondary);
}
.filterSelect:focus,
.controlSelect:focus {
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px rgba(224, 114, 86, 0.12);
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.16);
}
/* ── Advanced filters toggle ─────────────────────────── */
/* Selected — i.e. this control is narrowing the list. Sage tint, brand
line: the same pairing the results use for "on". */
.selectActive {
background-color: rgba(var(--sage-rgb), 0.38);
border-color: var(--brand);
color: var(--brand-strong);
font-weight: 600;
}
.selectActive:hover:not(:disabled) {
background-color: rgba(var(--sage-rgb), 0.5);
border-color: var(--brand);
}
.filters {
display: flex;
gap: 0.625rem;
flex-wrap: wrap;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
/* ── Advanced filters toggle ──────────────────────────────────────── */
.advancedToggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4375rem 0.75rem;
background: none;
border: 1.5px solid var(--border-color, #e5dfd5);
border-radius: 999px;
padding: 0.4rem 0.875rem;
font-size: 0.8125rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 500;
color: var(--text-secondary, #5a554d);
line-height: 1;
color: var(--text-secondary);
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
white-space: nowrap;
transition: border-color var(--transition), background-color var(--transition),
color var(--transition);
}
.advancedToggle:hover {
border-color: var(--text-muted, #8a847a);
background-color: var(--bg-secondary, #f8f4ef);
color: var(--text-primary, #1a1612);
border-color: var(--border-strong);
background-color: var(--bg-secondary);
color: var(--text-primary);
}
/* When filters are applied, promote the toggle to a coral pill so users
can see at a glance that the result list is being narrowed. */
/* Applied filters read as a brand chip, so it's obvious at a glance that
the list below is being narrowed. */
.advancedToggleActive {
border-color: var(--accent-coral, #e07256);
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
color: var(--accent-coral, #e07256);
border-color: var(--brand);
background: var(--brand-bg);
color: var(--brand);
font-weight: 600;
}
.advancedToggleActive:hover {
border-color: var(--accent-coral-dark, #c45a3f);
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
color: var(--accent-coral-dark, #c45a3f);
border-color: var(--brand);
background: var(--brand-bg);
color: var(--brand-strong);
}
.clearButton {
padding: 0.4375rem 0.875rem;
font-size: var(--step--1);
font-weight: 500;
border-radius: var(--radius-sm);
}
.chevronDown,
@@ -377,3 +407,70 @@
.chevronUp {
border-bottom: 4.5px solid currentColor;
}
/* ── Narrow ───────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.filterBar {
padding: 0.875rem;
}
/* The hero pill stays a pill at every width — stacking it would lose the
one shape the page is built around. Only the results bar stacks. */
.filterBar:not(.heroMode) .omniBoxContainer {
flex-direction: column;
align-items: stretch;
}
.filterBar:not(.heroMode) .searchButton {
width: 100%;
}
.filters {
flex-direction: column;
}
.selectShellWide {
min-width: 100%;
}
.controlsRow {
gap: 0.5rem;
}
.controlsRow .advancedToggle {
margin-left: 0;
}
.controlSelect {
flex: 1;
min-width: 140px;
}
.searchHint {
text-align: left;
}
.nearMeRow {
align-items: flex-start;
}
.geoError {
text-align: left;
}
}
@media (max-width: 420px) {
/* Below this the pin costs more room than it earns. */
.heroMode .omniIcon {
display: none;
}
.heroMode .omniInput {
padding-left: 0.75rem;
}
.heroMode .searchButton {
padding: 0.75rem 0.875rem;
}
}
+152 -94
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import type { ReactNode } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode } from "@/lib/utils";
import { track } from "@/lib/analytics";
@@ -18,6 +19,28 @@ interface FilterBarProps {
geoError?: string | null;
}
/**
* Wraps a native <select> so the disclosure arrow can be drawn in CSS from
* currentColor rather than baked into a background image — a hardcoded arrow
* colour is the one thing in a select that can't follow the theme.
*/
function SelectShell({
children,
wide,
}: {
children: ReactNode;
wide?: boolean;
}) {
return (
<span
className={`${styles.selectShell}${wide ? ` ${styles.selectShellWide}` : ""}`}
>
{children}
<span className={styles.selectChevron} aria-hidden="true" />
</span>
);
}
export function FilterBar({
filters,
isHero,
@@ -172,12 +195,33 @@ export function FilterBar({
const isSecondaryMode =
currentPhase === "secondary" || genderOptions.length > 0;
// A select that is narrowing the results carries a sage tint; the class is
// only ever additive, so the control's behaviour is untouched.
const activeIf = (value: string) => (value ? ` ${styles.selectActive}` : "");
return (
<div
className={`${styles.filterBar} ${isPending ? styles.isLoading : ""} ${isHero ? styles.heroMode : ""}`}
>
<form onSubmit={handleSearchSubmit} className={styles.searchSection}>
<div className={styles.omniBoxContainer}>
{isHero && (
<span className={styles.omniIcon} aria-hidden="true">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z" />
<circle cx="12" cy="10" r="2.6" />
</svg>
</span>
)}
<input
ref={inputRef}
type="search"
@@ -188,10 +232,10 @@ export function FilterBar({
/>
<button
type="submit"
className={`btn btn-primary ${styles.searchButton}`}
className={`btn btn-action ${styles.searchButton}`}
disabled={isPending}
>
{isPending ? <div className={styles.spinner}></div> : "Search"}
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
</button>
</div>
{isHero && (
@@ -248,34 +292,38 @@ export function FilterBar({
{currentPostcode && (
<div className={styles.radiusControl}>
<label className={styles.radiusLabel}>Within:</label>
<select
value={currentRadius}
onChange={(e) => updateURL({ radius: e.target.value })}
className={styles.controlSelect}
disabled={isPending}
>
<option value="0.5">0.5 miles</option>
<option value="1">1 mile</option>
<option value="3">3 miles</option>
<option value="5">5 miles</option>
</select>
<SelectShell>
<select
value={currentRadius}
onChange={(e) => updateURL({ radius: e.target.value })}
className={styles.controlSelect}
disabled={isPending}
>
<option value="0.5">0.5 miles</option>
<option value="1">1 mile</option>
<option value="3">3 miles</option>
<option value="5">5 miles</option>
</select>
</SelectShell>
</div>
)}
{phaseOptions.length > 0 && (
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={styles.controlSelect}
disabled={isPending}
>
<option value="">All Phases</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
<SelectShell>
<select
value={currentPhase}
onChange={(e) => handleFilterChange("phase", e.target.value)}
className={`${styles.controlSelect}${activeIf(currentPhase)}`}
disabled={isPending}
>
<option value="">All Phases</option>
{phaseOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
)}
<button
@@ -307,87 +355,97 @@ export function FilterBar({
{filtersOpen && (
<div className={styles.filters}>
<select
value={currentLA}
onChange={(e) =>
handleFilterChange("local_authority", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
<SelectShell wide>
<select
value={currentLA}
onChange={(e) =>
handleFilterChange("local_authority", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentLA)}`}
disabled={isPending}
>
<option value="">All Local Authorities</option>
{laOptions.map((la) => (
<option key={la} value={la}>
{la}
</option>
))}
</select>
</SelectShell>
<select
value={currentType}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">All School Types</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<SelectShell wide>
<select
value={currentType}
onChange={(e) =>
handleFilterChange("school_type", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentType)}`}
disabled={isPending}
>
<option value="">All School Types</option>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</SelectShell>
{isSecondaryMode && (
<>
{genderOptions.length > 0 && (
<select
value={currentGender}
onChange={(e) =>
handleFilterChange("gender", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
<SelectShell wide>
<select
value={currentGender}
onChange={(e) =>
handleFilterChange("gender", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentGender)}`}
disabled={isPending}
>
<option value="">Boys, Girls &amp; Mixed</option>
{genderOptions.map((g) => (
<option key={g} value={g.toLowerCase()}>
{g}
</option>
))}
</select>
</SelectShell>
)}
<select
value={currentHasSixthForm}
onChange={(e) =>
handleFilterChange("has_sixth_form", e.target.value)
}
className={styles.filterSelect}
disabled={isPending}
>
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
{admissionsPolicyOptions.length > 0 && (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
value={currentHasSixthForm}
onChange={(e) =>
handleFilterChange("admissions_policy", e.target.value)
handleFilterChange("has_sixth_form", e.target.value)
}
className={styles.filterSelect}
className={`${styles.filterSelect}${activeIf(currentHasSixthForm)}`}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
<option value="">With or without sixth form</option>
<option value="yes">With sixth form</option>
<option value="no">Without sixth form</option>
</select>
</SelectShell>
{admissionsPolicyOptions.length > 0 && (
<SelectShell wide>
<select
value={currentAdmissionsPolicy}
onChange={(e) =>
handleFilterChange("admissions_policy", e.target.value)
}
className={`${styles.filterSelect}${activeIf(currentAdmissionsPolicy)}`}
disabled={isPending}
>
<option value="">All admissions types</option>
{admissionsPolicyOptions.map((p) => (
<option key={p} value={p.toLowerCase()}>
{p}
</option>
))}
</select>
</SelectShell>
)}
</>
)}
+102 -25
View File
@@ -1,6 +1,16 @@
/*
* The footer band.
*
* --surface-sunken is deep teal in BOTH themes on purpose, so nothing in here
* may read a --text-* or --bg-* token: those invert with the theme and would
* put dark ink on the teal (or teal on teal) in one of the two. Every
* foreground below comes from the --on-sunken-* family, which is pinned to the
* band rather than to the page.
*/
.footer {
background: var(--accent-navy, #2c3e50);
color: var(--bg-secondary, #f3ede4);
background: var(--surface-sunken);
color: var(--on-sunken);
margin-top: auto;
}
@@ -12,7 +22,7 @@
.content {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
grid-template-columns: 1.6fr 1fr 1fr;
gap: 2rem;
margin-bottom: 3rem;
}
@@ -20,31 +30,77 @@
.section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.title {
/* ─── Brand lockup ──────────────────────────────────────────────── */
.lockup {
display: flex;
align-items: center;
gap: 0.625rem;
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--bg-primary, #faf7f2);
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.lockupMark {
display: inline-flex;
flex: 0 0 auto;
/* Height drives; the artwork is taller than it is wide. */
height: 34px;
}
.lockupMark img {
display: block;
height: 100%;
width: auto;
}
/* Same wordmark as the header, inverted: "school" in the band's foreground,
"compare" in sage — the one accent that reads cleanly on deep teal. */
.wordmark {
font-family: var(--font-display);
font-size: var(--step-2);
font-weight: 700;
line-height: 1;
letter-spacing: -0.035em;
color: var(--on-sunken);
}
.wordmarkAccent {
color: var(--sage);
}
/* Key messaging, so this is the one place in the footer that takes Manrope
for a full sentence. */
.tagline {
margin: 0;
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--on-sunken);
}
.description {
margin: 0;
font-size: 0.875rem;
margin: -0.5rem 0 0;
font-size: var(--step--1);
line-height: 1.6;
color: rgba(250, 247, 242, 0.7);
color: var(--on-sunken-muted);
max-width: 34ch;
}
.sectionTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--accent-gold, #c9a227);
font-family: var(--font-display);
font-size: var(--step--1);
font-weight: 700;
line-height: 1;
color: var(--sage);
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.1em;
}
.links {
@@ -57,43 +113,64 @@
}
.link {
font-size: 0.875rem;
color: rgba(250, 247, 242, 0.7);
font-family: var(--font-ui);
font-size: var(--step--1);
color: var(--on-sunken-muted);
text-decoration: none;
transition: color 0.2s ease;
border-radius: var(--radius-sm);
transition: color var(--transition);
}
.link:hover {
color: var(--accent-gold, #c9a227);
color: var(--on-sunken);
}
.linkDisabled {
font-size: 0.875rem;
color: rgba(250, 247, 242, 0.4);
font-size: var(--step--1);
color: var(--on-sunken-faint);
cursor: not-allowed;
}
/* ─── Bottom rule ───────────────────────────────────────────────── */
/*
* The divider is drawn as a pseudo-element at low opacity rather than as an
* rgba() border: there is no channel token for --on-sunken, and every literal
* that would work here (rgba(255,255,255,…)) is exactly the kind of hardcoded
* colour the token layer exists to remove.
*/
.bottom {
position: relative;
padding-top: 2rem;
border-top: 1px solid rgba(250, 247, 242, 0.15);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bottom::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--on-sunken);
opacity: 0.16;
}
.copyright,
.disclaimer {
margin: 0;
font-size: 0.875rem;
color: rgba(250, 247, 242, 0.6);
font-size: var(--step--1);
color: var(--on-sunken-muted);
}
.disclaimer .link {
color: var(--accent-coral, #e07256);
color: var(--on-sunken-link);
}
.disclaimer .link:hover {
color: var(--accent-gold, #c9a227);
color: var(--on-sunken);
}
@media (max-width: 768px) {
+24 -4
View File
@@ -1,8 +1,13 @@
/**
* Footer Component
* Site footer with links and info
*
* The footer is the guideline's "brand in action" band: a deep teal anchor
* that stays teal in both themes. Everything inside it therefore reads from
* the --on-sunken-* family rather than the page's --text-* tokens, which
* invert and would go dark against the teal in one theme or the other.
*/
import { LogoMark } from './Logo';
import styles from './Footer.module.css';
export function Footer() {
@@ -13,7 +18,20 @@ export function Footer() {
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.section}>
<h3 className={styles.title}>SchoolCompare</h3>
{/*
On the sunken band the mark inverts: the pin takes the band's
foreground and the leaf is knocked out in the band itself. The
header's defaults (brand pin, card leaf) would disappear here.
*/}
<h3 className={styles.lockup}>
<span className={styles.lockupMark}>
<LogoMark variant="onDark" size={34} />
</span>
<span className={styles.wordmark}>
school<span className={styles.wordmarkAccent}>compare</span>
</span>
</h3>
<p className={styles.tagline}>Find the right school. For their future.</p>
<p className={styles.description}>
Compare primary and secondary schools across England.
</p>
@@ -31,7 +49,9 @@ export function Footer() {
<ul className={styles.links}>
<li><a href="/" className={styles.link}>Search schools</a></li>
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
{/* "Compare", not "shortlist" — the nav, this link and the
landing page all name the same feature the same way. */}
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
</ul>
</div>
@@ -69,7 +89,7 @@ export function Footer() {
<div className={styles.bottom}>
<p className={styles.copyright}>
© {currentYear} SchoolCompare.co.uk
© {currentYear} schoolcompare.co.uk
</p>
</div>
</div>
File diff suppressed because it is too large. Load diff
+300 -117
View File
@@ -12,6 +12,7 @@ import { SchoolRow } from './SchoolRow';
import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap';
import { EmptyState } from './EmptyState';
import { HeroIllustration, HeroIllustrationBand } from './Illustration';
import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
@@ -39,13 +40,22 @@ function daysUntil(month: number, day: number): number {
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
}
function formatCountdownDate(month: number, day: number): string {
function nextOccurrence(month: number, day: number): Date {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
let target = new Date(y, month - 1, day);
if (target < today) target = new Date(y + 1, month - 1, day);
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
const target = new Date(y, month - 1, day);
return target < today ? new Date(y + 1, month - 1, day) : target;
}
function formatCountdownDate(month: number, day: number): string {
return nextOccurrence(month, day)
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
}
function formatShortDate(month: number, day: number): string {
return nextOccurrence(month, day)
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
}
interface CountdownChipData {
@@ -56,6 +66,16 @@ interface CountdownChipData {
day: number;
}
interface AdmissionsMilestone {
chip: CountdownChipData;
/** Whole days from today. Always present — computed during render. */
days: number;
/** "Sat, 31 October 2026" — for the headline bar. */
dateLabel: string;
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
shortDate: string;
}
const ADMISSIONS_CHIPS: CountdownChipData[] = [
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
@@ -63,6 +83,103 @@ const ADMISSIONS_CHIPS: CountdownChipData[] = [
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
];
/* ── Value-prop icons ──────────────────────────────────────────────────────
Rounded 2px line icons on a 24px grid, inheriting the chip's colour. Kept
local because they exist for this one row and nothing else. */
const ICON_PROPS = {
viewBox: '0 0 24 24',
width: 21,
height: 21,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': true,
} as const;
function ShieldCheckIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 3 4 6v6c0 4.5 3.2 8.2 8 9 4.8-.8 8-4.5 8-9V6l-8-3Z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function BarsIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />
</svg>
);
}
function HeartIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8a3.9 3.9 0 0 1 7 2.8C19 15.6 12 20 12 20Z" />
</svg>
);
}
function CalendarIcon() {
return (
<svg {...ICON_PROPS}>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
);
}
interface ValueProp {
icon: React.ReactNode;
tintClass: string;
title: string;
body: string;
}
/*
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
* favourites). That feature does not exist in this product, so it is
* deliberately replaced by the admissions-deadline prop below — which is real,
* and is backed by the next-deadline bar further down this page.
*
* Every claim here must name something the product actually does. Two of the
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
* in context/ComparisonProvider.tsx (and the card further down the page, which
* correctly said five), and "class sizes" described data the codebase has never
* held — grep for it and this line was the only hit. Both are corrected below
* against the real fields, which live in components/school/InclusionSection.tsx.
*/
const VALUE_PROPS: ValueProp[] = [
{
icon: <ShieldCheckIcon />,
tintClass: styles.propIconTrust,
title: 'Official & trusted',
body: 'Every figure comes from DfE performance tables and Ofsted.',
},
{
icon: <BarsIcon />,
tintClass: styles.propIconCompare,
title: 'Easy to compare',
body: 'Up to five schools side by side, on the measures that matter.',
},
{
icon: <HeartIcon />,
tintClass: styles.propIconContext,
title: 'Beyond the numbers',
body: 'SEN support, pupil premium and attendance — not just results.',
},
{
icon: <CalendarIcon />,
tintClass: styles.propIconDeadline,
title: 'Never miss a deadline',
body: 'Application and offer dates for both primary and secondary.',
},
];
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -83,9 +200,30 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
const mapParamsRef = useRef<string>('');
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
const [geoError, setGeoError] = useState<string | null>(null);
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
);
/*
* Computed during render, on the server as well as the client, so the
* deadline bar is in the first HTML rather than appearing on hydrate.
*
* The previous version deferred this to an effect to dodge a hydration
* mismatch, which meant the section had to reserve its own height — and the
* reservation was a single guessed number for a block whose supporting line
* wraps to a different height at every width. Measured, it was short at
* every breakpoint, shifting the page by up to 108px on a phone.
*
* The mismatch it was dodging is real but tiny: a server rendering at
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
* confined to two text nodes, which carry suppressHydrationWarning below.
*/
const [milestones] = useState<AdmissionsMilestone[]>(() => {
const measured = ADMISSIONS_CHIPS.map(chip => ({
chip,
days: daysUntil(chip.month, chip.day),
dateLabel: formatCountdownDate(chip.month, chip.day),
shortDate: formatShortDate(chip.month, chip.day),
}));
measured.sort((a, b) => a.days - b.days);
return measured;
});
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
const isLocationSearch = !!searchParams.get('postcode');
@@ -148,13 +286,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
.catch(() => {});
}, []);
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
useEffect(() => {
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
setSortedChips(withDays);
}, []);
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore) return;
track('results_load_more', { next_page: currentPage + 1 });
@@ -243,6 +374,28 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
});
}, [initialSchools.total, isSearchActive, searchParams]);
/*
* The coverage figure, or nothing at all.
*
* This used to be the string "24,000+", hardcoded in three places while the
* database held 27,230 — and the fact box that was meant to show the live
* number silently rendered its fallback, because DataInfoResponse declared a
* `total_schools` field the API has never sent. Now there is one source: if
* the real count is unavailable the sentence simply omits it rather than
* inventing a floor.
*/
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
/*
* The headline milestone is the next thing a parent can actually miss — an
* application deadline. Offer days are dates you receive something on, so
* they belong in the supporting line, not in the page's largest numeral.
* Four equally-sized cards counting down to things 245 days away was the
* loudest element on the page and asked nothing of anyone.
*/
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
const laterMilestones = milestones.filter(m => m !== nextDeadline);
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
// split which surface drives compare adds.
const addSchoolFromSearch = useCallback((school: School) => {
@@ -255,113 +408,142 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
}, [addSchool, selectedSchools.length]);
return (
<div className={styles.homeView}>
{/* Combined Hero + Search and Filters */}
{!isSearchActive && (
<div className={styles.heroSection}>
<span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated for the 2026/27 admissions round
</span>
<h1 className={styles.heroTitle}>
Every school in England, <em className={styles.heroEmph}>compared.</em>
</h1>
<p className={styles.heroDescription}>
{/* Full copy for larger screens; a compact merged line (coverage +
freshness, standing in for the hidden eyebrow too) on phones,
where every line above the fold costs. */}
<span className={styles.heroDescriptionFull}>
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
/* The landing arrangement owns its own vertical rhythm (one gap, set in
CSS); the search arrangement is a filter bar directly above its results
and wants none of it. */
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
{/* Hero: a Sand panel with the proposition and the search on the left and
the brand landscape bleeding to the panel edge on the right. The
search lives inside the panel here and above the results elsewhere,
which is why FilterBar is rendered in two places rather than moved.
The hero and the four value props are one group, so they sit closer
together than the page's section gap. */}
{!isSearchActive ? (
<div className={styles.heroGroup}>
<section className={styles.heroPanel}>
<div className={styles.heroContent}>
<span className={styles.heroEyebrow}>
<span className={styles.heroEyebrowDot} aria-hidden="true" />
Updated for the 2026/27 admissions round
</span>
<span className={styles.heroDescriptionCompact}>
<strong>24,000+ English schools</strong> — SATs, GCSEs, Ofsted &amp; admissions, side by side. Updated for 2026/27.
</span>
</p>
</div>
)}
<h1 className={styles.heroTitle}>
Find the school where your child will{' '}
<em className={styles.heroEmph}>flourish.</em>
</h1>
<p className={styles.heroDescription}>
Compare every school in England with clarity and confidence.
{/* The coverage sentence is the detail a first-time visitor needs
but a returning one does not, so it drops away on phones where
every line above the fold costs. */}
<span className={styles.heroDescriptionFull}>
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
side, in one place.
</span>
</p>
<FilterBar
filters={filters}
isHero={!isSearchActive}
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/>
<FilterBar
filters={filters}
isHero
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/>
{/* Admissions countdown strip — only on landing page */}
{!isSearchActive && (
<section className={styles.admissionsStrip}>
<div className={styles.stripHeader}>
<span className={styles.stripLabel}>Key admissions deadlines</span>
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
</div>
<div
className={styles.countdownRail}
style={{
opacity: sortedChips[0]?.days !== null ? 1 : 0,
transition: 'opacity 0.2s ease',
}}
>
{sortedChips.map(({ chip, days }) => {
const isUrgent = days !== null && days <= 14;
const chipClass = [
styles.countdownChip,
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
isUrgent ? styles.countdownChipUrgent : '',
].filter(Boolean).join(' ');
const trackClass = [
styles.chipTrack,
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
].join(' ');
return (
<div key={chip.milestone} className={chipClass}>
<span className={trackClass}>
<span className={styles.chipTrackDot} aria-hidden="true" />
{chip.track}
</span>
<div>
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
</div>
<div className={styles.chipMilestone}>{chip.milestone}</div>
<div className={styles.chipDate}>
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
</div>
</div>
);
})}
{/*
Two drawings, one visible — see the note at the top of
Illustration.tsx. The hero art slot ranges from about 1.34:1 to
4.9:1 across the breakpoints, which no single composition survives.
*/}
<div className={styles.heroArt}>
<div className={styles.heroArtWide}>
<HeroIllustration />
</div>
<div className={styles.heroArtBand}>
<HeroIllustrationBand />
</div>
</div>
</section>
{/* Why this site, in four lines. These titles are labels on a list,
not section headings — as <h2> they outranked the page's real
headings in the document outline and gave a screen-reader user
four false landmarks before any content. */}
<ul className={styles.valueProps}>
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
<li key={title} className={styles.valueProp}>
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
<div>
<p className={styles.propTitle}>{title}</p>
<p className={styles.propBody}>{body}</p>
</div>
</li>
))}
</ul>
</div>
) : (
<FilterBar
filters={filters}
isHero={false}
resultFilters={initialSchools.result_filters}
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
/>
)}
{/* Secondary discovery — moved below deadlines so the admissions
countdown (time-sensitive) shows ahead of generic "explore" links. */}
{!isSearchActive && initialSchools.schools.length === 0 && (
<div className={styles.exploringRow}>
<span className={styles.exploringLabel}>Start exploring</span>
<div className={styles.exploringChips}>
<a href="/rankings" className={styles.exploringChip}>
<span className={styles.chipDot} aria-hidden="true" />
Top-rated primary schools
</a>
<a href="/rankings" className={styles.exploringChip}>
<span className={styles.chipDot} aria-hidden="true" />
Top-rated secondary schools
</a>
<a href="/compare" className={styles.exploringChip}>
<span className={styles.chipDot} aria-hidden="true" />
Start a comparison
</a>
</div>
</div>
{/* Next admissions deadline — one bar, not four cards. */}
{!isSearchActive && (
<section className={styles.admissions} aria-label="Admissions deadlines">
{nextDeadline && (
<>
<a
href="/admissions"
className={
nextDeadline.days <= 14
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
: styles.nextDeadline
}
>
<span className={styles.nextDeadlineBody}>
<span className={styles.nextDeadlineKicker}>
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
</span>
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
{nextDeadline.dateLabel}
</span>
</span>
<span className={styles.nextDeadlineCount}>
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
</span>
{nextDeadline.days !== 0 && (
<span className={styles.nextDeadlineUnit}>days left</span>
)}
</span>
</a>
<p className={styles.laterDates} suppressHydrationWarning>
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
{' '}
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
</p>
</>
)}
</section>
)}
{/* How it works + Editorial — server-rendered slots, only on landing */}
{!isSearchActive && howItWorks}
{!isSearchActive && editorial}
{/* Results Section */}
{/* Results Section. Skipped entirely on the landing page when there is
nothing to list — an empty <section> is still a flex child, so it was
contributing a full section gap of blank space above the footer. */}
{(isSearchActive || initialSchools.schools.length > 0) && (
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
{!hasSearch && initialSchools.schools.length > 0 && (
<div className={styles.sectionHeader}>
@@ -547,6 +729,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
</>
)}
</section>
)}
</div>
);
}
@@ -573,10 +756,10 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
rwmDelta == null
? {}
: rwmDelta >= 2
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
: rwmDelta <= -2
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
: { fontSize: '0.7rem', color: 'var(--text-muted)' };
return (
<div className={styles.compactItem}>
@@ -601,13 +784,13 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm
fontWeight: 600,
borderRadius: '3px',
whiteSpace: 'nowrap',
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#2f7a2f' } :
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: 'var(--accent-gold-text, #7a6800)' } :
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
{ background: '#e0e0e0', color: '#666' }),
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
ofstedBadge.cssClass === 'ofsted2' ? { background: 'var(--status-above-bg)', color: 'var(--status-above)' } :
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--status-below-bg)', color: 'var(--status-below)' } :
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--status-below)', color: 'var(--text-inverse)' } :
ofstedBadge.cssClass === 'ofstedRc' ? { background: 'var(--phase-secondary-text)', color: 'var(--text-inverse)' } :
ofstedBadge.cssClass === 'ofstedPending' ? { background: 'var(--bg-secondary)', color: 'var(--text-muted)' } :
{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }),
}}
>
{ofstedBadge.label}
+27 -8
View File
@@ -13,6 +13,16 @@ export function HowItWorksSection() {
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
];
/*
* The columns are deliberately unnamed.
*
* They previously carried two real school names — "Our Lady Queen of Heaven"
* and "St Mary's Catholic Primary" — beside invented results and invented
* Ofsted grades. On a site whose entire proposition is official DfE and
* Ofsted data, publishing made-up figures against identifiable schools is
* the one thing it cannot do, and no illustrative intent survives being
* screenshotted. Generic labels make the layout point just as well.
*/
const compareRows = [
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
@@ -29,9 +39,15 @@ export function HowItWorksSection() {
return (
<section className={styles.howItWorks}>
<div className={styles.hiwHeader}>
<h2 className={styles.hiwHeading}>What you&apos;ll see on every school</h2>
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
{/* One header pattern across every band on this page: kicker, heading,
optional aside. The page previously ran four different treatments
(two uppercase micro-labels — one left, one centred — a large heading
with right-aligned grey text, and a kicker-plus-heading), which is
most of why a designed page read as a stack of unrelated strips. */}
<div className={styles.sectionHead}>
<p className={styles.sectionKicker}>Every school page</p>
<h2 className={styles.sectionHeading}>What you&apos;ll see on every school</h2>
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
</div>
<div className={styles.hiwGrid}>
{/* Card 1 — Performance */}
@@ -117,8 +133,8 @@ export function HowItWorksSection() {
<div className={styles.comparePreview}>
<div className={styles.compareHead}>
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
<div className={styles.compareHeadCell}>St Mary&apos;s<br />Catholic Primary</div>
<div className={styles.compareHeadCell}>School A</div>
<div className={styles.compareHeadCell}>School B</div>
</div>
{compareRows.map(({ label, a, b, aHi }) => (
<div key={label} className={styles.compareRow}>
@@ -127,13 +143,16 @@ export function HowItWorksSection() {
<span className={styles.compareRowVal}>{b}</span>
</div>
))}
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
</div>
</div>
<div className={styles.hiwCardBody}>
<div className={styles.hiwStep}>Compare</div>
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
<div className={styles.hiwTitle}>Side by side</div>
{/* One verb for one feature. The site previously called this
"compare" in the nav, "shortlist" in the footer and "pin" here,
which reads as three separate things it does not have. */}
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
</div>
</div>
</div>
+212
View File
@@ -0,0 +1,212 @@
/**
* Brand illustration — the landing hero's landscape.
*
* Guideline rules for illustration: no people or faces, soft shapes, rounded
* corners, minimal detail, maximum clarity. The scene reads as "the journey to
* a school" — a path climbing through layered hills to a small schoolhouse,
* with a pin marking where you are.
*
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so
* it renders in the RSC pass and never reaches the client bundle.
*
* TWO COMPOSITIONS, ON PURPOSE
* ----------------------------
* The hero art slot is not one shape. On a wide desktop it is roughly 1.5:1;
* at the 1024px breakpoint about 1.34:1; on a phone it collapses to a band
* near 2.6:1, and just above the one-column breakpoint it is nearly 5:1.
*
* A single drawing cannot cover 1.34:1 → 4.9:1. With `slice`, the SVG scales
* to cover and crops the overflow, anchored here to the bottom edge — so at
* 860×176 a 540×520 scene shows only its bottom 110 user units. That is what
* used to happen: the schoolhouse sat at y 218–324 and was cropped away
* entirely on phones, leaving bare hills and a pin pointing at nothing.
*
* So there are two drawings and CSS shows exactly one. They are both static
* server-rendered SVG, so this costs markup and nothing else — no JS, no
* second request, no layout shift. Each is composed against the crop window
* its own breakpoint actually produces; see the safe-zone note on each.
*
* Colour comes from the --ill-* tokens in globals.css, which are re-graded for
* the dark theme. Everything else about the scene is identical in both themes.
*/
/** Scoped so the gradient ids can't collide with another inline SVG. */
const SKY_TALL_ID = 'sc-hero-sky-tall';
const SKY_BAND_ID = 'sc-hero-sky-band';
function SkyGradient({ id }: { id: string }) {
return (
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--ill-sky-high)" />
<stop offset="1" stopColor="var(--ill-sky-low)" />
</linearGradient>
);
}
/**
* The schoolhouse. Drawn once, placed by each composition.
*
* Detail deliberately below what it was: the gable clock, the flagpole
* pennant and the four-window grid are gone. At the sizes this actually
* renders they read as noise, and they were what tipped the drawing from
* "soft brand illustration" into clip art.
*/
function Schoolhouse({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) {
return (
<g transform={`translate(${x} ${y}) scale(${scale})`}>
<rect x="12" y="34" width="98" height="58" rx="6" fill="var(--ill-wall)" />
<path d="M2 36 L61 6 L120 36 Z" fill="var(--ill-roof)" />
<rect x="52" y="60" width="20" height="32" rx="3" fill="var(--ill-door)" />
<rect x="26" y="48" width="18" height="16" rx="4" fill="var(--ill-window)" />
<rect x="80" y="48" width="18" height="16" rx="4" fill="var(--ill-window)" />
</g>
);
}
/** The "you are here" pin. The only saturated thing in either composition. */
function Pin({ x, y, scale = 1 }: { x: number; y: number; scale?: number }) {
return (
<g transform={`translate(${x} ${y}) scale(${scale})`}>
<path
d="M21 48 C21 48 42 26 42 15.5 A21 21 0 1 0 0 15.5 C0 26 21 48 21 48 Z"
fill="var(--ill-pin)"
/>
<circle cx="21" cy="15.5" r="8" fill="var(--ill-pin-eye)" />
</g>
);
}
/**
* Desktop composition — the two-column hero panel.
*
* Crop window: `slice` anchored to the bottom, so the widest slot (about
* 590×384) reveals y ∈ [169, 520] and the narrowest (about 430×320) reveals
* y ∈ [118, 520]. Everything that carries meaning is therefore kept at
* y ≥ 200; the sky above it exists only to be cropped.
*/
export function HeroIllustration() {
return (
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
<defs>
<SkyGradient id={SKY_TALL_ID} />
</defs>
<rect width="540" height="520" fill={`url(#${SKY_TALL_ID})`} />
{/* Two soft clouds. Low opacity so they never compete with the pin. */}
<g fill="var(--ill-cloud)" opacity="0.7">
<ellipse cx="96" cy="196" rx="34" ry="14" />
<ellipse cx="124" cy="189" rx="24" ry="17" />
<ellipse cx="424" cy="168" rx="28" ry="12" />
<ellipse cx="445" cy="162" rx="19" ry="14" />
</g>
{/* Four layered rolling hills, palest and furthest first */}
<path d="M0 300 C 90 264 168 294 246 276 C 330 256 410 282 540 254 L540 520 L0 520 Z" fill="var(--ill-hill-far)" />
<path d="M0 344 C 104 308 186 340 268 324 C 356 306 452 332 540 306 L540 520 L0 520 Z" fill="var(--ill-hill-mid)" />
<path d="M0 408 C 118 378 214 410 306 392 C 400 374 470 398 540 382 L540 520 L0 520 Z" fill="var(--ill-hill-near)" />
<path d="M0 468 C 130 446 236 474 340 458 C 432 444 486 462 540 452 L540 520 L0 520 Z" fill="var(--ill-hill-front)" />
{/* The path climbs from the near edge up to the school */}
<path
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
stroke="var(--ill-path)"
strokeWidth="18"
fill="none"
strokeLinecap="round"
/>
<path
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
stroke="var(--ill-path-edge)"
strokeWidth="18"
fill="none"
strokeLinecap="butt"
strokeLinejoin="round"
strokeDasharray="0.5 26"
opacity="0.5"
/>
<Schoolhouse x={276} y={232} />
{/* Scattered rounded trees */}
<g fill="var(--ill-tree-dark)">
<circle cx="104" cy="372" r="27" />
<circle cx="136" cy="386" r="19" />
<circle cx="470" cy="336" r="23" />
</g>
<g fill="var(--ill-tree-light)">
<circle cx="78" cy="394" r="21" />
<circle cx="492" cy="358" r="17" />
<circle cx="176" cy="420" r="18" />
</g>
<g fill="var(--ill-tree-mid)">
<ellipse cx="44" cy="336" rx="15" ry="22" />
<ellipse cx="508" cy="296" rx="13" ry="18" />
<ellipse cx="150" cy="342" rx="12" ry="17" />
</g>
<g fill="var(--ill-tree-pale)" opacity="0.9">
<circle cx="410" cy="420" r="16" />
<circle cx="438" cy="430" r="12" />
<circle cx="120" cy="470" r="14" />
</g>
<Pin x={196} y={372} />
</svg>
);
}
/**
* Band composition — the one-column hero, where the art becomes a short strip
* beneath the search.
*
* Crop window: at 358×136 (a phone) `slice` reveals x ∈ [42, 358] of the
* 400-unit width; at 860×176 (just under the one-column breakpoint) it reveals
* the full width but only y ∈ [38, 120]. The intersection — x ∈ [70, 330],
* y ∈ [38, 120] — is where the school and the pin sit. Hills and trees run to
* the edges precisely because they are the parts that may be cropped.
*/
export function HeroIllustrationBand() {
return (
<svg viewBox="0 0 400 120" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
<defs>
<SkyGradient id={SKY_BAND_ID} />
</defs>
<rect width="400" height="120" fill={`url(#${SKY_BAND_ID})`} />
{/* Hills sit low so the safe zone stays clear for the building */}
<path d="M0 74 C 70 62 120 76 176 70 C 244 62 318 74 400 62 L400 120 L0 120 Z" fill="var(--ill-hill-far)" />
<path d="M0 92 C 84 82 140 94 208 88 C 286 80 340 92 400 84 L400 120 L0 120 Z" fill="var(--ill-hill-mid)" />
<path d="M0 108 C 96 100 168 110 248 104 C 322 98 356 106 400 102 L400 120 L0 120 Z" fill="var(--ill-hill-near)" />
{/* A shorter path — the same idea at band scale, without the switchback,
which turns to mush below about 40 user units of height. */}
<path
d="M150 120 C 156 104 186 100 202 92 C 216 84 210 76 216 70"
stroke="var(--ill-path)"
strokeWidth="9"
fill="none"
strokeLinecap="round"
/>
<Schoolhouse x={186} y={30} scale={0.52} />
<g fill="var(--ill-tree-dark)">
<circle cx="58" cy="86" r="15" />
<circle cx="78" cy="94" r="11" />
<circle cx="342" cy="80" r="13" />
</g>
<g fill="var(--ill-tree-light)">
<circle cx="40" cy="98" r="12" />
<circle cx="360" cy="92" r="10" />
<circle cx="300" cy="100" r="11" />
</g>
<g fill="var(--ill-tree-pale)" opacity="0.9">
<circle cx="112" cy="104" r="9" />
<circle cx="266" cy="108" r="8" />
</g>
<Pin x={124} y={70} scale={0.62} />
</svg>
);
}
+10 -10
View File
@@ -11,7 +11,7 @@
/* font-size:0 hides the button's own "?" text node; the ::before glyph
below carries the visible circled "?" at its own explicit size. */
font-size: 0;
color: var(--text-muted, #8a7a72);
color: var(--text-muted);
cursor: help;
line-height: 1;
user-select: none;
@@ -34,17 +34,17 @@
.icon:hover,
.icon[aria-expanded='true'],
.icon:focus-visible {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
}
.tooltip {
z-index: 9999;
width: max-content;
max-width: min(260px, calc(100vw - 24px));
background: var(--bg-primary, #faf7f2);
border: 1px solid var(--border-color, #e8ddd4);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
box-shadow: 0 4px 16px rgba(var(--shadow-rgb), 0.15);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
@@ -52,26 +52,26 @@
}
.arrow {
fill: var(--bg-primary, #faf7f2);
stroke: var(--border-color, #e8ddd4);
fill: var(--bg-primary);
stroke: var(--border);
stroke-width: 1px;
}
.label {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
color: var(--text-primary);
}
.plain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
color: var(--text-secondary);
line-height: 1.4;
}
.detail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
color: var(--text-muted);
line-height: 1.4;
margin-top: 0.1rem;
}
@@ -22,10 +22,10 @@ interface LeafletHeroMapInnerProps {
// reliably inside Leaflet's divIcon HTML string (no CSS-module dependency).
const PIN_HTML = `
<div style="position:relative;width:26px;height:26px">
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:rgba(224,114,86,.25)"></span>
<span style="position:absolute;left:50%;top:74%;width:32px;height:32px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
<svg width="26" height="34" viewBox="0 0 26 34" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,.35))">
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="#e07256"/>
<circle cx="13" cy="13" r="5" fill="#fff"/>
<path d="M13 0C5.8 0 0 5.8 0 13c0 9.2 13 21 13 21s13-11.8 13-21C26 5.8 20.2 0 13 0Z" fill="var(--brand)"/>
<circle cx="13" cy="13" r="5" fill="var(--text-inverse)"/>
</svg>
</div>`;
+16 -16
View File
@@ -48,21 +48,21 @@ function buildPopupBadge(school: School): PopupBadge {
// ofsted_framework is the raw event grouping, never "ReportCard").
if (school.ofsted_rc_date) {
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
return { label: `Report Card · ${rcYear}`, style: 'background:#5a3a6e;color:#fff' };
return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
}
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
const yearStr = year ? ` · ${year}` : '';
if (school.ofsted_grade) {
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
const colours: Record<number, string> = {
1: 'background:#d4f0ea;color:#2d7d7d',
2: 'background:rgba(60,140,60,0.12);color:#2f7a2f',
3: 'background:#fef3cd;color:#7a6800',
4: 'background:#fde8e0;color:#e07256',
1: 'background:var(--status-above-bg);color:var(--status-above)',
2: 'background:var(--status-above-bg);color:var(--status-above)',
3: 'background:var(--status-below-bg);color:var(--status-below)',
4: 'background:var(--status-below);color:var(--text-inverse)',
};
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
}
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
}
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
@@ -99,7 +99,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
const refIcon = L.divIcon({
html: `<div style="
width: 20px; height: 20px;
background: #e07256;
background: var(--brand);
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
@@ -145,13 +145,13 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
const sign = diff >= 0 ? '+' : '';
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
// vs primary RWM % where ±2 pts is the threshold
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
const colour = diff >= 0.5 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
const laName = escapeHtml(school.local_authority ?? 'LA');
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
}
metricHtml = `<div style="margin-bottom:4px">
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
<span style="font-size:11px;color:#8a847a;margin-left:4px">Attainment 8</span>
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Attainment 8</span>
${deltaLine}
</div>`;
} else if (school.rwm_expected_pct != null) {
@@ -159,7 +159,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
let deltaLine = '';
if (nationalAvgRwm != null) {
const diff = Math.round(rwm - nationalAvgRwm);
const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
const colour = diff >= 2 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
const text =
diff >= 2 ? `+${diff} pts vs national` :
diff <= -2 ? `${diff} pts vs national` :
@@ -167,8 +167,8 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
}
metricHtml = `<div style="margin-bottom:4px">
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${rwm}%</span>
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing &amp; Maths</span>
<span style="font-size:20px;font-weight:700;color:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing &amp; Maths</span>
${deltaLine}
</div>`;
}
@@ -177,14 +177,14 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
<strong style="font-size:13px;color:#1a1612;line-height:1.3">${escapeHtml(school.school_name)}</strong>
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
</div>
<div style="font-size:11px;color:#8a847a;margin-bottom:8px">
<div style="font-size:11px;color:var(--text-muted);margin-bottom:8px">
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
</div>
${metricHtml}
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:#2d7d7d;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
</div>`;
marker.bindPopup(popupContent);
@@ -5,19 +5,19 @@
}
.skeletonCard {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.skeleton {
background: linear-gradient(
90deg,
var(--bg-secondary, #f3ede4) 25%,
rgba(224, 114, 86, 0.08) 50%,
var(--bg-secondary, #f3ede4) 75%
var(--bg-secondary) 25%,
rgba(var(--brand-rgb), 0.08) 50%,
var(--bg-secondary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
@@ -56,7 +56,7 @@
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
border-radius: 8px;
}
@@ -82,11 +82,11 @@
}
.skeletonListItem {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.listTitle {
+102
View File
@@ -0,0 +1,102 @@
/**
* The schoolcompare mark.
*
* This is the supplied brand artwork, not a reconstruction of it. Two
* colourways were extracted from the logo sheet and live in public/brand:
*
* mark.png the primary lockup's mark — teal pin, white window and
* path, green leaves. Reads correctly on Warm White,
* white cards and Sand, and on both dark-theme grounds.
* mark-on-dark.png the on-dark colourway — white pin with the counter
* knocked through to the ground. Required wherever the
* ground is teal, because there the teal pin's silhouette
* disappears and only the white window survives.
*
* Both are written onto one 176×208 canvas with the artwork at the same
* height, so they are drop-in swappable — see ASPECT below for why that
* matters rather than merely being neat.
*
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
* <picture> source, so this needs no JavaScript and no client boundary.
*
* The wordmark beside it is live text in Manrope rather than the sheet's
* raster: the written style guide specifies Manrope, and live text stays
* selectable, scales cleanly and recolours with the theme.
*
* RESOLUTION CAVEAT: the largest instance on the supplied sheet is 153×189,
* which is ample for the header (36px), the favicon and the share card, but
* short of a 512px PWA icon — that one is upscaled and is slightly soft. Drop
* a vector (SVG/AI/EPS) into public/brand and regenerate to fix it; every
* consumer goes through this component or public/brand, so it is one swap.
*/
const MARK_LIGHT = '/brand/mark.png';
const MARK_ON_DARK = '/brand/mark-on-dark.png';
/**
* Intrinsic size of the artwork files, used to derive width from height.
*
* Both colourways are deliberately written onto the SAME canvas at the same
* artwork height, so this one ratio is correct for either of them. That is not
* tidiness — it is required. `variant="auto"` renders a single <img> whose
* srcset swaps the file underneath it, so the width/height attributes are
* shared by both colourways and cannot be varied per file. When the two were
* tightly cropped they had different ratios (0.810 and 0.875) and different
* padding, which meant a wrong aspect hint before load — a reflow on load
* under `width: auto` — and a visible jump in logo size whenever the OS theme
* flipped. Re-crop or replace one file and you must re-normalise both.
*/
const ART_WIDTH = 176;
const ART_HEIGHT = 208;
const ASPECT = ART_WIDTH / ART_HEIGHT;
interface LogoMarkProps {
className?: string;
/** Rendered height in px. Width follows the artwork's aspect ratio. */
size?: number;
/**
* Which colourway to serve.
* - `auto` teal pin, swapping to the on-dark artwork in the dark theme
* - `onDark` always the white pin — for teal grounds, which are teal in
* both themes (the footer band)
*/
variant?: 'auto' | 'onDark';
title?: string;
}
export function LogoMark({ className, size = 36, variant = 'auto', title }: LogoMarkProps) {
const height = size;
const width = Math.round(size * ASPECT);
const alt = title ?? '';
if (variant === 'onDark') {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
className={className}
src={MARK_ON_DARK}
width={width}
height={height}
alt={alt}
aria-hidden={title ? undefined : true}
decoding="async"
/>
);
}
return (
<picture>
<source media="(prefers-color-scheme: dark)" srcSet={MARK_ON_DARK} />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className={className}
src={MARK_LIGHT}
width={width}
height={height}
alt={alt}
aria-hidden={title ? undefined : true}
decoding="async"
/>
</picture>
);
}
+13 -13
View File
@@ -1,7 +1,7 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(26, 22, 18, 0.6);
background: rgba(var(--shadow-rgb), 0.6);
display: flex;
align-items: center;
justify-content: center;
@@ -20,15 +20,15 @@
}
.modal {
background: var(--bg-card, white);
background: var(--bg-card);
border-radius: 16px;
box-shadow: 0 20px 40px rgba(26, 22, 18, 0.2);
box-shadow: 0 20px 40px rgba(var(--shadow-rgb), 0.2);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease;
border: 1px solid var(--border-color, #e5dfd5);
border: 1px solid var(--border);
}
@keyframes slideIn {
@@ -62,22 +62,22 @@
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
border-bottom: 1px solid var(--border);
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
font-family: var(--font-playfair), 'Playfair Display', serif;
color: var(--text-primary);
font-family: var(--font-display);
}
.closeButton {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
@@ -87,8 +87,8 @@
}
.closeButton:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--accent-coral, #e07256);
background: var(--bg-secondary);
color: var(--brand);
}
.content {
@@ -102,16 +102,16 @@
}
.content::-webkit-scrollbar-track {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
}
.content::-webkit-scrollbar-thumb {
background: var(--border-color, #e5dfd5);
background: var(--border);
border-radius: 4px;
}
.content::-webkit-scrollbar-thumb:hover {
background: var(--text-muted, #8a847a);
background: var(--text-muted);
}
@media (max-width: 640px) {
+117 -75
View File
@@ -1,10 +1,17 @@
/*
* Header and mobile tab bar, on the schoolcompare brand.
*
* The header is a white card band closed by a single hairline rule — no
* shadow. The guideline's chrome is quiet; the colour on this surface is the
* wordmark and the one active nav item, and nothing else.
*/
.header {
position: sticky;
top: 0;
z-index: 1000;
background: var(--bg-card, white);
border-bottom: 1px solid var(--border-color, #e5dfd5);
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.container {
@@ -16,112 +23,121 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 2rem;
/* 64px desktop / 56px mobile is load-bearing: the school detail page's
sticky section nav docks against it with a hardcoded `top` in
SchoolDetailShell.module.css and schoolSections.module.css, and the
scroll-spy observers use it as a rootMargin. Change it in all four
places or not at all. */
height: 64px;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.625rem;
/* Padded hit area so the logo link is ≥44×44 on touch */
margin: -0.375rem -0.5rem;
padding: 0.375rem 0.5rem;
text-decoration: none;
color: var(--text-primary, #1a1612);
font-size: 1.25rem;
font-weight: 700;
transition: color 0.2s ease;
color: var(--text-primary);
transition: color var(--transition);
-webkit-tap-highlight-color: transparent;
}
.logo:hover {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-stronger);
}
/*
* The mark colours itself — pin in var(--brand), leaf knocked out in
* var(--bg-card), which is this header's own ground. It takes no colour from
* currentColor, so there is deliberately no `color` here.
*/
.logoIcon {
display: inline-flex;
width: 36px;
height: 36px;
color: var(--accent-coral, #e07256);
flex: 0 0 auto;
/* The artwork is taller than it is wide (153:189), so height drives the box
and width follows. Constraining both would squash the pin. */
height: 38px;
}
.logoIcon svg {
width: 100%;
.logoIcon img {
display: block;
height: 100%;
width: auto;
}
/*
* "schoolcompare", one word, lower case. Manrope 700 pulled tight — the
* negative tracking is what makes the two halves read as a single word rather
* than as two.
*/
.logoText {
font-family: var(--font-playfair), 'Playfair Display', serif;
font-family: var(--font-display);
font-size: var(--step-2);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1;
letter-spacing: -0.035em;
}
.logoTextAccent {
color: var(--brand);
}
.logo:hover .logoTextAccent {
color: var(--brand-stronger);
}
.nav {
display: flex;
gap: 0.5rem;
align-items: center;
gap: 0.125rem;
}
.navLink {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
gap: 0.4375rem;
padding: 0.5rem 0.875rem;
font-family: var(--font-ui);
font-size: var(--step--1);
font-weight: 500;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
text-decoration: none;
border-radius: 6px;
transition: all 0.2s ease;
}
/* Sliding underline effect */
.navLink::after {
content: '';
position: absolute;
bottom: 4px;
left: 1rem;
right: 1rem;
height: 2px;
background: var(--accent-coral, #e07256);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.25s ease;
border-radius: 1px;
border-radius: var(--radius-md);
transition: color var(--transition), background-color var(--transition);
}
.navLink:hover {
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
}
.navLink:hover::after {
transform: scaleX(1);
color: var(--text-primary);
background: var(--bg-secondary);
}
/* The current page is marked twice — brand hue and a heavier weight — so the
state never rests on colour alone. */
.navLink.active {
color: var(--accent-coral-dark, #b04a2e);
background: var(--accent-coral-bg);
}
.navLink.active::after {
transform: scaleX(1);
color: var(--brand);
background: var(--brand-bg);
font-weight: 600;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
min-width: 1.125rem;
height: 1.125rem;
padding: 0 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: var(--accent-coral, #e07256);
font-family: var(--font-ui);
font-size: var(--step--2);
font-weight: 700;
line-height: 1;
color: var(--brand-on);
background: var(--brand);
border-radius: 9999px;
animation: badgePop 0.3s ease-out;
box-shadow: 0 2px 6px rgba(224, 114, 86, 0.4);
}
@keyframes badgePop {
@@ -149,25 +165,23 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
gap: 0.25rem;
flex: 1;
min-height: 56px;
padding: 0.375rem 0.25rem;
color: var(--text-secondary, #5c564d);
font-family: var(--font-ui);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.01em;
transition: color 0.15s ease, background-color 0.15s ease;
transition: color var(--transition);
-webkit-tap-highlight-color: transparent;
}
.tab:active {
background: var(--bg-secondary, #f3ede4);
}
.tabActive {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand);
font-weight: 600;
}
.tabIconWrap {
@@ -179,7 +193,29 @@
height: 24px;
}
/* The active tab's icon sits in a brand-tinted stadium — the same rounded
geometry as the rest of the system, and a second cue beyond hue. Drawn as a
pseudo-element so it can bleed wider than the 24px icon box without moving
the badge's anchor. */
.tabIconWrap::before {
content: '';
position: absolute;
inset: -2px -9px;
border-radius: var(--radius-xl);
background: transparent;
transition: background-color var(--transition);
}
.tabActive .tabIconWrap::before {
background: var(--brand-bg);
}
.tab:active .tabIconWrap::before {
background: var(--brand-bg);
}
.tabIcon {
position: relative;
width: 22px;
height: 22px;
}
@@ -190,19 +226,21 @@
.tabBadge {
position: absolute;
top: -6px;
right: -10px;
top: -7px;
right: -7px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-family: var(--font-ui);
font-size: 0.6875rem;
font-weight: 700;
color: white;
background: var(--accent-coral, #e07256);
border: 2px solid var(--bg-card, white);
line-height: 1;
color: var(--brand-on);
background: var(--brand);
border: 2px solid var(--bg-card);
border-radius: 9999px;
animation: badgePop 0.3s ease-out;
}
@@ -219,8 +257,12 @@
}
.logoIcon {
width: 32px;
height: 32px;
width: 34px;
height: 34px;
}
.logoText {
font-size: var(--step-1);
}
.bottomBar {
@@ -230,9 +272,9 @@
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-card, white);
border-top: 1px solid var(--border-color, #e5dfd5);
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
background: var(--bg-card);
border-top: 1px solid var(--border);
box-shadow: 0 -2px 12px rgba(var(--shadow-rgb), 0.06);
/* Respect iPhone home-indicator (bottom) and notch (left/right in
landscape) insets so the tab content never sits under system UI. */
padding-bottom: env(safe-area-inset-bottom, 0);
+22 -7
View File
@@ -9,6 +9,7 @@ import { useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import { LogoMark } from './Logo';
import styles from './Navigation.module.css';
type IconProps = { className?: string };
@@ -78,6 +79,16 @@ export function Navigation() {
};
}, []);
/*
* The brand guideline's nav also carries Guides, About, Favourites and a
* "Sign in" button. None of the four is added here, deliberately: there are
* no Guides or About pages, and Favourites and Sign in both need an accounts
* system that does not exist. Drawing chrome for features we do not have
* would be a promise the product cannot keep. Rankings and Admissions —
* which the guideline omits — take their place, because they are real.
*
* If accounts ever ship, this is the list to extend.
*/
const items = [
{ href: '/', label: 'Search', Icon: SearchIcon },
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
@@ -89,15 +100,19 @@ export function Navigation() {
<>
<header className={styles.header}>
<div className={styles.container}>
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
<Link href="/" className={styles.logo} aria-label="schoolcompare home">
{/*
variant="auto": the teal pin on the white header, swapping to the
on-dark artwork for dark-theme viewers. The footer has to force
onDark instead, because its band is teal in both themes and the
teal pin has no silhouette against it.
*/}
<span className={styles.logoIcon}>
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="20" cy="20" r="3" fill="currentColor" />
</svg>
<LogoMark size={38} />
</span>
<span className={styles.logoText}>
school<span className={styles.logoTextAccent}>compare</span>
</span>
<span className={styles.logoText}>SchoolCompare</span>
</Link>
<nav className={styles.nav} aria-label="Main navigation">
+19 -19
View File
@@ -8,7 +8,7 @@
.info {
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
}
.controls {
@@ -21,18 +21,18 @@
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: var(--bg-card, white);
color: var(--text-secondary, #5c564d);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.navButton:hover:not(:disabled) {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
color: var(--text-primary, #1a1612);
background: var(--bg-secondary);
border-color: var(--brand);
color: var(--text-primary);
}
.navButton:disabled {
@@ -51,32 +51,32 @@
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.pageButton {
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
}
.pageButton:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
color: var(--text-primary, #1a1612);
background: var(--bg-secondary);
border-color: var(--brand);
color: var(--text-primary);
}
.pageButtonActive {
background: var(--accent-coral, #e07256);
color: white;
border-color: var(--accent-coral, #e07256);
background: var(--brand);
color: var(--brand-on);
border-color: var(--brand);
}
.pageButtonActive:hover {
background: var(--accent-coral-dark, #c45a3f);
border-color: var(--accent-coral-dark, #c45a3f);
background: var(--brand-strong);
border-color: var(--brand-strong);
}
.ellipsis {
@@ -84,7 +84,7 @@
align-items: center;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
}
@media (max-width: 640px) {
@@ -12,10 +12,10 @@
.trendSummary {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
padding: 0.5rem 0.875rem;
background: var(--bg-secondary, #f3ede4);
border-left: 3px solid var(--accent-teal, #2d7d7d);
background: var(--bg-secondary);
border-left: 3px solid var(--brand);
border-radius: 0 4px 4px 0;
align-self: flex-start;
}
@@ -34,7 +34,7 @@
.covidNote,
.chartHint {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
margin: 0;
font-style: italic;
}
@@ -52,7 +52,7 @@
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
margin-bottom: 0.4rem;
}
@@ -63,9 +63,9 @@
}
.chip {
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card, #fff);
color: var(--text-secondary, #5c564d);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-secondary);
padding: 0.4rem 0.75rem;
border-radius: 999px;
font-size: 0.8125rem;
@@ -78,7 +78,7 @@
}
.chip:active {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
}
.chip:disabled {
@@ -87,21 +87,21 @@
}
.chipActive {
background: var(--accent-coral-dark, #b04a2e);
color: white;
border-color: var(--accent-coral, #e07256);
background: var(--brand-strong);
color: var(--brand-on);
border-color: var(--brand);
font-weight: 600;
}
.chipActive:active {
background: var(--accent-coral-dark, #c45a3f);
background: var(--brand-strong);
}
.miniLegend {
display: flex;
gap: 0.875rem;
font-size: 0.75rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
margin-top: -0.25rem;
}
+37 -24
View File
@@ -11,6 +11,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useThemeTokens, alpha } from '@/lib/theme';
import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup';
@@ -124,9 +125,17 @@ export function PerformanceChart({
const gapYears = axisYears.filter(y => !byYear.has(y));
const hasGap = gapYears.length > 0;
// Chart.js paints to a canvas, so it needs resolved colours rather than
// var(). Reading them through the token bridge keeps the series on the
// brand palette and lets them follow the theme.
const [c1, c2, c3, c4, c5, c6, cGrid, cRef, cInverse, cInverseText] = useThemeTokens(
'--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6',
'--chart-grid', '--chart-reference', '--surface-inverse', '--text-inverse'
);
// ── Datasets (full set; mobile filters them via the active chip) ─────
const refLineStyle = {
borderColor: 'rgba(90,80,70,0.35)',
borderColor: cRef,
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: [6, 4] as number[],
@@ -140,8 +149,8 @@ export function PerformanceChart({
{
label: 'Attainment 8',
data: col('attainment_8_score'),
borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)',
borderColor: c1,
backgroundColor: alpha('--chart-1', 0.08),
borderWidth: 2.5,
tension: 0.3,
pointRadius: 4,
@@ -151,8 +160,8 @@ export function PerformanceChart({
{
label: 'English & Maths Grade 4+',
data: col('english_maths_standard_pass_pct'),
borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,0.08)',
borderColor: c2,
backgroundColor: alpha('--chart-2', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
@@ -161,8 +170,8 @@ export function PerformanceChart({
{
label: 'Progress 8',
data: col('progress_8_score'),
borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,0.08)',
borderColor: c3,
backgroundColor: alpha('--chart-3', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
@@ -179,8 +188,8 @@ export function PerformanceChart({
{
label: 'Reading, Writing & Maths expected %',
data: col('rwm_expected_pct'),
borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)',
borderColor: c1,
backgroundColor: alpha('--chart-1', 0.08),
borderWidth: 2.5,
tension: 0.3,
pointRadius: 4,
@@ -190,8 +199,8 @@ export function PerformanceChart({
{
label: 'Exceeding expected level',
data: col('rwm_high_pct'),
borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,0.08)',
borderColor: c2,
backgroundColor: alpha('--chart-2', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
@@ -206,8 +215,8 @@ export function PerformanceChart({
{
label: 'Reading progress',
data: col('reading_progress'),
borderColor: 'rgb(59,130,246)',
backgroundColor: 'rgba(59,130,246,0.08)',
borderColor: c4,
backgroundColor: alpha('--chart-4', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
@@ -217,8 +226,8 @@ export function PerformanceChart({
{
label: 'Writing progress',
data: col('writing_progress'),
borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,0.08)',
borderColor: c3,
backgroundColor: alpha('--chart-3', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
@@ -228,8 +237,8 @@ export function PerformanceChart({
{
label: 'Maths progress',
data: col('maths_progress'),
borderColor: 'rgb(236,72,153)',
backgroundColor: 'rgba(236,72,153,0.08)',
borderColor: c5,
backgroundColor: alpha('--chart-5', 0.08),
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
@@ -301,7 +310,9 @@ export function PerformanceChart({
},
title: { display: false },
tooltip: {
backgroundColor: 'rgba(26,22,18,0.92)',
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
padding: 12,
titleFont: { size: 13 },
bodyFont: { size: 12 },
@@ -321,7 +332,7 @@ export function PerformanceChart({
type: 'linear', display: true, position: 'left',
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
min: 0, max: isSecondary ? undefined : 100,
grid: { color: 'rgba(0,0,0,0.05)' },
grid: { color: cGrid },
ticks: { font: { size: 11 } },
},
y1: {
@@ -346,7 +357,9 @@ export function PerformanceChart({
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: 'rgba(26,22,18,0.92)',
backgroundColor: cInverse,
titleColor: cInverseText,
bodyColor: cInverseText,
padding: 10,
titleFont: { size: 12 },
bodyFont: { size: 11 },
@@ -364,7 +377,7 @@ export function PerformanceChart({
y: {
type: 'linear', display: true, position: 'left',
min: mobileYBounds.min, max: mobileYBounds.max,
grid: { color: 'rgba(0,0,0,0.05)' },
grid: { color: cGrid },
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
},
x: {
@@ -429,9 +442,9 @@ export function PerformanceChart({
belong together. */}
{isMobile && activeChip === 'progress' && (
<div className={styles.miniLegend}>
<span><span className={styles.miniDot} style={{ background: 'rgb(59,130,246)' }} />Reading</span>
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
<span><span className={styles.miniDot} style={{ background: c4 }} />Reading</span>
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
</div>
)}
+55 -54
View File
@@ -11,14 +11,14 @@
.header h1 {
font-size: 2.25rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.5rem;
font-family: var(--font-playfair), 'Playfair Display', serif;
font-family: var(--font-display);
}
.subtitle {
font-size: 1rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
margin: 0;
line-height: 1.6;
}
@@ -28,7 +28,7 @@
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color, #e5dfd5);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
@@ -38,8 +38,8 @@
padding: 0.625rem 1.5rem;
font-size: 0.9375rem;
font-weight: 500;
background: var(--bg-card, white);
color: var(--text-secondary, #5c564d);
background: var(--bg-card);
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.15s ease;
@@ -47,34 +47,34 @@
}
.phaseTab:not(:last-child) {
border-right: 1px solid var(--border-color, #e5dfd5);
border-right: 1px solid var(--border);
}
.phaseTab:hover {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
}
.phaseTabActive {
background: var(--accent-coral-dark, #b04a2e);
color: white;
background: var(--brand-strong);
color: var(--brand-on);
font-weight: 600;
}
.phaseTabActive:hover {
background: var(--accent-coral-darker, #9c3f26);
background: var(--brand-stronger);
}
/* Filters */
.filters {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
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(26, 22, 18, 0.06));
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.filterGroup {
@@ -88,7 +88,7 @@
.filterLabel {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
white-space: nowrap;
}
@@ -98,44 +98,44 @@
max-width: 100%;
padding: 0.625rem 1rem;
font-size: 0.9375rem;
border: 1px solid var(--border-color, #e5dfd5);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card, white);
color: var(--text-primary, #1a1612);
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.filterSelect:hover {
border-color: var(--accent-coral, #e07256);
border-color: var(--brand);
}
.filterSelect:focus {
outline: none;
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px var(--accent-coral-bg);
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-bg);
}
.filterSelect optgroup {
font-weight: 700;
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary);
background: var(--bg-secondary);
padding: 0.5rem 0;
}
.filterSelect option {
font-weight: 400;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
padding: 0.375rem 1rem;
}
/* Rankings Section */
.rankingsSection {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.tableWrapper {
@@ -151,15 +151,15 @@
}
.rankingsTable thead {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
}
.rankingsTable th {
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary, #1a1612);
border-bottom: 2px solid var(--border-color, #e5dfd5);
color: var(--text-primary);
border-bottom: 2px solid var(--border);
white-space: nowrap;
text-transform: uppercase;
font-size: 0.75rem;
@@ -206,8 +206,8 @@
.rankingsTable td {
padding: 1rem;
border-bottom: 1px solid var(--border-color, #e5dfd5);
color: var(--text-secondary, #5c564d);
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
}
.rankingsTable tbody tr:last-child td {
@@ -216,24 +216,24 @@
/* Alternating row backgrounds for visual rhythm */
.rankingsTable tbody tr:nth-child(even) {
background: rgba(243, 237, 228, 0.5);
background: rgba(var(--text-inverse-rgb), 0.5);
}
.rankingsTable tbody tr:hover {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
}
/* Top 3 Highlighting with Gold */
.rank1 {
background: linear-gradient(90deg, rgba(201, 162, 39, 0.15) 0%, transparent 100%) !important;
background: linear-gradient(90deg, rgba(var(--status-below-rgb), 0.15) 0%, transparent 100%) !important;
}
.rank2 {
background: linear-gradient(90deg, rgba(192, 192, 192, 0.15) 0%, transparent 100%) !important;
background: linear-gradient(90deg, rgba(var(--medal-silver-rgb), 0.15) 0%, transparent 100%) !important;
}
.rank3 {
background: linear-gradient(90deg, rgba(205, 127, 50, 0.15) 0%, transparent 100%) !important;
background: linear-gradient(90deg, rgba(var(--medal-bronze-rgb), 0.15) 0%, transparent 100%) !important;
}
.rankCell {
@@ -241,7 +241,7 @@
align-items: center;
gap: 0.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
}
/* Styled rank badges for top 3 */
@@ -254,8 +254,8 @@
border-radius: 50%;
font-size: 1rem;
font-weight: 700;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: var(--brand-on);
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.15);
position: relative;
}
@@ -265,30 +265,31 @@
inset: -2px;
border-radius: 50%;
border: 2px solid transparent;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent) border-box;
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, #c9a227 0%, #e8c547 50%, #c9a227 100%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
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, #8c8c8c 0%, #c0c0c0 50%, #8c8c8c 100%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
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, #a5673f 0%, #cd7f32 50%, #a5673f 100%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
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, #5c564d);
color: var(--text-secondary);
}
.schoolCell {
@@ -296,18 +297,18 @@
}
.schoolLink {
color: var(--text-primary, #1a1612);
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.schoolLink:hover {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
}
.areaCell,
.typeCell {
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
}
/* LA subline under the school name — mobile only (Area column hidden there). */
@@ -315,7 +316,7 @@
display: none;
font-size: 0.75rem;
font-weight: 400;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
}
.valueCell {
@@ -324,7 +325,7 @@
}
.valueCell strong {
color: var(--accent-teal, #2d7d7d);
color: var(--status-above);
font-weight: 700;
}
@@ -346,7 +347,7 @@
.noResults {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
}
.noResults p {
+14 -14
View File
@@ -17,7 +17,7 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
margin-bottom: 0.75rem;
}
@@ -41,7 +41,7 @@
top: 0;
height: 100%;
width: 1px;
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
}
/* ── Per-bar national average marker ──
@@ -54,7 +54,7 @@
bottom: -3px;
width: 2px;
transform: translateX(-50%);
background: var(--accent-coral, #e07256);
background: var(--brand);
border-radius: 2px;
z-index: 4;
pointer-events: none;
@@ -69,7 +69,7 @@
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-coral, #e07256);
background: var(--brand);
}
.barHeaderRight {
@@ -81,7 +81,7 @@
.natLabel {
font-size: 0.55rem;
font-weight: 700;
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
text-transform: uppercase;
letter-spacing: 0.03em;
}
@@ -123,18 +123,18 @@
}
.barExpected {
background: var(--accent-teal-light, #3a9e9e);
background: var(--brand);
}
.barExceeding {
background: var(--accent-teal, #2d7d7d);
background: var(--brand);
}
.barLabel {
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
font-family: var(--font-display);
font-size: 0.78rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
white-space: nowrap;
position: relative;
z-index: 3;
@@ -142,7 +142,7 @@
.barLabelSuffix {
font-weight: 500;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -153,7 +153,7 @@
position: relative;
height: 14px;
margin-top: 0.3rem;
border-top: 1px solid var(--border-color, #e5dfd5);
border-top: 1px solid var(--border);
}
.rulerTick {
@@ -161,7 +161,7 @@
top: 0;
width: 1px;
height: 3px;
background: var(--border-color, #e5dfd5);
background: var(--border);
}
.rulerLabel {
@@ -169,7 +169,7 @@
top: 5px;
font-size: 0.5rem;
font-weight: 500;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
transform: translateX(-50%);
letter-spacing: 0.01em;
opacity: 0.7;
@@ -198,7 +198,7 @@
gap: 0.35rem;
font-size: 0.65rem;
font-weight: 500;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
}
.legendSwatch {
+3 -3
View File
@@ -145,15 +145,15 @@ export default function SatsChart({ subjects }: SatsChartProps) {
</div>
<div className={styles.legend}>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
Expected standard
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
National average
</div>
</div>
+25 -24
View File
@@ -1,6 +1,6 @@
.card {
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: 10px;
padding: 1rem 1.125rem;
@@ -8,13 +8,13 @@
}
.card.cardInCompare {
border-color: var(--accent-teal, #2d7d7d);
box-shadow: 0 0 0 1px var(--accent-teal, #2d7d7d);
border-color: var(--brand);
box-shadow: 0 0 0 1px var(--brand);
}
.card:hover {
border-left-color: var(--accent-coral, #e07256);
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
border-left-color: var(--brand);
box-shadow: var(--shadow-medium, 0 4px 20px rgba(var(--shadow-rgb), 0.1));
transform: translateY(-1px);
}
@@ -31,24 +31,24 @@
font-size: 1rem;
font-weight: 600;
line-height: 1.35;
font-family: var(--font-playfair), 'Playfair Display', serif;
font-family: var(--font-display);
}
.title a {
color: var(--text-primary, #1a1612);
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.title a:hover {
color: var(--accent-coral, #e07256);
color: var(--brand);
}
.distance {
font-size: 0.75rem;
color: var(--accent-teal, #2d7d7d);
color: var(--brand);
white-space: nowrap;
background: var(--accent-teal-bg);
background: var(--brand-bg);
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-weight: 500;
@@ -63,9 +63,9 @@
.metaItem {
font-size: 0.75rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
border-radius: 3px;
}
@@ -75,9 +75,9 @@
gap: 0.625rem;
margin-bottom: 0.875rem;
padding: 0.625rem 0.75rem;
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
border-radius: 6px;
border: 1px solid var(--border-color, #e5dfd5);
border: 1px solid var(--border);
}
.metric {
@@ -88,12 +88,13 @@
.metricLabel {
font-size: 0.6875rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metricValue {
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
gap: 0.375rem;
@@ -101,7 +102,7 @@
.metricValue strong {
font-size: 1rem;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
font-weight: 700;
}
@@ -126,18 +127,18 @@
}
.trendUp {
color: var(--accent-teal, #2d7d7d);
background: var(--accent-teal-bg);
color: var(--status-above);
background: var(--status-above-bg);
}
.trendDown {
color: var(--accent-coral, #e07256);
background: var(--accent-coral-bg);
color: var(--status-below);
background: var(--status-below-bg);
}
.trendStable {
color: var(--text-muted, #8a847a);
background: rgba(138, 132, 122, 0.15);
color: var(--text-muted);
background: rgba(var(--muted-rgb), 0.15);
}
.actions {
@@ -156,7 +157,7 @@
.metricHint {
font-size: 0.7rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
display: block;
margin-top: 1px;
font-weight: 400;
@@ -6,7 +6,7 @@
position: relative;
width: 100%;
height: 210px;
background: #dfe6e2;
background: var(--bg-secondary);
isolation: isolate;
}
@@ -47,7 +47,7 @@
width: 100%;
height: 100%;
background:
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) #e7e2da;
linear-gradient(100deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, .5) 50%, rgba(255, 255, 255, 0) 60%) var(--bg-secondary);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@@ -85,11 +85,11 @@
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
background: rgba(255, 255, 255, .85);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .16);
opacity: 0;
transform: translateY(4px);
transition: opacity .16s ease, transform .16s ease;
@@ -118,7 +118,7 @@
rgba(255, 255, 255, .35) 35%,
rgba(255, 255, 255, .75) 62%,
rgba(255, 255, 255, .95) 82%,
var(--bg-card, #fff) 100%);
var(--bg-card) 100%);
}
.closeBtn {
@@ -134,9 +134,9 @@
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, .92);
color: var(--text-primary, #1a1612);
color: var(--text-primary);
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, .2);
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), .2);
}
.closeBtn:hover {
+4 -4
View File
@@ -33,12 +33,12 @@
border: 2px solid rgba(0, 0, 0, 0.2);
border-radius: 4px;
cursor: pointer;
color: #333;
color: var(--text-primary);
transition: background 0.15s ease, color 0.15s ease;
}
.fullscreenBtn:hover {
background: #f4f4f4;
background: var(--bg-secondary);
color: #000;
}
@@ -57,9 +57,9 @@
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid rgba(224, 114, 86, 0.3);
border: 3px solid rgba(var(--brand-rgb), 0.3);
border-radius: 50%;
border-top-color: var(--accent-coral, #e07256);
border-top-color: var(--brand);
animation: spin 0.8s linear infinite;
}
+36 -35
View File
@@ -2,8 +2,8 @@
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: 8px;
padding: 1rem 1.25rem;
@@ -12,17 +12,17 @@
}
.row:hover {
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
}
/* Phase border colours */
.phasePrimary { border-left-color: var(--phase-primary, #5b8cbf); }
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
.phaseNursery { border-left-color: var(--phase-nursery, #e0a0b0); }
.phasePrimary { border-left-color: var(--phase-primary); }
.phaseAllThrough { border-left-color: var(--phase-all-through); }
.phaseNursery { border-left-color: var(--phase-nursery); }
.rowInCompare {
border-left-color: var(--accent-teal, #2d7d7d);
background: var(--bg-secondary, #f3ede4);
border-left-color: var(--brand);
background: var(--bg-secondary);
}
@keyframes rowFadeIn {
@@ -50,7 +50,7 @@
.schoolName {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
@@ -60,7 +60,7 @@
}
.schoolName:hover {
color: var(--accent-coral, #e07256);
color: var(--brand);
}
/* Phase label pill */
@@ -96,8 +96,8 @@
line-height: 1.4;
border-radius: 4px;
white-space: nowrap;
background: rgba(26, 22, 18, 0.05);
color: var(--text-secondary, #5c564d);
background: rgba(var(--shadow-rgb), 0.05);
color: var(--text-secondary);
}
/* Line 3: stats */
@@ -115,10 +115,11 @@
}
.statValue {
font-variant-numeric: tabular-nums;
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
font-family: var(--font-playfair), 'Playfair Display', serif;
color: var(--text-primary);
font-family: var(--font-display);
display: inline-flex;
align-items: center;
gap: 0.2rem;
@@ -126,7 +127,7 @@
.statLabel {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
white-space: nowrap;
}
@@ -137,9 +138,9 @@
margin-left: 1px;
}
.trendUp { color: var(--accent-teal, #2d7d7d); }
.trendDown { color: var(--accent-coral, #e07256); }
.trendStable { color: var(--text-muted, #8a847a); }
.trendUp { color: var(--status-above); }
.trendDown { color: var(--status-below); }
.trendStable { color: var(--text-muted); }
/* Line 4: location */
.line4 {
@@ -148,13 +149,13 @@
flex-wrap: wrap;
gap: 0;
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
}
.line4 span:not(:last-child)::after {
content: '·';
margin: 0 0.4rem;
color: var(--border-color, #e5dfd5);
color: var(--border);
}
.distanceBadge {
@@ -162,8 +163,8 @@
padding: 0.0625rem 0.375rem;
font-size: 0.75rem;
font-weight: 600;
background: var(--accent-teal, #2d7d7d);
color: white;
background: var(--brand);
color: var(--brand-on);
border-radius: 3px;
}
@@ -200,23 +201,23 @@
font-weight: 400;
}
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
/* ofsted1–4 already defined above; these cover the two new framework states */
.ofstedRc { background: #5a3a6e; color: #fff; }
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
distinct from the grey "Not yet inspected" pending state. */
.ofstedInspected { background: #e6ecf2; color: #45586b; }
.ofstedPending { background: #e0e0e0; color: #666; }
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
.ofstedPending { background: var(--border); color: var(--text-muted); }
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */
.vsNational { font-size: 0.7rem; color: var(--accent-teal, #2d7d7d); font-weight: 600; }
.vsNationalNeg { font-size: 0.7rem; color: var(--accent-coral, #e07256); font-weight: 600; }
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted, #8a847a); }
.vsNational { font-size: 0.7rem; color: var(--status-above); font-weight: 600; }
.vsNationalNeg { font-size: 0.7rem; color: var(--brand); font-weight: 600; }
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted); }
/* ── Mobile ──────────────────────────────────────────── */
@media (max-width: 640px) {
@@ -257,7 +258,7 @@
/* GIAS "Open, but proposed to close" marker */
.attrClosing {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
background: var(--status-below-bg);
color: var(--status-below);
border: 1px solid var(--status-below-bg);
}
@@ -3,9 +3,9 @@
}
.warning {
background: var(--accent-gold-bg);
border: 1px solid var(--accent-gold, #c9a227);
color: var(--text-primary, #1a1612);
background: var(--status-below-bg);
border: 1px solid var(--status-below);
color: var(--text-primary);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
@@ -21,21 +21,21 @@
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
border: 2px solid var(--border-color, #e5dfd5);
border: 2px solid var(--border);
border-radius: 8px;
background: var(--bg-card, white);
color: var(--text-primary, #1a1612);
background: var(--bg-card);
color: var(--text-primary);
transition: all 0.2s ease;
}
.searchInput::placeholder {
color: var(--text-muted, #8a847a);
color: var(--text-muted);
}
.searchInput:focus {
outline: none;
border-color: var(--accent-coral, #e07256);
box-shadow: 0 0 0 3px var(--accent-coral-bg);
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-bg);
}
.searchSpinner {
@@ -45,9 +45,9 @@
transform: translateY(-50%);
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(224, 114, 86, 0.3);
border: 2px solid rgba(var(--brand-rgb), 0.3);
border-radius: 50%;
border-top-color: var(--accent-coral, #e07256);
border-top-color: var(--brand);
animation: spin 0.6s linear infinite;
}
@@ -71,17 +71,17 @@
}
.results::-webkit-scrollbar-track {
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
border-radius: 4px;
}
.results::-webkit-scrollbar-thumb {
background: var(--border-color, #e5dfd5);
background: var(--border);
border-radius: 4px;
}
.results::-webkit-scrollbar-thumb:hover {
background: var(--text-muted, #8a847a);
background: var(--text-muted);
}
.resultItem {
@@ -90,16 +90,16 @@
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-secondary, #f3ede4);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
transition: all 0.2s ease;
}
.resultItem:hover {
background: var(--bg-card, white);
border-color: var(--accent-coral, #e07256);
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
background: var(--bg-card);
border-color: var(--brand);
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.06));
}
.schoolInfo {
@@ -110,7 +110,7 @@
.schoolName {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
@@ -125,7 +125,7 @@
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
}
.schoolMeta span {
@@ -138,14 +138,14 @@
.noResults {
text-align: center;
padding: 2rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
font-size: 0.9375rem;
}
.hint {
text-align: center;
padding: 2rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
font-size: 0.9375rem;
}
@@ -2,8 +2,8 @@
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid transparent;
border-radius: 8px;
padding: 1rem 1.25rem;
@@ -12,17 +12,17 @@
}
.row:hover {
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
}
/* Phase border colours */
.phaseSecondary { border-left-color: var(--phase-secondary, #9b6bb0); }
.phaseAllThrough { border-left-color: var(--phase-all-through, #7a9a6d); }
.phasePost16 { border-left-color: var(--phase-post16, #c4915e); }
.phaseSecondary { border-left-color: var(--phase-secondary); }
.phaseAllThrough { border-left-color: var(--phase-all-through); }
.phasePost16 { border-left-color: var(--phase-post16); }
.rowInCompare {
border-left-color: var(--accent-teal, #2d7d7d);
background: var(--bg-secondary, #f3ede4);
border-left-color: var(--brand);
background: var(--bg-secondary);
}
@keyframes rowFadeIn {
@@ -50,7 +50,7 @@
.schoolName {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
@@ -60,7 +60,7 @@
}
.schoolName:hover {
color: var(--accent-coral, #e07256);
color: var(--brand);
}
/* Line 2: context tags */
@@ -81,8 +81,8 @@
line-height: 1.4;
border-radius: 4px;
white-space: nowrap;
background: rgba(26, 22, 18, 0.05);
color: var(--text-secondary, #5c564d);
background: rgba(var(--shadow-rgb), 0.05);
color: var(--text-secondary);
}
/* Line 3: KS4 stats */
@@ -100,22 +100,24 @@
}
.statValueLarge {
font-variant-numeric: tabular-nums;
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
font-family: var(--font-playfair), 'Playfair Display', serif;
color: var(--text-primary);
font-family: var(--font-display);
}
.statValue {
font-variant-numeric: tabular-nums;
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
font-family: var(--font-playfair), 'Playfair Display', serif;
color: var(--text-primary);
font-family: var(--font-display);
}
.statLabel {
font-size: 0.75rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
white-space: nowrap;
}
@@ -125,8 +127,8 @@
white-space: nowrap;
}
.deltaPositive { color: #2f7a2f; }
.deltaNegative { color: var(--accent-coral, #e07256); }
.deltaPositive { color: var(--status-above); }
.deltaNegative { color: var(--status-below); }
/* Line 4: location + distance */
.line4 {
@@ -135,13 +137,13 @@
flex-wrap: wrap;
gap: 0;
font-size: 0.8rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
}
.line4 span:not(:last-child)::after {
content: '·';
margin: 0 0.4rem;
color: var(--border-color, #e5dfd5);
color: var(--border);
}
.distanceBadge {
@@ -149,8 +151,8 @@
padding: 0.0625rem 0.375rem;
font-size: 0.75rem;
font-weight: 600;
background: var(--accent-teal, #2d7d7d);
color: white;
background: var(--brand);
color: var(--brand-on);
border-radius: 3px;
}
@@ -175,15 +177,15 @@
font-size: 0.75rem;
font-weight: 600;
line-height: 1.4;
background: rgba(26, 22, 18, 0.05);
color: var(--text-secondary, #5c564d);
background: rgba(var(--shadow-rgb), 0.05);
color: var(--text-secondary);
border-radius: 4px;
white-space: nowrap;
}
.selectiveTag {
background: rgba(180, 120, 0, 0.1);
color: #8a6200;
background: rgba(var(--status-below-rgb), 0.1);
color: var(--status-below);
}
/* ── Ofsted badge ────────────────────────────────────── */
@@ -202,17 +204,17 @@
font-weight: 400;
}
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #2f7a2f; }
.ofsted3 { background: var(--accent-gold-bg); color: var(--accent-gold-text, #7a6800); }
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
.ofsted1 { background: var(--status-above-bg); color: var(--status-above); }
.ofsted2 { background: rgba(var(--status-above-rgb), 0.12); color: var(--status-above); }
.ofsted3 { background: var(--status-below-bg); color: var(--status-below); }
.ofsted4 { background: var(--status-below); color: var(--text-inverse); }
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
.ofstedRc { background: #5a3a6e; color: #fff; }
.ofstedRc { background: var(--phase-secondary-text); color: var(--text-inverse); }
/* Inspected but with no overall grade (OEIF post-Sept-2024) — neutral slate,
distinct from the grey "Not yet inspected" pending state. */
.ofstedInspected { background: #e6ecf2; color: #45586b; }
.ofstedPending { background: #e0e0e0; color: #666; }
.ofstedInspected { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
.ofstedPending { background: var(--border); color: var(--text-muted); }
/* ── Right actions column ────────────────────────────── */
.rowActions {
@@ -268,7 +270,7 @@
}
.closingTag {
background: #fdf6e3;
color: #8a6200;
border: 1px solid #e2c96f;
background: var(--status-below-bg);
color: var(--status-below);
border: 1px solid var(--status-below-bg);
}
@@ -1,15 +1,15 @@
/* Special-school / PRU / AP context note: soft, informational (teal), not a
warning. Shared by both detail views so the styling can't drift. */
.note {
background: var(--bg-secondary, #f3ede4);
border-left: 4px solid var(--accent-teal, #2d7d7d);
background: var(--bg-secondary);
border-left: 4px solid var(--brand);
border-radius: 0 6px 6px 0;
padding: 0.7rem 1rem;
margin: 0 0 1rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
}
.note strong {
color: var(--text-primary, #1a1612);
color: var(--text-primary);
}
@@ -1,6 +1,6 @@
.moreMeasures {
margin-top: 0.5rem;
border-top: 1px solid var(--border-light);
border-top: 1px solid var(--border);
padding-top: 0.75rem;
}
@@ -8,7 +8,7 @@
cursor: pointer;
font-weight: 600;
font-size: 0.88rem;
color: var(--accent-coral-dark);
color: var(--brand-strong);
}
.stripNote {
@@ -88,7 +88,7 @@ export function CompareAtAGlance({
)}
{display.kind === 'transitional' && (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
<span className={s.small}>Sub-judgements only</span>
@@ -54,7 +54,7 @@ function ResultCell({ display }: { display: OfstedDisplay }) {
if (display.kind === 'transitional') {
return (
<>
<span className={s.badge} style={{ backgroundColor: '#e2e8f0', color: '#475569' }}>
<span className={s.badge} style={{ backgroundColor: 'var(--bg-secondary)', color: 'var(--text-secondary)' }}>
No overall grade
</span>
<span className={s.small}>
@@ -5,10 +5,10 @@
.explore summary {
cursor: pointer;
font-weight: 600;
color: var(--accent-coral-dark);
color: var(--brand-strong);
padding: 0.85rem 1.1rem;
background: var(--bg-card);
border: 1px solid var(--border-light);
border: 1px solid var(--border);
border-radius: 8px;
}
@@ -17,7 +17,7 @@
}
.inner {
border: 1px solid var(--border-light);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
background: var(--bg-card);
@@ -43,7 +43,7 @@
font-size: 0.9rem;
padding: 0.4rem 0.6rem;
border-radius: 8px;
border: 1px solid var(--border-light);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
max-width: 100%;
@@ -9,12 +9,12 @@
}
.sectionTitle {
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
font-family: var(--font-display);
font-size: 1.45rem;
font-weight: 700;
margin: 0;
padding-left: 0.75rem;
border-left: 3px solid var(--accent-coral-dark);
border-left: 3px solid var(--brand-strong);
}
.how {
@@ -36,7 +36,7 @@
back into the shared grid. */
.measure {
background: var(--bg-card);
border: 1px solid var(--border-light);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-soft);
padding: 0.75rem 0.85rem;
@@ -59,7 +59,7 @@
gap: 0.35rem 0.5rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--border-light);
border-top: 1px solid var(--border);
margin-top: 0.5rem;
font-size: 0.95rem;
}
@@ -113,18 +113,18 @@
}
.chipGood {
background: rgba(45, 125, 125, 0.14);
color: var(--accent-teal);
background: rgba(var(--status-above-rgb), 0.14);
color: var(--status-above);
}
.chipWarn {
background: var(--accent-gold-bg);
color: var(--accent-gold-text);
background: var(--status-below-bg);
color: var(--status-below);
}
.chipBad {
background: var(--accent-coral-bg);
color: var(--accent-coral-dark);
background: var(--status-below-bg);
color: var(--status-below);
}
.chipNeutral {
@@ -141,18 +141,18 @@
}
.badgeGood {
background: rgba(45, 125, 125, 0.14);
color: var(--accent-teal);
background: rgba(var(--status-above-rgb), 0.14);
color: var(--status-above);
}
.badgeWarn {
background: var(--accent-gold-bg);
color: var(--accent-gold-text);
background: var(--status-below-bg);
color: var(--status-below);
}
.badgeBad {
background: var(--accent-coral-bg);
color: var(--accent-coral-dark);
background: var(--status-below-bg);
color: var(--status-below);
}
.rcList {
@@ -200,7 +200,7 @@
.card {
background: var(--bg-card);
border: 1px solid var(--border-light);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-soft);
padding: 1.25rem 1.5rem;
@@ -208,7 +208,7 @@
}
.link {
color: var(--accent-coral-dark);
color: var(--brand-strong);
}
@media (min-width: 761px) {
@@ -230,14 +230,14 @@
.rowLabel {
color: var(--text-secondary);
padding: 0.85rem 0.5rem 0.85rem 0;
border-bottom: 1px solid var(--border-light);
border-bottom: 1px solid var(--border);
}
.cell {
display: block;
padding: 0.85rem 0.25rem;
border-top: none;
border-bottom: 1px solid var(--border-light);
border-bottom: 1px solid var(--border);
margin-top: 0;
}
@@ -19,7 +19,7 @@
padding: 0.25rem 0;
font-size: 1.0625rem;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
background: none;
border: none;
cursor: pointer;
@@ -29,7 +29,7 @@
.topBack:hover {
color: var(--accent-coral-dark, #c85a3e);
color: var(--brand-strong);
text-decoration: underline;
text-underline-offset: 2px;
}
@@ -38,8 +38,8 @@
/* Header Section */
.header {
position: relative;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
/* Padding lives on .headerContent so the map band can bleed to the edges. */
padding: 0;
@@ -88,12 +88,12 @@
.headerHasMap .actions .btnAdd {
background: rgba(255, 255, 255, 0.9);
color: var(--accent-coral-dark, #b04a2e);
background: var(--bg-card);
color: var(--brand-strong);
border-color: transparent;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
box-shadow: 0 2px 10px rgba(var(--shadow-rgb), 0.16);
}
@@ -116,14 +116,14 @@
padding: 0;
font: inherit;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
cursor: pointer;
white-space: nowrap;
}
.mapLink:hover {
color: var(--accent-coral-dark, #c45a3f);
color: var(--brand-strong);
text-decoration: underline;
text-underline-offset: 2px;
}
@@ -137,11 +137,11 @@
.schoolName {
font-size: clamp(2rem, 5vw, 3.25rem);
font-weight: 700;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
margin-bottom: 0.5rem;
line-height: 1.1;
letter-spacing: -0.01em;
font-family: var(--font-playfair), "Playfair Display", serif;
font-family: var(--font-display);
}
@@ -155,16 +155,16 @@
.metaItem {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #f3ede4);
background: var(--bg-secondary);
border-radius: 3px;
}
.address {
font-size: 0.875rem;
color: var(--text-muted, #8a847a);
color: var(--text-muted);
margin: 0 0 0.75rem;
}
@@ -180,18 +180,18 @@
.headerDetail {
font-size: 0.8125rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
}
.headerDetail strong {
color: var(--text-primary, #1a1612);
color: var(--text-primary);
font-weight: 600;
}
.headerDetail a {
color: var(--accent-teal, #2d7d7d);
color: var(--brand);
text-decoration: none;
}
@@ -213,7 +213,7 @@
border: none;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent-teal, #2d7d7d);
color: var(--brand);
cursor: pointer;
}
@@ -236,25 +236,25 @@
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.08));
box-shadow: var(--shadow-soft, 0 2px 8px rgba(var(--shadow-rgb), 0.08));
}
.btnAdd {
background: var(--accent-coral-dark, #b04a2e);
color: white;
background: var(--brand-strong);
color: var(--brand-on);
}
.btnAdd:hover {
background: var(--accent-coral-darker, #9c3f26);
background: var(--brand-stronger);
transform: translateY(-1px);
}
.btnRemove {
background: var(--accent-teal, #2d7d7d);
color: white;
background: var(--brand);
color: var(--brand-on);
}
@@ -270,13 +270,13 @@
position: sticky;
top: 64px; /* global header height on desktop */
z-index: 10;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
padding: 0.5rem 0.75rem;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
box-shadow: 0 2px 4px rgba(var(--shadow-rgb), 0.04);
display: flex;
align-items: center;
gap: 0.5rem;
@@ -291,9 +291,9 @@
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
background: none;
border: 1px solid var(--border-color, #e5dfd5);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
@@ -302,8 +302,8 @@
.sectionNavBack:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
background: var(--bg-secondary);
border-color: var(--brand);
}
@@ -334,7 +334,7 @@
padding: 0.3rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
text-decoration: none;
border-radius: 4px;
transition: all 0.15s ease;
@@ -344,21 +344,21 @@
.sectionNavLink:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
background: var(--bg-secondary);
color: var(--text-primary);
}
.sectionNavLinkActive {
background: var(--accent-coral-dark, #b04a2e);
color: white;
background: var(--brand-strong);
color: var(--brand-on);
font-weight: 600;
}
.sectionNavLinkActive:hover {
background: var(--accent-coral-dark, #c45a3f);
color: white;
background: var(--brand-strong);
color: var(--brand-on);
}
@@ -373,12 +373,12 @@
gap: 0.5rem;
min-height: 38px;
padding: 0.34rem 0.7rem;
background: var(--bg-secondary, #f3ede4);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
color: var(--text-primary, #1a1612);
font-family: var(--font-ui);
color: var(--text-primary);
}
@@ -396,7 +396,7 @@
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
}
@@ -411,7 +411,7 @@
.sectionNavMenuChev {
flex: none;
color: var(--text-muted, #6d685f);
color: var(--text-muted);
font-size: 0.7rem;
}
@@ -426,9 +426,9 @@
width: 38px;
height: 38px;
border-radius: 9px;
border: 1px solid var(--accent-coral-dark, #b04a2e);
background: var(--accent-coral-dark, #b04a2e);
color: white;
border: 1px solid var(--brand-strong);
background: var(--brand-strong);
color: var(--brand-on);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
@@ -447,9 +447,9 @@
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg-card, white);
color: var(--accent-coral-dark, #c45a3f);
border: 1.5px solid var(--accent-coral, #e07256);
background: var(--bg-card);
color: var(--brand-strong);
border: 1.5px solid var(--brand);
display: flex;
align-items: center;
justify-content: center;
@@ -460,9 +460,9 @@
.sectionNavCompareIconIn {
background: var(--bg-card, white);
border-color: var(--accent-teal, #2d7d7d);
color: var(--accent-teal, #2d7d7d);
background: var(--bg-card);
border-color: var(--brand);
color: var(--brand);
}
@@ -474,9 +474,9 @@
padding: 0.34rem 0.7rem;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: var(--accent-coral-dark, #b04a2e);
border: 1px solid var(--accent-coral-dark, #b04a2e);
color: var(--brand-on);
background: var(--brand-strong);
border: 1px solid var(--brand-strong);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
@@ -485,21 +485,21 @@
.sectionNavCompare:hover {
background: var(--accent-coral-darker, #9c3f26);
border-color: var(--accent-coral-darker, #9c3f26);
background: var(--brand-stronger);
border-color: var(--brand-stronger);
}
.sectionNavCompareIn {
background: var(--bg-card, white);
color: var(--accent-teal, #2d7d7d);
border-color: var(--accent-teal, #2d7d7d);
background: var(--bg-card);
color: var(--brand);
border-color: var(--brand);
}
.sectionNavCompareIn:hover {
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-teal, #2d7d7d);
background: var(--bg-secondary);
border-color: var(--brand);
}
@@ -512,8 +512,8 @@
padding: 0.34rem 0.65rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary);
background: var(--bg-secondary);
border: none;
border-radius: 999px;
cursor: pointer;
@@ -523,7 +523,7 @@
.sectionNavAll:hover {
background: var(--border-color, #e5dfd5);
background: var(--border);
}
@@ -531,7 +531,7 @@
position: fixed;
inset: 0;
z-index: 1500;
background: rgba(26, 22, 18, 0.28);
background: rgba(var(--shadow-rgb), 0.28);
}
@@ -543,19 +543,19 @@
width: 230px;
max-height: min(70vh, 460px);
overflow-y: auto;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 18px 44px rgba(26, 22, 18, 0.2);
box-shadow: 0 18px 44px rgba(var(--shadow-rgb), 0.2);
padding: 0.35rem;
}
.sectionsPanelHead {
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
font-family: var(--font-display);
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
color: var(--text-primary);
padding: 0.4rem 0.6rem 0.5rem;
}
@@ -568,44 +568,44 @@
padding: 0.55rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
color: var(--text-secondary);
text-decoration: none;
transition: background 0.12s ease;
}
.sectionsItem:hover {
background: var(--bg-secondary, #f3ede4);
color: var(--text-primary, #1a1612);
background: var(--bg-secondary);
color: var(--text-primary);
}
.sectionsItemActive {
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
color: var(--accent-coral-dark, #c45a3f);
background: var(--brand-bg);
color: var(--brand-strong);
font-weight: 600;
}
.sectionsTick {
color: var(--accent-coral-dark, #b04a2e);
color: var(--brand-strong);
}
/* GIAS "Open, but proposed to close" notice strip */
.closingStrip {
background: #fdf6e3;
border-left: 4px solid #e2c96f;
background: var(--status-below-bg);
border-left: 4px solid var(--status-below-bg);
border-radius: 0 6px 6px 0;
padding: 0.55rem 0.9rem;
margin: 0.5rem 0;
font-size: 0.88rem;
color: #6e5a00;
color: var(--status-below);
max-width: 68ch;
}
.closingStrip strong {
color: #8a6200;
color: var(--status-below);
}
@media (max-width: 640px) {
@@ -738,7 +738,7 @@
max-height: 74vh;
border-radius: 16px 16px 0 0;
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom, 0));
box-shadow: 0 -10px 40px rgba(26, 22, 18, 0.25);
box-shadow: 0 -10px 40px rgba(var(--shadow-rgb), 0.25);
}
.sectionsItem {
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
/**
* Bridge between the CSS token layer and the bits of the UI that are painted
* from JavaScript — Chart.js datasets and Leaflet markers.
*
* Those two can't use var(), so before this they carried their own hardcoded
* hexes. That is how the chart palette drifted off-brand (a stray purple and
* Flat UI's stock blue) and why charts would have ignored the dark theme
* entirely. Reading the computed custom property keeps one source of truth.
*/
'use client';
import { useEffect, useState } from 'react';
export type ThemeToken =
| '--brand'
| '--brand-strong'
| '--brand-bg'
| '--status-above'
| '--status-above-bg'
| '--status-below'
| '--status-below-bg'
| '--text-primary'
| '--text-secondary'
| '--text-muted'
| '--text-inverse'
| '--bg-card'
| '--bg-secondary'
| '--border'
| '--border-strong'
| '--surface-inverse'
| '--chart-1'
| '--chart-2'
| '--chart-3'
| '--chart-4'
| '--chart-5'
| '--chart-6'
| '--chart-grid'
| '--chart-reference'
| '--phase-primary'
| '--phase-secondary-text'
| '--medal-gold'
| '--medal-silver'
| '--medal-bronze';
/** Values used before hydration and in any non-DOM context (SSR, tests). */
const FALLBACK: Record<string, string> = {
'--brand': '#0F766E',
'--brand-strong': '#0C5F58',
'--brand-bg': 'rgba(15, 118, 110, 0.10)',
'--status-above': '#36743F',
'--status-above-bg': 'rgba(54, 116, 63, 0.12)',
'--status-below': '#A9481F',
'--status-below-bg': 'rgba(249, 115, 96, 0.15)',
'--text-primary': '#1C2731',
'--text-secondary': '#4A5560',
'--text-muted': '#5F6A75',
'--text-inverse': '#FFFFFF',
'--bg-card': '#FFFFFF',
'--bg-secondary': '#F5EFE6',
'--border': '#E5E7EB',
'--border-strong': '#D3D7DD',
'--surface-inverse': '#0F766E',
'--chart-1': '#0F766E',
'--chart-2': '#36743F',
'--chart-3': '#806200',
'--chart-4': '#A9481F',
'--chart-5': '#2F6F8F',
'--chart-6': '#5F4FA8',
'--chart-grid': '#E5E7EB',
'--chart-reference': '#5F6A75',
'--phase-primary': '#0F766E',
'--phase-secondary-text': '#2A6180',
'--medal-gold': '#806200',
'--medal-silver': '#6B7580',
'--medal-bronze': '#8A5A2B',
};
/** Read one token. Safe to call during SSR — returns the light-theme value. */
export function token(name: ThemeToken): string {
if (typeof window === 'undefined') return FALLBACK[name];
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || FALLBACK[name];
}
/**
* Same as `token`, but the component re-renders when the OS theme flips so
* canvas-painted colours don't get stranded in the previous theme.
*/
export function useThemeTokens<T extends readonly ThemeToken[]>(
...names: T
): { [K in keyof T]: string } {
const read = () => names.map((n) => token(n)) as { [K in keyof T]: string };
const [values, setValues] = useState(read);
useEffect(() => {
// Only commit when something actually changed. In the light theme the
// computed values equal the fallbacks, so this is a no-op — which matters
// because a state update here re-renders the chart and makes Chart.js
// resize a canvas that may not be laid out yet.
const sync = () => setValues((prev) => (sameValues(prev, read()) ? prev : read()));
sync();
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
// `names` is a fixed-length literal tuple at every call site.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return values;
}
function sameValues(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
const SERIES = [
'--series-1', '--series-2', '--series-3', '--series-4',
'--series-5', '--series-6', '--series-7', '--series-8',
] as const;
const SERIES_FALLBACK = [
'#0F766E', '#36743F', '#806200', '#A9481F',
'#2F6F8F', '#5F4FA8', '#0E7A86', '#8A4A6B',
];
/**
* Resolved series colours for Chart.js datasets, in the same index order as
* CHART_COLORS — so a school keeps its colour between the canvas and the DOM
* swatch beside it. Re-reads when the OS theme flips.
*/
export function useSeriesColors(): string[] {
const read = () =>
typeof window === 'undefined'
? SERIES_FALLBACK
: SERIES.map((n, i) => {
const v = getComputedStyle(document.documentElement).getPropertyValue(n).trim();
return v || SERIES_FALLBACK[i];
});
const [colors, setColors] = useState(read);
useEffect(() => {
// See useThemeTokens: skip the commit when nothing changed, so mounting a
// chart doesn't cost an extra render.
const sync = () => setColors((prev) => (sameValues(prev, read()) ? prev : read()));
sync();
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
}, []);
return colors;
}
/** rgba() built from a token's hex, for fills that need their own alpha. */
export function alpha(name: ThemeToken, a: number): string {
const hex = token(name);
const m = /^#([0-9a-f]{6})$/i.exec(hex);
if (!m) return hex;
const n = parseInt(m[1], 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
}
+20 -4
View File
@@ -444,11 +444,27 @@ export interface MetricsResponse {
metrics: MetricDefinition[];
}
/**
* GET /api/data-info.
*
* The school count field is `unique_schools`. This interface declared
* `total_schools` instead — a field the API has never sent — so every read of
* it was `undefined` at runtime while type-checking cleanly, and the landing
* page silently fell back to a hardcoded "24,000+" against a real 27,230.
* Nothing threw and no test failed, because the interface, not the code, was
* the thing that was wrong. Keep these names matched to backend/app.py.
*/
export interface DataInfoResponse {
total_schools: number;
years_available: number[];
latest_year: number;
total_records: number;
status: 'loaded' | 'no_data';
data_source: string;
/** Absent on the `no_data` branch — see backend/app.py:1015. Optional here
* so callers are forced to handle the case instead of reading undefined. */
unique_schools?: number;
years_available?: number[];
total_records?: number;
schools_per_year?: Record<string, number>;
local_authorities?: Record<string, number>;
message?: string;
}
// ============================================================================
+40 -30
View File
@@ -273,17 +273,29 @@ export function debounce<T extends (...args: any[]) => any>(
// ============================================================================
/**
* Chart color palette (consistent with vanilla JS app)
* Series palette for comparing up to eight schools at once.
*
* These were Chart.js's stock demo colours, which is why the comparison view
* never looked like the rest of the site. They now point at the --series-*
* tokens, so the palette lives in one place and follows the theme.
*
* Every step clears WCAG AA on both grounds, so the same value works as a
* chart line and as the legend text keyed to it — there is no longer a
* separate text ramp to keep in sync.
*
* These are var() strings: correct anywhere the value lands in the DOM
* (inline styles, CSS custom properties). Canvas can't resolve var(), so
* Chart.js datasets must use `useSeriesColors()` from lib/theme instead.
*/
export const CHART_COLORS = [
'rgb(75, 192, 192)',
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 206, 86)',
'rgb(153, 102, 255)',
'rgb(255, 159, 64)',
'rgb(201, 203, 207)',
'rgb(255, 0, 255)',
'var(--series-1)',
'var(--series-2)',
'var(--series-3)',
'var(--series-4)',
'var(--series-5)',
'var(--series-6)',
'var(--series-7)',
'var(--series-8)',
];
/**
@@ -294,44 +306,42 @@ export function getChartColor(index: number): string {
}
/**
* Darker counterparts of CHART_COLORS for use as TEXT on the site's light
* backgrounds — the raw series colours fail WCAG AA (e.g. 1.9:1 on cream).
* Same hue per index so text still keys to its chart line. All ≥4.5:1 on
* #f3ede4 and lighter.
* @deprecated The series palette is now AA on both grounds, so line and text
* share one value. Kept as an alias so call sites can migrate incrementally.
*/
export const CHART_TEXT_COLORS = [
'#256868', // teal
'#c2255c', // pink/red
'#2069ad', // blue
'#8a5a00', // yellow → ochre
'#6d43c8', // purple
'#8f4e00', // orange → brown
'#5f5952', // grey
'#a300a3', // magenta
];
export const CHART_TEXT_COLORS = CHART_COLORS;
export function getChartTextColor(index: number): string {
return CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length];
}
/**
* Convert RGB color to RGBA with opacity
* Add opacity to a colour. Accepts `rgb(...)` or `#rrggbb` — the series
* palette resolves to hex now that it comes from the token layer, but the
* rgb() form is still used by callers passing Chart.js literals.
*/
export function rgbToRgba(rgb: string, alpha: number): string {
return rgb.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
export function rgbToRgba(color: string, alpha: number): string {
const hex = /^#([0-9a-f]{6})$/i.exec(color.trim());
if (hex) {
const n = parseInt(hex[1], 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
}
/**
* Get trend color based on direction
* Trend direction colour. Teal/amber rather than green/red so the signal
* survives red–green colour blindness; `at` is deliberately neutral so a
* school sitting on the average doesn't read as a verdict.
*/
export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
switch (trend) {
case 'up':
return '#22c55e'; // green
return 'var(--status-above)';
case 'down':
return '#ef4444'; // red
return 'var(--status-below)';
case 'stable':
return '#6b7280'; // gray
return 'var(--status-at)';
}
}
+13 -1
View File
@@ -3,6 +3,15 @@ const nextConfig = {
// Enable standalone output for Docker
output: 'standalone',
// app/opengraph-image.tsx reads the Schibsted Grotesk files off disk at
// request time (Satori needs a font buffer; it has no system fallback).
// File tracing currently picks these up on its own, but that relies on the
// tracer resolving a runtime join() — declare them so a Next upgrade can't
// silently drop them and turn every link preview into a 500.
outputFileTracingIncludes: {
'/opengraph-image': ['./assets/**'],
},
// The /api/* and /sitemap.xml proxies to the FastAPI backend are route
// handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather
// than rewrites, so the backend host is read from FASTAPI_URL at runtime
@@ -72,7 +81,10 @@ const nextConfig = {
],
},
{
source: '/favicon.svg',
// The mark is now the supplied raster artwork, so the favicon is
// app/icon.png rather than an SVG. Pointing this at the old path was
// caching a 404.
source: '/icon.png',
headers: [
{
key: 'Cache-Control',
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#1a1612"/>
<circle cx="20" cy="20" r="14" stroke="#e07256" stroke-width="2"/>
<path d="M20 8L20 32M12 14L28 14M10 20L30 20M12 26L28 26" stroke="#e07256" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="20" cy="20" r="3" fill="#e07256"/>
</svg>

Before

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+26 -7
View File
@@ -1,16 +1,35 @@
{
"name": "SchoolCompare",
"short_name": "SchoolCompare",
"name": "schoolcompare",
"short_name": "schoolcompare",
"description": "Compare primary and secondary school performance across England",
"start_url": "/",
"display": "standalone",
"background_color": "#faf7f2",
"theme_color": "#faf7f2",
"background_color": "#FAFAF8",
"theme_color": "#0F766E",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
"src": "/icon.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}