revert(dashboard): roll back speculative Android tablet fixes

After a long session of guess-and-check without remote debugging, none
of the viewport/portal/keyboardOpen interventions cleanly fixed the
Android Chrome multi-window layout bug. Reverting index.html,
MobileNavBar.tsx, Board.tsx, and the FN-5476 overlap test back to
their pre-session state. The board-squeeze fix in styles.css is kept
(confirmed working).

The remaining workaround: turn off "Request Desktop Site" on the
Android tablet for an acceptable layout. Revisit with chrome://inspect
remote debugging when available.

Reverts (squashed):
- 49839245e portal MobileNavBar
- 4bd9711e8 drop keyboardOpen guard
- d2e31dc10 don't fight pinch zoom
- f881ed22e visualViewport.width override
- 301e0a050 self-healing overlay
- 16b18bf33 publish mNavDebug
- d3dff9442 extend vpdebug
- a9094d79d restore overlap test
- 94bc43e04 restore FN-5476 observer
- 5a9095e54 re-drop viewport flags + add vpdebug
- and the index.html/Board.tsx portions of fbf7e2cb4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-22 11:23:03 -07:00
parent 49839245e1
commit 0443718346
4 changed files with 12 additions and 201 deletions

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
/**
* Android overlap contract:
* - MobileNavBar keeps a 44px SSR fallback token.
* - MobileNavBar.tsx republishs --mobile-nav-height at runtime from live DOM height.
* - Footer/content offsets consume the token, so Android's taller label line-box no longer overlaps.
*/
function extractRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`));
return match?.[1] ?? "";
}
describe("mobile nav / executor footer overlap contract", () => {
const css = loadAllAppCss();
it("keeps 44px :root fallback token", () => {
expect(css).toMatch(/:root\s*\{[\s\S]*--mobile-nav-height:\s*44px;/);
});
it("uses min-height var token for the nav bar", () => {
const navBlock = extractRuleBlock(css, ".mobile-nav-bar");
expect(navBlock).toContain("min-height: var(--mobile-nav-height)");
expect(navBlock).not.toContain("height: 44px");
});
it("positions executor footer above nav using the token on mobile", () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*\.executor-status-bar\s*\{[\s\S]*bottom:\s*calc\(var\(--mobile-nav-height\)\s*\+\s*env\(safe-area-inset-bottom,\s*0px\)\s*\+\s*var\(--standalone-bottom-gap\)\)/);
});
it("uses nav-height token in mobile project-content padding rules", () => {
expect(css).toMatch(/\.project-content--with-mobile-nav:not\(\.project-content--with-footer\)\s*\{[\s\S]*padding-bottom:\s*calc\(var\(--mobile-nav-height\)/);
expect(css).toMatch(/\.project-content--with-footer\.project-content--with-mobile-nav\s*\{[\s\S]*padding-bottom:\s*calc\([\s\S]*var\(--mobile-nav-height\)/);
});
});

View File

@@ -186,20 +186,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
};
}, [projectId]);
// FN-4574 + FN-001 diagnosis: on touch-primary devices (iOS Safari mobile,
// Android Chrome tablet) the board can snap against stale layout/visualViewport
// metrics before columns resolve, both on initial mount and on pageshow/bfcache
// restore. On Android tablets this also fires the first time cards populate
// — scroll-snap re-evaluates and lands on a non-zero offset, leaving column 1
// partially off-screen. We keep the FN-001 baseline (`scroll-snap-type: x
// proximity` + `overflow-anchor: none`) and only stabilize via reflow + scroll
// offset normalization; do NOT reintroduce `scroll-snap-type: x mandatory`.
const tasksLoaded = tasks.length > 0;
// FN-4574 + FN-001 diagnosis: on iOS Safari, the mobile board can occasionally
// snap against stale layout/visualViewport metrics before flex columns resolve,
// both on initial mount and on pageshow/bfcache restore after backgrounding.
// We keep the FN-001 baseline (`scroll-snap-type: x proximity` +
// `overflow-anchor: none`) and only stabilize via reflow + scroll offset
// normalization; do NOT reintroduce `scroll-snap-type: x mandatory`.
useEffect(() => {
const touchPrimary =
window.matchMedia("(max-width: 768px)").matches ||
window.matchMedia("(hover: none) and (pointer: coarse)").matches;
if (!touchPrimary) {
if (!window.matchMedia("(max-width: 768px)").matches) {
return;
}
@@ -267,7 +261,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
clearTimeout(timeoutId);
}
};
}, [tasksLoaded]);
}, []);
// FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`,
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`

View File

@@ -1,6 +1,5 @@
import "./MobileNavBar.css";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
Activity,
Bot,
@@ -149,16 +148,6 @@ export function MobileNavBar({
shellConnectionControl,
}: MobileNavBarProps) {
const mode = useViewportMode();
// vpdebug: surface MobileNavBar render decision inputs for diagnostic overlay
if (typeof window !== "undefined") {
(window as unknown as { __mNavDebug?: unknown }).__mNavDebug = {
mode,
modalOpen,
keyboardOpen,
footerVisible,
view,
};
}
const [isMoreOpen, setIsMoreOpen] = useState(false);
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
@@ -246,18 +235,9 @@ export function MobileNavBar({
};
}, []);
// We previously hid the bar when keyboardOpen was true to avoid sitting
// over the iOS soft keyboard. On Android Chrome, useMobileKeyboard can
// get stuck at keyboardOpen=true after a focus-triggered visualViewport
// resize whose matching dismiss event never fires — leaving the nav bar
// permanently invisible (FN-Android-tablet repro). Render unconditionally
// when in mobile mode; on Android the keyboard pushes content up so the
// bar isn't covered, and on iOS the worst case (bar overlapping keyboard)
// is far less broken than the bar vanishing.
if (mode !== "mobile" || modalOpen) {
if (mode !== "mobile" || modalOpen || keyboardOpen) {
return null;
}
void keyboardOpen;
const planningHandler = activePlanningSessionCount > 0 && onResumePlanning ? onResumePlanning : onOpenPlanning;
@@ -297,12 +277,7 @@ export function MobileNavBar({
|| view === "stash-recovery"
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
// Portal the bar directly into document.body so its `position: fixed`
// can't be hijacked by an ancestor's containing block (any ancestor with
// transform/filter/will-change/contain creates a fixed-positioning
// containing block — and we hit exactly that on Android tablet, leaving
// the bar positioned far below the visible window).
const content = (
return (
<>
<nav
ref={navRef}
@@ -843,6 +818,4 @@ export function MobileNavBar({
)}
</>
);
if (typeof document === "undefined") return content;
return createPortal(content, document.body);
}

View File

@@ -2,127 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" id="viewport-meta" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<script>
// Android Chrome (multi-window, split-screen, freeform, certain WebView
// configs) can cache device-width at a smaller value than the actual
// window, leaving the page rendered into a small upper-left canvas
// while position:fixed elements pin to the much larger visual viewport.
// window.innerWidth returns the (broken) layout viewport, so re-asserting
// width=innerWidth changes nothing. visualViewport.width returns the
// actual window — re-assert against that on resize/orientation so Chrome
// recomputes the layout at the correct size.
(function () {
var meta = document.getElementById('viewport-meta');
if (!meta || typeof window === 'undefined') return;
var apply = function () {
var vv = window.visualViewport;
if (!vv) return;
var w = Math.round(vv.width);
var inner = window.innerWidth;
// Only override when layout viewport is meaningfully smaller than
// the visual viewport (the bug we're fixing). Otherwise keep the
// standard `width=device-width` so phones with the keyboard up etc.
// behave normally.
if (w > inner + 8) {
meta.setAttribute('content', 'width=' + w + ', initial-scale=1.0, viewport-fit=cover');
} else {
meta.setAttribute('content', 'width=device-width, initial-scale=1.0, viewport-fit=cover');
}
};
// Only fire on events that genuinely change the window — NOT on
// visualViewport.resize, which also fires on pinch-zoom and would
// snap the user's zoom back to 1.0 every gesture.
window.addEventListener('orientationchange', apply);
apply();
// Re-check shortly after load — Chrome's initial reading sometimes
// settles a frame or two later in multi-window.
setTimeout(apply, 100);
setTimeout(apply, 500);
setTimeout(apply, 1500);
})();
</script>
<script>
// Append ?vpdebug to the URL to show a fixed overlay with the live
// viewport / element dimensions. Diagnostic only — remove once the
// Android tablet cut-off issue is understood.
(function () {
if (!/[?&]vpdebug\b/.test(location.search)) return;
var box = document.createElement('div');
box.style.cssText =
'position:fixed;top:0;right:0;z-index:2147483647;background:#000;color:#0f0;font:11px/1.3 monospace;padding:6px 8px;border:1px solid #0f0;white-space:pre;pointer-events:none;max-width:60vw;';
var ensureAttached = function () {
if (document.body && box.parentNode !== document.body) {
document.body.appendChild(box);
} else if (document.body) {
// Force to last child so it wins paint order regardless of stacking ctx
if (document.body.lastChild !== box) {
document.body.appendChild(box);
}
}
};
var tick = 0;
var update = function () {
ensureAttached();
tick += 1;
var f = function (el) {
if (!el) return 'none';
var r = el.getBoundingClientRect();
return Math.round(r.width) + 'x' + Math.round(r.height) + ' @' + Math.round(r.left) + ',' + Math.round(r.top);
};
var vv = window.visualViewport;
var nav = document.querySelector('.mobile-nav-bar');
var navInfo = 'null';
if (nav) {
var cs = window.getComputedStyle(nav);
navInfo = f(nav) + ' disp=' + cs.display + ' vis=' + cs.visibility + ' z=' + cs.zIndex + ' op=' + cs.opacity;
}
var dialogs = document.querySelectorAll('[role="dialog"],.modal,.modal-overlay').length;
var focused = document.activeElement && document.activeElement.tagName;
var d = window.__mNavDebug || {};
var dbg = 'mode=' + d.mode + ' modal=' + d.modalOpen + ' kb=' + d.keyboardOpen + ' fv=' + d.footerVisible + ' v=' + d.view;
var pc = document.querySelector('.project-content');
var pcCls = pc ? pc.className.replace('project-content', '').trim() : 'none';
box.textContent =
'win ' + window.innerWidth + 'x' + window.innerHeight + '\n' +
'vv ' + (vv ? Math.round(vv.width) + 'x' + Math.round(vv.height) + ' s' + vv.scale.toFixed(2) + ' o' + Math.round(vv.offsetLeft) + ',' + Math.round(vv.offsetTop) : 'n/a') + '\n' +
'dpr ' + window.devicePixelRatio + '\n' +
'html ' + f(document.documentElement) + '\n' +
'body ' + f(document.body) + '\n' +
'root ' + f(document.getElementById('root')) + '\n' +
'board ' + f(document.getElementById('board')) + '\n' +
'mNav ' + navInfo + '\n' +
'mNavR ' + dbg + '\n' +
'pcCls ' + pcCls + '\n' +
'mq768 ' + window.matchMedia('(max-width: 768px)').matches + '\n' +
'dialogs ' + dialogs + ' focus ' + focused + '\n' +
'sx ' + window.scrollX + ' sy ' + window.scrollY + ' tick ' + tick + '\n' +
'docCW ' + document.documentElement.clientWidth + ' bodyCW ' + document.body.clientWidth + '\n' +
'docSW ' + document.documentElement.scrollWidth + ' bodySW ' + document.body.scrollWidth + '\n' +
'ua ' + (navigator.userAgent || '').slice(0, 80);
try {
localStorage.setItem('vpdebug:last', box.textContent);
localStorage.setItem('vpdebug:lastAt', String(Date.now()));
} catch (e) {}
};
var attach = function () {
if (!document.body) {
requestAnimationFrame(attach);
return;
}
document.body.appendChild(box);
update();
setInterval(update, 500);
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', update);
window.visualViewport.addEventListener('scroll', update);
}
};
attach();
})();
</script>
<!-- Viewport configured for Capacitor mobile webview: disables pinch-zoom for app-like feel -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Fusion</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="manifest" href="/manifest.json" />