Skip to content

fix(staged): fix mobile Safari layout, keyboard, dialog and menu issues - #1015

Merged
matt2e merged 9 commits into
mainfrom
find-staged-web-weirdness
Nov 2, 2026
Merged

matt2e merged 9 commits into
mainfrom
find-staged-web-weirdness

Conversation

@matt2e

@matt2e matt2e commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Fixes the issues from the mobile Safari audit of Staged (issue numbers are in parentheses). Each fix was verified on the iPhone 17 Pro simulator (iOS 27.2), in Safari and as a home-screen app. The desktop/Tauri window is unchanged.

Changes

  • Menu sheets stay open after the tap that opens them (07): the scrim no longer treats the click that opened the sheet as an outside tap (scrimDismiss.ts).
  • Full-screen dialogs stay on screen above the keyboard (01, 04): the old keyboard-inset code is replaced by visualViewport.svelte.ts. Full-screen dialogs now follow the visual viewport's position and size, so the page no longer scrolls up by the keyboard's height.
  • Dialogs that focus an input on open now open in place (05): full-screen dialogs pack their rows at the top. Focus calls now go through focusAfterDialogEnter, which waits for the dialog's enter animation to finish, but only for full-screen dialogs on coarse pointers.
  • The shell stays inside Safari's visible viewport (02, 08): main uses 100dvh. On touch screens, dialogs restore focus with preventScroll. guardDocumentScroll undoes stray programmatic scrolls of the window or <body>. It leaves pinch-zoom and focused inputs outside full-screen dialogs alone.
  • Long-pressing a repo card opens its ⋮ menu (03): this uses the shared createLongPress controller. On coarse pointers the card no longer selects text or shows the callout. A hold that starts on the ⋮ trigger is left to the trigger.
  • The home-screen app keeps clear of the status bar and home indicator (06): the viewport meta now sets viewport-fit=cover, and the status bar style is now default. The TopBar, main, the DiffModal file list, the session launcher and toasts now pad for the safe-area insets. viewportFit.ts drops cover for installs made under the old black-translucent style, so those installs lay out as they did before.
  • The hashtag dropdown and other popovers stay inside the visible viewport (08): visibleViewportRect() and placeHashtagDropdown() choose a side and cap the height against the on-screen area instead of innerHeight. The dropdown no longer runs off the right edge. The remaining 100vh heights now use dvh.

Testing

  • Each new helper has unit tests: documentScroll, focusAfterDialogEnter, restoreFocusWithoutScroll, viewportFit, viewportGeometry, visualViewport, hashtagDropdownPlacement, scrimDismiss, safeArea and visibleViewportRect.
  • Each commit message records its manual check on the simulator.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-02T08:42:25.276920Z f606da9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

matt2e and others added 9 commits November 2, 2026 19:39
…em (07)

Issue 07 of the mobile Safari audit: a menu presented as a bottom sheet
(the ⋮ menus on repo cards and branches, the workspace-status chevron)
sometimes closed the instant it opened.

bits-ui opens a menu on the trigger's touch pointerup, before the tap's
synthesized click is dispatched. The sheet mounts under the lifting
finger, WebKit delivers that trailing click to the scrim now under it,
and the scrim closed the menu on any click.

The scrim now only dismisses on a click whose gesture began on it:
createScrimDismiss arms on the scrim's own pointerdown, disarms on
pointercancel, and fires once per armed click. Chosen over the note's
alternatives (ignore clicks for ~350 ms after mount, or dismiss on
pointerup) because it follows the gesture instead of a clock: a trailing
click delayed by a busy main thread still cannot close the sheet, and a
deliberate quick tap on the scrim right after opening still can.
Dismissing on pointerup would close before the click and let it fall
through to the page underneath, which is why the scrim owns the whole
gesture.

Overlay audit: the MenuSheet scrim is the only overlay that dismisses on
its own click. Dialogs, alert dialogs and popovers dismiss through
bits-ui's DismissibleLayer, which only acts on a pointerdown it captured
while mounted (the same arming rule), and popovers and sub-menus open on
click, so no trailing click follows them. Context-menu sheets and
sub-menu sheets share the MenuSheet frame and get the fix; long-press
opens already swallow their own click (longPress.ts).

