Files
Dimension-47/.planning/phases/01-level-up-pf2e-regelkonform/01-05-PLAN.md

1588 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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: "<LevelUpWizard"
- from: "use-character-socket.ts"
to: "LevelUpWizard / character-sheet"
via: "onLevelUpCommitted callback → react-query invalidate"
pattern: "level_up_committed"
- from: "All step components"
to: "level-up-choice-card.tsx"
via: "import and reuse"
pattern: "from.*level-up-choice-card"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Plan 04 REST contract that this plan consumes -->
<!-- POST /characters/:characterId/level-up (StartLevelUpDto) → LevelUpSession -->
<!-- PATCH /characters/:characterId/level-up/:sessionId (PatchLevelUpDto) → LevelUpSession -->
<!-- GET /characters/:characterId/level-up/:sessionId/preview → LevelUpPreviewDto -->
<!-- POST /characters/:characterId/level-up/:sessionId/commit → Character -->
<!-- DELETE /characters/:characterId/level-up/:sessionId → 204 -->
<!-- WebSocket payload added by Plan 04 (mirror in client) -->
```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 } }
```
<!-- Existing client API base (client/src/shared/lib/api.ts:381-388) — analog: getRestPreview / performRest -->
```typescript
async getRestPreview(campaignId: string, characterId: string) {
const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`);
return response.data;
}
```
<!-- Existing socket hook (client/src/shared/hooks/use-character-socket.ts:15) — extend union -->
```typescript
export type CharacterUpdateType = 'hp' | 'conditions' | ... | 'dying';
// Add: | 'level_up_committed'
```
<!-- Existing source-color map (client/src/features/characters/components/feat-detail-modal.tsx:22-29) — REUSE, don't redefine -->
```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',
};
```
<!-- Existing modal chrome canonical pattern (client/src/features/characters/components/add-feat-modal.tsx:246-260) -->
```tsx
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative w-full sm:max-w-2xl max-h-[90vh] bg-bg-secondary
rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
{/* Header / Body / Footer */}
</div>
</div>
```
<!-- Existing character-sheet header button cluster (character-sheet-page.tsx:1607-1626) — insert "Stufe steigen" first -->
<!-- Existing character-sheet modal mounts (character-sheet-page.tsx:1652-1666) — append <LevelUpWizard> -->
<!-- StepKind type from server lib must be mirrored client-side (or imported via shared/types) -->
```typescript
export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase'
| 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype'
| 'spellcaster' | 'review';
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Extend shared types + client API client + use-character-socket hook</name>
<files>
client/src/shared/types/index.ts,
client/src/shared/lib/api.ts,
client/src/shared/hooks/use-character-socket.ts
</files>
<read_first>
- 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)
</read_first>
<action>
**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<string, string>; // 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<LevelUpSession> {
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<LevelUpSession> {
const response = await this.client.patch(
`/characters/${characterId}/level-up/${sessionId}`,
{ state },
);
return response.data;
}
async getLevelUpPreview(characterId: string, sessionId: string): Promise<LevelUpPreview> {
const response = await this.client.get(
`/characters/${characterId}/level-up/${sessionId}/preview`,
);
return response.data;
}
async commitLevelUp(
characterId: string,
sessionId: string,
acknowledgePrereqWarnings?: boolean,
): Promise<Character> {
const response = await this.client.post(
`/characters/${characterId}/level-up/${sessionId}/commit`,
{ acknowledgePrereqWarnings },
);
return response.data;
}
async discardLevelUp(characterId: string, sessionId: string): Promise<void> {
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<LevelUpSession | null> {
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<unknown[]> {
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<unknown[]> {
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.
</action>
<verify>
<automated>cd client &amp;&amp; npx tsc --noEmit -p tsconfig.app.json 2&gt;&amp;1 | grep -E "level-up|LevelUp|level_up_committed" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>
Shared client surface (types + api + socket) extended to talk to the Plan 04 server endpoints + receive the new WebSocket event.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: wizard-state-reducer.ts + use-level-up-session.ts (state machine + react-query hooks)</name>
<files>
client/src/features/characters/components/level-up/wizard-state-reducer.ts,
client/src/features/characters/components/level-up/use-level-up-session.ts
</files>
<read_first>
- .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)
</read_first>
<action>
**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<LevelUpPreview>({
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.
</action>
<verify>
<automated>cd client &amp;&amp; npx tsc --noEmit -p tsconfig.app.json 2&gt;&amp;1 | grep -E "wizard-state-reducer|use-level-up-session" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
Reducer + hooks compile cleanly; ready to be consumed by the wizard container.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: Choice-Card primitive + Prereq-Confirm dialog + 2 banners (the shared building blocks)</name>
<files>
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
</files>
<read_first>
- .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.)
</read_first>
<action>
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: <button> with the className composition from UI-SPEC line 318-326
// - Top row: title + sub-line + icon stack (Lock, AlertTriangle, Check) in correct order
// - Meta row: source badge, level chip, action-cost icon, rarity chip
// - Description with line-clamp-2 + Mehr link
// - States per UI-SPEC §Locked / Selected / Prereq-warning
return (
<button
onClick={() => !props.isLocked && props.onSelect(props.id)}
disabled={props.isLocked}
className={cn(
'w-full text-left p-4 rounded-xl border transition-colors',
'min-h-[64px]',
!props.isSelected && !props.isLocked && 'bg-bg-tertiary border-border hover:border-border-hover',
props.isSelected && 'bg-bg-tertiary border-primary-500 ring-1 ring-primary-500/40',
props.isLocked && 'bg-bg-tertiary/60 border-border opacity-60 cursor-not-allowed',
)}
title={props.isLocked ? props.lockReason : undefined}
>
{/* Top row */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-text-primary">{props.title}</h4>
{props.englishName && props.englishName !== props.title && (
<p className="text-xs text-text-muted">{props.englishName}</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{props.isLocked && <Lock className="h-4 w-4 text-text-muted" />}
{props.hasNonEvaluablePrereq && (
<AlertTriangle
className="h-4 w-4 text-warning-500"
aria-label="Voraussetzung nicht prüfbar"
/>
)}
{props.isSelected && <Check className="h-4 w-4 text-primary-500" />}
</div>
</div>
{/* Meta row */}
<div className="mt-2 flex flex-wrap gap-2 items-center text-xs">
{props.sourceBadge && (
<span className={cn('px-1.5 py-0.5 rounded font-semibold', props.sourceBadge.colorClass)}>
{props.sourceBadge.label}
</span>
)}
{props.levelChip !== undefined && (
<span className="text-text-secondary">Stufe {props.levelChip}+</span>
)}
{/* Action-cost icon, rarity chip per UI-SPEC */}
</div>
{/* Description */}
{props.description && (
<p className="mt-2 text-sm text-text-secondary line-clamp-2">{props.description}</p>
)}
{props.onMore && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); props.onMore!(); }}
className="mt-1 text-xs text-primary-500 hover:underline"
>
Mehr
</button>
)}
</button>
);
}
```
**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 (
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/40" onClick={onCancel} />
<div className="relative w-full sm:max-w-md p-6 bg-bg-secondary rounded-t-2xl sm:rounded-2xl border border-warning-500/30">
<div className="flex items-center gap-3 mb-3">
<AlertTriangle className="h-5 w-5 text-warning-500" />
<h3 className="text-base font-semibold text-text-primary">Voraussetzung nicht prüfbar</h3>
</div>
<p className="text-sm text-text-secondary mb-2">Voraussetzung:</p>
<p className="text-sm font-semibold text-text-primary mb-4 p-3 rounded-lg bg-bg-tertiary border-l-4 border-warning-500">
„{rawPrereqText}"
</p>
<p className="text-sm text-text-secondary">
Dimension47 kann diese Bedingung nicht automatisch prüfen. Erfüllst du sie?
</p>
<div className="flex gap-3 mt-4 justify-end">
<Button variant="ghost" onClick={onCancel}>Abbrechen</Button>
<Button variant="default" onClick={onConfirm}>Trotzdem wählen</Button>
</div>
</div>
</div>
);
}
```
**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 (
<div className="bg-bg-tertiary border-l-4 border-primary-500 p-4 rounded-r-lg flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<RotateCcw className="h-5 w-5 text-primary-500 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-semibold text-text-primary">
Du hast eine offene Stufenaufstiegs-Session — Stufe {targetLevel}.
</h3>
<p className="text-xs text-text-secondary mt-0.5">
Zuletzt bearbeitet: {lastEditedRelative}.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onDiscard} className="text-error-500">
<Trash2 className="h-4 w-4" />
<span className="ml-1">Verwerfen</span>
</Button>
<Button variant="default" size="sm" onClick={onResume}>
<RotateCcw className="h-4 w-4" />
<span className="ml-1">Fortsetzen</span>
</Button>
</div>
</div>
);
}
```
**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 (
<div className="bg-warning-500/10 border-l-4 border-warning-500 p-4 rounded-r-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-warning-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-sm font-semibold text-warning-500">
{violations.length} Talente mit nicht erfüllter Voraussetzung
</h3>
<p className="text-sm text-text-secondary mt-1">
Beim Import wurde festgestellt, dass folgende Talente Voraussetzungen haben,
die der Charakter nicht erfüllt. Liste prüfen und ggf. nachträglich anpassen.
</p>
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)} className="mt-2 -ml-2">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<span className="ml-1">Liste {expanded ? 'verbergen' : 'anzeigen'}</span>
</Button>
{expanded && (
<ul className="mt-2 space-y-1 text-sm text-text-secondary">
{violations.map(v => (
<li key={v.featId}>
<span className="font-semibold text-text-primary">{v.featName}</span>
<span className="text-text-muted"> — Voraussetzung: {v.prereqText}</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
```
**Constraints:** All copy in German. Touch targets meet 44px floor. Lucide icons only (no emojis).
</action>
<verify>
<automated>cd client &amp;&amp; npx tsc --noEmit -p tsconfig.app.json 2&gt;&amp;1 | grep -E "level-up-(choice-card|prereq-confirm|resume-banner|violations-banner)" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
Four shared UI primitives exist matching UI-SPEC; ready for consumption by step components and the wizard container.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 4: 12 step components (class-features, class-feature-choice, boost, skill-increase, 5×feat, spellcaster, review)</name>
<files>
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
</files>
<read_first>
- .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)
</read_first>
<action>
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=<kind>&includeUnavailable=<bool>` -> `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.
</action>
<verify>
<automated>cd client &amp;&amp; npx tsc --noEmit -p tsconfig.app.json 2&gt;&amp;1 | grep -E "level-up-step" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 5: Wizard container (level-up-wizard.tsx) — chrome, stepper, motion, footer wiring</name>
<files>client/src/features/characters/components/level-up/level-up-wizard.tsx</files>
<read_first>
- .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)
</read_first>
<action>
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 `<AnimatePresence mode="wait">` + `<motion.div ...>` 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<StepKind, string> = {
'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<StepKind, { heading: string; sub: string }> = {
'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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<Spinner />
</div>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/60" onClick={handleClose} />
<div className="relative w-full sm:max-w-2xl max-h-[90vh] bg-bg-secondary rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div>
<h2 className="text-lg font-semibold text-text-primary">Stufenaufstieg — Stufe {state.targetLevel}</h2>
<p className="text-xs text-text-secondary">{character.name}</p>
</div>
<Button variant="ghost" size="icon" onClick={handleClose} aria-label="Schließen">
<X className="h-5 w-5" />
</Button>
</div>
{/* Stepper */}
<nav role="navigation" aria-label="Wizard-Schritte" className="px-4 py-3 border-b border-border">
<div className="text-xs text-text-secondary pb-2">
Schritt {state.currentIdx + 1} von {state.steps.length} — {STEP_LABELS[currentStep]}
</div>
<ol className="flex items-center gap-2 overflow-x-auto">
{state.steps.map((stepKind, idx) => (
// ... per UI-SPEC stepper lines 263-285
<li key={`${stepKind}-${idx}`}>
<button
type="button"
onClick={() => { setDirection(idx < state.currentIdx ? 'back' : 'forward'); dispatch({ type: 'GO_TO_STEP', idx }); }}
disabled={idx > state.currentIdx}
aria-current={idx === state.currentIdx ? 'step' : undefined}
className="h-11 w-11 -m-1 flex items-center justify-center disabled:cursor-not-allowed"
>
<span className={cn(
'rounded-full transition-colors duration-150',
idx === state.currentIdx ? 'h-2.5 w-2.5 bg-primary-500' :
idx < state.currentIdx ? 'h-2 w-2 bg-primary-500/40' :
'h-2 w-2 bg-bg-tertiary',
)} />
</button>
{idx < state.steps.length - 1 && <span className={cn('h-px w-4', idx < state.currentIdx ? 'bg-primary-500/40' : 'bg-bg-tertiary')} />}
</li>
))}
</ol>
</nav>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
<h3 className="text-lg font-semibold text-text-primary mb-1">{heading}</h3>
<p className="text-sm text-text-secondary mb-4">{STEP_HEADINGS[currentStep].sub}</p>
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, x: direction === 'forward' ? 24 : -24 }}
animate={reducedMotion ? { opacity: 1 } : { opacity: 1, x: 0 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, x: direction === 'forward' ? -24 : 24 }}
transition={{ duration: reducedMotion ? 0.1 : 0.2, ease: 'easeOut' }}
>
{renderStep(currentStep, state, dispatch, character)}
</motion.div>
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 p-4 border-t border-border bg-bg-secondary">
<span className="text-xs text-text-secondary">Schritt {state.currentIdx + 1}/{state.steps.length}</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => { setDirection('back'); dispatch({ type: 'GO_PREV' }); }}
disabled={isFirstStep}
>
<ChevronLeft className="h-4 w-4" /> Zurück
</Button>
{state.revisionMode ? (
<Button variant="default" onClick={() => dispatch({ type: 'RETURN_TO_REVIEW' })}>
<ArrowLeft className="h-4 w-4" /> Zurück zur Übersicht
</Button>
) : isLastStep ? (
<Button variant="default" onClick={handleCommit} isLoading={commitMut.isPending}>
<Check className="h-4 w-4" /> Bestätigen
</Button>
) : (
<Button variant="default" onClick={() => { setDirection('forward'); dispatch({ type: 'GO_NEXT' }); }} disabled={!isStepValid(currentStep, state)}>
Weiter <ChevronRight className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
{/* 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
)}
</div>
);
}
function renderStep(kind: StepKind, state: WizardState, dispatch: (e: WizardEvent) => void, character: Character) {
switch (kind) {
case 'class-features': return <LevelUpStepClassFeatures state={state} character={character} />;
case 'class-feature-choice': return <LevelUpStepClassFeatureChoice state={state} dispatch={dispatch} character={character} />;
case 'boost': return <LevelUpStepBoost state={state} dispatch={dispatch} character={character} />;
// ... 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).
</action>
<verify>
<automated>cd client &amp;&amp; npx tsc --noEmit -p tsconfig.app.json 2&gt;&amp;1 | grep -E "level-up-wizard" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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 `<AnimatePresence mode="wait">` (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
</acceptance_criteria>
<done>
Wizard container compiles, mounts, manages reducer, drives steps via switch, runs motion, exposes Bestätigen wired to commit mutation, abort flow handled.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 6: character-sheet-page.tsx — Stufe-steigen button + 2 banner mounts + wizard mount</name>
<files>client/src/features/characters/components/character-sheet-page.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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 && (
<Button
variant="default"
size="sm"
onClick={() => setShowLevelUpWizard(true)}
>
{hasOpenDraft ? <RotateCcw className="h-4 w-4" /> : <Sparkles className="h-4 w-4" />}
<span className="ml-1">{hasOpenDraft ? 'Stufe fortsetzen' : 'Stufe steigen'}</span>
</Button>
)}
```
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 && (
<LevelUpResumeBanner
targetLevel={openDraftMeta.targetLevel}
lastEditedRelative={formatRelativeDate(openDraftMeta.updatedAt)}
onResume={() => 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 && (
<LevelUpViolationsBanner violations={character.prereqViolations.violations} />
)}
{/* 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 && (
<LevelUpWizard
character={character}
onClose={() => 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.
</action>
<verify>
<automated>cd client &amp;&amp; npx tsc --noEmit -p tsconfig.app.json 2&gt;&amp;1 | grep -E "character-sheet-page" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- File `character-sheet-page.tsx` contains the literal string `Stufe steigen` (or `Stufe fortsetzen`)
- File contains the literal string `<LevelUpWizard` (mount JSX)
- File contains the literal string `<LevelUpResumeBanner` (banner mount)
- File contains the literal string `<LevelUpViolationsBanner` (banner mount)
- File contains import for `Sparkles` and `RotateCcw` from `lucide-react`
- File contains import for `LevelUpWizard`, `LevelUpResumeBanner`, `LevelUpViolationsBanner` from `./level-up/...`
- File contains the literal string `onLevelUpCommitted` (WebSocket callback wired)
- `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0
- `cd client && npm run build` exits 0 (full Vite production build)
</acceptance_criteria>
<done>
Character sheet now has the entry point + the resume banner + the violations banner + the wizard mount + the WebSocket callback wired.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 7: Human verification — walk through the wizard on mobile viewport</name>
<what-built>
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.
</what-built>
<how-to-verify>
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.
</how-to-verify>
<resume-signal>Type "approved" or describe issues found</resume-signal>
<files>(no file writes — manual UI verification only)</files>
<action>Pause execution and present the &lt;what-built&gt; and &lt;how-to-verify&gt; sections to the developer. Wait for the &lt;resume-signal&gt;. Do not modify any files during this checkpoint.</action>
<verify>Developer types "approved" — or describes issues found that block approval.</verify>
<done>Developer has walked through the wizard on a 375×667 mobile viewport per the &lt;how-to-verify&gt; steps and replied "approved".</done>
</task>
</tasks>
<threat_model>
## 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 `<p>`), 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). |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
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
</output>