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:
5
.changeset/fix-android-tablet-viewport.md
Normal file
5
.changeset/fix-android-tablet-viewport.md
Normal 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.
|
||||
5
.changeset/fix-tablet-board-squeeze.md
Normal file
5
.changeset/fix-tablet-board-squeeze.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Dashboard board no longer squeezes all 6 columns into the visible width on tablet-sized viewports (769–1024px). 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.
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
- 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/*`.
|
||||
|
||||
@@ -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,14 +186,20 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// 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`.
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -261,7 +267,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,5 +1,5 @@
|
||||
import "./MobileNavBar.css";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
@@ -152,7 +152,6 @@ export function MobileNavBar({
|
||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const scriptEntries = useMemo(
|
||||
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
||||
@@ -205,36 +204,6 @@ export function MobileNavBar({
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [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) {
|
||||
return null;
|
||||
}
|
||||
@@ -280,7 +249,6 @@ export function MobileNavBar({
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={`mobile-nav-bar${footerVisible ? " mobile-nav-bar--with-footer" : ""}`}
|
||||
role="tablist"
|
||||
aria-label="Primary navigation"
|
||||
|
||||
@@ -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 { 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Fusion</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
@@ -3464,13 +3464,6 @@ input[type="range"]:focus-visible {
|
||||
/* === Tablet Responsive Tier (769px–1024px) === */
|
||||
/* Cross-cutting tablet rules only. Component-specific tablet overrides
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user