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
@@ -1,6 +1,6 @@
# Functional Design Plan — Unit 3: Layout & Navigation # Functional Design Plan — Unit 3: Layout & Navigation
**Status**: 📋 Awaiting answers **Status**: ✅ Complete
## Unit Context ## Unit Context
- **Unit**: Unit 3 — Layout & Navigation - **Unit**: Unit 3 — Layout & Navigation
@@ -19,7 +19,7 @@ A) Remove the Topbar entirely — move LanguageSwitcher and UserMenu to the side
B) Keep the Topbar — move LanguageSwitcher and UserMenu to the sidebar footer, leave Topbar as an empty placeholder for future use B) Keep the Topbar — move LanguageSwitcher and UserMenu to the sidebar footer, leave Topbar as an empty placeholder for future use
C) Keep the Topbar with its current content, only add the same items also to the sidebar C) Keep the Topbar with its current content, only add the same items also to the sidebar
[Answer]: [Answer]: A
--- ---
@@ -30,7 +30,7 @@ A) Slide-over overlay — hamburger button in a slim top bar opens the full side
B) Bottom navigation bar — fixed bar at the bottom of the screen with icons only B) Bottom navigation bar — fixed bar at the bottom of the screen with icons only
C) Icon-only sidebar — sidebar collapses to icon-only width on mobile, expands on hover/click C) Icon-only sidebar — sidebar collapses to icon-only width on mobile, expands on hover/click
[Answer]: [Answer]: A
--- ---
@@ -41,7 +41,7 @@ A) Sidebar footer — next to LanguageSwitcher and UserMenu (recommended — kee
B) Top of the sidebar — visible without scrolling B) Top of the sidebar — visible without scrolling
C) Only accessible from the Profile page (not in the sidebar) C) Only accessible from the Profile page (not in the sidebar)
[Answer]: [Answer]: A
--- ---
@@ -52,7 +52,7 @@ A) Inline script in `index.html` — runs synchronously before React loads (reco
B) CSS-only — use `prefers-color-scheme` media query only, no localStorage persistence B) CSS-only — use `prefers-color-scheme` media query only, no localStorage persistence
C) Skip no-flash for now — accept a brief flash on load C) Skip no-flash for now — accept a brief flash on load
[Answer]: [Answer]: A
--- ---
@@ -71,4 +71,4 @@ A) Filter sidebar items client-side based on `user.role` from AuthContext (recom
B) Show all items to all roles, grey out inaccessible ones B) Show all items to all roles, grey out inaccessible ones
C) Show all items, let the route guard handle the redirect on click C) Show all items, let the route guard handle the redirect on click
[Answer]: [Answer]: A
@@ -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` |