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