fix(webapp): the page no longer glides under a phone tap (J5's lost taps, #875) #882

Merged
claude-bot merged 1 commit from fix/j5-phone-flake into main 2026-09-27 06:05:52 +00:00
Collaborator

Closes #875.

The cause

.book__main, the app's scroll container, had scroll-behavior: smooth. That animated every scroll of the page, including:

  • the browser's own scroll to whatever takes focus;
  • a restored position;
  • a test driver's or screen reader's scroll to the control it's about to press.

On a phone, Chrome builds the click from the tap after touchend and hit-tests it at that moment. If the page is still gliding, the click lands on whatever slid under the finger. So Strike didn't strike and Reword didn't open.

On desktop the click goes out with the mouse-up, before the first animation frame, which is why only the phone project failed.

Evidence comes from traces plus a scroll and input event log, recorded with local-only instrumentation:

  • Pressing Enter on the "1 table note" fold starts an animated scroll of about 830 ms.
  • The Strike tap landed where the button was before the scroll finished. The next scroll started 15 ms after the click.
  • On main each run showed 10–11 retries of the "not stable" / "outside the viewport" kind. With the fix there were 0 in all 10 runs.

:105 ("Accurate") follows from the first failure. It only failed on a retry. A retry re-runs the serial block with Priya's answer already saved, and pressing it again takes the answer back by design. So once :213 fails, a failing :105 retry is guaranteed; it isn't a second bug.

Honest limit: we never reproduced a red run locally, even under stress and with CPU throttling. The mechanism comes from traces and the event log. The margin was about 15 ms locally, which is thin enough that CI's loaded runners would lose some taps.

The fix

  • AppShell.css: removed scroll-behavior: smooth and its reduced-motion override, with a comment saying why.
  • New ui/motion.js, exported from the kit: jumpTo(el) glides for a jump the reader asked for, or jumps at once under prefers-reduced-motion. It's used for:
    • Contents hash jumps (SessionPage, PrepSheet, Planning);
    • the transcript's "at 1:09:12" citation jump.
  • Every other scroll is now instant. That covers focus scrolls, scroll-to-top on a page change, Back's restored position, and scrolling to a field with an error. This is a small visible change, and a departure from lane 0's smooth scroll in the shell.

Tests

  • ui/motion.test.js:
    • asserts no stylesheet under src/ sets scroll-behavior: smooth. It fails against main's AppShell.css.
    • four jumpTo cases.
  • J5 on phone: 10 of 10 consecutive local runs green.
  • One full local journeys run: 110 passed, with the usual 7 skips.
  • Full vitest suite: 2608 passed. eslint reports 0 errors.
  • Changelog: a changelog.d/ fragment.

🤖 Generated with Claude Code

Closes #875. ## The cause `.book__main`, the app's scroll container, had `scroll-behavior: smooth`. That animated **every** scroll of the page, including: - the browser's own scroll to whatever takes focus; - a restored position; - a test driver's or screen reader's scroll to the control it's about to press. On a phone, Chrome builds the click from the tap after `touchend` and hit-tests it at that moment. If the page is still gliding, the click lands on whatever slid under the finger. So Strike didn't strike and Reword didn't open. On desktop the click goes out with the mouse-up, before the first animation frame, which is why only the phone project failed. **Evidence** comes from traces plus a scroll and input event log, recorded with local-only instrumentation: - Pressing Enter on the "1 table note" fold starts an animated scroll of about 830 ms. - The Strike tap landed where the button was *before* the scroll finished. The next scroll started 15 ms after the click. - On main each run showed 10–11 retries of the "not stable" / "outside the viewport" kind. With the fix there were **0 in all 10 runs**. **`:105` ("Accurate") follows from the first failure.** It only failed on a retry. A retry re-runs the serial block with Priya's answer already saved, and pressing it again takes the answer back by design. So once `:213` fails, a failing `:105` retry is guaranteed; it isn't a second bug. **Honest limit:** we never reproduced a red run locally, even under stress and with CPU throttling. The mechanism comes from traces and the event log. The margin was about 15 ms locally, which is thin enough that CI's loaded runners would lose some taps. ## The fix - **`AppShell.css`:** removed `scroll-behavior: smooth` and its reduced-motion override, with a comment saying why. - **New `ui/motion.js`**, exported from the kit: `jumpTo(el)` glides for a jump the reader asked for, or jumps at once under `prefers-reduced-motion`. It's used for: - Contents hash jumps (SessionPage, PrepSheet, Planning); - the transcript's "at 1:09:12" citation jump. - **Every other scroll is now instant.** That covers focus scrolls, scroll-to-top on a page change, Back's restored position, and scrolling to a field with an error. This is a small visible change, and a departure from lane 0's smooth scroll in the shell. ## Tests - **`ui/motion.test.js`:** - asserts no stylesheet under `src/` sets `scroll-behavior: smooth`. It **fails against main's `AppShell.css`**. - four `jumpTo` cases. - **J5 on phone:** 10 of 10 consecutive local runs green. - **One full local journeys run:** 110 passed, with the usual 7 skips. - **Full vitest suite:** 2608 passed. eslint reports 0 errors. - **Changelog:** a `changelog.d/` fragment. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(webapp): the page no longer glides under a phone tap (J5's lost taps)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m0s
CI / Bot tests and audit (pull_request) Successful in 2m6s
CI / Backend lint (ruff) (pull_request) Successful in 32s
CI / Feedback site tests (pull_request) Successful in 46s
CI / Bot/backend version sync (pull_request) Successful in 28s
CI / Does this change touch the recording path? (pull_request) Successful in 30s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Docker image build (pull_request) Successful in 2m38s
CI / Frontend tests, audit, and build (pull_request) Successful in 21m14s
CI / Backend migration, tests, and audit (pull_request) Successful in 16m50s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 22m8s
cd046d033c
J5 failed on the phone project only, intermittently: Strike did not strike,
Reword opened no box, and "Accurate" stayed unpressed. The taps were lost.

`.book__main`, the page's scroller, had `scroll-behavior: smooth`, which
animates every scroll of it — the browser's own scroll to whatever takes
focus, a restored position, and the scroll to the button about to be
pressed. A phone's click is synthesised from the tap gesture a moment after
the finger lifts, and hit-tested then, so while the page was still gliding it
landed on whatever had slid under the finger. A desktop click is dispatched
with the mouse-up, before the first animation frame, which is why only the
phone lost them. The journeys' traces show it directly: each phone tap in
J5's events section waited through a 0.5-0.8s animated scroll, and a new one
began 15ms after the Strike click. Under CI's load the click comes later
than that.

Scrolls of the Book are now immediate. A jump the reader asked for — a
Contents entry, an event's "at 1:09:12" — still glides, through `jumpTo`
(ui/motion.js), and goes at once for a reader who prefers reduced motion.

A test pins that no stylesheet sets `scroll-behavior: smooth`; it fails on
main's AppShell.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.