Files
headroom/openspec/changes/p02-app-layout/design.md
Santhosh Janardhanan b9cb5170da docs(ui): Add UI layout refactor plan and OpenSpec changes
- 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
2026-02-18 13:03:08 -05:00

477 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```