Complete UI foundation and app layout implementation, stabilize container health checks, and archive both OpenSpec changes after verification.
270 lines
7.1 KiB
Markdown
270 lines
7.1 KiB
Markdown
# 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
|
|
└── ...
|
|
```
|