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>
This commit is contained in:
2026-06-18 09:37:08 +02:00
parent 5415d552bb
commit fc5a6f3832
5 changed files with 925 additions and 3 deletions
@@ -0,0 +1,354 @@
---
phase: 01-foundation-portal-shell
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- pnpm-workspace.yaml
- turbo.json
- tsconfig.base.json
- biome.json
- .gitignore
- .env
- .env.example
- .dockerignore
- docker-compose.yml
- docker-compose.dev.yml
- packages/shared/package.json
- packages/shared/tsconfig.json
- packages/shared/src/index.ts
- apps/api/package.json
- apps/api/tsconfig.json
- apps/api/src/main.ts
- apps/api/src/app.module.ts
- apps/api/src/health/health.module.ts
- apps/api/src/health/health.controller.ts
- apps/api/prisma/schema.prisma
- apps/api/Dockerfile
- apps/web/package.json
- apps/web/tsconfig.json
- apps/web/next.config.ts
- apps/web/postcss.config.mjs
- apps/web/src/app/layout.tsx
- apps/web/src/app/page.tsx
- apps/web/src/app/globals.css
- apps/web/Dockerfile
autonomous: true
requirements:
- INFRA-01
- INFRA-02
- INFRA-03
must_haves:
truths:
- "docker compose up starts all services without errors"
- "Browser request to localhost:80 returns a rendered Next.js page"
- "GET http://localhost:80/api/health returns JSON with status ok"
- "PostgreSQL container passes pg_isready health check"
- "Next.js container cannot directly reach PostgreSQL (network segmentation)"
artifacts:
- path: "docker-compose.yml"
provides: "Multi-service orchestration with 3 networks"
contains: "internal: true"
- path: "apps/api/src/health/health.controller.ts"
provides: "Health check endpoint"
exports: ["HealthController"]
- path: "apps/web/src/app/page.tsx"
provides: "Root page rendering"
min_lines: 5
- path: "pnpm-workspace.yaml"
provides: "Monorepo workspace definition"
contains: "apps/*"
- path: "apps/api/prisma/schema.prisma"
provides: "Database schema foundation"
contains: "datasource db"
key_links:
- from: "docker-compose.yml"
to: "apps/api/Dockerfile"
via: "build context"
pattern: "build.*apps/api"
- from: "docker-compose.yml"
to: "apps/web/Dockerfile"
via: "build context"
pattern: "build.*apps/web"
- from: "apps/api/src/main.ts"
to: "apps/api/src/app.module.ts"
via: "NestFactory.create"
pattern: "NestFactory\\.create.*AppModule"
---
## 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>
Walking Skeleton: Establish the complete Docker Compose infrastructure with pnpm monorepo, NestJS API (health endpoint + Prisma), Next.js frontend (minimal page), PostgreSQL database, and Traefik reverse proxy -- all connected via three segregated Docker networks per INFRA-01, INFRA-02, INFRA-03.
Purpose: Prove the full stack works end-to-end. After this plan, `docker compose up` serves a web page via Traefik, the API responds, and PostgreSQL is healthy -- the foundation every subsequent phase builds upon.
Output: Running Docker Compose stack accessible at localhost:80, monorepo with build tooling, both apps containerized.
</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
</context>
<tasks>
<task type="auto">
<name>Task 1: Monorepo scaffold with root configs and shared package</name>
<files>
package.json
pnpm-workspace.yaml
turbo.json
tsconfig.base.json
biome.json
.gitignore
.env
.env.example
.dockerignore
packages/shared/package.json
packages/shared/tsconfig.json
packages/shared/src/index.ts
</files>
<read_first>
.planning/phases/01-foundation-portal-shell/01-RESEARCH.md (Pattern 6: Turborepo Configuration, Project Structure, Installation section)
</read_first>
<action>
Initialize the monorepo root. Run `corepack enable && corepack prepare pnpm@9 --activate` to enable pnpm.
Create root `package.json` with:
- `"name": "tessera"`, `"private": true`
- `"packageManager": "pnpm@9.15.0"`
- `"scripts"`: `"dev": "turbo dev"`, `"build": "turbo build"`, `"lint": "turbo lint"`, `"type-check": "turbo type-check"`
- devDependencies: `turbo`, `@biomejs/biome`, `typescript`
Create `pnpm-workspace.yaml` with packages: `["apps/*", "packages/*"]`.
Create `turbo.json` per RESEARCH Pattern 6: tasks for build (dependsOn ^build, outputs dist/**/**.next/**), dev (cache false, persistent true), lint (outputs []), type-check (dependsOn ^build, outputs []).
Create `tsconfig.base.json` with compilerOptions: strict true, esModuleInterop true, skipLibCheck true, forceConsistentCasingInFileNames true, resolveJsonModule true, isolatedModules true, moduleResolution "bundler", module "ESNext", target "ES2022", declaration true, declarationMap true, sourceMap true, composite false.
Create `biome.json` with $schema, organizeImports enabled, formatter (indentStyle "space", indentWidth 2, lineWidth 100), linter enabled with recommended rules.
Create `.gitignore` covering: node_modules, .next, dist, .turbo, .env (NOT .env.example), *.log, .DS_Store, coverage, .pnpm-store.
Create `.env` with DB_PASSWORD=tessera_dev, DATABASE_URL=postgresql://tessera:tessera_dev@db:5432/tessera, NODE_ENV=development. Create `.env.example` as template (same keys, placeholder values).
Create `.dockerignore` excluding: node_modules, .next, dist, .turbo, .git, .env, *.md, coverage.
Create `packages/shared/package.json` with name "@tessera/shared", version "0.0.1", main "src/index.ts", types "src/index.ts", scripts (type-check: tsc --noEmit), devDependencies typescript. Create `packages/shared/tsconfig.json` extending ../../tsconfig.base.json with include ["src"]. Create `packages/shared/src/index.ts` exporting a simple APP_NAME constant "Tessera" and a HealthResponse type interface with status string and timestamp string fields.
Run `pnpm install` at root to generate lockfile.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && test -f pnpm-lock.yaml && test -f turbo.json && test -f packages/shared/src/index.ts && pnpm turbo type-check --filter=@tessera/shared 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- pnpm-lock.yaml exists at root (proves pnpm install succeeded)
- turbo.json contains "tasks" key with "build", "dev", "lint", "type-check"
- packages/shared/src/index.ts exports APP_NAME and HealthResponse interface
- `pnpm turbo type-check --filter=@tessera/shared` exits with code 0
- tsconfig.base.json has "strict": true
- biome.json has "formatter" and "linter" sections
- .env contains DATABASE_URL with postgresql:// prefix
</acceptance_criteria>
<done>Monorepo root with pnpm workspace, Turborepo, Biome, TypeScript base config, and shared package all type-check successfully.</done>
</task>
<task type="auto">
<name>Task 2: NestJS API with health endpoint and Prisma schema</name>
<files>
apps/api/package.json
apps/api/tsconfig.json
apps/api/nest-cli.json
apps/api/src/main.ts
apps/api/src/app.module.ts
apps/api/src/health/health.module.ts
apps/api/src/health/health.controller.ts
apps/api/prisma/schema.prisma
apps/api/Dockerfile
</files>
<read_first>
packages/shared/src/index.ts (HealthResponse type)
.planning/phases/01-foundation-portal-shell/01-RESEARCH.md (Pattern 5: NestJS Docker Build, NestJS Health Endpoint, Standard Stack)
</read_first>
<action>
Create `apps/api/package.json` with name "@tessera/api", scripts: "build": "nest build", "start": "node dist/main.js", "start:dev": "nest start --watch", "type-check": "tsc --noEmit". Dependencies: @nestjs/core@^11, @nestjs/common@^11, @nestjs/platform-express@^11, @nestjs/config@^4, rxjs@^7, reflect-metadata@^0.2, @prisma/client@^7. DevDependencies: prisma@^7, @nestjs/cli@^11, typescript@^5.5, @types/node@^22, @types/express@^5.
Create `apps/api/tsconfig.json` extending ../../tsconfig.base.json with compilerOptions: module "commonjs", target "ES2021", outDir "./dist", rootDir "./src", emitDecoratorMetadata true, experimentalDecorators true. Include ["src/**/*"], exclude ["node_modules", "dist"].
Create `apps/api/nest-cli.json` with $schema, collection @nestjs/schematics, sourceRoot "src", compilerOptions deleteOutDir true.
Create `apps/api/src/main.ts`: import NestFactory from @nestjs/core, import AppModule. Create app with NestFactory.create(AppModule), enable CORS (origin: true for dev), listen on port 3001. Log "Tessera API running on port 3001".
Create `apps/api/src/app.module.ts`: NestJS module importing ConfigModule.forRoot() (isGlobal: true) and HealthModule.
Create `apps/api/src/health/health.module.ts`: NestJS module declaring and exporting HealthController.
Create `apps/api/src/health/health.controller.ts`: Controller with prefix "health", single GET handler returning { status: "ok", timestamp: new Date().toISOString() } matching the HealthResponse interface from @tessera/shared (import the type for documentation but return plain object -- NestJS serializes it).
Create `apps/api/prisma/schema.prisma`: datasource db (provider "postgresql", url env("DATABASE_URL")), generator client (provider "prisma-client-js"). Add a minimal Tenant model with id (uuid, default uuid()), name (String), slug (String, unique), createdAt (DateTime, default now()), updatedAt (DateTime, updatedAt) -- this seeds the multi-tenancy foundation.
Create `apps/api/Dockerfile` per RESEARCH Pattern 5: multi-stage (base with corepack/pnpm, deps stage, builder stage running pnpm build, runner stage as non-root user "nestjs" exposing port 3001, CMD node dist/main.js). Use monorepo root as build context -- COPY the full workspace, install, then build only api.
Actually for the Dockerfile, since we use monorepo root context: COPY root package.json, pnpm-workspace.yaml, pnpm-lock.yaml, then apps/api/ and packages/shared/, then pnpm install --frozen-lockfile --filter=@tessera/api..., then pnpm --filter=@tessera/api build.
Run `cd apps/api && pnpm install` (from monorepo root `pnpm install` should suffice since workspace).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm install && pnpm turbo type-check --filter=@tessera/api 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- apps/api/src/main.ts imports NestFactory and calls listen(3001)
- apps/api/src/health/health.controller.ts has @Controller('health') and @Get() decorators
- apps/api/prisma/schema.prisma contains "datasource db" with provider "postgresql"
- apps/api/prisma/schema.prisma contains "model Tenant" with id, name, slug fields
- apps/api/Dockerfile has USER nestjs and EXPOSE 3001
- `pnpm turbo type-check --filter=@tessera/api` exits with code 0
- apps/api/package.json has @nestjs/core and @prisma/client in dependencies
</acceptance_criteria>
<done>NestJS API compiles, has a /health endpoint returning {status, timestamp}, Prisma schema defines PostgreSQL datasource with Tenant model, and Dockerfile is ready for multi-stage build.</done>
</task>
<task type="auto">
<name>Task 3: Next.js app + Docker Compose stack with network segmentation</name>
<files>
apps/web/package.json
apps/web/tsconfig.json
apps/web/next.config.ts
apps/web/postcss.config.mjs
apps/web/src/app/layout.tsx
apps/web/src/app/page.tsx
apps/web/src/app/globals.css
apps/web/Dockerfile
docker-compose.yml
docker-compose.dev.yml
</files>
<read_first>
apps/api/Dockerfile (to understand build context pattern)
.planning/phases/01-foundation-portal-shell/01-RESEARCH.md (Pattern 1: Docker Network Segmentation, Pattern 4: Next.js Docker Build, docker-compose.yml example)
</read_first>
<action>
Create `apps/web/package.json` with name "@tessera/web", scripts: "dev": "next dev --turbopack", "build": "next build", "start": "next start", "type-check": "tsc --noEmit". Dependencies: next@^16, react@^19, react-dom@^19. DevDependencies: tailwindcss@^4, @tailwindcss/postcss@^4, postcss@^8, typescript@^5.5, @types/react@^19, @types/react-dom@^19.
Create `apps/web/tsconfig.json` extending ../../tsconfig.base.json with compilerOptions: jsx "preserve", lib ["dom", "dom.iterable", "esnext"], module "esnext", moduleResolution "bundler", allowJs true, noEmit true, incremental true, plugins [{ name: "next" }], paths {"@/*": ["./src/*"]}. Include ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"].
Create `apps/web/next.config.ts` with output: "standalone" as const. Minimal for now -- next-intl plugin added in Plan 02.
Create `apps/web/postcss.config.mjs` exporting plugins: {"@tailwindcss/postcss": {}}.
Create `apps/web/src/app/globals.css` with just `@import "tailwindcss";` -- full design tokens added in Plan 02.
Create `apps/web/src/app/layout.tsx`: basic RootLayout with html (lang="de"), body with className for basic font. Import globals.css. Export metadata with title "Tessera" and description.
Create `apps/web/src/app/page.tsx`: simple page rendering an h1 "Tessera" and a paragraph indicating the portal is loading. This is the skeleton placeholder -- Plan 02 replaces with full portal shell.
Create `apps/web/Dockerfile` per RESEARCH Pattern 4: multi-stage (base with corepack/pnpm, deps, builder with standalone output, runner as non-root "nextjs" user, EXPOSE 3000, CMD node server.js). Use monorepo root context same strategy as API: COPY root configs, apps/web/, packages/shared/, install filtered, build.
Create `docker-compose.yml` per RESEARCH Pattern 1 with three networks:
- services: traefik (image traefik:v3.4, ports 80:80, command --api.insecure=true --providers.docker=true --providers.docker.exposedbydefault=false, networks frontend-net, volumes /var/run/docker.sock:/var/run/docker.sock:ro)
- web (build context . dockerfile apps/web/Dockerfile, networks frontend-net + backend-net, depends_on api condition service_healthy, labels traefik.enable=true + traefik.http.routers.web.rule=PathPrefix(/) + traefik.http.services.web.loadbalancer.server.port=3000 + traefik.http.routers.web.priority=1)
- api (build context . dockerfile apps/api/Dockerfile, networks backend-net + data-net, depends_on db condition service_healthy, environment DATABASE_URL from .env, healthcheck curl -f http://localhost:3001/health interval 10s timeout 5s retries 3, labels traefik.enable=true + traefik.http.routers.api.rule=PathPrefix(/api) + traefik.http.services.api.loadbalancer.server.port=3001 + traefik.http.routers.api.priority=2 + traefik.http.middlewares.api-strip.stripprefix.prefixes=/api + traefik.http.routers.api.middlewares=api-strip)
- db (image postgres:16-alpine, networks data-net, volumes pgdata:/var/lib/postgresql/data, environment POSTGRES_USER=tessera POSTGRES_PASSWORD=${DB_PASSWORD:-tessera_dev} POSTGRES_DB=tessera, healthcheck pg_isready -U tessera interval 5s timeout 3s retries 5)
- networks: frontend-net (bridge), backend-net (bridge), data-net (bridge, internal: true)
- volumes: pgdata
Create `docker-compose.dev.yml` with volume mounts for hot reload: web volumes ./apps/web/src:/app/apps/web/src, api volumes ./apps/api/src:/app/apps/api/src. Override web command to "pnpm --filter @tessera/web dev" and api command to "pnpm --filter @tessera/api start:dev".
Run `pnpm install` at root to install web dependencies. Then run `docker compose build` to verify images build successfully. Then `docker compose up -d` and verify with curl.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && docker compose build --no-cache 2>&1 | tail -10 && docker compose up -d && sleep 15 && curl -s http://localhost:80 | grep -o "Tessera" | head -1 && curl -s http://localhost:80/api/health | grep -o '"status":"ok"' && docker compose exec db pg_isready -U tessera && docker compose down</automated>
</verify>
<acceptance_criteria>
- docker compose build completes without errors for all services (web, api, db, traefik)
- docker compose up -d starts all 4 containers (traefik, web, api, db)
- curl http://localhost:80 returns HTML containing "Tessera"
- curl http://localhost:80/api/health returns JSON with "status":"ok"
- docker compose exec db pg_isready -U tessera exits with code 0
- docker-compose.yml defines three networks: frontend-net, backend-net, data-net
- data-net has "internal: true" (PostgreSQL unreachable from outside)
- web service is on frontend-net and backend-net (NOT data-net)
- api service is on backend-net and data-net (NOT frontend-net)
</acceptance_criteria>
<done>Full Docker Compose stack runs: Traefik proxies to Next.js (port 80) and NestJS API (/api/*), PostgreSQL is healthy on internal network, network segmentation enforced.</done>
</task>
</tasks>
## Artifacts This Phase Produces
| Symbol/File | Type | Consumed By |
|---|---|---|
| `docker-compose.yml` | Docker Compose config | All subsequent phases (service foundation) |
| `docker-compose.dev.yml` | Dev overrides | Local development workflow |
| `pnpm-workspace.yaml` | Monorepo config | All package operations |
| `turbo.json` | Build orchestration | All build/dev/lint commands |
| `tsconfig.base.json` | TypeScript config | All apps and packages |
| `biome.json` | Linter/formatter config | All code quality checks |
| `@tessera/shared` (packages/shared) | Shared types package | apps/web, apps/api |
| `HealthResponse` type | Interface | apps/api health controller |
| `APP_NAME` constant | String export | UI components |
| `apps/api/src/main.ts` | NestJS entrypoint | Docker CMD |
| `apps/api/src/health/health.controller.ts` | Health endpoint | Docker healthcheck, monitoring |
| `apps/api/prisma/schema.prisma` | Database schema | Prisma migrations, Phase 2 RLS |
| `apps/web/src/app/layout.tsx` | Root layout | All pages (Plan 02 adds providers) |
| `apps/web/src/app/page.tsx` | Dashboard page | Plan 02 replaces content |
| `apps/web/Dockerfile` | Frontend image | docker-compose.yml |
| `apps/api/Dockerfile` | Backend image | docker-compose.yml |
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> Traefik | External traffic enters Docker stack |
| Traefik -> Next.js | Reverse proxy to frontend container |
| Next.js -> NestJS | Frontend calls backend API |
| NestJS -> PostgreSQL | Backend accesses database on internal network |
| Docker socket -> Traefik | Container management API exposure |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01 | Elevation of Privilege | Docker socket in Traefik | mitigate | Mount /var/run/docker.sock:ro (read-only) |
| T-01-02 | Information Disclosure | PostgreSQL port | mitigate | data-net has internal:true, no port mapping to host |
| T-01-03 | Information Disclosure | .env file with DB credentials | mitigate | .env in .gitignore, .env.example has placeholder values only |
| T-01-04 | Elevation of Privilege | Container running as root | mitigate | Explicit USER directive (nextjs/nestjs) in both Dockerfiles, no sudo |
| T-01-05 | Tampering | Dependency confusion | mitigate | pnpm strict mode, lockfile committed, --frozen-lockfile in Docker builds |
| T-01-SC | Tampering | npm installs | mitigate | All packages pass legitimacy audit in RESEARCH.md; no [ASSUMED]/[SUS] packages requiring human checkpoint |
</threat_model>
<verification>
1. `docker compose up -d` starts all services without error
2. `curl http://localhost:80` returns rendered HTML with "Tessera"
3. `curl http://localhost:80/api/health` returns `{"status":"ok","timestamp":"..."}`
4. `docker compose exec db pg_isready -U tessera` exits 0
5. `docker compose exec web ping db` fails (network segmentation working)
6. `pnpm turbo type-check` passes for all packages
</verification>
<success_criteria>
- Docker Compose stack starts with 4 healthy containers (traefik, web, api, db)
- Browser at localhost:80 shows rendered Next.js page
- API health endpoint returns 200 with JSON body
- PostgreSQL is isolated on internal network
- Monorepo builds and type-checks cleanly
- All files follow the project structure defined in RESEARCH.md
</success_criteria>
<output>
Create `.planning/phases/01-foundation-portal-shell/01-01-SUMMARY.md` when done
</output>
@@ -0,0 +1,347 @@
---
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>
@@ -0,0 +1,162 @@
---
phase: 01-foundation-portal-shell
plan: 03
type: execute
wave: 3
depends_on:
- 01-02
files_modified: []
autonomous: false
requirements:
- PRTAL-01
- PRTAL-04
- UI-01
- UI-02
must_haves:
truths:
- "Human has verified the portal shell looks correct in browser"
- "Theme switching visually works (light/dark/system)"
- "Language switching visually works (DE/EN)"
- "Responsive layout adapts correctly on mobile and desktop"
- "Sidebar collapse/expand animates smoothly"
---
## 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>
Visual Verification: Human confirms the portal shell meets all design decisions (D-01 through D-17) and functional requirements (PRTAL-01, PRTAL-04, UI-01, UI-02, UI-03) by interacting with the running application in a browser.
Purpose: Catch visual/interaction issues that automated checks cannot detect -- color accuracy, animation smoothness, layout proportions, overall aesthetic quality.
Output: Human approval or issue list for correction.
</objective>
<execution_context>
@/home/vicolab/.claude/gsd-core/workflows/execute-plan.md
@/home/vicolab/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/01-foundation-portal-shell/01-CONTEXT.md
@.planning/phases/01-foundation-portal-shell/01-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Rebuild Docker stack and verify automated checks</name>
<files></files>
<read_first>
docker-compose.yml (verify configuration)
.planning/phases/01-foundation-portal-shell/01-02-SUMMARY.md (what was built)
</read_first>
<action>
Run `docker compose build` to rebuild images with the latest portal shell code.
Run `docker compose up -d` to start all services.
Wait for all containers to be healthy.
Run automated verification: curl localhost:80 returns HTML, curl localhost:80/api/health returns JSON with status ok, pg_isready passes.
Run `pnpm turbo type-check` to confirm all packages type-check.
Run the hardcoded-string grep check: `grep -rn ">[A-Z][a-z]" apps/web/src/components/ apps/web/src/app/page.tsx --include="*.tsx"` and review any matches (filter out non-user-visible strings like className values, imports, type annotations).
Leave the stack running for human verification.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && docker compose up -d --build 2>&1 | tail -5 && sleep 20 && curl -sf http://localhost:80 > /dev/null && echo "Web: OK" && curl -sf http://localhost:80/api/health && echo "" && echo "API: OK" && docker compose exec db pg_isready -U tessera && echo "DB: OK"</automated>
</verify>
<acceptance_criteria>
- All 4 containers are running (docker compose ps shows traefik, web, api, db)
- curl localhost:80 returns 200 with HTML content
- curl localhost:80/api/health returns {"status":"ok","timestamp":"..."}
- pg_isready exits 0
- pnpm turbo type-check exits 0
</acceptance_criteria>
<done>Docker stack is running and all automated checks pass, ready for human visual verification.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Complete Tessera portal shell with:
- Sticky header (logo left, page title center, theme toggle + locale switch + user avatar right) per D-07/D-08/D-09
- Collapsible left sidebar (240px expanded, ~64px collapsed icon-only) per D-01/D-02/D-04
- Sidebar with Dashboard + Marketplace nav items, accordion categories, footer with settings + user info per D-05/D-06/D-15
- Empty dashboard main content ("Keine Widgets aktiv" + add button) per D-16
- Yellow #ffed00 primary color, dark gray-blue dark mode per D-10/D-14
- Modern/clean visual style with rounded corners (~8px), subtle shadows per D-12/D-13
- German/English language switching via cookie per UI-02/UI-03
- Light/dark/system theme toggle per UI-01
- Responsive layout: mobile hides sidebar with hamburger per D-03/PRTAL-04
</what-built>
<how-to-verify>
Open http://localhost:80 in your browser and check the following:
**1. Overall Layout (D-07, D-12, PRTAL-01)**
- Header at top with logo "Tessera" on the left
- Page title / breadcrumb in center area
- Theme toggle, language switch, and user avatar placeholder on the right
- Header is sticky (scroll down if content exists -- it stays at top, D-09)
- Clean modern style with whitespace and subtle shadows
**2. Sidebar Behavior (D-01, D-02, D-04, D-05, D-06)**
- Sidebar on left side, approximately 240px wide
- Click the collapse/expand button -- sidebar animates to icon-only width (~64px)
- In expanded state: Dashboard and Marketplace labels visible with icons
- Dashboard entry appears active/highlighted (D-15)
- Categories accordion exists and can be expanded (shows "no modules" placeholder)
- Footer area at bottom of sidebar has settings icon and user placeholder
- Click collapse -- icons remain visible, labels disappear
**3. Theme Switching (UI-01, D-10, D-14)**
- Click theme toggle to switch to Dark mode
- Background changes to dark gray-blue (NOT pure black)
- Yellow primary color (#ffed00) remains vibrant on dark background
- Switch back to Light mode -- white/light background
- Refresh the page -- theme preference persists
**4. Language Switching (UI-02, UI-03)**
- Default language should be German (Deutsch)
- All text is in German: "Dashboard", "Marktplatz", "Einstellungen", "Keine Widgets aktiv"
- Click language switch to English
- All text changes: "Dashboard", "Marketplace", "Settings", "No active widgets"
- Refresh the page -- language preference persists
**5. Responsive Behavior (D-03, PRTAL-04)**
- Resize browser window to narrow width (< 768px)
- Sidebar should disappear
- Hamburger menu button should appear in the header
- Click hamburger -- sidebar slides in as overlay with backdrop
- Click backdrop -- sidebar closes
- Resize back to wide -- sidebar reappears normally
**6. Empty Dashboard (D-15, D-16)**
- Main content area shows "Keine Widgets aktiv" (or English equivalent)
- An "Add widget" button is visible (non-functional is OK for Phase 1)
**7. Color and Style (D-10, D-12, D-13)**
- Primary/brand color is yellow (#ffed00) visible on active states, buttons
- Corners are rounded (~8px radius)
- Overall feel: clean, modern, like Notion/Linear aesthetic
</how-to-verify>
<resume-signal>Type "approved" if the portal looks and works correctly, or describe specific issues that need fixing.</resume-signal>
</task>
</tasks>
## Artifacts This Phase Produces
This plan produces no new code artifacts -- it verifies the artifacts from Plans 01 and 02.
<verification>
All automated and human verification steps complete. The portal shell is confirmed visually correct and functionally working.
</verification>
<success_criteria>
- Human has verified all 7 checklist areas
- Human typed "approved" (or issues have been addressed)
- Docker stack remains running and healthy
</success_criteria>
<output>
Create `.planning/phases/01-foundation-portal-shell/01-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,55 @@
# Walking Skeleton — Tessera
**Phase:** 1
**Generated:** 2026-06-18
## Capability Proven End-to-End
A user can open a browser, see the Tessera portal shell with header, collapsible sidebar, and main content area, toggle between light and dark theme (persisted), and switch between German and English interface language -- all served from a Docker Compose stack with PostgreSQL, NestJS API, and Next.js frontend on segregated networks.
## Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Monorepo | pnpm 9 + Turborepo 2.9 | Strict deps, workspace protocol, parallel builds with caching |
| Frontend | Next.js 16 App Router (standalone output) | React 19, Server Components, Turbopack, multi-tenant middleware support |
| Backend | NestJS 11 + Express 5 | Modular architecture maps to Tessera module system, DI, guards |
| Data layer | PostgreSQL 16 + Prisma 7 | RLS for multi-tenancy, TypeScript-first ORM, declarative migrations |
| Styling | Tailwind CSS 4 + shadcn/ui (CLI v4) | CSS-first config, design tokens via CSS variables, accessible components |
| i18n | next-intl 4.13 (without i18n routing) | Cookie-based locale, Server Component native, App Router built-in |
| Theme | next-themes 0.4 | SSR-safe, system preference detection, .dark class toggle |
| Client state | Zustand 5 | Sidebar toggle, UI prefs, 1.1kb, localStorage persistence |
| Deployment | Docker Compose with Traefik 3.x reverse proxy | Three-network segmentation (frontend/backend/data), health checks |
| Directory layout | apps/web, apps/api, packages/shared monorepo | Clear separation, independent Dockerfiles, shared types |
| Linting | Biome 2.x | Single tool replaces ESLint + Prettier, 100x faster |
## Stack Touched in Phase 1
- [x] Project scaffold (pnpm monorepo, Turborepo, Biome, TypeScript)
- [x] Routing — Next.js App Router with root page (dashboard placeholder)
- [x] Database — PostgreSQL container with Prisma schema (health check read)
- [x] UI — Theme toggle, language switch, collapsible sidebar (interactive)
- [x] Deployment — Docker Compose full-stack with `docker compose up` command
## Out of Scope (Deferred to Later Slices)
- Authentication and user accounts (Phase 2)
- Multi-tenancy and RLS policies (Phase 2)
- Module system and dynamic loading (Phase 3)
- Marketplace and sidebar module listing (Phase 4)
- Dashboard widgets and drag-and-drop grid (Phase 5)
- Desktop Tauri wrapper (Phase 6)
- Configurable accent color UI (Phase 2 -- infrastructure prepared via CSS variable)
- Redis cache/sessions (Phase 2)
- Keycloak identity provider (Phase 2)
- E2E tests with Playwright (deferred -- smoke tests via curl suffice for skeleton)
## Subsequent Slice Plan
Each later phase adds one vertical slice on top of this skeleton without altering its architectural decisions:
- Phase 2: Authenticated user can log in, see their name, operate within tenant boundary
- Phase 3: Admin can activate a module, user can interact with Domaincheck
- Phase 4: User can browse marketplace, activate modules, navigate via sidebar
- Phase 5: User can configure personal dashboard with draggable widgets
- Phase 6: User can install and run Tessera as a native desktop app