# 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" && ( )} {formatModifier(mod)} {mode === "edit" && ( )}
``` 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} {value}
); } ``` 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}
)}
) : (
handleField("level", Number(e.target.value))} />
handleField("background", e.target.value)} />
handleField("deity", e.target.value)} />
handleField("languages", e.target.value)} />