From 0c0da9b90ec0802967a6784ec214c29c6a16a580 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 6 Mar 2026 15:07:04 +0100 Subject: [PATCH] Implement the 019-combatant-row-declutter feature that replaces always-visible HP controls and AC/MaxHP inputs with compact click-to-edit and click-to-adjust patterns in the encounter tracker Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + apps/web/src/components/combatant-row.tsx | 230 ++++++++++-------- apps/web/src/components/hp-adjust-popover.tsx | 109 +++++++++ apps/web/src/components/quick-hp-input.tsx | 99 -------- .../checklists/requirements.md | 34 +++ .../019-combatant-row-declutter/data-model.md | 29 +++ specs/019-combatant-row-declutter/plan.md | 75 ++++++ .../019-combatant-row-declutter/quickstart.md | 44 ++++ specs/019-combatant-row-declutter/research.md | 42 ++++ specs/019-combatant-row-declutter/spec.md | 113 +++++++++ specs/019-combatant-row-declutter/tasks.md | 154 ++++++++++++ 11 files changed, 723 insertions(+), 207 deletions(-) create mode 100644 apps/web/src/components/hp-adjust-popover.tsx delete mode 100644 apps/web/src/components/quick-hp-input.tsx create mode 100644 specs/019-combatant-row-declutter/checklists/requirements.md create mode 100644 specs/019-combatant-row-declutter/data-model.md create mode 100644 specs/019-combatant-row-declutter/plan.md create mode 100644 specs/019-combatant-row-declutter/quickstart.md create mode 100644 specs/019-combatant-row-declutter/research.md create mode 100644 specs/019-combatant-row-declutter/spec.md create mode 100644 specs/019-combatant-row-declutter/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index a22b118..d7f1ea7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: - N/A (no storage changes) (015-add-jscpd-gate) - Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac) - TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions) +- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter) ## Recent Changes - 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx index 06040da..3a55f29 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../lib/utils"; import { ConditionPicker } from "./condition-picker"; import { ConditionTags } from "./condition-tags"; -import { QuickHpInput } from "./quick-hp-input"; +import { HpAdjustPopover } from "./hp-adjust-popover"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -91,144 +91,169 @@ function EditableName({ ); } -function MaxHpInput({ +function MaxHpDisplay({ maxHp, onCommit, }: { maxHp: number | undefined; onCommit: (value: number | undefined) => void; }) { + const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(maxHp?.toString() ?? ""); - const prev = useRef(maxHp); - - if (maxHp !== prev.current) { - prev.current = maxHp; - setDraft(maxHp?.toString() ?? ""); - } + const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onCommit(undefined); - return; - } - const n = Number.parseInt(draft, 10); - if (!Number.isNaN(n) && n >= 1) { - onCommit(n); } else { - setDraft(maxHp?.toString() ?? ""); + const n = Number.parseInt(draft, 10); + if (!Number.isNaN(n) && n >= 1) { + onCommit(n); + } } - }, [draft, maxHp, onCommit]); + setEditing(false); + }, [draft, onCommit]); + + const startEditing = useCallback(() => { + setDraft(maxHp?.toString() ?? ""); + setEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }, [maxHp]); + + if (editing) { + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") setEditing(false); + }} + /> + ); + } return ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") commit(); - }} - /> + ); } -function CurrentHpInput({ +function ClickableHp({ currentHp, maxHp, - onCommit, - className, + onAdjust, }: { currentHp: number | undefined; maxHp: number | undefined; - onCommit: (value: number) => void; - className?: string; + onAdjust: (delta: number) => void; }) { - const [draft, setDraft] = useState(currentHp?.toString() ?? ""); - const prev = useRef(currentHp); + const [popoverOpen, setPopoverOpen] = useState(false); + const status = deriveHpStatus(currentHp, maxHp); - if (currentHp !== prev.current) { - prev.current = currentHp; - setDraft(currentHp?.toString() ?? ""); + if (maxHp === undefined) { + return ( + + -- + + ); } - const commit = useCallback(() => { - if (currentHp === undefined) return; - if (draft === "") { - setDraft(currentHp.toString()); - return; - } - const n = Number.parseInt(draft, 10); - if (!Number.isNaN(n)) { - onCommit(n); - } else { - setDraft(currentHp.toString()); - } - }, [draft, currentHp, onCommit]); - return ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") commit(); - }} - /> +
+ + {popoverOpen && ( + setPopoverOpen(false)} + /> + )} +
); } -function AcInput({ +function AcDisplay({ ac, onCommit, }: { ac: number | undefined; onCommit: (value: number | undefined) => void; }) { + const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(ac?.toString() ?? ""); - const prev = useRef(ac); - - if (ac !== prev.current) { - prev.current = ac; - setDraft(ac?.toString() ?? ""); - } + const inputRef = useRef(null); const commit = useCallback(() => { if (draft === "") { onCommit(undefined); - return; - } - const n = Number.parseInt(draft, 10); - if (!Number.isNaN(n) && n >= 0) { - onCommit(n); } else { - setDraft(ac?.toString() ?? ""); + const n = Number.parseInt(draft, 10); + if (!Number.isNaN(n) && n >= 0) { + onCommit(n); + } } - }, [draft, ac, onCommit]); + setEditing(false); + }, [draft, onCommit]); + + const startEditing = useCallback(() => { + setDraft(ac?.toString() ?? ""); + setEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }, [ac]); + + if (editing) { + return ( +
+ + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") setEditing(false); + }} + /> +
+ ); + } return ( -
- - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") commit(); - }} - /> -
+ ); } @@ -329,32 +354,21 @@ export function CombatantRow({ {/* AC */} - onSetAc(id, v)} /> + onSetAc(id, v)} /> {/* HP */}
- { - if (currentHp === undefined) return; - const delta = value - currentHp; - if (delta !== 0) onAdjustHp(id, delta); - }} + onAdjust={(delta) => onAdjustHp(id, delta)} /> {maxHp !== undefined && ( / )} - onSetHp(id, v)} /> - {maxHp !== undefined && ( - - )} + onSetHp(id, v)} />
{/* Actions */} diff --git a/apps/web/src/components/hp-adjust-popover.tsx b/apps/web/src/components/hp-adjust-popover.tsx new file mode 100644 index 0000000..17c8642 --- /dev/null +++ b/apps/web/src/components/hp-adjust-popover.tsx @@ -0,0 +1,109 @@ +import { Heart, Sword } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; + +interface HpAdjustPopoverProps { + readonly onAdjust: (delta: number) => void; + readonly onClose: () => void; +} + +export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) { + const [inputValue, setInputValue] = useState(""); + const ref = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + requestAnimationFrame(() => inputRef.current?.focus()); + }, []); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + const parsedValue = + inputValue === "" ? null : Number.parseInt(inputValue, 10); + const isValid = + parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0; + + const applyDelta = useCallback( + (sign: -1 | 1) => { + if (inputValue === "") return; + const n = Number.parseInt(inputValue, 10); + if (Number.isNaN(n) || n <= 0) return; + onAdjust(sign * n); + onClose(); + }, + [inputValue, onAdjust, onClose], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + if (e.shiftKey) { + applyDelta(1); + } else { + applyDelta(-1); + } + } else if (e.key === "Escape") { + onClose(); + } + }, + [applyDelta, onClose], + ); + + return ( +
+
+ { + const v = e.target.value; + if (v === "" || /^\d+$/.test(v)) { + setInputValue(v); + } + }} + onKeyDown={handleKeyDown} + /> + + +
+
+ ); +} diff --git a/apps/web/src/components/quick-hp-input.tsx b/apps/web/src/components/quick-hp-input.tsx deleted file mode 100644 index 27dbeab..0000000 --- a/apps/web/src/components/quick-hp-input.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { CombatantId } from "@initiative/domain"; -import { Heart, Sword } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; -import { cn } from "../lib/utils"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; - -interface QuickHpInputProps { - readonly combatantId: CombatantId; - readonly disabled?: boolean; - readonly onAdjustHp: (id: CombatantId, delta: number) => void; -} - -export function QuickHpInput({ - combatantId, - disabled, - onAdjustHp, -}: QuickHpInputProps) { - const [inputValue, setInputValue] = useState(""); - const inputRef = useRef(null); - - const parsedValue = - inputValue === "" ? null : Number.parseInt(inputValue, 10); - const isValid = - parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0; - - const applyDelta = useCallback( - (sign: -1 | 1) => { - if (inputValue === "") return; - const n = Number.parseInt(inputValue, 10); - if (Number.isNaN(n) || n <= 0) return; - onAdjustHp(combatantId, sign * n); - setInputValue(""); - }, - [inputValue, combatantId, onAdjustHp], - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - applyDelta(-1); - } else if (e.key === "Escape") { - setInputValue(""); - } - }, - [applyDelta], - ); - - return ( -
- { - const v = e.target.value; - if (v === "" || /^\d+$/.test(v)) { - setInputValue(v); - } - }} - onKeyDown={handleKeyDown} - /> - - -
- ); -} diff --git a/specs/019-combatant-row-declutter/checklists/requirements.md b/specs/019-combatant-row-declutter/checklists/requirements.md new file mode 100644 index 0000000..77a4b0a --- /dev/null +++ b/specs/019-combatant-row-declutter/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Combatant Row Declutter + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-06 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/019-combatant-row-declutter/data-model.md b/specs/019-combatant-row-declutter/data-model.md new file mode 100644 index 0000000..eec9a2b --- /dev/null +++ b/specs/019-combatant-row-declutter/data-model.md @@ -0,0 +1,29 @@ +# Data Model: Combatant Row Declutter + +**Feature**: 019-combatant-row-declutter +**Date**: 2026-03-06 + +## No Data Model Changes + +This feature is a purely visual/interaction refactor within the web adapter layer. No domain entities, application state, or persistence formats are affected. + +### Existing entities (unchanged) + +- **Combatant**: `id`, `name`, `initiative`, `maxHp`, `currentHp`, `ac`, `conditions`, `isConcentrating` — no fields added, removed, or modified. + +### Existing callbacks (unchanged) + +- `onAdjustHp(id: CombatantId, delta: number)` — same signature, now triggered from popover instead of inline QuickHpInput. +- `onSetAc(id: CombatantId, value: number | undefined)` — same signature, now triggered from click-to-edit instead of always-visible input. + +### UI State (component-local only) + +New component-local state introduced in the HP popover (not persisted): +- **popoverOpen**: boolean — whether the HP adjustment popover is visible. +- **inputValue**: string — the draft delta value in the popover input. + +New component-local state in the AC click-to-edit (not persisted): +- **editing**: boolean — whether the inline AC edit input is visible. +- **draft**: string — the draft AC value being edited. + +These follow the same patterns as existing component-local state (`EditableName.editing`, `ConditionPicker` open state). diff --git a/specs/019-combatant-row-declutter/plan.md b/specs/019-combatant-row-declutter/plan.md new file mode 100644 index 0000000..214bb61 --- /dev/null +++ b/specs/019-combatant-row-declutter/plan.md @@ -0,0 +1,75 @@ +# Implementation Plan: Combatant Row Declutter + +**Branch**: `019-combatant-row-declutter` | **Date**: 2026-03-06 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/019-combatant-row-declutter/spec.md` + +## Summary + +Replace always-visible HP adjustment controls (QuickHpInput with delta input + Sword/Heart buttons) and AC input field with compact, on-demand interaction patterns. HP becomes a clickable value that opens a popover for damage/healing. AC becomes a static shield+number display with click-to-edit inline editing. This is purely a web adapter (UI) change — domain and application layers are untouched. + +## Technical Context + +**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax) +**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) +**Storage**: N/A (no storage changes — existing localStorage persistence unchanged) +**Testing**: Vitest (unit tests for pure functions; UI behavior verified manually) +**Target Platform**: Desktop and tablet-width browsers +**Project Type**: Web application (monorepo with domain/application/web layers) +**Performance Goals**: N/A (no performance-sensitive changes) +**Constraints**: Keyboard-accessible, no domain/application layer modifications +**Scale/Scope**: 1 component modified (CombatantRow), 1 component removed (QuickHpInput), 1 new component (HpAdjustPopover). AC and Max HP click-to-edit are implemented inline within CombatantRow (no separate component files) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Deterministic Domain Core | PASS | No domain changes | +| II. Layered Architecture | PASS | All changes in adapter layer (apps/web) only | +| III. Agent Boundary | N/A | No agent features involved | +| IV. Clarification-First | PASS | Feature description was fully specified, no ambiguities | +| V. Escalation Gates | PASS | All changes within spec scope | +| VI. MVP Baseline Language | PASS | No permanent bans introduced | +| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan | + +**Pre-design gate: PASS** — no violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/019-combatant-row-declutter/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +apps/web/src/components/ +├── combatant-row.tsx # MODIFY: replace CurrentHpInput with clickable display + popover, +│ # replace AcInput with click-to-edit static display +├── hp-adjust-popover.tsx # NEW: popover with numeric input + Damage/Heal buttons +├── quick-hp-input.tsx # REMOVE: replaced by hp-adjust-popover +└── ui/ + ├── button.tsx # existing (used by popover) + └── input.tsx # existing (used by popover and AC inline edit) +``` + +**Structure Decision**: All changes are confined to `apps/web/src/components/`. No new directories needed. The existing monorepo structure (domain → application → web) is preserved. No contracts directory needed since this is an internal UI refactor with no external interfaces. + +## Post-Design Constitution Re-Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Deterministic Domain Core | PASS | No domain changes | +| II. Layered Architecture | PASS | Only adapter-layer files touched | +| IV. Clarification-First | PASS | No new ambiguities introduced | +| V. Escalation Gates | PASS | All within spec scope | + +**Post-design gate: PASS** — no violations. diff --git a/specs/019-combatant-row-declutter/quickstart.md b/specs/019-combatant-row-declutter/quickstart.md new file mode 100644 index 0000000..29407d1 --- /dev/null +++ b/specs/019-combatant-row-declutter/quickstart.md @@ -0,0 +1,44 @@ +# Quickstart: Combatant Row Declutter + +**Feature**: 019-combatant-row-declutter +**Date**: 2026-03-06 + +## What This Feature Does + +Replaces always-visible HP adjustment controls and AC input with compact, on-demand interactions: +1. **HP**: Click the current HP number to open a small popover for damage/healing. +2. **AC**: Click the shield+number display to edit AC inline. + +## Key Files + +| File | Role | +|------|------| +| `apps/web/src/components/combatant-row.tsx` | Main combatant row — modified to use new patterns | +| `apps/web/src/components/hp-adjust-popover.tsx` | New popover component for HP adjustment | +| `apps/web/src/components/quick-hp-input.tsx` | Removed — replaced by HP popover | + +## How It Works + +### HP Adjustment Flow +1. Current HP displays as a clickable, color-coded number (amber=bloodied, red=unconscious) +2. Click opens a popover with auto-focused numeric input + Damage/Heal buttons +3. Enter = apply damage (negative delta), Shift+Enter = apply healing (positive delta) +4. Popover dismisses on action, Escape, or click-outside + +### AC Edit Flow +1. AC displays as static text: shield icon + number (or just shield if unset) +2. Click opens inline input (same pattern as editable name) +3. Enter/blur commits, Escape cancels + +## Development + +```bash +pnpm --filter web dev # Start dev server +pnpm check # Run full quality gate before committing +``` + +## Constraints + +- Domain and application layers are unchanged +- `onAdjustHp` and `onSetAc` callback signatures unchanged +- Max HP input remains always-visible diff --git a/specs/019-combatant-row-declutter/research.md b/specs/019-combatant-row-declutter/research.md new file mode 100644 index 0000000..297c8bc --- /dev/null +++ b/specs/019-combatant-row-declutter/research.md @@ -0,0 +1,42 @@ +# Research: Combatant Row Declutter + +**Feature**: 019-combatant-row-declutter +**Date**: 2026-03-06 + +## R1: Popover Dismissal Pattern + +**Decision**: Use a click-outside listener with ref-based boundary detection, consistent with the existing ConditionPicker component pattern. + +**Rationale**: The codebase already uses this pattern in `condition-picker.tsx` / `condition-tags.tsx` where clicking outside the picker closes it. Reusing the same approach maintains consistency and avoids introducing new dependencies (e.g., a popover library). The popover will use `useEffect` with a document-level click handler that checks if the click target is outside the popover ref. + +**Alternatives considered**: +- Headless UI library (Radix Popover, Floating UI): Adds a dependency for a simple use case. Rejected — the project has no headless UI library and introducing one for a single popover is over-engineering. +- HTML `` / `popover` attribute: Native browser support is good, but the `popover` attribute doesn't provide the positioning control needed (anchored to the HP value). Rejected for insufficient positioning semantics. + +## R2: Click-to-Edit Pattern for AC + +**Decision**: Reuse the exact same pattern as the existing `EditableName` component — toggle between static display and input on click, commit on Enter/blur, cancel on Escape. + +**Rationale**: `EditableName` in `combatant-row.tsx` already implements this exact interaction pattern. The AC click-to-edit can follow the same state machine (editing boolean, draft state, commit/cancel callbacks). This ensures behavioral consistency across the row. + +**Alternatives considered**: +- Popover (same as HP): AC editing is simpler (just set a value, no damage/heal distinction), so a popover adds unnecessary complexity. Rejected. +- Double-click to edit: Less discoverable than single-click. Rejected — the existing name edit uses single-click. + +## R3: HP Popover Positioning + +**Decision**: Position the popover directly below (or above if near viewport bottom) the current HP value using simple CSS absolute/relative positioning. + +**Rationale**: The popover only needs to appear near the trigger element. The combatant row is a simple list layout — no complex scrolling containers or overflow clipping that would require a positioning library. Simple CSS positioning (relative parent + absolute child) is sufficient. + +**Alternatives considered**: +- Floating UI / Popper.js: Provides advanced positioning with flip/shift. Overkill for this use case since the encounter list is a straightforward vertical layout. Rejected. + +## R4: Keyboard Shortcuts in Popover + +**Decision**: Handle Enter (damage) and Shift+Enter (heal) via `onKeyDown` on the popover's input element. Escape handled at the same level to close the popover. + +**Rationale**: This matches the existing QuickHpInput pattern where Enter applies damage. Adding Shift+Enter for healing is a natural modifier key extension. The input captures all keyboard events, so no global listeners are needed. + +**Alternatives considered**: +- Separate keyboard shortcut system: Unnecessary complexity for two shortcuts scoped to a single input. Rejected. diff --git a/specs/019-combatant-row-declutter/spec.md b/specs/019-combatant-row-declutter/spec.md new file mode 100644 index 0000000..134d55e --- /dev/null +++ b/specs/019-combatant-row-declutter/spec.md @@ -0,0 +1,113 @@ +# Feature Specification: Combatant Row Declutter + +**Feature Branch**: `019-combatant-row-declutter` +**Created**: 2026-03-06 +**Status**: Draft +**Input**: User description: "Declutter combatant row with click-to-edit patterns — replace always-visible HP adjustment and AC inputs with compact, on-demand interactions" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - HP Click-to-Adjust Popover (Priority: P1) + +As a DM running combat, I want to click on a combatant's current HP value to open a small adjustment popover, so the combatant row is visually clean and I can still quickly apply damage or healing when needed. + +**Why this priority**: HP adjustment is the most frequent mid-combat interaction and the QuickHpInput with its Sword/Heart buttons is the biggest source of visual clutter. Replacing it with an on-demand popover delivers the largest declutter impact. + +**Independent Test**: Can be fully tested by clicking a combatant's current HP, entering a number, and pressing Enter (damage) or Shift+Enter (heal), then verifying the HP updates correctly and the popover dismisses. + +**Acceptance Scenarios**: + +1. **Given** a combatant with max HP set (e.g., 30/30), **When** I look at the row, **Then** I see only the current HP number and max HP — no delta input or action buttons are visible. +2. **Given** a combatant with max HP set, **When** I click the current HP number, **Then** a small popover opens containing an auto-focused numeric input and Damage/Heal buttons. +3. **Given** the HP popover is open with a valid number entered, **When** I press Enter, **Then** damage is applied (negative delta), the popover closes, and the HP value updates. +4. **Given** the HP popover is open with a valid number entered, **When** I press Shift+Enter, **Then** healing is applied (positive delta), the popover closes, and the HP value updates. +5. **Given** the HP popover is open with a valid number entered, **When** I click the Damage button, **Then** damage is applied and the popover closes. +6. **Given** the HP popover is open with a valid number entered, **When** I click the Heal button, **Then** healing is applied and the popover closes. +7. **Given** the HP popover is open, **When** I press Escape, **Then** the popover closes without applying any change. +8. **Given** the HP popover is open, **When** I click outside the popover, **Then** the popover closes without applying any change. +9. **Given** a combatant whose HP is at or below half (bloodied), **When** I view the row, **Then** the current HP number displays in the bloodied color (amber). +10. **Given** a combatant whose HP is at 0 (unconscious), **When** I view the row, **Then** the current HP number displays in the unconscious color (red). +11. **Given** a combatant with no max HP set, **When** I view the row, **Then** the HP area shows only the max HP input — no clickable current HP value. + +--- + +### User Story 2 - AC Click-to-Edit (Priority: P2) + +As a DM, I want the AC field to appear as a compact static display (shield icon + number) that I can click to edit inline, so the row is less cluttered while AC remains easy to set or change. + +**Why this priority**: AC is set less frequently than HP is adjusted, so converting it to a click-to-edit pattern provides good declutter value with lower interaction frequency impact. + +**Independent Test**: Can be fully tested by clicking the AC display, editing the value, and confirming via Enter or blur, then verifying the AC updates correctly. + +**Acceptance Scenarios**: + +1. **Given** a combatant with AC set (e.g., 15), **When** I view the row, **Then** I see a shield icon followed by the number "15" as static text — not an input field. +2. **Given** a combatant with no AC set, **When** I view the row, **Then** I see just the shield icon (no number) as a clickable element. +3. **Given** a combatant's AC display, **When** I click it, **Then** an inline input appears with the current AC value pre-filled and selected. +4. **Given** the AC inline edit is active, **When** I type a new value and press Enter, **Then** the AC updates and the display reverts to static mode. +5. **Given** the AC inline edit is active, **When** I click away (blur), **Then** the AC updates and the display reverts to static mode. +6. **Given** the AC inline edit is active, **When** I press Escape, **Then** the edit is cancelled, the original value is preserved, and the display reverts to static mode. +7. **Given** the AC inline edit is active, **When** I clear the field and press Enter, **Then** the AC is unset and the display shows just the shield icon. + +--- + +### User Story 3 - Max HP Click-to-Edit (Priority: P2) + +As a DM, I want the max HP field to appear as a compact static display that I can click to edit inline, so the row is consistent with the AC click-to-edit pattern and further reduces visual clutter. + +**Why this priority**: Max HP is set once during encounter setup and rarely changed. Converting it to click-to-edit applies the same declutter pattern as AC for consistency. + +**Independent Test**: Can be fully tested by clicking the max HP display, editing the value, and confirming via Enter or blur, then verifying the max HP updates correctly. + +**Acceptance Scenarios**: + +1. **Given** a combatant with max HP set (e.g., 30), **When** I view the row, **Then** I see the max HP as static text — not an input field. +2. **Given** a combatant with no max HP set, **When** I view the row, **Then** I see a clickable placeholder to set max HP. +3. **Given** a combatant's max HP display, **When** I click it, **Then** an inline input appears with the current max HP value pre-filled and selected. +4. **Given** the max HP inline edit is active, **When** I type a new value and press Enter, **Then** the max HP updates and the display reverts to static mode. +5. **Given** the max HP inline edit is active, **When** I click away (blur), **Then** the max HP updates and the display reverts to static mode. +6. **Given** the max HP inline edit is active, **When** I press Escape, **Then** the edit is cancelled and the display reverts to static mode. +7. **Given** the max HP inline edit is active, **When** I clear the field and press Enter, **Then** the max HP is unset and HP tracking is removed. + +--- + +### Edge Cases + +- What happens when the user enters non-numeric or negative values in the HP popover? The input only accepts positive integers; non-numeric input is ignored. +- What happens when the user enters 0 in the HP popover? Zero is not a valid delta and the action buttons remain disabled. +- What happens when the HP popover is open and the user tabs away? The popover closes without applying changes (same as blur/click-outside). +- What happens on tablet-width screens? The popover and inline edit must remain accessible and not overflow or clip at viewports ≥ 768px wide (Tailwind `md` breakpoint). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST replace the always-visible QuickHpInput (delta input + Sword/Heart buttons) with a clickable current HP display that opens a popover on interaction. +- **FR-002**: The HP popover MUST contain a single auto-focused numeric input and two action buttons (Damage and Heal). +- **FR-003**: The HP popover MUST support keyboard shortcuts: Enter for damage, Shift+Enter for heal, Escape to dismiss. +- **FR-004**: The HP popover MUST dismiss automatically after an action is applied, on Escape, or when clicking outside. +- **FR-005**: The current HP display MUST retain color-coded status indicators (amber for bloodied, red for unconscious) when the popover is closed. +- **FR-006**: System MUST replace the always-visible AC input field with a static display (shield icon + number). +- **FR-007**: Clicking the AC static display MUST open an inline edit input that commits on Enter or blur and cancels on Escape. +- **FR-008**: The max HP MUST display as compact static text with click-to-edit, consistent with the AC pattern. +- **FR-009**: The existing callback signatures (`onAdjustHp`, `onSetAc`) MUST remain unchanged — this is a UI-only change. +- **FR-010**: All interactive elements MUST remain keyboard-accessible (focusable, operable via keyboard). +- **FR-011**: The HP popover input MUST only accept positive integers as valid delta values. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: The combatant row displays fewer always-visible interactive controls — specifically, 3 fewer visible elements (delta input, Damage button, Heal button) per combatant when HP is set. +- **SC-002**: The AC field takes up less horizontal space in its default state compared to an always-visible input field. +- **SC-003**: Users can apply damage or healing in 3 or fewer interactions (click HP, type number, press Enter/Shift+Enter). +- **SC-004**: Users can edit AC in 3 or fewer interactions (click AC, type number, press Enter). +- **SC-005**: All HP and AC interactions remain fully operable using only a keyboard. + +## Assumptions + +- The popover is a lightweight component positioned near the current HP value — not a full modal dialog. +- The HP popover uses a simple overlay/click-outside pattern, consistent with how the condition picker already works in the app. +- The AC click-to-edit follows the exact same pattern as the existing editable name component (click to edit, Enter/blur to commit, Escape to cancel). +- Domain and application layers require zero changes — all modifications are confined to the web adapter (React components). +- The CurrentHpInput component is either removed or repurposed as a static display + popover trigger, rather than being an always-visible input. diff --git a/specs/019-combatant-row-declutter/tasks.md b/specs/019-combatant-row-declutter/tasks.md new file mode 100644 index 0000000..b70e12d --- /dev/null +++ b/specs/019-combatant-row-declutter/tasks.md @@ -0,0 +1,154 @@ +# Tasks: Combatant Row Declutter + +**Input**: Design documents from `/specs/019-combatant-row-declutter/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, quickstart.md + +**Tests**: Not explicitly requested in the feature specification. Test tasks are omitted. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: No new project setup needed — this feature modifies existing components only. Phase skipped. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: No foundational/blocking work required. Both user stories modify independent components within the same file and can proceed directly. + +--- + +## Phase 3: User Story 1 - HP Click-to-Adjust Popover (Priority: P1) MVP + +**Goal**: Replace the always-visible QuickHpInput (delta input + Sword/Heart buttons) with a clickable current HP display that opens a popover for damage/healing. + +**Independent Test**: Click a combatant's current HP number, enter a value, press Enter (damage) or Shift+Enter (heal), verify HP updates and popover dismisses. Verify bloodied/unconscious color coding on the static HP display. + +### Implementation for User Story 1 + +- [x] T001 [US1] Create HpAdjustPopover component in `apps/web/src/components/hp-adjust-popover.tsx` — popover with auto-focused numeric input, Damage button (red/Sword icon), and Heal button (green/Heart icon). Input accepts positive integers only. Enter applies damage (negative delta), Shift+Enter applies healing (positive delta). Popover dismisses on action, Escape, or click-outside (use ref-based click-outside listener per research.md R1). +- [x] T002 [US1] Replace CurrentHpInput in `apps/web/src/components/combatant-row.tsx` with a clickable static HP display — render current HP as a `