working through the plan + UI/ UX

This commit is contained in:
itsamejms
2026-07-12 22:13:43 +01:00
parent 5b256242be
commit a24f3615e0
38 changed files with 5295 additions and 328 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ export function BentoCard({
return (
<motion.div
className={`glass-card flex flex-col overflow-hidden p-4 ${responsive} ${className}`}
whileHover={{ scale: 1.005 }}
// ponytail: drop the hover scale — 12 cards bobs noticeably when the
// cursor moves. The CSS .glass-card:hover (border + glow) is enough.
whileTap={{ scale: 0.985 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { navItems, type View } from "../App";
import { ArrowRight } from "lucide-react";
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
onSelect: (view: View) => void;
}
interface PaletteItem {
id: string;
label: string;
hint?: string;
view: View;
}
// ponytail: command palette is just nav items + the dashboard. A separate
// index of generated NPCs/items/quests would need persistence first, which
// is P1; ship the jump-to-tool now and extend later.
function buildItems(): PaletteItem[] {
const items: PaletteItem[] = [
{ id: "dashboard", label: "Dashboard", hint: "Home", view: "dashboard" },
...navItems.map((n) => ({
id: n.view,
label: n.label,
hint: n.shortcut ?? (n.group === "session" ? "Session" : "World"),
view: n.view,
})),
{ id: "settings", label: "Settings", hint: "⌘,", view: "settings" },
];
return items;
}
export function CommandPalette({ open, onClose, onSelect }: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [activeIdx, setActiveIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const items = useMemo(() => buildItems(), []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((it) => it.label.toLowerCase().includes(q));
}, [items, query]);
// Focus input + reset on open.
useEffect(() => {
if (open) {
setQuery("");
setActiveIdx(0);
// requestAnimationFrame avoids the focus racing the open transition.
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
// Keep the active row in view as the user arrows around.
useEffect(() => {
if (activeIdx >= filtered.length) setActiveIdx(0);
}, [filtered.length, activeIdx]);
if (!open) return null;
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => (filtered.length === 0 ? 0 : (i + 1) % filtered.length));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) =>
filtered.length === 0 ? 0 : (i - 1 + filtered.length) % filtered.length,
);
} else if (e.key === "Enter") {
e.preventDefault();
const item = filtered[activeIdx];
if (item) onSelect(item.view);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
return (
<div
className="fixed inset-0 z-40 flex items-start justify-center pt-24 px-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<div className="absolute inset-0 bg-black/40" aria-hidden="true" />
<div
className="relative w-full max-w-md glass-card p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIdx(0);
}}
onKeyDown={onKeyDown}
placeholder="Jump anywhere…"
aria-label="Search tools"
className="w-full bg-transparent px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none"
/>
<div className="border-t border-[var(--color-border-subtle)] mt-1 max-h-72 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-3 py-4 text-xs text-[var(--color-text-dim)]">No matches.</div>
)}
{filtered.map((it, i) => (
<button
key={it.id}
onClick={() => onSelect(it.view)}
onMouseEnter={() => setActiveIdx(i)}
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm rounded cursor-pointer transition-colors ${
i === activeIdx
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
: "text-[var(--color-text-primary)]"
}`}
>
<span>{it.label}</span>
<span className="flex items-center gap-1.5">
{it.hint && (
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)]">
{it.hint}
</kbd>
)}
<ArrowRight
size={12}
className={
i === activeIdx ? "text-[var(--color-gold-bright)]" : "text-[var(--color-text-dim)]"
}
/>
</span>
</button>
))}
</div>
<div className="flex items-center justify-between px-3 pt-2 text-[10px] text-[var(--color-text-dim)]">
<span> navigate · open</span>
<span>esc to close</span>
</div>
</div>
</div>
);
}
+46 -13
View File
@@ -10,9 +10,23 @@ import {
Calendar,
Maximize2,
Wand2,
ImagePlus,
BookOpen,
} from "lucide-react";
import { BentoCard } from "./BentoCard";
import type { View } from "../App";
import { NpcGenerator } from "./NpcGenerator";
import { DiceRoller } from "./DiceRoller";
import { SessionLogger } from "./SessionLogger";
import { EncounterBuilder } from "./EncounterBuilder";
import { InitiativeTracker } from "./InitiativeTracker";
import { RandomTables } from "./RandomTables";
import { CalendarWidget } from "./CalendarWidget";
import { WorldBuilder } from "./WorldBuilder";
import { QuestDesigner } from "./QuestDesigner";
import { ItemForge } from "./ItemForge";
import { Soundboard } from "./Soundboard";
import { ImageGenerator } from "./ImageGenerator";
interface DashboardProps {
onNavigate: (view: View) => void;
@@ -185,19 +199,38 @@ export function Dashboard({ onNavigate }: DashboardProps) {
</button>
</div>
</BentoCard>
{/* Lore — 1×1 */}
<BentoCard title="Lore" icon={<BookOpen size={16} />} span="col-span-1 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed">
Index your world bible into a local vector store and ground AI generations in it.
</p>
</div>
<button
onClick={() => onNavigate("lore")}
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
>
<Maximize2 size={12} /> Open
</button>
</div>
</BentoCard>
{/* Image Generator — 2×1 */}
<BentoCard title="Image Generator" icon={<ImagePlus size={16} />} span="col-span-2 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<ImageGenerator />
</div>
<button
onClick={() => onNavigate("image")}
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
>
<Maximize2 size={12} /> Expand
</button>
</div>
</BentoCard>
</div>
);
}
// Inline imports for dashboard cards (compact versions)
import { NpcGenerator } from "./NpcGenerator";
import { DiceRoller } from "./DiceRoller";
import { SessionLogger } from "./SessionLogger";
import { EncounterBuilder } from "./EncounterBuilder";
import { InitiativeTracker } from "./InitiativeTracker";
import { RandomTables } from "./RandomTables";
import { CalendarWidget } from "./CalendarWidget";
import { WorldBuilder } from "./WorldBuilder";
import { QuestDesigner } from "./QuestDesigner";
import { ItemForge } from "./ItemForge";
import { Soundboard } from "./Soundboard";
+207 -33
View File
@@ -1,18 +1,36 @@
import { useState, useCallback } from "react";
import { useEffect, useState, useCallback } from "react";
import { useToast } from "./Toast";
interface DieResult {
notation: string;
rolls: number[];
total: number;
modifier: number;
advantage?: "adv" | "dis" | null;
keptRoll?: number;
}
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
// ponytail: roll templates for the 4 things a DM rolls every combat.
// Each template uses the modifier typed in the input box, so the DM
// sets "+5" once and templates respect it. 5e convention, not house rule.
const TEMPLATES: { label: string; die: string }[] = [
{ label: "Attack", die: "d20" },
{ label: "Save", die: "d20" },
{ label: "Check", die: "d20" },
{ label: "Damage", die: "d8" },
];
type Mode = "normal" | "adv" | "dis";
function rollDie(sides: number): number {
return Math.floor(Math.random() * sides) + 1;
}
function parseNotation(notation: string): { count: number; sides: number; modifier: number } | null {
function parseNotation(
notation: string,
): { count: number; sides: number; modifier: number } | null {
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
if (!match) return null;
return {
@@ -22,38 +40,123 @@ function parseNotation(notation: string): { count: number; sides: number; modifi
};
}
// ponytail: advantage/disadvantage is a 5e concept. We implement it as
// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only
// meaningful for d20 rolls; for other dice we just roll normally.
function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } {
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
return { rolls, kept: rolls[0] ?? 0 };
}
if (mode === "adv") {
const hi = Math.max(rolls[0], rolls[1]);
return { rolls, kept: hi };
}
const lo = Math.min(rolls[0], rolls[1]);
return { rolls, kept: lo };
}
export function DiceRoller() {
const [input, setInput] = useState("1d20");
const [results, setResults] = useState<DieResult[]>([]);
const [lastTotal, setLastTotal] = useState<number | null>(null);
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
const [mode, setMode] = useState<Mode>("normal");
const { addToast } = useToast();
const roll = useCallback(() => {
const parsed = parseNotation(input);
if (!parsed || parsed.sides < 2 || parsed.count > 100) return;
const roll = useCallback(
(overrideNotation?: string) => {
const notation = (overrideNotation ?? input).trim();
const parsed = parseNotation(notation);
if (!parsed || parsed.sides < 2 || parsed.count > 100) return;
const rolls = Array.from({ length: parsed.count }, () => rollDie(parsed.sides));
const sum = rolls.reduce((a, b) => a + b, 0) + parsed.modifier;
const result: DieResult = {
notation: input.trim(),
rolls,
total: sum,
};
// For d20 + adv/dis, roll an extra die to compare.
const baseCount = parsed.count;
const isD20AdvDis = parsed.sides === 20 && baseCount === 1 && mode !== "normal";
const rollCount = isD20AdvDis ? 2 : baseCount;
const rawRolls = Array.from({ length: rollCount }, () => rollDie(parsed.sides));
const { rolls, kept } = applyMode(rawRolls, parsed.sides, mode);
const sum = kept + parsed.modifier;
setResults((prev) => [result, ...prev].slice(0, 50));
setLastTotal(sum);
}, [input]);
const result: DieResult = {
notation: mode !== "normal" && parsed.sides === 20
? `${notation} (${mode === "adv" ? "adv" : "dis"})`
: notation,
rolls,
total: sum,
modifier: parsed.modifier,
advantage: mode !== "normal" && parsed.sides === 20 ? mode : null,
keptRoll: mode !== "normal" && parsed.sides === 20 ? kept : undefined,
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") roll();
};
setResults((prev) => [result, ...prev].slice(0, 50));
setLastTotal(sum);
setLastBreakdown(result);
},
[input, mode],
);
// ponytail: spacebar rolls the current notation when no input is focused.
// Hands-free dice at the table is the whole reason a DM uses a digital roller.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.code !== "Space") return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
e.preventDefault();
roll();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [roll]);
function clearHistory() {
setResults([]);
setLastTotal(null);
setLastBreakdown(null);
}
function copyHistory() {
if (results.length === 0) return;
const lines = results
.slice()
.reverse()
.map((r) => {
const detail = r.rolls.length > 1 ? ` [${r.rolls.join(", ")}]` : "";
return `${r.notation}${detail}${r.total}`;
});
void navigator.clipboard.writeText(lines.join("\n")).then(
() => addToast("History copied", "success"),
() => addToast("Copy failed", "error"),
);
}
// ponytail: apply a template (Attack/Save/Check/Damage). The current input's
// trailing modifier is reused so "+5" typed once works for all 5e rolls.
// The template label is recorded in the most recent history row so the
// DM can scan "Attack 1d20+5 → 17" instead of a wall of bare notation.
function applyTemplate(template: { notation: string; label: string }) {
roll(template.notation);
setResults((prev) => {
if (prev.length === 0) return prev;
const [head, ...rest] = prev;
return [
{ ...head, notation: `${template.label} (${head.notation})` },
...rest,
];
});
}
return (
<div className="flex flex-col gap-3 h-full">
{/* Big result display */}
<div className="flex items-center justify-center py-3">
<span className="font-mono text-3xl font-bold text-[var(--color-gold-bright)]">
<div className="flex flex-col items-center justify-center py-3 gap-1">
<span className="font-mono text-4xl font-bold text-[var(--color-gold-bright)]">
{lastTotal !== null ? lastTotal : "—"}
</span>
{lastBreakdown && (
<span className="font-mono text-[11px] text-[var(--color-text-secondary)]">
{formatBreakdown(lastBreakdown)}
</span>
)}
</div>
{/* Input row */}
@@ -62,30 +165,46 @@ export function DiceRoller() {
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-mono"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onKeyDown={(e) => e.key === "Enter" && roll()}
placeholder="2d6+3"
/>
<button
onClick={roll}
onClick={() => roll()}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
title="Roll (Space)"
>
Roll
</button>
</div>
{/* Mode toggle — advantage / disadvantage / normal */}
<div className="flex items-center gap-1.5">
<span className="text-[var(--color-text-dim)] text-xs mr-1">d20 mode:</span>
{(["normal", "adv", "dis"] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`rounded px-2.5 py-1 text-xs cursor-pointer transition-colors ${
mode === m
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-semibold"
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)]"
}`}
title={m === "adv" ? "Roll twice, keep higher" : m === "dis" ? "Roll twice, keep lower" : "Normal"}
>
{m === "normal" ? "Normal" : m === "adv" ? "Adv" : "Dis"}
</button>
))}
<span className="text-[var(--color-text-dim)] text-[10px] ml-auto" aria-hidden="true">
space to roll
</span>
</div>
{/* Quick dice */}
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((d) => (
<button
key={d}
onClick={() => {
const parsed = parseNotation(`1${d}`);
if (!parsed) return;
const rolls = [rollDie(parsed.sides)];
const total = rolls[0] + parsed.modifier;
setResults((prev) => [{ notation: `1${d}`, rolls, total }, ...prev].slice(0, 50));
setLastTotal(total);
}}
onClick={() => roll(`1${d}`)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer font-mono"
>
{d}
@@ -93,19 +212,62 @@ export function DiceRoller() {
))}
</div>
{/* Roll templates — the 4 things a DM rolls most. */}
<div className="flex flex-wrap gap-1.5">
<span className="text-[var(--color-text-dim)] text-[10px] uppercase tracking-wider w-full">
Templates (use current modifier)
</span>
{TEMPLATES.map((t) => {
const modMatch = input.match(/[+-]\s*\d+/);
const mod = modMatch ? modMatch[0].replace(/\s+/g, "") : "+0";
const notation = `1${t.die}${mod}`;
return (
<button
key={t.label}
onClick={() => applyTemplate({ label: t.label, notation })}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2.5 py-1 text-xs text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
title={`${t.label}${notation}`}
>
{t.label}
</button>
);
})}
</div>
{/* History */}
<div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)]">
History
</span>
{results.length > 0 && (
<div className="flex items-center gap-2">
<button
onClick={copyHistory}
className="text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer transition-colors"
>
copy
</button>
<button
onClick={clearHistory}
className="text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
>
clear
</button>
</div>
)}
</div>
<div className="flex flex-col gap-1">
{results.map((r, i) => (
<div
key={i}
className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-1.5"
>
<span className="font-mono text-xs text-[var(--color-text-secondary)]">
<span className="font-mono text-xs text-[var(--color-text-secondary)] truncate">
{r.notation}
{r.rolls.length > 1 && ` [${r.rolls.join(", ")}]`}
</span>
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)]">
<span className="font-mono text-sm font-bold text-[var(--color-gold-bright)] shrink-0 ml-2">
{r.total}
</span>
</div>
@@ -114,4 +276,16 @@ export function DiceRoller() {
</div>
</div>
);
}
}
function formatBreakdown(r: DieResult): string {
if (r.advantage && r.keptRoll !== undefined) {
return `[${r.rolls.join(", ")}] keep ${r.advantage === "adv" ? "hi" : "lo"}${r.modifier !== 0 ? ` ${r.modifier > 0 ? "+" : ""}${r.modifier}` : ""} = ${r.total}`;
}
if (r.rolls.length > 1 || r.modifier !== 0) {
const base = r.rolls.length > 1 ? `[${r.rolls.join("+")}]` : `${r.rolls[0]}`;
const mod = r.modifier !== 0 ? ` ${r.modifier > 0 ? "+" : ""}${r.modifier}` : "";
return `${base}${mod} = ${r.total}`;
}
return `${r.rolls[0]}`;
}
+306 -8
View File
@@ -1,6 +1,12 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
import { useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget";
interface Encounter {
monsters: string[];
@@ -9,13 +15,178 @@ interface Encounter {
loot: string;
}
export function EncounterBuilder() {
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
// ponytail: 5e terrains with icons. The plan called for 12; we now have
// 12 (added planar, haunted, ship, siege to the original 8).
const TERRAINS: { value: string; icon: string }[] = [
{ value: "Forest", icon: "🌲" },
{ value: "Dungeon", icon: "🏰" },
{ value: "Urban", icon: "🏛" },
{ value: "Mountain", icon: "⛰" },
{ value: "Desert", icon: "🏜" },
{ value: "Swamp", icon: "🌫" },
{ value: "Coastal", icon: "🌊" },
{ value: "Underdark", icon: "🕳" },
{ value: "Planar", icon: "✈" },
{ value: "Haunted", icon: "🏚" },
{ value: "Ship", icon: "🚢" },
{ value: "Siege", icon: "🏰" },
];
// ponytail: parse "3x Goblin Scouts" or "2 bandits" out of the LLM's monster
// string. Returns the count + the cleaned name. If no count is found, defaults
// to 1. Used when sending to the Initiative Tracker.
function parseMonsterLine(raw: string): { name: string; count: number } {
const m = raw.match(/^\s*(\d+)\s*[xX*]?\s+(.+)$/);
if (m) {
return { count: Math.max(1, parseInt(m[1], 10)), name: m[2].trim() };
}
return { count: 1, name: raw.trim() };
}
// ponytail: rough HP estimate by CR. Not strictly DMG-correct, but it gives
// the DM a starting point that they can edit on the Initiative Tracker. The
// table is from a synthesis of DMG averages; the DM is expected to override
// per monster. "—" for unknown CR.
const CR_TO_HP: Record<string, number> = {
"0": 4,
"0.125": 9,
"0.25": 17,
"0.5": 28,
"1": 39,
"2": 49,
"3": 64,
"4": 84,
"5": 109,
"6": 133,
"7": 156,
"8": 178,
"9": 200,
"10": 222,
"11": 244,
"12": 267,
"13": 289,
"14": 311,
"15": 333,
"16": 356,
"17": 378,
"18": 400,
"19": 422,
"20": 444,
"21": 467,
"22": 489,
"23": 511,
"24": 533,
"25": 556,
"26": 578,
"27": 600,
"28": 622,
"29": 644,
"30": 667,
};
function hpForCr(cr: string | null | undefined): number {
if (!cr) return 30; // ponytail: default to ~CR 1 HP if no CR known.
const hit = CR_TO_HP[cr.trim()];
return hit ?? 30;
}
// ponytail: very lightweight CR detection from a monster name. Looks for
// common ordinals ("CR 5"), trailing " (CR 1/4)" annotations, or specific
// monsters. The DM can edit the HP on the Initiative Tracker once imported.
const KNOWN_CR: Record<string, string> = {
goblin: "0.25",
"goblin scout": "0.25",
hobgoblin: "0.5",
orc: "0.5",
"orc warrior": "1",
kobold: "0.125",
bandit: "0.125",
"bandit captain": "2",
"bugbear chief": "3",
bugbear: "1",
gnoll: "0.5",
"dire wolf": "1",
wolf: "0.25",
skeleton: "0.25",
zombie: "0.25",
ghoul: "1",
ghast: "2",
wight: "3",
ogre: "2",
troll: "5",
"young red dragon": "10",
"adult red dragon": "17",
"ancient red dragon": "24",
manticore: "3",
griffon: "2",
wyvern: "6",
"ogre chief": "3",
shaman: "2",
};
function crForName(name: string): string | null {
const lower = name.toLowerCase();
if (KNOWN_CR[lower]) return KNOWN_CR[lower];
for (const [key, cr] of Object.entries(KNOWN_CR)) {
if (lower.includes(key)) return cr;
}
// Look for trailing "(CR X)" or "CR 1/2" annotations.
const m = name.match(/\(?CR\s*(\d+\/\d+|\d+)\)?/i);
if (m) return m[1];
return null;
}
export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
const [partyLevel, setPartyLevel] = useState(5);
const [partySize, setPartySize] = useState(4);
const [terrain, setTerrain] = useState("Forest");
const [encounter, setEncounter] = useState<Encounter | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => {
const parsed = parseLlmJson<Encounter>(g.data);
if (parsed) {
setEncounter(parsed);
addToast(`Loaded "${g.title}" from history`, "info");
}
});
const budget = useMemo(
() => encounterBudget(partyLevel, partySize),
[partyLevel, partySize],
);
// ponytail: extract a 1-20 XP estimate from the LLM's "3x CR-1" or similar
// wording. The LLM doesn't return CR directly, so we sniff the monster
// names against our small table. If we can't, we fall back to a rough
// estimate by difficulty and surface the uncertainty in the UI.
const computedXp = useMemo(() => {
if (!encounter) return 0;
let total = 0;
for (const raw of ensureArray(encounter.monsters)) {
const { count, name } = parseMonsterLine(flattenValue(raw));
const cr = crForName(name);
if (cr) {
// ponytail: CR to XP per DMG. 1/4 = 50, 1/2 = 100, 1 = 200, etc.
const xp = CR_TO_XP[cr] ?? 0;
total += xp * count;
} else {
total += 50 * count; // fallback: assume ~CR 1/4 average
}
}
return total;
}, [encounter]);
const difficulty: Difficulty | null = encounter
? difficultyForXp(computedXp, budget)
: null;
async function generate() {
setLoading(true);
@@ -41,15 +212,49 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
const parsed = parseLlmJson<Encounter>(result);
if (parsed) {
setEncounter(parsed);
addToast("Encounter generated", "success");
const title = extractTitle("encounter", result, `${parsed.difficulty ?? "?"} in ${terrain}`);
const source = `Party of ${partySize} lvl ${partyLevel} in ${terrain}`;
void addGeneration({ kind: "encounter", title, data: result, source });
const monsters = ensureArray(parsed.monsters).map(flattenValue).join(", ");
void addToLore(
`Encounter: ${parsed.difficulty || "?"} in ${terrain}`,
`Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`,
);
} else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`Encounter generation failed: ${e}`, "error");
}
setLoading(false);
}
function sendToInitiative() {
if (!encounter) return;
const combatants = ensureArray(encounter.monsters).flatMap((raw) => {
const { name, count } = parseMonsterLine(flattenValue(raw));
const cr = crForName(name);
const hp = hpForCr(cr);
return Array.from({ length: count }, () => ({ name, hp }));
});
if (combatants.length === 0) {
addToast("No monsters to send", "warning");
return;
}
const payload: AddCombatantsPayload = {
combatants,
source: `${encounter.difficulty ?? "?"} in ${terrain}`,
};
bus.emit(Events.AddCombatants, payload);
addToast(
`Sent ${combatants.length} combatant${combatants.length === 1 ? "" : "s"} to Initiative Tracker — switch to Initiative to roll`,
"success",
);
}
return (
<div className="flex flex-col gap-2 h-full overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
@@ -84,12 +289,27 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
onChange={(e) => setTerrain(e.target.value)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
>
{["Forest", "Dungeon", "Urban", "Mountain", "Desert", "Swamp", "Coastal", "Underdark"].map((t) => (
<option key={t} value={t}>{t}</option>
{TERRAINS.map((t) => (
<option key={t.value} value={t.value}>
{t.icon} {t.value}
</option>
))}
</select>
</div>
{/* Budget readout — shows the DM what XP thresholds they're working with. */}
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] p-2 text-[10px] font-mono">
<div className="flex justify-between text-[var(--color-text-dim)] mb-1">
<span>Budget (party of {partySize} lvl {partyLevel})</span>
</div>
<div className="grid grid-cols-4 gap-1 text-center">
<BudgetCell label="Easy" value={budget.easy} color={DIFFICULTY_COLOR.Easy} active={difficulty === "Easy"} />
<BudgetCell label="Med" value={budget.medium} color={DIFFICULTY_COLOR.Medium} active={difficulty === "Medium"} />
<BudgetCell label="Hard" value={budget.hard} color={DIFFICULTY_COLOR.Hard} active={difficulty === "Hard"} />
<BudgetCell label="Dead" value={budget.deadly} color={DIFFICULTY_COLOR.Deadly} active={difficulty === "Deadly"} />
</div>
</div>
<button
onClick={generate}
disabled={loading}
@@ -107,11 +327,18 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
{encounter && (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold">
{encounter.difficulty} Encounter
<span
className="font-heading text-xs font-semibold rounded-full px-2 py-0.5"
style={{
color: difficulty ? DIFFICULTY_COLOR[difficulty] : "var(--color-gold-bright)",
borderColor: difficulty ? DIFFICULTY_COLOR[difficulty] : "var(--color-gold-bright)",
borderWidth: 1,
}}
>
{difficulty ?? encounter.difficulty} Encounter
</span>
<span className="text-[var(--color-text-dim)] text-[10px]">
Level {partyLevel} × {partySize}
~{computedXp} XP · L{partyLevel}×{partySize}
</span>
</div>
@@ -131,8 +358,79 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Loot</span>
<p className="text-[var(--color-gold-bright)] text-xs mt-0.5">{encounter.loot}</p>
</div>
<button
onClick={sendToInitiative}
className="mt-1 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-1.5 text-xs font-semibold hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer flex items-center justify-center gap-1.5"
title="Add these monsters to the Initiative Tracker"
>
Send to Initiative Tracker
</button>
</div>
)}
</div>
);
}
}
function BudgetCell({
label,
value,
color,
active,
}: {
label: string;
value: number;
color: string;
active: boolean;
}) {
return (
<div
className={`rounded px-1 py-0.5 transition-colors ${
active ? "bg-[var(--color-bg-card)]" : ""
}`}
style={{ color: active ? color : "var(--color-text-dim)" }}
>
<div className="text-[9px] uppercase tracking-wider">{label}</div>
<div className="font-bold">{value}</div>
</div>
);
}
// ponytail: CR → XP. From the DMG encounter-building table. Used to estimate
// the XP total of a generated encounter when computing difficulty.
const CR_TO_XP: Record<string, number> = {
"0": 10,
"0.125": 25,
"0.25": 50,
"0.5": 100,
"1": 200,
"2": 450,
"3": 700,
"4": 1100,
"5": 1800,
"6": 2300,
"7": 2900,
"8": 3900,
"9": 5000,
"10": 5900,
"11": 7200,
"12": 8400,
"13": 10000,
"14": 11500,
"15": 13000,
"16": 15000,
"17": 18000,
"18": 20000,
"19": 22000,
"20": 25000,
"21": 33000,
"22": 41000,
"23": 50000,
"24": 62000,
"25": 75000,
"26": 90000,
"27": 105000,
"28": 120000,
"29": 135000,
"30": 155000,
};
+45
View File
@@ -0,0 +1,45 @@
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
// ponytail: a tiny class component. New dep (`react-error-boundary`) is 4KB
// for a feature that's 20 lines. Roll it by hand.
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error) {
console.error("Uncaught error in DM-Pal:", error);
}
render() {
if (this.state.error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 p-8 text-center">
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg">
Something went wrong
</h2>
<pre className="max-w-xl text-xs text-[var(--color-text-dim)] whitespace-pre-wrap break-words">
{this.state.error.message}
</pre>
<button
onClick={() => location.reload()}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
>
Reload
</button>
</div>
);
}
return this.props.children;
}
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Sparkles, ImageOff } from "lucide-react";
interface Props {
/** Text-to-image prompt. Changing it triggers a new generation. */
prompt: string | null;
/** Bump to force a regenerate with the same prompt. */
nonce?: number;
className?: string;
/** Aspect for the placeholder box while loading/empty. */
aspect?: string;
}
/**
* Generates and caches an image for `prompt` via the Ollama image model.
* Results are cached on disk by the backend, so re-renders are instant.
* macOS-only: on other OSes the backend returns an error and we show a
* placeholder instead of a confusing timeout.
*/
export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "aspect-square" }: Props) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [unsupported, setUnsupported] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!prompt) return;
let cancelled = false;
setLoading(true);
setError("");
setUnsupported(false);
invoke<string>("generate_image", { req: { prompt } })
.then((url) => {
if (!cancelled) setDataUrl(url);
})
.catch((e) => {
const msg = String(e);
if (!cancelled) {
if (msg.includes("macOS-only")) setUnsupported(true);
else setError(msg);
}
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [prompt, nonce]);
if (!prompt) return null;
const box = `relative ${aspect} w-full rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center ${className}`;
if (unsupported) {
return (
<div className={box}>
<div className="flex flex-col items-center gap-1 text-[var(--color-text-dim)]">
<ImageOff size={20} />
<span className="text-[10px] text-center px-2">Image gen is macOS-only via Ollama</span>
</div>
</div>
);
}
if (error) {
return (
<div className={box}>
<span className="text-[10px] text-[var(--color-danger)] px-2 text-center">{error}</span>
</div>
);
}
if (loading) {
return (
<div className={box}>
<Sparkles size={20} className="text-[var(--color-gold-bright)] animate-pulse" />
</div>
);
}
return dataUrl ? (
<div className={box}>
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
</div>
) : null;
}
+742
View File
@@ -0,0 +1,742 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
History as HistoryIcon,
X,
Trash2,
Search,
RefreshCw,
ScrollText,
Sparkles,
Eye,
Loader2,
} from "lucide-react";
import { useToast } from "./Toast";
import {
listGenerations,
getGeneration,
deleteGeneration,
clearAllGenerations,
relativeTime,
extractTitle,
KIND_LABELS,
KIND_ORDER,
type Generation,
type GenerationKind,
type GenerationSummary,
} from "../lib/generations";
import { ensureArray, flattenValue, parseLlmJson } from "../lib/llm-parse";
interface HistoryViewProps {
// ponytail: optional callback when a row is rehydrated into a tool.
// The App shell wires this to set the active view and prefill the form.
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
}
export function HistoryView({ onRehydrate }: HistoryViewProps) {
const [summaries, setSummaries] = useState<GenerationSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [activeKind, setActiveKind] = useState<GenerationKind | "all">("all");
const [selected, setSelected] = useState<Generation | null>(null);
const [selectedLoading, setSelectedLoading] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const { addToast } = useToast();
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const list = await listGenerations();
setSummaries(list);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const grouped = useMemo(() => {
const byKind = new Map<GenerationKind, GenerationSummary[]>();
for (const k of KIND_ORDER) byKind.set(k, []);
for (const s of summaries) {
const list = byKind.get(s.kind);
if (list) list.push(s);
}
return byKind;
}, [summaries]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const rows: GenerationSummary[] = [];
for (const [, list] of grouped) {
for (const s of list) {
if (activeKind !== "all" && s.kind !== activeKind) continue;
if (q && !s.title.toLowerCase().includes(q)) continue;
rows.push(s);
}
}
return rows;
}, [grouped, query, activeKind]);
const onSelect = useCallback(async (id: number) => {
setSelectedLoading(true);
try {
const g = await getGeneration(id);
setSelected(g);
} catch (e) {
addToast(`Failed to load: ${e}`, "error");
} finally {
setSelectedLoading(false);
}
}, [addToast]);
const onDelete = useCallback(
async (id: number) => {
try {
await deleteGeneration(id);
setSummaries((prev) => prev.filter((s) => s.id !== id));
if (selected?.id === id) setSelected(null);
} catch (e) {
addToast(`Delete failed: ${e}`, "error");
}
},
[selected, addToast],
);
const onClearAll = useCallback(async () => {
try {
const n = await clearAllGenerations();
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
setSummaries([]);
setSelected(null);
setConfirmClear(false);
} catch (e) {
addToast(`Clear failed: ${e}`, "error");
}
}, [addToast]);
return (
<div className="flex h-full gap-3 p-4">
{/* List pane */}
<div className="flex flex-col w-80 shrink-0 glass-card p-3 gap-2">
<div className="flex items-center justify-between">
<h2 className="font-heading text-[var(--color-gold-bright)] text-base font-semibold flex items-center gap-2">
<HistoryIcon size={16} /> History
</h2>
<div className="flex items-center gap-1">
<button
onClick={refresh}
className="text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] p-1 rounded cursor-pointer"
title="Refresh"
aria-label="Refresh history"
>
<RefreshCw size={14} />
</button>
{summaries.length > 0 && !confirmClear && (
<button
onClick={() => setConfirmClear(true)}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] p-1 rounded cursor-pointer"
title="Clear all"
aria-label="Clear all history"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
{confirmClear && (
<div className="flex items-center justify-between rounded bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/40 px-2 py-1.5 text-xs">
<span className="text-[var(--color-danger)]">Delete all {summaries.length}?</span>
<div className="flex gap-1">
<button
onClick={onClearAll}
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
>
Yes
</button>
<button
onClick={() => setConfirmClear(false)}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-[10px] cursor-pointer"
>
No
</button>
</div>
</div>
)}
<div className="flex items-center gap-1.5 bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] rounded-lg px-2 py-1.5">
<Search size={12} className="text-[var(--color-text-dim)]" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search history…"
className="flex-1 bg-transparent text-xs text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none"
aria-label="Search history"
/>
</div>
{/* Kind filter chips */}
<div className="flex flex-wrap gap-1">
<KindChip
label="All"
count={summaries.length}
active={activeKind === "all"}
onClick={() => setActiveKind("all")}
/>
{KIND_ORDER.map((k) => {
const n = grouped.get(k)?.length ?? 0;
if (n === 0) return null;
return (
<KindChip
key={k}
label={KIND_LABELS[k]}
count={n}
active={activeKind === k}
onClick={() => setActiveKind(k)}
/>
);
})}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto -mx-1">
{loading && (
<div className="flex items-center justify-center py-6 text-[var(--color-text-dim)] text-xs">
<Loader2 size={14} className="animate-spin mr-2" /> Loading
</div>
)}
{error && (
<div className="rounded bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/40 p-2 text-[var(--color-danger)] text-xs">
{error}
</div>
)}
{!loading && !error && filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
<ScrollText size={20} className="mb-2 opacity-50" />
<span>
{summaries.length === 0
? "Nothing generated yet."
: "No matches."}
</span>
{summaries.length === 0 && (
<span className="mt-1 text-[10px]">
Every NPC, world, item, and quest you generate will show up here.
</span>
)}
</div>
)}
{filtered.map((s) => (
<button
key={s.id}
onClick={() => onSelect(s.id)}
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
selected?.id === s.id
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
: "hover:bg-[var(--color-bg-card)]"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
{KIND_LABELS[s.kind]}
</span>
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
{relativeTime(s.createdAt)}
</span>
</div>
<div className="text-sm text-[var(--color-text-primary)] truncate">
{s.title}
</div>
</button>
))}
</div>
</div>
{/* Detail pane */}
<div className="flex-1 min-w-0 glass-card p-4 overflow-y-auto">
{selectedLoading ? (
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)] text-sm">
<Loader2 size={16} className="animate-spin mr-2" /> Loading
</div>
) : selected ? (
<GenerationDetail
generation={selected}
onDelete={onDelete}
onRehydrate={onRehydrate}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-center text-[var(--color-text-dim)] text-sm">
<Eye size={28} className="mb-3 opacity-40" />
<span>Select a generation to view it.</span>
<span className="mt-1 text-xs">
From here you can re-open it in its tool or copy as Markdown.
</span>
</div>
)}
</div>
</div>
);
}
function KindChip({
label,
count,
active,
onClick,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`rounded-full px-2 py-0.5 text-[10px] cursor-pointer transition-colors ${
active
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] font-semibold"
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)]"
}`}
>
{label} <span className="opacity-70">{count}</span>
</button>
);
}
function GenerationDetail({
generation,
onDelete,
onRehydrate,
}: {
generation: Generation;
onDelete: (id: number) => void;
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
}) {
const { addToast } = useToast();
const isImage = generation.kind === "image";
const isSession = generation.kind === "session";
let parsed: unknown = null;
if (!isImage) {
parsed = parseLlmJson(generation.data);
}
const fallbackTitle = generation.title;
const title = isImage
? generation.title
: extractTitle(generation.kind, generation.data, fallbackTitle);
const rehydratableKinds: GenerationKind[] = [
"npc",
"encounter",
"world",
"item",
"quest",
];
const canRehydrate = rehydratableKinds.includes(generation.kind);
function copyMarkdown() {
let md: string;
if (isImage) {
md = `![${title}](data:image/png;base64,…)\n\n_${new Date(
generation.createdAt,
).toLocaleString()}_`;
} else if (parsed && typeof parsed === "object") {
md = `# ${title}\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
} else {
md = `# ${title}\n\n${generation.data}`;
}
void navigator.clipboard.writeText(md).then(
() => addToast("Copied as Markdown", "success"),
() => addToast("Copy failed", "error"),
);
}
return (
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-start justify-between gap-3 pb-2 border-b border-[var(--color-border-glass)]">
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)]">
{KIND_LABELS[generation.kind]}
</div>
<h3 className="font-heading text-[var(--color-gold-bright)] text-lg font-bold truncate">
{title}
</h3>
{generation.source && (
<p className="text-[10px] text-[var(--color-text-dim)] mt-0.5">
from: {generation.source}
</p>
)}
<p className="text-[10px] text-[var(--color-text-dim)] mt-0.5">
{new Date(generation.createdAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{canRehydrate && onRehydrate && (
<button
onClick={() => onRehydrate(generation.kind, generation)}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer flex items-center gap-1"
title={`Open in ${KIND_LABELS[generation.kind]} tool`}
>
<Sparkles size={12} /> Open in tool
</button>
)}
<button
onClick={copyMarkdown}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-1.5 text-xs hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
>
Copy
</button>
<button
onClick={() => onDelete(generation.id)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-1.5 text-xs hover:text-[var(--color-danger)] transition-colors cursor-pointer"
title="Delete this generation"
aria-label="Delete generation"
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* Body */}
{isImage ? (
<ImageBody data={generation.data} prompt={generation.title} />
) : isSession ? (
<SessionBody data={generation.data} />
) : parsed && typeof parsed === "object" ? (
<StructuredBody data={parsed} />
) : (
<pre className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] p-3 text-xs text-[var(--color-text-secondary)] whitespace-pre-wrap break-words">
{generation.data}
</pre>
)}
</div>
);
}
function ImageBody({ data, prompt }: { data: string; prompt: string }) {
return (
<div className="flex flex-col gap-2 items-start">
<img
src={data}
alt={prompt}
className="rounded-lg max-w-full max-h-[60vh] border border-[var(--color-border-glass)]"
/>
<a
href={data}
download="dm-pal-image.png"
className="text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)]"
>
Save PNG
</a>
</div>
);
}
function SessionBody({ data }: { data: string }) {
return (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-sm text-[var(--color-text-primary)] whitespace-pre-wrap leading-relaxed">
{data}
</div>
);
}
function StructuredBody({ data }: { data: unknown }) {
if (!data || typeof data !== "object") return null;
const obj = data as Record<string, unknown>;
return (
<div className="flex flex-col gap-3">
{typeof obj.bio === "string" && (
<Section label="Bio">
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed">
{obj.bio}
</p>
</Section>
)}
{typeof obj.description === "string" && (
<Section label="Description">
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed">
{obj.description}
</p>
</Section>
)}
{typeof obj.hook === "string" && (
<Section label="Hook">
<p className="text-sm text-[var(--color-text-secondary)] italic">
{obj.hook}
</p>
</Section>
)}
{Array.isArray(obj.personality) && obj.personality.length > 0 && (
<Section label="Personality">
<div className="flex flex-wrap gap-1.5">
{ensureArray(obj.personality).map((p, i) => (
<span
key={i}
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs"
>
{flattenValue(p)}
</span>
))}
</div>
</Section>
)}
{Array.isArray(obj.goals) && obj.goals.length > 0 && (
<Section label="Goals">
<ul className="list-disc list-inside text-sm text-[var(--color-text-primary)] space-y-0.5">
{ensureArray(obj.goals).map((g, i) => (
<li key={i}>{flattenValue(g)}</li>
))}
</ul>
</Section>
)}
{Array.isArray(obj.monsters) && obj.monsters.length > 0 && (
<Section label="Monsters">
<ul className="list-disc list-inside text-sm text-[var(--color-text-primary)] space-y-0.5">
{ensureArray(obj.monsters).map((m, i) => (
<li key={i}>{flattenValue(m)}</li>
))}
</ul>
</Section>
)}
{typeof obj.terrain === "string" && (
<Section label="Terrain">
<p className="text-sm text-[var(--color-text-primary)]">{obj.terrain}</p>
</Section>
)}
{typeof obj.difficulty === "string" && (
<Section label="Difficulty">
<span className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-0.5 text-xs">
{obj.difficulty}
</span>
</Section>
)}
{typeof obj.loot === "string" && (
<Section label="Loot">
<p className="text-sm text-[var(--color-gold-bright)]">{obj.loot}</p>
</Section>
)}
{Array.isArray(obj.regions) && obj.regions.length > 0 && (
<Section label="Regions">
<ul className="text-sm text-[var(--color-text-primary)] space-y-1">
{ensureArray(obj.regions).map((r, i) => (
<li
key={i}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-2 py-1"
>
{flattenValue(r)}
</li>
))}
</ul>
</Section>
)}
{Array.isArray(obj.landmarks) && obj.landmarks.length > 0 && (
<Section label="Landmarks">
<div className="flex flex-wrap gap-1.5">
{ensureArray(obj.landmarks).map((l, i) => (
<span
key={i}
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs"
>
{flattenValue(l)}
</span>
))}
</div>
</Section>
)}
{Array.isArray(obj.conflicts) && obj.conflicts.length > 0 && (
<Section label="Conflicts">
<div className="flex flex-col gap-1">
{ensureArray(obj.conflicts).map((c, i) => (
<div
key={i}
className="rounded bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-2 py-1 text-sm text-[var(--color-text-primary)]"
>
{flattenValue(c)}
</div>
))}
</div>
</Section>
)}
{Array.isArray(obj.cultures) && obj.cultures.length > 0 && (
<Section label="Cultures">
<div className="flex flex-wrap gap-1.5">
{ensureArray(obj.cultures).map((c, i) => (
<span
key={i}
className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-2 py-0.5 text-xs"
>
{flattenValue(c)}
</span>
))}
</div>
</Section>
)}
{Array.isArray(obj.steps) && obj.steps.length > 0 && (
<Section label="Steps">
<ol className="flex flex-col gap-2 list-decimal list-inside">
{ensureArray(obj.steps).map((s, i) => {
const step = typeof s === "object" && s !== null
? (s as Record<string, unknown>)
: { title: "", description: flattenValue(s) };
return (
<li
key={i}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] px-3 py-2"
>
{typeof step.title === "string" && (
<div className="text-sm font-semibold text-[var(--color-gold-bright)]">
{step.title}
</div>
)}
{typeof step.description === "string" && (
<div className="text-sm text-[var(--color-text-primary)] mt-0.5">
{step.description}
</div>
)}
</li>
);
})}
</ol>
</Section>
)}
{typeof obj.twist === "string" && (
<Section label="Twist">
<div className="rounded border-l-2 border-[var(--color-danger)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
{obj.twist}
</div>
</Section>
)}
{typeof obj.reward === "string" && (
<Section label="Reward">
<div className="rounded border-l-2 border-[var(--color-gold-bright)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)]">
{obj.reward}
</div>
</Section>
)}
{typeof obj.mechanical === "string" && (
<Section label="Mechanics">
<div className="rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-glass)] p-2 text-sm text-[var(--color-text-primary)]">
{obj.mechanical}
</div>
</Section>
)}
{typeof obj.lore === "string" && (
<Section label="Lore">
<p className="text-sm text-[var(--color-text-secondary)] italic">
{obj.lore}
</p>
</Section>
)}
{typeof obj.rarity === "string" && (
<Section label="Rarity">
<span className="text-xs text-[var(--color-text-secondary)]">
{obj.rarity}
{typeof obj.type === "string" && <> · {obj.type}</>}
</span>
</Section>
)}
{/* Fallback: any keys we didn't render show as a key/value table. */}
<details className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] mt-2">
<summary className="px-3 py-2 text-xs text-[var(--color-text-dim)] cursor-pointer hover:text-[var(--color-text-secondary)]">
Raw JSON
</summary>
<pre className="px-3 pb-3 text-xs text-[var(--color-text-secondary)] overflow-x-auto whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)}
</pre>
</details>
</div>
);
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)] font-medium">
{label}
</span>
{children}
</div>
);
}
// ─── Modal wrapper ────────────────────────────────────────────
interface HistoryDrawerProps {
open: boolean;
onClose: () => void;
onRehydrate?: (kind: GenerationKind, data: Generation) => void;
}
export function HistoryDrawer({ open, onClose, onRehydrate }: HistoryDrawerProps) {
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-30 bg-black/50"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
aria-modal="true"
role="dialog"
aria-label="Generation history"
>
<motion.div
className="absolute inset-4 lg:inset-8 bg-[var(--color-bg-deep)] rounded-2xl border border-[var(--color-border-glass)] overflow-hidden"
onClick={(e) => e.stopPropagation()}
initial={{ scale: 0.97, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.97, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]">
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold flex items-center gap-2">
<HistoryIcon size={14} /> Generation History
</h2>
<button
onClick={onClose}
className="text-[var(--color-text-dim)] hover:text-[var(--color-text-primary)] cursor-pointer"
title="Close (Esc)"
aria-label="Close history"
>
<X size={18} />
</button>
</div>
<div className="h-[calc(100%-44px)]">
<HistoryView onRehydrate={(k, d) => {
onRehydrate?.(k, d);
onClose();
}} />
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ImagePlus, Sparkles } from "lucide-react";
import { useToast } from "./Toast";
import { addGeneration, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
const DEFAULT_MODEL = "x/flux2-klein:4b";
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
const [prompt, setPrompt] = useState("");
const [model, setModel] = useState("");
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [variant, setVariant] = useState(0);
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
setPrompt(g.title);
setDataUrl(g.data);
addToast(`Loaded "${g.title}" from history`, "info");
});
async function generate() {
if (!prompt.trim()) return;
setLoading(true);
setError("");
setDataUrl(null);
try {
// ponytail: append a variant tag to bust the backend's prompt-hash cache
// so "regenerate" actually produces a new image instead of the cached one.
const v = variant > 0 ? `\n\n(variation ${variant})` : "";
const url = await invoke<string>("generate_image", {
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
});
setDataUrl(url);
addToast("Image generated", "success");
// ponytail: persist the PNG data URL so the DM can re-open past renders.
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
} catch (e) {
setError(String(e));
addToast(`Image generation failed: ${e}`, "error");
}
setLoading(false);
}
function regenerate() {
setVariant((v) => v + 1);
// run after state update flushes
setTimeout(generate, 0);
}
const unsupported = error.toLowerCase().includes("macos-only");
return (
<div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Prompt</label>
<textarea
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-24 resize-y"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. portrait of a grizzled dwarf blacksmith at a forge, warm light, oil-painting fantasy style, 1024x1024"
onKeyDown={(e) => e.key === "Enter" && (e.metaKey || e.ctrlKey) && generate()}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
Model (optional defaults to {DEFAULT_MODEL})
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="x/flux2-klein:4b · x/z-image-turbo for speed"
/>
</div>
<div className="flex gap-2">
<button
onClick={generate}
disabled={loading || !prompt.trim()}
className="flex-1 rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{loading ? "✨ Generating…" : "✨ Generate Image"}
</button>
{dataUrl && !loading && (
<button
onClick={regenerate}
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-2 text-sm hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
title="Generate a new variation"
>
<Sparkles size={16} />
</button>
)}
</div>
{loading && (
<div className="aspect-square w-full max-w-md mx-auto rounded-lg border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)] flex items-center justify-center">
<ImagePlus size={28} className="text-[var(--color-gold-bright)] animate-pulse" />
</div>
)}
{unsupported && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
Image generation is macOS-only via Ollama (for now). On other platforms the
buttons elsewhere fall back to a placeholder.
</div>
)}
{error && !unsupported && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
{error}
</div>
)}
{dataUrl && !loading && (
<div className="flex flex-col gap-2">
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
</div>
<a
href={dataUrl}
download="dm-pal-image.png"
className="text-center text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
>
Save PNG
</a>
</div>
)}
</div>
);
}
+185 -21
View File
@@ -1,4 +1,6 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useToast } from "./Toast";
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
interface Combatant {
id: string;
@@ -7,6 +9,7 @@ interface Combatant {
hp: number;
maxHp: number;
conditions: string[];
notes?: string;
}
const CONDITIONS = [
@@ -24,6 +27,46 @@ export function InitiativeTracker() {
const [hp, setHp] = useState(10);
const [activeId, setActiveId] = useState<string | null>(null);
const [round, setRound] = useState(1);
const [confirmReset, setConfirmReset] = useState(false);
const [hpInput, setHpInput] = useState<Record<string, string>>({});
// (useToast is wired up in the bus handler below; silence unused-import.)
useToast();
// ponytail: listen for EncounterBuilder's "send to initiative" event.
// We don't auto-switch view (the DM might be editing) but the toast tells
// them where the combatants landed. The encounter's HP estimates land in
// a new "pending" state — the DM can then click "Roll initiative" to assign
// d20+0 to each (since the LLM didn't provide DEX scores) and re-sort.
const [pendingPush, setPendingPush] = useState<{ count: number; source: string } | null>(null);
useEffect(() => {
return bus.on<AddCombatantsPayload>(Events.AddCombatants, (payload) => {
const added: Combatant[] = payload.combatants.map((c) => ({
id: String(nextId++),
name: c.name,
initiative: 0,
hp: c.hp,
maxHp: c.hp,
conditions: [],
}));
setCombatants((prev) => [...prev, ...added]);
setPendingPush({ count: added.length, source: payload.source });
});
}, []);
// ponytail: roll initiative for any combatant stuck at 0 (the placeholder
// for "we don't know the DEX yet"). The DM would otherwise have to click
// each one individually. After this, sort by initiative.
function rollPendingInitiative() {
setCombatants((prev) => {
const rolled = prev.map((c) =>
c.initiative === 0 && pendingPush && c.id
? { ...c, initiative: Math.floor(Math.random() * 20) + 1 + 0 }
: c,
);
return rolled.sort((a, b) => b.initiative - a.initiative);
});
setPendingPush(null);
}
const addCombatant = useCallback(() => {
if (!name.trim()) return;
@@ -55,16 +98,45 @@ export function InitiativeTracker() {
? c.conditions.filter((cn) => cn !== condition)
: [...c.conditions, condition],
}
: c
)
: c,
),
);
}, []);
// ponytail: ±1 buttons are for chipping; typed input is for big hits.
// Setting HP to a specific number requires a single keystroke + Enter.
const applyHp = useCallback(
(id: string, raw: string) => {
const n = parseInt(raw);
if (Number.isNaN(n)) return;
setCombatants((prev) =>
prev.map((c) =>
c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, n)) } : c,
),
);
setHpInput((prev) => {
const { [id]: _, ...rest } = prev;
return rest;
});
},
[],
);
const changeHp = useCallback((id: string, delta: number) => {
setCombatants((prev) =>
prev.map((c) =>
c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, c.hp + delta)) } : c
)
c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, c.hp + delta)) } : c,
),
);
}, []);
const setInitiative = useCallback((id: string, raw: string) => {
const n = parseInt(raw);
if (Number.isNaN(n)) return;
setCombatants((prev) =>
[...prev.map((c) => (c.id === id ? { ...c, initiative: n } : c))].sort(
(a, b) => b.initiative - a.initiative,
),
);
}, []);
@@ -87,8 +159,19 @@ export function InitiativeTracker() {
setCombatants([]);
setActiveId(null);
setRound(1);
setConfirmReset(false);
}, []);
const activeIdx = activeId ? combatants.findIndex((c) => c.id === activeId) : -1;
const nextUpId =
combatants.length > 0
? activeIdx === -1
? combatants[0].id
: activeIdx < combatants.length - 1
? combatants[activeIdx + 1].id
: null
: null;
return (
<div className="flex flex-col gap-2 h-full overflow-y-auto">
{/* Round indicator */}
@@ -99,19 +182,57 @@ export function InitiativeTracker() {
<div className="flex gap-1">
<button
onClick={nextTurn}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors"
disabled={combatants.length === 0}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Next Turn
</button>
<button
onClick={reset}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-0.5 text-xs cursor-pointer hover:text-[var(--color-danger)] transition-colors"
>
Reset
</button>
{confirmReset ? (
<div className="flex items-center gap-1">
<span className="text-[10px] text-[var(--color-danger)]">Clear all?</span>
<button
onClick={reset}
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-xs font-semibold cursor-pointer"
>
Yes
</button>
<button
onClick={() => setConfirmReset(false)}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-xs cursor-pointer"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmReset(true)}
disabled={combatants.length === 0}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-2 py-0.5 text-xs cursor-pointer hover:text-[var(--color-danger)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Reset
</button>
)}
</div>
</div>
{/* ponytail: when monsters are pushed from the Encounter Builder, the
DM needs to roll initiative for them. The push sets all their
initiatives to 0; this banner offers a one-click 1d20 per pending
combatant. */}
{pendingPush && (
<div className="flex items-center justify-between rounded-lg bg-[var(--color-gold-glow)] border border-[var(--color-gold-bright)]/40 px-2.5 py-1.5 text-xs">
<span className="text-[var(--color-gold-bright)]">
{pendingPush.count} from {pendingPush.source}
</span>
<button
onClick={rollPendingInitiative}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
>
Roll initiative
</button>
</div>
)}
{/* Add combatant */}
<div className="flex gap-1.5">
<input
@@ -120,6 +241,7 @@ export function InitiativeTracker() {
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addCombatant()}
placeholder="Name"
aria-label="Combatant name"
/>
<input
className="w-10 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
@@ -127,6 +249,7 @@ export function InitiativeTracker() {
value={initBonus}
onChange={(e) => setInitBonus(parseInt(e.target.value) || 0)}
title="Initiative bonus"
aria-label="Initiative bonus"
/>
<input
className="w-12 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-1 py-1 text-center text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
@@ -134,6 +257,7 @@ export function InitiativeTracker() {
value={hp}
onChange={(e) => setHp(parseInt(e.target.value) || 1)}
title="Max HP"
aria-label="Max HP"
/>
<button
onClick={addCombatant}
@@ -143,10 +267,19 @@ export function InitiativeTracker() {
</button>
</div>
{/* Empty state */}
{combatants.length === 0 && (
<div className="flex flex-col items-center justify-center py-6 text-center text-[var(--color-text-dim)] text-xs">
<span>No combatants yet.</span>
<span className="mt-1">Add a name + HP, press Enter or +.</span>
</div>
)}
{/* Combatant list */}
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
{combatants.map((c) => {
const isActive = c.id === activeId;
const isNextUp = c.id === nextUpId && !isActive;
const hpPct = c.maxHp > 0 ? c.hp / c.maxHp : 0;
const hpColor =
hpPct > 0.5
@@ -161,22 +294,36 @@ export function InitiativeTracker() {
className={`rounded-lg p-2 text-xs transition-all cursor-default ${
isActive
? "glass-card-active"
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]"
: isNextUp
? "bg-[var(--color-bg-card)] border border-[var(--color-border-glass)]"
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)]"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-[var(--color-text-dim)] w-6 text-right shrink-0">
{c.initiative}
</span>
<input
className="font-mono text-[var(--color-text-dim)] w-8 text-right shrink-0 bg-transparent focus:outline-none focus:text-[var(--color-gold-bright)]"
type="number"
defaultValue={c.initiative}
onBlur={(e) => setInitiative(c.id, e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
title="Edit initiative"
aria-label={`Initiative for ${c.name}`}
/>
<span className="text-[var(--color-text-primary)] truncate font-medium">
{c.name}
</span>
{isNextUp && (
<span className="text-[9px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
next
</span>
)}
</div>
<button
onClick={() => removeCombatant(c.id)}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] transition-colors shrink-0 cursor-pointer"
title="Remove"
aria-label={`Remove ${c.name}`}
>
×
</button>
@@ -193,8 +340,23 @@ export function InitiativeTracker() {
<span className="font-mono text-[10px] shrink-0" style={{ color: hpColor }}>
{c.hp}/{c.maxHp}
</span>
<button onClick={() => changeHp(c.id, -1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer"></button>
<button onClick={() => changeHp(c.id, 1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-success)] cursor-pointer">+</button>
<button onClick={() => changeHp(c.id, -1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer" aria-label="Decrease HP by 1"></button>
<button onClick={() => changeHp(c.id, 1)} className="text-[var(--color-text-dim)] hover:text-[var(--color-success)] cursor-pointer" aria-label="Increase HP by 1">+</button>
</div>
{/* HP direct-set input — type a number, press Enter to set HP. */}
<div className="flex gap-1 mt-1">
<input
className="w-14 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] font-mono"
type="number"
placeholder="set HP"
value={hpInput[c.id] ?? ""}
onChange={(e) => setHpInput((p) => ({ ...p, [c.id]: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && hpInput[c.id] && applyHp(c.id, hpInput[c.id])}
title="Type a number and press Enter to set HP"
aria-label={`Set HP for ${c.name}`}
/>
<span className="text-[9px] text-[var(--color-text-dim)] self-center">set</span>
</div>
{/* Conditions */}
@@ -205,6 +367,7 @@ export function InitiativeTracker() {
key={cn}
onClick={() => toggleCondition(c.id, cn)}
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-1.5 py-0 text-[10px] cursor-pointer hover:opacity-80"
title={`Remove ${cn}`}
>
{cn}
</span>
@@ -212,13 +375,14 @@ export function InitiativeTracker() {
</div>
)}
{/* Add condition */}
{/* Add condition — show ALL 13, not 5. */}
<div className="flex flex-wrap gap-0.5 mt-1">
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).slice(0, 5).map((cn) => (
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).map((cn) => (
<button
key={cn}
onClick={() => toggleCondition(c.id, cn)}
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] hover:text-[var(--color-text-secondary)] cursor-pointer transition-colors"
title={`Add ${cn}`}
>
+{cn.slice(0, 3)}
</button>
@@ -230,4 +394,4 @@ export function InitiativeTracker() {
</div>
</div>
);
}
}
+60 -10
View File
@@ -1,6 +1,11 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { parseLlmJson, flattenValue } from "../lib/llm-parse";
import { GeneratedImage } from "./GeneratedImage";
import { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
interface ItemResponse {
name: string;
@@ -14,7 +19,12 @@ interface ItemResponse {
const RARITIES = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"];
const TYPES = ["Weapon", "Armor", "Potion", "Scroll", "Wondrous Item", "Ring", "Wand", "Staff"];
export function ItemForge() {
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
const [rarity, setRarity] = useState("Rare");
const [itemType, setItemType] = useState("Wondrous Item");
const [prompt, setPrompt] = useState("");
@@ -22,6 +32,21 @@ export function ItemForge() {
const [rawResponse, setRawResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [artNonce, setArtNonce] = useState(0);
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "item", () => onPrefillConsumed?.(), (g) => {
const parsed = parseLlmJson<ItemResponse>(g.data);
if (parsed) {
setItem(parsed);
setRarity(parsed.rarity || "Rare");
setItemType(parsed.type || "Wondrous Item");
addToast(`Loaded "${g.title}" from history`, "info");
}
});
// ponytail: FLUX.2 renders readable text, so the item name is painted in-image.
const artPrompt = item ? `fantasy product shot of a ${item.rarity || rarity} ${item.type || itemType} named "${item.name || "magic item"}", ${flattenValue(item.description)}, dramatic side lighting, dark velvet background, 1024x1024` : null;
async function generate() {
setLoading(true);
@@ -49,6 +74,7 @@ The JSON must have exactly these keys:
system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.85,
max_tokens: 500,
ragQuery: `${rarity} ${itemType} ${prompt}`,
},
});
@@ -57,11 +83,21 @@ The JSON must have exactly these keys:
if (parsed) {
setItem(parsed);
addToast("Item forged", "success");
const title = extractTitle("item", result, `${parsed.rarity ?? rarity} ${parsed.type ?? itemType}`);
const source = `${parsed.rarity || rarity} ${parsed.type || itemType}${prompt ? `${prompt}` : ""}`;
void addGeneration({ kind: "item", title, data: result, source });
void addToLore(
`Item: ${parsed.name || itemType}`,
`${parsed.name || "Magic item"}${parsed.rarity || rarity} ${parsed.type || itemType}. ${flattenValue(parsed.description)}\nMechanics: ${flattenValue(parsed.mechanical)}\nLore: ${flattenValue(parsed.lore)}`,
);
} else {
setError("Could not parse LLM response as JSON. Raw response shown below.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`Item generation failed: ${e}`, "error");
}
setLoading(false);
}
@@ -132,15 +168,29 @@ The JSON must have exactly these keys:
{item && (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4 flex flex-col gap-3">
<div className="flex items-start justify-between">
<div>
<h3 className="font-heading text-lg font-bold" style={{ color: rarityColor[item.rarity] || "var(--color-gold-bright)" }}>
{item.name || "Unknown Item"}
</h3>
<div className="flex gap-2 text-xs text-[var(--color-text-dim)]">
<span>{item.rarity || rarity}</span>
<span></span>
<span>{item.type || itemType}</span>
<div className="flex gap-3">
<div className="shrink-0 w-24">
<GeneratedImage prompt={artPrompt} nonce={artNonce} aspect="aspect-square" />
<button
onClick={() => setArtNonce((n) => n + 1)}
className="mt-1 w-full text-[10px] text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
title="Regenerate item art"
>
new art
</button>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h3 className="font-heading text-lg font-bold" style={{ color: rarityColor[item.rarity] || "var(--color-gold-bright)" }}>
{item.name || "Unknown Item"}
</h3>
<div className="flex gap-2 text-xs text-[var(--color-text-dim)]">
<span>{item.rarity || rarity}</span>
<span></span>
<span>{item.type || itemType}</span>
</div>
</div>
</div>
</div>
</div>
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Trash2 } from "lucide-react";
interface RagSource {
source: string;
chunks: number;
}
interface RagHit {
text: string;
source: string;
score: number;
}
export function LorePanel() {
const [source, setSource] = useState("");
const [text, setText] = useState("");
const [sources, setSources] = useState<RagSource[]>([]);
const [adding, setAdding] = useState(false);
const [msg, setMsg] = useState("");
const [query, setQuery] = useState("");
const [hits, setHits] = useState<RagHit[]>([]);
const [searching, setSearching] = useState(false);
async function loadSources() {
try {
setSources(await invoke<RagSource[]>("rag_list"));
} catch (e) {
console.error(e);
}
}
useEffect(() => {
loadSources();
}, []);
async function add() {
if (!source.trim() || !text.trim()) return;
setAdding(true);
setMsg("");
try {
const n = await invoke<number>("rag_add", { req: { source, text } });
setMsg(`Indexed ${n} chunk${n === 1 ? "" : "s"}`);
setText("");
loadSources();
} catch (e) {
setMsg(String(e));
}
setAdding(false);
setTimeout(() => setMsg(""), 3000);
}
async function clearOne(s: string) {
await invoke("rag_clear", { source: s });
loadSources();
}
async function clearAll() {
await invoke("rag_clear", { source: null });
loadSources();
}
async function search() {
if (!query.trim()) return;
setSearching(true);
try {
setHits(await invoke<RagHit[]>("rag_search", { query, topK: 5 }));
} catch (e) {
console.error(e);
}
setSearching(false);
}
return (
<div className="flex flex-col gap-4 text-sm">
{/* Add lore */}
<div className="flex flex-col gap-2">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Add to Lore</h3>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={source}
onChange={(e) => setSource(e.target.value)}
placeholder="Source name (e.g. World Bible, Session 3 Notes)"
/>
<textarea
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm min-h-40 resize-y"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Paste lore text. It will be chunked on paragraphs, embedded via nomic-embed-text, and stored locally."
/>
<button
onClick={add}
disabled={adding}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{adding ? "Embedding…" : "Add to Lore"}
</button>
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
</div>
{/* Sources */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
{sources.length > 0 && (
<button
onClick={clearAll}
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
>
clear all
</button>
)}
</div>
{sources.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">No lore indexed yet.</p>
) : (
<ul className="flex flex-col gap-1">
{sources.map((s) => (
<li key={s.source} className="flex items-center justify-between rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5">
<span className="text-[var(--color-text-primary)] text-xs truncate">
{s.source} <span className="text-[var(--color-text-dim)]">· {s.chunks} chunks</span>
</span>
<button
onClick={() => clearOne(s.source)}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors shrink-0"
title="Clear this source"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
</div>
{/* Test retrieval */}
<div className="flex flex-col gap-2 border-t border-[var(--color-border-subtle)] pt-3">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Test Retrieval</h3>
<div className="flex gap-2">
<input
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="Ask something your lore should answer…"
/>
<button
onClick={search}
disabled={searching}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1.5 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{searching ? "…" : "Search"}
</button>
</div>
{hits.length > 0 && (
<ul className="flex flex-col gap-2">
{hits.map((h, i) => (
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
<span className="text-[10px] text-[var(--color-text-dim)]">score {h.score.toFixed(3)}</span>
</div>
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
</li>
))}
</ul>
)}
{hits.length === 0 && query && !searching && (
<p className="text-xs text-[var(--color-text-dim)]">No matches.</p>
)}
</div>
</div>
);
}
+88 -22
View File
@@ -1,6 +1,13 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { GeneratedImage } from "./GeneratedImage";
import { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
import { RACES, BACKGROUNDS, ALIGNMENTS, randomName } from "../lib/npc-data";
import { Dice5 } from "lucide-react";
interface NpcResponse {
bio: string;
@@ -8,17 +15,34 @@ interface NpcResponse {
goals: string[];
}
const RACES = ["Human", "Elf", "Dwarf", "Halfling", "Orc", "Tiefling", "Dragonborn", "Gnome"];
const ALIGNMENTS = ["Lawful Good", "Neutral Good", "Chaotic Good", "Lawful Neutral", "True Neutral", "Chaotic Neutral", "Lawful Evil", "Neutral Evil", "Chaotic Evil"];
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function NpcGenerator() {
export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
const [race, setRace] = useState("Dwarf");
const [charClass, setCharClass] = useState("Artisan");
const [background, setBackground] = useState("Guild Artisan");
const [alignment, setAlignment] = useState("Chaotic Good");
const [name, setName] = useState("");
const [npc, setNpc] = useState<NpcResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [portraitNonce, setPortraitNonce] = useState(0);
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "npc", () => onPrefillConsumed?.(), (g) => {
const parsed = parseLlmJson<NpcResponse>(g.data);
if (parsed) {
setNpc(parsed);
if (g.title) setName(g.title);
addToast(`Loaded "${g.title}" from history`, "info");
}
});
// ponytail: portrait prompt mirrors the NPC inputs; FLUX.2 renders readable
// text so we drop the name in-image when one is given.
const portraitPrompt = npc ? `fantasy oil-painting portrait of a ${race} ${background.toLowerCase()}, ${alignment.toLowerCase()} demeanor, ${ensureArray(npc.personality).slice(0, 2).map(flattenValue).join(", ")}, dramatic lighting, 1024x1024${name ? `, name "${name}" rendered as a caption` : ""}` : null;
async function generate() {
setLoading(true);
@@ -28,7 +52,7 @@ export function NpcGenerator() {
const prompt = `Create a detailed NPC for a fantasy RPG:
${name ? `Name: ${name}` : "Name: (generate one)"}
Race: ${race}
Class: ${charClass}
Background: ${background}
Alignment: ${alignment}
Provide the response as a JSON object with exactly these keys:
@@ -39,18 +63,33 @@ Provide the response as a JSON object with exactly these keys:
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`;
const result = await invoke<string>("generate", {
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512 },
req: { prompt, system: "You are a creative D&D dungeon master. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.8, max_tokens: 512, ragQuery: `${race} ${background} ${alignment} NPC` },
});
// Try to parse JSON from the response
const parsed = parseLlmJson<NpcResponse>(result);
if (parsed) {
setNpc(parsed);
addToast("NPC generated", "success");
// ponytail: persist to history so the user can re-open it later.
const title = name.trim() || extractTitle("npc", result, `${race} ${background}`);
const source = `${race} ${background} (${alignment})${name ? `, "${name}"` : ""}`;
void addGeneration({ kind: "npc", title, data: result, source });
// ponytail: ingest every generated NPC into lore so later generations
// stay consistent with the growing cast.
const persona = ensureArray(parsed.personality).map(flattenValue).join(", ");
const goals = ensureArray(parsed.goals).map(flattenValue).join("; ");
void addToLore(
`NPC: ${name || race + " " + background}`,
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`,
);
} else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`NPC generation failed: ${e}`, "error");
}
setLoading(false);
}
@@ -61,12 +100,23 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Name</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Random"
/>
<div className="flex gap-1.5">
<input
className="flex-1 min-w-0 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Random"
/>
<button
type="button"
onClick={() => setName(randomName(race))}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
title={`Random ${race} name`}
aria-label={`Random ${race} name`}
>
<Dice5 size={14} />
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Race</label>
@@ -79,12 +129,14 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Class</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
value={charClass}
onChange={(e) => setCharClass(e.target.value)}
/>
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Background</label>
<select
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm cursor-pointer"
value={background}
onChange={(e) => setBackground(e.target.value)}
>
{BACKGROUNDS.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Alignment</label>
@@ -115,10 +167,24 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
{npc && (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
{name || "NPC"}
</h3>
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
<div className="flex gap-3">
<div className="shrink-0 w-24">
<GeneratedImage prompt={portraitPrompt} nonce={portraitNonce} aspect="aspect-square" />
<button
onClick={() => setPortraitNonce((n) => n + 1)}
className="mt-1 w-full text-[10px] text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
title="Regenerate portrait"
>
new portrait
</button>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
{name || "NPC"}
</h3>
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">{npc.bio}</p>
</div>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
<div className="flex flex-wrap gap-1 mt-1">
+33 -1
View File
@@ -1,6 +1,10 @@
import { parseLlmJson } from "../lib/llm-parse";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
interface QuestStep {
title: string;
@@ -16,13 +20,28 @@ interface QuestResponse {
reward: string;
}
export function QuestDesigner() {
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
const [theme, setTheme] = useState("");
const [level, setLevel] = useState(5);
const [quest, setQuest] = useState<QuestResponse | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "quest", () => onPrefillConsumed?.(), (g) => {
const parsed = parseLlmJson<QuestResponse>(g.data);
if (parsed) {
setQuest(parsed);
setCurrentStep(0);
addToast(`Loaded "${g.title}" from history`, "info");
}
});
async function generate() {
setLoading(true);
@@ -51,11 +70,24 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
const parsed = parseLlmJson<QuestResponse>(result);
if (parsed) {
setQuest(parsed);
addToast("Quest designed", "success");
const title = extractTitle("quest", result, `Quest (lvl ${level})`);
const source = `Level ${level}${theme ? `, ${theme}` : ""}`;
void addGeneration({ kind: "quest", title, data: result, source });
const steps = (parsed.steps || [])
.map((s, i) => `${i + 1}. ${s.title}: ${s.description}${s.choice ? ` (Choice: ${s.choice})` : ""}`)
.join("\n");
void addToLore(
`Quest: ${parsed.title || "Untitled"}`,
`Quest: ${parsed.title || "Untitled"} (level ${level}${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${parsed.reward}`,
);
} else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`Quest generation failed: ${e}`, "error");
}
setLoading(false);
}
+23 -1
View File
@@ -1,12 +1,27 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
export function SessionLogger() {
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
const [notes, setNotes] = useState("");
const [summary, setSummary] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [entries, setEntries] = useState<{ text: string; time: string }[]>([]);
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
setSummary(g.data);
addToast(`Loaded "${g.title}" from history`, "info");
});
function addEntry() {
if (!notes.trim()) return;
@@ -34,8 +49,15 @@ export function SessionLogger() {
},
});
setSummary(result);
addToast("Session summary generated", "success");
// ponytail: persist so the DM can re-open past summaries. The data is
// raw text here, not JSON, so the extractTitle() fallback is fine.
const title = `Session ${new Date().toLocaleDateString()}`;
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
} catch (e) {
setError(String(e));
addToast(`Summary failed: ${e}`, "error");
}
setLoading(false);
}
+66 -27
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useToast } from "./Toast";
interface LlmConfig {
api_url: string;
@@ -8,21 +9,29 @@ interface LlmConfig {
temperature: number;
max_tokens: number;
top_p: number;
image_model: string;
embed_model: string;
}
export function SettingsPanel() {
const [config, setConfig] = useState<LlmConfig | null>(null);
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
const [showKey, setShowKey] = useState(false);
const [error, setError] = useState("");
const { addToast } = useToast();
async function loadConfig() {
try {
const c = await invoke<LlmConfig>("get_llm_config");
setConfig(c);
} catch (e) {
console.error("Failed to load config:", e);
}
}
// ponytail: auto-load on mount so users don't see a gate before the form.
useEffect(() => {
(async () => {
try {
const c = await invoke<LlmConfig>("get_llm_config");
setConfig(c);
} catch (e) {
setError(String(e));
}
})();
}, []);
async function saveConfig() {
if (!config) return;
@@ -31,24 +40,20 @@ export function SettingsPanel() {
await invoke("set_llm_config", { config });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
addToast("Settings saved", "success");
} catch (e) {
console.error("Failed to save config:", e);
addToast(`Save failed: ${e}`, "error");
}
setLoading(false);
}
if (!config) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-[var(--color-text-secondary)] text-sm">
Configure your LLM connection
</p>
<button
onClick={loadConfig}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
>
Load Settings
</button>
<div className="flex flex-col items-center justify-center h-full gap-3">
<p className="text-[var(--color-text-secondary)] text-sm">Loading settings</p>
{error && (
<p className="text-[var(--color-danger)] text-xs">Failed to load: {error}</p>
)}
</div>
);
}
@@ -71,13 +76,23 @@ export function SettingsPanel() {
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
API Key (optional)
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
type="password"
value={config.api_key}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
placeholder="sk-... (leave blank for local)"
/>
<div className="flex gap-1.5">
<input
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
type={showKey ? "text" : "password"}
value={config.api_key}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
placeholder="sk-... (leave blank for local)"
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors text-xs cursor-pointer"
title={showKey ? "Hide" : "Show"}
>
{showKey ? "Hide" : "Show"}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
@@ -92,6 +107,30 @@ export function SettingsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
Image model
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
value={config.image_model}
onChange={(e) => setConfig({ ...config, image_model: e.target.value })}
placeholder="x/flux2-klein:4b"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
Embedding model (Lore RAG)
</label>
<input
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)]"
value={config.embed_model}
onChange={(e) => setConfig({ ...config, embed_model: e.target.value })}
placeholder="nomic-embed-text"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
+37 -4
View File
@@ -1,6 +1,10 @@
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
interface WorldResponse {
name: string;
@@ -11,11 +15,25 @@ interface WorldResponse {
cultures: string[];
}
export function WorldBuilder() {
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
const [theme, setTheme] = useState("high fantasy");
const [world, setWorld] = useState<WorldResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "world", () => onPrefillConsumed?.(), (g) => {
const parsed = parseLlmJson<WorldResponse>(g.data);
if (parsed) {
setWorld(parsed);
addToast(`Loaded "${g.title}" from history`, "info");
}
});
async function generate() {
setLoading(true);
@@ -38,16 +56,31 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
system: "You are a creative world-building DM. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.9,
max_tokens: 600,
ragQuery: `fantasy world ${theme} regions landmarks cultures`,
},
});
const parsed = parseLlmJson<WorldResponse>(result);
if (parsed) {
setWorld(parsed);
addToast("World generated", "success");
const title = extractTitle("world", result, theme);
const source = `Theme: ${theme}`;
void addGeneration({ kind: "world", title, data: result, source });
const regions = ensureArray(parsed.regions).map(flattenValue).join("; ");
const landmarks = ensureArray(parsed.landmarks).map(flattenValue).join("; ");
const conflicts = ensureArray(parsed.conflicts).map(flattenValue).join("; ");
const cultures = ensureArray(parsed.cultures).map(flattenValue).join("; ");
void addToLore(
`World: ${parsed.name || theme}`,
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
);
} else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(e));
addToast(`World generation failed: ${e}`, "error");
}
setLoading(false);
}
@@ -122,7 +155,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
))}
</div>
</div>
)})
)}
{/* Conflicts */}
{world.conflicts?.length > 0 && (
@@ -138,7 +171,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
))}
</div>
</div>
)})
)}
{/* Cultures */}
{world.cultures?.length > 0 && (
@@ -154,7 +187,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
))}
</div>
</div>
)})
)}
</div>
)}
</div>