dd694477b2
Crash fix: - restoreBackup (file + cloud pull) now validates each row through its Zod schema, backfilling new fields — an older backup no longer lands characters missing feats/concentration/etc and crashing the sheet. + test. pf2e parity (closing feature gaps vs 5e): - +9 missing classes (Animist, Exemplar, Gunslinger, Inventor, Kineticist, Magus, Psychic, Summoner, Thaumaturge) with data + class tips. - the wizard now enriches pf2e classes with their caster ability, so pf2e spellcasters get the Spells step + "Caster" tag like 5e. - editable Perception rank and spellcasting proficiency on the pf2e sheet (fixes wrong initiative / spell DC at higher levels); rank-10 spell slots. - pf2e monster resistances/weaknesses/immunities now apply in combat (flat amounts: resistance subtracts, weakness adds, 'all' matches any type). + tests. Glyph purge (finishing the emoji→Lucide migration): replaced ~40 leftover text-glyphs (✕ − + ✦ 🤫 ⬆ ⬇ ☑ ☐ ▶ ◀ ‹ › ★ ↻ ▸ ↑ ●) with Lucide icons across Modal (every dialog), the sheet sections, dice, settings, player panels, world pages, map editor, roll tray, and session UI. Gate: 293 unit + e2e green; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
56 lines
2.6 KiB
TypeScript
56 lines
2.6 KiB
TypeScript
import { Sparkles, Skull, X } from 'lucide-react';
|
|
import { useRollStore, type TrayRoll } from '@/stores/rollStore';
|
|
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
|
|
import { naturalD20 } from '@/lib/dice/notation';
|
|
import { cn } from '@/lib/cn';
|
|
|
|
/** A nat-20 / critical-success is a crit; a nat-1 / critical-failure is a fumble. */
|
|
function critKind(roll: TrayRoll): 'crit' | 'fumble' | null {
|
|
if (roll.degree === 'critical-success') return 'crit';
|
|
if (roll.degree === 'critical-failure') return 'fumble';
|
|
const n = naturalD20(roll.result);
|
|
if (n === 20) return 'crit';
|
|
if (n === 1) return 'fumble';
|
|
return null;
|
|
}
|
|
|
|
/** Floating result of the most recent roll, shared across the whole app. */
|
|
export function RollTray() {
|
|
const last = useRollStore((s) => s.last);
|
|
const dismiss = useRollStore((s) => s.dismiss);
|
|
if (!last) return null;
|
|
|
|
const crit = critKind(last);
|
|
|
|
return (
|
|
<div data-roll-tray className="pointer-events-none fixed bottom-4 right-4 z-50 w-72 print:hidden">
|
|
<div
|
|
key={last.seq}
|
|
className={cn(
|
|
'pointer-events-auto rounded-lg border bg-panel p-4 shadow-2xl',
|
|
crit === 'crit' && 'animate-crit animate-crit-glow border-warning',
|
|
crit === 'fumble' && 'animate-fumble border-danger',
|
|
!crit && 'animate-dice-settle border-line',
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>}
|
|
{crit === 'crit' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-warning"><Sparkles size={12} aria-hidden /> Critical <Sparkles size={12} aria-hidden /></div>}
|
|
{crit === 'fumble' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-danger"><Skull size={12} aria-hidden /> Fumble</div>}
|
|
{last.degree && (
|
|
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
|
|
{DEGREE_LABEL[last.degree]}
|
|
{last.dc !== undefined ? ` (DC ${last.dc})` : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"><X size={14} aria-hidden /></button>
|
|
</div>
|
|
<div className={cn('mt-1 font-display text-4xl font-bold', crit === 'crit' ? 'text-warning' : crit === 'fumble' ? 'text-danger' : 'text-accent')}>{last.result.total}</div>
|
|
<div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|