diff --git a/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md b/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md
index c029706..dc96e99 100644
--- a/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md
+++ b/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md
@@ -1,6 +1,6 @@
# Code Generation Plan — Unit 3: Layout & Navigation
-**Status**: 📋 Awaiting answers
+**Status**: 🚧 In Progress
## Unit Context
@@ -23,6 +23,103 @@
---
+## Answers
+
+| Q | Answer | Decision |
+|---|---|---|
+| Q1 Mobile animation | A | Slide in from left with CSS transition |
+| Q2 Sidebar footer | A | UserMenu dropdown + LanguageSwitcher + ThemeToggle as icon buttons |
+| Q3 Tests scope | A | All new and modified components |
+
+---
+
+## Code Generation Steps
+
+### Step 1: Add no-flash theme script to `index.html`
+- [ ] Add inline `
diff --git a/frontend/src/components/layout/AppLayout.test.tsx b/frontend/src/components/layout/AppLayout.test.tsx
new file mode 100644
index 0000000..f8ce53d
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.test.tsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils';
+import { server } from '@/mocks/server';
+import { setupHandlers } from '@/mocks/index';
+import { _resetSetupStatusCache } from '@/router';
+
+beforeEach(() => {
+ _resetSetupStatusCache();
+ server.use(...setupHandlers);
+});
+
+describe('AppLayout desktop', () => {
+ it('renders sidebar and main content area when authenticated', async () => {
+ mockAuthenticated();
+ renderApp('/dashboard');
+ expect(await screen.findByTestId('app-sidebar')).toBeInTheDocument();
+ expect(screen.getByTestId('app-main')).toBeInTheDocument();
+ });
+
+ it('renders mobile bar', async () => {
+ mockAuthenticated();
+ renderApp('/dashboard');
+ await screen.findByTestId('app-sidebar');
+ expect(screen.getByTestId('app-mobile-bar')).toBeInTheDocument();
+ });
+});
+
+describe('AppLayout mobile menu', () => {
+ it('opens sidebar overlay when hamburger is clicked', async () => {
+ mockAuthenticated();
+ const user = userEvent.setup();
+ renderApp('/dashboard');
+ await screen.findByTestId('app-mobile-bar');
+ await user.click(screen.getByTestId('mobile-menu-button'));
+ expect(screen.getByTestId('sidebar-overlay')).toBeInTheDocument();
+ });
+
+ it('closes sidebar overlay when backdrop is clicked', async () => {
+ mockAuthenticated();
+ const user = userEvent.setup();
+ renderApp('/dashboard');
+ await screen.findByTestId('app-mobile-bar');
+ await user.click(screen.getByTestId('mobile-menu-button'));
+ expect(screen.getByTestId('sidebar-overlay')).toBeInTheDocument();
+ await user.click(screen.getByTestId('sidebar-backdrop'));
+ expect(screen.queryByTestId('sidebar-overlay')).not.toBeInTheDocument();
+ });
+});
+
+describe('AppLayout auth guard', () => {
+ it('redirects to login when not authenticated', async () => {
+ mockGuest();
+ renderApp('/dashboard');
+ expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
index 5299ac8..d993c1f 100644
--- a/frontend/src/components/layout/AppLayout.tsx
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -1,17 +1,14 @@
-import { useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { Outlet, useNavigate } from '@tanstack/react-router';
import { useAuth } from '@/contexts/auth-context';
import { Sidebar } from '@/components/layout/Sidebar';
-import { Topbar } from '@/components/layout/Topbar';
+import { MobileBar } from '@/components/layout/MobileBar';
+import { SidebarOverlay } from '@/components/layout/SidebarOverlay';
-/**
- * Shell for authenticated routes. Renders the persistent chrome and reacts to
- * runtime auth loss (e.g. a failed refresh during an API call) by redirecting
- * to /login (BR-U1-14).
- */
export function AppLayout() {
const { isAuthenticated, status } = useAuth();
const navigate = useNavigate();
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
if (status === 'guest') {
@@ -25,9 +22,12 @@ export function AppLayout() {
return (
-
+
+
+
+ {isMenuOpen &&
setIsMenuOpen(false)} />}
-
+
setIsMenuOpen(true)} />
diff --git a/frontend/src/components/layout/MobileBar.tsx b/frontend/src/components/layout/MobileBar.tsx
new file mode 100644
index 0000000..cfdc128
--- /dev/null
+++ b/frontend/src/components/layout/MobileBar.tsx
@@ -0,0 +1,28 @@
+import { Menu } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+interface MobileBarProps {
+ onMenuOpen: () => void;
+}
+
+export function MobileBar({ onMenuOpen }: MobileBarProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('common.appName')}
+
+ );
+}
diff --git a/frontend/src/components/layout/Sidebar.test.tsx b/frontend/src/components/layout/Sidebar.test.tsx
new file mode 100644
index 0000000..ee1bbbf
--- /dev/null
+++ b/frontend/src/components/layout/Sidebar.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import { renderApp, mockAuthenticated } from '@/test/utils';
+import { server } from '@/mocks/server';
+import { setupHandlers } from '@/mocks/index';
+import { _resetSetupStatusCache } from '@/router';
+import { http, HttpResponse } from 'msw';
+import { API_BASE, makeAuthResponse, mockUser } from '@/mocks/auth/fixtures';
+
+beforeEach(() => {
+ _resetSetupStatusCache();
+ server.use(...setupHandlers);
+});
+
+function mockAuthenticatedAs(role: 'Owner' | 'Administrator' | 'User') {
+ server.use(
+ http.post(`${API_BASE}/api/v1/auth/refresh`, () =>
+ HttpResponse.json(makeAuthResponse({ user: { ...mockUser, role } })),
+ ),
+ );
+}
+
+describe('Sidebar role filtering (BR-U3-01 – BR-U3-06)', () => {
+ it('Owner sees all nav items', async () => {
+ mockAuthenticatedAs('Owner');
+ renderApp('/dashboard');
+ expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-users')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-cms')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-profile')).toBeInTheDocument();
+ });
+
+ it('Administrator sees Dashboard, Users, Profile — not Settings or CMS', async () => {
+ mockAuthenticatedAs('Administrator');
+ renderApp('/dashboard');
+ expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-users')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-profile')).toBeInTheDocument();
+ expect(screen.queryByTestId('nav-settings')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('nav-cms')).not.toBeInTheDocument();
+ });
+
+ it('User sees Dashboard and Profile only', async () => {
+ mockAuthenticatedAs('User');
+ renderApp('/dashboard');
+ expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-profile')).toBeInTheDocument();
+ expect(screen.queryByTestId('nav-users')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('nav-settings')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('nav-cms')).not.toBeInTheDocument();
+ });
+});
+
+describe('Sidebar active route highlight', () => {
+ it('highlights the dashboard link when on /dashboard', async () => {
+ mockAuthenticated();
+ renderApp('/dashboard');
+ const link = await screen.findByTestId('nav-dashboard');
+ expect(link.className).toMatch(/bg-accent/);
+ });
+});
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 306c7c9..f4af794 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -1,42 +1,76 @@
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
-import { LayoutDashboard, Users, FileText } from 'lucide-react';
+import { LayoutDashboard, Users, FileText, Settings, User, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
+import { useAuth } from '@/contexts/auth-context';
+import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
+import { ThemeToggle } from './ThemeToggle';
+import { UserMenu } from './UserMenu';
+
+type Role = 'Owner' | 'Administrator' | 'User';
interface NavItem {
to: string;
labelKey: string;
icon: LucideIcon;
testId: string;
+ roles?: Role[];
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
- { to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users' },
- { to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms' },
+ { to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
+ { to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'] },
+ { to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] },
+ { to: '/profile', labelKey: 'nav.profile', icon: User, testId: 'nav-profile' },
];
-export function Sidebar() {
+interface SidebarProps {
+ onClose?: () => void;
+}
+
+export function Sidebar({ onClose }: SidebarProps = {}) {
const { t } = useTranslation();
+ const { user } = useAuth();
+
+ const role = user?.role as Role | undefined;
+ const visibleItems = NAV_ITEMS.filter(
+ (item) => !item.roles || (role && item.roles.includes(role))
+ );
return (