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
+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 ? (