Verified on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403), Safari: 24 taps opening the repo
card ⋮, branch ⋮ and workspace-status sheets each delivered their
trailing click to the unarmed scrim and none closed a sheet; every one of
the following scrim taps closed its sheet. Four long-press context-menu
sheets opened and closed by scrim tap the same way.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…d (01, 04)

Issues 01 and 04 of the mobile Safari audit: focusing the chat composer
in a full-screen dialog scrolled the whole page up by the keyboard's
height (scrollY 347 in Safari, 413 as a home-screen app), taking the
dialog header and transcript off screen until the user panned back.

iOS does not shrink the layout viewport or 100dvh for the keyboard. It
shrinks the visual viewport and scrolls the page to reveal the input,
even with html/body overflow: hidden. The old inset,
innerHeight - vv.height - vv.offsetTop, is 0 once Safari has scrolled
(innerHeight shrinks with the visual viewport on iOS 27), so the dialog
kept its full height in a layout viewport that was now scrolled away.

Full-screen dialogs now size and place themselves from the visual
viewport: top = vv.offsetTop, height = vv.height, via a pure
computeViewportGeometry (unit tested) and a ref-counted watcher,
visualViewport.svelte.ts, that replaces keyboardInset.svelte.ts. While a
full-screen dialog is mounted, each visual viewport resize or scroll
undoes the page scroll Safari added; the dialog already keeps the
composer above the keyboard, so Safari has no reason to scroll it back.
The reset runs on every change, not just the first resize after focus,
because iOS fires resize and scroll in either order while the keyboard
slides. A pinch-zoomed page (iOS ignores user-scalable=no) is left alone
and the dialog falls back to 100dvh. The safe-area bottom padding drops
while the keyboard is up, since the footer then sits on the keyboard,
not the home indicator.

The visual viewport is only followed while a text input has focus; with
nothing to type into, the dialog keeps its old top: 0; height: 100dvh,
and focus changes re-measure. iOS can leave the visual viewport stale: a
keyboard raised by a system sheet (Add to Home Screen) and still up when
Safari went to the background stayed subtracted from it (484 of 714 px)
after Safari returned, with nothing covering the page, and a dialog
sized from it ended 230 px above the toolbar. The page cannot have a
keyboard over it without a focused text input, so that gate drops the
stale reading without guessing at heuristics.

The note suggested publishing --vv-top / --vv-height on :root. The
geometry is instead reactive state applied as inline style on the dialog
element only: every write to the root's style attribute restyles the
whole document and fires isDark's MutationObserver, and the keyboard
moves the viewport on every frame of its slide. The old module already
deduplicated its root writes for the same reason. The watcher only runs
while a full-screen (mobile) dialog is mounted, so desktop is unchanged.

Verified on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403), note dialog chat composer with
the software keyboard: in Safari the visual viewport is 384 px at offset
0, scrollY 0 and the dialog spans 0-384 with its header visible; six
focus/dismiss cycles return to 714 px each time and closing the dialog
with the keyboard up leaves scrollY 0. As a home-screen app: 416 px,
scrollY 0, dialog 0-416, back to 812 px on dismiss. With Safari's
visual viewport stuck at 484 px as above, a note dialog spans 0-714 and
the composer keyboard still pins it to 0-384.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Issue 05 of the mobile Safari audit: a full-screen dialog that focuses
an input as it opens (Add Repository) appeared with its title and fields
386 px above the top of the screen.

Two things put the input where Safari had to scroll a long way for it.
Full-screen dialog content is a CSS grid whose auto rows stretch over
the whole height, so a short form sat spread down the screen; and the
explicit focus() calls ran while the dialog was still sliding up from
below, so the input was mostly off screen at the moment iOS revealed it.

Full-screen dialogs now pack their rows at the top (align-content:
start). Chosen over switching the mobile dialog to flex-col because it
leaves every caller's layout alone: flex-col dialogs already start at
the top, grids with a 1fr row still fill, and only the stretched auto
rows of the short form dialogs change.

