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:
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user