fix(dashboard): unsqueeze board on Android tablets

- Drop the tablet-tier `.board` grid rule that crammed 6 columns into
  ≤1024px viewports with no min-width, scrunching column content to
  unreadable widths. Tablets now use the default `minmax(300px, 1fr)`
  and scroll horizontally like desktop.
- Drop `maximum-scale=1.0, user-scalable=no` from the viewport meta.
  Combined with `initial-scale=1.0` those flags trigger Android Chrome
  layout bugs in multi-window mode; the Capacitor-feel justification
  isn't worth the breakage in a browser-rendered dashboard.
- Broaden the existing iOS scroll-snap stabilization in Board.tsx from
  `(max-width: 768px)` to any touch-primary device, and re-run it the
  first time tasks populate so Android tablets get the same first-cards-
  loaded reflow that mobile already had.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-21 23:05:15 -07:00
parent 496d275595
commit fbf7e2cb4d
9 changed files with 27 additions and 172 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Dashboard no longer renders into a small upper-left rectangle on Android Chrome in multi-window/freeform/split-screen mode. The page now re-asserts its viewport meta with the live `innerWidth` on every resize and orientation change, defeating Chrome's habit of caching `device-width` at the original screen size (which left the layout viewport wider than the actual window, so normal-flow elements clipped while position-fixed elements pinned to the full window). Also drops `maximum-scale=1.0, user-scalable=no` from the viewport meta, and broadens the existing board scroll-snap stabilization from phones to all touch-primary devices so Android tablets get the same first-cards-loaded reflow that iOS Safari mobile already had.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Dashboard board no longer squeezes all 6 columns into the visible width on tablet-sized viewports (7691024px). Columns now keep their 300px minimum and the board scrolls horizontally, matching desktop behavior. Previously Android tablets in portrait mode rendered columns scrunched to unreadable widths.

View File

@@ -12,11 +12,6 @@ If you find yourself opening `SettingsModal.css`, `TaskCard.css`, `ChatView.css`
Exception: explicit named user request in chat that overrides this directive. Exception: explicit named user request in chat that overrides this directive.
### Mobile bottom spacing token contract
- `--mobile-nav-height` keeps a `44px` CSS fallback, then gets republished at runtime by `MobileNavBar.tsx` (`ResizeObserver`) to match live nav content height.
- Bottom offsets should compose as: `var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap)`.
### Spec Generation Hygiene ### Spec Generation Hygiene
- Do not cite `.fusion/tasks/<id>/<file>` paths in Context/Steps/File Scope unless the file already exists, is explicitly created as a `(new)` Artifact, or is sibling `PROMPT.md`/`task.json`/`attachments/*`. - Do not cite `.fusion/tasks/<id>/<file>` paths in Context/Steps/File Scope unless the file already exists, is explicitly created as a `(new)` Artifact, or is sibling `PROMPT.md`/`task.json`/`attachments/*`.

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

View File

