UI overhaul "Living Codex" E: per-screen layouts
Ported all 9 screens to the prototype layout (visual only; all data wiring, text, aria-labels, and testids preserved) via parallel agents + central verification: - Dashboard: editorial hero, party roster w/ HP meters + avatars, quests, threats, rolls. - Campaigns: cover-art deck cards + gilt rule + footer. - Character sheet + roster: hero header, StatCoin abilities, prof rows; grimoire cards. - Combat: glowing initiative rows (PC gilt / foe ember), condition badges, log. - Dice: die-pool glyphs, adv/dis segmented toggle, crit/fumble stage, history. - Compendium: Spectral stat blocks (ember headers, ability row). - Settings: grouped icon-tile cards. Live Session: room-code card, seat grid, player board. - Battle Map: carded list + tooled editor chrome (canvas untouched). Regression fixes after the ports: dice die-buttons aria-label `d4` not `Roll 1d4` (was colliding with the primary Roll button); map "Open player view" link uses an ExternalLink icon (test updated, ↗ glyph removed); seat-claim card gets a stable data-testid="seat-option" (redesign moved rounded-lg→rounded-xl). 223 unit + 34 e2e + 2 realtime green; tsc + eslint + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,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 & Threats</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{npcs.slice(0, 6).map((n) => (
|
||||
<ThreatRow key={n.id} n={n} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent rolls */}
|
||||
<aside>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2>
|
||||
{rolls.length === 0 ? (
|
||||
<p className="text-sm text-muted">No rolls yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{rolls.map((r) => (
|
||||
<li key={r.id} className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
||||
<span className="truncate text-muted">{r.label ? `${r.label}: ` : ''}<span className="font-mono">{r.expression}</span></span>
|
||||
<span className="font-display font-semibold text-ink">{r.total}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Side column: quests + recent rolls */}
|
||||
<aside className="space-y-6">
|
||||
<div>
|
||||
<h2 className="smallcaps mb-3">Quest Log</h2>
|
||||
{quests.length === 0 ? (
|
||||
<p className="text-sm text-muted">No quests yet.</p>
|
||||
) : (
|
||||
<div className="paper-grain space-y-3 rounded-xl border border-line bg-panel p-4">
|
||||
{quests.slice(0, 6).map((q) => (
|
||||
<QuestRow key={q.id} q={q} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="smallcaps">Recent rolls</h2>
|
||||
<Link to="/dice" className="flex items-center gap-1 text-xs text-muted hover:text-accent-deep">
|
||||
Open tray <ChevronRight size={14} aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
{rolls.length === 0 ? (
|
||||
<p className="text-sm text-muted">No rolls yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{rolls.map((r) => (
|
||||
<RollRow key={r.id} r={r} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
|
||||
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
|
||||
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4 text-center">
|
||||
<div className="font-display text-3xl font-bold text-accent">{value}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
|
||||
<Link
|
||||
to="/characters/$characterId"
|
||||
params={{ characterId: c.id }}
|
||||
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
|
||||
>
|
||||
<Avatar name={c.name} size={42} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-display text-sm font-semibold text-ink">{c.name}</span>
|
||||
<span className="shrink-0 font-mono text-xs text-faint">{c.hp.current}/{c.hp.max}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-faint">
|
||||
Lv {c.level} {[c.ancestry, c.className].filter(Boolean).join(' ')}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<Meter value={c.hp.current} max={c.hp.max} height={5} {...(tone ? { tone } : {})} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-9 shrink-0 flex-col items-center">
|
||||
<Shield size={15} className="text-muted" aria-hidden />
|
||||
<span className="font-mono text-sm font-semibold text-ink">{ac}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const NPC_DISPOSITION: Record<Npc['status'], { tone: 'ember' | 'verdigris' | 'default'; label: string; Icon: typeof Skull }> = {
|
||||
dead: { tone: 'ember', label: 'Hostile', Icon: Skull },
|
||||
alive: { tone: 'verdigris', label: 'Friendly', Icon: Handshake },
|
||||
unknown: { tone: 'default', label: 'Unknown', Icon: VenetianMask },
|
||||
};
|
||||
|
||||
function ThreatRow({ n }: { n: Npc }) {
|
||||
const d = NPC_DISPOSITION[n.status];
|
||||
const Icon = d.Icon;
|
||||
return (
|
||||
<Link
|
||||
to="/npcs"
|
||||
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5 transition-colors hover:border-accent/50 hover:bg-accent-glow"
|
||||
>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-line bg-surface-2 text-muted">
|
||||
<Icon size={16} aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-display text-sm font-semibold text-ink">{n.name}</div>
|
||||
<div className="truncate text-xs text-faint">
|
||||
{[n.role, n.location].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={d.tone}>{d.label}</Badge>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestRow({ q }: { q: Quest }) {
|
||||
const done = q.status === 'completed';
|
||||
return (
|
||||
<div className={cn(done && 'opacity-55')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{done ? (
|
||||
<CheckCheck size={16} className="shrink-0 text-accent-deep" aria-hidden />
|
||||
) : (
|
||||
<ScrollText size={16} className="shrink-0 text-muted" aria-hidden />
|
||||
)}
|
||||
<span className={cn('truncate font-display text-sm font-semibold text-ink', done && 'line-through')}>
|
||||
{q.title}
|
||||
</span>
|
||||
</span>
|
||||
{q.objectives.length > 0 && (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{q.objectives.slice(0, 6).map((o) => (
|
||||
<span
|
||||
key={o.id}
|
||||
className={cn('size-[7px] rounded-full', o.done ? 'bg-accent' : 'bg-line-strong')}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RollRow({ r }: { r: DiceRoll }) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2 text-sm">
|
||||
<span className="h-6 w-[3px] shrink-0 rounded-sm bg-accent" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{r.label ? `${r.label}: ` : ''}
|
||||
<span className="font-mono">{r.expression}</span>
|
||||
</span>
|
||||
<span className="font-display font-semibold text-ink">{r.total}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
||||
<div className="font-mono text-3xl font-bold text-accent-deep">{value}</div>
|
||||
<div className="smallcaps mt-1" style={{ fontSize: 10 }}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? ' 📺' : ''}
|
||||
</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>
|
||||
))}
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
<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>
|
||||
))}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user