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

14 KiB
Raw Blame History

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

<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

<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

<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

<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

<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

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