---
phase: 03-module-system-domaincheck
plan: 03
type: execute
wave: 2
depends_on: ["03-01"]
files_modified:
- apps/web/src/app/(portal)/modules/[category]/page.tsx
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx
- apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/api.ts
- apps/web/messages/de.json
- apps/web/messages/en.json
autonomous: true
requirements:
- MOD-04
must_haves:
truths:
- "Module UIs load on demand via dynamic imports — inactive modules are never bundled"
- "Category page shows module cards in a grid layout"
- "Module can be opened in expanded view from category page"
artifacts:
- path: "apps/web/src/lib/module-loader.ts"
provides: "Dynamic import registry mapping slugs to lazy components"
exports: ["loadModuleComponent", "loadModuleCard", "MODULE_REGISTRY"]
- path: "apps/web/src/app/(portal)/modules/[category]/page.tsx"
provides: "Category page showing activated module cards"
- path: "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"
provides: "Expanded module view using lazy-loaded component"
key_links:
- from: "apps/web/src/lib/module-loader.ts"
to: "next/dynamic"
via: "dynamic import with ssr:false"
pattern: "dynamic.*import"
- from: "apps/web/src/app/(portal)/modules/[category]/page.tsx"
to: "/modules/active"
via: "API fetch for tenant's active modules"
pattern: "fetch.*modules/active"
---
Deliver lazy-loaded module UI rendering — category pages show module cards from the registry, and modules load on demand via Next.js dynamic imports. No bundle bloat from inactive modules.
Purpose: Per D-04, D-05a, D-05b and MOD-04 — modules appear as cards within category pages and can expand to full view, all lazy-loaded.
Output: Module loader utility, category page with dynamic routing, module card component.
@/home/vicolab/.claude/gsd-core/workflows/execute-plan.md
@/home/vicolab/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/03-module-system-domaincheck/03-CONTEXT.md
@.planning/phases/03-module-system-domaincheck/03-RESEARCH.md
@.planning/phases/03-module-system-domaincheck/03-01-SUMMARY.md
@apps/web/src/app/(portal)/layout.tsx
@apps/web/src/app/(portal)/page.tsx
## Phase Goal
**As a** user, **I want to** browse module categories and open modules without page bloat, **so that** only active modules load their UI code on demand.
Task 1: Module Loader Utility + Category Page with Lazy Cards
apps/web/src/lib/module-loader.ts,
apps/web/src/app/(portal)/modules/[category]/page.tsx,
apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx,
apps/web/messages/de.json,
apps/web/messages/en.json
apps/web/src/app/(portal)/layout.tsx,
apps/web/src/app/(portal)/page.tsx,
apps/web/messages/de.json
1. Create apps/web/src/lib/module-loader.ts:
- MODULE_REGISTRY: Record mapping module slugs to { component: () => import(...), cardComponent: () => import(...) }
- For domaincheck: component maps to dynamic(() => import('@/app/(portal)/modules/domaincheck/page'), { ssr: false }), cardComponent maps to a DomaincheckCard component
- Export loadModuleComponent(slug: string): returns the dynamic component or null if not in registry
- Export loadModuleCard(slug: string): returns the card component or a generic fallback card
- This is the lazy loading mechanism — components are only imported when rendered (per MOD-04)
2. Add i18n keys to both message files under "modules" namespace:
- DE: categoryTitle: "Module: {category}", openModule: "Oeffnen", noModules: "Keine aktiven Module in dieser Kategorie"
- EN: categoryTitle: "Modules: {category}", openModule: "Open", noModules: "No active modules in this category"
3. Create apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx:
- Client component ("use client")
- Props: module object (name, slug, description, icon, category)
- Renders a shadcn/ui Card with: icon area, module name, description (from current locale), "Open" link to /modules/{category}/{slug}
- Per D-05a: uniform card appearance, compact, looks good even with single module
- Uses Tailwind for consistent spacing and theming
4. Create apps/web/src/app/(portal)/modules/[category]/page.tsx:
- Server component that receives params.category
- Fetches active modules for current tenant from API (GET /modules/active), filters by category matching params.category
- Renders a responsive grid of ModuleCard components (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3)
- Empty state shows "noModules" i18n message
- Title shows category name formatted (capitalize, replace hyphens with spaces)
- Per D-04: category page groups all modules of that category
cd /home/vicolab/projects/tessera-ctl && pnpm --filter web build 2>&1 | tail -5
- Web build succeeds with dynamic route [category]
- /modules/domain-tools page fetches and displays active modules filtered by "domain-tools" category
- ModuleCard renders module info with link to expanded view
- module-loader.ts maps 'domaincheck' slug to lazy-imported components
- Inactive modules never trigger an import (verified by MODULE_REGISTRY only containing active slug mappings at build time)
Category page at /modules/domain-tools shows domaincheck as a card, lazy loading is wired via module-loader registry
Task 2: Expanded Module View with Dynamic Routing
apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx,
apps/web/src/lib/api.ts
apps/web/src/lib/module-loader.ts,
apps/web/src/app/(portal)/modules/[category]/page.tsx
1. Create or extend apps/web/src/lib/api.ts (if not existing):
- Export getActiveModules(cookie: string): fetches GET /modules/active with auth cookie, returns module array
- Export getModuleBySlug(slug: string, cookie: string): fetches GET /modules, finds by slug, returns module or null
- Base URL from NEXT_PUBLIC_API_URL env var
2. Create apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx:
- Per D-05b: expanded/full view when user opens a specific module
- Server component wrapper that validates moduleSlug exists in registry
- Uses loadModuleComponent(params.moduleSlug) from module-loader.ts to get the lazy component
- If module not found in registry: show 404/not-found state
- If found: render the dynamically imported component within a container
- The loaded component for 'domaincheck' is the DomaincheckPage from Plan 02
- Wrapped in shadcn/ui Card with back-link to category page
3. Ensure the domaincheck page.tsx from Plan 02 can function both as standalone (/modules/domaincheck direct access) AND as the loaded component in expanded view. The [moduleSlug] route provides the canonical expanded path per D-05b: /modules/domain-tools/domaincheck
cd /home/vicolab/projects/tessera-ctl && pnpm --filter web build 2>&1 | tail -5
- /modules/domain-tools/domaincheck route renders the domaincheck module component
- Module component loads lazily (not in initial bundle of category page)
- Unknown module slugs show appropriate not-found state
- Back-link navigates to category page /modules/domain-tools
User can navigate from category card grid to expanded module view. Module UI code loads on demand only when opened (MOD-04).
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| URL params -> module loader | User can craft arbitrary slugs in URL |
| API -> frontend | Module list from API determines what renders |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-09 | Tampering | [moduleSlug] param | mitigate | Only load from MODULE_REGISTRY whitelist — arbitrary slugs get not-found, no arbitrary imports |
| T-03-10 | Elevation of Privilege | category page | mitigate | API filters by tenant's active modules — inactive modules never returned, cards never rendered |
| T-03-11 | Information Disclosure | module metadata | accept | Module names/descriptions are non-sensitive catalog data |
- pnpm --filter web build succeeds
- /modules/domain-tools renders category page with module cards
- /modules/domain-tools/domaincheck loads domaincheck UI lazily
- Bundle analysis confirms domaincheck code not in category page chunk
- Module UIs load on demand via next/dynamic with ssr:false (MOD-04)
- Category page displays modules as cards per D-04, D-05a
- Expanded view available per D-05b
- No bundle impact from inactive/unregistered modules
## Artifacts this phase produces
| Symbol | Location | Type |
|--------|----------|------|
| MODULE_REGISTRY | apps/web/src/lib/module-loader.ts | const |
| loadModuleComponent | apps/web/src/lib/module-loader.ts | function |
| loadModuleCard | apps/web/src/lib/module-loader.ts | function |
| CategoryPage | apps/web/src/app/(portal)/modules/[category]/page.tsx | React component |
| ModuleCard | apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx | React component |
| ExpandedModulePage | apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx | React component |
| getActiveModules | apps/web/src/lib/api.ts | function |
| getModuleBySlug | apps/web/src/lib/api.ts | function |