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:
Lukas
2026-03-06 14:34:28 +01:00
parent febe892e15
commit e59fd83292
19 changed files with 779 additions and 7 deletions

View File

@@ -3,8 +3,8 @@ import {
type ConditionId,
deriveHpStatus,
} from "@initiative/domain";
import { Shield, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Brain, Shield, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
@@ -20,6 +20,7 @@ interface Combatant {
readonly currentHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
}
interface CombatantRowProps {
@@ -32,6 +33,7 @@ interface CombatantRowProps {
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
}
function EditableName({
@@ -240,22 +242,69 @@ export function CombatantRow({
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
}: CombatantRowProps) {
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
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 (
<div
className={cn(
"rounded-md px-3 py-2 transition-colors",
"group rounded-md px-3 py-2 transition-colors",
isActive
? "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",
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 */}
<Input
type="text"