fix(dashboard): compensate Android Chrome inflated ICB for fixed-position UI

Some Android Chrome builds (multi-window / split-screen / certain WebViews)
leave window.innerWidth/Height stuck larger than the actual rendered canvas.
DOM, body, and visualViewport report the true dimensions, but position:fixed
uses the ICB, pinning fixed-bottom elements offscreen below the visible area.
JS-side meta override (setAttribute and full replacement) does not force
Chrome to recompute the ICB on those builds.

index.html now publishes the ICB→visualViewport delta as CSS variables
(--icb-bottom-offset, --icb-right-offset) on <html>. MobileNavBar.css and
ExecutorStatusBar.css consume them so the bars pin to the visible viewport
edge regardless of ICB drift. Math is visualViewport-relative so it also
handles pinch-zoom in (offsets compensate) and pinch-zoom out (clamp at 0).
Healthy browsers see 0px and behave unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-22 13:55:20 -07:00
parent ed4d021d3d
commit e138289a80
4 changed files with 70 additions and 6 deletions

View File

@@ -0,0 +1,9 @@
---
"@fusion/dashboard": patch
---
fix(dashboard): compensate Android Chrome inflated ICB for fixed-position UI
Android Chrome (multi-window / split-screen / certain WebView configs) can leave the initial containing block (window.innerWidth/Height) stuck larger than the actual rendered canvas — DOM, body, and visualViewport report the true dimensions, but `position: fixed` uses the ICB, pinning fixed-bottom elements off the bottom of the visible viewport. JS-side meta override (both setAttribute and full element replacement) does not force Chrome to recompute the ICB on these builds.
Instead, publish the ICB→visualViewport delta as CSS variables (`--icb-bottom-offset`, `--icb-right-offset`) on `<html>` and consume them in `MobileNavBar` and `ExecutorStatusBar` so they pin to the visible viewport edge regardless of ICB drift. The math also handles pinch-zoom in (offsets compensate) and pinch-zoom out (offsets clamp at 0). Healthy browsers see 0px and behave unchanged.

View File

@@ -6,9 +6,11 @@
*/
.executor-status-bar {
position: fixed;
bottom: 0;
/* See MobileNavBar.css — ICB compensation for Android Chrome's inflated
initial containing block. Defaults to 0px on healthy browsers. */
bottom: var(--icb-bottom-offset, 0px);
left: 0;
right: 0;
right: var(--icb-right-offset, 0px);
z-index: 50;
display: flex;
align-items: center;
@@ -289,7 +291,7 @@
font-size: 11px;
height: calc(var(--space-lg) * 2 + var(--space-xs));
overflow: hidden;
bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
bottom: calc(var(--icb-bottom-offset, 0px) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
.executor-status-bar__segment {

View File

@@ -10,9 +10,14 @@
.mobile-nav-bar {
position: fixed;
bottom: 0;
/* --icb-bottom-offset / --icb-right-offset are published by the viewport
compensation script in index.html. On healthy browsers they stay 0px.
On Android Chrome when the ICB is inflated past the visible canvas
(multi-window / split-screen quirk) and on pinch-zoom, they pull
fixed-position elements back into the visible viewport. */
bottom: var(--icb-bottom-offset, 0px);
left: 0;
right: 0;
right: var(--icb-right-offset, 0px);
z-index: 45;
display: none;
align-items: stretch;
@@ -32,7 +37,7 @@
}
.mobile-nav-bar--with-footer {
bottom: 0;
bottom: var(--icb-bottom-offset, 0px);
}
/* Content padding: mobile nav only (no footer). Bar is flush at bottom (no safe-area pad). */

View File

@@ -8,6 +8,54 @@
this, Chrome decouples layout viewport (innerHeight) from visual
viewport, leaving innerHeight stale and breaking keyboard-overlap math. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<script>
// Android Chrome (multi-window / split-screen / certain WebView modes)
// can leave the initial containing block (window.innerWidth/Height)
// stuck larger than the actual rendered canvas, while the DOM,
// visualViewport, and body all report the true dimensions. JS cannot
// force Chrome to recompute the ICB (neither setAttribute nor full
// element replacement of <meta viewport> works on those builds). So
// we publish the ICB→visible-viewport delta as CSS variables and
// `position: fixed` elements (nav bar, status bar) compensate via
// `bottom: var(--icb-bottom-offset)` etc. Healthy browsers see 0px.
(function () {
if (typeof window === 'undefined') return;
var apply = function () {
var vv = window.visualViewport;
var de = document.documentElement;
if (!de) return;
var innerW = window.innerWidth;
var innerH = window.innerHeight;
// Position relative to the visual viewport (what the user actually
// sees). This works correctly across states:
// - Healthy: vv == ICB, offsets are 0.
// - Chrome ICB-stuck-large bug: vv smaller than ICB → offset > 0
// pulls fixed elements up/left into the visible area.
// - Pinch-zoom in: vv smaller than ICB → ditto.
// - Pinch-zoom out: vv larger than ICB → offsets clamp at 0.
var vvW = vv ? Math.round(vv.width) : innerW;
var vvH = vv ? Math.round(vv.height) : innerH;
var vvOffTop = vv ? vv.offsetTop : 0;
var vvOffLeft = vv ? vv.offsetLeft : 0;
var rightOffset = Math.max(0, innerW - vvOffLeft - vvW);
var bottomOffset = Math.max(0, innerH - vvOffTop - vvH);
de.style.setProperty('--icb-right-offset', rightOffset + 'px');
de.style.setProperty('--icb-bottom-offset', bottomOffset + 'px');
};
window.addEventListener('orientationchange', apply);
window.addEventListener('resize', apply);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', apply);
window.visualViewport.addEventListener('scroll', apply);
}
apply();
// Tail: ICB can drift after task content renders.
setTimeout(apply, 100);
setTimeout(apply, 500);
setTimeout(apply, 1500);
setTimeout(apply, 3000);
})();
</script>
<script>
// Append ?vpdebug to the URL to show a fixed overlay with live viewport
// + media-query state. Diagnostic only.