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();
await player.goto(`/play?room=${code}`);
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.
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
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.getByTestId('map-token')).toHaveCount(1);
});
+64 -13
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
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 { seedSampleCampaign } from '@/lib/sample';
import { SYSTEM_OPTIONS } from '@/lib/rules';
@@ -8,6 +10,7 @@ import { useCampaigns } from './hooks';
import type { Campaign } from '@/lib/schemas';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal';
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
@@ -19,6 +22,7 @@ export function CampaignsPage() {
return (
<Page>
<PageHeader
eyebrow="Your tables"
title="Campaigns"
subtitle="Each campaign holds its own characters, encounters, and dice history."
actions={
@@ -55,31 +59,35 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
void navigate({ to: '/dashboard' });
};
return (
<div className="mx-auto max-w-2xl rounded-xl border border-line bg-panel p-6 text-center">
<h2 className="font-display text-2xl font-bold text-accent">Welcome to TTRPG Manager</h2>
<div className="paper-grain relative mx-auto max-w-2xl overflow-hidden rounded-xl border border-line bg-panel p-7 text-center">
<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">
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
play your players join from their own devices. Everything is stored on this device.
</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="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." />
</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="secondary" disabled={loading} onClick={() => void loadSample()}>{loading ? 'Loading…' : 'Try the sample campaign'}</Button>
</div>
</div>
</div>
);
}
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
return (
<div className="rounded-lg border border-line bg-surface p-3">
<div className="text-xl">{icon}</div>
<div className="mt-1 font-semibold text-ink">{title}</div>
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
<div className="text-xl" aria-hidden>{icon}</div>
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
<div className="text-xs text-muted">{desc}</div>
</div>
);
@@ -96,20 +104,62 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
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 (
<div className="group relative flex flex-col rounded-lg border border-line bg-panel p-4">
{activeId === campaign.id && (
<span className="absolute right-3 top-3 rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
<div
className={cn(
'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
</span>
)}
<div className="text-xs font-medium uppercase tracking-wide text-muted">
{campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e'}
</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 && (
<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">
<Button size="sm" variant="primary" onClick={open}>
Open
@@ -126,6 +176,7 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
Delete
</Button>
</div>
</div>
<Modal
open={confirming}
+62 -26
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
@@ -9,8 +10,10 @@ import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll';
import { cn } from '@/lib/cn';
import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format';
@@ -88,38 +91,61 @@ export function CharacterSheet({ character }: { character: Character }) {
return (
<Page>
<div className="mb-4">
<Link to="/characters" className="text-sm text-muted hover:text-ink">
Back to characters
<Link to="/characters" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft className="h-4 w-4" aria-hidden />
Back to characters
</Link>
</div>
{/* Header */}
<div className="mb-6 flex flex-wrap items-center gap-3">
<label className="group relative h-14 w-14 shrink-0 cursor-pointer overflow-hidden rounded-full border border-line bg-surface" title="Upload portrait (also used as the map token)">
{/* Header hero */}
<div className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
<div className="flex flex-wrap items-center gap-x-5 gap-y-4 p-5">
<label className="group relative h-20 w-20 shrink-0 cursor-pointer overflow-hidden rounded-2xl border border-line bg-surface-2" title="Upload portrait (also used as the map token)">
{c.portrait
? <img src={c.portrait} alt="" className="h-full w-full object-cover" />
: <span className="grid h-full w-full place-items-center text-[10px] text-muted">+ art</span>}
<span className="absolute inset-x-0 bottom-0 bg-black/55 text-center text-[9px] text-white opacity-0 transition group-hover:opacity-100">edit</span>
<input type="file" accept="image/*" className="hidden" aria-label="Portrait" onChange={(e) => { const f = e.target.files?.[0]; if (f) void onPortrait(f); e.target.value = ''; }} />
</label>
<div className="min-w-0 flex-1">
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
<Input
className="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}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
/>
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-sm text-muted">{profLabel}</span>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge tone="gold">Level {c.level}</Badge>
<Badge>{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}</Badge>
<Badge tone="default">
<Award className="h-3 w-3" aria-hidden />
{profLabel}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
{[
{ icon: Shield, value: ac, label: 'Armor' },
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
{ icon: Footprints, value: c.speed, label: 'Speed' },
].map(({ icon: Ic, value, label }) => (
<div key={label} className="flex w-[4.5rem] flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center">
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
<span className="smallcaps text-[8.5px]">{label}</span>
</div>
))}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div>
</div>
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="paper-grain mb-6 grid gap-4 rounded-xl border border-line bg-panel p-4 sm:grid-cols-2 lg:grid-cols-4">
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
@@ -150,7 +176,7 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Abilities */}
<section className="mb-6">
<div className="mb-2 flex items-center justify-between">
<div className="mb-3 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
</div>
@@ -158,10 +184,11 @@ export function CharacterSheet({ character }: { character: Character }) {
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
return (
<div key={a} className="rounded-lg border border-line bg-panel p-3 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
<div className="my-1 font-display text-2xl font-bold text-accent">{formatModifier(mod)}</div>
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center">
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
<NumberField
className="mt-1"
value={c.abilities[a]}
min={1}
max={30}
@@ -270,14 +297,18 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
}
setDelta(0);
};
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Hit Points</div>
<div className="my-1 font-display text-2xl font-bold text-ink">
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">Hit Points</div>
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span>
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div>
<div className="mb-2">
<Meter value={c.hp.current} max={c.hp.max} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
</div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
@@ -311,11 +342,16 @@ function RankRow({
onRank: (r: ProficiencyRank) => void;
system: SystemId;
}) {
const proficient = rank !== 'untrained';
return (
<div className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5">
<div className="flex items-center gap-2 rounded-lg border border-line bg-panel px-3 py-1.5">
<span
className={cn('h-2 w-2 shrink-0 rounded-full', proficient ? 'bg-accent' : 'bg-line-strong')}
aria-hidden
/>
<button
onClick={() => rollCheck(modifier, label, { system })}
className="w-9 rounded font-display text-lg font-semibold text-accent hover:bg-accent/10"
className="w-9 rounded font-mono font-display text-lg font-semibold text-accent-deep hover:bg-accent/10"
title={`Roll ${label}`}
>
{formatModifier(modifier)}
@@ -335,7 +371,7 @@ function RankRow({
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted">{label}</span>
<span className="smallcaps mb-1 block">{label}</span>
{children}
</label>
);
@@ -343,9 +379,9 @@ function Labeled({ label, children }: { label: string; children: React.ReactNode
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
<div className="my-1 font-display text-3xl font-bold text-accent">{value}</div>
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">{label}</div>
<div className="my-1 font-mono font-display text-3xl font-semibold text-accent-deep">{value}</div>
{hint && <div className="text-xs text-muted">{hint}</div>}
{children}
</div>
@@ -353,5 +389,5 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-2 font-display text-lg font-semibold text-ink">{children}</h2>;
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
}
+31 -11
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { Heart, Shield } from 'lucide-react';
import { charactersRepo } from '@/lib/db/repositories';
import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
@@ -8,6 +9,7 @@ import { pickTextFile } from '@/lib/io/file';
import { useCharacters } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Avatar, Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal';
import { CreationWizard } from './builder/CreationWizard';
@@ -37,6 +39,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
return (
<Page>
<PageHeader
eyebrow="The Roster"
title="Characters"
subtitle={campaign.name}
actions={
@@ -83,7 +86,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
if (items.length === 0) return null;
return (
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
<h2 className="smallcaps mb-3">{title}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((c) => (
<CharacterCard key={c.id} c={c} />
@@ -98,25 +101,42 @@ function CharacterCard({ c }: { c: Character }) {
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
return (
<div className="flex flex-col rounded-lg border border-line bg-panel transition-colors hover:border-accent/50">
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
<Link
to="/characters/$characterId"
params={{ characterId: c.id }}
className="block p-4"
>
<div className="flex items-baseline justify-between">
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
<span className="text-sm text-muted">Lv {c.level}</span>
<div className="flex items-start gap-3">
{c.portrait ? (
<img
src={c.portrait}
alt=""
className="h-12 w-12 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<Avatar name={c.name} size={48} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="truncate font-display text-lg font-semibold text-ink">{c.name}</h3>
<Badge tone="gold">Lv {c.level}</Badge>
</div>
<p className="mt-0.5 text-sm text-muted">
<p className="mt-0.5 truncate text-sm text-muted">
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
</p>
<div className="mt-3 flex gap-4 text-sm">
<span className="text-muted">
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<span className="flex items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-2 py-1.5 text-sm">
<Heart className="h-3.5 w-3.5 text-danger" aria-hidden />
<span className="smallcaps text-[10px]">HP</span>
<span className="font-mono font-semibold text-ink">{c.hp.current}/{c.hp.max}</span>
</span>
<span className="text-muted">
AC <span className="font-medium text-ink">{ac}</span>
<span className="flex items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-2 py-1.5 text-sm">
<Shield className="h-3.5 w-3.5 text-accent-deep" aria-hidden />
<span className="smallcaps text-[10px]">AC</span>
<span className="font-mono font-semibold text-ink">{ac}</span>
</span>
</div>
</Link>
+21 -8
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Swords, X } from 'lucide-react';
import type { Campaign } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories';
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 { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { useEncounters, useEncounter } from './hooks';
import { EncounterTracker } from './EncounterTracker';
import { RollFeed } from '@/features/player/RollFeed';
@@ -38,6 +40,7 @@ function Combat({ campaign }: { campaign: Campaign }) {
return (
<Page>
<PageHeader
eyebrow="Roll for initiative"
title="Combat"
subtitle={campaign.name}
actions={
@@ -59,23 +62,33 @@ function Combat({ campaign }: { campaign: Campaign }) {
/>
) : (
<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) => (
<div
key={e.id}
className={
'group flex items-center gap-1 rounded-md border px-2 ' +
(validSelection?.id === e.id ? 'border-accent bg-elevated' : 'border-line')
}
className={cn(
'group flex items-center gap-1 rounded-xl border px-2 transition-colors',
validSelection?.id === e.id
? 'border-accent bg-accent-glow'
: 'border-line bg-panel hover:border-line-strong',
)}
>
<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)}
>
<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">
{STATUS_LABEL[e.status]} · {e.combatants.length} combatants
</span>
</span>
</button>
<button
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);
}}
>
<X size={15} aria-hidden />
</button>
</div>
))}
+123 -43
View File
@@ -1,4 +1,20 @@
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 { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
@@ -27,6 +43,7 @@ import { cn } from '@/lib/cn';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Badge, Meter, Avatar } from '@/components/ui/Codex';
import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
@@ -70,31 +87,41 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
return (
<div>
{/* 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 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">
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'}
{current && isActive ? ` · ${current.name}'s turn` : ''}
</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 && (
<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])}>
{budget.difficulty}
</div>
<div className="text-muted">
<div className="font-mono text-muted">
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
</div>
</div>
)}
<div className="ml-auto flex flex-wrap gap-2">
{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 && (
<Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone">
🎲 Roll all
<Dices size={15} aria-hidden /> Roll all
</Button>
)}
{!isActive && encounter.status !== 'ended' && (
@@ -112,10 +139,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
{isActive && (
<>
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
Prev
<ChevronLeft size={15} aria-hidden /> Prev
</Button>
<Button variant="primary" onClick={() => mutate(nextTurn)}>
Next turn
Next turn <ChevronRight size={15} aria-hidden />
</Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
End
@@ -138,7 +165,11 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
{encounter.combatants.length === 0 ? (
<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) => (
<CombatantRow
key={c.id}
@@ -154,6 +185,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
/>
))}
</ul>
</>
)}
<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();
return (
<div className="mt-6">
<div className="mb-1 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Combat Log</h3>
<div className="mb-1.5 flex items-center justify-between">
<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>
</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) => (
<li key={i} className="text-muted">
<span className="mr-2 text-accent/70">R{l.round}</span>
<li key={i} className="flex items-start gap-2 text-ink-soft">
<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}
</span>
</li>
))}
</ul>
@@ -245,7 +284,7 @@ function AddCombatantBar({
};
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">
<label className="flex-1 min-w-40 text-xs text-muted">
Name
@@ -318,14 +357,22 @@ function CombatantRow({
}) {
const [delta, setDelta] = useState(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 (
<li
className={
'rounded-lg border bg-panel p-3 ' +
(isCurrent ? 'border-accent ring-1 ring-accent/40' : 'border-line') +
(dead ? ' opacity-60' : '')
}
className={cn(
'rounded-xl border p-3 transition-colors',
isCurrent
? 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-col items-center">
@@ -333,58 +380,91 @@ function CombatantRow({
<span className="mt-0.5 text-[10px] uppercase text-muted">init</span>
</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="flex items-center gap-2">
<span className="font-medium text-ink">{c.name}</span>
<span className="rounded bg-elevated px-1.5 text-[10px] uppercase text-muted">{c.kind}</span>
{dead && <span className="text-xs font-semibold text-danger">DOWN</span>}
<span className="font-display font-semibold text-ink">{c.name}</span>
{isCurrent && (
<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>
{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) => (
<button
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'}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
{cond.name}
{cond.value ? ` ${cond.value}` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''}
<X size={11} aria-hidden />
</button>
))}
</div>
)}
</div>
{/* HP */}
<div className="text-center">
<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">
{/* HP controls */}
<div className="flex items-center gap-1">
<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">
Dmg
<Sword size={14} aria-hidden /> Dmg
</Button>
<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>
</div>
</div>
<div className="text-center text-sm">
<div className="text-muted">AC</div>
<NumberField className="w-12" value={c.ac} min={0} onChange={(ac) => onChange({ ac })} aria-label={`${c.name} AC`} />
<div className="flex flex-col items-center">
<div className="flex items-center gap-1 text-muted">
<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 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 down" title="Move down"></Button>
<Button size="icon" variant="ghost" onClick={() => onMove(-1)} aria-label="Move up" title="Move up">
<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>
<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 className="mt-2">
+33 -14
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Search, BookOpenText } from 'lucide-react';
import Fuse from 'fuse.js';
import type { SystemId } from '@/lib/rules';
import { SYSTEM_OPTIONS } from '@/lib/rules';
@@ -126,17 +127,18 @@ export function CompendiumPage() {
return (
<Page>
<PageHeader
eyebrow="The Codex"
title="Compendium"
subtitle="Searchable reference — bestiary, spells, items, feats, and more."
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) => (
<button
key={o.id}
onClick={() => changeSystem(o.id)}
className={cn(
'rounded px-3 py-1 text-sm transition-colors',
system === o.id ? 'bg-accent text-accent-ink' : 'text-muted hover:text-ink',
'rounded-md px-3 py-1 text-sm transition-colors',
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}
@@ -153,8 +155,10 @@ export function CompendiumPage() {
key={c.id}
onClick={() => setCategoryId(c.id)}
className={cn(
'rounded-md 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',
'rounded-md border px-3 py-1.5 text-sm transition-colors',
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}
@@ -164,12 +168,20 @@ export function CompendiumPage() {
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
<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
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${category.label.toLowerCase()}`}
aria-label="Search compendium"
className="pl-9"
/>
</div>
{category.filters.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
{category.filters.map((f) => {
@@ -203,7 +215,7 @@ export function CompendiumPage() {
>
{sortOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</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'}`}
</span>
</div>
@@ -220,15 +232,17 @@ export function CompendiumPage() {
<button
onClick={() => setSelected(entry)}
className={cn(
'flex h-full w-full items-center justify-between gap-2 rounded-md border px-3 text-left text-sm',
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
'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-accent-glow'
: 'border-line bg-panel hover:border-accent/40',
)}
>
<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>}
<span className="truncate text-ink">{entry.name}</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 font-display font-medium text-ink">{entry.name}</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>
</div>
);
@@ -237,14 +251,17 @@ export function CompendiumPage() {
</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 ? (
<div>
<DetailActions category={category} entry={selected} system={system} />
{isHomebrew(selected) ? <HomebrewDetail entry={selected} /> : category.detail(selected)}
</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>
@@ -271,7 +288,9 @@ function DetailActions({ category, entry, system }: { category: CategoryDef; ent
}
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 } }) {
+45 -26
View File
@@ -7,6 +7,17 @@ function actionList(value: MonsterAction[] | string | undefined): MonsterAction[
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 }) {
const abilities: [string, number | undefined][] = [
['STR', m.strength],
@@ -16,44 +27,52 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
['WIS', m.wisdom],
['CHA', m.charisma],
];
const subtitle = [m.size, m.type, m.alignment].filter(Boolean).join(', ');
return (
<div className="space-y-4">
<div className="font-display">
<header>
<h2 className="font-display text-2xl font-bold text-accent">{m.name}</h2>
<p className="text-sm italic text-muted">
{[m.size, m.type, m.alignment].filter(Boolean).join(', ')}
</p>
<h2 className="font-display text-3xl font-semibold tracking-tight text-danger">{m.name}</h2>
{subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
</header>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
<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>
<span><strong className="text-ink">CR</strong> {crLabel(m)}</span>
{m.speed?.walk !== undefined && <span><strong className="text-ink">Speed</strong> {m.speed.walk} ft.</span>}
<hr className="gilt-rule my-4" />
<div className="space-y-1">
<StatLine label="Armor Class">{m.armor_class ?? '—'}{m.armor_desc ? ` (${m.armor_desc})` : ''}</StatLine>
<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 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]) => (
<div key={label}>
<div className="text-xs font-semibold text-muted">{label}</div>
<div className="text-ink">{score ?? '—'}</div>
<div className="text-xs text-accent">
<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">
<span className="smallcaps text-danger" style={{ fontSize: 9 }}>{label}</span>
<span className="font-display text-lg font-semibold text-ink">{score ?? '—'}</span>
<span className="font-mono text-xs text-muted">
{score !== undefined ? formatModifier(abilityModifier(score)) : ''}
</div>
</span>
</div>
))}
</div>
{(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>}
{m.damage_immunities && <p><strong className="text-ink">Immunities</strong> {m.damage_immunities}</p>}
{m.condition_immunities && <p><strong className="text-ink">Condition Immunities</strong> {m.condition_immunities}</p>}
{m.senses && <p><strong className="text-ink">Senses</strong> {m.senses}</p>}
{m.languages && <p><strong className="text-ink">Languages</strong> {m.languages}</p>}
<>
<hr className="gilt-rule my-4" />
<div className="space-y-1">
<StatLine label="Resistances">{m.damage_resistances}</StatLine>
<StatLine label="Immunities">{m.damage_immunities}</StatLine>
<StatLine label="Condition Immunities">{m.condition_immunities}</StatLine>
<StatLine label="Senses">{m.senses}</StatLine>
<StatLine label="Languages">{m.languages}</StatLine>
</div>
</>
)}
<hr className="gilt-rule my-4" />
<StatLine label="Challenge">{crLabel(m)}</StatLine>
<ActionBlock title="Special Abilities" actions={actionList(m.special_abilities)} />
<ActionBlock title="Actions" actions={actionList(m.actions)} />
<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[] }) {
if (actions.length === 0) return null;
return (
<section>
<h3 className="mb-1 border-b border-line pb-1 font-display text-lg font-semibold text-ink">{title}</h3>
<section className="mt-5">
<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">
{actions.map((a, i) => (
<p key={`${a.name}-${i}`} className="text-sm text-muted">
<strong className="text-ink">{a.name}.</strong> {a.desc}
<p key={`${a.name}-${i}`} className="text-sm leading-relaxed text-ink-soft">
<strong className="italic text-ink">{a.name}.</strong> {a.desc}
</p>
))}
</div>
+82 -44
View File
@@ -8,17 +8,53 @@ import type {
CompendiumEntry,
} from '@/lib/compendium/types';
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);
/** 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 }) {
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 }) {
return (
<div className="space-y-2 text-sm">
{c.description && <p className="text-muted">{c.description}</p>}
<div className="space-y-3 text-sm">
{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}`} />
{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(', ')} />}
@@ -27,34 +63,24 @@ export function ClassDetail({ c }: { c: RulesetClass }) {
{c.caster !== 'none' && <ClassRow label="Spellcasting" value={cap(c.caster)} />}
{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(', ')} />}
</div>
{c.subclasses.length > 0 && (
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{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>
<div className="smallcaps mb-2">{c.system === 'pf2e' ? 'Options' : 'Subclasses'}</div>
<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>
)}
<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>
);
}
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 }) {
if (!traits || traits.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{traits.map((t) => (
<span key={t} className="rounded bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
{t}
</span>
<Badge key={t}>{t}</Badge>
))}
</div>
);
@@ -63,17 +89,22 @@ function Traits({ traits }: { traits?: string[] | undefined }) {
export function Spell5eDetail({ entry: s }: { entry: Spell }) {
return (
<div className="space-y-3">
<Header title={s.name} subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`} />
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
<span><strong className="text-ink">Casting Time</strong> {s.casting_time}</span>
<span><strong className="text-ink">Range</strong> {s.range}</span>
<span><strong className="text-ink">Duration</strong> {s.duration}</span>
<span><strong className="text-ink">Components</strong> {s.components}</span>
<Header
title={s.name}
subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`}
tone="text-info"
/>
<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>
<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 && (
<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>
)}
</div>
@@ -90,7 +121,8 @@ export function Item5eDetail({ entry: item }: { entry: MagicItem }) {
(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>
);
}
@@ -99,10 +131,11 @@ export function Weapon5eDetail({ entry: w }: { entry: Weapon5e }) {
return (
<div className="space-y-3">
<Header title={w.name} subtitle={w.category} />
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
<span><strong className="text-ink">Damage</strong> {w.damage_dice} {w.damage_type}</span>
<span><strong className="text-ink">Cost</strong> {w.cost ?? '—'}</span>
<span><strong className="text-ink">Weight</strong> {w.weight ?? '—'}</span>
<hr className="gilt-rule my-1" />
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<FactWell label="Damage" value={`${w.damage_dice} ${w.damage_type}`} />
<FactWell label="Cost" value={w.cost ?? '—'} />
<FactWell label="Weight" value={w.weight ?? '—'} />
</div>
{w.properties && w.properties.length > 0 && (
<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 (
<div className="space-y-3">
<Header title={a.name} subtitle={`${a.type ?? ''} armor`} />
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
<span><strong className="text-ink">AC</strong> {a.ac}</span>
<span><strong className="text-ink">Cost</strong> {a.cost ?? '—'}</span>
<span><strong className="text-ink">Weight</strong> {a.weight ?? '—'} lb</span>
<span><strong className="text-ink">Stealth</strong> {a.stealthPenalty ? 'Disadvantage' : '—'}</span>
<hr className="gilt-rule my-1" />
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<FactWell label="AC" value={a.ac} />
<FactWell label="Cost" value={a.cost ?? '—'} />
<FactWell label="Weight" value={`${a.weight ?? '—'} lb`} />
<FactWell label="Stealth" value={a.stealthPenalty ? 'Disadvantage' : '—'} />
</div>
</div>
);
@@ -129,7 +163,8 @@ export function Feat5eDetail({ entry: f }: { entry: Feat5e }) {
return (
<div className="space-y-3">
<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>
);
}
@@ -138,7 +173,8 @@ export function Condition5eDetail({ entry: c }: { entry: Condition5e }) {
return (
<div className="space-y-3">
<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 && (
<ul className="list-disc space-y-1 pl-5 text-sm text-muted">
{c.effects.map((e, i) => <li key={i}>{e}</li>)}
@@ -156,14 +192,15 @@ export function HomebrewDetail({ entry }: { entry: CompendiumEntry }) {
return (
<div className="space-y-3">
<Header title={entry.name} subtitle={`Homebrew ${kind}`} />
<hr className="gilt-rule my-1" />
{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]) => (
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span>
<FactWell key={k} label={k} value={String(v)} />
))}
</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>
);
}
@@ -191,14 +228,15 @@ export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
<div className="space-y-3">
<Header title={entry.name} subtitle={(entry.type as string | undefined) ?? undefined} />
<Traits traits={traits} />
<hr className="gilt-rule my-1" />
{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]) => (
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span>
<FactWell key={k} label={k} value={String(v)} />
))}
</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>
);
}
+107 -38
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
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 { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories';
@@ -11,9 +12,25 @@ import { useSessionStore } from '@/stores/sessionStore';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Codex';
import { cn } from '@/lib/cn';
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() {
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
@@ -59,6 +76,7 @@ export function DicePage() {
return (
<Page>
<PageHeader
eyebrow="Cast the bones"
title="Dice"
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
actions={
@@ -72,6 +90,46 @@ export function DicePage() {
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<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">
<Input
value={expr}
@@ -83,34 +141,56 @@ export function DicePage() {
aria-label="Dice expression"
/>
<Button variant="primary" onClick={() => void roll(expr)}>
<Dices size={16} aria-hidden />
Roll
</Button>
</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) => (
<Button key={d} size="sm" onClick={() => void roll(`1${d}`)}>
{d}
</Button>
<button
key={d}
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
size="sm"
variant={mode === 'advantage' ? 'primary' : 'secondary'}
</div>
{/* 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'}
onClick={() => toggleMode('advantage')}
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
</Button>
<Button
size="sm"
variant={mode === 'disadvantage' ? 'primary' : 'secondary'}
</button>
<button
type="button"
aria-pressed={mode === 'disadvantage'}
onClick={() => toggleMode('disadvantage')}
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
</Button>
</button>
</div>
{isGm && (
<Button
size="sm"
@@ -133,9 +213,9 @@ export function DicePage() {
)}
{/* Saved rolls (macros) */}
<div className="mt-4">
<div className="mt-6">
<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
size="sm"
variant="ghost"
@@ -146,17 +226,17 @@ export function DicePage() {
</Button>
</div>
{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) => (
<span key={m.id} className="inline-flex items-center overflow-hidden rounded-md border border-line bg-panel">
<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 : ''); }}
>
{m.label}
</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)}
aria-label={`Remove ${m.label}`}
>
@@ -169,26 +249,15 @@ export function DicePage() {
</div>
{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>
{/* History rail */}
<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 ? (
<p className="text-sm text-muted">No rolls yet.</p>
) : (
@@ -196,13 +265,13 @@ export function DicePage() {
{history.map((h) => (
<li
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}: ` : ''}
<span className="font-mono">{h.expression}</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>
))}
</ul>
@@ -243,7 +312,7 @@ function AnimatedTotal({ result }: { result: RollResult }) {
<div
aria-hidden
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',
)}
>
+11 -9
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { RadioTower, X } from 'lucide-react';
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories';
@@ -43,25 +44,26 @@ export function SessionControl() {
const link = joinCode ? `${location.origin}/play?room=${joinCode}` : '';
return (
<div className="relative flex items-center gap-1 rounded-md border border-accent/40 bg-accent/5 px-2 py-1 text-xs">
<span className="text-muted">{status === 'connected' ? 'Live' : status}:</span>
<div className="relative flex items-center gap-1.5 rounded-lg border border-accent/50 bg-accent-glow px-2 py-1 text-xs">
<RadioTower size={14} className="text-accent-deep" aria-hidden />
<span className="smallcaps text-[9px] text-muted">{status === 'connected' ? 'Live' : status}</span>
<button
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"
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
>{joinCode}{copied ? ' ✓' : ''}</button>
{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>
)}
<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 && (
<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="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Seat requests</div>
<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="smallcaps mb-1.5 px-1 text-muted">Seat requests</div>
<ul className="space-y-2">
{seatRequests.map((r) => <SeatRequestRow key={r.playerId} req={r} />)}
</ul>
@@ -87,8 +89,8 @@ function SeatRequestRow({ req }: { req: SeatRequest }) {
};
return (
<li className="rounded-md border border-line bg-surface p-2 text-sm">
<div className="mb-1 text-ink">Player wants <span className="font-semibold">{name}</span></div>
<li className="rounded-lg border border-line bg-surface-2 p-2 text-sm">
<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">
<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>}
+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 { PlayerMap } from '@/lib/map';
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { EmptyState } from '@/components/ui/Page';
import { Meter, Badge } from '@/components/ui/Codex';
import { cn } from '@/lib/cn';
import { PlayerMapView } from './PlayerMapView';
@@ -13,8 +15,10 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
return (
<>
{privateHandout && (
<section className="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>
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
<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>
{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" />}
@@ -22,8 +26,11 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
)}
{handout && (
<section className="mb-6 rounded-xl border border-accent/50 bg-accent/5 p-4">
<h2 className="font-display text-2xl font-bold text-accent">{handout.title || 'Handout'}</h2>
<section className="mb-6 rounded-xl border border-accent/50 bg-accent-glow p-4">
<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>}
{handoutImage && <img src={handoutImage} alt="" className="mt-3 max-h-[60vh] w-auto rounded-lg border border-line" />}
</section>
@@ -31,14 +38,18 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{map && (
<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" />
</section>
)}
<div className="grid gap-6 lg:grid-cols-2">
<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 ? (
<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) => {
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 hpTone = pct > 50 ? 'var(--app-success)' : pct > 0 ? 'var(--app-warning)' : 'var(--app-danger)';
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 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>
</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 className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
<div className="mt-2">
<Meter value={c.hp.current} max={c.hp.max} tone={hpTone} height={10} />
</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 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
@@ -74,24 +86,24 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
</section>
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">
Initiative {encounter ? `· Round ${encounter.round}` : ''}
<h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
<Swords size={13} aria-hidden /> Initiative {encounter ? `· Round ${encounter.round}` : ''}
</h2>
{!encounter ? (
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
) : (
<ol className="space-y-1">
{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')}>
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
<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-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="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) => (
<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>
{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 ? (
<span className={cn('shrink-0 text-sm font-medium', c.status.cls)}>{c.status.label}</span>
) : 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(() => {});
return (
<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>
<h1 className="font-display text-3xl font-bold text-accent">{title}</h1>
<p className="text-sm text-muted">{subtitle}</p>
<div className="font-display text-sm italic text-accent">The table is live</div>
<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>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div>
<hr className="gilt-rule mt-3" />
</div>
{children}
</Page>
);
+13 -7
View File
@@ -1,7 +1,9 @@
import { UserPlus } from 'lucide-react';
import type { Snapshot } from '@/lib/sync/messages';
import type { Character } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
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
@@ -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 (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Which character is yours?</h2>
<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>
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
<div className="font-display text-sm italic text-accent">Claim your seat</div>
<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">
{snapshot.party.map((c) => {
const hasOffline = localChars.has(c.id);
return (
<div key={c.id} className="flex items-center gap-3 rounded-lg border border-line bg-panel p-3">
<div className="flex-1">
<div className="font-display text-lg font-semibold text-ink">{c.name}</div>
<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 key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
<Avatar name={c.name} size={40} />
<div className="min-w-0 flex-1">
<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>
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
</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 {
Palette,
Sun,
Moon,
Download,
Upload,
Sparkles,
Cloud,
ScrollText,
TriangleAlert,
type LucideProps,
} from 'lucide-react';
import { useUiStore, type Theme } from '@/stores/uiStore';
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
import { pickTextFile } from '@/lib/io/file';
@@ -13,6 +25,43 @@ import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
/** A Living-Codex setting card: section eyebrow + a gilt hairline over its rows. */
function SettingsCard({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<section className={cn('rounded-xl border border-line bg-panel p-4 sm:p-5', className)}>
<h2 className="smallcaps text-[11px] text-muted">{label}</h2>
<hr className="gilt-rule mt-2 mb-1" />
<div>{children}</div>
</section>
);
}
/** An icon-tile row: leading rounded tile, title + description, trailing controls. */
function SettingsRow({
icon: Icon,
title,
desc,
children,
}: {
icon: ComponentType<LucideProps>;
title: ReactNode;
desc?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b border-line py-4 last:border-b-0">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-deep" aria-hidden>
<Icon size={18} />
</span>
<div className="min-w-0 flex-1">
<div className="font-display text-[15px] font-semibold text-ink">{title}</div>
{desc && <div className="mt-0.5 text-xs text-faint">{desc}</div>}
</div>
{children && <div className="flex shrink-0 flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
export function SettingsPage() {
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
@@ -44,29 +93,35 @@ export function SettingsPage() {
return (
<Page>
<PageHeader title="Settings" subtitle="Appearance, backups, and data." />
<PageHeader eyebrow="Preferences" title="Settings" subtitle="Appearance, backups, and data." />
<section className="mb-8">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</h2>
<div className="flex gap-2">
<div className="space-y-6">
<SettingsCard label="Appearance">
<SettingsRow
icon={theme === 'dark' ? Moon : Sun}
title="Theme"
desc="Parchment by day, candlelight by night."
>
{(['dark', 'light'] as Theme[]).map((t) => (
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} onClick={() => setTheme(t)} className="capitalize">
<Button key={t} variant={theme === t ? 'primary' : 'secondary'} size="sm" onClick={() => setTheme(t)} className="capitalize">
{t}
</Button>
))}
</div>
</section>
</SettingsRow>
</SettingsCard>
<section className="mb-8">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data</h2>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => void exportBackup()}>Export backup</Button>
<Button variant="secondary" onClick={doImport}>Import backup</Button>
<Button variant="secondary" onClick={loadSample}>Load sample campaign</Button>
</div>
<p className="mt-2 text-xs text-muted">Backups include every campaign and all of its data, as a single JSON file.</p>
{msg && <p className={cn('mt-2 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
</section>
<SettingsCard label="Data">
<SettingsRow icon={Download} title="Export backup" desc="Download a single JSON file with every campaign and all of its data.">
<Button variant="secondary" size="sm" onClick={() => void exportBackup()}>Export backup</Button>
</SettingsRow>
<SettingsRow icon={Upload} title="Import backup" desc="Restore from a previously exported file. Replaces current data.">
<Button variant="secondary" size="sm" onClick={doImport}>Import backup</Button>
</SettingsRow>
<SettingsRow icon={Sparkles} title="Sample campaign" desc="Load a ready-made campaign to explore the app.">
<Button variant="secondary" size="sm" onClick={loadSample}>Load sample campaign</Button>
</SettingsRow>
{msg && <p className={cn('mt-3 text-sm', msg.includes('fail') || msg.includes('not') ? 'text-danger' : 'text-success')}>{msg}</p>}
</SettingsCard>
<AssistantSettings />
@@ -74,20 +129,25 @@ export function SettingsPage() {
<CloudCampaigns />
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2>
<ul className="space-y-1 text-sm text-muted">
<SettingsCard label="Data sources & licenses">
<SettingsRow icon={ScrollText} title="Attribution" desc="Open-licensed game content powering the compendium.">
<Palette className="text-faint" size={18} aria-hidden />
</SettingsRow>
<ul className="space-y-1 pt-3 text-sm text-muted">
<li><span className="text-ink">D&amp;D 5e</span> SRD content via <a className="text-accent hover:underline" href="https://open5e.com" target="_blank" rel="noreferrer">Open5e</a> (OGL 1.0a / CC-BY-4.0).</li>
<li><span className="text-ink">Pathfinder 2e</span> content from the <a className="text-accent hover:underline" href="https://github.com/foundryvtt/pf2e" target="_blank" rel="noreferrer">Foundry VTT pf2e</a> project &amp; Archives of Nethys (OGL / ORC / Paizo Community Use).</li>
<li className="text-xs">Map import follows the Universal VTT (.dd2vtt) format used by Dungeondraft, Foundry &amp; others.</li>
</ul>
</section>
</SettingsCard>
<section className="rounded-lg border border-danger/40 bg-danger/5 p-4">
<h2 className="mb-1 text-sm font-semibold text-danger">Danger zone</h2>
<p className="mb-3 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
<section className="rounded-xl border border-danger/40 bg-danger/5 p-4 sm:p-5">
<h2 className="flex items-center gap-1.5 text-sm font-semibold text-danger">
<TriangleAlert size={15} aria-hidden /> Danger zone
</h2>
<p className="mb-3 mt-1 text-sm text-muted">Permanently delete all campaigns and data on this device.</p>
<Button variant="danger" onClick={() => setConfirmClear(true)}>Clear all data</Button>
</section>
</div>
<Modal
open={!!confirmRestore}
@@ -141,12 +201,16 @@ function CloudSync() {
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
return (
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Cloud sync (optional)</h2>
<p className="mb-3 text-sm text-muted">Sign in to back up your campaigns and sync them across devices. Local-first stays the default nothing leaves this device until you push.</p>
<SettingsCard label="Cloud sync (optional)">
<SettingsRow
icon={Cloud}
title="Cloud sync"
desc="Sign in to back up your campaigns and sync them across devices. Local-first stays the default — nothing leaves this device until you push."
>
{user && <span className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></span>}
</SettingsRow>
{user ? (
<div className="space-y-2">
<p className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></p>
<div className="space-y-3 pt-4">
<div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button>
@@ -154,19 +218,19 @@ function CloudSync() {
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 pt-4">
<Input className="max-w-40" placeholder="Username" value={u} onChange={(e) => setU(e.target.value)} aria-label="Cloud username" />
<Input className="max-w-40" type="password" placeholder="Password" value={p} onChange={(e) => setP(e.target.value)} aria-label="Cloud password" />
<Button variant="primary" disabled={busy || !u || !p} onClick={() => auth('in')}>Sign in</Button>
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
</div>
)}
{msg && <p className="mt-2 text-sm text-accent">{msg}</p>}
{msg && <p className="mt-3 text-sm text-accent">{msg}</p>}
<Modal open={confirmPull} onClose={() => setConfirmPull(false)} title="Restore from cloud?"
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
</Modal>
</section>
</SettingsCard>
);
}
+175 -34
View File
@@ -1,12 +1,15 @@
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import type { Campaign } from '@/lib/schemas';
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react';
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
import { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
import { cn } from '@/lib/cn';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useNotes, useNpcs, useQuests, useCalendar } from './hooks';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Page, RequireCampaign } from '@/components/ui/Page';
import { Meter, Badge, Avatar } from '@/components/ui/Codex';
export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
@@ -42,12 +45,24 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
return (
<Page>
<PageHeader
title={campaign.name}
subtitle={`${sys.label}${calendar ? ` · in-world day ${calendar.currentDay}` : ''}`}
/>
{campaign.description && <p className="mb-6 max-w-3xl text-sm text-muted">{campaign.description}</p>}
{/* Editorial hero */}
<section className="paper-grain mb-6 overflow-hidden rounded-xl border border-line bg-panel">
<div className="px-7 py-7 sm:px-9">
<div className="font-display text-sm italic text-accent">
{sys.label}
{calendar ? ` · in-world day ${calendar.currentDay}` : ''}
</div>
<h1 className="mt-1 font-display text-4xl font-semibold leading-[1.04] tracking-tight text-ink">
{campaign.name}
</h1>
{campaign.description && (
<p className="mt-2.5 max-w-2xl font-display text-base italic text-muted">
{campaign.description}
</p>
)}
</div>
<hr className="gilt-rule" />
</section>
{/* Quick stats */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@@ -61,13 +76,13 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Navigation cards */}
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Jump to</h2>
<h2 className="smallcaps mb-3">Jump to</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{LINKS.map((l) => (
<Link
key={l.to}
to={l.to}
className="flex flex-col items-center gap-1 rounded-lg border border-line bg-panel p-4 text-center transition-colors hover:border-accent/50"
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
>
<span className="text-2xl" aria-hidden>{l.icon}</span>
<span className="text-sm text-ink">{l.label}</span>
@@ -76,54 +91,180 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
</div>
{/* Party overview */}
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Party</h2>
<div className="mb-3 mt-7 flex items-center justify-between">
<h2 className="smallcaps">Party</h2>
<Link to="/characters" className="flex items-center gap-1 text-xs text-muted hover:text-accent-deep">
Manage <ChevronRight size={14} aria-hidden />
</Link>
</div>
{pcs.length === 0 ? (
<p className="text-sm text-muted">No player characters yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{pcs.map((c) => (
<Link
key={c.id}
to="/characters/$characterId"
params={{ characterId: c.id }}
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-2 text-sm hover:border-accent/40"
>
<span className="truncate text-ink">{c.name} <span className="text-muted">Lv {c.level}</span></span>
<span className="shrink-0 text-muted">
HP {c.hp.current}/{c.hp.max} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
</span>
</Link>
<PartyRow key={c.id} c={c} sys={sys} />
))}
</div>
)}
{/* Cast & threats */}
{npcs.length > 0 && (
<>
<h2 className="smallcaps mb-3 mt-7">Cast &amp; Threats</h2>
<div className="grid gap-2 sm:grid-cols-2">
{npcs.slice(0, 6).map((n) => (
<ThreatRow key={n.id} n={n} />
))}
</div>
</>
)}
</section>
{/* Recent rolls */}
<aside>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2>
{/* Side column: quests + recent rolls */}
<aside className="space-y-6">
<div>
<h2 className="smallcaps mb-3">Quest Log</h2>
{quests.length === 0 ? (
<p className="text-sm text-muted">No quests yet.</p>
) : (
<div className="paper-grain space-y-3 rounded-xl border border-line bg-panel p-4">
{quests.slice(0, 6).map((q) => (
<QuestRow key={q.id} q={q} />
))}
</div>
)}
</div>
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="smallcaps">Recent rolls</h2>
<Link to="/dice" className="flex items-center gap-1 text-xs text-muted hover:text-accent-deep">
Open tray <ChevronRight size={14} aria-hidden />
</Link>
</div>
{rolls.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p>
) : (
<ul className="space-y-1">
<ul className="space-y-1.5">
{rolls.map((r) => (
<li key={r.id} className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<span className="truncate text-muted">{r.label ? `${r.label}: ` : ''}<span className="font-mono">{r.expression}</span></span>
<span className="font-display font-semibold text-ink">{r.total}</span>
</li>
<RollRow key={r.id} r={r} />
))}
</ul>
)}
</div>
</aside>
</div>
</Page>
);
}
function Stat({ label, value }: { label: string; value: number }) {
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="font-display text-3xl font-bold text-accent">{value}</div>
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
<Link
to="/characters/$characterId"
params={{ characterId: c.id }}
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
>
<Avatar name={c.name} size={42} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-display text-sm font-semibold text-ink">{c.name}</span>
<span className="shrink-0 font-mono text-xs text-faint">{c.hp.current}/{c.hp.max}</span>
</div>
<div className="mt-0.5 truncate text-xs text-faint">
Lv {c.level} {[c.ancestry, c.className].filter(Boolean).join(' ')}
</div>
<div className="mt-1.5">
<Meter value={c.hp.current} max={c.hp.max} height={5} {...(tone ? { tone } : {})} />
</div>
</div>
<div className="flex w-9 shrink-0 flex-col items-center">
<Shield size={15} className="text-muted" aria-hidden />
<span className="font-mono text-sm font-semibold text-ink">{ac}</span>
</div>
</Link>
);
}
const NPC_DISPOSITION: Record<Npc['status'], { tone: 'ember' | 'verdigris' | 'default'; label: string; Icon: typeof Skull }> = {
dead: { tone: 'ember', label: 'Hostile', Icon: Skull },
alive: { tone: 'verdigris', label: 'Friendly', Icon: Handshake },
unknown: { tone: 'default', label: 'Unknown', Icon: VenetianMask },
};
function ThreatRow({ n }: { n: Npc }) {
const d = NPC_DISPOSITION[n.status];
const Icon = d.Icon;
return (
<Link
to="/npcs"
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-line bg-surface-2 text-muted">
<Icon size={16} aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-display text-sm font-semibold text-ink">{n.name}</div>
<div className="truncate text-xs text-faint">
{[n.role, n.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge tone={d.tone}>{d.label}</Badge>
</Link>
);
}
function QuestRow({ q }: { q: Quest }) {
const done = q.status === 'completed';
return (
<div className={cn(done && 'opacity-55')}>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
{done ? (
<CheckCheck size={16} className="shrink-0 text-accent-deep" aria-hidden />
) : (
<ScrollText size={16} className="shrink-0 text-muted" aria-hidden />
)}
<span className={cn('truncate font-display text-sm font-semibold text-ink', done && 'line-through')}>
{q.title}
</span>
</span>
{q.objectives.length > 0 && (
<span className="flex shrink-0 items-center gap-1">
{q.objectives.slice(0, 6).map((o) => (
<span
key={o.id}
className={cn('size-[7px] rounded-full', o.done ? 'bg-accent' : 'bg-line-strong')}
/>
))}
</span>
)}
</div>
</div>
);
}
function RollRow({ r }: { r: DiceRoll }) {
return (
<li className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2 text-sm">
<span className="h-6 w-[3px] shrink-0 rounded-sm bg-accent" aria-hidden />
<span className="min-w-0 flex-1 truncate text-muted">
{r.label ? `${r.label}: ` : ''}
<span className="font-mono">{r.expression}</span>
</span>
<span className="font-display font-semibold text-ink">{r.total}</span>
</li>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="font-mono text-3xl font-bold text-accent-deep">{value}</div>
<div className="smallcaps mt-1" style={{ fontSize: 10 }}>{label}</div>
</div>
);
}
+29 -13
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
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 { mapsRepo } from '@/lib/db/repositories';
import { parseUvtt, toUvtt } from '@/lib/vtt/uvtt';
@@ -68,6 +69,7 @@ function Maps({ campaign }: { campaign: Campaign }) {
return (
<Page>
<PageHeader
eyebrow="The cartographer's table"
title="Maps"
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => fileRef.current?.click()}>+ Add map</Button>}
@@ -79,30 +81,44 @@ function Maps({ campaign }: { campaign: Campaign }) {
{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>} />
) : (
<div className="grid gap-4 lg:grid-cols-[210px_1fr]">
<ul className="space-y-1">
{maps.map((mp) => (
<li key={mp.id} className="group flex items-center gap-1 rounded-md border border-line">
<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)}>
{mp.name}{activeMapId === mp.id ? ' 📺' : ''}
<div className="grid gap-4 lg:grid-cols-[230px_1fr]">
<ul className="space-y-1.5">
{maps.map((mp) => {
const active = selected?.id === mp.id;
return (
<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 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>
))}
);
})}
</ul>
{selected ? (
<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)}>
{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>
<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>
<MapEditor key={selected.id} map={selected} campaign={campaign} />
</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>
)}
+33 -10
View File
@@ -1,4 +1,8 @@
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 { mapsRepo, encountersRepo } from '@/lib/db/repositories';
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'];
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). */
function distToSegment(p: Point, a: Point, b: Point): number {
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="mb-2 flex flex-wrap items-center gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
{!showPalette && <Button size="sm" variant="ghost" onClick={() => setShowPalette(true)}>Tokens </Button>}
<Input className="h-8 max-w-36" value={m.name} onChange={(e) => update({ name: e.target.value })} aria-label="Map name" />
{(['move', 'reveal', 'hide', 'measure', 'aoe', 'draw', 'ping', 'walls'] as Tool[]).map((t) => (
<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 <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}
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>
{/* 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.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>
@@ -300,12 +320,15 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
</>
)}
<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>
{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">
<span className="text-xs uppercase tracking-wide text-muted">Combat · Round {activeEncounter.round}</span>
<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="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</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="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn </Button>