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,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}
|
||||
@@ -153,7 +184,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</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>
|
||||
{l.text}
|
||||
<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">
|
||||
<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
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
|
||||
Heal
|
||||
</Button>
|
||||
</div>
|
||||
{/* 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">
|
||||
<Sword size={14} aria-hidden /> Dmg
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
|
||||
<Heart size={14} aria-hidden className="text-verdigris" /> Heal
|
||||
</Button>
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user