Shift the dark theme from neutral gray to a richer blue-tinted palette inspired by CharBuilder-style TTRPG apps. Deeper navy background, steel-blue card surfaces, and visible blue borders create more depth and visual layering. - Update design tokens: background, card, border, input, muted colors - Add card-glow utility (radial gradient + blue box-shadow) for card surfaces - Add panel-glow utility (top-down gradient) for tall panels like stat blocks - Apply glow and rounded-lg to all card surfaces, dropdowns, dialogs, toasts - Give outline buttons a subtle fill instead of transparent background - Active combatant row now uses full border with glow instead of left accent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
559 lines
14 KiB
TypeScript
559 lines
14 KiB
TypeScript
import {
|
|
type CombatantId,
|
|
type ConditionId,
|
|
deriveHpStatus,
|
|
type PlayerIcon,
|
|
} from "@initiative/domain";
|
|
import { BookOpen, Brain, X } from "lucide-react";
|
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
|
import { cn } from "../lib/utils";
|
|
import { AcShield } from "./ac-shield";
|
|
import { ConditionPicker } from "./condition-picker";
|
|
import { ConditionTags } from "./condition-tags";
|
|
import { D20Icon } from "./d20-icon";
|
|
import { HpAdjustPopover } from "./hp-adjust-popover";
|
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
|
import { ConfirmButton } from "./ui/confirm-button";
|
|
import { Input } from "./ui/input";
|
|
|
|
interface Combatant {
|
|
readonly id: CombatantId;
|
|
readonly name: string;
|
|
readonly initiative?: number;
|
|
readonly maxHp?: number;
|
|
readonly currentHp?: number;
|
|
readonly ac?: number;
|
|
readonly conditions?: readonly ConditionId[];
|
|
readonly isConcentrating?: boolean;
|
|
readonly color?: string;
|
|
readonly icon?: string;
|
|
}
|
|
|
|
interface CombatantRowProps {
|
|
combatant: Combatant;
|
|
isActive: boolean;
|
|
onRename: (id: CombatantId, newName: string) => void;
|
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
|
onRemove: (id: CombatantId) => void;
|
|
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
|
|
onAdjustHp: (id: CombatantId, delta: number) => void;
|
|
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
|
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
|
|
onToggleConcentration: (id: CombatantId) => void;
|
|
onShowStatBlock?: () => void;
|
|
onRollInitiative?: (id: CombatantId) => void;
|
|
}
|
|
|
|
function EditableName({
|
|
name,
|
|
combatantId,
|
|
onRename,
|
|
color,
|
|
}: Readonly<{
|
|
name: string;
|
|
combatantId: CombatantId;
|
|
onRename: (id: CombatantId, newName: string) => void;
|
|
color?: string;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(name);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const commit = useCallback(() => {
|
|
const trimmed = draft.trim();
|
|
if (trimmed !== "" && trimmed !== name) {
|
|
onRename(combatantId, trimmed);
|
|
}
|
|
setEditing(false);
|
|
}, [draft, name, combatantId, onRename]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(name);
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [name]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={draft}
|
|
className="h-7 text-sm"
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={startEditing}
|
|
className="cursor-text truncate text-left text-foreground text-sm transition-colors hover:text-hover-neutral"
|
|
style={color ? { color } : undefined}
|
|
>
|
|
{name}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function MaxHpDisplay({
|
|
maxHp,
|
|
onCommit,
|
|
}: Readonly<{
|
|
maxHp: number | undefined;
|
|
onCommit: (value: number | undefined) => void;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const commit = useCallback(() => {
|
|
if (draft === "") {
|
|
onCommit(undefined);
|
|
} else {
|
|
const n = Number.parseInt(draft, 10);
|
|
if (!Number.isNaN(n) && n >= 1) {
|
|
onCommit(n);
|
|
}
|
|
}
|
|
setEditing(false);
|
|
}, [draft, onCommit]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(maxHp?.toString() ?? "");
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [maxHp]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draft}
|
|
placeholder="Max"
|
|
className="h-7 w-[7ch] text-center text-sm tabular-nums"
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={startEditing}
|
|
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral"
|
|
>
|
|
{maxHp ?? "Max"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ClickableHp({
|
|
currentHp,
|
|
maxHp,
|
|
onAdjust,
|
|
dimmed,
|
|
}: Readonly<{
|
|
currentHp: number | undefined;
|
|
maxHp: number | undefined;
|
|
onAdjust: (delta: number) => void;
|
|
dimmed?: boolean;
|
|
}>) {
|
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
const status = deriveHpStatus(currentHp, maxHp);
|
|
|
|
if (maxHp === undefined) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
role="status"
|
|
aria-label="No HP set"
|
|
>
|
|
--
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPopoverOpen(true)}
|
|
aria-label={`Current HP: ${currentHp} (${status})`}
|
|
className={cn(
|
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral",
|
|
status === "bloodied" && "text-amber-400",
|
|
status === "unconscious" && "text-red-400",
|
|
status === "healthy" && "text-foreground",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
>
|
|
{currentHp}
|
|
</button>
|
|
{!!popoverOpen && (
|
|
<HpAdjustPopover
|
|
onAdjust={onAdjust}
|
|
onClose={() => setPopoverOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AcDisplay({
|
|
ac,
|
|
onCommit,
|
|
}: Readonly<{
|
|
ac: number | undefined;
|
|
onCommit: (value: number | undefined) => void;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const commit = useCallback(() => {
|
|
if (draft === "") {
|
|
onCommit(undefined);
|
|
} else {
|
|
const n = Number.parseInt(draft, 10);
|
|
if (!Number.isNaN(n) && n >= 0) {
|
|
onCommit(n);
|
|
}
|
|
}
|
|
setEditing(false);
|
|
}, [draft, onCommit]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(ac?.toString() ?? "");
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [ac]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draft}
|
|
placeholder="AC"
|
|
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <AcShield value={ac} onClick={startEditing} />;
|
|
}
|
|
|
|
function InitiativeDisplay({
|
|
initiative,
|
|
combatantId,
|
|
dimmed,
|
|
onSetInitiative,
|
|
onRollInitiative,
|
|
}: Readonly<{
|
|
initiative: number | undefined;
|
|
combatantId: CombatantId;
|
|
dimmed: boolean;
|
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
|
onRollInitiative?: (id: CombatantId) => void;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const commit = useCallback(() => {
|
|
if (draft === "") {
|
|
onSetInitiative(combatantId, undefined);
|
|
} else {
|
|
const n = Number.parseInt(draft, 10);
|
|
if (!Number.isNaN(n)) {
|
|
onSetInitiative(combatantId, n);
|
|
}
|
|
}
|
|
setEditing(false);
|
|
}, [draft, combatantId, onSetInitiative]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(initiative?.toString() ?? "");
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [initiative]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draft}
|
|
placeholder="--"
|
|
className={cn(
|
|
"h-7 w-[6ch] text-center text-sm tabular-nums",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Empty + bestiary creature → d20 roll button
|
|
if (initiative === undefined && onRollInitiative) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onRollInitiative(combatantId)}
|
|
className={cn(
|
|
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
title="Roll initiative"
|
|
aria-label="Roll initiative"
|
|
>
|
|
<D20Icon className="h-7 w-7" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// Has value → bold number, click to edit
|
|
// Empty + manual → "--" placeholder, click to edit
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={startEditing}
|
|
className={cn(
|
|
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
|
|
initiative === undefined
|
|
? "text-muted-foreground hover:text-hover-neutral"
|
|
: "font-medium text-foreground hover:text-hover-neutral",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
>
|
|
{initiative ?? "--"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function rowBorderClass(
|
|
isActive: boolean,
|
|
isConcentrating: boolean | undefined,
|
|
): string {
|
|
if (isActive) return "border border-accent/40 bg-accent/10 card-glow";
|
|
if (isConcentrating) return "border-l-2 border-l-purple-400";
|
|
return "border-l-2 border-l-transparent";
|
|
}
|
|
|
|
function concentrationIconClass(
|
|
isConcentrating: boolean | undefined,
|
|
dimmed: boolean,
|
|
): string {
|
|
if (!isConcentrating)
|
|
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
|
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
|
}
|
|
|
|
export function CombatantRow({
|
|
ref,
|
|
combatant,
|
|
isActive,
|
|
onRename,
|
|
onSetInitiative,
|
|
onRemove,
|
|
onSetHp,
|
|
onAdjustHp,
|
|
onSetAc,
|
|
onToggleCondition,
|
|
onToggleConcentration,
|
|
onShowStatBlock,
|
|
onRollInitiative,
|
|
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
|
const status = deriveHpStatus(currentHp, maxHp);
|
|
const dimmed = status === "unconscious";
|
|
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]);
|
|
|
|
const pcColor = combatant.color
|
|
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
"group rounded-lg pr-3 transition-colors",
|
|
rowBorderClass(isActive, combatant.isConcentrating),
|
|
isPulsing && "animate-concentration-pulse",
|
|
)}
|
|
>
|
|
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
|
|
{/* Concentration */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggleConcentration(id)}
|
|
title="Concentrating"
|
|
aria-label="Toggle concentration"
|
|
className={cn(
|
|
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
|
)}
|
|
>
|
|
<Brain size={16} />
|
|
</button>
|
|
|
|
{/* Initiative */}
|
|
<InitiativeDisplay
|
|
initiative={initiative}
|
|
combatantId={id}
|
|
dimmed={dimmed}
|
|
onSetInitiative={onSetInitiative}
|
|
onRollInitiative={onRollInitiative}
|
|
/>
|
|
|
|
{/* Name + Conditions */}
|
|
<div
|
|
className={cn(
|
|
"relative flex min-w-0 flex-wrap items-center gap-1",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
>
|
|
{!!onShowStatBlock && (
|
|
<button
|
|
type="button"
|
|
onClick={onShowStatBlock}
|
|
title="View stat block"
|
|
aria-label="View stat block"
|
|
className="shrink-0 text-muted-foreground transition-colors hover:text-hover-neutral"
|
|
>
|
|
<BookOpen size={14} />
|
|
</button>
|
|
)}
|
|
{!!combatant.icon &&
|
|
!!combatant.color &&
|
|
(() => {
|
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
|
const iconColor =
|
|
PLAYER_COLOR_HEX[
|
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
|
];
|
|
return PcIcon ? (
|
|
<PcIcon
|
|
size={14}
|
|
style={{ color: iconColor }}
|
|
className="shrink-0"
|
|
/>
|
|
) : null;
|
|
})()}
|
|
<EditableName
|
|
name={name}
|
|
combatantId={id}
|
|
onRename={onRename}
|
|
color={pcColor}
|
|
/>
|
|
<ConditionTags
|
|
conditions={combatant.conditions}
|
|
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
|
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
|
/>
|
|
{!!pickerOpen && (
|
|
<ConditionPicker
|
|
activeConditions={combatant.conditions}
|
|
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
|
|
onClose={() => setPickerOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* AC */}
|
|
<div className={cn(dimmed && "opacity-50")}>
|
|
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
|
</div>
|
|
|
|
{/* HP */}
|
|
<div className="flex items-center gap-1">
|
|
<ClickableHp
|
|
currentHp={currentHp}
|
|
maxHp={maxHp}
|
|
onAdjust={(delta) => onAdjustHp(id, delta)}
|
|
dimmed={dimmed}
|
|
/>
|
|
{maxHp !== undefined && (
|
|
<span
|
|
className={cn(
|
|
"text-muted-foreground text-sm tabular-nums",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
>
|
|
/
|
|
</span>
|
|
)}
|
|
<div className={cn(dimmed && "opacity-50")}>
|
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<ConfirmButton
|
|
icon={<X size={16} />}
|
|
label="Remove combatant"
|
|
onConfirm={() => onRemove(id)}
|
|
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|