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

86 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
01-level-up-pf2e-regelkonform 05 execute 5
01-01
01-02
01-03
01-03b
01-04
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
false
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
react
ui
wizard
level-up
character-sheet
mobile-first
websocket-client
truths artifacts gotchas key_links
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.
path provides exports
client/src/features/characters/components/level-up/level-up-wizard.tsx Outer modal container — header, stepper, motion orchestration, footer wiring
LevelUpWizard
path provides exports
client/src/features/characters/components/level-up/wizard-state-reducer.ts useReducer + WizardState/WizardEvent discriminated unions
wizardReducer
WizardState
WizardEvent
path provides exports
client/src/features/characters/components/level-up/use-level-up-session.ts react-query hooks: start, patch, preview, commit, discard
useStartLevelUpMutation
usePatchLevelUpMutation
useCommitLevelUpMutation
useDiscardLevelUpMutation
useLevelUpPreviewQuery
path provides
client/src/features/characters/components/level-up/level-up-step-*.tsx (12 files) 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 provides exports
client/src/features/characters/components/level-up/level-up-resume-banner.tsx DRAFT-Resume banner — Fortsetzen / Verwerfen
LevelUpResumeBanner
path provides exports
client/src/features/characters/components/level-up/level-up-violations-banner.tsx Pathbuilder-import-violations banner with collapsible list
LevelUpViolationsBanner
path provides
client/src/features/characters/components/character-sheet-page.tsx (extended) Header button + 2 banner mounts + wizard mount
path provides
client/src/shared/lib/api.ts (extended) 8 new methods: startLevelUp, patchLevelUp, getLevelUpPreview, commitLevelUp, discardLevelUp, getOpenLevelUpDraft, getLevelUpFeats, getLevelUpClassFeatureOptions
path provides
client/src/shared/types/index.ts (extended) LevelUpSession, LevelUpPreview, WizardChoices types + extended Character with freeArchetype, prereqViolations
path provides
client/src/shared/hooks/use-character-socket.ts (extended) Adds 'level_up_committed' to CharacterUpdateType union + onLevelUpCommitted callback
Ä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.
from to via pattern
level-up-wizard.tsx LevelingService REST API use-level-up-session hooks → api methods → axios /characters/.*level-up
from to via pattern
character-sheet-page.tsx LevelUpWizard mount showLevelUpWizard state + conditional render <LevelUpWizard
from to via pattern
use-character-socket.ts LevelUpWizard / character-sheet onLevelUpCommitted callback → react-query invalidate level_up_committed
from to via pattern
All step components level-up-choice-card.tsx import and reuse from.*level-up-choice-card
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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
// Server → client (server/src/modules/characters/characters.gateway.ts)
// type='level_up_committed' data: { level: number; derived: { hpMax, ac, classDc, perception, fortitude, reflex, will } }
async getRestPreview(campaignId: string, characterId: string) {
  const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`);
  return response.data;
}
export type CharacterUpdateType = 'hp' | 'conditions' | ... | 'dying';
// Add: | 'level_up_committed'
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',
};
<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>
export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase'
  | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype'
  | 'spellcaster' | 'review';
Task 1: Extend shared types + client API client + use-character-socket hook client/src/shared/types/index.ts, client/src/shared/lib/api.ts, client/src/shared/hooks/use-character-socket.ts - client/src/shared/types/index.ts (entire file — must understand existing Character + Campaign types) - client/src/shared/lib/api.ts (lines 381-388 — getRestPreview/performRest analog; entire file for structure) - client/src/shared/hooks/use-character-socket.ts (entire file — must understand CharacterUpdateType union, existing onXxxUpdate callbacks) - server/src/modules/leveling/dto/level-up-state.dto.ts (Plan 04 — LevelUpSessionDto + LevelUpPreviewDto field shapes) - server/src/modules/characters/characters.gateway.ts (Plan 04 — extended payload doc) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 769-808 — api.ts extension pattern) **Step 1 — Add types to `client/src/shared/types/index.ts`:**
Append to the existing types file:

```typescript
// ============ Phase 1 Level-Up types ============

export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY';

export type StepKind =
  | 'class-features'
  | 'class-feature-choice'
  | 'boost'
  | 'skill-increase'
  | 'feat-class'
  | 'feat-skill'
  | 'feat-general'
  | 'feat-ancestry'
  | 'feat-archetype'
  | 'spellcaster'
  | 'review';

export interface WizardChoices {
  boostTargets?: AbilityAbbreviation[];
  skillIncrease?: { skillName: string; toRank: Proficiency };
  featClassId?: string;
  featSkillId?: string;
  featGeneralId?: string;
  featAncestryId?: string;
  featArchetypeId?: string;
  classFeatureChoices?: Record<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.
cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up|LevelUp|level_up_committed" || echo "tsc clean" - `client/src/shared/types/index.ts` exports `LevelUpSession`, `LevelUpPreview`, `DerivedStats`, `WizardChoices`, `StepKind`, `Proficiency`, `AbilityAbbreviation`, `PrereqViolation` - `client/src/shared/types/index.ts` Character interface has `freeArchetype?: boolean` and `prereqViolations?` fields - `client/src/shared/lib/api.ts` contains 8 new method names: `startLevelUp`, `patchLevelUp`, `getLevelUpPreview`, `commitLevelUp`, `discardLevelUp`, `getOpenLevelUpDraft`, `getLevelUpFeats`, `getLevelUpClassFeatureOptions` - `client/src/shared/hooks/use-character-socket.ts` CharacterUpdateType union contains `'level_up_committed'` - `client/src/shared/hooks/use-character-socket.ts` UseCharacterSocketOptions interface contains `onLevelUpCommitted?:` - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Shared client surface (types + api + socket) extended to talk to the Plan 04 server endpoints + receive the new WebSocket event. Task 2: wizard-state-reducer.ts + use-level-up-session.ts (state machine + react-query hooks) client/src/features/characters/components/level-up/wizard-state-reducer.ts, client/src/features/characters/components/level-up/use-level-up-session.ts - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 354-401 — exact reducer shape; lines 812-816 — react-query hook recommendation) - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — reducer must support every interaction the UI needs, especially the Ändern revision contract) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 819-823 — wizard-state-reducer; no analog — first useReducer in codebase) - server/src/modules/leveling/lib/types.ts (Plan 02 — StepKind, WizardChoices types — mirror in client) - client/src/shared/types/index.ts (Task 1 added the wire types — reuse) **Step 1 — Create `wizard-state-reducer.ts`:**
```typescript
import type { StepKind, WizardChoices } from '@/shared/types';

/**
 * The full wizard state — JSON-serializable so it survives PATCH to the DRAFT.
 * Mirror of server/src/modules/leveling/lib/types.ts WizardChoices, plus client-only
 * navigation/UI fields.
 */
export interface WizardState {
  sessionId: string;
  targetLevel: number;
  steps: StepKind[];
  currentIdx: number;
  choices: WizardChoices;
  acknowledgedNonEvaluablePrereqs: string[];
  revisionMode: { fromStep: number } | null;       // tracks "Ändern" → "Zurück zur Übersicht"
}

export type WizardEvent =
  | { type: 'GO_NEXT' }
  | { type: 'GO_PREV' }
  | { type: 'GO_TO_STEP'; idx: number }
  | { type: 'GO_TO_STEP_FROM_REVIEW'; idx: number }      // sets revisionMode
  // RETURN_TO_REVIEW: clears revisionMode flag and routes back to review step. NOTE: downstream picks are NOT auto-cleared (v1) — commit-time validation in Plan 04 surfaces invalid combos. See must_haves.gotchas.
  | { type: 'RETURN_TO_REVIEW' }
  | { type: 'SET_BOOST_TARGETS'; targets: WizardChoices['boostTargets'] }
  | { type: 'SET_SKILL_INCREASE'; pick: WizardChoices['skillIncrease'] }
  | { type: 'SET_FEAT'; slot: 'class' | 'skill' | 'general' | 'ancestry' | 'archetype'; featId: string }
  | { type: 'SET_CLASS_FEATURE_CHOICE'; key: string; optionId: string }
  | { type: 'SET_REPERTOIRE_PICKS'; picks: string[] }
  | { type: 'ACKNOWLEDGE_PREREQ_WARNING'; featId: string };

export function wizardReducer(state: WizardState, ev: WizardEvent): WizardState {
  switch (ev.type) {
    case 'GO_NEXT':
      return state.currentIdx < state.steps.length - 1
        ? { ...state, currentIdx: state.currentIdx + 1 }
        : state;
    case 'GO_PREV':
      return state.currentIdx > 0
        ? { ...state, currentIdx: state.currentIdx - 1 }
        : state;
    case 'GO_TO_STEP':
      if (ev.idx < 0 || ev.idx > state.currentIdx) return state;  // can't jump forward to incomplete
      return { ...state, currentIdx: ev.idx };
    case 'GO_TO_STEP_FROM_REVIEW': {
      const reviewIdx = state.steps.findIndex(s => s === 'review');
      return { ...state, currentIdx: ev.idx, revisionMode: { fromStep: reviewIdx } };
    }
    case 'RETURN_TO_REVIEW': {
      const reviewIdx = state.steps.findIndex(s => s === 'review');
      // GOTCHA (intentional v1 behaviour): RETURN_TO_REVIEW does NOT auto-clear downstream
      // choices that depended on an upstream revision. Per Plan-05 must_haves.gotchas and
      // D-12 (no live recompute per step — review-only), commit-time validation in Plan 04
      // is the source of truth: invalid combinations surface as a German BadRequestException
      // when the user clicks Bestätigen, and the wizard surfaces the message inline.
      // Refinement to per-dependency clearing is a v2 enhancement.
      return { ...state, currentIdx: reviewIdx, revisionMode: null };
    }
    case 'SET_BOOST_TARGETS':
      return { ...state, choices: { ...state.choices, boostTargets: ev.targets } };
    case 'SET_SKILL_INCREASE':
      return { ...state, choices: { ...state.choices, skillIncrease: ev.pick } };
    case 'SET_FEAT': {
      const fieldName = `feat${ev.slot.charAt(0).toUpperCase() + ev.slot.slice(1)}Id` as keyof WizardChoices;
      return { ...state, choices: { ...state.choices, [fieldName]: ev.featId } };
    }
    case 'SET_CLASS_FEATURE_CHOICE':
      return {
        ...state,
        choices: {
          ...state.choices,
          classFeatureChoices: {
            ...(state.choices.classFeatureChoices ?? {}),
            [ev.key]: ev.optionId,
          },
        },
      };
    case 'SET_REPERTOIRE_PICKS':
      return { ...state, choices: { ...state.choices, spellcasterRepertoirePicks: ev.picks } };
    case 'ACKNOWLEDGE_PREREQ_WARNING':
      return state.acknowledgedNonEvaluablePrereqs.includes(ev.featId)
        ? state
        : { ...state, acknowledgedNonEvaluablePrereqs: [...state.acknowledgedNonEvaluablePrereqs, ev.featId] };
  }
}

/** Initialize the wizard state from a freshly-fetched LevelUpSession. */
export function initWizardState(session: {
  id: string;
  targetLevel: number;
  state: { choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] };
}, steps: StepKind[]): WizardState {
  return {
    sessionId: session.id,
    targetLevel: session.targetLevel,
    steps,
    currentIdx: 0,
    choices: session.state.choices ?? {},
    acknowledgedNonEvaluablePrereqs: session.state.acknowledgedNonEvaluablePrereqs ?? [],
    revisionMode: null,
  };
}
```

**Step 2 — Create `use-level-up-session.ts`** (react-query hooks):

```typescript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/shared/lib/api';
import type { LevelUpSession, LevelUpPreview, WizardChoices } from '@/shared/types';

/**
 * Start or resume a LevelUpSession for the given character.
 */
export function useStartLevelUpMutation(characterId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (targetLevel?: number) => api.startLevelUp(characterId, targetLevel),
    onSuccess: (session) => {
      qc.setQueryData(['levelUpSession', characterId], session);
    },
  });
}

export function usePatchLevelUpMutation(characterId: string, sessionId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (state: Partial<{ choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }>) =>
      api.patchLevelUp(characterId, sessionId, state),
    onSuccess: (session) => {
      qc.setQueryData(['levelUpSession', characterId], session);
    },
  });
}

export function useLevelUpPreviewQuery(characterId: string, sessionId: string, enabled: boolean) {
  return useQuery<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.
cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "wizard-state-reducer|use-level-up-session" || echo "tsc clean" - File `client/src/features/characters/components/level-up/wizard-state-reducer.ts` exists - File exports `wizardReducer`, `WizardState`, `WizardEvent`, `initWizardState` - File contains a `case 'RETURN_TO_REVIEW':` (proves revision-mode contract) - File contains NO `: any` outside comments - File `client/src/features/characters/components/level-up/use-level-up-session.ts` exists - File exports `useStartLevelUpMutation`, `usePatchLevelUpMutation`, `useLevelUpPreviewQuery`, `useCommitLevelUpMutation`, `useDiscardLevelUpMutation` - File imports `useMutation`, `useQuery`, `useQueryClient` from `@tanstack/react-query` - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Reducer + hooks compile cleanly; ready to be consumed by the wizard container. Task 3: Choice-Card primitive + Prereq-Confirm dialog + 2 banners (the shared building blocks) client/src/features/characters/components/level-up/level-up-choice-card.tsx, client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx, client/src/features/characters/components/level-up/level-up-resume-banner.tsx, client/src/features/characters/components/level-up/level-up-violations-banner.tsx - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — these four components are fully specified): - Choice-Card: lines 308-359 (canonical layout, locked / selected / warning states) - Prereq-Confirm Dialog: lines 570-579 (z-60 layered modal, AlertTriangle + raw prereq quote) - DRAFT-Resume Banner: lines 583-613 (full JSX provided in UI-SPEC) - Violations Banner: lines 617-649 (full JSX provided in UI-SPEC) - client/src/features/characters/components/feat-detail-modal.tsx (lines 22-29 — featSourceColors map to REUSE) - client/src/features/characters/components/add-feat-modal.tsx (lines 272-330 — feat card pattern + add-condition-modal.tsx:91-103 — modal chrome) - client/src/shared/components/ui/index.ts (Button, Card, etc. exports) - client/src/index.css (design tokens — bg-bg-tertiary, text-warning-500, etc.) Implement each component EXACTLY per `01-UI-SPEC.md`. Do NOT redesign — UI-SPEC is the contract.
**1. `level-up-choice-card.tsx`** — UI-SPEC §"Component Contract — Choice-Card" (lines 308-359):

```tsx
import { Lock, AlertTriangle, Check } from 'lucide-react';
import { cn } from '@/shared/lib/utils';

export interface ChoiceCardOption {
  id: string;
  title: string;             // German display name
  englishName?: string;      // sub-line if German != English
  description?: string;      // truncated to 2 lines via line-clamp-2
  sourceBadge?: { label: string; colorClass: string };  // colorClass from featSourceColors map
  levelChip?: number;
  actionCost?: 'A' | 'AA' | 'AAA' | 'R' | 'F' | null;   // ActionIcon
  rarityChip?: 'Uncommon' | 'Rare' | 'Unique';
  isSelected: boolean;
  isLocked: boolean;          // shows Lock icon, opacity 60%, disabled
  lockReason?: string;        // tooltip
  hasNonEvaluablePrereq: boolean;  // shows yellow AlertTriangle
  rawPrereqText?: string;     // for AlertTriangle tooltip
  onSelect: (id: string) => void;
  onMore?: () => void;        // opens FeatDetailModal at z-60
}

export function ChoiceCard(props: ChoiceCardOption) {
  // Implement per UI-SPEC §Choice-Card layout.
  // - Container: <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).
cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-(choice-card|prereq-confirm|resume-banner|violations-banner)" || echo "tsc clean" - All 4 files exist in `client/src/features/characters/components/level-up/` - `level-up-choice-card.tsx` exports `ChoiceCard` and `ChoiceCardOption` interface - `level-up-choice-card.tsx` contains `Lock`, `AlertTriangle`, `Check` icon imports + the conditional-render block for each - `level-up-prereq-confirm-dialog.tsx` exports `PrereqConfirmDialog` + uses `z-[60]` - `level-up-prereq-confirm-dialog.tsx` contains the German strings: `Voraussetzung nicht prüfbar`, `Trotzdem wählen`, `Abbrechen` - `level-up-resume-banner.tsx` exports `LevelUpResumeBanner` + contains German `Du hast eine offene Stufenaufstiegs-Session` - `level-up-violations-banner.tsx` exports `LevelUpViolationsBanner` + contains the German violations heading - All 4 files use Lucide icons only (no emojis); verify with `grep -E "[\\u{1F000}-\\u{1FFFF}]"` returning no matches - All 4 files use `Button` from `@/shared/components/ui` - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Four shared UI primitives exist matching UI-SPEC; ready for consumption by step components and the wizard container. Task 4: 12 step components (class-features, class-feature-choice, boost, skill-increase, 5×feat, spellcaster, review) client/src/features/characters/components/level-up/level-up-step-class-features.tsx, client/src/features/characters/components/level-up/level-up-step-class-feature-choice.tsx, client/src/features/characters/components/level-up/level-up-step-boost.tsx, client/src/features/characters/components/level-up/level-up-step-skill-increase.tsx, client/src/features/characters/components/level-up/level-up-step-feat-class.tsx, client/src/features/characters/components/level-up/level-up-step-feat-skill.tsx, client/src/features/characters/components/level-up/level-up-step-feat-general.tsx, client/src/features/characters/components/level-up/level-up-step-feat-ancestry.tsx, client/src/features/characters/components/level-up/level-up-step-feat-archetype.tsx, client/src/features/characters/components/level-up/level-up-step-spellcaster.tsx, client/src/features/characters/components/level-up/level-up-step-review.tsx - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (per-step contracts): - Class-Features step: §Wizard-step screen headings (line 156) - Class-Feature-Choice (D-19): §Component Contract — Choice-Klassenmerkmal Sub-Step (lines 480-489) - Boost: §Component Contract — Boost-Set Step (lines 362-423) — FULL JSX provided - Skill-Increase: §Component Contract — Skill-Increase Step (lines 426-450) - Feat steps: §Component Contract — Choice-Card (lines 308-359) — all 5 feat steps reuse ChoiceCard with different filters - FA-Slot: §Component Contract — Free-Archetype-Slot Step (lines 454-463) - Spellcaster: §Component Contract — Spellcaster Step (lines 466-477) - Review: §Component Contract — Review Step (lines 492-565) — Section A choices summary + Section B Vorher/Nachher cards + Section C spellcaster diff + Ändern revision contract - client/src/features/characters/components/level-up/level-up-choice-card.tsx (Task 3 — primitive) - client/src/features/characters/components/level-up/wizard-state-reducer.ts (Task 2 — events to dispatch) - client/src/features/characters/components/feat-detail-modal.tsx (lines 22-29 — featSourceColors map to REUSE) - client/src/features/characters/components/hp-control.tsx (analog: +/- counter pattern for Boost step) Implement each of the 11 step components per UI-SPEC. Each component: - Receives `(state: WizardState, dispatch: (ev: WizardEvent) => void, characterId: string, ...)` as props OR uses a context provider — planner discretion. - Uses the ChoiceCard primitive from Task 3 for talent picks (5 feat steps + class-feature-choice + spellcaster repertoire picks). - Uses the existing FeatDetailModal (or a new `level-up-detail-modal.tsx` if needed) for the Mehr / detail-popover, opened at `z-60` per UI-SPEC.
**Critical implementation notes per step:**

**A. `level-up-step-class-features.tsx`** — Auto-summary, no input. Renders the `ClassProgression.grants` list for the new level as a read-only list. Header + sub-line per UI-SPEC. Server endpoint to fetch the list: GET ClassProgression for `(className, targetLevel)` — Plan 04 may need a small read endpoint OR the wizard can fetch from a public ClassProgression endpoint. If neither exists, the wizard receives the data via the LevelUpSession's preview endpoint.

**B. `level-up-step-class-feature-choice.tsx`** -- D-19 sub-step (Cleric Doctrine, Wizard School, etc.). Calls `api.getLevelUpClassFeatureOptions(characterId, sessionId, optionsRef)` (added in Task 1 alongside the other api.* methods) which hits `GET /characters/:characterId/level-up/:sessionId/class-feature-options/:optionsRef` -- this endpoint is provided by Plan 04's LevelingController (see 01-04-PLAN.md Task 5). Renders one ChoiceCard per option (single-select / radio-group semantics). Dispatches `SET_CLASS_FEATURE_CHOICE`. No server-side edits in this plan -- the endpoint is already present.

**C. `level-up-step-boost.tsx`** — UI-SPEC lines 362-423 give the FULL JSX. Use it verbatim with these wires:
- State source: `state.choices.boostTargets` (current selection — array of 0..4 strings)
- Per attribute, count = 1 if in boostTargets else 0
- Dec: removes the ability from boostTargets. Inc: adds (only if length < 4 and not already present)
- Dispatches `SET_BOOST_TARGETS`
- Live-preview "wird {newScore}" calls a local `applyBoostLocal(currentScore)` helper (mirror of server's applyAttributeBoost — could import shared lib if a shared package were set up; for now, inline a 5-line client helper)
- Footer-helper "X von 4 Boosts gewählt"

**D. `level-up-step-skill-increase.tsx`** — UI-SPEC lines 426-450. Renders all skills as compact rows. For each row: current rank → next rank (or "Cap erreicht" chip if `canIncreaseSkill` would return false; mirror Plan 02 logic client-side). Dispatch `SET_SKILL_INCREASE` on click.

**E. `level-up-step-feat-class.tsx`** through **`level-up-step-feat-archetype.tsx`** (5 files) -- All five feat steps share the same shape:
- Fetch filtered feat list via `api.getLevelUpFeats(characterId, sessionId, slot, includeUnavailable)` (added in Task 1 alongside the other api.* methods). It hits `GET /characters/:characterId/level-up/:sessionId/feats?slot=class|skill|general|ancestry|archetype&includeUnavailable=true|false` returning `FeatWithEval[]` -- this endpoint is provided by Plan 04's LevelingController (see 01-04-PLAN.md Task 5). No server-side edits in this plan.
- Render each feat as a ChoiceCard.
- For non-evaluable prereqs: show yellow AlertTriangle on the card; clicking the card opens the PrereqConfirmDialog (Task 3) -> on confirm, dispatch `ACKNOWLEDGE_PREREQ_WARNING` + `SET_FEAT`.
- For failed prereqs (`{ok:false}`): hidden by default; toggle "Auch nicht erfuellbare anzeigen" passes `includeUnavailable=true` to the api call to reveal them grayed.
- Slot-specific: feat-archetype only renders if `state.steps.includes('feat-archetype')` (FA enabled); shows the "vor/nach Dedication" filter per UI-SPEC line 462.

**F. `level-up-step-spellcaster.tsx`** — UI-SPEC lines 466-477. Two sub-shapes:
- Prepared caster: info strip "+N Slot auf Grad M (automatisch)" + immediate `Weiter` enable.
- Spontaneous caster: info strip + repertoire-pick sub-section. Renders spell ChoiceCards filtered by tradition. Dispatch `SET_REPERTOIRE_PICKS`.

**G. `level-up-step-review.tsx`** — UI-SPEC lines 492-565. THIS IS THE LOAD-BEARING STEP:
- **Section A** — Wahlen-Zusammenfassung: Card with one row per choice. Each row has an `Ändern` link that dispatches `GO_TO_STEP_FROM_REVIEW` (entering revision mode).
- **Section B** — Vorher/Nachher cards: Use `useLevelUpPreviewQuery(characterId, sessionId, enabled=true)` from Task 2. Render two `Card`s side-by-side on `sm:`, stacked on mobile. Display `before` / `after` HP-Max, RK, Klassen-DC, Wahrnehmung, Saves. Use `font-mono text-2xl` for stat numbers. Show success-tinted delta chips (`+X`).
- **Section C** — Spellcaster diff (only if `preview.spellcaster` is non-null): table of slot increments + repertoire delta.
- **Bestätigen button** in the wizard footer (handled by the wizard container, NOT this step) calls `useCommitLevelUpMutation(characterId, sessionId).mutateAsync(...)`.

**Constraint specific to all step files:**
- Each is a function component with named export.
- Props interface ends with `Props`.
- All copy in German (UI-SPEC §Wizard-step screen headings line 156 supplies the exact strings).
- All touch targets ≥ 44px.
- No `: any`.

**Server endpoints used by these steps (already provided by Plan 04 -- no patches in this plan):**
  - `GET /characters/:characterId/level-up/:sessionId/feats?slot=<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.
cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-step" || echo "tsc clean" - All 11 step files exist in `client/src/features/characters/components/level-up/` - Each file uses `function` export with PascalCase name (e.g. `LevelUpStepBoost`) - Each file imports `Button` and other primitives from `@/shared/components/ui` - Each file uses German strings matching UI-SPEC §Copywriting (line 156) - `level-up-step-boost.tsx` contains the literal string `wird` (live preview text) - `level-up-step-boost.tsx` contains `h-11 w-11` (44px +/- buttons) - `level-up-step-boost.tsx` contains the literal string `Cap bei 18` (warning chip) - `level-up-step-skill-increase.tsx` contains the literal string `Cap erreicht` - `level-up-step-review.tsx` uses `useLevelUpPreviewQuery` (Task 2 hook) - `level-up-step-review.tsx` contains the literal string `Vorher` AND `Nachher` - All step files contain NO `: any` outside comments - All step files contain NO emoji literals - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 - `cd server && npm run build` exits 0 (no server changes in this plan; the build remains green from Plan 04) All 11 step components implemented per UI-SPEC; all use German copy; all use ChoiceCard for talent picks; Review step wires up the preview query. Server endpoints consumed are owned by Plan 04 -- no server-side edits in this plan. Task 5: Wizard container (level-up-wizard.tsx) — chrome, stepper, motion, footer wiring client/src/features/characters/components/level-up/level-up-wizard.tsx - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — chrome/header/stepper/footer all specified): - Chrome §Component Contract — Wizard Chrome (lines 221-305) - Motion §Motion (lines 654-665) - States §States Inventory (lines 671-687) - client/src/features/characters/components/level-up/wizard-state-reducer.ts (Task 2) - client/src/features/characters/components/level-up/use-level-up-session.ts (Task 2) - client/src/features/characters/components/level-up/level-up-step-*.tsx (Task 4 — all 11) - client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx (Task 3) - client/src/features/characters/components/rest-modal.tsx (analog: REST + commit lifecycle) - client/src/features/characters/components/add-feat-modal.tsx (analog: modal chrome) Create `level-up-wizard.tsx` — the outer container that: 1. Renders the modal chrome per UI-SPEC §Component Contract — Wizard Chrome. 2. Loads the session via `useStartLevelUpMutation` on mount (start-or-resume). 3. Reads the step list from the session response: `LevelUpSessionDto.steps: StepKind[]` is populated server-side by Plan 04 (LevelingService.startOrResume runs `computeApplicableSteps` on the character + targetLevel and stores the result in the response payload). The wizard simply uses `session.steps` directly -- no client-side computation, no extra round-trip. 4. Initializes the reducer via `initWizardState(session, steps)`. 5. Renders the active step component based on `state.steps[state.currentIdx]`. 6. PATCHes the DRAFT after each meaningful state change (debounced — every 500ms after a SET_* event). 7. Wraps the active step in `` + `` for slide transitions per UI-SPEC §Motion. 8. Renders the stepper, header (with X close button + character name), and footer (Zurück / Weiter / Bestätigen / Zurück zur Übersicht based on state). 9. Handles the backdrop-click and X-close → opens the "Wizard mid-flow abbrechen" confirm dialog (reuse PrereqConfirmDialog with different copy, OR a separate small dialog component — planner discretion). 10. On Bestätigen: `useCommitLevelUpMutation.mutateAsync()` → on success, close the wizard, fire `onCommitted` callback (parent invalidates character query + shows toast).
**Skeleton:**

```tsx
import { useEffect, useReducer, useMemo, useState } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Check, ArrowLeft } from 'lucide-react';
import { Button, Spinner } from '@/shared/components/ui';
import { cn } from '@/shared/lib/utils';
import { wizardReducer, initWizardState } from './wizard-state-reducer';
import {
  useStartLevelUpMutation,
  usePatchLevelUpMutation,
  useCommitLevelUpMutation,
} from './use-level-up-session';
import { LevelUpStepClassFeatures } from './level-up-step-class-features';
import { LevelUpStepClassFeatureChoice } from './level-up-step-class-feature-choice';
import { LevelUpStepBoost } from './level-up-step-boost';
import { LevelUpStepSkillIncrease } from './level-up-step-skill-increase';
import { LevelUpStepFeatClass } from './level-up-step-feat-class';
import { LevelUpStepFeatSkill } from './level-up-step-feat-skill';
import { LevelUpStepFeatGeneral } from './level-up-step-feat-general';
import { LevelUpStepFeatAncestry } from './level-up-step-feat-ancestry';
import { LevelUpStepFeatArchetype } from './level-up-step-feat-archetype';
import { LevelUpStepSpellcaster } from './level-up-step-spellcaster';
import { LevelUpStepReview } from './level-up-step-review';
import type { Character, StepKind } from '@/shared/types';

export interface LevelUpWizardProps {
  character: Character;
  onClose: () => void;
  onCommitted: () => void;
}

const STEP_LABELS: Record<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).
cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-wizard" || echo "tsc clean" - File `level-up-wizard.tsx` exists with `LevelUpWizard` named export - File contains `useReducer(wizardReducer` (uses Task 2 reducer) - File contains `useStartLevelUpMutation` AND `useCommitLevelUpMutation` (uses Task 2 hooks) - File contains `` (motion per UI-SPEC §Motion) - File contains `useReducedMotion` (accessibility per UI-SPEC line 660) - File contains `Stufenaufstieg — Stufe` (header German copy) - File contains `Schritt {state.currentIdx + 1} von {state.steps.length}` or close (always-visible progress label per UI-SPEC line 257) - File contains `h-11 w-11` (stepper hit-zone) AND `-m-1` (negative margin to compensate) - File contains `aria-modal="true"` and `role="dialog"` (a11y) - File contains `Bestätigen`, `Zurück`, `Weiter`, `Zurück zur Übersicht` German strings - File contains NO emoji - File contains NO `: any` outside comments - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 Wizard container compiles, mounts, manages reducer, drives steps via switch, runs motion, exposes Bestätigen wired to commit mutation, abort flow handled. Task 6: character-sheet-page.tsx — Stufe-steigen button + 2 banner mounts + wizard mount client/src/features/characters/components/character-sheet-page.tsx - client/src/features/characters/components/character-sheet-page.tsx (entire file — header cluster lines 1607-1626; modal mount cluster lines 1652-1666; state pattern lines 122-132) - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (lines 208-217 — Stufe-steigen button states; lines 588 — banner positions) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 728-765 — exact insert positions) Make four additions to `character-sheet-page.tsx`:
**Add 1 — State variables** (around lines 122-132 with the other useState's):
```tsx
const [showLevelUpWizard, setShowLevelUpWizard] = useState(false);
const [hasOpenDraft, setHasOpenDraft] = useState(false);
const [openDraftMeta, setOpenDraftMeta] = useState<{ targetLevel: number; updatedAt: string } | null>(null);
```

**Add 2 — Effect to detect open DRAFT on character load** (after the character query):
```tsx
useEffect(() => {
  if (!character) return;
  // GET /characters/:id/level-up should return the open DRAFT or 404
  // Implementation: call api.startLevelUp() with no targetLevel — if a DRAFT exists, it's returned;
  // if not, a new DRAFT is created. To avoid spurious DRAFT creation on a fresh load, instead
  // add a separate read-only endpoint. Planner: extend Plan 04 with GET /characters/:id/level-up
  // (returns DRAFT or 404). For now: lazily check by leaving hasOpenDraft = false until the user
  // clicks 'Stufe steigen' and the start endpoint resolves it. The banner then hydrates on commit/discard mutations.
}, [character]);
```

Note for executor: DRAFT detection uses the `GET /characters/:characterId/level-up` endpoint already provided by Plan 04 (LevelingController.getOpenDraft -- 404 when none, 200 with the DRAFT row when present). Wire it via `api.getOpenLevelUpDraft(characterId)` (added in Task 1) and use `useQuery({ queryKey: ['levelUpDraft', characterId], queryFn: () => api.getOpenLevelUpDraft(characterId), retry: false })`. The banner reads `data` and `setHasOpenDraft(!!data)`. No server-side changes in this plan.

**Add 3 — Header button** (in the header cluster around lines 1607-1626 — INSERT AS FIRST in the cluster, left of Download):

```tsx
{(isOwner || isGM) && character.level < 20 && (
  <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.
cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "character-sheet-page" || echo "tsc clean" - File `character-sheet-page.tsx` contains the literal string `Stufe steigen` (or `Stufe fortsetzen`) - File contains the literal string ` Character sheet now has the entry point + the resume banner + the violations banner + the wizard mount + the WebSocket callback wired. Task 7: Human verification — walk through the wizard on mobile viewport The full Level-Up wizard (Plans 01-05). Player can click "Stufe steigen" on a character sheet, walk through 4-11 steps depending on level/class/FA/caster, see Vorher/Nachher in Review, click Bestätigen, and have the character mutated atomically with a single WebSocket broadcast.
Specifically:
- Server: 5 REST endpoints + 1 WebSocket event
- Client: 1 modal wizard + 11 step components + 2 banners + character-sheet integration
- DB: 4 new tables, partial unique index, ClassProgression seeded with 320+ rows
- Tests: ≥60 unit + integration tests passing

Manual verification is required for the UI surface because:
1. There is no client-side test framework yet (per VALIDATION.md decision — Phase 1 doesn't introduce vitest).
2. UI-SPEC compliance is visual.
3. The "feel" of the wizard on mobile (375×667) is a product judgment.
1. Start the dev environment: ```bash # Terminal 1 cd server && npm run start:dev # Terminal 2 cd client && npm run dev ``` 2. Open Chrome DevTools, set viewport to **iPhone SE (375×667)** (Mobile-First per CLAUDE.md). 3. Log in as a user who owns at least one character below level 20. 4. Open that character's character sheet. 5. **Verify the "Stufe steigen" button appears** at the FIRST position in the header button cluster (left of Download), with `Sparkles` icon and German label. 6. Click "Stufe steigen". **Verify the wizard opens as a bottom-sheet** (mobile) with: - Header: `Stufenaufstieg — Stufe N`, character name beneath, X close button on right. - Stepper: dot row at top, with always-visible `Schritt 1 von M — Merkmale` progress label. - Body: first step (Klassenmerkmale auf Stufe N) heading + sub-line. - Footer: `Zurück` (disabled on first step) + `Weiter`. 7. **Walk through each applicable step.** For a Fighter L4→L5 (boost level), the steps are: Klassenmerkmale → Boost → Skill-Increase → Klassentalent → Fertigkeitstalent → Abstammungstalent → Übersicht. For each step, verify: - The step heading + sub-line match `01-UI-SPEC.md` §Wizard-step screen headings (line 156). - Touch targets are ≥44px (use DevTools "Inspect" → check `Computed → height/width`). - Choice-cards have the correct source-color badges (Klasse=red, Abstammung=blue, etc.) per UI-SPEC §Color §Inherited Exceptions. - For Boost step: 6 attribute rows with +/- buttons; tapping `+` increments the boost count, shows "wird {newScore}" preview, "+1 (Cap bei 18)" chip appears for STR if STR is at 18. Footer hint "X von 4 Boosts gewählt" updates. - The Weiter button is disabled when the step is invalid (e.g. fewer than 4 boosts). 8. **Verify the prereq-confirm dialog (D-03):** if any feat in the Klassentalent / Fertigkeitstalent step has a yellow `AlertTriangle`, click it. A z-60 layered dialog appears with the raw prereq quote. `Trotzdem wählen` selects it; `Abbrechen` cancels. 9. **Reach the Review step.** Verify: - Section A: Wahlen-Zusammenfassung lists every choice made, each with `Ändern` link. - Section B: Vorher / Nachher cards with HP-Max, RK, Klassen-DC, Wahrnehmung, Saves. Numbers are in `font-mono`. `Sparkles` icon next to "Nachher". Positive deltas show as green chips. - Section C: only appears for caster characters; shows slot/cantrip increment. 10. **Test the Ändern revision contract:** click `Ändern` on the Boost row. Wizard navigates back. Footer button changes from `Weiter` to `Zurück zur Übersicht` (with `ArrowLeft` icon). Change a boost. Click `Zurück zur Übersicht`. Wizard returns to Review. 11. **Click Bestätigen.** Verify: - Spinner appears on the button. - Wizard closes. - Toast appears (planner discretion on toast lib): `Stufenaufstieg bestätigt — Stufe N.` - Character header now shows the new level. - HP-Max updated. AC, Saves, Klassen-DC updated. - `hpCurrent` UNCHANGED (Pitfall #9 — verify by checking HP shows e.g. 12/63 NOT 63/63). 12. **Open a second browser tab** on the same character (different user with access — e.g. GM viewing a player's sheet). Confirm the new level + stat block appears within ~1s without a page reload (WebSocket sync). 13. **Test DRAFT-Resume:** - Start a level-up, make some choices, close the wizard via X (the abort dialog appears: confirm with `Als Entwurf speichern und schließen`). - Verify the DRAFT-Resume Banner now appears at the top of the character sheet with `Du hast eine offene Stufenaufstiegs-Session — Stufe N.`, `Zuletzt bearbeitet: vor wenigen Minuten.` - Click `Verwerfen`. Confirm the discard dialog. The DRAFT row disappears. 14. **Test Pathbuilder-Import-Violations Banner (D-06):** - Re-import a Pathbuilder JSON whose feats violate prereqs. Open the character sheet. The yellow violations banner appears below the avatar header. Click `Liste anzeigen` to expand the list of violating feats. 15. **Verify accessibility basics:** - Tab through the wizard with the keyboard. All controls reachable. - The stepper-dot wrappers are keyboard-focusable. - The choice-cards announce their selected/locked/warning state via aria-label. - In DevTools "Rendering" panel, enable `Emulate CSS prefers-reduced-motion: reduce`. Re-walk the wizard. Step transitions should fade only (no horizontal slide). 16. **Verify TypeScript + build green** (executor confirms before this checkpoint): ```bash cd server && npm run build && npm test -- --testPathPattern=leveling cd client && npx tsc --noEmit -p tsconfig.app.json && npm run build ``` 17. If everything checks out, type `approved` to mark the checkpoint passed. 18. If any item fails, describe the issue and the executor revises. Type "approved" or describe issues found (no file writes — manual UI verification only) Pause execution and present the <what-built> and <how-to-verify> sections to the developer. Wait for the <resume-signal>. Do not modify any files during this checkpoint. Developer types "approved" — or describes issues found that block approval. Developer has walked through the wizard on a 375×667 mobile viewport per the <how-to-verify> steps and replied "approved".

<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>
After Task 6 (before checkpoint):
# 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.

<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>
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