feat(FN-1139): improve mobile board touch experience
- Add mobile board overrides for smooth horizontal snap scrolling, hidden scrollbars, and fixed 280px centered columns - Update task card mobile styles to keep key actions visible and enforce 44px touch targets for primary controls - Adapt inline create UI for narrow columns with 16px input text, wrapped controls, and constrained dependency dropdown sizing - Add board-mobile tests covering CSS mobile rules plus touch tap-vs-scroll behavior for TaskCard and InlineCreateCard - Document the mobile board interaction model and touch target conventions in the dashboard README
This commit is contained in:
@@ -132,6 +132,15 @@ The dashboard stylesheet defines a shared mobile foundation in `app/styles.css`
|
||||
- Text-entry controls (`input`, `select`, `textarea`) use **16px font-size** on mobile to prevent iOS Safari auto-zoom.
|
||||
- **Safe-area pattern (notched devices / Capacitor webview):** use `env(safe-area-inset-top|right|bottom|left, 0px)` for root/layout containers (for example `#root`, `.header`, `.modal`, `.board`) so content avoids status bars and home indicators.
|
||||
|
||||
### Mobile Board View
|
||||
At the mobile breakpoint (`@media (max-width: 768px)`), the board and card surfaces switch to a touch-first layout:
|
||||
|
||||
- **Horizontal board navigation:** `.board` uses horizontal scroll with `scroll-snap-type: x mandatory`, smooth scrolling, and hidden scrollbars so users can swipe cleanly between columns.
|
||||
- **Column sizing and centering:** each board column is fixed to `280px` (`width` + `min-width`) with `scroll-snap-align: center`, so one column is centered at a time during horizontal navigation.
|
||||
- **Compact card layout:** task cards use tighter spacing for badges/progress metadata on narrow columns, and mobile action controls (edit/archive/unarchive) remain visible without hover.
|
||||
- **Touch interaction model:** quick taps on cards open task details, while horizontal/vertical movement beyond the touch threshold is treated as scroll/gesture input (so swiping between columns does not accidentally open a card).
|
||||
- **Touch target convention:** interactive card controls (edit button, archive/unarchive actions, steps toggle, session-files button) follow a minimum `44px` touch target on mobile.
|
||||
|
||||
### Mobile Dropdown & Touch
|
||||
Mobile dropdown behavior follows a consistent viewport-aware anchoring pattern so menus stay usable in narrow viewports and virtual-keyboard scenarios.
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import fs from "node:fs";
|
||||
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 }),
|
||||
}));
|
||||
|
||||
const stylesPath = path.resolve(__dirname, "../../styles.css");
|
||||
|
||||
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 = fs.readFileSync(stylesPath, "utf-8");
|
||||
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 = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".board", "scroll-behavior: smooth;");
|
||||
});
|
||||
|
||||
it("contains .board > .column width: 280px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".board > .column", "width: 280px;");
|
||||
});
|
||||
|
||||
it("contains .board > .column min-width: 280px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".board > .column", "min-width: 280px;");
|
||||
});
|
||||
|
||||
it("contains .column-header min-height: 44px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".column-header", "min-height: 44px;");
|
||||
});
|
||||
|
||||
it("hides board scrollbars in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".board", "scrollbar-width: none;");
|
||||
expectRuleToContain(mobileSection, ".board::-webkit-scrollbar", "display: none;");
|
||||
});
|
||||
|
||||
it("keeps safe-area-inset-bottom handling on .board in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".board", "env(safe-area-inset-bottom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard mobile", () => {
|
||||
it("sets .card-archive-btn opacity: 1 in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".card-archive-btn", "opacity: 1;");
|
||||
});
|
||||
|
||||
it("sets .card-archive-btn min-height: 44px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".card-archive-btn", "min-height: 44px;");
|
||||
});
|
||||
|
||||
it("sets .card-steps-toggle min-height: 44px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".card-steps-toggle", "min-height: 44px;");
|
||||
});
|
||||
|
||||
it("sets .card-session-files min-height: 44px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".card-session-files", "min-height: 44px;");
|
||||
});
|
||||
|
||||
it("keeps .card-edit-btn width and height at 44px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".card-edit-btn", "width: 44px;");
|
||||
expectRuleToContain(mobileSection, ".card-edit-btn", "height: 44px;");
|
||||
});
|
||||
|
||||
it("opens task detail on quick tap", async () => {
|
||||
const task = createTask({ id: "FN-200", column: "todo" });
|
||||
const detail = {
|
||||
...task,
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
} as TaskDetail;
|
||||
|
||||
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(detail);
|
||||
|
||||
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 }],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith(task.id, undefined);
|
||||
});
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(detail);
|
||||
});
|
||||
|
||||
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("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("renders the progress bar when task has steps", () => {
|
||||
const task = createTask({
|
||||
id: "FN-203",
|
||||
column: "todo",
|
||||
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 = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".inline-create-input", "font-size: 16px;");
|
||||
});
|
||||
|
||||
it("contains .inline-create-toggle min-height: 44px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".inline-create-toggle", "min-height: 44px;");
|
||||
});
|
||||
|
||||
it("contains .inline-create-controls .btn min-height: 44px in the mobile media block", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
const mobileSection = getMainMobileSection(css);
|
||||
|
||||
expectRuleToContain(mobileSection, ".inline-create-controls .btn", "min-height: 44px;");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -5487,18 +5487,36 @@ body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: calc(50% - 140px);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
padding: var(--space-md);
|
||||
padding-bottom: max(var(--space-md), env(safe-area-inset-bottom, 0px));
|
||||
gap: var(--space-md);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.board::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.board > .column {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
flex-shrink: 0;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
/* Column header touch target */
|
||||
.column-header {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Column count badge: slightly larger on mobile for tapping */
|
||||
.column-count {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* Reduce header padding to reclaim horizontal space */
|
||||
.header {
|
||||
padding: var(--space-md);
|
||||
@@ -5686,6 +5704,102 @@ body {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Card: always show action buttons on mobile (no hover state) */
|
||||
.card-header-actions {
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-archive-btn,
|
||||
.card-unarchive-btn {
|
||||
opacity: 1;
|
||||
min-height: 44px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* Card: compact progress bar on narrow cards */
|
||||
.card-progress {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.card-progress-label {
|
||||
font-size: 10px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.card-steps-toggle {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Card: smaller status badges for 280px width */
|
||||
.card-status-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.card-mission-badge {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Card: wrap dependency badges */
|
||||
.card-dep-list {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Card: session files button touch target */
|
||||
.card-session-files {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Inline create: fit within 280px column */
|
||||
.inline-create-card {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inline-create-main-row {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.inline-create-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Inline create: 44px touch targets */
|
||||
.inline-create-toggle {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inline-create-description-actions .btn {
|
||||
min-height: 44px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
/* Inline create: wrap footer controls at 280px */
|
||||
.inline-create-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.inline-create-controls .btn {
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Inline create: constrain dependency dropdown to card width */
|
||||
.dep-dropdown {
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Tablet Responsive Tier (769px–1024px) === */
|
||||
|
||||
Reference in New Issue
Block a user