Merge pull request #82 from bluk1020/fix/fn-001-mobile-board-corner

fix(FN-001): fix mobile board corner rendering
This commit is contained in:
gsxdsm
2026-05-12 17:02:08 -07:00
committed by GitHub
10 changed files with 275 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile Safari layout glitch where switching to the Kanban board view could render the dashboard compressed in a corner.

View File

@@ -14,6 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
@@ -25,6 +28,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
@@ -36,6 +42,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
@@ -54,6 +63,9 @@ jobs:
shard: [1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm

View File

@@ -891,3 +891,4 @@ Cards have `--focus-ring-strong` focus style and `--card-hover` background on ho
- **CSS regex tests in test files** — When changing mobile CSS values (e.g., `min-height`), update both the CSS and the corresponding test assertions. Use non-greedy `[^}]*` patterns for block-scoped regex, not `[\s\S]*` which can bleed across block boundaries.
- **BEM specificity conflicts** — When a container state class (`.quick-entry-box--expanded`) and an element modifier (`.quick-entry-input--expanded`) both target the same element, the container may win due to higher specificity. Use `:not(.modifier)` to scope container rules: `.quick-entry-box--expanded .quick-entry-input:not(.quick-entry-input--expanded)`.
- **CSS in `@media` blocks** — Don't search backwards for the nearest `@media` to check if a rule is mobile-scoped. Track brace depth to confirm the line is inside the block. Many components are defined globally even if they only visually appear on mobile.
- **Mobile board view-switch scroll-snap pitfall (FN-001)** — `scroll-snap-type: x mandatory` on mobile `.board` can cause iOS Safari to compress the viewport into a corner when switching from ListView because stale layout measurements are snapped before flex children resolve. Use `scroll-snap-type: x proximity` combined with `overflow-anchor: none` instead.

View File

@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
/**
* Stylesheet regression test for FN-001: Mobile board "corner rendering" bug.
*
* When switching from ListView to Board view on iOS Safari, the board could
* render compressed in a corner of the viewport. The root cause was
* `scroll-snap-type: x mandatory` forcing an immediate snap using stale layout
* measurements before flex children had resolved their intrinsic widths.
*
* This test locks in the stabilization properties that prevent the bug.
*/
/** Extract all content inside @media (max-width: 768px) blocks. */
function extractMobileMediaBlocks(content: string): string {
const blocks: string[] = [];
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
let match;
while ((match = regex.exec(content)) !== null) {
const startIdx = match.index + match[0].length;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount++;
if (content[endIdx] === "}") braceCount--;
endIdx++;
}
if (braceCount === 0) {
blocks.push(content.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
describe("board-mobile-corner-rendering (FN-001)", () => {
const cssContent = loadAllAppCss();
const mobileCss = extractMobileMediaBlocks(cssContent);
it("mobile .board uses scroll-snap-type: x proximity (not mandatory)", () => {
const boardBlock = mobileCss.match(/\.board\s*\{[^}]*\}/)?.[0] ?? "";
expect(boardBlock).toContain("scroll-snap-type: x proximity");
expect(boardBlock).not.toContain("scroll-snap-type: x mandatory");
});
it("mobile .board declares overflow-anchor: none", () => {
const boardBlock = mobileCss.match(/\.board\s*\{[^}]*\}/)?.[0] ?? "";
expect(boardBlock).toContain("overflow-anchor: none");
});
it("mobile .board declares width: 100%", () => {
const boardBlock = mobileCss.match(/\.board\s*\{[^}]*\}/)?.[0] ?? "";
expect(boardBlock).toContain("width: 100%");
});
it("mobile .list-view declares width: 100%", () => {
// .list-view width is defined in the base rule, not inside a media query,
// so we check the full CSS bundle.
const listBlock = cssContent.match(/\.list-view\s*\{[^}]*\}/)?.[0] ?? "";
expect(listBlock).toContain("width: 100%");
});
it("mobile .board does not use scroll-snap-type: x mandatory anywhere", () => {
// Mandatory snap should not appear in any mobile media block.
expect(mobileCss).not.toContain("scroll-snap-type: x mandatory");
});
});

View File

