Implement the 018-combatant-concentration feature that adds a per-combatant concentration toggle with Brain icon, purple border accent, and damage pulse animation in the encounter tracker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ export function App() {
|
|||||||
adjustHp,
|
adjustHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
|
toggleConcentration,
|
||||||
} = useEncounter();
|
} = useEncounter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +54,7 @@ export function App() {
|
|||||||
onAdjustHp={adjustHp}
|
onAdjustHp={adjustHp}
|
||||||
onSetAc={setAc}
|
onSetAc={setAc}
|
||||||
onToggleCondition={toggleCondition}
|
onToggleCondition={toggleCondition}
|
||||||
|
onToggleConcentration={toggleConcentration}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Shield, X } from "lucide-react";
|
import { Brain, Shield, X } from "lucide-react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { ConditionPicker } from "./condition-picker";
|
import { ConditionPicker } from "./condition-picker";
|
||||||
import { ConditionTags } from "./condition-tags";
|
import { ConditionTags } from "./condition-tags";
|
||||||
@@ -20,6 +20,7 @@ interface Combatant {
|
|||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
|
readonly isConcentrating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CombatantRowProps {
|
interface CombatantRowProps {
|
||||||
@@ -32,6 +33,7 @@ interface CombatantRowProps {
|
|||||||
onAdjustHp: (id: CombatantId, delta: number) => void;
|
onAdjustHp: (id: CombatantId, delta: number) => void;
|
||||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||||
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
||||||
|
onToggleConcentration: (id: CombatantId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableName({
|
function EditableName({
|
||||||
@@ -240,22 +242,69 @@ export function CombatantRow({
|
|||||||
onAdjustHp,
|
onAdjustHp,
|
||||||
onSetAc,
|
onSetAc,
|
||||||
onToggleCondition,
|
onToggleCondition,
|
||||||
|
onToggleConcentration,
|
||||||
}: CombatantRowProps) {
|
}: CombatantRowProps) {
|
||||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const prevHpRef = useRef(currentHp);
|
||||||
|
const [isPulsing, setIsPulsing] = useState(false);
|
||||||
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevHp = prevHpRef.current;
|
||||||
|
prevHpRef.current = currentHp;
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevHp !== undefined &&
|
||||||
|
currentHp !== undefined &&
|
||||||
|
currentHp < prevHp &&
|
||||||
|
combatant.isConcentrating
|
||||||
|
) {
|
||||||
|
setIsPulsing(true);
|
||||||
|
clearTimeout(pulseTimerRef.current);
|
||||||
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
|
}
|
||||||
|
}, [currentHp, combatant.isConcentrating]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!combatant.isConcentrating) {
|
||||||
|
clearTimeout(pulseTimerRef.current);
|
||||||
|
setIsPulsing(false);
|
||||||
|
}
|
||||||
|
}, [combatant.isConcentrating]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md px-3 py-2 transition-colors",
|
"group rounded-md px-3 py-2 transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "border-l-2 border-l-accent bg-accent/10"
|
? "border-l-2 border-l-accent bg-accent/10"
|
||||||
: "border-l-2 border-l-transparent",
|
: combatant.isConcentrating
|
||||||
|
? "border-l-2 border-l-purple-400"
|
||||||
|
: "border-l-2 border-l-transparent",
|
||||||
status === "unconscious" && "opacity-50",
|
status === "unconscious" && "opacity-50",
|
||||||
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[3rem_1fr_auto_auto_2rem] items-center gap-3">
|
<div className="grid grid-cols-[1.25rem_3rem_1fr_auto_auto_2rem] items-center gap-3">
|
||||||
|
{/* Concentration */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleConcentration(id)}
|
||||||
|
title="Concentrating"
|
||||||
|
aria-label="Toggle concentration"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center transition-opacity",
|
||||||
|
combatant.isConcentrating
|
||||||
|
? "opacity-100 text-purple-400"
|
||||||
|
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Brain size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type {
|
import type {
|
||||||
@@ -204,6 +205,19 @@ export function useEncounter() {
|
|||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toggleConcentration = useCallback(
|
||||||
|
(id: CombatantId) => {
|
||||||
|
const result = toggleConcentrationUseCase(makeStore(), id);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
events,
|
events,
|
||||||
@@ -217,5 +231,6 @@ export function useEncounter() {
|
|||||||
adjustHp,
|
adjustHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
|
toggleConcentration,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,42 @@
|
|||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes concentration-shake {
|
||||||
|
0% {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
translate: -3px;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
translate: 3px;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
translate: -2px;
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
translate: 1px;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes concentration-glow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 4px 2px #c084fc;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-concentration-pulse {
|
||||||
|
animation:
|
||||||
|
concentration-shake 450ms ease-out,
|
||||||
|
concentration-glow 1200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export function loadEncounter(): Encounter | null {
|
|||||||
? validConditions
|
? validConditions
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Validate isConcentrating field
|
||||||
|
const isConcentrating = entry.isConcentrating === true ? true : undefined;
|
||||||
|
|
||||||
// Validate and attach HP fields if valid
|
// Validate and attach HP fields if valid
|
||||||
const maxHp = entry.maxHp;
|
const maxHp = entry.maxHp;
|
||||||
const currentHp = entry.currentHp;
|
const currentHp = entry.currentHp;
|
||||||
@@ -85,12 +88,13 @@ export function loadEncounter(): Encounter | null {
|
|||||||
...base,
|
...base,
|
||||||
ac: validAc,
|
ac: validAc,
|
||||||
conditions,
|
conditions,
|
||||||
|
isConcentrating,
|
||||||
maxHp,
|
maxHp,
|
||||||
currentHp: validCurrentHp ? currentHp : maxHp,
|
currentHp: validCurrentHp ? currentHp : maxHp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...base, ac: validAc, conditions };
|
return { ...base, ac: validAc, conditions, isConcentrating };
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = createEncounter(
|
const result = createEncounter(
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
|||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||||
|
|||||||
23
packages/application/src/toggle-concentration-use-case.ts
Normal file
23
packages/application/src/toggle-concentration-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
toggleConcentration,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function toggleConcentrationUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = toggleConcentration(encounter, combatantId);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
69
packages/domain/src/__tests__/toggle-concentration.test.ts
Normal file
69
packages/domain/src/__tests__/toggle-concentration.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { toggleConcentration } from "../toggle-concentration.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
|
||||||
|
return isConcentrating
|
||||||
|
? { id: combatantId(name), name, isConcentrating }
|
||||||
|
: { id: combatantId(name), name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(combatants: Combatant[]): Encounter {
|
||||||
|
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(encounter: Encounter, id: string) {
|
||||||
|
const result = toggleConcentration(encounter, combatantId(id));
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("toggleConcentration", () => {
|
||||||
|
it("toggles concentration on when falsy", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const { encounter, events } = success(e, "A");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].isConcentrating).toBe(true);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "ConcentrationStarted", combatantId: combatantId("A") },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles concentration off when true", () => {
|
||||||
|
const e = enc([makeCombatant("A", true)]);
|
||||||
|
const { encounter, events } = success(e, "A");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].isConcentrating).toBeUndefined();
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ type: "ConcentrationEnded", combatantId: combatantId("A") },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for nonexistent combatant", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = toggleConcentration(e, combatantId("missing"));
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const original = JSON.parse(JSON.stringify(e));
|
||||||
|
toggleConcentration(e, combatantId("A"));
|
||||||
|
expect(e).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect other combatants", () => {
|
||||||
|
const e = enc([makeCombatant("A"), makeCombatant("B", true)]);
|
||||||
|
const { encounter } = success(e, "A");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].isConcentrating).toBe(true);
|
||||||
|
expect(encounter.combatants[1].isConcentrating).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,6 +88,16 @@ export interface ConditionRemoved {
|
|||||||
readonly condition: ConditionId;
|
readonly condition: ConditionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConcentrationStarted {
|
||||||
|
readonly type: "ConcentrationStarted";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConcentrationEnded {
|
||||||
|
readonly type: "ConcentrationEnded";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
}
|
||||||
|
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| TurnAdvanced
|
| TurnAdvanced
|
||||||
| RoundAdvanced
|
| RoundAdvanced
|
||||||
@@ -101,4 +111,6 @@ export type DomainEvent =
|
|||||||
| RoundRetreated
|
| RoundRetreated
|
||||||
| AcSet
|
| AcSet
|
||||||
| ConditionAdded
|
| ConditionAdded
|
||||||
| ConditionRemoved;
|
| ConditionRemoved
|
||||||
|
| ConcentrationStarted
|
||||||
|
| ConcentrationEnded;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export type {
|
|||||||
CombatantAdded,
|
CombatantAdded,
|
||||||
CombatantRemoved,
|
CombatantRemoved,
|
||||||
CombatantUpdated,
|
CombatantUpdated,
|
||||||
|
ConcentrationEnded,
|
||||||
|
ConcentrationStarted,
|
||||||
ConditionAdded,
|
ConditionAdded,
|
||||||
ConditionRemoved,
|
ConditionRemoved,
|
||||||
CurrentHpAdjusted,
|
CurrentHpAdjusted,
|
||||||
@@ -39,6 +41,10 @@ export {
|
|||||||
type SetInitiativeSuccess,
|
type SetInitiativeSuccess,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "./set-initiative.js";
|
} from "./set-initiative.js";
|
||||||
|
export {
|
||||||
|
type ToggleConcentrationSuccess,
|
||||||
|
toggleConcentration,
|
||||||
|
} from "./toggle-concentration.js";
|
||||||
export {
|
export {
|
||||||
type ToggleConditionSuccess,
|
type ToggleConditionSuccess,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
|
|||||||
44
packages/domain/src/toggle-concentration.ts
Normal file
44
packages/domain/src/toggle-concentration.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface ToggleConcentrationSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleConcentration(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
): ToggleConcentrationSuccess | DomainError {
|
||||||
|
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
|
||||||
|
|
||||||
|
if (targetIdx === -1) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "combatant-not-found",
|
||||||
|
message: `No combatant found with ID "${combatantId}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = encounter.combatants[targetIdx];
|
||||||
|
const wasConcentrating = target.isConcentrating === true;
|
||||||
|
|
||||||
|
const event: DomainEvent = wasConcentrating
|
||||||
|
? { type: "ConcentrationEnded", combatantId }
|
||||||
|
: { type: "ConcentrationStarted", combatantId };
|
||||||
|
|
||||||
|
const updatedCombatants = encounter.combatants.map((c) =>
|
||||||
|
c.id === combatantId
|
||||||
|
? { ...c, isConcentrating: wasConcentrating ? undefined : true }
|
||||||
|
: c,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: updatedCombatants,
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
},
|
||||||
|
events: [event],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export interface Combatant {
|
|||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionId[];
|
||||||
|
readonly isConcentrating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Encounter {
|
export interface Encounter {
|
||||||
|
|||||||
34
specs/018-combatant-concentration/checklists/requirements.md
Normal file
34
specs/018-combatant-concentration/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Combatant Concentration
|
||||||
|
|
||||||
|
**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. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
45
specs/018-combatant-concentration/data-model.md
Normal file
45
specs/018-combatant-concentration/data-model.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Data Model: Combatant Concentration
|
||||||
|
|
||||||
|
**Feature**: 018-combatant-concentration | **Date**: 2026-03-06
|
||||||
|
|
||||||
|
## Entity Changes
|
||||||
|
|
||||||
|
### Combatant (modified)
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Notes |
|
||||||
|
|-------|------|----------|---------|-------|
|
||||||
|
| id | CombatantId | yes | - | Existing, unchanged |
|
||||||
|
| name | string | yes | - | Existing, unchanged |
|
||||||
|
| initiative | number | no | undefined | Existing, unchanged |
|
||||||
|
| maxHp | number | no | undefined | Existing, unchanged |
|
||||||
|
| currentHp | number | no | undefined | Existing, unchanged |
|
||||||
|
| ac | number | no | undefined | Existing, unchanged |
|
||||||
|
| conditions | ConditionId[] | no | undefined | Existing, unchanged |
|
||||||
|
| **isConcentrating** | **boolean** | **no** | **undefined (falsy)** | **New field. Independent of conditions.** |
|
||||||
|
|
||||||
|
### Domain Events (new)
|
||||||
|
|
||||||
|
| Event | Fields | Emitted When |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| ConcentrationStarted | `type`, `combatantId` | Concentration toggled from off to on |
|
||||||
|
| ConcentrationEnded | `type`, `combatantId` | Concentration toggled from on to off |
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
toggleConcentration(encounter, combatantId)
|
||||||
|
├── combatant not found → DomainError("combatant-not-found")
|
||||||
|
├── isConcentrating is falsy → set to true, emit ConcentrationStarted
|
||||||
|
└── isConcentrating is true → set to undefined, emit ConcentrationEnded
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
- `combatantId` must reference an existing combatant in the encounter.
|
||||||
|
- No other validation needed (boolean toggle has no invalid input beyond missing combatant).
|
||||||
|
|
||||||
|
## Storage Impact
|
||||||
|
|
||||||
|
- **Format**: JSON via localStorage (existing adapter).
|
||||||
|
- **Migration**: None. Field is optional; absent field is treated as `false`.
|
||||||
|
- **Backward compatibility**: Old data loads without `isConcentrating`; new data with the field serializes/deserializes transparently.
|
||||||
73
specs/018-combatant-concentration/plan.md
Normal file
73
specs/018-combatant-concentration/plan.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Implementation Plan: Combatant Concentration
|
||||||
|
|
||||||
|
**Branch**: `018-combatant-concentration` | **Date**: 2026-03-06 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/018-combatant-concentration/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add concentration as a separate per-combatant boolean state (not a condition). A Brain icon toggle appears on hover (or stays visible when active), a colored left border accent marks concentrating combatants, and a pulse animation fires when a concentrating combatant takes damage. Implementation follows the existing toggle-condition pattern across all three layers (domain, application, web adapter).
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons)
|
||||||
|
**Storage**: Browser localStorage (existing adapter, transparent JSON serialization)
|
||||||
|
**Testing**: Vitest
|
||||||
|
**Target Platform**: Modern browsers (local-first, single-user)
|
||||||
|
**Project Type**: Web application (monorepo: domain / application / web)
|
||||||
|
**Performance Goals**: Instant UI response on toggle; pulse animation ~600-800ms
|
||||||
|
**Constraints**: No migration needed for localStorage; new optional boolean field is backward-compatible
|
||||||
|
**Scale/Scope**: Single-user encounter tracker; ~10-20 combatants per encounter
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | `toggleConcentration` is a pure function: same input encounter + combatant ID yields same output. No I/O, randomness, or clocks. |
|
||||||
|
| II. Layered Architecture | PASS | Domain defines the toggle function; Application orchestrates via `EncounterStore` port; Web adapter implements UI + persistence. No layer violations. |
|
||||||
|
| III. Agent Boundary | N/A | No agent/AI features in this feature. |
|
||||||
|
| IV. Clarification-First | PASS | Spec is fully specified; no NEEDS CLARIFICATION markers. |
|
||||||
|
| V. Escalation Gates | PASS | All work is within spec scope. |
|
||||||
|
| VI. MVP Baseline Language | PASS | No permanent bans introduced. |
|
||||||
|
| VII. No Gameplay Rules | PASS | Concentration is state tracking only; no automatic save mechanics or rule enforcement. |
|
||||||
|
|
||||||
|
All gates pass. No violations to track.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/018-combatant-concentration/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── types.ts # Add isConcentrating to Combatant
|
||||||
|
├── events.ts # Add ConcentrationStarted/ConcentrationEnded
|
||||||
|
├── toggle-concentration.ts # New pure domain function
|
||||||
|
├── index.ts # Re-export new function + events
|
||||||
|
└── __tests__/
|
||||||
|
└── toggle-concentration.test.ts # New test file
|
||||||
|
|
||||||
|
packages/application/src/
|
||||||
|
├── toggle-concentration-use-case.ts # New use case
|
||||||
|
└── index.ts # Re-export
|
||||||
|
|
||||||
|
apps/web/src/
|
||||||
|
├── hooks/use-encounter.ts # Add toggleConcentration callback
|
||||||
|
├── components/combatant-row.tsx # Add Brain icon toggle + left border accent + pulse
|
||||||
|
├── persistence/encounter-storage.ts # Add isConcentrating to rehydration
|
||||||
|
└── App.tsx # Wire toggleConcentration prop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows existing monorepo layout with domain/application/web layers. Each new file mirrors the established pattern (e.g., `toggle-concentration.ts` mirrors `toggle-condition.ts`).
|
||||||
48
specs/018-combatant-concentration/quickstart.md
Normal file
48
specs/018-combatant-concentration/quickstart.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Quickstart: Combatant Concentration
|
||||||
|
|
||||||
|
**Feature**: 018-combatant-concentration | **Date**: 2026-03-06
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds a per-combatant boolean `isConcentrating` state with a Brain icon toggle, a colored left border accent for visual identification, and a pulse animation when a concentrating combatant takes damage.
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
### Domain Layer
|
||||||
|
1. **`packages/domain/src/types.ts`** — Add `isConcentrating?: boolean` to `Combatant` interface.
|
||||||
|
2. **`packages/domain/src/events.ts`** — Add `ConcentrationStarted` and `ConcentrationEnded` event types to the `DomainEvent` union.
|
||||||
|
3. **`packages/domain/src/toggle-concentration.ts`** (new) — Pure function mirroring `toggle-condition.ts` pattern.
|
||||||
|
4. **`packages/domain/src/index.ts`** — Re-export new function and event types.
|
||||||
|
|
||||||
|
### Application Layer
|
||||||
|
5. **`packages/application/src/toggle-concentration-use-case.ts`** (new) — Thin orchestration following `toggle-condition-use-case.ts` pattern.
|
||||||
|
6. **`packages/application/src/index.ts`** — Re-export new use case.
|
||||||
|
|
||||||
|
### Web Adapter
|
||||||
|
7. **`apps/web/src/persistence/encounter-storage.ts`** — Add `isConcentrating` to combatant rehydration.
|
||||||
|
8. **`apps/web/src/hooks/use-encounter.ts`** — Add `toggleConcentration` callback.
|
||||||
|
9. **`apps/web/src/components/combatant-row.tsx`** — Add Brain icon toggle, left border accent, and pulse animation.
|
||||||
|
10. **`apps/web/src/App.tsx`** — Wire `onToggleConcentration` prop through to `CombatantRow`.
|
||||||
|
|
||||||
|
## Implementation Pattern
|
||||||
|
|
||||||
|
Follow the existing `toggleCondition` pattern end-to-end:
|
||||||
|
|
||||||
|
```
|
||||||
|
Domain: toggleConcentration(encounter, combatantId) → { encounter, events } | DomainError
|
||||||
|
App: toggleConcentrationUseCase(store, combatantId) → events | DomainError
|
||||||
|
Hook: toggleConcentration = useCallback((id) => { ... toggleConcentrationUseCase(makeStore(), id) ... })
|
||||||
|
Component: <Brain onClick={() => onToggleConcentration(id)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Domain tests**: `toggle-concentration.test.ts` — toggle on/off, combatant not found, immutability, correct events emitted.
|
||||||
|
- **UI behavior**: Manual verification of hover show/hide, tooltip, left border accent, pulse animation on damage.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- Concentration is **not** a condition — it has its own boolean field and separate UI treatment.
|
||||||
|
- Pulse animation uses **CSS keyframes** triggered by transient React state, not a domain event.
|
||||||
|
- Damage detection for pulse uses **HP comparison** in the component (prevHp vs currentHp), not domain events.
|
||||||
|
- No localStorage migration needed — optional boolean field is backward-compatible.
|
||||||
50
specs/018-combatant-concentration/research.md
Normal file
50
specs/018-combatant-concentration/research.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Research: Combatant Concentration
|
||||||
|
|
||||||
|
**Feature**: 018-combatant-concentration | **Date**: 2026-03-06
|
||||||
|
|
||||||
|
## R1: Domain Toggle Pattern
|
||||||
|
|
||||||
|
**Decision**: Mirror the `toggleCondition` pattern with a simpler `toggleConcentration` pure function.
|
||||||
|
|
||||||
|
**Rationale**: `toggleCondition` (in `packages/domain/src/toggle-condition.ts`) is the closest analogue. It takes an encounter + combatant ID, validates the combatant exists, toggles the state, and returns a new encounter + domain events. Concentration is simpler because it's a boolean (no condition ID validation needed).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Reuse condition system with a special "concentration" condition ID: Rejected because the spec explicitly requires concentration to be separate from conditions and not appear in the condition tag UI.
|
||||||
|
- Store concentration at the encounter level (map of combatant ID to boolean): Rejected because co-locating with the combatant is consistent with how all other per-combatant state (HP, AC, conditions) is stored.
|
||||||
|
|
||||||
|
## R2: Storage Backward Compatibility
|
||||||
|
|
||||||
|
**Decision**: Add `isConcentrating?: boolean` as an optional field on the `Combatant` type. No migration needed.
|
||||||
|
|
||||||
|
**Rationale**: The localStorage adapter (`apps/web/src/persistence/encounter-storage.ts`) rehydrates combatants field-by-field with lenient validation. The AC field was added the same way (optional, defaults to `undefined` if absent). Old saved data without `isConcentrating` will load with the field absent (treated as `false`).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Versioned storage with explicit migration: Rejected because optional boolean fields don't require migration (same pattern used for AC).
|
||||||
|
|
||||||
|
## R3: Damage Pulse Detection
|
||||||
|
|
||||||
|
**Decision**: Detect damage in the UI layer by comparing previous and current `currentHp` values, triggering the pulse animation when HP decreases on a concentrating combatant.
|
||||||
|
|
||||||
|
**Rationale**: The domain emits `CurrentHpAdjusted` events with a `delta` field, but the UI layer already receives updated combatant props. Comparing `prevHp` vs `currentHp` via a React ref or `useEffect` is the simplest approach and avoids threading domain events through additional channels. This keeps the pulse animation purely in the adapter layer (no domain logic for "should pulse" needed).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- New domain event `ConcentrationCheckRequired`: Rejected because it would encode gameplay rules (concentration saves) in the domain, violating Constitution Principle VII (no gameplay rules in domain/constitution). The pulse is purely a UI hint.
|
||||||
|
- Pass domain events to CombatantRow: Rejected because events are consumed at the hook level and not currently threaded to individual row components. Adding event-based props would increase coupling.
|
||||||
|
|
||||||
|
## R4: Pulse Animation Approach
|
||||||
|
|
||||||
|
**Decision**: Use CSS keyframe animation with a Tailwind utility class, triggered by a transient state flag.
|
||||||
|
|
||||||
|
**Rationale**: The project uses Tailwind CSS v4. A CSS `@keyframes` animation on the left border and icon can be triggered by adding/removing a CSS class. A short-lived React state flag (`isPulsing`) set on damage detection and auto-cleared after animation duration (~700ms) is the simplest approach.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- JavaScript-driven animation (requestAnimationFrame): Rejected as over-engineered for a simple pulse effect.
|
||||||
|
- Framer Motion / React Spring: Rejected because neither is a project dependency, and a CSS keyframe animation is sufficient.
|
||||||
|
|
||||||
|
## R5: Brain Icon Availability
|
||||||
|
|
||||||
|
**Decision**: Use `Brain` from `lucide-react`, already a project dependency.
|
||||||
|
|
||||||
|
**Rationale**: Confirmed `lucide-react` is listed in `apps/web/package.json` dependencies. The `Brain` icon is part of the standard Lucide icon set. Other icons used in the project (`Swords`, `Heart`, `Shield`, `Plus`, `ChevronDown`, etc.) follow the same import pattern.
|
||||||
|
|
||||||
|
**Alternatives considered**: None needed; icon is available.
|
||||||
106
specs/018-combatant-concentration/spec.md
Normal file
106
specs/018-combatant-concentration/spec.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Feature Specification: Combatant Concentration
|
||||||
|
|
||||||
|
**Feature Branch**: `018-combatant-concentration`
|
||||||
|
**Created**: 2026-03-06
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Add concentration as a separate per-combatant state, not as a normal condition."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Toggle Concentration (Priority: P1)
|
||||||
|
|
||||||
|
A DM wants to mark a combatant as concentrating on a spell. They hover over the combatant row to reveal a Brain icon button on the left side of the row, then click it to activate concentration. Clicking the icon again toggles concentration off.
|
||||||
|
|
||||||
|
**Why this priority**: Core interaction; without toggle, the feature has no function.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by hovering a combatant row, clicking the Brain icon, and verifying concentration state toggles on/off.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a combatant row is not hovered, **When** the row is at rest, **Then** the Brain icon is hidden.
|
||||||
|
2. **Given** a combatant row is hovered, **When** concentration is inactive, **Then** the Brain icon appears (muted/faded).
|
||||||
|
3. **Given** the Brain icon is visible, **When** the user clicks it, **Then** concentration activates and the icon remains visible with an active style.
|
||||||
|
4. **Given** concentration is active, **When** the user clicks the Brain icon again, **Then** concentration deactivates and the icon hides (unless row is still hovered).
|
||||||
|
5. **Given** concentration is active, **When** the row is not hovered, **Then** the Brain icon remains visible (active state keeps it shown).
|
||||||
|
6. **Given** the Brain icon is visible, **When** the user hovers over it, **Then** a tooltip reading "Concentrating" appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Visual Feedback for Concentration (Priority: P2)
|
||||||
|
|
||||||
|
A DM wants to see at a glance which combatants are concentrating. When concentration is active, the combatant row displays a subtle colored left border accent to visually distinguish it from normal conditions.
|
||||||
|
|
||||||
|
**Why this priority**: Provides passive visual awareness without requiring interaction; builds on toggle.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by activating concentration on a combatant and verifying the row gains a colored left border accent.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a combatant has concentration active, **When** viewing the encounter tracker, **Then** the combatant row shows a colored left border accent.
|
||||||
|
2. **Given** a combatant has concentration inactive, **When** viewing the encounter tracker, **Then** the combatant row has no concentration accent.
|
||||||
|
3. **Given** concentration is active, **When** the user toggles concentration off, **Then** the left border accent disappears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Damage Pulse Alert (Priority: P3)
|
||||||
|
|
||||||
|
A DM deals damage to a concentrating combatant. The concentration icon and the row accent briefly pulse/flash to draw attention, reminding the DM that a concentration check may be needed.
|
||||||
|
|
||||||
|
**Why this priority**: Enhances situational awareness but depends on both toggle (P1) and visual feedback (P2) being in place.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by activating concentration on a combatant, applying damage, and verifying the pulse animation triggers.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a combatant is concentrating, **When** the combatant takes damage (HP reduced), **Then** the Brain icon and row accent briefly pulse/flash.
|
||||||
|
2. **Given** a combatant is concentrating, **When** the combatant is healed (HP increased), **Then** no pulse/flash occurs.
|
||||||
|
3. **Given** a combatant is not concentrating, **When** the combatant takes damage, **Then** no pulse/flash occurs.
|
||||||
|
4. **Given** a concentrating combatant takes damage, **When** the pulse animation completes, **Then** the row returns to its normal concentration-active appearance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when a combatant with concentration active is removed from the encounter? Concentration state is discarded with the combatant.
|
||||||
|
- What happens when concentration is toggled during an active pulse animation? The animation cancels and the new state applies immediately.
|
||||||
|
- Can multiple combatants concentrate simultaneously? Yes, concentration is independent per combatant.
|
||||||
|
- Does concentration state persist across page reloads? Yes, it is part of the combatant state stored via existing persistence.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST store concentration as a boolean property on each combatant, separate from the conditions list.
|
||||||
|
- **FR-002**: System MUST provide a toggle operation that flips concentration on/off for a given combatant.
|
||||||
|
- **FR-003**: The Brain icon MUST be hidden by default and appear on combatant row hover.
|
||||||
|
- **FR-004**: The Brain icon MUST remain visible whenever concentration is active, regardless of hover state.
|
||||||
|
- **FR-005**: Clicking the Brain icon MUST toggle the combatant's concentration state.
|
||||||
|
- **FR-006**: A tooltip reading "Concentrating" MUST appear when hovering the Brain icon.
|
||||||
|
- **FR-007**: When concentration is active, the combatant row MUST display a subtle colored left border accent.
|
||||||
|
- **FR-008**: When a concentrating combatant takes damage, the Brain icon and row accent MUST briefly pulse/flash.
|
||||||
|
- **FR-009**: The pulse/flash MUST NOT trigger on healing or when concentration is inactive.
|
||||||
|
- **FR-010**: Concentration MUST persist across page reloads via existing storage mechanisms.
|
||||||
|
- **FR-011**: Concentration MUST NOT appear in or interact with the condition tag system.
|
||||||
|
- **FR-012**: The concentration left border accent MUST use `border-l-purple-400`. The active Brain icon MUST use `text-purple-400` to visually associate icon and border.
|
||||||
|
- **FR-013**: The inactive (hover-revealed) Brain icon MUST use a muted style (`text-muted-foreground opacity-50`).
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Combatant**: Gains a new `isConcentrating` optional boolean property (default: `undefined`/falsy), independent of the existing `conditions` array.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can toggle concentration on/off for any combatant in a single click.
|
||||||
|
- **SC-002**: Concentrating combatants are visually distinguishable from non-concentrating combatants at a glance without hovering or interacting.
|
||||||
|
- **SC-003**: When a concentrating combatant takes damage, the visual alert draws attention within the same interaction flow (no separate notification needed).
|
||||||
|
- **SC-004**: Concentration state survives a full page reload without data loss.
|
||||||
|
- **SC-005**: The concentration UI does not increase the resting height of combatant rows (icon hidden by default keeps rows compact).
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The Lucide `Brain` icon is available in the project's existing Lucide React dependency.
|
||||||
|
- The colored left border accent will use a distinct color that does not conflict with existing condition tag colors.
|
||||||
|
- The pulse/flash animation duration MUST be 700ms. Both the CSS animation and the JS timeout MUST use this single value.
|
||||||
|
- "Takes damage" means any HP reduction (negative delta applied to current HP).
|
||||||
154
specs/018-combatant-concentration/tasks.md
Normal file
154
specs/018-combatant-concentration/tasks.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Tasks: Combatant Concentration
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/018-combatant-concentration/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Domain tests included (testing strategy from quickstart.md). UI behavior verified manually.
|
||||||
|
|
||||||
|
**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, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundational (Domain + Application Layer)
|
||||||
|
|
||||||
|
**Purpose**: Core domain type change, domain function, events, use case, persistence, and re-exports that ALL user stories depend on.
|
||||||
|
|
||||||
|
**Note**: No setup phase needed — existing project with all dependencies in place.
|
||||||
|
|
||||||
|
- [x] T001 [P] Add `readonly isConcentrating?: boolean` field to the `Combatant` interface in `packages/domain/src/types.ts`
|
||||||
|
- [x] T002 [P] Add `ConcentrationStarted` and `ConcentrationEnded` event interfaces to `packages/domain/src/events.ts` and include them in the `DomainEvent` union type. Each event has `type` and `combatantId` fields only.
|
||||||
|
- [x] T003 Create `packages/domain/src/toggle-concentration.ts` — pure function `toggleConcentration(encounter, combatantId)` returning `ToggleConcentrationSuccess | DomainError`. Mirror the pattern in `toggle-condition.ts`: validate combatant exists, flip `isConcentrating` (falsy→true emits `ConcentrationStarted`, true→falsy emits `ConcentrationEnded`), return new encounter + events array.
|
||||||
|
- [x] T004 Create domain tests in `packages/domain/src/__tests__/toggle-concentration.test.ts` — test cases: toggle on (falsy→true, emits ConcentrationStarted), toggle off (true→falsy, emits ConcentrationEnded), combatant not found returns DomainError, original encounter is not mutated (immutability), other combatants are unaffected.
|
||||||
|
- [x] T005 [P] Re-export `toggleConcentration` function and `ConcentrationStarted`/`ConcentrationEnded` event types from `packages/domain/src/index.ts`
|
||||||
|
- [x] T006 Create `packages/application/src/toggle-concentration-use-case.ts` — thin orchestration: `toggleConcentrationUseCase(store: EncounterStore, combatantId: CombatantId)` following the pattern in `toggle-condition-use-case.ts` (get→call domain→check error→save→return events).
|
||||||
|
- [x] T007 [P] Re-export `toggleConcentrationUseCase` from `packages/application/src/index.ts`
|
||||||
|
- [x] T008 Add `isConcentrating` boolean to combatant rehydration in `apps/web/src/persistence/encounter-storage.ts` — extract `isConcentrating` from stored entry, validate it is `true` (else `undefined`), include in reconstructed combatant object. Follow the AC field pattern.
|
||||||
|
|
||||||
|
**Checkpoint**: Domain function tested, use case ready, persistence handles the new field. All user story implementation can now begin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 - Toggle Concentration (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Brain icon button on combatant rows that toggles concentration on/off with hover-to-reveal behavior and tooltip.
|
||||||
|
|
||||||
|
**Independent Test**: Hover a combatant row → Brain icon appears → click toggles concentration on → icon stays visible when unhovered → click again toggles off → icon hides.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T009 [US1] Add `toggleConcentration` callback to `apps/web/src/hooks/use-encounter.ts` — follow the `toggleCondition` pattern: `useCallback((id: CombatantId) => { const result = toggleConcentrationUseCase(makeStore(), id); ... })`, add to returned object.
|
||||||
|
- [x] T010 [US1] Add `onToggleConcentration` prop to `CombatantRowProps` in `apps/web/src/components/combatant-row.tsx` and add the Brain icon button (from `lucide-react`) on the left side of each combatant row. Implement hover-to-reveal: icon hidden by default (CSS opacity/visibility), visible on row hover (use existing group-hover or add group class), always visible when `combatant.isConcentrating` is truthy. Active state: distinct icon style (e.g., filled/colored). Muted state on hover when inactive.
|
||||||
|
- [x] T011 [US1] Add tooltip "Concentrating" on the Brain icon hover in `apps/web/src/components/combatant-row.tsx` — use a `title` attribute or existing tooltip pattern.
|
||||||
|
- [x] T012 [US1] Wire `onToggleConcentration` prop through from `apps/web/src/App.tsx` to `CombatantRow`, passing `toggleConcentration` from the `useEncounter` hook.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 fully functional — concentration toggles on/off via Brain icon with hover behavior and tooltip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 - Visual Feedback for Concentration (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Concentrating combatant rows display a subtle colored left border accent, visually distinct from the active-turn indicator.
|
||||||
|
|
||||||
|
**Independent Test**: Activate concentration on a combatant → row shows colored left border → deactivate → border returns to normal.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T013 [US2] Add concentration-specific left border accent to the combatant row wrapper in `apps/web/src/components/combatant-row.tsx` — when `combatant.isConcentrating` is truthy, apply a colored left border class (e.g., `border-l-purple-400` or similar) that is visually distinct from the existing active-turn `border-l-accent`. Ensure the concentration border coexists with or takes precedence alongside the active-turn border when both apply.
|
||||||
|
|
||||||
|
**Checkpoint**: User Stories 1 AND 2 work independently — toggle + visual accent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 3 - Damage Pulse Alert (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: When a concentrating combatant takes damage, the Brain icon and row accent briefly pulse/flash to draw attention.
|
||||||
|
|
||||||
|
**Independent Test**: Activate concentration → apply damage via HP input → Brain icon and border pulse briefly → animation ends, returns to normal concentration appearance.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T014 [US3] Add CSS `@keyframes` pulse animation to the app's stylesheet or as a Tailwind utility in `apps/web/src/index.css` (or inline via Tailwind arbitrary values). The animation should briefly intensify the left border color and Brain icon, lasting ~700ms.
|
||||||
|
- [x] T015 [US3] Add damage detection logic in `apps/web/src/components/combatant-row.tsx` — use a `useRef` to track previous `currentHp` value. In a `useEffect`, compare previous HP to current HP: if HP decreased AND `combatant.isConcentrating` is truthy, set a transient `isPulsing` state to `true`. Auto-clear `isPulsing` after animation duration (~700ms) via `setTimeout`.
|
||||||
|
- [x] T016 [US3] Apply the pulse animation class conditionally in `apps/web/src/components/combatant-row.tsx` — when `isPulsing` is true, add the pulse animation class to both the row wrapper (left border) and the Brain icon. When pulse ends (`isPulsing` resets to false), classes are removed and row returns to normal concentration appearance. Handle edge case: if concentration is toggled off during pulse, cancel the timeout and remove pulse immediately.
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories functional — toggle + visual accent + damage pulse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Validation and cleanup across all stories.
|
||||||
|
|
||||||
|
- [x] T017 Run `pnpm check` (knip + format + lint + typecheck + test) and fix any issues
|
||||||
|
- [x] T018 Verify concentration does NOT appear in condition tags or condition picker in `apps/web/src/components/condition-tags.tsx` and `apps/web/src/components/condition-picker.tsx` (should require no changes — just verify FR-011)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Foundational (Phase 1)**: No dependencies — can start immediately. BLOCKS all user stories.
|
||||||
|
- **User Story 1 (Phase 2)**: Depends on Phase 1 completion.
|
||||||
|
- **User Story 2 (Phase 3)**: Depends on Phase 2 (US1) — builds on the same component with concentration state already wired.
|
||||||
|
- **User Story 3 (Phase 4)**: Depends on Phase 3 (US2) — pulse animates the border accent from US2.
|
||||||
|
- **Polish (Phase 5)**: Depends on all user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational — no other story dependencies.
|
||||||
|
- **User Story 2 (P2)**: Depends on US1 (concentration state must be toggleable to show the accent).
|
||||||
|
- **User Story 3 (P3)**: Depends on US2 (pulse animates the accent border from US2) and US1 (needs concentration state + icon).
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- T009 (hook) before T010 (component) — component needs the callback
|
||||||
|
- T010 (icon) before T011 (tooltip) — tooltip is on the icon
|
||||||
|
- T010, T011 before T012 (wiring) — App needs component props to exist
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
Within Phase 1 (Foundational):
|
||||||
|
```
|
||||||
|
Parallel: T001 (types.ts) + T002 (events.ts)
|
||||||
|
Then: T003 (toggle-concentration.ts) — depends on T001, T002
|
||||||
|
Then: T004 (tests) — depends on T003
|
||||||
|
Parallel: T005 (domain index.ts) + T006 (use case) + T008 (persistence)
|
||||||
|
Then: T007 (app index.ts) — depends on T006
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Foundational (T001–T008)
|
||||||
|
2. Complete Phase 2: User Story 1 (T009–T012)
|
||||||
|
3. **STOP and VALIDATE**: Toggle concentration via Brain icon works end-to-end
|
||||||
|
4. Run `pnpm check` to verify no regressions
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Foundational → Domain + app layer ready
|
||||||
|
2. Add User Story 1 → Toggle works → Validate
|
||||||
|
3. Add User Story 2 → Visual accent → Validate
|
||||||
|
4. Add User Story 3 → Damage pulse → Validate
|
||||||
|
5. Polish → Full quality gate pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- Concentration is NOT a condition — no changes to condition-related files
|
||||||
|
- No localStorage migration needed — optional boolean field is backward-compatible
|
||||||
|
- Pulse animation is purely UI-layer (CSS + React state), no domain logic
|
||||||
|
- Commit after each phase or logical group
|
||||||
Reference in New Issue
Block a user