1588 lines
86 KiB
Markdown
1588 lines
86 KiB
Markdown
---
|
||
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 && npx tsc --noEmit -p tsconfig.app.json 2>&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 && npx tsc --noEmit -p tsconfig.app.json 2>&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 && npx tsc --noEmit -p tsconfig.app.json 2>&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 && npx tsc --noEmit -p tsconfig.app.json 2>&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 && npx tsc --noEmit -p tsconfig.app.json 2>&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 && npx tsc --noEmit -p tsconfig.app.json 2>&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 <what-built> and <how-to-verify> sections to the developer. Wait for the <resume-signal>. 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 <how-to-verify> 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>
|