fix(web): the page never zooms on a pinch; rows never swipe while selecting or with two fingers #1003

Closed
kayg wants to merge 8 commits from t3/prevent-pinch-zoom-swipe into dev
Owner

Fixes both bugs kayg reported on prod from the iPhone (11-07 ~11:45 IST): the page zooms on a pinch, and a row half-swipes while two fingers select. Rules from kayg: "pinching should be bound to contextual actions and if there's none, to no action" and "selected items cannot be horizontally swiped as the bottom bar is already showing contextual items". Rebased on dev 98f14a3d1 (= prod). Head 9401f7134.

Bug 1: pinch zooms the whole page

  • touch-action: pan-x pan-y on html (packages/ui/src/styles/base.css): no pinch-zoom or double-tap zoom from any element on Chrome / Firefox / Android. The buttons' own touch-action: manipulation is gone (redundant: the effective value is the intersection with html's).
  • Safari gesture cancel (packages/ui/src/lib/no-page-zoom.ts): gesturestart/change/end are cancelled unless the pinch begins inside [data-pinch]. It installs when the idle-screens chunk is evaluated, not when a screen mounts: Settings.svelte and SignIn.svelte call installNoPageZoom() from a <script module> block (module evaluation). That chunk is fetched right after first paint (warmUI on idle when signed in; signIn.load() as soon as auth says signed out), so the first paint pays nothing and nothing depends on Settings being mounted or opened. (Review point on f7511158: the call used to sit in the instance script. App.svelte does pre-mount Settings hidden on idle for signed-in users, and SignIn is never instantiated for them, so it worked, but only by that coupling; now it doesn't.)
  • Window before it is active, Mac VM, real Safari, 390×844, signed in (gw.mjs, polled every ~10–20 ms): cold first load 511 ms after navigation start (first contentful paint 301 ms, so ~210 ms after paint); warm reloads 186–288 ms (FCP 94–180 ms), i.e. ~90–110 ms after paint. Raw: comment below.
  • Gotcha, fixed on the way: a bare import "…/no-page-zoom" for its side effect is dropped by the build (the ui package declares only its CSS as having side effects), so the guard vanished in my first attempt and the byte count looked better than it was. It is an explicit call (from <script module>), documented in the module, and the dist was checked to contain it (idle-screens-*.js).
  • Measured on the iOS 27 simulator: the CSS alone does NOT stop it. A build with the CSS and without the gesture cancel zoomed the note page on a 2.5× pinch exactly like dev. The gesture cancel is what stops it on iOS.
  • No touch listener at all, so one-finger scrolling, text selection and the caret handles are untouched. Gesture events fire only while pinching.
  • Contextual pinch: there is no image/photo/file viewer in apps/web yet and no other pinch handler (the only one was the lists' lazy gesturestart handler in two-finger.ts, which this replaces). The contract for a future viewer: wear data-pinch, set touch-action: none, read the fingers yourself. Tested by no-page-zoom.spec.ts. Waiting for the viewer to exist (agreed).
  • Accessibility: browser text size, Safari's Page Zoom (aA menu), Android font scale and the app's own text-size setting are not pinches and keep working. Desktop ctrl+wheel (trackpad pinch) is the browser's own zoom and is untouched.

Bug 2: a row swipes while selecting

Root cause: when a second finger landed on a row that was already swiping, tstart set the gesture to null without finishing it, so the row stayed half open (the screenshot) and the list's shut() refuses to close a "swiping" row. Nothing stopped a swipe that began before selecting started.
Fix, in the shared row (apps/web/src/rows/swipe-row.ts, used by every RowActions list; today Notes, Journal and Trash, later Tasks and the rest):

  • A second finger (landing on this row, another row, or outside the list) puts the swipe back with a spring. abort() never runs an action, not even an armed long swipe (Delete).
  • While the list is selecting (handle.off(), a one-line getter), no swipe is claimed, selected or not; a swipe already moving goes back on its next move, or on let-go.
  • A system touchcancel puts the row back (it used to release, which could run a long-swipe Delete).
  • Axis lock unchanged and already stricter than asked: the shared claimsSwipe needs ≥10 px sideways, more than 2× the vertical, within 300 ms; multi-touch never starts one.

First paint: PR ≤ dev, in bytes (dev 98f14a3d1 vs PR 9401f7134, node scripts/bundle-size.mjs <dist> --json, builds are deterministic)

first paint dev PR delta
JS brotli 231,091 231,028 −63
CSS brotli 18,416 18,386 −30
JS gzip 271,847 271,746 −101
JS raw 814,403 814,071 −332
CSS raw 102,844 102,703 −141

perf/web.sh --bundle-only --gate (dev-02): exit 0; web.bundle.first_js_brotli 225.613 KB (≤ 240), first_css_brotli 17.955 KB (≤ 22.1), chunk.index 225.613 (≤ 226.25), total_js_brotli 471.895 (≤ 488.3). No budget line changed; perf/budgets.json is identical to dev.
Real cuts that fund the swipe change and the CSS declaration (no behaviour change; checked by grep over apps/web, apps/mockup, packages, docs):

  • Icon.svelte: the sun and moon paths (no <Icon> draws them; the sky draws its own discs) deleted; users (drawn only by Settings) moved behind it into icons-extra.ts (icons.test.ts passes).
  • swipe-open.ts: isOpen deleted (markShut already ignores a row that isn't the open one); Row.active was dead; one beat helper for the pill's two pulses.
  • tokens.css: --strong-weight (×2), --icon-bar, --chrome-ink, --ease-in-out, --r-control had no var() or string reference anywhere.
  • brotli on this chunk is chaotic (the minifier renames every identifier after a deletion; a build with −648 raw bytes came out +109 brotli, another with −332 came out −33), so the numbers above are of the exact head (moving the call to <script module> happened to land 30 B lower again). Left alone on purpose: the --s-1…--s-10 tokens and five block-type icons are also unused/lazy-only, but cutting them did not help the count, and the icons are shared with the mockup.

Gates

gate command result
new + touched specs, Chromium 1440+390 (dev-01) CALTERNAL_BIN=… bunx playwright test e2e/no-page-zoom.spec.ts e2e/swipe-guard.spec.ts e2e/swipe-rows.spec.ts e2e/select-rows.spec.ts e2e/touch-select.spec.ts e2e/icons-extra.spec.ts e2e/bar-taps.spec.ts e2e/pill-press.spec.ts --project=chromium-1440 --project=chromium-390 --workers=1 98 passed
same, WebKit 1440+390 (dev-02) same with --project=webkit-1440 --project=webkit-390 94 passed
control: new specs on dev's code the two new spec files with the fix reverted, WebKit 6 failed (incl. the stuck row), pass with the fix
bun run check / bun run lint / bun run test (apps/web) dev-02 0 errors (16 warnings, as dev) / 23 pass / 166 pass
bundle gate perf/web.sh --bundle-only --gate exit 0, first paint ≤ dev (above)
real iOS iOS 27 simulator on std pass, see comments
10k-note list scroll + typing, real Safari on the Mac VM pm.mjs ABAB ×3 on the final head flat, see below

Flake seen earlier, not caused here: touch-select.spec.ts:246 (the editor's block swipe, Chromium 390) failed intermittently on a first run on both dev and the PR (--repeat-each=30: PR 1–2, dev 1); did not recur in the final runs. The Chromium CDP pinch spec cannot fail by itself (Chromium's emulated pinch does not zoom even an unguarded page; Playwright WebKit cannot send a pinch), so the simulator is the real proof.

iOS simulator, cold, signed in, Settings never opened (final head 9401f7134)

Each on a fresh origin (no service-worker cache), loaded, ~7 s idle, then a real 2.5× pinch: note opened directly by /notes/<id>: no zoom; All-notes list: no zoom; Home Screen app (added from that URL, launched standalone): no zoom. (The standalone app starts on the daily note, not on the linked note: that is the app's start route; Settings was not opened in any of the three.) New spec no-page-zoom.spec.ts: a note opened straight by URL, Settings never opened, gets the guard (WebKit + Chromium). Screenshots in the comments below. Earlier simulator evidence (swipe, selection, second finger) is from f7511158; the row code did not change since.

iOS simulator earlier (iPhone 18 Pro, iOS 27, Safari, real fingers; head f75111586), screenshots 1206×2622 in the comments

dev zooms on a 2.5× pinch on a note page, the PR does not; a short swipe outside selection still opens the row; two fingers slid down select 3 notes and then left/right drags on selected and unselected rows move nothing; a second finger landing 0.25 s after a left swipe began leaves the row at rest. (On this simulator another agent's run once took the screen between my steps; that step was re-run.)

Perf (Mac VM, real Safari, 10,000 notes, 390×844, dev vs PR, ABAB ×3, fresh server per arm, final head)

Frame deltas (ms): scroll A p50 17 / p95 20 / p99 23 / max 25 / ≥50 ms 0; scroll B p50 17 / p95 20 / p99 23 / max 26 / ≥50 ms 0. Idle baseline A max 30, B max 32. Typing (keydown → second rAF after input, 171 keys per arm): A p50 19 / p95 30; B p50 20 / p95 30. Free memory ≥ 3 GB incl. inactive, swap 0. (dev-02 shares the Studio with this VM and was running its spec job during the guard-window timing, so those numbers are an upper bound.)

Open

  • Image/photo/file-viewer pinch demo: waits for the viewer (agreed).
  • Mail, Files, Contacts lists inherit the row fix when they use RowActions.
Fixes both bugs kayg reported on prod from the iPhone (11-07 ~11:45 IST): the page zooms on a pinch, and a row half-swipes while two fingers select. Rules from kayg: "pinching should be bound to contextual actions and if there's none, to no action" and "selected items cannot be horizontally swiped as the bottom bar is already showing contextual items". Rebased on dev `98f14a3d1` (= prod). Head `9401f7134`. ## Bug 1: pinch zooms the whole page - `touch-action: pan-x pan-y` on `html` (`packages/ui/src/styles/base.css`): no pinch-zoom or double-tap zoom from any element on Chrome / Firefox / Android. The buttons' own `touch-action: manipulation` is gone (redundant: the effective value is the intersection with html's). - Safari gesture cancel (`packages/ui/src/lib/no-page-zoom.ts`): `gesturestart/change/end` are cancelled unless the pinch begins inside `[data-pinch]`. **It installs when the `idle-screens` chunk is evaluated**, not when a screen mounts: `Settings.svelte` and `SignIn.svelte` call `installNoPageZoom()` from a `<script module>` block (module evaluation). That chunk is fetched right after first paint (`warmUI` on idle when signed in; `signIn.load()` as soon as auth says signed out), so the first paint pays nothing and nothing depends on Settings being mounted or opened. (Review point on `f7511158`: the call used to sit in the instance script. `App.svelte` does pre-mount Settings hidden on idle for signed-in users, and `SignIn` is never instantiated for them, so it worked, but only by that coupling; now it doesn't.) - **Window before it is active, Mac VM, real Safari, 390×844, signed in** (`gw.mjs`, polled every ~10–20 ms): cold first load **511 ms** after navigation start (first contentful paint 301 ms, so ~210 ms after paint); warm reloads **186–288 ms** (FCP 94–180 ms), i.e. ~90–110 ms after paint. Raw: comment below. - **Gotcha, fixed on the way:** a bare `import "…/no-page-zoom"` for its side effect is dropped by the build (the ui package declares only its CSS as having side effects), so the guard vanished in my first attempt and the byte count looked better than it was. It is an explicit call (from `<script module>`), documented in the module, and the dist was checked to contain it (`idle-screens-*.js`). - **Measured on the iOS 27 simulator: the CSS alone does NOT stop it.** A build with the CSS and without the gesture cancel zoomed the note page on a 2.5× pinch exactly like dev. The gesture cancel is what stops it on iOS. - No touch listener at all, so one-finger scrolling, text selection and the caret handles are untouched. Gesture events fire only while pinching. - Contextual pinch: there is no image/photo/file viewer in `apps/web` yet and no other pinch handler (the only one was the lists' lazy `gesturestart` handler in `two-finger.ts`, which this replaces). The contract for a future viewer: wear `data-pinch`, set `touch-action: none`, read the fingers yourself. Tested by `no-page-zoom.spec.ts`. Waiting for the viewer to exist (agreed). - Accessibility: browser text size, Safari's Page Zoom (aA menu), Android font scale and the app's own text-size setting are not pinches and keep working. Desktop ctrl+wheel (trackpad pinch) is the browser's own zoom and is untouched. ## Bug 2: a row swipes while selecting Root cause: when a second finger landed on a row that was already swiping, `tstart` set the gesture to `null` without finishing it, so the row stayed half open (the screenshot) and the list's `shut()` refuses to close a "swiping" row. Nothing stopped a swipe that began before selecting started. Fix, in the shared row (`apps/web/src/rows/swipe-row.ts`, used by every `RowActions` list; today Notes, Journal and Trash, later Tasks and the rest): - A second finger (landing on this row, another row, or outside the list) puts the swipe back with a spring. `abort()` never runs an action, not even an armed long swipe (Delete). - While the list is selecting (`handle.off()`, a one-line getter), no swipe is claimed, selected or not; a swipe already moving goes back on its next move, or on let-go. - A system `touchcancel` puts the row back (it used to release, which could run a long-swipe Delete). - Axis lock unchanged and already stricter than asked: the shared `claimsSwipe` needs ≥10 px sideways, more than **2×** the vertical, within 300 ms; multi-touch never starts one. ## First paint: PR ≤ dev, in bytes (dev `98f14a3d1` vs PR `9401f7134`, `node scripts/bundle-size.mjs <dist> --json`, builds are deterministic) | first paint | dev | PR | delta | |---|---|---|---| | JS brotli | 231,091 | 231,028 | **−63** | | CSS brotli | 18,416 | 18,386 | **−30** | | JS gzip | 271,847 | 271,746 | −101 | | JS raw | 814,403 | 814,071 | −332 | | CSS raw | 102,844 | 102,703 | −141 | `perf/web.sh --bundle-only --gate` (dev-02): **exit 0**; `web.bundle.first_js_brotli` 225.613 KB (≤ 240), `first_css_brotli` 17.955 KB (≤ 22.1), `chunk.index` 225.613 (≤ 226.25), `total_js_brotli` 471.895 (≤ 488.3). **No budget line changed; `perf/budgets.json` is identical to dev.** Real cuts that fund the swipe change and the CSS declaration (no behaviour change; checked by grep over `apps/web`, `apps/mockup`, `packages`, docs): - `Icon.svelte`: the `sun` and `moon` paths (no `<Icon>` draws them; the sky draws its own discs) deleted; `users` (drawn only by Settings) moved behind it into `icons-extra.ts` (`icons.test.ts` passes). - `swipe-open.ts`: `isOpen` deleted (`markShut` already ignores a row that isn't the open one); `Row.active` was dead; one `beat` helper for the pill's two pulses. - `tokens.css`: `--strong-weight` (×2), `--icon-bar`, `--chrome-ink`, `--ease-in-out`, `--r-control` had no `var()` or string reference anywhere. - brotli on this chunk is chaotic (the minifier renames every identifier after a deletion; a build with −648 raw bytes came out +109 brotli, another with −332 came out −33), so the numbers above are of the exact head (moving the call to `<script module>` happened to land 30 B lower again). Left alone on purpose: the `--s-1…--s-10` tokens and five block-type icons are also unused/lazy-only, but cutting them did not help the count, and the icons are shared with the mockup. ## Gates | gate | command | result | |---|---|---| | new + touched specs, Chromium 1440+390 (dev-01) | `CALTERNAL_BIN=… bunx playwright test e2e/no-page-zoom.spec.ts e2e/swipe-guard.spec.ts e2e/swipe-rows.spec.ts e2e/select-rows.spec.ts e2e/touch-select.spec.ts e2e/icons-extra.spec.ts e2e/bar-taps.spec.ts e2e/pill-press.spec.ts --project=chromium-1440 --project=chromium-390 --workers=1` | 98 passed | | same, WebKit 1440+390 (dev-02) | same with `--project=webkit-1440 --project=webkit-390` | 94 passed | | control: new specs on dev's code | the two new spec files with the fix reverted, WebKit | 6 failed (incl. the stuck row), pass with the fix | | `bun run check` / `bun run lint` / `bun run test` (apps/web) | dev-02 | 0 errors (16 warnings, as dev) / 23 pass / 166 pass | | bundle gate | `perf/web.sh --bundle-only --gate` | exit 0, first paint ≤ dev (above) | | real iOS | iOS 27 simulator on std | pass, see comments | | 10k-note list scroll + typing, real Safari on the Mac VM | `pm.mjs` ABAB ×3 on the final head | flat, see below | Flake seen earlier, not caused here: `touch-select.spec.ts:246` (the editor's block swipe, Chromium 390) failed intermittently on a first run on both dev and the PR (`--repeat-each=30`: PR 1–2, dev 1); did not recur in the final runs. The Chromium CDP pinch spec cannot fail by itself (Chromium's emulated pinch does not zoom even an unguarded page; Playwright WebKit cannot send a pinch), so the simulator is the real proof. ## iOS simulator, cold, signed in, Settings never opened (final head `9401f7134`) Each on a fresh origin (no service-worker cache), loaded, ~7 s idle, then a real 2.5× pinch: **note opened directly by `/notes/<id>`: no zoom; All-notes list: no zoom; Home Screen app (added from that URL, launched standalone): no zoom.** (The standalone app starts on the daily note, not on the linked note: that is the app's start route; Settings was not opened in any of the three.) New spec `no-page-zoom.spec.ts`: a note opened straight by URL, Settings never opened, gets the guard (WebKit + Chromium). Screenshots in the comments below. Earlier simulator evidence (swipe, selection, second finger) is from `f7511158`; the row code did not change since. ## iOS simulator earlier (iPhone 18 Pro, iOS 27, Safari, real fingers; head f75111586), screenshots 1206×2622 in the comments dev zooms on a 2.5× pinch on a note page, the PR does not; a short swipe outside selection still opens the row; two fingers slid down select 3 notes and then left/right drags on selected and unselected rows move nothing; a second finger landing 0.25 s after a left swipe began leaves the row at rest. (On this simulator another agent's run once took the screen between my steps; that step was re-run.) ## Perf (Mac VM, real Safari, 10,000 notes, 390×844, dev vs PR, ABAB ×3, fresh server per arm, final head) Frame deltas (ms): scroll A p50 17 / p95 20 / p99 23 / max 25 / ≥50 ms 0; scroll B p50 17 / p95 20 / p99 23 / max 26 / ≥50 ms 0. Idle baseline A max 30, B max 32. Typing (keydown → second rAF after `input`, 171 keys per arm): A p50 19 / p95 30; B p50 20 / p95 30. Free memory ≥ 3 GB incl. inactive, swap 0. (dev-02 shares the Studio with this VM and was running its spec job during the guard-window timing, so those numbers are an upper bound.) ## Open - Image/photo/file-viewer pinch demo: waits for the viewer (agreed). - Mail, Files, Contacts lists inherit the row fix when they use `RowActions`.
A second finger landing mid-swipe used to drop the gesture without finishing it, leaving the row
stuck half open under the selection tint. Now the row goes back (no action runs), the claim is
refused while the list is being selected, and beginSelect aborts a swipe in progress.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
touch-action: pan-x pan-y on html, plus a cancel of Safari's gesture events for anything that
doesn't wear data-pinch. Replaces the lists' own gesturestart handler (lazy, so a pinch before it
loaded still zoomed the page).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Selecting is read through a cheap handle.off() (no per-move config), the dead Row.active export goes,
and the gesture cancel is one short loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
perf(web): fetch the pinch guard right after the first paint, not in it
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled
9042c77c34
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

iOS 27 simulator, DEV build (origin/dev 792b335), note page, 2.5× pinch out: the page zooms (left: before pinch, right: after).

**iOS 27 simulator, DEV build** (origin/dev 792b335), note page, 2.5× pinch out: the page zooms (left: before pinch, right: after).
Author
Owner

iOS 27 simulator, PR build 9042c77c3, same note page and same 2.5× pinch: nothing moves (left before, right after).

**iOS 27 simulator, PR build 9042c77c3**, same note page and same 2.5× pinch: nothing moves (left before, right after).
Author
Owner

PR build: a short left swipe outside selection still opens the row (Delete shown).

**PR build**: a short left swipe outside selection still opens the row (Delete shown).
Author
Owner

PR build: two real fingers slid down select 3 notes; then a left drag on a selected row, a right drag on a selected row, a left drag on an unselected row: no row moves (in this order).

**PR build**: two real fingers slid down select 3 notes; then a left drag on a selected row, a right drag on a selected row, a left drag on an unselected row: no row moves (in this order).
Author
Owner

PR build: a left swipe started, a second finger landed 0.25 s later: the row is back at rest (left: before, right: after).

**PR build**: a left swipe started, a second finger landed 0.25 s later: the row is back at rest (left: before, right: after).
Author
Owner

Mac VM real-Safari run (10k notes, ABAB ×3), raw output of the throwaway pm.mjs (rAF frame deltas in ms; the script is not committed):

Pages free:                                    42967.
vm.swapusage: total = 0.00M  used = 0.00M  free = 0.00M  (encrypted)
viewport [ 390, 844, 1 ]
round 0 A rows 20 scroll ms 7132 pos [0,660366]
round 0 B rows 20 scroll ms 7132 pos [0,660366]
round 1 A rows 20 scroll ms 7116 pos [0,660432]
round 1 B rows 20 scroll ms 7116 pos [0,660432]
round 2 A rows 20 scroll ms 7140 pos [0,660498]
round 2 B rows 20 scroll ms 7141 pos [0,660498]
RESULT scroll A {"n":1278,"p50":17,"p95":21,"p99":23,"ge50":0,"gt25":2,"max":27}
RESULT scroll B {"n":1278,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":2,"max":38}
RESULT idle A {"n":1281,"p50":17,"p95":20,"p99":24,"ge50":0,"gt25":4,"max":27}
RESULT idle B {"n":1282,"p50":17,"p95":21,"p99":24,"ge50":0,"gt25":2,"max":26}
RESULT type A {"n":171,"p50":20,"p95":30,"p99":33,"ge50":0,"gt25":36,"max":34}
RESULT type B {"n":171,"p50":19,"p95":27,"p99":31,"ge50":0,"gt25":20,"max":32}
meta {"msA0":7132,"scrollHeightA":660498,"msB0":7132,"scrollHeightB":660498,"msA1":7116,"msB1":7116,"msA2":7140,"msB2":7141}
Pages free:                                    19959.
vm.swapusage: total = 0.00M  used = 0.00M  free = 0.00M  (encrypted)

**Mac VM real-Safari run (10k notes, ABAB ×3), raw output of the throwaway `pm.mjs`** (rAF frame deltas in ms; the script is not committed): ``` Pages free: 42967. vm.swapusage: total = 0.00M used = 0.00M free = 0.00M (encrypted) viewport [ 390, 844, 1 ] round 0 A rows 20 scroll ms 7132 pos [0,660366] round 0 B rows 20 scroll ms 7132 pos [0,660366] round 1 A rows 20 scroll ms 7116 pos [0,660432] round 1 B rows 20 scroll ms 7116 pos [0,660432] round 2 A rows 20 scroll ms 7140 pos [0,660498] round 2 B rows 20 scroll ms 7141 pos [0,660498] RESULT scroll A {"n":1278,"p50":17,"p95":21,"p99":23,"ge50":0,"gt25":2,"max":27} RESULT scroll B {"n":1278,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":2,"max":38} RESULT idle A {"n":1281,"p50":17,"p95":20,"p99":24,"ge50":0,"gt25":4,"max":27} RESULT idle B {"n":1282,"p50":17,"p95":21,"p99":24,"ge50":0,"gt25":2,"max":26} RESULT type A {"n":171,"p50":20,"p95":30,"p99":33,"ge50":0,"gt25":36,"max":34} RESULT type B {"n":171,"p50":19,"p95":27,"p99":31,"ge50":0,"gt25":20,"max":32} meta {"msA0":7132,"scrollHeightA":660498,"msB0":7132,"scrollHeightB":660498,"msA1":7116,"msB1":7116,"msA2":7140,"msB2":7141} Pages free: 19959. vm.swapusage: total = 0.00M used = 0.00M free = 0.00M (encrypted) ```
Author
Owner

Final head f75111586, iOS 27 simulator: note page, 2.5× pinch (guard now in the idle-screens chunk): nothing moves (before / after).

**Final head f75111586, iOS 27 simulator**: note page, 2.5× pinch (guard now in the idle-screens chunk): nothing moves (before / after).
Author
Owner

Final head: a short left swipe outside selection still opens the row.

**Final head**: a short left swipe outside selection still opens the row.
Author
Owner

Final head: two real fingers slid down select 3 notes; then left drag on a selected row, right drag on a selected row, left drag on an unselected row: no row moves.

**Final head**: two real fingers slid down select 3 notes; then left drag on a selected row, right drag on a selected row, left drag on an unselected row: no row moves.
Author
Owner

Final head: a left swipe started, a second finger landed 0.25 s later: list at rest, nothing deleted (before / after).

**Final head**: a left swipe started, a second finger landed 0.25 s later: list at rest, nothing deleted (before / after).
Author
Owner

Mac VM real-Safari runs on the final head (throwaway scripts, not committed).

Guard window (gw.mjs):

B f75111586
Checked 134 installs across 169 packages (no changes) [88.00ms]
cold {"activeAtMs":511,"idleScreensEndMs":411,"fcpMs":301,"polls":13}
warm {"activeAtMs":288,"idleScreensEndMs":237,"fcpMs":180,"polls":8}
warm {"activeAtMs":224,"idleScreensEndMs":136,"fcpMs":99,"polls":9}
warm {"activeAtMs":188,"idleScreensEndMs":127,"fcpMs":95,"polls":6}
warm {"activeAtMs":241,"idleScreensEndMs":186,"fcpMs":121,"polls":8}
warm {"activeAtMs":186,"idleScreensEndMs":144,"fcpMs":94,"polls":9}

List scroll + typing, 10k notes (pm.mjs):

Pages free:                                    70763.
vm.swapusage: total = 0.00M  used = 0.00M  free = 0.00M  (encrypted)
viewport [ 390, 844, 1 ]
round 0 A rows 20 scroll ms 7128 pos [0,660366]
round 0 B rows 20 scroll ms 7135 pos [0,660366]
round 1 A rows 20 scroll ms 7137 pos [0,660432]
round 1 B rows 20 scroll ms 7135 pos [0,660432]
round 2 A rows 20 scroll ms 7146 pos [0,660498]
round 2 B rows 20 scroll ms 7143 pos [0,660498]
RESULT scroll A {"n":1279,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":0,"max":25}
RESULT scroll B {"n":1280,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":2,"max":26}
RESULT idle A {"n":1282,"p50":17,"p95":20,"p99":22,"ge50":0,"gt25":2,"max":30}
RESULT idle B {"n":1282,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":2,"max":32}
RESULT type A {"n":171,"p50":19,"p95":30,"p99":34,"ge50":0,"gt25":34,"max":35}
RESULT type B {"n":171,"p50":20,"p95":30,"p99":33,"ge50":0,"gt25":33,"max":42}
meta {"msA0":7128,"scrollHeightA":660498,"msB0":7135,"scrollHeightB":660498,"msA1":7137,"msB1":7135,"msA2":7146,"msB2":7143}
Pages free:                                    22362.
vm.swapusage: total = 0.00M  used = 0.00M  free = 0.00M  (encrypted)

**Mac VM real-Safari runs on the final head** (throwaway scripts, not committed). Guard window (`gw.mjs`): ``` B f75111586 Checked 134 installs across 169 packages (no changes) [88.00ms] cold {"activeAtMs":511,"idleScreensEndMs":411,"fcpMs":301,"polls":13} warm {"activeAtMs":288,"idleScreensEndMs":237,"fcpMs":180,"polls":8} warm {"activeAtMs":224,"idleScreensEndMs":136,"fcpMs":99,"polls":9} warm {"activeAtMs":188,"idleScreensEndMs":127,"fcpMs":95,"polls":6} warm {"activeAtMs":241,"idleScreensEndMs":186,"fcpMs":121,"polls":8} warm {"activeAtMs":186,"idleScreensEndMs":144,"fcpMs":94,"polls":9} ``` List scroll + typing, 10k notes (`pm.mjs`): ``` Pages free: 70763. vm.swapusage: total = 0.00M used = 0.00M free = 0.00M (encrypted) viewport [ 390, 844, 1 ] round 0 A rows 20 scroll ms 7128 pos [0,660366] round 0 B rows 20 scroll ms 7135 pos [0,660366] round 1 A rows 20 scroll ms 7137 pos [0,660432] round 1 B rows 20 scroll ms 7135 pos [0,660432] round 2 A rows 20 scroll ms 7146 pos [0,660498] round 2 B rows 20 scroll ms 7143 pos [0,660498] RESULT scroll A {"n":1279,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":0,"max":25} RESULT scroll B {"n":1280,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":2,"max":26} RESULT idle A {"n":1282,"p50":17,"p95":20,"p99":22,"ge50":0,"gt25":2,"max":30} RESULT idle B {"n":1282,"p50":17,"p95":20,"p99":23,"ge50":0,"gt25":2,"max":32} RESULT type A {"n":171,"p50":19,"p95":30,"p99":34,"ge50":0,"gt25":34,"max":35} RESULT type B {"n":171,"p50":20,"p95":30,"p99":33,"ge50":0,"gt25":33,"max":42} meta {"msA0":7128,"scrollHeightA":660498,"msB0":7135,"scrollHeightB":660498,"msA1":7137,"msB1":7135,"msA2":7146,"msB2":7143} Pages free: 22362. vm.swapusage: total = 0.00M used = 0.00M free = 0.00M (encrypted) ```
Author
Owner

Head 9401f7134, iOS 27 simulator, cold + signed in + Settings never opened. Note opened directly by /notes/<id> on a fresh origin; ~7 s later a real 2.5× pinch: nothing moves (before / after).

**Head 9401f7134, iOS 27 simulator, cold + signed in + Settings never opened.** Note opened directly by `/notes/<id>` on a fresh origin; ~7 s later a real 2.5× pinch: nothing moves (before / after).
Author
Owner

Same, All-notes list (fresh origin, Settings never opened): after the 2.5× pinch nothing moves.

**Same, All-notes list** (fresh origin, Settings never opened): after the 2.5× pinch nothing moves.
Author
Owner

Same, as the Home Screen app (added from the note URL, launched standalone on a fresh origin, Settings never opened; it opens on the daily note, the app's start route): before / after a 2.5× pinch, nothing moves.

**Same, as the Home Screen app** (added from the note URL, launched standalone on a fresh origin, Settings never opened; it opens on the daily note, the app's start route): before / after a 2.5× pinch, nothing moves.
Author
Owner

Merged into dev (U1): specs in four projects, iOS 27 simulator proof (cold, signed in, Settings never opened: note, list, Home Screen app), Mac Safari scroll/typing flat, first paint −63 B JS / −30 B CSS vs dev. Checked the simulator shots at 2×.

Merged into dev (U1): specs in four projects, iOS 27 simulator proof (cold, signed in, Settings never opened: note, list, Home Screen app), Mac Safari scroll/typing flat, first paint −63 B JS / −30 B CSS vs dev. Checked the simulator shots at 2×.
kayg closed this pull request 2026-11-07 08:30:54 +00:00
Some checks failed
CI / server (pull_request) Has been cancelled
CI / rust (pull_request) Has been cancelled
CI / web (pull_request) Has been cancelled
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
Markdown extraction contract / markdown-editor-contract (pull_request) Has been cancelled

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!1003
No description provided.