Skip to content

fix(status-bar): make status title tap reliably open rename on iOS#31

Open
goyamegh wants to merge 5 commits into
ashwin-pc:mainfrom
goyamegh:fix/ios-status-title-tap
Open

fix(status-bar): make status title tap reliably open rename on iOS#31
goyamegh wants to merge 5 commits into
ashwin-pc:mainfrom
goyamegh:fix/ios-status-title-tap

Conversation

@goyamegh

Copy link
Copy Markdown

Bug

On iPhone, tapping the session title in the top header was supposed to swap the title for an inline rename <input>. Instead, iOS Safari treated the tap as native text-selection on the underlying <span> (showing the blue selection handles + magnifier) and the click event for the rename handler never fired, so the session could not be renamed from the header.

Reproducible on iOS Safari (iPhone) — the screenshot in the bug report shows the selection handles around the title text with no rename input rendered.

Root cause

#statusTitle is a <span class="statusTitle"> with role="button" and cursor: pointer, but no user-select: none / -webkit-touch-callout: none. iOS Safari falls back to its default text-selection gesture on selectable text, which suppresses the click handler that swaps the span for the rename input.

We already use the same fix on .sessionItemMarkerBtn for exactly this reason.

Fix

src/styles/statusBar.css — on .statusTitle:

  • -webkit-user-select: none + user-select: none
  • -webkit-touch-callout: none
  • touch-action: manipulation

The element is already role="button" and keyboard-activatable (Enter/Space), so disabling text selection only removes a behaviour that was incompatible with the button contract.

Tests (Playwright e2e, tests/e2e/pi-web.spec.ts)

  1. New: assert the rendered .statusTitle has user-select: none, touch-action: manipulation, cursor: pointer, and role="button" — guards the CSS contract from regressing.
  2. New (mobile project only): simulate a real touchscreen.tap on the title and assert the rename <input> becomes visible + focused, then rename through it. This is the touch-event path the mouse-based existing test does not exercise.
  3. Existing: renames the current session from the status title still passes on mobile / tablet / desktop.

Verified the new CSS-contract test fails on origin/main without this change and passes with it.

Out of scope

No behaviour change on desktop / non-touch viewports. No JS changes.

On iOS Safari, tapping the status-title <span> in the top header was
interpreted as native text-selection (showing the blue selection
handles + magnifier) instead of firing the click handler that swaps
the span for a rename <input>. As a result the rename input never
appeared and the session could not be renamed from the header on
iPhone.

Mirror what we already do on .sessionItemMarkerBtn: mark the title as
non-user-selectable, suppress the iOS callout, and set
touch-action: manipulation so taps go straight to the click handler
without the 300ms delay. The element is already role="button" and
keyboard-activatable, so disabling text selection only removes a
behaviour that was incompatible with the button contract.

Tests:
- e2e: assert the rendered .statusTitle has user-select: none and
  touch-action: manipulation (regression for the CSS contract).
- e2e: on the mobile project, simulate a real touchscreen tap and
  assert the rename input becomes visible and focused, then rename
  the session through it.

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
Copilot AI review requested due to automatic review settings June 18, 2026 20:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an iOS Safari usability bug where tapping the status/session title in the header triggers native text selection instead of the rename interaction, preventing inline renaming from opening.

Changes:

  • Update .statusTitle styling to disable text selection/callout on iOS and prefer tap-style interactions (touch-action: manipulation).
  • Add Playwright e2e coverage for the CSS “non-selectable” contract and for a real touch tap flow on the mobile project.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/styles/statusBar.css Adds iOS/touch-focused CSS to ensure taps on the status title reliably behave like a button activation.
