diff --git a/backend/config/scribe.php b/backend/config/scribe.php
index 3fb82ec7..f747ed20 100644
--- a/backend/config/scribe.php
+++ b/backend/config/scribe.php
@@ -1,11 +1,5 @@
true,
// Where is the auth value meant to be sent in a request?
- 'in' => AuthIn::BEARER->value,
+ 'in' => 'bearer',
// The name of the auth parameter (e.g. token, key, apiKey) or header (e.g. Authorization, Api-Key).
'name' => 'Authorization',
@@ -211,32 +205,18 @@ return [
// Use configureStrategy() to specify settings for a strategy in the list.
// Use removeStrategies() to remove an included strategy.
'strategies' => [
- 'metadata' => [
- ...Defaults::METADATA_STRATEGIES,
- ],
+ 'metadata' => [],
'headers' => [
- ...Defaults::HEADERS_STRATEGIES,
- Strategies\StaticData::withSettings(data: [
+ [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
- ]),
- ],
- 'urlParameters' => [
- ...Defaults::URL_PARAMETERS_STRATEGIES,
- ],
- 'queryParameters' => [
- ...Defaults::QUERY_PARAMETERS_STRATEGIES,
- ],
- 'bodyParameters' => [
- ...Defaults::BODY_PARAMETERS_STRATEGIES,
- ],
- 'responses' => removeStrategies(
- Defaults::RESPONSES_STRATEGIES,
- [Strategies\Responses\ResponseCalls::class],
- ),
- 'responseFields' => [
- ...Defaults::RESPONSE_FIELDS_STRATEGIES,
+ ],
],
+ 'urlParameters' => [],
+ 'queryParameters' => [],
+ 'bodyParameters' => [],
+ 'responses' => [],
+ 'responseFields' => [],
],
// For response calls, API resource responses and transformer responses,
diff --git a/docker-compose.yml b/docker-compose.yml
index a9606345..1d475c37 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -81,7 +81,6 @@ services:
restart: unless-stopped
working_dir: /app
volumes:
- - ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 93771d3d..24aaa7c1 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -19,7 +19,7 @@ EXPOSE 5173
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:5173 || exit 1
+ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:5173 || exit 1
# Start the application
CMD ["node", "build/index.js"]
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index ee6eec43..04c2c0d5 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@tanstack/table-core": "^8.21.2",
+ "lucide-svelte": "^0.574.0",
"recharts": "^2.15.1",
"sveltekit-superforms": "^2.24.0",
"zod": "^3.24.2"
@@ -4186,6 +4187,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lucide-svelte": {
+ "version": "0.574.0",
+ "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.574.0.tgz",
+ "integrity": "sha512-KhMbh4uFO8jm60bYPWHV5GmNy1P5hs1M6BfDYUjw0CZ5tJoJ+lMzAdTBBb9L+Gj3xOlHIgjsLMVnjQK3qcWrEA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5.0.0-next.42"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 599b5e25..23fe4a69 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -46,6 +46,7 @@
},
"dependencies": {
"@tanstack/table-core": "^8.21.2",
+ "lucide-svelte": "^0.574.0",
"recharts": "^2.15.1",
"sveltekit-superforms": "^2.24.0",
"zod": "^3.24.2"
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 31624135..47def589 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -8,7 +8,7 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
- baseURL: 'http://localhost:5173',
+ baseURL: 'http://127.0.0.1:5173',
trace: 'on-first-retry',
},
projects: [
diff --git a/frontend/src/app.css b/frontend/src/app.css
index f1d37dc5..0477fb42 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1,2 +1,16 @@
@import "tailwindcss";
@import "daisyui";
+
+:root {
+ --sidebar-width-expanded: 240px;
+ --sidebar-width-collapsed: 64px;
+ --topbar-height: 56px;
+}
+
+[data-theme='light'] {
+ color-scheme: light;
+}
+
+[data-theme='dark'] {
+ color-scheme: dark;
+}
diff --git a/frontend/src/app.html b/frontend/src/app.html
index 6769ed5e..a95435fa 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -4,6 +4,25 @@
+
%sveltekit.head%
diff --git a/frontend/src/lib/components/layout/AppLayout.svelte b/frontend/src/lib/components/layout/AppLayout.svelte
new file mode 100644
index 00000000..634b1605
--- /dev/null
+++ b/frontend/src/lib/components/layout/AppLayout.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/layout/Breadcrumbs.svelte b/frontend/src/lib/components/layout/Breadcrumbs.svelte
new file mode 100644
index 00000000..5fe5b0a4
--- /dev/null
+++ b/frontend/src/lib/components/layout/Breadcrumbs.svelte
@@ -0,0 +1,50 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/layout/MonthSelector.svelte b/frontend/src/lib/components/layout/MonthSelector.svelte
new file mode 100644
index 00000000..ff345e12
--- /dev/null
+++ b/frontend/src/lib/components/layout/MonthSelector.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+ -
+
+
+
+ -
+
+
+
+ {#each monthOptions as option}
+ -
+
+
+ {/each}
+
+
diff --git a/frontend/src/lib/components/layout/Sidebar.svelte b/frontend/src/lib/components/layout/Sidebar.svelte
new file mode 100644
index 00000000..ca08907d
--- /dev/null
+++ b/frontend/src/lib/components/layout/Sidebar.svelte
@@ -0,0 +1,132 @@
+
+
+
+
+ setSidebarState('hidden')}
+ data-testid="sidebar-backdrop"
+>
+
+
+
+
diff --git a/frontend/src/lib/components/layout/SidebarItem.svelte b/frontend/src/lib/components/layout/SidebarItem.svelte
new file mode 100644
index 00000000..a7229388
--- /dev/null
+++ b/frontend/src/lib/components/layout/SidebarItem.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+ {#if expanded}
+ {label}
+ {/if}
+
diff --git a/frontend/src/lib/components/layout/SidebarSection.svelte b/frontend/src/lib/components/layout/SidebarSection.svelte
new file mode 100644
index 00000000..03e56258
--- /dev/null
+++ b/frontend/src/lib/components/layout/SidebarSection.svelte
@@ -0,0 +1,21 @@
+
+
+
+ {#if expanded}
+
+ {section.title}
+
+ {/if}
+
+
+ {#each section.items as item (item.href)}
+
+ {/each}
+
+
diff --git a/frontend/src/lib/components/layout/TopBar.svelte b/frontend/src/lib/components/layout/TopBar.svelte
new file mode 100644
index 00000000..401a7cbe
--- /dev/null
+++ b/frontend/src/lib/components/layout/TopBar.svelte
@@ -0,0 +1,32 @@
+
+
+
diff --git a/frontend/src/lib/components/layout/UserMenu.svelte b/frontend/src/lib/components/layout/UserMenu.svelte
new file mode 100644
index 00000000..49712cb3
--- /dev/null
+++ b/frontend/src/lib/components/layout/UserMenu.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ - Dashboard
+ {#if $user?.role === 'superuser' || $user?.role === 'manager'}
+ - Team Members
+ - Projects
+ {/if}
+ - Reports
+ -
+
+
+
+
diff --git a/frontend/src/lib/config/navigation.ts b/frontend/src/lib/config/navigation.ts
new file mode 100644
index 00000000..10fb6b7d
--- /dev/null
+++ b/frontend/src/lib/config/navigation.ts
@@ -0,0 +1,32 @@
+import type { NavSection } from '$lib/types/layout';
+
+export const navigationSections: NavSection[] = [
+ {
+ title: 'PLANNING',
+ items: [
+ { label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' },
+ { label: 'Team', href: '/team', icon: 'Users' },
+ { label: 'Projects', href: '/projects', icon: 'Folder' },
+ { label: 'Allocations', href: '/allocations', icon: 'Calendar' },
+ { label: 'Actuals', href: '/actuals', icon: 'CheckCircle' }
+ ]
+ },
+ {
+ title: 'REPORTS',
+ items: [
+ { label: 'Forecast', href: '/reports/forecast', icon: 'TrendingUp' },
+ { label: 'Utilization', href: '/reports/utilization', icon: 'BarChart3' },
+ { label: 'Costs', href: '/reports/costs', icon: 'DollarSign' },
+ { label: 'Variance', href: '/reports/variance', icon: 'AlertTriangle' },
+ { label: 'Allocation Matrix', href: '/reports/allocation', icon: 'Grid3X3' }
+ ]
+ },
+ {
+ title: 'ADMIN',
+ roles: ['superuser'],
+ items: [
+ { label: 'Settings', href: '/settings', icon: 'Settings' },
+ { label: 'Master Data', href: '/master-data', icon: 'Database' }
+ ]
+ }
+];
diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts
index 8aa8534b..697b70b1 100644
--- a/frontend/src/lib/services/api.ts
+++ b/frontend/src/lib/services/api.ts
@@ -140,13 +140,24 @@ export async function apiRequest(endpoint: string, options: ApiRequestOptions
// Prepare request options
const requestOptions: RequestInit = {
- ...options,
+ method: options.method,
headers,
};
// Handle request body
- if (options.body && typeof options.body === 'object') {
- requestOptions.body = JSON.stringify(options.body);
+ if (options.body !== undefined) {
+ const body = options.body;
+
+ const isJsonObject =
+ typeof body === 'object' &&
+ body !== null &&
+ !(body instanceof FormData) &&
+ !(body instanceof URLSearchParams) &&
+ !(body instanceof Blob) &&
+ !(body instanceof ArrayBuffer) &&
+ !ArrayBuffer.isView(body);
+
+ requestOptions.body = isJsonObject ? JSON.stringify(body) : (body as BodyInit);
}
try {
@@ -213,12 +224,12 @@ async function handleResponse(response: Response): Promise {
export const api = {
get: (endpoint: string, options: ApiRequestOptions = {}) =>
apiRequest(endpoint, { ...options, method: 'GET' }),
- post: (endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
- apiRequest(endpoint, { ...options, method: 'POST', body }),
- put: (endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
- apiRequest(endpoint, { ...options, method: 'PUT', body }),
- patch: (endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
- apiRequest(endpoint, { ...options, method: 'PATCH', body }),
+ post: (endpoint: string, body?: unknown, options: ApiRequestOptions = {}) =>
+ apiRequest(endpoint, body === undefined ? { ...options, method: 'POST' } : { ...options, method: 'POST', body }),
+ put: (endpoint: string, body?: unknown, options: ApiRequestOptions = {}) =>
+ apiRequest(endpoint, body === undefined ? { ...options, method: 'PUT' } : { ...options, method: 'PUT', body }),
+ patch: (endpoint: string, body?: unknown, options: ApiRequestOptions = {}) =>
+ apiRequest(endpoint, body === undefined ? { ...options, method: 'PATCH' } : { ...options, method: 'PATCH', body }),
delete: (endpoint: string, options: ApiRequestOptions = {}) =>
apiRequest(endpoint, { ...options, method: 'DELETE' }),
};
@@ -236,7 +247,7 @@ interface LoginResponse {
user: {
id: string;
email: string;
- role: string;
+ role: 'superuser' | 'manager' | 'developer' | 'top_brass';
};
}
diff --git a/frontend/src/lib/stores/auth.ts b/frontend/src/lib/stores/auth.ts
index 8085b6d0..1c64f8d5 100644
--- a/frontend/src/lib/stores/auth.ts
+++ b/frontend/src/lib/stores/auth.ts
@@ -7,7 +7,7 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
-import { authApi, setTokens, clearTokens, getAccessToken } from '$lib/services/api.js';
+import { authApi, setTokens, clearTokens, getAccessToken } from '$lib/services/api';
// User type
export interface User {
diff --git a/frontend/src/lib/stores/layout.ts b/frontend/src/lib/stores/layout.ts
new file mode 100644
index 00000000..55bb4049
--- /dev/null
+++ b/frontend/src/lib/stores/layout.ts
@@ -0,0 +1,83 @@
+import { writable } from 'svelte/store';
+import type { SidebarState, Theme } from '$lib/types/layout';
+
+const SIDEBAR_STORAGE_KEY = 'headroom_sidebar_state';
+const THEME_STORAGE_KEY = 'headroom_theme';
+
+const DEFAULT_SIDEBAR_STATE: SidebarState = 'expanded';
+const DEFAULT_THEME: Theme = 'light';
+
+function isSidebarState(value: string | null): value is SidebarState {
+ return value === 'expanded' || value === 'collapsed' || value === 'hidden';
+}
+
+function isTheme(value: string | null): value is Theme {
+ return value === 'light' || value === 'dark';
+}
+
+function getInitialSidebarState(): SidebarState {
+ if (typeof localStorage === 'undefined') return DEFAULT_SIDEBAR_STATE;
+
+ const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY);
+ return isSidebarState(stored) ? stored : DEFAULT_SIDEBAR_STATE;
+}
+
+function getInitialTheme(): Theme {
+ if (typeof localStorage === 'undefined') return DEFAULT_THEME;
+
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
+ if (isTheme(stored)) return stored;
+
+ const prefersDark =
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+ return prefersDark ? 'dark' : DEFAULT_THEME;
+}
+
+function applyTheme(value: Theme): void {
+ if (typeof document === 'undefined') return;
+ document.documentElement.setAttribute('data-theme', value);
+}
+
+const sidebarStateWritable = writable(getInitialSidebarState());
+const themeWritable = writable(getInitialTheme());
+
+if (typeof localStorage !== 'undefined') {
+ sidebarStateWritable.subscribe((value) => {
+ localStorage.setItem(SIDEBAR_STORAGE_KEY, value);
+ });
+
+ themeWritable.subscribe((value) => {
+ localStorage.setItem(THEME_STORAGE_KEY, value);
+ applyTheme(value);
+ });
+}
+
+export const sidebarState = {
+ subscribe: sidebarStateWritable.subscribe
+};
+
+export const theme = {
+ subscribe: themeWritable.subscribe
+};
+
+export function toggleSidebar(): void {
+ sidebarStateWritable.update((current) => {
+ if (current === 'expanded') return 'collapsed';
+ if (current === 'collapsed') return 'hidden';
+ return 'expanded';
+ });
+}
+
+export function setSidebarState(state: SidebarState): void {
+ sidebarStateWritable.set(state);
+}
+
+export function toggleTheme(): void {
+ themeWritable.update((current) => (current === 'light' ? 'dark' : 'light'));
+}
+
+export function setTheme(newTheme: Theme): void {
+ themeWritable.set(newTheme);
+}
diff --git a/frontend/src/lib/stores/period.ts b/frontend/src/lib/stores/period.ts
new file mode 100644
index 00000000..8eee4d41
--- /dev/null
+++ b/frontend/src/lib/stores/period.ts
@@ -0,0 +1,73 @@
+import { derived, writable } from 'svelte/store';
+
+const PERIOD_STORAGE_KEY = 'headroom_selected_period';
+
+function getCurrentMonthPeriod(): string {
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, '0');
+ return `${year}-${month}`;
+}
+
+function isPeriod(value: string | null): value is string {
+ if (value === null) return false;
+ return /^\d{4}-(0[1-9]|1[0-2])$/.test(value);
+}
+
+function getInitialPeriod(): string {
+ if (typeof localStorage === 'undefined') return getCurrentMonthPeriod();
+
+ const stored = localStorage.getItem(PERIOD_STORAGE_KEY);
+ return isPeriod(stored) ? stored : getCurrentMonthPeriod();
+}
+
+const selectedPeriodWritable = writable(getInitialPeriod());
+
+if (typeof localStorage !== 'undefined') {
+ selectedPeriodWritable.subscribe((value) => {
+ localStorage.setItem(PERIOD_STORAGE_KEY, value);
+ });
+}
+
+export const selectedPeriod = {
+ subscribe: selectedPeriodWritable.subscribe
+};
+
+export const selectedMonth = derived(selectedPeriodWritable, ($period) => {
+ const [year, month] = $period.split('-').map(Number);
+ return { year, month };
+});
+
+export const selectedDate = derived(selectedPeriodWritable, ($period) => {
+ const [year, month] = $period.split('-').map(Number);
+ return new Date(year, month - 1, 1);
+});
+
+export function setPeriod(period: string): void {
+ if (!isPeriod(period)) return;
+ selectedPeriodWritable.set(period);
+}
+
+export function previousMonth(): void {
+ selectedPeriodWritable.update((current) => {
+ const [year, month] = current.split('-').map(Number);
+ const date = new Date(year, month - 2, 1);
+ const nextYear = date.getFullYear();
+ const nextMonth = String(date.getMonth() + 1).padStart(2, '0');
+ return `${nextYear}-${nextMonth}`;
+ });
+}
+
+export function nextMonth(): void {
+ selectedPeriodWritable.update((current) => {
+ const [year, month] = current.split('-').map(Number);
+ const date = new Date(year, month, 1);
+ const nextYear = date.getFullYear();
+ const nextMonth = String(date.getMonth() + 1).padStart(2, '0');
+ return `${nextYear}-${nextMonth}`;
+ });
+}
+
+export function currentMonth(): void {
+ selectedPeriodWritable.set(getCurrentMonthPeriod());
+}
diff --git a/frontend/src/lib/types/layout.ts b/frontend/src/lib/types/layout.ts
new file mode 100644
index 00000000..998ab121
--- /dev/null
+++ b/frontend/src/lib/types/layout.ts
@@ -0,0 +1,17 @@
+export type SidebarState = 'expanded' | 'collapsed' | 'hidden';
+
+export interface NavItem {
+ label: string;
+ href: string;
+ icon: string;
+ badge?: string | number;
+ roles?: string[];
+}
+
+export interface NavSection {
+ title: string;
+ items: NavItem[];
+ roles?: string[];
+}
+
+export type Theme = 'light' | 'dark';
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 569889db..e60d4689 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,17 +1,22 @@
-
-
-
-
+{#if shouldUseAppLayout}
+
-
-
+
+{:else}
+
+{/if}
diff --git a/frontend/tests/component/app-layout.test.ts b/frontend/tests/component/app-layout.test.ts
new file mode 100644
index 00000000..d5124cd1
--- /dev/null
+++ b/frontend/tests/component/app-layout.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import AppLayout, { getContentOffsetClass } from '../../src/lib/components/layout/AppLayout.svelte';
+
+describe('AppLayout component', () => {
+ it('exports a component module', () => {
+ expect(AppLayout).toBeDefined();
+ });
+
+ it('renders children via slot', () => {
+ const source = readFileSync(
+ resolve(process.cwd(), 'src/lib/components/layout/AppLayout.svelte'),
+ 'utf-8'
+ );
+
+ expect(source).toContain('');
+ });
+
+ it('maps sidebar states to layout offsets', () => {
+ expect(getContentOffsetClass('expanded')).toBe('md:ml-60');
+ expect(getContentOffsetClass('collapsed')).toBe('md:ml-16');
+ expect(getContentOffsetClass('hidden')).toBe('md:ml-0');
+ });
+});
diff --git a/frontend/tests/component/breadcrumbs.test.ts b/frontend/tests/component/breadcrumbs.test.ts
new file mode 100644
index 00000000..d9b6423b
--- /dev/null
+++ b/frontend/tests/component/breadcrumbs.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import Breadcrumbs, { generateBreadcrumbs } from '../../src/lib/components/layout/Breadcrumbs.svelte';
+
+describe('Breadcrumbs component', () => {
+ it('exports a component module', () => {
+ expect(Breadcrumbs).toBeDefined();
+ });
+
+ it('generates correct crumbs', () => {
+ const crumbs = generateBreadcrumbs('/reports/allocation-matrix');
+ expect(crumbs.map((crumb) => crumb.label)).toEqual(['Home', 'Reports', 'Allocation Matrix']);
+ expect(crumbs[2].href).toBe('/reports/allocation-matrix');
+ });
+});
diff --git a/frontend/tests/component/lucide-icon.test.ts b/frontend/tests/component/lucide-icon.test.ts
new file mode 100644
index 00000000..9df17fc4
--- /dev/null
+++ b/frontend/tests/component/lucide-icon.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import { Menu } from 'lucide-svelte';
+
+describe('lucide icon', () => {
+ it('exports menu icon component', () => {
+ expect(Menu).toBeDefined();
+ expect(typeof Menu).toBe('function');
+ });
+});
diff --git a/frontend/tests/component/month-selector.test.ts b/frontend/tests/component/month-selector.test.ts
new file mode 100644
index 00000000..4e4917dc
--- /dev/null
+++ b/frontend/tests/component/month-selector.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import MonthSelector, {
+ formatMonth,
+ generateMonthOptions
+} from '../../src/lib/components/layout/MonthSelector.svelte';
+import { selectedPeriod, setPeriod } from '../../src/lib/stores/period';
+
+function getStoreValue(store: { subscribe: (run: (value: T) => void) => () => void }): T {
+ let value!: T;
+ const unsubscribe = store.subscribe((current) => {
+ value = current;
+ });
+ unsubscribe();
+ return value;
+}
+
+describe('MonthSelector component', () => {
+ it('exports a component module', () => {
+ expect(MonthSelector).toBeDefined();
+ });
+
+ it('formats month labels', () => {
+ expect(formatMonth('2026-02')).toBe('Feb 2026');
+ });
+
+ it('builds +/- 6 month options', () => {
+ const options = generateMonthOptions(new Date(2026, 1, 1));
+ expect(options).toHaveLength(13);
+ expect(options[0]).toBe('2025-08');
+ expect(options[12]).toBe('2026-08');
+ });
+
+ it('selection updates period store', () => {
+ setPeriod('2026-02');
+ expect(getStoreValue(selectedPeriod)).toBe('2026-02');
+ });
+});
diff --git a/frontend/tests/component/sidebar-item.test.ts b/frontend/tests/component/sidebar-item.test.ts
new file mode 100644
index 00000000..7402749f
--- /dev/null
+++ b/frontend/tests/component/sidebar-item.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import SidebarItem from '../../src/lib/components/layout/SidebarItem.svelte';
+
+describe('SidebarItem component', () => {
+ it('exports a component module', () => {
+ expect(SidebarItem).toBeDefined();
+ expect(typeof SidebarItem).toBe('function');
+ });
+});
diff --git a/frontend/tests/component/sidebar-section.test.ts b/frontend/tests/component/sidebar-section.test.ts
new file mode 100644
index 00000000..a02f1b76
--- /dev/null
+++ b/frontend/tests/component/sidebar-section.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import SidebarSection from '../../src/lib/components/layout/SidebarSection.svelte';
+
+describe('SidebarSection component', () => {
+ it('exports a component module', () => {
+ expect(SidebarSection).toBeDefined();
+ expect(typeof SidebarSection).toBe('function');
+ });
+});
diff --git a/frontend/tests/component/sidebar.test.ts b/frontend/tests/component/sidebar.test.ts
new file mode 100644
index 00000000..16e8de99
--- /dev/null
+++ b/frontend/tests/component/sidebar.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import Sidebar, { isSectionVisible } from '../../src/lib/components/layout/Sidebar.svelte';
+
+describe('Sidebar component', () => {
+ it('exports a component module', () => {
+ expect(Sidebar).toBeDefined();
+ });
+
+ it('supports role-based visibility checks', () => {
+ expect(isSectionVisible({ title: 'ADMIN', roles: ['superuser'], items: [] }, 'superuser')).toBe(true);
+ expect(isSectionVisible({ title: 'ADMIN', roles: ['superuser'], items: [] }, 'manager')).toBe(false);
+ expect(isSectionVisible({ title: 'PLANNING', items: [] }, null)).toBe(true);
+ });
+});
diff --git a/frontend/tests/component/topbar.test.ts b/frontend/tests/component/topbar.test.ts
new file mode 100644
index 00000000..9f80656f
--- /dev/null
+++ b/frontend/tests/component/topbar.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import TopBar from '../../src/lib/components/layout/TopBar.svelte';
+
+describe('TopBar component', () => {
+ it('exports a component module', () => {
+ expect(TopBar).toBeDefined();
+ });
+
+ it('includes breadcrumbs, month selector, and user menu', () => {
+ const source = readFileSync(
+ resolve(process.cwd(), 'src/lib/components/layout/TopBar.svelte'),
+ 'utf-8'
+ );
+
+ expect(source).toContain('');
+ expect(source).toContain('');
+ expect(source).toContain('');
+ });
+});
diff --git a/frontend/tests/e2e/layout.spec.ts b/frontend/tests/e2e/layout.spec.ts
new file mode 100644
index 00000000..58a79b6b
--- /dev/null
+++ b/frontend/tests/e2e/layout.spec.ts
@@ -0,0 +1,167 @@
+import { expect, test } from '@playwright/test';
+
+function createToken() {
+ const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' }), 'utf-8').toString('base64');
+ const payload = Buffer.from(
+ JSON.stringify({ sub: 'e2e', exp: Math.floor(Date.now() / 1000) + 3600 }),
+ 'utf-8'
+ ).toString('base64');
+ return `${header}.${payload}.sig`;
+}
+
+async function openDashboard(page: import('@playwright/test').Page) {
+ const token = createToken();
+ await page.goto('/login');
+ await page.evaluate((accessToken) => {
+ localStorage.setItem('headroom_access_token', accessToken);
+ localStorage.setItem('headroom_refresh_token', accessToken);
+ }, token);
+ await page.goto('/dashboard');
+ await page.waitForURL('/dashboard');
+}
+
+async function loginThroughForm(page: import('@playwright/test').Page) {
+ await page.goto('/login');
+ await page.fill('input[type="email"]', 'superuser@headroom.test');
+ await page.fill('input[type="password"]', 'password');
+ await page.click('button[type="submit"]');
+ await page.waitForURL('/dashboard');
+}
+
+test.describe('Layout E2E', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/login');
+ await page.context().clearCookies();
+ await page.evaluate(() => localStorage.clear());
+ });
+
+ test('dashboard page has sidebar', async ({ page }) => {
+ await openDashboard(page);
+ await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
+ });
+
+ test('login redirects to dashboard with sidebar', async ({ page }) => {
+ await loginThroughForm(page);
+ await expect(page.locator('[data-testid="sidebar"]')).toBeVisible();
+ });
+
+ test('sidebar toggle works', async ({ page }) => {
+ await openDashboard(page);
+
+ const initial = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar'));
+ await page.click('[data-testid="sidebar-toggle"]');
+
+ await expect
+ .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
+ .not.toBe(initial);
+ });
+
+ test('theme toggle works', async ({ page }) => {
+ await openDashboard(page);
+
+ const before = await page.evaluate(() => localStorage.getItem('headroom_theme') ?? 'light');
+ await page.click('[data-testid="theme-toggle"]');
+
+ await expect
+ .poll(async () => page.evaluate(() => localStorage.getItem('headroom_theme')))
+ .not.toBe(before);
+ });
+
+ test('month selector updates period store', async ({ page }) => {
+ await openDashboard(page);
+
+ const before = await page.evaluate(() => localStorage.getItem('headroom_selected_period'));
+ await page.click('[data-testid="month-selector"] button');
+ await page.getByRole('button', { name: 'Next' }).click();
+
+ await expect
+ .poll(async () => page.evaluate(() => localStorage.getItem('headroom_selected_period')))
+ .not.toBe(before);
+ });
+
+ test('breadcrumbs reflect current route', async ({ page }) => {
+ await openDashboard(page);
+ await expect(page.locator('[data-testid="breadcrumbs"]')).toContainText('Dashboard');
+ });
+
+ test('login page has no sidebar', async ({ page }) => {
+ await page.goto('/login');
+ await expect(page.locator('[data-testid="sidebar"]')).toHaveCount(0);
+ });
+
+ test('sidebar hidden by default on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await openDashboard(page);
+
+ await expect
+ .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
+ .toBe('hidden');
+ });
+
+ test('hamburger shows sidebar on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await openDashboard(page);
+
+ await page.click('[data-testid="mobile-hamburger"]');
+ await expect
+ .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
+ .toBe('expanded');
+ });
+
+ test('sidebar overlays content and closes on backdrop click on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await openDashboard(page);
+
+ await page.click('[data-testid="mobile-hamburger"]');
+ await expect
+ .poll(async () =>
+ page.evaluate(() => {
+ const main = document.querySelector('[data-testid="layout-main"]');
+ if (!main) return null;
+ return getComputedStyle(main).marginLeft;
+ })
+ )
+ .toBe('0px');
+
+ await page.evaluate(() => {
+ const backdrop = document.querySelector('[data-testid="sidebar-backdrop"]') as HTMLElement | null;
+ backdrop?.click();
+ });
+ await expect
+ .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
+ .toBe('hidden');
+ });
+
+ test('all key breakpoints render expected sidebar state', async ({ page }) => {
+ const breakpoints: Array<[number, number, 'hidden' | 'collapsed' | 'expanded']> = [
+ [320, 700, 'hidden'],
+ [768, 900, 'collapsed'],
+ [1024, 900, 'collapsed'],
+ [1280, 900, 'expanded']
+ ];
+
+ for (const [width, height, expected] of breakpoints) {
+ await page.setViewportSize({ width, height });
+ await page.goto('/login');
+ await page.evaluate(() => localStorage.setItem('headroom_sidebar_state', 'expanded'));
+ await openDashboard(page);
+ await expect
+ .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
+ .toBe(expected);
+ }
+ });
+
+ test('keyboard shortcut toggles sidebar', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await openDashboard(page);
+
+ const before = await page.evaluate(() => document.documentElement.getAttribute('data-sidebar'));
+ await page.keyboard.down('Control');
+ await page.keyboard.press('\\');
+ await page.keyboard.up('Control');
+
+ await expect
+ .poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
+ .not.toBe(before);
+ });
+});
diff --git a/frontend/tests/e2e/theme.spec.ts b/frontend/tests/e2e/theme.spec.ts
new file mode 100644
index 00000000..434474ed
--- /dev/null
+++ b/frontend/tests/e2e/theme.spec.ts
@@ -0,0 +1,37 @@
+import { expect, test } from '@playwright/test';
+
+async function expectTheme(page: import('@playwright/test').Page, theme: 'light' | 'dark') {
+ await expect
+ .poll(async () =>
+ page.evaluate(() => ({
+ attr: document.documentElement.getAttribute('data-theme'),
+ stored: localStorage.getItem('headroom_theme')
+ }))
+ )
+ .toEqual({ attr: theme, stored: theme });
+
+}
+
+test.describe('Theme persistence', () => {
+ test('applies dark theme from localStorage on reload', async ({ page }) => {
+ await page.goto('/login');
+
+ await page.evaluate(() => {
+ localStorage.setItem('headroom_theme', 'dark');
+ });
+
+ await page.reload();
+ await expectTheme(page, 'dark');
+ });
+
+ test('applies light theme from localStorage on reload', async ({ page }) => {
+ await page.goto('/login');
+
+ await page.evaluate(() => {
+ localStorage.setItem('headroom_theme', 'light');
+ });
+
+ await page.reload();
+ await expectTheme(page, 'light');
+ });
+});
diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts
index 12fa744c..9cb5e393 100644
--- a/frontend/tests/setup.ts
+++ b/frontend/tests/setup.ts
@@ -1,13 +1,19 @@
import { vi } from 'vitest';
// Mock localStorage
-global.localStorage = {
+const localStorageMock: Storage = {
+ get length() {
+ return 0;
+ },
+ key: vi.fn(() => null),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
+global.localStorage = localStorageMock;
+
// Mock fetch
global.fetch = vi.fn();
diff --git a/frontend/tests/unit/layout.store.test.ts b/frontend/tests/unit/layout.store.test.ts
new file mode 100644
index 00000000..4983e6f6
--- /dev/null
+++ b/frontend/tests/unit/layout.store.test.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
+
+function getStoreValue(store: { subscribe: (run: (value: T) => void) => () => void }): T {
+ let value!: T;
+ const unsubscribe = store.subscribe((current) => {
+ value = current;
+ });
+ unsubscribe();
+ return value;
+}
+
+describe('layout store', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ document.documentElement.removeAttribute('data-theme');
+ (localStorage.getItem as Mock).mockReturnValue(null);
+ });
+
+ it('initializes with default values', async () => {
+ const store = await import('../../src/lib/stores/layout');
+
+ expect(getStoreValue(store.sidebarState)).toBe('expanded');
+ expect(getStoreValue(store.theme)).toBe('light');
+ });
+
+ it('toggleSidebar cycles through states', async () => {
+ const store = await import('../../src/lib/stores/layout');
+
+ store.setSidebarState('expanded');
+ store.toggleSidebar();
+ expect(getStoreValue(store.sidebarState)).toBe('collapsed');
+ expect(localStorage.setItem).toHaveBeenCalledWith('headroom_sidebar_state', 'collapsed');
+
+ store.toggleSidebar();
+ expect(getStoreValue(store.sidebarState)).toBe('hidden');
+
+ store.toggleSidebar();
+ expect(getStoreValue(store.sidebarState)).toBe('expanded');
+ });
+
+ it('theme toggle works and applies to document', async () => {
+ const store = await import('../../src/lib/stores/layout');
+
+ store.setTheme('light');
+ store.toggleTheme();
+ expect(getStoreValue(store.theme)).toBe('dark');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ expect(localStorage.setItem).toHaveBeenCalledWith('headroom_theme', 'dark');
+
+ store.toggleTheme();
+ expect(getStoreValue(store.theme)).toBe('light');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light');
+ expect(localStorage.setItem).toHaveBeenCalledWith('headroom_theme', 'light');
+ });
+});
diff --git a/frontend/tests/unit/navigation.config.test.ts b/frontend/tests/unit/navigation.config.test.ts
new file mode 100644
index 00000000..a529ebd1
--- /dev/null
+++ b/frontend/tests/unit/navigation.config.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+import { navigationSections } from '../../src/lib/config/navigation';
+
+describe('navigation config', () => {
+ it('has expected section structure', () => {
+ expect(navigationSections).toHaveLength(3);
+
+ expect(navigationSections[0].title).toBe('PLANNING');
+ expect(navigationSections[0].items).toHaveLength(5);
+
+ expect(navigationSections[1].title).toBe('REPORTS');
+ expect(navigationSections[1].items).toHaveLength(5);
+
+ expect(navigationSections[2].title).toBe('ADMIN');
+ expect(navigationSections[2].roles).toEqual(['superuser']);
+ });
+});
diff --git a/frontend/tests/unit/period.store.test.ts b/frontend/tests/unit/period.store.test.ts
new file mode 100644
index 00000000..29098b83
--- /dev/null
+++ b/frontend/tests/unit/period.store.test.ts
@@ -0,0 +1,44 @@
+import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
+
+function getStoreValue(store: { subscribe: (run: (value: T) => void) => () => void }): T {
+ let value!: T;
+ const unsubscribe = store.subscribe((current) => {
+ value = current;
+ });
+ unsubscribe();
+ return value;
+}
+
+describe('period store', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ (localStorage.getItem as Mock).mockReturnValue(null);
+ });
+
+ it('initializes with current month', async () => {
+ const store = await import('../../src/lib/stores/period');
+
+ const now = new Date();
+ const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+ expect(getStoreValue(store.selectedPeriod)).toBe(expected);
+ });
+
+ it('previousMonth decrements correctly', async () => {
+ const store = await import('../../src/lib/stores/period');
+
+ store.setPeriod('2025-01');
+ store.previousMonth();
+
+ expect(getStoreValue(store.selectedPeriod)).toBe('2024-12');
+ expect(localStorage.setItem).toHaveBeenCalledWith('headroom_selected_period', '2024-12');
+ });
+
+ it('nextMonth increments correctly', async () => {
+ const store = await import('../../src/lib/stores/period');
+
+ store.setPeriod('2025-12');
+ store.nextMonth();
+
+ expect(getStoreValue(store.selectedPeriod)).toBe('2026-01');
+ });
+});
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 819a9b46..b0e61c9b 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,4 +1,5 @@
{
+ "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
diff --git a/openspec/changes/p01-ui-foundation/design.md b/openspec/changes/archive/2026-02-18-p01-ui-foundation/design.md
similarity index 100%
rename from openspec/changes/p01-ui-foundation/design.md
rename to openspec/changes/archive/2026-02-18-p01-ui-foundation/design.md
diff --git a/openspec/changes/p01-ui-foundation/proposal.md b/openspec/changes/archive/2026-02-18-p01-ui-foundation/proposal.md
similarity index 100%
rename from openspec/changes/p01-ui-foundation/proposal.md
rename to openspec/changes/archive/2026-02-18-p01-ui-foundation/proposal.md
diff --git a/openspec/changes/archive/2026-02-18-p01-ui-foundation/tasks.md b/openspec/changes/archive/2026-02-18-p01-ui-foundation/tasks.md
new file mode 100644
index 00000000..d6de42e2
--- /dev/null
+++ b/openspec/changes/archive/2026-02-18-p01-ui-foundation/tasks.md
@@ -0,0 +1,86 @@
+# Tasks: UI Foundation
+
+## Phase 1: Install Dependencies
+
+- [x] 1.1 Install Lucide icons: `npm install lucide-svelte`
+- [x] 1.2 Verify installation in package.json
+- [x] 1.3 Test import in a test file: `import { Menu } from 'lucide-svelte'`
+
+## Phase 2: Create Types
+
+- [x] 1.4 Create `src/lib/types/` directory if not exists
+- [x] 1.5 Create `src/lib/types/layout.ts`
+- [x] 1.6 Define `SidebarState` type
+- [x] 1.7 Define `NavItem` interface
+- [x] 1.8 Define `NavSection` interface
+- [x] 1.9 Define `Theme` type
+- [x] 1.10 Export all types
+
+## Phase 3: Create Stores
+
+### Layout Store
+- [x] 1.11 Create `src/lib/stores/layout.ts`
+- [x] 1.12 Implement `sidebarState` writable with localStorage persistence
+- [x] 1.13 Implement `theme` writable with localStorage persistence
+- [x] 1.14 Implement `toggleSidebar()` function
+- [x] 1.15 Implement `setSidebarState()` function
+- [x] 1.16 Implement `toggleTheme()` function
+- [x] 1.17 Implement `setTheme()` function
+- [x] 1.18 Add system preference detection for initial theme
+
+### Period Store
+- [x] 1.19 Create `src/lib/stores/period.ts`
+- [x] 1.20 Implement `selectedPeriod` writable with localStorage persistence
+- [x] 1.21 Create `selectedMonth` derived store
+- [x] 1.22 Create `selectedDate` derived store
+- [x] 1.23 Implement `setPeriod()` function
+- [x] 1.24 Implement `previousMonth()` function
+- [x] 1.25 Implement `nextMonth()` function
+- [x] 1.26 Implement `currentMonth()` function
+
+## Phase 4: Create Navigation Config
+
+- [x] 1.27 Create `src/lib/config/` directory if not exists
+- [x] 1.28 Create `src/lib/config/navigation.ts`
+- [x] 1.29 Define PLANNING section (Dashboard, Team, Projects, Allocations, Actuals)
+- [x] 1.30 Define REPORTS section (Forecast, Utilization, Costs, Variance, Allocation Matrix)
+- [x] 1.31 Define ADMIN section with `roles: ['superuser']`
+- [x] 1.32 Export `navigationSections` array
+
+## Phase 5: Theme System
+
+- [x] 1.33 Update `src/app.css` with theme CSS variables
+- [x] 1.34 Add sidebar width CSS variables
+- [x] 1.35 Add theme color-scheme definitions
+- [x] 1.36 Test theme switching in browser console
+
+## Phase 6: Testing
+
+### Unit Tests
+- [x] 1.37 Write test: layoutStore initializes with default values
+- [x] 1.38 Write test: layoutStore.toggleSidebar cycles through states
+- [x] 1.39 Write test: layoutStore theme toggle works
+- [x] 1.40 Write test: periodStore initializes with current month
+- [x] 1.41 Write test: periodStore.previousMonth decrements correctly
+- [x] 1.42 Write test: periodStore.nextMonth increments correctly
+- [x] 1.43 Write test: navigationSections has correct structure
+
+### Component Tests
+- [x] 1.44 Create test: Lucide icon renders correctly
+
+## Phase 7: Verification
+
+- [x] 1.45 Run `npm run check` - no type errors
+- [x] 1.46 Run `npm run test:unit` - all tests pass
+- [x] 1.47 Verify stores persist to localStorage
+- [x] 1.48 Verify theme applies to document
+
+## Commits
+
+1. `feat(ui): Install lucide-svelte for icon library`
+2. `feat(ui): Add layout types (SidebarState, NavItem, NavSection, Theme)`
+3. `feat(ui): Create layoutStore for sidebar state and theme management`
+4. `feat(ui): Create periodStore for global month/period selection`
+5. `feat(ui): Add navigation configuration for sidebar menu`
+6. `feat(ui): Add CSS variables for theme and layout`
+7. `test(ui): Add unit tests for layout and period stores`
diff --git a/openspec/changes/p02-app-layout/design.md b/openspec/changes/archive/2026-02-18-p02-app-layout/design.md
similarity index 100%
rename from openspec/changes/p02-app-layout/design.md
rename to openspec/changes/archive/2026-02-18-p02-app-layout/design.md
diff --git a/openspec/changes/p02-app-layout/proposal.md b/openspec/changes/archive/2026-02-18-p02-app-layout/proposal.md
similarity index 100%
rename from openspec/changes/p02-app-layout/proposal.md
rename to openspec/changes/archive/2026-02-18-p02-app-layout/proposal.md
diff --git a/openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md b/openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md
new file mode 100644
index 00000000..3cd70fbf
--- /dev/null
+++ b/openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md
@@ -0,0 +1,132 @@
+# Tasks: App Layout
+
+## Phase 1: Create Layout Components Directory
+
+- [x] 2.1 Create `src/lib/components/layout/` directory
+
+## Phase 2: Sidebar Components
+
+### SidebarItem
+- [x] 2.2 Create `SidebarItem.svelte`
+- [x] 2.3 Add icon prop (Lucide component)
+- [x] 2.4 Add label prop
+- [x] 2.5 Add href prop
+- [x] 2.6 Add active state styling (current path matching)
+- [x] 2.7 Handle collapsed state (icon only, tooltip on hover)
+- [x] 2.8 Write component test: renders with icon and label
+
+### SidebarSection
+- [x] 2.9 Create `SidebarSection.svelte`
+- [x] 2.10 Add section prop (NavSection type)
+- [x] 2.11 Add expanded prop (for collapsed sidebar)
+- [x] 2.12 Render section title
+- [x] 2.13 Render SidebarItem for each item
+- [x] 2.14 Write component test: renders all items
+
+### Sidebar
+- [x] 2.15 Create `Sidebar.svelte`
+- [x] 2.16 Import and use navigationSections
+- [x] 2.17 Import layoutStore for state
+- [x] 2.18 Implement three visual states (expanded, collapsed, hidden)
+- [x] 2.19 Add toggle button in header
+- [x] 2.20 Add logo/brand in header
+- [x] 2.21 Implement role-based section visibility
+- [x] 2.22 Add dark mode toggle in footer
+- [x] 2.23 Add keyboard shortcut (Cmd/Ctrl + \)
+- [x] 2.24 Implement CSS transitions
+- [x] 2.25 Write component test: toggle state works
+- [x] 2.26 Write component test: role-based visibility
+
+## Phase 3: TopBar Components
+
+### UserMenu
+- [x] 2.27 Create `UserMenu.svelte` (migrate from Navigation.svelte)
+- [x] 2.28 Import authStore for user info
+- [x] 2.29 Add dropdown with user name/avatar
+- [x] 2.30 Add logout action
+- [x] 2.31 Style with DaisyUI dropdown
+
+### MonthSelector
+- [x] 2.32 Create `MonthSelector.svelte`
+- [x] 2.33 Import periodStore
+- [x] 2.34 Display current month (format: Feb 2026)
+- [x] 2.35 Add dropdown with month options (-6 to +6 months)
+- [x] 2.36 Add Previous/Today/Next quick actions
+- [x] 2.37 Style with DaisyUI dropdown
+- [x] 2.38 Write component test: selection updates store
+
+### Breadcrumbs
+- [x] 2.39 Create `Breadcrumbs.svelte`
+- [x] 2.40 Import $page store for current path
+- [x] 2.41 Implement generateBreadcrumbs function
+- [x] 2.42 Render Home icon for root
+- [x] 2.43 Render segments as links
+- [x] 2.44 Style last item as current (no link)
+- [x] 2.45 Write component test: generates correct crumbs
+
+### TopBar
+- [x] 2.46 Create `TopBar.svelte`
+- [x] 2.47 Import Breadcrumbs, MonthSelector, UserMenu
+- [x] 2.48 Add hamburger toggle for mobile
+- [x] 2.49 Implement sticky positioning
+- [x] 2.50 Style with DaisyUI
+- [x] 2.51 Write component test: renders all components
+
+## Phase 4: AppLayout
+
+- [x] 2.52 Create `AppLayout.svelte`
+- [x] 2.53 Import Sidebar, TopBar
+- [x] 2.54 Add slot for page content
+- [x] 2.55 Implement flex layout (sidebar + main content)
+- [x] 2.56 Adjust main content margin based on sidebar state
+- [x] 2.57 Handle responsive behavior (mobile drawer)
+- [x] 2.58 Write component test: renders children
+- [x] 2.59 Write component test: sidebar toggle affects layout
+
+## Phase 5: Route Integration
+
+- [x] 2.60 Update `src/routes/+layout.svelte`
+- [x] 2.61 Add conditional AppLayout wrapper
+- [x] 2.62 Define publicPages array (['/login', '/auth'])
+- [x] 2.63 Test: login page has NO sidebar
+- [x] 2.64 Test: dashboard page has sidebar
+
+## Phase 6: Responsive & Mobile
+
+- [x] 2.65 Test: Sidebar hidden by default on mobile
+- [x] 2.66 Test: Hamburger shows sidebar on mobile
+- [x] 2.67 Test: Sidebar overlays content on mobile (not push)
+- [x] 2.68 Test: Clicking outside closes sidebar on mobile
+- [x] 2.69 Add backdrop overlay for mobile drawer
+
+## Phase 7: E2E Tests
+
+- [x] 2.70 E2E test: Login redirects to dashboard with sidebar
+- [x] 2.71 E2E test: Sidebar toggle works
+- [x] 2.72 E2E test: Theme toggle works
+- [x] 2.73 E2E test: Month selector updates period store
+- [x] 2.74 E2E test: Breadcrumbs reflect current route
+
+## Phase 8: Verification
+
+- [x] 2.75 Run `npm run check` - no type errors
+- [x] 2.76 Run `npm run test:unit` - all component tests pass
+- [x] 2.77 Run `npm run test:e2e` - all E2E tests pass
+- [x] 2.78 Manual test: All breakpoints (320px, 768px, 1024px, 1280px)
+- [x] 2.79 Manual test: Dark mode toggle
+- [x] 2.80 Manual test: Keyboard shortcut (Cmd/Ctrl + \)
+
+## Commits
+
+1. `feat(layout): Create SidebarItem component with active state`
+2. `feat(layout): Create SidebarSection component`
+3. `feat(layout): Create Sidebar with three states and theme toggle`
+4. `feat(layout): Create UserMenu component (migrated from Navigation)`
+5. `feat(layout): Create MonthSelector with period store integration`
+6. `feat(layout): Create Breadcrumbs with auto-generation`
+7. `feat(layout): Create TopBar with all components`
+8. `feat(layout): Create AppLayout wrapper component`
+9. `feat(layout): Integrate AppLayout into root layout`
+10. `feat(layout): Add responsive mobile drawer behavior`
+11. `test(layout): Add component tests for all layout components`
+12. `test(e2e): Add E2E tests for layout functionality`
diff --git a/openspec/changes/p01-ui-foundation/tasks.md b/openspec/changes/p01-ui-foundation/tasks.md
deleted file mode 100644
index 33e7b836..00000000
--- a/openspec/changes/p01-ui-foundation/tasks.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# Tasks: UI Foundation
-
-## Phase 1: Install Dependencies
-
-- [ ] 1.1 Install Lucide icons: `npm install lucide-svelte`
-- [ ] 1.2 Verify installation in package.json
-- [ ] 1.3 Test import in a test file: `import { Menu } from 'lucide-svelte'`
-
-## Phase 2: Create Types
-
-- [ ] 1.4 Create `src/lib/types/` directory if not exists
-- [ ] 1.5 Create `src/lib/types/layout.ts`
-- [ ] 1.6 Define `SidebarState` type
-- [ ] 1.7 Define `NavItem` interface
-- [ ] 1.8 Define `NavSection` interface
-- [ ] 1.9 Define `Theme` type
-- [ ] 1.10 Export all types
-
-## Phase 3: Create Stores
-
-### Layout Store
-- [ ] 1.11 Create `src/lib/stores/layout.ts`
-- [ ] 1.12 Implement `sidebarState` writable with localStorage persistence
-- [ ] 1.13 Implement `theme` writable with localStorage persistence
-- [ ] 1.14 Implement `toggleSidebar()` function
-- [ ] 1.15 Implement `setSidebarState()` function
-- [ ] 1.16 Implement `toggleTheme()` function
-- [ ] 1.17 Implement `setTheme()` function
-- [ ] 1.18 Add system preference detection for initial theme
-
-### Period Store
-- [ ] 1.19 Create `src/lib/stores/period.ts`
-- [ ] 1.20 Implement `selectedPeriod` writable with localStorage persistence
-- [ ] 1.21 Create `selectedMonth` derived store
-- [ ] 1.22 Create `selectedDate` derived store
-- [ ] 1.23 Implement `setPeriod()` function
-- [ ] 1.24 Implement `previousMonth()` function
-- [ ] 1.25 Implement `nextMonth()` function
-- [ ] 1.26 Implement `currentMonth()` function
-
-## Phase 4: Create Navigation Config
-
-- [ ] 1.27 Create `src/lib/config/` directory if not exists
-- [ ] 1.28 Create `src/lib/config/navigation.ts`
-- [ ] 1.29 Define PLANNING section (Dashboard, Team, Projects, Allocations, Actuals)
-- [ ] 1.30 Define REPORTS section (Forecast, Utilization, Costs, Variance, Allocation Matrix)
-- [ ] 1.31 Define ADMIN section with `roles: ['superuser']`
-- [ ] 1.32 Export `navigationSections` array
-
-## Phase 5: Theme System
-
-- [ ] 1.33 Update `src/app.css` with theme CSS variables
-- [ ] 1.34 Add sidebar width CSS variables
-- [ ] 1.35 Add theme color-scheme definitions
-- [ ] 1.36 Test theme switching in browser console
-
-## Phase 6: Testing
-
-### Unit Tests
-- [ ] 1.37 Write test: layoutStore initializes with default values
-- [ ] 1.38 Write test: layoutStore.toggleSidebar cycles through states
-- [ ] 1.39 Write test: layoutStore theme toggle works
-- [ ] 1.40 Write test: periodStore initializes with current month
-- [ ] 1.41 Write test: periodStore.previousMonth decrements correctly
-- [ ] 1.42 Write test: periodStore.nextMonth increments correctly
-- [ ] 1.43 Write test: navigationSections has correct structure
-
-### Component Tests
-- [ ] 1.44 Create test: Lucide icon renders correctly
-
-## Phase 7: Verification
-
-- [ ] 1.45 Run `npm run check` - no type errors
-- [ ] 1.46 Run `npm run test:unit` - all tests pass
-- [ ] 1.47 Verify stores persist to localStorage
-- [ ] 1.48 Verify theme applies to document
-
-## Commits
-
-1. `feat(ui): Install lucide-svelte for icon library`
-2. `feat(ui): Add layout types (SidebarState, NavItem, NavSection, Theme)`
-3. `feat(ui): Create layoutStore for sidebar state and theme management`
-4. `feat(ui): Create periodStore for global month/period selection`
-5. `feat(ui): Add navigation configuration for sidebar menu`
-6. `feat(ui): Add CSS variables for theme and layout`
-7. `test(ui): Add unit tests for layout and period stores`
diff --git a/openspec/changes/p02-app-layout/tasks.md b/openspec/changes/p02-app-layout/tasks.md
deleted file mode 100644
index 8c220ca9..00000000
--- a/openspec/changes/p02-app-layout/tasks.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# Tasks: App Layout
-
-## Phase 1: Create Layout Components Directory
-
-- [ ] 2.1 Create `src/lib/components/layout/` directory
-
-## Phase 2: Sidebar Components
-
-### SidebarItem
-- [ ] 2.2 Create `SidebarItem.svelte`
-- [ ] 2.3 Add icon prop (Lucide component)
-- [ ] 2.4 Add label prop
-- [ ] 2.5 Add href prop
-- [ ] 2.6 Add active state styling (current path matching)
-- [ ] 2.7 Handle collapsed state (icon only, tooltip on hover)
-- [ ] 2.8 Write component test: renders with icon and label
-
-### SidebarSection
-- [ ] 2.9 Create `SidebarSection.svelte`
-- [ ] 2.10 Add section prop (NavSection type)
-- [ ] 2.11 Add expanded prop (for collapsed sidebar)
-- [ ] 2.12 Render section title
-- [ ] 2.13 Render SidebarItem for each item
-- [ ] 2.14 Write component test: renders all items
-
-### Sidebar
-- [ ] 2.15 Create `Sidebar.svelte`
-- [ ] 2.16 Import and use navigationSections
-- [ ] 2.17 Import layoutStore for state
-- [ ] 2.18 Implement three visual states (expanded, collapsed, hidden)
-- [ ] 2.19 Add toggle button in header
-- [ ] 2.20 Add logo/brand in header
-- [ ] 2.21 Implement role-based section visibility
-- [ ] 2.22 Add dark mode toggle in footer
-- [ ] 2.23 Add keyboard shortcut (Cmd/Ctrl + \)
-- [ ] 2.24 Implement CSS transitions
-- [ ] 2.25 Write component test: toggle state works
-- [ ] 2.26 Write component test: role-based visibility
-
-## Phase 3: TopBar Components
-
-### UserMenu
-- [ ] 2.27 Create `UserMenu.svelte` (migrate from Navigation.svelte)
-- [ ] 2.28 Import authStore for user info
-- [ ] 2.29 Add dropdown with user name/avatar
-- [ ] 2.30 Add logout action
-- [ ] 2.31 Style with DaisyUI dropdown
-
-### MonthSelector
-- [ ] 2.32 Create `MonthSelector.svelte`
-- [ ] 2.33 Import periodStore
-- [ ] 2.34 Display current month (format: Feb 2026)
-- [ ] 2.35 Add dropdown with month options (-6 to +6 months)
-- [ ] 2.36 Add Previous/Today/Next quick actions
-- [ ] 2.37 Style with DaisyUI dropdown
-- [ ] 2.38 Write component test: selection updates store
-
-### Breadcrumbs
-- [ ] 2.39 Create `Breadcrumbs.svelte`
-- [ ] 2.40 Import $page store for current path
-- [ ] 2.41 Implement generateBreadcrumbs function
-- [ ] 2.42 Render Home icon for root
-- [ ] 2.43 Render segments as links
-- [ ] 2.44 Style last item as current (no link)
-- [ ] 2.45 Write component test: generates correct crumbs
-
-### TopBar
-- [ ] 2.46 Create `TopBar.svelte`
-- [ ] 2.47 Import Breadcrumbs, MonthSelector, UserMenu
-- [ ] 2.48 Add hamburger toggle for mobile
-- [ ] 2.49 Implement sticky positioning
-- [ ] 2.50 Style with DaisyUI
-- [ ] 2.51 Write component test: renders all components
-
-## Phase 4: AppLayout
-
-- [ ] 2.52 Create `AppLayout.svelte`
-- [ ] 2.53 Import Sidebar, TopBar
-- [ ] 2.54 Add slot for page content
-- [ ] 2.55 Implement flex layout (sidebar + main content)
-- [ ] 2.56 Adjust main content margin based on sidebar state
-- [ ] 2.57 Handle responsive behavior (mobile drawer)
-- [ ] 2.58 Write component test: renders children
-- [ ] 2.59 Write component test: sidebar toggle affects layout
-
-## Phase 5: Route Integration
-
-- [ ] 2.60 Update `src/routes/+layout.svelte`
-- [ ] 2.61 Add conditional AppLayout wrapper
-- [ ] 2.62 Define publicPages array (['/login', '/auth'])
-- [ ] 2.63 Test: login page has NO sidebar
-- [ ] 2.64 Test: dashboard page has sidebar
-
-## Phase 6: Responsive & Mobile
-
-- [ ] 2.65 Test: Sidebar hidden by default on mobile
-- [ ] 2.66 Test: Hamburger shows sidebar on mobile
-- [ ] 2.67 Test: Sidebar overlays content on mobile (not push)
-- [ ] 2.68 Test: Clicking outside closes sidebar on mobile
-- [ ] 2.69 Add backdrop overlay for mobile drawer
-
-## Phase 7: E2E Tests
-
-- [ ] 2.70 E2E test: Login redirects to dashboard with sidebar
-- [ ] 2.71 E2E test: Sidebar toggle works
-- [ ] 2.72 E2E test: Theme toggle works
-- [ ] 2.73 E2E test: Month selector updates period store
-- [ ] 2.74 E2E test: Breadcrumbs reflect current route
-
-## Phase 8: Verification
-
-- [ ] 2.75 Run `npm run check` - no type errors
-- [ ] 2.76 Run `npm run test:unit` - all component tests pass
-- [ ] 2.77 Run `npm run test:e2e` - all E2E tests pass
-- [ ] 2.78 Manual test: All breakpoints (320px, 768px, 1024px, 1280px)
-- [ ] 2.79 Manual test: Dark mode toggle
-- [ ] 2.80 Manual test: Keyboard shortcut (Cmd/Ctrl + \)
-
-## Commits
-
-1. `feat(layout): Create SidebarItem component with active state`
-2. `feat(layout): Create SidebarSection component`
-3. `feat(layout): Create Sidebar with three states and theme toggle`
-4. `feat(layout): Create UserMenu component (migrated from Navigation)`
-5. `feat(layout): Create MonthSelector with period store integration`
-6. `feat(layout): Create Breadcrumbs with auto-generation`
-7. `feat(layout): Create TopBar with all components`
-8. `feat(layout): Create AppLayout wrapper component`
-9. `feat(layout): Integrate AppLayout into root layout`
-10. `feat(layout): Add responsive mobile drawer behavior`
-11. `test(layout): Add component tests for all layout components`
-12. `test(e2e): Add E2E tests for layout functionality`
diff --git a/openspec/config.yaml b/openspec/config.yaml
index acb20e56..ddd09997 100644
--- a/openspec/config.yaml
+++ b/openspec/config.yaml
@@ -196,20 +196,17 @@ rules:
# Component Patterns
component_patterns:
- layout:
- - AppLayout.svelte: Main wrapper with sidebar + content area
- - Sidebar.svelte: Collapsible navigation with sections
- - TopBar.svelte: Breadcrumbs, month selector, user menu
- - Breadcrumbs.svelte: Auto-generated from route
- - PageHeader.svelte: Page title + action buttons slot
- state:
- - layoutStore: sidebarState ('expanded'|'collapsed'|'hidden'), theme
- - periodStore: selectedMonth (global YYYY-MM format)
- - Persist user preferences to localStorage
- navigation:
- - Sections: PLANNING, REPORTS, ADMIN
- - ADMIN section visible only to superuser role
- - Active route highlighting required
+ - "layout: AppLayout.svelte -> Main wrapper with sidebar + content area"
+ - "layout: Sidebar.svelte -> Collapsible navigation with sections"
+ - "layout: TopBar.svelte -> Breadcrumbs, month selector, user menu"
+ - "layout: Breadcrumbs.svelte -> Auto-generated from route"
+ - "layout: PageHeader.svelte -> Page title + action buttons slot"
+ - "state: layoutStore -> sidebarState ('expanded'|'collapsed'|'hidden'), theme"
+ - "state: periodStore -> selectedMonth (global YYYY-MM format)"
+ - "state: Persist user preferences to localStorage"
+ - "navigation: Sections -> PLANNING, REPORTS, ADMIN"
+ - "navigation: ADMIN section visible only to superuser role"
+ - "navigation: Active route highlighting required"
# Accessibility Requirements
accessibility: