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
+110 -46
View File
@@ -1,5 +1,17 @@
import { useState } from 'react';
import { useState, type ComponentType, type ReactNode } from 'react';
import { useNavigate } from '@tanstack/react-router';
import {
Palette,
Sun,
Moon,
Download,
Upload,
Sparkles,
Cloud,
ScrollText,
TriangleAlert,
type LucideProps,
} from 'lucide-react';
import { useUiStore, type Theme } from '@/stores/uiStore';
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
import { pickTextFile } from '@/lib/io/file';
@@ -13,6 +25,43 @@ import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
/** A Living-Codex setting card: section eyebrow + a gilt hairline over its rows. */
function SettingsCard({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<section className={cn('rounded-xl border border-line bg-panel p-4 sm:p-5', className)}>
<h2 className="smallcaps text-[11px] text-muted">{label}</h2>
<hr className="gilt-rule mt-2 mb-1" />
<div>{children}</div>
</section>
);
}
/** An icon-tile row: leading rounded tile, title + description, trailing controls. */
function SettingsRow({
icon: Icon,
title,
desc,
children,
}: {
icon: ComponentType<LucideProps>;
title: ReactNode;
desc?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b border-line py-4 last:border-b-0">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-deep" aria-hidden>
<Icon size={18} />
</span>
<div className="min-w-0 flex-1">
<div className="font-display text-[15px] font-semibold text-ink">{title}</div>
{desc && <div className="mt-0.5 text-xs text-faint">{desc}</div>}
</div>
{children && <div className="flex shrink-0 flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
export function SettingsPage() {
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
@@ -44,50 +93,61 @@ export function SettingsPage() {
return (
<Page>
<PageHeader title="Settings" subtitle="Appearance, backups, and data." />
<PageHeader eyebrow="Preferences" title="Settings" subtitle="Appearance, backups, and data." />
<section className="mb-8">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</h2>
<div className="flex gap-2">
{(['dark', 'light'] as Theme[]).map((t) => (
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} onClick={() => setTheme(t)} className="capitalize">
{t}
</Button>
))}
</div>
</section>
<div className="space-y-6">
<SettingsCard label="Appearance">
<SettingsRow
icon={theme === 'dark' ? Moon : Sun}
title="Theme"
desc="Parchment by day, candlelight by night."
>
{(['dark', 'light'] as Theme[]).map((t) => (
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} size="sm" onClick={() => setTheme(t)} className="capitalize">
{t}
</Button>
))}
</SettingsRow>
</SettingsCard>
<section className="mb-8">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data</h2>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => void exportBackup()}>Export backup</Button>
<Button variant="secondary" onClick={doImport}>Import backup</Button>
<Button variant="secondary" onClick={loadSample}>Load sample campaign</Button>
</div>
<p className="mt-2 text-xs text-muted">Backups include every campaign and all of its data, as a single JSON file.</p>
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
</section>
<SettingsCard label="Data">
<SettingsRow icon={Download} title="Export backup" desc="Download a single JSON file with every campaign and all of its data.">
<Button variant="secondary" size="sm" onClick={() => void exportBackup()}>Export backup</Button>
</SettingsRow>
<SettingsRow icon={Upload} title="Import backup" desc="Restore from a previously exported file. Replaces current data.">
<Button variant="secondary" size="sm" onClick={doImport}>Import backup</Button>
</SettingsRow>
<SettingsRow icon={Sparkles} title="Sample campaign" desc="Load a ready-made campaign to explore the app.">
<Button variant="secondary" size="sm" onClick={loadSample}>Load sample campaign</Button>
</SettingsRow>
{msg && <p className={cn('mt-3 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
</SettingsCard>
<AssistantSettings />
<AssistantSettings />
<CloudSync />
<CloudSync />
<CloudCampaigns />
<CloudCampaigns />
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2>
<ul className="space-y-1 text-sm text-muted">
<li><span className="text-ink">D&amp;D 5e</span> SRD content via <a className="text-accent hover:underline" href="https://open5e.com" target="_blank" rel="noreferrer">Open5e</a> (OGL 1.0a / CC-BY-4.0).</li>
<li><span className="text-ink">Pathfinder 2e</span> content from the <a className="text-accent hover:underline" href="https://github.com/foundryvtt/pf2e" target="_blank" rel="noreferrer">Foundry VTT pf2e</a> project &amp; Archives of Nethys (OGL / ORC / Paizo Community Use).</li>
<li className="text-xs">Map import follows the Universal VTT (.dd2vtt) format used by Dungeondraft, Foundry &amp; others.</li>
</ul>
</section>
<SettingsCard label="Data sources & licenses">
<SettingsRow icon={ScrollText} title="Attribution" desc="Open-licensed game content powering the compendium.">
<Palette className="text-faint" size={18} aria-hidden />
</SettingsRow>
<ul className="space-y-1 pt-3 text-sm text-muted">
<li><span className="text-ink">D&amp;D 5e</span> SRD content via <a className="text-accent hover:underline" href="https://open5e.com" target="_blank" rel="noreferrer">Open5e</a> (OGL 1.0a / CC-BY-4.0).</li>
<li><span className="text-ink">Pathfinder 2e</span> content from the <a className="text-accent hover:underline" href="https://github.com/foundryvtt/pf2e" target="_blank" rel="noreferrer">Foundry VTT pf2e</a> project &amp; Archives of Nethys (OGL / ORC / Paizo Community Use).</li>
<li className="text-xs">Map import follows the Universal VTT (.dd2vtt) format used by Dungeondraft, Foundry &amp; others.</li>
</ul>
</SettingsCard>
<section className="rounded-lg border border-danger/40 bg-danger/5 p-4">
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2>
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
<Button variant="danger" onClick={() => setConfirmClear(true)}>Clear all data</Button>
</section>
<section className="rounded-xl border border-danger/40 bg-danger/5 p-4 sm:p-5">
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-danger">
<TriangleAlert size={15} aria-hidden /> Danger zone
</h2>
<p className="mb-3 mt-1 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
<Button variant="danger" onClick={() => setConfirmClear(true)}>Clear all data</Button>
</section>
</div>
<Modal
open={!!confirmRestore}
@@ -141,12 +201,16 @@ function CloudSync() {
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
return (
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Cloud sync (optional)</h2>
<p className="mb-3 text-sm text-muted">Sign in to back up your campaigns and sync them across devices. Local-first stays the default nothing leaves this device until you push.</p>
<SettingsCard label="Cloud sync (optional)">
<SettingsRow
icon={Cloud}
title="Cloud sync"
desc="Sign in to back up your campaigns and sync them across devices. Local-first stays the default — nothing leaves this device until you push."
>
{user && <span className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></span>}
</SettingsRow>
{user ? (
<div className="space-y-2">
<p className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></p>
<div className="space-y-3 pt-4">
<div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button>
@@ -154,19 +218,19 @@ function CloudSync() {
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 pt-4">
<Input className="max-w-40" placeholder="Username" value={u} onChange={(e) => setU(e.target.value)} aria-label="Cloud username" />
<Input className="max-w-40" type="password" placeholder="Password" value={p} onChange={(e) => setP(e.target.value)} aria-label="Cloud password" />
<Button variant="primary" disabled={busy || !u || !p} onClick={() => auth('in')}>Sign in</Button>
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
</div>
)}
{msg && <p className="mt-2 text-sm text-accent">{msg}</p>}
{msg && <p className="mt-3 text-sm text-accent">{msg}</p>}
<Modal open={confirmPull} onClose={() => setConfirmPull(false)} title="Restore from cloud?"
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
</Modal>
</section>
</SettingsCard>
);
}