docs(unit-3): Complete functional design based on user answers

All Q1-Q5 answered A:
- Topbar removed; LanguageSwitcher + UserMenu move to sidebar footer
- Mobile: slide-over overlay with hamburger button
- Theme toggle in sidebar footer
- No-flash init via inline script in index.html
- Role-filtered sidebar items client-side

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 11:51:24 +02:00
co-authored by Claude Haiku 4.5
parent 037332e52f
commit 6760257e49
5 changed files with 364 additions and 6 deletions
@@ -0,0 +1,74 @@
# Business Logic Model — Unit 3: Layout & Navigation
## Component Hierarchy
```
AppLayout
├── Sidebar desktop: always visible (md+)
│ ├── SidebarHeader (logo + app name)
│ ├── NavList
│ │ └── NavItem × N filtered by user.role
│ └── SidebarFooter
│ ├── LanguageSwitcher moved from Topbar
│ ├── ThemeToggle new
│ └── UserMenu moved from Topbar
├── MobileBar mobile only (< md)
│ └── hamburger button → opens SidebarOverlay
├── SidebarOverlay rendered only when mobile menu is open
│ ├── backdrop (closes on click)
│ └── Sidebar (with onClose prop)
└── <main>
└── <Outlet />
```
## NavItem Filtering
```ts
const visibleItems = NAV_ITEMS.filter(item =>
!item.roles || item.roles.includes(user.role)
);
```
`item.roles === undefined` means visible to all authenticated users. Evaluated at render time.
## Mobile Overlay State
`isMenuOpen: boolean` state lives in `AppLayout`. Passed as:
- `onMenuOpen` to `MobileBar`
- `onClose` to `SidebarOverlay` → forwarded as `onClose` to `Sidebar`
Closes automatically on route change via `useEffect` watching the current pathname.
## Theme Initialisation (no flash)
A blocking inline `<script>` in `index.html` runs synchronously before React loads:
```html
<script>
(function() {
var stored = localStorage.getItem('cms-theme');
var theme = stored
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.add(theme);
})();
</script>
```
The `useTheme` hook reads from `localStorage` on mount and keeps toggle in sync with the DOM class.
## Theme Toggle Logic (`useTheme`)
```ts
function toggleTheme() {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem('cms-theme', next);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(next);
}
```
## Logout Flow (unchanged)
`UserMenu` in sidebar footer calls `useAuth().logout()` then `navigate({ to: '/login' })`.
@@ -0,0 +1,56 @@
# Business Rules — Unit 3: Layout & Navigation
## Navigation Visibility Rules (BR-U3-01 BR-U3-06)
| Route / Item | Owner | Administrator | User | Rule ID |
|---|---|---|---|---|
| Dashboard (`/dashboard`) | ✅ visible | ✅ visible | ✅ visible | BR-U3-01 |
| User Management (`/users`) | ✅ visible | ✅ visible | ❌ hidden | BR-U3-02 |
| System Settings (`/settings`) | ✅ visible | ❌ hidden | ❌ hidden | BR-U3-03 |
| CMS Management (`/cms`) | ✅ visible | ❌ hidden | ❌ hidden | BR-U3-04 |
| Profile (`/profile`) | ✅ visible | ✅ visible | ✅ visible | BR-U3-05 |
| Logout | ✅ visible | ✅ visible | ✅ visible | BR-U3-06 |
Sidebar items are filtered client-side based on `user.role` from `AuthContext`. The backend is the authoritative enforcement point; sidebar filtering is defence-in-depth UX only.
---
## Active Route Highlighting (BR-U3-07)
The sidebar highlights the nav item whose `to` path matches the current route via TanStack Router `activeProps`. Exact match for leaf routes; prefix match for section roots.
---
## Responsive Behaviour (BR-U3-08)
- **≥ 768px (md)**: sidebar always visible at fixed width (`w-64`), no top bar.
- **< 768px**: sidebar hidden by default. A slim mobile bar (hamburger + app name) is shown. Tapping the hamburger opens the sidebar as a slide-over overlay.
- The overlay closes when: the user taps the backdrop, taps the close button in the sidebar, or navigates to a new route.
---
## Topbar Removal (BR-U3-09)
The `Topbar` component is removed entirely. No top bar exists on desktop. A slim mobile-only bar replaces it solely for the hamburger button. All functionality previously in the Topbar (`LanguageSwitcher`, `UserMenu`) moves to the sidebar footer.
---
## Theme Toggle (BR-U3-10 BR-U3-13)
| Rule | Description |
|---|---|
| BR-U3-10 | Toggle switches between `light` and `dark` class on `<html>` immediately |
| BR-U3-11 | Chosen theme persisted in `localStorage` under key `cms-theme` |
| BR-U3-12 | On app load, persisted preference applied before first React render via inline script in `index.html` (no flash) |
| BR-U3-13 | If no preference is stored, OS preference (`prefers-color-scheme`) is used as default |
Only `cms-theme` is stored in localStorage — no auth data.
---
## Sidebar Footer (BR-U3-14)
The sidebar footer contains three controls in a fixed bottom section:
1. `LanguageSwitcher` (moved from Topbar)
2. `ThemeToggle` (new)
3. `UserMenu` (moved from Topbar) — shows name, email, logout action
@@ -0,0 +1,37 @@
# Domain Entities — Unit 3: Layout & Navigation
## NavItem
Represents a single entry in the sidebar navigation. Defined as a constant array in `Sidebar.tsx`.
| Field | Type | Description |
|---|---|---|
| `to` | `string` | TanStack Router route path |
| `labelKey` | `string` | i18n key for the display label |
| `icon` | `LucideIcon` | Icon component from lucide-react |
| `testId` | `string` | `data-testid` value |
| `roles` | `Role[] \| undefined` | Roles that can see this item; `undefined` = all authenticated roles |
## Role (from AuthContext — existing)
```ts
type Role = 'Owner' | 'Administrator' | 'User';
```
Sourced from `useAuth().user.role`. Used by `Sidebar` to filter `NavItem[]`.
## Theme
```ts
type Theme = 'light' | 'dark';
```
Persisted in `localStorage` under key `cms-theme`. Applied as class on `<html>`.
## SupportedLanguage (existing, Unit 1)
```ts
type SupportedLanguage = 'en' | 'nl';
```
No changes — `changeLanguage` from `@/i18n/config` is reused as-is.
@@ -0,0 +1,191 @@
# Frontend Components — Unit 3: Layout & Navigation
## Modified Components
### `AppLayout.tsx` (modify)
**Path**: `frontend/src/components/layout/AppLayout.tsx`
Remove `Topbar`. Add `MobileBar` and `SidebarOverlay`. Manage `isMenuOpen` state.
```tsx
<div className="flex min-h-svh">
<Sidebar className="hidden md:flex" />
{isMenuOpen && <SidebarOverlay onClose={() => setIsMenuOpen(false)} />}
<div className="flex flex-1 flex-col">
<MobileBar onMenuOpen={() => setIsMenuOpen(true)} />
<main className="flex-1 p-6" data-testid="app-main">
<Outlet />
</main>
</div>
</div>
```
---
### `Sidebar.tsx` (modify)
**Path**: `frontend/src/components/layout/Sidebar.tsx`
Changes:
- Add `roles?: Role[]` to `NavItem` — filter visible items by `user.role`
- Add `SidebarFooter` section at the bottom with `LanguageSwitcher`, `ThemeToggle`, `UserMenu`
- Accept optional `onClose?: () => void` prop (used by mobile overlay close button)
- Retain `data-testid="app-sidebar"`
Nav items and role visibility:
| Label key | Route | Roles | Icon |
|---|---|---|---|
| `nav.dashboard` | `/dashboard` | all | `LayoutDashboard` |
| `nav.users` | `/users` | Owner, Administrator | `Users` |
| `nav.settings` | `/settings` | Owner | `Settings` |
| `nav.cms` | `/cms` | Owner | `FileText` |
| `nav.profile` | `/profile` | all | `User` |
---
## New Components
### `MobileBar.tsx`
**Path**: `frontend/src/components/layout/MobileBar.tsx`
Visible only on `< md`. Contains hamburger button and app name.
Props: `onMenuOpen: () => void`
```tsx
<header
className="flex h-14 items-center gap-3 border-b border-border bg-card px-4 md:hidden"
data-testid="app-mobile-bar"
>
<button onClick={onMenuOpen} aria-label={t('nav.openMenu')} data-testid="mobile-menu-button">
<Menu className="size-5" />
</button>
<span className="font-semibold">{t('common.appName')}</span>
</header>
```
---
### `SidebarOverlay.tsx`
**Path**: `frontend/src/components/layout/SidebarOverlay.tsx`
Full-screen overlay wrapping `Sidebar` for mobile. Backdrop closes it on click.
```tsx
<div className="fixed inset-0 z-40 md:hidden" data-testid="sidebar-overlay">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
data-testid="sidebar-backdrop"
/>
<div className="absolute left-0 top-0 h-full w-64 shadow-xl">
<Sidebar onClose={onClose} />
</div>
</div>
```
Props: `onClose: () => void`
---
### `ThemeToggle.tsx`
**Path**: `frontend/src/components/layout/ThemeToggle.tsx`
Toggles dark/light mode. Uses `useTheme()` hook.
```tsx
<button
onClick={toggleTheme}
aria-label={isDark ? t('theme.switchToLight') : t('theme.switchToDark')}
data-testid="theme-toggle"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
```
---
### `useTheme.ts`
**Path**: `frontend/src/hooks/useTheme.ts`
```ts
function useTheme(): { theme: Theme; isDark: boolean; toggleTheme: () => void }
```
Reads initial value from `localStorage['cms-theme']` or `prefers-color-scheme`. Syncs DOM class on `<html>`.
---
## Removed Components
### `Topbar.tsx`
Deleted. `data-testid="app-topbar"` removed from the DOM.
---
## `index.html` Change
Add no-flash inline script in `<head>` before any stylesheet:
```html
<script>
(function(){
var s=localStorage.getItem('cms-theme');
var t=s?s:(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');
document.documentElement.classList.add(t);
})();
</script>
```
---
## i18n Additions
**`nl/translation.json`**:
```json
{
"nav": {
"settings": "Instellingen",
"profile": "Profiel",
"openMenu": "Navigatie openen"
},
"theme": {
"switchToLight": "Overschakelen naar licht thema",
"switchToDark": "Overschakelen naar donker thema"
}
}
```
**`en/translation.json`**:
```json
{
"nav": {
"settings": "Settings",
"profile": "Profile",
"openMenu": "Open navigation"
},
"theme": {
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
}
}
```
---
## Test IDs Summary
| Element | `data-testid` |
|---|---|
| Sidebar | `app-sidebar` |
| Mobile bar | `app-mobile-bar` |
| Hamburger button | `mobile-menu-button` |
| Sidebar overlay container | `sidebar-overlay` |
| Sidebar backdrop | `sidebar-backdrop` |
| Theme toggle | `theme-toggle` |
| Nav: dashboard | `nav-dashboard` |
| Nav: users | `nav-users` |
| Nav: settings | `nav-settings` |
| Nav: cms | `nav-cms` |
| Nav: profile | `nav-profile` |
| Main content area | `app-main` |