feat(layout): finalize p01 and p02 changes
Complete UI foundation and app layout implementation, stabilize container health checks, and archive both OpenSpec changes after verification.
This commit is contained in:
269
openspec/changes/archive/2026-02-18-p01-ui-foundation/design.md
Normal file
269
openspec/changes/archive/2026-02-18-p01-ui-foundation/design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Design: UI Foundation
|
||||
|
||||
## Icon Library: Lucide Svelte
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
npm install lucide-svelte
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
```svelte
|
||||
<script>
|
||||
import { Menu, X, Sun, Moon, Home, Users } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<Menu size={20} />
|
||||
<Home size={18} class="text-base-content" />
|
||||
```
|
||||
|
||||
### Icon Mapping for Navigation
|
||||
| Nav Item | Lucide Icon |
|
||||
|-----------------|------------------|
|
||||
| Dashboard | `LayoutDashboard`|
|
||||
| Team Members | `Users` |
|
||||
| Projects | `Folder` |
|
||||
| Allocations | `Calendar` |
|
||||
| Actuals | `CheckCircle` |
|
||||
| Forecast | `TrendingUp` |
|
||||
| Utilization | `BarChart3` |
|
||||
| Costs | `DollarSign` |
|
||||
| Variance | `AlertTriangle` |
|
||||
| Settings | `Settings` |
|
||||
| Master Data | `Database` |
|
||||
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
### `src/lib/types/layout.ts`
|
||||
```typescript
|
||||
export type SidebarState = 'expanded' | 'collapsed' | 'hidden';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string; // Lucide icon name
|
||||
badge?: string | number; // Optional notification badge
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
roles?: string[]; // If set, only visible to these roles
|
||||
}
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stores
|
||||
|
||||
### `src/lib/stores/layout.ts`
|
||||
```typescript
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { SidebarState, Theme } from '$lib/types/layout';
|
||||
|
||||
const DEFAULT_SIDEBAR_STATE: SidebarState = 'expanded';
|
||||
const DEFAULT_THEME: Theme = 'light';
|
||||
|
||||
function createLayoutStore() {
|
||||
// Initialize from localStorage or defaults
|
||||
const getInitialState = (): SidebarState => {
|
||||
if (!browser) return DEFAULT_SIDEBAR_STATE;
|
||||
const stored = localStorage.getItem('headroom_sidebar_state');
|
||||
return (stored as SidebarState) || DEFAULT_SIDEBAR_STATE;
|
||||
};
|
||||
|
||||
const getInitialTheme = (): Theme => {
|
||||
if (!browser) return DEFAULT_THEME;
|
||||
const stored = localStorage.getItem('headroom_theme');
|
||||
if (stored) return stored as Theme;
|
||||
// Respect system preference on first visit
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
};
|
||||
|
||||
const sidebarState = writable<SidebarState>(getInitialState());
|
||||
const theme = writable<Theme>(getInitialTheme());
|
||||
|
||||
// Apply theme to document
|
||||
if (browser) {
|
||||
theme.subscribe((value) => {
|
||||
document.documentElement.setAttribute('data-theme', value);
|
||||
localStorage.setItem('headroom_theme', value);
|
||||
});
|
||||
|
||||
sidebarState.subscribe((value) => {
|
||||
localStorage.setItem('headroom_sidebar_state', value);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarState: { subscribe: sidebarState.subscribe },
|
||||
theme: { subscribe: theme.subscribe },
|
||||
toggleSidebar: () => {
|
||||
sidebarState.update((current) => {
|
||||
if (current === 'expanded') return 'collapsed';
|
||||
if (current === 'collapsed') return 'hidden';
|
||||
return 'expanded';
|
||||
});
|
||||
},
|
||||
setSidebarState: (state: SidebarState) => sidebarState.set(state),
|
||||
toggleTheme: () => {
|
||||
theme.update((current) => (current === 'light' ? 'dark' : 'light'));
|
||||
},
|
||||
setTheme: (newTheme: Theme) => theme.set(newTheme),
|
||||
};
|
||||
}
|
||||
|
||||
export const layoutStore = createLayoutStore();
|
||||
```
|
||||
|
||||
### `src/lib/stores/period.ts`
|
||||
```typescript
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
function createPeriodStore() {
|
||||
const getCurrentMonth = (): string => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getInitialPeriod = (): string => {
|
||||
if (!browser) return getCurrentMonth();
|
||||
const stored = localStorage.getItem('headroom_selected_period');
|
||||
return stored || getCurrentMonth();
|
||||
};
|
||||
|
||||
const selectedPeriod = writable<string>(getInitialPeriod());
|
||||
|
||||
if (browser) {
|
||||
selectedPeriod.subscribe((value) => {
|
||||
localStorage.setItem('headroom_selected_period', value);
|
||||
});
|
||||
}
|
||||
|
||||
// Derived values for convenience
|
||||
const selectedMonth = derived(selectedPeriod, ($period) => {
|
||||
const [year, month] = $period.split('-').map(Number);
|
||||
return { year, month };
|
||||
});
|
||||
|
||||
const selectedDate = derived(selectedPeriod, ($period) => {
|
||||
const [year, month] = $period.split('-').map(Number);
|
||||
return new Date(year, month - 1, 1);
|
||||
});
|
||||
|
||||
return {
|
||||
selectedPeriod: { subscribe: selectedPeriod.subscribe },
|
||||
selectedMonth,
|
||||
selectedDate,
|
||||
setPeriod: (period: string) => selectedPeriod.set(period),
|
||||
previousMonth: () => {
|
||||
selectedPeriod.update((current) => {
|
||||
const [year, month] = current.split('-').map(Number);
|
||||
const date = new Date(year, month - 2, 1);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
},
|
||||
nextMonth: () => {
|
||||
selectedPeriod.update((current) => {
|
||||
const [year, month] = current.split('-').map(Number);
|
||||
const date = new Date(year, month, 1);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
},
|
||||
currentMonth: () => selectedPeriod.set(getCurrentMonth()),
|
||||
};
|
||||
}
|
||||
|
||||
export const periodStore = createPeriodStore();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Configuration
|
||||
|
||||
### `src/lib/config/navigation.ts`
|
||||
```typescript
|
||||
import type { NavSection } from '$lib/types/layout';
|
||||
|
||||
export const navigationSections: NavSection[] = [
|
||||
{
|
||||
title: 'PLANNING',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: 'LayoutDashboard' },
|
||||
{ label: 'Team Members', href: '/team-members', 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Theme CSS
|
||||
|
||||
### Update `src/app.css`
|
||||
```css
|
||||
@import 'tailwindcss';
|
||||
@import 'daisyui';
|
||||
|
||||
/* Theme variables - optional customization */
|
||||
:root {
|
||||
--sidebar-width-expanded: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--topbar-height: 56px;
|
||||
}
|
||||
|
||||
/* Ensure theme attribute works */
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
```
|
||||
src/lib/
|
||||
├── types/
|
||||
│ └── layout.ts # NEW
|
||||
├── stores/
|
||||
│ ├── layout.ts # NEW
|
||||
│ ├── period.ts # NEW
|
||||
│ └── auth.ts # EXISTS
|
||||
├── config/
|
||||
│ └── navigation.ts # NEW
|
||||
└── ...
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
# Proposal: UI Foundation
|
||||
|
||||
## Overview
|
||||
Establish the foundational UI building blocks for Headroom's layout system. This includes types, stores, icon library, and theme configuration that all subsequent UI changes will depend on.
|
||||
|
||||
## Goals
|
||||
- Install and configure Lucide icons for Svelte
|
||||
- Create layout-related TypeScript types
|
||||
- Create layout/period stores for state management
|
||||
- Set up theme system (light/dark mode with persistence)
|
||||
- Define navigation configuration structure
|
||||
|
||||
## Non-Goals
|
||||
- Creating visual components (done in p02-app-layout)
|
||||
- Building actual pages (done in p03+)
|
||||
- API documentation (done in p00-api-documentation)
|
||||
|
||||
## Priority
|
||||
**HIGH** - Foundation for all UI changes (p02-p05 depend on this)
|
||||
|
||||
## Scope
|
||||
|
||||
### Icon Library
|
||||
- Install `lucide-svelte` package
|
||||
- Create icon usage patterns/documentation
|
||||
- Replace existing inline SVGs where applicable
|
||||
|
||||
### Types
|
||||
- `SidebarState` - 'expanded' | 'collapsed' | 'hidden'
|
||||
- `NavItem` - label, href, icon, roles
|
||||
- `NavSection` - title, items, roles (for role-based visibility)
|
||||
|
||||
### Stores
|
||||
- `layoutStore` - sidebar state, theme preference
|
||||
- `periodStore` - global month/period selection
|
||||
|
||||
### Theme System
|
||||
- DaisyUI theme switching via `data-theme` attribute
|
||||
- Light mode default ("light" theme)
|
||||
- Dark mode option ("dark" theme)
|
||||
- Persistence to localStorage
|
||||
- Respect system preference on first visit
|
||||
|
||||
### Navigation Configuration
|
||||
- Centralized navigation structure
|
||||
- Role-based visibility for admin section
|
||||
|
||||
## Success Criteria
|
||||
- [ ] Lucide icons installed and working
|
||||
- [ ] Types defined and exported
|
||||
- [ ] Stores created with localStorage persistence
|
||||
- [ ] Theme toggle functional
|
||||
- [ ] Navigation config exported
|
||||
- [ ] All tests pass
|
||||
|
||||
## Estimated Effort
|
||||
1-2 hours
|
||||
|
||||
## Dependencies
|
||||
- None (foundation change)
|
||||
|
||||
## Blocks
|
||||
- p02-app-layout
|
||||
- p03-dashboard-enhancement
|
||||
- p04-content-patterns
|
||||
- p05-page-migrations
|
||||
@@ -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`
|
||||
476
openspec/changes/archive/2026-02-18-p02-app-layout/design.md
Normal file
476
openspec/changes/archive/2026-02-18-p02-app-layout/design.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Design: App Layout
|
||||
|
||||
## Component Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ COMPONENT HIERARCHY │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ +layout.svelte │
|
||||
│ └── {#if shouldUseAppLayout} │
|
||||
│ └── AppLayout │
|
||||
│ ├── Sidebar │
|
||||
│ │ ├── SidebarHeader (toggle button) │
|
||||
│ │ ├── SidebarSection (×3: Planning, Reports, Admin) │
|
||||
│ │ │ └── SidebarItem (nav links with icons) │
|
||||
│ │ └── SidebarFooter (theme toggle) │
|
||||
│ │ │
|
||||
│ └── div.main-content │
|
||||
│ ├── TopBar │
|
||||
│ │ ├── Breadcrumbs │
|
||||
│ │ ├── MonthSelector │
|
||||
│ │ └── UserMenu │
|
||||
│ │ │
|
||||
│ └── slot (page content) │
|
||||
│ {:else} │
|
||||
│ └── slot (login, public pages) │
|
||||
│ {/if} │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AppLayout Component
|
||||
|
||||
### `src/lib/components/layout/AppLayout.svelte`
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import TopBar from './TopBar.svelte';
|
||||
import { layoutStore } from '$lib/stores/layout';
|
||||
|
||||
let { children }: { children: import('svelte').Snippet } = $props();
|
||||
|
||||
// Responsive: determine initial sidebar state
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
if (isMobile && $layoutStore.sidebarState === 'expanded') {
|
||||
layoutStore.setSidebarState('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-layout flex min-h-screen">
|
||||
<!-- Sidebar -->
|
||||
<Sidebar />
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div
|
||||
class="main-content flex-1 flex flex-col"
|
||||
style="margin-left: var(--sidebar-offset, 0);"
|
||||
>
|
||||
<TopBar />
|
||||
<main class="flex-1 p-4 md:p-6 overflow-auto">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-layout {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Adjust main content based on sidebar state */
|
||||
:global([data-sidebar='expanded']) .main-content {
|
||||
--sidebar-offset: 240px;
|
||||
}
|
||||
|
||||
:global([data-sidebar='collapsed']) .main-content {
|
||||
--sidebar-offset: 64px;
|
||||
}
|
||||
|
||||
:global([data-sidebar='hidden']) .main-content {
|
||||
--sidebar-offset: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:global([data-sidebar]) .main-content {
|
||||
--sidebar-offset: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sidebar Component
|
||||
|
||||
### `src/lib/components/layout/Sidebar.svelte`
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { navigationSections } from '$lib/config/navigation';
|
||||
import { layoutStore } from '$lib/stores/layout';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import SidebarSection from './SidebarSection.svelte';
|
||||
import SidebarItem from './SidebarItem.svelte';
|
||||
import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelLeft,
|
||||
Sun,
|
||||
Moon
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// Check if section should be visible based on roles
|
||||
const isSectionVisible = (section: typeof navigationSections[0]) => {
|
||||
if (!section.roles) return true;
|
||||
return section.roles.includes($authStore.user?.role || '');
|
||||
};
|
||||
|
||||
// Get current path for active highlighting
|
||||
$derived currentPath = $page.url.pathname;
|
||||
|
||||
// Handle keyboard shortcut
|
||||
$effect(() => {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
layoutStore.toggleSidebar();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="sidebar fixed left-0 top-0 h-full z-30 bg-base-200 border-r border-base-300 transition-all duration-200"
|
||||
class:expanded={$layoutStore.sidebarState === 'expanded'}
|
||||
class:collapsed={$layoutStore.sidebarState === 'collapsed'}
|
||||
class:hidden={$layoutStore.sidebarState === 'hidden'}
|
||||
data-sidebar={$layoutStore.sidebarState}
|
||||
>
|
||||
<!-- Header with toggle -->
|
||||
<div class="sidebar-header flex items-center justify-between p-4 border-b border-base-300">
|
||||
{#if $layoutStore.sidebarState === 'expanded'}
|
||||
<span class="font-bold text-lg">Headroom</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={() => layoutStore.toggleSidebar()}
|
||||
aria-label="Collapse sidebar"
|
||||
>
|
||||
<PanelLeftClose size={20} />
|
||||
</button>
|
||||
{:else if $layoutStore.sidebarState === 'collapsed'}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle mx-auto"
|
||||
onclick={() => layoutStore.toggleSidebar()}
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<PanelLeftOpen size={20} />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle mx-auto"
|
||||
onclick={() => layoutStore.setSidebarState('expanded')}
|
||||
aria-label="Show sidebar"
|
||||
>
|
||||
<PanelLeft size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation sections -->
|
||||
<nav class="sidebar-nav flex-1 overflow-y-auto py-2">
|
||||
{#each navigationSections as section (section.title)}
|
||||
{#if isSectionVisible(section)}
|
||||
<SidebarSection {section} expanded={$layoutStore.sidebarState === 'expanded'} />
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Footer with theme toggle -->
|
||||
<div class="sidebar-footer p-4 border-t border-base-300">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm w-full gap-2"
|
||||
onclick={() => layoutStore.toggleTheme()}
|
||||
>
|
||||
{#if $layoutStore.theme === 'light'}
|
||||
<Moon size={18} />
|
||||
{#if $layoutStore.sidebarState === 'expanded'}
|
||||
<span>Dark Mode</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<Sun size={18} />
|
||||
{#if $layoutStore.sidebarState === 'expanded'}
|
||||
<span>Light Mode</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar.hidden {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.expanded,
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(0);
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TopBar Component
|
||||
|
||||
### `src/lib/components/layout/TopBar.svelte`
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Breadcrumbs from './Breadcrumbs.svelte';
|
||||
import MonthSelector from './MonthSelector.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
import { layoutStore } from '$lib/stores/layout';
|
||||
import { Menu } from 'lucide-svelte';
|
||||
|
||||
// Show hamburger on mobile
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
</script>
|
||||
|
||||
<header class="topbar sticky top-0 z-20 bg-base-100 border-b border-base-300 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- Left: Hamburger (mobile) + Breadcrumbs -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle md:hidden"
|
||||
onclick={() => {
|
||||
if ($layoutStore.sidebarState === 'hidden') {
|
||||
layoutStore.setSidebarState('expanded');
|
||||
} else {
|
||||
layoutStore.setSidebarState('hidden');
|
||||
}
|
||||
}}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
<!-- Right: Month selector + User menu -->
|
||||
<div class="flex items-center gap-3">
|
||||
<MonthSelector />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Breadcrumbs Component
|
||||
|
||||
### `src/lib/components/layout/Breadcrumbs.svelte`
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Home } from 'lucide-svelte';
|
||||
|
||||
// Generate breadcrumbs from route
|
||||
$derived crumbs = generateBreadcrumbs($page.url.pathname);
|
||||
|
||||
function generateBreadcrumbs(path: string) {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const crumbs = [{ label: 'Home', href: '/dashboard' }];
|
||||
|
||||
let currentPath = '';
|
||||
for (const segment of segments) {
|
||||
currentPath += `/${segment}`;
|
||||
const label = formatLabel(segment);
|
||||
crumbs.push({ label, href: currentPath });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
}
|
||||
|
||||
function formatLabel(segment: string): string {
|
||||
// Convert 'team-members' to 'Team Members'
|
||||
return segment
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<ul class="flex items-center gap-1 text-sm">
|
||||
{#each crumbs as crumb, i (crumb.href)}
|
||||
<li class="flex items-center gap-1">
|
||||
{#if i > 0}
|
||||
<span class="text-base-content/50">/</span>
|
||||
{/if}
|
||||
{#if i === 0}
|
||||
<a href={crumb.href} class="hover:text-primary">
|
||||
<Home size={16} />
|
||||
</a>
|
||||
{:else if i === crumbs.length - 1}
|
||||
<span class="text-base-content font-medium">{crumb.label}</span>
|
||||
{:else}
|
||||
<a href={crumb.href} class="hover:text-primary text-base-content/70">
|
||||
{crumb.label}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MonthSelector Component
|
||||
|
||||
### `src/lib/components/layout/MonthSelector.svelte`
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { periodStore } from '$lib/stores/period';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
|
||||
// Format for display
|
||||
$derived displayMonth = formatMonth($periodStore.selectedPeriod);
|
||||
|
||||
function formatMonth(period: string): string {
|
||||
const [year, month] = period.split('-');
|
||||
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// Generate month options for dropdown
|
||||
const monthOptions = generateMonthOptions();
|
||||
|
||||
function generateMonthOptions(): string[] {
|
||||
const options: string[] = [];
|
||||
const now = new Date();
|
||||
for (let i = -6; i <= 6; i++) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
||||
options.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="month-selector dropdown dropdown-end">
|
||||
<button class="btn btn-sm btn-ghost gap-1" tabindex="0">
|
||||
<span class="font-medium">{displayMonth}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<ul class="dropdown-content menu bg-base-100 rounded-box z-[1] w-40 p-2 shadow-lg">
|
||||
<li>
|
||||
<button onclick={() => periodStore.previousMonth()}>
|
||||
<ChevronLeft size={16} />
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={() => periodStore.currentMonth()}>
|
||||
Today
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button onclick={() => periodStore.nextMonth()}>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
{#each monthOptions as option}
|
||||
<li>
|
||||
<button
|
||||
class:selected={option === $periodStore.selectedPeriod}
|
||||
onclick={() => periodStore.setPeriod(option)}
|
||||
>
|
||||
{formatMonth(option)}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route Integration
|
||||
|
||||
### Update `src/routes/+layout.svelte`
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import AppLayout from '$lib/components/layout/AppLayout.svelte';
|
||||
import { initAuth } from '$lib/stores/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
initAuth();
|
||||
});
|
||||
|
||||
// Pages that should NOT use AppLayout
|
||||
const publicPages = ['/login', '/auth'];
|
||||
|
||||
$derived shouldUseAppLayout = !publicPages.some(p => $page.url.pathname.startsWith(p));
|
||||
</script>
|
||||
|
||||
{#if shouldUseAppLayout}
|
||||
<AppLayout>
|
||||
{@render children()}
|
||||
</AppLayout>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
| Breakpoint | Sidebar Default | Toggle Behavior |
|
||||
|------------|-----------------|---------------------------|
|
||||
| ≥1280px | expanded | Manual toggle |
|
||||
| 768-1279px | collapsed | Manual toggle |
|
||||
| <768px | hidden | Hamburger → drawer overlay|
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
```
|
||||
src/lib/components/layout/
|
||||
├── AppLayout.svelte # Main wrapper
|
||||
├── Sidebar.svelte # Collapsible sidebar
|
||||
├── SidebarSection.svelte # Section container
|
||||
├── SidebarItem.svelte # Individual nav link
|
||||
├── TopBar.svelte # Top bar
|
||||
├── Breadcrumbs.svelte # Auto-generated breadcrumbs
|
||||
├── MonthSelector.svelte # Period dropdown
|
||||
└── UserMenu.svelte # User dropdown (reuse from Navigation)
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# Proposal: App Layout
|
||||
|
||||
## Overview
|
||||
Create the main application layout components including collapsible sidebar, top bar, and breadcrumbs. This establishes the structural skeleton that all authenticated pages will use.
|
||||
|
||||
## Goals
|
||||
- Create AppLayout component that wraps dashboard pages
|
||||
- Create Sidebar component with three states (expanded, collapsed, hidden)
|
||||
- Create TopBar component with breadcrumbs, month selector, user menu
|
||||
- Create Breadcrumbs component with auto-generation from route
|
||||
- Implement responsive behavior (drawer on mobile)
|
||||
- Update root layout to conditionally use AppLayout
|
||||
|
||||
## Non-Goals
|
||||
- Content components (DataTable, StatCard) - done in p04
|
||||
- Dashboard page implementation - done in p03
|
||||
- Page migrations - done in p05
|
||||
|
||||
## Priority
|
||||
**HIGH** - Required before any page can use new layout
|
||||
|
||||
## Scope
|
||||
|
||||
### AppLayout Component
|
||||
- Wraps Sidebar + Main content area
|
||||
- Handles responsive behavior
|
||||
- Slot for page content
|
||||
- Skip for public pages (login)
|
||||
|
||||
### Sidebar Component
|
||||
- Three visual states: expanded (240px), collapsed (64px), hidden (0px)
|
||||
- Collapsible sections (Planning, Reports, Admin)
|
||||
- Active route highlighting
|
||||
- Role-based visibility (admin section)
|
||||
- Dark mode toggle at bottom
|
||||
- Keyboard shortcut: Cmd/Ctrl + \
|
||||
|
||||
### TopBar Component
|
||||
- Left: Breadcrumbs
|
||||
- Center: Page title (optional)
|
||||
- Right: Month selector, User menu
|
||||
- Mobile: Hamburger toggle
|
||||
- Sticky positioning
|
||||
|
||||
### Breadcrumbs Component
|
||||
- Auto-generate from route path
|
||||
- Home icon for root
|
||||
- DaisyUI breadcrumbs styling
|
||||
|
||||
## Success Criteria
|
||||
- [ ] AppLayout renders correctly
|
||||
- [ ] Sidebar toggles between states
|
||||
- [ ] TopBar displays correctly
|
||||
- [ ] Breadcrumbs auto-generate
|
||||
- [ ] Responsive drawer works on mobile
|
||||
- [ ] Login page exempt from layout
|
||||
- [ ] All tests pass
|
||||
|
||||
## Estimated Effort
|
||||
4-6 hours
|
||||
|
||||
## Dependencies
|
||||
- p01-ui-foundation (types, stores, icons, navigation config)
|
||||
|
||||
## Blocks
|
||||
- p03-dashboard-enhancement
|
||||
- p04-content-patterns
|
||||
- p05-page-migrations
|
||||
132
openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md
Normal file
132
openspec/changes/archive/2026-02-18-p02-app-layout/tasks.md
Normal file
@@ -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`
|
||||
Reference in New Issue
Block a user