docs: meet the mobile baseline, which the carousel was not doing

Checked the section against MOBILE.md at its three reference widths instead
of assuming the breakpoints were enough. Two real failures at 360px.

The arrows sat in the heading's flex row, taking 96px from a 328px card and
crushing the lede into a four-line column — for a control that swiping
already provides. Below 640px they are now gone, the header is a single
column, one card shows at 86% so the next one peeks, and the affordance is
the right-edge scroll-fade MOBILE.md already documents for horizontal
scrollers. The fade lifts at the end of the travel, so the at-end state is
computed whether or not an arrow exists to consume it.

The arrow and add-to-compare buttons were 40px against a 44px floor. Both
are 44 now. A card title's own box is shorter, but its hit area is the whole
card through the ::after overlay, so it passes on the target that actually
receives the tap.

360, 390 and 430 now all report zero overflow, no failing tap targets and no
text under 11px. MOBILE.md wanted a Playwright width check and recorded that
Playwright was not in the project; it is, so the journey now carries one for
this page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5 committed 2026-09-21 22:37:34 +01:00
1 parent e4e8f02599
commit 8a23e3657d
3 files changed
+170 -18

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