@@ -30,8 +30,8 @@ describe("scroll-snap CSS", () => {
);
const afterMedia = css.slice(mediaStart);
it("contains scroll-snap-type: x mandatory", () => {
expect(css).toContain("scroll-snap-type: x mandatory");
it("contains scroll-snap-type: x proximity", () => {
expect(css).toContain("scroll-snap-type: x proximity");
});
it("contains scroll-snap-align: center (not start)", () => {
@@ -53,7 +53,7 @@ describe("scroll-snap CSS", () => {
it("scroll-snap rules are inside a @media block", () => {
expect(mediaStart).toBeGreaterThanOrEqual(0);
expect(afterMedia).toContain("scroll-snap-type: x mandatory");
expect(afterMedia).toContain("scroll-snap-type: x proximity");
expect(afterMedia).toContain("scroll-snap-align: center");
});
});

View File

@@ -3,6 +3,7 @@
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
background: var(--bg);
}

View File

@@ -0,0 +1,179 @@
import React, { useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Board } from "../Board";
import { ListView } from "../ListView";
import "../../styles.css";
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
}),
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: vi.fn() }),
}));
vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => ({
badgeUpdates: new Map(),
isConnected: false,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
}),
}));
vi.mock("../../hooks/useSessionFiles", () => ({
useSessionFiles: () => ({ files: [], loading: false }),
}));
vi.mock("../../hooks/useTaskDiffStats", () => ({
useTaskDiffStats: () => ({ stats: null, loading: false }),
}));
vi.mock("../Column", () => ({
Column: React.memo(({ column }: { column: string }) => (
<div data-testid={`column-${column}`} />
)),
}));
function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
function mockMobileViewport() {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function ViewSwitchHarness() {
const [view, setView] = useState<"list" | "board">("list");
const boardProps = {
tasks: [],
maxConcurrent: 2,
onMoveTask: vi.fn(async () => ({}) as any),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
onQuickCreate: vi.fn(async () => ({}) as any),
onNewTask: vi.fn(),
autoMerge: true,
onToggleAutoMerge: vi.fn(),
globalPaused: false,
};
const listProps = {
tasks: [],
onMoveTask: vi.fn(async () => ({}) as any),
onRetryTask: vi.fn(async () => ({}) as any),
onDeleteTask: vi.fn(async () => ({}) as any),
onMergeTask: vi.fn(async () => ({ merged: false })),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
onNewTask: vi.fn(),
projectId: "proj-123",
};
return (
<div>
<button data-testid="switch-to-list" onClick={() => setView("list")}>
List
</button>
<button data-testid="switch-to-board" onClick={() => setView("board")}>
Board
</button>
<div className="project-content">
{view === "board" ? <Board {...boardProps} /> : <ListView {...listProps} />}
</div>
</div>
);
}
describe("Board mobile view switch (FN-001)", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders .board as <main> with the board class after switching from list view on mobile", () => {
const viewportSpy = mockMobileViewport();
render(<ViewSwitchHarness />);
// Start in list view
expect(document.querySelector(".list-view")).not.toBeNull();
expect(document.querySelector(".board")).toBeNull();
// Switch to board view
fireEvent.click(screen.getByTestId("switch-to-board"));
const board = document.querySelector(".board");
expect(board).not.toBeNull();
expect(board!.tagName).toBe("MAIN");
expect(board!.id).toBe("board");
viewportSpy.mockRestore();
});
it("preserves board structure through list -> board -> list -> board cycle on mobile", () => {
const viewportSpy = mockMobileViewport();
render(<ViewSwitchHarness />);
fireEvent.click(screen.getByTestId("switch-to-board"));
expect(document.querySelector(".board")).not.toBeNull();
expect(document.querySelector(".list-view")).toBeNull();
fireEvent.click(screen.getByTestId("switch-to-list"));
expect(document.querySelector(".list-view")).not.toBeNull();
expect(document.querySelector(".board")).toBeNull();
fireEvent.click(screen.getByTestId("switch-to-board"));
const board = document.querySelector(".board");
expect(board).not.toBeNull();
expect(board!.tagName).toBe("MAIN");
viewportSpy.mockRestore();
});
it("does not reintroduce scroll-snap-type: x mandatory after switching on mobile", () => {
const viewportSpy = mockMobileViewport();
render(<ViewSwitchHarness />);
fireEvent.click(screen.getByTestId("switch-to-board"));
const board = document.querySelector(".board") as HTMLElement;
expect(board).not.toBeNull();
expect(board.className).toContain("board");
viewportSpy.mockRestore();
});
});

View File

@@ -156,11 +156,11 @@ describe("Board desktop column width CSS", () => {
});
describe("Board and Column mobile CSS", () => {
it("contains .board scroll-snap-type: x mandatory in the mobile media block", () => {
it("contains .board scroll-snap-type: x proximity in the mobile media block (FN-001)", () => {
const css = loadAllAppCss();
const mobileSection = getMainMobileSection(css);
expectRuleToContain(mobileSection, ".board", "scroll-snap-type: x mandatory;");
expectRuleToContain(mobileSection, ".board", "scroll-snap-type: x proximity;");
});
it("contains .board scroll-behavior: smooth in the mobile media block", () => {

View File

@@ -3250,7 +3250,8 @@ input[type="range"]:focus-visible {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x mandatory;
scroll-snap-type: x proximity;
overflow-anchor: none;
scroll-padding-inline: calc(50% - 150px);
scroll-behavior: smooth;
scrollbar-width: none;
@@ -3258,6 +3259,7 @@ input[type="range"]:focus-visible {
padding-bottom: var(--space-md);
gap: var(--space-md);
height: 100%;
width: 100%;
}
.board::-webkit-scrollbar {

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",