The focus calls the note lists (RepoSearchInput, NewProjectForm,
RenameBranchDialog, MoveBranchDialog, NoteTransferDialog,
ActionEditDialog, NewSessionModal, SessionChatPane) go through
focusAfterDialogEnter, which waits for the enclosing dialog's own enter
animation to finish (getAnimations().finished) instead of a fixed delay,
so it tracks the real duration and a cancelled animation still focuses.
It only waits inside a full-screen dialog (marked data-full-screen);
desktop dialogs and non-dialog hosts focus immediately as before, so
typing straight after opening a dialog keeps every keystroke. The pooled
chat pane re-checks that it is still open when the deferred focus lands.

Verified on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403), Safari: Add Repository opens
with title, close button and search field on screen, the keyboard up and
the dialog at 0-411 with scrollY 0; Rename Branch opens top-aligned with
the name selected, the keyboard up and the dialog at 0-384. The deferred
focus still raises the software keyboard.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
In iOS Safari `100vh` is the viewport with the toolbar hidden, 754 px,
inside a 714 px `<body>` that is `overflow: hidden`. The 40 px overflow
can't be scrolled by the user, but it can be scrolled programmatically.
Closing a dialog returned focus to the workspace layer, which ran past
the body's bottom edge, and Safari scrolled `<body>` by 40 px to reveal
it. That left the TopBar and every hit target offset until a reload
(02). The same overflow kept the bottom 40 px of every pane under the
toolbar (08).

- `main` is `height: 100dvh`, with `100vh` as the fallback for webviews
  without dynamic units. The note also offered `100%`. I chose `100dvh`
  because `main` sits in `#app`, which has no definite height, so `100%`
  would only work if the whole ancestor chain were sized as well. The
  desktop window is unchanged because `dvh` equals `vh` there.
- On touch screens (`viewport.hasCoarsePointer`), dialog and alert
  dialog focus restore uses `focus({ preventScroll: true })`
  (restoreFocusWithoutScroll). It records the restore target the same
  way bits-ui's focus scope does and only takes over when the caller
  hasn't handled the close itself. With a mouse and keyboard, bits-ui's
  restore still scrolls the trigger into view.
- guardDocumentScroll, installed from App.svelte, undoes any scroll of
  the window (the scrolling element) or `<body>` as it happens. It
  leaves two cases alone:
  - a pinch-zoomed page;
  - a text input that has focus outside a full-screen dialog, where
    Safari's scroll is what keeps the input above the keyboard.
  The guard does nothing wherever the document never scrolls, which
  includes the desktop window.

isTextInput moves to textInput.ts and the zoom check becomes
isPinchZoomed, so visualViewport.svelte.ts and the guard share them.

Verified in Safari on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403):
- After closing a note dialog, `<body>` stays 714/714 and unscrolled,
  and the TopBar stays in place.
- I recreated issue 02's 754 px `main` with a dist-only probe. Closing
  the dialog then returned focus to the 42-754 layer with no `<body>`
  scroll event.
- A forced `body.scrollTop = 40` was back to 0 within 300 ms.
- Settings scrolled to the bottom shows its last line above the
  toolbar.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Long-pressing a repo card on iOS selected the title and raised the Copy
callout. The card had no long-press wiring, and only a reorderable card
suppressed selection.

A touch long press anywhere on the card now opens its ⋮ menu, which is
a bottom sheet on phones. It uses the shared context-menu controller
(createLongPress in longPress.ts) with the same 500 ms hold, movement
threshold and suppression of the release click as project, session and
timeline rows. On coarse pointers the card gets `user-select: none` and
`-webkit-touch-callout: none`, as context-menu-trigger does. A
`contextmenu` event opens the menu only on coarse pointers (Android
Chrome raises one for a touch hold); a mouse right-click keeps the
browser's default. A reorderable card is left alone, because there a
touch hold starts the drag.

The note offered two options: wrap the card in `ContextMenu.Trigger`, or
use `createLongPress`. I chose `createLongPress` driving the existing
DropdownMenu through `bind:open` because:
- the card keeps one menu definition instead of a second copy of its
  items to keep in sync;
- desktop gets no new right-click menu;
- on phones the dropdown and the context menu render as the same
  MenuSheet.

A long press can open the menu while the ⋮ trigger is scrolled out of
the horizontal repos row. bits-ui's close focus on that trigger then
scrolled the row sideways under the finger: the next tap meant for the
neighbouring card's ⋮ hit its "Add project". On touch screens the
trigger is now focused with `preventScroll`.

