--- 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. 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. @/home/vicolab/.claude/gsd-core/workflows/execute-plan.md @/home/vicolab/.claude/gsd-core/templates/summary.md @.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 Task 1: Design token system + i18n framework + theme provider setup 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 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) 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. 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')" - 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 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. Task 2: Portal layout components -- header, sidebar, app shell, interactions 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 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) 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). 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 - 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 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. ## 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 | ## 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 | 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 - 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 Create `.planning/phases/01-foundation-portal-shell/01-02-SUMMARY.md` when done