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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user