# 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
{mode === "edit" && (
onStatChange(name, value - 1)}
>
−
)}
{formatModifier(mod)}
{mode === "edit" && (
onStatChange(name, value + 1)}
>
+
)}
```
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" && ;
}
```
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' && (✕ )}`
- Wrap the entire add form: `{mode === 'edit' && ()}`
- [ ] **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 `` with:
```tsx
{gearIcon(item.type)}
```
Conditionally render remove buttons: `{mode === 'edit' && (✕ )}`
Conditionally render the add area: `{mode === 'edit' && (...
)}`
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 (
{label}
onChange(field, Number(e.target.value))}
/>
);
}
return (
{label}
onChange(field, value - 1)}
>
−
{value}
onChange(field, value + 1)}
>
+
);
}
```
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 ? ( ) : ({breakdown.effective} )
) : (
// view mode: just display the number
{breakdown.effective}
)}
```
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 (
Ability Scores
onStatChange(character.id, statName, value)
}
mode={mode}
/>
);
}
```
- [ ] **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) => 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>();
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 (
Talents
onAddTalent(character.id, data)}
onRemove={(id) => onRemoveTalent(character.id, id)}
mode={mode}
/>
Info
{mode === "view" ? (
{character.background && (
Background
{character.background}
)}
{character.deity && (
Deity
{character.deity}
)}
{character.languages && (
Languages
{character.languages}
)}
Alignment
{character.alignment}
{character.notes && (
<>
Notes
{character.notes}
>
)}
) : (
)}
);
}
```
- [ ] **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 (
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}
/>
);
}
```
- [ ] **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) => 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>();
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);
}
return (
<>
{/* HEADER BANNER */}
{mode === "edit" ? (
handleNameField("name", e.target.value)}
/>
handleNameField("title", e.target.value)}
/>
) : (
{character.name}
{character.title && (
{character.title}
)}
)}
Level {character.level} {character.ancestry} {character.class}
{/* HP with +/- always */}
onUpdate(character.id, {
hp_current: character.hp_current - 1,
})
}
>
−
{character.hp_current}
/
{character.hp_max}
onUpdate(character.id, {
hp_current: character.hp_current + 1,
})
}
>
+
HP
{/* AC — display only in view, override in edit */}
{/* XP with +/- always */}
onUpdate(character.id, { xp: Math.max(0, character.xp - 1) })
}
>
−
{character.xp}
/ {xpThreshold}
onUpdate(character.id, { xp: character.xp + 1 })}
>
+
XP
{/* THREE PANELS */}
onUpdate(charId, { [field]: value })
}
/>
{/* DELETE — edit mode only */}
{mode === "edit" && (
{confirmDelete ? (
Delete {character.name}?
onDelete(character.id)}
>
Yes, delete
{" "}
setConfirmDelete(false)}
>
Cancel
) : (
setConfirmDelete(true)}
>
Delete Character
)}
)}
>
);
}
```
---
### 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) => 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 (
e.stopPropagation()}>
setMode(mode === "view" ? "edit" : "view")}
>
{mode === "view" ? "Edit" : "Done"}
✕
);
}
```
---
### 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