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:
@@ -49,7 +49,7 @@ export function SessionPrepCard({ campaign, characters, notes, quests }: Props)
|
||||
Generate three distinct session opening hooks grounded in your campaign'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 (0–6)">
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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={<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user