@@ -1,5 +1,5 @@
import "./MobileNavBar.css"; import "./MobileNavBar.css";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { import {
Activity, Activity,
Bot, Bot,
@@ -152,7 +152,6 @@ export function MobileNavBar({
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false); const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({}); const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false); const [scriptsLoading, setScriptsLoading] = useState(false);
const navRef = useRef<HTMLElement | null>(null);
const scriptEntries = useMemo( const scriptEntries = useMemo(
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)), () => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
@@ -205,36 +204,6 @@ export function MobileNavBar({
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [isMoreOpen]); }, [isMoreOpen]);
useLayoutEffect(() => {
const navEl = navRef.current;
if (!navEl || typeof document === "undefined") {
return;
}
const publishMeasuredHeight = () => {
const computed = window.getComputedStyle(navEl);
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
const contentHeight = navEl.offsetHeight - paddingBottom;
const publishedHeight = Math.max(44, Math.ceil(contentHeight));
document.documentElement.style.setProperty("--mobile-nav-height", `${publishedHeight}px`);
};
publishMeasuredHeight();
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => {
publishMeasuredHeight();
});
observer.observe(navEl);
}
return () => {
observer?.disconnect();
document.documentElement.style.removeProperty("--mobile-nav-height");
};
}, []);
if (mode !== "mobile" || modalOpen || keyboardOpen) { if (mode !== "mobile" || modalOpen || keyboardOpen) {
return null; return null;
} }
@@ -280,7 +249,6 @@ export function MobileNavBar({
return ( return (
<> <>
<nav <nav
ref={navRef}
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`} className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
role="tablist" role="tablist"
aria-label="Primary navigation" aria-label="Primary navigation"

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MobileNavBar } from "../MobileNavBar"; import { MobileNavBar } from "../MobileNavBar";
@@ -761,83 +761,4 @@ describe("MobileNavBar", () => {
}); });
}); });
}); });
describe("ResizeObserver height measurement", () => {
const originalResizeObserver = globalThis.ResizeObserver;
let resizeObserverCallback: ResizeObserverCallback | null = null;
let offsetHeightValue = 52;
class ResizeObserverMock {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver;
resizeObserverCallback = null;
offsetHeightValue = 52;
document.documentElement.style.removeProperty("--mobile-nav-height");
vi.restoreAllMocks();
});
it("publishes measured height on mount and clears inline token on unmount", () => {
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
vi.spyOn(window, "getComputedStyle").mockReturnValue({ paddingBottom: "0px" } as CSSStyleDeclaration);
const { container, unmount } = render(<MobileNavBar {...createDefaultProps()} />);
const navEl = container.querySelector(".mobile-nav-bar") as HTMLElement;
Object.defineProperty(navEl, "offsetHeight", {
configurable: true,
get: () => offsetHeightValue,
});
if (resizeObserverCallback) {
resizeObserverCallback([], {} as ResizeObserver);
}
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("52px");
unmount();
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("");
});
it("updates measured token when ResizeObserver callback fires", () => {
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
vi.spyOn(window, "getComputedStyle").mockReturnValue({ paddingBottom: "0px" } as CSSStyleDeclaration);
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const navEl = container.querySelector(".mobile-nav-bar") as HTMLElement;
Object.defineProperty(navEl, "offsetHeight", {
configurable: true,
get: () => offsetHeightValue,
});
if (resizeObserverCallback) {
resizeObserverCallback([], {} as ResizeObserver);
}
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("52px");
offsetHeightValue = 58;
if (resizeObserverCallback) {
resizeObserverCallback([{ target: navEl } as ResizeObserverEntry], {} as ResizeObserver);
}
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("58px");
});
it("still performs initial measurement when ResizeObserver is unavailable", () => {
// @ts-expect-error test intentionally clears ResizeObserver
delete globalThis.ResizeObserver;
vi.spyOn(window, "getComputedStyle").mockReturnValue({ paddingBottom: "0px" } as CSSStyleDeclaration);
render(<MobileNavBar {...createDefaultProps()} />);
expect(document.documentElement.style.getPropertyValue("--mobile-nav-height")).toBe("44px");
});
});
}); });

View File

@@ -2,8 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Viewport configured for Capacitor mobile webview: disables pinch-zoom for app-like feel --> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Fusion</title> <title>Fusion</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />

View File

@@ -3464,13 +3464,6 @@ input[type="range"]:focus-visible {
/* === Tablet Responsive Tier (769px1024px) === */ /* === Tablet Responsive Tier (769px1024px) === */
/* Cross-cutting tablet rules only. Component-specific tablet overrides /* Cross-cutting tablet rules only. Component-specific tablet overrides
live in co-located @media blocks in app/components/. */ live in co-located @media blocks in app/components/. */
@media (min-width: 769px) and (max-width: 1024px) {
.board {
grid-template-columns: repeat(6, minmax(0, 1fr));
overflow-x: hidden;
overflow-y: hidden;
}
}
/* === Badge Base === */ /* === Badge Base === */
.badge { .badge {