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