# Character Creation Wizard Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the bare "New Character" inline form in CampaignView with a themed 4-step multi-step wizard that walks players through the full Shadowdark character creation process: name/class/ancestry → stat rolling → background/alignment/deity → review & create. **Architecture:** A standalone `CharacterWizard` modal component manages step state internally. A `character-creation.ts` util handles all dice math (3d6, HP by class, starting gold). A `backgrounds.ts` data file holds all 20 Shadowdark backgrounds. The server `POST /campaigns/:id/characters` endpoint is extended to accept full character data so we can create a complete character in one round-trip. **Tech Stack:** React + TypeScript + CSS Modules (matching existing patterns); existing `getModifier()` from `client/src/utils/modifiers.ts`; existing `getShadowdarkTitle()` from `client/src/utils/shadowdark-titles.ts`; existing `SelectDropdown` component; Express/MariaDB on server. --- ### Task 1: Extend server character creation endpoint **Files:** - Modify: `server/src/routes/characters.ts:93-151` The current endpoint accepts only `name`, `class`, `ancestry`, `hp_max`. We need it to also accept `alignment`, `background`, `deity`, `title`, and stat values so we can create a fully-formed character without a second PATCH call. - [ ] **Step 1: Write the failing test** (manual — run the server and test via curl after implementation) ```bash # After implementation, verify this works: curl -s -X POST http://localhost:3001/api/campaigns/1/characters \ -H "Content-Type: application/json" \ -b "..." \ -d '{ "name": "Tharyn", "class": "Fighter", "ancestry": "Human", "alignment": "Lawful", "background": "Soldier", "deity": "", "title": "Squire", "hp_max": 9, "gp": 30, "stats": {"STR":15,"DEX":10,"CON":12,"INT":8,"WIS":11,"CHA":9} }' | jq '.alignment, .background, .title, .gp' # Expected: "Lawful" "Soldier" "Squire" 30 ``` - [ ] **Step 2: Extend the POST handler to accept full character fields** In `server/src/routes/characters.ts`, replace the destructure at line 97 and the INSERT at line 106: ```typescript // Replace lines 97-120 with: const { name, class: charClass, ancestry, hp_max, alignment, background, deity, title, gp, stats, } = req.body; if (!name?.trim()) { res.status(400).json({ error: "Character name is required" }); return; } const userId = req.user?.userId ?? null; const [result] = await db.execute( `INSERT INTO characters (campaign_id, user_id, name, class, ancestry, hp_current, hp_max, alignment, background, deity, title, gp, color) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ campaignId, userId, name.trim(), charClass ?? "Fighter", ancestry ?? "Human", hp_max ?? 1, hp_max ?? 1, alignment ?? "Neutral", background ?? "", deity ?? "", title ?? "", gp ?? 0, generateCharacterColor(), ] ); const characterId = result.insertId; ``` - [ ] **Step 3: Use provided stat values instead of default 10s** Replace the `Promise.all` stat insert block (lines 123-130) with: ```typescript const statNames = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; const providedStats: Record = stats && typeof stats === "object" ? stats : {}; await Promise.all( statNames.map((stat) => db.execute( "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, ?)", [characterId, stat, providedStats[stat] ?? 10] ) ) ); ``` Then update the enriched object's stats line (currently `DEFAULT_STATS.map((s) => ({ stat_name: s, value: 10 }))`): ```typescript const enriched = { ...charRows[0], overrides: {}, stats: statNames.map((s) => ({ stat_name: s, value: providedStats[s] ?? 10 })), gear: [], talents: [], }; ``` - [ ] **Step 4: Run the server and test** ```bash cd /Users/aaron.wood/workspace/shadowdark/server && npm run dev ``` Confirm no TypeScript errors and the curl test from Step 1 returns expected values. - [ ] **Step 5: Commit** ```bash git -C /Users/aaron.wood/workspace/shadowdark add server/src/routes/characters.ts git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: extend character creation endpoint to accept full character data" ``` --- ### Task 2: Backgrounds data file **Files:** - Create: `client/src/data/backgrounds.ts` 20 official Shadowdark backgrounds from the Player Quickstart, each with a name and the skill it grants. - [ ] **Step 1: Create the file** ```typescript // client/src/data/backgrounds.ts export interface Background { name: string; skill: string; } export const BACKGROUNDS: Background[] = [ { name: "Urchin", skill: "You are never lost in a city and can always find food and shelter in urban areas." }, { name: "Wanted", skill: "You know how to move unseen and blend into crowds to avoid pursuit." }, { name: "Cult Initiate", skill: "You know the rituals, symbols, and secrets of a dark cult." }, { name: "Thieves' Guild", skill: "You can pick locks, pocket items, and recognize guild signs." }, { name: "Banished", skill: "You can survive in the wilderness indefinitely and sense approaching weather." }, { name: "Orphaned", skill: "You can beg, scrounge, and identify charity-givers in any settlement." }, { name: "Wizard's Apprentice", skill: "You can read and identify magical writing and recognize common spell effects." }, { name: "Jeweler", skill: "You can appraise gems, metals, and jewelry with accuracy." }, { name: "Herbalist", skill: "You know which plants heal, harm, or alter the mind in any terrain." }, { name: "Barbarian", skill: "You can track creatures and navigate by the stars in any wilderness." }, { name: "Mercenary", skill: "You know the going rate for violence and can always find hired work." }, { name: "Sailor", skill: "You can navigate by stars and read weather patterns at sea or in the open." }, { name: "Acolyte", skill: "You know the prayers, calendar, and hierarchy of a major religion." }, { name: "Soldier", skill: "You can read a battlefield and know the tactics of common military units." }, { name: "Ranger", skill: "You can move silently through wilderness and identify animal tracks." }, { name: "Scout", skill: "You can estimate distances, draw rough maps, and spot ambushes." }, { name: "Minstrel", skill: "You can perform music, recite epics, and always find a crowd willing to listen." }, { name: "Scholar", skill: "You can read most languages and recall obscure historical and arcane lore." }, { name: "Noble", skill: "You know the etiquette, rumors, and politics of the upper class." }, { name: "Chirurgeon", skill: "You can stabilize the dying and treat wounds to prevent infection." }, ]; export const BACKGROUND_NAMES = BACKGROUNDS.map((b) => b.name); ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git -C /Users/aaron.wood/workspace/shadowdark add client/src/data/backgrounds.ts git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add Shadowdark backgrounds data file" ``` --- ### Task 3: Character creation utilities **Files:** - Create: `client/src/utils/character-creation.ts` Pure math functions used by the wizard: roll 3d6, calculate HP by class, roll starting gold, calculate derived title. - [ ] **Step 1: Create the util file** ```typescript // client/src/utils/character-creation.ts import { getModifier } from "./modifiers.js"; import { getShadowdarkTitle } from "./shadowdark-titles.js"; const CLASS_HIT_DIE: Record = { Fighter: 8, Priest: 6, Thief: 4, Wizard: 4, }; /** Roll 3d6 and return the sum (3–18). */ export function roll3d6(): number { return ( Math.ceil(Math.random() * 6) + Math.ceil(Math.random() * 6) + Math.ceil(Math.random() * 6) ); } /** Roll a full set of 6 stats (STR/DEX/CON/INT/WIS/CHA). */ export function rollAllStats(): Record { return { STR: roll3d6(), DEX: roll3d6(), CON: roll3d6(), INT: roll3d6(), WIS: roll3d6(), CHA: roll3d6(), }; } /** * Roll HP for level 1: roll the class hit die, add CON modifier. * Minimum result is 1. */ export function rollStartingHp(charClass: string, conScore: number): number { const die = CLASS_HIT_DIE[charClass] ?? 4; const rolled = Math.ceil(Math.random() * die); return Math.max(1, rolled + getModifier(conScore)); } /** * Roll starting gold: 2d6 × 5 gp. */ export function rollStartingGold(): number { const d1 = Math.ceil(Math.random() * 6); const d2 = Math.ceil(Math.random() * 6); return (d1 + d2) * 5; } /** * Derive starting gear slots: 10 + STR modifier. */ export function startingGearSlots(strScore: number): number { return 10 + getModifier(strScore); } /** Convenience: get title for a level-1 character. */ export function getStartingTitle(charClass: string, alignment: string): string { return getShadowdarkTitle(charClass, alignment, 1) ?? ""; } ``` - [ ] **Step 2: Verify TypeScript** ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git -C /Users/aaron.wood/workspace/shadowdark add client/src/utils/character-creation.ts git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add character creation utility functions (roll3d6, rollHp, rollGold)" ``` --- ### Task 4: Extend client API function **Files:** - Modify: `client/src/api.ts:69-76` Extend `createCharacter` to accept the full set of fields the server now supports. - [ ] **Step 1: Update the createCharacter function signature** Replace the existing `createCharacter` export: ```typescript // Before: export const createCharacter = ( campaignId: number, data: { name: string; class?: string; ancestry?: string; hp_max?: number }, ) => request(`/campaigns/${campaignId}/characters`, { method: "POST", body: JSON.stringify(data), }); ``` ```typescript // After: export interface CreateCharacterData { name: string; class?: string; ancestry?: string; alignment?: string; background?: string; deity?: string; title?: string; hp_max?: number; gp?: number; stats?: Record; } export const createCharacter = (campaignId: number, data: CreateCharacterData) => request(`/campaigns/${campaignId}/characters`, { method: "POST", body: JSON.stringify(data), }); ``` - [ ] **Step 2: Verify TypeScript compiles** ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git -C /Users/aaron.wood/workspace/shadowdark add client/src/api.ts git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: extend createCharacter API to accept full character fields" ``` --- ### Task 5: CharacterWizard component — step state & step 1 (Name / Class / Ancestry) **Files:** - Create: `client/src/components/CharacterWizard.tsx` - Create: `client/src/components/CharacterWizard.module.css` The wizard manages all step state. Step 1 collects name, class, and ancestry. It validates that name is non-empty before Next is enabled. - [ ] **Step 1: Create the CSS file** ```css /* client/src/components/CharacterWizard.module.css */ /* ── Backdrop ─────────────────────────────────────────── */ .backdrop { position: fixed; inset: 0; background: var(--bg-overlay); display: flex; align-items: center; justify-content: center; z-index: 200; padding: 1rem; } /* ── Dialog ───────────────────────────────────────────── */ .dialog { background-color: var(--bg-modal); background-image: var(--texture-surface), var(--texture-speckle); background-size: 256px 256px, 128px 128px; background-repeat: repeat, repeat; border: 2px solid rgba(var(--gold-rgb), 0.3); border-radius: 4px; padding: 1.5rem; width: 100%; max-width: 480px; box-shadow: 0 8px 40px rgba(var(--shadow-rgb), 0.7), 0 2px 8px rgba(var(--shadow-rgb), 0.5), inset 0 1px 0 rgba(var(--gold-rgb), 0.1), inset 0 0 60px rgba(var(--shadow-rgb), 0.2); } /* ── Header ───────────────────────────────────────────── */ .title { font-family: "Cinzel", Georgia, serif; font-size: 1.2rem; font-weight: 700; color: var(--gold); letter-spacing: 0.05em; text-shadow: 0 1px 2px rgba(var(--shadow-rgb), 0.3); margin-bottom: 0.25rem; } .stepLabel { font-size: 0.75rem; color: var(--text-tertiary); font-family: "Cinzel", Georgia, serif; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 1.25rem; } /* ── Progress dots ────────────────────────────────────── */ .dots { display: flex; gap: 0.4rem; margin-bottom: 1.25rem; } .dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(var(--gold-rgb), 0.2); border: 1px solid rgba(var(--gold-rgb), 0.3); transition: background 0.2s; } .dotActive { background: var(--gold); border-color: var(--gold); } .dotDone { background: rgba(var(--gold-rgb), 0.5); border-color: rgba(var(--gold-rgb), 0.5); } /* ── Form fields ──────────────────────────────────────── */ .field { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; } .fieldLabel { font-family: "Cinzel", Georgia, serif; font-size: 0.75rem; color: var(--text-secondary); text-transform: uppercase; font-weight: 600; letter-spacing: 0.05em; } .input { padding: 0.5rem 0.75rem; background: var(--bg-inset); border: 1px solid rgba(var(--gold-rgb), 0.15); border-radius: 4px; color: var(--text-primary); font-size: 0.9rem; font-family: "Alegreya", Georgia, serif; } .input:focus { outline: none; border-color: var(--gold); } /* ── Stat rolling (step 2) ────────────────────────────── */ .statGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; } .statRow { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; background: var(--bg-inset); border: 1px solid rgba(var(--gold-rgb), 0.12); border-radius: 4px; } .statName { font-family: "Cinzel", Georgia, serif; font-size: 0.7rem; color: var(--text-secondary); letter-spacing: 0.05em; font-weight: 600; } .statValue { font-size: 1.1rem; font-weight: 700; color: var(--text-primary); min-width: 2ch; text-align: right; } .statMod { font-size: 0.75rem; color: var(--text-tertiary); min-width: 3ch; text-align: right; } .rerollBtn { width: 100%; padding: 0.5rem; background: none; border: 1px solid rgba(var(--gold-rgb), 0.25); border-radius: 4px; color: var(--text-secondary); font-family: "Cinzel", Georgia, serif; font-size: 0.8rem; cursor: pointer; letter-spacing: 0.05em; transition: border-color 0.15s, color 0.15s; margin-bottom: 0.75rem; } .rerollBtn:hover { border-color: rgba(var(--gold-rgb), 0.5); color: var(--gold); } /* ── Background list (step 3) ─────────────────────────── */ .bgList { display: flex; flex-direction: column; gap: 0.3rem; max-height: 180px; overflow-y: auto; margin-bottom: 0.75rem; scrollbar-width: thin; scrollbar-color: rgba(var(--gold-rgb), 0.2) transparent; } .bgItem { padding: 0.4rem 0.6rem; border-radius: 4px; border: 1px solid transparent; cursor: pointer; font-size: 0.88rem; font-family: "Alegreya", Georgia, serif; transition: background 0.1s, border-color 0.1s; } .bgItem:hover { background: rgba(var(--gold-rgb), 0.06); } .bgItemSelected { background: rgba(var(--gold-rgb), 0.12); border-color: rgba(var(--gold-rgb), 0.3); color: var(--gold); } .bgSkill { font-size: 0.75rem; color: var(--text-tertiary); font-style: italic; margin-top: 0.2rem; } .randomBtn { width: 100%; padding: 0.35rem; background: none; border: 1px dashed rgba(var(--gold-rgb), 0.2); border-radius: 4px; color: var(--text-tertiary); font-size: 0.8rem; cursor: pointer; font-family: "Cinzel", Georgia, serif; letter-spacing: 0.05em; transition: border-color 0.15s, color 0.15s; margin-bottom: 0.75rem; } .randomBtn:hover { border-color: rgba(var(--gold-rgb), 0.4); color: var(--text-secondary); } /* ── Review (step 4) ──────────────────────────────────── */ .reviewGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem 1rem; margin-bottom: 0.75rem; } .reviewRow { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid rgba(var(--gold-rgb), 0.08); padding-bottom: 0.2rem; } .reviewLabel { font-family: "Cinzel", Georgia, serif; font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } .reviewValue { font-size: 0.88rem; color: var(--text-primary); font-family: "Alegreya", Georgia, serif; } .reviewSectionTitle { font-family: "Cinzel", Georgia, serif; font-size: 0.75rem; color: var(--gold); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.4rem; grid-column: 1 / -1; border-bottom: 1px solid rgba(var(--gold-rgb), 0.15); padding-bottom: 0.2rem; } .reviewStats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.35rem; margin-bottom: 0.75rem; } .reviewStat { text-align: center; padding: 0.35rem; background: var(--bg-inset); border-radius: 4px; border: 1px solid rgba(var(--gold-rgb), 0.1); } .reviewStatName { font-family: "Cinzel", Georgia, serif; font-size: 0.65rem; color: var(--text-tertiary); letter-spacing: 0.05em; } .reviewStatVal { font-size: 1rem; font-weight: 700; color: var(--text-primary); } .reviewStatMod { font-size: 0.7rem; color: var(--text-tertiary); } /* ── Navigation buttons ───────────────────────────────── */ .actions { display: flex; gap: 0.5rem; justify-content: space-between; margin-top: 1rem; } .btnSecondary { padding: 0.5rem 1rem; border-radius: 4px; font-weight: 600; cursor: pointer; font-size: 0.9rem; background: none; border: 1px solid rgba(var(--gold-rgb), 0.2); color: var(--text-secondary); font-family: "Cinzel", Georgia, serif; transition: border-color 0.15s, color 0.15s; } .btnSecondary:hover { border-color: rgba(var(--gold-rgb), 0.4); color: var(--text-primary); } .btnPrimary { padding: 0.5rem 1.25rem; background: var(--btn-gold-bg); color: var(--btn-active-text); border: none; border-radius: 4px; font-family: "Cinzel", Georgia, serif; font-weight: 600; cursor: pointer; font-size: 0.9rem; box-shadow: 0 2px 4px rgba(var(--shadow-rgb), 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1); text-shadow: 0 1px 1px rgba(var(--shadow-rgb), 0.2); } .btnPrimary:hover { background: linear-gradient(180deg, var(--gold-bright), var(--gold-hover) 40%, var(--gold)); } .btnPrimary:disabled { opacity: 0.4; cursor: not-allowed; } ``` - [ ] **Step 2: Create CharacterWizard.tsx with step 1** ```tsx // client/src/components/CharacterWizard.tsx import { useState } from "react"; import type { CreateCharacterData } from "../api.js"; import SelectDropdown from "./SelectDropdown.js"; import { BACKGROUNDS, type Background } from "../data/backgrounds.js"; import { rollAllStats, rollStartingHp, rollStartingGold, startingGearSlots, getStartingTitle, } from "../utils/character-creation.js"; import { getModifier, formatModifier } from "../utils/modifiers.js"; import styles from "./CharacterWizard.module.css"; const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"]; const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"]; const STAT_NAMES = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; const STEP_LABELS = [ "Step 1 of 4 — Name & Origins", "Step 2 of 4 — Ability Scores", "Step 3 of 4 — Background & Beliefs", "Step 4 of 4 — Review & Create", ]; interface Props { campaignId: number; onSubmit: (data: CreateCharacterData) => Promise; onClose: () => void; } export default function CharacterWizard({ onSubmit, onClose }: Props) { const [step, setStep] = useState(0); // Step 1 state const [name, setName] = useState(""); const [charClass, setCharClass] = useState("Fighter"); const [ancestry, setAncestry] = useState("Human"); // Step 2 state const [stats, setStats] = useState>(rollAllStats); // Step 3 state const [selectedBg, setSelectedBg] = useState(null); const [alignment, setAlignment] = useState("Neutral"); const [deity, setDeity] = useState(""); // Derived for review / submit const conMod = getModifier(stats.CON); const hp = rollStartingHp(charClass, stats.CON); const gp = rollStartingGold(); const gearSlots = startingGearSlots(stats.STR); const title = getStartingTitle(charClass, alignment); // Keep hp/gp stable across re-renders by computing once at step transition const [rolledHp, setRolledHp] = useState(0); const [rolledGp, setRolledGp] = useState(0); const [rolledGearSlots, setRolledGearSlots] = useState(10); function handleRerollStats() { setStats(rollAllStats()); } function advanceToStep(nextStep: number) { if (nextStep === 3 && step === 2) { // Roll HP and gold when entering review so they're stable setRolledHp(rollStartingHp(charClass, stats.CON)); setRolledGp(rollStartingGold()); setRolledGearSlots(startingGearSlots(stats.STR)); } setStep(nextStep); } async function handleCreate() { await onSubmit({ name: name.trim(), class: charClass, ancestry, alignment, background: selectedBg?.name ?? "", deity: charClass === "Priest" ? deity : "", title, hp_max: rolledHp, gp: rolledGp, stats, }); } return (
e.stopPropagation()}>
New Character
{STEP_LABELS[step]}
{/* Progress dots */}
{[0, 1, 2, 3].map((i) => (
))}
{/* ── Step 1: Name / Class / Ancestry ── */} {step === 0 && ( <>
setName(e.target.value)} autoFocus placeholder="Tharyn the Bold..." />
)} {/* ── Step 2: Stat Rolling ── */} {step === 1 && ( <>
{STAT_NAMES.map((s) => (
{s} {stats[s]} {formatModifier(getModifier(stats[s]))}
))}
)} {/* ── Step 3: Background / Alignment / Deity ── */} {step === 2 && ( <>
{BACKGROUNDS.map((bg) => (
setSelectedBg(bg)} >
{bg.name}
{selectedBg?.name === bg.name && (
{bg.skill}
)}
))}
{charClass === "Priest" && (
setDeity(e.target.value)} placeholder="Saint Terragnis..." />
)} )} {/* ── Step 4: Review ── */} {step === 3 && ( <>
Character
{[ ["Name", name], ["Class", charClass], ["Ancestry", ancestry], ["Alignment", alignment], ["Title", title], ["Background", selectedBg?.name ?? "—"], ["HP", String(rolledHp)], ["Starting Gold", `${rolledGp} gp`], ["Gear Slots", String(rolledGearSlots)], ...(charClass === "Priest" && deity ? [["Deity", deity]] : []), ].map(([label, value]) => (
{label} {value}
))}
Ability Scores
{STAT_NAMES.map((s) => (
{s}
{stats[s]}
{formatModifier(getModifier(stats[s]))}
))}
)} {/* ── Navigation ── */}
{step < 3 ? ( ) : ( )}
); } ``` - [ ] **Step 3: Verify TypeScript compiles** ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git -C /Users/aaron.wood/workspace/shadowdark add client/src/components/CharacterWizard.tsx client/src/components/CharacterWizard.module.css git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: add CharacterWizard multi-step character creation modal" ``` --- ### Task 6: Wire CharacterWizard into CampaignView **Files:** - Modify: `client/src/pages/CampaignView.tsx` Replace the inline `showCreate` / `newChar` form with `CharacterWizard`. Remove the `newChar` state, the inline form JSX block, and the `CLASSES`/`ANCESTRIES` constants from this file (they now live in the wizard). - [ ] **Step 1: Add the import** At the top of `CampaignView.tsx`, after the existing component imports, add: ```typescript import CharacterWizard from "../components/CharacterWizard.js"; ``` - [ ] **Step 2: Remove newChar state** Remove this block (around lines 45-50): ```typescript const [newChar, setNewChar] = useState({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1, }); ``` - [ ] **Step 3: Update handleCreate** Replace the existing `handleCreate` function (around lines 290-299): ```typescript // Before: async function handleCreate(e: React.FormEvent) { e.preventDefault(); if (!newChar.name.trim()) return; try { await createCharacter(campaignId, newChar); setNewChar({ name: "", class: "Fighter", ancestry: "Human", hp_max: 1 }); setShowCreate(false); } catch (err) { console.error("Failed to create character:", err); } } ``` ```typescript // After: async function handleCreate(data: import("../api.js").CreateCharacterData) { try { await createCharacter(campaignId, data); setShowCreate(false); } catch (err) { console.error("Failed to create character:", err); } } ``` - [ ] **Step 4: Replace inline form JSX with CharacterWizard** Find the inline form block in the JSX (around lines 440-508) — the entire `{showCreate && (
...
)}` block. Replace it with: ```tsx {showCreate && ( setShowCreate(false)} /> )} ``` - [ ] **Step 5: Remove unused imports and constants** Remove `CLASSES` and `ANCESTRIES` from the top of `CampaignView.tsx` (they're now only used internally in `CharacterWizard`). Also remove the `SelectDropdown` import if it's no longer used elsewhere in this file (check — it may still be used in the create form, which is now gone). ```typescript // Remove these lines if no longer used: const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"]; import SelectDropdown from "../components/SelectDropdown.js"; ``` - [ ] **Step 6: Verify TypeScript compiles** ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 7: Commit** ```bash git -C /Users/aaron.wood/workspace/shadowdark add client/src/pages/CampaignView.tsx git -C /Users/aaron.wood/workspace/shadowdark commit -m "feat: replace inline create form with CharacterWizard multi-step modal" ``` --- ### Task 7: Visual QA and CSS polish **Files:** - Modify: `client/src/components/CharacterWizard.tsx` (minor fixes as needed) - Modify: `client/src/components/CharacterWizard.module.css` (polish as needed) Open the app, click "+ Add Character", and walk through all 4 steps. Verify each step works as expected and looks good. - [ ] **Step 1: Start the dev server** ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npm run dev ``` - [ ] **Step 2: Walk through each step manually** Check: - Step 1: Name field auto-focuses. "Next" disabled with empty name. Class/ancestry dropdowns work. - Step 2: Stats show correct values. Reroll All gives new numbers. Modifiers display correctly. - Step 3: Background list scrolls smoothly. Clicking a background shows skill text. Random Background works. Alignment dropdown works. Deity field only shows for Priest. - Step 4: All review values are correct. Title matches class/alignment. HP and gold are reasonable values. Stats block shows all 6 stats. - "Create Character" closes modal and shows new character card in grid. - [ ] **Step 3: Fix any visual issues found** Common things to check: - Background list scroll height feels right (adjust `max-height` in `.bgList`) - Review section not cut off on smaller screens (adjust `max-width` on `.dialog` if needed) - Dark/light mode both look correct - [ ] **Step 4: Commit any CSS fixes** ```bash git -C /Users/aaron.wood/workspace/shadowdark add client/src/components/CharacterWizard.tsx client/src/components/CharacterWizard.module.css git -C /Users/aaron.wood/workspace/shadowdark commit -m "fix: CharacterWizard visual polish" ``` --- ## Implementation Notes **HP is rolled at step transition:** `rolledHp` is set when advancing from step 3 → 4, so the value shown in review is what gets sent to the server. It won't re-roll if the user navigates back and forward. **Gold is also rolled at step transition:** Same pattern as HP — rolled once entering review, stable through multiple visits. **Gear slots are derived from STR:** `startingGearSlots()` returns `10 + STR modifier`. This is shown in review and sent as part of the create payload. The server currently uses `gear_slots_max` — ensure the PATCH call in the server endpoint saves this value. If the server's create endpoint doesn't yet support `gear_slots_max`, add it alongside the other extended fields in Task 1. **Deity field only shows for Priests:** Step 3 conditionally renders the deity input. For all other classes it's skipped (empty string sent to server). **Background is optional:** A player can advance from step 3 without selecting a background — `selectedBg` will be `null` and an empty string is sent to the server. **The `conMod` / `hp` / `gp` / `gearSlots` computed values at the top of the component are not used directly** — they're only used as a seed for the rolled values stored in state. This avoids re-rolling on every render.