Polish sprint 2/2: layouts, UX consistency, pf2e conditions
Layout: - CombatantRow: action controls (damage/AC/move/remove) grouped into one right-aligned wrapper so they wrap together instead of scattering on narrow widths. - top bar: the campaign switcher now shrinks/truncates instead of overflowing on tablet widths. - character sheet header: stat coins go full-width grid on mobile; the name truncates and scales down on small screens. UX consistency: - player Cast and resource Spend/Regain no longer fail silently — Cast shows the reason, resource −/+ disable at their bounds. - the AI encounter builder now catches load failures and reports them; level-up advisor shows the human error message, not the error-kind enum. - section headers standardized on the .smallcaps design token across 16 files (replacing an ad-hoc uppercase class). - player HP bar uses the shared Meter primitive. pf2e depth: condition-effects table gains drained, dazzled, enfeebled, fatigued. FeatCard: dropped a dead text-xl wrapper left from the emoji purge. Left as documented (functional, off-theme, refactor-risky): the native confirm/prompt in Settings cloud-conflict and the session-password flow. Gate: 293 unit + 35 e2e + 2 realtime; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ function CampaignSwitcher() {
|
|||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
aria-label="Active campaign"
|
aria-label="Active campaign"
|
||||||
className="w-auto min-w-44"
|
className="w-auto min-w-0 max-w-[34vw] shrink truncate sm:min-w-44"
|
||||||
value={active?.id ?? ''}
|
value={active?.id ?? ''}
|
||||||
onChange={(e) => setActive(e.target.value || null)}
|
onChange={(e) => setActive(e.target.value || null)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
|||||||
await encountersRepo.save(enc);
|
await encountersRepo.save(enc);
|
||||||
setActiveEncounter(enc.id);
|
setActiveEncounter(enc.id);
|
||||||
void navigate({ to: '/combat' });
|
void navigate({ to: '/combat' });
|
||||||
|
} catch {
|
||||||
|
setMsg('Could not build an encounter — the monster data failed to load. Try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,7 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
|||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Encounter builder</h2>
|
<h2 className="mb-2 smallcaps">Encounter builder</h2>
|
||||||
<div className="rounded-lg border border-line bg-panel p-4">
|
<div className="rounded-lg border border-line bg-panel p-4">
|
||||||
<p className="mb-3 text-sm text-muted">Build a balanced encounter for your party and jump into combat.</p>
|
<p className="mb-3 text-sm text-muted">Build a balanced encounter for your party and jump into combat.</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -106,24 +108,24 @@ function Assistant({ campaign }: { campaign: Campaign }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Combat</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Combat</h2>
|
||||||
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
|
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Party resources</h2>
|
<h2 className="mb-2 smallcaps">Party resources</h2>
|
||||||
<SuggestionList items={byCat('resources')} onAction={runAction} empty="The party looks ready." />
|
<SuggestionList items={byCat('resources')} onAction={runAction} empty="The party looks ready." />
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Session prep</h2>
|
||||||
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
|
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session hooks</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Session hooks</h2>
|
||||||
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
|
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Quick NPC</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Quick NPC</h2>
|
||||||
<NpcGenCard campaign={campaign} />
|
<NpcGenCard campaign={campaign} />
|
||||||
|
|
||||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
|
<h2 className="mb-2 mt-6 smallcaps">Campaign insights</h2>
|
||||||
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
|
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
|
|||||||
{state === 'ready' && suggestion && (
|
{state === 'ready' && suggestion && (
|
||||||
<div className="mt-2 rounded-md border border-line bg-surface p-2">
|
<div className="mt-2 rounded-md border border-line bg-surface p-2">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
|
<span className="smallcaps">
|
||||||
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
|
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted">→ {suggestion.targetDifficulty}</span>
|
<span className="text-xs text-muted">→ {suggestion.targetDifficulty}</span>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function SignalsBellInner({ campaign }: { campaign: Campaign }) {
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 top-11 z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-line bg-panel p-2 shadow-xl">
|
<div className="absolute right-0 top-11 z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-line bg-panel p-2 shadow-xl">
|
||||||
<div className="flex items-center justify-between px-2 py-1">
|
<div className="flex items-center justify-between px-2 py-1">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Signals</span>
|
<span className="smallcaps">Signals</span>
|
||||||
{signals.length > 0 && (
|
{signals.length > 0 && (
|
||||||
<button className="text-xs text-faint hover:text-ink" onClick={() => setDismissed(new Set(signals.map((s) => s.id)))}>Clear all</button>
|
<button className="text-xs text-faint hover:text-ink" onClick={() => setDismissed(new Set(signals.map((s) => s.id)))}>Clear all</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
|
|||||||
setState('ready');
|
setState('ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.ok) setMessage(`AI unavailable (${res.error}); showing general routes.`);
|
if (!res.ok) setMessage(`AI unavailable: ${res.message} Showing general routes.`);
|
||||||
}
|
}
|
||||||
setRoutes(deterministicRoutes(campaign.system, character.className));
|
setRoutes(deterministicRoutes(campaign.system, character.className));
|
||||||
setSource('deterministic');
|
setSource('deterministic');
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
|||||||
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
|
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
|
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
|
||||||
<div className="text-xl" aria-hidden>{icon}</div>
|
<div className="text-accent" aria-hidden>{icon}</div>
|
||||||
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
|
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
|
||||||
<div className="text-xs text-muted">{desc}</div>
|
<div className="text-xs text-muted">{desc}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
|
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
|
||||||
<Input
|
<Input
|
||||||
className="mt-0.5 max-w-md border-transparent bg-transparent px-0 font-display text-3xl font-semibold leading-tight tracking-tight focus-visible:border-line"
|
className="mt-0.5 w-full max-w-md truncate border-transparent bg-transparent px-0 font-display text-2xl font-semibold leading-tight tracking-tight focus-visible:border-line sm:text-3xl"
|
||||||
value={c.name}
|
value={c.name}
|
||||||
aria-label="Character name"
|
aria-label="Character name"
|
||||||
onChange={(e) => update({ name: e.target.value })}
|
onChange={(e) => update({ name: e.target.value })}
|
||||||
@@ -149,13 +149,13 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex w-full items-center gap-2 sm:w-auto">
|
||||||
{[
|
{[
|
||||||
{ icon: Shield, value: ac, label: 'Armor' },
|
{ icon: Shield, value: ac, label: 'Armor' },
|
||||||
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
|
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
|
||||||
{ icon: Footprints, value: c.speed, label: 'Speed' },
|
{ icon: Footprints, value: c.speed, label: 'Speed' },
|
||||||
].map(({ icon: Ic, value, label }) => (
|
].map(({ icon: Ic, value, label }) => (
|
||||||
<div key={label} className="flex w-[4.5rem] flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center">
|
<div key={label} className="flex flex-1 flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center sm:w-[4.5rem] sm:flex-none">
|
||||||
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
|
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
|
||||||
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
|
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
|
||||||
<span className="smallcaps text-[8.5px]">{label}</span>
|
<span className="smallcaps text-[8.5px]">{label}</span>
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Game system</div>
|
<div className="mb-1 smallcaps">Game system</div>
|
||||||
<div className="flex gap-1" role="group" aria-label="Game system">
|
<div className="flex gap-1" role="group" aria-label="Game system">
|
||||||
{SYSTEM_OPTIONS.map((opt) => (
|
{SYSTEM_OPTIONS.map((opt) => (
|
||||||
<button key={opt.id} type="button" onClick={() => changeSystem(opt.id)} aria-pressed={system === opt.id}
|
<button key={opt.id} type="button" onClick={() => changeSystem(opt.id)} aria-pressed={system === opt.id}
|
||||||
@@ -305,7 +305,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
|
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">New here? Start from a ready-made hero</div>
|
<div className="mb-1 smallcaps">New here? Start from a ready-made hero</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(TEMPLATES[system] ?? []).map((t) => (
|
{(TEMPLATES[system] ?? []).map((t) => (
|
||||||
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
|
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
|
||||||
@@ -314,7 +314,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Choose a class</div>
|
<div className="mb-1 smallcaps">Choose a class</div>
|
||||||
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||||
{classes.map((c) => (
|
{classes.map((c) => (
|
||||||
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
|
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
|
||||||
@@ -417,7 +417,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
const isKey = selectedClass?.keyAbilities.includes(a);
|
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||||
return (
|
return (
|
||||||
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
||||||
<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>
|
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
|
||||||
{usesPool ? (
|
{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)))}>
|
<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>)}
|
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
|
||||||
@@ -513,7 +513,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</span>
|
<span className="smallcaps">{title}</span>
|
||||||
{value && <button className="text-[11px] text-muted hover:text-ink" onClick={() => onPick('')}>clear</button>}
|
{value && <button className="text-[11px] text-muted hover:text-ink" onClick={() => onPick('')}>clear</button>}
|
||||||
</div>
|
</div>
|
||||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}…`} className="mb-1" aria-label={`Search ${title}`} />
|
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}…`} className="mb-1" aria-label={`Search ${title}`} />
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
|
|||||||
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
|
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
|
||||||
return (
|
return (
|
||||||
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center">
|
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
|
<div className="smallcaps">{ABILITY_ABBR[a]}</div>
|
||||||
{usesPool ? (
|
{usesPool ? (
|
||||||
<Select
|
<Select
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function DefensesSection({ c, update }: SectionProps) {
|
|||||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-line bg-panel px-3 py-2">
|
<div className="rounded-md border border-line bg-panel px-3 py-2">
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
|
<div className="mb-1 smallcaps">{label}</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function LevelUpAdvisor({ campaign, character }: { campaign: Campaign; ch
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
|
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
|
<span className="smallcaps">
|
||||||
{source === 'llm' ? 'AI build routes' : 'Build routes'}
|
{source === 'llm' ? 'AI build routes' : 'Build routes'}
|
||||||
</span>
|
</span>
|
||||||
{state === 'loading' && <span className="text-xs text-muted">Thinking…</span>}
|
{state === 'loading' && <span className="text-xs text-muted">Thinking…</span>}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Hit points</h3>
|
<h3 className="mb-1 smallcaps">Hit points</h3>
|
||||||
{character.system === '5e' ? (
|
{character.system === '5e' ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
|
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
|
||||||
@@ -108,7 +108,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{/* ASI (5e) */}
|
{/* ASI (5e) */}
|
||||||
{asi && (
|
{asi && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability Score Improvement</h3>
|
<h3 className="mb-1 smallcaps">Ability Score Improvement</h3>
|
||||||
<div className="mb-2 flex gap-1">
|
<div className="mb-2 flex gap-1">
|
||||||
{(['asi', 'feat'] as const).map((m) => (
|
{(['asi', 'feat'] as const).map((m) => (
|
||||||
<button key={m} onClick={() => setAsiMode(m)} className={`rounded-md px-3 py-1 text-sm ${asiMode === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink'}`}>
|
<button key={m} onClick={() => setAsiMode(m)} className={`rounded-md px-3 py-1 text-sm ${asiMode === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink'}`}>
|
||||||
@@ -134,7 +134,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{/* Boosts (pf2e) */}
|
{/* Boosts (pf2e) */}
|
||||||
{boosts && (
|
{boosts && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability boosts (choose 4)</h3>
|
<h3 className="mb-1 smallcaps">Ability boosts (choose 4)</h3>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{[0, 1, 2, 3].map((i) => (
|
||||||
<Select key={i} aria-label={`Boost ${i + 1}`} value={boostPicks[i]} onChange={(e) => setBoostPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
|
<Select key={i} aria-label={`Boost ${i + 1}`} value={boostPicks[i]} onChange={(e) => setBoostPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
|
||||||
@@ -148,7 +148,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{/* Skill increase (pf2e) */}
|
{/* Skill increase (pf2e) */}
|
||||||
{skillInc && (
|
{skillInc && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Skill increase</h3>
|
<h3 className="mb-1 smallcaps">Skill increase</h3>
|
||||||
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
|
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
|
||||||
{sys.skills.map((s) => {
|
{sys.skills.map((s) => {
|
||||||
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
|
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ export function ResourcesSection({ c, update }: SectionProps) {
|
|||||||
{c.resources.map((r) => (
|
{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">
|
<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" />
|
<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"><Minus size={14} aria-hidden /></Button>
|
<Button size="icon" variant="ghost" disabled={r.current <= 0} 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="w-12 text-center text-sm">
|
||||||
<span className="font-medium text-ink">{r.current}</span>
|
<span className="font-medium text-ink">{r.current}</span>
|
||||||
<span className="text-muted">/{r.max}</span>
|
<span className="text-muted">/{r.max}</span>
|
||||||
</span>
|
</span>
|
||||||
<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>
|
<Button size="icon" variant="ghost" disabled={r.current >= r.max} 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" />
|
<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'] })}>
|
<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) => (
|
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
{/* Spell slots */}
|
{/* Spell slots */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Spell slots</span>
|
<span className="smallcaps">Spell slots</span>
|
||||||
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
|
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
|
||||||
</div>
|
</div>
|
||||||
{sc.slots.length === 0 ? (
|
{sc.slots.length === 0 ? (
|
||||||
|
|||||||
@@ -514,6 +514,8 @@ function CombatantRow({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action controls — kept grouped so they wrap together, not scattered, on narrow widths */}
|
||||||
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
{/* HP controls */}
|
{/* HP controls */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
|
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
|
||||||
@@ -560,6 +562,7 @@ function CombatantRow({
|
|||||||
<X size={15} aria-hidden />
|
<X size={15} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
|
|||||||
@@ -96,14 +96,14 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
|||||||
|
|
||||||
{inSession && !isGm && (
|
{inSession && !isGm && (
|
||||||
<div className="border-b border-line p-3">
|
<div className="border-b border-line p-3">
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-muted">Your name</label>
|
<label className="smallcaps">Your name</label>
|
||||||
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); }} placeholder="What should we call you?" aria-label="Your display name" className="mt-1" />
|
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); }} placeholder="What should we call you?" aria-label="Your display name" className="mt-1" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inSession && (
|
{inSession && (
|
||||||
<div className="border-b border-line p-3">
|
<div className="border-b border-line p-3">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Who's here ({roster.length})</h3>
|
<h3 className="mb-1 smallcaps">Who's here ({roster.length})</h3>
|
||||||
<ul className="flex flex-wrap gap-1">
|
<ul className="flex flex-wrap gap-1">
|
||||||
{roster.map((p) => (
|
{roster.map((p) => (
|
||||||
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
|
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useRollStore } from '@/stores/rollStore';
|
|||||||
import { sendPlayerRoll } from '@/lib/sync/wsSync';
|
import { sendPlayerRoll } from '@/lib/sync/wsSync';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Meter } from '@/components/ui/Codex';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
||||||
@@ -22,6 +23,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
|
|
||||||
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
|
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
|
||||||
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
|
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
|
||||||
|
const [castMsg, setCastMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
|
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
|
||||||
@@ -45,8 +47,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
<span>Hit points</span>
|
<span>Hit points</span>
|
||||||
<span className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
|
<span className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 h-3 overflow-hidden rounded-full bg-surface">
|
<div className="mb-2">
|
||||||
<div className={cn('h-full', hpPct > 50 ? 'bg-success' : hpPct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${hpPct}%` }} />
|
<Meter value={c.hp.current} max={c.hp.max} tone={hpPct > 50 ? 'var(--app-verdigris)' : hpPct > 0 ? 'var(--app-accent)' : 'var(--app-danger)'} height={10} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>−5</Button>
|
<Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>−5</Button>
|
||||||
@@ -116,12 +118,13 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
{s.name}
|
{s.name}
|
||||||
{s.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
|
{s.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
|
||||||
</span>
|
</span>
|
||||||
<Button size="sm" variant="ghost" onClick={() => { const r = castSpell(c, s.id); if (r.ok) onPatch(r.patch); }} aria-label={`Cast ${s.name}`}>
|
<Button size="sm" variant="ghost" onClick={() => { const r = castSpell(c, s.id); if (r.ok) { onPatch(r.patch); setCastMsg(r.log.join(' ')); } else setCastMsg(r.reason); }} aria-label={`Cast ${s.name}`}>
|
||||||
<Sparkles size={12} aria-hidden /> Cast
|
<Sparkles size={12} aria-hidden /> Cast
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{castMsg && <p className="mt-2 text-xs text-muted" aria-live="polite">{castMsg}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function AssistantSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Assistant (AI)</h2>
|
<h2 className="mb-2 smallcaps">Assistant (AI)</h2>
|
||||||
<p className="mb-3 max-w-2xl text-sm text-muted">
|
<p className="mb-3 max-w-2xl text-sm text-muted">
|
||||||
Optional. Bring your own provider and key to enable AI-grounded tips (encounter balancing,
|
Optional. Bring your own provider and key to enable AI-grounded tips (encounter balancing,
|
||||||
level-up routes). The assistant always works without this — it falls back to deterministic
|
level-up routes). The assistant always works without this — it falls back to deterministic
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function CloudCampaigns() {
|
|||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-line bg-panel p-4">
|
<section className="rounded-lg border border-line bg-panel p-4">
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
|
<h2 className="mb-2 smallcaps">Shared campaigns (cloud)</h2>
|
||||||
<p className="text-sm text-muted">Sign in above to publish a campaign for your players, or join one and publish your character — it stays yours and syncs when you edit it.</p>
|
<p className="text-sm text-muted">Sign in above to publish a campaign for your players, or join one and publish your character — it stays yours and syncs when you edit it.</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -48,7 +48,7 @@ export function CloudCampaigns() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
|
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
|
<h2 className="smallcaps">Shared campaigns (cloud)</h2>
|
||||||
|
|
||||||
{list.length > 0 && (
|
{list.length > 0 && (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
@@ -126,7 +126,7 @@ export function CloudCampaigns() {
|
|||||||
|
|
||||||
{usage?.admin && (
|
{usage?.admin && (
|
||||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Admin · users & storage</h3>
|
<h3 className="mb-1 smallcaps">Admin · users & storage</h3>
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
|
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
|
|||||||
if (list.length === 0) return null;
|
if (list.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section key={k}>
|
<section key={k}>
|
||||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{HOMEBREW_KIND_LABEL[k]}s</h2>
|
<h2 className="mb-2 smallcaps">{HOMEBREW_KIND_LABEL[k]}s</h2>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
|
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ function NoteEditor({
|
|||||||
|
|
||||||
{/* Rendered preview with clickable links */}
|
{/* Rendered preview with clickable links */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Preview</h3>
|
<h3 className="mb-1 smallcaps">Preview</h3>
|
||||||
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
|
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
|
||||||
{splitWikiLinks(body).map((part, i) =>
|
{splitWikiLinks(body).map((part, i) =>
|
||||||
part.link ? (
|
part.link ? (
|
||||||
@@ -172,7 +172,7 @@ function NoteEditor({
|
|||||||
|
|
||||||
{backlinks.length > 0 && (
|
{backlinks.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Linked from</h3>
|
<h3 className="mb-1 smallcaps">Linked from</h3>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{backlinks.map((n) => (
|
{backlinks.map((n) => (
|
||||||
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
|
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function QuestCard({ quest }: { quest: Quest }) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">
|
<div className="mb-1 smallcaps">
|
||||||
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
|
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
|
|||||||
return (
|
return (
|
||||||
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
|
<h3 className="smallcaps">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>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
|
|||||||
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
|
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
|
||||||
blinded: { attacksAgainst: 'advantage' },
|
blinded: { attacksAgainst: 'advantage' },
|
||||||
clumsy: { statusPenalty: true },
|
clumsy: { statusPenalty: true },
|
||||||
|
drained: { statusPenalty: true },
|
||||||
|
dazzled: { attacksAgainst: 'advantage' },
|
||||||
enfeebled: { statusPenalty: true },
|
enfeebled: { statusPenalty: true },
|
||||||
|
fatigued: { statusPenalty: true },
|
||||||
frightened: { statusPenalty: true },
|
frightened: { statusPenalty: true },
|
||||||
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
|
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
|
||||||
immobilized: { speedZero: true },
|
immobilized: { speedZero: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user