tests/e2e/pi-web.spec.ts Adds regression tests for the computed CSS contract and a mobile touchscreen.tap rename interaction.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/e2e/pi-web.spec.ts
Comment on lines +275 to +291
const styles = await page.locator("#statusTitle").evaluate((el) => {
const cs = getComputedStyle(el);
return {
userSelect: cs.userSelect || (cs as any).webkitUserSelect,
webkitUserSelect: (cs as any).webkitUserSelect,
webkitTouchCallout: (cs as any).webkitTouchCallout,
touchAction: cs.touchAction,
cursor: cs.cursor,
role: el.getAttribute("role"),
};
});
expect(styles.role).toBe("button");
expect(styles.cursor).toBe("pointer");
expect([styles.userSelect, styles.webkitUserSelect]).toContain("none");
// touch-action: manipulation removes the iOS 300ms tap delay too.
expect(styles.touchAction).toBe("manipulation");
});
…pointer tap

The CSS-only fix (user-select:none) stopped the title <span> itself from
being text-selected, but two iOS-specific issues still made the header
rename unusable on iPhone:

1. iOS Safari fires a spurious `blur` on the freshly-focused rename input
   while the on-screen keyboard animates in / the visual viewport resizes.
   The old `blur -> finish(true)` handler tore the input back down to the
   static title before the user could type, so the tap looked like it did
   nothing. Now the blur-to-save handler is only armed once focus has
   settled (350ms); an earlier blur re-focuses the input and keeps the
   keyboard up.

2. The tap could still be partly consumed by iOS's selection gesture. Open
   the editor on `pointerup` with preventDefault (suppressing the native
   selection), falling back to `click` where Pointer Events are absent, with
   a short window to stop the synthesized click from double-triggering.

Tests (tests/e2e/pi-web.spec.ts, mobile project):
- extend the touch-tap test to dispatch the spurious post-focus blur within
  the grace window and assert the rename input survives it and still saves.
  Verified this fails without the blur guard (input torn down) and passes
  with it.

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
@goyamegh

Copy link
Copy Markdown
Author

Verification

Reproduced on an iPhone (iOS Safari): tapping the session title in the header did not open the rename field. Root cause turned out to be three separate iOS behaviours, fixed in two commits:

  1. Native text-selection on the <span> — a tap was treated as a text-selection gesture (blue handles + magnifier) and swallowed the click. Fixed with user-select:none; -webkit-touch-callout:none; touch-action:manipulation on .statusTitle.
  2. Spurious blur on the freshly-focused input while the on-screen keyboard animates in — the old blur -> finish(true) tore the rename input back down before the user could type (tap appeared to do nothing). Fixed by arming the blur-to-save handler only after focus settles (350ms); an earlier blur re-focuses instead.
  3. Tap partly consumed by the selection gesture — now the editor opens on pointerup with preventDefault, falling back to click.

Automated proof (Playwright, tests/e2e/pi-web.spec.ts)

  • status title is not text-selectable on iOS so taps reach the rename handler — asserts the rendered .statusTitle has user-select:none, touch-action:manipulation, role=button.
  • tapping the status title on a touch viewport opens the rename input and survives an iOS keyboard blur (mobile project) — real touchscreen.tap opens the input, a spurious blur dispatched within the grace window does not tear it down, and the rename still saves.
$ npx playwright test tests/e2e/pi-web.spec.ts -g "status title|rename|touch viewport|survives|New session resets"
  2 skipped
  10 passed (13.3s)

Verified the blur-survival test fails without the guard (input torn down by the spurious blur) and passes with it. Also verified end-to-end against a running server under iPhone 13 emulation: title styles correct, tap opens the input, input survives the spurious blur, and typing + Enter persists the new name.

…rkFirst nav)

Even with the rename fix shipped, iPhone clients kept running the old
bundle because the service worker layer never let the new build through.
Two root causes, both fixed here:

1. The static file server sent no Cache-Control headers, so iOS
   heuristically cached /sw.js, never re-fetched the service-worker
   script, never saw the new SW, and the old SW kept serving the old
   precached bundle forever. Now:
     - sw.js / registerSW.js / workbox-*.js -> no-cache, no-store, must-revalidate
     - index.html / *.html / manifest         -> no-cache
     - content-hashed assets/index-*.{js,css} -> public, max-age=31536000, immutable

