Implement the 020-fix-zero-hp-opacity feature that replaces container-level opacity dimming with element-level opacity on individual leaf elements so that HP popover and condition picker render at full opacity for unconscious combatants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 15:43:27 +01:00
parent 0c0da9b90e
commit 04a4f18f98
9 changed files with 489 additions and 14 deletions

View File

@@ -154,17 +154,24 @@ function ClickableHp({
currentHp,
maxHp,
onAdjust,
dimmed,
}: {
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="inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground">
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
--
</span>
);
@@ -180,6 +187,7 @@ function ClickableHp({
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)}
>
{currentHp}
@@ -271,6 +279,7 @@ export function CombatantRow({
}: CombatantRowProps) {
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);
@@ -309,7 +318,6 @@ export function CombatantRow({
: combatant.isConcentrating
? "border-l-2 border-l-purple-400"
: "border-l-2 border-l-transparent",
status === "unconscious" && "opacity-50",
isPulsing && "animate-concentration-pulse",
)}
>
@@ -323,7 +331,9 @@ export function CombatantRow({
className={cn(
"flex items-center justify-center transition-opacity",
combatant.isConcentrating
? "opacity-100 text-purple-400"
? dimmed
? "opacity-50 text-purple-400"
: "opacity-100 text-purple-400"
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
)}
>
@@ -336,7 +346,10 @@ export function CombatantRow({
inputMode="numeric"
value={initiative ?? ""}
placeholder="--"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
@@ -351,10 +364,14 @@ export function CombatantRow({
/>
{/* Name */}
<EditableName name={name} combatantId={id} onRename={onRename} />
<div className={cn(dimmed && "opacity-50")}>
<EditableName name={name} combatantId={id} onRename={onRename} />
</div>
{/* AC */}
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
<div className="flex items-center gap-1">
@@ -362,20 +379,31 @@ export function CombatantRow({
currentHp={currentHp}
maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)}
dimmed={dimmed}
/>
{maxHp !== undefined && (
<span className="text-sm tabular-nums text-muted-foreground">
<span
className={cn(
"text-sm tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
/
</span>
)}
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
<div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
</div>
{/* Actions */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
className={cn(
"h-7 w-7 text-muted-foreground hover:text-destructive",
dimmed && "opacity-50",
)}
onClick={() => onRemove(id)}
title="Remove combatant"
aria-label="Remove combatant"
@@ -386,11 +414,13 @@ export function CombatantRow({
{/* Conditions */}
<div className="relative ml-[calc(3rem+0.75rem)]">
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
<div className={cn(dimmed && "opacity-50")}>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
</div>
{pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}