36 KiB
View/Edit Mode Redesign — 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: Redesign CharacterDetail as a view/edit mode split with a clean "Modern Card Panels" layout in view mode and full editing controls in edit mode.
Architecture: Split CharacterDetail.tsx into CharacterDetail (modal shell + mode state), CharacterSheet (layout), and three panel components (StatsPanel, InfoPanel, GearPanel). Existing sub-components (StatBlock, TalentList, GearList, etc.) gain a mode prop to conditionally show/hide interactive controls. No backend changes.
Tech Stack: React 18, TypeScript, CSS Modules (same as existing)
File Structure — New and Modified Files
client/src/components/
├── CharacterDetail.tsx # REWRITE: modal shell only, mode state
├── CharacterDetail.module.css # REWRITE: just overlay/modal styles
├── CharacterSheet.tsx # CREATE: header banner + 3-panel layout
├── CharacterSheet.module.css # CREATE: banner + panel grid styles
├── StatsPanel.tsx # CREATE: ability scores + attacks panel
├── StatsPanel.module.css # CREATE: compact stat list styles
├── InfoPanel.tsx # CREATE: talents + character info panel
├── InfoPanel.module.css # CREATE: info display/edit styles
├── GearPanel.tsx # CREATE: gear list + currency panel
├── GearPanel.module.css # CREATE: gear table + icon styles
├── StatBlock.tsx # MODIFY: add mode prop to hide +/- buttons
├── StatBlock.module.css # (unchanged)
├── TalentList.tsx # MODIFY: add mode prop to hide add/remove
├── TalentList.module.css # (unchanged)
├── GearList.tsx # MODIFY: add mode prop to hide add/remove
├── GearList.module.css # MODIFY: gear type icons
├── CurrencyRow.tsx # MODIFY: add mode prop for +/- vs direct input
├── CurrencyRow.module.css # MODIFY: +/- button styles
├── AcDisplay.tsx # MODIFY: add mode prop to disable override
├── HpBar.tsx # (unchanged — always shows +/-)
├── AttackBlock.tsx # (unchanged — always read-only)
└── ItemPicker.tsx # (unchanged — only rendered in edit mode)
client/src/pages/
└── CampaignView.tsx # MODIFY: pass new props to CharacterDetail
Task 1: Add mode Prop to Existing Sub-Components
Files:
-
Modify:
client/src/components/StatBlock.tsx -
Modify:
client/src/components/TalentList.tsx -
Modify:
client/src/components/GearList.tsx -
Modify:
client/src/components/GearList.module.css -
Modify:
client/src/components/CurrencyRow.tsx -
Modify:
client/src/components/CurrencyRow.module.css -
Modify:
client/src/components/AcDisplay.tsx -
Step 1: Update StatBlock.tsx — add
modeprop
Read the file first. Add mode to the props interface and conditionally render +/- buttons:
interface StatBlockProps {
stats: Stat[];
onStatChange: (statName: string, newValue: number) => void;
mode?: "view" | "edit";
}
In the JSX, wrap each button pair with a mode check. Replace the button rendering inside the statRow div:
<div className={styles.statRow}>
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, value - 1)}
>
−
</button>
)}
<span className={styles.modifier}>{formatModifier(mod)}</span>
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, value + 1)}
>
+
</button>
)}
</div>
Default mode to 'view' in the destructuring: { stats, onStatChange, mode = 'view' }
Add a rollSpace div after the modifier in view mode for future dice buttons:
{
mode === "view" && <span className={styles.rollSpace}></span>;
}
Add to StatBlock.module.css:
.rollSpace {
width: 2.5rem;
}
- Step 2: Update TalentList.tsx — add
modeprop
Read the file first. Add mode to props:
interface TalentListProps {
talents: Talent[];
onAdd: (data: { name: string; description: string }) => void;
onRemove: (talentId: number) => void;
mode?: "view" | "edit";
}
Default to 'view'. Conditionally render the remove buttons and add form:
-
Wrap each remove button:
{mode === 'edit' && (<button ...>✕</button>)} -
Wrap the entire add form:
{mode === 'edit' && (<form ...>...</form>)} -
Step 3: Update GearList.tsx — add
modeprop and gear type icons
Read the file first. Add mode to props:
interface GearListProps {
gear: Gear[];
gp: number;
sp: number;
cp: number;
slotsUsed: number;
slotsMax: number;
onAddFromItem: (item: GameItem) => void;
onAddCustom: (data: {
name: string;
type: string;
slot_count: number;
}) => void;
onRemove: (gearId: number) => void;
onCurrencyChange: (field: "gp" | "sp" | "cp", value: number) => void;
mode?: "view" | "edit";
}
Default to 'view'.
Replace the type badge column with a unicode icon. Add a helper function:
function gearIcon(type: string): string {
switch (type) {
case "weapon":
return "⚔";
case "armor":
return "🛡";
case "spell":
return "✨";
default:
return "⚙";
}
}
In the table, replace the type badge <span> with:
<td className={`${styles.cell} ${styles.center}`}>
<span title={item.type}>{gearIcon(item.type)}</span>
</td>
Conditionally render remove buttons: {mode === 'edit' && (<button ...>✕</button>)}
Conditionally render the add area: {mode === 'edit' && (<div className={styles.addArea}>...</div>)}
In GearList.module.css, remove the .typeBadge, .weapon, .armor, .gear, .spell classes (no longer needed).
- Step 4: Update CurrencyRow.tsx — add
modeprop for +/- vs direct input
Read the file first. Add mode to props:
interface CurrencyRowProps {
gp: number;
sp: number;
cp: number;
onChange: (field: "gp" | "sp" | "cp", value: number) => void;
mode?: "view" | "edit";
}
Default to 'view'. In view mode, show +/- buttons around the value. In edit mode, show direct input:
function CoinDisplay({
label,
value,
field,
className,
}: {
label: string;
value: number;
field: "gp" | "sp" | "cp";
className: string;
}) {
if (mode === "edit") {
return (
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${className}`}>{label}</span>
<input
className={styles.coinInput}
type="number"
min={0}
value={value}
onChange={(e) => onChange(field, Number(e.target.value))}
/>
</div>
);
}
return (
<div className={styles.coin}>
<span className={`${styles.coinLabel} ${className}`}>{label}</span>
<button
className={styles.coinBtn}
onClick={() => onChange(field, value - 1)}
>
−
</button>
<span className={styles.coinValue}>{value}</span>
<button
className={styles.coinBtn}
onClick={() => onChange(field, value + 1)}
>
+
</button>
</div>
);
}
Add to CurrencyRow.module.css:
.coinBtn {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #444;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.coinBtn:hover {
border-color: #c9a84c;
color: #c9a84c;
}
.coinValue {
min-width: 1.5rem;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
}
- Step 5: Update AcDisplay.tsx — add
modeprop
Read the file first. Add mode to props:
interface AcDisplayProps {
breakdown: AcBreakdown;
onOverride: (value: number | null) => void;
mode?: "view" | "edit";
}
Default to 'view'. In view mode, the AC value is just a display — no click to edit. Only show the edit/override behavior when mode === 'edit':
{mode === 'edit' ? (
// existing click-to-edit behavior
editing ? (<input .../>) : (<span onClick={startEdit} ...>{breakdown.effective}</span>)
) : (
// view mode: just display the number
<span className={styles.value}>{breakdown.effective}</span>
)}
Still show the source label and override indicator (if overridden) in both modes — just don't allow editing in view mode. Show the revert button only in edit mode.
- Step 6: Verify TypeScript compiles
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
Expected: No errors. All existing usages pass no mode prop, which defaults to 'view', so the app should look read-only now. This is expected — we'll wire up edit mode in later tasks.
Task 2: Create CharacterSheet + Panel Components (View Mode)
Files:
-
Create:
client/src/components/StatsPanel.tsx -
Create:
client/src/components/StatsPanel.module.css -
Create:
client/src/components/InfoPanel.tsx -
Create:
client/src/components/InfoPanel.module.css -
Create:
client/src/components/GearPanel.tsx -
Create:
client/src/components/GearPanel.module.css -
Create:
client/src/components/CharacterSheet.tsx -
Create:
client/src/components/CharacterSheet.module.css -
Step 1: Create StatsPanel.module.css
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
}
.sectionTitle {
font-size: 0.8rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.separator {
border: none;
border-top: 1px solid #2a2a4a;
margin: 0.75rem 0;
}
- Step 2: Create StatsPanel.tsx
import type { Character } from "../types";
import { generateAttacks } from "../utils/derived-attacks";
import StatBlock from "./StatBlock";
import AttackBlock from "./AttackBlock";
import styles from "./StatsPanel.module.css";
interface StatsPanelProps {
character: Character;
mode: "view" | "edit";
onStatChange: (characterId: number, statName: string, value: number) => void;
}
export default function StatsPanel({
character,
mode,
onStatChange,
}: StatsPanelProps) {
const attacks = generateAttacks(character);
return (
<div className={styles.panel}>
<div className={styles.sectionTitle}>Ability Scores</div>
<StatBlock
stats={character.stats}
onStatChange={(statName, value) =>
onStatChange(character.id, statName, value)
}
mode={mode}
/>
<hr className={styles.separator} />
<AttackBlock attacks={attacks} />
</div>
);
}
- Step 3: Create InfoPanel.module.css
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
}
.sectionTitle {
font-size: 0.8rem;
font-weight: 700;
color: #c9a84c;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.infoGrid {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.75rem;
}
.infoRow {
display: flex;
gap: 0.3rem;
font-size: 0.85rem;
}
.infoLabel {
color: #666;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 600;
min-width: 5rem;
}
.infoValue {
color: #e0e0e0;
}
.notes {
font-size: 0.85rem;
color: #aaa;
white-space: pre-wrap;
margin-top: 0.5rem;
}
.editField {
padding: 0.3rem 0.5rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
width: 100%;
}
.editField:focus {
outline: none;
border-color: #c9a84c;
}
.editSelect {
padding: 0.3rem 0.5rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
}
.editRow {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.editRow > * {
flex: 1;
}
.field {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.fieldLabel {
font-size: 0.65rem;
color: #666;
text-transform: uppercase;
font-weight: 600;
}
.notesEdit {
width: 100%;
min-height: 50px;
padding: 0.4rem;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
color: #e0e0e0;
font-size: 0.85rem;
font-family: inherit;
resize: vertical;
}
.notesEdit:focus {
outline: none;
border-color: #c9a84c;
}
- Step 4: Create InfoPanel.tsx
import { useRef, useEffect } from "react";
import type { Character } from "../types";
import TalentList from "./TalentList";
import styles from "./InfoPanel.module.css";
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"];
interface InfoPanelProps {
character: Character;
mode: "view" | "edit";
onUpdate: (id: number, data: Partial<Character>) => void;
onAddTalent: (
characterId: number,
data: { name: string; description: string },
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
}
export default function InfoPanel({
character,
mode,
onUpdate,
onAddTalent,
onRemoveTalent,
}: InfoPanelProps) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
function handleField(field: string, value: string | number) {
if (typeof value === "string") {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdate(character.id, { [field]: value });
}, 400);
} else {
onUpdate(character.id, { [field]: value });
}
}
return (
<div className={styles.panel}>
<div className={styles.sectionTitle}>Talents</div>
<TalentList
talents={character.talents}
onAdd={(data) => onAddTalent(character.id, data)}
onRemove={(id) => onRemoveTalent(character.id, id)}
mode={mode}
/>
<div className={styles.sectionTitle} style={{ marginTop: "0.75rem" }}>
Info
</div>
{mode === "view" ? (
<div className={styles.infoGrid}>
{character.background && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Background</span>
<span className={styles.infoValue}>{character.background}</span>
</div>
)}
{character.deity && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Deity</span>
<span className={styles.infoValue}>{character.deity}</span>
</div>
)}
{character.languages && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Languages</span>
<span className={styles.infoValue}>{character.languages}</span>
</div>
)}
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Alignment</span>
<span className={styles.infoValue}>{character.alignment}</span>
</div>
{character.notes && (
<>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Notes</span>
</div>
<div className={styles.notes}>{character.notes}</div>
</>
)}
</div>
) : (
<div className={styles.infoGrid}>
<div className={styles.editRow}>
<div className={styles.field}>
<label className={styles.fieldLabel}>Class</label>
<select
className={styles.editSelect}
value={character.class}
onChange={(e) => handleField("class", e.target.value)}
>
{CLASSES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Ancestry</label>
<select
className={styles.editSelect}
value={character.ancestry}
onChange={(e) => handleField("ancestry", e.target.value)}
>
{ANCESTRIES.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
</div>
<div className={styles.editRow}>
<div className={styles.field}>
<label className={styles.fieldLabel}>Level</label>
<input
className={styles.editField}
type="number"
min={0}
value={character.level}
onChange={(e) => handleField("level", Number(e.target.value))}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Alignment</label>
<select
className={styles.editSelect}
value={character.alignment}
onChange={(e) => handleField("alignment", e.target.value)}
>
{ALIGNMENTS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Background</label>
<input
className={styles.editField}
value={character.background}
placeholder="Urchin..."
onChange={(e) => handleField("background", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Deity</label>
<input
className={styles.editField}
value={character.deity}
placeholder="None..."
onChange={(e) => handleField("deity", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Languages</label>
<input
className={styles.editField}
value={character.languages}
placeholder="Common, Elvish..."
onChange={(e) => handleField("languages", e.target.value)}
/>
</div>
<div className={styles.field}>
<label className={styles.fieldLabel}>Notes</label>
<textarea
className={styles.notesEdit}
value={character.notes}
onChange={(e) => handleField("notes", e.target.value)}
placeholder="Freeform notes..."
/>
</div>
</div>
)}
</div>
);
}
- Step 5: Create GearPanel.module.css
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
}
- Step 6: Create GearPanel.tsx
import type { Character, GameItem } from "../types";
import GearList from "./GearList";
import styles from "./GearPanel.module.css";
interface GearPanelProps {
character: Character;
mode: "view" | "edit";
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
characterId: number,
data: { name: string; type: string; slot_count: number },
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onCurrencyChange: (
characterId: number,
field: "gp" | "sp" | "cp",
value: number,
) => void;
}
export default function GearPanel({
character,
mode,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onCurrencyChange,
}: GearPanelProps) {
const slotsUsed = character.gear.reduce((sum, g) => sum + g.slot_count, 0);
return (
<div className={styles.panel}>
<GearList
gear={character.gear}
gp={character.gp}
sp={character.sp}
cp={character.cp}
slotsUsed={slotsUsed}
slotsMax={character.gear_slots_max}
onAddFromItem={(item) => onAddGearFromItem(character.id, item)}
onAddCustom={(data) => onAddGearCustom(character.id, data)}
onRemove={(gearId) => onRemoveGear(character.id, gearId)}
onCurrencyChange={(field, value) =>
onCurrencyChange(character.id, field, value)
}
mode={mode}
/>
</div>
);
}
- Step 7: Create CharacterSheet.module.css
.banner {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #16213e, #0f1a30);
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.identity {
flex: 1;
}
.name {
font-size: 1.4rem;
font-weight: 700;
color: #c9a84c;
}
.title {
color: #888;
font-size: 0.9rem;
}
.subtitle {
color: #666;
font-size: 0.8rem;
margin-top: 0.15rem;
}
.vitals {
display: flex;
gap: 1.25rem;
align-items: center;
}
.vital {
text-align: center;
}
.vitalValue {
font-size: 1.3rem;
font-weight: 700;
}
.vitalValue.hp {
color: #4caf50;
}
.vitalValue.ac {
color: #5dade2;
}
.vitalLabel {
font-size: 0.65rem;
color: #888;
text-transform: uppercase;
font-weight: 600;
}
.hpControls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.vitalBtn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid #444;
background: #16213e;
color: #e0e0e0;
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
}
.vitalBtn:hover {
border-color: #c9a84c;
color: #c9a84c;
}
.hpSlash {
color: #666;
font-size: 0.9rem;
}
.xpThreshold {
font-size: 0.75rem;
color: #666;
}
.xpCurrent {
color: #c9a84c;
font-weight: 600;
}
.nameInput {
font-size: 1.3rem;
font-weight: 700;
color: #c9a84c;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
padding: 0.2rem 0.4rem;
width: 10rem;
}
.nameInput:focus {
outline: none;
border-color: #c9a84c;
}
.titleInput {
font-size: 0.85rem;
color: #888;
background: #0f1a30;
border: 1px solid #333;
border-radius: 5px;
padding: 0.15rem 0.4rem;
width: 8rem;
margin-left: 0.3rem;
}
.titleInput:focus {
outline: none;
border-color: #c9a84c;
}
.panels {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 1100px) {
.panels {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 768px) {
.panels {
grid-template-columns: 1fr;
}
}
.deleteSection {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #333;
}
.deleteBtn {
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid #e74c3c;
border-radius: 5px;
color: #e74c3c;
cursor: pointer;
font-size: 0.8rem;
}
.deleteBtn:hover {
background: rgba(231, 76, 60, 0.1);
}
- Step 8: Create CharacterSheet.tsx
import { useState, useRef, useEffect } from "react";
import type { Character, GameItem } from "../types";
import { calculateAC } from "../utils/derived-ac";
import AcDisplay from "./AcDisplay";
import StatsPanel from "./StatsPanel";
import InfoPanel from "./InfoPanel";
import GearPanel from "./GearPanel";
import styles from "./CharacterSheet.module.css";
interface CharacterSheetProps {
character: Character;
mode: "view" | "edit";
onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
characterId: number,
data: { name: string; type: string; slot_count: number },
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onAddTalent: (
characterId: number,
data: { name: string; description: string },
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
onDelete: (id: number) => void;
}
export default function CharacterSheet({
character,
mode,
onUpdate,
onStatChange,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onAddTalent,
onRemoveTalent,
onDelete,
}: CharacterSheetProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const acBreakdown = calculateAC(character);
const xpThreshold = character.level * 10;
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
function handleNameField(field: string, value: string) {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdate(character.id, { [field]: value });
}, 400);
}
function handleAcOverride(value: number | null) {
const overrides = { ...(character.overrides || {}) };
if (value === null) {
delete overrides.ac;
} else {
overrides.ac = value;
}
onUpdate(character.id, { overrides } as Partial<Character>);
}
return (
<>
{/* HEADER BANNER */}
<div className={styles.banner}>
<div className={styles.identity}>
{mode === "edit" ? (
<div>
<input
className={styles.nameInput}
defaultValue={character.name}
onChange={(e) => handleNameField("name", e.target.value)}
/>
<input
className={styles.titleInput}
defaultValue={character.title}
placeholder="title..."
onChange={(e) => handleNameField("title", e.target.value)}
/>
</div>
) : (
<div className={styles.name}>
{character.name}
{character.title && (
<span className={styles.title}> {character.title}</span>
)}
</div>
)}
<div className={styles.subtitle}>
Level {character.level} {character.ancestry} {character.class}
</div>
</div>
<div className={styles.vitals}>
{/* HP with +/- always */}
<div className={styles.vital}>
<div className={styles.hpControls}>
<button
className={styles.vitalBtn}
onClick={() =>
onUpdate(character.id, {
hp_current: character.hp_current - 1,
})
}
>
−
</button>
<span className={`${styles.vitalValue} ${styles.hp}`}>
{character.hp_current}
</span>
<span className={styles.hpSlash}>/</span>
<span style={{ color: "#888", fontWeight: 600 }}>
{character.hp_max}
</span>
<button
className={styles.vitalBtn}
onClick={() =>
onUpdate(character.id, {
hp_current: character.hp_current + 1,
})
}
>
+
</button>
</div>
<div className={styles.vitalLabel}>HP</div>
</div>
{/* AC — display only in view, override in edit */}
<div className={styles.vital}>
<AcDisplay
breakdown={acBreakdown}
onOverride={handleAcOverride}
mode={mode}
/>
</div>
{/* XP with +/- always */}
<div className={styles.vital}>
<div className={styles.hpControls}>
<button
className={styles.vitalBtn}
onClick={() =>
onUpdate(character.id, { xp: Math.max(0, character.xp - 1) })
}
>
−
</button>
<span className={styles.vitalValue}>
<span className={styles.xpCurrent}>{character.xp}</span>
<span className={styles.xpThreshold}> / {xpThreshold}</span>
</span>
<button
className={styles.vitalBtn}
onClick={() => onUpdate(character.id, { xp: character.xp + 1 })}
>
+
</button>
</div>
<div className={styles.vitalLabel}>XP</div>
</div>
</div>
</div>
{/* THREE PANELS */}
<div className={styles.panels}>
<StatsPanel
character={character}
mode={mode}
onStatChange={onStatChange}
/>
<InfoPanel
character={character}
mode={mode}
onUpdate={onUpdate}
onAddTalent={onAddTalent}
onRemoveTalent={onRemoveTalent}
/>
<GearPanel
character={character}
mode={mode}
onAddGearFromItem={onAddGearFromItem}
onAddGearCustom={onAddGearCustom}
onRemoveGear={onRemoveGear}
onCurrencyChange={(charId, field, value) =>
onUpdate(charId, { [field]: value })
}
/>
</div>
{/* DELETE — edit mode only */}
{mode === "edit" && (
<div className={styles.deleteSection}>
{confirmDelete ? (
<div>
<span>Delete {character.name}? </span>
<button
className={styles.deleteBtn}
onClick={() => onDelete(character.id)}
>
Yes, delete
</button>{" "}
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(false)}
>
Cancel
</button>
</div>
) : (
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(true)}
>
Delete Character
</button>
)}
</div>
)}
</>
);
}
Task 3: Rewrite CharacterDetail as Modal Shell with Mode Toggle
Files:
-
Rewrite:
client/src/components/CharacterDetail.tsx -
Rewrite:
client/src/components/CharacterDetail.module.css -
Step 1: Rewrite CharacterDetail.module.css
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 12px;
width: 100%;
max-width: 1100px;
max-height: 95vh;
overflow-y: auto;
padding: 1.5rem;
}
.topBar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.editBtn {
padding: 0.35rem 0.75rem;
background: transparent;
border: 1px solid #c9a84c;
border-radius: 5px;
color: #c9a84c;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
.editBtn:hover {
background: rgba(201, 168, 76, 0.15);
}
.editBtn.active {
background: #c9a84c;
color: #1a1a2e;
}
.closeBtn {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.closeBtn:hover {
color: #e0e0e0;
}
- Step 2: Rewrite CharacterDetail.tsx
import { useState } from "react";
import type { Character, GameItem } from "../types";
import CharacterSheet from "./CharacterSheet";
import styles from "./CharacterDetail.module.css";
interface CharacterDetailProps {
character: Character;
onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
characterId: number,
data: { name: string; type: string; slot_count: number },
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onAddTalent: (
characterId: number,
data: { name: string; description: string },
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
onDelete: (id: number) => void;
onClose: () => void;
}
export default function CharacterDetail({
character,
onUpdate,
onStatChange,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onAddTalent,
onRemoveTalent,
onDelete,
onClose,
}: CharacterDetailProps) {
const [mode, setMode] = useState<"view" | "edit">("view");
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.topBar}>
<button
className={`${styles.editBtn} ${mode === "edit" ? styles.active : ""}`}
onClick={() => setMode(mode === "view" ? "edit" : "view")}
>
{mode === "view" ? "Edit" : "Done"}
</button>
<button className={styles.closeBtn} onClick={onClose}>
✕
</button>
</div>
<CharacterSheet
character={character}
mode={mode}
onUpdate={onUpdate}
onStatChange={onStatChange}
onAddGearFromItem={onAddGearFromItem}
onAddGearCustom={onAddGearCustom}
onRemoveGear={onRemoveGear}
onAddTalent={onAddTalent}
onRemoveTalent={onRemoveTalent}
onDelete={onDelete}
/>
</div>
</div>
);
}
Task 4: Update CampaignView Props
Files:
-
Modify:
client/src/pages/CampaignView.tsx -
Step 1: Update CampaignView.tsx
The CharacterDetail props interface hasn't changed — it still expects the same handlers. No changes needed to CampaignView unless the prop names changed.
Read CampaignView.tsx and verify the CharacterDetail usage still matches. The props are: character, onUpdate, onStatChange, onAddGearFromItem, onAddGearCustom, onRemoveGear, onAddTalent, onRemoveTalent, onDelete, onClose — same as before.
If there are any mismatches, fix them.
- Step 2: Verify full TypeScript compilation
cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit
Expected: No errors.
Task 5: End-to-End Smoke Test
Files: None (testing only)
- Step 1: Restart the full stack
# Kill any running servers
lsof -ti:3000 | xargs kill 2>/dev/null
lsof -ti:5173 | xargs kill 2>/dev/null
cd /Users/aaron.wood/workspace/shadowdark && npm run dev
- Step 2: Test view mode (default)
Open http://localhost:5173, go into a campaign, click a character:
- Modal opens in VIEW mode — clean card panel layout
- Header shows name, class/ancestry/level, HP with +/-, AC display, XP with +/-
- Left panel: ability scores as compact 2-column list (no +/- buttons), attacks below with separator
- Center panel: talents as read-only list, info fields as plain text
- Right panel: gear list with unicode type icons (no delete buttons), currency with +/- buttons
- No "Delete Character" button visible
- Click HP +/- — works in view mode
- Click currency +/- — works in view mode
- Click XP +/- — works in view mode
- Step 3: Test edit mode
- Click "Edit" button — toggles to edit mode, button text changes to "Done"
- Header: name and title become input fields
- Left panel: ability scores get +/- buttons
- Center panel: talents get add form and remove buttons, info fields become editable inputs/dropdowns
- Right panel: gear gets "+ Add Gear" and delete buttons, currency switches to direct input
- "Delete Character" button appears at bottom
- AC becomes click-to-override
- Click "Done" — returns to view mode, all changes persisted
- Step 4: Test real-time sync
Open second tab, same campaign:
- In tab 1 (view mode), click HP −
- Tab 2 shows updated HP on the card
- In tab 2, open same character — both tabs viewing same character
- Tab 1 changes XP — tab 2 updates
- Step 5: Test responsive layout
Resize browser:
- 1100px+: 3 panels side by side
- 768-1100px: 2 panels
- <768px: single column stacked