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:
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.
|
crawlable `<a>` in the server-rendered markup.
|
||||||
- **Arrow edge tests use an 8px tolerance, never `=== 0`.** The scroller's 2px
|
- **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.
|
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.
|
- **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,
|
- **Hard filters never relax:** self, non-open status, missing coordinates,
|
||||||
different phase group, special↔mainstream, selective↔non-selective,
|
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 { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
import styles from './SimilarSchools.module.css';
|
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;
|
const VISIBLE = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1170,6 +1179,8 @@ export function SimilarSchoolsCarousel({
|
|||||||
setAtStart(node.scrollLeft <= EDGE);
|
setAtStart(node.scrollLeft <= EDGE);
|
||||||
setAtEnd(node.scrollLeft >= max - 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.
|
// Also on mount: the first measurement can only happen once there is layout.
|
||||||
useEffect(sync, [sync]);
|
useEffect(sync, [sync]);
|
||||||
@@ -1213,6 +1224,7 @@ export function SimilarSchoolsCarousel({
|
|||||||
ref={scroller}
|
ref={scroller}
|
||||||
className={styles.scroller}
|
className={styles.scroller}
|
||||||
onScroll={sync}
|
onScroll={sync}
|
||||||
|
data-at-end={atEnd}
|
||||||
{...(scrollable
|
{...(scrollable
|
||||||
? { tabIndex: 0, role: 'group', 'aria-labelledby': labelledBy }
|
? { 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; }
|
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||||
.arrows { display: flex; gap: 0.5rem; flex: none; }
|
.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:hover:not(:disabled) { border-color: var(--brand); background: var(--brand-bg); }
|
||||||
.arrow:disabled { opacity: 0.35; cursor: default; }
|
.arrow:disabled { opacity: 0.35; cursor: default; }
|
||||||
.arrow svg { width: 17px; height: 17px; }
|
.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 { 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; }
|
.scroller::-webkit-scrollbar { display: none; }
|
||||||
@media (max-width: 820px) { .scroller { grid-auto-columns: calc((100% - 0.9rem) / 2); } }
|
@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 { 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); }
|
.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); }
|
.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); }
|
.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:hover { border-color: var(--brand); background: var(--brand-bg); }
|
||||||
.add[aria-pressed="true"] { border-color: var(--brand); background: var(--brand-bg); font-weight: 600; }
|
.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
|
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
|
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
|
provable in the PR checks. Verify the PR on the unit suites, and check the
|
||||||
post-merge staging run.
|
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
|
In `docs/ARCHITECTURE.md`, under "Frontend boundaries", after the sentence about
|
||||||
`components/school/`, add:
|
`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.
|
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:
|
Run:
|
||||||
```sh
|
```sh
|
||||||
@@ -1722,11 +1830,11 @@ cd nextjs-app && npm run typecheck && npm test -- --runInBand
|
|||||||
```
|
```
|
||||||
Expected: PASS on all three.
|
Expected: PASS on all three.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit and open the PR**
|
- [ ] **Step 7: Commit and open the PR**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add e2e/tests/journeys.spec.ts docs/ARCHITECTURE.md
|
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>"
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
|
||||||
git push -u origin feat/similar-schools-nearby
|
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
|
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
|
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
|
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
|
trackpad. Three cards are visible at desktop width and two below 820px.
|
||||||
below 560px, where touch swiping makes the arrows redundant but harmless.
|
|
||||||
|
|
||||||
**Arrows appear only when there is somewhere to go** — that is, only when more
|
**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
|
than three schools were found. Each disables itself at its own end of the
|
||||||
travel.
|
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
|
**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
|
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
|
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
|
moves the row
|
||||||
- selecting a school does not reset the scroll position
|
- selecting a school does not reset the scroll position
|
||||||
- add-to-compare reaches `/compare` with the expected `urns`
|
- 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
|
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
|
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 { 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 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; }
|
.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); }
|
.theme-switch:hover { border-color:var(--brand); color:var(--brand); }
|
||||||
|
|
||||||
/* ── The page context each variant is shown inside ─────────────────── */
|
/* ── The page context each variant is shown inside ─────────────────── */
|
||||||
@@ -83,7 +83,7 @@ body {
|
|||||||
plain horizontally scrollable row. */
|
plain horizontally scrollable row. */
|
||||||
.arrows { display:flex; gap:8px; flex:none; }
|
.arrows { display:flex; gap:8px; flex:none; }
|
||||||
.arrow {
|
.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;
|
border:1px solid var(--border-strong); border-radius:999px;
|
||||||
background:var(--bg-card); color:var(--brand);
|
background:var(--bg-card); color:var(--brand);
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,17 @@ body {
|
|||||||
.scroller::-webkit-scrollbar { display:none; }
|
.scroller::-webkit-scrollbar { display:none; }
|
||||||
.scroller:focus-visible { outline:2px solid var(--brand); outline-offset:4px; border-radius:var(--radius-md); }
|
.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: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 {
|
.school {
|
||||||
position:relative; display:flex; flex-direction:column; scroll-snap-align:start;
|
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 .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); }
|
.metric .ref { margin:2px 0 0; font-size:12px; color:var(--text-muted); }
|
||||||
.add {
|
.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);
|
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);
|
border:1px solid var(--border-strong); background:var(--bg-card); color:var(--brand);
|
||||||
}
|
}
|
||||||
@@ -307,12 +317,19 @@ function render() {
|
|||||||
const EDGE = 8;
|
const EDGE = 8;
|
||||||
|
|
||||||
function syncArrows(id, scroller) {
|
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 prev = document.querySelector(`.arrow[data-scroll="prev"][data-variant="${id}"]`);
|
||||||
const next = document.querySelector(`.arrow[data-scroll="next"][data-variant="${id}"]`);
|
const next = document.querySelector(`.arrow[data-scroll="next"][data-variant="${id}"]`);
|
||||||
if (!prev || !next) return;
|
if (!prev || !next) return;
|
||||||
const max = scroller.scrollWidth - scroller.clientWidth;
|
prev.disabled = atStart;
|
||||||
prev.disabled = scroller.scrollLeft <= EDGE;
|
next.disabled = atEnd;
|
||||||
next.disabled = scroller.scrollLeft >= max - EDGE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('variants').addEventListener('click', (event) => {
|
document.getElementById('variants').addEventListener('click', (event) => {
|
||||||
|
|||||||
Reference in new issue
Block a user