Verified in Safari on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403), on the home screen's repos row:
- Long-pressing a card's title opens its sheet, with no selection, no
  callout and no flash-close.
- Long-pressing "Add project" opens the sheet without starting a new
  project.
- A cloned repo's sheet loads its clone details (Copy Path).
- After cancelling the sheet of a card whose ⋮ is off screen, the row
  stays put, and tapping the next card's ⋮ opens that card's own sheet.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…e indicator (06)

The viewport meta had no `viewport-fit=cover`, so every
`env(safe-area-inset-*)` was 0. The safe-area padding already on the
menu sheet and full-screen dialogs fell back to nothing, which left the
sheets' Cancel row and the chat composer flush on the home indicator in
the home-screen app.

- index.html sets `viewport-fit=cover`.
- `main` pads the right, bottom and left insets, so the panes end above
  the home indicator and clear the notch in landscape.
- The TopBar pads the top inset itself, so its background fills the
  status bar wherever the page runs under it. The reset shell has no
  TopBar, so it pads the top inset directly.
- Three other edge-anchored surfaces would otherwise run under the
  status bar or home indicator, so they measure from the safe area too:
  - DiffModal's full-screen "Changed files" list;
  - the session launcher;
  - toasts, via svelte-sonner's offsets (safeAreaOffset).
- `apple-mobile-web-app-status-bar-style` is now `default` instead of
  `black-translucent`. With black-translucent and cover, iOS 27 draws
  the page under the status bar but keeps the layout viewport at the
  height below it (812 of 874 pt). The page then ends 62 pt above the
  bottom of the screen, a `bottom: 0` scrim leaves that strip undimmed,
  and the 34 px bottom inset lands above the real home indicator. With
  `default`, the page sits below the status bar and runs to the bottom
  of the screen, and the insets are top 0 / bottom 34, which puts the
  bottom inset on the home indicator. iOS tints the status bar to match
  the page in both themes. iOS reads this tag only when the app is
  added to the Home Screen, so an existing install keeps the old style
  until it is added again.

The desktop window is unchanged. In a WKWebView hosted like Tauri's
window (overlay title bar, full-size content view), every
`env(safe-area-inset-*)` reads 0 with and without
`viewport-fit=cover`, even though the view's own safe area is 32 pt.
The TopBar stays 42 px tall. Safari in portrait also reads 0 for every
inset and its layout is unchanged.

Verified on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403). In the home-screen app with
the clip's status bar style set to default (as a fresh Add to Home
Screen now records it):
- The page runs from below the status bar to the bottom of the screen,
  with insets 0/34.
- The repo ⋮ sheet's Cancel row and the chat composer sit above the
  home indicator, and the sheet still survives the opening tap (07).
- The status bar is tinted light or dark to match the theme.
- The keyboard still fits the full-screen chat dialog to 416 px.
In Safari, the insets read 0 and the layout is unchanged.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The rest of issue 08: popovers sized or placed from `innerHeight` or
`100vh` still assumed the layout viewport was what was on screen. On
iOS the keyboard covers the bottom of the layout viewport without
shrinking it, so with the keyboard up the hashtag dropdown measured
room below the caret down to `innerHeight` (714 - 328 = 386 px in
Safari), opened below, and landed under the keyboard.

- visibleViewportRect() gives the on-screen rectangle in client
  coordinates: the visual viewport, offset into the layout viewport, or
  the window where `visualViewport` is missing. Wherever the two agree,
  including the desktop window, it equals the window.
- placeHashtagDropdown() picks a side of the caret against that
  rectangle, caps the list's height to the room on that side (with a
  96 px minimum), and places it with `top` alone. Above the caret it
  uses `translate: 0 -100%` instead of `bottom`, so every position is in
  the caret's own coordinates and none is measured from the bottom of a
  layout viewport the keyboard doesn't shrink.
- On device the dropdown also ran off the right edge. Its left edge
  followed the caret while its width was `min(420px, 100vw - 32px)`, so
  it ended at x 407 on a 402 px screen even with the caret at the start
  of the line. It now gets the same width inline and moves left just
  far enough to keep the 16 px side margin that `max-width` already
  implied.
