Skip to content

fix(studio): iPhone Safari layout + touch-drag scrubber#308

Merged
miguel-heygen merged 2 commits intomainfrom
fix/studio-ios-safari
Apr 17, 2026
Merged

fix(studio): iPhone Safari layout + touch-drag scrubber#308
miguel-heygen merged 2 commits intomainfrom
fix/studio-ios-safari

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Stacked on top of #307. Fixes three mobile UX bugs that made the studio unusable on iPhone Safari — discovered while testing the audio-ownership work from #307 on a physical device.

Bugs fixed

1. Untappable Play button / bottom controls

#root was set to 100vh. iOS Safari reports 100vh as the largest viewport (toolbar hidden) and never shrinks it — so with the toolbar visible, the bottom of the layout sits under it. The Play button + timecode were fully occluded.

2. Scrubber not draggable by touch

The seek bar had only onMouseDown. Mouse events don't fire for touches on iOS Safari, so nothing responded. You could tap to jump but not drag.

3. Safari's horizontal swipe hijacked scrubber drags

Even when the seek bar caught pointerdown, touch-action: manipulation still let Safari consume horizontal edge-swipes for back-navigation — dragging the scrubber left was impossible.

What changed

File Fix
packages/studio/src/styles/studio.css #rootheight: 100dvh with 100vh fallback. Dynamic viewport height shrinks when the iOS toolbar is visible, so the bottom of #root lines up with the visible area.
packages/studio/src/App.tsx Two h-screen containers → h-full so nested children fill the now-dynamic parent instead of asserting 100vh and overflowing.
packages/studio/index.html Added viewport-fit=cover so iOS exposes real env(safe-area-inset-bottom) values.
packages/studio/src/player/components/PlayerControls.tsx Controls row gets padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)) so it clears the landscape home indicator. Scrubber replaced onMouseDown with onPointerDown + setPointerCapture, plus touch-action: none so Safari doesn't hijack horizontal swipes. Added pointercancel + window-level pointerup fallbacks.

All desktop code paths are unchanged: 100dvh falls back to 100vh, env(safe-area-inset-bottom) is 0 off-iOS, Pointer Events subsume Mouse Events on desktop.

Verified live

Via the cloudflared tunnel I ran during review on the factory-series-c-video project:

  • iPhone Safari, portrait: Play button now fully visible and tappable. Bottom controls sit just above the URL bar.
  • iPhone Safari, landscape: controls clear the home indicator.
  • Finger-drag the scrubber left and right: tracks the touch smoothly, finger can leave the 6 px bar height without losing the drag.
  • Desktop click-to-seek and click-drag: still work.
  • Arrow-key seeking: still works.

Stacked dependency

