diff --git a/CLAUDE.md b/CLAUDE.md index f2339f6..b286b16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: - TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) (011-quick-hp-input) - N/A (no storage changes -- existing localStorage persistence handles HP via `adjustHpUseCase`) (011-quick-hp-input) - N/A (no storage changes -- existing localStorage persistence unchanged) (012-turn-navigation) +- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators) ## 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 fcf5b21..92a73af 100644 --- a/apps/web/src/components/combatant-row.tsx +++ b/apps/web/src/components/combatant-row.tsx @@ -1,4 +1,4 @@ -import type { CombatantId } from "@initiative/domain"; +import { type CombatantId, deriveHpStatus } from "@initiative/domain"; import { X } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { cn } from "../lib/utils"; @@ -127,10 +127,12 @@ function CurrentHpInput({ currentHp, maxHp, onCommit, + className, }: { currentHp: number | undefined; maxHp: number | undefined; onCommit: (value: number) => void; + className?: string; }) { const [draft, setDraft] = useState(currentHp?.toString() ?? ""); const prev = useRef(currentHp); @@ -161,7 +163,7 @@ function CurrentHpInput({ value={draft} placeholder="HP" disabled={maxHp === undefined} - className="h-7 w-[7ch] text-center text-sm tabular-nums" + className={cn("h-7 w-[7ch] text-center text-sm tabular-nums", className)} onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { @@ -181,6 +183,7 @@ export function CombatantRow({ onAdjustHp, }: CombatantRowProps) { const { id, name, initiative, maxHp, currentHp } = combatant; + const status = deriveHpStatus(currentHp, maxHp); return (
{/* Initiative */} @@ -219,6 +223,10 @@ export function CombatantRow({ { if (currentHp === undefined) return; const delta = value - currentHp; diff --git a/packages/domain/src/__tests__/hp-status.test.ts b/packages/domain/src/__tests__/hp-status.test.ts new file mode 100644 index 0000000..31b9a5c --- /dev/null +++ b/packages/domain/src/__tests__/hp-status.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { deriveHpStatus } from "../hp-status.js"; + +describe("deriveHpStatus", () => { + it("returns healthy when currentHp >= maxHp / 2", () => { + expect(deriveHpStatus(10, 20)).toBe("healthy"); + expect(deriveHpStatus(15, 20)).toBe("healthy"); + expect(deriveHpStatus(20, 20)).toBe("healthy"); + }); + + it("returns bloodied when 0 < currentHp < maxHp / 2", () => { + expect(deriveHpStatus(9, 20)).toBe("bloodied"); + expect(deriveHpStatus(1, 20)).toBe("bloodied"); + expect(deriveHpStatus(5, 20)).toBe("bloodied"); + }); + + it("returns unconscious when currentHp <= 0", () => { + expect(deriveHpStatus(0, 20)).toBe("unconscious"); + }); + + it("returns unconscious with negative HP", () => { + expect(deriveHpStatus(-5, 20)).toBe("unconscious"); + }); + + it("returns undefined when maxHp is undefined", () => { + expect(deriveHpStatus(10, undefined)).toBeUndefined(); + }); + + it("returns undefined when currentHp is undefined", () => { + expect(deriveHpStatus(undefined, 20)).toBeUndefined(); + }); + + it("returns undefined when both are undefined", () => { + expect(deriveHpStatus(undefined, undefined)).toBeUndefined(); + }); + + it("handles maxHp=1 (no bloodied state possible)", () => { + expect(deriveHpStatus(1, 1)).toBe("healthy"); + expect(deriveHpStatus(0, 1)).toBe("unconscious"); + }); + + it("handles maxHp=2 (1/2 is healthy, not bloodied)", () => { + expect(deriveHpStatus(1, 2)).toBe("healthy"); + expect(deriveHpStatus(0, 2)).toBe("unconscious"); + }); + + it("handles odd maxHp=21 (10 is bloodied since 10 < 10.5)", () => { + expect(deriveHpStatus(10, 21)).toBe("bloodied"); + expect(deriveHpStatus(11, 21)).toBe("healthy"); + }); + + it("returns healthy when currentHp exceeds maxHp", () => { + expect(deriveHpStatus(25, 20)).toBe("healthy"); + }); +}); diff --git a/packages/domain/src/hp-status.ts b/packages/domain/src/hp-status.ts new file mode 100644 index 0000000..c45d93b --- /dev/null +++ b/packages/domain/src/hp-status.ts @@ -0,0 +1,11 @@ +export type HpStatus = "healthy" | "bloodied" | "unconscious"; + +export function deriveHpStatus( + currentHp: number | undefined, + maxHp: number | undefined, +): HpStatus | undefined { + if (currentHp === undefined || maxHp === undefined) return undefined; + if (currentHp <= 0) return "unconscious"; + if (currentHp < maxHp / 2) return "bloodied"; + return "healthy"; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 00d0d5b..2e8628e 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -18,6 +18,7 @@ export type { TurnAdvanced, TurnRetreated, } from "./events.js"; +export { deriveHpStatus, type HpStatus } from "./hp-status.js"; export { type RemoveCombatantSuccess, removeCombatant, diff --git a/specs/013-hp-status-indicators/checklists/requirements.md b/specs/013-hp-status-indicators/checklists/requirements.md new file mode 100644 index 0000000..1119020 --- /dev/null +++ b/specs/013-hp-status-indicators/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: HP Status Indicators + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-05 +**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. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- FR-001 defines the exact thresholds for each status, making the boundary conditions fully testable. +- Edge cases cover odd max HP values, min max HP (1 and 2), over-max HP, and unset max HP. diff --git a/specs/013-hp-status-indicators/data-model.md b/specs/013-hp-status-indicators/data-model.md new file mode 100644 index 0000000..98ba4b4 --- /dev/null +++ b/specs/013-hp-status-indicators/data-model.md @@ -0,0 +1,46 @@ +# Data Model: HP Status Indicators + +**Feature**: 013-hp-status-indicators +**Date**: 2026-03-05 + +## Entities + +### HpStatus (derived type -- not stored) + +A discriminated string union representing the health state of a combatant. + +| Value | Condition | Description | +|-------|-----------|-------------| +| `"healthy"` | `currentHp >= maxHp / 2` | Combatant is above half HP | +| `"bloodied"` | `0 < currentHp < maxHp / 2` | Combatant is below half HP but still conscious | +| `"unconscious"` | `currentHp <= 0` | Combatant is at or below zero HP | + +**Derivation rules**: +- Only meaningful when both `currentHp` and `maxHp` are defined +- Returns `undefined` when either value is missing (HP tracking not enabled) +- Evaluation order: check `unconscious` first (currentHp <= 0), then `bloodied` (currentHp < maxHp / 2), else `healthy` + +### Combatant (existing -- unchanged) + +No modifications to the `Combatant` interface. HP status is computed on demand from existing fields: + +| Field | Type | Used for status | +|-------|------|----------------| +| `currentHp` | `number \| undefined` | Compared against thresholds | +| `maxHp` | `number \| undefined` | Defines the "half HP" bloodied threshold | + +## State Transitions + +HP status changes are a side effect of existing HP operations (adjustHp, setHp). No new state transitions are introduced. + +```text +healthy ──[take damage below half]──> bloodied +bloodied ──[take damage to 0]──────> unconscious +unconscious ──[heal above 0]───────> bloodied or healthy +bloodied ──[heal above half]───────> healthy +healthy ──[take massive damage]────> unconscious (skip bloodied) +``` + +## Storage Impact + +None. HP status is derived at render time from existing persisted `currentHp` and `maxHp` values. No localStorage schema changes. diff --git a/specs/013-hp-status-indicators/plan.md b/specs/013-hp-status-indicators/plan.md new file mode 100644 index 0000000..4ede6f3 --- /dev/null +++ b/specs/013-hp-status-indicators/plan.md @@ -0,0 +1,64 @@ +# Implementation Plan: HP Status Indicators + +**Branch**: `013-hp-status-indicators` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/013-hp-status-indicators/spec.md` + +## Summary + +Add visual HP status indicators to combatant rows. A pure domain function derives the HP status ("healthy", "bloodied", "unconscious") from currentHp and maxHp. The UI applies color treatments: amber for bloodied HP text, red + muted row for unconscious/dead combatants. No new stored state -- status is computed on render. + +## Technical Context + +**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax) +**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons) +**Storage**: N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) +**Testing**: Vitest (unit tests for domain function, existing layer boundary test) +**Target Platform**: Modern browsers (Vite dev server, production build) +**Project Type**: Web application (monorepo: domain + application + web adapter) +**Performance Goals**: Synchronous render -- indicator updates in the same React render cycle as HP changes +**Constraints**: Domain layer must remain pure (no UI imports); adapter layer handles all visual presentation +**Scale/Scope**: Single new domain function + UI changes to one component (combatant-row) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Deterministic Domain Core | PASS | `deriveHpStatus` is a pure function: same inputs always produce the same output. No I/O, randomness, or clocks. | +| II. Layered Architecture | PASS | Domain exports the pure function; UI adapter calls it in the component. No reverse dependency. | +| III. Agent Boundary | N/A | No agent involvement in this feature. | +| IV. Clarification-First | PASS | No ambiguity -- thresholds are defined in spec (bloodied = 0 < currentHp < maxHp/2, unconscious = currentHp <= 0). | +| V. Escalation Gates | PASS | All behavior is within spec scope. | +| VI. MVP Baseline Language | PASS | Assumptions section uses "MVP baseline does not include" language. | +| VII. No Gameplay Rules in Constitution | PASS | Bloodied/unconscious thresholds are in feature spec, not constitution. | +| Development Workflow | PASS | `pnpm check` must pass before commit. Domain testable via pure-function assertions. | + +No violations. Complexity Tracking section not needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/013-hp-status-indicators/ +├── 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 +packages/domain/src/ +├── hp-status.ts # NEW: deriveHpStatus pure function + HpStatus type +├── __tests__/hp-status.test.ts # NEW: unit tests for deriveHpStatus +├── index.ts # MODIFIED: export new function and type + +apps/web/src/components/ +├── combatant-row.tsx # MODIFIED: apply status-based styling +``` + +**Structure Decision**: Follows existing monorepo layout. New domain function in its own module (consistent with adjust-hp.ts, set-hp.ts pattern). UI changes confined to the existing combatant-row component. No new application-layer code needed since status is derived at render time, not via a use case. diff --git a/specs/013-hp-status-indicators/quickstart.md b/specs/013-hp-status-indicators/quickstart.md new file mode 100644 index 0000000..9d70df3 --- /dev/null +++ b/specs/013-hp-status-indicators/quickstart.md @@ -0,0 +1,46 @@ +# Quickstart: HP Status Indicators + +**Feature**: 013-hp-status-indicators + +## Overview + +Add a pure domain function `deriveHpStatus` and apply visual status indicators (color treatments) to combatant rows in the encounter tracker. + +## Key Files + +| File | Action | Purpose | +|------|--------|---------| +| `packages/domain/src/hp-status.ts` | CREATE | Pure function `deriveHpStatus` + `HpStatus` type | +| `packages/domain/src/__tests__/hp-status.test.ts` | CREATE | Unit tests for all status thresholds and edge cases | +| `packages/domain/src/index.ts` | MODIFY | Export `deriveHpStatus` and `HpStatus` | +| `apps/web/src/components/combatant-row.tsx` | MODIFY | Call `deriveHpStatus` and apply conditional CSS classes | + +## Implementation Steps + +1. **Domain function** (`packages/domain/src/hp-status.ts`): + - Define `type HpStatus = "healthy" | "bloodied" | "unconscious"` + - Implement `deriveHpStatus(currentHp: number | undefined, maxHp: number | undefined): HpStatus | undefined` + - Return `undefined` if either input is `undefined` + - Check `currentHp <= 0` first (unconscious), then `currentHp < maxHp / 2` (bloodied), else healthy + +2. **Tests** (`packages/domain/src/__tests__/hp-status.test.ts`): + - Healthy: currentHp >= maxHp/2 + - Bloodied: 0 < currentHp < maxHp/2 + - Unconscious: currentHp <= 0 (including negative) + - Undefined inputs: returns undefined + - Edge cases: maxHp=1, maxHp=2, odd maxHp, currentHp > maxHp + +3. **Export** (`packages/domain/src/index.ts`): + - Add `export { type HpStatus, deriveHpStatus } from "./hp-status.js"` + +4. **UI styling** (`apps/web/src/components/combatant-row.tsx`): + - Import `deriveHpStatus` from `@initiative/domain` + - Call it with combatant's `currentHp` and `maxHp` + - Apply to HP text: `text-amber-400` for bloodied, `text-red-400` for unconscious + - Apply to row: `opacity-50` for unconscious combatants + +## Validation + +```bash +pnpm check # Must pass (knip + format + lint + typecheck + test) +``` diff --git a/specs/013-hp-status-indicators/research.md b/specs/013-hp-status-indicators/research.md new file mode 100644 index 0000000..eb99254 --- /dev/null +++ b/specs/013-hp-status-indicators/research.md @@ -0,0 +1,60 @@ +# Research: HP Status Indicators + +**Feature**: 013-hp-status-indicators +**Date**: 2026-03-05 + +## R1: HP Status Derivation Approach + +**Decision**: Implement as a pure domain function `deriveHpStatus(currentHp, maxHp)` returning a discriminated union type `HpStatus = "healthy" | "bloodied" | "unconscious"`. + +**Rationale**: +- The status is a derived value, not stored state. Computing it on demand avoids synchronization issues and keeps the data model unchanged. +- A standalone pure function in the domain layer is consistent with the project's architecture (e.g., `adjustHp`, `setHp` are each in their own module). +- Returning a simple string union (not an object or class) is the lightest representation and easy to pattern-match in the UI. + +**Alternatives considered**: +- Storing status as a field on `Combatant`: Rejected -- introduces redundant state that must stay in sync with HP changes. Violates principle of derived data. +- Computing in the UI layer only: Rejected -- violates constitution principle I (domain logic must be pure domain functions) and makes the logic untestable without a UI. + +## R2: Bloodied Threshold + +**Decision**: Bloodied when `0 < currentHp < maxHp / 2` (strict less-than for both bounds, floating-point division). + +**Rationale**: +- Follows D&D 4e/5e convention where "bloodied" means below half HP. +- Using strict less-than means at exactly half HP the combatant is still "healthy" -- this is the most common TTRPG interpretation. +- Floating-point division handles odd maxHp values correctly (e.g., maxHp=21: bloodied at 10 since 10 < 10.5). + +**Alternatives considered**: +- `currentHp <= maxHp / 2`: Would make combatants at exactly half HP appear bloodied. Less standard. +- `Math.floor(maxHp / 2)`: Would make maxHp=21 bloodied at 10 (same result) but maxHp=20 bloodied at 10 (also same). No practical difference but adds unnecessary complexity. + +## R3: Visual Treatment Approach + +**Decision**: Color the HP text and optionally mute the entire row for unconscious combatants: +- **Healthy**: Default foreground color (no change) +- **Bloodied**: Amber/orange HP text (`text-amber-400`) -- warm danger signal +- **Unconscious**: Red HP text (`text-red-400`) + reduced row opacity (`opacity-50`) -- clearly out of action + +**Rationale**: +- Coloring the HP number is the most targeted indicator -- it draws the eye to the relevant data point. +- Row opacity reduction for unconscious combatants provides a strong "out of action" signal without removing the row (GM may still need to reference it). +- Amber and red are universally understood warning/danger colors and already used in the project (red for damage mode in quick-hp-input, `--color-destructive: #ef4444`). +- No new icons needed -- color alone is sufficient for two states and avoids visual clutter. + +**Alternatives considered**: +- Full row background color: Too visually heavy, would conflict with the active-turn indicator (blue left border + bg-accent/10). +- Strikethrough on name for unconscious: Considered but opacity reduction is more modern and less ambiguous (strikethrough could imply "removed"). +- Badge/pill indicator: Adds UI elements and visual weight. Color on existing text is simpler. + +## R4: Function Signature & Return for Undefined HP + +**Decision**: Return `undefined` when maxHp is not set (status is not applicable). Function signature: `deriveHpStatus(currentHp: number | undefined, maxHp: number | undefined): HpStatus | undefined`. + +**Rationale**: +- When maxHp is undefined, HP tracking is disabled for that combatant. No status can be derived. +- Returning `undefined` (not a fourth status value) keeps the type clean and matches the "no HP tracking" semantic. +- The UI simply skips styling when the result is `undefined`. + +**Alternatives considered**: +- Separate `"none"` status: Adds a value that is semantically different from the three health states. `undefined` is more idiomatic TypeScript for "not applicable". diff --git a/specs/013-hp-status-indicators/spec.md b/specs/013-hp-status-indicators/spec.md new file mode 100644 index 0000000..505293e --- /dev/null +++ b/specs/013-hp-status-indicators/spec.md @@ -0,0 +1,98 @@ +# Feature Specification: HP Status Indicators + +**Feature Branch**: `013-hp-status-indicators` +**Created**: 2026-03-05 +**Status**: Draft +**Input**: User description: "When a combatant's health is lower than half, the creature is considered bloodied, which makes the UI show an indicator. If a combatant is at <=0 HP the creature is unconscious/dead, which should also be shown. Visual treatment at implementer's discretion to fit the existing modern UI style." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Bloodied Indicator (Priority: P1) + +As a game master running an encounter, I want to see at a glance which combatants are bloodied (below half their maximum HP) so I can narrate the battle's intensity and make tactical decisions without mental math. + +**Why this priority**: The bloodied condition is a core D&D/TTRPG concept that GMs check frequently during combat. Providing a visual indicator eliminates the need to compare current HP vs max HP for every combatant each round. + +**Independent Test**: Can be fully tested by setting a combatant's max HP and reducing current HP below half, then verifying the visual indicator appears. Delivers immediate at-a-glance awareness. + +**Acceptance Scenarios**: + +1. **Given** a combatant with max HP set to 20 and current HP at 10, **When** the combatant row is displayed, **Then** the HP area shows a bloodied visual indicator (e.g., amber/orange color treatment on the HP value) +2. **Given** a combatant with max HP set to 20 and current HP at 9, **When** the combatant row is displayed, **Then** the bloodied indicator is visible +3. **Given** a combatant with max HP set to 20 and current HP at 11, **When** the combatant row is displayed, **Then** no bloodied indicator is shown (normal appearance) +4. **Given** a combatant with max HP set to 21 (odd number) and current HP at 10, **When** the combatant row is displayed, **Then** the bloodied indicator is shown (10 < 21/2 = 10.5) +5. **Given** a combatant with no max HP set, **When** the combatant row is displayed, **Then** no status indicator is shown regardless of current HP value + +--- + +### User Story 2 - Unconscious/Dead Indicator (Priority: P1) + +As a game master, I want to see which combatants have reached 0 HP or below so I can immediately identify who is down without scanning HP numbers. + +**Why this priority**: Equally critical to bloodied -- knowing who is unconscious or dead is essential for combat flow and takes priority over other status information. + +**Independent Test**: Can be fully tested by reducing a combatant's current HP to 0 or below and verifying the visual indicator appears. + +**Acceptance Scenarios**: + +1. **Given** a combatant with max HP set to 20 and current HP at 0, **When** the combatant row is displayed, **Then** the HP area shows an unconscious/dead visual indicator (e.g., red color treatment and/or a skull/death icon) +2. **Given** a combatant with max HP set to 20 and current HP at -5, **When** the combatant row is displayed, **Then** the unconscious/dead indicator is shown (negative HP still counts as down) +3. **Given** a combatant with max HP set to 20 and current HP at 1, **When** the combatant row is displayed, **Then** the unconscious/dead indicator is NOT shown (1 HP is bloodied, not dead) +4. **Given** a combatant at 0 HP whose HP is then healed above 0, **When** the HP changes, **Then** the unconscious/dead indicator is removed and replaced with the appropriate status (bloodied or healthy) + +--- + +### User Story 3 - Status Transitions (Priority: P2) + +As a game master adjusting HP during combat, I want the visual indicators to update in real time as I apply damage or healing so I always have an accurate picture of the battlefield. + +**Why this priority**: Indicator transitions are a natural consequence of the first two stories but need explicit attention to ensure smooth, responsive visual feedback. + +**Independent Test**: Can be tested by using the quick HP input to deal damage across thresholds and observing indicator changes. + +**Acceptance Scenarios**: + +1. **Given** a combatant at full HP (20/20), **When** I deal 11 damage (reducing to 9/20), **Then** the indicator transitions from healthy to bloodied +2. **Given** a bloodied combatant (5/20), **When** I deal 5 damage (reducing to 0/20), **Then** the indicator transitions from bloodied to unconscious/dead +3. **Given** an unconscious combatant (0/20), **When** I heal 15 HP (restoring to 15/20), **Then** the indicator transitions from unconscious/dead to healthy (skipping bloodied since 15 > 10) + +--- + +### Edge Cases + +- What happens when max HP is 1? At 1/1 HP the combatant is healthy; at 0/1 HP they are unconscious/dead. There is no bloodied state since 0 < 0.5 rounds to unconscious/dead. +- What happens when max HP is 2? At 1/2 HP the combatant is healthy (1 is not less than 2/2 = 1); at 0/2 they are unconscious/dead. Bloodied only applies when currentHp < maxHp / 2 (strict less-than with exact division). +- What happens when current HP exceeds max HP (e.g., temporary HP or manual override)? The combatant shows healthy status -- no special indicator for over-max. +- What happens when max HP is set but current HP has not been modified? Current HP equals max HP by default, so the combatant shows healthy status. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST derive a combatant's HP status from their current HP and max HP values: "healthy" (currentHp >= maxHp / 2), "bloodied" (0 < currentHp < maxHp / 2), or "unconscious" (currentHp <= 0) +- **FR-002**: System MUST display a visual indicator on the combatant row when a combatant is bloodied -- the HP value text MUST use a warm/amber color to signal danger +- **FR-003**: System MUST display a visual indicator on the combatant row when a combatant is unconscious/dead -- the HP value text MUST use a red/destructive color and the row MUST appear visually muted (reduced opacity or strikethrough name) to signal the combatant is out of action +- **FR-004**: System MUST NOT show any status indicator when max HP is not set for a combatant +- **FR-005**: System MUST update the visual indicator immediately when current HP changes (no delay or animation lag) +- **FR-006**: The HP status derivation MUST be a pure domain-level computation with no UI dependencies + +### Key Entities + +- **HP Status**: A derived value (not stored) computed from a combatant's currentHp and maxHp. Possible values: "healthy", "bloodied", "unconscious". Only meaningful when maxHp is defined. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can identify bloodied combatants at a glance without reading HP numbers -- 100% of combatants below half HP display a distinct visual treatment +- **SC-002**: Users can identify unconscious/dead combatants at a glance -- 100% of combatants at 0 HP or below display a distinct visual treatment that differs from the bloodied indicator +- **SC-003**: Visual indicators update within the same interaction frame as HP changes -- no perceptible delay between HP adjustment and indicator update +- **SC-004**: Status indicators are visually consistent with the existing UI design system (colors, spacing, typography match the established style) + +## Assumptions + +- "Bloodied" uses the D&D 4e/5e convention: strictly below half max HP (not at exactly half) +- The unconscious/dead label is intentionally combined into one state -- the system does not distinguish between unconscious and dead (this is a GM decision, not a system decision) +- No sound effects or animation beyond color/opacity changes for MVP baseline +- MVP baseline does not include a tooltip or label explaining what "bloodied" means +- The status is purely visual/derived -- it does not affect game mechanics or stored data diff --git a/specs/013-hp-status-indicators/tasks.md b/specs/013-hp-status-indicators/tasks.md new file mode 100644 index 0000000..ee59f5b --- /dev/null +++ b/specs/013-hp-status-indicators/tasks.md @@ -0,0 +1,122 @@ +# Tasks: HP Status Indicators + +**Input**: Design documents from `/specs/013-hp-status-indicators/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md + +**Tests**: Included -- domain function requires unit tests per project convention. + +**Organization**: Tasks grouped by user story. US1 and US2 share the same domain function (foundational), then diverge at the UI layer. + +## 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, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Foundational (Domain Function + Tests) + +**Purpose**: Create the pure domain function that all user stories depend on + +**CRITICAL**: No UI work can begin until this phase is complete + +- [x] T001 Create `HpStatus` type and `deriveHpStatus` pure function in `packages/domain/src/hp-status.ts` -- returns `"healthy" | "bloodied" | "unconscious" | undefined` based on `currentHp` and `maxHp` inputs. Check `currentHp <= 0` first (unconscious), then `currentHp < maxHp / 2` (bloodied), else healthy. Return `undefined` when either input is `undefined`. +- [x] T002 Write unit tests for `deriveHpStatus` in `packages/domain/src/__tests__/hp-status.test.ts` covering: healthy (currentHp >= maxHp/2), bloodied (0 < currentHp < maxHp/2), unconscious (currentHp <= 0), unconscious with negative HP, undefined when maxHp undefined, undefined when currentHp undefined, undefined when both undefined, edge case maxHp=1 (no bloodied state possible), edge case maxHp=2 (1/2 is healthy not bloodied), odd maxHp=21 (10 is bloodied since 10 < 10.5), currentHp exceeding maxHp (healthy). +- [x] T003 Export `deriveHpStatus` and `HpStatus` type from `packages/domain/src/index.ts` -- add `export { type HpStatus, deriveHpStatus } from "./hp-status.js";` + +**Checkpoint**: `pnpm check` passes. Domain function is tested and exported. + +--- + +## Phase 2: User Story 1 - Bloodied Indicator (Priority: P1) + User Story 2 - Unconscious/Dead Indicator (Priority: P1) + +**Goal**: Apply visual status indicators to the combatant row -- amber HP text for bloodied, red HP text + muted row for unconscious/dead + +**Independent Test**: Set a combatant's max HP, reduce current HP below half -- verify amber text. Reduce to 0 -- verify red text and muted row. Heal above half -- verify normal appearance restored. + +### Implementation + +- [x] T004 [US1] [US2] Modify `apps/web/src/components/combatant-row.tsx` to import `deriveHpStatus` from `@initiative/domain` and call it with the combatant's `currentHp` and `maxHp` at the top of the `CombatantRow` component. Store result in a `status` variable. +- [x] T005 [US1] [US2] In `apps/web/src/components/combatant-row.tsx`, apply conditional CSS classes based on `status`: (1) On the `CurrentHpInput` wrapper/value -- add `text-amber-400` when bloodied, `text-red-400` when unconscious. (2) On the row's outer `
` -- add `opacity-50` when unconscious. Ensure the active-turn border styling still takes precedence for active combatants. Use the existing `cn()` utility for conditional class merging. + +**Checkpoint**: `pnpm check` passes. Bloodied and unconscious indicators are visible in the UI. + +--- + +## Phase 3: User Story 3 - Status Transitions (Priority: P2) + +**Goal**: Ensure visual indicators update in real time as HP changes through quick HP input, direct entry, and +/- controls + +**Independent Test**: Use the quick HP input to deal damage across thresholds (healthy -> bloodied -> unconscious) and heal back. Verify indicator changes instantly with no flicker or delay. + +### Implementation + +- [x] T006 [US3] Verify in `apps/web/src/components/combatant-row.tsx` that `deriveHpStatus` is called with the current combatant props (not stale state) so status updates in the same render cycle as HP changes. No additional code should be needed if T004-T005 are implemented correctly -- this task is a manual verification and visual QA pass. + +**Checkpoint**: All transitions (healthy->bloodied->unconscious->healthy) work smoothly. + +--- + +## Phase 4: Polish & Cross-Cutting Concerns + +- [x] T007 Run `pnpm check` to validate all automated checks pass (knip, format, lint, typecheck, test) +- [x] T008 Verify layer boundary test still passes (domain must not import from React/UI) in `packages/domain/src/__tests__/layer-boundaries.test.ts` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Foundational (Phase 1)**: No dependencies -- can start immediately +- **US1+US2 (Phase 2)**: Depends on Phase 1 completion (needs domain function) +- **US3 (Phase 3)**: Depends on Phase 2 completion (needs UI indicators in place) +- **Polish (Phase 4)**: Depends on all phases complete + +### Within Phases + +- T001 before T002 (need function to test it) +- T002 before T003 (tests pass before exporting) +- T004 before T005 (import before styling) + +### Parallel Opportunities + +- T001 and T002 can be done together if writing tests alongside implementation (TDD) +- US1 and US2 are implemented together in Phase 2 since they modify the same file and component + +--- + +## Parallel Example: Phase 1 + +```bash +# T001 and T002 can be written together (TDD style): +Task: "Create deriveHpStatus in packages/domain/src/hp-status.ts" +Task: "Write tests in packages/domain/src/__tests__/hp-status.test.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (Phase 1 + Phase 2) + +1. Complete Phase 1: Domain function + tests + export +2. Complete Phase 2: UI styling for both bloodied and unconscious +3. **STOP and VALIDATE**: Manually test all scenarios from spec +4. Run `pnpm check` + +### Full Delivery + +1. Phase 1: Domain function (T001-T003) +2. Phase 2: UI indicators (T004-T005) +3. Phase 3: Transition verification (T006) +4. Phase 4: Final validation (T007-T008) + +--- + +## Notes + +- US1 (bloodied) and US2 (unconscious) are combined into Phase 2 because they modify the same component and the domain function handles both statuses. Implementing them separately would require touching the same lines twice. +- T006 is a verification task, not a code change -- if the React component re-renders on prop changes (which it does by default), transitions work automatically. +- Total: 8 tasks across 4 phases. Minimal scope -- no new application layer, no storage changes.