fix(phone): one push/pop slide for every phone stack; no flash of the previous pane (#1005) #1006

Closed
kayg wants to merge 19 commits from t3/smooth-pane-transitions into dev
Owner

Design note, round 5: rethink of the phone pane transition (decision needed; nothing built)

State. Branch head 810e05294 is left as is. Four rounds of patches each fixed what the last strips showed and each exposed the next fault (z-order, start latency, an iOS-only dropped tap), and the round-4 strips still show kayg's original bug: the departed note's card is drawn back over the list for one frame, about 265 ms after the pop starts, in light and in dark; and the pop is not a slide by its curve (13, 32, 55, 74, 84, 89 % of the width in successive frames, then it sits). Evidence, the spike page and its numbers: round 5 comment.

Why it keeps breaking (structure, not tuning). One pane's position is written by up to five things (the data-place CSS transition, the animate() head start, the MutationObserver that starts it, the swipe module's inline styles, the settle that unmounts the screen), the note is built ahead under the finger (this is what made iOS drop the tap's click) and then swapped for a plain-note stand-in and then for the editor. I could not isolate which pair draws the flash (it does not reproduce on Mac Safari in 10 sampled pops, only on iOS), and I am not going to guess a seventh patch.

Requirements (rounds 1 to 5): no flash of any earlier pane, ever; one slide for tap, back pill and swipe; tap to first motion within one frame; no frame of 33 ms or more while it moves; nothing built or changed under the finger; first paint at or under prod (JS 225.675 KB, CSS 17.984 KB); Reduce Motion crossfade.

Options (spiked on Mac Safari 26.5.2, 10 reps x 3 interleaved rounds; full table in the comment)

what can cause a flash, and why it cannot here first motion, push / pop p50 frames ≥33 ms in the slide first-paint bytes (core code, zlib, estimate)
a. View Transitions (startViewTransition, named list/note/dock groups) The browser shows snapshots, so a stale pane cannot be drawn by our code. But the flash is then WebKit's to get right: bug 297845 (view transitions flash backgrounds) is still open, 26.2 fixed "flickering of slow-painting content" and clipping of fixed content, so phones below 26.2 keep them. Taps are swallowed for the whole slide (a tap 120 ms in never reached the page), motion cannot start before the new state is rendered (every start includes the real DOM update), a cancelled swipe is a second transition. 20 to 22 / 29 to 30 ms 0 of ~1130 415 B
b. Both panes always mounted, one animate() per layer (fill both; state committed and animation cancelled in the same task as finished) One owner of each layer's position, and the animated end state equals the committed state, so there is nothing to hand over and nothing to unmount or mount again. Only a bug in the commit step can show a stale frame. 4 to 8 / 12 to 13 ms (16 ms head start) 0 of ~1130 313 B
c. Both panes always mounted, one attribute, one CSS transition per layer (the recommendation) Same as b with no JS animation at all: the rest state and the animated state are the same CSS under one attribute (data-route), so a flash would need the attribute to say two things. The browser reverses an interrupted slide itself. 20 to 22 / 28 to 30 ms; with transition-delay: -16ms: 4 to 6 / 13 ms 0 of ~750 179 B

All three: the live editor mounts only after landing (one long frame right after landing with nothing moving, 0 with a free mount); nothing is built under the finger, so the dropped-tap bug cannot recur; the plain-note stand-in, the build-ahead (lead), the touchend tap workaround, the MutationObserver head start, pane-stack.svelte.ts and the settle timers all go. The first paint should fall by about 1 to 2 KB zlib of first JS plus the lazy chunk (about 2.9 KB zlib) shrinking to the edge swipe; to be measured on the build, since brotli moves the first JS by about ±150 B on any edit.

Recommendation

(c): it has the fewest parts (about 180 B of core code, no animation lifecycle) and, with a negative transition-delay, the same first motion as the JS version (4 to 6 ms push, 13 ms pop on Mac Safari); (b) buys nothing over it except explicit control, which is the thing that failed here; (a) hands the flash to a WebKit area that still has an open flash bug, blocks taps during the slide and cannot start before the new state is drawn.

What I need from kayg

  1. Option (a), (b) or (c) (I recommend (c)); I then rebuild from dev, not on this branch.
  2. What the card shows while it slides in. The spike shows the card with the note's title only (the list already has it), the editor fading in after landing. A plain-text body (the current stand-in) is what this note proposes to drop.
  3. Curve and length: cubic-bezier(0.25, 0.8, 0.25, 1) at 300 ms (17, 34, 49, 63, 73, 80, 85, 89 % per frame; at most 17 % a frame) instead of (0.32,0.72,0,1) at 280 ms; per-frame tables for six curves are in the comment.

Not established: the cause of the current flash; the real app's synchronous click work under (b) and (c) (the spike's DOM and editor cost are synthetic); iOS looks beyond a 30 fps simulator video.


Fixes #1005 (kayg, prod iPhone 11-07 14:19 IST: "a flash of the previous pane … the black middle pane still disappearing"). On dev efb10220b.

Root cause

PhoneStack built the incoming screen already at its final place, so a push had nothing to slide from: the note popped in whole while the list under it was still on its 380 ms slide to −30 %. The leftover was a hard-edged block because (1) each glass screen painted its own tint, so the list drifting left showed its right edge, a tinted block cut at ~77 % of the width, through the translucent card above it; (2) the screen beneath only went visibility:hidden after the slide, with no fade; (3) a popped note was unmounted in the same task as the route change, so a pop had no slide. Contacts and Tasks had the same code pasted in.

