feat: similar schools nearby on the detail page #150

Merged
tudor merged 12 commits from feat/similar-schools-nearby into main 2026-09-22 05:53:07 +00:00
3 changed files with 170 additions and 18 deletions
Showing only changes of commit 8a23e3657d - Show all commits

No files matched your search

@@ -36,6 +36,14 @@ Playwright.
crawlable `<a>` 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.
- **[MOBILE.md](../../../MOBILE.md) is binding.** Design at 360px first and
verify at 360 / 390 / 430px before the PR. Its checks: zero horizontal
overflow (`document.documentElement.scrollWidth - innerWidth === 0`), every
interactive element ≥44×44px, no visible text under 11px.
- **Below 640px the arrows are not rendered.** One card at 86% width, and the
right-edge scroll-fade mask MOBILE.md documents carries the affordance. At
360px two arrow buttons take 96px from a 328px card and crush the lede into
four lines, for a control swiping already provides.
- **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,
@@ -1133,7 +1141,8 @@ Create `nextjs-app/components/school/SimilarSchoolsCarousel.tsx`:
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. */
/** Three cards fit the row, so fewer than four has nowhere to scroll to.
* Below 640px the arrows are not rendered at all — see the stylesheet. */
const VISIBLE = 3;
/**
@@ -1170,6 +1179,8 @@ export function SimilarSchoolsCarousel({
setAtStart(node.scrollLeft <= EDGE);
setAtEnd(node.scrollLeft >= max - EDGE);
}, []);
// `atEnd` is not only the forward arrow's disabled state: below 640px, where
// no arrow is rendered, it is the only thing driving the scroll-fade.
// Also on mount: the first measurement can only happen once there is layout.
useEffect(sync, [sync]);
@@ -1213,6 +1224,7 @@ export function SimilarSchoolsCarousel({
ref={scroller}
className={styles.scroller}
onScroll={sync}
data-at-end={atEnd}
{...(scrollable
? { tabIndex: 0, role: 'group', 'aria-labelledby': labelledBy }
: {})}
@@ -1387,7 +1399,7 @@ Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only —
.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 { width: 44px; height: 44px; 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; }
@@ -1398,7 +1410,16 @@ Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only —
.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%; } }
/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card and
crush the lede into four lines, for a control swiping already provides. They
go, and the documented right-edge fade carries the affordance — lifting at
the end of the travel, where there is nothing more to hint at. */
@media (max-width: 640px) {
.top { display: block; }
.arrows { display: none; }
.scroller { grid-auto-columns: 86%; mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); }
.scroller[data-at-end="true"] { mask-image: none; }
}
.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); }
@@ -1424,7 +1445,7 @@ Create `nextjs-app/components/school/SimilarSchools.module.css`. Tokens only —
.metricLabel { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--text-secondary); }
.metricRef { margin: 0.1rem 0 0; font-size: 0.75rem; color: var(--text-muted); }
.add { position: relative; z-index: 1; margin-top: 0.85rem; width: 100%; min-height: 40px; font: inherit; font-size: 0.82rem; font-weight: 500; cursor: pointer; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--bg-card); color: var(--brand); }
.add { position: relative; z-index: 1; margin-top: 0.85rem; width: 100%; min-height: 44px; font: inherit; font-size: 0.82rem; font-weight: 500; cursor: pointer; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--bg-card); color: var(--brand); }
.add:hover { border-color: var(--brand); background: var(--brand-bg); }
.add[aria-pressed="true"] { border-color: var(--brand); background: var(--brand-bg); font-weight: 600; }
@@ -1693,14 +1714,101 @@ test('similar schools link on to other schools and into compare', async ({ page
});
```
- [ ] **Step 2: Note the staging gate**
- [ ] **Step 2: Add the mobile journey**
Append to `e2e/tests/journeys.spec.ts`, after the journey above:
```ts
/**
* The section at MOBILE.md's three reference widths.
*
* MOBILE.md asks for exactly this check and records that it was not written
* because "Playwright isn't currently in the project dependency set". That is
* no longer true — this suite is Playwright — so the check exists now, scoped
* to the page this feature touches.
*/
for (const width of [360, 390, 430]) {
test(`similar schools survives a ${width}px viewport`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 });
await searchByName(page, 'Primary');
await schoolLinks(page).first().click();
await page.waitForURL(/\/school\//);
const section = page.locator('#similar');
if ((await section.count()) === 0) {
test.skip(true, 'No qualifying similar schools for this school');
}
// 1. Nothing bleeds past the right edge.
expect(
await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth),
).toBe(0);
// 2. No arrows on touch widths — swiping does the job, and they would take
// 96px from a 328px card.
await expect(section.getByRole('button', { name: 'More schools' })).toHaveCount(0);
// 3. Every tap target in the section clears 44px. A card title's own box is
// shorter, but its hit area is the whole card via ::after.
const failing = await section.evaluate((root: HTMLElement) =>
Array.from(root.querySelectorAll('a, button'))
.filter((el) => (el as HTMLElement).offsetParent)
.map((el) => {
const card = el.closest('li');
const box = el.matches('h3 a') && card
? card.getBoundingClientRect()
: el.getBoundingClientRect();
return { text: (el as HTMLElement).innerText.trim().slice(0, 24), w: box.width, h: box.height };
})
.filter((o) => o.w < 44 || o.h < 44),
);
expect(failing).toEqual([]);
});
}
```
- [ ] **Step 3: Verify the three reference widths by hand**
MOBILE.md requires this before any PR that touches user-visible UI, and it is
the check that caught the arrows crushing the lede at 360px in the first place.
Do not start the app for this. Open `mockups/similar-schools-nearby.html`, which
carries the same stylesheet rules, and run MOBILE.md's own probes at 360, 390
and 430px:
```js
// 1. No horizontal overflow — must be 0 at each width.
document.documentElement.scrollWidth - innerWidth
// 2. Every interactive element ≥44×44px. A card title reports a short box but
// its hit area is the whole card via ::after, so measure the card for those.
Array.from(document.querySelector('.card').querySelectorAll('a, button'))
.filter((el) => el.offsetParent)
.map((el) => {
const card = el.closest('.school');
const box = el.matches('h3 a') && card
? card.getBoundingClientRect()
: el.getBoundingClientRect();
return { t: el.innerText.trim().slice(0, 24), w: box.width, h: box.height };
})
.filter((o) => o.w < 44 || o.h < 44)
// 3. No arrows below 640px, and the fade present until the end of the travel.
document.querySelector('.arrows')?.offsetParent
getComputedStyle(document.querySelector('.scroller')).maskImage
```
Expected: `0` overflow, an empty array of failing targets, no visible arrows,
and a mask that is present at rest and `none` once `data-at-end="true"`.
- [ ] **Step 4: Note the staging gate**
Do **not** try to run this journey locally against a dev server. On this
project the E2E gate runs against staging *after* merge, so this journey is not
provable in the PR checks. Verify the PR on the unit suites, and check the
post-merge staging run.
- [ ] **Step 3: Document the section**
- [ ] **Step 5: Document the section**
In `docs/ARCHITECTURE.md`, under "Frontend boundaries", after the sentence about
`components/school/`, add:
@@ -1713,7 +1821,7 @@ preferences that do (religious character, then gender exactness) — and served
kept out of `marts.*` so they can be tuned by deploy rather than by pipeline run.
```
- [ ] **Step 4: Run every check before the PR**
- [ ] **Step 6: Run every check before the PR**
Run:
```sh
@@ -1722,11 +1830,11 @@ cd nextjs-app && npm run typecheck && npm test -- --runInBand
```
Expected: PASS on all three.
- [ ] **Step 5: Commit and open the PR**
- [ ] **Step 7: Commit and open the PR**
```bash
git add e2e/tests/journeys.spec.ts docs/ARCHITECTURE.md
git commit -m "test(e2e): cover the similar-schools section and compare hand-off
git commit -m "test(e2e): cover the similar-schools section, compare hand-off and mobile widths
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
git push -u origin feat/similar-schools-nearby
@@ -223,13 +223,38 @@ beyond a reader with no JavaScript.
So the scroller is a plain overflowing `<ul>` with `scroll-snap-type: x
mandatory`, and the arrows call `scrollBy` on it. With no JavaScript it
degrades to a horizontally scrollable row that still works by touch and by
trackpad. Three cards are visible at desktop width, two below 820px, and one
below 560px, where touch swiping makes the arrows redundant but harmless.
trackpad. Three cards are visible at desktop width and two below 820px.
**Arrows appear only when there is somewhere to go** — that is, only when more
than three schools were found. Each disables itself at its own end of the
travel.
#### Below 640px the arrows go away
This follows [MOBILE.md](../../../MOBILE.md), which makes 360px the design
floor and mobile the primary target at ≥55% of traffic.
Kept in the heading's flex row at 360px, the two arrow buttons take 96px from a
328px card and crush the lede into a four-line column — measured, not guessed.
And swiping already does what they do. So below 640px the header becomes a
single column, the arrows are not rendered, one card shows at 86% width so the
next one peeks, and the affordance is carried by the right-edge scroll-fade that
MOBILE.md documents for exactly this case:
```css
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
```
The fade lifts at the end of the travel, where there is nothing left to hint
at. That means the at-end state must be computed whether or not an arrow exists
to consume it — on mobile it drives the mask alone.
**Every interactive element clears 44×44px**, per MOBILE.md's iOS HIG check: the
arrow buttons and the add-to-compare button are both 44px, up from the 40px they
were first drawn at. A card title's own box is shorter than that, but its hit
area is the whole card through the `::after` overlay, so it passes on the target
that actually receives the tap.
**The edge test needs a tolerance, and this is not fussiness.** 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 scroller sitting at its start
@@ -355,6 +380,8 @@ repository's rule on user-facing behaviour:
moves the row
- selecting a school does not reset the scroll position
- add-to-compare reaches `/compare` with the expected `urns`
- at 360, 390 and 430px: no horizontal overflow, every interactive element in the
section clears 44×44px, and no arrows are rendered
The E2E gate runs after merge on this project, so these journeys are not
provable in the PR checks; the PR is verified on the unit tests, and the
+24 -7
View File
@@ -56,7 +56,7 @@ body {
.page > header { margin-bottom:28px; display:flex; flex-wrap:wrap; gap:16px; align-items:flex-start; justify-content:space-between; }
.page > header h1 { font:700 25px/1.25 var(--font-display); letter-spacing:-.6px; margin:0 0 6px; }
.page > header p { margin:0; color:var(--text-muted); font-size:14px; max-width:60ch; }
.theme-switch { border:1px solid var(--border-strong); background:var(--bg-card); color:var(--text-secondary); border-radius:999px; padding:8px 14px; font:500 13px var(--font-ui); cursor:pointer; min-height:40px; }
.theme-switch { border:1px solid var(--border-strong); background:var(--bg-card); color:var(--text-secondary); border-radius:999px; padding:8px 14px; font:500 13px var(--font-ui); cursor:pointer; min-height:44px; }
.theme-switch:hover { border-color:var(--brand); color:var(--brand); }
/* ── The page context each variant is shown inside ─────────────────── */
@@ -83,7 +83,7 @@ body {
plain horizontally scrollable row. */
.arrows { display:flex; gap:8px; flex:none; }
.arrow {
width:40px; height:40px; display:grid; place-items:center; cursor:pointer;
width:44px; height:44px; display:grid; place-items:center; cursor:pointer;
border:1px solid var(--border-strong); border-radius:999px;
background:var(--bg-card); color:var(--brand);
}
@@ -103,7 +103,17 @@ body {
.scroller::-webkit-scrollbar { display:none; }
.scroller:focus-visible { outline:2px solid var(--brand); outline-offset:4px; border-radius:var(--radius-md); }
@media (max-width:820px) { .scroller { grid-auto-columns:calc((100% - 14px) / 2); } }
@media (max-width:560px) { .scroller { grid-auto-columns:86%; } .card { padding:20px; } }
/* Touch widths: the arrows would squeeze the lede into a four-line column for a
control that swiping already provides, so they go and the documented
right-edge fade carries the affordance instead (MOBILE.md). The fade lifts at
the end of the travel, where there is nothing more to hint at. */
@media (max-width:640px) {
.top { display:block; }
.arrows { display:none; }
.scroller { grid-auto-columns:86%; mask-image:linear-gradient(to right, #000 calc(100% - 28px), transparent); }
.scroller[data-at-end=true] { mask-image:none; }
.card { padding:20px; }
}
.school {
position:relative; display:flex; flex-direction:column; scroll-snap-align:start;
@@ -132,7 +142,7 @@ body {
.metric .label { margin:4px 0 0; font-size:12px; color:var(--text-secondary); }
.metric .ref { margin:2px 0 0; font-size:12px; color:var(--text-muted); }
.add {
position:relative; z-index:1; margin-top:14px; width:100%; min-height:40px;
position:relative; z-index:1; margin-top:14px; width:100%; min-height:44px;
font:500 13px var(--font-ui); cursor:pointer; border-radius:var(--radius-md);
border:1px solid var(--border-strong); background:var(--bg-card); color:var(--brand);
}
@@ -307,12 +317,19 @@ function render() {
const EDGE = 8;
function syncArrows(id, scroller) {
const max = scroller.scrollWidth - scroller.clientWidth;
const atStart = scroller.scrollLeft <= EDGE;
const atEnd = scroller.scrollLeft >= max - EDGE;
// Drives the mobile scroll-fade, so it is computed even where no arrow is
// rendered to consume it.
scroller.dataset.atEnd = String(atEnd);
const prev = document.querySelector(`.arrow[data-scroll="prev"][data-variant="${id}"]`);
const next = document.querySelector(`.arrow[data-scroll="next"][data-variant="${id}"]`);
if (!prev || !next) return;
const max = scroller.scrollWidth - scroller.clientWidth;
prev.disabled = scroller.scrollLeft <= EDGE;
next.disabled = scroller.scrollLeft >= max - EDGE;
prev.disabled = atStart;
next.disabled = atEnd;
}
document.getElementById('variants').addEventListener('click', (event) => {