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:
2026-02-18 16:12:11 -05:00
parent cdfb15bbfd
commit 493cb78173
47 changed files with 1400 additions and 283 deletions

View 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)
```

View File

@@ -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

View 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`