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

36 KiB
Raw Permalink Blame History

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:

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 mode prop

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 mode prop 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 mode prop 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 mode prop

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:

  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