diff --git a/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md b/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md index 8ac950b..c4d0580 100644 --- a/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md +++ b/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md @@ -3,8 +3,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a "Similar schools nearby" section to the school detail page, -showing up to three crawlable links to nearby schools of the same phase and a -comparable intake, each addable to the comparison basket. +showing up to six crawlable links to nearby schools of the same phase and a +comparable intake — three at a time in a carousel — each addable to the +comparison basket. **Architecture:** A pure backend function ranks candidates out of the cached latest-year DataFrame using hard filters (never relaxed) and tiered soft @@ -25,8 +26,16 @@ Playwright. workflow. - **Do not start a local server** to check the application. Use the unit tests in this plan. -- **Target 3 cards, minimum 2.** Fewer than 2 qualifying schools renders no - section and no nav item. +- **Maximum 6 cards, 3 visible, minimum 2.** Fewer than 2 qualifying schools + renders no section and no nav item. Six is a cap, not a quota. +- **Tiers relax to reach three, never to fill six.** Descend the tiers until the + set reaches 3; take up to 6 from the tiers used; never open the next tier just + to fill remaining slots. +- **Every card is in the initial HTML.** The arrows scroll an overflowing list; + they never mount or unmount a card. A card behind an arrow must still be a + crawlable `` in the server-rendered markup. +- **Arrow edge tests use an 8px tolerance, never `=== 0`.** The scroller's 2px + padding is the first snap position, so a row at rest reports `scrollLeft` of 2. - **Tier radii, in miles:** tier 1 = 3.0, tier 2 = 5.0, tier 3 = 10.0. - **Hard filters never relax:** self, non-open status, missing coordinates, different phase group, special↔mainstream, selective↔non-selective, @@ -43,9 +52,8 @@ Playwright. tier 3. A missing metric renders the exact string `Not published`. - **No "how these schools are chosen" disclosure.** One caption line only: distances are straight-line, not road distance. -- **Three cards maximum, with no overflow affordance.** Surplus qualifying - schools are dropped silently; `NearbyPlaces` below already leads to the full - lists. +- **Past six, surplus schools are dropped silently.** No "show more" and no + count; `NearbyPlaces` below already leads to the full lists. - **The neighbour's metric never carries a valence colour.** No `--status-above` / `--status-below` anywhere in this feature. - **Backend tests:** @@ -69,6 +77,7 @@ Playwright. | `nextjs-app/components/school/SimilarSchools.module.css` *(new)* | Section styles, tokens only. | | `nextjs-app/components/school/AddToCompareButton.tsx` *(new)* | Client island: the per-card basket toggle. | | `nextjs-app/components/school/SimilarSchoolsCompareBar.tsx` *(new)* | Client island: the selection count and the CTA into `/compare`. | +| `nextjs-app/components/school/SimilarSchoolsCarousel.tsx` *(new)* | Client island: the scroller ref, the arrows and their disabled state. | | `nextjs-app/lib/schoolSections.ts` *(modify)* | `similar` nav item in both builders. | | `nextjs-app/components/school/PrimarySchoolSections.tsx` *(modify)* | Render the section last. | | `nextjs-app/components/school/SecondarySchoolSections.tsx` *(modify)* | Render the section last. | @@ -76,9 +85,11 @@ Playwright. | `nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx` *(new)* | Render gates, lede wording, chips, "Not published". | | `e2e/tests/journeys.spec.ts` *(modify)* | Journey covering the section and the compare hand-off. | -Two client islands rather than one, because they need different things: the -button needs a single school, the bar needs the whole selection. Keeping them -apart means a card never re-renders when the count changes. +Three client islands rather than one, because they need different things: the +button needs a single school, the bar needs the whole selection, and the +carousel needs a DOM ref and nothing else. Keeping them apart means a card never +re-renders when the count changes — which is also what stops the row jumping +back to the start when someone ticks the fifth school. The selection logic lives in its own module rather than in `app.py` because `app.py` is already ~1700 lines, and because a pure function over a DataFrame is @@ -98,7 +109,8 @@ testable without a TestClient, a database or a monkeypatch. Each dict has keys `urn` (int), `school_name` (str), `distance_miles` (float), `school_type` (str | None), `age_range` (str | None), `shared` (list[str]), `tier` (int), `metric_value` (float | None), `metric_key` (str), - `metric_year` (int | None). Returns `[]` when fewer than 2 qualify. + `metric_year` (int | None). At most 6 entries. Returns `[]` when fewer than 2 + qualify. - [ ] **Step 1: Write the failing test** @@ -111,7 +123,8 @@ The hard filters encode claims the section is not allowed to make — that a selective school is an alternative to a non-selective one, that a special school is comparable to a mainstream one, or that a Girls school is an option for a Boys school's reader. They never relax. The soft preferences describe -how close the intake is, and they do. +how close the intake is, and they do — but only far enough to reach a usable +set, never far enough to fill the last of the six slots. """ import numpy as np @@ -237,6 +250,35 @@ def test_tiers_relax_faith_before_gender(): assert [s["urn"] for s in result] == [100003, 100004, 100002] +def test_caps_at_six_taking_the_nearest(): + frame = _frame( + _row(100001, "Subject"), + *[_row(100010 + n, f"Peer {n}", latitude=_at(0.1 * (n + 1))) for n in range(7)], + ) + result = select_similar(frame, 100001, is_secondary=False) + assert len(result) == 6 + # The seventh-nearest is the one dropped, not an arbitrary one. + assert 100016 not in {s["urn"] for s in result} + + +def test_tiers_stop_once_enough_are_found(): + """Four tier-1 matches are a usable set, so tier 2 is never opened — even + though it holds a school that is closer than any of them.""" + frame = _frame( + _row(100001, "Subject", religious_denomination="Roman Catholic"), + _row(100002, "RC one", religious_denomination="Roman Catholic", latitude=_at(0.5)), + _row(100003, "RC two", religious_denomination="Roman Catholic", latitude=_at(0.6)), + _row(100004, "RC three", religious_denomination="Roman Catholic", latitude=_at(0.7)), + _row(100005, "RC four", religious_denomination="Roman Catholic", latitude=_at(0.8)), + # Closer than every one of them, but only a tier-2 match. + _row(100006, "Secular and nearer", religious_denomination="None", latitude=_at(0.2)), + ) + result = select_similar(frame, 100001, is_secondary=False) + assert 100006 not in {s["urn"] for s in result} + assert len(result) == 4 + assert all(s["tier"] == 1 for s in result) + + def test_a_school_is_never_taken_twice(): frame = _frame( _row(100001, "Subject"), @@ -392,7 +434,13 @@ import pandas as pd from .schemas import PHASE_GROUPS -TARGET = 3 +# A cap, not a quota: the section shows everything that qualified at the tiers +# it used, up to this many. Three fit the row; the rest are behind the arrows. +MAX_SCHOOLS = 6 +# Tiers stop relaxing once this many have been found. Without it, a cap of six +# would reliably drag in tier-3 schools ten miles away to fill a row that three +# good matches had already earned. +ENOUGH = 3 MINIMUM = 2 # (tier, radius in miles). Faith relaxes before gender: a faith mismatch @@ -490,7 +538,7 @@ def _chips(subject: pd.Series, candidate: pd.Series, tier: int, is_secondary: bo def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[dict]: - """Up to TARGET nearby schools this page may offer, or [] below MINIMUM. + """Up to MAX_SCHOOLS nearby schools this page may offer, or [] below MINIMUM. Selected by tier, displayed by distance: the tier decides which schools earn a slot, and the render order is then closest-first, because "nearby" @@ -551,23 +599,28 @@ def select_similar(frame: pd.DataFrame, urn: int, is_secondary: bool) -> list[di 3: pd.Series(True, index=candidates.index), } + # Descend the tiers only until the set reaches ENOUGH. The tier that gets + # there is the last one opened, and the remaining slots up to MAX_SCHOOLS + # are filled from the tiers already used — never by widening again. picked: dict[int, tuple[int, pd.Series]] = {} for tier, radius in TIERS: - if len(picked) >= TARGET: - break within = candidates[tier_masks[tier] & (candidates["distance_miles"] <= radius)] for _, row in within.sort_values("distance_miles").iterrows(): candidate_urn = int(row["urn"]) if candidate_urn in picked: continue picked[candidate_urn] = (tier, row) - if len(picked) >= TARGET: + if len(picked) >= MAX_SCHOOLS: break + if len(picked) >= ENOUGH: + break if len(picked) < MINIMUM: return [] - selected = sorted(picked.values(), key=lambda pair: float(pair[1]["distance_miles"])) + selected = sorted( + picked.values(), key=lambda pair: float(pair[1]["distance_miles"]) + )[:MAX_SCHOOLS] return [ { "urn": int(row["urn"]), @@ -591,7 +644,7 @@ Run: ```sh /tmp/schoolcompare-backend-venv/bin/python -m pytest backend/tests/test_similar_schools.py -q ``` -Expected: PASS, 14 tests. +Expected: PASS, 16 tests. - [ ] **Step 6: Run the whole backend suite for the `PHASE_GROUPS` move** @@ -874,6 +927,25 @@ describe('cards', () => { expect(screen.getByTestId('compare-bar')).toHaveTextContent('bar for 100001'); }); + it('keeps every card in the DOM, including the ones scrolled out of view', () => { + const six = Array.from({ length: 6 }, (_, n) => + school({ urn: 100002 + n, school_name: `Peer ${n} School` }), + ); + renderSection(six); + expect(screen.getAllByRole('link', { name: /Peer \d School/ })).toHaveLength(6); + }); + + it('offers no arrows when three cards fit the row', () => { + renderSection([school(), school({ urn: 100003 }), school({ urn: 100004 })]); + expect(screen.queryByRole('button', { name: /More schools/ })).not.toBeInTheDocument(); + }); + + it('offers arrows once there is a fourth school', () => { + renderSection(Array.from({ length: 4 }, (_, n) => school({ urn: 100002 + n }))); + expect(screen.getByRole('button', { name: /More schools/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Previous schools/ })).toBeInTheDocument(); + }); + it('says distances are straight-line, and offers no method panel', () => { const { container } = renderSection([school(), school({ urn: 100003 })]); expect(screen.getByText(/straight-line from this school/i)).toBeInTheDocument(); @@ -1038,7 +1110,137 @@ export function SimilarSchoolsCompareBar({ } ``` -- [ ] **Step 6: Write the section** +- [ ] **Step 6: Write the carousel** + +Create `nextjs-app/components/school/SimilarSchoolsCarousel.tsx`: + +```tsx +'use client'; + +/** + * The scroller and its arrows. + * + * `children` are the server-rendered cards and `header` the server-rendered + * heading and lede: both stay server components, passed through, so this file + * owns a DOM ref and nothing else. That is what keeps all six links in the + * initial HTML — a carousel that mounted cards on click would put four of the + * six beyond a crawler and beyond a reader with no JavaScript. + * + * With JavaScript off this degrades to a horizontally scrollable row, which is + * still usable by touch and trackpad. + */ + +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import styles from './SimilarSchools.module.css'; + +/** Three cards fit the row, so fewer than four has nowhere to scroll to. */ +const VISIBLE = 3; + +/** + * Why a tolerance rather than `=== 0`. + * + * The scroller carries 2px of padding so focus rings are not clipped, and + * scroll-snap treats that padding as the first card's snap position — a row at + * rest reports scrollLeft 2, not 0. Sub-pixel rounding moves it again at other + * zoom levels. An exact test leaves the back arrow live on first paint, + * pointing nowhere. + */ +const EDGE = 8; + +export function SimilarSchoolsCarousel({ + count, + labelledBy, + header, + children, +}: { + count: number; + labelledBy: string; + header: ReactNode; + children: ReactNode; +}) { + const scroller = useRef(null); + const [atStart, setAtStart] = useState(true); + const [atEnd, setAtEnd] = useState(false); + const scrollable = count > VISIBLE; + + const sync = useCallback(() => { + const node = scroller.current; + if (!node) return; + const max = node.scrollWidth - node.clientWidth; + setAtStart(node.scrollLeft <= EDGE); + setAtEnd(node.scrollLeft >= max - EDGE); + }, []); + + // Also on mount: the first measurement can only happen once there is layout. + useEffect(sync, [sync]); + + const page = (direction: 1 | -1) => { + const node = scroller.current; + if (!node) return; + // A page is what the reader can see, so the viewport is the step. + node.scrollBy({ left: direction * node.clientWidth, behavior: 'smooth' }); + }; + + return ( + <> +
+ {header} + {scrollable && ( +
+ + +
+ )} +
+ + + + ); +} + +function Chevron({ direction }: { direction: 'prev' | 'next' }) { + return ( + + ); +} +``` + +- [ ] **Step 7: Write the section** Create `nextjs-app/components/school/SimilarSchoolsSection.tsx`: @@ -1061,6 +1263,7 @@ import Link from 'next/link'; import type { SimilarSchool } from '@/lib/types'; import { schoolUrl } from '@/lib/utils'; import { AddToCompareButton } from './AddToCompareButton'; +import { SimilarSchoolsCarousel } from './SimilarSchoolsCarousel'; import { SimilarSchoolsCompareBar } from './SimilarSchoolsCompareBar'; import { Section } from './sectionShared'; import styles from './SimilarSchools.module.css'; @@ -1103,14 +1306,22 @@ export function SimilarSchoolsSection({ return (
-

Similar schools nearby

-

- {loosest >= 3 - ? `Other ${phaseNoun} schools near ${schoolName}.` - : `Other ${phaseNoun} schools near ${schoolName}, with a similar intake.`} -

- - + @@ -1164,7 +1375,7 @@ export function SimilarSchoolsSection({ } ``` -- [ ] **Step 7: Write the stylesheet** +- [ ] **Step 8: Write the stylesheet** Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only — `darkThemeSafety.test.ts` fails the build on any hardcoded colour: @@ -1174,11 +1385,22 @@ Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only — .lede { margin: 0.5rem 0 1.25rem; color: var(--text-secondary); max-width: 64ch; } .caption { margin: 1rem 0 0; font-size: 0.72rem; color: var(--text-muted); } -.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.9rem; list-style: none; margin: 0; padding: 0; } -@media (max-width: 820px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 560px) { .grid { grid-template-columns: 1fr; } } +.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.arrows { display: flex; gap: 0.5rem; flex: none; } +.arrow { width: 40px; height: 40px; display: grid; place-items: center; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); color: var(--brand); } +.arrow:hover:not(:disabled) { border-color: var(--brand); background: var(--brand-bg); } +.arrow:disabled { opacity: 0.35; cursor: default; } +.arrow svg { width: 17px; height: 17px; } -.school { position: relative; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--bg-card); } +/* A scroller, not a paginated view: every card is in the DOM and the arrows + only move the viewport across them. The 2px padding keeps focus rings from + being clipped — and is why the arrows' edge test needs a tolerance. */ +.scroller { display: grid; grid-auto-flow: column; grid-auto-columns: calc((100% - 1.8rem) / 3); gap: 0.9rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 2px; margin: -2px; list-style: none; scrollbar-width: none; -ms-overflow-style: none; } +.scroller::-webkit-scrollbar { display: none; } +@media (max-width: 820px) { .scroller { grid-auto-columns: calc((100% - 0.9rem) / 2); } } +@media (max-width: 560px) { .scroller { grid-auto-columns: 86%; } } + +.school { position: relative; display: flex; flex-direction: column; scroll-snap-align: start; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--bg-card); } .school:hover { border-color: var(--border-strong); } .distance { margin: 0 0 0.6rem; font-size: 0.75rem; color: var(--text-muted); } @@ -1217,7 +1439,7 @@ Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only — .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } ``` -- [ ] **Step 8: Run the tests to verify they pass** +- [ ] **Step 9: Run the tests to verify they pass** Run (from `nextjs-app/`): ```sh @@ -1226,10 +1448,16 @@ npm run typecheck ``` Expected: PASS on both suites, and typecheck clean. -- [ ] **Step 9: Commit** +Do **not** add a Jest assertion on which arrow is disabled. jsdom has no layout, +so `scrollWidth` and `clientWidth` are both 0 there and the component measures +an empty row — a test written against that passes on a measurement that does not +exist. The arrows' disabled behaviour is covered in Task 5's journey, against a +real engine. + +- [ ] **Step 10: Commit** ```bash -git add nextjs-app/lib/types.ts nextjs-app/components/school/SimilarSchoolsSection.tsx nextjs-app/components/school/SimilarSchools.module.css nextjs-app/components/school/AddToCompareButton.tsx nextjs-app/components/school/SimilarSchoolsCompareBar.tsx nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx +git add nextjs-app/lib/types.ts nextjs-app/components/school/SimilarSchoolsSection.tsx nextjs-app/components/school/SimilarSchools.module.css nextjs-app/components/school/AddToCompareButton.tsx nextjs-app/components/school/SimilarSchoolsCompareBar.tsx nextjs-app/components/school/SimilarSchoolsCarousel.tsx nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx git commit -m "feat(web): similar schools section, honest about what it matched Co-Authored-By: Claude Opus 5 " @@ -1406,10 +1634,13 @@ convention of asserting stable invariants rather than exact numbers: /** * Similar schools nearby. * - * The section is absent by design where fewer than two schools qualify, so - * this walks from a search hit to a school page and asserts the section's - * contract only where it renders — and asserts the compare hand-off, which is - * the one part that can break silently. + * The section is absent by design where fewer than two schools qualify, and the + * arrows are absent where three cards fit, so this walks from a search hit to a + * school page and asserts each part of the contract only where it applies. + * + * Two things here cannot be tested anywhere else: the arrows' disabled state, + * which jsdom cannot measure because it has no layout, and the scroll position + * surviving a selection, which is DOM state rather than React state. */ test('similar schools link on to other schools and into compare', async ({ page }) => { await searchByName(page, 'Primary'); @@ -1421,20 +1652,44 @@ test('similar schools link on to other schools and into compare', async ({ page test.skip(true, 'No qualifying similar schools for this school'); } - // Every card is a real link to another school page. + // Every card is a real link to another school page — including the ones + // behind the arrows, which is the whole reason this is a scroller and not a + // paginated widget. const links = section.locator('a[href^="/school/"]'); - expect(await links.count()).toBeGreaterThanOrEqual(2); + const linkCount = await links.count(); + expect(linkCount).toBeGreaterThanOrEqual(2); + expect(linkCount).toBeLessThanOrEqual(6); const href = await links.first().getAttribute('href'); expect(href).toMatch(/^\/school\/\d{6}-/); - // A missing figure says so rather than showing a number. await expect(section.getByText(/miles away/).first()).toBeVisible(); - // The compare hand-off. + // The carousel, where this school had more than three matches. jsdom cannot + // measure a row, so this is the only place the arrows are really exercised. + const forward = section.getByRole('button', { name: 'More schools' }); + if (await forward.count()) { + const back = section.getByRole('button', { name: 'Previous schools' }); + await expect(back).toBeDisabled(); + + const scroller = section.locator('ul[role="group"]'); + await forward.click(); + await expect.poll( + () => scroller.evaluate((node: HTMLElement) => node.scrollLeft), + ).toBeGreaterThan(8); + await expect(back).toBeEnabled(); + } + + // The compare hand-off, and the row must not jump back to the start when the + // footer re-renders underneath it. + const scroller = section.locator('ul').first(); + const offsetBefore = await scroller.evaluate((node: HTMLElement) => node.scrollLeft); await section.getByRole('button', { name: /Add to compare/ }).first().click(); await expect( section.getByRole('button', { name: /Added to compare/ }).first(), ).toBeVisible(); + expect( + await scroller.evaluate((node: HTMLElement) => node.scrollLeft), + ).toBe(offsetBefore); }); ``` diff --git a/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md b/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md index e0ba5cc..9c78a5b 100644 --- a/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md +++ b/docs/superpowers/specs/2026-09-21-similar-schools-nearby-design.md @@ -11,9 +11,9 @@ after the results tables: *and what else is around here?* Today a school page links outward to its place pages through `components/school/NearbyPlaces.tsx` and nowhere else. It never links to another -school. This section adds that edge — three nearby schools of the same phase and -a comparable intake, each a crawlable link and each addable to the comparison -basket in one click. +school. This section adds that edge — up to six nearby schools of the same phase +and a comparable intake, three at a time in a carousel, each a crawlable link and +each addable to the comparison basket in one click. Mockup, with all three tier states live in both themes: @@ -77,8 +77,10 @@ school the frontend treats as special for benchmarking but the backend treats as mainstream for matching would be dropped from its own England comparison and then offered as a peer to a mainstream school on the next page along. -**Three cards.** The target count is 3, which is what the grid is built for; 2 -is the minimum that renders at all. +**Up to six cards, three visible.** Six is a cap, not a quota: the section shows +every school that qualifies at the tiers it used, up to six. Three fit the row, +and the rest are reached with the carousel arrows. Two is the minimum that +renders at all. ### Soft preferences, relaxed in tiers @@ -88,9 +90,27 @@ is the minimum that renders at all. | 2 | exact gender equality | 5 miles | | 3 | nothing beyond the hard filters | 10 miles | -Candidates are taken from tier 1 first, ordered by distance; if fewer than three -have been found the next tier tops up, and so on. A school already taken cannot -be taken again by a later tier. +**Tiers relax to reach a usable set, never to fill the last slots.** + +Work down the tiers until the schools found so far reach three. Call the tier +that got there T. The section then shows up to six schools drawn from tiers 1 +to T, nearest first — and does not open tier T+1 merely because six slots are +not yet full. + +Worked through: + +| Qualifying | T | Shown | +|---|---|---| +| 14 at tier 1 | 1 | the 6 nearest tier-1 schools | +| 4 at tier 1 | 1 | all 4 — tier 2 is never opened | +| 2 at tier 1, 7 more at tier 2 | 2 | the 6 nearest of those 9 | +| 2 at tier 1, 1 at tier 2 | 2 | all 3 | +| 2 across all three tiers | 3 | both, since 2 is the minimum | + +Without that stopping rule, a cap of six would reliably drag in tier-3 schools +ten miles away to fill a row that three good matches had already earned. The old +cap of three hid this; six exposes it, which is why the rule is stated rather +than left to the loop. **Faith relaxes before gender.** A faith mismatch changes the character of a school; a gender mismatch can mean the school is not available to the reader's @@ -105,12 +125,11 @@ the promise in the heading and a reader scanning the row reads the first card as the closest. A tier-2 school at 0.4 miles therefore appears above a tier-1 school at 2.9 miles, and the chips explain the difference in match quality. -**More than three qualifying schools are dropped, not paginated.** In a dense -urban area dozens of schools clear tier 1, and the section shows the three -nearest of them. There is no "show more" and no count of what was left out, -because `NearbyPlaces` sits directly beneath and already answers "more schools -near here" by linking to the place pages — which are the pages built for -browsing a full list, and which the school page exists to feed. +**Past the sixth school, the rest are dropped without a count.** In inner +London dozens clear tier 1, and a parent there will notice three is not the +neighbourhood — hence six. Beyond that the section does not try to be the list: +`NearbyPlaces` sits directly beneath and already leads to the place pages, which +are built for browsing a full set and which the school page exists to feed. **Fewer than two results renders nothing.** Not an empty state, not a single lonely card, not padding with schools that failed the hard filters. The section @@ -157,7 +176,7 @@ lede may claim a similar intake, and whether a chip renders as a brand-tinted fill or a muted outline — and inferring it from chip count would couple those decisions to the copy. -Three rows of roughly 130 bytes each. It rides in the existing detail payload +Up to six rows of roughly 130 bytes each. It rides in the existing detail payload rather than a new endpoint because the page already makes exactly one server fetch for its data, and `/school/[slug]` regenerates at most weekly (`revalidate = 604800`), so the per-request cost is paid once per school per @@ -180,14 +199,50 @@ the shared `Section` shell from `sectionShared.tsx`. It renders the heading, the lede, the card grid, the footer CTA and one caption line. Every card's title is an `
` to the school's canonical slug URL via `schoolUrl()`. -`components/school/AddToCompareButton.tsx` — the only `'use client'` file this -adds, and the only client JavaScript in the section. It calls `addSchool` from +`components/school/AddToCompareButton.tsx` — calls `addSchool` from `ComparisonProvider` and reports the selection with a `from: 'similar_schools'` attribution, mirroring `addSchoolFromSearch` in `HomeView.tsx:442`. +`components/school/SimilarSchoolsCarousel.tsx` — the scroller and its arrows. It +takes the server-rendered cards as `children` and the server-rendered heading and +lede as a `header` prop, so those stay server components while the client +component owns only the ref, the scroll handler and the arrows' disabled state. + The split matters: the links — the part with SEO value and the part that must work without JavaScript — are server-rendered into the initial HTML, and only -the basket interaction is hydrated. +the basket interaction and the arrows are hydrated. + +### The carousel + +**Every card is in the initial HTML.** The arrows scroll a list; they never swap +a view. Six `` elements are in the markup whether or not anything is +hydrated, which is the whole reason the section exists — a paginated widget that +mounts cards on click would put four of the six links beyond a crawler and +beyond a reader with no JavaScript. + +So the scroller is a plain overflowing `