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 c4d0580..6eb0929 100644 --- a/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md +++ b/docs/superpowers/plans/2026-09-21-similar-schools-nearby.md @@ -36,6 +36,14 @@ Playwright. 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. +- **[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 " git push -u origin feat/similar-schools-nearby 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 9c78a5b..bb65fb8 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 @@ -223,13 +223,38 @@ beyond a reader with no JavaScript. So the scroller is a plain overflowing `