Skip to content

fix: keep the bottom panels on screen after leaving fullscreen on iPad - #382

Merged
lstein merged 1 commit into
masterfrom
lstein/fix/ipad-fullscreen-panels
Aug 21, 2026
Merged

fix: keep the bottom panels on screen after leaving fullscreen on iPad#382
lstein merged 1 commit into
masterfrom
lstein/fix/ipad-fullscreen-panels

Conversation

@lstein

@lstein lstein commented Aug 21, 2026

Copy link
Copy Markdown
Owner

The report

On iPad (Chrome), entering fullscreen and leaving it again takes the control and search panels with it. They slide past the bottom of the tablet on the way out and never come back — rotating, resizing and switching to window mode all fail to restore them, and only a reload does. Safari is unaffected.

The cause

They are not hidden. They are laid out below the bottom edge of the screen.

Both panels are position: fixed; bottom: 10px, which resolves against the layout viewport, and iPadOS keeps the taller fullscreen-sized layout viewport after the browser chrome comes back. Ten pixels above that bottom edge is off the tablet, and nothing in the page ever re-lays them out.

The decisive clue was the reporter watching the panels move rather than vanish — an earlier attempt aimed at the hidden-fullscreen class did nothing, because visibility was never the problem.

The fix

panel-anchor.js measures how far the layout viewport's bottom sits below window.visualViewport — the region actually on screen — and translates the anchored elements up by that overshoot, following the viewport from then on. Where the two agree, which is every desktop browser and an iPad that behaves, the offset is zero and no transform is set at all.

The software keyboard produces the same signal, and on iPad it produces it far more often: tapping into the text search field shrinks the visual viewport by the whole keyboard height, which would fling both icon bars up into the middle of the photo at z-index 4000, over the dialog they belong under. Nothing in the geometry tells the two cases apart, so while a text field holds focus the last correction is held rather than recomputed, and focusin/focusout resync so it is recomputed once the keyboard goes away. For the same reason the correction has a 24px floor — pinch-zoom is live (iOS ignores user-scalable=no) and the first fraction of a pinch shrinks the visible area while the scale is still 1.00.

Anchored alongside the panels: #textSearchPanel, which strands identically. Not anchored: the score display, which hangs off the top of the viewport and would be pushed off that edge instead, and .curation-panel, which animates itself with a transform this would overwrite. The back-nav flyout and the bookmark menu clamp against window.innerHeight — the layout bottom that is off the tablet — and now clamp to the exported visibleViewportBottom(), so their bottom rows are not cut off once the panels are reachable again.

Hardening carried along

control-panel.js was making three assumptions a single iPad browser can each break, and being wrong about any of them latches .hidden-fullscreen (opacity: 0 + visibility: hidden, both !important) onto the panels while the app is windowed — unrecoverable, because visibility: hidden also takes the fullscreen button out of hit testing:

  • The state is read through the vendor-prefixed properties and the prefixed change events are subscribed to, matching what touch.js already does. The exit chain covers every spelling the state check accepts, including mozCancelFullScreen and webkitCancelFullScreen — neither legacy vendor calls it "exit", and without them the button enters fullscreen once and then does nothing for ever.
  • The state is resampled at 0/250/750 ms after each change event: an event delivered while the document still names the outgoing element reads as "still fullscreen", and the exit is animated so the viewport settles after the event.
  • resize, orientationchange and visualViewport resize all resync. The last matters most — on a stranded exit the layout viewport does not change, so window.resize may never fire, while the visible area shrinking always does.

Both syncs derive their result from the current state rather than toggling it, which is what makes every extra sample free.

Testing

  • tests/frontend/panel-anchor.test.js — overshoot maths, an offset visible area, clearing again, the resync triggers, the keyboard hold and its release, the mid-transition resample, visibleViewportBottom, six no-op cases, and the seam with control-panel.js.
  • tests/frontend/fullscreen-panels.test.js — the webkit-only transition, the prefixed request/exit paths including the cancel spelling, a browser with no fullscreen API, a stale-read exit, and all three resync signals.
  • Frontend 633 passed, backend 770 passed, ruff/eslint/prettier clean.
  • Verified on the reporter's iPad, text search included.

🤖 Generated with Claude Code

On iPad, entering fullscreen and leaving it again took the control and search
panels with it: they slide past the bottom of the tablet on the way out and
never come back. Rotating, resizing and switching to window mode all fail to
restore them; only a reload does. Safari is unaffected.

They are not hidden — they are laid out below the bottom edge of the screen.
Both panels are `position: fixed; bottom: 10px`, which resolves against the
layout viewport, and iPadOS keeps the taller fullscreen-sized layout viewport
after the browser chrome comes back. Ten pixels above *that* bottom edge is
off the tablet, and nothing in the page ever re-lays them out.

panel-anchor.js measures how far the layout viewport's bottom sits below
window.visualViewport — the region actually on screen — and translates the
anchored elements up by that overshoot, following the viewport from then on.
Where the two agree, which is every desktop browser and an iPad that behaves,
the offset is zero and no transform is set at all.

