Removes the 'What Parents Say' section and all supporting elements:
Frontend:
- Drop the OfstedParentView type, the parent_view field, the survey
section and the 'X% would recommend' callouts in the primary and
secondary detail views, the Parents nav item, and the parent-view CSS.
Backend:
- Remove the FactParentView model, its loading in data_loader, and
parent_view from the school-details API response.
- Bump SCHEMA_VERSION to 6 and add an idempotent drop step
(DROP TABLE IF EXISTS marts.fact_parent_view) to the CLI migration;
add scripts/sql/drop_fact_parent_view.sql to apply directly to the
dbt-owned marts DBs on staging and prod.
Pipeline:
- Delete the stg_parent_view + fact_parent_view dbt models and their
source/schema entries, the tap-uk-parent-view Meltano extractor, and
the monthly Parent View DAG; drop it from the Dockerfile and the
staging bootstrap docs.
The rest of dbt (which builds every mart the app reads) is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compare view refetches /api/compare on the client after SSR; on any
failure the catch nulled comparisonData, destroying the working
SSR-provided chart. A transient error (or staging's broken external /api
proxy) should not blank a comparison the user is already viewing — keep
the existing data instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compare chart squashed clustered schools into a thin band (y pinned
0-100) under an in-chart title + per-school legend that ate ~40% of a
300px card, leaving converging lines indistinguishable on phones.
- Auto-fit the y-axis to the data on all viewports (computeYBounds in
lib/utils: padded + min-span for percentages, symmetric around 0 for
progress, fitted for scores; negative pct-named trend metrics are not
zero-clamped).
- Distinct point style per school (circle/triangle/rect/rectRot/star)
as secondary encoding for convergence and colour-blindness.
- Mobile: drop in-chart title/legend/axis titles; add a chip row (colour
dot + name) that doubles as tap-to-focus — highlights one school's
line and dims the rest. Chart card 300px -> 340px, nearly all plot.
- Fix a latent colour mismatch: datasets were built from Object.entries
whose integer-like URN keys enumerate in ascending numeric order,
desyncing line colours from card colours; the chart now receives the
ordered school list.
- Union years across schools instead of taking the first school's.
- Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile.
Unit tests for metricKind/computeYBounds; e2e journey covers the mobile
chips and focus toggle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iOS Safari has no Element.requestFullscreen (fullscreen is video-only),
so tapping the map band or 'View on map' silently did nothing on
iPhones. Fall back to a fixed-position CSS overlay driven by state when
the Fullscreen API is missing or its promise rejects, locking body
scroll while open. The Leaflet map already re-measures via the shared
isFullscreen flag. New e2e journey simulates the iOS condition by
deleting the API and asserts the overlay opens and closes; it fails
against the current production build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phones showed only the poetic h1 and a bare search box — no coverage,
scope, or freshness statement above the fold for the 63%-of-entries,
56%-mobile audience. One compact line ('24,000+ English schools — SATs,
GCSEs, Ofsted & admissions, side by side. Updated for 2026/27') now
replaces the hidden eyebrow + full paragraph at ≤640px, costing ~2 short
lines. Desktop copy unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Table auto-layout sizes columns by unwrapped header text, so labels like
'Reading, Writing & Maths Combined Higher %' pushed the value column —
and the table — past the viewport. The label now renders inside a block
span capped at 110px (84px on phones), forcing multiline and keeping the
table fully in view at any metric.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rankings mobile: the metric value — the point of the page — sat behind
a sideways swipe at 390px. The Area column now folds into a subline
under the school name, leaving Rank | School | Value to fit the
viewport with no horizontal scroll.
Rankings default metric becomes 'expected standard' (rwm_expected_pct);
'higher standard' stays available but no longer frames every school's
headline number in the terms parents least understand.
MetricTooltip was hover-only and display:none on phones — the mobile-
primary audience had zero access to the Attainment 8 / Progress 8 /
EBacc definitions. It is now a real button: tap/click/keyboard
toggleable with outside-click and Escape dismissal, 24px target,
shown at all viewports.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The seed effect only adopted the URL's schools when localStorage was
empty, so a recipient who had ever used compare silently saw their own
old shortlist instead of the shared one. Explicit URL urns now replace
the stored selection on load (then persist as usual); bare /compare
still restores the visitor's own selection. Adds replaceSchools() to
the comparison context. Card values also switch to CHART_TEXT_COLORS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live axe-core inventory found 13 failing fg/bg pairs (3.12:1 coral text/
fills, 4.0-4.5 teal near-misses, 2.7 gold badge, 1.9 chart-colour text).
All replacement values validated ≥4.5:1 against every background they
sit on:
- --accent-coral-dark deepened to #b04a2e and used for coral text roles
(nav active, back/map links, kickers, chips) and coral fills under
white text (btn-primary, phase tabs, section-nav compare, chart chips);
new --accent-coral-darker #9c3f26 for their hovers. Decorative coral
(pins, bars, borders, focus ring) keeps the brand #e07256.
- --accent-teal darkened to #296f6f (rankings values, metric rows,
ofsted grades, eyebrows all pass).
- Ofsted green #3c8c3c→#2f7a2f; off-token gold #b8920e→--accent-gold-text.
- Gender-split pink text #b45778→#a04a68; admissions tip numerals to a
3.8:1 muted brown (28px display text).
- New CHART_TEXT_COLORS: AA-dark counterparts of the Chart.js series
palette for compare-card values (swatch dots keep true series colour).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On ≤640px the floating Compare button becomes a 40px round glyph (+ / ✓,
aria-labelled) like the section-nav compare icon, instead of a full-width
text pill over the map. The OSM attribution moves to the map band's
top-left in preview so it no longer collides with the school name sliding
up under the fade; fullscreen keeps Leaflet's default bottom-right since
the zoom control occupies the top-left there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regression from d4d9ae5 (map-blended hero): .headerHasMap .actions was
absolutely positioned intending to float over the map, but its containing
block was .headerContent — made position:relative in the same commit — so
top:14px anchored it to the title block below the map. On mobile the H1
spans the full width and the legacy '.actions { width: 100% }' rule still
applied, stretching the glassy button across the school name and leaving
a ~6px legible sliver between it and the map fade.
Anchor .actions to .header by moving position:relative/z-index:3 from
.headerContent to .titleSection (the element that actually needs to sit
above the fade), and set width:auto on the floating variant so the mobile
full-width rule can't reach it. Applied to both detail views.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the faint middot separators on the characteristics line with
subtle borderless "quiet chips" so each attribute (type, age, faith,
gender) is its own scannable unit. Keep the coloured phase pill as the
one accent, and align every chip — including phase and provision tags —
to a single size/weight/radius (0.75rem / 600 / 4px).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bare "3-11" range gave no unit. Add a display-only formatAgeRange
helper ("3-11" -> "Ages 3–11", en-dash) used in the primary/secondary
search rows and the secondary detail badge. The raw age_range field is
left untouched so sixth-form detection (.includes('18')) still works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a per-step link on the primary and secondary admissions timelines
pointing to the relevant SchoolCompare tool (search, compare shortlist,
rankings) for that stage — from researching criteria through offer day
and appeals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The report link hardcoded provider type 21, which 404s for every school
whose Ofsted provider type isn't 21 (nurseries -> 20, academies -> 23, etc.).
Use Ofsted's URN-based find-inspection-report URL, which redirects to the
correct provider page for any school, and rename the link to "Ofsted reports"
since it points to the school's Ofsted page (all reports), not one report.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The blend gradient sat at z-index 2 — below Leaflet's tile pane (200) — so it
was painted behind the map and the band ended in a hard edge with no
diffusion. Raise the fade above the tile/overlay panes (450, still below the
marker pane so the pin stays crisp) and the whole-band open button above the
marker pane (800), and isolate the wrapper's stacking context so those raised
z-indexes don't leak out and outrank the header's Compare button.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the header's at-a-glance stats row with a location map that sits
atop the hero and blends into the school title. The map is a static,
non-interactive preview (never traps page scroll) with a coral pin; the
whole band — or the inline "View on map ↗" link by the address — opens a
fullscreen, interactive map. Compare floats glassy over the band.
The separate "Location" section (and its nav item) is removed; the map now
lives only in the hero. Schools without lat/long render the header with no
map band, as before.
New: SchoolHeroMap (fullscreen wrapper, forwardRef open handle) +
LeafletHeroMapInner (minimal single-school map with interaction toggle).
Applied to both primary and secondary detail views; dead heroStats/tone/
mapContainer CSS removed (shared .heroStat* card classes kept).
Also drops the orphaned "Latest data" note and does not reintroduce an
Ofsted strip in the hero (it would duplicate the Ofsted section directly
below). Mockup kept at mockups/header-map-hero.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replicate the Autotrader pattern: pull the page-back control out of the
sticky section-nav bar and place a standalone "← Back" text link above the
header card, on the page background, where it scrolls away with the page.
It keeps the context-aware behaviour (router.back → /search fallback); the
secondary view gains the same fallback for parity (it previously used a bare
router.back() that dead-ends on deep links).
The pinned slot the Back button vacated now holds a "↑ Top" control that
smooth-scrolls to the top, so the sticky bar keeps a useful affordance.
Applies to both primary and secondary detail views.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A school inspected under the OEIF framework after September 2024 has an
inspection on record (ofsted_date set) but no overall_effectiveness grade,
since Ofsted no longer issues an overall judgement. buildOfstedListBadge
had no branch for this and fell through to "Not yet inspected", while the
detail page's hero chip correctly reported it as inspected — so the same
school read two contradictory ways.
Add an "Inspected · YYYY" branch that fires when an inspection is on record
(date or framework present) but no grade and not a Report Card, mirroring
the hero chip's fallback. Genuinely un-inspected schools (all Ofsted fields
null) still show "Not yet inspected". Add an .ofstedInspected badge style
(neutral slate) distinct from the grey pending state, and cover both cases
with tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hero header rendered the Ofsted signal twice — once as a chip in the
strip and again as a tile in the at-a-glance scorecard — and an
Oversubscribed chip already covered by the First-choice tile's footnote.
Remove the chip strip on both primary and secondary detail views, leaving
the scorecard trio (Results · Ofsted · First-choice) as the single home
for the headline numbers. Widen the scorecard's render gate to hasHeroStats
so a school with an Ofsted rating but no results still shows its Ofsted
signal (previously carried by the chip strip). Drop the now-dead .heroChip
CSS, keeping the shared .tone-* tokens the scorecard's serif number uses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"How Hard to Get Into This School" presupposes difficulty, which misleads for the
many undersubscribed primaries (e.g. schools offering places to 100% of
first-choice applicants). Rename to the neutral "Admissions", matching the
secondary page and the section nav label; the demand figures and the
"Oversubscribed" chip convey the difficulty where it applies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
Reorders the detail-page sections so the most-sought content leads, based on
30-day section_nav_used analytics (nav clicks over-count buried sections, so a
low, high-demand section is a strong "surface me" signal):
Primary: Ofsted → SATs Results → Admissions → Pupils & Inclusion → History →
Phonics → What Parents Say → School Life → Location → Local Area → Finances
Secondary: Ofsted → GCSEs → Admissions → History → Parents → Wellbeing →
Location → Finances
Key moves: Admissions (23%, most-clicked) lifted from #6→#3; Pupils & Inclusion
(18%) from #7→#4; History (13%) off the bottom. Ofsted stays first (its low click
rate is positional — everyone reaches it first). Low-demand context sections
(Local Area, Finances) stay last. Brings the primary page in line with secondary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
The mobile breakpoint's display:none for .sectionNavAll / .sectionNavCompare was
declared before their base display rules, so equal-specificity cascade let the
base win and "All ▾" showed on mobile alongside the section menu. Move the
breakpoint swap block after the base declarations so the hide rules win.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
Fixes the cramped mobile bar where Back, the carried Compare pill and All ▾ all
competed with the scrolling section links:
- On mobile the horizontal swipe strip collapses into a single "Section: <current> ▾"
button that opens the existing jump-to-section sheet — no fragile horizontal
swipe, and it doubles as a "you are here" indicator.
- The carried Compare CTA becomes a compact 38px icon on mobile (compare-arrows +
"add" badge; flips to a teal check when in the comparison), so it no longer
crowds the section control. Desktop keeps the labelled pill.
- Back is icon-only on mobile (label hidden), text on desktop.
- Desktop is unchanged: horizontal links + All ▾, full CTA in the hero.
Both the mobile section menu and the desktop All ▾ open the same sheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
Reworks the sticky section bar on the school detail page so mobile users can
perceive the page's breadth and get back to their results without hunting:
- Dock the bar directly under the global header (top: 56px mobile / 64px desktop)
so it's reachable immediately instead of buried below the tall hero.
- Pin "← Back" so it never scrolls off; only the section links scroll, keeping
the existing right-edge fade. Back uses router.back() with a /search fallback
so deep-links never dead-end.
- Carry the hero's "Add to Compare" CTA into the bar as a compact pill once the
hero button scrolls out of view, so the conversion action survives.
- Add an "All ▾" menu (dropdown on desktop, bottom sheet on mobile) listing every
section with the active one ticked — full breadth without horizontal scrolling.
Reuses the site's existing pill / coral / edge-fade / sheet vocabulary rather
than introducing a new navigation paradigm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7Js5xSetKNzLVr9ArXLF
The "How Hard to Get Into This School" this-year view rendered four
question/answer rows stretched to fill the trend chart's height, leaving
tall gaps and a long horizontal jump from a small muted question to its
number. Replace with a 2×2 grid of stat tiles — each number paired with
its label directly beneath — so the panel reads quickly and fills the
reserved height without artificial spacing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A first-choice offer rate sitting on the y-max ceiling (100%) was drawn
flush against the top of the plot area, clipping the point marker. Add 8px
top layout padding and clip:false so ceiling values render in full, without
introducing a misleading >100% tick.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hand-rolled SVG sparkline used px font sizes inside a 520-wide viewBox
that stretched to the full card width, so labels ballooned ~4x and collided —
and with 10+ years of real data the per-point labels and year ticks
overlapped badly, while the oversized chart stretched the "this year" view.
Replace it with a Chart.js line chart (AdmissionsTrendChart) in a fixed
200px wrapper, matching PerformanceChart: responsive px fonts, auto-skipping
x ticks, auto-scaled y-axis clamped to 0-100, emphasised latest point.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The school detail page only showed the latest admissions year. We store
every year, which is more decision-relevant for parents (the trend and its
consistency matter more than a single noisy year).
Backend now returns the full admissions_history (oldest first) alongside the
existing latest-year object. The primary SchoolDetailView gains a header
toggle ("This year | N-year trend") that swaps the Q&A for an SVG sparkline
of the first-choice offer rate. The toggle only appears when >=2 years carry
an offer rate; otherwise it falls back to the single-year card. Both views
share one CSS-grid cell so switching causes no layout shift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Peaked at X%" hint didn't say which metric it referenced, leaving
parents to guess. Both branches now lead with "Reading, Writing & Maths".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Frontend
- Dynamic-import Chart.js components on detail/compare views so Chart.js
no longer ships in initial JS.
- Drop force-dynamic on home, compare, rankings so internal data fetches
reuse Next.js's per-call revalidate cache.
- Switch /school/[slug] to ISR with a 7-day revalidate window (school
data updates annually).
- Preconnect to analytics + postcodes.io; remove redundant defer on the
Umami Script tag (afterInteractive already covers it).
- Bump images.minimumCacheTTL to 1 year.
- Extract HowItWorks and Editorial sections as server components passed
to HomeView via slot props so their JSX stays out of the client bundle.
Backend
- Add GZipMiddleware (min 512 bytes).
- Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage
+ stale-while-revalidate, ETag generation, and 304 on If-None-Match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add lib/analytics.ts with a single typed track() wrapper. SSR-safe,
never throws, no-ops when Umami isn't loaded. Event names form a
fixed union so refactors stay safe.
14 events wired:
Discovery (3)
search_submitted FilterBar submit + near_me path
near_me_used all geolocation outcomes
empty_results search returns 0 schools
Engagement (5)
school_viewed SchoolDetail + Secondary on mount, with
urn / phase / local_authority / from
section_nav_used section-nav links on both detail views
chart_metric_changed mobile chart chip switch
metric_compared_in_rankings rankings metric dropdown
external_link_clicked Ofsted / school website / DfE (declarative
data-umami-event attributes)
Conversion (5)
compare_school_added search/rankings/detail/compare sources
compare_school_removed detail toggle and compare page
compare_viewed once per session when there's a selection
(school_count, phase_mix)
compare_metric_changed compare page metric dropdown
compare_shared native sheet vs clipboard distinguished
Operational (1)
api_error caught in handleResponse, includes
endpoint / status / route
Suggested Goals to configure in the Umami dashboard for the funnel
report: search_submitted → school_viewed → compare_school_added →
compare_viewed → compare_shared.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
.chartWrapper had height:100% but its parent .chartOuter (a flex
column) had no explicit height — so the canvas couldn't resolve a
real height and fell back to Chart.js's small default, leaving a
big empty band between the plot and the "View raw year-by-year data"
disclosure on desktop.
Give .chartOuter height:100% so it fills the 280px .chartContainer,
and switch .chartWrapper to flex:1 1 auto / min-height:0 so the
canvas fills whatever space remains after the (primary-only) trend
banner. Mobile's explicit .chartWrapper height:220px still wins
inside the responsive override.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The expected-standard ranking groups too many schools at or near
100% — the leaderboard isn't useful when the top 30+ schools all
share the same value. Switch the default primary metric to the
higher (above-expected) standard, which discriminates between
schools much more clearly at the top end. Secondary still defaults
to Attainment 8. Users can still pick either via the dropdown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous commit added the chip strip and subtitle inside the
.chartOuter, but .chartContainer on the parent SchoolDetailView still
had a fixed height: 220px. With the new content stacking above the
canvas the chart + COVID footnote overflowed past 220px, and the
<details> "View raw year-by-year data" disclosure (rendered just
after the container) landed on top of the plot. It also pushed the
COVID footnote out of the card onto the page background.
- .chartContainer at ≤640px now flows naturally (height: auto)
- .chartWrapper at ≤640px gets an explicit 220px height so the canvas
itself still has a known size for Chart.js to render into.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Results Over Time chart on the school detail page was overcrowded
on phones — 5-item legend wrapping over the plot area, a hidden right
y-axis still rendered for the (collapsed) progress series, fixed
0–100 percentage scale that flattened all variation, angled x-axis
labels eating vertical space.
At ≤640px the chart now renders one metric at a time, selected via a
chip strip above the plot. No more legend. No more dual y-axis. The
y-axis auto-tightens around the actual data range so variation is
visible (a school sitting in the 70–90% band now uses a 65–95 axis
instead of squashing onto a 0–100 line). A small subtitle above the
chips sets the subject context ("KS2 SATs · Reading, Writing & Maths"
or "GCSE results · Year 11") so chip labels can describe the *view*
rather than re-stating the subject.
Chip labels are spelled out in parent-facing language — no internal
shorthand like "RWM":
Primary (KS2): At expected level (default)
Above expected level
Pupil progress (shows 3 series + mini-legend)
Secondary (KS4): Attainment 8 (default)
English & Maths grade 4+
Progress 8
Chips disable themselves (greyed, with a "No data for this school"
title) when the underlying series has no data points. Desktop
behaviour is unchanged — the full multi-series chart with dual y-axis
still renders >640px.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-08: Search list pagination is already implemented (page_size 50,
"Load more schools" button + count) — no change needed; ticket closed.
MOB-10: Rather than build a full bottom-sheet filter modal (large
change, modal/focus-trap/scroll-lock infra), promote the existing
"Advanced" toggle to a coral pill labeled "Filters (n)" whenever
dropdown filters are applied. Users now see at a glance that the list
is being narrowed; the inline accordion remains the disclosure
mechanism. Adds aria-expanded for screen readers.
MOB-23: Add MOBILE.md at the repo root with the 360 px design baseline,
acceptance checks for any UI PR (no horizontal overflow, ≥44px tap
targets, no <11px visible text, iOS Chrome parity, safe-area-inset,
dvh), and the established component patterns. Playwright regression
test deferred — adding the dep for one test is heavier than the
current value warrants; documented as a future option.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-19: Add a viewport Viewport export with viewportFit: 'cover' and
themeColor entries for light (#faf7f2) / dark (#1a1612), plus the
appleWebApp metadata for the home-screen status bar style and title.
Manifest's stale #3b82f6 theme_color updated to match brand cream.
MOB-20: Apply env(safe-area-inset-*) to the sticky chrome — the top
header gets max(padding, inset-left/right) so the logo and tab links
clear the notch in landscape; the bottom tab bar already had
inset-bottom and now also gets inset-left/right.
MOB-21: Replace 100vh with 100dvh in body min-height, modal max-heights,
the map view container, and the fullscreen map. Older engines fall
back via the duplicated vh declaration.
MOB-22: Set -webkit-tap-highlight-color: transparent on body to
suppress the iOS Safari grey flash; add a generic touch-pointer
:active rule (opacity 0.7) so taps still register visually on plain
anchors and bare buttons. Components with their own :active styling
are unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-14: PerformanceChart and ComparisonChart both already configure
legend position 'top' — no change needed.
MOB-15: Compare's detailedTable is 774px wide inside an overflow-x:
auto wrapper. Add a right-edge mask-image fade at ≤640px so phone
users see the table extends past the viewport.
MOB-16: ComparisonView's Share button previously did clipboard-only.
Prefer navigator.share when available (iOS/Android native sheet) so
users can send straight to Messages / WhatsApp / Mail / etc. Fall
back to clipboard with the existing "Copied!" toast otherwise. User
cancellations swallow silently; only real errors trigger fallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-07: Admissions deadlines strip becomes a horizontal snap-scroller
on ≤640px instead of a cramped 2×2 grid (which forced "Secondary ·
Deadline" track labels down to 9.6px). Cards stay readable, the right
edge fades to signal more content past the viewport, .chipTrack font
bumped to 0.7rem.
MOB-09: Result-card line3 (headline metric + secondary stats) was
crowding everything onto one row. Force the first .stat (Attainment
8 / RWM headline) to flex-basis 100% on mobile so delta-vs-LA and
pupil count wrap below it with a visible row-gap. Applied to both
SchoolRow (primary) and SecondarySchoolRow.
MOB-12: MetricTooltip ⓘ icons rendered at ~9px and relied on :hover
(which doesn't fire on touch). Hide the whole .wrapper at ≤640px —
metric labels themselves carry the meaning. Saves building a
tap-to-show layer for now.
MOB-13: The "Ofsted pending / No inspection on record" empty state
took a full hero card to communicate non-information. Add a
data-ofsted-state attribute on the hero chip; on ≤640px, the
"none" state collapses to a single muted line.
MOB-17: Already had Type+Action columns hidden on rankings mobile —
no change needed beyond marking complete.
MOB-18: Long metric headers ("Reading, Writing & Maths Combined %")
forced the value column wide. Drop .valueHeader to 0.625rem with
white-space: normal at ≤640px so labels wrap onto 2 short lines.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-06: On phones the home hero stacked eyebrow tag, h1, description
paragraph, search input, button, "Schools near me" and three explore
chips before the user could see the deadlines strip. Hide the eyebrow
and the descriptive paragraph at ≤640px (the h1 already names the
product; the search input is the primary action) and move the
"Start exploring" chips to render after the admissions deadlines —
time-sensitive info now leads, generic discovery follows. Result on
390×844: heading → search → Schools near me → first deadline chip
all fit above the fold.
MOB-11: The school-detail hero took ~2 viewports before the first real
metric. At ≤768px, switch .meta back to row+wrap so the short pills
("Manchester" / "Voluntary aided") flow 2-per-row instead of stacking
3 full rows, and hide the .headerDetails block (headteacher / website /
pupil count / trust) — secondary info that lives in the Pupils &
Inclusion section anyway. Reclaims ~70px of hero so the Ofsted card
and the headline metric surface within a single viewport.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-05: With the bottom tab bar's Compare badge now showing the
selected count and providing one-tap navigation to /compare, the
floating toast becomes redundant chrome on phones — it cost ~70px
of permanent vertical space and visually competed with the tab bar
right above it. Hide at ≤640px. Per-school removal still works on
the /compare page itself. Desktop unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
iOS Chrome (and some Android browsers) auto-hide their URL bar on
scroll. This grows the visual viewport without changing the layout
viewport, so a position:fixed bar pinned to bottom:0 — which is
relative to the layout viewport — appears to float mid-screen with
a gap beneath it. Safari masks the bug because its toolbar shrinks
rather than fully retracting.
Track the delta between the visual and layout viewports via the
VisualViewport API and write it to a --mobile-bar-offset CSS var.
The bar uses translate3d to apply that offset, which both fixes the
gap and enables hardware compositing so it tracks the toolbar
animation without flicker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-03: The school-detail section nav (SATs / Admissions / Pupils /
Location / History) overflows on phones (scrollWidth 462 vs clientWidth
356) with no signal that more tabs exist past the right edge. Add a
right-edge mask-image fade at ≤640px, scroll-snap on each link, and
bigger tap targets (min-height 36px, padding bumped). A scroll/resize
listener toggles an .atEnd class that removes the fade once the user
has scrolled to the last tab.
MOB-04: The "What you'll see on every school" cards rendered preview
visuals (mini cascade chart, Ofsted badge, compare table) with text
scaled down to 6–10px — unreadable on a phone. Hide .hiwVisual at
≤640px and let the explanatory text carry each card; tiny visible
text count on the home dropped from 51 to 8.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MOB-01: At ≤640px the top inline nav is hidden and replaced by a
fixed bottom tab bar (Search / Compare / Rankings / Admissions) with
icon + label, 56px tap targets, env(safe-area-inset-bottom) padding,
and the Compare count rendered as a badge on the icon. Eliminates
horizontal page overflow on every route (docW was 401–429 at vw 390;
now docW === vw).
MOB-02: Logo link gains a padded hit area so the touch target is
≥44×44 (was 36×36), without resizing the visual mark.
ComparisonToast lifted above the new bottom bar on mobile so the two
do not stack on top of each other. body gets a bottom padding equal
to the bar height + safe-area inset so page content is never hidden.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Header "Pupils" line reverts to plain inline text matching Headteacher
and website weight. Gender split lives in Pupils & Inclusion as a card
alongside pupil premium / EAL / SEN support — peers of similar weight.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Upgrades the existing "Pupils" stat to include a compact split bar and
percentage hint for mixed schools (single-sex schools already carry a
"Boys's/Girls's school" badge, so the split would be redundant).
Wires fact_pupil_characteristics into the API: new SQLAlchemy model and
a real census block in /api/schools/{urn} replacing the prior null stub.
On the primary detail page the inline "Pupils: 241" text is replaced by
a richer block (display number + bar + "52% girls · 48% boys"). On the
secondary detail page the existing "Total pupils" hero stat card grows
the bar and hint beneath the number. Both fall back to the previous
text-only rendering when census gender data is missing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>