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
+8
View File
@@ -18,6 +18,8 @@ import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { CampaignInsights } from './CampaignInsights';
import { SessionPrepCard } from './SessionPrepCard';
import { NpcGenCard } from './NpcGenCard';
export function AssistantPage() {
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
@@ -128,6 +130,12 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session hooks</h2>
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Quick NPC</h2>
<NpcGenCard campaign={campaign} />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
</section>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Brain } from 'lucide-react';
import type { Campaign, Encounter } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
@@ -22,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
data-testid="encounter-tip"
>
<div className="flex items-start gap-3">
<span className="text-lg" aria-hidden>🧠</span>
<Brain size={18} className="shrink-0 text-muted" aria-hidden />
<div className="min-w-0 flex-1">
{theme ? (
<>
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
import { getSystem } from '@/lib/rules';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface Props {
campaign: Campaign;
}
export function NpcGenCard({ campaign }: Props) {
const navigate = useNavigate();
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const [hint, setHint] = useState('');
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [npc, setNpc] = useState<NpcQuick | null>(null);
const [message, setMessage] = useState<string | null>(null);
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 generate = async () => {
setState('loading');
setMessage(null);
if (canUseLlm) {
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
const result = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
if (result.ok && 'data' in result) {
setNpc(result.data);
setState('ready');
return;
}
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template NPC instead.`);
}
setNpc(fallbackNpc());
setState('ready');
};
const addToCampaign = async () => {
if (!npc) return;
const created = await npcsRepo.create(campaign.id, npc.name);
await npcsRepo.update(created.id, {
role: npc.role,
description: `Motivation: ${npc.motivation}\n\nSecret: ${npc.secret}\n\nIntro hook: ${npc.hook}`,
});
void navigate({ to: '/npcs' });
};
return (
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">
Generate a ready-to-use NPC with motivation, secret, and an intro hook for the table.
</p>
<div className="flex gap-2">
<Input
value={hint}
onChange={(e) => setHint(e.target.value)}
placeholder={`Optional: "a nervous blacksmith" or "a corrupt city guard"`}
className="flex-1 text-sm"
aria-label="NPC hint"
/>
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Generate (AI)' : 'Generate'}
</Button>
</div>
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
{state === 'ready' && npc && (
<div className="mt-4 rounded-md border border-line bg-surface-2 p-3 text-sm">
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<span className="font-display text-lg font-semibold text-ink">{npc.name}</span>
<span className="ml-2 text-xs text-muted">{npc.role}</span>
</div>
<Button size="sm" variant="primary" onClick={() => void addToCampaign()}>
Add to campaign
</Button>
</div>
<dl className="space-y-1.5 text-xs">
<NpcField label="Motivation" value={npc.motivation} />
<NpcField label="Secret" value={npc.secret} />
<NpcField label="Intro hook" value={npc.hook} />
</dl>
</div>
)}
</div>
);
}
function NpcField({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="inline font-semibold text-ink">{label}: </dt>
<dd className="inline text-muted">{value}</dd>
</div>
);
}
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import type { Campaign, Character, Note, Quest } from '@/lib/schemas';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildCampaignContext } from '@/lib/assistant/context';
import { buildSessionHookPrompt, sessionHookSchema, fallbackSessionHooks, type SessionHook } from '@/lib/assistant/session';
import { Button } from '@/components/ui/Button';
interface Props {
campaign: Campaign;
characters: Character[];
notes: Note[];
quests: Quest[];
}
export function SessionPrepCard({ campaign, characters, notes, quests }: Props) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [hooks, setHooks] = useState<SessionHook[]>([]);
const [message, setMessage] = useState<string | null>(null);
const generate = async () => {
setState('loading');
setMessage(null);
const ctx = buildCampaignContext({ campaign, characters, encounters: [], quests, notes });
if (canUseLlm) {
const { system, user } = buildSessionHookPrompt(ctx);
const result = await complete<{ hooks: SessionHook[] }>(getLlmConfig(), { system, user, schema: sessionHookSchema });
if (result.ok && 'data' in result) {
setHooks(result.data.hooks);
setState('ready');
return;
}
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template hooks instead.`);
}
setHooks(fallbackSessionHooks(ctx));
setState('ready');
};
return (
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">
Generate three distinct session opening hooks grounded in your campaign&apos;s current threads.
</p>
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
{state === 'loading' ? 'Thinking…' : state === 'ready' ? '↻ Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
</Button>
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
{state === 'ready' && hooks.length > 0 && (
<div className="mt-4 space-y-3">
{hooks.map((h, i) => (
<HookCard key={i} hook={h} />
))}
</div>
)}
</div>
);
}
function HookCard({ hook }: { hook: SessionHook }) {
const [copied, setCopied] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(`${hook.title}\n\n${hook.opening}\n\n${hook.tension}`).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div className="rounded-md border border-line bg-surface-2 p-3 text-sm">
<div className="mb-1 flex items-start justify-between gap-2">
<span className="font-display font-semibold text-ink">{hook.title}</span>
<button onClick={copy} title="Copy hook" className="shrink-0 text-muted hover:text-ink">
{copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
</button>
</div>
<p className="text-muted">{hook.opening}</p>
<p className="mt-1.5 text-xs italic text-faint">{hook.tension}</p>
</div>
);
}
+6 -6
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { type ReactNode, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react';
import { ArrowRight, BookOpenText, CalendarDays, Map, RadioTower, ScrollText, Users } from 'lucide-react';
import { cn } from '@/lib/cn';
import { campaignsRepo } from '@/lib/db/repositories';
import { seedSampleCampaign } from '@/lib/sample';
@@ -70,9 +70,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
</p>
<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." />
<FeatCard icon={<Users size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon={<Map size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
</div>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
@@ -83,7 +83,7 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
);
}
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
return (
<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>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
import { ArrowLeft, Check, 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';
@@ -161,7 +161,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</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>}
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? <><Check size={12} aria-hidden /> 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>
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Lightbulb } from 'lucide-react';
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
@@ -12,8 +13,10 @@ import type { RulesetClass } from '@/lib/ruleset/normalize';
import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format';
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Field, Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
@@ -33,16 +36,16 @@ function playstyle(c: RulesetClass): string {
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
'5e': [
{ label: '🛡️ Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
{ label: '🔥 Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
{ label: '🗡️ Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
{ label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
{ label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
{ label: 'Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
],
pf2e: [
{ label: '🛡️ Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
{ label: '🔥 Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
{ label: '🗡️ Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
{ label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
{ label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
{ label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
],
};
@@ -65,10 +68,37 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
useEffect(() => {
let on = true;
if (system === '5e') {
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed }))));
void loadRaces5e().then((rs) => {
if (!on) return;
const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim();
setOrigins(rs.map((r) => {
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
const asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)];
const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', ');
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
return { name: r.name, desc, meta };
}));
});
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
} else {
void loadPf2e('ancestries').then((rs) => on && setOrigins(rs.map((r) => ({ name: String(r.name), desc: String((r.description ?? r.text) ?? ''), ...(typeof r.hp === 'number' ? { hp: r.hp } : {}), ...((r.speed as { land?: number } | undefined)?.land ? { speed: (r.speed as { land: number }).land } : {}) }))));
void loadPf2e('ancestries').then((rs) => {
if (!on) return;
setOrigins(rs.map((r) => {
const hp = typeof r.hp === 'number' ? r.hp : undefined;
const speed = (r.speed as { land?: number } | undefined)?.land;
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
return {
name: String(r.name),
desc: String((r.summary ?? r.text) ?? ''),
...(hp !== undefined ? { hp } : {}),
...(speed ? { speed } : {}),
meta,
};
}));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
}
return () => { on = false; };
@@ -285,21 +315,61 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div>
</div>
{selectedClass && selectedClass.subclasses.length > 0 && (
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
<option value=""> decide later </option>
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select>
</Field>
)}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
if (!tip) return null;
return (
<div className="rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm">
<div className="flex items-start gap-2">
<Lightbulb size={15} className="mt-0.5 shrink-0 text-accent" aria-hidden />
<div>
<div className="font-medium text-ink">{selectedClass.name}</div>
<p className="mt-0.5 text-muted">{tip.role}</p>
<div className="mt-1.5 text-xs text-muted">
<span className="font-medium text-ink">Key abilities:</span> {tip.primaryAbilities}
</div>
{tip.beginner && <Badge tone="verdigris" className="mt-1.5">Good for beginners</Badge>}
</div>
</div>
</div>
);
})()}
{selectedClass && selectedClass.subclasses.length > 0 && (() => {
const sc = selectedClass.subclasses.find((s) => s.name === subclass);
return (
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
<option value=""> decide later </option>
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select>
{sc?.desc && (
<p className="mt-1 max-h-28 overflow-y-auto rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">
{sc.desc}
</p>
)}
</Field>
);
})()}
</div>
)}
{stepName === 'Origin' && (
<div className="grid gap-4 sm:grid-cols-2">
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
<div className="space-y-3">
<div className="grid gap-4 sm:grid-cols-2">
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div>
{selectedClass && selectedOrigin && (() => {
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
if (!synergy) return null;
return (
<p className="flex items-center gap-1.5 text-xs text-accent">
<Lightbulb size={12} className="shrink-0" aria-hidden />
{synergy}
</p>
);
})()}
</div>
)}
@@ -312,7 +382,18 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{selectedClass && selectedClass.keyAbilities.length > 0 && <p className="mb-3 text-xs text-muted">Tip: prioritise <span className="text-ink">{selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')}</span> for a {selectedClass.name}.</p>}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
: null);
return text ? (
<p className="mb-3 flex items-start gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
{text}
</p>
) : null;
})()}
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{ABILITIES.map((a, i) => {
@@ -339,7 +420,16 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Skills' && (
<div>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-3 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
<p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
return tip?.skillSuggestions ? (
<p className="mb-3 flex items-center gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="shrink-0 text-accent" aria-hidden />
{selectedClass.name}s often pick: <span className="ml-1 text-ink">{tip.skillSuggestions}</span>
</p>
) : null;
})()}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => {
const checked = skills.includes(key);
+2 -1
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import {
ArrowDown,
ArrowUp,
Check,
ChevronLeft,
ChevronRight,
Dices,
@@ -548,7 +549,7 @@ function ConditionPicker({
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
{cd.name}
{cd.valued ? ' (#)' : ''}
{taken.has(cd.name) ? ' ✓' : ''}
{taken.has(cd.name) ? <Check size={12} aria-hidden /> : ''}
</option>
))}
<option value="__custom__">Custom</option>
+2 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Check, Megaphone } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Button } from '@/components/ui/Button';
@@ -21,7 +22,7 @@ export function HandoutControl() {
return (
<>
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
{active ? '📣 Handout ✓' : '📣 Handout'}
<Megaphone size={14} aria-hidden /> Handout {active && <Check size={12} aria-hidden />}
</Button>
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"></Button>}
{open && (
+4 -4
View File
@@ -1,7 +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 { Check, Lock, 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';
@@ -28,7 +28,7 @@ export function SessionControl() {
if (!cloudUsername()) {
return (
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}>
🔒 Sign in to host
<Lock size={14} aria-hidden /> Sign in to host
</Button>
);
}
@@ -38,7 +38,7 @@ export function SessionControl() {
const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
hostSession(campaign.id, pw || undefined);
}}
>📡 Host</Button>
><RadioTower size={14} aria-hidden /> Host</Button>
);
}
@@ -52,7 +52,7 @@ export function SessionControl() {
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>
>{joinCode}{copied ? <><Check size={11} aria-hidden /></> : ''}</button>
{seatRequests.length > 0 && (
<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"
+3 -2
View File
@@ -8,6 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
import type { SessionLogEntry } from '@/lib/schemas';
import type { RosterEntry } from '@/lib/sync/messages';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Dice5, Mail, MessageSquare } from 'lucide-react';
import { Input, Textarea } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -109,7 +110,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
{isGm && p.playerId !== 'gm' && (
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button>
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info"><Mail size={12} aria-hidden /></button>
)}
</li>
))}
@@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
<ul className="space-y-0.5 text-xs">
{(recap ?? []).map((e) => (
<li key={e.id} className="flex gap-2">
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'}>{e.kind === 'roll' ? '🎲' : '💬'}</span>
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'} aria-hidden>{e.kind === 'roll' ? <Dice5 size={12} /> : <MessageSquare size={12} />}</span>
<span className="text-muted">{e.from}:</span>
<span className="min-w-0 flex-1 text-ink">{e.text}</span>
</li>
+39
View File
@@ -0,0 +1,39 @@
import { Check } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import { getTurnGuide, getGenericGuide } from '@/lib/assistant/actions';
/** Collapsible "What can I do?" guide for the player in-session view. */
export function ActionGuide({ character }: { character: Character }) {
const guide = getTurnGuide(character.system, character.className?.toLowerCase() ?? '') ?? getGenericGuide(character.system);
return (
<details className="mt-3 rounded-lg border border-line bg-surface-2">
<summary className="cursor-pointer select-none px-3 py-2 text-xs text-muted hover:text-ink">
What can I do on my turn?
</summary>
<div className="border-t border-line px-3 pb-3 pt-2">
<ul className="space-y-2">
{guide.actions.map((a) => (
<li key={a.label}>
<span className="text-xs font-semibold text-ink">{a.label}: </span>
<span className="text-xs text-muted">{a.desc}</span>
</li>
))}
</ul>
{guide.upgradeAt && character.level < guide.upgradeAt.level && (
<p className="mt-2 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
{guide.upgradeAt.note}
</p>
)}
{guide.upgradeAt && character.level >= guide.upgradeAt.level && (
<p className="mt-2 rounded-md border border-success/30 bg-success/5 px-2 py-1 text-xs text-success">
<Check size={12} className="mr-1 inline" aria-hidden /> {guide.upgradeAt.note}
</p>
)}
{guide.tip && (
<p className="mt-2 border-t border-line pt-2 text-xs italic text-muted">{guide.tip}</p>
)}
</div>
</details>
);
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import type { Character, Defenses } from '@/lib/schemas';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { useRollStore } from '@/stores/rollStore';
@@ -61,7 +62,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
</div>
) : (
<div className="space-y-2 text-sm">
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
<Pips label="Death saves ✗" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
<div className="flex items-center justify-between">
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
@@ -114,6 +115,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
{/* Dice */}
<DiceBox characterId={c.id} />
</div>
<ActionGuide character={c} />
</section>
);
}
+10 -4
View File
@@ -1,6 +1,8 @@
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle } from 'lucide-react';
import type { SystemId } from '@/lib/rules';
import type { Snapshot } from '@/lib/sync/messages';
import type { PlayerMap } from '@/lib/map';
import { useConditionGlossary } from '@/features/combat/useConditionGlossary';
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { EmptyState } from '@/components/ui/Page';
import { Meter, Badge } from '@/components/ui/Codex';
@@ -11,6 +13,7 @@ import { PlayerMapView } from './PlayerMapView';
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
const { party, encounter, map, handout } = snapshot;
const quests = snapshot.quests ?? [];
const condGlossary = useConditionGlossary((snapshot.system ?? '5e') as SystemId);
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
return (
@@ -18,7 +21,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{privateHandout && (
<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
<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>}
@@ -74,9 +77,12 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
<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) => (
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
{c.conditions.map((cond, i) => {
const tooltip = condGlossary.get(cond.name.toLowerCase());
return (
<span key={i} title={tooltip || undefined} className="cursor-help rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
);
})}
</div>
)}
</div>
+2 -1
View File
@@ -11,6 +11,7 @@ import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useCalendar, useMaps } from '@/features/world/hooks';
import { Maximize2 } from 'lucide-react';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { PlayerBoards } from './PlayerBoards';
@@ -47,7 +48,7 @@ function Shell({ title, subtitle, children }: { title: string; subtitle: string;
<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>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"><Maximize2 size={14} aria-hidden /> Fullscreen</Button>
</div>
<hr className="gilt-rule mt-3" />
</div>
+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>} />