Design (one shared timeline; ruling 11-07 applied)

  • lib/pane-stack.svelte.ts: each screen is front / under / ahead. A pushed screen is built at ahead, style flushed, then moved to front. Screens are removed when the screens' own slides have finished (not on a clock, not waiting for the dock's spring).
  • styles/panes.css: only translate + opacity; push 280 ms --ease-drawer, the covered screen drifts −28 % and fades; pop is the reverse; one still glass tint under the stack; Reduce Motion = 140 ms crossfade.
  • Build ahead on press and on focus (lib/swipe-back.ts, lazy): a pointer-down on a row, or a row that keeps the keyboard focus for 80 ms, builds that note's screen off screen (one at most; cancelled when the touch scrolls / focus moves on). A screen built ahead gets 90 ms from being built before it slides (a press spends that before the tap lands); one that was not built ahead slides at once with the plain note, no pre-roll.
  • Plain note first, editor after: the pushed screen shows the note's title and first lines as plain read-only DOM (editor's own classes, nothing shifts). The live editor is made after the slide lands, hidden under the plain note; only after that frame (2 rAF) does the plain note fade out over it (140 ms, opacity only), and it is removed on animationend. At the editor-mount moment the only running animation is the editor's own caret blink (probe, Safari). Text that arrives mid-slide is not redrawn over the plain note.
  • The keyboard never animates (the app's rule), so a keyboard open is the note appearing at once; with the focus-built screen it is ready.
  • Pop: the list dock stays built under a note (hidden at once, not stepped down), so a pop finds it.
  • Browser-tab (document-scroll) mode: the list stays in the flow until the slide is over (the tap's own frame used to lay the whole document out again, ~35 ms).
  • Notes (PhoneStack), Contacts, Tasks. (Mail, Files, Settings have no phone push/pop stack today.)

First paint (exact head, perf/web.sh --bundle-only --gate)

prod 98f14a3d1 dev efb10220b this PR
first JS brotli 225.675 225.368 225.617 (−58 B vs prod)
first CSS brotli 17.984 17.955 17.776 (−208 B vs prod)
  • CSS: the backdrop's 2 % film-grain data-URI moved to styles/grain.css, imported by the idle screens (applied right after first paint, unchanged picture); dead --chrome (4 definitions), --solid, --side-tint; duplicate strip/edge rules; pane styles golfed.
  • JS: press/focus build-ahead, the plain-note renderer and its draw loop are all in the lazy swipe-back chunk. Caveat: brotli on this 800 KB file is not monotonic: neutral reorderings of the same code moved it by ±150 B (e.g. removing 41 raw bytes once made it 155 B bigger). The statement order in PhoneStack and the shownNote / active expressions were chosen by measuring variants; a future edit there needs a re-measure.
  • Budgets: swipe-back 0.6 → 1.57 KB (it holds the press/focus code and the plain note), from vim (38.4 → 38.0) and Palette (22 → 21.43, actual 20.7); total unchanged (the Palette donation approved).

Mac VM real Safari 26.5, 15 reps each (macos-01, 390 px, ≥ 2 GB free, no swap; idle floor p95 20 ms)

Two full runs; build 3c086c311 is the later (build-ahead, focus lead, dock, fade ordering; later commits change only a toast fix, byte-order and specs). Range shows run 1 → run 2.

scenario tap/key → first visible/moving (p50 / max) slide frames p95 / max / ≥ 33 ms one frame after landing (editor made)
push, warm list, finger press 120 ms 17 → 8 ms / 34 → 103 18 / 20–25 / 0 45–51 ms
push, cold note, finger press 23 → 25 ms / 30 → 121 18 / 19 → 102 / 0, then 1 in 15 133–201 ms
keyboard, row rested 300 ms (warm / cold) 7–8 ms / 10–11 — (appears at once) 49–50 / 81–139 ms
keyboard, Enter at once after focus (warm) 10–11 ms / 14–15 — 57–67 ms
pop, back pill 45–49 ms / 53–110 18 / 19–22 / 0 (1–2 frames of 41–54 ms in the keyboard-run pops) none

Not met, said plainly

  1. Pop tap → motion is not ≤ 1 frame (45–58 ms p50). Cause found: it is not main-thread work. Probe (Safari, MutationObserver + rAF): the click handler and Svelte flush finish by +6 ms; the style change lands in the frame after; frames are 14 → 34 → 49 ms (20, 15, 17 ms: steady). A CSS transition made in the tap's own task becomes visible two rAFs later (first frame starts it at progress 0). Not the hidden list (visibility forced visible: same), not the list dock (now pre-built: same), not scripts (no rAF/timeout callback > 2 ms). Push looks faster only because its place change happens after a timeout, aligned just before a frame. Getting pop under one frame would need the first frame to start the animation part-way (WAAPI with a head start), which I did not do.
  2. One slow frame in 1 of 15 cold press rows per run (102 and 176 ms, at the first moving frame; the cold run in between had none). Cause not pinned; the mid-slide text redraw I removed was one source.
  3. The editor frame after landing is 45–67 ms warm and up to 200 ms cold (CodeMirror + first paint of the live note). It is after the slide, under the plain note, with nothing animating; making it smaller means a lighter editor mount (separate job).
  4. Mac numbers are at 3c086c311; the strips are at 08b0d8384; the final head only adds a toast fix (below), byte order and specs.

Bugs this round found and fixed

  • a screen built ahead had its own bar take toasts (swipe-rows "Restored" toast went into the hidden bar): the note's bar is inactive until the note is open;
  • focus leaving the note on a pop removed the screen mid-slide (a 265 px frame): taking a screen away is only for one not in use (settled);
  • the settle wait also waited for the dock's 380 ms spring (editor made at 700 ms): only the screens' own slides count;
  • the swipe release on a pointercancel finishes the slide (earlier).

Specs (4 projects, 32 phone/navigation files + pane-transition.spec.ts, --workers=1)

head chromium-390/1440 webkit-390/1440
8005cc03e (code of the final head) 744 passed, 0 failed (at 3c086c311: 738 passed; its failures were the toast bug and spec waits, fixed)
6924438c7 (final; spec-only change) 742 passed, 2 failed: journal-trash "in this session", tint "tagged, dark: on a phone › swipe tints" 739 passed, 1 failed: journal-trash "in this session"
journal-trash and the tint swipe pass alone: 40/40 (--repeat-each=10) on the branch; the same 32-file subset on dev efb10220b (chromium-390) is 609 passed, 0 failed; journal-trash fails with another spec's task lines in today's shared daily note (spec-order pollution, seen 3 of 6 of my subset runs). Flaky rule: not reproducible → not blocking, to be confirmed by the daily train. pane-transition.spec.ts: 30/30 in webkit-390 (--repeat-each=6), 15/15 in chromium-390 at the final head; it waits for the editor before dragging and no longer expects the note to be gone (a focused row builds it ahead again).
bun run check 0 errors; bun run lint ok.

Frame strips

Attached to the comment: quick tap and a 350 ms press, push and pop, before (dev) and after, at 2× from the iOS 27 simulator Home Screen app; a real edge swipe back (XCUITest drag from x = 23); videos and the full frame sets.

🤖 Generated with Claude Code


Round 4 (head 810e05294)

Pop z-order fixed (the list's bar fades in after the card has left; never over it), pop starts with an animate() head start (tap to first motion p50 13 ms on Mac Safari), cold-push tail measured ABAB x5 against dev with a dev-vs-dev control (the tail is the epoch), first paint JS 225.666 KB / CSS 17.820 KB (prod 225.675 / 17.984), and a tap bug that only iOS shows (the row's click was dropped while a note was built under the finger) fixed by making the tap at touchend. Numbers, strips and what is not met: see the round 4 comment.

## Design note, round 5: rethink of the phone pane transition (decision needed; nothing built) **State.** Branch head `810e05294` is left as is. Four rounds of patches each fixed what the last strips showed and each exposed the next fault (z-order, start latency, an iOS-only dropped tap), and the round-4 strips still show kayg's original bug: the departed note's card is drawn back over the list for one frame, about 265 ms after the pop starts, in light and in dark; and the pop is not a slide by its curve (13, 32, 55, 74, 84, 89 % of the width in successive frames, then it sits). Evidence, the spike page and its numbers: [round 5 comment](https://git.kayg.org/kayg/calternal/pulls/1006#issuecomment-86133). **Why it keeps breaking (structure, not tuning).** One pane's position is written by up to five things (the `data-place` CSS transition, the `animate()` head start, the MutationObserver that starts it, the swipe module's inline styles, the settle that unmounts the screen), the note is built ahead under the finger (this is what made iOS drop the tap's click) and then swapped for a plain-note stand-in and then for the editor. I could not isolate which pair draws the flash (it does not reproduce on Mac Safari in 10 sampled pops, only on iOS), and I am not going to guess a seventh patch. **Requirements** (rounds 1 to 5): no flash of any earlier pane, ever; one slide for tap, back pill and swipe; tap to first motion within one frame; no frame of 33 ms or more while it moves; nothing built or changed under the finger; first paint at or under prod (JS 225.675 KB, CSS 17.984 KB); Reduce Motion crossfade. ### Options (spiked on Mac Safari 26.5.2, 10 reps x 3 interleaved rounds; full table in the comment) | | what can cause a flash, and why it cannot here | first motion, push / pop p50 | frames ≥33 ms in the slide | first-paint bytes (core code, zlib, estimate) | |---|---|---|---|---| | **a. View Transitions** (`startViewTransition`, named list/note/dock groups) | The browser shows snapshots, so a stale pane cannot be drawn by our code. But the flash is then WebKit's to get right: [bug 297845](https://bugs.webkit.org/show_bug.cgi?id=297845) (view transitions flash backgrounds) is still open, 26.2 fixed "flickering of slow-painting content" and clipping of fixed content, so phones below 26.2 keep them. Taps are swallowed for the whole slide (a tap 120 ms in never reached the page), motion cannot start before the new state is rendered (every start includes the real DOM update), a cancelled swipe is a second transition. | 20 to 22 / 29 to 30 ms | 0 of ~1130 | 415 B | | **b. Both panes always mounted, one `animate()` per layer** (fill both; state committed and animation cancelled in the same task as `finished`) | One owner of each layer's position, and the animated end state equals the committed state, so there is nothing to hand over and nothing to unmount or mount again. Only a bug in the commit step can show a stale frame. | 4 to 8 / 12 to 13 ms (16 ms head start) | 0 of ~1130 | 313 B | | **c. Both panes always mounted, one attribute, one CSS transition per layer** (the recommendation) | Same as b with no JS animation at all: the rest state and the animated state are the same CSS under one attribute (`data-route`), so a flash would need the attribute to say two things. The browser reverses an interrupted slide itself. | 20 to 22 / 28 to 30 ms; with `transition-delay: -16ms`: 4 to 6 / 13 ms | 0 of ~750 | 179 B | All three: the live editor mounts only after landing (one long frame right after landing with nothing moving, 0 with a free mount); nothing is built under the finger, so the dropped-tap bug cannot recur; the plain-note stand-in, the build-ahead (`lead`), the touchend tap workaround, the MutationObserver head start, `pane-stack.svelte.ts` and the settle timers all go. The first paint should fall by about 1 to 2 KB zlib of first JS plus the lazy chunk (about 2.9 KB zlib) shrinking to the edge swipe; to be measured on the build, since brotli moves the first JS by about ±150 B on any edit. ### Recommendation **(c)**: it has the fewest parts (about 180 B of core code, no animation lifecycle) and, with a negative `transition-delay`, the same first motion as the JS version (4 to 6 ms push, 13 ms pop on Mac Safari); (b) buys nothing over it except explicit control, which is the thing that failed here; (a) hands the flash to a WebKit area that still has an open flash bug, blocks taps during the slide and cannot start before the new state is drawn. ### What I need from kayg 1. Option (a), (b) or (c) (I recommend (c)); I then rebuild from `dev`, not on this branch. 2. What the card shows while it slides in. The spike shows the card with the note's **title only** (the list already has it), the editor fading in after landing. A plain-text body (the current stand-in) is what this note proposes to drop. 3. Curve and length: `cubic-bezier(0.25, 0.8, 0.25, 1)` at 300 ms (17, 34, 49, 63, 73, 80, 85, 89 % per frame; at most 17 % a frame) instead of `(0.32,0.72,0,1)` at 280 ms; per-frame tables for six curves are in the comment. **Not established:** the cause of the current flash; the real app's synchronous click work under (b) and (c) (the spike's DOM and editor cost are synthetic); iOS looks beyond a 30 fps simulator video. --- Fixes #1005 (kayg, prod iPhone 11-07 14:19 IST: "a flash of the previous pane … the black middle pane still disappearing"). On dev `efb10220b`. ## Root cause `PhoneStack` built the incoming screen **already at its final place**, so a push had nothing to slide from: the note popped in whole while the list under it was still on its 380 ms slide to −30 %. The leftover was a hard-edged block because (1) each glass screen painted its **own** tint, so the list drifting left showed its right edge, a tinted block cut at ~77 % of the width, through the translucent card above it; (2) the screen beneath only went `visibility:hidden` after the slide, with no fade; (3) a popped note was unmounted in the same task as the route change, so a pop had no slide. Contacts and Tasks had the same code pasted in. ## Design (one shared timeline; ruling 11-07 applied) - `lib/pane-stack.svelte.ts`: each screen is `front` / `under` / `ahead`. A pushed screen is built at `ahead`, style flushed, then moved to `front`. Screens are removed when the **screens' own slides have finished** (not on a clock, not waiting for the dock's spring). - `styles/panes.css`: only `translate` + `opacity`; push 280 ms `--ease-drawer`, the covered screen drifts −28 % and fades; pop is the reverse; one still glass tint under the stack; Reduce Motion = 140 ms crossfade. - **Build ahead on press and on focus** (`lib/swipe-back.ts`, lazy): a pointer-down on a row, or a row that keeps the keyboard focus for 80 ms, builds that note's screen off screen (one at most; cancelled when the touch scrolls / focus moves on). A screen built ahead gets 90 ms from being built before it slides (a press spends that before the tap lands); one that was **not** built ahead slides at once with the plain note, no pre-roll. - **Plain note first, editor after**: the pushed screen shows the note's title and first lines as plain read-only DOM (editor's own classes, nothing shifts). The live editor is made after the slide lands, **hidden under** the plain note; only after that frame (2 rAF) does the plain note fade out over it (140 ms, opacity only), and it is removed on `animationend`. At the editor-mount moment the only running animation is the editor's own caret blink (probe, Safari). Text that arrives mid-slide is not redrawn over the plain note. - The keyboard never animates (the app's rule), so a keyboard open is the note appearing at once; with the focus-built screen it is ready. - Pop: the list dock stays built under a note (hidden at once, not stepped down), so a pop finds it. - Browser-tab (document-scroll) mode: the list stays in the flow until the slide is over (the tap's own frame used to lay the whole document out again, ~35 ms). - Notes (`PhoneStack`), Contacts, Tasks. (Mail, Files, Settings have no phone push/pop stack today.) ## First paint (exact head, `perf/web.sh --bundle-only --gate`) | | prod `98f14a3d1` | dev `efb10220b` | this PR | |---|---|---|---| | first JS brotli | 225.675 | 225.368 | **225.617** (−58 B vs prod) | | first CSS brotli | 17.984 | 17.955 | **17.776** (−208 B vs prod) | - CSS: the backdrop's 2 % film-grain data-URI moved to `styles/grain.css`, imported by the idle screens (applied right after first paint, unchanged picture); dead `--chrome` (4 definitions), `--solid`, `--side-tint`; duplicate strip/edge rules; pane styles golfed. - JS: press/focus build-ahead, the plain-note renderer and its draw loop are all in the lazy `swipe-back` chunk. **Caveat:** brotli on this 800 KB file is not monotonic: neutral reorderings of the same code moved it by ±150 B (e.g. removing 41 raw bytes once made it 155 B bigger). The statement order in `PhoneStack` and the `shownNote` / `active` expressions were chosen by measuring variants; a future edit there needs a re-measure. - Budgets: `swipe-back` 0.6 → 1.57 KB (it holds the press/focus code and the plain note), from `vim` (38.4 → 38.0) and `Palette` (22 → 21.43, actual 20.7); total unchanged (the Palette donation approved). ## Mac VM real Safari 26.5, 15 reps each (macos-01, 390 px, ≥ 2 GB free, no swap; idle floor p95 20 ms) Two full runs; build `3c086c311` is the later (build-ahead, focus lead, dock, fade ordering; later commits change only a toast fix, byte-order and specs). Range shows run 1 → run 2. | scenario | tap/key → first visible/moving (p50 / max) | slide frames p95 / max / ≥ 33 ms | one frame after landing (editor made) | |---|---|---|---| | push, warm list, finger press 120 ms | **17 → 8 ms** / 34 → 103 | 18 / 20–25 / **0** | 45–51 ms | | push, cold note, finger press | **23 → 25 ms** / 30 → 121 | 18 / 19 → 102 / **0, then 1 in 15** | 133–201 ms | | keyboard, row rested 300 ms (warm / cold) | **7–8 ms** / 10–11 | — (appears at once) | 49–50 / 81–139 ms | | keyboard, Enter at once after focus (warm) | 10–11 ms / 14–15 | — | 57–67 ms | | pop, back pill | **45–49 ms** / 53–110 | 18 / 19–22 / 0 (1–2 frames of 41–54 ms in the keyboard-run pops) | none | ## Not met, said plainly 1. **Pop tap → motion is not ≤ 1 frame (45–58 ms p50).** Cause found: it is not main-thread work. Probe (Safari, `MutationObserver` + rAF): the click handler and Svelte flush finish by +6 ms; the style change lands in the frame after; frames are 14 → 34 → 49 ms (20, 15, 17 ms: steady). A CSS transition made in the tap's own task becomes visible two rAFs later (first frame starts it at progress 0). Not the hidden list (visibility forced visible: same), not the list dock (now pre-built: same), not scripts (no rAF/timeout callback > 2 ms). Push looks faster only because its place change happens after a timeout, aligned just before a frame. Getting pop under one frame would need the first frame to start the animation part-way (WAAPI with a head start), which I did not do. 2. **One slow frame in 1 of 15 cold press rows per run** (102 and 176 ms, at the first moving frame; the cold run in between had none). Cause not pinned; the mid-slide text redraw I removed was one source. 3. **The editor frame after landing is 45–67 ms warm and up to 200 ms cold** (CodeMirror + first paint of the live note). It is after the slide, under the plain note, with nothing animating; making it smaller means a lighter editor mount (separate job). 4. Mac numbers are at `3c086c311`; the strips are at `08b0d8384`; the final head only adds a toast fix (below), byte order and specs. ## Bugs this round found and fixed - a screen built ahead had its own bar take **toasts** (swipe-rows "Restored" toast went into the hidden bar): the note's bar is inactive until the note is open; - focus leaving the note on a pop **removed the screen mid-slide** (a 265 px frame): taking a screen away is only for one not in use (`settled`); - the settle wait also waited for the dock's 380 ms spring (editor made at 700 ms): only the screens' own slides count; - the swipe release on a `pointercancel` finishes the slide (earlier). ## Specs (4 projects, 32 phone/navigation files + `pane-transition.spec.ts`, `--workers=1`) | head | chromium-390/1440 | webkit-390/1440 | |---|---|---| | `8005cc03e` (code of the final head) | **744 passed, 0 failed** | (at `3c086c311`: 738 passed; its failures were the toast bug and spec waits, fixed) | | `6924438c7` (final; spec-only change) | 742 passed, 2 failed: `journal-trash` "in this session", `tint` "tagged, dark: on a phone › swipe tints" | 739 passed, 1 failed: `journal-trash` "in this session" | `journal-trash` and the tint swipe pass alone: 40/40 (`--repeat-each=10`) on the branch; the same 32-file subset on dev `efb10220b` (chromium-390) is 609 passed, 0 failed; `journal-trash` fails with another spec's task lines in today's shared daily note (spec-order pollution, seen 3 of 6 of my subset runs). Flaky rule: not reproducible → not blocking, to be confirmed by the daily train. `pane-transition.spec.ts`: 30/30 in webkit-390 (`--repeat-each=6`), 15/15 in chromium-390 at the final head; it waits for the editor before dragging and no longer expects the note to be *gone* (a focused row builds it ahead again). `bun run check` 0 errors; `bun run lint` ok. ## Frame strips Attached to the comment: quick tap and a 350 ms press, push and pop, before (dev) and after, at 2× from the iOS 27 simulator Home Screen app; a real edge swipe back (XCUITest drag from x = 23); videos and the full frame sets. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ### Round 4 (head `810e05294`) Pop z-order fixed (the list's bar fades in after the card has left; never over it), pop starts with an `animate()` head start (tap to first motion p50 13 ms on Mac Safari), cold-push tail measured ABAB x5 against dev with a dev-vs-dev control (the tail is the epoch), first paint JS 225.666 KB / CSS 17.820 KB (prod 225.675 / 17.984), and a tap bug that only iOS shows (the row's click was dropped while a note was built under the finger) fixed by making the tap at touchend. Numbers, strips and what is not met: see the round 4 comment.
kayg added 19 commits 2026-11-07 10:28:29 +00:00
A pushed screen was built at its final place, so it popped in whole while the screen it covered
slid away behind it with its own tint (a hard-edged block). A popped screen vanished in one frame.
Now each screen has a place (front, under, ahead): a new one is built ahead, its style flushed, then
moved in; a left one stays until its slide ends. The tint is the stack's, so no screen shows its edge.

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>
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>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The gesture no longer renders anything per move: it writes data-drag and --p on the stack, and the
slide styles follow. Out of the first screen's script, which keeps first paint no bigger than dev.

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>
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>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
fix(phone): a touch the system cancels still finishes a swipe back past the threshold (#1005)
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
b85c58757c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Frame evidence for #1005 (iOS simulator, 2×): before/after push and pop sheets, Home Screen videos, edge-swipe sheet and the full frame sets.

Frame evidence for #1005 (iOS simulator, 2×): before/after push and pop sheets, Home Screen videos, edge-swipe sheet and the full frame sets.
Author
Owner

Round 2 frame evidence (iOS simulator Home Screen app, 2×): quick tap and 350 ms press, push and pop, before (dev) and after, plus the full frame sets.

Round 2 frame evidence (iOS simulator Home Screen app, 2×): quick tap and 350 ms press, push and pop, before (dev) and after, plus the full frame sets.
Author
Owner

Round 3 frame evidence (iOS simulator Home Screen app, 2×): quick tap and 350 ms press, push and pop, before (dev) and after; a real edge swipe back; full frame sets.

Round 3 frame evidence (iOS simulator Home Screen app, 2×): quick tap and 350 ms press, push and pop, before (dev) and after; a real edge swipe back; full frame sets.
Author
Owner

Round 4: pop z-order, pop start, cold-push tail, and a tap bug found on the way

Head 810e05294. Everything below was measured at this head unless it says otherwise.

New finding: on a real iPhone a tap on a row did nothing in rounds 2 to 4 (fixed here)

Rows only opened with a mouse. In the Home Screen simulator the log (an on-screen event overlay, not committed) showed pointerdown, touchstart, pointerup, touchend and then no mousedown, mouseup or click, while taps on the back pill, the places rows and the dev build were fine. Cause: Safari sends no click for a tap when something visible appears during the touch (WebKit's content-change check for hover menus), and the note built under the finger on press (lead) is that. Checked, each on the same build: hiding the parked screen (visibility) lets the click through; opacity: 0 on it, or showing it 45 ms into the press, does not. Hiding it until the slide costs the push a first frame (first motion p50 27 ms and 3 slow slide frames per run in the same epoch), so it was reverted. The fix is tap in lib/swipe-back.ts: at touchend of a touch that went down on a led row (primary finger, not moved, under 400 ms) the browser's own click is cancelled and the row gets one click of ours (detail: 1, so it is not taken for a key press). That also skips the wait between touchend and the click. Spec: every browser click is dropped (as iOS does), a touch tap must still open the note and the row must get exactly one click of ours and none of the browser's.

1. Pop z-order

The list's bar is no longer drawn over the departing card: it is hidden while a note covers the list, and on a pop it fades in (170 ms, starting 183 ms after the tap) once the card has mostly left. Frame by frame in the spec: no frame has the bar above 2 % opacity unless the card is past 60 % of the width. Same in the edge swipe (the bar stays hidden for the whole drag and the release) and a cancelled swipe. On the Home Screen the dock is positioned against the stack, so its wrapper has no box (display: contents); a wrapper with position: relative had lost the dock there.

2. Pop start

element.animate() head start in the tap's own task (delay: -15ms, same curve and duration), then the state change carries on. Mac Safari, 15 reps each: pop tap to first moving frame p50 13 ms, max 14 (warm press), 8 / 6 ms from the keyboard. A pop that follows a swipe release keeps the finger's own motion (no head start, Reduce Motion skips it).

3. Cold push, ABAB x5 at this head (Mac VM, one session, both perf locks, 15 reps each, cold press)

A = dev (4286), B = this PR, C = a second dev copy as control.

run A dev: frames ≥33 ms in the tap window A win max C control ≥33 C max B PR ≥33 B win max B slide max B slide ≥33 B first p50
1 61 405 59 118 29 71 19 0 26
2 61 139 62 145 29 61 19 0 27
3 63 191 54 215 31 66 19 0 27
4 54 340 61 288 32 181 112 2 29
5 62 167 64 153 32 83 18 0 24

Dev and its control show the same tail (win max 118 to 405 ms, 15 to 24 frames ≥100 ms in runs 3 to 5), so the tail is the epoch, not the PR; the PR has about half the slow frames dev has and its slide is clean in 4 of 5 runs (run 4 is the epoch where dev and control spike too). The 102 ms slide frame of the earlier run 2 did not come back. Not met: cold press tap to first moving frame is 24 to 29 ms p50 (target 20); warm press is 7 ms.

Mac Safari gate (15 reps each)

tap to first moving frame slide frames ≥33 ms note
push, warm press p50 7, max 30 1 of 226 (max 54)
push, cold press p50 25, max 33 0 of 220 (max 19) not ≤20
push, keyboard (rested / at once) p50 7 / 9 no slide (opens in place) as before
pop, back pill p50 13, max 14 13 of 273 main-thread frames: one 33 to 48 ms frame right after the tap (the state change; the slide itself already moves on the compositor) and one at landing (about 330 ms, the screen leaves the page); max 80
pop, keyboard p50 8 / 6 15 of 265 same two frames, max 135

These are requestAnimationFrame gaps (main thread); the slide is translate/opacity only. I did not get the pop's two main-thread frames under 33 ms; I did not chase the landing one further.

First paint (perf/web.sh --bundle-only --gate, this head)

First JS 225.666 KB (prod 98f14a3d1: 225.675), first CSS 17.820 KB (prod: 17.984). All budgets ok. Budget moves only, nothing raised: swipe-back 1.85 to 2.0 KB (the tap), svelte chunk 9.75 to 9.6 KB, total unchanged. The first JS moves by about ±150 B with any neutral edit (brotli on 800 KB, chunk names feed into it), so the dock delay (183 ms) was picked among 30 neutral variants measured in parallel worktrees; any later edit to swipe-back, PhoneStack's style or the stack code needs a re-measure.

Specs and checks (this head)

  • Phone and navigation specs (31 files plus the pane-transition spec), chromium-390 and webkit-390: 1227 passed, 82 skipped, 1 failed: journal-trash.spec.ts:25 "trash today, open Journal in this session…" in webkit-390 (it reads another note's text). It passes 8 of 8 when run alone at the same head, and it has failed in some earlier full runs of this branch too, so I take it for a load flake of that spec, not for this change; I have not run it on dev under the same load.
  • chromium-1440 and webkit-1440: 260 passed, the phone specs skip there.
  • svelte-check: 0 errors (20 warnings), lint: all pass.

Strips at 2× (iOS simulator, Home Screen app, iPhone 18 Pro)

Each image: tap pop, edge swipe (held at 83 % and released), cancelled swipe (released at 17 %), every second recorded frame, labelled with the source frame number (about 60 per second; windows picked by eye). before is the dev build, after is this head. The note slides away over the list with no bar over it; the bar fades in after the card has gone; dev vanishes in one frame with a dark gap.

after, light
after light

after, dark
after dark

before (dev), light
before light

before (dev), dark
before dark

The before windows for the edge swipe and cancelled swipe are approximate (dev has no 1:1 slide of the list under the finger).

🤖 Generated with Claude Code

## Round 4: pop z-order, pop start, cold-push tail, and a tap bug found on the way Head `810e05294`. Everything below was measured at this head unless it says otherwise. ### New finding: on a real iPhone a tap on a row did nothing in rounds 2 to 4 (fixed here) Rows only opened with a mouse. In the Home Screen simulator the log (an on-screen event overlay, not committed) showed `pointerdown, touchstart, pointerup, touchend` and then **no mousedown, mouseup or click**, while taps on the back pill, the places rows and the dev build were fine. Cause: Safari sends no click for a tap when something visible appears during the touch (WebKit's content-change check for hover menus), and the note built under the finger on press (`lead`) is that. Checked, each on the same build: hiding the parked screen (`visibility`) lets the click through; `opacity: 0` on it, or showing it 45 ms into the press, does not. Hiding it until the slide costs the push a first frame (first motion p50 27 ms and 3 slow slide frames per run in the same epoch), so it was reverted. The fix is `tap` in `lib/swipe-back.ts`: at `touchend` of a touch that went down on a led row (primary finger, not moved, under 400 ms) the browser's own click is cancelled and the row gets one click of ours (`detail: 1`, so it is not taken for a key press). That also skips the wait between touchend and the click. Spec: every browser click is dropped (as iOS does), a touch tap must still open the note and the row must get exactly one click of ours and none of the browser's. ### 1. Pop z-order The list's bar is no longer drawn over the departing card: it is hidden while a note covers the list, and on a pop it fades in (170 ms, starting 183 ms after the tap) once the card has mostly left. Frame by frame in the spec: no frame has the bar above 2 % opacity unless the card is past 60 % of the width. Same in the edge swipe (the bar stays hidden for the whole drag and the release) and a cancelled swipe. On the Home Screen the dock is positioned against the stack, so its wrapper has no box (`display: contents`); a wrapper with `position: relative` had lost the dock there. ### 2. Pop start `element.animate()` head start in the tap's own task (`delay: -15ms`, same curve and duration), then the state change carries on. Mac Safari, 15 reps each: pop tap to first moving frame **p50 13 ms, max 14** (warm press), 8 / 6 ms from the keyboard. A pop that follows a swipe release keeps the finger's own motion (no head start, Reduce Motion skips it). ### 3. Cold push, ABAB x5 at this head (Mac VM, one session, both perf locks, 15 reps each, cold press) A = dev (4286), B = this PR, C = a second dev copy as control. | run | A dev: frames ≥33 ms in the tap window | A win max | C control ≥33 | C max | B PR ≥33 | B win max | B slide max | B slide ≥33 | B first p50 | |---|---|---|---|---|---|---|---|---|---| | 1 | 61 | 405 | 59 | 118 | 29 | 71 | 19 | 0 | 26 | | 2 | 61 | 139 | 62 | 145 | 29 | 61 | 19 | 0 | 27 | | 3 | 63 | 191 | 54 | 215 | 31 | 66 | 19 | 0 | 27 | | 4 | 54 | 340 | 61 | 288 | 32 | 181 | 112 | 2 | 29 | | 5 | 62 | 167 | 64 | 153 | 32 | 83 | 18 | 0 | 24 | Dev and its control show the same tail (win max 118 to 405 ms, 15 to 24 frames ≥100 ms in runs 3 to 5), so the tail is the epoch, not the PR; the PR has about half the slow frames dev has and its slide is clean in 4 of 5 runs (run 4 is the epoch where dev and control spike too). The 102 ms slide frame of the earlier run 2 did not come back. **Not met:** cold press tap to first moving frame is 24 to 29 ms p50 (target 20); warm press is 7 ms. ### Mac Safari gate (15 reps each) | | tap to first moving frame | slide frames ≥33 ms | note | |---|---|---|---| | push, warm press | p50 7, max 30 | 1 of 226 (max 54) | | | push, cold press | p50 25, max 33 | 0 of 220 (max 19) | not ≤20 | | push, keyboard (rested / at once) | p50 7 / 9 | no slide (opens in place) | as before | | pop, back pill | p50 13, max 14 | 13 of 273 | main-thread frames: one 33 to 48 ms frame right after the tap (the state change; the slide itself already moves on the compositor) and one at landing (about 330 ms, the screen leaves the page); max 80 | | pop, keyboard | p50 8 / 6 | 15 of 265 | same two frames, max 135 | These are `requestAnimationFrame` gaps (main thread); the slide is `translate`/`opacity` only. I did not get the pop's two main-thread frames under 33 ms; I did not chase the landing one further. ### First paint (`perf/web.sh --bundle-only --gate`, this head) First JS 225.666 KB (prod `98f14a3d1`: 225.675), first CSS 17.820 KB (prod: 17.984). All budgets ok. Budget moves only, nothing raised: `swipe-back` 1.85 to 2.0 KB (the tap), `svelte` chunk 9.75 to 9.6 KB, total unchanged. The first JS moves by about ±150 B with any neutral edit (brotli on 800 KB, chunk names feed into it), so the dock delay (183 ms) was picked among 30 neutral variants measured in parallel worktrees; any later edit to swipe-back, PhoneStack's style or the stack code needs a re-measure. ### Specs and checks (this head) - Phone and navigation specs (31 files plus the pane-transition spec), chromium-390 and webkit-390: **1227 passed, 82 skipped, 1 failed**: `journal-trash.spec.ts:25` "trash today, open Journal in this session…" in webkit-390 (it reads another note's text). It passes 8 of 8 when run alone at the same head, and it has failed in some earlier full runs of this branch too, so I take it for a load flake of that spec, not for this change; I have not run it on dev under the same load. - chromium-1440 and webkit-1440: 260 passed, the phone specs skip there. - `svelte-check`: 0 errors (20 warnings), `lint`: all pass. ### Strips at 2× (iOS simulator, Home Screen app, iPhone 18 Pro) Each image: tap pop, edge swipe (held at 83 % and released), cancelled swipe (released at 17 %), every second recorded frame, labelled with the source frame number (about 60 per second; windows picked by eye). `before` is the dev build, `after` is this head. The note slides away over the list with no bar over it; the bar fades in after the card has gone; dev vanishes in one frame with a dark gap. **after, light** ![after light](https://git.kayg.org/attachments/48b7332b-6ff9-4e13-a7e4-3d22e471c4ce) **after, dark** ![after dark](https://git.kayg.org/attachments/3112fbc3-54d7-4e92-a4f8-ec52042e6cb6) **before (dev), light** ![before light](https://git.kayg.org/attachments/f410bd9a-1ba5-44e2-ab0c-eca930c79c2a) **before (dev), dark** ![before dark](https://git.kayg.org/attachments/d256fc9c-36ae-4409-b6a2-b555b7b4547e) The before windows for the edge swipe and cancelled swipe are approximate (dev has no 1:1 slide of the list under the finger). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Round 5: evidence for the rethink (design note is at the top of the PR description)

Branch left as is (head 810e05294). The spike is a separate throwaway page, not on this branch: pane-spike.html.txt (three variants behind ?v=a|b|c, rows, card, glass bars and backdrop at about the weight of the real screens, the live editor stood in for by a 90 ms main-thread task after landing; &head=16 is the 16 ms head start).

What I could and could not establish about the current flash

  • Both of kayg's strips show it at the same moment: 16 frames (about 265 ms) after the pop starts (light: pop 176, card back at 192; dark: pop 182, card back at 198), at about 45 % of the width, gone one frame later. That is the end of the animate() head start (280 ms minus its 15 ms), just before the 340 ms settle. I read frame 192 in the last round as the next push; it is not (the next tap is 2 s later). My mistake.
  • Several writers own the same pane during a pop: the CSS transition from the data-place change, the animate() head start on the same translate, the MutationObserver that starts it, the swipe module's inline styles, and the settle that unmounts the screen, while a focused row can mount the note ahead again.
  • I did not isolate which pair causes it. Mac Safari: 10 pops sampled every frame (getBoundingClientRect of each screen), no reversal, no card back; so it is iOS (compositor) only, and my iOS A/B with the head start switched off did not run cleanly (Home Screen apps restore their last route, so the tap script drifted). Treat the cause as "handoff between more than one animation system, or a screen mounted again", unproven.
  • The pop is also not a slide by its curve: cubic-bezier(0.32,0.72,0,1) over 280 ms with the 15 ms head start moves the card 13, 32, 55, 74, 84, 89 % of the width in successive frames (steps of 19 to 23 %, 90 % after 6 frames) and then creeps 10 % over 12 more. Candidate: cubic-bezier(0.25,0.8,0.25,1), 300 ms: 17, 34, 49, 63, 73, 80, 85, 89 % (max step 17 %). Per-frame tables for six curves are in the spike notes below.

Spike on Mac Safari 26.5.2 (real Safari, safaridriver, 390 px, 10 reps x 3 interleaved rounds, perf lock held)

Tap = the page's click event; "first motion" = first frame the card is off its rest place (for VT: first frame after ready, where the animation starts). Frames are requestAnimationFrame gaps over the 330 ms of the slide.

push, first motion p50 (max) pop, first motion p50 (max) frames ≥33 ms during the slide card back after the pop (every frame sampled)
a: View Transitions 20 to 22 ms (27 to 29) 29 to 30 (31 to 32) 0 of ~1130 cannot sample the snapshots; see strips
b: both panes mounted, one animate() per layer, state committed on finish, 16 ms head start 4 to 8 (max 17) 12 to 13 (max 15) 0 of ~1130 none in 30 pops
c: both panes mounted, one attribute, one CSS transition per layer 20 to 22 (25 to 28) 28 to 30 (32) 0 of ~1130 none in 30 pops
c with transition-delay: -16ms 4 to 6 (max 14) 13 (max 15) 0 of ~750 none in 20 pops
  • ready (old state captured, new state rendered, animation about to start) comes 12 to 15 ms after the tap in (a) with a callback that only changes one attribute; with the real app's update (a list or note render inside the callback) that is added to every start, because motion cannot begin before the new state is drawn.
  • With the simulated 90 ms editor mount after landing, b and c have one long frame (92 ms) per push right after landing, nothing moving; with the mount cost set to 0 there is none. a has the same frame after finished.
  • Interruption: a tap on the back pill 120 ms into the slide. (a) The tap never reached the page (safaridriver could not click; the click event never fired on the pill): during a view transition the pseudo-element tree takes the hits for the whole slide, and WebKit ignores pointer activity from before the old capture until the first pointer move after it. (b), (c) the click reaches the pill.
  • Simulator (iOS 27.0, Safari tab, 30 fps video): the three variants push and pop as slides with no card back after the pop; a, b, c are index sheets, every frame labelled by index. The video is too coarse to rule out a one-frame glitch.
  • WebKit bug 297845: view transitions flash the backgrounds when switching, status NEW, unassigned, last changed 2026-01-12; the duplicate 300877 reports "page flash when opening and closing components", worked in Safari 18.0.6, broken from 18.2.
  • Safari 26.2 notes: fixed flickering of elements with slow-painting content during view transitions; fixed clipping of fixed and sticky content during them; ::view-transition is now position: absolute; document.activeViewTransition added. Before 26.2 those bugs exist on the phones that are not updated.
  • Pointer activity is ignored from before the old capture to the first pointer move after the transition (csswg-drafts #11274 as reported; consistent with the interruption result above).
  • An iOS 27.0.1 report of animations that start during a same-document transition playing twice: tweakpad/ui #19.
  • Safari 18.0 and 18.1 lack ViewTransition.types (Shopify/horizon #104).
  • 1:1 swipe back: Safari 26.5 does expose the transition's animations (document.getAnimations() lists the ::view-transition-* ones), so pausing and scrubbing currentTime is possible, but the DOM has already switched when the transition starts, so a cancelled swipe is a second transition; not attempted.

First-paint bytes

Not measured on a build (nothing is built in this round). Core code of each option, minified, zlib (brotli will be about 10 % smaller): a 415 B (JS plus the ::view-transition CSS), b 313 B, c 179 B. The current branch's first-paint machinery that a rethink deletes: pane-stack.svelte.ts about 730 B zlib, plus the led/plain/fading/stable logic in PhoneStack (about 1.6 KB zlib added over dev); the lazy swipe-back chunk (about 2.9 KB zlib) shrinks to the edge swipe. Any of a, b, c should land below the branch's first JS 225.666 KB and prod's 225.675 KB by a margin, but brotli moves the first JS by about ±150 B on any edit, so that is to be measured on the build, not assumed.

Per-frame table (percent of the width, 60 fps, head start where stated)

curve duration frames
(0.32,0.72,0,1) now, 15 ms head 280 13 32 55 74 84 89 92 95 96 97 98 99 99 100
(0.32,0.72,0,1) now, no head 280 0 15 34 58 76 85 89 93 95 96 98 98 99 99 100
(0.25,0.8,0.25,1) 320 0 17 34 49 63 73 80 85 89 92 94 96 97 98 99 99 100
(0.25,1,0.5,1) 320 0 19 36 50 61 71 78 84 88 92 94 96 98 99 99 100
(0.2,0,0,1) 320 0 3 17 37 52 63 71 77 82 86 89 92 94 96 97 98 99 100
(0.3,0,0.15,1) 340 0 1 5 12 24 38 50 61 69 76 81 85 89 92 94 96 98 99 99 100

Limits of the spike: synthetic DOM and editor cost, one phone width, Mac Safari for numbers and a coarse simulator video for looks; the real click handler's synchronous work (route, state, DOM) is not included and must be re-measured on the build (the earlier real-app cold path, which also built a plain note, was 25 ms p50).

🤖 Generated with Claude Code

## Round 5: evidence for the rethink (design note is at the top of the PR description) Branch left as is (head `810e05294`). The spike is a separate throwaway page, not on this branch: [pane-spike.html.txt](https://git.kayg.org/attachments/0612d308-6ec3-43db-ad85-5de71a37fec1) (three variants behind `?v=a|b|c`, rows, card, glass bars and backdrop at about the weight of the real screens, the live editor stood in for by a 90 ms main-thread task after landing; `&head=16` is the 16 ms head start). ### What I could and could not establish about the current flash - Both of kayg's strips show it at the same moment: 16 frames (about 265 ms) after the pop starts (light: pop 176, card back at 192; dark: pop 182, card back at 198), at about 45 % of the width, gone one frame later. That is the end of the `animate()` head start (280 ms minus its 15 ms), just before the 340 ms settle. I read frame 192 in the last round as the next push; it is not (the next tap is 2 s later). My mistake. - Several writers own the same pane during a pop: the CSS transition from the `data-place` change, the `animate()` head start on the same `translate`, the MutationObserver that starts it, the swipe module's inline styles, and the settle that unmounts the screen, while a focused row can mount the note ahead again. - I did **not** isolate which pair causes it. Mac Safari: 10 pops sampled every frame (`getBoundingClientRect` of each screen), no reversal, no card back; so it is iOS (compositor) only, and my iOS A/B with the head start switched off did not run cleanly (Home Screen apps restore their last route, so the tap script drifted). Treat the cause as "handoff between more than one animation system, or a screen mounted again", unproven. - The pop is also not a slide by its curve: `cubic-bezier(0.32,0.72,0,1)` over 280 ms with the 15 ms head start moves the card 13, 32, 55, 74, 84, 89 % of the width in successive frames (steps of 19 to 23 %, 90 % after 6 frames) and then creeps 10 % over 12 more. Candidate: `cubic-bezier(0.25,0.8,0.25,1)`, 300 ms: 17, 34, 49, 63, 73, 80, 85, 89 % (max step 17 %). Per-frame tables for six curves are in the spike notes below. ### Spike on Mac Safari 26.5.2 (real Safari, safaridriver, 390 px, 10 reps x 3 interleaved rounds, perf lock held) Tap = the page's `click` event; "first motion" = first frame the card is off its rest place (for VT: first frame after `ready`, where the animation starts). Frames are `requestAnimationFrame` gaps over the 330 ms of the slide. | | push, first motion p50 (max) | pop, first motion p50 (max) | frames ≥33 ms during the slide | card back after the pop (every frame sampled) | |---|---|---|---|---| | a: View Transitions | 20 to 22 ms (27 to 29) | 29 to 30 (31 to 32) | 0 of ~1130 | cannot sample the snapshots; see strips | | b: both panes mounted, one `animate()` per layer, state committed on finish, 16 ms head start | 4 to 8 (max 17) | 12 to 13 (max 15) | 0 of ~1130 | none in 30 pops | | c: both panes mounted, one attribute, one CSS transition per layer | 20 to 22 (25 to 28) | 28 to 30 (32) | 0 of ~1130 | none in 30 pops | | c with `transition-delay: -16ms` | 4 to 6 (max 14) | 13 (max 15) | 0 of ~750 | none in 20 pops | - `ready` (old state captured, new state rendered, animation about to start) comes 12 to 15 ms after the tap in (a) with a callback that only changes one attribute; with the real app's update (a list or note render inside the callback) that is added to every start, because motion cannot begin before the new state is drawn. - With the simulated 90 ms editor mount after landing, b and c have one long frame (92 ms) per push right after landing, nothing moving; with the mount cost set to 0 there is none. a has the same frame after `finished`. - Interruption: a tap on the back pill 120 ms into the slide. (a) The tap never reached the page (safaridriver could not click; the click event never fired on the pill): during a view transition the pseudo-element tree takes the hits for the whole slide, and WebKit ignores pointer activity from before the old capture until the first pointer move after it. (b), (c) the click reaches the pill. - Simulator (iOS 27.0, Safari tab, 30 fps video): the three variants push and pop as slides with no card back after the pop; [a](https://git.kayg.org/attachments/9036612a-ea6d-4b44-8d97-74802f2873c9), [b](https://git.kayg.org/attachments/123031ce-f27d-4bbd-bad3-9eaf69c52638), [c](https://git.kayg.org/attachments/21757edb-9a26-4c3d-99fc-d109a223e536) are index sheets, every frame labelled by index. The video is too coarse to rule out a one-frame glitch. ### View Transitions in WebKit today (web search) - [WebKit bug 297845](https://bugs.webkit.org/show_bug.cgi?id=297845): view transitions flash the backgrounds when switching, status NEW, unassigned, last changed 2026-01-12; the duplicate [300877](https://bugs.webkit.org/show_bug.cgi?id=300877) reports "page flash when opening and closing components", worked in Safari 18.0.6, broken from 18.2. - [Safari 26.2 notes](https://webkit.org/blog/17640/webkit-features-for-safari-26-2/): fixed flickering of elements with slow-painting content during view transitions; fixed clipping of fixed and sticky content during them; `::view-transition` is now `position: absolute`; `document.activeViewTransition` added. Before 26.2 those bugs exist on the phones that are not updated. - Pointer activity is ignored from before the old capture to the first pointer move after the transition ([csswg-drafts #11274](https://github.com/w3c/csswg-drafts/issues/11274) as reported; consistent with the interruption result above). - An iOS 27.0.1 report of animations that start during a same-document transition playing twice: [tweakpad/ui #19](https://github.com/tweakpad/ui/issues/19). - Safari 18.0 and 18.1 lack `ViewTransition.types` ([Shopify/horizon #104](https://github.com/Shopify/horizon/pull/104)). - 1:1 swipe back: Safari 26.5 does expose the transition's animations (`document.getAnimations()` lists the `::view-transition-*` ones), so pausing and scrubbing `currentTime` is possible, but the DOM has already switched when the transition starts, so a cancelled swipe is a second transition; not attempted. ### First-paint bytes Not measured on a build (nothing is built in this round). Core code of each option, minified, zlib (brotli will be about 10 % smaller): a 415 B (JS plus the `::view-transition` CSS), b 313 B, c 179 B. The current branch's first-paint machinery that a rethink deletes: `pane-stack.svelte.ts` about 730 B zlib, plus the led/plain/fading/stable logic in PhoneStack (about 1.6 KB zlib added over dev); the lazy `swipe-back` chunk (about 2.9 KB zlib) shrinks to the edge swipe. Any of a, b, c should land below the branch's first JS 225.666 KB and prod's 225.675 KB by a margin, but brotli moves the first JS by about ±150 B on any edit, so that is to be measured on the build, not assumed. ### Per-frame table (percent of the width, 60 fps, head start where stated) | curve | duration | frames | |---|---|---| | (0.32,0.72,0,1) now, 15 ms head | 280 | 13 32 55 74 84 89 92 95 96 97 98 99 99 100 | | (0.32,0.72,0,1) now, no head | 280 | 0 15 34 58 76 85 89 93 95 96 98 98 99 99 100 | | (0.25,0.8,0.25,1) | 320 | 0 17 34 49 63 73 80 85 89 92 94 96 97 98 99 99 100 | | (0.25,1,0.5,1) | 320 | 0 19 36 50 61 71 78 84 88 92 94 96 98 99 99 100 | | (0.2,0,0,1) | 320 | 0 3 17 37 52 63 71 77 82 86 89 92 94 96 97 98 99 100 | | (0.3,0,0.15,1) | 340 | 0 1 5 12 24 38 50 61 69 76 81 85 89 92 94 96 98 99 99 100 | Limits of the spike: synthetic DOM and editor cost, one phone width, Mac Safari for numbers and a coarse simulator video for looks; the real click handler's synchronous work (route, state, DOM) is not included and must be re-measured on the build (the earlier real-app cold path, which also built a plain note, was 25 ms p50). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Superseded by #1014 (the option (c) rebuild from dev, as decided). This PR is left open for you to close.

🤖 Generated with Claude Code

Superseded by #1014 (the option (c) rebuild from `dev`, as decided). This PR is left open for you to close. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Closing: superseded by #1014 (rebuilt from dev with the simpler always-mounted design after the rethink rule; the branch stays for reference).

Closing: superseded by #1014 (rebuilt from dev with the simpler always-mounted design after the rethink rule; the branch stays for reference).
kayg closed this pull request 2026-11-08 07:53:06 +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!1006
No description provided.