fix(notes): the top pills stay while typing, held on the top of the view (#660) #926

Closed
kayg wants to merge 0 commits from fix/typing-pills into dev
Owner

kayg asked why, on the phone, the top pills (back, capsule, share/⋯) disappear while typing in a note.

Why they were hidden. #660 / 7523c343 added .top-chrome.typing { visibility: hidden }. With the keyboard up, iOS pans the view down to show the caret (visualViewport.offsetTop > 0). A fixed box stays on the page's own top, so it is drawn that far above the screen (seen on the simulator: pills up in the status-bar blur). WebKit has no API that holds a fixed box still, so the pills were simply not drawn.

Fix. The pills and the band stay, as in Notes. holdAtVisualTop (lib/keyboard.svelte.ts) reads where the pills' container is drawn (the rects' frame, as the bar already does, #445) and moves the band and the pills down by exactly that with translate: 0 var(--hold): a transform on the two children, never on the container (no stacking-context change, so the band can never be over a pill's icon). It runs from place(), so it follows the same events and settle frames as the bottom bar. With no keyboard the shift is forced to 0, never offsetTop, because iOS 26 can leave offsetTop stale after the keyboard (WebKit 297779). Nothing is added to the typing path (no per-keystroke work): the read happens only on visual-viewport / scroll events while the keyboard is open.

Research: Apple forums and WebKit bugs 297779, 191204 (fixed box moves with the pan), and the common translateY(visualViewport.offsetTop) workaround; only apply it while the keyboard is open.

Tests. e2e/note-pills.spec.ts: the old "hidden while typing" test is replaced by one that fakes the keyboard and two pans (panTo(…, true)) and checks the pills stay on the top of the view, are visible, and leave no shift when the keyboard goes.

Evidence (recordings, frames, spec runs) is in the comment below. Not done: real iPhone check (device checklist in the comment).

🤖 Generated with Claude Code

kayg asked why, on the phone, the top pills (back, capsule, share/⋯) disappear while typing in a note. **Why they were hidden.** #660 / 7523c343 added `.top-chrome.typing { visibility: hidden }`. With the keyboard up, iOS pans the view down to show the caret (`visualViewport.offsetTop` > 0). A fixed box stays on the page's own top, so it is drawn that far above the screen (seen on the simulator: pills up in the status-bar blur). WebKit has no API that holds a fixed box still, so the pills were simply not drawn. **Fix.** The pills and the band stay, as in Notes. `holdAtVisualTop` (lib/keyboard.svelte.ts) reads where the pills' container is drawn (the rects' frame, as the bar already does, #445) and moves the band and the pills down by exactly that with `translate: 0 var(--hold)`: a transform on the two children, never on the container (no stacking-context change, so the band can never be over a pill's icon). It runs from `place()`, so it follows the same events and settle frames as the bottom bar. With no keyboard the shift is forced to 0, never `offsetTop`, because iOS 26 can leave `offsetTop` stale after the keyboard (WebKit 297779). Nothing is added to the typing path (no per-keystroke work): the read happens only on visual-viewport / scroll events while the keyboard is open. Research: Apple forums and WebKit bugs 297779, 191204 (fixed box moves with the pan), and the common `translateY(visualViewport.offsetTop)` workaround; only apply it while the keyboard is open. **Tests.** `e2e/note-pills.spec.ts`: the old "hidden while typing" test is replaced by one that fakes the keyboard and two pans (`panTo(…, true)`) and checks the pills stay on the top of the view, are visible, and leave no shift when the keyboard goes. Evidence (recordings, frames, spec runs) is in the comment below. Not done: real iPhone check (device checklist in the comment). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
iOS pans the view to the caret and a fixed box stays on the page's top, off the screen; #660 hid the pills for that. holdAtVisualTop reads back where the box is drawn (the rects' frame, as keyboard.svelte.ts already does for the bar) and moves the band and the pills down by the pan with a transform (--hold, translate on the two children, never on the container). Zero without a keyboard, so a stale offsetTop on iOS 26 cannot leave them shifted. The spec now pans the fake view and checks the pills stay put.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Typing: measure the pan from the box that does not move
Some checks failed
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
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-editor-contract (pull_request) Has been cancelled
4711bb2472
The pills' container is not moved (its children are), so its rect is the pan alone. Reading it minus what was applied added the shift again on every call and walked the pills down the screen (seen on the simulator).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Author
Owner