The software keyboard produces the same signal, and on iPad it produces it far
more often: tapping into the text search field shrinks the visual viewport by
the whole keyboard height. Treating that as an overshoot flings both icon bars
up into the middle of the photo at z-index 4000, over the dialog they belong
under. Nothing in the geometry tells the two apart, so while a text field
holds focus the last correction is held rather than recomputed, and
focusin/focusout resync so it is recomputed once the keyboard goes away. For
the same reason the correction has a 24px floor: pinch-zoom is live (iOS
ignores user-scalable=no) and the first fraction of a pinch shrinks the
visible area while the scale is still 1.00, which would twitch the panels
before the scale guard engages. Browser chrome, the thing actually being
corrected for, is far taller than that.

Anchored with the panels: #textSearchPanel, which strands identically. Not
anchored: the score display, which hangs off the *top* of the viewport and
would be pushed off that edge instead, and .curation-panel, which animates
itself with a transform this would overwrite. The back-nav flyout and the
bookmark menu clamp themselves against window.innerHeight — the layout bottom
that is off the tablet — and now clamp to the exported visibleViewportBottom()
instead, so their bottom rows are not cut off once the panels are reachable
again.

control-panel.js is hardened in passing, since it was making three assumptions
that a single iPad browser can each break, and being wrong about any of them
latches .hidden-fullscreen (opacity:0 + visibility:hidden, both !important)
onto the panels while the app is windowed — unrecoverable, because
visibility:hidden also takes the fullscreen button out of hit testing:

- The state is read through the vendor-prefixed properties and the prefixed
  change events are subscribed to, matching what touch.js already does. Two
  modules answering "are we fullscreen?" differently was a bug waiting for a
  browser to disagree with one of them. The exit chain covers every spelling
  the state check accepts, including mozCancelFullScreen and
  webkitCancelFullScreen — neither legacy vendor calls it "exit", and without
  them the button enters fullscreen once and then does nothing for ever.
- The state is resampled at 0/250/750ms after each change event, since an
  event delivered while the document still names the outgoing element reads as
  "still fullscreen", and the exit is animated so the viewport settles later
  than the event.
- resize, orientationchange and visualViewport resize all resync. The last of
  those matters most: on a stranded exit the layout viewport does not change,
  so window.resize may never fire, while the visible area shrinking always
  does.

Both syncs derive their result from the current state rather than toggling it,
which is what makes every extra sample free.

Tests: panel-anchor.test.js covers the overshoot maths, an offset visible
area, clearing again, the resync triggers, the keyboard hold and its release,
the mid-transition resample, visibleViewportBottom, the six no-op cases and
the seam with control-panel.js; fullscreen-panels.test.js covers the
webkit-only transition, the prefixed request/exit paths including the cancel
spelling, a browser with no fullscreen API, a stale-read exit and all three
resync signals. Frontend 633 passed, backend 770 passed, lint and format
clean. Verified on the reporter's iPad, text search included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lstein
lstein merged commit 205e62a into master Aug 21, 2026
10 checks passed
@lstein
lstein deleted the lstein/fix/ipad-fullscreen-panels branch August 21, 2026 22:56
lstein added a commit that referenced this pull request Aug 22, 2026
…383)

PR #382 (the iPad fullscreen-panel fix) carried stale copies of files it
never meant to touch, and squash-merging it rolled two already-merged PRs
all the way back to their pre-merge state:

  #378  Resolve InvokeAI board media through their recorded subfolder
        photomap/backend/invokeai_client.py
        photomap/backend/routers/index.py
        tests/backend/test_invokeai_client.py
        tests/backend/test_invokeai_board_index.py

  #376  Pausing mid-edit no longer discards the Cluster Strength
        photomap/frontend/static/javascript/umap.js
        photomap/frontend/static/css/umap-floating-window.css
        tests/backend/test_cluster_eps.py
        tests/frontend/umap-eps-debounce.test.js  (deleted outright)
        tests/frontend/umap-reindex-refresh.test.js

Every one of those files was byte-identical to its pre-merge content on
master, tests included, which is why nothing failed: the tests that would
have caught it went back with the code they covered.

The user-visible symptom is #378's: board albums went back to joining the
bare filename to <invokeai_root>/outputs/{images,videos}, so on a backend
whose subfolder strategy is not `flat` almost nothing resolved — indexing
the reporter's board skipped 386 of 387 files.

This restores both commits verbatim (cherry-picked, no conflicts) on top
of current master. #377's `asyncio.to_thread` hunk in index.py, which
landed after the revert in an untouched region, is preserved.

Verified live against the reporter's InvokeAI at localhost:9090: the same
album now resolves 386 of 387 files, the inverse of the reported failure.
The one remaining miss is a genuine gap — InvokeAI lists a video whose
subfolder resolves correctly and whose directory exists, but the file
itself is not on disk.

Backend 844 passed, frontend 656 passed (633 before, the difference being
umap-eps-debounce.test.js coming back), ruff clean. Checked the seam
between restored #376 and #375, which was authored against the reverted
tree: both floor the Cluster Strength at MIN_CLUSTER_EPS (0.01, the
spinner's own `min`), so they agree.


Claude-Session: https://claude.ai/code/session_01KtGdMfK3k6z2tazjDjMFtE

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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.

1 participant