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
+1 -1
View File
@@ -27,7 +27,7 @@ test('a player claims a seat and manages their character; edits round-trip to th
const player = await playerCtx.newPage(); const player = await playerCtx.newPage();
await player.goto(`/play?room=${code}`); await player.goto(`/play?room=${code}`);
await expect(player.getByRole('heading', { name: 'Which character is yours?' })).toBeVisible(); await expect(player.getByRole('heading', { name: 'Which character is yours?' })).toBeVisible();
await player.locator('div.rounded-lg', { hasText: 'Lia the Brave' }).getByRole('button', { name: 'This is me' }).click(); await player.getByTestId('seat-option').filter({ hasText: 'Lia the Brave' }).getByRole('button', { name: 'This is me' }).click();
// GM: approve the seat request. // GM: approve the seat request.
await gm.getByTestId('seat-requests').click(); await gm.getByTestId('seat-requests').click();
+1 -1
View File
@@ -33,7 +33,7 @@ test('maps: upload, place token, reveal all, show to players', async ({ page })
// Show to players, then the player view renders the battle map // Show to players, then the player view renders the battle map
await page.getByRole('button', { name: 'Show to players' }).click(); await page.getByRole('button', { name: 'Show to players' }).click();
await page.getByRole('link', { name: 'Open player view' }).click(); await page.getByRole('link', { name: 'Open player view' }).click();
await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Battle map' })).toBeVisible();
await expect(page.getByTestId('map-token')).toHaveCount(1); await expect(page.getByTestId('map-token')).toHaveCount(1);
}); });
+64 -13
View File
@@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react';
import { cn } from '@/lib/cn';
import { campaignsRepo } from '@/lib/db/repositories'; import { campaignsRepo } from '@/lib/db/repositories';
import { seedSampleCampaign } from '@/lib/sample'; import { seedSampleCampaign } from '@/lib/sample';
import { SYSTEM_OPTIONS } from '@/lib/rules'; import { SYSTEM_OPTIONS } from '@/lib/rules';
@@ -8,6 +10,7 @@ import { useCampaigns } from './hooks';
import type { Campaign } from '@/lib/schemas'; import type { Campaign } from '@/lib/schemas';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Field, Input, Select, Textarea } from '@/components/ui/Input'; import { Field, Input, Select, Textarea } from '@/components/ui/Input';
@@ -19,6 +22,7 @@ export function CampaignsPage() {
return ( return (
<Page> <Page>
<PageHeader <PageHeader
eyebrow="Your tables"
title="Campaigns" title="Campaigns"
subtitle="Each campaign holds its own characters, encounters, and dice history." subtitle="Each campaign holds its own characters, encounters, and dice history."
actions={ actions={
@@ -55,31 +59,35 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
void navigate({ to: '/dashboard' }); void navigate({ to: '/dashboard' });
}; };
return ( return (
<div className="mx-auto max-w-2xl rounded-xl border border-line bg-panel p-6 text-center"> <div className="paper-grain relative mx-auto max-w-2xl overflow-hidden rounded-xl border border-line bg-panel p-7 text-center">
<h2 className="font-display text-2xl font-bold text-accent">Welcome to TTRPG Manager</h2> <div className="relative z-[1]">
<div className="font-display text-sm italic text-accent">A local-first companion</div>
<h2 className="mt-1 font-display text-2xl font-bold text-accent-deep">Welcome to TTRPG Manager</h2>
<p className="mx-auto mt-2 max-w-xl text-sm text-muted"> <p className="mx-auto mt-2 max-w-xl text-sm text-muted">
A local-first companion for D&amp;D 5e &amp; Pathfinder 2e guided character building, combat A local-first companion for D&amp;D 5e &amp; Pathfinder 2e guided character building, combat
tracking, a full battle-map VTT with fog &amp; dynamic vision, a searchable compendium, and live tracking, a full battle-map VTT with fog &amp; dynamic vision, a searchable compendium, and live
play your players join from their own devices. Everything is stored on this device. play your players join from their own devices. Everything is stored on this device.
</p> </p>
<div className="mt-4 grid gap-2 text-left sm:grid-cols-3"> <hr className="gilt-rule mx-auto mt-5 max-w-xs" />
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." /> <FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." /> <FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." /> <FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." />
</div> </div>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2"> <div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button> <Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
<Button variant="secondary" disabled={loading} onClick={() => void loadSample()}>{loading ? 'Loading…' : 'Try the sample campaign'}</Button> <Button variant="secondary" disabled={loading} onClick={() => void loadSample()}>{loading ? 'Loading…' : 'Try the sample campaign'}</Button>
</div> </div>
</div> </div>
</div>
); );
} }
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) { function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
return ( return (
<div className="rounded-lg border border-line bg-surface p-3"> <div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
<div className="text-xl">{icon}</div> <div className="text-xl" aria-hidden>{icon}</div>
<div className="mt-1 font-semibold text-ink">{title}</div> <div className="mt-1 font-display font-semibold text-ink">{title}</div>
<div className="text-xs text-muted">{desc}</div> <div className="text-xs text-muted">{desc}</div>
</div> </div>
); );
@@ -96,20 +104,62 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
void navigate({ to: '/dashboard' }); void navigate({ to: '/dashboard' });
}; };
const isActive = activeId === campaign.id;
const systemLabel = campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e';
const created = new Date(campaign.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return ( return (
<div className="group relative flex flex-col rounded-lg border border-line bg-panel p-4"> <div
{activeId === campaign.id && ( className={cn(
<span className="absolute right-3 top-3 rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent"> 'paper-grain group relative flex flex-col overflow-hidden rounded-xl border bg-panel transition-colors',
isActive ? 'border-accent' : 'border-line hover:border-line-strong',
)}
>
{/* Cover band */}
<div className="relative h-24 border-b border-line bg-gradient-to-br from-accent-glow via-panel-2 to-surface-2">
<BookOpenText
aria-hidden
className="absolute right-3 top-3 h-12 w-12 text-accent/25"
strokeWidth={1.25}
/>
<div className="absolute left-4 top-3">
<Badge tone="gold">{systemLabel}</Badge>
</div>
{isActive && (
<span className="absolute right-3 bottom-3 rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
Active Active
</span> </span>
)} )}
<div className="text-xs font-medium uppercase tracking-wide text-muted">
{campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e'}
</div> </div>
<h3 className="mt-1 font-display text-lg font-semibold text-ink">{campaign.name}</h3>
<div className="relative z-[1] flex flex-1 flex-col p-4">
<h3 className="font-display text-lg font-semibold tracking-tight text-ink">{campaign.name}</h3>
{campaign.description && ( {campaign.description && (
<p className="mt-1 line-clamp-3 text-sm text-muted">{campaign.description}</p> <p className="mt-1 line-clamp-3 text-sm text-muted">{campaign.description}</p>
)} )}
<hr className="gilt-rule my-4" />
{/* Stats footer */}
<div className="flex items-center gap-4 text-xs text-faint">
<span className="inline-flex items-center gap-1.5">
<ScrollText aria-hidden className="h-4 w-4" />
{systemLabel}
</span>
<span className="inline-flex items-center gap-1.5">
<CalendarDays aria-hidden className="h-4 w-4" />
<span className="font-mono">{created}</span>
</span>
<ArrowRight
aria-hidden
className="ml-auto h-4 w-4 text-accent-deep transition-transform group-hover:translate-x-0.5"
/>
</div>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<Button size="sm" variant="primary" onClick={open}> <Button size="sm" variant="primary" onClick={open}>
Open Open
@@ -126,6 +176,7 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
Delete Delete
</Button> </Button>
</div> </div>
</div>
<Modal <Modal
open={confirming} open={confirming}
+62 -26
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas'; import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } 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 { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll'; import { rollCheck } from '@/lib/useRoll';
import { cn } from '@/lib/cn';
import { Page } from '@/components/ui/Page'; import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format'; import { formatModifier } from '@/lib/format';
@@ -88,38 +91,61 @@ export function CharacterSheet({ character }: { character: Character }) {
return ( return (
<Page> <Page>
<div className="mb-4"> <div className="mb-4">
<Link to="/characters" className="text-sm text-muted hover:text-ink"> <Link to="/characters" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
Back to characters <ArrowLeft className="h-4 w-4" aria-hidden />
Back to characters
</Link> </Link>
</div> </div>
{/* Header */} {/* Header hero */}
<div className="mb-6 flex flex-wrap items-center gap-3"> <div className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
<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)"> <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 {c.portrait
? <img src={c.portrait} alt="" className="h-full w-full object-cover" /> ? <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="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> <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 = ''; }} /> <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> </label>
<div className="min-w-0 flex-1">
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
<Input <Input
className="max-w-xs font-display text-xl font-bold" 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} value={c.name}
aria-label="Character name" aria-label="Character name"
onChange={(e) => update({ name: e.target.value })} 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"> <div className="mt-2 flex flex-wrap items-center gap-2">
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label} <Badge tone="gold">Level {c.level}</Badge>
</span> <Badge>{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}</Badge>
<div className="ml-auto flex items-center gap-2"> <Badge tone="default">
<span className="text-sm text-muted">{profLabel}</span> <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>} {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="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button> <Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div> </div>
</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'}> <Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} /> <Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled> </Labeled>
@@ -150,7 +176,7 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Abilities */} {/* Abilities */}
<section className="mb-6"> <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> <SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button> <Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
</div> </div>
@@ -158,10 +184,11 @@ export function CharacterSheet({ character }: { character: Character }) {
{ABILITIES.map((a) => { {ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]); const mod = sys.abilityModifier(c.abilities[a]);
return ( return (
<div key={a} className="rounded-lg border border-line bg-panel p-3 text-center"> <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="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div> <div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
<div className="my-1 font-display text-2xl font-bold text-accent">{formatModifier(mod)}</div> <div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
<NumberField <NumberField
className="mt-1"
value={c.abilities[a]} value={c.abilities[a]}
min={1} min={1}
max={30} max={30}
@@ -270,14 +297,18 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
} }
setDelta(0); setDelta(0);
}; };
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
return ( return (
<div className="rounded-lg border border-line bg-panel p-4 text-center"> <div className="rounded-xl 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="smallcaps">Hit Points</div>
<div className="my-1 font-display text-2xl font-bold text-ink"> <div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{c.hp.current} {c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span> <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>} {c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div> </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"> <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>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> <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; onRank: (r: ProficiencyRank) => void;
system: SystemId; system: SystemId;
}) { }) {
const proficient = rank !== 'untrained';
return ( 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 <button
onClick={() => rollCheck(modifier, label, { system })} 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}`} title={`Roll ${label}`}
> >
{formatModifier(modifier)} {formatModifier(modifier)}
@@ -335,7 +371,7 @@ function RankRow({
function Labeled({ label, children }: { label: string; children: React.ReactNode }) { function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<label className="block"> <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} {children}
</label> </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 }) { function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
return ( return (
<div className="rounded-lg border border-line bg-panel p-4 text-center"> <div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</div> <div className="smallcaps">{label}</div>
<div className="my-1 font-display text-3xl font-bold text-accent">{value}</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>} {hint && <div className="text-xs text-muted">{hint}</div>}
{children} {children}
</div> </div>
@@ -353,5 +389,5 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri
} }
function SectionTitle({ children }: { children: React.ReactNode }) { 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>;
} }
+31 -11
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Heart, Shield } from 'lucide-react';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import type { Campaign, Character } from '@/lib/schemas'; import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
@@ -8,6 +9,7 @@ import { pickTextFile } from '@/lib/io/file';
import { useCharacters } from './hooks'; import { useCharacters } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Avatar, Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { CreationWizard } from './builder/CreationWizard'; import { CreationWizard } from './builder/CreationWizard';
@@ -37,6 +39,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
return ( return (
<Page> <Page>
<PageHeader <PageHeader
eyebrow="The Roster"
title="Characters" title="Characters"
subtitle={campaign.name} subtitle={campaign.name}
actions={ actions={
@@ -83,7 +86,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
<section> <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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((c) => ( {items.map((c) => (
<CharacterCard key={c.id} c={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 }); const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
return ( 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 <Link
to="/characters/$characterId" to="/characters/$characterId"
params={{ characterId: c.id }} params={{ characterId: c.id }}
className="block p-4" className="block p-4"
> >
<div className="flex items-baseline justify-between"> <div className="flex items-start gap-3">
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3> {c.portrait ? (
<span className="text-sm text-muted">Lv {c.level}</span> <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> </div>
<p className="mt-0.5 text-sm text-muted"> <p className="mt-0.5 truncate text-sm text-muted">
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'} {[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
</p> </p>
<div className="mt-3 flex gap-4 text-sm"> </div>
<span className="text-muted"> </div>
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>
<span className="text-muted"> <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">
AC <span className="font-medium text-ink">{ac}</span> <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> </span>
</div> </div>
</Link> </Link>
+21 -8
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Swords, X } from 'lucide-react';
import type { Campaign } from '@/lib/schemas'; import type { Campaign } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories'; import { encountersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
@@ -6,6 +7,7 @@ import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/P
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { useEncounters, useEncounter } from './hooks'; import { useEncounters, useEncounter } from './hooks';
import { EncounterTracker } from './EncounterTracker'; import { EncounterTracker } from './EncounterTracker';
import { RollFeed } from '@/features/player/RollFeed'; import { RollFeed } from '@/features/player/RollFeed';
@@ -38,6 +40,7 @@ function Combat({ campaign }: { campaign: Campaign }) {
return ( return (
<Page> <Page>
<PageHeader <PageHeader
eyebrow="Roll for initiative"
title="Combat" title="Combat"
subtitle={campaign.name} subtitle={campaign.name}
actions={ actions={
@@ -59,23 +62,33 @@ function Combat({ campaign }: { campaign: Campaign }) {
/> />
) : ( ) : (
<div className="grid gap-6 lg:grid-cols-[240px_1fr]"> <div className="grid gap-6 lg:grid-cols-[240px_1fr]">
<aside className="space-y-1"> <aside className="space-y-1.5">
<div className="smallcaps px-1 pb-1">Encounters</div>
{encounters.map((e) => ( {encounters.map((e) => (
<div <div
key={e.id} key={e.id}
className={ className={cn(
'group flex items-center gap-1 rounded-md border px-2 ' + 'group flex items-center gap-1 rounded-xl border px-2 transition-colors',
(validSelection?.id === e.id ? 'border-accent bg-elevated' : 'border-line') validSelection?.id === e.id
} ? 'border-accent bg-accent-glow'
: 'border-line bg-panel hover:border-line-strong',
)}
> >
<button <button
className="flex-1 py-2 text-left text-sm" className="flex flex-1 items-center gap-2.5 py-2 text-left text-sm"
onClick={() => setActiveEncounter(e.id)} onClick={() => setActiveEncounter(e.id)}
> >
<span className="block truncate text-ink">{e.name}</span> <Swords
size={16}
aria-hidden
className={validSelection?.id === e.id ? 'text-accent-deep' : 'text-faint'}
/>
<span className="min-w-0">
<span className="block truncate font-display font-medium text-ink">{e.name}</span>
<span className="text-xs text-muted"> <span className="text-xs text-muted">
{STATUS_LABEL[e.status]} · {e.combatants.length} combatants {STATUS_LABEL[e.status]} · {e.combatants.length} combatants
</span> </span>
</span>
</button> </button>
<button <button
className="px-1 text-muted opacity-0 transition-opacity hover:text-danger group-hover:opacity-100" className="px-1 text-muted opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
@@ -85,7 +98,7 @@ function Combat({ campaign }: { campaign: Campaign }) {
if (activeEncounterId === e.id) setActiveEncounter(null); if (activeEncounterId === e.id) setActiveEncounter(null);
}} }}
> >
<X size={15} aria-hidden />
</button> </button>
</div> </div>
))} ))}
+123 -43
View File
@@ -1,4 +1,20 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import {
ArrowDown,
ArrowUp,
ChevronLeft,
ChevronRight,
Dices,
Heart,
Hourglass,
Play,
ScrollText,
Shield,
Skull,
Sword,
Undo2,
X,
} from 'lucide-react';
import type { Campaign, Character, Combatant, Condition, Encounter } from '@/lib/schemas'; import type { Campaign, Character, Combatant, Condition, Encounter } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories'; import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
@@ -27,6 +43,7 @@ import { cn } from '@/lib/cn';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { Badge, Meter, Avatar } from '@/components/ui/Codex';
import { EncounterTipCard } from '@/features/assistant/EncounterTipCard'; import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) { export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
@@ -70,31 +87,41 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
return ( return (
<div> <div>
{/* Control bar */} {/* Control bar */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-line bg-panel p-3"> <div className="paper-grain mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-line bg-panel p-3">
<div> <div>
<div className="font-display text-lg font-semibold text-ink">{encounter.name}</div> <div className="font-display text-sm italic text-accent">Encounter · {encounter.name}</div>
<div className="font-display text-lg font-semibold text-ink">Initiative</div>
<div className="text-xs text-muted"> <div className="text-xs text-muted">
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'} {isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'}
{current && isActive ? ` · ${current.name}'s turn` : ''} {current && isActive ? ` · ${current.name}'s turn` : ''}
</div> </div>
</div> </div>
{isActive && (
<div className="flex items-center gap-2 rounded-xl border border-line bg-surface-2 px-3 py-1.5">
<Hourglass size={15} aria-hidden className="text-accent-deep" />
<span className="smallcaps" style={{ fontSize: 10 }}>Round</span>
<span className="font-display text-xl font-semibold text-accent-deep">{encounter.round}</span>
</div>
)}
{budget && ( {budget && (
<div className="rounded-md border border-line bg-surface px-3 py-1 text-center text-xs"> <div className="rounded-xl border border-line bg-surface-2 px-3 py-1 text-center text-xs">
<div className={cn('font-display text-sm font-semibold capitalize', DIFFICULTY_COLOR[budget.difficulty])}> <div className={cn('font-display text-sm font-semibold capitalize', DIFFICULTY_COLOR[budget.difficulty])}>
{budget.difficulty} {budget.difficulty}
</div> </div>
<div className="text-muted"> <div className="font-mono text-muted">
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC {budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
</div> </div>
</div> </div>
)} )}
<div className="ml-auto flex flex-wrap gap-2"> <div className="ml-auto flex flex-wrap gap-2">
{undoStack.current.length > 0 && ( {undoStack.current.length > 0 && (
<Button variant="ghost" onClick={undo} title="Undo last change"> Undo</Button> <Button variant="ghost" onClick={undo} title="Undo last change">
<Undo2 size={15} aria-hidden /> Undo
</Button>
)} )}
{encounter.combatants.length > 0 && ( {encounter.combatants.length > 0 && (
<Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone"> <Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone">
🎲 Roll all <Dices size={15} aria-hidden /> Roll all
</Button> </Button>
)} )}
{!isActive && encounter.status !== 'ended' && ( {!isActive && encounter.status !== 'ended' && (
@@ -112,10 +139,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
{isActive && ( {isActive && (
<> <>
<Button variant="secondary" onClick={() => mutate(previousTurn)}> <Button variant="secondary" onClick={() => mutate(previousTurn)}>
Prev <ChevronLeft size={15} aria-hidden /> Prev
</Button> </Button>
<Button variant="primary" onClick={() => mutate(nextTurn)}> <Button variant="primary" onClick={() => mutate(nextTurn)}>
Next turn Next turn <ChevronRight size={15} aria-hidden />
</Button> </Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}> <Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
End End
@@ -138,7 +165,11 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
{encounter.combatants.length === 0 ? ( {encounter.combatants.length === 0 ? (
<p className="mt-6 text-sm text-muted">No combatants yet. Add characters or monsters above.</p> <p className="mt-6 text-sm text-muted">No combatants yet. Add characters or monsters above.</p>
) : ( ) : (
<ul className="mt-4 space-y-2"> <>
<div className="mt-4 flex items-center justify-between px-1 pb-1.5">
<span className="smallcaps">Turn order · {encounter.combatants.length} combatants</span>
</div>
<ul className="space-y-1.5">
{encounter.combatants.map((c, idx) => ( {encounter.combatants.map((c, idx) => (
<CombatantRow <CombatantRow
key={c.id} key={c.id}
@@ -154,6 +185,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
/> />
))} ))}
</ul> </ul>
</>
)} )}
<CombatLog log={encounter.log ?? []} onClear={() => mutate((e) => ({ ...e, log: [] }))} /> <CombatLog log={encounter.log ?? []} onClear={() => mutate((e) => ({ ...e, log: [] }))} />
@@ -166,15 +198,22 @@ function CombatLog({ log, onClear }: { log: { round: number; text: string }[]; o
const recent = log.slice(-40).reverse(); const recent = log.slice(-40).reverse();
return ( return (
<div className="mt-6"> <div className="mt-6">
<div className="mb-1 flex items-center justify-between"> <div className="mb-1.5 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Combat Log</h3> <h3 className="smallcaps flex items-center gap-1.5">
<ScrollText size={13} aria-hidden className="text-accent-deep" />
Combat Log
</h3>
<Button size="sm" variant="ghost" onClick={onClear}>Clear</Button> <Button size="sm" variant="ghost" onClick={onClear}>Clear</Button>
</div> </div>
<ul className="max-h-48 space-y-0.5 overflow-auto rounded-md border border-line bg-surface p-2 text-xs"> <hr className="gilt-rule mb-2" />
<ul className="max-h-48 space-y-1 overflow-auto rounded-xl border border-line bg-surface-2 p-3 text-xs">
{recent.map((l, i) => ( {recent.map((l, i) => (
<li key={i} className="text-muted"> <li key={i} className="flex items-start gap-2 text-ink-soft">
<span className="mr-2 text-accent/70">R{l.round}</span> <span className="mt-1.5 inline-block size-1.5 shrink-0 rounded-full bg-accent" aria-hidden />
<span className="font-display leading-snug">
<span className="mr-1.5 font-mono text-faint">R{l.round}</span>
{l.text} {l.text}
</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -245,7 +284,7 @@ function AddCombatantBar({
}; };
return ( return (
<div className="space-y-2 rounded-lg border border-line bg-panel/60 p-3"> <div className="space-y-2 rounded-xl border border-line bg-panel-2 p-3">
<div className="flex flex-wrap items-end gap-2"> <div className="flex flex-wrap items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted"> <label className="flex-1 min-w-40 text-xs text-muted">
Name Name
@@ -318,14 +357,22 @@ function CombatantRow({
}) { }) {
const [delta, setDelta] = useState(0); const [delta, setDelta] = useState(0);
const dead = c.hp.current <= 0; const dead = c.hp.current <= 0;
const foe = c.kind === 'monster';
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const hpTone = ratio < 0.35 ? 'var(--app-danger)' : ratio < 0.7 ? 'var(--app-accent)' : 'var(--app-verdigris)';
const avatarTone = foe ? 'var(--app-danger)' : 'var(--app-accent)';
return ( return (
<li <li
className={ className={cn(
'rounded-lg border bg-panel p-3 ' + 'rounded-xl border p-3 transition-colors',
(isCurrent ? 'border-accent ring-1 ring-accent/40' : 'border-line') + isCurrent
(dead ? ' opacity-60' : '') ? foe
} ? 'border-danger bg-danger-glow'
: 'border-accent bg-accent-glow'
: 'border-line bg-panel',
dead && 'opacity-60',
)}
> >
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@@ -333,58 +380,91 @@ function CombatantRow({
<span className="mt-0.5 text-[10px] uppercase text-muted">init</span> <span className="mt-0.5 text-[10px] uppercase text-muted">init</span>
</div> </div>
<div className="relative">
<Avatar name={c.name} size={40} tone={avatarTone} />
{foe && (
<span
className="absolute -bottom-0.5 -right-0.5 grid size-4 place-items-center rounded-full border-2 border-panel bg-danger"
aria-hidden
>
<Skull size={9} className="text-white" />
</span>
)}
</div>
<div className="min-w-32 flex-1"> <div className="min-w-32 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-ink">{c.name}</span> <span className="font-display font-semibold text-ink">{c.name}</span>
<span className="rounded bg-elevated px-1.5 text-[10px] uppercase text-muted">{c.kind}</span> {isCurrent && (
{dead && <span className="text-xs font-semibold text-danger">DOWN</span>} <Badge tone={foe ? 'ember' : 'gold'}>
<Play size={11} aria-hidden /> Active
</Badge>
)}
<span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span>
{dead && (
<Badge tone="ember">
<Skull size={11} aria-hidden /> DOWN
</Badge>
)}
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="w-32">
<Meter value={c.hp.current} max={c.hp.max} tone={hpTone} height={6} />
</div>
<span className="font-mono text-xs text-faint">
{c.hp.current}/{c.hp.max}
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
</span>
</div> </div>
{c.conditions.length > 0 && ( {c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => ( {c.conditions.map((cond, i) => (
<button <button
key={`${cond.name}-${i}`} key={`${cond.name}-${i}`}
className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:line-through" className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris transition-colors hover:line-through"
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'} title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })} onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
> >
{cond.name} {cond.name}
{cond.value ? ` ${cond.value}` : ''} {cond.value ? ` ${cond.value}` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''} {cond.duration ? ` (${cond.duration}r)` : ''}
<X size={11} aria-hidden />
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
{/* HP */} {/* HP controls */}
<div className="text-center"> <div className="flex items-center gap-1">
<div className="text-sm">
<span className={dead ? 'text-danger' : 'text-ink'}>{c.hp.current}</span>
<span className="text-muted">/{c.hp.max}</span>
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
</div>
<div className="mt-1 flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} /> <NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage"> <Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
Dmg <Sword size={14} aria-hidden /> Dmg
</Button> </Button>
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal"> <Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
Heal <Heart size={14} aria-hidden className="text-verdigris" /> Heal
</Button> </Button>
</div> </div>
</div>
<div className="text-center text-sm"> <div className="flex flex-col items-center">
<div className="text-muted">AC</div> <div className="flex items-center gap-1 text-muted">
<NumberField className="w-12" value={c.ac} min={0} onChange={(ac) => onChange({ ac })} aria-label={`${c.name} AC`} /> <Shield size={13} aria-hidden />
<span className="smallcaps" style={{ fontSize: 9 }}>AC</span>
</div>
<NumberField className="mt-0.5 w-12" value={c.ac} min={0} onChange={(ac) => onChange({ ac })} aria-label={`${c.name} AC`} />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Button size="icon" variant="ghost" onClick={() => onMove(-1)} aria-label="Move up" title="Move up"></Button> <Button size="icon" variant="ghost" onClick={() => onMove(-1)} aria-label="Move up" title="Move up">
<Button size="icon" variant="ghost" onClick={() => onMove(1)} aria-label="Move down" title="Move down"></Button> <ArrowUp size={15} aria-hidden />
</Button>
<Button size="icon" variant="ghost" onClick={() => onMove(1)} aria-label="Move down" title="Move down">
<ArrowDown size={15} aria-hidden />
</Button>
</div> </div>
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}></Button> <Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}>
<X size={15} aria-hidden />
</Button>
</div> </div>
<div className="mt-2"> <div className="mt-2">
+33 -14
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Search, BookOpenText } from 'lucide-react';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import type { SystemId } from '@/lib/rules'; import type { SystemId } from '@/lib/rules';
import { SYSTEM_OPTIONS } from '@/lib/rules'; import { SYSTEM_OPTIONS } from '@/lib/rules';
@@ -126,17 +127,18 @@ export function CompendiumPage() {
return ( return (
<Page> <Page>
<PageHeader <PageHeader
eyebrow="The Codex"
title="Compendium" title="Compendium"
subtitle="Searchable reference — bestiary, spells, items, feats, and more." subtitle="Searchable reference — bestiary, spells, items, feats, and more."
actions={ actions={
<div className="flex gap-1 rounded-md border border-line p-0.5"> <div className="flex gap-0.5 rounded-lg border border-line bg-panel p-0.5">
{SYSTEM_OPTIONS.map((o) => ( {SYSTEM_OPTIONS.map((o) => (
<button <button
key={o.id} key={o.id}
onClick={() => changeSystem(o.id)} onClick={() => changeSystem(o.id)}
className={cn( className={cn(
'rounded px-3 py-1 text-sm transition-colors', 'rounded-md px-3 py-1 text-sm transition-colors',
system === o.id ? 'bg-accent text-accent-ink' : 'text-muted hover:text-ink', system === o.id ? 'bg-accent text-accent-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]' : 'text-muted hover:text-ink',
)} )}
> >
{o.label} {o.label}
@@ -153,8 +155,10 @@ export function CompendiumPage() {
key={c.id} key={c.id}
onClick={() => setCategoryId(c.id)} onClick={() => setCategoryId(c.id)}
className={cn( className={cn(
'rounded-md px-3 py-1.5 text-sm transition-colors', 'rounded-md border px-3 py-1.5 text-sm transition-colors',
c.id === category.id ? 'bg-elevated text-accent ring-1 ring-accent/40' : 'bg-panel text-muted hover:text-ink', c.id === category.id
? 'border-accent bg-accent-glow font-medium text-accent-deep'
: 'border-line bg-panel text-muted hover:text-ink',
)} )}
> >
{c.label} {c.label}
@@ -164,12 +168,20 @@ export function CompendiumPage() {
<div className="grid gap-4 lg:grid-cols-[340px_1fr]"> <div className="grid gap-4 lg:grid-cols-[340px_1fr]">
<div> <div>
<div className="relative">
<Search
aria-hidden
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-faint"
/>
<Input <Input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${category.label.toLowerCase()}`} placeholder={`Search ${category.label.toLowerCase()}`}
aria-label="Search compendium" aria-label="Search compendium"
className="pl-9"
/> />
</div>
{category.filters.length > 0 && ( {category.filters.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
{category.filters.map((f) => { {category.filters.map((f) => {
@@ -203,7 +215,7 @@ export function CompendiumPage() {
> >
{sortOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)} {sortOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select> </Select>
<span className="text-xs text-muted"> <span className="font-mono text-xs text-faint">
{loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length.toLocaleString()} result${results.length === 1 ? '' : 's'}`} {loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length.toLocaleString()} result${results.length === 1 ? '' : 's'}`}
</span> </span>
</div> </div>
@@ -220,15 +232,17 @@ export function CompendiumPage() {
<button <button
onClick={() => setSelected(entry)} onClick={() => setSelected(entry)}
className={cn( className={cn(
'flex h-full w-full items-center justify-between gap-2 rounded-md border px-3 text-left text-sm', 'flex h-full w-full items-center justify-between gap-2 rounded-md border px-3 text-left text-sm transition-colors',
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40', selected === entry
? 'border-accent bg-accent-glow'
: 'border-line bg-panel hover:border-accent/40',
)} )}
> >
<span className="flex min-w-0 items-center gap-1.5"> <span className="flex min-w-0 items-center gap-1.5">
{isHomebrew(entry) && <span className="shrink-0 rounded bg-accent/20 px-1 text-[10px] font-semibold uppercase text-accent">HB</span>} {isHomebrew(entry) && <span className="shrink-0 rounded bg-accent-glow px-1 text-[10px] font-semibold uppercase text-accent-deep">HB</span>}
<span className="truncate text-ink">{entry.name}</span> <span className="truncate font-display font-medium text-ink">{entry.name}</span>
</span> </span>
<span className="shrink-0 text-xs text-muted">{isHomebrew(entry) ? 'Homebrew' : category.meta(entry)}</span> <span className="shrink-0 font-mono text-xs text-faint">{isHomebrew(entry) ? 'Homebrew' : category.meta(entry)}</span>
</button> </button>
</div> </div>
); );
@@ -237,14 +251,17 @@ export function CompendiumPage() {
</div> </div>
</div> </div>
<div className="rounded-lg border border-line bg-panel p-5"> <div className="paper-grain overflow-hidden rounded-xl border border-line border-t-4 border-t-accent bg-panel p-6">
{selected ? ( {selected ? (
<div> <div>
<DetailActions category={category} entry={selected} system={system} /> <DetailActions category={category} entry={selected} system={system} />
{isHomebrew(selected) ? <HomebrewDetail entry={selected} /> : category.detail(selected)} {isHomebrew(selected) ? <HomebrewDetail entry={selected} /> : category.detail(selected)}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted">Select an entry to view details.</p> <div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 text-center">
<BookOpenText aria-hidden size={28} className="text-faint" />
<p className="font-display italic text-muted">Select an entry to view details.</p>
</div>
)} )}
</div> </div>
</div> </div>
@@ -271,7 +288,9 @@ function DetailActions({ category, entry, system }: { category: CategoryDef; ent
} }
function ActionBar({ children }: { children: React.ReactNode }) { function ActionBar({ children }: { children: React.ReactNode }) {
return <div className="mb-3 flex flex-wrap items-center gap-2 border-b border-line pb-3">{children}</div>; return (
<div className="mb-4 flex flex-wrap items-center gap-2 border-b border-line/70 pb-4">{children}</div>
);
} }
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) { function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
+45 -26
View File
@@ -7,6 +7,17 @@ function actionList(value: MonsterAction[] | string | undefined): MonsterAction[
return Array.isArray(value) ? value : []; return Array.isArray(value) ? value : [];
} }
/** A single "Label value" line in the Codex stat block. */
function StatLine({ label, children }: { label: string; children?: React.ReactNode }) {
if (children === undefined || children === null || children === '' || children === false) return null;
return (
<p className="text-sm leading-relaxed text-ink-soft">
<span className="font-display font-bold text-danger">{label} </span>
{children}
</p>
);
}
export function MonsterDetail({ monster: m }: { monster: Monster }) { export function MonsterDetail({ monster: m }: { monster: Monster }) {
const abilities: [string, number | undefined][] = [ const abilities: [string, number | undefined][] = [
['STR', m.strength], ['STR', m.strength],
@@ -16,44 +27,52 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
['WIS', m.wisdom], ['WIS', m.wisdom],
['CHA', m.charisma], ['CHA', m.charisma],
]; ];
const subtitle = [m.size, m.type, m.alignment].filter(Boolean).join(', ');
return ( return (
<div className="space-y-4"> <div className="font-display">
<header> <header>
<h2 className="font-display text-2xl font-bold text-accent">{m.name}</h2> <h2 className="font-display text-3xl font-semibold tracking-tight text-danger">{m.name}</h2>
<p className="text-sm italic text-muted"> {subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
{[m.size, m.type, m.alignment].filter(Boolean).join(', ')}
</p>
</header> </header>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm"> <hr className="gilt-rule my-4" />
<span><strong className="text-ink">AC</strong> {m.armor_class ?? '—'} {m.armor_desc ? `(${m.armor_desc})` : ''}</span>
<span><strong className="text-ink">HP</strong> {m.hit_points ?? '—'} {m.hit_dice ? `(${m.hit_dice})` : ''}</span> <div className="space-y-1">
<span><strong className="text-ink">CR</strong> {crLabel(m)}</span> <StatLine label="Armor Class">{m.armor_class ?? '—'}{m.armor_desc ? ` (${m.armor_desc})` : ''}</StatLine>
{m.speed?.walk !== undefined && <span><strong className="text-ink">Speed</strong> {m.speed.walk} ft.</span>} <StatLine label="Hit Points">{m.hit_points ?? '—'}{m.hit_dice ? ` (${m.hit_dice})` : ''}</StatLine>
{m.speed?.walk !== undefined && <StatLine label="Speed">{m.speed.walk} ft.</StatLine>}
</div> </div>
<div className="grid grid-cols-6 gap-2 rounded-lg border border-line bg-surface p-3 text-center text-sm"> <hr className="gilt-rule my-4" />
<div className="grid grid-cols-6 gap-2">
{abilities.map(([label, score]) => ( {abilities.map(([label, score]) => (
<div key={label}> <div key={label} className="flex flex-col items-center gap-0.5 rounded-xl border border-line bg-surface-2 px-1 py-2 text-center">
<div className="text-xs font-semibold text-muted">{label}</div> <span className="smallcaps text-danger" style={{ fontSize: 9 }}>{label}</span>
<div className="text-ink">{score ?? '—'}</div> <span className="font-display text-lg font-semibold text-ink">{score ?? '—'}</span>
<div className="text-xs text-accent"> <span className="font-mono text-xs text-muted">
{score !== undefined ? formatModifier(abilityModifier(score)) : ''} {score !== undefined ? formatModifier(abilityModifier(score)) : ''}
</div> </span>
</div> </div>
))} ))}
</div> </div>
{(m.damage_resistances || m.damage_immunities || m.condition_immunities || m.senses || m.languages) && ( {(m.damage_resistances || m.damage_immunities || m.condition_immunities || m.senses || m.languages) && (
<div className="space-y-1 text-sm text-muted"> <>
{m.damage_resistances && <p><strong className="text-ink">Resistances</strong> {m.damage_resistances}</p>} <hr className="gilt-rule my-4" />
{m.damage_immunities && <p><strong className="text-ink">Immunities</strong> {m.damage_immunities}</p>} <div className="space-y-1">
{m.condition_immunities && <p><strong className="text-ink">Condition Immunities</strong> {m.condition_immunities}</p>} <StatLine label="Resistances">{m.damage_resistances}</StatLine>
{m.senses && <p><strong className="text-ink">Senses</strong> {m.senses}</p>} <StatLine label="Immunities">{m.damage_immunities}</StatLine>
{m.languages && <p><strong className="text-ink">Languages</strong> {m.languages}</p>} <StatLine label="Condition Immunities">{m.condition_immunities}</StatLine>
<StatLine label="Senses">{m.senses}</StatLine>
<StatLine label="Languages">{m.languages}</StatLine>
</div> </div>
</>
)} )}
<hr className="gilt-rule my-4" />
<StatLine label="Challenge">{crLabel(m)}</StatLine>
<ActionBlock title="Special Abilities" actions={actionList(m.special_abilities)} /> <ActionBlock title="Special Abilities" actions={actionList(m.special_abilities)} />
<ActionBlock title="Actions" actions={actionList(m.actions)} /> <ActionBlock title="Actions" actions={actionList(m.actions)} />
<ActionBlock title="Reactions" actions={actionList(m.reactions)} /> <ActionBlock title="Reactions" actions={actionList(m.reactions)} />
@@ -65,12 +84,12 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
function ActionBlock({ title, actions }: { title: string; actions: MonsterAction[] }) { function ActionBlock({ title, actions }: { title: string; actions: MonsterAction[] }) {
if (actions.length === 0) return null; if (actions.length === 0) return null;
return ( return (
<section> <section className="mt-5">
<h3 className="mb-1 border-b border-line pb-1 font-display text-lg font-semibold text-ink">{title}</h3> <h3 className="mb-2 border-b-2 border-danger pb-1 font-display text-lg font-semibold text-danger">{title}</h3>
<div className="space-y-2"> <div className="space-y-2">
{actions.map((a, i) => ( {actions.map((a, i) => (
<p key={`${a.name}-${i}`} className="text-sm text-muted"> <p key={`${a.name}-${i}`} className="text-sm leading-relaxed text-ink-soft">
<strong className="text-ink">{a.name}.</strong> {a.desc} <strong className="italic text-ink">{a.name}.</strong> {a.desc}
</p> </p>
))} ))}
</div> </div>
+82 -44
View File
@@ -8,17 +8,53 @@ import type {
CompendiumEntry, CompendiumEntry,
} from '@/lib/compendium/types'; } from '@/lib/compendium/types';
import type { RulesetClass } from '@/lib/ruleset/normalize'; import type { RulesetClass } from '@/lib/ruleset/normalize';
import { Badge } from '@/components/ui/Codex';
const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); const cap = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
/** Editorial header shared by every detail renderer: Spectral title + italic sub + gilt rule. */
function Header({
title,
subtitle,
tone = 'text-accent-deep',
}: {
title: string;
subtitle?: string | undefined;
tone?: string;
}) {
return (
<header>
<h2 className={`font-display text-3xl font-semibold tracking-tight ${tone}`}>{title}</h2>
{subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
</header>
);
}
/** A sunken fact well: smallcaps label over a serif value. */
function FactWell({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5 rounded-xl border border-line bg-surface-2 px-3 py-2">
<span className="smallcaps" style={{ fontSize: 9 }}>{label}</span>
<span className="font-display text-sm font-semibold text-ink">{value}</span>
</div>
);
}
function ClassRow({ label, value }: { label: string; value: string }) { function ClassRow({ label, value }: { label: string; value: string }) {
return <div className="flex gap-2"><span className="w-28 shrink-0 text-xs uppercase tracking-wide text-muted">{label}</span><span className="text-ink">{value}</span></div>; return (
<div className="flex gap-2">
<span className="smallcaps w-28 shrink-0">{label}</span>
<span className="text-ink-soft">{value}</span>
</div>
);
} }
export function ClassDetail({ c }: { c: RulesetClass }) { export function ClassDetail({ c }: { c: RulesetClass }) {
return ( return (
<div className="space-y-2 text-sm"> <div className="space-y-3 text-sm">
{c.description && <p className="text-muted">{c.description}</p>} {c.description && <p className="font-display leading-relaxed text-ink-soft">{c.description}</p>}
<hr className="gilt-rule my-1" />
<div className="space-y-2">
<ClassRow label={c.system === 'pf2e' ? 'HP / level' : 'Hit die'} value={c.system === 'pf2e' ? String(c.hitDie) : `d${c.hitDie}`} /> <ClassRow label={c.system === 'pf2e' ? 'HP / level' : 'Hit die'} value={c.system === 'pf2e' ? String(c.hitDie) : `d${c.hitDie}`} />
{c.keyAbilities.length > 0 && <ClassRow label="Key ability" value={c.keyAbilities.map(cap).join(' or ')} />} {c.keyAbilities.length > 0 && <ClassRow label="Key ability" value={c.keyAbilities.map(cap).join(' or ')} />}
{c.saves.length > 0 && <ClassRow label="Saves" value={c.saves.map((s) => (c.saveRanks?.[s] ? `${cap(s)} (${c.saveRanks[s]})` : cap(s))).join(', ')} />} {c.saves.length > 0 && <ClassRow label="Saves" value={c.saves.map((s) => (c.saveRanks?.[s] ? `${cap(s)} (${c.saveRanks[s]})` : cap(s))).join(', ')} />}
@@ -27,34 +63,24 @@ export function ClassDetail({ c }: { c: RulesetClass }) {
{c.caster !== 'none' && <ClassRow label="Spellcasting" value={cap(c.caster)} />} {c.caster !== 'none' && <ClassRow label="Spellcasting" value={cap(c.caster)} />}
{c.weapons.length > 0 && <ClassRow label="Weapons" value={c.weapons.map(cap).join(', ')} />} {c.weapons.length > 0 && <ClassRow label="Weapons" value={c.weapons.map(cap).join(', ')} />}
{c.armor.length > 0 && <ClassRow label="Armor" value={c.armor.map(cap).join(', ')} />} {c.armor.length > 0 && <ClassRow label="Armor" value={c.armor.map(cap).join(', ')} />}
</div>
{c.subclasses.length > 0 && ( {c.subclasses.length > 0 && (
<div> <div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{c.system === 'pf2e' ? 'Options' : 'Subclasses'}</div> <div className="smallcaps mb-2">{c.system === 'pf2e' ? 'Options' : 'Subclasses'}</div>
<ul className="list-disc space-y-0.5 pl-5 text-ink">{c.subclasses.slice(0, 16).map((s) => <li key={s.name}>{s.name}</li>)}</ul> <ul className="list-disc space-y-0.5 pl-5 text-ink-soft">{c.subclasses.slice(0, 16).map((s) => <li key={s.name}>{s.name}</li>)}</ul>
</div> </div>
)} )}
<p className="pt-1 text-xs text-muted">Source: {c.source}{c.license ? ` · ${c.license}` : ''}</p> <p className="pt-1 text-xs text-faint">Source: {c.source}{c.license ? ` · ${c.license}` : ''}</p>
</div> </div>
); );
} }
function Header({ title, subtitle }: { title: string; subtitle?: string | undefined }) {
return (
<header>
<h2 className="font-display text-2xl font-bold text-accent">{title}</h2>
{subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
</header>
);
}
function Traits({ traits }: { traits?: string[] | undefined }) { function Traits({ traits }: { traits?: string[] | undefined }) {
if (!traits || traits.length === 0) return null; if (!traits || traits.length === 0) return null;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{traits.map((t) => ( {traits.map((t) => (
<span key={t} className="rounded bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted"> <Badge key={t}>{t}</Badge>
{t}
</span>
))} ))}
</div> </div>
); );
@@ -63,17 +89,22 @@ function Traits({ traits }: { traits?: string[] | undefined }) {
export function Spell5eDetail({ entry: s }: { entry: Spell }) { export function Spell5eDetail({ entry: s }: { entry: Spell }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Header title={s.name} subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`} /> <Header
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted"> title={s.name}
<span><strong className="text-ink">Casting Time</strong> {s.casting_time}</span> subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`}
<span><strong className="text-ink">Range</strong> {s.range}</span> tone="text-info"
<span><strong className="text-ink">Duration</strong> {s.duration}</span> />
<span><strong className="text-ink">Components</strong> {s.components}</span> <hr className="gilt-rule my-1" />
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<FactWell label="Casting Time" value={s.casting_time} />
<FactWell label="Range" value={s.range} />
<FactWell label="Duration" value={s.duration} />
<FactWell label="Components" value={s.components} />
</div> </div>
<p className="whitespace-pre-wrap text-sm text-ink">{s.desc}</p> <p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{s.desc}</p>
{s.higher_level && ( {s.higher_level && (
<p className="whitespace-pre-wrap text-sm text-muted"> <p className="whitespace-pre-wrap text-sm text-muted">
<strong className="text-ink">At Higher Levels.</strong> {s.higher_level} <strong className="italic text-ink">At Higher Levels.</strong> {s.higher_level}
</p> </p>
)} )}
</div> </div>
@@ -90,7 +121,8 @@ export function Item5eDetail({ entry: item }: { entry: MagicItem }) {
(item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : '') (item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : '')
} }
/> />
<p className="whitespace-pre-wrap text-sm text-ink">{item.desc}</p> <hr className="gilt-rule my-1" />
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{item.desc}</p>
</div> </div>
); );
} }
@@ -99,10 +131,11 @@ export function Weapon5eDetail({ entry: w }: { entry: Weapon5e }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Header title={w.name} subtitle={w.category} /> <Header title={w.name} subtitle={w.category} />
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted"> <hr className="gilt-rule my-1" />
<span><strong className="text-ink">Damage</strong> {w.damage_dice} {w.damage_type}</span> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<span><strong className="text-ink">Cost</strong> {w.cost ?? '—'}</span> <FactWell label="Damage" value={`${w.damage_dice} ${w.damage_type}`} />
<span><strong className="text-ink">Weight</strong> {w.weight ?? '—'}</span> <FactWell label="Cost" value={w.cost ?? '—'} />
<FactWell label="Weight" value={w.weight ?? '—'} />
</div> </div>
{w.properties && w.properties.length > 0 && ( {w.properties && w.properties.length > 0 && (
<p className="text-sm text-muted"><strong className="text-ink">Properties</strong> {w.properties.join(', ')}</p> <p className="text-sm text-muted"><strong className="text-ink">Properties</strong> {w.properties.join(', ')}</p>
@@ -115,11 +148,12 @@ export function Armor5eDetail({ entry: a }: { entry: Armor5e }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Header title={a.name} subtitle={`${a.type ?? ''} armor`} /> <Header title={a.name} subtitle={`${a.type ?? ''} armor`} />
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted"> <hr className="gilt-rule my-1" />
<span><strong className="text-ink">AC</strong> {a.ac}</span> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<span><strong className="text-ink">Cost</strong> {a.cost ?? '—'}</span> <FactWell label="AC" value={a.ac} />
<span><strong className="text-ink">Weight</strong> {a.weight ?? '—'} lb</span> <FactWell label="Cost" value={a.cost ?? '—'} />
<span><strong className="text-ink">Stealth</strong> {a.stealthPenalty ? 'Disadvantage' : '—'}</span> <FactWell label="Weight" value={`${a.weight ?? '—'} lb`} />
<FactWell label="Stealth" value={a.stealthPenalty ? 'Disadvantage' : '—'} />
</div> </div>
</div> </div>
); );
@@ -129,7 +163,8 @@ export function Feat5eDetail({ entry: f }: { entry: Feat5e }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Header title={f.name} subtitle="Feat" /> <Header title={f.name} subtitle="Feat" />
<p className="whitespace-pre-wrap text-sm text-ink">{f.description}</p> <hr className="gilt-rule my-1" />
<p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{f.description}</p>
</div> </div>
); );
} }
@@ -138,7 +173,8 @@ export function Condition5eDetail({ entry: c }: { entry: Condition5e }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Header title={c.name} subtitle="Condition" /> <Header title={c.name} subtitle="Condition" />
{c.description && <p className="text-sm text-ink">{c.description}</p>} <hr className="gilt-rule my-1" />
{c.description && <p className="font-display text-[15px] leading-relaxed text-ink-soft">{c.description}</p>}
{c.effects && c.effects.length > 0 && ( {c.effects && c.effects.length > 0 && (
<ul className="list-disc space-y-1 pl-5 text-sm text-muted"> <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
{c.effects.map((e, i) => <li key={i}>{e}</li>)} {c.effects.map((e, i) => <li key={i}>{e}</li>)}
@@ -156,14 +192,15 @@ export function HomebrewDetail({ entry }: { entry: CompendiumEntry }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Header title={entry.name} subtitle={`Homebrew ${kind}`} /> <Header title={entry.name} subtitle={`Homebrew ${kind}`} />
<hr className="gilt-rule my-1" />
{facts.length > 0 && ( {facts.length > 0 && (
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{facts.map(([k, v]) => ( {facts.map(([k, v]) => (
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span> <FactWell key={k} label={k} value={String(v)} />
))} ))}
</div> </div>
)} )}
{entry.description ? <p className="whitespace-pre-wrap text-sm text-ink">{String(entry.description)}</p> : null} {entry.description ? <p className="whitespace-pre-wrap font-display text-[15px] leading-relaxed text-ink-soft">{String(entry.description)}</p> : null}
</div> </div>
); );
} }
@@ -191,14 +228,15 @@ export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
<div className="space-y-3"> <div className="space-y-3">
<Header title={entry.name} subtitle={(entry.type as string | undefined) ?? undefined} /> <Header title={entry.name} subtitle={(entry.type as string | undefined) ?? undefined} />
<Traits traits={traits} /> <Traits traits={traits} />
<hr className="gilt-rule my-1" />
{shown.length > 0 && ( {shown.length > 0 && (
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{shown.map(([k, v]) => ( {shown.map(([k, v]) => (
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span> <FactWell key={k} label={k} value={String(v)} />
))} ))}
</div> </div>
)} )}
<p className="whitespace-pre-line text-sm text-ink">{text}</p> <p className="whitespace-pre-line font-display text-[15px] leading-relaxed text-ink-soft">{text}</p>
</div> </div>
); );
} }
+107 -38
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { Dices, Sparkles, Skull, History as HistoryIcon } from 'lucide-react';
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation'; import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
@@ -11,9 +12,25 @@ import { useSessionStore } from '@/stores/sessionStore';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Codex';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100']; const DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100'];
const DIE_SIDES: Record<string, number> = { d4: 4, d6: 6, d8: 8, d10: 10, d12: 12, d20: 20, d100: 100 };
/** A faceted-die glyph with the side count inscribed — used for the quick-roll pool. */
function DieGlyph({ sides, size = 44 }: { sides: number; size?: number }) {
return (
<span className="relative grid place-items-center text-line-strong transition-colors group-hover:text-accent" style={{ width: size, height: size }}>
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinejoin="round" aria-hidden>
<polygon points="12,2 21,7 21,17 12,22 3,17 3,7" />
</svg>
<span className="absolute inset-0 grid place-items-center pt-px font-mono text-[11px] font-semibold text-ink transition-colors group-hover:text-accent-deep">
{sides}
</span>
</span>
);
}
export function DicePage() { export function DicePage() {
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined; const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
@@ -59,6 +76,7 @@ export function DicePage() {
return ( return (
<Page> <Page>
<PageHeader <PageHeader
eyebrow="Cast the bones"
title="Dice" title="Dice"
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20." subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
actions={ actions={
@@ -72,6 +90,46 @@ export function DicePage() {
<div className="grid gap-6 lg:grid-cols-[1fr_320px]"> <div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<div> <div>
{/* Result stage */}
{last ? (() => {
const nat = naturalD20(last);
const crit = nat === 20 ? 'crit' : nat === 1 ? 'fumble' : null;
return (
<div
className={cn(
'paper-grain mb-6 overflow-hidden rounded-xl border bg-panel p-7 text-center',
crit === 'crit' ? 'animate-crit-glow border-accent' : crit === 'fumble' ? 'border-danger' : 'border-line',
)}
>
<div className="font-mono text-xs text-faint">{last.expression}</div>
{crit === 'crit' && (
<div className="mt-2 flex justify-center">
<Badge tone="verdigris">
<Sparkles size={12} aria-hidden /> Critical!
</Badge>
</div>
)}
{crit === 'fumble' && (
<div className="mt-2 flex justify-center">
<Badge tone="ember">
<Skull size={12} aria-hidden /> Fumble!
</Badge>
</div>
)}
<AnimatedTotal key={rollCount} result={last} />
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
<span className="sr-only" aria-live="polite">
Rolled {last.expression}: {last.total}
</span>
</div>
);
})() : (
<div className="paper-grain mb-6 grid min-h-[140px] place-items-center rounded-xl border border-line bg-panel p-7 text-center">
<p className="font-display text-lg italic text-muted">Choose your dice, then cast.</p>
</div>
)}
{/* Expression + Roll */}
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={expr} value={expr}
@@ -83,34 +141,56 @@ export function DicePage() {
aria-label="Dice expression" aria-label="Dice expression"
/> />
<Button variant="primary" onClick={() => void roll(expr)}> <Button variant="primary" onClick={() => void roll(expr)}>
<Dices size={16} aria-hidden />
Roll Roll
</Button> </Button>
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> {/* Die pool */}
<div className="mt-4 flex flex-wrap items-end justify-center gap-3 sm:justify-start">
{DICE.map((d) => ( {DICE.map((d) => (
<Button key={d} size="sm" onClick={() => void roll(`1${d}`)}> <button
{d} key={d}
</Button> type="button"
onClick={() => void roll(`1${d}`)}
aria-label={d}
className="group grid place-items-center rounded-lg p-1 transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
<DieGlyph sides={DIE_SIDES[d] ?? 20} />
<span className="mt-0.5 font-mono text-[11px] text-muted transition-colors group-hover:text-accent-deep">{d}</span>
</button>
))} ))}
<Button </div>
size="sm"
variant={mode === 'advantage' ? 'primary' : 'secondary'} {/* Advantage / Disadvantage segmented toggle + secret */}
<div className="mt-5 flex flex-wrap items-center gap-3">
<span className="smallcaps">Mode</span>
<div className="inline-flex gap-0.5 rounded-lg border border-line bg-surface-2 p-0.5" role="group" aria-label="Roll mode">
<button
type="button"
aria-pressed={mode === 'advantage'} aria-pressed={mode === 'advantage'}
onClick={() => toggleMode('advantage')} onClick={() => toggleMode('advantage')}
title="Toggle advantage — rolls the next die twice, keep highest" title="Toggle advantage — rolls the next die twice, keep highest"
className={cn(
'whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
mode === 'advantage' ? 'bg-panel text-ink shadow-sm' : 'text-muted hover:text-ink',
)}
> >
Advantage Advantage
</Button> </button>
<Button <button
size="sm" type="button"
variant={mode === 'disadvantage' ? 'primary' : 'secondary'}
aria-pressed={mode === 'disadvantage'} aria-pressed={mode === 'disadvantage'}
onClick={() => toggleMode('disadvantage')} onClick={() => toggleMode('disadvantage')}
title="Toggle disadvantage — rolls the next die twice, keep lowest" title="Toggle disadvantage — rolls the next die twice, keep lowest"
className={cn(
'whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
mode === 'disadvantage' ? 'bg-panel text-ink shadow-sm' : 'text-muted hover:text-ink',
)}
> >
Disadvantage Disadvantage
</Button> </button>
</div>
{isGm && ( {isGm && (
<Button <Button
size="sm" size="sm"
@@ -133,9 +213,9 @@ export function DicePage() {
)} )}
{/* Saved rolls (macros) */} {/* Saved rolls (macros) */}
<div className="mt-4"> <div className="mt-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Saved rolls</span> <span className="smallcaps">Saved rolls</span>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -146,17 +226,17 @@ export function DicePage() {
</Button> </Button>
</div> </div>
{macros.length > 0 && ( {macros.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1.5">
{macros.map((m) => ( {macros.map((m) => (
<span key={m.id} className="inline-flex items-center overflow-hidden rounded-md border border-line bg-panel"> <span key={m.id} className="inline-flex items-center overflow-hidden rounded-md border border-line bg-panel">
<button <button
className="px-2 py-1 text-sm text-ink hover:bg-elevated" className="px-2.5 py-1 font-mono text-sm text-ink hover:bg-elevated"
onClick={() => { setExpr(m.expression); void roll(m.expression, m.label !== m.expression ? m.label : ''); }} onClick={() => { setExpr(m.expression); void roll(m.expression, m.label !== m.expression ? m.label : ''); }}
> >
{m.label} {m.label}
</button> </button>
<button <button
className="px-1.5 text-muted hover:text-danger" className="px-1.5 py-1 text-muted hover:text-danger"
onClick={() => removeMacro(macroKey, m.id)} onClick={() => removeMacro(macroKey, m.id)}
aria-label={`Remove ${m.label}`} aria-label={`Remove ${m.label}`}
> >
@@ -169,26 +249,15 @@ export function DicePage() {
</div> </div>
{error && <p className="mt-4 text-sm text-danger">{error}</p>} {error && <p className="mt-4 text-sm text-danger">{error}</p>}
{last && (() => {
const nat = naturalD20(last);
const crit = nat === 20 ? 'crit' : nat === 1 ? 'fumble' : null;
return (
<div className={cn('mt-6 rounded-lg border bg-panel p-6 text-center', crit === 'crit' ? 'animate-crit-glow border-warning' : crit === 'fumble' ? 'border-danger' : 'border-line')}>
{crit === 'crit' && <div className="mb-1 text-sm font-bold uppercase tracking-wide text-warning"> Critical! </div>}
{crit === 'fumble' && <div className="mb-1 text-sm font-bold uppercase tracking-wide text-danger"> Fumble! </div>}
<AnimatedTotal key={rollCount} result={last} />
<div className="mt-2 font-mono text-sm text-muted">{last.breakdown}</div>
<span className="sr-only" aria-live="polite">
Rolled {last.expression}: {last.total}
</span>
</div>
);
})()}
</div> </div>
{/* History rail */}
<aside> <aside>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">History</h2> <div className="mb-2 flex items-center gap-2">
<HistoryIcon size={14} className="text-accent" aria-hidden />
<h2 className="smallcaps">History</h2>
</div>
<hr className="gilt-rule mb-3" />
{history.length === 0 ? ( {history.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p> <p className="text-sm text-muted">No rolls yet.</p>
) : ( ) : (
@@ -196,13 +265,13 @@ export function DicePage() {
{history.map((h) => ( {history.map((h) => (
<li <li
key={h.id} key={h.id}
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm" className="flex items-center justify-between gap-3 rounded-lg border border-line bg-panel px-3 py-2 text-sm"
> >
<span className="text-muted"> <span className="min-w-0 truncate text-muted">
{h.label ? `${h.label}: ` : ''} {h.label ? `${h.label}: ` : ''}
<span className="font-mono">{h.expression}</span> <span className="font-mono">{h.expression}</span>
</span> </span>
<span className="font-display text-lg font-semibold text-ink">{h.total}</span> <span className="shrink-0 font-display text-lg font-semibold text-accent-deep">{h.total}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -243,7 +312,7 @@ function AnimatedTotal({ result }: { result: RollResult }) {
<div <div
aria-hidden aria-hidden
className={cn( className={cn(
'font-display text-6xl font-bold text-accent tabular-nums', 'mt-2 font-display text-7xl font-bold text-accent tabular-nums',
rolling ? 'animate-dice-rolling opacity-80' : 'animate-dice-settle', rolling ? 'animate-dice-rolling opacity-80' : 'animate-dice-settle',
)} )}
> >
+11 -9
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { RadioTower, X } from 'lucide-react';
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore'; import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync'; import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
@@ -43,25 +44,26 @@ export function SessionControl() {
const link = joinCode ? `${location.origin}/play?room=${joinCode}` : ''; const link = joinCode ? `${location.origin}/play?room=${joinCode}` : '';
return ( return (
<div className="relative flex items-center gap-1 rounded-md border border-accent/40 bg-accent/5 px-2 py-1 text-xs"> <div className="relative flex items-center gap-1.5 rounded-lg border border-accent/50 bg-accent-glow px-2 py-1 text-xs">
<span className="text-muted">{status === 'connected' ? 'Live' : status}:</span> <RadioTower size={14} className="text-accent-deep" aria-hidden />
<span className="smallcaps text-[9px] text-muted">{status === 'connected' ? 'Live' : status}</span>
<button <button
data-testid="join-code" data-testid="join-code"
className="font-mono font-semibold text-accent" className="font-mono font-semibold tracking-wider text-accent-deep"
title="Copy player link" title="Copy player link"
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }} onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
>{joinCode}{copied ? ' ✓' : ''}</button> >{joinCode}{copied ? ' ✓' : ''}</button>
{seatRequests.length > 0 && ( {seatRequests.length > 0 && (
<button data-testid="seat-requests" className="ml-1 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 text-[10px] font-bold text-white" <button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white"
title="Pending seat requests" onClick={() => setShowSeats((v) => !v)}>{seatRequests.length}</button> title="Pending seat requests" onClick={() => setShowSeats((v) => !v)}>{seatRequests.length}</button>
)} )}
<Button size="sm" variant="ghost" className="h-6 px-1 text-danger" onClick={() => stopSession()} title="Stop session"></Button> <Button size="sm" variant="ghost" className="h-6 w-6 px-0 text-danger" onClick={() => stopSession()} title="Stop session" aria-label="Stop session"><X size={14} aria-hidden /></Button>
{showSeats && seatRequests.length > 0 && ( {showSeats && seatRequests.length > 0 && (
<div className="absolute right-0 top-full z-50 mt-1 w-72 rounded-lg border border-line bg-panel p-2 shadow-lg"> <div className="paper-grain absolute right-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-line-strong bg-panel p-2 shadow-lg">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Seat requests</div> <div className="smallcaps mb-1.5 px-1 text-muted">Seat requests</div>
<ul className="space-y-2"> <ul className="space-y-2">
{seatRequests.map((r) => <SeatRequestRow key={r.playerId} req={r} />)} {seatRequests.map((r) => <SeatRequestRow key={r.playerId} req={r} />)}
</ul> </ul>
@@ -87,8 +89,8 @@ function SeatRequestRow({ req }: { req: SeatRequest }) {
}; };
return ( return (
<li className="rounded-md border border-line bg-surface p-2 text-sm"> <li className="rounded-lg border border-line bg-surface-2 p-2 text-sm">
<div className="mb-1 text-ink">Player wants <span className="font-semibold">{name}</span></div> <div className="mb-1.5 text-ink">Player wants <span className="font-display font-semibold text-ink">{name}</span></div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
<Button size="sm" variant="primary" onClick={() => void grant(false)}>Grant</Button> <Button size="sm" variant="primary" onClick={() => void grant(false)}>Grant</Button>
{req.offlineSnapshot && <Button size="sm" variant="secondary" onClick={() => void grant(true)}>Apply offline & grant</Button>} {req.offlineSnapshot && <Button size="sm" variant="secondary" onClick={() => void grant(true)}>Apply offline & grant</Button>}
+30 -18
View File
@@ -1,7 +1,9 @@
import { Mail, Eye, Map as MapIcon, Users, Swords } from 'lucide-react';
import type { Snapshot } from '@/lib/sync/messages'; import type { Snapshot } from '@/lib/sync/messages';
import type { PlayerMap } from '@/lib/map'; import type { PlayerMap } from '@/lib/map';
import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { EmptyState } from '@/components/ui/Page'; import { EmptyState } from '@/components/ui/Page';
import { Meter, Badge } from '@/components/ui/Codex';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { PlayerMapView } from './PlayerMapView'; import { PlayerMapView } from './PlayerMapView';
@@ -13,8 +15,10 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
return ( return (
<> <>
{privateHandout && ( {privateHandout && (
<section className="mb-6 rounded-xl border border-info/60 bg-info/5 p-4"> <section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-info">📨 Just for you</div> <div className="smallcaps mb-1 flex items-center gap-1.5 text-info">
<Mail size={13} aria-hidden /> 📨 Just for you
</div>
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2> <h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>} {privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
{privateHandout.image && <img src={privateHandout.image} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />} {privateHandout.image && <img src={privateHandout.image} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
@@ -22,8 +26,11 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
)} )}
{handout && ( {handout && (
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4"> <section className="mb-6 rounded-xl border border-accent/50 bg-accent-glow p-4">
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2> <div className="mb-1 flex items-center justify-between gap-2">
<h2 className="font-display text-2xl font-bold text-accent-deep">{handout.title || 'Handout'}</h2>
<Badge tone="gold"><Eye size={12} aria-hidden /> From the GM</Badge>
</div>
{handout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{handout.body}</p>} {handout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{handout.body}</p>}
{handoutImage && <img src={handoutImage} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />} {handoutImage && <img src={handoutImage} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
</section> </section>
@@ -31,14 +38,18 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{map && ( {map && (
<section className="mb-6"> <section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2> <h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
<MapIcon size={13} aria-hidden /> Battle map
</h2>
<PlayerMapView map={map as PlayerMap} image={image} images={images} viewportHeight="65vh" /> <PlayerMapView map={map as PlayerMap} image={image} images={images} viewportHeight="65vh" />
</section> </section>
)} )}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<section> <section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Party</h2> <h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
<Users size={13} aria-hidden /> Party
</h2>
{party.length === 0 ? ( {party.length === 0 ? (
<p className="text-sm text-muted">No player characters.</p> <p className="text-sm text-muted">No player characters.</p>
) : ( ) : (
@@ -46,19 +57,20 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{party.map((c) => { {party.map((c) => {
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0; const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
const portrait = c.imageId ? images?.[c.imageId] : undefined; const portrait = c.imageId ? images?.[c.imageId] : undefined;
const hpTone = pct > 50 ? 'var(--app-success)' : pct > 0 ? 'var(--app-warning)' : 'var(--app-danger)';
return ( return (
<div key={c.id} className="rounded-lg border border-line bg-panel p-3"> <div key={c.id} className="rounded-xl border border-line bg-panel p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{portrait && <img src={portrait} alt="" className="h-9 w-9 shrink-0 rounded-full object-cover" />} {portrait && <img src={portrait} alt="" className="h-9 w-9 shrink-0 rounded-full border border-line object-cover" />}
<span className="truncate font-display text-lg font-semibold text-ink">{c.name}</span> <span className="truncate font-display text-lg font-semibold text-ink">{c.name}</span>
</div> </div>
<span className="shrink-0 text-sm text-muted">Lv {c.level} · AC {c.ac}</span> <span className="shrink-0 font-mono text-xs text-muted">Lv {c.level} · AC {c.ac}</span>
</div> </div>
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface"> <div className="mt-2">
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} /> <Meter value={c.hp.current} max={c.hp.max} tone={hpTone} height={10} />
</div> </div>
<div className="mt-1 text-right text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div> <div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
{c.conditions.length > 0 && ( {c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => ( {c.conditions.map((cond, i) => (
@@ -74,24 +86,24 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
</section> </section>
<section> <section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted"> <h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
Initiative {encounter ? `· Round ${encounter.round}` : ''} <Swords size={13} aria-hidden /> Initiative {encounter ? `· Round ${encounter.round}` : ''}
</h2> </h2>
{!encounter ? ( {!encounter ? (
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." /> <EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
) : ( ) : (
<ol className="space-y-1"> <ol className="space-y-1">
{encounter.combatants.map((c) => ( {encounter.combatants.map((c) => (
<li key={c.id} className={cn('flex items-center gap-3 rounded-md border px-3 py-2', c.isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel')}> <li key={c.id} className={cn('flex items-center gap-3 rounded-xl border px-3 py-2', c.isCurrent ? 'border-accent bg-accent-glow ring-1 ring-accent/40' : 'border-line bg-panel')}>
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span> <span className="w-8 text-center font-mono text-lg font-semibold text-accent-deep">{c.initiative}</span>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2"> <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
<span className="text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span> <span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
{c.conditions.map((cond, i) => ( {c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span> <span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))} ))}
</span> </span>
{c.hp ? ( {c.hp ? (
<span className="shrink-0 text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span> <span className="shrink-0 font-mono text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
) : c.status ? ( ) : c.status ? (
<span className={cn('shrink-0 text-sm font-medium', c.status.cls)}>{c.status.label}</span> <span className={cn('shrink-0 text-sm font-medium', c.status.cls)}>{c.status.label}</span>
) : null} ) : null}
+7 -3
View File
@@ -40,13 +40,17 @@ function Shell({ title, subtitle, children }: { title: string; subtitle: string;
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {}); const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
return ( return (
<Page> <Page>
<div className="mb-6 flex items-end justify-between"> <div className="mb-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 className="font-display text-3xl font-bold text-accent">{title}</h1> <div className="font-display text-sm italic text-accent">The table is live</div>
<p className="text-sm text-muted">{subtitle}</p> <h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
<p className="mt-1 text-sm text-muted">{subtitle}</p>
</div> </div>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button> <Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div> </div>
<hr className="gilt-rule mt-3" />
</div>
{children} {children}
</Page> </Page>
); );
+13 -7
View File
@@ -1,7 +1,9 @@
import { UserPlus } from 'lucide-react';
import type { Snapshot } from '@/lib/sync/messages'; import type { Snapshot } from '@/lib/sync/messages';
import type { Character } from '@/lib/schemas'; import type { Character } from '@/lib/schemas';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/Page'; import { EmptyState } from '@/components/ui/Page';
import { Avatar, Badge } from '@/components/ui/Codex';
/** /**
* Shown to a joined player before they control a character: pick "you" from the * Shown to a joined player before they control a character: pick "you" from the
@@ -18,17 +20,21 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />; return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />;
} }
return ( return (
<section className="mb-6"> <section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Which character is yours?</h2> <div className="font-display text-sm italic text-accent">Claim your seat</div>
<p className="mb-3 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p> <h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
<hr className="gilt-rule my-3" />
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{snapshot.party.map((c) => { {snapshot.party.map((c) => {
const hasOffline = localChars.has(c.id); const hasOffline = localChars.has(c.id);
return ( return (
<div key={c.id} className="flex items-center gap-3 rounded-lg border border-line bg-panel p-3"> <div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
<div className="flex-1"> <Avatar name={c.name} size={40} />
<div className="font-display text-lg font-semibold text-ink">{c.name}</div> <div className="min-w-0 flex-1">
<div className="text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP{hasOffline ? ' · offline edits ready' : ''}</div> <div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
<div className="font-mono text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP</div>
{hasOffline && <Badge tone="arcane" className="mt-1"><UserPlus size={11} aria-hidden /> offline edits ready</Badge>}
</div> </div>
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button> <Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
</div> </div>
+97 -33
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 { 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 { useUiStore, type Theme } from '@/stores/uiStore';
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup'; import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
import { pickTextFile } from '@/lib/io/file'; import { pickTextFile } from '@/lib/io/file';
@@ -13,6 +25,43 @@ import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn'; 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() { export function SettingsPage() {
const theme = useUiStore((s) => s.theme); const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme); const setTheme = useUiStore((s) => s.setTheme);
@@ -44,29 +93,35 @@ export function SettingsPage() {
return ( return (
<Page> <Page>
<PageHeader title="Settings" subtitle="Appearance, backups, and data." /> <PageHeader eyebrow="Preferences" title="Settings" subtitle="Appearance, backups, and data." />
<section className="mb-8"> <div className="space-y-6">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</h2> <SettingsCard label="Appearance">
<div className="flex gap-2"> <SettingsRow
icon={theme === 'dark' ? Moon : Sun}
title="Theme"
desc="Parchment by day, candlelight by night."
>
{(['dark', 'light'] as Theme[]).map((t) => ( {(['dark', 'light'] as Theme[]).map((t) => (
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} onClick={() => setTheme(t)} className="capitalize"> <Button key={t} variant={theme === t ? 'primary' : 'secondary'} size="sm" onClick={() => setTheme(t)} className="capitalize">
{t} {t}
</Button> </Button>
))} ))}
</div> </SettingsRow>
</section> </SettingsCard>
<section className="mb-8"> <SettingsCard label="Data">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data</h2> <SettingsRow icon={Download} title="Export backup" desc="Download a single JSON file with every campaign and all of its data.">
<div className="flex flex-wrap gap-2"> <Button variant="secondary" size="sm" onClick={() => void exportBackup()}>Export backup</Button>
<Button variant="secondary" onClick={() => void exportBackup()}>Export backup</Button> </SettingsRow>
<Button variant="secondary" onClick={doImport}>Import backup</Button> <SettingsRow icon={Upload} title="Import backup" desc="Restore from a previously exported file. Replaces current data.">
<Button variant="secondary" onClick={loadSample}>Load sample campaign</Button> <Button variant="secondary" size="sm" onClick={doImport}>Import backup</Button>
</div> </SettingsRow>
<p className="mt-2 text-xs text-muted">Backups include every campaign and all of its data, as a single JSON file.</p> <SettingsRow icon={Sparkles} title="Sample campaign" desc="Load a ready-made campaign to explore the app.">
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>} <Button variant="secondary" size="sm" onClick={loadSample}>Load sample campaign</Button>
</section> </SettingsRow>
{msg && <p className={cn('mt-3 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
</SettingsCard>
<AssistantSettings /> <AssistantSettings />
@@ -74,20 +129,25 @@ export function SettingsPage() {
<CloudCampaigns /> <CloudCampaigns />
<section className="rounded-lg border border-line bg-panel p-4"> <SettingsCard label="Data sources & licenses">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2> <SettingsRow icon={ScrollText} title="Attribution" desc="Open-licensed game content powering the compendium.">
<ul className="space-y-1 text-sm text-muted"> <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">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><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> <li className="text-xs">Map import follows the Universal VTT (.dd2vtt) format used by Dungeondraft, Foundry &amp; others.</li>
</ul> </ul>
</section> </SettingsCard>
<section className="rounded-lg border border-danger/40 bg-danger/5 p-4"> <section className="rounded-xl border border-danger/40 bg-danger/5 p-4 sm:p-5">
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2> <h2 className="flex items-center gap-1.5 text-sm font-semibold text-danger">
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p> <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> <Button variant="danger" onClick={() => setConfirmClear(true)}>Clear all data</Button>
</section> </section>
</div>
<Modal <Modal
open={!!confirmRestore} open={!!confirmRestore}
@@ -141,12 +201,16 @@ function CloudSync() {
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); }); const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
return ( return (
<section className="rounded-lg border border-line bg-panel p-4"> <SettingsCard label="Cloud sync (optional)">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Cloud sync (optional)</h2> <SettingsRow
<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> 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 ? ( {user ? (
<div className="space-y-2"> <div className="space-y-3 pt-4">
<p className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button> <Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button> <Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button>
@@ -154,19 +218,19 @@ function CloudSync() {
</div> </div>
</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" 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" /> <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="primary" disabled={busy || !u || !p} onClick={() => auth('in')}>Sign in</Button>
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button> <Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
</div> </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?" <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></>}> 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> <p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
</Modal> </Modal>
</section> </SettingsCard>
); );
} }
+175 -34
View File
@@ -1,12 +1,15 @@
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; 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 { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
import { cn } from '@/lib/cn';
import { useCharacters } from '@/features/characters/hooks'; import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks'; import { useEncounters } from '@/features/combat/hooks';
import { useNotes, useNpcs, useQuests, useCalendar } from './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() { export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>; return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
@@ -42,12 +45,24 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
return ( return (
<Page> <Page>
<PageHeader {/* Editorial hero */}
title={campaign.name} <section className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
subtitle={`${sys.label}${calendar ? ` · in-world day ${calendar.currentDay}` : ''}`} <div className="px-7 py-7 sm:px-9">
/> <div className="font-display text-sm italic text-accent">
{sys.label}
{campaign.description && <p className="mb-6 max-w-3xl text-sm text-muted">{campaign.description}</p>} {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 */} {/* Quick stats */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <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]"> <div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Navigation cards */} {/* Navigation cards */}
<section> <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"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{LINKS.map((l) => ( {LINKS.map((l) => (
<Link <Link
key={l.to} key={l.to}
to={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-2xl" aria-hidden>{l.icon}</span>
<span className="text-sm text-ink">{l.label}</span> <span className="text-sm text-ink">{l.label}</span>
@@ -76,54 +91,180 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
</div> </div>
{/* Party overview */} {/* 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 ? ( {pcs.length === 0 ? (
<p className="text-sm text-muted">No player characters yet.</p> <p className="text-sm text-muted">No player characters yet.</p>
) : ( ) : (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{pcs.map((c) => ( {pcs.map((c) => (
<Link <PartyRow key={c.id} c={c} sys={sys} />
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>
))} ))}
</div> </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> </section>
{/* Recent rolls */} {/* Side column: quests + recent rolls */}
<aside> <aside className="space-y-6">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2> <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 ? ( {rolls.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p> <p className="text-sm text-muted">No rolls yet.</p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1.5">
{rolls.map((r) => ( {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"> <RollRow key={r.id} r={r} />
<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> </ul>
)} )}
</div>
</aside> </aside>
</div> </div>
</Page> </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 ( return (
<div className="rounded-lg border border-line bg-panel p-4 text-center"> <Link
<div className="font-display text-3xl font-bold text-accent">{value}</div> to="/characters/$characterId"
<div className="text-xs uppercase tracking-wide text-muted">{label}</div> 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> </div>
); );
} }
+29 -13
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Map as MapIcon, Cast, Download, ExternalLink, X } from 'lucide-react';
import type { BattleMap, Campaign } from '@/lib/schemas'; import type { BattleMap, Campaign } from '@/lib/schemas';
import { mapsRepo } from '@/lib/db/repositories'; import { mapsRepo } from '@/lib/db/repositories';
import { parseUvtt, toUvtt } from '@/lib/vtt/uvtt'; import { parseUvtt, toUvtt } from '@/lib/vtt/uvtt';
@@ -68,6 +69,7 @@ function Maps({ campaign }: { campaign: Campaign }) {
return ( return (
<Page> <Page>
<PageHeader <PageHeader
eyebrow="The cartographer's table"
title="Maps" title="Maps"
subtitle={campaign.name} subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>}
@@ -79,30 +81,44 @@ function Maps({ campaign }: { campaign: Campaign }) {
{maps.length === 0 ? ( {maps.length === 0 ? (
<EmptyState title="No maps yet" hint="Upload an image or import a Universal VTT (.dd2vtt / .uvtt) map — walls, doors and lights come across — then add a grid, fog, tokens, and show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} /> <EmptyState title="No maps yet" hint="Upload an image or import a Universal VTT (.dd2vtt / .uvtt) map — walls, doors and lights come across — then add a grid, fog, tokens, and show it to players." action={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>} />
) : ( ) : (
<div className="grid gap-4 lg:grid-cols-[210px_1fr]"> <div className="grid gap-4 lg:grid-cols-[230px_1fr]">
<ul className="space-y-1"> <ul className="space-y-1.5">
{maps.map((mp) => ( {maps.map((mp) => {
<li key={mp.id} className="group flex items-center gap-1 rounded-md border border-line"> const active = selected?.id === mp.id;
<button className={cn('flex-1 truncate px-3 py-2 text-left text-sm', selected?.id === mp.id ? 'text-accent' : 'text-ink')} onClick={() => setSelectedId(mp.id)}> return (
{mp.name}{activeMapId === mp.id ? ' 📺' : ''} <li key={mp.id} className={cn('group flex items-center gap-1 rounded-xl border bg-panel transition-colors', active ? 'border-accent bg-accent-glow' : 'border-line hover:border-line-strong')}>
<button className={cn('flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm', active ? 'text-accent-deep' : 'text-ink')} onClick={() => setSelectedId(mp.id)}>
<MapIcon size={15} className={active ? 'text-accent-deep' : 'text-muted'} aria-hidden />
<span className="truncate font-display font-medium">{mp.name}</span>
{activeMapId === mp.id && <Cast size={13} className="ml-auto shrink-0 text-accent-deep" aria-hidden />}
</button>
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}>
<X size={14} aria-hidden />
</button> </button>
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}></button>
</li> </li>
))} );
})}
</ul> </ul>
{selected ? ( {selected ? (
<div> <div>
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2">
<Button size="sm" variant={activeMapId === selected.id ? 'primary' : 'secondary'} onClick={() => setActiveMap(activeMapId === selected.id ? null : selected.id)}> <Button size="sm" variant={activeMapId === selected.id ? 'primary' : 'secondary'} onClick={() => setActiveMap(activeMapId === selected.id ? null : selected.id)}>
{activeMapId === selected.id ? '📺 Showing to players' : 'Show to players'} <Cast size={15} aria-hidden />
{activeMapId === selected.id ? 'Showing to players' : 'Show to players'}
</Button>
<Link to="/play" className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent-deep hover:underline">Open player view <ExternalLink size={13} aria-hidden /></Link>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => void exportUvtt(selected)} title="Export as Universal VTT (.uvtt)">
<Download size={15} aria-hidden />
Export .uvtt
</Button> </Button>
<Link to="/play" className="text-sm text-accent hover:underline">Open player view </Link>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => void exportUvtt(selected)} title="Export as Universal VTT (.uvtt)">Export .uvtt</Button>
</div> </div>
<MapEditor key={selected.id} map={selected} campaign={campaign} /> <MapEditor key={selected.id} map={selected} campaign={campaign} />
</div> </div>
) : ( ) : (
<div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a map.</div> <div className="grid place-items-center rounded-xl border border-dashed border-line bg-panel/50 p-10 text-center text-sm text-muted">
<MapIcon size={26} className="mb-2 text-faint" aria-hidden />
Select a map.
</div>
)} )}
</div> </div>
)} )}
+33 -10
View File
@@ -1,4 +1,8 @@
import { useMemo, useRef, useState } from 'react'; import { useMemo, useRef, useState } from 'react';
import {
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
ChevronRight, type LucideIcon,
} from 'lucide-react';
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas'; import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
import { mapsRepo, encountersRepo } from '@/lib/db/repositories'; import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
import { nextTurn, previousTurn, applyDamage, applyHealing, updateCombatant, logEvent } from '@/lib/combat/engine'; import { nextTurn, previousTurn, applyDamage, applyHealing, updateCombatant, logEvent } from '@/lib/combat/engine';
@@ -24,6 +28,11 @@ import { TokenPalette, type TokenSpec } from './TokenPalette';
const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000']; const TOKEN_COLORS = ['#d4af37', '#ef5350', '#66bb6a', '#64b5f6', '#ba68c8', '#ffa726', '#bdbdbd', '#000000'];
type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping' | 'walls'; type Tool = 'move' | 'reveal' | 'hide' | 'measure' | 'aoe' | 'draw' | 'ping' | 'walls';
const TOOL_ICON: Record<Tool, LucideIcon> = {
move: Move, reveal: ScanEye, hide: EyeOff, measure: Ruler,
aoe: Cloud, draw: PenTool, ping: Crosshair, walls: Spline,
};
/** Distance from point p to segment a-b (image px). */ /** Distance from point p to segment a-b (image px). */
function distToSegment(p: Point, a: Point, b: Point): number { function distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x, dy = b.y - a.y; const dx = b.x - a.x, dy = b.y - a.y;
@@ -238,17 +247,28 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
)} )}
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm"> <div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2 text-sm paper-grain">
{!showPalette && <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>Tokens </Button>} {!showPalette && (
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" /> <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => ( Tokens <ChevronRight size={14} aria-hidden />
</Button>
)}
<Input className="h-8 max-w-36 font-display" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
<span className="mx-1 h-5 w-px bg-line" aria-hidden />
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => {
const ToolIcon = TOOL_ICON[t];
return (
<Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t} <Button key={t} size="sm" variant={tool === t ? 'primary' : 'secondary'} aria-pressed={tool === t}
onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">{t}</Button> onClick={() => { setTool(t); setPoly([]); setOverlay({}); anchor.current = null; }} className="capitalize">
))} <ToolIcon size={15} aria-hidden />
{t}
</Button>
);
})}
</div> </div>
{/* Contextual sub-toolbar */} {/* Contextual sub-toolbar */}
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-xs text-muted"> <div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel-2 p-2 text-xs text-muted">
<label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label> <label className="flex items-center gap-1"><input type="checkbox" checked={m.showGrid} onChange={(e) => update({ showGrid: e.target.checked })} /> Grid</label>
<label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label> <label className="flex items-center gap-1"><input type="checkbox" checked={m.fogEnabled} onChange={(e) => update({ fogEnabled: e.target.checked })} /> Fog</label>
<label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label> <label className="flex items-center gap-1" title="Auto-reveal fog from party line of sight (walls block sight)"><input type="checkbox" checked={m.dynamicVision} onChange={(e) => update(e.target.checked ? { dynamicVision: true, fogEnabled: true, revealed: visionReveal(m.tokens, m.revealed) } : { dynamicVision: false })} /> Vision</label>
@@ -300,12 +320,15 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
</> </>
)} )}
<span className="mx-1 h-4 w-px bg-line" /> <span className="mx-1 h-4 w-px bg-line" />
<Button size="sm" variant="secondary" onClick={addToken}>+ Token</Button> <Button size="sm" variant="secondary" onClick={addToken}>
<CirclePlus size={14} aria-hidden />
+ Token
</Button>
</div> </div>
{activeEncounter && ( {activeEncounter && (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/5 p-2 text-sm"> <div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
<span className="text-xs uppercase tracking-wide text-muted">Combat · Round {activeEncounter.round}</span> <span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
<span className="font-display font-semibold text-ink"> {activeCombatant?.name ?? '—'}</span> <span className="font-display font-semibold text-ink"> {activeCombatant?.name ?? '—'}</span>
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}> Prev</Button> <Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}> Prev</Button>
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn </Button> <Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn </Button>