From 99c7657f965e9519fea45884f831265a47fa07fe Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 21:37:00 +0200 Subject: [PATCH] UI overhaul "Living Codex" E: per-screen layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- e2e-realtime/seats.spec.ts | 2 +- e2e/maps.spec.ts | 2 +- src/features/campaigns/CampaignsPage.tsx | 139 +++++++++---- src/features/characters/CharacterSheet.tsx | 110 ++++++---- src/features/characters/CharactersPage.tsx | 46 +++-- src/features/combat/CombatPage.tsx | 33 ++- src/features/combat/EncounterTracker.tsx | 180 ++++++++++++----- src/features/compendium/CompendiumPage.tsx | 59 ++++-- src/features/compendium/MonsterDetail.tsx | 73 ++++--- src/features/compendium/details.tsx | 146 +++++++++----- src/features/dice/DicePage.tsx | 165 ++++++++++----- src/features/play/SessionControl.tsx | 20 +- src/features/player/PlayerBoards.tsx | 48 +++-- src/features/player/PlayerViewPage.tsx | 14 +- src/features/player/SeatClaimScreen.tsx | 20 +- src/features/settings/SettingsPage.tsx | 156 +++++++++----- src/features/world/DashboardPage.tsx | 223 +++++++++++++++++---- src/features/world/MapsPage.tsx | 46 +++-- src/features/world/map/MapEditor.tsx | 45 ++++- 19 files changed, 1070 insertions(+), 457 deletions(-) diff --git a/e2e-realtime/seats.spec.ts b/e2e-realtime/seats.spec.ts index 498742c..bdc7f5a 100644 --- a/e2e-realtime/seats.spec.ts +++ b/e2e-realtime/seats.spec.ts @@ -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(); diff --git a/e2e/maps.spec.ts b/e2e/maps.spec.ts index 6b1e7e7..06ca48f 100644 --- a/e2e/maps.spec.ts +++ b/e2e/maps.spec.ts @@ -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); }); diff --git a/src/features/campaigns/CampaignsPage.tsx b/src/features/campaigns/CampaignsPage.tsx index b950681..7611321 100644 --- a/src/features/campaigns/CampaignsPage.tsx +++ b/src/features/campaigns/CampaignsPage.tsx @@ -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 ( void }) { void navigate({ to: '/dashboard' }); }; return ( -
-

Welcome to TTRPG Manager

-

- A local-first companion for D&D 5e & Pathfinder 2e — guided character building, combat - tracking, a full battle-map VTT with fog & dynamic vision, a searchable compendium, and live - play your players join from their own devices. Everything is stored on this device. -

-
- - - -
-
- - +
+
+
A local-first companion
+

Welcome to TTRPG Manager

+

+ A local-first companion for D&D 5e & Pathfinder 2e — guided character building, combat + tracking, a full battle-map VTT with fog & dynamic vision, a searchable compendium, and live + play your players join from their own devices. Everything is stored on this device. +

+
+
+ + + +
+
+ + +
); @@ -77,9 +85,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) { function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) { return ( -
-
{icon}
-
{title}
+
+
{icon}
+
{title}
{desc}
); @@ -96,35 +104,78 @@ 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 ( -
- {activeId === campaign.id && ( - - Active - +
- {campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e'} + > + {/* Cover band */} +
+ +
+ {systemLabel} +
+ {isActive && ( + + Active + + )}
-

{campaign.name}

- {campaign.description && ( -

{campaign.description}

- )} -
- - - + +
+

{campaign.name}

+ {campaign.description && ( +

{campaign.description}

+ )} + +
+ + {/* Stats footer */} +
+ + + {systemLabel} + + + + {created} + + +
+ +
+ + + +
- - ← Back to characters + + + Back to characters
- {/* Header */} -
- - update({ name: e.target.value })} - /> - - {c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label} - -
- {profLabel} + {/* Header hero */} +
+
+ +
+
{c.ancestry || sys.label}
+ update({ name: e.target.value })} + /> +
+ Level {c.level} + {c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label} + + + {profLabel} + +
+
+
+ {[ + { 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 }) => ( +
+ + {value} + {label} +
+ ))} +
+
+
{c.kind === 'pc' && }
-
+
update({ ancestry: e.target.value })} /> @@ -150,7 +176,7 @@ export function CharacterSheet({ character }: { character: Character }) { {/* Abilities */}
-
+
Ability Scores
@@ -158,10 +184,11 @@ export function CharacterSheet({ character }: { character: Character }) { {ABILITIES.map((a) => { const mod = sys.abilityModifier(c.abilities[a]); return ( -
-
{ABILITY_ABBR[a]}
-
{formatModifier(mod)}
+
+
{ABILITY_ABBR[a]}
+
{formatModifier(mod)}
) = } setDelta(0); }; + const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0; return ( -
-
Hit Points
-
+
+
Hit Points
+
{c.hp.current} / {c.hp.max} {c.hp.temp > 0 && (+{c.hp.temp})}
+
+ +
@@ -311,11 +342,16 @@ function RankRow({ onRank: (r: ProficiencyRank) => void; system: SystemId; }) { + const proficient = rank !== 'untrained'; return ( -
+
+
))} diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 94896fb..3558e66 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -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 (
{/* Control bar */} -
+
-
{encounter.name}
+
Encounter · {encounter.name}
+
Initiative
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'} {current && isActive ? ` · ${current.name}'s turn` : ''}
+ {isActive && ( +
+ + Round + {encounter.round} +
+ )} {budget && ( -
+
{budget.difficulty}
-
+
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
)}
{undoStack.current.length > 0 && ( - + )} {encounter.combatants.length > 0 && ( )} {!isActive && encounter.status !== 'ended' && ( @@ -112,10 +139,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter {isActive && ( <>
-
    +
    +
      {recent.map((l, i) => ( -
    • - R{l.round} - {l.text} +
    • + + + R{l.round} + {l.text} +
    • ))}
    @@ -245,7 +284,7 @@ function AddCombatantBar({ }; return ( -
    +