- ParentBranchCommitsHover clamps to visibleViewportRect() instead of
  `innerWidth`/`innerHeight`.
- The remaining `100vh` heights (ParentBranchCommitsHover,
  AcpConfigPickerShell, NewSessionModal, DiagramViewerModal) use `dvh`.

On desktop the side choice, the above/below positions and the width are
unchanged. The height cap and the left shift only apply where the list
used to overflow the window edge.

Verified on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403), by typing `#` on the on-screen
keyboard in the session composer:
- Safari: the visible area is 384 px, the caret top is 301, and the
  list sits at 17-297 x 16-386.
- Home-screen app: the visible area is 416 px, the caret top is 334, and
  the list sits at 49-329 x 16-386, with the caret mid-line after
  "Qwertyuiop ".
- Tapping an item inserted its reference badge.
- `<body>` stayed unscrolled throughout.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Review #review:9f8db66d-f08f-4ca5-a842-2ff263cb1b3e of the seven mobile
Safari fix commits a623787..c556063.

Repo card long press (03). A touch that begins inside the card's ⋮
DropdownMenu.Trigger no longer starts the long press, and a coarse-pointer
`contextmenu` there no longer opens the menu. bits-ui toggles a menu on
the trigger's touch pointerup, so a hold on ⋮ that had already opened the
sheet closed it again on release. Left to the trigger, the release opens
it like a tap.

Shared scroll predicate (02 vs 01/04). shouldKeepDocumentScroll is
exported from documentScroll.ts and visualViewport.svelte.ts uses it in
place of its own zoom check. Where the guard keeps Safari's page scroll
(a pinch-zoomed page, or a focused input outside the full-screen dialog,
such as IconPicker's search field portalled over ActionEditDialog), the
full-screen dialog now also leaves the scroll alone and keeps the layout
viewport's geometry, so it scrolls with the page and the input stays
where Safari revealed it. Following the visual viewport while keeping
the scroll would have carried the dialog, and the popover anchored in
it, back over the input. Unit tests cover the outside-input case in both
modules.

focusAfterDialogEnter (05). Only waits for the enter animation on a
coarse pointer, read directly from `(pointer: coarse)` because a dialog's
form can ask for focus before anything has synced viewport.hasCoarsePointer;
a narrow desktop window whose dialogs are full-screen focuses immediately
again. The deferred focus also requires the dialog's `data-state` to
still be `open`, since closing mid-slide cancels the enter animation with
the dialog still connected. SessionChatPane's insertSlashCommand goes
back to a plain `inputEl?.focus()`.

Existing home-screen installs (06). I recreated an install made before
viewport-fit=cover by restoring the web clip's original Info.plist
(WebClipStatusBarStyle LegacyBlackTranslucent, the backup from the fix
session) on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403), and launching the current build.
The reviewer's concern is real: insets read 62/34, the layout viewport
was 812 of 874 pt, drawn from the top of the screen, and with a ⋮ sheet
open the scrim ended at 812 and left the bottom 62 pt undimmed, with the
sheet's Cancel row and its 34 px inset 62 pt above the home indicator.

viewportFit.ts (new, called from main.ts before mount) drops the
`viewport-fit` key from the viewport meta when, in a home-screen app,
`env(safe-area-inset-top)` is non-zero but the layout viewport
(documentElement.clientHeight, which iOS leaves alone for the keyboard)
is shorter than the window (outerHeight). Without cover such an install
lays out exactly as it did before cover was added: page below the status
bar to the bottom of the screen, insets 0, sheets on the fallback 8 px
padding. A fresh install (status bar `default`) has no top inset and
keeps cover; so would a black-translucent install wherever iOS lays it
out to the bottom edge. The status-bar style itself is not readable from
JS, and gating cover on display-mode alone would take it from the fresh
installs that need it. The check could not be a one-shot at startup:
iOS delivers the insets about 2 s after launch, well after load and the
first frame, with a `resize`; so the guard listens to `resize` until it
has dropped cover, reading one hidden probe kept outside <body>.

