Polish sprint 1/2: restore crash fix, pf2e parity, full glyph purge

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>
This commit is contained in:
2026-06-09 11:29:36 +02:00
parent f75b50adb8
commit dd694477b2
38 changed files with 355 additions and 84 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ export function SessionPrepCard({ campaign, characters, notes, quests }: Props)
Generate three distinct session opening hooks grounded in your campaign&apos;s current threads.
</p>
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
{state === 'loading' ? 'Thinking…' : state === 'ready' ? 'Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
{state === 'loading' ? 'Thinking…' : state === 'ready' ? 'Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
</Button>
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
@@ -229,6 +229,23 @@ export function CharacterSheet({ character }: { character: Character }) {
</div>
</section>
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
{c.system === 'pf2e' && (
<section className="mb-6">
<SectionTitle>Perception</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<RankRow
label="Perception (WIS)"
modifier={sys.passivePerception(rulesInput) - 10}
rank={c.perceptionRank}
ranks={ranks}
onRank={(rank) => update({ perceptionRank: rank })}
system={c.system}
/>
</div>
</section>
)}
{/* Saves */}
<section className="mb-6">
<SectionTitle>Saving Throws</SectionTitle>
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Lightbulb } from 'lucide-react';
import { Lightbulb, RotateCcw, Star } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, SYSTEM_OPTIONS,
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
@@ -65,7 +65,22 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
useEffect(() => { let on = true; void loadClasses(system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [system]);
useEffect(() => {
let on = true;
void loadClasses(system).then((c) => {
if (!on) return;
// The pf2e class data loads with caster:'none'; merge the curated caster
// ability so pf2e spellcasters get the Spells step + "Caster" tag like 5e.
const enriched = system === 'pf2e'
? c.map((rc) => {
const def = getClassDef('pf2e', rc.name);
return def && def.caster !== 'none' && def.spellAbility ? { ...rc, caster: def.spellAbility } : rc;
})
: c;
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
});
return () => { on = false; };
}, [system]);
useEffect(() => {
let on = true;
if (system === '5e') {
@@ -381,7 +396,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
))}
</div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}> Reroll</Button>}
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}><RotateCcw size={13} aria-hidden /> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
@@ -402,7 +417,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const isKey = selectedClass?.keyAbilities.includes(a);
return (
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}</div>
<div className="flex items-center justify-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
{usesPool ? (
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RotateCcw } from 'lucide-react';
import type { AbilityKey, AbilityScores } from '@/lib/rules';
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
import { createRng } from '@/lib/rng';
@@ -75,7 +76,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
</div>
{method === 'roll' && (
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}> Reroll</Button>
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}><RotateCcw size={13} aria-hidden /> Reroll</Button>
)}
{method === 'pointbuy' && (
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}>
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
@@ -84,7 +86,7 @@ export function AttacksSection({ c, update }: SectionProps) {
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
</button>
</span>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}><X size={14} aria-hidden /></Button>
</li>
);
})}
@@ -1,3 +1,5 @@
import { Minus, Plus, Star } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common';
@@ -44,7 +46,7 @@ export function DefensesSection({ c, update }: SectionProps) {
</Field>
<Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
{d.inspiration ? '★ Inspired' : 'Grant inspiration'}
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
</Button>
</Field>
<Field label="Exhaustion (06)">
@@ -64,9 +66,9 @@ export function DefensesSection({ c, update }: SectionProps) {
</Field>
<Field label="Hero Points">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"></Button>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point">+</Button>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
</>
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import type { Feat } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
@@ -37,7 +38,7 @@ export function FeatsSection({ c, update }: SectionProps) {
<li key={f.id} className="rounded-md border border-line bg-panel p-2">
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-medium" value={f.name} onChange={(e) => patch(f.id, { name: e.target.value })} aria-label="Feat name" />
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(f.id)} aria-label={`Remove ${f.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(f.id)} aria-label={`Remove ${f.name}`}><X size={14} aria-hidden /></Button>
</div>
<Textarea className="mt-1 text-xs" rows={2} value={f.description} onChange={(e) => patch(f.id, { description: e.target.value })} placeholder="What it does — kept handy at the table." aria-label={`${f.name} description`} />
</li>
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { InventoryItem } from '@/lib/schemas';
@@ -118,7 +120,7 @@ export function InventorySection({ c, update }: SectionProps) {
Attuned
</label>
)}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}><X size={14} aria-hidden /></Button>
</li>
))}
</ul>
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X, Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem, applyRest } from '@/lib/rules';
import { spendResource, regainResource } from '@/lib/mechanics';
@@ -62,19 +64,19 @@ export function ResourcesSection({ c, update }: SectionProps) {
{c.resources.map((r) => (
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
<Button size="icon" variant="ghost" onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Spend"></Button>
<Button size="icon" variant="ghost" onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Spend"><Minus size={14} aria-hidden /></Button>
<span className="w-12 text-center text-sm">
<span className="font-medium text-ink">{r.current}</span>
<span className="text-muted">/{r.max}</span>
</span>
<Button size="icon" variant="ghost" onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Regain">+</Button>
<Button size="icon" variant="ghost" onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Regain"><Plus size={14} aria-hidden /></Button>
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
))}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}><X size={14} aria-hidden /></Button>
</li>
))}
</ul>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit } from 'lucide-react';
import { Sparkles, BrainCircuit, X, Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
@@ -36,9 +36,11 @@ export function SpellcastingSection({ c, update }: SectionProps) {
const setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s)));
// pf2e has 10 spell ranks; 5e tops out at 9.
const maxRank = c.system === 'pf2e' ? 10 : 9;
const addSlotLevel = () => {
const next = (sc.slots.at(-1)?.level ?? 0) + 1;
if (next > 9) return;
if (next > maxRank) return;
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
};
@@ -83,6 +85,21 @@ export function SpellcastingSection({ c, update }: SectionProps) {
))}
</Select>
</label>
{/* pf2e: spell DC/attack scale with spellcasting proficiency (expert/master/legendary). */}
{c.system === 'pf2e' && ability && (
<label className="text-xs text-muted">
Proficiency
<Select
className="ml-2 w-auto"
value={c.spellcastingRank ?? 'trained'}
onChange={(e) => update({ spellcastingRank: e.target.value as ProficiencyRank })}
>
{(['trained', 'expert', 'master', 'legendary'] as const).map((r) => (
<option key={r} value={r}>{r[0]!.toUpperCase() + r.slice(1)}</option>
))}
</Select>
</label>
)}
{ability && (
<div className="flex gap-4 text-sm">
<span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
@@ -117,9 +134,9 @@ export function SpellcastingSection({ c, update }: SectionProps) {
<div key={s.level} className="rounded-md border border-line bg-panel px-2 py-1 text-center">
<div className="text-[10px] uppercase text-muted">Lv {s.level}</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}></Button>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}><Minus size={14} aria-hidden /></Button>
<span className="text-sm"><span className="font-medium text-ink">{s.current}</span>/<NumberField className="inline-block w-12" value={s.max} min={0} onChange={(v) => patchSlot(s.level, { max: v, current: Math.min(v, s.current) })} aria-label={`Level ${s.level} max slots`} /></span>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}>+</Button>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}><Plus size={14} aria-hidden /></Button>
</div>
</div>
))}
@@ -138,7 +155,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
Level
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
<option value={0}>Cantrip</option>
{Array.from({ length: 9 }, (_, i) => i + 1).map((l) => (
{Array.from({ length: maxRank }, (_, i) => i + 1).map((l) => (
<option key={l} value={l}>{l}</option>
))}
</Select>
@@ -165,7 +182,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={13} aria-hidden /> Cast
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><X size={14} aria-hidden /></Button>
</li>
))}
</ul>
+11 -4
View File
@@ -423,7 +423,7 @@ function CombatantRow({
const [dmgType, setDmgType] = useState('');
// Only show the damage-type picker when this creature actually has typed defenses.
const def = c.damageDefenses;
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0);
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0 || (def.resistFlat?.length ?? 0) > 0 || (def.weakness?.length ?? 0) > 0);
const dead = c.hp.current <= 0;
const foe = c.kind === 'monster';
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
@@ -520,9 +520,16 @@ function CombatantRow({
{hasDefenses && (
<Select className="w-auto py-1 text-xs" value={dmgType} onChange={(e) => setDmgType(e.target.value)} aria-label={`${c.name} damage type`}>
<option value="">untyped</option>
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => (
<option key={t} value={t}>{def!.immune.includes(t) ? `${t} (immune)` : def!.resist.includes(t) ? `${t} (resist)` : def!.vulnerable.includes(t) ? `${t} (vuln)` : t}</option>
))}
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => {
const rf = def!.resistFlat?.find((r) => r.type === t);
const wk = def!.weakness?.find((w) => w.type === t);
const tag = def!.immune.includes(t) ? ' (immune)'
: def!.vulnerable.includes(t) ? ' (vuln)'
: def!.resist.includes(t) ? ' (resist)'
: rf ? ` (resist ${rf.amount})`
: wk ? ` (weak ${wk.amount})` : '';
return <option key={t} value={t}>{t}{tag}</option>;
})}
</Select>
)}
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta, dmgType || undefined); setDelta(0); }} title="Apply damage">
+14 -9
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import type { SystemId } from '@/lib/rules';
import { abilityModifier } from '@/lib/rules';
import { normalizeMonsterDefenses } from '@/lib/mechanics';
import { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e } from '@/lib/mechanics';
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
import type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
import {
@@ -171,7 +171,7 @@ export const CATEGORIES: CategoryDef[] = [
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
initBonus: abilityModifier(m.dexterity ?? 10),
...(m.cr !== undefined ? { cr: m.cr } : {}),
damageDefenses: normalizeMonsterDefenses(m),
damageDefenses: { ...normalizeMonsterDefenses(m), resistFlat: [], weakness: [] },
};
},
numericSort: { label: 'CR', get: (e) => Number(e.cr) },
@@ -245,13 +245,18 @@ export const CATEGORIES: CategoryDef[] = [
// ---------- Pathfinder 2e ----------
{
...pf2eCategory('creatures', 'Bestiary', 'creatures'),
toCombatant: (e) => ({
name: e.name,
ac: Number(e.ac) || 10,
hp: Number(e.hp) || 1,
initBonus: Number(e.perception) || 0,
...(e.level !== undefined ? { level: Number(e.level) } : {}),
}),
toCombatant: (e) => {
const d = normalizeMonsterDefensesPf2e(e as { immunity?: unknown; resistance?: unknown; weakness?: unknown });
const hasDef = d.immune.length || d.conditionImmune.length || d.resistFlat.length || d.weakness.length;
return {
name: e.name,
ac: Number(e.ac) || 10,
hp: Number(e.hp) || 1,
initBonus: Number(e.perception) || 0,
...(e.level !== undefined ? { level: Number(e.level) } : {}),
...(hasDef ? { damageDefenses: { resist: [], vulnerable: [], ...d } } : {}),
};
},
},
classCategory('pf2e'),
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
+4 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { Dices, Sparkles, Skull, History as HistoryIcon } from 'lucide-react';
import { Dices, Sparkles, Skull, EyeOff, X, History as HistoryIcon } from 'lucide-react';
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories';
@@ -199,7 +199,7 @@ export function DicePage() {
onClick={toggleSecret}
title="When on, your rolls are NOT shared with the players"
>
🤫 Secret
<EyeOff size={14} aria-hidden /> Secret
</Button>
)}
</div>
@@ -209,7 +209,7 @@ export function DicePage() {
</p>
)}
{isGm && secret && (
<p className="mt-2 text-xs text-warning">🤫 Secret is on your rolls are not shared with the players.</p>
<p className="mt-2 flex items-center gap-1 text-xs text-warning"><EyeOff size={12} aria-hidden /> Secret is on your rolls are not shared with the players.</p>
)}
{/* Saved rolls (macros) */}
@@ -240,7 +240,7 @@ export function DicePage() {
onClick={() => removeMacro(macroKey, m.id)}
aria-label={`Remove ${m.label}`}
>
<X size={13} aria-hidden />
</button>
</span>
))}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Check, Megaphone } from 'lucide-react';
import { Check, Megaphone, X } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Button } from '@/components/ui/Button';
@@ -24,7 +24,7 @@ export function HandoutControl() {
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
<Megaphone size={14} aria-hidden /> Handout {active && <Check size={12} aria-hidden />}
</Button>
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"></Button>}
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"><X size={14} aria-hidden /></Button>}
{open && (
<Modal open onClose={() => setOpen(false)} title="Handout for players"
footer={<>
+5 -2
View File
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router';
import { X } from 'lucide-react';
import { useSessionStore } from '@/stores/sessionStore';
import { leaveRoom } from '@/lib/sync/wsSync';
@@ -11,9 +12,11 @@ export function PlayerSessionBadge() {
const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status;
return (
<div data-testid="player-live" className="flex items-center gap-1 rounded-md border border-success/40 bg-success/5 px-2 py-1 text-xs">
<span className={status === 'connected' ? 'text-success' : 'text-muted'}> {label}</span>
<span className={`inline-flex items-center gap-1 ${status === 'connected' ? 'text-success' : 'text-muted'}`}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${status === 'connected' ? 'bg-success' : 'bg-muted'}`} aria-hidden />{label}
</span>
<Link to="/play" className="font-mono font-semibold text-accent" title="Open the table">{intent.joinCode}</Link>
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session"></button>
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session" aria-label="Leave session"><X size={13} aria-hidden /></button>
</div>
);
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
import type { SessionLogEntry } from '@/lib/schemas';
import type { RosterEntry } from '@/lib/sync/messages';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Dice5, Mail, MessageSquare } from 'lucide-react';
import { Dice5, Mail, MessageSquare, X } from 'lucide-react';
import { Input, Textarea } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -87,7 +87,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
</div>
)}
</div>
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink"></button>
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink"><X size={15} aria-hidden /></button>
</div>
{!inSession && (
+5 -5
View File
@@ -1,4 +1,4 @@
import { Check } from 'lucide-react';
import { Check, ChevronRight, ArrowUp } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import { getTurnGuide, getGenericGuide } from '@/lib/assistant/actions';
@@ -8,8 +8,8 @@ export function ActionGuide({ character }: { character: Character }) {
return (
<details className="mt-3 rounded-lg border border-line bg-surface-2">
<summary className="cursor-pointer select-none px-3 py-2 text-xs text-muted hover:text-ink">
What can I do on my turn?
<summary className="flex cursor-pointer select-none items-center gap-1 px-3 py-2 text-xs text-muted hover:text-ink">
<ChevronRight size={13} aria-hidden /> What can I do on my turn?
</summary>
<div className="border-t border-line px-3 pb-3 pt-2">
<ul className="space-y-2">
@@ -21,8 +21,8 @@ export function ActionGuide({ character }: { character: Character }) {
))}
</ul>
{guide.upgradeAt && character.level < guide.upgradeAt.level && (
<p className="mt-2 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
{guide.upgradeAt.note}
<p className="mt-2 flex items-center gap-1 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
<ArrowUp size={12} aria-hidden /> {guide.upgradeAt.note}
</p>
)}
{guide.upgradeAt && character.level >= guide.upgradeAt.level && (
+8 -8
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit } from 'lucide-react';
import { Sparkles, BrainCircuit, Minus, Plus, X } from 'lucide-react';
import type { Character, Defenses } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { ActionGuide } from './ActionGuide';
@@ -55,8 +55,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<Button size="sm" variant="primary" onClick={() => setHp(c.hp.current + 5)}>+5</Button>
<span className="mx-1 h-4 w-px bg-line" />
<span className="text-xs text-muted">Temp</span>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}></Button>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}>+</Button>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}><Minus size={14} aria-hidden /></Button>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}><Plus size={14} aria-hidden /></Button>
</div>
</div>
@@ -73,7 +73,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
) : (
<div className="space-y-2 text-sm">
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
<Pips label="Death saves " count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
<Pips label="Death saves (fails)" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
<div className="flex items-center justify-between">
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
<label className="flex items-center gap-1"><input type="checkbox" checked={c.defenses.inspiration} onChange={(e) => setDef({ inspiration: e.target.checked })} /> Inspiration</label>
@@ -155,9 +155,9 @@ function Counter({ label, value, min, max, onChange, suffix }: { label: string;
return (
<div className="flex items-center gap-1">
{label && <span className="flex-1 text-xs text-muted">{label}</span>}
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}></Button>
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}><Minus size={14} aria-hidden /></Button>
<span className="w-8 text-center tabular-nums text-ink">{value}{suffix ?? ''}</span>
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}>+</Button>
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}><Plus size={14} aria-hidden /></Button>
</div>
);
}
@@ -191,8 +191,8 @@ function ConditionsBox({ character: c, onPatch }: { character: Character; onPatc
<div className="mb-2 flex flex-wrap gap-1">
{c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>}
{c.conditions.map((cond, i) => (
<button key={i} onClick={() => remove(i)} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
{cond.name}{cond.value ? ` ${cond.value}` : ''}
<button key={i} onClick={() => remove(i)} className="inline-flex items-center gap-1 rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
{cond.name}{cond.value ? ` ${cond.value}` : ''} <X size={11} aria-hidden />
</button>
))}
</div>
+3 -3
View File
@@ -1,4 +1,4 @@
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle } from 'lucide-react';
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle, Play } from 'lucide-react';
import type { SystemId } from '@/lib/rules';
import type { Snapshot } from '@/lib/sync/messages';
import type { PlayerMap } from '@/lib/map';
@@ -104,7 +104,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
<li key={c.id} className={cn('flex items-center gap-3 rounded-xl border px-3 py-2', c.isCurrent ? 'border-accent bg-accent-glow ring-1 ring-accent/40' : 'border-line bg-panel')}>
<span className="w-8 text-center font-mono text-lg font-semibold text-accent-deep">{c.initiative}</span>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 inline-flex items-center gap-0.5 text-xs text-accent"><Play size={11} aria-hidden /> turn</span>}</span>
{c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
@@ -137,7 +137,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
<ul className="mt-1.5 space-y-0.5 pl-1 text-sm">
{q.objectives.map((o, oi) => (
<li key={oi} className={cn('flex items-start gap-1.5', o.done ? 'text-muted line-through' : 'text-ink')}>
<span className="mt-0.5 text-xs text-faint">{o.done ? '☑' : '☐'}</span>
{o.done ? <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-success" aria-hidden /> : <Circle size={13} className="mt-0.5 shrink-0 text-faint" aria-hidden />}
<span>{o.text}</span>
</li>
))}
+2 -2
View File
@@ -219,8 +219,8 @@ function CloudSync() {
{user ? (
<div className="space-y-3 pt-4">
<div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button>
<Button variant="primary" disabled={busy} onClick={push}><Upload size={14} aria-hidden /> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}><Download size={14} aria-hidden /> Restore from cloud</Button>
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
</div>
</div>
+2 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import type { Calendar, Campaign } from '@/lib/schemas';
import { calendarRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
@@ -55,7 +56,7 @@ function CalendarView({ campaign }: { campaign: Campaign }) {
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
<span className="flex-1 text-ink">{ev.title}</span>
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}></button>
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}><X size={13} aria-hidden /></button>
</li>
))}
</ul>
+3 -1
View File
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas';
import { homebrewSchema } from '@/lib/schemas';
import { homebrewRepo } from '@/lib/db/repositories';
@@ -102,7 +104,7 @@ function HomebrewCard({ hb }: { hb: Homebrew }) {
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2">
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></Button>
</div>
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
+2 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Wand2 } from 'lucide-react';
import { Wand2, X } from 'lucide-react';
import type { Campaign, Npc } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
@@ -89,7 +89,7 @@ function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
<Button size="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
<Wand2 size={13} aria-hidden />
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}><X size={14} aria-hidden /></Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
+4 -2
View File
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import type { Campaign, Quest, Objective } from '@/lib/schemas';
import { questsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
@@ -108,7 +110,7 @@ function QuestCard({ quest }: { quest: Quest }) {
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
</div>
<textarea
@@ -136,7 +138,7 @@ function QuestCard({ quest }: { quest: Quest }) {
value={o.text}
onChange={(e) => setObjective(o.id, { text: e.target.value })}
/>
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"></button>
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"><X size={13} aria-hidden /></button>
</li>
))}
</ul>
+4 -4
View File
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from 'react';
import {
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
ChevronRight, type LucideIcon,
ChevronRight, ChevronLeft, Play, type LucideIcon,
} from 'lucide-react';
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
@@ -329,9 +329,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
{activeEncounter && (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
<span className="font-display font-semibold text-ink"> {activeCombatant?.name ?? '—'}</span>
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}> Prev</Button>
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn </Button>
<span className="flex items-center gap-1 font-display font-semibold text-ink"><Play size={14} aria-hidden /> {activeCombatant?.name ?? '—'}</span>
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}><ChevronLeft size={14} aria-hidden /> Prev</Button>
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn <ChevronRight size={14} aria-hidden /></Button>
{activeTokenId ? <span className="text-xs text-muted"> their token glows on the map</span> : <span className="text-xs text-muted"> place their token from the palette</span>}
</div>
)}
+1 -1
View File
@@ -39,7 +39,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette"> Hide</button>
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">Hide</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">