Files
tessera-ctl/.planning/phases/01-foundation-portal-shell/01-02-PLAN.md
T
schalli fc5a6f3832 docs(01): create phase 1 plans -- walking skeleton + portal shell
Three plans for Foundation & Portal Shell phase:
- 01-01: Monorepo scaffold, Docker Compose with 3-network segmentation, NestJS API, Next.js app, PostgreSQL
- 01-02: Design tokens (yellow #ffed00 primary), i18n (DE/EN), theme switching, responsive portal layout
- 01-03: Visual verification checkpoint for human approval

Includes SKELETON.md documenting architectural decisions for subsequent phases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:37:08 +02:00

348 lines
25 KiB
Markdown

---
phase: 01-foundation-portal-shell
plan: 02
type: execute
wave: 2
depends_on:
- 01-01
files_modified:
- apps/web/package.json
- apps/web/next.config.ts
- apps/web/src/app/globals.css
- apps/web/src/app/layout.tsx
- apps/web/src/app/page.tsx
- apps/web/src/i18n/request.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/lib/stores/sidebar-store.ts
- apps/web/src/components/layout/app-shell.tsx
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar-footer.tsx
- apps/web/src/components/locale-switcher.tsx
- apps/web/src/components/theme-toggle.tsx
autonomous: true
requirements:
- PRTAL-01
- PRTAL-04
- UI-01
- UI-02
- UI-03
must_haves:
truths:
- "User sees a sticky header with logo/branding left, page title center, and user menu area right (D-07, D-08, D-09)"
- "User sees a left sidebar that collapses to icon-width and expands to ~240px (D-01, D-02)"
- "On mobile, sidebar is hidden with a hamburger button to open (D-03)"
- "Sidebar shows Dashboard and Marketplace entries with accordion categories (D-05, D-15)"
- "Sidebar has a footer area with settings icon and user info placeholder (D-06)"
- "User can toggle between light and dark theme and the preference persists across refresh (UI-01)"
- "User can switch between German and English, preference stored in cookie (UI-02)"
- "All visible UI text is rendered via t() -- no hardcoded strings (UI-03)"
- "Main content area shows empty dashboard state with placeholder text per D-16"
- "Layout adapts responsively to different screen sizes (PRTAL-04)"
artifacts:
- path: "apps/web/src/components/layout/header.tsx"
provides: "Sticky header with branding, title, user menu"
min_lines: 30
- path: "apps/web/src/components/layout/sidebar.tsx"
provides: "Collapsible sidebar with accordion categories"
min_lines: 50
- path: "apps/web/src/components/layout/app-shell.tsx"
provides: "Portal layout composition (header + sidebar + main)"
min_lines: 20
- path: "apps/web/src/components/theme-toggle.tsx"
provides: "Light/dark theme toggle button"
min_lines: 15
- path: "apps/web/src/components/locale-switcher.tsx"
provides: "DE/EN language switcher"
min_lines: 15
- path: "apps/web/src/messages/de.json"
provides: "German translations"
contains: "Dashboard"
- path: "apps/web/src/messages/en.json"
provides: "English translations"
contains: "Dashboard"
- path: "apps/web/src/app/globals.css"
provides: "Design tokens with OKLCH colors and dark mode"
contains: "oklch"
- path: "apps/web/src/lib/stores/sidebar-store.ts"
provides: "Sidebar collapse/expand state with persistence"
exports: ["useSidebarStore"]
- path: "apps/web/src/i18n/request.ts"
provides: "Locale resolution from NEXT_LOCALE cookie"
contains: "getRequestConfig"
key_links:
- from: "apps/web/src/app/layout.tsx"
to: "apps/web/src/components/layout/app-shell.tsx"
via: "Component composition"
pattern: "AppShell"
- from: "apps/web/src/app/layout.tsx"
to: "next-themes"
via: "ThemeProvider wrapper"
pattern: "ThemeProvider"
- from: "apps/web/src/app/layout.tsx"
to: "next-intl"
via: "NextIntlClientProvider wrapper"
pattern: "NextIntlClientProvider"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "apps/web/src/lib/stores/sidebar-store.ts"
via: "Zustand store consumption"
pattern: "useSidebarStore"
- from: "apps/web/src/components/locale-switcher.tsx"
to: "NEXT_LOCALE cookie"
via: "document.cookie set"
pattern: "NEXT_LOCALE"
---
## Phase Goal
**As a** user, **I want to** access a running portal application with responsive layout, theme switching, and bilingual interface, **so that** I have the structural frame into which all workflow modules will be placed.
<objective>
Portal Shell: Build the complete portal UI with design token system (yellow #ffed00 primary, dark gray-blue dark mode per D-10, D-14), i18n framework (DE/EN via next-intl cookie-based per UI-02, UI-03), theme switching (light/dark via next-themes per UI-01), and responsive layout (sticky header per D-07/D-08/D-09, collapsible sidebar per D-01 through D-06, main content area with empty dashboard state per D-15/D-16) -- implementing all 17 locked user decisions.
Purpose: After this plan, the user sees and interacts with the full portal frame. They can collapse the sidebar, toggle theme, switch language, and see the empty dashboard -- every visual and interactive element of Phase 1.
Output: Fully styled, responsive, bilingual portal shell with theme support, ready for module content in later phases.
</objective>
<execution_context>
@/home/vicolab/.claude/gsd-core/workflows/execute-plan.md
@/home/vicolab/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation-portal-shell/01-RESEARCH.md
@.planning/phases/01-foundation-portal-shell/01-CONTEXT.md
@.planning/phases/01-foundation-portal-shell/01-01-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Design token system + i18n framework + theme provider setup</name>
<files>
apps/web/package.json
apps/web/next.config.ts
apps/web/src/app/globals.css
apps/web/src/app/layout.tsx
apps/web/src/i18n/request.ts
apps/web/src/messages/de.json
apps/web/src/messages/en.json
</files>
<read_first>
apps/web/package.json (current dependencies from Plan 01)
apps/web/next.config.ts (current config from Plan 01)
apps/web/src/app/layout.tsx (current layout from Plan 01)
apps/web/src/app/globals.css (current CSS from Plan 01)
.planning/phases/01-foundation-portal-shell/01-RESEARCH.md (Pattern 2: next-intl, Pattern 3: Design Tokens, Root Layout with Providers, Message JSON Structure, next.config.ts example)
.planning/phases/01-foundation-portal-shell/01-CONTEXT.md (D-10 through D-14 for colors/style)
</read_first>
<action>
Install additional dependencies in apps/web: `pnpm add next-intl next-themes zustand` (from the web directory or via --filter @tessera/web from root).
Update `apps/web/next.config.ts` to use the next-intl plugin: import createNextIntlPlugin from "next-intl/plugin", call withNextIntl("./src/i18n/request.ts"), wrap the nextConfig (output: "standalone") with it, export default.
Replace `apps/web/src/app/globals.css` with the full design token system from RESEARCH Pattern 3. Include:
- `@import "tailwindcss";` at top
- `@theme inline` block mapping CSS variable names to Tailwind utility classes (--color-primary, --color-primary-foreground, --color-accent, --color-accent-foreground, --color-background, --color-foreground, --color-card, --color-card-foreground, --color-muted, --color-muted-foreground, --color-border, --color-input, --color-ring, --color-sidebar-*, --radius-sm, --radius-md, --radius-lg)
- `:root` block with light mode OKLCH values: --radius 0.5rem (8px per D-13), --primary oklch(0.91 0.19 102) (yellow #ffed00 per D-10), --primary-foreground oklch(0.20 0.02 90) dark text on yellow, --background oklch(0.99 0 0), --foreground oklch(0.15 0 0), plus all card/secondary/muted/accent/border/input/ring tokens
- `.dark` block with dark mode values: --background oklch(0.17 0.01 260) dark gray-blue per D-14 discretion, --foreground oklch(0.95 0 0), --primary stays oklch(0.91 0.19 102) vibrant yellow, all other dark variants as in RESEARCH
- Add --font-sans variable set to "Inter, system-ui, -apple-system, sans-serif" (discretion: Inter for clean modern style)
- Add sidebar-specific tokens: --sidebar-width: 240px (D-02), --sidebar-width-collapsed: 64px, --header-height: 60px (D-08, middle of 56-64px range)
- Add base body styles: font-family var(--font-sans), antialiased
Create `apps/web/src/i18n/request.ts` per RESEARCH Pattern 2: import getRequestConfig from "next-intl/server", import cookies from "next/headers". Export default getRequestConfig that reads NEXT_LOCALE cookie (fallback "de"), dynamically imports messages from ../messages/${locale}.json, returns { locale, messages }.
Create `apps/web/src/messages/de.json` with the full German translation structure from RESEARCH: common (appName, loading, save, cancel), header (search placeholder, userMenu), sidebar (dashboard, marketplace, settings, collapse, expand), dashboard (empty: "Keine Widgets aktiv", addWidget: "Widget hinzufuegen"), theme (light: "Hell", dark: "Dunkel", system: "System"), locale (de: "Deutsch", en: "English"). Add additional keys: sidebar.categories (label), header.breadcrumb (home: "Startseite").
Create `apps/web/src/messages/en.json` with matching English translations: common (appName, loading, save, cancel), header (search, userMenu), sidebar (dashboard, marketplace, settings, collapse, expand), dashboard (empty: "No active widgets", addWidget: "Add widget"), theme (light, dark, system), locale (de: "Deutsch", en: "English"). Mirror all keys from de.json exactly.
Update `apps/web/src/app/layout.tsx` to the full provider-wrapped layout: async function RootLayout, call getLocale() and getMessages() from "next-intl/server". Wrap children in html (lang={locale}, suppressHydrationWarning) > body (className with font-sans, antialiased, bg-background, text-foreground) > ThemeProvider (attribute="class", defaultTheme="system", enableSystem, disableTransitionOnChange) > NextIntlClientProvider (messages={messages}) > {children}. Import globals.css. Export metadata with title "Tessera", description from t or static.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/web 2>&1 | tail -5 && node -e "const de = require('./apps/web/src/messages/de.json'); const en = require('./apps/web/src/messages/en.json'); const deKeys = JSON.stringify(Object.keys(de).sort()); const enKeys = JSON.stringify(Object.keys(en).sort()); if(deKeys !== enKeys) { console.error('MISMATCH:', deKeys, enKeys); process.exit(1); } console.log('i18n keys match')"</automated>
</verify>
<acceptance_criteria>
- apps/web/src/app/globals.css contains @import "tailwindcss" and @theme inline block
- globals.css :root contains --primary with oklch(0.91 0.19 102) value (yellow #ffed00 per D-10)
- globals.css .dark contains --background with oklch(0.17 0.01 260) value (dark gray-blue per D-14)
- globals.css contains --radius: 0.5rem (8px per D-13)
- globals.css contains --sidebar-width: 240px (per D-02)
- globals.css contains --header-height: 60px (per D-08)
- apps/web/src/i18n/request.ts imports getRequestConfig and reads NEXT_LOCALE cookie
- de.json and en.json have identical top-level key structure
- de.json contains "dashboard.empty": "Keine Widgets aktiv" (per D-16 German)
- en.json contains "dashboard.empty": "No active widgets"
- apps/web/src/app/layout.tsx wraps children in ThemeProvider and NextIntlClientProvider
- layout.tsx calls getLocale() and getMessages() from next-intl/server
- layout.tsx html tag has lang={locale} and suppressHydrationWarning
- apps/web/next.config.ts uses createNextIntlPlugin wrapping the config
- `pnpm turbo type-check --filter=@tessera/web` exits with code 0
</acceptance_criteria>
<done>Design token system with OKLCH colors (yellow primary, dark gray-blue dark mode), i18n framework with DE/EN message files and cookie-based locale, and provider-wrapped root layout are all in place and type-check.</done>
</task>
<task type="auto">
<name>Task 2: Portal layout components -- header, sidebar, app shell, interactions</name>
<files>
apps/web/src/lib/stores/sidebar-store.ts
apps/web/src/components/layout/app-shell.tsx
apps/web/src/components/layout/header.tsx
apps/web/src/components/layout/sidebar.tsx
apps/web/src/components/layout/sidebar-footer.tsx
apps/web/src/components/locale-switcher.tsx
apps/web/src/components/theme-toggle.tsx
apps/web/src/app/page.tsx
</files>
<read_first>
apps/web/src/app/layout.tsx (provider structure from Task 1)
apps/web/src/app/globals.css (design tokens and CSS variables from Task 1)
apps/web/src/messages/de.json (translation keys available)
apps/web/src/lib/stores/sidebar-store.ts (if exists, otherwise will create)
.planning/phases/01-foundation-portal-shell/01-CONTEXT.md (ALL decisions D-01 through D-17)
.planning/phases/01-foundation-portal-shell/01-RESEARCH.md (Zustand Sidebar Store example)
</read_first>
<action>
Create `apps/web/src/lib/stores/sidebar-store.ts` per RESEARCH example: Zustand store with persist middleware. Interface SidebarState with isCollapsed (boolean), isMobileOpen (boolean), toggle() (toggles isCollapsed), setMobileOpen(open: boolean). Persist to localStorage key "tessera-sidebar". Default isCollapsed: false, isMobileOpen: false.
Create `apps/web/src/components/theme-toggle.tsx` ("use client"): Import useTheme from "next-themes", useTranslations from "next-intl". Render a button that cycles through light/dark/system themes. Display current theme icon (sun for light, moon for dark, monitor for system) using inline SVG or unicode symbols. Use t("theme.light"), t("theme.dark"), t("theme.system") for aria-label/tooltip. Style with Tailwind: rounded-md (per D-13 border-radius), hover:bg-muted, transition-colors. Include mounted state check to avoid hydration mismatch (common next-themes pattern: const [mounted, setMounted] = useState(false), useEffect to set mounted true, render placeholder while not mounted).
Create `apps/web/src/components/locale-switcher.tsx` ("use client"): Per RESEARCH Pattern 2 locale-switcher example. Import useRouter from "next/navigation", useTranslations from "next-intl", useLocale from "next-intl". Button displays current locale label (t("locale.de") or t("locale.en")). On click: set document.cookie NEXT_LOCALE to opposite locale with path=/ max-age=31536000, then router.refresh() inside startTransition. Style with Tailwind matching the theme-toggle button style.
Create `apps/web/src/components/layout/header.tsx` ("use client"): Per D-07, D-08, D-09. Fixed/sticky header at top. Height var(--header-height) via h-[var(--header-height)] or h-15 (60px). CSS: sticky top-0 z-50 bg-background border-b border-border.
Layout is a flex row with three sections:
- Left: Logo area. Render the Tessera logo text (use t("common.appName")) styled with primary color (text-primary font-bold). Include a mobile hamburger button (visible only on md:hidden) that calls useSidebarStore().setMobileOpen(true).
- Center: Breadcrumb / page title. Render t("header.breadcrumb.home") or a simple "Dashboard" text. Use text-muted-foreground, truncate for overflow.
- Right: Actions area with theme-toggle, locale-switcher, and a user avatar placeholder (circular div with bg-muted, will be wired to auth in Phase 2). Flex row with gap-2.
All strings via useTranslations(). No hardcoded text per UI-03.
Create `apps/web/src/components/layout/sidebar.tsx` ("use client"): Per D-01 through D-06.
Outer container: fixed left-0, top-[var(--header-height)], height calc(100vh - var(--header-height)), bg-background, border-r border-border, transition-all duration-200. Width toggles between var(--sidebar-width) when expanded and var(--sidebar-width-collapsed) when collapsed. Use useSidebarStore() for isCollapsed state.
Mobile behavior (D-03): On screens below md breakpoint, sidebar is position fixed, full height, translated off-screen by default (transform -translate-x-full). When isMobileOpen is true, translate-x-0 with a backdrop overlay (fixed inset-0 bg-black/50 z-40). Tapping overlay calls setMobileOpen(false).
Content structure:
- Top section: Navigation items. Two fixed entries visible in empty state (discretion: Dashboard and Marketplace). Each entry is a flex row with icon (inline SVG or emoji placeholder) + label text. When collapsed (D-01), only icons show (label hidden via overflow-hidden or conditional render). Active item (Dashboard per D-15) has bg-primary/10 text-primary styling.
- Categories section (D-05): An accordion-style expandable section labeled t("sidebar.categories"). When expanded shows "No modules" placeholder. Use a simple disclosure pattern (button toggles visibility of child div) -- no external accordion library needed.
- Toggle button: At bottom of nav section, a button to collapse/expand. Calls useSidebarStore().toggle(). Shows collapse icon (chevron-left) when expanded, expand icon (chevron-right) when collapsed. Label: t("sidebar.collapse") / t("sidebar.expand"), hidden when collapsed.
All strings via useTranslations("sidebar"). No hardcoded text per UI-03.
Create `apps/web/src/components/layout/sidebar-footer.tsx` ("use client"): Per D-06. Footer area at bottom of sidebar. Contains:
- Settings icon (gear/cog) as a button/link. When sidebar collapsed, just the icon. When expanded, icon + t("sidebar.settings") label.
- User info placeholder: small avatar circle + username text (placeholder "User" for now -- wired in Phase 2). When collapsed, just avatar. When expanded, avatar + name.
Flex column with gap-2, border-t border-border, p-3.
Create `apps/web/src/components/layout/app-shell.tsx`: Server component or client component composing the layout. Renders Header at top, Sidebar on left, main content area filling remaining space. Main area: ml-[var(--sidebar-width)] when sidebar expanded, ml-[var(--sidebar-width-collapsed)] when collapsed. On mobile (below md): ml-0 (sidebar overlays). Transition-all for smooth sidebar animation. Padding p-6 on main content. The children prop renders inside main.
Since app-shell needs to read sidebar state for margin calculation, make it "use client" and import useSidebarStore. Alternatively, use CSS-only approach with a data attribute on a parent. Prefer the Zustand approach for consistency.
Update `apps/web/src/app/page.tsx`: The dashboard page. Per D-15 (dashboard is start page) and D-16 (empty state). Render within the app-shell. Display a centered empty state: an icon (layout-grid or similar), t("dashboard.empty") text ("Keine Widgets aktiv" in German), and a button styled with bg-primary text-primary-foreground rounded-md (per D-13) showing t("dashboard.addWidget"). Button is non-functional for now (dashboard implementation is Phase 5). Use useTranslations("dashboard").
Wire everything together: In layout.tsx (from Task 1), the children are wrapped by providers. In page.tsx, render AppShell wrapping the dashboard content. AppShell renders Header, Sidebar (with SidebarFooter inside), and main area with {children}.
Responsive behavior (PRTAL-04): Test at Tailwind breakpoints. Below md (~768px): sidebar hidden, hamburger visible in header. Above md: sidebar visible, hamburger hidden. Above lg (~1024px): full sidebar width.
Visual style (D-12): Modern/clean with whitespace. Use shadow-sm on header (subtle shadow per D-12). Cards and containers use rounded-lg (per D-13). Spacing generous (p-4, gap-4 minimum).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm turbo type-check --filter=@tessera/web 2>&1 | tail -5 && grep -r "\"use client\"" apps/web/src/components/ --include="*.tsx" -l | wc -l && grep -rn ">[A-Z][a-z]" apps/web/src/components/ apps/web/src/app/page.tsx --include="*.tsx" | grep -v "import\|from\|//\|className\|type\|interface\|export\|const\|SVG\|path\|svg\|function\|return\|useState\|useEffect\|use client\|next" | head -20</automated>
</verify>
<acceptance_criteria>
- apps/web/src/lib/stores/sidebar-store.ts exports useSidebarStore with isCollapsed, isMobileOpen, toggle, setMobileOpen
- sidebar-store.ts uses persist middleware with key "tessera-sidebar"
- apps/web/src/components/layout/header.tsx is a "use client" component
- header.tsx has sticky positioning (className contains "sticky" and "top-0")
- header.tsx contains three sections: logo/branding left, breadcrumb center, actions right
- header.tsx includes hamburger button visible only on mobile (md:hidden class)
- header.tsx renders ThemeToggle and LocaleSwitcher components
- apps/web/src/components/layout/sidebar.tsx reads isCollapsed from useSidebarStore
- sidebar.tsx implements width transition between expanded (~240px) and collapsed (~64px) states
- sidebar.tsx shows Dashboard entry with active styling (bg-primary or similar)
- sidebar.tsx shows Marketplace entry
- sidebar.tsx has accordion-style categories section per D-05
- sidebar.tsx on mobile uses translate-x transform with backdrop overlay
- apps/web/src/components/layout/sidebar-footer.tsx renders settings icon and user placeholder
- apps/web/src/components/layout/app-shell.tsx composes Header, Sidebar, and main content area
- app-shell.tsx adjusts main content margin based on sidebar collapse state
- apps/web/src/app/page.tsx shows empty dashboard state with t("dashboard.empty") and t("dashboard.addWidget")
- apps/web/src/components/theme-toggle.tsx uses useTheme() from next-themes with mounted guard
- apps/web/src/components/locale-switcher.tsx sets NEXT_LOCALE cookie and calls router.refresh()
- All component text uses useTranslations() calls -- no hardcoded user-visible strings (UI-03)
- `pnpm turbo type-check --filter=@tessera/web` exits with code 0
</acceptance_criteria>
<done>Complete portal shell with sticky header (logo, breadcrumb, theme/locale/user), collapsible sidebar (nav items, accordion categories, footer with settings), responsive layout (mobile hamburger + overlay), empty dashboard state, theme toggle persisting to localStorage, and locale switcher persisting to cookie -- all text via i18n framework.</done>
</task>
</tasks>
## Artifacts This Phase Produces
| Symbol/File | Type | Consumed By |
|---|---|---|
| `useSidebarStore` | Zustand store | sidebar.tsx, app-shell.tsx, header.tsx (hamburger) |
| `apps/web/src/components/layout/header.tsx` | React component | app-shell.tsx |
| `apps/web/src/components/layout/sidebar.tsx` | React component | app-shell.tsx |
| `apps/web/src/components/layout/sidebar-footer.tsx` | React component | sidebar.tsx |
| `apps/web/src/components/layout/app-shell.tsx` | React component | page.tsx (and all future pages) |
| `apps/web/src/components/theme-toggle.tsx` | React component | header.tsx |
| `apps/web/src/components/locale-switcher.tsx` | React component | header.tsx |
| `apps/web/src/i18n/request.ts` | i18n config | next.config.ts plugin |
| `apps/web/src/messages/de.json` | German translations | All components via t() |
| `apps/web/src/messages/en.json` | English translations | All components via t() |
| `apps/web/src/app/globals.css` | Design token system | All styled components |
| CSS variables: `--primary`, `--sidebar-width`, `--header-height` | Design tokens | All layout components |
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Cookie (NEXT_LOCALE) | Client-set locale cookie consumed server-side |
| localStorage (tessera-sidebar, theme) | Client-persisted UI state |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Tampering | NEXT_LOCALE cookie | accept | Cookie only controls UI language (de/en). Invalid values fall back to "de". No security impact. |
| T-02-02 | Tampering | localStorage sidebar state | accept | Controls only UI collapse state. No data or auth implications. |
| T-02-03 | Information Disclosure | SSR-rendered locale/theme | accept | No sensitive data in theme/locale preferences |
| T-02-SC | Tampering | npm installs (next-intl, next-themes, zustand) | mitigate | All packages pass legitimacy audit in RESEARCH.md with OK verdict |
</threat_model>
<verification>
1. `pnpm turbo type-check --filter=@tessera/web` passes
2. `pnpm turbo build --filter=@tessera/web` completes successfully
3. Docker rebuild and `docker compose up` serves the portal shell at localhost:80
4. Clicking theme toggle cycles light/dark/system -- preference survives page refresh
5. Clicking locale switcher changes all text between DE and EN -- preference survives page refresh
6. Clicking sidebar collapse button animates sidebar to icon width
7. On narrow viewport (< 768px), sidebar is hidden and hamburger button appears in header
8. `grep -rn ">[A-Z]" apps/web/src/components/ --include="*.tsx"` returns no hardcoded user-visible strings
</verification>
<success_criteria>
- Portal shell renders with header (sticky, ~60px, logo + breadcrumb + actions)
- Sidebar collapses from 240px to 64px icon-width with smooth transition
- Mobile sidebar overlays with backdrop on hamburger click
- Theme toggle persists dark/light preference across browser refresh
- Language switch changes all UI text between German and English
- Empty dashboard shows "Keine Widgets aktiv" (DE) / "No active widgets" (EN) with add button
- All user-visible text comes from i18n message files (no hardcoded strings)
- Design uses yellow #ffed00 primary, dark gray-blue dark mode, 8px border-radius
- Modern/clean visual style with whitespace, subtle shadows per D-12
</success_criteria>
<output>
Create `.planning/phases/01-foundation-portal-shell/01-02-SUMMARY.md` when done
</output>