Base is fix/player-audio-ownership-review (PR #307). Once #307 merges, rebase this branch onto main — the changes are fully independent; the stacking is just to avoid waiting on the review for #307 before shipping pure UX wins.

Test plan

  • tsc --noEmit on packages/studio — clean
  • bun run --filter @hyperframes/studio build — clean
  • Live repro on iPhone Safari via cloudflared tunnel: Play button tappable, scrubber drags with touch
  • Android Chrome sanity pass before release (same Pointer Events code path, but worth eye-balling)

Copy link
Copy Markdown
Collaborator Author

miguel-heygen commented Apr 17, 2026

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Nice scoped fix. The 100dvh with 100vh fallback, viewport-fit=cover + safe-area inset pairing, h-screen → h-full cascade, and Pointer Events migration with touch-action: none on the scrubber (not page-wide) are all textbook. Appreciate the explanatory comment blocks on the CSS and touch-action decisions — exactly the kind of context the next person maintaining this will need.

Two small concerns inline, both minor. Unblock on your call — happy to ship this and follow up.

One item from the test plan to flag: the Android Chrome sanity pass is still unchecked. Pointer Events are well-supported there, but env(safe-area-inset-bottom) semantics differ from iOS (0 on most devices). Worth two minutes in Chrome DevTools device mode or on a physical Android before merge — the controls row will look a touch tighter at the bottom on gesture-bar Android devices if the inset is 0.

Comment thread packages/studio/src/player/components/PlayerControls.tsx
Comment thread packages/studio/src/player/components/PlayerControls.tsx
Comment thread packages/studio/src/player/components/PlayerControls.tsx
miguel-heygen added a commit that referenced this pull request Apr 17, 2026
…focus

Addresses #308 review feedback from @jrusso1020:

- visibilitychange + window blur: synthesize drag cleanup when the page
  is backgrounded mid-drag. iOS Safari does not reliably fire
  pointercancel on alt-tab / incoming call / app switch, which would
  leave isDraggingRef latched true until the next pointerdown.
- Restore focus on pointerdown via e.currentTarget.focus() after the
  preventDefault() call, so the click-then-arrow-key seek workflow
  works without requiring an explicit Tab first.
- Clarify the releasePointerCapture try/catch comment so the next
  reader does not chase the second-invocation no-op throw as a bug.
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Walked through 1a21d93. All three prior concerns addressed cleanly.

Prior concerns — resolved

  1. Mid-drag cleanup on background/blur. Shared release extracted into a cleanup() closure; visibilitychange + window blur both call it. Cleanup removes its own listeners, so repeat invocation is a no-op. Covers the iOS alt-tab / incoming-call / app-switch paths where pointercancel doesn't fire.

  2. Click-to-focus regression. e.currentTarget.focus() after preventDefault() restores the click-then-arrow-key workflow. Comment explains the native <input type="range"> divergence and why we're intentionally diverging.

  3. Comment ambiguity on releasePointerCapture throw. Rewritten to read as intentional rather than a latent bug.

One non-blocking concern introduced by the delta

Listener leak if the component unmounts mid-drag. cleanup is registered on document (visibilitychange) and window (blur) and removes itself only when invoked. If PlayerControls unmounts while a drag is active (route change, React StrictMode double-mount in dev), neither listener fires, so the closures — holding target (now detached) and pointerId — linger on window / document until the next visibility or blur event. Practically harmless since one of those fires eventually, but it's a detached-node reference in the meantime.

Cheap fix if you want to close it: track the active drag's cleanup on a ref and call it from a useEffect return, or gate on isDraggingRef at unmount. Ship as-is and follow up is also fine.

Test plan

Android Chrome sanity pass still unchecked — acknowledged in your reply. Trust-but-verify before merge.

Approve.

@miguel-heygen miguel-heygen changed the base branch from fix/player-audio-ownership-review to graphite-base/308 April 17, 2026 22:49
@graphite-app
Copy link
Copy Markdown

graphite-app Bot commented Apr 17, 2026

Merge activity

  • Apr 17, 10:50 PM UTC: This pull request can not be added to the Graphite merge queue. Please try rebasing and resubmitting to merge when ready.
  • Apr 17, 10:50 PM UTC: Graphite disabled "merge when ready" on this PR due to: a merge conflict with the target branch; resolve the conflict and try again..
  • Apr 17, 11:44 PM UTC: @miguel-heygen merged this pull request with Graphite.

Three mobile UX bugs that made the studio unusable on iPhone Safari.

Untappable Play button / bottom controls
----------------------------------------
- #root used 100vh. iOS Safari reports 100vh as the largest viewport
  (toolbar hidden) and never shrinks it, so with the toolbar visible
  the bottom of the layout sat under it. Play button + timecode were
  occluded. Switched to 100dvh with 100vh fallback; old browsers keep
  the existing behaviour.
- App.tsx's two h-screen containers updated to h-full so nested
  children fill the now-dynamic #root instead of asserting 100vh and
  overflowing it.
- Added viewport-fit=cover to the viewport meta so iOS exposes real
  env(safe-area-inset-bottom) values to CSS.
- PlayerControls bottom padding now calc(0.5rem +
  env(safe-area-inset-bottom)) so the controls clear the landscape
  home indicator / curved bottom edge. No-op on desktop.

Scrubber not draggable by touch
-------------------------------
- Seek bar only had onMouseDown — iOS Safari fires Pointer Events
  for touches and nothing responded. Replaced with onPointerDown +
  setPointerCapture so one handler covers mouse / touch / stylus
  and drags keep tracking when the finger leaves the 6px hit zone.
- Added pointercancel handler so a home-indicator swipe doesn't
  wedge the drag in progress.
- touch-action: none (was 'manipulation') so Safari doesn't grab
  horizontal swipes for back-navigation while the user scrubs left.
- Window-level pointerup fallback defends against older WebKit
  builds that don't honour setPointerCapture.

Verified live on iPhone via cloudflared tunnel on the
factory-series-c-video repro: Play button tappable, scrubber drags
smoothly left/right with touch, landscape home indicator no longer
overlaps the controls.
…focus

Addresses #308 review feedback from @jrusso1020:

- visibilitychange + window blur: synthesize drag cleanup when the page
  is backgrounded mid-drag. iOS Safari does not reliably fire
  pointercancel on alt-tab / incoming call / app switch, which would
  leave isDraggingRef latched true until the next pointerdown.
- Restore focus on pointerdown via e.currentTarget.focus() after the
  preventDefault() call, so the click-then-arrow-key seek workflow
  works without requiring an explicit Tab first.
- Clarify the releasePointerCapture try/catch comment so the next
  reader does not chase the second-invocation no-op throw as a bug.
@miguel-heygen miguel-heygen force-pushed the fix/studio-ios-safari branch from 1a21d93 to db1f696 Compare April 17, 2026 23:43
@miguel-heygen miguel-heygen changed the base branch from graphite-base/308 to main April 17, 2026 23:43
@miguel-heygen miguel-heygen merged commit 42d3986 into main Apr 17, 2026
17 of 25 checks passed
@jrusso1020 jrusso1020 mentioned this pull request Apr 18, 2026
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