--- phase: 01-level-up-pf2e-regelkonform plan: 05 type: execute wave: 5 depends_on: ["01-01", "01-02", "01-03", "01-03b", "01-04"] files_modified: - client/src/features/characters/components/level-up/wizard-state-reducer.ts - client/src/features/characters/components/level-up/use-level-up-session.ts - client/src/features/characters/components/level-up/level-up-choice-card.tsx - client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx - client/src/features/characters/components/level-up/level-up-resume-banner.tsx - client/src/features/characters/components/level-up/level-up-violations-banner.tsx - client/src/features/characters/components/level-up/level-up-step-class-features.tsx - client/src/features/characters/components/level-up/level-up-step-class-feature-choice.tsx - client/src/features/characters/components/level-up/level-up-step-boost.tsx - client/src/features/characters/components/level-up/level-up-step-skill-increase.tsx - client/src/features/characters/components/level-up/level-up-step-feat-class.tsx - client/src/features/characters/components/level-up/level-up-step-feat-skill.tsx - client/src/features/characters/components/level-up/level-up-step-feat-general.tsx - client/src/features/characters/components/level-up/level-up-step-feat-ancestry.tsx - client/src/features/characters/components/level-up/level-up-step-feat-archetype.tsx - client/src/features/characters/components/level-up/level-up-step-spellcaster.tsx - client/src/features/characters/components/level-up/level-up-step-review.tsx - client/src/features/characters/components/level-up/level-up-wizard.tsx - client/src/features/characters/components/character-sheet-page.tsx - client/src/shared/lib/api.ts - client/src/shared/types/index.ts - client/src/shared/hooks/use-character-socket.ts autonomous: false requirements: [LVL-01, LVL-02, LVL-03, LVL-04, LVL-05, LVL-06, LVL-07, LVL-08, LVL-09, LVL-10, LVL-11, LVL-12, LVL-13, LVL-14, LVL-15] tags: [react, ui, wizard, level-up, character-sheet, mobile-first, websocket-client] must_haves: truths: - "Player on a character sheet can click 'Stufe steigen' (Sparkles icon) and a modal wizard opens, ranging from 4 to 11 steps depending on level/class/FA/caster, ending in a Review step." - "Wizard chrome (header, stepper with progress label, body, footer) matches `01-UI-SPEC.md` exactly — no redesign." - "Choice-cards show source-color badges (Klassen/Abstammung/etc. — reuse existing featSourceColors) and a yellow AlertTriangle for non-evaluable prereqs (D-03)." - "Boost step uses +/- counters at h-11 w-11 (44×44 touch targets), shows 'wird {newScore}' live with cap-bei-18 chip when applicable." - "Review step shows Vorher/Nachher cards (HP-Max, RK, Klassen-DC, Wahrnehmung, Saves) using server-computed preview, with Ändern links to revise upstream choices." - "Bestätigen runs the commit; wizard closes; toast shows; WebSocket level_up_committed event arrives at all other open clients of the character within ~1s." - "DRAFT-Resume banner appears at the top of the character-sheet when an open DRAFT exists, with Fortsetzen + Verwerfen actions." - "Pathbuilder-import-violations banner appears below the avatar header when Character.prereqViolations is non-null." - "All UI text is in German; no emojis; only Lucide icons; mobile-first (44px touch targets)." - "Human verification checkpoint passes — user walks through the wizard for a real character on a mobile viewport (375×667) and confirms each step matches UI-SPEC." artifacts: - path: "client/src/features/characters/components/level-up/level-up-wizard.tsx" provides: "Outer modal container — header, stepper, motion orchestration, footer wiring" exports: ["LevelUpWizard"] - path: "client/src/features/characters/components/level-up/wizard-state-reducer.ts" provides: "useReducer + WizardState/WizardEvent discriminated unions" exports: ["wizardReducer", "WizardState", "WizardEvent"] - path: "client/src/features/characters/components/level-up/use-level-up-session.ts" provides: "react-query hooks: start, patch, preview, commit, discard" exports: ["useStartLevelUpMutation", "usePatchLevelUpMutation", "useCommitLevelUpMutation", "useDiscardLevelUpMutation", "useLevelUpPreviewQuery"] - path: "client/src/features/characters/components/level-up/level-up-step-*.tsx (12 files)" provides: "Per-step components — class-features, class-feature-choice, boost, skill-increase, feat-class, feat-skill, feat-general, feat-ancestry, feat-archetype, spellcaster, review (and choice-card primitive)" - path: "client/src/features/characters/components/level-up/level-up-resume-banner.tsx" provides: "DRAFT-Resume banner — Fortsetzen / Verwerfen" exports: ["LevelUpResumeBanner"] - path: "client/src/features/characters/components/level-up/level-up-violations-banner.tsx" provides: "Pathbuilder-import-violations banner with collapsible list" exports: ["LevelUpViolationsBanner"] - path: "client/src/features/characters/components/character-sheet-page.tsx (extended)" provides: "Header button + 2 banner mounts + wizard mount" - path: "client/src/shared/lib/api.ts (extended)" provides: "8 new methods: startLevelUp, patchLevelUp, getLevelUpPreview, commitLevelUp, discardLevelUp, getOpenLevelUpDraft, getLevelUpFeats, getLevelUpClassFeatureOptions" - path: "client/src/shared/types/index.ts (extended)" provides: "LevelUpSession, LevelUpPreview, WizardChoices types + extended Character with freeArchetype, prereqViolations" - path: "client/src/shared/hooks/use-character-socket.ts (extended)" provides: "Adds 'level_up_committed' to CharacterUpdateType union + onLevelUpCommitted callback" gotchas: - "Ändern (revision) does NOT auto-clear downstream choices that depended on the revised upstream choice. Per D-12 (review-only recompute, no live per-step recompute) the wizard intentionally keeps stale downstream picks; commit-time validation in Plan 04 (LevelingService.commit + isValidBoostSet/prereq guards) is the source of truth and rejects invalid combinations with a German BadRequestException that the wizard surfaces inline. Per-dependency clearing is a v2 enhancement." key_links: - from: "level-up-wizard.tsx" to: "LevelingService REST API" via: "use-level-up-session hooks → api methods → axios" pattern: "/characters/.*level-up" - from: "character-sheet-page.tsx" to: "LevelUpWizard mount" via: "showLevelUpWizard state + conditional render" pattern: " Build the React wizard UI exactly as specified in `01-UI-SPEC.md` — no redesign. The wizard is the user-facing surface that drives the LevelingService REST endpoints (Plan 04). It is mobile-first (Bottom-Sheet on small screens, centered modal on `sm:` and up), German throughout, and reuses existing project primitives (`Button`, `Card`, `Spinner`, `ActionIcon`, `featSourceColors`). The plan also extends the character-sheet page with the "Stufe steigen" button, the DRAFT-Resume banner, and the Pathbuilder-Import-Violations banner. Purpose: Without this plan, the entire backend (Plans 01-04) has no consumer. The wizard is the product surface — it converts the regelkonform engine into a usable feature for the player and GM at the table. Output: 18 new client files (1 reducer + 1 hooks file + 1 choice-card + 1 prereq dialog + 2 banners + 12 step components) + 1 modal container + 4 extended files (character-sheet-page, api, shared types, use-character-socket). One human-verification checkpoint at the end. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/REQUIREMENTS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md @.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md @.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md @.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md @.planning/phases/01-level-up-pf2e-regelkonform/01-04-SUMMARY.md @client/src/features/characters/components/character-sheet-page.tsx @client/src/features/characters/components/add-feat-modal.tsx @client/src/features/characters/components/add-condition-modal.tsx @client/src/features/characters/components/rest-modal.tsx @client/src/features/characters/components/hp-control.tsx @client/src/features/characters/components/feat-detail-modal.tsx @client/src/shared/lib/api.ts @client/src/shared/hooks/use-character-socket.ts @client/src/shared/components/ui/index.ts @client/src/index.css ```typescript // Server → client (server/src/modules/characters/characters.gateway.ts) // type='level_up_committed' data: { level: number; derived: { hpMax, ac, classDc, perception, fortitude, reflex, will } } ``` ```typescript async getRestPreview(campaignId: string, characterId: string) { const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`); return response.data; } ``` ```typescript export type CharacterUpdateType = 'hp' | 'conditions' | ... | 'dying'; // Add: | 'level_up_committed' ``` ```typescript const featSourceColors = { Class: 'bg-red-500/20 text-red-400', Ancestry: 'bg-blue-500/20 text-blue-400', General: 'bg-yellow-500/20 text-yellow-400', Skill: 'bg-green-500/20 text-green-400', Archetype: 'bg-purple-500/20 text-purple-400', Bonus: 'bg-cyan-500/20 text-cyan-400', }; ``` ```tsx
{/* Header / Body / Footer */}
``` ```typescript export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'; ``` Task 1: Extend shared types + client API client + use-character-socket hook client/src/shared/types/index.ts, client/src/shared/lib/api.ts, client/src/shared/hooks/use-character-socket.ts - client/src/shared/types/index.ts (entire file — must understand existing Character + Campaign types) - client/src/shared/lib/api.ts (lines 381-388 — getRestPreview/performRest analog; entire file for structure) - client/src/shared/hooks/use-character-socket.ts (entire file — must understand CharacterUpdateType union, existing onXxxUpdate callbacks) - server/src/modules/leveling/dto/level-up-state.dto.ts (Plan 04 — LevelUpSessionDto + LevelUpPreviewDto field shapes) - server/src/modules/characters/characters.gateway.ts (Plan 04 — extended payload doc) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 769-808 — api.ts extension pattern) **Step 1 — Add types to `client/src/shared/types/index.ts`:** Append to the existing types file: ```typescript // ============ Phase 1 Level-Up types ============ export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA'; export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'; export type StepKind = | 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'; export interface WizardChoices { boostTargets?: AbilityAbbreviation[]; skillIncrease?: { skillName: string; toRank: Proficiency }; featClassId?: string; featSkillId?: string; featGeneralId?: string; featAncestryId?: string; featArchetypeId?: string; classFeatureChoices?: Record; // optionsRef → optionKey spellcasterRepertoirePicks?: string[]; // spell IDs } export interface LevelUpSession { id: string; characterId: string; targetLevel: number; state: { choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }; committedAt: string | null; createdAt: string; updatedAt: string; } export interface DerivedStats { level: number; hpMax: number; ac: number; classDc: number; perception: number; fortitude: number; reflex: number; will: number; } export interface LevelUpPreview { before: DerivedStats; after: DerivedStats; spellcaster?: { slotIncrements: { tradition: string; spellLevel: number; count: number }[]; cantripDelta?: number; repertoireDelta?: number; }; } export interface PrereqViolation { featId: string; featName: string; prereqText: string; } ``` **Step 2 — Extend the existing `Character` interface** in the same file. Find the `interface Character {` block and append two fields: ```typescript export interface Character { // ... existing fields ... freeArchetype?: boolean; prereqViolations?: { violations: PrereqViolation[] } | null; } ``` **Step 3 — Add 5 methods to `client/src/shared/lib/api.ts`** alongside the existing methods (analog: `getRestPreview` at lines 381-388): ```typescript // ============ Phase 1 Level-Up methods ============ async startLevelUp(characterId: string, targetLevel?: number): Promise { const response = await this.client.post( `/characters/${characterId}/level-up`, { targetLevel }, ); return response.data; } async patchLevelUp( characterId: string, sessionId: string, state: Partial<{ choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }>, ): Promise { const response = await this.client.patch( `/characters/${characterId}/level-up/${sessionId}`, { state }, ); return response.data; } async getLevelUpPreview(characterId: string, sessionId: string): Promise { const response = await this.client.get( `/characters/${characterId}/level-up/${sessionId}/preview`, ); return response.data; } async commitLevelUp( characterId: string, sessionId: string, acknowledgePrereqWarnings?: boolean, ): Promise { const response = await this.client.post( `/characters/${characterId}/level-up/${sessionId}/commit`, { acknowledgePrereqWarnings }, ); return response.data; } async discardLevelUp(characterId: string, sessionId: string): Promise { await this.client.delete( `/characters/${characterId}/level-up/${sessionId}`, ); } /** GET open DRAFT for resume-banner detection. Returns null on 404. */ async getOpenLevelUpDraft(characterId: string): Promise { try { const response = await this.client.get(`/characters/${characterId}/level-up`); return response.data; } catch (err) { if (axios.isAxiosError(err) && err.response?.status === 404) return null; throw err; } } /** GET filtered feat list for a wizard slot. Driven by Plan 04 FeatFilterService. */ async getLevelUpFeats( characterId: string, sessionId: string, slot: 'class' | 'skill' | 'general' | 'ancestry' | 'archetype', includeUnavailable: boolean = false, ): Promise { const response = await this.client.get( `/characters/${characterId}/level-up/${sessionId}/feats`, { params: { slot, includeUnavailable: includeUnavailable ? 'true' : 'false' } }, ); return response.data; } /** GET ClassFeatureOption rows for a class-feature choice step. */ async getLevelUpClassFeatureOptions( characterId: string, sessionId: string, optionsRef: string, ): Promise { const response = await this.client.get( `/characters/${characterId}/level-up/${sessionId}/class-feature-options/${encodeURIComponent(optionsRef)}`, ); return response.data; } ``` Add the imports at the top of api.ts: ```typescript import type { Character, LevelUpSession, LevelUpPreview, WizardChoices } from '@/shared/types'; ``` **Step 4 — Extend `client/src/shared/hooks/use-character-socket.ts`:** Find the `CharacterUpdateType` union (line 15) and append `'level_up_committed'`: ```typescript export type CharacterUpdateType = | 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying' | 'level_up_committed'; ``` Find the `UseCharacterSocketOptions` interface and add a new optional callback field (analog: `onRestUpdate` ~line 53): ```typescript export interface UseCharacterSocketOptions { // ... existing fields ... onLevelUpCommitted?: (data: { level: number; derived: import('@/shared/types').DerivedStats; }) => void; } ``` Find the place in the hook body where `onAlchemyUpdate` and similar callbacks are dispatched (look for the switch on `payload.type` / similar). Add the new branch: ```typescript if (payload.type === 'level_up_committed') { options.onLevelUpCommitted?.(payload.data as { level: number; derived: DerivedStats }); } ``` Import `DerivedStats` if not already. **Constraints (per CLAUDE.md):** - TypeScript strict — no `any` outside the existing untyped places. - Use `import type` for type-only imports. - All file naming kebab-case (these files already exist and are kebab-case). - All UI text added later is German. cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up|LevelUp|level_up_committed" || echo "tsc clean" - `client/src/shared/types/index.ts` exports `LevelUpSession`, `LevelUpPreview`, `DerivedStats`, `WizardChoices`, `StepKind`, `Proficiency`, `AbilityAbbreviation`, `PrereqViolation` - `client/src/shared/types/index.ts` Character interface has `freeArchetype?: boolean` and `prereqViolations?` fields - `client/src/shared/lib/api.ts` contains 8 new method names: `startLevelUp`, `patchLevelUp`, `getLevelUpPreview`, `commitLevelUp`, `discardLevelUp`, `getOpenLevelUpDraft`, `getLevelUpFeats`, `getLevelUpClassFeatureOptions` - `client/src/shared/hooks/use-character-socket.ts` CharacterUpdateType union contains `'level_up_committed'` - `client/src/shared/hooks/use-character-socket.ts` UseCharacterSocketOptions interface contains `onLevelUpCommitted?:` - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Shared client surface (types + api + socket) extended to talk to the Plan 04 server endpoints + receive the new WebSocket event. Task 2: wizard-state-reducer.ts + use-level-up-session.ts (state machine + react-query hooks) client/src/features/characters/components/level-up/wizard-state-reducer.ts, client/src/features/characters/components/level-up/use-level-up-session.ts - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 354-401 — exact reducer shape; lines 812-816 — react-query hook recommendation) - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — reducer must support every interaction the UI needs, especially the Ändern revision contract) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 819-823 — wizard-state-reducer; no analog — first useReducer in codebase) - server/src/modules/leveling/lib/types.ts (Plan 02 — StepKind, WizardChoices types — mirror in client) - client/src/shared/types/index.ts (Task 1 added the wire types — reuse) **Step 1 — Create `wizard-state-reducer.ts`:** ```typescript import type { StepKind, WizardChoices } from '@/shared/types'; /** * The full wizard state — JSON-serializable so it survives PATCH to the DRAFT. * Mirror of server/src/modules/leveling/lib/types.ts WizardChoices, plus client-only * navigation/UI fields. */ export interface WizardState { sessionId: string; targetLevel: number; steps: StepKind[]; currentIdx: number; choices: WizardChoices; acknowledgedNonEvaluablePrereqs: string[]; revisionMode: { fromStep: number } | null; // tracks "Ändern" → "Zurück zur Übersicht" } export type WizardEvent = | { type: 'GO_NEXT' } | { type: 'GO_PREV' } | { type: 'GO_TO_STEP'; idx: number } | { type: 'GO_TO_STEP_FROM_REVIEW'; idx: number } // sets revisionMode // RETURN_TO_REVIEW: clears revisionMode flag and routes back to review step. NOTE: downstream picks are NOT auto-cleared (v1) — commit-time validation in Plan 04 surfaces invalid combos. See must_haves.gotchas. | { type: 'RETURN_TO_REVIEW' } | { type: 'SET_BOOST_TARGETS'; targets: WizardChoices['boostTargets'] } | { type: 'SET_SKILL_INCREASE'; pick: WizardChoices['skillIncrease'] } | { type: 'SET_FEAT'; slot: 'class' | 'skill' | 'general' | 'ancestry' | 'archetype'; featId: string } | { type: 'SET_CLASS_FEATURE_CHOICE'; key: string; optionId: string } | { type: 'SET_REPERTOIRE_PICKS'; picks: string[] } | { type: 'ACKNOWLEDGE_PREREQ_WARNING'; featId: string }; export function wizardReducer(state: WizardState, ev: WizardEvent): WizardState { switch (ev.type) { case 'GO_NEXT': return state.currentIdx < state.steps.length - 1 ? { ...state, currentIdx: state.currentIdx + 1 } : state; case 'GO_PREV': return state.currentIdx > 0 ? { ...state, currentIdx: state.currentIdx - 1 } : state; case 'GO_TO_STEP': if (ev.idx < 0 || ev.idx > state.currentIdx) return state; // can't jump forward to incomplete return { ...state, currentIdx: ev.idx }; case 'GO_TO_STEP_FROM_REVIEW': { const reviewIdx = state.steps.findIndex(s => s === 'review'); return { ...state, currentIdx: ev.idx, revisionMode: { fromStep: reviewIdx } }; } case 'RETURN_TO_REVIEW': { const reviewIdx = state.steps.findIndex(s => s === 'review'); // GOTCHA (intentional v1 behaviour): RETURN_TO_REVIEW does NOT auto-clear downstream // choices that depended on an upstream revision. Per Plan-05 must_haves.gotchas and // D-12 (no live recompute per step — review-only), commit-time validation in Plan 04 // is the source of truth: invalid combinations surface as a German BadRequestException // when the user clicks Bestätigen, and the wizard surfaces the message inline. // Refinement to per-dependency clearing is a v2 enhancement. return { ...state, currentIdx: reviewIdx, revisionMode: null }; } case 'SET_BOOST_TARGETS': return { ...state, choices: { ...state.choices, boostTargets: ev.targets } }; case 'SET_SKILL_INCREASE': return { ...state, choices: { ...state.choices, skillIncrease: ev.pick } }; case 'SET_FEAT': { const fieldName = `feat${ev.slot.charAt(0).toUpperCase() + ev.slot.slice(1)}Id` as keyof WizardChoices; return { ...state, choices: { ...state.choices, [fieldName]: ev.featId } }; } case 'SET_CLASS_FEATURE_CHOICE': return { ...state, choices: { ...state.choices, classFeatureChoices: { ...(state.choices.classFeatureChoices ?? {}), [ev.key]: ev.optionId, }, }, }; case 'SET_REPERTOIRE_PICKS': return { ...state, choices: { ...state.choices, spellcasterRepertoirePicks: ev.picks } }; case 'ACKNOWLEDGE_PREREQ_WARNING': return state.acknowledgedNonEvaluablePrereqs.includes(ev.featId) ? state : { ...state, acknowledgedNonEvaluablePrereqs: [...state.acknowledgedNonEvaluablePrereqs, ev.featId] }; } } /** Initialize the wizard state from a freshly-fetched LevelUpSession. */ export function initWizardState(session: { id: string; targetLevel: number; state: { choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }; }, steps: StepKind[]): WizardState { return { sessionId: session.id, targetLevel: session.targetLevel, steps, currentIdx: 0, choices: session.state.choices ?? {}, acknowledgedNonEvaluablePrereqs: session.state.acknowledgedNonEvaluablePrereqs ?? [], revisionMode: null, }; } ``` **Step 2 — Create `use-level-up-session.ts`** (react-query hooks): ```typescript import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/shared/lib/api'; import type { LevelUpSession, LevelUpPreview, WizardChoices } from '@/shared/types'; /** * Start or resume a LevelUpSession for the given character. */ export function useStartLevelUpMutation(characterId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (targetLevel?: number) => api.startLevelUp(characterId, targetLevel), onSuccess: (session) => { qc.setQueryData(['levelUpSession', characterId], session); }, }); } export function usePatchLevelUpMutation(characterId: string, sessionId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (state: Partial<{ choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }>) => api.patchLevelUp(characterId, sessionId, state), onSuccess: (session) => { qc.setQueryData(['levelUpSession', characterId], session); }, }); } export function useLevelUpPreviewQuery(characterId: string, sessionId: string, enabled: boolean) { return useQuery({ queryKey: ['levelUpPreview', characterId, sessionId], queryFn: () => api.getLevelUpPreview(characterId, sessionId), enabled, staleTime: 0, // always re-fetch when the user reaches Review }); } export function useCommitLevelUpMutation(characterId: string, sessionId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (acknowledgePrereqWarnings?: boolean) => api.commitLevelUp(characterId, sessionId, acknowledgePrereqWarnings), onSuccess: () => { qc.invalidateQueries({ queryKey: ['character', characterId] }); qc.removeQueries({ queryKey: ['levelUpSession', characterId] }); }, }); } export function useDiscardLevelUpMutation(characterId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (sessionId: string) => api.discardLevelUp(characterId, sessionId), onSuccess: () => { qc.removeQueries({ queryKey: ['levelUpSession', characterId] }); }, }); } ``` **Constraint:** No `any` types. The mutation payload types must match the Plan 04 DTOs exactly. cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "wizard-state-reducer|use-level-up-session" || echo "tsc clean" - File `client/src/features/characters/components/level-up/wizard-state-reducer.ts` exists - File exports `wizardReducer`, `WizardState`, `WizardEvent`, `initWizardState` - File contains a `case 'RETURN_TO_REVIEW':` (proves revision-mode contract) - File contains NO `: any` outside comments - File `client/src/features/characters/components/level-up/use-level-up-session.ts` exists - File exports `useStartLevelUpMutation`, `usePatchLevelUpMutation`, `useLevelUpPreviewQuery`, `useCommitLevelUpMutation`, `useDiscardLevelUpMutation` - File imports `useMutation`, `useQuery`, `useQueryClient` from `@tanstack/react-query` - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Reducer + hooks compile cleanly; ready to be consumed by the wizard container. Task 3: Choice-Card primitive + Prereq-Confirm dialog + 2 banners (the shared building blocks) client/src/features/characters/components/level-up/level-up-choice-card.tsx, client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx, client/src/features/characters/components/level-up/level-up-resume-banner.tsx, client/src/features/characters/components/level-up/level-up-violations-banner.tsx - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — these four components are fully specified): - Choice-Card: lines 308-359 (canonical layout, locked / selected / warning states) - Prereq-Confirm Dialog: lines 570-579 (z-60 layered modal, AlertTriangle + raw prereq quote) - DRAFT-Resume Banner: lines 583-613 (full JSX provided in UI-SPEC) - Violations Banner: lines 617-649 (full JSX provided in UI-SPEC) - client/src/features/characters/components/feat-detail-modal.tsx (lines 22-29 — featSourceColors map to REUSE) - client/src/features/characters/components/add-feat-modal.tsx (lines 272-330 — feat card pattern + add-condition-modal.tsx:91-103 — modal chrome) - client/src/shared/components/ui/index.ts (Button, Card, etc. exports) - client/src/index.css (design tokens — bg-bg-tertiary, text-warning-500, etc.) Implement each component EXACTLY per `01-UI-SPEC.md`. Do NOT redesign — UI-SPEC is the contract. **1. `level-up-choice-card.tsx`** — UI-SPEC §"Component Contract — Choice-Card" (lines 308-359): ```tsx import { Lock, AlertTriangle, Check } from 'lucide-react'; import { cn } from '@/shared/lib/utils'; export interface ChoiceCardOption { id: string; title: string; // German display name englishName?: string; // sub-line if German != English description?: string; // truncated to 2 lines via line-clamp-2 sourceBadge?: { label: string; colorClass: string }; // colorClass from featSourceColors map levelChip?: number; actionCost?: 'A' | 'AA' | 'AAA' | 'R' | 'F' | null; // ActionIcon rarityChip?: 'Uncommon' | 'Rare' | 'Unique'; isSelected: boolean; isLocked: boolean; // shows Lock icon, opacity 60%, disabled lockReason?: string; // tooltip hasNonEvaluablePrereq: boolean; // shows yellow AlertTriangle rawPrereqText?: string; // for AlertTriangle tooltip onSelect: (id: string) => void; onMore?: () => void; // opens FeatDetailModal at z-60 } export function ChoiceCard(props: ChoiceCardOption) { // Implement per UI-SPEC §Choice-Card layout. // - Container: )} ); } ``` **2. `level-up-prereq-confirm-dialog.tsx`** — UI-SPEC §"Component Contract — Prereq-Confirm Dialog" (lines 570-579): ```tsx import { AlertTriangle } from 'lucide-react'; import { Button } from '@/shared/components/ui'; export interface PrereqConfirmDialogProps { featName: string; rawPrereqText: string; onCancel: () => void; onConfirm: () => void; } /** Layered modal at z-60 (sits over the wizard z-50). Used for non-evaluable prereqs (D-03). */ export function PrereqConfirmDialog({ rawPrereqText, onCancel, onConfirm }: PrereqConfirmDialogProps) { return (

Voraussetzung nicht prüfbar

Voraussetzung:

„{rawPrereqText}"

Dimension47 kann diese Bedingung nicht automatisch prüfen. Erfüllst du sie?

); } ``` **3. `level-up-resume-banner.tsx`** — UI-SPEC §"Component Contract — DRAFT-Resume Banner" (lines 583-613). Use the exact JSX from UI-SPEC lines 591-613 with these tweaks: ```tsx import { RotateCcw, Trash2 } from 'lucide-react'; import { Button } from '@/shared/components/ui'; export interface LevelUpResumeBannerProps { targetLevel: number; lastEditedRelative: string; // e.g. "vor 3 Tagen" via Intl.RelativeTimeFormat onResume: () => void; onDiscard: () => void; } export function LevelUpResumeBanner({ targetLevel, lastEditedRelative, onResume, onDiscard }: LevelUpResumeBannerProps) { return (

Du hast eine offene Stufenaufstiegs-Session — Stufe {targetLevel}.

Zuletzt bearbeitet: {lastEditedRelative}.

); } ``` **4. `level-up-violations-banner.tsx`** — UI-SPEC §"Component Contract — Pathbuilder-Import-Violations Banner" (lines 617-649). Use the exact JSX from UI-SPEC with these tweaks: ```tsx import { useState } from 'react'; import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'; import { Button } from '@/shared/components/ui'; import type { PrereqViolation } from '@/shared/types'; export interface LevelUpViolationsBannerProps { violations: PrereqViolation[]; } export function LevelUpViolationsBanner({ violations }: LevelUpViolationsBannerProps) { const [expanded, setExpanded] = useState(false); if (violations.length === 0) return null; return (

