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): -49839245eportal MobileNavBar -4bd9711e8drop keyboardOpen guard -d2e31dc10don't fight pinch zoom - f881ed22e visualViewport.width override -301e0a050self-healing overlay -16b18bf33publish mNavDebug -d3dff9442extend vpdebug -a9094d79drestore overlap test - 94bc43e04 restore FN-5476 observer -5a9095e54re-drop viewport flags + add vpdebug - and the index.html/Board.tsx portions offbf7e2cb4Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user