# Shadowdark Character Sheet Manager — 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:** Build a real-time web app for managing Shadowdark RPG character sheets across a group of players. **Architecture:** React SPA frontend communicating with a Node/Express + Socket.IO backend. SQLite for persistence. All mutations go through REST endpoints which save to DB then broadcast via Socket.IO to all clients in the same campaign room. **Tech Stack:** React 18, Vite, TypeScript, Node.js, Express, Socket.IO, better-sqlite3, CSS Modules --- ## File Structure ``` shadowdark/ ├── client/ │ ├── index.html │ ├── package.json │ ├── tsconfig.json │ ├── vite.config.ts │ └── src/ │ ├── main.tsx │ ├── App.tsx │ ├── App.module.css │ ├── socket.ts # Socket.IO client singleton │ ├── api.ts # REST API helper functions │ ├── types.ts # Shared TypeScript types │ ├── utils/ │ │ └── modifiers.ts # Ability score -> modifier lookup │ ├── pages/ │ │ ├── CampaignList.tsx # Home page — list/create campaigns │ │ ├── CampaignList.module.css │ │ ├── CampaignView.tsx # Main campaign screen with character grid │ │ └── CampaignView.module.css │ └── components/ │ ├── CharacterCard.tsx # Compact card in the grid │ ├── CharacterCard.module.css │ ├── CharacterDetail.tsx # Expanded modal for full editing │ ├── CharacterDetail.module.css │ ├── StatBlock.tsx # 3x2 stat display with +/- buttons │ ├── StatBlock.module.css │ ├── GearList.tsx # Gear/inventory management │ ├── GearList.module.css │ ├── TalentList.tsx # Talents management │ ├── TalentList.module.css │ ├── HpBar.tsx # HP current/max with +/- buttons │ └── HpBar.module.css ├── server/ │ ├── package.json │ ├── tsconfig.json │ ├── data/ # SQLite DB lives here (created at runtime) │ └── src/ │ ├── index.ts # Express + Socket.IO server entry │ ├── db.ts # SQLite setup, schema init, query helpers │ ├── routes/ │ │ ├── campaigns.ts # CRUD routes for campaigns │ │ └── characters.ts # CRUD routes for characters, stats, gear, talents │ └── socket.ts # Socket.IO room management and broadcast logic └── package.json # Root package.json with dev scripts ``` --- ### Task 1: Project Scaffolding **Files:** - Create: `package.json` (root) - Create: `server/package.json` - Create: `server/tsconfig.json` - Create: `client/package.json` - Create: `client/tsconfig.json` - Create: `client/vite.config.ts` - Create: `client/index.html` - [ ] **Step 1: Create root package.json** ```json { "name": "shadowdark", "private": true, "scripts": { "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "dev:server": "cd server && npm run dev", "dev:client": "cd client && npm run dev" }, "devDependencies": { "concurrently": "^9.1.2" } } ``` - [ ] **Step 2: Create server/package.json** ```json { "name": "shadowdark-server", "private": true, "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js" }, "dependencies": { "better-sqlite3": "^11.7.0", "cors": "^2.8.5", "express": "^4.21.2", "socket.io": "^4.8.1" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } } ``` - [ ] **Step 3: Create server/tsconfig.json** ```json { "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true }, "include": ["src"] } ``` - [ ] **Step 4: Create client/package.json** ```json { "name": "shadowdark-client", "private": true, "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.1.1", "socket.io-client": "^4.8.1" }, "devDependencies": { "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.0", "vite": "^6.0.0" } } ``` - [ ] **Step 5: Create client/tsconfig.json** ```json { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true }, "include": ["src"] } ``` - [ ] **Step 6: Create client/vite.config.ts** ```ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], server: { port: 5173, proxy: { "/api": "http://localhost:3000", "/socket.io": { target: "http://localhost:3000", ws: true, }, }, }, }); ``` - [ ] **Step 7: Create client/index.html** ```html Shadowdark Character Manager
``` - [ ] **Step 8: Install dependencies** Run from project root: ```bash cd /Users/aaron.wood/workspace/shadowdark && npm install cd /Users/aaron.wood/workspace/shadowdark/server && npm install cd /Users/aaron.wood/workspace/shadowdark/client && npm install ``` Expected: All three install successfully with no errors. --- ### Task 2: Database Layer **Files:** - Create: `server/src/db.ts` - [ ] **Step 1: Create server/src/db.ts with schema initialization** ```ts import Database from "better-sqlite3"; import path from "path"; import fs from "fs"; const DATA_DIR = path.join(import.meta.dirname, "..", "data"); fs.mkdirSync(DATA_DIR, { recursive: true }); const db = new Database(path.join(DATA_DIR, "shadowdark.db")); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); db.exec(` CREATE TABLE IF NOT EXISTS campaigns ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_by TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS characters ( id INTEGER PRIMARY KEY AUTOINCREMENT, campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, created_by TEXT DEFAULT '', name TEXT NOT NULL, class TEXT NOT NULL DEFAULT 'Fighter', ancestry TEXT NOT NULL DEFAULT 'Human', level INTEGER NOT NULL DEFAULT 1, xp INTEGER NOT NULL DEFAULT 0, hp_current INTEGER NOT NULL DEFAULT 0, hp_max INTEGER NOT NULL DEFAULT 0, ac INTEGER NOT NULL DEFAULT 10, alignment TEXT NOT NULL DEFAULT 'Neutral', title TEXT DEFAULT '', notes TEXT DEFAULT '' ); CREATE TABLE IF NOT EXISTS character_stats ( character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, stat_name TEXT NOT NULL, value INTEGER NOT NULL DEFAULT 10, PRIMARY KEY (character_id, stat_name) ); CREATE TABLE IF NOT EXISTS character_gear ( id INTEGER PRIMARY KEY AUTOINCREMENT, character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'gear', slot_count INTEGER NOT NULL DEFAULT 1, properties TEXT DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS character_talents ( id INTEGER PRIMARY KEY AUTOINCREMENT, character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT DEFAULT '', effect TEXT DEFAULT '{}' ); `); export default db; ``` - [ ] **Step 2: Verify the database initializes** Run: ```bash cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/db.ts ``` Expected: No errors. File `server/data/shadowdark.db` is created. --- ### Task 3: Server Entry + Socket.IO Setup **Files:** - Create: `server/src/index.ts` - Create: `server/src/socket.ts` - [ ] **Step 1: Create server/src/socket.ts** ```ts import { Server } from "socket.io"; export function setupSocket(io: Server) { io.on("connection", (socket) => { socket.on("join-campaign", (campaignId: string) => { socket.join(`campaign:${campaignId}`); }); socket.on("leave-campaign", (campaignId: string) => { socket.leave(`campaign:${campaignId}`); }); socket.on("disconnect", () => { // Rooms are cleaned up automatically by Socket.IO }); }); } export function broadcastToCampaign( io: Server, campaignId: number, event: string, data: unknown ) { io.to(`campaign:${campaignId}`).emit(event, data); } ``` - [ ] **Step 2: Create server/src/index.ts** ```ts import express from "express"; import cors from "cors"; import { createServer } from "http"; import { Server } from "socket.io"; import { setupSocket } from "./socket.js"; const app = express(); const httpServer = createServer(app); const io = new Server(httpServer, { cors: { origin: "*" }, }); app.use(cors()); app.use(express.json()); // Make io accessible to route handlers app.set("io", io); setupSocket(io); const PORT = process.env.PORT || 3000; httpServer.listen(PORT, () => { console.log(`Shadowdark server running on http://localhost:${PORT}`); }); export { io }; ``` - [ ] **Step 3: Verify server starts** Run: ```bash cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts ``` Expected: Console prints `Shadowdark server running on http://localhost:3000`. Kill with Ctrl+C. --- ### Task 4: Campaign API Routes **Files:** - Create: `server/src/routes/campaigns.ts` - Modify: `server/src/index.ts` (register routes) - [ ] **Step 1: Create server/src/routes/campaigns.ts** ```ts import { Router } from "express"; import db from "../db.js"; const router = Router(); // GET /api/campaigns — list all campaigns router.get("/", (_req, res) => { const campaigns = db .prepare("SELECT * FROM campaigns ORDER BY created_at DESC") .all(); res.json(campaigns); }); // POST /api/campaigns — create a campaign router.post("/", (req, res) => { const { name } = req.body; if (!name || !name.trim()) { res.status(400).json({ error: "Campaign name is required" }); return; } const result = db .prepare("INSERT INTO campaigns (name) VALUES (?)") .run(name.trim()); const campaign = db .prepare("SELECT * FROM campaigns WHERE id = ?") .get(result.lastInsertRowid); res.status(201).json(campaign); }); // GET /api/campaigns/:id — get a single campaign router.get("/:id", (req, res) => { const campaign = db .prepare("SELECT * FROM campaigns WHERE id = ?") .get(req.params.id); if (!campaign) { res.status(404).json({ error: "Campaign not found" }); return; } res.json(campaign); }); // DELETE /api/campaigns/:id — delete a campaign (cascades to characters) router.delete("/:id", (req, res) => { const result = db .prepare("DELETE FROM campaigns WHERE id = ?") .run(req.params.id); if (result.changes === 0) { res.status(404).json({ error: "Campaign not found" }); return; } res.status(204).end(); }); export default router; ``` - [ ] **Step 2: Register campaign routes in server/src/index.ts** Add these lines before the `httpServer.listen()` call: ```ts import campaignRoutes from "./routes/campaigns.js"; app.use("/api/campaigns", campaignRoutes); ``` - [ ] **Step 3: Test campaign endpoints manually** Run server: ```bash cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts & ``` Test: ```bash # Create curl -s -X POST http://localhost:3000/api/campaigns -H 'Content-Type: application/json' -d '{"name":"Test Campaign"}' | jq . # List curl -s http://localhost:3000/api/campaigns | jq . # Get curl -s http://localhost:3000/api/campaigns/1 | jq . # Delete curl -s -X DELETE http://localhost:3000/api/campaigns/1 -w "%{http_code}" ``` Expected: 201 with campaign object, 200 with array, 200 with object, 204. Kill server after testing. --- ### Task 5: Character API Routes **Files:** - Create: `server/src/routes/characters.ts` - Modify: `server/src/index.ts` (register routes) - [ ] **Step 1: Create server/src/routes/characters.ts** ```ts import { Router } from "express"; import type { Server } from "socket.io"; import db from "../db.js"; import { broadcastToCampaign } from "../socket.js"; const router = Router(); const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; // GET /api/campaigns/:campaignId/characters — list characters in a campaign router.get("/", (req, res) => { const { campaignId } = req.params; const characters = db .prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name") .all(campaignId) as Array>; // Attach stats, gear, and talents to each character const stmtStats = db.prepare( "SELECT stat_name, value FROM character_stats WHERE character_id = ?" ); const stmtGear = db.prepare( "SELECT * FROM character_gear WHERE character_id = ?" ); const stmtTalents = db.prepare( "SELECT * FROM character_talents WHERE character_id = ?" ); const enriched = characters.map((char) => ({ ...char, stats: stmtStats.all(char.id), gear: stmtGear.all(char.id), talents: stmtTalents.all(char.id), })); res.json(enriched); }); // POST /api/campaigns/:campaignId/characters — create a character router.post("/", (req, res) => { const { campaignId } = req.params; const { name, class: charClass, ancestry, hp_max } = req.body; if (!name || !name.trim()) { res.status(400).json({ error: "Character name is required" }); return; } const insertChar = db.prepare(` INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max) VALUES (?, ?, ?, ?, ?, ?) `); const insertStat = db.prepare(` INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10) `); const result = insertChar.run( campaignId, name.trim(), charClass || "Fighter", ancestry || "Human", hp_max || 0, hp_max || 0 ); const characterId = result.lastInsertRowid; for (const stat of DEFAULT_STATS) { insertStat.run(characterId, stat); } const character = db .prepare("SELECT * FROM characters WHERE id = ?") .get(characterId); const stats = db .prepare( "SELECT stat_name, value FROM character_stats WHERE character_id = ?" ) .all(characterId); const enriched = { ...(character as Record), stats, gear: [], talents: [], }; const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(campaignId), "character:created", enriched); res.status(201).json(enriched); }); // PATCH /api/characters/:id — update character fields router.patch("/:id", (req, res) => { const { id } = req.params; const allowedFields = [ "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max", "ac", "alignment", "title", "notes", ]; const updates: string[] = []; const values: unknown[] = []; for (const field of allowedFields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (updates.length === 0) { res.status(400).json({ error: "No valid fields to update" }); return; } values.push(id); db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run( ...values ); const character = db .prepare("SELECT * FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "character:updated", { id: Number(id), ...req.body, }); res.json(character); }); // DELETE /api/characters/:id — delete a character router.delete("/:id", (req, res) => { const character = db .prepare("SELECT * FROM characters WHERE id = ?") .get(req.params.id) as Record | undefined; if (!character) { res.status(404).json({ error: "Character not found" }); return; } db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id); const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", { id: Number(req.params.id), }); res.status(204).end(); }); // PATCH /api/characters/:id/stats/:statName — update a single stat router.patch("/:id/stats/:statName", (req, res) => { const { id, statName } = req.params; const { value } = req.body; const upper = statName.toUpperCase(); if (!DEFAULT_STATS.includes(upper)) { res.status(400).json({ error: "Invalid stat name" }); return; } db.prepare( "UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?" ).run(value, id, upper); const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", { characterId: Number(id), statName: upper, value, }); res.json({ characterId: Number(id), statName: upper, value }); }); // POST /api/characters/:id/gear — add gear router.post("/:id/gear", (req, res) => { const { id } = req.params; const { name, type, slot_count, properties } = req.body; if (!name || !name.trim()) { res.status(400).json({ error: "Gear name is required" }); return; } const result = db .prepare( "INSERT INTO character_gear (character_id, name, type, slot_count, properties) VALUES (?, ?, ?, ?, ?)" ) .run( id, name.trim(), type || "gear", slot_count ?? 1, JSON.stringify(properties || {}) ); const gear = db .prepare("SELECT * FROM character_gear WHERE id = ?") .get(result.lastInsertRowid); const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "gear:added", { characterId: Number(id), gear, }); res.status(201).json(gear); }); // DELETE /api/characters/:id/gear/:gearId — remove gear router.delete("/:id/gear/:gearId", (req, res) => { const { id, gearId } = req.params; const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const result = db .prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?") .run(gearId, id); if (result.changes === 0) { res.status(404).json({ error: "Gear not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", { characterId: Number(id), gearId: Number(gearId), }); res.status(204).end(); }); // POST /api/characters/:id/talents — add talent router.post("/:id/talents", (req, res) => { const { id } = req.params; const { name, description, effect } = req.body; if (!name || !name.trim()) { res.status(400).json({ error: "Talent name is required" }); return; } const result = db .prepare( "INSERT INTO character_talents (character_id, name, description, effect) VALUES (?, ?, ?, ?)" ) .run(id, name.trim(), description || "", JSON.stringify(effect || {})); const talent = db .prepare("SELECT * FROM character_talents WHERE id = ?") .get(result.lastInsertRowid); const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "talent:added", { characterId: Number(id), talent, }); res.status(201).json(talent); }); // DELETE /api/characters/:id/talents/:talentId — remove talent router.delete("/:id/talents/:talentId", (req, res) => { const { id, talentId } = req.params; const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const result = db .prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?") .run(talentId, id); if (result.changes === 0) { res.status(404).json({ error: "Talent not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", { characterId: Number(id), talentId: Number(talentId), }); res.status(204).end(); }); export default router; ``` - [ ] **Step 2: Register character routes in server/src/index.ts** Add these lines after the campaign routes registration: ```ts import characterRoutes from "./routes/characters.js"; app.use("/api/campaigns/:campaignId/characters", characterRoutes); app.use("/api/characters", characterRoutes); ``` The dual mount gives us: - `/api/campaigns/:campaignId/characters` — list/create characters in a campaign - `/api/characters/:id` — update/delete a specific character, manage stats/gear/talents - [ ] **Step 3: Test character endpoints manually** Run server and test: ```bash cd /Users/aaron.wood/workspace/shadowdark/server && npx tsx src/index.ts & # Create campaign first curl -s -X POST http://localhost:3000/api/campaigns -H 'Content-Type: application/json' -d '{"name":"Test"}' | jq . # Create character curl -s -X POST http://localhost:3000/api/campaigns/1/characters -H 'Content-Type: application/json' -d '{"name":"Kira","class":"Thief","ancestry":"Elf","hp_max":4}' | jq . # Update stat curl -s -X PATCH http://localhost:3000/api/characters/1/stats/STR -H 'Content-Type: application/json' -d '{"value":16}' | jq . # Add gear curl -s -X POST http://localhost:3000/api/characters/1/gear -H 'Content-Type: application/json' -d '{"name":"Longsword +1","type":"weapon","slot_count":1,"properties":{"damage":"1d8","melee":true,"bonus":1}}' | jq . # Add talent curl -s -X POST http://localhost:3000/api/characters/1/talents -H 'Content-Type: application/json' -d '{"name":"Backstab","description":"Advantage on attacks from behind","effect":{"advantage":"melee_stealth"}}' | jq . # List characters curl -s http://localhost:3000/api/campaigns/1/characters | jq . ``` Expected: All return appropriate status codes and JSON responses. Characters include stats, gear, and talents arrays. Kill server after testing. --- ### Task 6: Client Foundation — Types, API, Socket, Modifier Utils **Files:** - Create: `client/src/types.ts` - Create: `client/src/api.ts` - Create: `client/src/socket.ts` - Create: `client/src/utils/modifiers.ts` - [ ] **Step 1: Create client/src/types.ts** ```ts export interface Campaign { id: number; name: string; created_by: string; created_at: string; } export interface Stat { stat_name: string; value: number; } export interface Gear { id: number; character_id: number; name: string; type: "weapon" | "armor" | "gear" | "spell"; slot_count: number; properties: Record; } export interface Talent { id: number; character_id: number; name: string; description: string; effect: Record; } export interface Character { id: number; campaign_id: number; created_by: string; name: string; class: string; ancestry: string; level: number; xp: number; hp_current: number; hp_max: number; ac: number; alignment: string; title: string; notes: string; stats: Stat[]; gear: Gear[]; talents: Talent[]; } ``` - [ ] **Step 2: Create client/src/utils/modifiers.ts** ```ts const MODIFIER_TABLE: [number, number][] = [ [3, -4], [5, -3], [7, -2], [9, -1], [11, 0], [13, 1], [15, 2], [17, 3], [18, 4], ]; export function getModifier(score: number): number { for (const [max, mod] of MODIFIER_TABLE) { if (score <= max) return mod; } return 4; } export function formatModifier(mod: number): string { return mod >= 0 ? `+${mod}` : `${mod}`; } ``` - [ ] **Step 3: Create client/src/socket.ts** ```ts import { io } from "socket.io-client"; const socket = io("/", { autoConnect: true, reconnection: true, }); export default socket; ``` - [ ] **Step 4: Create client/src/api.ts** ```ts const BASE = "/api"; async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { headers: { "Content-Type": "application/json" }, ...options, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || res.statusText); } if (res.status === 204) return undefined as T; return res.json(); } // Campaigns export const getCampaigns = () => request("/campaigns"); export const createCampaign = (name: string) => request("/campaigns", { method: "POST", body: JSON.stringify({ name }), }); export const deleteCampaign = (id: number) => request(`/campaigns/${id}`, { method: "DELETE" }); // Characters export const getCharacters = (campaignId: number) => request(`/campaigns/${campaignId}/characters`); export const createCharacter = ( campaignId: number, data: { name: string; class?: string; ancestry?: string; hp_max?: number } ) => request(`/campaigns/${campaignId}/characters`, { method: "POST", body: JSON.stringify(data), }); export const updateCharacter = (id: number, data: Partial) => request(`/characters/${id}`, { method: "PATCH", body: JSON.stringify(data), }); export const deleteCharacter = (id: number) => request(`/characters/${id}`, { method: "DELETE" }); // Stats export const updateStat = ( characterId: number, statName: string, value: number ) => request<{ characterId: number; statName: string; value: number }>( `/characters/${characterId}/stats/${statName}`, { method: "PATCH", body: JSON.stringify({ value }) } ); // Gear export const addGear = ( characterId: number, data: { name: string; type?: string; slot_count?: number; properties?: Record; } ) => request(`/characters/${characterId}/gear`, { method: "POST", body: JSON.stringify(data), }); export const removeGear = (characterId: number, gearId: number) => request(`/characters/${characterId}/gear/${gearId}`, { method: "DELETE", }); // Talents export const addTalent = ( characterId: number, data: { name: string; description?: string; effect?: Record } ) => request(`/characters/${characterId}/talents`, { method: "POST", body: JSON.stringify(data), }); export const removeTalent = (characterId: number, talentId: number) => request(`/characters/${characterId}/talents/${talentId}`, { method: "DELETE", }); import type { Campaign, Character, Gear, Talent } from "./types"; ``` - [ ] **Step 5: Verify client builds** Run: ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx tsc --noEmit ``` Expected: No type errors (may warn about missing main.tsx, that's fine — created next task). --- ### Task 7: App Shell — Routing and Layout **Files:** - Create: `client/src/main.tsx` - Create: `client/src/App.tsx` - Create: `client/src/App.module.css` - [ ] **Step 1: Create client/src/main.tsx** ```tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; createRoot(document.getElementById("root")!).render( ); ``` - [ ] **Step 2: Create client/src/App.module.css** ```css * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: "Segoe UI", system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; } .app { max-width: 1400px; margin: 0 auto; padding: 1rem; } .header { text-align: center; padding: 1rem 0; border-bottom: 1px solid #333; margin-bottom: 1.5rem; } .header h1 { font-size: 1.8rem; color: #c9a84c; font-variant: small-caps; letter-spacing: 0.05em; } ``` - [ ] **Step 3: Create client/src/App.tsx** ```tsx import { BrowserRouter, Routes, Route } from "react-router-dom"; import CampaignList from "./pages/CampaignList"; import CampaignView from "./pages/CampaignView"; import styles from "./App.module.css"; export default function App() { return (

Shadowdark

} /> } />
); } ``` - [ ] **Step 4: Verify the app compiles** Run: ```bash cd /Users/aaron.wood/workspace/shadowdark/client && npx vite build ``` Expected: Build succeeds (may warn about missing page components — they're in the next tasks). --- ### Task 8: Campaign List Page **Files:** - Create: `client/src/pages/CampaignList.tsx` - Create: `client/src/pages/CampaignList.module.css` - [ ] **Step 1: Create client/src/pages/CampaignList.module.css** ```css .container { max-width: 600px; margin: 0 auto; } .campaignGrid { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; } .campaignCard { display: flex; align-items: center; justify-content: space-between; background: #16213e; border: 1px solid #333; border-radius: 8px; padding: 1rem 1.25rem; cursor: pointer; transition: border-color 0.15s; } .campaignCard:hover { border-color: #c9a84c; } .campaignName { font-size: 1.1rem; font-weight: 600; } .campaignDate { font-size: 0.8rem; color: #888; } .deleteBtn { background: none; border: none; color: #666; cursor: pointer; font-size: 1.2rem; padding: 0.25rem 0.5rem; border-radius: 4px; } .deleteBtn:hover { color: #e74c3c; background: rgba(231, 76, 60, 0.1); } .createForm { display: flex; gap: 0.5rem; } .createInput { flex: 1; padding: 0.6rem 1rem; background: #16213e; border: 1px solid #333; border-radius: 8px; color: #e0e0e0; font-size: 1rem; } .createInput:focus { outline: none; border-color: #c9a84c; } .createBtn { padding: 0.6rem 1.25rem; background: #c9a84c; color: #1a1a2e; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem; } .createBtn:hover { background: #d4b65a; } .empty { text-align: center; color: #666; padding: 3rem 0; font-style: italic; } ``` - [ ] **Step 2: Create client/src/pages/CampaignList.tsx** ```tsx import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { getCampaigns, createCampaign, deleteCampaign } from "../api"; import type { Campaign } from "../types"; import styles from "./CampaignList.module.css"; export default function CampaignList() { const [campaigns, setCampaigns] = useState([]); const [newName, setNewName] = useState(""); const navigate = useNavigate(); useEffect(() => { getCampaigns().then(setCampaigns); }, []); async function handleCreate(e: React.FormEvent) { e.preventDefault(); if (!newName.trim()) return; try { const campaign = await createCampaign(newName.trim()); setCampaigns((prev) => [campaign, ...prev]); setNewName(""); } catch (err) { console.error("Failed to create campaign:", err); } } async function handleDelete(e: React.MouseEvent, id: number) { e.stopPropagation(); try { await deleteCampaign(id); setCampaigns((prev) => prev.filter((c) => c.id !== id)); } catch (err) { console.error("Failed to delete campaign:", err); } } return (
setNewName(e.target.value)} />
{campaigns.length === 0 && (

No campaigns yet. Create one above!

)} {campaigns.map((c) => (
navigate(`/campaign/${c.id}`)} >
{c.name}
{new Date(c.created_at).toLocaleDateString()}
))}
); } ``` --- ### Task 9: HpBar and StatBlock Components **Files:** - Create: `client/src/components/HpBar.tsx` - Create: `client/src/components/HpBar.module.css` - Create: `client/src/components/StatBlock.tsx` - Create: `client/src/components/StatBlock.module.css` - [ ] **Step 1: Create client/src/components/HpBar.module.css** ```css .hpBar { display: flex; align-items: center; gap: 0.5rem; } .label { font-size: 0.75rem; color: #888; text-transform: uppercase; font-weight: 600; } .values { display: flex; align-items: center; gap: 0.25rem; font-size: 1.1rem; font-weight: 700; } .current { color: #4caf50; } .current.hurt { color: #ff9800; } .current.critical { color: #e74c3c; } .slash { color: #666; } .max { color: #888; } .btn { width: 24px; height: 24px; border-radius: 50%; border: 1px solid #444; background: #16213e; color: #e0e0e0; cursor: pointer; font-size: 0.9rem; display: flex; align-items: center; justify-content: center; line-height: 1; } .btn:hover { border-color: #c9a84c; color: #c9a84c; } ``` - [ ] **Step 2: Create client/src/components/HpBar.tsx** ```tsx import styles from "./HpBar.module.css"; interface HpBarProps { current: number; max: number; onChange: (current: number) => void; } export default function HpBar({ current, max, onChange }: HpBarProps) { const ratio = max > 0 ? current / max : 1; const colorClass = ratio <= 0.25 ? styles.critical : ratio <= 0.5 ? styles.hurt : ""; return (
HP {current} / {max}
); } ``` - [ ] **Step 3: Create client/src/components/StatBlock.module.css** ```css .statGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; } .stat { display: flex; flex-direction: column; align-items: center; background: #0f1a30; border-radius: 6px; padding: 0.3rem; } .statName { font-size: 0.65rem; color: #888; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; } .statRow { display: flex; align-items: center; gap: 0.25rem; } .modifier { font-size: 1rem; font-weight: 700; color: #c9a84c; min-width: 2rem; text-align: center; } .score { font-size: 0.7rem; color: #666; } .btn { 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; line-height: 1; } .btn:hover { border-color: #c9a84c; color: #c9a84c; } ``` - [ ] **Step 4: Create client/src/components/StatBlock.tsx** ```tsx import type { Stat } from "../types"; import { getModifier, formatModifier } from "../utils/modifiers"; import styles from "./StatBlock.module.css"; const STAT_ORDER = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; interface StatBlockProps { stats: Stat[]; onStatChange: (statName: string, newValue: number) => void; } export default function StatBlock({ stats, onStatChange }: StatBlockProps) { const statMap = new Map(stats.map((s) => [s.stat_name, s.value])); return (
{STAT_ORDER.map((name) => { const value = statMap.get(name) ?? 10; const mod = getModifier(value); return (
{name}
{formatModifier(mod)}
{value}
); })}
); } ``` --- ### Task 10: CharacterCard Component **Files:** - Create: `client/src/components/CharacterCard.tsx` - Create: `client/src/components/CharacterCard.module.css` - [ ] **Step 1: Create client/src/components/CharacterCard.module.css** ```css .card { background: #16213e; border: 1px solid #333; border-radius: 10px; padding: 1rem; cursor: pointer; transition: border-color 0.15s, transform 0.1s; } .card:hover { border-color: #c9a84c; transform: translateY(-2px); } .cardHeader { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.5rem; } .name { font-size: 1.1rem; font-weight: 700; color: #e0e0e0; } .level { font-size: 0.8rem; color: #888; } .meta { font-size: 0.8rem; color: #666; margin-bottom: 0.75rem; } .hpAcRow { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; } .ac { display: flex; align-items: center; gap: 0.3rem; font-weight: 700; } .acLabel { font-size: 0.75rem; color: #888; text-transform: uppercase; font-weight: 600; } .acValue { font-size: 1.1rem; color: #5dade2; } .gearSummary { font-size: 0.75rem; color: #666; text-align: right; margin-top: 0.5rem; } ``` - [ ] **Step 2: Create client/src/components/CharacterCard.tsx** ```tsx import type { Character } from "../types"; import HpBar from "./HpBar"; import StatBlock from "./StatBlock"; import styles from "./CharacterCard.module.css"; interface CharacterCardProps { character: Character; onHpChange: (characterId: number, hp: number) => void; onStatChange: (characterId: number, statName: string, value: number) => void; onClick: (characterId: number) => void; } export default function CharacterCard({ character, onHpChange, onStatChange, onClick, }: CharacterCardProps) { const totalSlots = character.gear.reduce((sum, g) => sum + g.slot_count, 0); return (
onClick(character.id)}>
{character.name} {character.title ? ` ${character.title}` : ""} Lvl {character.level}
{character.ancestry} {character.class}
e.stopPropagation()}> onHpChange(character.id, hp)} />
AC {character.ac}
e.stopPropagation()}> onStatChange(character.id, statName, value) } />
{totalSlots} gear slot{totalSlots !== 1 ? "s" : ""} used
); } ``` --- ### Task 11: GearList and TalentList Components **Files:** - Create: `client/src/components/GearList.tsx` - Create: `client/src/components/GearList.module.css` - Create: `client/src/components/TalentList.tsx` - Create: `client/src/components/TalentList.module.css` - [ ] **Step 1: Create client/src/components/GearList.module.css** ```css .section { margin-bottom: 1rem; } .sectionHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } .sectionTitle { font-size: 0.9rem; font-weight: 700; color: #c9a84c; text-transform: uppercase; letter-spacing: 0.05em; } .list { display: flex; flex-direction: column; gap: 0.4rem; } .item { display: flex; justify-content: space-between; align-items: center; background: #0f1a30; border-radius: 6px; padding: 0.5rem 0.75rem; } .itemInfo { flex: 1; } .itemName { font-weight: 600; font-size: 0.9rem; } .itemMeta { font-size: 0.75rem; color: #888; } .removeBtn { background: none; border: none; color: #666; cursor: pointer; font-size: 1rem; padding: 0.25rem; } .removeBtn:hover { color: #e74c3c; } .addForm { display: flex; gap: 0.4rem; margin-top: 0.5rem; } .addInput { flex: 1; padding: 0.4rem 0.6rem; background: #0f1a30; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.85rem; } .addInput:focus { outline: none; border-color: #c9a84c; } .addSelect { padding: 0.4rem 0.6rem; background: #0f1a30; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.85rem; } .addBtn { padding: 0.4rem 0.75rem; background: #c9a84c; color: #1a1a2e; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 0.85rem; } .addBtn:hover { background: #d4b65a; } .empty { font-size: 0.8rem; color: #555; font-style: italic; padding: 0.5rem 0; } ``` - [ ] **Step 2: Create client/src/components/GearList.tsx** ```tsx import { useState } from "react"; import type { Gear } from "../types"; import styles from "./GearList.module.css"; interface GearListProps { gear: Gear[]; onAdd: (data: { name: string; type: string; slot_count: number }) => void; onRemove: (gearId: number) => void; } export default function GearList({ gear, onAdd, onRemove }: GearListProps) { const [name, setName] = useState(""); const [type, setType] = useState("gear"); function handleAdd(e: React.FormEvent) { e.preventDefault(); if (!name.trim()) return; onAdd({ name: name.trim(), type, slot_count: 1 }); setName(""); } function formatGearMeta(item: Gear): string { const parts = [item.type]; if (item.slot_count !== 1) parts.push(`${item.slot_count} slots`); const props = item.properties as Record; if (props.damage) parts.push(String(props.damage)); if (props.bonus) parts.push(`+${props.bonus}`); return parts.join(" · "); } return (
Gear & Inventory
{gear.length === 0 &&

No gear yet

} {gear.map((item) => (
{item.name}
{formatGearMeta(item)}
))}
setName(e.target.value)} />
); } ``` - [ ] **Step 3: Create client/src/components/TalentList.module.css** ```css .section { margin-bottom: 1rem; } .sectionHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } .sectionTitle { font-size: 0.9rem; font-weight: 700; color: #c9a84c; text-transform: uppercase; letter-spacing: 0.05em; } .list { display: flex; flex-direction: column; gap: 0.4rem; } .item { display: flex; justify-content: space-between; align-items: flex-start; background: #0f1a30; border-radius: 6px; padding: 0.5rem 0.75rem; } .itemInfo { flex: 1; } .itemName { font-weight: 600; font-size: 0.9rem; } .itemDesc { font-size: 0.75rem; color: #888; } .removeBtn { background: none; border: none; color: #666; cursor: pointer; font-size: 1rem; padding: 0.25rem; } .removeBtn:hover { color: #e74c3c; } .addForm { display: flex; gap: 0.4rem; margin-top: 0.5rem; } .addInput { flex: 1; padding: 0.4rem 0.6rem; background: #0f1a30; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.85rem; } .addInput:focus { outline: none; border-color: #c9a84c; } .addBtn { padding: 0.4rem 0.75rem; background: #c9a84c; color: #1a1a2e; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 0.85rem; } .addBtn:hover { background: #d4b65a; } .empty { font-size: 0.8rem; color: #555; font-style: italic; padding: 0.5rem 0; } ``` - [ ] **Step 4: Create client/src/components/TalentList.tsx** ```tsx import { useState } from "react"; import type { Talent } from "../types"; import styles from "./TalentList.module.css"; interface TalentListProps { talents: Talent[]; onAdd: (data: { name: string; description: string }) => void; onRemove: (talentId: number) => void; } export default function TalentList({ talents, onAdd, onRemove, }: TalentListProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); function handleAdd(e: React.FormEvent) { e.preventDefault(); if (!name.trim()) return; onAdd({ name: name.trim(), description: description.trim() }); setName(""); setDescription(""); } return (
Talents
{talents.length === 0 &&

No talents yet

} {talents.map((t) => (
{t.name}
{t.description && (
{t.description}
)}
))}
setName(e.target.value)} /> setDescription(e.target.value)} />
); } ``` --- ### Task 12: CharacterDetail Modal **Files:** - Create: `client/src/components/CharacterDetail.tsx` - Create: `client/src/components/CharacterDetail.module.css` - [ ] **Step 1: Create client/src/components/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: 700px; max-height: 90vh; overflow-y: auto; padding: 1.5rem; } .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; } .nameSection { flex: 1; } .name { font-size: 1.5rem; font-weight: 700; } .meta { color: #888; font-size: 0.9rem; margin-top: 0.25rem; } .closeBtn { background: none; border: none; color: #888; font-size: 1.5rem; cursor: pointer; padding: 0.25rem 0.5rem; } .closeBtn:hover { color: #e0e0e0; } .fieldGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1.25rem; } .field { display: flex; flex-direction: column; gap: 0.25rem; } .fieldLabel { font-size: 0.75rem; color: #888; text-transform: uppercase; font-weight: 600; } .fieldInput { padding: 0.4rem 0.6rem; background: #0f1a30; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.9rem; } .fieldInput:focus { outline: none; border-color: #c9a84c; } .fieldSelect { padding: 0.4rem 0.6rem; background: #0f1a30; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.9rem; } .statsSection { margin-bottom: 1.25rem; } .statsSectionTitle { font-size: 0.9rem; font-weight: 700; color: #c9a84c; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } .notesField { width: 100%; min-height: 80px; padding: 0.6rem; background: #0f1a30; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; font-size: 0.9rem; font-family: inherit; resize: vertical; } .notesField:focus { outline: none; border-color: #c9a84c; } .deleteSection { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #333; } .deleteBtn { padding: 0.5rem 1rem; background: transparent; border: 1px solid #e74c3c; border-radius: 6px; color: #e74c3c; cursor: pointer; font-size: 0.85rem; } .deleteBtn:hover { background: rgba(231, 76, 60, 0.1); } ``` - [ ] **Step 2: Create client/src/components/CharacterDetail.tsx** ```tsx import { useState } from "react"; import type { Character } from "../types"; import StatBlock from "./StatBlock"; import GearList from "./GearList"; import TalentList from "./TalentList"; import styles from "./CharacterDetail.module.css"; const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"]; const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"]; const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"]; interface CharacterDetailProps { character: Character; onUpdate: (id: number, data: Partial) => void; onStatChange: (characterId: number, statName: string, value: number) => void; onAddGear: ( 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, onAddGear, onRemoveGear, onAddTalent, onRemoveTalent, onDelete, onClose, }: CharacterDetailProps) { const [confirmDelete, setConfirmDelete] = useState(false); function handleFieldChange(field: string, value: string | number) { onUpdate(character.id, { [field]: value }); } return (
e.stopPropagation()}>
{character.name}
Level {character.level} {character.ancestry} {character.class}
handleFieldChange("name", e.target.value)} />
handleFieldChange("title", e.target.value)} />
handleFieldChange("level", Number(e.target.value)) } />
handleFieldChange("xp", Number(e.target.value))} />
handleFieldChange("hp_max", Number(e.target.value)) } />
handleFieldChange("ac", Number(e.target.value))} />
Ability Scores
onStatChange(character.id, statName, value) } />
onAddGear(character.id, data)} onRemove={(gearId) => onRemoveGear(character.id, gearId)} /> onAddTalent(character.id, data)} onRemove={(talentId) => onRemoveTalent(character.id, talentId)} />