feat(FN-766): add three-tier responsive header with tablet compact mode

- Add tablet compact-header breakpoint (769px–1024px) with condensed layout
- Implement responsive Header component with mobile/tablet/desktop modes
- Add tablet-header-controls test suite (373 lines) for compact mode behavior
- Update existing Header and mobile-header-controls tests for new responsive tiers
- Refactor server routes and styles.css for three-tier responsive support
- Document responsive header architecture in README
This commit is contained in:
gsxdsm
2026-04-03 07:39:11 -07:00
parent 74a93ba0d4
commit 797ac1a8d8
7 changed files with 618 additions and 171 deletions

View File

@@ -52,12 +52,12 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks
### Responsive Header
The dashboard header adapts to small screens to remain usable without wrapping or dropping controls:
The dashboard header adapts across three responsive tiers to remain usable without wrapping or dropping controls:
- **Mobile Overflow Menu**: On screens narrower than 768px, lower-priority actions (GitHub Import, Planning, Settings, and optionally Usage) move into an accessible overflow menu triggered by a "More actions" button. The menu closes on outside click, Escape key, or after selecting an action.
- **Collapsible Board Search**: On mobile board view, the search input collapses to an icon button. Tapping the icon expands a focused search field. If a search query is already active, the search stays expanded until cleared or explicitly closed so active filters remain visible.
- **Always-Visible Controls**: View toggle (Board/List), Terminal, Pause, and Stop buttons remain inline on mobile for immediate access.
- **Keyboard Accessible**: All mobile controls expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.
- **Mobile (≤768px)**: Lower-priority actions (GitHub Import, Planning, Settings, and optionally Usage) move into an accessible overflow menu triggered by a "More actions" button. The menu closes on outside click, Escape key, or after selecting an action. The board search input collapses to an icon button; tapping it expands a focused search field that stays visible while a query is active. The project selector and back button are hidden to save space. View toggle (Board/List), Terminal, Pause, and Stop buttons remain inline for immediate access.
- **Tablet (769px1024px)**: The header uses a compact layout that keeps the view toggle, search input, and both engine controls (Pause/Resume scheduling and Stop/Start AI engine) inline at all times. Lower-priority utility actions (GitHub Import, Planning, Settings, Usage) move into the overflow menu so the engine controls never disappear. The project selector and back button are hidden on tablet.
- **Desktop (>1024px)**: Full header with all controls and the project selector inline. No overflow menu.
- **Keyboard Accessible**: All controls across tiers expose proper ARIA attributes (aria-expanded, aria-haspopup, aria-label) and support keyboard navigation.
### Mobile Task Entry
Task entry inputs (the quick entry box in the Triage column and the New Task modal's description field) are sized to prevent browser zoom-on-focus on iOS Safari. On mobile viewports (≤768px), these inputs use a minimum 16px font size, which keeps the viewport stable when users focus the fields.

View File

@@ -9,10 +9,12 @@ import { resolve } from "path";
* header selectors and rules for:
* - Collapsed search trigger (.mobile-search-trigger)
* - Expanded mobile search panel (.mobile-search-expanded)
* - Overflow menu trigger (.mobile-overflow-trigger)
* - Compact overflow trigger (.compact-overflow-trigger)
* - Overflow menu popover (.mobile-overflow-menu, .mobile-overflow-item)
*
* The mobile styles are located within @media (max-width: 768px) blocks.
* The overflow trigger and menu styles are defined at the top level (shared
* by mobile and tablet viewports), while the mobile search styles remain
* inside @media (max-width: 768px) blocks.
*/
describe("mobile-header-controls.css", () => {
@@ -57,17 +59,17 @@ describe("mobile-header-controls.css", () => {
expect(mobileCss).toContain(".mobile-search-expanded");
});
it("has mobile overflow trigger styles", () => {
expect(mobileCss).toContain(".mobile-overflow-trigger");
it("has compact overflow trigger styles at top level (shared mobile/tablet)", () => {
expect(cssContent).toContain(".compact-overflow-trigger");
});
it("has mobile overflow menu styles", () => {
expect(mobileCss).toContain(".mobile-overflow-menu");
expect(mobileCss).toContain(".mobile-overflow-item");
it("has overflow menu styles at top level (shared mobile/tablet)", () => {
expect(cssContent).toContain(".mobile-overflow-menu");
expect(cssContent).toContain(".mobile-overflow-item");
});
it("has mobile overflow menu item hover states", () => {
expect(mobileCss).toMatch(/\.mobile-overflow-item:hover/);
it("has overflow menu item hover states", () => {
expect(cssContent).toMatch(/\.mobile-overflow-item:hover/);
});
it("does not contain obsolete mobile header search wrap rules", () => {

View File

@@ -0,0 +1,373 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Header } from "../components/Header";
/**
* Tablet header controls test suite.
*
* Verifies that the tablet viewport tier (769px1024px) renders the
* header with engine controls inline while moving lower-priority actions
* into the overflow menu.
*/
type ViewportTier = "mobile" | "tablet" | "desktop";
const mockMatchMedia = (tier: ViewportTier) => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => {
let matches = false;
if (tier === "mobile" && query.includes("max-width: 768px")) {
matches = true;
} else if (tier === "tablet" && query.includes("769px") && query.includes("1024px")) {
matches = true;
}
return {
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
};
const noop = () => {};
function renderTabletHeader(props = {}) {
mockMatchMedia("tablet");
return render(
<Header
onOpenSettings={noop}
onOpenGitHubImport={noop}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
{...props}
/>
);
}
function renderDesktopHeader(props = {}) {
mockMatchMedia("desktop");
return render(
<Header
onOpenSettings={noop}
onOpenGitHubImport={noop}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
{...props}
/>
);
}
describe("tablet header controls", () => {
afterEach(() => {
vi.restoreAllMocks();
});
// ── Engine controls stay inline on tablet ──────────────────────
it("renders pause scheduling button inline on tablet", () => {
renderTabletHeader();
expect(screen.getByTitle("Pause scheduling")).toBeDefined();
});
it("renders stop AI engine button inline on tablet", () => {
renderTabletHeader();
expect(screen.getByTitle("Stop AI engine")).toBeDefined();
});
it("renders view toggle inline on tablet", () => {
renderTabletHeader({ onChangeView: noop });
expect(screen.getByTitle("Board view")).toBeDefined();
expect(screen.getByTitle("List view")).toBeDefined();
expect(screen.getByTitle("Agents view")).toBeDefined();
});
// ── Lower-priority actions move to overflow on tablet ──────────
it("does not render settings inline on tablet", () => {
renderTabletHeader();
// Settings should be in overflow, not inline
expect(screen.queryByTitle("Settings")).toBeNull();
});
it("does not render import from GitHub inline on tablet", () => {
renderTabletHeader();
expect(screen.queryByTitle("Import from GitHub")).toBeNull();
});
it("does not render planning button inline on tablet", () => {
renderTabletHeader({ onOpenPlanning: noop });
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
});
it("does not render terminal button inline on tablet", () => {
renderTabletHeader({ onToggleTerminal: noop });
expect(screen.queryByTitle("Open Terminal")).toBeNull();
});
it("does not render schedules button inline on tablet", () => {
renderTabletHeader({ onOpenSchedules: noop });
expect(screen.queryByTitle("Scheduled tasks")).toBeNull();
});
it("does not render usage button inline on tablet", () => {
renderTabletHeader({ onOpenUsage: noop });
expect(screen.queryByTitle("View usage")).toBeNull();
});
it("does not render activity log button inline on tablet", () => {
renderTabletHeader({ onOpenActivityLog: noop });
expect(screen.queryByTitle("View Activity Log")).toBeNull();
});
it("does not render files button inline on tablet", () => {
renderTabletHeader({ onOpenFiles: noop });
expect(screen.queryByTitle("Browse files")).toBeNull();
});
it("does not render git manager button inline on tablet", () => {
renderTabletHeader({ onOpenGitManager: noop });
expect(screen.queryByTitle("Git Manager")).toBeNull();
});
it("does not render workflow steps button inline on tablet", () => {
renderTabletHeader({ onOpenWorkflowSteps: noop });
expect(screen.queryByTitle("Workflow Steps")).toBeNull();
});
it("does not render missions button inline on tablet", () => {
renderTabletHeader({ onOpenMissions: noop });
expect(screen.queryByTitle("Mission Manager")).toBeNull();
});
// ── Overflow menu on tablet ────────────────────────────────────
it("renders overflow menu trigger on tablet", () => {
renderTabletHeader();
expect(screen.getByTitle("More header actions")).toBeDefined();
});
it("overflow menu contains settings on tablet", () => {
renderTabletHeader();
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Settings")).toBeDefined();
});
it("overflow menu contains planning on tablet", () => {
renderTabletHeader({ onOpenPlanning: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
});
it("overflow menu contains GitHub import on tablet", () => {
renderTabletHeader();
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Import from GitHub")).toBeDefined();
});
it("overflow menu contains terminal on tablet", () => {
renderTabletHeader({ onToggleTerminal: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
});
it("overflow menu contains scheduled tasks on tablet", () => {
renderTabletHeader({ onOpenSchedules: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
});
it("overflow menu contains usage on tablet when provided", () => {
renderTabletHeader({ onOpenUsage: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
});
it("overflow menu contains activity log on tablet when provided", () => {
renderTabletHeader({ onOpenActivityLog: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-activity-log-btn")).toBeDefined();
});
it("overflow menu contains files on tablet when provided", () => {
renderTabletHeader({ onOpenFiles: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-files-btn")).toBeDefined();
});
it("overflow menu contains git manager on tablet when provided", () => {
renderTabletHeader({ onOpenGitManager: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-git-btn")).toBeDefined();
});
it("overflow menu contains workflow steps on tablet when provided", () => {
renderTabletHeader({ onOpenWorkflowSteps: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-workflow-steps-btn")).toBeDefined();
});
it("overflow menu contains missions on tablet when provided", () => {
renderTabletHeader({ onOpenMissions: noop });
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-missions-btn")).toBeDefined();
});
// ── Overflow menu callbacks work on tablet ─────────────────────
it("calls onOpenSettings from overflow menu on tablet", () => {
const onOpenSettings = vi.fn();
renderTabletHeader({ onOpenSettings });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByText("Settings"));
expect(onOpenSettings).toHaveBeenCalled();
});
it("calls onToggleTerminal from overflow menu on tablet", () => {
const onToggleTerminal = vi.fn();
renderTabletHeader({ onToggleTerminal });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("calls onOpenPlanning from overflow menu on tablet", () => {
const onOpenPlanning = vi.fn();
renderTabletHeader({ onOpenPlanning });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-planning-btn"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("calls onOpenUsage from overflow menu on tablet", () => {
const onOpenUsage = vi.fn();
renderTabletHeader({ onOpenUsage });
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-usage-btn"));
expect(onOpenUsage).toHaveBeenCalled();
});
it("closes overflow menu after selecting an action on tablet", () => {
renderTabletHeader();
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
fireEvent.click(screen.getByText("Settings"));
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes overflow menu on outside click on tablet", () => {
renderTabletHeader();
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
fireEvent.mouseDown(document.body);
expect(screen.queryByRole("menu")).toBeNull();
});
it("closes overflow menu on Escape key on tablet", () => {
renderTabletHeader();
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
});
// ── Search on tablet ───────────────────────────────────────────
it("renders desktop-style search input on tablet (not mobile search trigger)", () => {
renderTabletHeader({ onSearchChange: noop, view: "board" });
// Tablet uses the desktop-style inline search, not the mobile trigger
expect(screen.getByPlaceholderText("Search tasks...")).toBeDefined();
expect(screen.queryByTitle("Open search")).toBeNull();
});
it("does not render search when view is not board on tablet", () => {
renderTabletHeader({ onSearchChange: noop, view: "list" });
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
});
// ── Project selector hidden on tablet ──────────────────────────
it("does not render project selector on tablet", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderTabletHeader({
projects,
currentProject: projects[0],
onSelectProject: noop,
onViewAllProjects: noop,
});
expect(screen.queryByTestId("project-selector-trigger")).toBeNull();
});
it("does not render back-to-projects button on tablet", () => {
renderTabletHeader({
currentProject: { id: "1", name: "Project One", path: "/path/one", status: "active" as const },
onViewAllProjects: noop,
});
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("shows switch project in overflow menu when multiple projects on tablet", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
const onViewAllProjects = vi.fn();
renderTabletHeader({
projects,
currentProject: projects[0],
onViewAllProjects,
});
fireEvent.click(screen.getByTitle("More header actions"));
const btn = screen.getByTestId("overflow-project-selector-btn");
expect(btn).toBeDefined();
expect(btn.textContent).toContain("Project One");
});
// ── Desktop still shows everything inline ──────────────────────
describe("desktop regression (contrasted with tablet)", () => {
it("renders settings inline on desktop", () => {
renderDesktopHeader();
expect(screen.getByTitle("Settings")).toBeDefined();
});
it("renders import from GitHub inline on desktop", () => {
renderDesktopHeader();
expect(screen.getByTitle("Import from GitHub")).toBeDefined();
});
it("renders terminal inline on desktop", () => {
renderDesktopHeader({ onToggleTerminal: noop });
expect(screen.getByTitle("Open Terminal")).toBeDefined();
});
it("does not render overflow menu trigger on desktop", () => {
renderDesktopHeader();
expect(screen.queryByTitle("More header actions")).toBeNull();
});
it("renders project selector on desktop with multiple projects", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
{ id: "2", name: "Project Two", path: "/path/two", status: "active" as const },
];
renderDesktopHeader({
projects,
currentProject: projects[0],
onSelectProject: noop,
onViewAllProjects: noop,
});
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
});
});
});

View File

@@ -4,23 +4,34 @@ import { Header } from "./Header";
const noop = () => {};
// Helper to mock mobile/desktop viewport
function mockMatchMedia(matches: boolean) {
// Helper to mock mobile/tablet/desktop viewport
type ViewportTier = "mobile" | "tablet" | "desktop";
function mockMatchMedia(tier: ViewportTier) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: matches && query.includes("max-width: 768px"),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
value: vi.fn().mockImplementation((query: string) => {
let matches = false;
if (tier === "mobile" && query.includes("max-width: 768px")) {
matches = true;
} else if (tier === "tablet" && query.includes("769px") && query.includes("1024px")) {
matches = true;
}
// desktop: neither mobile nor tablet query matches
return {
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
function renderHeader(props = {}, isMobile = false) {
mockMatchMedia(isMobile);
function renderHeader(props = {}, tier: ViewportTier = "desktop") {
mockMatchMedia(tier);
return render(
<Header
onOpenSettings={noop}
@@ -114,24 +125,24 @@ describe("Header", () => {
describe("terminal button", () => {
it("renders terminal button with correct title on desktop", () => {
renderHeader({ onToggleTerminal: noop }, false);
renderHeader({ onToggleTerminal: noop }, "desktop");
expect(screen.getByTitle("Open Terminal")).toBeDefined();
});
it("does not render terminal button inline on mobile", () => {
renderHeader({ onToggleTerminal: noop }, true);
renderHeader({ onToggleTerminal: noop }, "mobile");
expect(screen.queryByTitle("Open Terminal")).toBeNull();
});
it("calls onToggleTerminal when terminal button is clicked", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, false);
renderHeader({ onToggleTerminal }, "desktop");
fireEvent.click(screen.getByTitle("Open Terminal"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("is always enabled regardless of task state", () => {
renderHeader({ onToggleTerminal: noop }, false);
renderHeader({ onToggleTerminal: noop }, "desktop");
const btn = screen.getByTitle("Open Terminal");
expect(btn.hasAttribute("disabled")).toBe(false);
});
@@ -139,36 +150,36 @@ describe("Header", () => {
describe("files button", () => {
it("renders files button on desktop when handler is provided", () => {
renderHeader({ onOpenFiles: vi.fn() }, false);
renderHeader({ onOpenFiles: vi.fn() }, "desktop");
expect(screen.getByTitle("Browse files")).toBeDefined();
});
it("does not render files button on desktop when handler is omitted", () => {
renderHeader({}, false);
renderHeader({}, "desktop");
expect(screen.queryByTitle("Browse files")).toBeNull();
});
it("calls onOpenFiles when desktop files button is clicked", () => {
const onOpenFiles = vi.fn();
renderHeader({ onOpenFiles }, false);
renderHeader({ onOpenFiles }, "desktop");
fireEvent.click(screen.getByTitle("Browse files"));
expect(onOpenFiles).toHaveBeenCalled();
});
it("applies active class when files modal is open", () => {
renderHeader({ onOpenFiles: vi.fn(), filesOpen: true }, false);
renderHeader({ onOpenFiles: vi.fn(), filesOpen: true }, "desktop");
expect(screen.getByTitle("Browse files").className).toContain("btn-icon--active");
});
it("shows files action in mobile overflow menu", () => {
renderHeader({ onOpenFiles: vi.fn() }, true);
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-files-btn")).toBeDefined();
});
it("calls onOpenFiles from mobile overflow menu", () => {
const onOpenFiles = vi.fn();
renderHeader({ onOpenFiles }, true);
renderHeader({ onOpenFiles }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-files-btn"));
expect(onOpenFiles).toHaveBeenCalled();
@@ -213,42 +224,42 @@ describe("Header", () => {
describe("usage button", () => {
it("does not render usage button when onOpenUsage is not provided", () => {
renderHeader({}, false);
renderHeader({}, "desktop");
expect(screen.queryByTitle("View usage")).toBeNull();
});
it("does not render usage button when onOpenUsage is not provided on mobile", () => {
renderHeader({}, true);
renderHeader({}, "mobile");
expect(screen.queryByTitle("View usage")).toBeNull();
});
it("renders usage button with correct title when onOpenUsage is provided on desktop", () => {
renderHeader({ onOpenUsage: vi.fn() }, false);
renderHeader({ onOpenUsage: vi.fn() }, "desktop");
expect(screen.getByTitle("View usage")).toBeDefined();
});
it("does not render usage button inline on mobile when onOpenUsage is provided", () => {
renderHeader({ onOpenUsage: vi.fn() }, true);
renderHeader({ onOpenUsage: vi.fn() }, "mobile");
// Button should NOT be inline on mobile (it's in overflow menu)
expect(screen.queryByTitle("View usage")).toBeNull();
});
it("shows usage in overflow menu on mobile", () => {
renderHeader({ onOpenUsage: vi.fn() }, true);
renderHeader({ onOpenUsage: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-usage-btn")).toBeDefined();
});
it("calls onOpenUsage when usage button is clicked on desktop", () => {
const onOpenUsage = vi.fn();
renderHeader({ onOpenUsage }, false);
renderHeader({ onOpenUsage }, "desktop");
fireEvent.click(screen.getByTitle("View usage"));
expect(onOpenUsage).toHaveBeenCalled();
});
it("calls onOpenUsage when usage button in overflow menu is clicked", () => {
const onOpenUsage = vi.fn();
renderHeader({ onOpenUsage }, true);
renderHeader({ onOpenUsage }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-usage-btn"));
expect(onOpenUsage).toHaveBeenCalled();
@@ -257,42 +268,42 @@ describe("Header", () => {
describe("activity log button", () => {
it("does not render activity log button when onOpenActivityLog is not provided", () => {
renderHeader({}, false);
renderHeader({}, "desktop");
expect(screen.queryByTitle("View Activity Log")).toBeNull();
});
it("does not render activity log button when onOpenActivityLog is not provided on mobile", () => {
renderHeader({}, true);
renderHeader({}, "mobile");
expect(screen.queryByTitle("View Activity Log")).toBeNull();
});
it("renders activity log button with correct title when onOpenActivityLog is provided on desktop", () => {
renderHeader({ onOpenActivityLog: vi.fn() }, false);
renderHeader({ onOpenActivityLog: vi.fn() }, "desktop");
expect(screen.getByTitle("View Activity Log")).toBeDefined();
});
it("does not render activity log button inline on mobile when onOpenActivityLog is provided", () => {
renderHeader({ onOpenActivityLog: vi.fn() }, true);
renderHeader({ onOpenActivityLog: vi.fn() }, "mobile");
// Button should NOT be inline on mobile (it's in overflow menu)
expect(screen.queryByTitle("View Activity Log")).toBeNull();
});
it("shows activity log in overflow menu on mobile", () => {
renderHeader({ onOpenActivityLog: vi.fn() }, true);
renderHeader({ onOpenActivityLog: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-activity-log-btn")).toBeDefined();
});
it("calls onOpenActivityLog when activity log button is clicked on desktop", () => {
const onOpenActivityLog = vi.fn();
renderHeader({ onOpenActivityLog }, false);
renderHeader({ onOpenActivityLog }, "desktop");
fireEvent.click(screen.getByTitle("View Activity Log"));
expect(onOpenActivityLog).toHaveBeenCalled();
});
it("calls onOpenActivityLog when activity log button in overflow menu is clicked", () => {
const onOpenActivityLog = vi.fn();
renderHeader({ onOpenActivityLog }, true);
renderHeader({ onOpenActivityLog }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-activity-log-btn"));
expect(onOpenActivityLog).toHaveBeenCalled();
@@ -301,80 +312,80 @@ describe("Header", () => {
describe("planning button", () => {
it("renders planning button with correct title on desktop", () => {
renderHeader({ onOpenPlanning: vi.fn() }, false);
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.getByTitle("Create a task with AI planning")).toBeDefined();
});
it("does not render planning button inline on mobile", () => {
renderHeader({ onOpenPlanning: vi.fn() }, true);
renderHeader({ onOpenPlanning: vi.fn() }, "mobile");
expect(screen.queryByTitle("Create a task with AI planning")).toBeNull();
});
it("calls onOpenPlanning when planning button is clicked", () => {
const onOpenPlanning = vi.fn();
renderHeader({ onOpenPlanning }, false);
renderHeader({ onOpenPlanning }, "desktop");
fireEvent.click(screen.getByTitle("Create a task with AI planning"));
expect(onOpenPlanning).toHaveBeenCalled();
});
it("has correct data-testid for testing on desktop", () => {
renderHeader({ onOpenPlanning: vi.fn() }, false);
renderHeader({ onOpenPlanning: vi.fn() }, "desktop");
expect(screen.getByTestId("planning-btn")).toBeDefined();
});
});
describe("mobile overflow menu", () => {
it("renders overflow trigger on mobile", () => {
renderHeader({}, true);
renderHeader({}, "mobile");
expect(screen.getByTitle("More header actions")).toBeDefined();
});
it("does not render overflow trigger on desktop", () => {
renderHeader({}, false);
renderHeader({}, "desktop");
expect(screen.queryByTitle("More header actions")).toBeNull();
});
it("shows terminal in overflow menu on mobile", () => {
renderHeader({ onToggleTerminal: noop }, true);
renderHeader({ onToggleTerminal: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-terminal-btn")).toBeDefined();
});
it("shows GitHub import in overflow menu on mobile", () => {
renderHeader({}, true);
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Import from GitHub")).toBeDefined();
});
it("shows planning in overflow menu on mobile", () => {
renderHeader({ onOpenPlanning: noop }, true);
renderHeader({ onOpenPlanning: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-planning-btn")).toBeDefined();
});
it("shows settings in overflow menu on mobile", () => {
renderHeader({}, true);
renderHeader({}, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Settings")).toBeDefined();
});
it("calls onToggleTerminal when overflow terminal button is clicked", () => {
const onToggleTerminal = vi.fn();
renderHeader({ onToggleTerminal }, true);
renderHeader({ onToggleTerminal }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-terminal-btn"));
expect(onToggleTerminal).toHaveBeenCalled();
});
it("shows scripts in overflow menu on mobile", () => {
renderHeader({ onOpenScripts: noop }, true);
renderHeader({ onOpenScripts: noop }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByTestId("overflow-scripts-btn")).toBeDefined();
});
it("calls onOpenScripts from mobile overflow menu", () => {
const onOpenScripts = vi.fn();
renderHeader({ onOpenScripts }, true);
renderHeader({ onOpenScripts }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-scripts-btn"));
expect(onOpenScripts).toHaveBeenCalled();
@@ -431,36 +442,36 @@ describe("Header", () => {
describe("schedules button", () => {
it("renders schedules button on desktop", () => {
renderHeader({ onOpenSchedules: vi.fn() }, false);
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
expect(screen.getByTitle("Scheduled tasks")).toBeDefined();
});
it("does not render schedules button inline on mobile", () => {
renderHeader({ onOpenSchedules: vi.fn() }, true);
renderHeader({ onOpenSchedules: vi.fn() }, "mobile");
expect(screen.queryByTitle("Scheduled tasks")).toBeNull();
});
it("calls onOpenSchedules when schedules button is clicked", () => {
const onOpenSchedules = vi.fn();
renderHeader({ onOpenSchedules }, false);
renderHeader({ onOpenSchedules }, "desktop");
fireEvent.click(screen.getByTitle("Scheduled tasks"));
expect(onOpenSchedules).toHaveBeenCalled();
});
it("has correct data-testid for testing on desktop", () => {
renderHeader({ onOpenSchedules: vi.fn() }, false);
renderHeader({ onOpenSchedules: vi.fn() }, "desktop");
expect(screen.getByTestId("schedules-btn")).toBeDefined();
});
it("includes scheduled tasks in overflow menu on mobile", () => {
renderHeader({ onOpenSchedules: vi.fn() }, true);
renderHeader({ onOpenSchedules: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
});
it("calls onOpenSchedules from mobile overflow menu", () => {
const onOpenSchedules = vi.fn();
renderHeader({ onOpenSchedules }, true);
renderHeader({ onOpenSchedules }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByTestId("overflow-schedules-btn"));
expect(onOpenSchedules).toHaveBeenCalled();
@@ -495,12 +506,12 @@ describe("Header", () => {
});
it("does not render header-back-button on mobile when no currentProject", () => {
const { container } = renderHeader({}, true);
const { container } = renderHeader({}, "mobile");
expect(container.querySelector(".header-back-button")).toBeNull();
});
it("mobile overflow menu closes when clicking outside", () => {
renderHeader({ onOpenFiles: vi.fn() }, true);
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
@@ -510,7 +521,7 @@ describe("Header", () => {
});
it("mobile overflow menu closes on Escape key", () => {
renderHeader({ onOpenFiles: vi.fn() }, true);
renderHeader({ onOpenFiles: vi.fn() }, "mobile");
fireEvent.click(screen.getByTitle("More header actions"));
expect(screen.getByRole("menu")).toBeDefined();
@@ -519,7 +530,7 @@ describe("Header", () => {
});
it("mobile overflow trigger has correct accessibility attributes", () => {
renderHeader({}, true);
renderHeader({}, "mobile");
const trigger = screen.getByTitle("More header actions");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
@@ -529,7 +540,7 @@ describe("Header", () => {
});
it("hides logo-sub on mobile via CSS", () => {
renderHeader({}, true);
renderHeader({}, "mobile");
// The "tasks" element no longer exists - it was removed
});
});

View File

@@ -51,21 +51,43 @@ export interface HeaderProps {
projectId?: string;
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(max-width: 768px)").matches;
});
type ViewportMode = "mobile" | "tablet" | "desktop";
function getViewportMode(): ViewportMode {
if (typeof window === "undefined") return "desktop";
if (window.matchMedia("(max-width: 768px)").matches) return "mobile";
if (window.matchMedia("(min-width: 769px) and (max-width: 1024px)").matches) return "tablet";
return "desktop";
}
function useViewportMode(): ViewportMode {
const [mode, setMode] = useState<ViewportMode>(getViewportMode);
useEffect(() => {
if (typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(max-width: 768px)");
const handleChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
const mobileQuery = window.matchMedia("(max-width: 768px)");
const tabletQuery = window.matchMedia("(min-width: 769px) and (max-width: 1024px)");
const updateMode = () => {
if (mobileQuery.matches) {
setMode("mobile");
} else if (tabletQuery.matches) {
setMode("tablet");
} else {
setMode("desktop");
}
};
mobileQuery.addEventListener("change", updateMode);
tabletQuery.addEventListener("change", updateMode);
return () => {
mobileQuery.removeEventListener("change", updateMode);
tabletQuery.removeEventListener("change", updateMode);
};
}, []);
return isMobile;
return mode;
}
export function Header({
@@ -97,7 +119,10 @@ export function Header({
onViewAllProjects,
projectId,
}: HeaderProps) {
const isMobile = useIsMobile();
const mode = useViewportMode();
const isMobile = mode === "mobile";
const isTablet = mode === "tablet";
const isCompact = isMobile || isTablet;
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
@@ -172,7 +197,7 @@ export function Header({
<h1 className="logo">Fusion</h1>
{/* Project Selector - shown when 2+ projects on desktop only */}
{!isMobile && projects.length > 1 && (
{!isCompact && projects.length > 1 && (
<div className="header-project-selector">
<ProjectSelector
projects={projects}
@@ -186,7 +211,7 @@ export function Header({
)}
{/* Back to All Projects button when viewing a specific project (desktop only) */}
{!isMobile && currentProject && onViewAllProjects && (
{!isCompact && currentProject && onViewAllProjects && (
<button
className="header-back-button"
onClick={onViewAllProjects}
@@ -295,28 +320,28 @@ export function Header({
</div>
)}
{/* Usage button - desktop only (moved to overflow on mobile) */}
{!isMobile && onOpenUsage && (
{/* Usage button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenUsage && (
<button className="btn-icon" onClick={onOpenUsage} title="View usage">
<Activity size={16} />
</button>
)}
{/* Activity Log button - desktop only (moved to overflow on mobile) */}
{!isMobile && onOpenActivityLog && (
{/* Activity Log button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenActivityLog && (
<button className="btn-icon" onClick={onOpenActivityLog} title="View Activity Log">
<History size={16} />
</button>
)}
{/* Desktop actions */}
{!isMobile && (
{!isCompact && (
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<GitHubLogo size={16} />
</button>
)}
{!isMobile && (
{!isCompact && (
<button
className="btn-icon"
onClick={onOpenPlanning}
@@ -327,8 +352,8 @@ export function Header({
</button>
)}
{/* Schedules button - desktop only (moved to overflow on mobile) */}
{!isMobile && (
{/* Schedules button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && (
<button
className="btn-icon"
onClick={onOpenSchedules}
@@ -339,8 +364,8 @@ export function Header({
</button>
)}
{/* Terminal button - desktop only (moved to overflow on mobile) */}
{!isMobile && (
{/* Terminal button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && (
<button
className="btn-icon btn-icon--terminal"
onClick={onToggleTerminal}
@@ -351,8 +376,8 @@ export function Header({
</button>
)}
{/* Files button - desktop only */}
{!isMobile && onOpenFiles && (
{/* Files button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenFiles && (
<button
className={`btn-icon${filesOpen ? " btn-icon--active" : ""}`}
onClick={onOpenFiles}
@@ -363,8 +388,8 @@ export function Header({
</button>
)}
{/* Git Manager button - desktop only */}
{!isMobile && onOpenGitManager && (
{/* Git Manager button - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenGitManager && (
<button
className="btn-icon"
onClick={onOpenGitManager}
@@ -375,8 +400,8 @@ export function Header({
</button>
)}
{/* Workflow Steps - desktop only */}
{!isMobile && onOpenWorkflowSteps && (
{/* Workflow Steps - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenWorkflowSteps && (
<button
className="btn-icon"
onClick={onOpenWorkflowSteps}
@@ -387,8 +412,8 @@ export function Header({
</button>
)}
{/* Missions - desktop only */}
{!isMobile && onOpenMissions && (
{/* Missions - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenMissions && (
<button
className="btn-icon"
onClick={onOpenMissions}
@@ -399,8 +424,8 @@ export function Header({
</button>
)}
{/* Scripts - desktop only */}
{!isMobile && onOpenScripts && onRunScript && (
{/* Scripts - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenScripts && onRunScript && (
<QuickScriptsDropdown
onOpenScripts={onOpenScripts}
onRunScript={onRunScript}
@@ -408,8 +433,8 @@ export function Header({
/>
)}
{/* Settings - always inline on desktop */}
{!isMobile && (
{/* Settings - always inline on desktop only (overflow on mobile/tablet) */}
{!isCompact && (
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
<Settings size={16} />
</button>
@@ -434,11 +459,11 @@ export function Header({
{globalPaused ? <Play size={16} /> : <Square size={16} />}
</button>
{/* Mobile overflow menu trigger */}
{isMobile && (
{/* Compact overflow menu trigger (mobile + tablet) */}
{isCompact && (
<button
ref={overflowButtonRef}
className="btn-icon mobile-overflow-trigger"
className="btn-icon compact-overflow-trigger"
onClick={handleOverflowToggle}
title="More header actions"
aria-label="More header actions"
@@ -449,8 +474,8 @@ export function Header({
</button>
)}
{/* Mobile overflow menu */}
{isMobile && isOverflowMenuOpen && (
{/* Compact overflow menu (mobile + tablet) */}
{isCompact && isOverflowMenuOpen && (
<div
ref={overflowMenuRef}
className="mobile-overflow-menu"

View File

@@ -2,24 +2,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Header } from "../Header";
// Mock matchMedia for mobile/desktop viewport tests
const mockMatchMedia = (matches: boolean) => {
// Mock matchMedia for mobile/tablet/desktop viewport tests
type ViewportTier = "mobile" | "tablet" | "desktop";
const mockMatchMedia = (tier: ViewportTier) => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
value: vi.fn().mockImplementation((query: string) => {
let matches = false;
if (tier === "mobile" && query.includes("max-width: 768px")) {
matches = true;
} else if (tier === "tablet" && query.includes("769px") && query.includes("1024px")) {
matches = true;
}
return {
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
};
describe("Header", () => {
beforeEach(() => {
// Default to desktop viewport
mockMatchMedia(false);
mockMatchMedia("desktop");
});
afterEach(() => {
@@ -330,7 +340,7 @@ describe("Header", () => {
describe("mobile viewport", () => {
beforeEach(() => {
mockMatchMedia(true); // Mobile viewport
mockMatchMedia("mobile"); // Mobile viewport
});
it("renders mobile search trigger instead of inline search on mobile", () => {

View File

@@ -470,6 +470,55 @@ body {
background: var(--border);
}
/* === Compact Overflow Menu (mobile + tablet) === */
/* Styles for the overflow menu used on mobile and tablet viewports.
Visibility is controlled by React rendering, not CSS display. */
.compact-overflow-trigger {
display: flex;
align-items: center;
justify-content: center;
}
.mobile-overflow-menu {
position: absolute;
top: calc(100% + 4px);
right: 12px;
min-width: 220px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
padding: var(--space-xs);
display: flex;
flex-direction: column;
gap: 2px;
}
.mobile-overflow-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 10px 12px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
.mobile-overflow-item:hover {
background: var(--card);
}
.mobile-overflow-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
.logo {
font-size: 20px;
font-weight: 700;
@@ -4634,52 +4683,6 @@ body {
box-shadow: var(--shadow-md);
}
.mobile-overflow-trigger {
display: flex;
align-items: center;
justify-content: center;
}
.mobile-overflow-menu {
position: absolute;
top: calc(100% + 4px);
right: 12px;
min-width: 220px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
padding: var(--space-xs);
display: flex;
flex-direction: column;
gap: 2px;
}
.mobile-overflow-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: 10px 12px;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
.mobile-overflow-item:hover {
background: var(--card);
}
.mobile-overflow-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
/* Modal: full-screen on mobile */
.modal-overlay {
padding-top: 0;
@@ -4808,6 +4811,29 @@ body {
}
}
/* === Tablet Responsive Tier (769px1024px) === */
/* On tablet-width screens the header uses a compact layout:
view toggle and engine controls stay inline while lower-priority
actions move into the overflow menu. */
@media (min-width: 769px) and (max-width: 1024px) {
/* Reduce header padding slightly to reclaim horizontal space */
.header {
padding: var(--space-md) var(--space-lg);
}
/* Hide project selector and back button on tablet (controlled by React rendering too) */
.header-project-selector,
.header-back-button {
display: none;
}
/* Compact search: narrower than desktop */
.header-search {
max-width: 200px;
min-width: 120px;
}
}
/* === GitHub Import Modal ===
Note: Some hardcoded colors below (e.g. #238636, #2ea043, #f0883e, #1f6feb)
are intentional GitHub brand colors and should NOT be replaced with design tokens. */