UI overhaul "Living Codex" E: per-screen layouts

Ported all 9 screens to the prototype layout (visual only; all data wiring, text,
aria-labels, and testids preserved) via parallel agents + central verification:
- Dashboard: editorial hero, party roster w/ HP meters + avatars, quests, threats, rolls.
- Campaigns: cover-art deck cards + gilt rule + footer.
- Character sheet + roster: hero header, StatCoin abilities, prof rows; grimoire cards.
- Combat: glowing initiative rows (PC gilt / foe ember), condition badges, log.
- Dice: die-pool glyphs, adv/dis segmented toggle, crit/fumble stage, history.
- Compendium: Spectral stat blocks (ember headers, ability row).
- Settings: grouped icon-tile cards. Live Session: room-code card, seat grid, player board.
- Battle Map: carded list + tooled editor chrome (canvas untouched).

Regression fixes after the ports: dice die-buttons aria-label `d4` not `Roll 1d4`
(was colliding with the primary Roll button); map "Open player view" link uses an
ExternalLink icon (test updated, ↗ glyph removed); seat-claim card gets a stable
data-testid="seat-option" (redesign moved rounded-lg→rounded-xl).

223 unit + 34 e2e + 2 realtime green; tsc + eslint + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:37:00 +02:00
parent e4b399eaa8
commit 99c7657f96
19 changed files with 1070 additions and 457 deletions
+73 -37
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
@@ -9,8 +10,10 @@ import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll';
import { cn } from '@/lib/cn';
import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
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';
@@ -88,38 +91,61 @@ export function CharacterSheet({ character }: { character: Character }) {
return (
<Page>
<div className="mb-4">
<Link to="/characters" className="text-sm text-muted hover:text-ink">
Back to characters
<Link to="/characters" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft className="h-4 w-4" aria-hidden />
Back to characters
</Link>
</div>
{/* Header */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<label className="group relative h-14 w-14 shrink-0 cursor-pointer overflow-hidden rounded-full border border-line bg-surface" title="Upload portrait (also used as the map token)">
{c.portrait
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
</label>
<Input
className="max-w-xs font-display text-xl font-bold"
value={c.name}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
/>
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-sm text-muted">{profLabel}</span>
{/* Header hero */}
<div className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
<div className="flex flex-wrap items-center gap-x-5 gap-y-4 p-5">
<label className="group relative h-20 w-20 shrink-0 cursor-pointer overflow-hidden rounded-2xl border border-line bg-surface-2" title="Upload portrait (also used as the map token)">
{c.portrait
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
</label>
<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"
value={c.name}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge tone="gold">Level {c.level}</Badge>
<Badge>{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}</Badge>
<Badge tone="default">
<Award className="h-3 w-3" aria-hidden />
{profLabel}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
{[
{ 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">
<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>
</div>
))}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div>
</div>
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="paper-grain mb-6 grid gap-4 rounded-xl border border-line bg-panel p-4 sm:grid-cols-2 lg:grid-cols-4">
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
@@ -150,7 +176,7 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Abilities */}
<section className="mb-6">
<div className="mb-2 flex items-center justify-between">
<div className="mb-3 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
</div>
@@ -158,10 +184,11 @@ export function CharacterSheet({ character }: { character: Character }) {
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
return (
<div key={a} className="rounded-lg border border-line bg-panel p-3 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
<div className="my-1 font-display text-2xl font-bold text-accent">{formatModifier(mod)}</div>
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center">
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
<NumberField
className="mt-1"
value={c.abilities[a]}
min={1}
max={30}
@@ -270,14 +297,18 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
}
setDelta(0);
};
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Hit Points</div>
<div className="my-1 font-display text-2xl font-bold text-ink">
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">Hit Points</div>
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span>
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div>
<div className="mb-2">
<Meter value={c.hp.current} max={c.hp.max} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
</div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
@@ -311,11 +342,16 @@ function RankRow({
onRank: (r: ProficiencyRank) => void;
system: SystemId;
}) {
const proficient = rank !== 'untrained';
return (
<div className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5">
<div className="flex items-center gap-2 rounded-lg border border-line bg-panel px-3 py-1.5">
<span
className={cn('h-2 w-2 shrink-0 rounded-full', proficient ? 'bg-accent' : 'bg-line-strong')}
aria-hidden
/>
<button
onClick={() => rollCheck(modifier, label, { system })}
className="w-9 rounded font-display text-lg font-semibold text-accent hover:bg-accent/10"
className="w-9 rounded font-mono font-display text-lg font-semibold text-accent-deep hover:bg-accent/10"
title={`Roll ${label}`}
>
{formatModifier(modifier)}
@@ -335,7 +371,7 @@ function RankRow({
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted">{label}</span>
<span className="smallcaps mb-1 block">{label}</span>
{children}
</label>
);
@@ -343,9 +379,9 @@ function Labeled({ label, children }: { label: string; children: React.ReactNode
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
<div className="my-1 font-display text-3xl font-bold text-accent">{value}</div>
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">{label}</div>
<div className="my-1 font-mono font-display text-3xl font-semibold text-accent-deep">{value}</div>
{hint && <div className="text-xs text-muted">{hint}</div>}
{children}
</div>
@@ -353,5 +389,5 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-2 font-display text-lg font-semibold text-ink">{children}</h2>;
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
}
+33 -13
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { Heart, Shield } from 'lucide-react';
import { charactersRepo } from '@/lib/db/repositories';
import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
@@ -8,6 +9,7 @@ import { pickTextFile } from '@/lib/io/file';
import { useCharacters } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } 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';
@@ -37,6 +39,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
return (
<Page>
<PageHeader
eyebrow="The Roster"
title="Characters"
subtitle={campaign.name}
actions={
@@ -83,7 +86,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
if (items.length === 0) return null;
return (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
<h2 className="smallcaps mb-3">{title}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((c) => (
<CharacterCard key={c.id} c={c} />
@@ -98,25 +101,42 @@ function CharacterCard({ c }: { c: Character }) {
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
return (
<div className="flex flex-col rounded-lg border border-line bg-panel transition-colors hover:border-accent/50">
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
<Link
to="/characters/$characterId"
params={{ characterId: c.id }}
className="block p-4"
>
<div className="flex items-baseline justify-between">
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
<span className="text-sm text-muted">Lv {c.level}</span>
<div className="flex items-start gap-3">
{c.portrait ? (
<img
src={c.portrait}
alt=""
className="h-12 w-12 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<Avatar name={c.name} size={48} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate font-display text-lg font-semibold text-ink">{c.name}</h3>
<Badge tone="gold">Lv {c.level}</Badge>
</div>
<p className="mt-0.5 truncate text-sm text-muted">
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
</p>
</div>
</div>
<p className="mt-0.5 text-sm text-muted">
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
</p>
<div className="mt-3 flex gap-4 text-sm">
<span className="text-muted">
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
<div className="mt-3 grid grid-cols-2 gap-2">
<span className="flex items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-2 py-1.5 text-sm">
<Heart className="h-3.5 w-3.5 text-danger" aria-hidden />
<span className="smallcaps text-[10px]">HP</span>
<span className="font-mono font-semibold text-ink">{c.hp.current}/{c.hp.max}</span>
</span>
<span className="text-muted">
AC <span className="font-medium text-ink">{ac}</span>
<span className="flex items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-2 py-1.5 text-sm">
<Shield className="h-3.5 w-3.5 text-accent-deep" aria-hidden />
<span className="smallcaps text-[10px]">AC</span>
<span className="font-mono font-semibold text-ink">{ac}</span>
</span>
</div>
</Link>