UI/UX "Living Codex" 4/n: assistant features + emoji purge

- Replace all emoji icons (~26 instances) with Lucide icons across the app
  (DashboardPage, SessionSidebar, PlayerBoards, MyCharacterPanel,
  PlayerViewPage, EncounterTracker, CampaignsPage, HandoutControl,
  SessionControl, CharacterSheet, EncounterTipCard, ActionGuide)
- Expose real 5e race data in wizard (ASI/vision/traits instead of stub desc);
  add vision field to loadRaces5e return type
- Surface subclass descriptions after picking a subclass in the wizard
- Add per-class tips, race synergy notes, and ability/skill guidance in wizard
- New ActionGuide component: collapsible "What can I do on my turn?" in player view
- New SessionPrepCard: AI + fallback session hook generator on assistant page
- New NpcGenCard: AI + fallback quick NPC generator with "Add to campaign" action
- Inline NPC detail generator (wand button) on each NPC card in NpcsPage
- Quest hook generator button in QuestsPage header
- Condition tooltips in player party view (useConditionGlossary)
- Pass campaign.system through session snapshot so player view is system-aware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:29:06 +02:00
parent 235cdecb53
commit 740cf20b93
24 changed files with 1176 additions and 67 deletions
+20 -16
View File
@@ -1,6 +1,9 @@
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react';
import {
Brain, Users, Swords, ScrollText, VenetianMask, Target, Map, CalendarDays, Library, Hammer, Dice5, Monitor,
ChevronRight, Shield, CheckCheck, Skull, Handshake,
} from 'lucide-react';
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
import { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
@@ -15,20 +18,21 @@ export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
}
const LINKS = [
{ to: '/assistant', label: 'Assistant', icon: '🧠' },
{ to: '/characters', label: 'Characters', icon: '🧝' },
{ to: '/combat', label: 'Combat', icon: '⚔' },
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
{ to: '/quests', label: 'Quests', icon: '🗺' },
{ to: '/maps', label: 'Maps', icon: '🗺️' },
{ to: '/calendar', label: 'Calendar', icon: '📅' },
{ to: '/compendium', label: 'Compendium', icon: '📚' },
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' },
{ to: '/dice', label: 'Dice', icon: '🎲' },
{ to: '/play', label: 'Player View', icon: '📺' },
] as const;
type NavIcon = React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
const LINKS: { to: string; label: string; icon: NavIcon }[] = [
{ to: '/assistant', label: 'Assistant', icon: Brain },
{ to: '/characters', label: 'Characters', icon: Users },
{ to: '/combat', label: 'Combat', icon: Swords },
{ to: '/notes', label: 'Notes & Wiki', icon: ScrollText },
{ to: '/npcs', label: 'NPCs', icon: VenetianMask },
{ to: '/quests', label: 'Quests', icon: Target },
{ to: '/maps', label: 'Maps', icon: Map },
{ to: '/calendar', label: 'Calendar', icon: CalendarDays },
{ to: '/compendium', label: 'Compendium', icon: Library },
{ to: '/homebrew', label: 'Homebrew', icon: Hammer },
{ to: '/dice', label: 'Dice', icon: Dice5 },
{ to: '/play', label: 'Player View', icon: Monitor },
];
function Dashboard({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
@@ -84,7 +88,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
to={l.to}
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>
<l.icon size={24} className="text-muted" aria-hidden />
<span className="text-sm text-ink">{l.label}</span>
</Link>
))}
+36 -2
View File
@@ -1,6 +1,11 @@
import { useState } from 'react';
import { Wand2 } from 'lucide-react';
import type { Campaign, Npc } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useNpcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
@@ -33,7 +38,7 @@ function Npcs({ campaign }: { campaign: Campaign }) {
<>
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
<div className="grid gap-3 md:grid-cols-2">
{filtered.map((n) => <NpcCard key={n.id} npc={n} />)}
{filtered.map((n) => <NpcCard key={n.id} npc={n} campaign={campaign} />)}
</div>
</>
)}
@@ -41,13 +46,39 @@ function Npcs({ campaign }: { campaign: Campaign }) {
);
}
function NpcCard({ npc }: { npc: Npc }) {
function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
const [n, setN] = useState(npc);
const [genState, setGenState] = useState<'idle' | 'loading'>('idle');
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
}), 350);
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
const generateDetails = async () => {
setGenState('loading');
const ctx = {
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
systemLabel: getSystem(campaign.system).label,
questsSummary: 'No active quests.',
notesSummary: 'No notes yet.',
};
const hint = [n.name, n.role].filter(Boolean).join(', ');
let result: NpcQuick | null = null;
if (canUseLlm) {
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
const res = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
if (res.ok && 'data' in res) result = res.data;
}
if (!result) result = fallbackNpc();
const desc = `Motivation: ${result.motivation}\n\nSecret: ${result.secret}\n\nIntro hook: ${result.hook}`;
update({ description: desc, role: result.role || n.role });
setGenState('idle');
};
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2">
@@ -55,6 +86,9 @@ function NpcCard({ npc }: { npc: Npc }) {
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
<Wand2 size={13} aria-hidden />
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}></Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
+44 -1
View File
@@ -2,8 +2,15 @@ import { useState } from 'react';
import type { Campaign, Quest, Objective } from '@/lib/schemas';
import { questsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildQuestHookPrompt, questHookSchema, fallbackQuestHook, type QuestHook } from '@/lib/assistant/session';
import { buildCampaignContext } from '@/lib/assistant/context';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useQuests } from './hooks';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -20,15 +27,51 @@ export function QuestsPage() {
function Quests({ campaign }: { campaign: Campaign }) {
const quests = useQuests(campaign.id);
const characters = useCharacters(campaign.id);
const notes = useNotes(campaign.id);
const encounters = useEncounters(campaign.id);
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
const [genBusy, setGenBusy] = useState(false);
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const generateQuest = async () => {
setGenBusy(true);
try {
let hook: QuestHook | null = null;
if (canUseLlm) {
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes });
const { system, user } = buildQuestHookPrompt(ctx);
const res = await complete<QuestHook>(getLlmConfig(), { system, user, schema: questHookSchema });
if (res.ok && 'data' in res) hook = res.data;
}
if (!hook) hook = fallbackQuestHook();
const created = await questsRepo.create(campaign.id, hook.title);
await questsRepo.update(created.id, {
description: hook.description,
reward: hook.reward,
objectives: hook.objectives.map((text) => ({ id: newId(), text, done: false })),
});
} finally {
setGenBusy(false);
}
};
return (
<Page>
<PageHeader
title="Quests"
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>}
actions={
<div className="flex gap-2">
<Button variant="secondary" disabled={genBusy} onClick={() => void generateQuest()}>
{genBusy ? 'Generating…' : 'Generate quest hook'}
</Button>
<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>
</div>
}
/>
{quests.length === 0 ? (
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />