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
+182 -41
View File
@@ -1,12 +1,15 @@
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import type { Campaign } from '@/lib/schemas';
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react';
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
import { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
import { cn } from '@/lib/cn';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useNotes, useNpcs, useQuests, useCalendar } from './hooks';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Page, RequireCampaign } from '@/components/ui/Page';
import { Meter, Badge, Avatar } from '@/components/ui/Codex';
export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
@@ -42,12 +45,24 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
return (
<Page>
<PageHeader
title={campaign.name}
subtitle={`${sys.label}${calendar ? ` · in-world day ${calendar.currentDay}` : ''}`}
/>
{campaign.description && <p className="mb-6 max-w-3xl text-sm text-muted">{campaign.description}</p>}
{/* Editorial hero */}
<section className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
<div className="px-7 py-7 sm:px-9">
<div className="font-display text-sm italic text-accent">
{sys.label}
{calendar ? ` · in-world day ${calendar.currentDay}` : ''}
</div>
<h1 className="mt-1 font-display text-4xl font-semibold leading-[1.04] tracking-tight text-ink">
{campaign.name}
</h1>
{campaign.description && (
<p className="mt-2.5 max-w-2xl font-display text-base italic text-muted">
{campaign.description}
</p>
)}
</div>
<hr className="gilt-rule" />
</section>
{/* Quick stats */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@@ -61,13 +76,13 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Navigation cards */}
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Jump to</h2>
<h2 className="smallcaps mb-3">Jump to</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{LINKS.map((l) => (
<Link
key={l.to}
to={l.to}
className="flex flex-col items-center gap-1 rounded-lg border border-line bg-panel p-4 text-center transition-colors hover:border-accent/50"
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
>
<span className="text-2xl" aria-hidden>{l.icon}</span>
<span className="text-sm text-ink">{l.label}</span>
@@ -76,54 +91,180 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
</div>
{/* Party overview */}
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Party</h2>
<div className="mb-3 mt-7 flex items-center justify-between">
<h2 className="smallcaps">Party</h2>
<Link to="/characters" className="flex items-center gap-1 text-xs text-muted hover:text-accent-deep">
Manage <ChevronRight size={14} aria-hidden />
</Link>
</div>
{pcs.length === 0 ? (
<p className="text-sm text-muted">No player characters yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{pcs.map((c) => (
<Link
key={c.id}
to="/characters/$characterId"
params={{ characterId: c.id }}
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-2 text-sm hover:border-accent/40"
>
<span className="truncate text-ink">{c.name} <span className="text-muted">Lv {c.level}</span></span>
<span className="shrink-0 text-muted">
HP {c.hp.current}/{c.hp.max} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
</span>
</Link>
<PartyRow key={c.id} c={c} sys={sys} />
))}
</div>
)}
{/* Cast & threats */}
{npcs.length > 0 && (
<>
<h2 className="smallcaps mb-3 mt-7">Cast &amp; Threats</h2>
<div className="grid gap-2 sm:grid-cols-2">
{npcs.slice(0, 6).map((n) => (
<ThreatRow key={n.id} n={n} />
))}
</div>
</>
)}
</section>
{/* Recent rolls */}
<aside>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2>
{rolls.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p>
) : (
<ul className="space-y-1">
{rolls.map((r) => (
<li key={r.id} className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<span className="truncate text-muted">{r.label ? `${r.label}: ` : ''}<span className="font-mono">{r.expression}</span></span>
<span className="font-display font-semibold text-ink">{r.total}</span>
</li>
))}
</ul>
)}
{/* Side column: quests + recent rolls */}
<aside className="space-y-6">
<div>
<h2 className="smallcaps mb-3">Quest Log</h2>
{quests.length === 0 ? (
<p className="text-sm text-muted">No quests yet.</p>
) : (
<div className="paper-grain space-y-3 rounded-xl border border-line bg-panel p-4">
{quests.slice(0, 6).map((q) => (
<QuestRow key={q.id} q={q} />
))}
</div>
)}
</div>
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="smallcaps">Recent rolls</h2>
<Link to="/dice" className="flex items-center gap-1 text-xs text-muted hover:text-accent-deep">
Open tray <ChevronRight size={14} aria-hidden />
</Link>
</div>
{rolls.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p>
) : (
<ul className="space-y-1.5">
{rolls.map((r) => (
<RollRow key={r.id} r={r} />
))}
</ul>
)}
</div>
</aside>
</div>
</Page>
);
}
function Stat({ label, value }: { label: string; value: number }) {
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="font-display text-3xl font-bold text-accent">{value}</div>
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
<Link
to="/characters/$characterId"
params={{ characterId: c.id }}
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
>
<Avatar name={c.name} size={42} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-display text-sm font-semibold text-ink">{c.name}</span>
<span className="shrink-0 font-mono text-xs text-faint">{c.hp.current}/{c.hp.max}</span>
</div>
<div className="mt-0.5 truncate text-xs text-faint">
Lv {c.level} {[c.ancestry, c.className].filter(Boolean).join(' ')}
</div>
<div className="mt-1.5">
<Meter value={c.hp.current} max={c.hp.max} height={5} {...(tone ? { tone } : {})} />
</div>
</div>
<div className="flex w-9 shrink-0 flex-col items-center">
<Shield size={15} className="text-muted" aria-hidden />
<span className="font-mono text-sm font-semibold text-ink">{ac}</span>
</div>
</Link>
);
}
const NPC_DISPOSITION: Record<Npc['status'], { tone: 'ember' | 'verdigris' | 'default'; label: string; Icon: typeof Skull }> = {
dead: { tone: 'ember', label: 'Hostile', Icon: Skull },
alive: { tone: 'verdigris', label: 'Friendly', Icon: Handshake },
unknown: { tone: 'default', label: 'Unknown', Icon: VenetianMask },
};
function ThreatRow({ n }: { n: Npc }) {
const d = NPC_DISPOSITION[n.status];
const Icon = d.Icon;
return (
<Link
to="/npcs"
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-line bg-surface-2 text-muted">
<Icon size={16} aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-display text-sm font-semibold text-ink">{n.name}</div>
<div className="truncate text-xs text-faint">
{[n.role, n.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge tone={d.tone}>{d.label}</Badge>
</Link>
);
}
function QuestRow({ q }: { q: Quest }) {
const done = q.status === 'completed';
return (
<div className={cn(done && 'opacity-55')}>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
{done ? (
<CheckCheck size={16} className="shrink-0 text-accent-deep" aria-hidden />
) : (
<ScrollText size={16} className="shrink-0 text-muted" aria-hidden />
)}
<span className={cn('truncate font-display text-sm font-semibold text-ink', done && 'line-through')}>
{q.title}
</span>
</span>
{q.objectives.length > 0 && (
<span className="flex shrink-0 items-center gap-1">
{q.objectives.slice(0, 6).map((o) => (
<span
key={o.id}
className={cn('size-[7px] rounded-full', o.done ? 'bg-accent' : 'bg-line-strong')}
/>
))}
</span>
)}
</div>
</div>
);
}
function RollRow({ r }: { r: DiceRoll }) {
return (
<li className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2 text-sm">
<span className="h-6 w-[3px] shrink-0 rounded-sm bg-accent" aria-hidden />
<span className="min-w-0 flex-1 truncate text-muted">
{r.label ? `${r.label}: ` : ''}
<span className="font-mono">{r.expression}</span>
</span>
<span className="font-display font-semibold text-ink">{r.total}</span>
</li>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="font-mono text-3xl font-bold text-accent-deep">{value}</div>
<div className="smallcaps mt-1" style={{ fontSize: 10 }}>{label}</div>
</div>
);
}