Evidence (iOS 27 Simulator on the Studio, build of 4711bb24; real iPhone not checked)

Served from dev-02 through a port forward to the Studio (localhost), Safari tab and a Home Screen app (fresh icon per build). Scenario: tap the text, type, tap a line low in the note (iOS pans), type again, scroll, tap ✓ to close the keyboard.

Before (dev): pills and band are gone while the keyboard is up (Safari tab and Home Screen). After: back, capsule and ⋯ stay, with the band.

The pan, measured in the page (Safari tab). After typing low in a long note: visualViewport.offsetTop = 100, the pills' container drawn at -100 (off screen), a fixed marker at top:100px reads 0. With the fix the pills ([data-chrome-pills]) read 0 while that holds (screenshot s-after-safari-during-pan: ⋯ visible at its normal place).

Per-frame sampler (7 s of rAF from the first focus, Safari tab, the low-tap + typing scenario; the page's own overlay, not committed):

build frames dt p50 / p95 / max (ms) frames with a pan (offsetTop > 0.5) frames with the pills off their place max distance off (px)
dev (pills hidden anyway; container position) 408 17.0 / 17.0 / 202 406 406 243
this PR 409 17.0 / 17.0 / 48 404 0 0

Honest limits: this is the Simulator on the Studio host, not the Mac VM, and the VM numbers are not measured yet; the rAF p95 of 17.0 ms is the sim's 60 Hz tick, so it only shows the correction adds no dropped frames there. Screen recordings are attached (long idle stretches removed; the sim redraws only on change). A frame-by-frame diff of the recordings was too noisy (caret blink, backdrop) to be a gate, so the per-frame rect sampler above is the check for "no jump or flicker".

First attempt caught by the simulator: measuring the pan against the already-moved children added the shift again on every call and walked the pills down the screen (4711bb24 fixes that; the container's own rect is the pan alone).

Specs (compute/dev VMs, webkit-390 + chromium-390, --repeat-each=5, CI=1 --workers=1 --retries=0): e2e/note-pills.spec.ts 50 passed at 4711bb24. keyboard.spec / phone-bars.spec: waiting for a slot (train bm14e holds them); results follow in a comment.

Real-iPhone checklist for kayg: open a long note; tap low in the text and type: the ‹, capsule and ⋯ stay put through the keyboard's opening and the scroll-to-caret; scroll with the keyboard up; tap ✓: no pill left shifted; repeat from the Home Screen icon.

## Evidence (iOS 27 Simulator on the Studio, build of 4711bb24; real iPhone not checked) Served from dev-02 through a port forward to the Studio (`localhost`), Safari tab and a Home Screen app (fresh icon per build). Scenario: tap the text, type, tap a line low in the note (iOS pans), type again, scroll, tap ✓ to close the keyboard. **Before (dev):** pills and band are gone while the keyboard is up (Safari tab and Home Screen). **After:** back, capsule and ⋯ stay, with the band. **The pan, measured in the page (Safari tab).** After typing low in a long note: `visualViewport.offsetTop` = 100, the pills' container drawn at -100 (off screen), a fixed marker at `top:100px` reads 0. With the fix the pills (`[data-chrome-pills]`) read 0 while that holds (screenshot `s-after-safari-during-pan`: ⋯ visible at its normal place). **Per-frame sampler (7 s of rAF from the first focus, Safari tab, the low-tap + typing scenario; the page's own overlay, not committed):** | build | frames | dt p50 / p95 / max (ms) | frames with a pan (`offsetTop` > 0.5) | frames with the pills off their place | max distance off (px) | |---|---|---|---|---|---| | dev (pills hidden anyway; container position) | 408 | 17.0 / 17.0 / 202 | 406 | 406 | 243 | | this PR | 409 | 17.0 / 17.0 / 48 | 404 | **0** | **0** | Honest limits: this is the Simulator on the Studio host, not the Mac VM, and the VM numbers are not measured yet; the rAF p95 of 17.0 ms is the sim's 60 Hz tick, so it only shows the correction adds no dropped frames there. Screen recordings are attached (long idle stretches removed; the sim redraws only on change). A frame-by-frame diff of the recordings was too noisy (caret blink, backdrop) to be a gate, so the per-frame rect sampler above is the check for "no jump or flicker". **First attempt caught by the simulator:** measuring the pan against the already-moved children added the shift again on every call and walked the pills down the screen (4711bb24 fixes that; the container's own rect is the pan alone). **Specs (compute/dev VMs, webkit-390 + chromium-390, `--repeat-each=5`, `CI=1 --workers=1 --retries=0`):** `e2e/note-pills.spec.ts` 50 passed at 4711bb24. keyboard.spec / phone-bars.spec: waiting for a slot (train bm14e holds them); results follow in a comment. **Real-iPhone checklist for kayg:** open a long note; tap low in the text and type: the ‹, capsule and ⋯ stay put through the keyboard's opening and the scroll-to-caret; scroll with the keyboard up; tap ✓: no pill left shifted; repeat from the Home Screen icon.
Author
Owner

Spec results at 4711bb24

CI=1 CALTERNAL_BIN=<bm14e debug build> bunx playwright test e2e/note-pills.spec.ts e2e/keyboard.spec.ts e2e/phone-bars.spec.ts e2e/pill-room.spec.ts --project=chromium-390 --project=webkit-390 --workers=1 --retries=0 --repeat-each=5 through ~/bin/slot on dev-01 compute02: 2680 passed (2.2 h), 0 failed, 0 flaky. (There is no top-pills spec; the pills are covered by note-pills and pill-room.)

Not measured: frame times on the Mac VM (the per-frame sampler above ran in the Studio simulator; p95 17.0 ms there is the 60 Hz tick). No new work on the typing path: the shift is read only from visualViewport / scroll events while the keyboard is open.

## Spec results at 4711bb24 `CI=1 CALTERNAL_BIN=<bm14e debug build> bunx playwright test e2e/note-pills.spec.ts e2e/keyboard.spec.ts e2e/phone-bars.spec.ts e2e/pill-room.spec.ts --project=chromium-390 --project=webkit-390 --workers=1 --retries=0 --repeat-each=5` through `~/bin/slot` on dev-01 compute02: **2680 passed (2.2 h), 0 failed, 0 flaky.** (There is no `top-pills` spec; the pills are covered by note-pills and pill-room.) Not measured: frame times on the Mac VM (the per-frame sampler above ran in the Studio simulator; p95 17.0 ms there is the 60 Hz tick). No new work on the typing path: the shift is read only from `visualViewport` / scroll events while the keyboard is open.
Author
Owner

First-paint diet (BM15, #947). Head d52759dc.

First-paint bytes are brotli q11 from apps/web/scripts/bundle-size.mjs (JS of the first-paint closure = the index chunk; CSS of the first stylesheet). Dev baseline 41f2efa3: 238,797 B JS, 19,250 B CSS. A second figure, "hash-normalised", is the same chunk with the 8-character chunk-file hashes replaced by a fixed string: the hashes are random, and they move the real brotli number by ±300 B between two builds of identical code, so a few-hundred-byte claim is only meaningful in the normalised figure (dev: 238,804). Chunk budgets: every new lazy chunk's line is taken from the first-script line or the idle-screens line so the total stays 404.2; perf/web.sh --bundle-only --gate passes on this head.

first JS first CSS
dev 41f2efa3 238,797 19,250
this PR 239,147 (+350; normalised 238,828 = +24) 19,238 (−12)

The hold is now one follower function instead of a Set and two loops (d52759dc); the pan hold is iOS-only, so it cannot be lazy without a flash. The +24 B normalised is the whole cost; the offset is in #875 (the swipe and tooltip engines, −2.5 KB), so merge #875 right after #887 and the running total stays under dev at every step.

note-pills.spec 10 passed (chromium 1440/390, webkit 1440/390). Lint ok, svelte-check = baseline, units ok.

**First-paint diet (BM15, #947).** Head `d52759dc`. First-paint bytes are brotli q11 from `apps/web/scripts/bundle-size.mjs` (JS of the first-paint closure = the `index` chunk; CSS of the first stylesheet). Dev baseline `41f2efa3`: **238,797 B JS, 19,250 B CSS**. A second figure, "hash-normalised", is the same chunk with the 8-character chunk-file hashes replaced by a fixed string: the hashes are random, and they move the real brotli number by ±300 B between two builds of identical code, so a few-hundred-byte claim is only meaningful in the normalised figure (dev: 238,804). Chunk budgets: every new lazy chunk's line is taken from the first-script line or the idle-screens line so the total stays 404.2; `perf/web.sh --bundle-only --gate` passes on this head. | | first JS | first CSS | |---|---|---| | dev `41f2efa3` | 238,797 | 19,250 | | this PR | 239,147 (**+350**; normalised 238,828 = **+24**) | 19,238 (**−12**) | The hold is now one follower function instead of a `Set` and two loops (`d52759dc`); the pan hold is iOS-only, so it cannot be lazy without a flash. The +24 B normalised is the whole cost; the offset is in #875 (the swipe and tooltip engines, −2.5 KB), so merge #875 right after #887 and the running total stays under dev at every step. `note-pills.spec` 10 passed (chromium 1440/390, webkit 1440/390). Lint ok, svelte-check = baseline, units ok.
Author
Owner

#926 per-PR gates: GREEN at bf3d7193

Head = e05e6b76 + merge of origin/dev b5e8dcb2, no conflicts. Gates on this branch alone (runner pergate.sh, through ~/bin/slot, CI=1 --retries=0 --workers=1).

gate result
bun run lint pass
svelte-check 2 errors, 16 warnings = dev's baseline
vitest web / ui primitives 107 / 55 pass
Playwright Chromium (dev-01) 1440 + 390: note-pills, list-chrome, keyboard, chrome-blur, note-bars-fit 41 passed, 37 skipped (project-specific), 0 failed
Playwright WebKit (dev-02) 1440 + 390, same specs 40 passed, 38 skipped, 0 failed
check-ui-primitives not on dev yet (arrives with #941)
perf/web.sh --bundle-only --gate pass (rc 0)
Rust perf n/a (no Rust)
## #926 per-PR gates: GREEN at `bf3d7193` Head = `e05e6b76` + merge of origin/dev `b5e8dcb2`, no conflicts. Gates on this branch alone (runner `pergate.sh`, through `~/bin/slot`, `CI=1 --retries=0 --workers=1`). | gate | result | |---|---| | `bun run lint` | pass | | svelte-check | 2 errors, 16 warnings = dev's baseline | | vitest web / ui primitives | 107 / 55 pass | | Playwright Chromium (dev-01) 1440 + 390: note-pills, list-chrome, keyboard, chrome-blur, note-bars-fit | 41 passed, 37 skipped (project-specific), 0 failed | | Playwright WebKit (dev-02) 1440 + 390, same specs | 40 passed, 38 skipped, 0 failed | | `check-ui-primitives` | not on dev yet (arrives with #941) | | `perf/web.sh --bundle-only --gate` | pass (rc 0) | | Rust perf | n/a (no Rust) |
Author
Owner

Merged into dev by the orchestrator (U1 per-PR gates GREEN), dev head 34c14822.

Merged into dev by the orchestrator (U1 per-PR gates GREEN), dev head 34c14822.
kayg closed this pull request 2026-11-05 16:51:05 +00:00
Some checks failed
Markdown extraction contract / markdown-native-contract (pull_request) Has been cancelled
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-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!926
No description provided.