Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).
- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
React.lazy + Suspense; prefetch all chunks on idle so first navigation is
instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.
- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
so CSS regression tests load the full per-component bundle (mirroring Vite
source order). Migrate 30+ tests off direct readFileSync('../styles.css').
- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
imports actually inject styles in jsdom (fixes getComputedStyle assertions).
- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
dashboard test files; points at loadAllAppCss() instead.
- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
.form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.
- Mobile fixes along the way: scroll containment for view containers
(min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
mobile (with safe-area-inset for iOS home bar), AgentsView single-row
header layout, ActivityLogModal close button on right, model-combobox
z-index above the mobile quick-chat panel.
- Bug fix: SkillsView toggle was display:none which hid the input from the
accessibility tree; replaced with the visually-hidden pattern so screen
readers + getByRole still find the checkbox.
- Bug fix: standalone Delete button in TaskDetailModal for triage-column
tasks (Actions dropdown is hidden in triage state, so previously no way
to delete a freshly-created task without status change first).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
import fs from "node:fs";
|
|
import { loadAllAppCss } from "../../test/cssFixture";
|
|
import path from "node:path";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import type { Task, TaskDetail, Settings } from "@fusion/core";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { fetchTaskDetail } from "../../api";
|
|
import { InlineCreateCard } from "../InlineCreateCard";
|
|
import { TaskCard } from "../TaskCard";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchTaskDetail: vi.fn(),
|
|
uploadAttachment: vi.fn(),
|
|
fetchMission: vi.fn(),
|
|
fetchAgent: vi.fn(),
|
|
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
|
fetchSettings: vi.fn().mockResolvedValue({
|
|
modelPresets: [],
|
|
autoSelectModelPreset: false,
|
|
defaultPresetBySize: {},
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 30_000,
|
|
groupOverlappingFiles: true,
|
|
autoMerge: true,
|
|
} satisfies Partial<Settings>),
|
|
updateGlobalSettings: vi.fn(),
|
|
fetchAgents: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
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 }),
|
|
}));
|
|
|
|
|
|
function getMainMobileSection(css: string): string {
|
|
const sectionStart = css.indexOf("/* === Mobile Responsive Overrides ===");
|
|
const sectionEnd = css.indexOf("/* === Tablet Responsive Tier", sectionStart);
|
|
|
|
expect(sectionStart).toBeGreaterThan(-1);
|
|
expect(sectionEnd).toBeGreaterThan(sectionStart);
|
|
|
|
return css.slice(sectionStart, sectionEnd);
|
|
}
|
|
|
|
function expectRuleToContain(section: string, selectorFragment: string, declaration: string): void {
|
|
const pattern = /([^{}]+)\{([\s\S]*?)\}/g;
|
|
let foundSelector = false;
|
|
let foundDeclaration = false;
|
|
|
|
for (const match of section.matchAll(pattern)) {
|
|
const selector = match[1];
|
|
const block = match[2];
|
|
|
|
if (!selector.includes(selectorFragment)) {
|
|
continue;
|
|
}
|
|
|
|
foundSelector = true;
|
|
if (block.includes(declaration)) {
|
|
foundDeclaration = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(foundSelector).toBe(true);
|
|
expect(foundDeclaration).toBe(true);
|
|
}
|
|
|
|
function createTask(overrides: Partial<Task> & { id?: string } = {}): Task {
|
|
return {
|
|
id: overrides.id ?? "FN-1139",
|
|
title: overrides.title,
|
|
description: overrides.description ?? "Mobile board test task",
|
|
column: overrides.column ?? "todo",
|
|
dependencies: overrides.dependencies ?? [],
|
|
steps: overrides.steps ?? [],
|
|
currentStep: overrides.currentStep ?? 0,
|
|
log: overrides.log ?? [],
|
|
createdAt: overrides.createdAt ?? "2026-04-08T00:00:00.000Z",
|
|
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
|
|
...overrides,
|
|
} as Task;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.clear();
|
|
}
|
|
});
|
|
|
|
describe("Board and Column mobile CSS", () => {
|
|
it("contains .board scroll-snap-type: x mandatory in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".board", "scroll-snap-type: x mandatory;");
|
|
});
|
|
|
|
it("contains .board scroll-behavior: smooth in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".board", "scroll-behavior: smooth;");
|
|
});
|
|
|
|
it("contains .board > .column width: 280px in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".board > .column", "width: 280px;");
|
|
});
|
|
|
|
it("contains .board > .column min-width: 280px in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".board > .column", "min-width: 280px;");
|
|
});
|
|
|
|
it("does not force .column-header min-height in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
// Column headers use natural height from padding/font — no forced min-height
|
|
const pattern = /([^{}]+)\{([\s\S]*?)\}/g;
|
|
for (const match of mobileSection.matchAll(pattern)) {
|
|
const selector = match[1].trim();
|
|
const block = match[2];
|
|
// Match exactly ".column-header" (not ".column-header .btn-icon" etc.)
|
|
if (selector !== ".column-header") continue;
|
|
// No rule targeting .column-header should set min-height
|
|
expect(block).not.toContain("min-height");
|
|
}
|
|
});
|
|
|
|
it("hides board scrollbars in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".board", "scrollbar-width: none;");
|
|
expectRuleToContain(mobileSection, ".board::-webkit-scrollbar", "display: none;");
|
|
});
|
|
|
|
it("uses simple padding-bottom on .board (safe-area handled by parent)", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
// Board padding-bottom is just var(--space-md) to avoid double-counting safe-area-inset-bottom
|
|
// which is already handled by .project-content--with-mobile-nav padding
|
|
expectRuleToContain(mobileSection, ".board", "padding-bottom: var(--space-md);");
|
|
});
|
|
});
|
|
|
|
describe("TaskCard mobile", () => {
|
|
it("sets .card-archive-btn opacity: 1 in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".card-archive-btn", "opacity: 1;");
|
|
});
|
|
|
|
it("does not force .card-archive-btn min-height in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
// Archive buttons use natural height from padding — no forced min-height
|
|
const pattern = /([^{}]+)\{([\s\S]*?)\}/g;
|
|
for (const match of mobileSection.matchAll(pattern)) {
|
|
const selector = match[1].trim();
|
|
const block = match[2];
|
|
if (!selector.includes(".card-archive-btn") && !selector.includes(".card-unarchive-btn")) continue;
|
|
// Archive/unarchive buttons should not have min-height in mobile block
|
|
expect(block).not.toContain("min-height");
|
|
}
|
|
});
|
|
|
|
it("does not force .card-steps-toggle min-height in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
// Steps toggle uses natural height from content and padding
|
|
const pattern = /([^{}]+)\{([\s\S]*?)\}/g;
|
|
for (const match of mobileSection.matchAll(pattern)) {
|
|
const selector = match[1].trim();
|
|
const block = match[2];
|
|
if (selector !== ".card-steps-toggle") continue;
|
|
expect(block).not.toContain("min-height");
|
|
}
|
|
});
|
|
|
|
it("does not force .card-session-files min-height in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
// Session files button uses natural height
|
|
const pattern = /([^{}]+)\{([\s\S]*?)\}/g;
|
|
for (const match of mobileSection.matchAll(pattern)) {
|
|
const selector = match[1].trim();
|
|
const block = match[2];
|
|
if (selector !== ".card-session-files") continue;
|
|
expect(block).not.toContain("min-height");
|
|
}
|
|
});
|
|
|
|
it("sets .card-edit-btn width and height to 32px in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".card-edit-btn", "width: 32px;");
|
|
expectRuleToContain(mobileSection, ".card-edit-btn", "height: 32px;");
|
|
});
|
|
|
|
it("opens task detail on quick tap", async () => {
|
|
const task = createTask({ id: "FN-200", column: "todo" });
|
|
|
|
const onOpenDetail = vi.fn();
|
|
const { container } = render(
|
|
<TaskCard task={task} onOpenDetail={onOpenDetail} addToast={vi.fn()} />,
|
|
);
|
|
|
|
const card = container.querySelector(`[data-id="${task.id}"]`) as HTMLElement;
|
|
expect(card).toBeTruthy();
|
|
|
|
fireEvent.touchStart(card, {
|
|
touches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
fireEvent.touchEnd(card, {
|
|
changedTouches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
|
|
// TaskCard calls onOpenDetail directly with the task - no fetchTaskDetail needed for card taps
|
|
expect(onOpenDetail).toHaveBeenCalledWith(task);
|
|
});
|
|
|
|
it("does not open task detail when touch gesture indicates scroll", async () => {
|
|
const task = createTask({ id: "FN-201", column: "todo" });
|
|
vi.mocked(fetchTaskDetail).mockResolvedValueOnce({
|
|
...task,
|
|
prompt: "",
|
|
attachments: [],
|
|
} as TaskDetail);
|
|
|
|
const { container } = render(
|
|
<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />,
|
|
);
|
|
|
|
const card = container.querySelector(`[data-id="${task.id}"]`) as HTMLElement;
|
|
expect(card).toBeTruthy();
|
|
|
|
fireEvent.touchStart(card, {
|
|
touches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
fireEvent.touchMove(card, {
|
|
touches: [{ clientX: 150, clientY: 100 }],
|
|
});
|
|
fireEvent.touchEnd(card, {
|
|
changedTouches: [{ clientX: 150, clientY: 100 }],
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not open task detail when tapping the edit button", async () => {
|
|
const task = createTask({ id: "FN-204", column: "todo" });
|
|
|
|
render(
|
|
<TaskCard
|
|
task={task}
|
|
onOpenDetail={vi.fn()}
|
|
addToast={vi.fn()}
|
|
onUpdateTask={vi.fn().mockResolvedValue(task)}
|
|
/>,
|
|
);
|
|
|
|
const editButton = screen.getByRole("button", { name: "Edit task" });
|
|
fireEvent.touchStart(editButton, {
|
|
touches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
fireEvent.touchEnd(editButton, {
|
|
changedTouches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not open task detail when tapping the steps toggle", async () => {
|
|
const task = createTask({
|
|
id: "FN-205",
|
|
column: "todo",
|
|
status: "executing",
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />,
|
|
);
|
|
|
|
const toggleButton = screen.getByRole("button", { name: "Show steps" });
|
|
fireEvent.touchStart(toggleButton, {
|
|
touches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
fireEvent.touchEnd(toggleButton, {
|
|
changedTouches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not open task detail when touch target is an SVG element inside a button", async () => {
|
|
const task = createTask({
|
|
id: "FN-206",
|
|
column: "todo",
|
|
status: "executing",
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
],
|
|
});
|
|
|
|
render(
|
|
<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />,
|
|
);
|
|
|
|
const toggleButton = screen.getByRole("button", { name: "Show steps" });
|
|
const svgTarget = toggleButton.querySelector("svg");
|
|
expect(svgTarget).toBeTruthy();
|
|
|
|
fireEvent.touchStart(svgTarget as SVGElement, {
|
|
touches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
fireEvent.touchEnd(svgTarget as SVGElement, {
|
|
changedTouches: [{ clientX: 100, clientY: 100 }],
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders edit button with aria-label in editable columns", () => {
|
|
const task = createTask({ id: "FN-202", column: "todo" });
|
|
|
|
render(
|
|
<TaskCard
|
|
task={task}
|
|
onOpenDetail={vi.fn()}
|
|
addToast={vi.fn()}
|
|
onUpdateTask={vi.fn().mockResolvedValue(task)}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole("button", { name: "Edit task" })).toBeTruthy();
|
|
});
|
|
|
|
it("hides the progress bar for todo cards that are not executing", () => {
|
|
const task = createTask({
|
|
id: "FN-203",
|
|
column: "todo",
|
|
status: "queued",
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
],
|
|
});
|
|
|
|
const { container } = render(
|
|
<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />,
|
|
);
|
|
|
|
expect(container.querySelector(".card-progress-bar")).toBeNull();
|
|
});
|
|
|
|
it("renders the progress bar when a todo card is executing", () => {
|
|
const task = createTask({
|
|
id: "FN-207",
|
|
column: "todo",
|
|
status: "executing",
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
],
|
|
});
|
|
|
|
const { container } = render(
|
|
<TaskCard task={task} onOpenDetail={vi.fn()} addToast={vi.fn()} />,
|
|
);
|
|
|
|
expect(container.querySelector(".card-progress-bar")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("InlineCreateCard mobile", () => {
|
|
it("contains .inline-create-input font-size: 16px in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".inline-create-input", "font-size: 16px;");
|
|
});
|
|
|
|
it("contains .inline-create-toggle min-height: 36px in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".inline-create-toggle", "min-height: 36px;");
|
|
});
|
|
|
|
it("contains .inline-create-controls .btn min-height: 36px in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".inline-create-controls .btn", "min-height: 36px;");
|
|
});
|
|
|
|
it("contains .inline-create-priority-select min-height: 36px in the mobile media block", () => {
|
|
const css = loadAllAppCss();
|
|
const mobileSection = getMainMobileSection(css);
|
|
|
|
expectRuleToContain(mobileSection, ".inline-create-priority-select", "min-height: 36px;");
|
|
});
|
|
|
|
it("renders Plan and Subtask buttons when expanded", () => {
|
|
render(
|
|
<InlineCreateCard
|
|
tasks={[]}
|
|
onSubmit={vi.fn().mockResolvedValue(createTask({ id: "FN-300" }))}
|
|
onCancel={vi.fn()}
|
|
addToast={vi.fn()}
|
|
availableModels={[]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("inline-create-toggle"));
|
|
|
|
expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "Subtask" })).toBeTruthy();
|
|
});
|
|
|
|
it("renders dependency dropdown when Deps button is clicked", () => {
|
|
render(
|
|
<InlineCreateCard
|
|
tasks={[createTask({ id: "FN-301", description: "Existing dependency task" })]}
|
|
onSubmit={vi.fn().mockResolvedValue(createTask({ id: "FN-302" }))}
|
|
onCancel={vi.fn()}
|
|
addToast={vi.fn()}
|
|
availableModels={[]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId("inline-create-toggle"));
|
|
fireEvent.click(screen.getByRole("button", { name: /Deps/i }));
|
|
|
|
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
|
});
|
|
});
|