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 651ec03698
commit ee8f19fc93
7 changed files with 618 additions and 171 deletions

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();
});
});
});