diff --git a/.claude/metrics/review-metrics.jsonl b/.claude/metrics/review-metrics.jsonl
index 43830045b..53b71531b 100644
--- a/.claude/metrics/review-metrics.jsonl
+++ b/.claude/metrics/review-metrics.jsonl
@@ -188,3 +188,4 @@
{"pr":1141,"issues":[1135,1136,1137,1138,1139,1140],"epic":null,"type":"fix","createdAt":"2026-03-22T10:59:20Z","mergedAt":"2026-03-22T12:30:00Z","filesChanged":17,"linesChanged":865,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["frontend-developer"]}],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":1},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":2,"informational":3}}
{"pr":1144,"issues":[1142],"epic":null,"type":"feat","createdAt":"2026-03-22T11:13:49Z","mergedAt":"2026-03-22T13:00:00Z","filesChanged":30,"linesChanged":1788,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["frontend-developer","translator","e2e-test-engineer"]}],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2}}
{"pr":1163,"issues":[1162],"epic":null,"type":"fix","createdAt":"2026-03-22T13:23:33Z","mergedAt":"2026-03-22T14:00:00Z","filesChanged":6,"linesChanged":44,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1165,"issues":[1161],"epic":null,"type":"fix","createdAt":"2026-03-22T13:39:25Z","mergedAt":"2026-03-22T14:30:00Z","filesChanged":7,"linesChanged":292,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":1,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":1,"low":1,"informational":1}}
diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx
index faf01461c..691dc1f60 100644
--- a/client/src/App.test.tsx
+++ b/client/src/App.test.tsx
@@ -353,7 +353,7 @@ describe('App', () => {
});
});
- it('renders the AppShell layout with sidebar and header', async () => {
+ it('renders the AppShell layout with sidebar and floating menu button', async () => {
render();
// Wait for auth loading to complete
@@ -365,9 +365,9 @@ describe('App', () => {
const sidebar = screen.getByRole('complementary');
expect(sidebar).toBeInTheDocument();
- // Header should be present
- const header = screen.getByRole('banner');
- expect(header).toBeInTheDocument();
+ // Floating menu button (FAB) should be present
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
// Main content area should be present
const main = screen.getByRole('main');
diff --git a/client/src/components/AppShell/AppShell.module.css b/client/src/components/AppShell/AppShell.module.css
index ca077dd1c..f298a9ad5 100644
--- a/client/src/components/AppShell/AppShell.module.css
+++ b/client/src/components/AppShell/AppShell.module.css
@@ -38,3 +38,48 @@
display: block;
}
}
+
+.menuFab {
+ display: none;
+ position: fixed;
+ bottom: var(--spacing-6);
+ right: var(--spacing-6);
+ min-width: 44px;
+ min-height: 44px;
+ width: var(--spacing-12);
+ height: var(--spacing-12);
+ border-radius: var(--radius-full);
+ background-color: var(--color-primary);
+ color: var(--color-primary-text);
+ font-size: var(--font-size-2xl);
+ border: none;
+ cursor: pointer;
+ z-index: calc(var(--z-overlay) + 10);
+ box-shadow: var(--shadow-md);
+ transition: var(--transition-button);
+}
+
+.menuFab:hover {
+ background-color: var(--color-primary-hover);
+ box-shadow: var(--shadow-lg);
+}
+
+.menuFab:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+/* Mobile and tablet: show floating menu button */
+@media (max-width: 1024px) {
+ .menuFab {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .menuFab {
+ transition: none;
+ }
+}
diff --git a/client/src/components/AppShell/AppShell.test.tsx b/client/src/components/AppShell/AppShell.test.tsx
index b3e21031d..a0302a58f 100644
--- a/client/src/components/AppShell/AppShell.test.tsx
+++ b/client/src/components/AppShell/AppShell.test.tsx
@@ -50,7 +50,7 @@ describe('AppShell', () => {
}
});
- it('renders sidebar, header, and outlet area', () => {
+ it('renders sidebar, floating menu button, and outlet area', () => {
renderWithRouter(
} path="*">
@@ -63,9 +63,9 @@ describe('AppShell', () => {
const sidebar = screen.getByRole('complementary');
expect(sidebar).toBeInTheDocument();
- // Header should be present
- const header = screen.getByRole('banner');
- expect(header).toBeInTheDocument();
+ // Floating menu button (FAB) should be present
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
// Main content area should be present
const main = screen.getByRole('main');
@@ -133,7 +133,7 @@ describe('AppShell', () => {
expect(screen.getByRole('button', { name: /^settings$/i })).toBeInTheDocument();
});
- it('renders header with menu toggle button', () => {
+ it('renders floating menu button for mobile sidebar toggle', () => {
renderWithRouter(
} path="*">
@@ -142,8 +142,24 @@ describe('AppShell', () => {
,
);
- const menuButton = screen.getByRole('button', { name: /open menu/i });
- expect(menuButton).toBeInTheDocument();
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
+ expect(fab).toHaveAttribute('type', 'button');
+ });
+
+ it('floating menu button has data-testid and type="button"', () => {
+ renderWithRouter(
+
+ } path="*">
+ Test Content} />
+
+ ,
+ );
+
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
+ expect(fab).toHaveAttribute('type', 'button');
+ expect(fab).toHaveAttribute('aria-label', 'Open menu');
});
it('overlay is not visible initially (sidebar starts closed)', () => {
@@ -270,9 +286,9 @@ describe('AppShell', () => {
let overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).toBeInTheDocument();
- // Header button should now say "Close menu"
- const header = screen.getByRole('banner');
- menuButton = within(header).getByRole('button', { name: /close menu/i });
+ // FAB button should now say "Close menu"
+ menuButton = screen.getByTestId('menu-fab');
+ expect(menuButton).toHaveAttribute('aria-label', 'Close menu');
// Close
await user.click(menuButton);
@@ -298,17 +314,16 @@ describe('AppShell', () => {
,
);
- // Initially button shows hamburger icon
- let menuButton = screen.getByRole('button', { name: /open menu/i });
- expect(menuButton).toHaveTextContent('☰');
+ // Initially FAB shows hamburger icon
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toHaveTextContent('☰');
// Click to open
- await user.click(menuButton);
+ await user.click(fab);
- // Header button should now show close icon
- const header = screen.getByRole('banner');
- menuButton = within(header).getByRole('button', { name: /close menu/i });
- expect(menuButton).toHaveTextContent('✕');
+ // FAB button should now show close icon
+ const fabAfterOpen = screen.getByTestId('menu-fab');
+ expect(fabAfterOpen).toHaveTextContent('✕');
});
it('clicking close button inside sidebar closes the sidebar', async () => {
@@ -351,16 +366,15 @@ describe('AppShell', () => {
,
);
- // Initially button has "Open menu" label
- let menuButton = screen.getByRole('button', { name: /open menu/i });
- expect(menuButton).toHaveAttribute('aria-label', 'Open menu');
+ // Initially FAB has "Open menu" label
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toHaveAttribute('aria-label', 'Open menu');
// Click to open
- await user.click(menuButton);
+ await user.click(fab);
- // Header button should now have "Close menu" label
- const header = screen.getByRole('banner');
- menuButton = within(header).getByRole('button', { name: /close menu/i });
- expect(menuButton).toHaveAttribute('aria-label', 'Close menu');
+ // FAB button should now have "Close menu" label
+ const fabAfterOpen = screen.getByTestId('menu-fab');
+ expect(fabAfterOpen).toHaveAttribute('aria-label', 'Close menu');
});
});
diff --git a/client/src/components/AppShell/AppShell.tsx b/client/src/components/AppShell/AppShell.tsx
index c2c681d54..79875c1f0 100644
--- a/client/src/components/AppShell/AppShell.tsx
+++ b/client/src/components/AppShell/AppShell.tsx
@@ -1,10 +1,11 @@
import { Outlet } from 'react-router-dom';
import { Suspense, useState, useCallback, useEffect } from 'react';
-import { Header } from '../Header/Header';
+import { useTranslation } from 'react-i18next';
import { Sidebar } from '../Sidebar/Sidebar';
import styles from './AppShell.module.css';
export function AppShell() {
+ const { t } = useTranslation('common');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const handleToggleSidebar = useCallback(() => {
@@ -39,8 +40,16 @@ export function AppShell() {
data-testid="sidebar-overlay"
/>
)}
+
-
Loading...
}>
diff --git a/client/src/components/Header/Header.module.css b/client/src/components/Header/Header.module.css
deleted file mode 100644
index abf7110c3..000000000
--- a/client/src/components/Header/Header.module.css
+++ /dev/null
@@ -1,47 +0,0 @@
-.header {
- height: 60px;
- border-bottom: 1px solid var(--color-border);
- display: flex;
- align-items: center;
- padding: 0 1rem;
- background-color: var(--color-bg-primary);
-}
-
-.menuButton {
- min-width: 44px;
- min-height: 44px;
- border: none;
- background: transparent;
- font-size: 1.5rem;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 0.25rem;
- transition: background-color 0.2s ease;
-}
-
-.menuButton:hover {
- background-color: var(--color-bg-tertiary);
-}
-
-.menuButton:focus-visible {
- outline: 2px solid var(--color-primary);
- outline-offset: 2px;
-}
-
-.titleArea {
- margin-left: 1rem;
- flex: 1;
-}
-
-/* Desktop: hide menu button */
-@media (min-width: 1025px) {
- .menuButton {
- display: none;
- }
-
- .titleArea {
- margin-left: 0;
- }
-}
diff --git a/client/src/components/Header/Header.test.tsx b/client/src/components/Header/Header.test.tsx
deleted file mode 100644
index 42dd42e0c..000000000
--- a/client/src/components/Header/Header.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { jest } from '@jest/globals';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { Header } from './Header';
-
-describe('Header', () => {
- it('renders menu toggle button with "Open menu" aria-label when sidebar is closed', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toBeInTheDocument();
- });
-
- it('calls onToggleSidebar when menu button is clicked', async () => {
- const mockToggle = jest.fn();
- const user = userEvent.setup();
-
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- await user.click(button);
-
- expect(mockToggle).toHaveBeenCalledTimes(1);
- });
-
- it('calls onToggleSidebar multiple times on repeated clicks', async () => {
- const mockToggle = jest.fn();
- const user = userEvent.setup();
-
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- await user.click(button);
- await user.click(button);
- await user.click(button);
-
- expect(mockToggle).toHaveBeenCalledTimes(3);
- });
-
- it('renders title area with correct data-testid', () => {
- const mockToggle = jest.fn();
- render();
-
- const titleArea = screen.getByTestId('page-title');
- expect(titleArea).toBeInTheDocument();
- });
-
- it('menu button has type="button" to prevent form submission', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toHaveAttribute('type', 'button');
- });
-
- it('shows ☰ icon when sidebar is closed', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toHaveTextContent('☰');
- });
-
- it('shows ✕ icon when sidebar is open', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /close menu/i });
- expect(button).toHaveTextContent('✕');
- });
-
- it('has "Open menu" aria-label when sidebar is closed', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toHaveAttribute('aria-label', 'Open menu');
- });
-
- it('has "Close menu" aria-label when sidebar is open', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /close menu/i });
- expect(button).toHaveAttribute('aria-label', 'Close menu');
- });
-});
diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx
deleted file mode 100644
index c437210f0..000000000
--- a/client/src/components/Header/Header.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import styles from './Header.module.css';
-
-interface HeaderProps {
- onToggleSidebar: () => void;
- isSidebarOpen: boolean;
-}
-
-export function Header({ onToggleSidebar, isSidebarOpen }: HeaderProps) {
- const { t } = useTranslation('common');
-
- return (
-
-
-
-
- );
-}