Files
school_compare/docs/superpowers/specs/2026-07-02-ux-audit-report.md
TudorandClaude Fable 5 f2b71b67d4 docs(audit): correct postcode finding — full postcodes do trigger proximity search
Recheck with 'B91 3DL' via the search box shows the geocoded radius
search works (distances, nearest-first, radius selector, map toggle).
Original P0.3 tested only a partial postcode, which silently falls back
to text search — refiled as P2.0 (silent mode switch on partial input).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:01:02 +01:00

216 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SchoolCompare UX/UI Audit — 2026-07-02
## Method summary
Journey-led walk-through of the **live site** (schoolcompare.co.uk) via Playwright browser tools, at two viewports: **390×844 mobile (primary — 56% of traffic)** and **1440×900 desktop** (720×450 used as the 200%-zoom reflow proxy). Five journeys in traffic order — home→find-school (63% of entries), cold landing on a school page (SEO long tail), building a comparison (27% of views), rankings→shortlist (12%), admissions (5%, light pass) — followed by a cross-cutting cohesion pass comparing computed typography, colour, and component styles across all six page types, verified against `nextjs-app` source to distinguish "token exists but bypassed" from "no token exists".
Every page state visited was scanned with **axe-core 4.10.2** (WCAG 2.2 A/AA rule set) plus manual checks: touch targets ≥24px (2.5.8), keyboard order and focus visibility, and 200% reflow. A finding is included only if it cites a Nielsen heuristic, a WCAG 2.2 AA failure, a mobile-usability standard, or an observed task-flow obstruction — no taste-only findings. Priorities are traffic-weighted using the 30-day analytics baseline: entries `/` 63% / `/compare` 20% / `/rankings` 6%; exits `/` 46% / `/compare` 32% / `/rankings` 13%; 56% mobile. Uplift bands are honest small/moderate/large indications — there is no funnel instrumentation to support percentages.
Full evidence trail: `docs/superpowers/specs/2026-07-02-ux-audit-notes/` (six notes files; screenshots referenced by filename live in the session scratchpad, not committed).
## What works today — keep
These are strengths the fixes below must not regress:
- **Name search is genuinely excellent.** "Welland Primary" → the correct school as sole top result, in 3 taps with instant loads — comfortably under the 15s target. Result cards are information-rich (Ofsted grade+year, RWM % with trend arrow, "+12 pts vs national", pupil count, LA) and adapt correctly to phase (Attainment 8 for secondary, RWM for primary).
- **Full-postcode proximity search works, and works well** *(verified on 2026-07-02 recheck)* — typing a full postcode (e.g. `B91 3DL`) into the home search box yields "13 schools within 1.0 miles of B91 3DL", distance on every card, nearest-first ordering, a 0.55 mile radius selector, and a List/Map toggle; the "Schools near me" geolocation button offers the same without typing. Only partial input degrades (see P2.0).
- **Search is the unmistakable primary action** — above the fold on both viewports, 70px tap target, plain-English placeholder, content order puts the task first.
- **The primary (KS2) school page explains its data in plain English** — "End-of-primary-school tests taken by Year 6 pupils", the in-place "Why is combined lower?" explainer, legends on subject charts. This is the model the secondary template should copy.
- **Everything is anchored to the national average** ("+14 pts", "National avg 39.1") — a non-specialist can judge good/bad without leaving the page.
- **Graceful, honest missing-data handling** — COVID-cancellation notes, "contact the admissions authority" for absent cut-offs; no broken or empty UI anywhere data is missing.
- **Ofsted sections are clear, current, and sourced** — grade + inspection date, the post-Sept-2024 grading-change note, link to the real Ofsted report.
- **Compare's empty state is instructive and actionable** — "No schools selected" + "+ Add Schools to Compare" opening an in-page search modal; the 20% who enter cold can start immediately. The modal supports efficient multi-add (stays open, clears field) and accepts postcodes.
- **State persistence is strong across the site** — compare selections survive navigation (localStorage) and are URL-encoded/deep-linkable; rankings restores filter, metric, *and* scroll position on back-navigation (the classic mobile task-killer check, passed cleanly).
- **Compare's metric selector carries a plain-English caption** — jargon defined at point of use; the chart legend labels series by colour *and* name (WCAG 1.4.1 satisfied).
- **Accessibility fundamentals are partly in place** — working skip link, logical tab order everywhere, strong 2px orange focus outlines on links/buttons, real `<table>` semantics on rankings, properly labelled rankings filters, clean heading hierarchy on admissions, no horizontal scroll at 200% reflow on any page tested.
- **Admissions content is well-integrated** — reachable in one tap from anywhere, surfaced proactively on the homepage, with 12 stage-tailored cross-links into Search/Compare/Rankings that read as genuinely contextual.
- **Cohesion anchors:** header/footer pixel-identical on all six pages; coral=primary / teal=secondary held without role collisions; rankings and compare share one phase-tab treatment; the deadline chip is byte-for-byte reused between home and admissions.
## Findings
### P0 — Urgent
- **P0.1 — Colour-contrast failures on every main route, including primary data and primary CTAs** *(merges J1-F4, J2-F4, J3-F6, J4-F1, J5-F1)*
- Evidence: axe `color-contrast` (serious) on **all five routes**: home 6 nodes (active nav tab, `.btn`, how-it-works text) + 3 on results (`.btn-primary`, Ofsted badge); school template **13 nodes** (Back button, "View on map", active metric tab); compare 5 nodes (`.btn-primary`, active phase/nav tabs); rankings **100 nodes on desktop** — the teal metric values (`.valueCell strong`) in every row plus "+ Compare" links; admissions 13 nodes including the **deadline countdown chips**. Screenshots `j4-rankings-desktop.png`, `j5-admissions-mobile.png`.
- Criterion: WCAG 2.2 **1.4.3 Contrast (Minimum), AA** — failing on every main page.
- Argument: this is not chrome-only. On rankings the failing text is the ranking score itself — the entire reason a parent is on the page; on admissions it is the days-until-deadline chip — the page's most load-bearing fact; on home/compare it is the primary CTA styling. The mobile-primary audience reads this outdoors on phones, where low contrast bites everyone, not just low-vision users.
- Recommendation: adjust the failing token values once (`--accent-coral` on light text pairings, the teal-on-cream value colour, tint-background label colours) and re-run axe on all five routes. This is a token-level fix, not per-page work.
- Uplift: **accessibility compliance — large improvement** (clears the single biggest violation class, present on 100% of audited pages); secondary small positive effect on all engagement metrics via legibility for the 56% mobile share.
- **P0.2 — School name (H1) is obscured by the map and the "+ Add to Compare" button on mobile — systemic to the school template** *(J2-F1)*
- Evidence: measured bounding boxes on both audited schools — Castle: map bottom 280px / H1 top 272px / compare-button top 286px / H1 bottom 309px; Welland: same pattern — leaving a ~6px legible sliver of the name. Screenshots `j2-school-mobile-fold.png`, `j2-welland-mobile-full.png`. Desktop is unaffected.
- Criterion: Nielsen #1 (visibility of system status) and #8; task-flow obstruction of cold-landing orientation; defeats the intent of WCAG 1.4.10 (content obscured at the primary viewport).
- Argument: school pages are the SEO landing template. A parent arriving from Google asks one question first — "is this the school I searched for?" — and the element that answers it is illegible on the majority viewport. This is the exact first-screen failure that produces an immediate back-to-Google bounce, on the template whose entire job is converting search traffic.
- Recommendation: restack the mobile hero so map, H1, and compare button occupy non-overlapping space (e.g. compare button below the identity block or docked, map height capped above the H1). One template, both school-detail components (see P2.6).
- Uplift: **school-page bounce-back-to-Google — decrease, moderate** (fixes the first-glance orientation on every SEO landing; the page content behind it is already strong, so retention should follow).
### P1 — High
- **P1.1 — Filter and Sort selects on home results have no accessible name** *(J1-F3)*
- Evidence: axe `select-name` (impact **critical**, 2 nodes) on `/?search=…` — the phase filter and sort selects. (Borderline P0 by the "clear WCAG failure on a main page" rule; held at P1 because it affects one page state and two controls.)
- Criterion: WCAG 2.2 **4.1.2 Name, Role, Value, Level A**.
- Argument: these are the controls a parent uses to narrow a mixed result set to "Primary" — a screen-reader user hears two anonymous comboboxes on the main journey's results view. Rankings labels its identical selects correctly, so this is an omission, not a pattern.
- Recommendation: associate visible `<label>`s (or `aria-label`) exactly as `/rankings` already does.
- Uplift: **accessibility compliance — moderate** (removes the only *critical*-impact violation found); minutes of effort.
- **P1.2 — School pages offer no "nearby / similar schools" path — the compare value proposition has no on-ramp** *(J2-F2, corroborated by J3 tap-count)*
- Evidence: full-page snapshots of both school pages show no nearby/similar module (sections end Wellbeing → footer). Building a real 2-school comparison from a school page requires Compare → "+ Add School" → **manual name search** — 6 interactions total, and the parent must already know the competitor's name (`j3-compare-two-schools-mobile.png`, `j2-compare-oneschool-mobile.png`).
- Criterion: Nielsen #7 (flexibility & efficiency); task-flow obstruction — the site's differentiator is unreachable from its SEO entry template without prior knowledge.
- Argument: a parent cold-landing on one school has maximum comparison intent and zero alternatives on screen. The site sends them back to Google to discover the other local schools — Google then keeps them.
- Recommendation: add a "Schools nearby" module to the detail template (nearest same-phase schools by lat/long — data already exists) with per-row "+ Compare"; the same list can power an in-modal "nearby" tab on `/compare`.
- Uplift: **school-page bounce-back-to-Google — decrease, moderate**, and **compare 32% exit — decrease, small-to-moderate** (creates the missing bridge between the two biggest content surfaces).
- **P1.3 — Shared compare links are silently overridden by the recipient's own localStorage** *(J3-F4)*
- Evidence: navigating to `/compare?urns=142161,113105` with a different stored selection resolved the page and URL back to the stored set; the shared schools never appeared. Reproduced; only clearing localStorage restored the link's intent.
- Criterion: Nielsen #1 (visibility of system status); task-flow obstruction of an advertised primary feature (the Share button).
- Argument: 20% of sessions *enter* on `/compare`, many via shared links. A parent shares a shortlist with a partner; if the partner has ever used compare, they silently see their own old schools with no notice — the collaboration breaks and neither party knows why.
- Recommendation: explicit URL params must win over localStorage (persist *after* honouring the URL); optionally prompt "Replace your saved comparison with the shared one?".
- Uplift: **compare 32% exit rate — decrease, small-to-moderate** (repairs a confusing dead end at a high-intent entry point; band limited by the shared-link share of entries being unmeasured).
- **P1.4 — Trend/results charts expose no text alternative to assistive tech** *(merges J2-F5, J3-F3)*
- Evidence: axe `role-img-alt` (serious) on the `<canvas>` on both school pages and populated `/compare`; `aria-label` is null at both viewports.
- Criterion: WCAG 2.2 **1.1.1 Non-text Content, Level A** — on the two main content templates.
- Argument: the site's key visualisation — performance over time — announces nothing to a screen-reader parent. Compare's data table partially mitigates; the school-page trend chart has only a collapsed raw-data expander.
- Recommendation: generate a descriptive `aria-label` per chart ("Attainment 8, 20192025: school 53.4 vs national 39.1, school above national every year") — the data is already in the component; one shared chart wrapper fixes all instances.
- Uplift: **accessibility compliance — moderate** (clears a Level-A class on the highest-traffic templates).
- **P1.5 — Horizontally-scrollable regions are keyboard-inaccessible site-wide** *(merges J1-F5(a), J3-F2, J4-F4)*
- Evidence: axe `scrollable-region-focusable` (serious) on the home countdown rail, the compare detailed-table wrapper, and (manually confirmed, same pattern) the rankings table wrapper — `overflow-x: auto` with no `tabindex="0"`, no role/label.
- Criterion: WCAG 2.2 **2.1.1 Keyboard, Level A**.
- Argument: keyboard-only users literally cannot reach the off-screen columns — which on compare and rankings contain the actual comparison data (see P2.1). Three instances of one missing pattern.
- Recommendation: `tabindex="0"` + `role="region"` + `aria-label` on every overflow wrapper; one shared wrapper component prevents recurrence.
- Uplift: **accessibility compliance — moderate**; unlocks the data for keyboard users wherever P2.1's layout still requires scrolling.
- **P1.6 — Search offers no autocomplete/typeahead** *(J1-F1)*
- Evidence: typing "Welland Primary" (both viewports) produced no suggestions at any keystroke — raw input until submit. The compare modal's live-results search proves the capability exists in the codebase.
- Criterion: Nielsen #6 (recognition rather than recall); NN-g site-search guidance; error prevention.
- Argument: parents rarely know a school's exact registered name ("Welland" vs "Welland Church of England…"). With no suggestions, a misspelling means a zero-result dead end on the 63%-of-entries path, and every search costs full typing plus a blind submit.
- Recommendation: reuse the compare modal's live-search behaviour on the home search box (debounced suggestions: school name + LA, and a "search this postcode" row for postcode-shaped input).
- Uplift: **home 46% exit rate — decrease, moderate** and **share of sessions reaching a school page — increase, moderate** (assists the majority entry path at its first interaction).
- **P1.7 — The mobile hero omits the value proposition entirely** *(J1-F6)*
- Evidence: desktop shows the "UPDATED WITH 2026/2027 ADMISSIONS RESULTS" trust badge and the "24,000+ schools… side by side, in one place" subheading; mobile renders only the poetic H1 ("Every school in England, *compared.*") and a bare search box (`j1-home-desktop-fold.png` vs `j1-home-mobile-fold.png`).
- Criterion: mobile content parity; Nielsen #1 — first-visit orientation ("what is this, why trust it") absent on the primary viewport.
- Argument: 63% of entries land here and 56% of traffic is mobile; a first-time visitor gets no statement of coverage, data source, or freshness above the fold. Weak value proposition at first glance is a classic bounce driver and plausibly a material slice of the 46% exit rate.
- Recommendation: restore a compact version of the badge + one-line value prop under the mobile H1 (one text block; the fold has room above the deadline rail).
- Uplift: **home 46% exit rate — decrease, small-to-moderate** (copy-only change aimed squarely at first-impression exits).
### P2 — Medium
- **P2.0 — Partial or malformed postcode input silently falls back to text search with no distances** *(J1-F2, corrected on 2026-07-02 recheck — the original P0 claim that postcode search lacks proximity was wrong: a full postcode works, see "What works today")*
- Evidence: the search submit handler routes input through a strict full-postcode regex (`FilterBar.tsx` `isValidPostcode()`). `B91 3DL``/?postcode=…&radius=1`: "13 schools within 1.0 miles", distance on every card, nearest-first, radius selector, map toggle (`j1-fullpostcode-results-mobile.png`). But `B91 3` (an outcode+sector a parent plausibly types) → `/?search=B91+3`: text-matched, relevance-ordered, distance-less, mixed-phase results led by a secondary school (`j1-postcode-results-mobile.png`) — with no notice that a different, better mode exists.
- Criterion: Nielsen #1 (visibility of system status — silent mode switch) and #9 (help users recognise and recover); error tolerance for the "or postcode" promise in the placeholder.
- Argument: the two result pages look near-identical, so a parent who types "B91" or fat-fingers a character never learns that proximity search exists; they just get an apparently arbitrary list on the highest-traffic path.
- Recommendation: detect postcode-*shaped* input that fails full validation (outcode/sector patterns, near-miss typos) and either geocode it anyway (postcodes.io supports outcodes) or show an inline nudge — "Enter a full postcode like B91 3DL to see schools by distance."
- Uplift: **home 46% exit rate — decrease, small-to-moderate** (recovers the postcode-search subset who type partial input; the full-postcode path already works, which caps the band).
- **P2.1 — Mobile data tables hide the decision-critical columns behind unaffordanced horizontal scroll** *(merges J3-F1, J4-F3)*
- Evidence: compare's "Detailed Comparison" table is 626px inside a 324px wrapper — only Year + the first school's column visible, no scroll cue (`j3-compare-two-schools-mobile.png`); rankings shows RANK/SCHOOL/AREA but the metric % (the ranking's justification) sits off-screen (scrollWidth 551 > 324) (`j4-rankings-mobile.png`).
- Criterion: mobile-usability standard (primary content hidden off-viewport); Nielsen #6 — the user must memorise school A's numbers while scrolling to school B.
- Argument: on compare the side-by-side promise is defeated on the majority viewport (a parent may believe the second school's data is missing); on rankings the list shows names without the scores that rank them. Softened by compare's stacked metric cards, which do show both schools — hence P2 not P1.
- Recommendation: on ≤390px, prioritise the metric column next to the school name (rankings) and use a per-metric stacked layout or sticky first column + visible scroll affordance (compare).
- Uplift: **compare 32% exit — decrease, small**; **rankings→school click-through — increase, small**.
- **P2.2 — Rankings on mobile removes the View/+Compare actions** *(J4-F2)*
- Evidence: the Action column `td` computes `display:none` at 390px; shortlisting from rankings is desktop-only.
- Criterion: task-flow obstruction (the journey is rankings → *shortlist*); Nielsen #7.
- Argument: a parent on a phone scanning the good local schools cannot build a comparison set from the list — each school needs a page visit and a different route in, on the majority device class.
- Recommendation: keep a compact "+" compare affordance per row on mobile (the bottom-tab badge already communicates basket state).
- Uplift: **rankings→school click-through / compare usage — increase, small-to-moderate** (restores the journey's intended endpoint on mobile).
- **P2.3 — Metric jargon unexplained at the point of first contact** *(merges J2-F3, J4-F5)*
- Evidence: secondary school pages show "Attainment 8 score 53.4", "EBacc average point score 4.72" with zero definitions/tooltips (the plain-English definition exists — but only on `/compare`); rankings *defaults* to "higher standard" (greater depth) without explaining it, so the top school's headline reads "63.0%" in the terms parents least understand (`j2-school-mobile-gcse.png`).
- Criterion: Nielsen #2 and #10.
- Argument: a non-specialist cannot judge whether 53.4 or 4.72 is good; a parent may misread 63% at higher standard as mediocre when it is exceptional. The site owns the explanations and withholds them at first contact. The primary KS2 page proves in-place explanation works.
- Recommendation: surface the existing `/compare` metric definitions on school pages (caption or expander beside each figure); default rankings to "expected standard" with "higher standard" as an explained option.
- Uplift: **school-page bounce-back-to-Google — decrease, small-to-moderate** (comprehension is the retention lever for the secondary template).
- **P2.4 — Add-school modal lacks dialog semantics and drops focus** *(J3-F5)*
- Evidence: no `role="dialog"`/`aria-modal`; after a keyboard add, focus lands on `document.body`; after Escape-close, focus is on `body`, not the trigger.
- Criterion: WCAG 2.2 **4.1.2 (A)** + **2.4.3 Focus Order (A)**.
- Argument: screen-reader users aren't told a dialog opened; keyboard users re-tab from the page top after every add — compounding friction on the flow every comparison depends on.
- Recommendation: dialog role + focus trap + focus restoration to the trigger on close; keep focus in the search field after an add (the multi-add UX is otherwise good).
- Uplift: **accessibility compliance — moderate**.
- **P2.5 — School-page sticky section nav clips its last items on mobile** *(J2-F7)*
- Evidence: at 390px, "History" and "Wellbeing" render off-screen; the 448px nav row is clipped, not scrollable (document scrollWidth stays 390) (`j2-school-mobile-gcse.png`).
- Criterion: mobile usability — interactive content unreachable in-viewport; Nielsen #7.
- Argument: 2 of 6 jump links are dead weight on the primary viewport; parents hunting SEN/Wellbeing data must scroll-hunt instead.
- Recommendation: make the nav row horizontally scrollable with an overflow affordance (fade/chevron), or wrap to two lines.
- Uplift: **school-page bounce-back-to-Google — decrease, small**.
- **P2.6 — Two parallel school-detail components have drifted; a duplicate `.btn` rule makes button geometry unpredictable** *(merges cohesion F1, F2)*
- Evidence: `SchoolDetailView` vs `SecondarySchoolDetailView` hardcode divergent values for the same controls (`.btnAdd` radius 8/pad 12×20 vs radius 6/pad 8×16; badge radii 4 vs 3; tab padding differs). Separately `globals.css` defines `.btn` **twice** (lines 151 and 1558) with `.btn-sm` declared between them — source order makes the second definition override `.btn-sm`, so "small" buttons render at 12×24px padding (live-verified on rankings).
- Criterion: Nielsen #4 (consistency & standards); maintenance hazard.
- Argument: parents moving between primary and secondary school pages (the compare flow mixes phases) meet the same controls at subtly different sizes/corners; and any future button edit has a 50/50 chance of landing in the dead rule.
- Recommendation: dedupe `.btn` to one definition (restoring `.btn-sm`); converge both school-detail modules on shared tokens (`--radius-md`), ideally one shared component.
- Uplift: **school-page bounce-back-to-Google — decrease, small** (visual consistency is a trust signal on the template that must convert cold traffic); also removes a recurring source of future drift.
- **P2.7 — Design tokens exist but are bypassed system-wide** *(merges cohesion F3, F4, F5)*
- Evidence: **15 distinct raw radius values** in module CSS against a 4-token scale (no pill token for the 21× `999px` uses); **four different H1 sizes** across five pages (36/44/48/52px), each a separate hardcoded `clamp()`; the "switch view/phase" control has **three different treatments** (rounded segmented on home, square tabs on rankings/compare, underline strip on admissions).
- Criterion: Nielsen #4.
- Argument: corner rounding, title scale, and segmented controls are the primary "one system" cues; the drift reads as many hands and erodes the data-source credibility the site trades on. (Rankings↔compare already share one tab treatment — the model to extend.)
- Recommendation: token-enforcement pass (map raw radii to tokens; add `--radius-pill`), create hero/section title tokens, consolidate the segmented control on the rankings/compare pattern.
- Uplift: **home 46% exit rate — decrease, small** (a visibly coherent system reads as a credible data source at first glance); primarily protects future velocity.
- **P2.8 — Two admissions cross-link CTA variants are below the 24px touch-target minimum** *(J5-F2)*
- Evidence: "Build and compare your shortlist →" and "Look up your allocated school →" measure **21px** tall vs 42px for the other four CTA variants on the same page (getBoundingClientRect).
- Criterion: WCAG 2.2 **2.5.8 Target Size (Minimum), AA** (rendered as standalone CTAs, so the inline-text exception is doubtful).
- Argument: these are the conversion path the admissions content exists to support; a 3px-under-spec target inconsistent with siblings adds mis-tap friction for a stressed parent. (Admissions-only, but a WCAG failure, so it stays P2 per the prioritisation rules.)
- Recommendation: apply the 42px CTA style to all twelve cross-links.
- Uplift: **accessibility compliance — small**.
### P3 — Nice-to-have
- **P3.1 — Mixed-phase selection reports "Comparing 2 schools" while rendering one** *(J3-F7)* — the second school hides behind a "Secondary (1)" tab with no explanation (Nielsen #1/#2). Add one line of copy ("Shown separately — KS2 and GCSE metrics differ"). Uplift: compare exit, small.
- **P3.2 — Compare remove (×) is instant with no undo** *(J3-F8)* — accidental tap on the 28px target silently loses a searched-for school (Nielsen #3). Add a brief undo toast. Uplift: compare exit, small.
- **P3.3 — "Add to Compare" gives no toast/live-region confirmation** *(J2-F8)* — feedback is only a button relabel in the (currently overlapped) hero zone plus a small tab badge; no `role=status` fires (Nielsen #1). Largely mitigated once P0.2 lands. Uplift: compare usage, small.
- **P3.4 — Inconsistent focus indicators on form controls** *(merges J1 search-input note, J4-F8)* — filter selects suppress the outline for a faint 1.5px border hue shift; the home search input uses a 0.12-alpha ring — both far weaker than the site's standard 2px orange outline (WCAG 2.4.11 borderline). Apply the standard outline. Uplift: accessibility compliance, small.
- **P3.5 — Rankings table headers aren't sortable and lack `scope`** *(J4-F7)* — users expect ranked tables to sort on header click (Nielsen #4); `<th>` cells lack `scope`/`aria-sort` (WCAG 1.3.1 best practice). Uplift: rankings→school CTR, small.
- **P3.6 — 33-option metric dropdown is a flat jargon list** *(J4-F6)* — GPS/EAL/SEN abbreviations, ungrouped (Nielsen #6). Group by theme (Results / Progress / Cohort) with plain-language labels. Uplift: rankings→school CTR, small.
- **P3.7 — Home deadline rail defaults its scroll to the least-urgent card** *(J1-F5(b))* — the nearest deadline (121 days) hides behind less urgent ones (Nielsen #1). Order/scroll to soonest-first. Uplift: home exit, small.
- **P3.8 — School trend chart has no legend for the national series; badge overlaps a heading** *(J2-F6)* — the grey dashed line is unexplained (the primary page's charts *do* carry legends — inconsistent); "Nat avg 39.1" overlaps the section heading. Uplift: school-page bounce, small.
- **P3.9 — Residual colour/type token drift** *(cohesion F6, F7, F8)* — pressed-coral exists as three hexes (`#e07256`/`#c45a3f`/`#d4654a`), an off-token gold `#b8920e` on the school-page cross-link badge, H3s render in body font while H2s are Playfair, and school-detail tables use an 11/13px type scale vs 12/15px on rankings/compare. Fold into the P2.7 token pass. Uplift: school-page bounce-back-to-Google, decrease, small.
## Accessibility summary
Axe-core 4.10.2, WCAG 2.2 A/AA rule set. Desktop serves the same DOM/CSS, so mobile results apply at both viewports unless noted.
| Page / state | Viewport | Violations | Detail |
|---|---|---|---|
| `/` (initial) | 390×844 | 2 | `color-contrast` (serious, 6 nodes); `scrollable-region-focusable` (serious, 1) |
| `/?search=Welland+Primary` | 390×844 | 2 | `color-contrast` (serious, 3); **`select-name` (critical, 2)** |
| `/school/…castle` (secondary) | both | 2 | `color-contrast` (serious, 13); `role-img-alt` (serious, 1) |
| `/school/…welland` (primary) | 390×844 | 1 | `role-img-alt` (serious, 1) |
| `/compare` (empty) | 390×844 | 1 | `color-contrast` (serious, 2) |
| `/compare?urns=…` (2 schools) | 390×844 | 3 | `color-contrast` (serious, 5); `role-img-alt` (serious, 1); `scrollable-region-focusable` (serious, 1) |
| `/rankings` (default & filtered) | 390×844 | 1 | `color-contrast` (serious, 2) |
| `/rankings` | 1440×900 | 1 | `color-contrast` (serious, **100 nodes** — metric values in every row) |
| `/admissions` | 390×844 | 1 | `color-contrast` (serious, 13 — incl. deadline chips) |
**Manual checks:** Touch targets ≥24px — pass on all core paths (smallest: compare × at 28px, Back at 64×28) **except** two admissions CTAs at 21px (P2.8). Keyboard — order logical everywhere with a strong 2px outline on links/buttons; gaps: unlabelled home-results selects (P1.1), modal focus loss (P2.4), suppressed outlines on filter selects and the faint search-input ring (P3.4), keyboard-unreachable scroll regions (P1.5). 200% reflow — **pass on every page tested** (no horizontal scroll at 720px; tables retain contained scroll, a recognised 1.4.10 exception).
**Overall WCAG 2.2 AA posture:** not currently conformant. Four violation classes account for everything axe found — contrast (every route), missing chart text alternatives (Level A), keyboard-inaccessible scroll regions (Level A), unlabelled selects (Level A, critical) — plus manual findings on target size, focus appearance, and dialog semantics. All are pattern-level fixes; none require redesign. The foundations (skip links, tab order, semantics, reflow, labels on rankings) are solid, which is why the fix list is short relative to the violation node counts.
## Suggested implementation sequence
Batches ordered by uplift-per-effort; each is a plausible standalone follow-up project.
**Batch 1 — WCAG compliance sweep (small effort, large compliance uplift).**
P0.1 contrast token fixes; P1.1 label the two selects; P1.4 chart `aria-label`s via one shared wrapper; P1.5 `tabindex`/role on the three scroll wrappers; P2.4 dialog semantics + focus restoration; P2.8 42px CTAs on admissions; P3.4 standard focus outlines. Mostly CSS-variable and attribute changes; re-run the axe harness on all five routes as the acceptance check. Clears every axe violation class found.
**Batch 2 — School-template mobile fold (small effort, direct SEO-retention uplift).**
P0.2 un-overlap the H1/map/compare button; P2.5 scrollable section nav; P3.8 trend-chart legend + badge overlap; P3.3 add-confirmation live region. One template, all SEO landings.
**Batch 3 — Home search assistance (small-moderate effort, home-funnel lever).**
P1.6 autocomplete on the home search (reuse the compare modal's live search); P2.0 graceful handling of partial/malformed postcodes (geocode outcodes or nudge toward a full postcode — the full-postcode proximity search already works); P1.7 mobile value-prop line. Together these target the 46% home exit rate from three directions.
**Batch 4 — Discovery and sharing bridges (moderate effort, compare-funnel uplift).**
P1.2 "Schools nearby" module on school pages (feeds compare); P1.3 URL-over-localStorage precedence for shared links; P2.2 mobile +Compare on rankings rows; P3.1/P3.2 compare state-clarity nits.
**Batch 5 — Comprehension pass (small-moderate effort).**
P2.3 reuse `/compare` metric definitions on school pages and default rankings to "expected standard"; P2.1 mobile table layouts (sticky first column / stacked metrics); P3.5/P3.6 rankings sort affordance + grouped metric picker; P3.7 deadline-rail ordering.
**Batch 6 — Cohesion & token enforcement (housekeeping, protects velocity).**
P2.6 dedupe `.btn`, converge the two school-detail components; P2.7 radius/title/segmented-control tokenisation; P3.9 residual colour/type drift. Best done after Batches 12 so the new token values land once.