The desktop toolbar's Clear went to the bare home URL, dropping the
search along with the filters, so it landed on the home page instead of
the unfiltered results. It now clears every filter and keeps the search,
postcode and distance, as the phone's Clear all already did. It is
offered only once a filter is applied, since with only a search there is
nothing for it to clear, and is labelled Clear all to match the phone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The School type filter split state schools into "academy or free
school" and "council-run". The two were near-halves of one pool (11,186
and 9,316 schools), so choosing one rarely narrowed anything, and the
split did not follow the difference a parent feels most, admissions:
voluntary aided and foundation schools set their own, as academies do.
Faith, which voluntary aided mostly meant, has its own filter.
They are now one group, "State school (free)", leaving five. The old
keys academy and council resolve to state, so a link made with them
keeps working instead of falling through to the raw-label path and
returning nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row repeated the search, local authority and school type above the
results list, and nothing else: no phase, distance, gender, sixth form,
admissions or faith. The filter bar already shows every one of them, in
its selects and More filters count on desktop and as removable chips on
phones, so the row was a partial second copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The /api/schools section still described filtering on stored
school_type_group and faith_groups columns, which the architecture
correction had already dropped: the filters look up the name columns at
request time. Also trims a trailing blank line from two test files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The results list's chip printed the school type group's key ("post16")
rather than its label. A URL value that no option spelled the same way
(an old link's raw GIAS type, or a key in another case) left the select
reading "Any school type" while the list stayed filtered, and choosing
"Any" changed nothing. Values now match options ignoring case, as the
API does, and an unlisted one is shown as an option of its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The secondary-only filters were hidden for an exact set of lowercased
labels, so a change of case, hyphen or spacing in a GIAS phase label
would have left them showing. Phases are now matched on their words, as
compareGroups already reads "primary".
The E2E journey no longer counts school types: it asserts that choosing
one leaves the same list on offer, whatever the data holds. A test now
pins that a filter with no options is left out, not shown with only its
"any" option.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
School type, gender and admissions took their options from the result
set, which the filter had already narrowed: choose "Girls" and only
"Girls" was offered, so switching to "Boys" meant clearing first. They
now offer the full lists from /api/filters, as phase already did. Local
authority stays scoped to the results, so a postcode search offers the
councils nearby rather than all 153.
Whether gender, sixth form and admissions show was also decided by the
results (any secondary school in them). It is now decided by the phase
alone: hidden for Primary, Nursery and Middle deemed primary, shown
otherwise. Choosing one of those phases clears the three filters, which
would otherwise stay applied with no control showing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sheet's controls were disabled while a filter change navigated, and
a control disabled under focus drops it to <body>, out of the dialog.
They now stay enabled, with aria-busy on the sheet instead.
The disabling had also been covering a race: updateURL built from
useSearchParams, which only catches up once a navigation lands, so a
second change made before then undid the first. It now builds on the
URL the navigation in flight is heading to.
The sheet also closes if the screen widens past phone width while it is
open, so its selects and the desktop row's are never both showing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On phones the results toolbar's filters were a sideways-scrolling row
led by "More filters", so phase showed only in part and school type not
at all, and the panel "More filters" opened held neither of them.
Phones now get a single Filters button beside the folded search summary,
counting every applied filter. It opens a bottom sheet with every filter:
distance as five segments, then phase, school type, local authority and
the secondary-only filters. Changes apply at once, as on desktop, so the
footer's "Show N schools" only closes the sheet. Applied filters show as
removable chips on a second line, which appears only when something is
applied. Desktop and tablet are unchanged.
Modal gains dialog semantics, a pinned footer and focus handling, and
moves above the pinned toolbar, the floating List/Map button and the
comparison toast, which its old z-index sat beneath.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pinned-toolbar journeys scrolled the results of a typed B1 1BB search.
At the new half-mile default that is two schools, so the page ended before
the toolbar had anything to stay pinned over (desktop scrollY stopped at
229; on phones the scroll reached the footer). They now open the search
at a mile, which is what they were measuring.
Leaflet crossfades popups for 200ms, so for that window there are two
.sc-popup elements, and a strict locator fails at once rather than
retrying. Wait for one popup before reading it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
leaflet.css colours every link in the map `.leaflet-container a`
(#0078A8, specificity 0,1,1), which beat .btn-tertiary (0,1,0), so the
card's View button came out in Leaflet's link blue in both themes. Pull
it back onto --text-secondary next to the other Leaflet overrides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds "Within 0.25 miles" to the Distance control; 0.5 stays the default.
The radius shown in the results heading and drawn on the map was rounded
to one decimal place, which would have turned 0.25 into 0.3. It now keeps
two, and the list heading says "1 mile" rather than "1.0 miles".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New postcode searches, the near-me button and URLs without a radius now
use 0.5 miles. A postcode URL with no radius used to show "1 mile" in the
Distance control while the API applied its own 5-mile default; the page
and the map fetch now send the same default the control displays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The results toolbar wrapped wherever it ran out of room, and the List/Map
switch only appears when there are results, so the same search took two
lines with results and one without.
From 1340px the controls never wrap away from the search, which takes
what they leave (at least 12rem); phase and type chips cap at 11rem to
fit. Between 641px and 1339px the controls always take a full line of
their own. The switch now sits in FilterBar's row via a viewSwitch slot,
so that line runs the full width instead of stopping short of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/api/schools only recognised primary, secondary and all-through. Any
other phase the search page offers (nursery, 16 plus, middle deemed
primary/secondary) fell through to no filter, so "Nursery" returned the
whole result set, mostly primaries. Ungrouped phases now match exactly,
and an unknown phase returns nothing rather than everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phase select read its options from the result-scoped filters, which
the backend computes after applying the phase filter. With secondary
chosen only secondary and all-through were offered, so switching to
primary meant going back to "Any phase" first. Read the global phase
list instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beside the map, "19 schools within 1.0 miles of SW196AR" (359px) and the
sort menu (281px, as wide as its longest option) could not share the
pane's 427px, so the sort dropped to a second line.
In the map view the heading now reads "19 schools within 1 mile": the
postcode is already in the search box just above, and the radius reads
as the toolbar's does. The sort menu is capped at 11rem, truncating a
long choice, and the header no longer wraps; on a narrow pane the count
truncates rather than pushing the sort down. List view keeps the full
heading. Tried on staging by injecting the change: 243px + 176px, one
line at 1440.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The rows' narrow layout now switches at a 608px list, not 600px. Below
769px the page pads 1rem each side, so a 640px screen gives a 608px
list: exactly the old max-width: 640px media query, where 600px left
633-640px screens on the wide layout.
- rowContainerGuard.test.ts fails if anything other than HomeView renders
SchoolRow or SecondarySchoolRow, or if one of HomeView's row lists loses
its `results` container. Outside one the rows silently keep their wide
layout on phones. (Checked: HomeView is the only importer today.)
- Picking a pin from the list beside the map now works from the keyboard:
each row carries a "Show <school> on the map" button, visually hidden
until focused, with aria-pressed for the selected school. The row itself
cannot be the button, since it holds links and buttons of its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list beside the map used its own cut-down card, so it showed less
than the list view (no phase, age range, denomination, trend, local
authority) and looked different. It now renders the list view's
SchoolRow / SecondarySchoolRow, through one renderRow shared by the list
view, the map pane and the phone's bottom sheet, which also gains
Remove from compare. CompactSchoolItem and its styles are gone.
The rows switched to their stacked layout on a max-width: 640px media
query, which never fires in a ~430px pane on a desktop screen. It is now
a container query on the list the row sits in (`results`, set on the
list view, the map pane and the bottom sheet). 600px of list is a 632px
screen less padding, so phones lay out as before.
Clicking a row, outside its links and buttons, still picks its pin, and
the selected row is outlined. The pane widens to 360–460px for the
taller rows.
The bottom sheet's close button was a 30px target; it is now 44px with
the same 30px circle (MOBILE.md), and the phone journey checks it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The search summary ("SW196AR · within 1 mile Edit") ran about 40px off
the right edge on every phone, cutting "Edit" in half, and the filter row
bled past the screen with it (reported on an iPhone 17, 402px).
The phone toolbar is a column flex container, but it kept flex-wrap:
wrap from the desktop rule. In a wrapping flex container each line is as
wide as its widest item's content, not the container, so the summary's
~410px of text stretched the line past a 370px toolbar. nowrap makes
stretch mean the container's width. Verified on staging by injecting the
rule: nothing in the toolbar passes the right edge at 360 or 402px.
The E2E overflow check could not see this: .main clips on x, so
scrollWidth stayed equal to the viewport. The phone journey now measures
the toolbar's own right edges, and runs at 402px as well. It also waits
for the bottom sheet's slide-up to finish before measuring, and measures
the Map/List button again after the sheet pushes it up; both assertions
read a mid-animation or stale box before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trial the map as the default for every postcode search, not only on
desktop (DEFAULT_LOCATION_VIEW in HomeView; results_view_changed shows
how often readers switch away). The view is now derived: the reader's
choice once made, else the default for the kind of search. That follows
a hero search through client-side navigation without syncing state.
With nothing left varying by device, the user-agent default goes
(lib/device.ts, the headers() read in page.tsx). That settles two review
points: the HTML no longer needs Vary: User-Agent, and there is no
narrow-window fallback left to flash the map before the list.
Review fix in LeafletMapInner: the card reopened only when the selection
or the school list changed, so a rebuild caused by the centre, reference
point or radius closed it for good while the page still had the school
selected. The selection effect now follows a pins-rebuilt counter.
SchoolMap memoises its fallback centre, which was a fresh array on every
render and would have rebuilt every pin each time.
Phones hide the map view's list pane, so it no longer builds up to 500
hidden cards there (decided after mount, so hydration still matches).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The map view now follows option B of the results-controls mockups:
- The list sits in a pane on the left, under the result count and sort,
and the map fills the rest of the screen below the pinned toolbar. The
toolbar's height is measured, so the split ends 1rem above the bottom
of the screen however the controls wrap.
- Pins are brand-teal dots and the selected one is coral. The search
location is an ink dot, the search radius a dashed circle with its
distance, and the view fits that circle. Tiles are muted and the zoom
sits under the fullscreen button.
- A school picked on the map or in the list opens a card on the map
(View, + Compare, following the basket), and its list card is ringed
and scrolled into view. Phones keep the bottom sheet.
- The list cards show the full name, Ofsted and school type, the
headline figure and pupils.
The map cards now follow the list rows: no England benchmark for
special schools, PRUs and AP, and no placeholder all-zero RWM (Greenmead
showed "0% RWM -62 pts vs national"). That rule moves to a shared
listRwmValue helper.
A postcode search opens on the map for desktop browsers, chosen on the
server from the user agent so the list never paints first. The client
falls back to the list below 1024px, and follows the default through
client-side navigation from the hero until the reader picks a view.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Refold the phone search, and resync its text, when the query changes
without a submit (back/forward, "Use my location"). The summary and the
unfolded input could otherwise describe two different searches.
- Cap the "More filters" panel at every width, not only on phones, so an
open panel scrolls inside the pinned toolbar on a short screen.
- Drop the phone filter row's right-edge fade once it is scrolled to the
end or does not overflow (the MOBILE.md pattern), and give focus rings
room inside the scroll clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The List/Map switch was a small grey control beside the results heading,
and the filters were plain dropdowns labelled "All Phases" and "Advanced".
Both scrolled away with the first result.
Search, filters and the List/Map switch now share one card pinned under
the header. Distance, phase and school type are pill controls in the row;
"Advanced" becomes "More filters" and counts only what it hides. The
switch is filled brand teal and says which view is on (aria-pressed).
On phones the search folds to a one-line summary once made, the filter
pills scroll sideways, and a floating Map/List button sits above the tab
bar in place of the toolbar switch. The selected pin's card now stacks
under that button instead of covering the tab bar.
Switching view from far down the list scrolls back to the top of the
results, and each switch is tracked as results_view_changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the per-phase limit found entries reaching the basket with no
phase, and a phase-less entry holds a slot in both groups:
- Nearby-school cards added without one. The API now returns each
peer's own phase (its pool is a phase group, so an all-through school
can sit beside a primary); the button passes it through, and an older
API simply leaves the conservative both-groups count in place.
- Baskets saved before this change were never migrated. The compare
page now backfills missing phases from the data it already fetches,
and never overwrites a phase the basket has.
- "16 plus" counted against both groups; it is secondary, as the API's
PHASE_GROUPS files it.
Also rewraps the HomeView doc comment the previous commit left awkward.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A parent choosing a primary and a secondary school at once hit the old
cap of five total. The basket now holds up to five primary and five
secondary schools (ten in all), matching the compare page's phase tabs.
Schools that could land in either tab (all-through, special schools with
phase "Not applicable", unknown phase) count against both groups, so no
tab ever exceeds the five-slot chart palette and point styles.
- lib/compareLogic: compareGroups, fullGroupFor, fitToGroupLimits
- search modal disables only the full group and says which one
- rankings rows carry the phase of the tab they are ranked under
- shared ?urns= links are trimmed per group
- copy: compare metadata, homepage value prop, How it works card now
name primary and secondary schools (also better for search intent)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>