2. vite-plugin-pwa registers an SPA NavigationRoute bound to the precached
   index.html before runtimeCaching, shadowing our NetworkFirst navigate
   route. A stale SW therefore served old precached HTML (old asset hashes)
   with no network fallback. Deny-list every navigation from that precache
   fallback so navigations fall through to NetworkFirst (3s timeout, cached
   fallback offline) — any reload now self-heals to the latest build.

Tests:
- tests/api.test.ts boots server.ts in production mode and asserts the
  three Cache-Control classes (all three fail without the change).
- tests/sw-config.test.ts asserts the generated dist/sw.js denylists the
  precache navigation fallback, routes navigations NetworkFirst, and keeps
  skipWaiting/clientsClaim/cleanupOutdatedCaches.

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
@goyamegh

Copy link
Copy Markdown
Author

Added: make iOS actually receive new builds

The rename fix alone wasn't enough on a real iPhone — the device kept running the old bundle because the service-worker layer never let the new build through. Two root causes, both fixed (commit 6c9748a):

1. No Cache-Control headers on the static server

The server sent no cache headers at all, so iOS heuristically cached /sw.js, never re-fetched the SW script, never saw the new service worker, and the old SW kept serving the old precached bundle indefinitely. Now:

  • sw.js / registerSW.js / workbox-*.jsno-cache, no-store, must-revalidate
  • index.html / *.html / manifest → no-cache
  • content-hashed assets/index-*.{js,css}public, max-age=31536000, immutable

2. Precache NavigationRoute shadowed NetworkFirst

vite-plugin-pwa registers an SPA NavigationRoute bound to the precached index.html before runtimeCaching, so it shadowed our NetworkFirst navigate route. A stale SW therefore served old precached HTML (old asset hashes) with no network fallback. Now every navigation is deny-listed from that precache fallback (navigateFallbackDenylist: [/./]) and falls through to NetworkFirst (3s timeout, cached fallback offline) — so any reload self-heals to the latest build.

Tests

  • tests/api.test.ts boots server.ts in production mode and asserts all three Cache-Control classes (verified all three fail without the change).
  • tests/sw-config.test.ts asserts the generated dist/sw.js denylists the precache navigation fallback, routes navigations NetworkFirst, and keeps skipWaiting/clientsClaim/cleanupOutdatedCaches.
$ npx vitest run tests/sw-config.test.ts tests/api.test.ts -t "cache headers|navigation strategy"
  Test Files  2 passed (2)
  Tests  4 passed | 36 skipped (40)

Also re-verified end-to-end under iPhone 13 emulation against a running server: tap opens the rename input, it survives the spurious iOS keyboard blur, and it is editable.

goyamegh added 2 commits June 19, 2026 23:28
Opt-in self-destroying service worker served at /sw.js when
PI_WEB_SW_RESET=1 or a .pi-sw-reset sentinel file exists. Lets a stale,
captured client (e.g. an iPhone that won't update) be forced to drop its
old SW + caches server-side: the client re-fetches /sw.js on navigation,
installs the kill SW, which clears caches, unregisters, and reloads once
(loop-safe via a marker cache). Flip off to restore the normal PWA SW.

Test asserts /sw.js is the self-destroying script under PI_WEB_SW_RESET=1.

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
iOS Safari only raises the keyboard when input.focus() runs inside a
click/touchend gesture; focusing from pointerup (or after preventDefault)
opens the field but leaves the keyboard down, so a finger tap appears to
do nothing while a desktop click works. Trigger purely on click; selection
is already prevented by user-select:none + touch-action:manipulation.

Verified on real WebKit (iPhone 14 profile) with a genuine touch tap.

Signed-off-by: Megha Goyal <goyamegh@amazon.com>
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.

2 participants