darkwatch/docs/plans/2026-04-09-view-edit-mode.md

1412 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `mode` prop**
Read the file first. Add `mode` to the props interface and conditionally render +/- buttons:
```tsx
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:
```tsx
<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:
```tsx
{
mode === "view" && <span className={styles.rollSpace}></span>;
}
```
Add to StatBlock.module.css:
```css
.rollSpace {
width: 2.5rem;
}
```
- [ ] **Step 2: Update TalentList.tsx — add `mode` prop**
Read the file first. Add `mode` to props:
```tsx
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 `mode` prop and gear type icons**
Read the file first. Add `mode` to props:
```tsx
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:
```tsx
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:
```tsx
<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 `mode` prop for +/- vs direct input**
Read the file first. Add `mode` to props:
```tsx
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:
```tsx
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:
```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 `mode` prop**
Read the file first. Add `mode` to props:
```tsx
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'`:
```tsx
{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**
```bash
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**
```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**
```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**
```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**
```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**
```css
.panel {
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 0.75rem;
}
```
- [ ] **Step 6: Create GearPanel.tsx**
```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**
```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**
```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**
```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**
```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**
```bash
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**
```bash
# 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:
1. Modal opens in VIEW mode — clean card panel layout
2. Header shows name, class/ancestry/level, HP with +/-, AC display, XP with +/-
3. Left panel: ability scores as compact 2-column list (no +/- buttons), attacks below with separator
4. Center panel: talents as read-only list, info fields as plain text
5. Right panel: gear list with unicode type icons (no delete buttons), currency with +/- buttons
6. No "Delete Character" button visible
7. Click HP +/- — works in view mode
8. Click currency +/- — works in view mode
9. Click XP +/- — works in view mode
- [ ] **Step 3: Test edit mode**
1. Click "Edit" button — toggles to edit mode, button text changes to "Done"
2. Header: name and title become input fields
3. Left panel: ability scores get +/- buttons
4. Center panel: talents get add form and remove buttons, info fields become editable inputs/dropdowns
5. Right panel: gear gets "+ Add Gear" and delete buttons, currency switches to direct input
6. "Delete Character" button appears at bottom
7. AC becomes click-to-override
8. Click "Done" — returns to view mode, all changes persisted
- [ ] **Step 4: Test real-time sync**
Open second tab, same campaign:
1. In tab 1 (view mode), click HP
2. Tab 2 shows updated HP on the card
3. In tab 2, open same character — both tabs viewing same character
4. 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