Verified on the same simulator:
- Black-translucent clip, home-screen app: the insets arrived at
  17.65 s with a `resize`, cover was dropped, the next `resize` read
  insets 0/0, the TopBar sat at CSS 0-42 below the status bar, and a ⋮
  sheet's scrim spanned CSS 0-812 = the full screen below the status bar.
  The sheet ended at the bottom of the screen with 8 px padding, as before
  cover existed.
- Default clip (restored afterwards): cover kept, insets 0/34, TopBar
  below the status bar, as commit ecf2924 verified.
- Repo card, home-screen app: holds of 0.63, 0.65, 0.98 and 1.3 s on ⋮
  opened the sheet on release and it stayed open (the trailing click,
  when there was one, hit the unarmed scrim); holds on the title and on
  the card body opened it at ~500 ms and it stayed open; a plain tap on
  ⋮ still opens it. Verify counter: 7 mounts, 0 flashes.
Android Chrome's `contextmenu` path on ⋮ is reasoned from the same
toggle-on-pointerup, not tested.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ortFit

Review #review:6d54fcb5-8360-4276-8866-ca4a86eef4be of 8e86046 warned
that the viewport-fit=cover guard can only act once iOS delivers the
safe-area insets, which the guard's comment put at about 2 s after
launch. If so, every cold launch of an install added under
black-translucent would draw the TopBar under the clock for a second or
two, then shift it 62 px when cover was dropped.

Measured on the iPhone 17 Pro simulator, iOS 27.2
(0B6E1FFA-E4B0-4B95-B63E-7C195593E403), it does not happen, so there
is no behaviour change. The comment now gives the measured timing and
names the race that remains.

Setup: the Staged web clip's Info.plist swapped to the
LegacyBlackTranslucent backup, the app terminated before each launch
and opened from its icon. A dist-only probe, the first script in
<head>, logged the env() insets, the viewport meta, the layout height
and the TopBar's rect on every `resize` and animation frame that
changed them, and sent each line to a local logger.

Eight cold launches (bt-cold1..8):
- The insets arrived in a `resize` 71-136 ms after the first script
  ran, 18-77 ms after `load`. The guard dropped cover in that same
  `resize`, and the next one, 26-63 ms later, laid the page out below
  the status bar with insets 0/0.
- The TopBar first rendered 123-298 ms in. In seven launches its first
  frame was already its final position. In bt-cold6 it mounted in the
  same frame as the inset `resize`, already padded by the 62 px inset,
  so its content sat where it stayed when the layout switched one
  `resize` later.
- bt-cold6's video, frame by frame: before the app's first frame the
  window shows a blank page under the status bar for about 80 ms of
  iOS's launch fade. The first frame with app content has the TopBar
  below the status bar.
- Timed screenshots of bt-cold8, about 0.8 s apart from the tap: home
  screen, black launch screen, blank page, then the app below the
  status bar in every shot from 6.1 s to 14.5 s.
The 2 s figure did not reproduce in any launch.

The margin is small: in bt-cold6 the app rendered in the same frame as
the insets. If startup ever renders earlier, the first frames would sit
under the status bar and shift 62 px, and the comment says so. The fix
would then be the review's suggestion: store the decision and apply it
before mount. It is not added now because there is nothing to fix, and
a stored flag must never reach a `default` install. For whoever picks
it up, each web clip keeps its website data in its own folder
(Library/WebClips/<id>.webclip/Storage, with LocalStorage, CacheStorage
and IndexedDB), apart from Safari's. Isolation between clips was
not exercised.

Default clip restored afterwards, byte-identical to its backup: a cold
launch kept cover with insets 0/34 and the TopBar at CSS 0-46, below
the status bar.

Evidence in /tmp/staged-audit/work/launch/: summary.txt (per-launch
table), beacons.log (probe timelines), bt-cold1..7.mov,
zoom-bt6-95-106.png (bt-cold6 around its first app frame),
sheet-bt1.png, bt-cold8/ and sheet-bt8-timed.png (timed screenshots),
launch-probe.js and the launch scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the find-staged-web-weirdness branch from f606da9 to 0f89d14 Compare November 2, 2026 08:46
@matt2e
matt2e merged commit 4e48993 into main Nov 2, 2026
4 checks passed
@matt2e
matt2e deleted the find-staged-web-weirdness branch November 2, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant