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:
2026-06-09 11:38:24 +02:00
parent dd694477b2
commit 895807d6c9
24 changed files with 55 additions and 44 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ function CampaignSwitcher() {
return (
<Select
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 ?? ''}
onChange={(e) => setActive(e.target.value || null)}
>
+9 -7
View File
@@ -82,6 +82,8 @@ function Assistant({ campaign }: { campaign: Campaign }) {
await encountersRepo.save(enc);
setActiveEncounter(enc.id);
void navigate({ to: '/combat' });
} catch {
setMsg('Could not build an encounter — the monster data failed to load. Try again.');
} finally {
setBusy(false);
}
@@ -96,7 +98,7 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<div className="grid gap-6 lg:grid-cols-2">
<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">
<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">
@@ -106,24 +108,24 @@ function Assistant({ campaign }: { campaign: Campaign }) {
</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." />
</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." />
<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." />
<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} />
<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} />
<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} />
</section>
</div>
+1 -1
View File
@@ -46,7 +46,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
{state === 'ready' && suggestion && (
<div className="mt-2 rounded-md border border-line bg-surface p-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'}
</span>
<span className="text-xs text-muted"> {suggestion.targetDifficulty}</span>
+1 -1
View File
@@ -77,7 +77,7 @@ function SignalsBellInner({ campaign }: { campaign: Campaign }) {
{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="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 && (
<button className="text-xs text-faint hover:text-ink" onClick={() => setDismissed(new Set(signals.map((s) => s.id)))}>Clear all</button>
)}
+1 -1
View File
@@ -43,7 +43,7 @@ export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
setState('ready');
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));
setSource('deterministic');
+1 -1
View File
@@ -86,7 +86,7 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
return (
<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="text-xs text-muted">{desc}</div>
</div>
+3 -3
View File
@@ -135,7 +135,7 @@ export function CharacterSheet({ character }: { character: Character }) {
<div className="min-w-0 flex-1">
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
<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}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
@@ -149,13 +149,13 @@ export function CharacterSheet({ character }: { character: Character }) {
</Badge>
</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: Gauge, value: formatModifier(initiative), label: 'Init' },
{ icon: Footprints, value: c.speed, label: 'Speed' },
].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 />
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
<span className="smallcaps text-[8.5px]">{label}</span>
@@ -293,7 +293,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</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">
{SYSTEM_OPTIONS.map((opt) => (
<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 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">
{(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>
@@ -314,7 +314,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</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">
{classes.map((c) => (
<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);
return (
<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 ? (
<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>)}
@@ -513,7 +513,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
return (
<div>
<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>}
</div>
<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]!;
return (
<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 ? (
<Select
className="mt-1"
@@ -81,7 +81,7 @@ export function DefensesSection({ c, update }: SectionProps) {
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<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}
</div>
);
@@ -28,7 +28,7 @@ export function LevelUpAdvisor({ campaign, character }: { campaign: Campaign; ch
return (
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
<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'}
</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">
{/* HP */}
<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' ? (
<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">
@@ -108,7 +108,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* ASI (5e) */}
{asi && (
<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">
{(['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'}`}>
@@ -134,7 +134,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* Boosts (pf2e) */}
{boosts && (
<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">
{[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)))}>
@@ -148,7 +148,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* Skill increase (pf2e) */}
{skillInc && (
<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">
{sys.skills.map((s) => {
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
@@ -64,12 +64,12 @@ 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"><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="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"><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" />
<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) => (
@@ -123,7 +123,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
{/* Spell slots */}
<div className="mb-4">
<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>
</div>
{sc.slots.length === 0 ? (
+3
View File
@@ -514,6 +514,8 @@ function CombatantRow({
})()}
</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 */}
<div className="flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
@@ -559,6 +561,7 @@ function CombatantRow({
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}>
<X size={15} aria-hidden />
</Button>
</div>
</div>
<div className="mt-2">
+2 -2
View File
@@ -96,14 +96,14 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
{inSession && !isGm && (
<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" />
</div>
)}
{inSession && (
<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">
{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')}>
+6 -3
View File
@@ -9,6 +9,7 @@ import { useRollStore } from '@/stores/rollStore';
import { sendPlayerRoll } from '@/lib/sync/wsSync';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Meter } from '@/components/ui/Codex';
import { cn } from '@/lib/cn';
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 slots = c.spellcasting.slots.filter((s) => s.max > 0);
const [castMsg, setCastMsg] = useState<string | null>(null);
return (
<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 className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
</div>
<div className="mb-2 h-3 overflow-hidden rounded-full bg-surface">
<div className={cn('h-full', hpPct > 50 ? 'bg-success' : hpPct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${hpPct}%` }} />
<div className="mb-2">
<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 className="flex flex-wrap items-center gap-1">
<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.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
</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
</Button>
</div>
))}
</div>
{castMsg && <p className="mt-2 text-xs text-muted" aria-live="polite">{castMsg}</p>}
</div>
)}
+1 -1
View File
@@ -40,7 +40,7 @@ export function AssistantSettings() {
return (
<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">
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
+3 -3
View File
@@ -38,7 +38,7 @@ export function CloudCampaigns() {
if (!signedIn) {
return (
<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>
</section>
);
@@ -48,7 +48,7 @@ export function CloudCampaigns() {
return (
<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 && (
<ul className="space-y-2">
@@ -126,7 +126,7 @@ export function CloudCampaigns() {
{usage?.admin && (
<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 &amp; storage</h3>
<h3 className="mb-1 smallcaps">Admin · users &amp; storage</h3>
<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>
<tbody>
+1 -1
View File
@@ -79,7 +79,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
if (list.length === 0) return null;
return (
<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">
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
</div>
+2 -2
View File
@@ -151,7 +151,7 @@ function NoteEditor({
{/* Rendered preview with clickable links */}
<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">
{splitWikiLinks(body).map((part, i) =>
part.link ? (
@@ -172,7 +172,7 @@ function NoteEditor({
{backlinks.length > 0 && (
<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">
{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">
+1 -1
View File
@@ -126,7 +126,7 @@ function QuestCard({ quest }: { quest: Quest }) {
</label>
<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})`}
</div>
<ul className="space-y-1">
+1 -1
View File
@@ -38,7 +38,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
return (
<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>
<h3 className="smallcaps">Tokens</h3>
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">Hide</button>
</div>
+3
View File
@@ -49,7 +49,10 @@ export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
blinded: { attacksAgainst: 'advantage' },
clumsy: { statusPenalty: true },
drained: { statusPenalty: true },
dazzled: { attacksAgainst: 'advantage' },
enfeebled: { statusPenalty: true },
fatigued: { statusPenalty: true },
frightened: { statusPenalty: true },
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
immobilized: { speedZero: true },