fix: keep the bottom panels on screen after leaving fullscreen on iPad - #382
Merged
Conversation
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
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>
lstein
added a commit
that referenced
this pull request
Aug 23, 2026
…384) PR #382 pulled the bottom panels back into the visible area after iPadOS strands the layout viewport at its fullscreen size, but the photo itself has the same problem: the swiper container and slide images are sized with 100dvh, which resolves against that same stranded layout viewport. After leaving fullscreen the bottom ~40px of the image — the height of the returning browser chrome — hangs off the bottom of the tablet, and neither pinch nor rotation brings it back. Only landscape shows it, because that is the orientation in which a contain-fit photo actually touches the top and bottom edges; in portrait the loss falls inside the letterbox bars. panel-anchor.js already measures the overshoot; it now also publishes the visible height as a --visible-viewport-height custom property on the root element, and the three 100dvh sizing rules in swiper.css consume it with 100dvh as the fallback. Where the viewports agree the property is never set and the CSS behaves exactly as before. Swiper notices the container resize through its default ResizeObserver, so no explicit update call is needed. Sizing the photo by the correction raises the stakes on two moments where the sync previously misfired against the small panels and now would visibly bounce the whole image, so both hold the current correction instead of recomputing: - The blur edge of a text field. On iPad the blur arrives while the software keyboard is still fully on screen — hiding the search panel blurs its input first, the keyboard collapses after — so the first resyncs after blur read the keyboard as a ~360px overshoot. The hold now extends from focus until 700ms past blur, and the expiry resamples the truth. - Pinch-zoom. Clearing the correction the moment scale crosses 1.01 grew the container back to the stranded 100dvh under the user's fingers (and dropped the panels back off-screen). The overshoot has not gone away because the user zoomed, so it is held; zoom-out resyncs via the viewport resize. Holding instead of recomputing also stops a rotate-with-keyboard-up from publishing a height that mixes the held overshoot with a fresh clientHeight. Not touched: grid view sizes its rows from window.innerHeight and recomputes only on its own events, so it still under-shows the bottom row on a stranded viewport — a follow-up, not part of this fix. The 100vh modals are centered and unaffected in practice. Tests: panel-anchor.test.js covers the property being published with the correction, staying unset when the viewports agree, clearing when they re-converge, the keyboard hold across focus, blur edge and collapse, the pinch hold and its release, and the rotate-with-keyboard case. Frontend 665 passed, lint and format clean. Claude-Session: https://claude.ai/code/session_01KLxwVusSvKkfdJS3g9QbB9 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-fullscreenclass did nothing, because visibility was never the problem.The fix
panel-anchor.jsmeasures how far the layout viewport's bottom sits belowwindow.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/focusoutresync so it is recomputed once the keyboard goes away. For the same reason the correction has a 24px floor — pinch-zoom is live (iOS ignoresuser-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 againstwindow.innerHeight— the layout bottom that is off the tablet — and now clamp to the exportedvisibleViewportBottom(), so their bottom rows are not cut off once the panels are reachable again.Hardening carried along
control-panel.jswas 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, becausevisibility: hiddenalso takes the fullscreen button out of hit testing:touch.jsalready does. The exit chain covers every spelling the state check accepts, includingmozCancelFullScreenandwebkitCancelFullScreen— neither legacy vendor calls it "exit", and without them the button enters fullscreen once and then does nothing for ever.resize,orientationchangeandvisualViewport resizeall resync. The last matters most — on a stranded exit the layout viewport does not change, sowindow.resizemay 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 withcontrol-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.🤖 Generated with Claude Code