feat: similar schools nearby on the detail page #150
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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user