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:
5
.changeset/fn-001-mobile-board-corner-fix.md
Normal file
5
.changeset/fn-001-mobile-board-corner-fix.md
Normal 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.
|
||||
12
.github/workflows/pr-checks.yml
vendored
12
.github/workflows/pr-checks.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user