Phase 1: builder/UX quick wins (5e wording, no-campaign, details, ability UI)

- 5e skill/save proficiency shows 'Not proficient/Proficient/Expertise' instead of the
  PF2e rank names (Untrained/Trained/Expert); PF2e keeps its ranks. (shared rankLabel)
- Characters can be created without a campaign: campaignId is optional ('' = unassigned),
  CharactersPage no longer requires an active campaign, the wizard's campaign prop is
  optional, and the sheet has a Campaign selector to attach/move later.
- New character details: appearance, personality/behaviour, alignment, and a real
  background field (promoted out of notes); a Details wizard step + a Details sheet card.
- Ability step shows a clearer total + 'base · +race' breakdown.
- Point-buy verified correct (8:0..15:9 / 27, clamped 8-15, Next gated on budget).

All schema additions default-safe (characterDefaults updated). 329 tests green, build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:56:26 +02:00
parent f1e15d4011
commit 11a9e87100
9 changed files with 124 additions and 29 deletions
+33 -8
View File
@@ -19,6 +19,8 @@ import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format';
import { useCampaigns } from '@/features/campaigns/hooks';
import { rankLabel } from './sheet/labels';
import { InventorySection } from './sheet/InventorySection';
import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection';
@@ -32,17 +34,11 @@ const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
const RANK_LABEL: Record<ProficiencyRank, string> = {
untrained: 'Untrained',
trained: 'Trained',
expert: 'Expert',
master: 'Master',
legendary: 'Legendary',
};
export function CharacterSheet({ character }: { character: Character }) {
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
const [c, setC] = useState<Character>(character);
const campaigns = useCampaigns();
const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false);
const [shared, setShared] = useState(false);
@@ -184,6 +180,12 @@ export function CharacterSheet({ character }: { character: Character }) {
<Labeled label="Speed">
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
</Labeled>
<Labeled label="Campaign">
<Select value={c.campaignId} onChange={(e) => update({ campaignId: e.target.value })}>
<option value="">Unassigned</option>
{campaigns.map((cm) => <option key={cm.id} value={cm.id}>{cm.name}</option>)}
</Select>
</Labeled>
</div>
{/* Vital stats */}
@@ -313,6 +315,29 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Inventory, currency, encumbrance */}
<InventorySection c={c} update={update} />
{/* Details */}
<section className="mb-6">
<SectionTitle>Details</SectionTitle>
<div className="grid gap-3 sm:grid-cols-2">
<Labeled label="Background">
<Input value={c.background} onChange={(e) => update({ background: e.target.value })} placeholder="Acolyte, Soldier…" />
</Labeled>
<Labeled label="Alignment">
<Input value={c.alignment} onChange={(e) => update({ alignment: e.target.value })} placeholder="Chaotic Good, Unaligned…" />
</Labeled>
<Labeled label="Appearance">
<textarea value={c.appearance} onChange={(e) => update({ appearance: e.target.value })} rows={3}
placeholder="Looks, age, distinguishing features…"
className="w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</Labeled>
<Labeled label="Personality & behaviour">
<textarea value={c.personality} onChange={(e) => update({ personality: e.target.value })} rows={3}
placeholder="Traits, ideals, bonds, flaws…"
className="w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</Labeled>
</div>
</section>
{/* Notes */}
<section className="mb-6">
<SectionTitle>Notes</SectionTitle>
@@ -427,7 +452,7 @@ function RankRow({
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
{ranks.map((r) => (
<option key={r} value={r}>
{RANK_LABEL[r]}
{rankLabel(r, system)}
</option>
))}
</Select>
+12 -6
View File
@@ -7,20 +7,23 @@ import { getSystem } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
import { pickTextFile } from '@/lib/io/file';
import { useCharacters, useAllPcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { Page, PageHeader, EmptyState } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Avatar, Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal';
import { CreationWizard } from './builder/CreationWizard';
// A campaign is NOT required: PCs are campaign-agnostic and can be created unassigned.
export function CharactersPage() {
return <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>;
const campaign = useActiveCampaign();
return <CharactersList campaign={campaign} />;
}
function CharactersList({ campaign }: { campaign: Campaign }) {
function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
// PCs are campaign-agnostic — show every player character; NPCs stay per-campaign.
const pcs = useAllPcs();
const npcs = useCharacters(campaign.id).filter((c) => c.kind === 'npc');
const npcs = useCharacters(campaign?.id ?? '').filter((c) => c.kind === 'npc');
const [creating, setCreating] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
@@ -29,7 +32,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
const text = await pickTextFile();
if (text === null) return;
try {
const character = parseCharacterImport(text, campaign.id);
const character = parseCharacterImport(text, campaign?.id ?? '');
await charactersRepo.insert(character);
} catch (e) {
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
@@ -41,7 +44,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
<PageHeader
eyebrow="The Roster"
title="Characters"
subtitle={campaign.name}
subtitle={campaign?.name ?? 'All player characters'}
actions={
<>
<Button variant="secondary" onClick={importCharacter}>
@@ -78,6 +81,9 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
)}
{creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />}
{npcs.length === 0 && !campaign && pcs.length > 0 && (
<p className="mt-4 text-xs text-muted">Tip: select or create a campaign to add NPCs and link this roster.</p>
)}
</Page>
);
}
@@ -66,11 +66,11 @@ const TEMPLATES: Record<string, { label: string; hint: string; className: string
],
};
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | undefined; onClose: () => void }) {
const navigate = useNavigate();
// Characters are campaign-agnostic: the wizard asks which system to build for,
// defaulting to the campaign's. Changing it resets all dependent selections.
const [system, setSystem] = useState<SystemId>(campaign.system);
const [system, setSystem] = useState<SystemId>(campaign?.system ?? '5e');
const sys = getSystem(system);
const allSkillKeys = sys.skills.map((s) => s.key);
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
@@ -165,6 +165,9 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [name, setName] = useState('');
const [kind, setKind] = useState<Character['kind']>('pc');
const [level, setLevel] = useState(1);
const [alignment, setAlignment] = useState('');
const [appearance, setAppearance] = useState('');
const [personality, setPersonality] = useState('');
const [classSlug, setClassSlug] = useState('');
const [subclass, setSubclass] = useState('');
const [ancestry, setAncestry] = useState('');
@@ -303,7 +306,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
const suggestedSpells = Math.max(4, leveledSlots + 2);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Details', 'Review'], [isCaster]);
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
const changeSystem = (next: SystemId) => {
@@ -339,12 +342,13 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
Abilities: system === 'pf2e' ? true : poolValid && pbValid,
Skills: skills.length === Math.min(skillCount, skillOptions.length),
Spells: true,
Details: true,
Review: true,
};
const finish = async () => {
if (!selectedClass) return;
const created = await charactersRepo.create(campaign.id, {
const created = await charactersRepo.create(campaign?.id, {
system, name: name.trim() || selectedClass.name, kind,
ancestry: ancestry.trim(), className: selectedClass.name, level,
});
@@ -354,11 +358,16 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
// Subclass still rides in notes for now (Phase 3 moves it onto classes[]); background is a real field.
const notes = subclass ? `Subclass: ${subclass}` : '';
await charactersRepo.update(created.id, {
...built,
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
spellcasting: { ...built.spellcasting, spells },
...(background ? { background } : {}),
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
...(personality.trim() ? { personality: personality.trim() } : {}),
...(notes ? { notes } : {}),
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
});
@@ -596,8 +605,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
) : (
<NumberField className="mt-1" value={pb[i]!} min={fMin} max={fMax} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(fMin, Math.min(fMax, v)) : x)))} />
)}
<div className="mt-1 text-xs text-accent">
{racial ? <span className="text-muted">{base}+{racial} · </span> : null}{formatModifier(abilityModifier(value))}
<div className="mt-1">
<span className="font-display text-sm font-semibold text-ink">{value}</span>
<span className="ml-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</span>
{racial ? <div className="text-[10px] leading-tight text-muted">{base} base · +{racial} race</div> : null}
</div>
</div>
);
@@ -659,6 +670,32 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div>
)}
{stepName === 'Details' && (
<div className="space-y-3">
<p className="text-sm text-muted">Optional flavour you can fill any of this in later on the sheet.</p>
<Field label="Alignment">
<Select value={alignment} onChange={(e) => setAlignment(e.target.value)}>
<option value=""> none </option>
{['Lawful Good', 'Neutral Good', 'Chaotic Good', 'Lawful Neutral', 'True Neutral', 'Chaotic Neutral', 'Lawful Evil', 'Neutral Evil', 'Chaotic Evil', 'Unaligned'].map((a) => (
<option key={a} value={a}>{a}</option>
))}
</Select>
</Field>
<label className="block text-xs text-muted">
Appearance
<textarea value={appearance} onChange={(e) => setAppearance(e.target.value)} rows={3}
placeholder="Looks, age, distinguishing features…"
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</label>
<label className="block text-xs text-muted">
Personality &amp; behaviour
<textarea value={personality} onChange={(e) => setPersonality(e.target.value)} rows={3}
placeholder="Traits, ideals, bonds, flaws, how they act…"
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</label>
</div>
)}
{stepName === 'Review' && selectedClass && (
<div className="space-y-3 text-sm">
<div className="rounded-lg border border-line bg-surface p-3">
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common';
import { rankLabel } from './labels';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
@@ -65,7 +66,7 @@ export function AttacksSection({ c, update }: SectionProps) {
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
</Select>
<Select className="w-auto py-1" value={a.rank} onChange={(e) => patch(a.id, { rank: e.target.value as ProficiencyRank })} aria-label="Attack proficiency">
{ranks.map((r) => <option key={r} value={r}>{r}</option>)}
{ranks.map((r) => <option key={r} value={r}>{rankLabel(r, c.system)}</option>)}
</Select>
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
+14
View File
@@ -0,0 +1,14 @@
import type { ProficiencyRank, SystemId } from '@/lib/rules';
const RANK_LABEL_PF2E: Record<ProficiencyRank, string> = {
untrained: 'Untrained', trained: 'Trained', expert: 'Expert', master: 'Master', legendary: 'Legendary',
};
// 5e proficiency is binary; "Expertise" = doubled proficiency (stored as 'expert').
const RANK_LABEL_5E: Record<ProficiencyRank, string> = {
untrained: 'Not proficient', trained: 'Proficient', expert: 'Expertise', master: 'Expertise', legendary: 'Expertise',
};
/** Display label for a proficiency rank in the active system (5e avoids PF2e rank names). */
export function rankLabel(rank: ProficiencyRank, system: SystemId): string {
return system === '5e' ? RANK_LABEL_5E[rank] : RANK_LABEL_PF2E[rank];
}