feat: CharacterCard dying pulse border + countdown; dead state; DM Revive button

Add dying (pulsing red border + skull countdown in vitals), dead (greyed-out,
skull prefix on name, HP bar no-op), and DM-only Revive button. Wire isDM prop
via role check in CampaignView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-12 01:34:40 -04:00
parent f853fdbce3
commit d39d6fa9b7
3 changed files with 85 additions and 10 deletions

View file

@ -153,3 +153,45 @@
color: var(--text-tertiary);
text-align: right;
}
.dying {
border: 2px solid var(--danger) !important;
animation: dyingPulse 1.5s ease-in-out infinite;
}
@keyframes dyingPulse {
0%, 100% { box-shadow: 0 0 6px rgba(var(--danger-rgb), 0.4); }
50% { box-shadow: 0 0 20px rgba(var(--danger-rgb), 0.85); }
}
.dead {
opacity: 0.45;
filter: grayscale(0.75);
}
.dyingLabel {
font-size: 0.8rem;
color: var(--danger);
font-weight: 700;
flex-shrink: 0;
white-space: nowrap;
}
.reviveBtn {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: transparent;
border: 1px solid var(--gold);
color: var(--gold);
border-radius: 3px;
cursor: pointer;
font-family: "Cinzel", Georgia, serif;
letter-spacing: 0.04em;
}
.reviveBtn:hover {
background: rgba(var(--gold-rgb), 0.12);
}

View file

@ -22,6 +22,7 @@ interface CharacterCardProps {
onClick: (characterId: number) => void;
canEdit?: boolean;
focusSpell?: string;
isDM?: boolean;
}
export default function CharacterCard({
@ -31,26 +32,42 @@ export default function CharacterCard({
onClick,
canEdit = true,
focusSpell,
isDM = false,
}: CharacterCardProps) {
const dyingCondition = character.conditions?.find((c) => c.name === "Dying");
const isDying = !!dyingCondition;
const isDead = !!character.is_dead;
const cardClass = [
styles.card,
isDying ? styles.dying : "",
isDead ? styles.dead : "",
]
.filter(Boolean)
.join(" ");
// When dying/dead the left-color border is replaced by the dying/dead CSS
const cardStyle = isDying || isDead
? {}
: { borderLeftColor: character.color, borderLeftWidth: "3px" };
return (
<div
className={styles.card}
onClick={() => onClick(character.id)}
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
>
<div className={cardClass} onClick={() => onClick(character.id)} style={cardStyle}>
<div className={styles.cardHeader}>
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
<div className={styles.nameRow}>
<span className={styles.name}>
{character.name}
{isDead ? "\u{1F480} " : ""}{character.name}
{character.title ? ` ${character.title}` : ""}
</span>
<span className={styles.level}>Lvl {character.level}</span>
</div>
</div>
<div className={styles.meta}>
{character.ancestry} {character.class}
</div>
{focusSpell && (
<div className={styles.focusIndicator}>
&#9679; Focusing: {focusSpell}
@ -61,13 +78,16 @@ export default function CharacterCard({
<HpBar
current={character.hp_current}
max={character.hp_max + getTalentHpBonus(character)}
onChange={(hp) => onHpChange(character.id, hp)}
onChange={isDead ? () => {} : (hp) => onHpChange(character.id, hp)}
/>
{isDying && dyingCondition && (
<span className={styles.dyingLabel} title="Dying">
{"\u{1F480}"} {dyingCondition.rounds_remaining}
</span>
)}
<div className={styles.ac}>
<span className={styles.acLabel}>AC</span>
<span className={styles.acValue}>
{calculateAC(character).effective}
</span>
<span className={styles.acValue}>{calculateAC(character).effective}</span>
</div>
<span
className={styles.luck}
@ -86,6 +106,18 @@ export default function CharacterCard({
/>
</div>
{isDead && isDM && (
<button
className={styles.reviveBtn}
onClick={(e) => {
e.stopPropagation();
onUpdate(character.id, { is_dead: false, hp_current: 1 } as Partial<Character>);
}}
>
Revive
</button>
)}
<div className={styles.modRow}>
{STATS.map((stat) => {
const value = getEffectiveStat(character, stat);

View file

@ -453,6 +453,7 @@ export default function CampaignView() {
onClick={setSelectedId}
canEdit={role === "dm" || char.user_id === user?.userId}
focusSpell={focusSpells.get(char.id)}
isDM={role === "dm"}
/>
))}
</div>