{violations.length} Talente mit nicht erfüllter Voraussetzung

Beim Import wurde festgestellt, dass folgende Talente Voraussetzungen haben, die der Charakter nicht erfüllt. Liste prüfen und ggf. nachträglich anpassen.

{expanded && (
    {violations.map(v => (
  • {v.featName} — Voraussetzung: {v.prereqText}
  • ))}
)}
); } ``` **Constraints:** All copy in German. Touch targets meet 44px floor. Lucide icons only (no emojis). cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-(choice-card|prereq-confirm|resume-banner|violations-banner)" || echo "tsc clean" - All 4 files exist in `client/src/features/characters/components/level-up/` - `level-up-choice-card.tsx` exports `ChoiceCard` and `ChoiceCardOption` interface - `level-up-choice-card.tsx` contains `Lock`, `AlertTriangle`, `Check` icon imports + the conditional-render block for each - `level-up-prereq-confirm-dialog.tsx` exports `PrereqConfirmDialog` + uses `z-[60]` - `level-up-prereq-confirm-dialog.tsx` contains the German strings: `Voraussetzung nicht prüfbar`, `Trotzdem wählen`, `Abbrechen` - `level-up-resume-banner.tsx` exports `LevelUpResumeBanner` + contains German `Du hast eine offene Stufenaufstiegs-Session` - `level-up-violations-banner.tsx` exports `LevelUpViolationsBanner` + contains the German violations heading - All 4 files use Lucide icons only (no emojis); verify with `grep -E "[\\u{1F000}-\\u{1FFFF}]"` returning no matches - All 4 files use `Button` from `@/shared/components/ui` - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Four shared UI primitives exist matching UI-SPEC; ready for consumption by step components and the wizard container. Task 4: 12 step components (class-features, class-feature-choice, boost, skill-increase, 5×feat, spellcaster, review) client/src/features/characters/components/level-up/level-up-step-class-features.tsx, client/src/features/characters/components/level-up/level-up-step-class-feature-choice.tsx, client/src/features/characters/components/level-up/level-up-step-boost.tsx, client/src/features/characters/components/level-up/level-up-step-skill-increase.tsx, client/src/features/characters/components/level-up/level-up-step-feat-class.tsx, client/src/features/characters/components/level-up/level-up-step-feat-skill.tsx, client/src/features/characters/components/level-up/level-up-step-feat-general.tsx, client/src/features/characters/components/level-up/level-up-step-feat-ancestry.tsx, client/src/features/characters/components/level-up/level-up-step-feat-archetype.tsx, client/src/features/characters/components/level-up/level-up-step-spellcaster.tsx, client/src/features/characters/components/level-up/level-up-step-review.tsx - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (per-step contracts): - Class-Features step: §Wizard-step screen headings (line 156) - Class-Feature-Choice (D-19): §Component Contract — Choice-Klassenmerkmal Sub-Step (lines 480-489) - Boost: §Component Contract — Boost-Set Step (lines 362-423) — FULL JSX provided - Skill-Increase: §Component Contract — Skill-Increase Step (lines 426-450) - Feat steps: §Component Contract — Choice-Card (lines 308-359) — all 5 feat steps reuse ChoiceCard with different filters - FA-Slot: §Component Contract — Free-Archetype-Slot Step (lines 454-463) - Spellcaster: §Component Contract — Spellcaster Step (lines 466-477) - Review: §Component Contract — Review Step (lines 492-565) — Section A choices summary + Section B Vorher/Nachher cards + Section C spellcaster diff + Ändern revision contract - client/src/features/characters/components/level-up/level-up-choice-card.tsx (Task 3 — primitive) - client/src/features/characters/components/level-up/wizard-state-reducer.ts (Task 2 — events to dispatch) - client/src/features/characters/components/feat-detail-modal.tsx (lines 22-29 — featSourceColors map to REUSE) - client/src/features/characters/components/hp-control.tsx (analog: +/- counter pattern for Boost step) Implement each of the 11 step components per UI-SPEC. Each component: - Receives `(state: WizardState, dispatch: (ev: WizardEvent) => void, characterId: string, ...)` as props OR uses a context provider — planner discretion. - Uses the ChoiceCard primitive from Task 3 for talent picks (5 feat steps + class-feature-choice + spellcaster repertoire picks). - Uses the existing FeatDetailModal (or a new `level-up-detail-modal.tsx` if needed) for the Mehr / detail-popover, opened at `z-60` per UI-SPEC. **Critical implementation notes per step:** **A. `level-up-step-class-features.tsx`** — Auto-summary, no input. Renders the `ClassProgression.grants` list for the new level as a read-only list. Header + sub-line per UI-SPEC. Server endpoint to fetch the list: GET ClassProgression for `(className, targetLevel)` — Plan 04 may need a small read endpoint OR the wizard can fetch from a public ClassProgression endpoint. If neither exists, the wizard receives the data via the LevelUpSession's preview endpoint. **B. `level-up-step-class-feature-choice.tsx`** -- D-19 sub-step (Cleric Doctrine, Wizard School, etc.). Calls `api.getLevelUpClassFeatureOptions(characterId, sessionId, optionsRef)` (added in Task 1 alongside the other api.* methods) which hits `GET /characters/:characterId/level-up/:sessionId/class-feature-options/:optionsRef` -- this endpoint is provided by Plan 04's LevelingController (see 01-04-PLAN.md Task 5). Renders one ChoiceCard per option (single-select / radio-group semantics). Dispatches `SET_CLASS_FEATURE_CHOICE`. No server-side edits in this plan -- the endpoint is already present. **C. `level-up-step-boost.tsx`** — UI-SPEC lines 362-423 give the FULL JSX. Use it verbatim with these wires: - State source: `state.choices.boostTargets` (current selection — array of 0..4 strings) - Per attribute, count = 1 if in boostTargets else 0 - Dec: removes the ability from boostTargets. Inc: adds (only if length < 4 and not already present) - Dispatches `SET_BOOST_TARGETS` - Live-preview "wird {newScore}" calls a local `applyBoostLocal(currentScore)` helper (mirror of server's applyAttributeBoost — could import shared lib if a shared package were set up; for now, inline a 5-line client helper) - Footer-helper "X von 4 Boosts gewählt" **D. `level-up-step-skill-increase.tsx`** — UI-SPEC lines 426-450. Renders all skills as compact rows. For each row: current rank → next rank (or "Cap erreicht" chip if `canIncreaseSkill` would return false; mirror Plan 02 logic client-side). Dispatch `SET_SKILL_INCREASE` on click. **E. `level-up-step-feat-class.tsx`** through **`level-up-step-feat-archetype.tsx`** (5 files) -- All five feat steps share the same shape: - Fetch filtered feat list via `api.getLevelUpFeats(characterId, sessionId, slot, includeUnavailable)` (added in Task 1 alongside the other api.* methods). It hits `GET /characters/:characterId/level-up/:sessionId/feats?slot=class|skill|general|ancestry|archetype&includeUnavailable=true|false` returning `FeatWithEval[]` -- this endpoint is provided by Plan 04's LevelingController (see 01-04-PLAN.md Task 5). No server-side edits in this plan. - Render each feat as a ChoiceCard. - For non-evaluable prereqs: show yellow AlertTriangle on the card; clicking the card opens the PrereqConfirmDialog (Task 3) -> on confirm, dispatch `ACKNOWLEDGE_PREREQ_WARNING` + `SET_FEAT`. - For failed prereqs (`{ok:false}`): hidden by default; toggle "Auch nicht erfuellbare anzeigen" passes `includeUnavailable=true` to the api call to reveal them grayed. - Slot-specific: feat-archetype only renders if `state.steps.includes('feat-archetype')` (FA enabled); shows the "vor/nach Dedication" filter per UI-SPEC line 462. **F. `level-up-step-spellcaster.tsx`** — UI-SPEC lines 466-477. Two sub-shapes: - Prepared caster: info strip "+N Slot auf Grad M (automatisch)" + immediate `Weiter` enable. - Spontaneous caster: info strip + repertoire-pick sub-section. Renders spell ChoiceCards filtered by tradition. Dispatch `SET_REPERTOIRE_PICKS`. **G. `level-up-step-review.tsx`** — UI-SPEC lines 492-565. THIS IS THE LOAD-BEARING STEP: - **Section A** — Wahlen-Zusammenfassung: Card with one row per choice. Each row has an `Ändern` link that dispatches `GO_TO_STEP_FROM_REVIEW` (entering revision mode). - **Section B** — Vorher/Nachher cards: Use `useLevelUpPreviewQuery(characterId, sessionId, enabled=true)` from Task 2. Render two `Card`s side-by-side on `sm:`, stacked on mobile. Display `before` / `after` HP-Max, RK, Klassen-DC, Wahrnehmung, Saves. Use `font-mono text-2xl` for stat numbers. Show success-tinted delta chips (`+X`). - **Section C** — Spellcaster diff (only if `preview.spellcaster` is non-null): table of slot increments + repertoire delta. - **Bestätigen button** in the wizard footer (handled by the wizard container, NOT this step) calls `useCommitLevelUpMutation(characterId, sessionId).mutateAsync(...)`. **Constraint specific to all step files:** - Each is a function component with named export. - Props interface ends with `Props`. - All copy in German (UI-SPEC §Wizard-step screen headings line 156 supplies the exact strings). - All touch targets ≥ 44px. - No `: any`. **Server endpoints used by these steps (already provided by Plan 04 -- no patches in this plan):** - `GET /characters/:characterId/level-up/:sessionId/feats?slot=&includeUnavailable=` -> `FeatWithEval[]` - `GET /characters/:characterId/level-up/:sessionId/class-feature-options/:optionsRef` -> `ClassFeatureOption[]` - `GET /characters/:characterId/level-up` -> open DRAFT or 404 (used by character-sheet-page banner detection) All three are declared in 01-04-PLAN.md Task 5 (LevelingController) and 01-04-PLAN.md Task 4 (LevelingService). LevelUpSessionDto already includes `steps: StepKind[]` (Plan 04 Task 1) -- the wizard reads it directly from the start/resume response. cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-step" || echo "tsc clean" - All 11 step files exist in `client/src/features/characters/components/level-up/` - Each file uses `function` export with PascalCase name (e.g. `LevelUpStepBoost`) - Each file imports `Button` and other primitives from `@/shared/components/ui` - Each file uses German strings matching UI-SPEC §Copywriting (line 156) - `level-up-step-boost.tsx` contains the literal string `wird` (live preview text) - `level-up-step-boost.tsx` contains `h-11 w-11` (44px +/- buttons) - `level-up-step-boost.tsx` contains the literal string `Cap bei 18` (warning chip) - `level-up-step-skill-increase.tsx` contains the literal string `Cap erreicht` - `level-up-step-review.tsx` uses `useLevelUpPreviewQuery` (Task 2 hook) - `level-up-step-review.tsx` contains the literal string `Vorher` AND `Nachher` - All step files contain NO `: any` outside comments - All step files contain NO emoji literals - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 - `cd server && npm run build` exits 0 (no server changes in this plan; the build remains green from Plan 04) All 11 step components implemented per UI-SPEC; all use German copy; all use ChoiceCard for talent picks; Review step wires up the preview query. Server endpoints consumed are owned by Plan 04 -- no server-side edits in this plan. Task 5: Wizard container (level-up-wizard.tsx) — chrome, stepper, motion, footer wiring client/src/features/characters/components/level-up/level-up-wizard.tsx - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — chrome/header/stepper/footer all specified): - Chrome §Component Contract — Wizard Chrome (lines 221-305) - Motion §Motion (lines 654-665) - States §States Inventory (lines 671-687) - client/src/features/characters/components/level-up/wizard-state-reducer.ts (Task 2) - client/src/features/characters/components/level-up/use-level-up-session.ts (Task 2) - client/src/features/characters/components/level-up/level-up-step-*.tsx (Task 4 — all 11) - client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx (Task 3) - client/src/features/characters/components/rest-modal.tsx (analog: REST + commit lifecycle) - client/src/features/characters/components/add-feat-modal.tsx (analog: modal chrome) Create `level-up-wizard.tsx` — the outer container that: 1. Renders the modal chrome per UI-SPEC §Component Contract — Wizard Chrome. 2. Loads the session via `useStartLevelUpMutation` on mount (start-or-resume). 3. Reads the step list from the session response: `LevelUpSessionDto.steps: StepKind[]` is populated server-side by Plan 04 (LevelingService.startOrResume runs `computeApplicableSteps` on the character + targetLevel and stores the result in the response payload). The wizard simply uses `session.steps` directly -- no client-side computation, no extra round-trip. 4. Initializes the reducer via `initWizardState(session, steps)`. 5. Renders the active step component based on `state.steps[state.currentIdx]`. 6. PATCHes the DRAFT after each meaningful state change (debounced — every 500ms after a SET_* event). 7. Wraps the active step in `` + `` for slide transitions per UI-SPEC §Motion. 8. Renders the stepper, header (with X close button + character name), and footer (Zurück / Weiter / Bestätigen / Zurück zur Übersicht based on state). 9. Handles the backdrop-click and X-close → opens the "Wizard mid-flow abbrechen" confirm dialog (reuse PrereqConfirmDialog with different copy, OR a separate small dialog component — planner discretion). 10. On Bestätigen: `useCommitLevelUpMutation.mutateAsync()` → on success, close the wizard, fire `onCommitted` callback (parent invalidates character query + shows toast). **Skeleton:** ```tsx import { useEffect, useReducer, useMemo, useState } from 'react'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { X, ChevronLeft, ChevronRight, Check, ArrowLeft } from 'lucide-react'; import { Button, Spinner } from '@/shared/components/ui'; import { cn } from '@/shared/lib/utils'; import { wizardReducer, initWizardState } from './wizard-state-reducer'; import { useStartLevelUpMutation, usePatchLevelUpMutation, useCommitLevelUpMutation, } from './use-level-up-session'; import { LevelUpStepClassFeatures } from './level-up-step-class-features'; import { LevelUpStepClassFeatureChoice } from './level-up-step-class-feature-choice'; import { LevelUpStepBoost } from './level-up-step-boost'; import { LevelUpStepSkillIncrease } from './level-up-step-skill-increase'; import { LevelUpStepFeatClass } from './level-up-step-feat-class'; import { LevelUpStepFeatSkill } from './level-up-step-feat-skill'; import { LevelUpStepFeatGeneral } from './level-up-step-feat-general'; import { LevelUpStepFeatAncestry } from './level-up-step-feat-ancestry'; import { LevelUpStepFeatArchetype } from './level-up-step-feat-archetype'; import { LevelUpStepSpellcaster } from './level-up-step-spellcaster'; import { LevelUpStepReview } from './level-up-step-review'; import type { Character, StepKind } from '@/shared/types'; export interface LevelUpWizardProps { character: Character; onClose: () => void; onCommitted: () => void; } const STEP_LABELS: Record = { 'class-features': 'Merkmale', 'class-feature-choice': 'Wahl', 'boost': 'Boost', 'skill-increase': 'Skill', 'feat-class': 'Klasse', 'feat-skill': 'Fertigkeit', 'feat-general': 'Allgemein', 'feat-ancestry': 'Abstammung', 'feat-archetype': 'Archetyp', 'spellcaster': 'Zauber', 'review': 'Übersicht', }; const STEP_HEADINGS: Record = { 'class-features': { heading: 'Klassenmerkmale auf Stufe {N}', sub: 'Diese Merkmale werden automatisch übernommen.' }, // ... all 11 per UI-SPEC line 156 table }; export function LevelUpWizard({ character, onClose, onCommitted }: LevelUpWizardProps) { const startMut = useStartLevelUpMutation(character.id); const [state, dispatch] = useReducer(wizardReducer, undefined as never); const [direction, setDirection] = useState<'forward' | 'back'>('forward'); const [showAbortDialog, setShowAbortDialog] = useState(false); const reducedMotion = useReducedMotion(); // Start/resume on mount useEffect(() => { startMut.mutate(undefined); }, [character.id]); // Once session loads, init the reducer // (executor wires up: pull steps from session response + initWizardState) // PATCH on choice change — debounced 500ms // (executor wires up: useEffect on state.choices, debounced patchLevelUp) const commitMut = useCommitLevelUpMutation(character.id, state?.sessionId ?? ''); const handleClose = () => { if (state && Object.keys(state.choices).length > 0) { setShowAbortDialog(true); } else { onClose(); } }; const handleCommit = async () => { await commitMut.mutateAsync(state.acknowledgedNonEvaluablePrereqs.length > 0); onCommitted(); onClose(); }; if (startMut.isPending) { return (
); } if (!state) return null; const currentStep = state.steps[state.currentIdx]; const heading = STEP_HEADINGS[currentStep].heading.replace('{N}', String(state.targetLevel)); const isLastStep = state.currentIdx === state.steps.length - 1; const isFirstStep = state.currentIdx === 0; return (
{/* Header */}

Stufenaufstieg — Stufe {state.targetLevel}

{character.name}

{/* Stepper */} {/* Body */}

{heading}

{STEP_HEADINGS[currentStep].sub}

{renderStep(currentStep, state, dispatch, character)}
{/* Footer */}
Schritt {state.currentIdx + 1}/{state.steps.length}
{state.revisionMode ? ( ) : isLastStep ? ( ) : ( )}
{/* Abort confirm dialog */} {showAbortDialog && ( // Reuse PrereqConfirmDialog shape with different copy, OR inline a similar dialog. // Per UI-SPEC §Destructive confirmations: // Heading: 'Wizard abbrechen?' // Body: 'Deine bisherigen Wahlen werden als Entwurf gespeichert und du kannst später fortsetzen.' // Buttons: 'Weiter bearbeiten' (default) + 'Als Entwurf speichern und schließen' (outline) null )}
); } function renderStep(kind: StepKind, state: WizardState, dispatch: (e: WizardEvent) => void, character: Character) { switch (kind) { case 'class-features': return ; case 'class-feature-choice': return ; case 'boost': return ; // ... all 11 } } function isStepValid(kind: StepKind, state: WizardState): boolean { switch (kind) { case 'class-features': return true; case 'boost': return state.choices.boostTargets?.length === 4; case 'skill-increase': return !!state.choices.skillIncrease; case 'feat-class': return !!state.choices.featClassId; // ... per step default: return true; } } ``` **Constraints:** - All copy German. - Modal chrome exactly matches UI-SPEC line 222-238. - Stepper has 44×44 hit-zones with -m-1 negative margin (UI-SPEC line 271-275). - Motion respects `prefers-reduced-motion` (UI-SPEC line 660). cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-wizard" || echo "tsc clean" - File `level-up-wizard.tsx` exists with `LevelUpWizard` named export - File contains `useReducer(wizardReducer` (uses Task 2 reducer) - File contains `useStartLevelUpMutation` AND `useCommitLevelUpMutation` (uses Task 2 hooks) - File contains `` (motion per UI-SPEC §Motion) - File contains `useReducedMotion` (accessibility per UI-SPEC line 660) - File contains `Stufenaufstieg — Stufe` (header German copy) - File contains `Schritt {state.currentIdx + 1} von {state.steps.length}` or close (always-visible progress label per UI-SPEC line 257) - File contains `h-11 w-11` (stepper hit-zone) AND `-m-1` (negative margin to compensate) - File contains `aria-modal="true"` and `role="dialog"` (a11y) - File contains `Bestätigen`, `Zurück`, `Weiter`, `Zurück zur Übersicht` German strings - File contains NO emoji - File contains NO `: any` outside comments - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Wizard container compiles, mounts, manages reducer, drives steps via switch, runs motion, exposes Bestätigen wired to commit mutation, abort flow handled. Task 6: character-sheet-page.tsx — Stufe-steigen button + 2 banner mounts + wizard mount client/src/features/characters/components/character-sheet-page.tsx - client/src/features/characters/components/character-sheet-page.tsx (entire file — header cluster lines 1607-1626; modal mount cluster lines 1652-1666; state pattern lines 122-132) - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (lines 208-217 — Stufe-steigen button states; lines 588 — banner positions) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 728-765 — exact insert positions) Make four additions to `character-sheet-page.tsx`: **Add 1 — State variables** (around lines 122-132 with the other useState's): ```tsx const [showLevelUpWizard, setShowLevelUpWizard] = useState(false); const [hasOpenDraft, setHasOpenDraft] = useState(false); const [openDraftMeta, setOpenDraftMeta] = useState<{ targetLevel: number; updatedAt: string } | null>(null); ``` **Add 2 — Effect to detect open DRAFT on character load** (after the character query): ```tsx useEffect(() => { if (!character) return; // GET /characters/:id/level-up should return the open DRAFT or 404 // Implementation: call api.startLevelUp() with no targetLevel — if a DRAFT exists, it's returned; // if not, a new DRAFT is created. To avoid spurious DRAFT creation on a fresh load, instead // add a separate read-only endpoint. Planner: extend Plan 04 with GET /characters/:id/level-up // (returns DRAFT or 404). For now: lazily check by leaving hasOpenDraft = false until the user // clicks 'Stufe steigen' and the start endpoint resolves it. The banner then hydrates on commit/discard mutations. }, [character]); ``` Note for executor: DRAFT detection uses the `GET /characters/:characterId/level-up` endpoint already provided by Plan 04 (LevelingController.getOpenDraft -- 404 when none, 200 with the DRAFT row when present). Wire it via `api.getOpenLevelUpDraft(characterId)` (added in Task 1) and use `useQuery({ queryKey: ['levelUpDraft', characterId], queryFn: () => api.getOpenLevelUpDraft(characterId), retry: false })`. The banner reads `data` and `setHasOpenDraft(!!data)`. No server-side changes in this plan. **Add 3 — Header button** (in the header cluster around lines 1607-1626 — INSERT AS FIRST in the cluster, left of Download): ```tsx {(isOwner || isGM) && character.level < 20 && ( )} ``` Add the icon imports: `import { Sparkles, RotateCcw } from 'lucide-react';` (or extend existing import). **Add 4 — Banner mounts** (above the avatar header AND below the avatar header for the violations banner): ```tsx {/* Resume banner — above avatar */} {hasOpenDraft && openDraftMeta && ( setShowLevelUpWizard(true)} onDiscard={async () => { // Use useDiscardLevelUpMutation // ... wire it }} /> )} {/* Avatar header (existing) */} {/* Violations banner — below avatar, above tabs */} {character.prereqViolations?.violations && character.prereqViolations.violations.length > 0 && ( )} {/* Tab navigation (existing) */} ``` Add a tiny `formatRelativeDate(iso: string): string` helper using `Intl.RelativeTimeFormat`: ```tsx function formatRelativeDate(iso: string): string { const diffMs = Date.now() - new Date(iso).getTime(); const days = Math.floor(diffMs / 86400000); const rtf = new Intl.RelativeTimeFormat('de-DE', { numeric: 'auto' }); if (days === 0) return rtf.format(0, 'day'); // 'heute' return rtf.format(-days, 'day'); // 'vor 3 Tagen' } ``` **Add 5 — Wizard mount** (in the modal mounts area lines 1652-1666): ```tsx {showLevelUpWizard && ( setShowLevelUpWizard(false)} onCommitted={() => { setShowLevelUpWizard(false); // refetchCharacter or rely on react-query invalidate from the commit mutation }} /> )} ``` **Add 6 — Wire WebSocket callback** to invalidate character query when level_up_committed arrives: Find where `useCharacterSocket` is called (probably elsewhere in the page or a parent). Add the new callback: ```tsx useCharacterSocket({ characterId: character.id, // ... existing callbacks ... onLevelUpCommitted: () => { queryClient.invalidateQueries({ queryKey: ['character', character.id] }); }, }); ``` **Constraints:** - Do NOT change any existing rendering logic for HP, conditions, inventory, etc. The additions are strictly additive. - All visible new copy German. - Use existing icon imports where possible; extend the `lucide-react` import line. cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "character-sheet-page" || echo "tsc clean" - File `character-sheet-page.tsx` contains the literal string `Stufe steigen` (or `Stufe fortsetzen`) - File contains the literal string ` Character sheet now has the entry point + the resume banner + the violations banner + the wizard mount + the WebSocket callback wired. Task 7: Human verification — walk through the wizard on mobile viewport The full Level-Up wizard (Plans 01-05). Player can click "Stufe steigen" on a character sheet, walk through 4-11 steps depending on level/class/FA/caster, see Vorher/Nachher in Review, click Bestätigen, and have the character mutated atomically with a single WebSocket broadcast. Specifically: - Server: 5 REST endpoints + 1 WebSocket event - Client: 1 modal wizard + 11 step components + 2 banners + character-sheet integration - DB: 4 new tables, partial unique index, ClassProgression seeded with 320+ rows - Tests: ≥60 unit + integration tests passing Manual verification is required for the UI surface because: 1. There is no client-side test framework yet (per VALIDATION.md decision — Phase 1 doesn't introduce vitest). 2. UI-SPEC compliance is visual. 3. The "feel" of the wizard on mobile (375×667) is a product judgment. 1. Start the dev environment: ```bash # Terminal 1 cd server && npm run start:dev # Terminal 2 cd client && npm run dev ``` 2. Open Chrome DevTools, set viewport to **iPhone SE (375×667)** (Mobile-First per CLAUDE.md). 3. Log in as a user who owns at least one character below level 20. 4. Open that character's character sheet. 5. **Verify the "Stufe steigen" button appears** at the FIRST position in the header button cluster (left of Download), with `Sparkles` icon and German label. 6. Click "Stufe steigen". **Verify the wizard opens as a bottom-sheet** (mobile) with: - Header: `Stufenaufstieg — Stufe N`, character name beneath, X close button on right. - Stepper: dot row at top, with always-visible `Schritt 1 von M — Merkmale` progress label. - Body: first step (Klassenmerkmale auf Stufe N) heading + sub-line. - Footer: `Zurück` (disabled on first step) + `Weiter`. 7. **Walk through each applicable step.** For a Fighter L4→L5 (boost level), the steps are: Klassenmerkmale → Boost → Skill-Increase → Klassentalent → Fertigkeitstalent → Abstammungstalent → Übersicht. For each step, verify: - The step heading + sub-line match `01-UI-SPEC.md` §Wizard-step screen headings (line 156). - Touch targets are ≥44px (use DevTools "Inspect" → check `Computed → height/width`). - Choice-cards have the correct source-color badges (Klasse=red, Abstammung=blue, etc.) per UI-SPEC §Color §Inherited Exceptions. - For Boost step: 6 attribute rows with +/- buttons; tapping `+` increments the boost count, shows "wird {newScore}" preview, "+1 (Cap bei 18)" chip appears for STR if STR is at 18. Footer hint "X von 4 Boosts gewählt" updates. - The Weiter button is disabled when the step is invalid (e.g. fewer than 4 boosts). 8. **Verify the prereq-confirm dialog (D-03):** if any feat in the Klassentalent / Fertigkeitstalent step has a yellow `AlertTriangle`, click it. A z-60 layered dialog appears with the raw prereq quote. `Trotzdem wählen` selects it; `Abbrechen` cancels. 9. **Reach the Review step.** Verify: - Section A: Wahlen-Zusammenfassung lists every choice made, each with `Ändern` link. - Section B: Vorher / Nachher cards with HP-Max, RK, Klassen-DC, Wahrnehmung, Saves. Numbers are in `font-mono`. `Sparkles` icon next to "Nachher". Positive deltas show as green chips. - Section C: only appears for caster characters; shows slot/cantrip increment. 10. **Test the Ändern revision contract:** click `Ändern` on the Boost row. Wizard navigates back. Footer button changes from `Weiter` to `Zurück zur Übersicht` (with `ArrowLeft` icon). Change a boost. Click `Zurück zur Übersicht`. Wizard returns to Review. 11. **Click Bestätigen.** Verify: - Spinner appears on the button. - Wizard closes. - Toast appears (planner discretion on toast lib): `Stufenaufstieg bestätigt — Stufe N.` - Character header now shows the new level. - HP-Max updated. AC, Saves, Klassen-DC updated. - `hpCurrent` UNCHANGED (Pitfall #9 — verify by checking HP shows e.g. 12/63 NOT 63/63). 12. **Open a second browser tab** on the same character (different user with access — e.g. GM viewing a player's sheet). Confirm the new level + stat block appears within ~1s without a page reload (WebSocket sync). 13. **Test DRAFT-Resume:** - Start a level-up, make some choices, close the wizard via X (the abort dialog appears: confirm with `Als Entwurf speichern und schließen`). - Verify the DRAFT-Resume Banner now appears at the top of the character sheet with `Du hast eine offene Stufenaufstiegs-Session — Stufe N.`, `Zuletzt bearbeitet: vor wenigen Minuten.` - Click `Verwerfen`. Confirm the discard dialog. The DRAFT row disappears. 14. **Test Pathbuilder-Import-Violations Banner (D-06):** - Re-import a Pathbuilder JSON whose feats violate prereqs. Open the character sheet. The yellow violations banner appears below the avatar header. Click `Liste anzeigen` to expand the list of violating feats. 15. **Verify accessibility basics:** - Tab through the wizard with the keyboard. All controls reachable. - The stepper-dot wrappers are keyboard-focusable. - The choice-cards announce their selected/locked/warning state via aria-label. - In DevTools "Rendering" panel, enable `Emulate CSS prefers-reduced-motion: reduce`. Re-walk the wizard. Step transitions should fade only (no horizontal slide). 16. **Verify TypeScript + build green** (executor confirms before this checkpoint): ```bash cd server && npm run build && npm test -- --testPathPattern=leveling cd client && npx tsc --noEmit -p tsconfig.app.json && npm run build ``` 17. If everything checks out, type `approved` to mark the checkpoint passed. 18. If any item fails, describe the issue and the executor revises. Type "approved" or describe issues found (no file writes — manual UI verification only) Pause execution and present the <what-built> and <how-to-verify> sections to the developer. Wait for the <resume-signal>. Do not modify any files during this checkpoint. Developer types "approved" — or describes issues found that block approval. Developer has walked through the wizard on a 375×667 mobile viewport per the <how-to-verify> steps and replied "approved". ## Trust Boundaries | Boundary | Description | |----------|-------------| | browser → REST | All wizard interactions cross HTTP via Plan 04 endpoints; client trusts server's authoritative responses. | | browser → WebSocket | level_up_committed event arrives from server; client invalidates query — no inbound user-tampering surface. | | translated text → React render | German prereq strings + class-feature descriptions from TranslationsService render in the prereq-confirm dialog and ChoiceCard. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-1-W4-01 | Tampering | Player crafts a custom POST commit bypassing the wizard's validation | mitigate | Plan 04 server-side validation (DTO + commit guards) is the source of truth; the wizard is convenience UI. Any client tampering is rejected by the server. | | T-1-W4-02 | Injection | Stored XSS via German feat-prereq translation text rendered in PrereqConfirmDialog or ChoiceCard | mitigate | All German strings rendered via React text-binding (`{prereqText}` inside `

`), never `dangerouslySetInnerHTML`. Verified by code review — no usage of dangerouslySetInnerHTML in any of the new client files. | | T-1-W4-03 | Injection | Stored XSS via class-feature description rendered in ChoiceCard.description | mitigate | Same pattern — text-binding only. Description is `line-clamp-2` truncated CSS-style; full text in the FeatDetailModal also uses text-binding. | | T-1-W4-04 | Information Disclosure | Wizard JSON state PATCHed to server contains user-typed text (e.g. notes) that surfaces to other character viewers | accept | Phase 1 wizard state contains only IDs (featId, optionKey, etc.) and structured choices — no user-typed text. Future steps that add notes need to reconsider. | | T-1-W4-05 | DoS | Rapid-fire PATCHes from the wizard overload the server | mitigate | The patchLevelUp call is debounced 500ms client-side (Task 5 implementation). Server endpoint is light (single update). | After Task 6 (before checkpoint): ```bash # Client TS clean cd client && npx tsc --noEmit -p tsconfig.app.json # Client production build clean cd client && npm run build # Server still builds (no server changes in this plan -- sanity check only) cd server && npm run build # Full server test suite still green cd server && npm test ``` All four commands must exit 0 before the human checkpoint. - 18 new client files exist in `client/src/features/characters/components/level-up/` - 4 extended files (character-sheet-page, api, shared types, use-character-socket) extended additively - All UI text in German; no emojis; only Lucide icons - Touch targets ≥44px throughout - Wizard chrome matches UI-SPEC §Component Contract — Wizard Chrome exactly (header, stepper, body, footer) - Choice-Card primitive matches UI-SPEC §Component Contract — Choice-Card (states, source badges, prereq warning) - Boost step uses h-11 w-11 +/- buttons + live "wird {newScore}" + cap-bei-18 chip - Review step uses font-mono for stat numbers, two-column Vorher/Nachher cards - Ändern revision contract implemented (revision-mode flag + Zurück-zur-Übersicht button — chain re-validation deferred to v2 per must_haves.gotchas) - DRAFT-Resume banner implemented per UI-SPEC §Component Contract — DRAFT-Resume Banner - Pathbuilder-Import-Violations banner implemented per UI-SPEC §Component Contract — Pathbuilder-Import-Violations Banner - WebSocket level_up_committed callback wired to invalidate character query - Motion respects prefers-reduced-motion - TypeScript strict — no `: any` - Client production build clean (`cd client && npm run build` exits 0) - Server still builds and tests green (this plan adds no server changes) - Human verification checkpoint approved After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-05-SUMMARY.md` documenting: - Final file list (all 18 new client files + 4 extended) - Confirmation that the wizard consumes the Plan 04-owned endpoints (GET open-draft, GET feats, GET class-feature-options) without any server-side edits in this plan - Toast library used (if any introduced) or pattern chosen (browser alert is NOT acceptable; planner discretion: react-hot-toast, sonner, or hand-rolled context — record the decision) - Any deviations from UI-SPEC noted with rationale - Result of the human-verification checkpoint