- Update decision-log with UI layout decisions (Feb 18, 2026) - Update architecture with frontend layout patterns - Update config.yaml with TDD, documentation, UI standards rules - Create p00-api-documentation change (Scribe annotations) - Create p01-ui-foundation change (types, stores, Lucide) - Create p02-app-layout change (AppLayout, Sidebar, TopBar) - Create p03-dashboard-enhancement change (PageHeader, StatCard) - Create p04-content-patterns change (DataTable, FilterBar) - Create p05-page-migrations change (page migrations) - Fix E2E auth tests (11/11 passing) - Add JWT expiry validation to dashboard guard
477 lines
14 KiB
Markdown
477 lines
14 KiB
Markdown
# 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)
|
||
```
|