minor UI fixes

This commit is contained in:
itsamejms
2026-07-13 10:39:51 +01:00
parent a24f3615e0
commit a6171a8158
31 changed files with 1528 additions and 508 deletions
+90 -90
View File
@@ -4,8 +4,8 @@
ordered by impact, not file order. Each item ships with a concrete, checkable ordered by impact, not file order. Each item ships with a concrete, checkable
recommendation.* recommendation.*
> **Status:** audit only — no code changes yet. Treat the checklist at the > **Status:** in progress — P0 and P1 shipped, P2 largely done. The
> bottom as the punch list for the next sprint. > checklist below is updated as items land.
--- ---
@@ -32,16 +32,16 @@ the wins are layout, IA, and copy — not a redesign.
### Top 5 wins (do these first) ### Top 5 wins (do these first)
1. **Kill the "expand to use it" pattern.** Make every bento card a *summary* 1. **Kill the "expand to use it" pattern.** Make every bento card a *summary*
with a clear `Open` button. Tools render full-width in the detail view, with a clear `Open` button. Tools render full-width in the detail view,
not at 1/4 size on the dashboard. not at 1/4 size on the dashboard.
2. **Reorganize the left rail into 2 visual groups** (Session vs World) with 2. **Reorganize the left rail into 2 visual groups** (Session vs World) with
separators and tooltips that show on hover for icon-only nav. separators and tooltips that show on hover for icon-only nav.
3. **Add a real loading/streaming UI** for LLM and image generation. The 3. **Add a real loading/streaming UI** for LLM and image generation. The
"Generating…" text in a button is not enough for 520 s waits. "Generating…" text in a button is not enough for 520 s waits.
4. **Ship a real Settings page** (load on mount, group endpoints/models/gen 4. **Ship a real Settings page** (load on mount, group endpoints/models/gen
params, show connection status). params, show connection status).
5. **Fix the world/nav mismatch**: `isDetailView` in `App.tsx` only shows 5. **Fix the world/nav mismatch**: `isDetailView` in `App.tsx` only shows
the back button for some views, and `view === "settings"` is treated the back button for some views, and `view === "settings"` is treated
separately so a back arrow is missing. separately so a back arrow is missing.
@@ -997,113 +997,113 @@ familiarity).
These are blockers or bugs that make the app feel broken. These are blockers or bugs that make the app feel broken.
- [ ] **Fix `WorldBuilder.tsx` syntax bug** (lines 132, 147, 161: - [x] **Fix `WorldBuilder.tsx` syntax bug** (lines 132, 147, 161:
`})` should be `}`). Currently renders broken JSX. `})` should be `}`). Currently renders broken JSX.
- [ ] **Auto-load Settings on mount.** Remove the "Load Settings" - [x] **Auto-load Settings on mount.** Remove the "Load Settings"
gate. gate.
- [ ] **Add `data-tauri-no-drag` to interactive elements in the - [x] **Add `data-tauri-no-drag` to interactive elements in the
header** (back button, status pill). header** (back button, status pill).
- [ ] **Show back button on Settings** (drop the - [x] **Show back button on Settings** (drop the
`view !== "settings"` exclusion in `isDetailView`). `view !== "settings"` exclusion in `isDetailView`).
- [ ] **Add a React error boundary** around the main content - [x] **Add a React error boundary** around the main content
area. area.
- [ ] **Self-host the fonts** in `public/fonts/` (offline-first). - [x] **Self-host the fonts** in `public/fonts/` (offline-first).
- [ ] **Standardize errors via the Toast system** — delete inline - [x] **Standardize errors via the Toast system** — delete inline
error boxes from NPC, Encounter, Quest, World, Item, Image. error boxes from NPC, Encounter, Quest, World, Item, Image.
- [ ] **Replace native `title=""` tooltips** with a styled - [x] **Replace native `title=""` tooltips** with a styled
`data-tooltip` (visible on hover and focus). `data-tooltip` (visible on hover and focus).
- [ ] **Add `aria-label`** to every icon-only button. - [x] **Add `aria-label`** to every icon-only button.
### P1 — High impact (next sprint) ### P1 — High impact (next sprint)
- [ ] **Reorganize the left rail** into 2 visual groups - [x] **Reorganize the left rail** into 2 visual groups
(Session / World) with a thin gold separator and small (Session / World) with a thin gold separator and small
uppercase labels. uppercase labels.
- [ ] **Replace the duplicate `Sparkles` icon** on Random Tables - [x] **Replace the duplicate `Sparkles` icon** on Random Tables
(use `Shuffle` or `Dices`). (use `Shuffle` or `Dices`).
- [ ] **Add a global ⌘K command palette** for navigation. - [x] **Add a global ⌘K command palette** for navigation.
- [ ] **Implement persistence** for Initiative, Dice history, - [x] **Implement persistence** for Initiative, Dice history,
Calendar events, Generated NPCs/Encounters/Items/Quests. Calendar events, Generated NPCs/Encounters/Items/Quests.
Use `tauri-plugin-store` for small state, `tauri-plugin-fs` Use `tauri-plugin-store` for small state, `tauri-plugin-fs`
for generated content. for generated content.
- [ ] **Ship a loading skeleton + streaming UI** for LLM and - [x] **Ship a loading skeleton + streaming UI** for LLM and
image generation. Use the existing Tauri Channel image generation. Use the existing Tauri Channel
infrastructure. infrastructure.
- [ ] **Add roll breakdown** to Dice Roller for any roll with - [x] **Add roll breakdown** to Dice Roller for any roll with
>1 die or any modifier. >1 die or any modifier.
- [ ] **Add Advantage/Disadvantage buttons** to Dice Roller. - [x] **Add Advantage/Disadvantage buttons** to Dice Roller.
- [ ] **Make the dashboard a launcher** — replace embedded - [x] **Make the dashboard a launcher** — replace embedded
mini-tools with at-a-glance summaries + primary CTA. mini-tools with at-a-glance summaries + primary CTA.
- [ ] **Drop the universal `max-w-2xl glass-card` wrapper** in - [x] **Drop the universal `max-w-2xl glass-card` wrapper** in
the detail view. Let each tool set its own max-width. the detail view. Let each tool set its own max-width.
- [ ] **Add a real loading state** to Settings: connection test - [x] **Add a real loading state** to Settings: connection test
with model list. with model list.
- [ ] **Replace "My Campaign" placeholder** with a real status - [x] **Replace "My Campaign" placeholder** with a real status
pill (LLM connection, image model, lore chunk count). pill (LLM connection, image model, lore chunk count).
- [ ] **Lighten `--color-text-dim`** to meet WCAG AA (4.5:1) - [x] **Lighten `--color-text-dim`** to meet WCAG AA (4.5:1)
against `--color-bg-deep`. against `--color-bg-deep`.
### P2 — Important (next month) ### P2 — Important (next month)
- [ ] **Initiative Tracker**: - [ ] **Initiative Tracker**:
- [ ] Click-to-edit initiative number - [x] Click-to-edit initiative number
- [ ] Custom conditions - [x] Custom conditions
- [ ] Damage input (numeric, not +/- only) - [x] Damage input (numeric, not +/- only)
- [ ] Death saves - [x] Death saves
- [ ] Turn timer - [x] Turn timer
- [ ] Notes per combatant - [x] Notes per combatant
- [ ] Highlight next-up combatant - [x] Highlight next-up combatant
- [ ] Confirm before Reset - [x] Confirm before Reset
- [ ] "Import from Encounter" button - [x] "Import from Encounter" button
- [ ] **Dice Roller**: - [ ] **Dice Roller**:
- [ ] Modifier input (saved per session) - [x] Modifier input (saved per session)
- [ ] Spacebar rolls - [x] Spacebar rolls
- [ ] Roll templates (Attack, Save, Check, Damage) - [x] Roll templates (Attack, Save, Check, Damage)
- [ ] Clear/copy history - [x] Clear/copy history
- [ ] **Encounter Builder**: - [ ] **Encounter Builder**:
- [ ] XP budget math (party size, level, difficulty) - [x] XP budget math (party size, level, difficulty)
- [ ] Color-coded difficulty - [x] Color-coded difficulty
- [ ] Send to Initiative Tracker - [x] Send to Initiative Tracker
- [ ] Send to Soundboard (terrain-based) - [x] Send to Soundboard (terrain-based)
- [ ] Save to lore - [x] Save to lore
- [ ] 4 more terrain presets (planar, haunted, ship, siege) - [x] 4 more terrain presets (planar, haunted, ship, siege)
- [ ] **NPC Generator**: - [ ] **NPC Generator**:
- [ ] Rename "Class" to "Background" with curated list - [x] Rename "Class" to "Background" with curated list
- [ ] Expand races to 9 (Half-Orc, Goliath) - [x] Expand races to 9 (Half-Orc, Goliath)
- [ ] NPC Roster view - [ ] NPC Roster view
- [ ] Stat block section (AC, HP, ability scores) - [ ] Stat block section (AC, HP, ability scores)
- [ ] Random name button - [x] Random name button
- [ ] Save portrait (right-click) - [ ] Save portrait (right-click)
- [ ] Edit personality/goals in-place - [x] Edit personality/goals in-place
- [ ] **Item Forge**: - [ ] **Item Forge**:
- [ ] Click art to expand - [x] Click art to expand
- [ ] Structured mechanics (attunement, charges, value, weight) - [ ] Structured mechanics (attunement, charges, value, weight)
- [ ] Save to inventory - [ ] Save to inventory
- [ ] Random item button - [x] Random item button
- [ ] **Quest Designer**: - [ ] **Quest Designer**:
- [ ] Branching quest graph (react-flow) - [ ] Branching quest graph (react-flow)
- [ ] Step status (Pending/Active/Done) - [x] Step status (Pending/Active/Done)
- [ ] Player-facing view toggle (hide Twist) - [x] Player-facing view toggle (hide Twist)
- [ ] Reward breakdown (XP per character, gold, items) - [ ] Reward breakdown (XP per character, gold, items)
- [ ] Quest roster - [ ] Quest roster
- [ ] **Image Generator**: - [ ] **Image Generator**:
- [ ] Negative prompt, seed, aspect ratio, steps, guidance - [ ] Negative prompt, seed, aspect ratio, steps, guidance
- [ ] Batch mode (2×2 variants) - [ ] Batch mode (2×2 variants)
- [ ] Gallery view with thumbnails - [ ] Gallery view with thumbnails
- [ ] Persist to campaign-scoped folder - [x] Persist to campaign-scoped folder
- [ ] Copy to clipboard - [x] Copy to clipboard
- [ ] **Session Logger**: - [ ] **Session Logger**:
- [ ] Markdown editor + live preview - [ ] Markdown editor + live preview
- [ ] Sessions as first-class concept (multiple sessions) - [ ] Sessions as first-class concept (multiple sessions)
- [ ] Streaming AI summary - [x] Streaming AI summary
- [ ] Export to Markdown - [x] Export to Markdown
- [ ] Per-entry actions (edit, delete, highlight) - [x] Per-entry actions (edit, delete, highlight)
- [ ] Tags - [ ] Tags
- [ ] **Calendar**: - [ ] **Calendar**:
- [ ] Custom calendar editor - [ ] Custom calendar editor
- [ ] Current campaign date with "Next day" button - [x] Current campaign date with "Next day" button
- [ ] Weather generator - [x] Weather generator
- [ ] Moon phases - [x] Moon phases
- [ ] Event categories with colors - [ ] Event categories with colors
- [ ] Recurring events - [ ] Recurring events
- [ ] Agenda view - [ ] Agenda view
@@ -1111,43 +1111,43 @@ These are blockers or bugs that make the app feel broken.
- [ ] Real ambience pack (23 royalty-free loops) - [ ] Real ambience pack (23 royalty-free loops)
- [ ] Custom sound import (drag-drop) - [ ] Custom sound import (drag-drop)
- [ ] Scenes (one-click combinations) - [ ] Scenes (one-click combinations)
- [ ] Master mute + per-sound volume - [x] Master mute + per-sound volume
- [ ] Always-visible Stop All - [x] Always-visible Stop All
- [ ] Save/load board state - [x] Save/load board state
- [ ] Pre-warm AudioContext - [x] Pre-warm AudioContext
- [ ] **Random Tables**: - [ ] **Random Tables**:
- [ ] 10+ more built-in tables - [x] 10+ more built-in tables
- [ ] User-defined tables (create/edit/delete) - [ ] User-defined tables (create/edit/delete)
- [ ] Table import (paste Markdown) - [ ] Table import (paste Markdown)
- [ ] Weighted tables - [ ] Weighted tables
- [ ] Export table - [x] Export table
- [ ] **World Builder**: - [ ] **World Builder**:
- [ ] Map canvas (react-konva) - [ ] Map canvas (react-konva)
- [ ] Theme presets - [x] Theme presets
- [ ] Hierarchy tree - [ ] Hierarchy tree
- [ ] Persist world - [x] Persist world
- [ ] **Lore Panel**: - [ ] **Lore Panel**:
- [ ] Two-pane layout - [ ] Two-pane layout
- [ ] Drag-drop file upload - [ ] Drag-drop file upload
- [ ] Directory picker - [ ] Directory picker
- [ ] Chunk preview - [ ] Chunk preview
- [ ] Source filter - [ ] Source filter
- [ ] ★ relevance (not raw cosine) - [x] ★ relevance (not raw cosine)
- [ ] Confirm before "Clear all" - [x] Confirm before "Clear all"
- [ ] **Settings**: - [ ] **Settings**:
- [ ] Group into fieldsets (Connection, Text, Image, Embedding, Danger) - [x] Group into fieldsets (Connection, Text, Image, Embedding, Danger)
- [ ] Connection test with model list - [x] Connection test with model list
- [ ] Model picker with autocomplete (`GET /api/tags`) - [x] Model picker with autocomplete (`GET /api/tags`)
- [ ] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom) - [x] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom)
- [ ] Reset to defaults - [x] Reset to defaults
- [ ] Export/import config - [ ] Export/import config
- [ ] **Cross-cutting**: - [ ] **Cross-cutting**:
- [ ] Keyboard shortcuts: ⌘K, 19, Space, ⌘,, ⌘S, Esc, ? - [x] Keyboard shortcuts: ⌘K, 19, Space, ⌘,, ⌘S, Esc, ?
- [ ] Skeleton loaders for all async states - [x] Skeleton loaders for all async states
- [ ] Empty states for all tools - [x] Empty states for all tools
- [ ] `prefers-reduced-motion` respect on framer-motion - [x] `prefers-reduced-motion` respect on framer-motion
- [ ] Skip-to-content link - [x] Skip-to-content link
- [ ] Color + label for state (not color alone) - [x] Color + label for state (not color alone)
### P3 — Nice to have ### P3 — Nice to have
@@ -1173,7 +1173,7 @@ These are blockers or bugs that make the app feel broken.
If you want a single PR that lands a lot of value, here are 8 If you want a single PR that lands a lot of value, here are 8
items that take <1 day combined and touch mostly `App.tsx` and items that take <1 day combined and touch mostly `App.tsx` and
small CSS: small CSS. **All 10 below are now shipped.**
1. Fix the `WorldBuilder.tsx` syntax bug (5 min) 1. Fix the `WorldBuilder.tsx` syntax bug (5 min)
2. Auto-load Settings on mount (10 min) 2. Auto-load Settings on mount (10 min)
@@ -1181,11 +1181,11 @@ small CSS:
(2 min) (2 min)
4. Add `aria-label` to every icon-only button (30 min) 4. Add `aria-label` to every icon-only button (30 min)
5. Add `data-tooltip` styled tooltips (1 hr) 5. Add `data-tooltip` styled tooltips (1 hr)
6. Group the left rail into Session / World (30 min) 6. Group the left rail into Session / World (30 min)
7. Replace the duplicate `Sparkles` icon (2 min) 7. Replace the duplicate `Sparkles` icon (2 min)
8. Lighten `--color-text-dim` to meet WCAG AA (2 min) 8. Lighten `--color-text-dim` to meet WCAG AA (2 min)
9. Add the ⌘K command palette (3 hrs, the biggest of the bunch) 9. Add the ⌘K command palette (3 hrs, the biggest of the bunch)
10. Self-host the fonts in `public/fonts/` (1 hr) 10. Self-host the fonts in `public/fonts/` (1 hr)
Total: ~68 hours. Lands a much more polished, accessible Total: ~68 hours. Lands a much more polished, accessible
baseline that the rest of the checklist builds on. baseline that the rest of the checklist builds on.
+10
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-log": "^2", "@tauri-apps/plugin-log": "^2",
"@tauri-apps/plugin-store": "^2.4.3",
"framer-motion": "^12", "framer-motion": "^12",
"lucide-react": "^0.525", "lucide-react": "^0.525",
"react": "^19.2.7", "react": "^19.2.7",
@@ -1306,6 +1307,15 @@
"@tauri-apps/api": "^2.8.0" "@tauri-apps/api": "^2.8.0"
} }
}, },
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz",
"integrity": "sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+1
View File
@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-log": "^2", "@tauri-apps/plugin-log": "^2",
"@tauri-apps/plugin-store": "^2.4.3",
"framer-motion": "^12", "framer-motion": "^12",
"lucide-react": "^0.525", "lucide-react": "^0.525",
"react": "^19.2.7", "react": "^19.2.7",
Binary file not shown.
Binary file not shown.
Binary file not shown.
+66
View File
@@ -81,6 +81,72 @@ pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::Llm
Ok(()) Ok(())
} }
// ─── Connection test: list models from the configured endpoint ──
// ponytail: Ollama exposes GET /api/tags, OpenAI-compatible exposes
// GET /v1/models. Returns the model names so Settings can show a list and
// a green/red pill. Best-effort — surface the error string on failure.
#[derive(serde::Serialize)]
pub struct ConnectionTest {
pub ok: bool,
pub models: Vec<String>,
pub error: String,
}
#[tauri::command]
pub async fn test_connection(state: tauri::State<'_, AppState>) -> Result<ConnectionTest, String> {
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.build()
.map_err(|e| e.to_string())?;
if llm::is_ollama(&config.api_url) {
let url = format!("{}/api/tags", config.api_url.trim_end_matches('/'));
let res = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Ok(ConnectionTest {
ok: false,
models: vec![],
error: format!("HTTP {}", res.status()),
});
}
let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let models = body["models"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Ok(ConnectionTest { ok: true, models, error: String::new() })
} else {
let url = format!("{}/v1/models", config.api_url.trim_end_matches('/'));
let mut req = client.get(&url);
if !config.api_key.is_empty() {
req = req.bearer_auth(&config.api_key);
}
let res = req.send().await.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Ok(ConnectionTest {
ok: false,
models: vec![],
error: format!("HTTP {}", res.status()),
});
}
let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let models = body["data"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Ok(ConnectionTest { ok: true, models, error: String::new() })
}
}
// ─── Lore RAG injection (shared by generate + generate_stream) ─ // ─── Lore RAG injection (shared by generate + generate_stream) ─
/// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them /// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them
+1
View File
@@ -36,6 +36,7 @@ pub fn run() {
commands::llm_commands::generate_stream, commands::llm_commands::generate_stream,
commands::llm_commands::get_llm_config, commands::llm_commands::get_llm_config,
commands::llm_commands::set_llm_config, commands::llm_commands::set_llm_config,
commands::llm_commands::test_connection,
commands::image_commands::generate_image, commands::image_commands::generate_image,
commands::rag_commands::rag_add, commands::rag_commands::rag_add,
commands::rag_commands::rag_search, commands::rag_commands::rag_search,
+10 -1
View File
@@ -34,6 +34,7 @@ import { ImageGenerator } from "./components/ImageGenerator";
import { ToastContainer } from "./components/Toast"; import { ToastContainer } from "./components/Toast";
import { CommandPalette } from "./components/CommandPalette"; import { CommandPalette } from "./components/CommandPalette";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { ConnectionPill } from "./components/ConnectionPill";
import { HistoryView } from "./components/HistoryView"; import { HistoryView } from "./components/HistoryView";
import type { Generation, GenerationKind } from "./lib/generations"; import type { Generation, GenerationKind } from "./lib/generations";
@@ -251,6 +252,13 @@ export default function App() {
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]"> <div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
{/* ponytail: skip-to-content for keyboard users — jumps past the rail. */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-2 focus:left-2 focus:px-3 focus:py-1.5 focus:rounded-lg focus:bg-[var(--color-gold-bright)] focus:text-[var(--color-bg-deep)] focus:text-xs focus:font-semibold"
>
Skip to content
</a>
{/* Left Rail */} {/* Left Rail */}
<nav <nav
aria-label="Primary" aria-label="Primary"
@@ -348,10 +356,11 @@ export default function App() {
</kbd> </kbd>
</button> </button>
<div className="ml-auto" data-tauri-no-drag /> <div className="ml-auto" data-tauri-no-drag />
<ConnectionPill />
</header> </header>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto" id="main-content">
<ErrorBoundary> <ErrorBoundary>
{isOnDashboard ? ( {isOnDashboard ? (
<Dashboard onNavigate={setView} /> <Dashboard onNavigate={setView} />
+5 -15
View File
@@ -9,30 +9,20 @@ interface BentoCardProps {
className?: string; className?: string;
} }
// Convert desktop spans like "col-span-2 row-span-1" into responsive spans // ponytail: callers pass responsive span classes directly (e.g.
// On mobile (1 col): everything is col-span-1 // "sm:col-span-2"). No string regex — the old replace() mangled pre-responsive
// On tablet (2 cols): col-span-2 stays, col-span-1 stays // classes and silently dropped unknown spans like col-span-3.
// On desktop (4 cols): original spans
function responsiveSpan(span: string): string {
return span
.replace("col-span-2", "sm:col-span-2 lg:col-span-2 col-span-1")
.replace("row-span-2", "sm:row-span-2 lg:row-span-2 row-span-1")
.replace("col-span-1", "col-span-1")
.replace("row-span-1", "row-span-1");
}
export function BentoCard({ export function BentoCard({
title, title,
icon, icon,
span = "col-span-1 row-span-1", span = "",
children, children,
className = "", className = "",
}: BentoCardProps) { }: BentoCardProps) {
const responsive = responsiveSpan(span);
return ( return (
<motion.div <motion.div
className={`glass-card flex flex-col overflow-hidden p-4 ${responsive} ${className}`} className={`glass-card flex flex-col overflow-hidden p-4 ${span} ${className}`}
// ponytail: drop the hover scale — 12 cards bobs noticeably when the // ponytail: drop the hover scale — 12 cards bobs noticeably when the
// cursor moves. The CSS .glass-card:hover (border + glow) is enough. // cursor moves. The CSS .glass-card:hover (border + glow) is enough.
whileTap={{ scale: 0.985 }} whileTap={{ scale: 0.985 }}
+76 -5
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { usePersistentState } from "../lib/usePersistentState";
const MONTHS = [ const MONTHS = [
"Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn", "Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn",
@@ -17,11 +18,52 @@ const COLORS = ["var(--color-gold-bright)", "var(--color-info)", "var(--color-su
export function CalendarWidget() { export function CalendarWidget() {
const [month, setMonth] = useState(0); const [month, setMonth] = useState(0);
const [year, setYear] = useState(1492); const [year, setYear] = useState(1492);
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = usePersistentState<CalendarEvent[]>("calendar.events", []);
const [selectedDay, setSelectedDay] = useState<number | null>(null); const [selectedDay, setSelectedDay] = useState<number | null>(null);
const [newEvent, setNewEvent] = useState(""); const [newEvent, setNewEvent] = useState("");
// ponytail: the campaign's current date, persisted so it survives reloads.
// Advances with the "Next day" button; "Today" jumps the view to it.
const [today, setToday] = usePersistentState<{ day: number; month: number; year: number }>(
"calendar.today",
{ day: 15, month: 5, year: 1492 },
);
const today = { day: 15, month: 5 }; // 15th of Kythorn (arbitrary "today") // ponytail: 8 moon phases cycling on a ~30-day lunar cycle. Derived from
// a continuous day index so the phase is stable for any date.
const MOON_PHASES = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"];
function moonPhase(day: number, month: number, year: number): string {
const idx = Math.floor(((year * 360 + month * 30 + (day - 1)) % 30) / 30 * 8);
return MOON_PHASES[((idx % 8) + 8) % 8];
}
// ponytail: deterministic weather per day (stable for a given date).
// A simple PRNG seeded by the date index so the forecast doesn't reshuffle
// every render. Coarse by design — a DM can override narratively.
const WEATHER = [
"☀ Clear", "⛅ Fair", "☁ Overcast", "🌧 Light rain", "⛈ Storm",
"❄ Snow", "🌫 Fog", "💨 Windy", "🌀 Unnatural",
];
function weatherFor(day: number, month: number, year: number): string {
const seed = (year * 360 + month * 30 + (day - 1)) % 97;
return WEATHER[seed % WEATHER.length];
}
// Advance the campaign date by one day (30-day months, 12-month year).
function nextDay() {
setToday((t) => {
let { day, month, year } = t;
day += 1;
if (day > 30) { day = 1; month += 1; }
if (month > 11) { month = 0; year += 1; }
return { day, month, year };
});
}
function jumpToToday() {
setMonth(today.month);
setYear(today.year);
setSelectedDay(today.day);
}
function addEvent() { function addEvent() {
if (!newEvent.trim() || selectedDay === null) return; if (!newEvent.trim() || selectedDay === null) return;
@@ -62,6 +104,29 @@ export function CalendarWidget() {
</button> </button>
</div> </div>
{/* Campaign date controls */}
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] text-[var(--color-text-secondary)]">
Today: {MONTHS[today.month]} {today.day}, {today.year} DR {moonPhase(today.day, today.month, today.year)}
</span>
<div className="flex gap-1">
<button
onClick={jumpToToday}
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-1.5 py-0.5 text-[9px] cursor-pointer hover:text-[var(--color-gold-bright)]"
title="Jump the view to today"
>
Today
</button>
<button
onClick={nextDay}
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
title="Advance the campaign date by one day"
>
Next day
</button>
</div>
</div>
{/* Day grid */} {/* Day grid */}
<div className="grid grid-cols-7 gap-0.5"> <div className="grid grid-cols-7 gap-0.5">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => ( {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
@@ -72,7 +137,7 @@ export function CalendarWidget() {
{/* Start offset - Faerûn calendar starts on first of month */} {/* Start offset - Faerûn calendar starts on first of month */}
{Array.from({ length: 30 }, (_, i) => { {Array.from({ length: 30 }, (_, i) => {
const dayNum = i + 1; const dayNum = i + 1;
const isToday = dayNum === today.day && month === today.month; const isToday = dayNum === today.day && month === today.month && year === today.year;
const hasEvent = events.some((e) => e.day === dayNum && e.month === month); const hasEvent = events.some((e) => e.day === dayNum && e.month === month);
const isSelected = dayNum === selectedDay; const isSelected = dayNum === selectedDay;
@@ -92,6 +157,7 @@ export function CalendarWidget() {
{hasEvent && !isToday && ( {hasEvent && !isToday && (
<span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" /> <span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" />
)} )}
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
</button> </button>
); );
})} })}
@@ -100,8 +166,13 @@ export function CalendarWidget() {
{/* Events for selected day */} {/* Events for selected day */}
{selectedDay !== null && ( {selectedDay !== null && (
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2"> <div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
<div className="text-[10px] font-medium text-[var(--color-text-secondary)] mb-1"> <div className="flex items-center justify-between mb-1">
{MONTHS[month]} {selectedDay} <div className="text-[10px] font-medium text-[var(--color-text-secondary)]">
{MONTHS[month]} {selectedDay}
</div>
<span className="text-[10px] text-[var(--color-gold-bright)]" title="Weather for this day">
{weatherFor(selectedDay, month, year)}
</span>
</div> </div>
{dayEvents.length === 0 && ( {dayEvents.length === 0 && (
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div> <div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
type State = "unknown" | "connected" | "offline";
// ponytail: a title-bar pill that probes the LLM endpoint on mount and on
// click. Shows ● connected / ○ offline so the DM knows their backend is up
// before generating. Replaces the inert "My Campaign" placeholder.
export function ConnectionPill() {
const [state, setState] = useState<State>("unknown");
const [models, setModels] = useState(0);
async function test() {
setState("unknown");
try {
const result = await invoke<{ ok: boolean; models: string[]; error: string }>("test_connection");
if (result.ok) {
setState("connected");
setModels(result.models.length);
} else {
setState("offline");
}
} catch {
setState("offline");
}
}
useEffect(() => {
void test();
}, []);
const color =
state === "connected"
? "var(--color-success)"
: state === "offline"
? "var(--color-danger)"
: "var(--color-text-dim)";
const label =
state === "connected"
? `LLM · ${models} models`
: state === "offline"
? "LLM offline"
: "LLM…";
return (
<button
onClick={test}
data-tauri-no-drag
className="ml-2 flex items-center gap-1.5 text-[var(--color-text-dim)] text-xs hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
title="Click to re-test LLM connection"
aria-label={`LLM connection: ${state}`}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="hidden sm:inline">{label}</span>
</button>
);
}
+172 -211
View File
@@ -2,235 +2,196 @@ import {
Map, Map,
User, User,
Swords, Swords,
ScrollText,
Dice5, Dice5,
Sparkles,
Timer,
Volume2, Volume2,
Timer,
Calendar, Calendar,
Maximize2,
Wand2, Wand2,
ImagePlus, ImagePlus,
BookOpen, BookOpen,
Shuffle,
Flag,
ScrollText,
ArrowRight,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react";
import { BentoCard } from "./BentoCard"; import { BentoCard } from "./BentoCard";
import type { View } from "../App"; import type { View } from "../App";
import { NpcGenerator } from "./NpcGenerator"; import {
import { DiceRoller } from "./DiceRoller"; listGenerations,
import { SessionLogger } from "./SessionLogger"; relativeTime,
import { EncounterBuilder } from "./EncounterBuilder"; type GenerationKind,
import { InitiativeTracker } from "./InitiativeTracker"; type GenerationSummary,
import { RandomTables } from "./RandomTables"; } from "../lib/generations";
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 { interface DashboardProps {
onNavigate: (view: View) => void; onNavigate: (view: View) => void;
} }
// ponytail: launcher cards — icon, title, one-line description, Open CTA.
// No embedded mini-tools: the dashboard is a launcher, not an ant farm.
interface Card {
icon: typeof Map;
title: string;
description: string;
view: View;
span?: string;
// when set, the card shows "Last: {title} · {relativeTime}" from history.
kind?: GenerationKind;
}
const CARDS: Card[] = [
{
icon: Map,
title: "World Builder",
description: "Generate a world with regions, landmarks, and conflicts.",
view: "world",
span: "sm:col-span-2",
kind: "world",
},
{
icon: Timer,
title: "Initiative",
description: "Track combatants, HP, conditions, and turn order.",
view: "initiative",
kind: undefined,
},
{
icon: Dice5,
title: "Dice Roller",
description: "Roll with advantage, modifiers, and roll templates.",
view: "dice",
},
{
icon: Swords,
title: "Encounter",
description: "Build balanced encounters with an XP budget.",
view: "encounter",
kind: "encounter",
},
{
icon: User,
title: "NPC Generator",
description: "Generate NPCs with portraits, personality, and goals.",
view: "npcs",
kind: "npc",
},
{
icon: Flag,
title: "Quest Designer",
description: "Design multi-step quests with twists and rewards.",
view: "quest",
kind: "quest",
},
{
icon: Wand2,
title: "Item Forge",
description: "Forge magic items with art and mechanics.",
view: "items",
kind: "item",
},
{
icon: ScrollText,
title: "Session Log",
description: "Take notes and generate an AI session summary.",
view: "session",
kind: "session",
},
{
icon: BookOpen,
title: "Lore (RAG)",
description: "Index your world bible and ground generations in it.",
view: "lore",
},
{
icon: Calendar,
title: "Calendar",
description: "Track the in-world date, events, and moon phases.",
view: "calendar",
},
{
icon: Shuffle,
title: "Random Tables",
description: "Roll on built-in and custom random tables.",
view: "tables",
},
{
icon: ImagePlus,
title: "Image Generator",
description: "Generate portraits, maps, and scene art (macOS).",
view: "image",
span: "sm:col-span-2",
kind: "image",
},
{
icon: Volume2,
title: "Soundboard",
description: "Synthesized ambience and SFX for the table.",
view: "sound",
},
];
export function Dashboard({ onNavigate }: DashboardProps) { export function Dashboard({ onNavigate }: DashboardProps) {
// ponytail: one fetch on mount → most-recent generation per kind, for the
// "Last: …" footer. listGenerations returns newest-first per kind.
const [latest, setLatest] = useState<Partial<Record<GenerationKind, GenerationSummary>>>({});
useEffect(() => {
(async () => {
try {
const all = await listGenerations();
const byKind: Partial<Record<GenerationKind, GenerationSummary>> = {};
for (const g of all) {
if (!byKind[g.kind]) byKind[g.kind] = g; // first occurrence is newest
}
setLatest(byKind);
} catch {
// generations backend unavailable — footer just stays hidden.
}
})();
}, []);
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 grid-rows-5 gap-3 p-4 h-full bg-[var(--color-bg-deep)] overflow-y-auto"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-3 p-4 h-full bg-[var(--color-bg-deep)] overflow-y-auto">
{/* World Map — 2×2 */} {CARDS.map((card) => {
<BentoCard title="World Builder" icon={<Map size={16} />} span="col-span-2 row-span-2"> const last = card.kind ? latest[card.kind] : undefined;
<div className="flex flex-col h-full"> return (
<div className="flex-1 overflow-hidden"> <BentoCard
<WorldBuilder /> key={card.view}
</div> title={card.title}
<button icon={<card.icon size={16} />}
onClick={() => onNavigate("world")} span={card.span}
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 <div className="flex flex-col h-full min-h-[120px]">
</button> <div className="flex items-start gap-3 flex-1">
</div> <div className="shrink-0 w-9 h-9 rounded-lg bg-[var(--color-gold-glow)] flex items-center justify-center text-[var(--color-gold-bright)]">
</BentoCard> <card.icon size={18} />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed line-clamp-2">
{card.description}
</p>
</div>
</div>
{/* NPC Sheet — 1×1 */} {last && (
<BentoCard title="NPC Sheet" icon={<User size={16} />} span="col-span-1 row-span-1"> <p className="text-[10px] text-[var(--color-text-dim)] mt-2 truncate">
<div className="flex flex-col h-full"> Last:{" "}
<div className="flex-1 overflow-hidden"> <span className="text-[var(--color-text-secondary)]">{last.title}</span>{" "}
<NpcGenerator /> · {relativeTime(last.createdAt)}
</div> </p>
<button )}
onClick={() => onNavigate("npcs")}
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>
{/* Dice Roller — 1×1 */} <button
<BentoCard title="Dice Roller" icon={<Dice5 size={16} />} span="col-span-1 row-span-1"> onClick={() => onNavigate(card.view)}
<div className="flex flex-col h-full"> className="mt-2 self-start text-xs font-medium text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] flex items-center gap-1 cursor-pointer transition-colors"
<div className="flex-1 overflow-hidden"> aria-label={`Open ${card.title}`}
<DiceRoller /> >
</div> Open <ArrowRight size={12} />
<button </button>
onClick={() => onNavigate("dice")} </div>
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)]" </BentoCard>
> );
<Maximize2 size={12} /> Expand })}
</button>
</div>
</BentoCard>
{/* Encounter Builder — 1×1 */}
<BentoCard title="Encounter" icon={<Swords size={16} />} span="col-span-1 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<EncounterBuilder />
</div>
<button
onClick={() => onNavigate("encounter")}
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>
{/* Session Log — 2×1 */}
<BentoCard title="Session Log" icon={<ScrollText size={16} />} span="col-span-2 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<SessionLogger />
</div>
<button
onClick={() => onNavigate("session")}
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>
{/* Quest Designer — 2×1 */}
<BentoCard title="Quest Designer" icon={<Sparkles size={16} />} span="col-span-2 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<QuestDesigner />
</div>
<button
onClick={() => onNavigate("quest")}
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>
{/* Initiative Tracker — 1×1 */}
<BentoCard title="Initiative" icon={<Timer size={16} />} span="col-span-1 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<InitiativeTracker />
</div>
<button
onClick={() => onNavigate("initiative")}
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>
{/* Random Tables — 1×1 */}
<BentoCard title="Random Tables" icon={<Sparkles size={16} />} span="col-span-1 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<RandomTables />
</div>
<button
onClick={() => onNavigate("tables")}
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>
{/* Calendar — 1×1 */}
<BentoCard title="Calendar" icon={<Calendar size={16} />} span="col-span-1 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<CalendarWidget />
</div>
<button
onClick={() => onNavigate("calendar")}
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>
{/* Soundboard — 1×1 */}
<BentoCard title="Soundboard" icon={<Volume2 size={16} />} span="col-span-1 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<Soundboard />
</div>
<button
onClick={() => onNavigate("sound")}
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>
{/* Item Forge — 2×1 */}
<BentoCard title="Item Forge" icon={<Wand2 size={16} />} span="col-span-2 row-span-1">
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<ItemForge />
</div>
<button
onClick={() => onNavigate("items")}
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>
{/* 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> </div>
); );
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { usePersistentState } from "../lib/usePersistentState";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
interface DieResult { interface DieResult {
@@ -56,11 +57,11 @@ function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[
} }
export function DiceRoller() { export function DiceRoller() {
const [input, setInput] = useState("1d20"); const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
const [results, setResults] = useState<DieResult[]>([]); const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []);
const [lastTotal, setLastTotal] = useState<number | null>(null); const [lastTotal, setLastTotal] = useState<number | null>(null);
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null); const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
const [mode, setMode] = useState<Mode>("normal"); const [mode, setMode] = usePersistentState<Mode>("dice.mode", "normal");
const { addToast } = useToast(); const { addToast } = useToast();
const roll = useCallback( const roll = useCallback(
+28 -8
View File
@@ -5,7 +5,7 @@ import { addToLore } from "../lib/lore";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations"; import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill"; import { usePrefillEffect } from "../lib/usePrefill";
import { bus, Events, type AddCombatantsPayload } from "../lib/bus"; import { bus, Events, type AddCombatantsPayload, type PlayAmbientPayload } from "../lib/bus";
import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget"; import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget";
interface Encounter { interface Encounter {
@@ -147,7 +147,6 @@ export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
const [terrain, setTerrain] = useState("Forest"); const [terrain, setTerrain] = useState("Forest");
const [encounter, setEncounter] = useState<Encounter | null>(null); const [encounter, setEncounter] = useState<Encounter | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { addToast } = useToast(); const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => { usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => {
@@ -190,7 +189,6 @@ export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
async function generate() { async function generate() {
setLoading(true); setLoading(true);
setError("");
setEncounter(null); setEncounter(null);
try { try {
const result = await invoke<string>("generate", { const result = await invoke<string>("generate", {
@@ -222,11 +220,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
`Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`, `Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`,
); );
} else { } else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error"); addToast("LLM returned an unparseable response", "error");
} }
} catch (e) { } catch (e) {
setError(String(e));
addToast(`Encounter generation failed: ${e}`, "error"); addToast(`Encounter generation failed: ${e}`, "error");
} }
setLoading(false); setLoading(false);
@@ -255,6 +251,21 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
); );
} }
// ponytail: map the encounter's terrain to the closest synthesized ambient.
// Best-effort — the DM can tweak on the Soundboard. Covers the 12 terrains.
const TERRAIN_AMBIENT: Record<string, string> = {
Forest: "forest", Swamp: "rain", Coastal: "ocean", Ocean: "ocean",
Mountain: "wind", Desert: "wind", Underdark: "cave", Dungeon: "cave",
Urban: "tavern", Haunted: "thunder", Planar: "thunder", Ship: "ocean",
Siege: "wind",
};
function sendToSoundboard() {
const id = TERRAIN_AMBIENT[terrain] ?? "wind";
const payload: PlayAmbientPayload = { id, source: `${terrain} ambient` };
bus.emit(Events.PlayAmbient, payload);
addToast(`Sent ${terrain} ambient to Soundboard`, "success");
}
return ( return (
<div className="flex flex-col gap-2 h-full overflow-y-auto"> <div className="flex flex-col gap-2 h-full overflow-y-auto">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -318,12 +329,14 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
{loading ? "⚔ Generating…" : "⚔ Generate Encounter"} {loading ? "⚔ Generating…" : "⚔ Generate Encounter"}
</button> </button>
{error && ( {!encounter && !loading && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs"> <div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
{error} <span>No encounter yet.</span>
<span className="mt-1">Set the party + terrain, then Generate.</span>
</div> </div>
)} )}
{encounter && ( {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="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"> <div className="flex items-center justify-between">
@@ -366,6 +379,13 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
> >
Send to Initiative Tracker Send to Initiative Tracker
</button> </button>
<button
onClick={sendToSoundboard}
className="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="Play an ambient matching the terrain"
>
🎧 Send to Soundboard
</button>
</div> </div>
)} )}
</div> </div>
+17 -14
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Sparkles, ImageOff } from "lucide-react"; import { ImageOff } from "lucide-react";
import { useToast } from "./Toast";
interface Props { interface Props {
/** Text-to-image prompt. Changing it triggers a new generation. */ /** Text-to-image prompt. Changing it triggers a new generation. */
@@ -22,13 +23,13 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
const [dataUrl, setDataUrl] = useState<string | null>(null); const [dataUrl, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [unsupported, setUnsupported] = useState(false); const [unsupported, setUnsupported] = useState(false);
const [error, setError] = useState(""); const [expanded, setExpanded] = useState(false);
const { addToast } = useToast();
useEffect(() => { useEffect(() => {
if (!prompt) return; if (!prompt) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError("");
setUnsupported(false); setUnsupported(false);
invoke<string>("generate_image", { req: { prompt } }) invoke<string>("generate_image", { req: { prompt } })
.then((url) => { .then((url) => {
@@ -38,7 +39,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
const msg = String(e); const msg = String(e);
if (!cancelled) { if (!cancelled) {
if (msg.includes("macOS-only")) setUnsupported(true); if (msg.includes("macOS-only")) setUnsupported(true);
else setError(msg); else addToast(`Image failed: ${msg}`, "error");
} }
}) })
.finally(() => !cancelled && setLoading(false)); .finally(() => !cancelled && setLoading(false));
@@ -62,25 +63,27 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
); );
} }
if (error) {
return (
<div className={box}>
<span className="text-[10px] text-[var(--color-danger)] px-2 text-center">{error}</span>
</div>
);
}
if (loading) { if (loading) {
return ( return (
<div className={box}> <div className={box}>
<Sparkles size={20} className="text-[var(--color-gold-bright)] animate-pulse" /> <div className="absolute inset-0 skeleton" />
<span className="relative text-[10px] text-[var(--color-text-dim)]">Painting</span>
</div> </div>
); );
} }
return dataUrl ? ( return dataUrl ? (
<div className={box}> <div className={box}>
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" /> <img src={dataUrl} alt={prompt} className="w-full h-full object-cover cursor-zoom-in" onClick={() => setExpanded(true)} />
{expanded && (
<div
className="fixed inset-0 z-[60] bg-black/80 flex items-center justify-center p-8"
onClick={() => setExpanded(false)}
aria-label="Close image preview"
>
<img src={dataUrl} alt={prompt} className="max-w-full max-h-full rounded-lg object-contain" />
</div>
)}
</div> </div>
) : null; ) : null;
} }
+34 -18
View File
@@ -17,8 +17,8 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [dataUrl, setDataUrl] = useState<string | null>(null); const [dataUrl, setDataUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [variant, setVariant] = useState(0); const [variant, setVariant] = useState(0);
const [unsupported, setUnsupported] = useState(false);
const { addToast } = useToast(); const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => { usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
@@ -30,7 +30,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
async function generate() { async function generate() {
if (!prompt.trim()) return; if (!prompt.trim()) return;
setLoading(true); setLoading(true);
setError("");
setDataUrl(null); setDataUrl(null);
try { try {
// ponytail: append a variant tag to bust the backend's prompt-hash cache // ponytail: append a variant tag to bust the backend's prompt-hash cache
@@ -40,12 +39,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
req: { prompt: `${prompt}${v}`, model: model.trim() || null }, req: { prompt: `${prompt}${v}`, model: model.trim() || null },
}); });
setDataUrl(url); setDataUrl(url);
setUnsupported(false);
addToast("Image generated", "success"); addToast("Image generated", "success");
// ponytail: persist the PNG data URL so the DM can re-open past renders. // 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 }); void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
} catch (e) { } catch (e) {
setError(String(e)); const msg = String(e);
addToast(`Image generation failed: ${e}`, "error"); if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
addToast(`Image generation failed: ${msg}`, "error");
} }
setLoading(false); setLoading(false);
} }
@@ -56,8 +57,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
setTimeout(generate, 0); setTimeout(generate, 0);
} }
const unsupported = error.toLowerCase().includes("macos-only");
return ( return (
<div className="flex flex-col gap-3 text-sm"> <div className="flex flex-col gap-3 text-sm">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -115,24 +114,41 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
</div> </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 && ( {dataUrl && !loading && (
<div className="flex flex-col gap-2"> <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)]"> <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" /> <img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
</div> </div>
<a <div className="flex justify-center gap-3">
href={dataUrl} <a
download="dm-pal-image.png" href={dataUrl}
className="text-center text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors" download="dm-pal-image.png"
> className="text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
Save PNG >
</a> Save PNG
</a>
<button
onClick={() => {
// ponytail: copy the rendered PNG to the clipboard. The
// Clipboard API needs a Blob, not a data URL string.
fetch(dataUrl).then((r) => r.blob()).then((blob) =>
navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]),
).then(() => addToast("Image copied to clipboard", "success"))
.catch(() => addToast("Copy failed", "error"));
}}
className="text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
>
Copy
</button>
</div>
</div>
)}
{!dataUrl && !loading && !unsupported && (
<div className="flex flex-col items-center justify-center aspect-square max-w-md mx-auto text-center text-[var(--color-text-dim)] text-xs">
<span>No image yet.</span>
<span className="mt-1">Describe a scene or portrait, then Generate.</span>
</div> </div>
)} )}
</div> </div>
+205 -15
View File
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { bus, Events, type AddCombatantsPayload } from "../lib/bus"; import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
import { usePersistentState } from "../lib/usePersistentState";
interface Combatant { interface Combatant {
id: string; id: string;
@@ -10,6 +11,8 @@ interface Combatant {
maxHp: number; maxHp: number;
conditions: string[]; conditions: string[];
notes?: string; notes?: string;
// ponytail: 5e death saves — 3 successes stabilize, 3 failures = dead.
deathSaves?: { successes: number; failures: number };
} }
const CONDITIONS = [ const CONDITIONS = [
@@ -18,19 +21,33 @@ const CONDITIONS = [
"Restrained", "Stunned", "Unconscious", "Restrained", "Stunned", "Unconscious",
]; ];
const TURN_SECONDS = 60;
let nextId = 1; let nextId = 1;
export function InitiativeTracker() { export function InitiativeTracker() {
const [combatants, setCombatants] = useState<Combatant[]>([]); // ponytail: persist combatants, round, and active turn so a refresh
// mid-combat doesn't lose the tracker. nextId stays module-level (cosmetic).
const [combatants, setCombatants] = usePersistentState<Combatant[]>("initiative.combatants", []);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [initBonus, setInitBonus] = useState(0); const [initBonus, setInitBonus] = useState(0);
const [hp, setHp] = useState(10); const [hp, setHp] = useState(10);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = usePersistentState<string | null>("initiative.activeId", null);
const [round, setRound] = useState(1); const [round, setRound] = usePersistentState<number>("initiative.round", 1);
const [confirmReset, setConfirmReset] = useState(false); const [confirmReset, setConfirmReset] = useState(false);
const [hpInput, setHpInput] = useState<Record<string, string>>({}); const [hpInput, setHpInput] = useState<Record<string, string>>({});
// (useToast is wired up in the bus handler below; silence unused-import.) // ponytail: per-combatant UI flags. Live in component state, not persisted.
useToast(); const [condInput, setCondInput] = useState<Record<string, string>>({});
const [condEditing, setCondEditing] = useState<string | null>(null);
const [notesEditing, setNotesEditing] = useState<string | null>(null);
// ponytail: custom conditions the DM has added, persisted so they reuse
// across sessions (e.g. "Concentrating", "Raging", "Hexed").
const [customConditions, setCustomConditions] = usePersistentState<string[]>("initiative.customConditions", []);
// ponytail: optional turn timer — counts down from TURN_SECONDS, resets on
// Next Turn, turns red at 0. Off until the DM starts it.
const [timerOn, setTimerOn] = useState(false);
const [timerLeft, setTimerLeft] = useState(TURN_SECONDS);
const { addToast } = useToast();
// ponytail: listen for EncounterBuilder's "send to initiative" event. // ponytail: listen for EncounterBuilder's "send to initiative" event.
// We don't auto-switch view (the DM might be editing) but the toast tells // We don't auto-switch view (the DM might be editing) but the toast tells
@@ -153,8 +170,65 @@ export function InitiativeTracker() {
} else { } else {
setActiveId(combatants[idx + 1].id); setActiveId(combatants[idx + 1].id);
} }
setTimerLeft(TURN_SECONDS); // ponytail: reset the per-turn timer.
}, [combatants, activeId]); }, [combatants, activeId]);
// ponytail: tick the timer once per second while armed. Stopping at 0 lets
// the red zero sit until the DM advances (don't auto-advance — that would
// steal a player's turn).
useEffect(() => {
if (!timerOn) return;
if (timerLeft <= 0) return;
const t = setTimeout(() => setTimerLeft((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [timerOn, timerLeft]);
// ponytail: 5e death save. d20: 10+ = success, <10 = failure, nat 20 = 2
// successes, nat 1 = 2 failures. 3 successes stabilize, 3 failures = dead.
function rollDeathSave(id: string) {
const roll = Math.floor(Math.random() * 20) + 1;
setCombatants((prev) =>
prev.map((c) => {
if (c.id !== id) return c;
const ds = c.deathSaves ?? { successes: 0, failures: 0 };
let succ = 0;
let fail = 0;
if (roll === 20) succ += 2;
else if (roll === 1) fail += 2;
else if (roll >= 10) succ += 1;
else fail += 1;
const next = { successes: ds.successes + succ, failures: ds.failures + fail };
return { ...c, deathSaves: next };
}),
);
addToast(`Death save: ${roll}`);
}
function adjustDeathSave(id: string, kind: "successes" | "failures", delta: number) {
setCombatants((prev) =>
prev.map((c) => {
if (c.id !== id) return c;
const ds = c.deathSaves ?? { successes: 0, failures: 0 };
return { ...c, deathSaves: { ...ds, [kind]: Math.max(0, Math.min(3, ds[kind] + delta)) } };
}),
);
}
function addCustomCondition(id: string, raw: string) {
const cond = raw.trim();
if (!cond) return;
toggleCondition(id, cond);
if (!customConditions.includes(cond) && !CONDITIONS.includes(cond)) {
setCustomConditions((prev) => [...prev, cond]);
}
setCondInput((p) => { const { [id]: _, ...r } = p; return r; });
setCondEditing(null);
}
function setNotes(id: string, raw: string) {
setCombatants((prev) => prev.map((c) => (c.id === id ? { ...c, notes: raw } : c)));
}
const reset = useCallback(() => { const reset = useCallback(() => {
setCombatants([]); setCombatants([]);
setActiveId(null); setActiveId(null);
@@ -179,7 +253,24 @@ export function InitiativeTracker() {
<span className="text-[var(--color-gold-bright)] font-heading text-xs font-semibold"> <span className="text-[var(--color-gold-bright)] font-heading text-xs font-semibold">
Round {round} Round {round}
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1 items-center">
{/* ponytail: optional per-turn timer. Toggle to arm; resets on Next Turn. */}
{activeId && (
<button
onClick={() => { setTimerOn((o) => !o); if (!timerOn) setTimerLeft(TURN_SECONDS); }}
className={`rounded px-1.5 py-0.5 text-[10px] font-mono cursor-pointer border transition-colors ${
timerOn
? timerLeft === 0
? "bg-[var(--color-danger)]/20 border-[var(--color-danger)] text-[var(--color-danger)]"
: "border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
: "border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
title="Toggle turn timer"
aria-label="Toggle turn timer"
>
{timerOn ? `${timerLeft}s` : "⏱"}
</button>
)}
<button <button
onClick={nextTurn} onClick={nextTurn}
disabled={combatants.length === 0} disabled={combatants.length === 0}
@@ -359,6 +450,45 @@ export function InitiativeTracker() {
<span className="text-[9px] text-[var(--color-text-dim)] self-center">set</span> <span className="text-[9px] text-[var(--color-text-dim)] self-center">set</span>
</div> </div>
{/* Death saves — only when downed (HP 0). 5e: 3 succ stabilize, 3 fail = dead. */}
{c.hp === 0 && (
<div className="flex items-center gap-1 mt-1">
<span className="text-[9px] text-[var(--color-text-dim)] shrink-0">Deaths:</span>
{[0, 1, 2].map((i) => (
<button
key={`s${i}`}
onClick={() => adjustDeathSave(c.id, "successes", ((c.deathSaves?.successes ?? 0) > i) ? -1 : 1)}
className="w-3 h-3 rounded-full border text-[8px] leading-none cursor-pointer"
style={{
backgroundColor: (c.deathSaves?.successes ?? 0) > i ? "var(--color-success)" : "transparent",
borderColor: "var(--color-success)",
}}
aria-label="Death save success"
/>
))}
<span className="text-[var(--color-text-dim)] mx-0.5">/</span>
{[0, 1, 2].map((i) => (
<button
key={`f${i}`}
onClick={() => adjustDeathSave(c.id, "failures", ((c.deathSaves?.failures ?? 0) > i) ? -1 : 1)}
className="w-3 h-3 rounded-full border text-[8px] leading-none cursor-pointer"
style={{
backgroundColor: (c.deathSaves?.failures ?? 0) > i ? "var(--color-danger)" : "transparent",
borderColor: "var(--color-danger)",
}}
aria-label="Death save failure"
/>
))}
<button
onClick={() => rollDeathSave(c.id)}
className="ml-1 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1.5 py-0 text-[9px] text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer"
title="Roll a death save (d20)"
>
🎲
</button>
</div>
)}
{/* Conditions */} {/* Conditions */}
{c.conditions.length > 0 && ( {c.conditions.length > 0 && (
<div className="flex flex-wrap gap-0.5 mt-1"> <div className="flex flex-wrap gap-0.5 mt-1">
@@ -375,18 +505,78 @@ export function InitiativeTracker() {
</div> </div>
)} )}
{/* Add condition — show ALL 13, not 5. */} {/* Add condition — standard 13 + custom, plus a free-text input. */}
<div className="flex flex-wrap gap-0.5 mt-1"> {condEditing === c.id ? (
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).map((cn) => ( <div className="flex gap-1 mt-1">
<input
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-gold-bright)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-primary)] focus:outline-none"
placeholder="Custom condition…"
value={condInput[c.id] ?? ""}
onChange={(e) => setCondInput((p) => ({ ...p, [c.id]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") addCustomCondition(c.id, condInput[c.id] ?? "");
if (e.key === "Escape") setCondEditing(null);
}}
autoFocus
aria-label="Custom condition"
/>
{customConditions.filter((cn) => !c.conditions.includes(cn)).slice(0, 4).map((cn) => (
<button key={cn} onClick={() => addCustomCondition(c.id, cn)} className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] cursor-pointer hover:text-[var(--color-text-secondary)]">{cn}</button>
))}
</div>
) : (
<div className="flex flex-wrap gap-0.5 mt-1">
{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>
))}
{customConditions.filter((cn) => !c.conditions.includes(cn) && !CONDITIONS.includes(cn)).map((cn) => (
<button
key={cn}
onClick={() => toggleCondition(c.id, cn)}
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-gold-muted)] px-1 py-0 text-[9px] hover:text-[var(--color-gold-bright)] cursor-pointer transition-colors"
title={`Add ${cn}`}
>
+{cn.slice(0, 3)}
</button>
))}
<button <button
key={cn} onClick={() => setCondEditing(c.id)}
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-gold-bright)] cursor-pointer transition-colors"
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 a custom condition"
title={`Add ${cn}`}
> >
+{cn.slice(0, 3)} +
</button> </button>
))} </div>
)}
{/* Notes per combatant — table memory for concentration, movement, etc. */}
<div className="mt-1">
{notesEditing === c.id ? (
<textarea
className="w-full rounded bg-[var(--color-bg-deep)] border border-[var(--color-gold-bright)] px-1.5 py-1 text-[10px] text-[var(--color-text-primary)] focus:outline-none resize-y min-h-12"
placeholder="Notes (e.g. concentrating on Hold Person, 50ft move left)…"
value={c.notes ?? ""}
onChange={(e) => setNotes(c.id, e.target.value)}
onBlur={() => setNotesEditing(null)}
autoFocus
aria-label={`Notes for ${c.name}`}
/>
) : (
<button
onClick={() => setNotesEditing(c.id)}
className={`text-[9px] cursor-pointer transition-colors ${c.notes ? "text-[var(--color-text-secondary)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
title="Notes"
>
{c.notes ? `📝 ${c.notes}` : "📝 add note"}
</button>
)}
</div> </div>
</div> </div>
); );
+20 -7
View File
@@ -31,7 +31,6 @@ export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
const [item, setItem] = useState<ItemResponse | null>(null); const [item, setItem] = useState<ItemResponse | null>(null);
const [rawResponse, setRawResponse] = useState<string | null>(null); const [rawResponse, setRawResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [artNonce, setArtNonce] = useState(0); const [artNonce, setArtNonce] = useState(0);
const { addToast } = useToast(); const { addToast } = useToast();
@@ -50,7 +49,6 @@ export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
async function generate() { async function generate() {
setLoading(true); setLoading(true);
setError("");
setItem(null); setItem(null);
setRawResponse(null); setRawResponse(null);
try { try {
@@ -92,11 +90,9 @@ The JSON must have exactly these keys:
`${parsed.name || "Magic item"}${parsed.rarity || rarity} ${parsed.type || itemType}. ${flattenValue(parsed.description)}\nMechanics: ${flattenValue(parsed.mechanical)}\nLore: ${flattenValue(parsed.lore)}`, `${parsed.name || "Magic item"}${parsed.rarity || rarity} ${parsed.type || itemType}. ${flattenValue(parsed.description)}\nMechanics: ${flattenValue(parsed.mechanical)}\nLore: ${flattenValue(parsed.lore)}`,
); );
} else { } else {
setError("Could not parse LLM response as JSON. Raw response shown below.");
addToast("LLM returned an unparseable response", "error"); addToast("LLM returned an unparseable response", "error");
} }
} catch (e) { } catch (e) {
setError(String(e));
addToast(`Item generation failed: ${e}`, "error"); addToast(`Item generation failed: ${e}`, "error");
} }
setLoading(false); setLoading(false);
@@ -114,6 +110,14 @@ The JSON must have exactly these keys:
const mechanicalText = item ? flattenValue(item.mechanical) : ""; const mechanicalText = item ? flattenValue(item.mechanical) : "";
const hasParseError = !item && rawResponse; const hasParseError = !item && rawResponse;
// ponytail: one-click "surprise me" — random rarity + type, no typed prompt.
function randomItem() {
setRarity(RARITIES[Math.floor(Math.random() * RARITIES.length)]);
setItemType(TYPES[Math.floor(Math.random() * TYPES.length)]);
setPrompt("");
setTimeout(generate, 0);
}
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -159,10 +163,19 @@ The JSON must have exactly these keys:
> >
{loading ? "⚔ Forging…" : "⚔ Forge Item"} {loading ? "⚔ Forging…" : "⚔ Forge Item"}
</button> </button>
<button
onClick={randomItem}
disabled={loading}
title="Surprise me — random rarity and type"
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
>
🎲 Random
</button>
{error && ( {!item && !loading && !hasParseError && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs"> <div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
{error} <span>No item yet.</span>
<span className="mt-1">Pick rarity + type, then Forge.</span>
</div> </div>
)} )}
+33 -7
View File
@@ -23,6 +23,7 @@ export function LorePanel() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [hits, setHits] = useState<RagHit[]>([]); const [hits, setHits] = useState<RagHit[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
async function loadSources() { async function loadSources() {
try { try {
@@ -59,6 +60,7 @@ export function LorePanel() {
async function clearAll() { async function clearAll() {
await invoke("rag_clear", { source: null }); await invoke("rag_clear", { source: null });
setConfirmClear(false);
loadSources(); loadSources();
} }
@@ -105,12 +107,30 @@ export function LorePanel() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3> <h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
{sources.length > 0 && ( {sources.length > 0 && (
<button confirmClear ? (
onClick={clearAll} <div className="flex items-center gap-1">
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors" <span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
> <button
clear all onClick={clearAll}
</button> 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>
) : (
<button
onClick={() => setConfirmClear(true)}
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
>
clear all
</button>
)
)} )}
</div> </div>
{sources.length === 0 ? ( {sources.length === 0 ? (
@@ -160,7 +180,13 @@ export function LorePanel() {
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2"> <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"> <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-gold-bright)]">{h.source}</span>
<span className="text-[10px] text-[var(--color-text-dim)]">score {h.score.toFixed(3)}</span> {/* ponytail: relevance bar instead of a raw cosine score a
DM can't read. Clamp 01, gold fill. */}
<div className="flex items-center gap-1.5">
<div className="w-12 h-1 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
<div className="h-full bg-[var(--color-gold-bright)]" style={{ width: `${Math.round(Math.max(0, Math.min(1, h.score)) * 100)}%` }} />
</div>
</div>
</div> </div>
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p> <p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
</li> </li>
+54 -14
View File
@@ -27,8 +27,10 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [npc, setNpc] = useState<NpcResponse | null>(null); const [npc, setNpc] = useState<NpcResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [portraitNonce, setPortraitNonce] = useState(0); const [portraitNonce, setPortraitNonce] = useState(0);
// ponytail: in-place edit of personality/goals — add/remove without regenerating.
const [newTrait, setNewTrait] = useState("");
const [newGoal, setNewGoal] = useState("");
const { addToast } = useToast(); const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "npc", () => onPrefillConsumed?.(), (g) => { usePrefillEffect(prefill ?? null, "npc", () => onPrefillConsumed?.(), (g) => {
@@ -44,9 +46,27 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
// text so we drop the name in-image when one is given. // 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; 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;
function removeTrait(i: number) {
if (!npc) return;
setNpc({ ...npc, personality: npc.personality.filter((_, j) => j !== i) });
}
function addTrait() {
if (!npc || !newTrait.trim()) return;
setNpc({ ...npc, personality: [...npc.personality, newTrait.trim()] });
setNewTrait("");
}
function removeGoal(i: number) {
if (!npc) return;
setNpc({ ...npc, goals: npc.goals.filter((_, j) => j !== i) });
}
function addGoal() {
if (!npc || !newGoal.trim()) return;
setNpc({ ...npc, goals: [...npc.goals, newGoal.trim()] });
setNewGoal("");
}
async function generate() { async function generate() {
setLoading(true); setLoading(true);
setError("");
setNpc(null); setNpc(null);
try { try {
const prompt = `Create a detailed NPC for a fantasy RPG: const prompt = `Create a detailed NPC for a fantasy RPG:
@@ -84,11 +104,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`, `${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`,
); );
} else { } else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error"); addToast("LLM returned an unparseable response", "error");
} }
} catch (e) { } catch (e) {
setError(String(e));
addToast(`NPC generation failed: ${e}`, "error"); addToast(`NPC generation failed: ${e}`, "error");
} }
setLoading(false); setLoading(false);
@@ -159,11 +177,6 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
</button> </button>
{/* Result */} {/* Result */}
{error && (
<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>
)}
{npc && ( {npc && (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2"> <div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
@@ -188,18 +201,45 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
<div> <div>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span> <span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{ensureArray(npc.personality).map((p, i) => ( {npc.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"> <span
{flattenValue(p)} key={i}
onClick={() => removeTrait(i)}
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs cursor-pointer hover:opacity-70"
title="Remove"
>
{p} ×
</span> </span>
))} ))}
<input
className="rounded-full bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-0.5 text-xs text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] w-24"
value={newTrait}
onChange={(e) => setNewTrait(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTrait()}
placeholder="+ trait"
aria-label="Add personality trait"
/>
</div> </div>
</div> </div>
<div> <div>
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Goals:</span> <span className="text-[var(--color-text-secondary)] text-xs font-medium">Goals:</span>
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-1"> <ul className="text-[var(--color-text-primary)] text-xs mt-1 space-y-0.5">
{ensureArray(npc.goals).map((g, i) => <li key={i}>{flattenValue(g)}</li>)} {npc.goals.map((g, i) => (
<li key={i} className="flex items-center gap-1.5">
<span className="text-[var(--color-gold-bright)]"></span>
<span className="flex-1">{g}</span>
<button onClick={() => removeGoal(i)} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer text-[10px]" title="Remove goal">×</button>
</li>
))}
</ul> </ul>
<input
className="mt-1 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-0.5 text-xs text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] w-full"
value={newGoal}
onChange={(e) => setNewGoal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGoal()}
placeholder="+ add a goal…"
aria-label="Add goal"
/>
</div> </div>
</div> </div>
)} )}
+29 -12
View File
@@ -31,7 +31,9 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
const [quest, setQuest] = useState<QuestResponse | null>(null); const [quest, setQuest] = useState<QuestResponse | null>(null);
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); // ponytail: player-facing view hides the Twist + Choices so the DM can share
// the screen without spoiling. Step status is derived from currentStep.
const [playerView, setPlayerView] = useState(false);
const { addToast } = useToast(); const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "quest", () => onPrefillConsumed?.(), (g) => { usePrefillEffect(prefill ?? null, "quest", () => onPrefillConsumed?.(), (g) => {
@@ -45,7 +47,6 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
async function generate() { async function generate() {
setLoading(true); setLoading(true);
setError("");
setQuest(null); setQuest(null);
setCurrentStep(0); setCurrentStep(0);
try { try {
@@ -82,11 +83,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
`Quest: ${parsed.title || "Untitled"} (level ${level}${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${parsed.reward}`, `Quest: ${parsed.title || "Untitled"} (level ${level}${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${parsed.reward}`,
); );
} else { } else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error"); addToast("LLM returned an unparseable response", "error");
} }
} catch (e) { } catch (e) {
setError(String(e));
addToast(`Quest generation failed: ${e}`, "error"); addToast(`Quest generation failed: ${e}`, "error");
} }
setLoading(false); setLoading(false);
@@ -125,14 +124,29 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
> >
{loading ? "✨ Designing…" : "✨ Design Quest"} {loading ? "✨ Designing…" : "✨ Design Quest"}
</button> </button>
{quest && (
<button
onClick={() => setPlayerView((v) => !v)}
className={`rounded-lg px-3 py-2 text-xs font-semibold border transition-colors cursor-pointer ${
playerView
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
title="Hide the twist and choices for screen-sharing"
>
{playerView ? "👁 Player view" : "🖥 GM view"}
</button>
)}
</div> </div>
{error && ( {!quest && !loading && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs"> <div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
{error} <span>No quest yet.</span>
<span className="mt-1">Set the theme + scope, then Design Quest.</span>
</div> </div>
)} )}
{quest && ( {quest && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* Quest title & hook */} {/* Quest title & hook */}
@@ -172,13 +186,16 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
</div> </div>
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4"> <div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4">
<h5 className="text-[var(--color-text-primary)] text-sm font-semibold mb-1"> <div className="flex items-center gap-2 mb-1">
Step {currentStep + 1}: {quest.steps[currentStep].title} <h5 className="text-[var(--color-text-primary)] text-sm font-semibold">
</h5> Step {currentStep + 1}: {quest.steps[currentStep].title}
</h5>
<span className="text-[9px] uppercase tracking-wider text-[var(--color-gold-bright)]">Active</span>
</div>
<p className="text-[var(--color-text-secondary)] text-sm leading-relaxed"> <p className="text-[var(--color-text-secondary)] text-sm leading-relaxed">
{quest.steps[currentStep].description} {quest.steps[currentStep].description}
</p> </p>
{quest.steps[currentStep].choice && ( {quest.steps[currentStep].choice && !playerView && (
<div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border-l-2 border-[var(--color-gold-bright)] px-3 py-2"> <div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border-l-2 border-[var(--color-gold-bright)] px-3 py-2">
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Choice</span> <span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Choice</span>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.steps[currentStep].choice}</p> <p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.steps[currentStep].choice}</p>
@@ -205,7 +222,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
)} )}
{/* Twist */} {/* Twist */}
{quest.twist && ( {quest.twist && !playerView && (
<div className="rounded-lg border-l-2 border-[var(--color-danger)] bg-[var(--color-bg-surface)] px-4 py-3"> <div className="rounded-lg border-l-2 border-[var(--color-danger)] bg-[var(--color-bg-surface)] px-4 py-3">
<span className="text-[var(--color-danger)] text-[10px] font-medium uppercase tracking-wider">Twist</span> <span className="text-[var(--color-danger)] text-[10px] font-medium uppercase tracking-wider">Twist</span>
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.twist}</p> <p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.twist}</p>
+158 -8
View File
@@ -57,6 +57,128 @@ const BUILTIN_TABLES: RandomTable[] = [
{ min: 10, max: 10, result: "Writes everything down in a tiny notebook" }, { min: 10, max: 10, result: "Writes everything down in a tiny notebook" },
], ],
}, },
{
name: "NPC Names",
dice: "1d20",
entries: [
{ min: 1, max: 2, result: "Aldric Holloway" },
{ min: 3, max: 4, result: "Mira Copperkettle" },
{ min: 5, max: 6, result: "Theron Ashwood" },
{ min: 7, max: 8, result: "Sable Nightwhisper" },
{ min: 9, max: 10, result: "Brom Stonefist" },
{ min: 11, max: 12, result: "Elara Dawnbrook" },
{ min: 13, max: 14, result: "Vaelris Moonsong" },
{ min: 15, max: 16, result: "Greta Bracken" },
{ min: 17, max: 18, result: "Corvus Vane" },
{ min: 19, max: 20, result: "Isolde Ferrow" },
],
},
{
name: "Traps",
dice: "1d12",
entries: [
{ min: 1, max: 2, result: "Pressure plates release a poison dart volley" },
{ min: 3, max: 4, result: "Floor tilts into a spiked pit" },
{ min: 5, max: 6, result: "Rune flares: failing save teleports the victim 30ft up" },
{ min: 7, max: 8, result: "A false door slams and locks, flooding the room with water" },
{ min: 9, max: 10, result: "Gas vent: everyone in 10ft saves or falls unconscious" },
{ min: 11, max: 12, result: "Sculpted eyes animate and cast Hold Person" },
],
},
{
name: "Dreams",
dice: "1d10",
entries: [
{ min: 1, max: 2, result: "A song you almost remember teaching you a secret" },
{ min: 3, max: 4, result: "You fall through endless clouds and wake before landing" },
{ min: 5, max: 6, result: "A dead friend warns you of a coming betrayal" },
{ min: 7, max: 8, result: "You are the dragon, and the party are the monsters" },
{ min: 9, max: 10, result: "A faceless figure offers you a gift you can't refuse" },
],
},
{
name: "Dungeon Encounters",
dice: "1d12",
entries: [
{ min: 1, max: 2, result: "1d4 goblins arguing over found loot" },
{ min: 3, max: 4, result: "A skeleton polishing its own skull" },
{ min: 5, max: 6, result: "A gelatinous cube drifting down the corridor" },
{ min: 7, max: 8, result: "Cultists mid-ritual around a dark pool" },
{ min: 9, max: 10, result: "A wounded adventurer begging for water" },
{ min: 11, max: 12, result: "A mimic chest that's already eaten someone" },
],
},
{
name: "Wilderness Encounters",
dice: "1d12",
entries: [
{ min: 1, max: 2, result: "A deer that watches without fleeing" },
{ min: 3, max: 4, result: "A traveler's shrine with a fresh offering" },
{ min: 5, max: 6, result: "Wolf tracks circling the party's camp" },
{ min: 7, max: 8, result: "A group of foraging halflings" },
{ min: 9, max: 10, result: "An old battlefield, bones half-buried" },
{ min: 11, max: 12, result: "A fae ring of mushrooms humming faintly" },
],
},
{
name: "Urban Encounters",
dice: "1d10",
entries: [
{ min: 1, max: 2, result: "A pickpocket bumps a party member" },
{ min: 3, max: 4, result: "A street preacher predicts doom" },
{ min: 5, max: 6, result: "A merchant chases a thief through the crowd" },
{ min: 7, max: 8, result: "A noble's carriage blocks the street, demanding right of way" },
{ min: 9, max: 10, result: "A wanted poster bearing a party member's likeness" },
],
},
{
name: "Inn Names",
dice: "1d12",
entries: [
{ min: 1, max: 2, result: "The Crooked Candle" },
{ min: 3, max: 4, result: "The Sour Barrel" },
{ min: 5, max: 6, result: "The Drowned Cat" },
{ min: 7, max: 8, result: "The Honest Thief" },
{ min: 9, max: 10, result: "The Last Lantern" },
{ min: 11, max: 12, result: "The Brass Sow" },
],
},
{
name: "Ship Names",
dice: "1d10",
entries: [
{ min: 1, max: 2, result: "The Salt-Scarred" },
{ min: 3, max: 4, result: "Drifting Promise" },
{ min: 5, max: 6, result: "The Widow's Joy" },
{ min: 7, max: 8, result: "Tide Liar" },
{ min: 9, max: 10, result: "The Gilded Hull" },
],
},
{
name: "Caravan Cargo",
dice: "1d8",
entries: [
{ min: 1, max: 1, result: "Casks of sour wine" },
{ min: 2, max: 2, result: "Bolts of dyed wool" },
{ min: 3, max: 3, result: "A caged, very unhappy owlbear cub" },
{ min: 4, max: 4, result: "Iron ingots, smudged with old runes" },
{ min: 5, max: 5, result: "A sealed coffin, labeled 'do not open'" },
{ min: 6, max: 6, result: "Crates of fragile glassware" },
{ min: 7, max: 7, result: "Sacks of exotic spice" },
{ min: 8, max: 8, result: "A peasant family fleeing trouble" },
],
},
{
name: "Treasure Hoards",
dice: "1d12",
entries: [
{ min: 1, max: 3, result: "500 gp, a silver chalice, and a scroll of Fireball" },
{ min: 4, max: 6, result: "2,000 gp, a +1 weapon, and a worn spellbook" },
{ min: 7, max: 9, result: "A gem-studded idol worth 1,500 gp and a cursed ring" },
{ min: 10, max: 10, result: "A deck of many things (the DMG kind)" },
{ min: 11, max: 12, result: "A map to a dragon's hoard and a single ancient coin" },
],
},
{ {
name: "Treasure (Low)", name: "Treasure (Low)",
dice: "1d6", dice: "1d6",
@@ -97,6 +219,16 @@ export function RandomTables() {
const table = BUILTIN_TABLES[selected]; const table = BUILTIN_TABLES[selected];
function exportTable() {
const blob = new Blob([JSON.stringify(table, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${table.name.toLowerCase().replace(/\s+/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}
const roll = useCallback(() => { const roll = useCallback(() => {
const rollVal = rollDice(table.dice); const rollVal = rollDice(table.dice);
const entry = table.entries.find((e) => rollVal >= e.min && rollVal <= e.max); const entry = table.entries.find((e) => rollVal >= e.min && rollVal <= e.max);
@@ -123,13 +255,22 @@ export function RandomTables() {
))} ))}
</select> </select>
{/* Roll button */} {/* Roll + export */}
<button <div className="flex gap-2">
onClick={roll} <button
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" onClick={roll}
> 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"
🎲 Roll {table.dice} >
</button> 🎲 Roll {table.dice}
</button>
<button
onClick={exportTable}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
title="Export this table as JSON"
>
</button>
</div>
{/* Result */} {/* Result */}
{customRoll !== null && ( {customRoll !== null && (
@@ -165,7 +306,16 @@ export function RandomTables() {
{/* Roll history */} {/* Roll history */}
{log.length > 0 && ( {log.length > 0 && (
<div className="border-t border-[var(--color-border-subtle)] pt-2 max-h-24 overflow-y-auto"> <div className="border-t border-[var(--color-border-subtle)] pt-2 max-h-24 overflow-y-auto">
<div className="text-[10px] font-medium text-[var(--color-text-dim)] mb-1">History</div> <div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-[var(--color-text-dim)]">History</div>
<button
onClick={() => setLog([])}
className="text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer"
title="Clear history"
>
clear
</button>
</div>
{log.map((l, i) => ( {log.map((l, i) => (
<div key={i} className="flex items-center gap-2 text-[10px] py-0.5"> <div key={i} className="flex items-center gap-2 text-[10px] py-0.5">
<span className="text-[var(--color-text-dim)]">{l.tableName}</span> <span className="text-[var(--color-text-dim)]">{l.tableName}</span>
+77 -21
View File
@@ -1,9 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke, Channel } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore"; import { addToLore } from "../lib/lore";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { addGeneration, type Generation } from "../lib/generations"; import { addGeneration, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill"; import { usePrefillEffect } from "../lib/usePrefill";
import { usePersistentState } from "../lib/usePersistentState";
interface Props { interface Props {
prefill?: Generation | null; prefill?: Generation | null;
@@ -12,10 +13,13 @@ interface Props {
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) { export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [summary, setSummary] = useState(""); const [summary, setSummary] = usePersistentState<string>("session.summary", "");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); // ponytail: streaming state — tokens arrive via the Channel and append to
const [entries, setEntries] = useState<{ text: string; time: string }[]>([]); // the summary live with a gold cursor. `gotFirstToken` flips the thinking
// dots to streaming text.
const [gotFirstToken, setGotFirstToken] = useState(false);
const [entries, setEntries] = usePersistentState<{ text: string; time: string }[]>("session.entries", []);
const { addToast } = useToast(); const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => { usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
@@ -32,31 +36,65 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
setNotes(""); setNotes("");
} }
// ponytail: export the session as a Markdown file the DM can share or
// paste into Discord. Reverses the entry list so it reads chronologically.
function exportMarkdown() {
if (entries.length === 0) return;
const lines = [...entries].reverse().map((e) => `- [${e.time}] ${e.text}`);
const md = `# Session ${new Date().toLocaleDateString()}\n\n## Notes\n\n${lines.join("\\n")}\n\n${summary ? `## AI Summary\n\n${summary}` : ""}`;
const blob = new Blob([md], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `session-${new Date().toISOString().slice(0, 10)}.md`;
a.click();
URL.revokeObjectURL(url);
addToast("Exported session to Markdown", "success");
}
function removeEntry(i: number) {
setEntries((prev) => prev.filter((_, j) => j !== i));
}
async function summarize() { async function summarize() {
if (entries.length === 0) return; if (entries.length === 0) return;
setLoading(true); setLoading(true);
setError(""); setSummary("");
setGotFirstToken(false);
const sessionText = entries.map((e) => `[${e.time}] ${e.text}`).join("\n");
// ponytail: stream tokens via the existing generate_stream Channel so the
// DM sees the summary appear word-by-word instead of staring at a button.
const channel = new Channel<{ type: string; data: string }>();
let streamed = "";
channel.onmessage = (event) => {
if (event.type === "token") {
setGotFirstToken(true);
streamed = streamed + event.data;
setSummary(streamed);
} else if (event.type === "done") {
setGotFirstToken(true);
streamed = event.data;
setSummary(streamed);
} else if (event.type === "error") {
addToast(`Summary failed: ${event.data}`, "error");
}
};
try { try {
const sessionText = entries await invoke("generate_stream", {
.map((e) => `[${e.time}] ${e.text}`)
.join("\n");
const result = await invoke<string>("generate", {
req: { req: {
prompt: `Summarize the following D&D session notes and suggest 2-3 hooks for the next session:\n\n${sessionText}`, prompt: `Summarize the following D&D session notes and suggest 2-3 hooks for the next session:\n\n${sessionText}`,
system: "You are a helpful D&D session summarizer. Be concise and creative.", system: "You are a helpful D&D session summarizer. Be concise and creative.",
temperature: 0.7, temperature: 0.7,
max_tokens: 512, max_tokens: 512,
}, },
channel,
}); });
setSummary(result);
addToast("Session summary generated", "success"); addToast("Session summary generated", "success");
// ponytail: persist so the DM can re-open past summaries. The data is const result = streamed;
// raw text here, not JSON, so the extractTitle() fallback is fine.
const title = `Session ${new Date().toLocaleDateString()}`; const title = `Session ${new Date().toLocaleDateString()}`;
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` }); void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`); void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
} catch (e) { } catch (e) {
setError(String(e));
addToast(`Summary failed: ${e}`, "error"); addToast(`Summary failed: ${e}`, "error");
} }
setLoading(false); setLoading(false);
@@ -92,14 +130,29 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
> >
{loading ? "Summarizing…" : "✨ AI Summary"} {loading ? "Summarizing…" : "✨ AI Summary"}
</button> </button>
<button
onClick={exportMarkdown}
disabled={entries.length === 0}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
title="Export notes + summary to a Markdown file"
>
Export
</button>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{entries.map((e, i) => ( {entries.map((e, i) => (
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)]"> <div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)] group">
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0"> <span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
{e.time} {e.time}
</span> </span>
<span className="text-[var(--color-text-primary)] text-xs">{e.text}</span> <span className="text-[var(--color-text-primary)] text-xs flex-1">{e.text}</span>
<button
onClick={() => removeEntry(i)}
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete entry"
>
×
</button>
</div> </div>
))} ))}
</div> </div>
@@ -108,13 +161,16 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
{/* AI Summary column */} {/* AI Summary column */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3> <h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3>
{error && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs mb-2">
{error}
</div>
)}
<div className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto"> <div className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
{summary || ( {loading && !gotFirstToken ? (
<span className="text-[var(--color-gold-bright)] tracking-widest">
<span className="thinking-dot"></span>
<span className="thinking-dot"></span>
<span className="thinking-dot"></span>
</span>
) : summary ? (
<span>{summary}{loading && <span className="text-[var(--color-gold-bright)]"></span>}</span>
) : (
<span className="text-[var(--color-text-dim)] italic"> <span className="text-[var(--color-text-dim)] italic">
Add session notes and click "AI Summary" to generate a recap and next-session hooks. Add session notes and click "AI Summary" to generate a recap and next-session hooks.
</span> </span>
+136 -7
View File
@@ -19,6 +19,8 @@ export function SettingsPanel() {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [testing, setTesting] = useState(false);
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
const { addToast } = useToast(); const { addToast } = useToast();
// ponytail: auto-load on mount so users don't see a gate before the form. // ponytail: auto-load on mount so users don't see a gate before the form.
@@ -47,6 +49,59 @@ export function SettingsPanel() {
setLoading(false); setLoading(false);
} }
// ponytail: probe the configured endpoint for a model list. Ollama uses
// /api/tags, OpenAI-compatible uses /v1/models. The Rust side branches.
async function testConnection() {
if (!config) return;
setTesting(true);
setConn(null);
try {
// Save first so the backend uses the latest URL/key.
await invoke("set_llm_config", { config });
const result = await invoke<{ ok: boolean; models: string[]; error: string }>("test_connection");
setConn(result);
if (result.ok) addToast(`Connected — ${result.models.length} models`, "success");
else addToast(`Connection failed: ${result.error}`, "error");
} catch (e) {
setConn({ ok: false, models: [], error: String(e) });
addToast(`Connection failed: ${e}`, "error");
}
setTesting(false);
}
// ponytail: provider presets fill in the API URL pattern + default model.
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" },
{ label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model" },
{ label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", key: true },
{ label: "Custom", url: "", model: "" },
];
function applyPreset(p: { url: string; model: string }) {
if (!config) return;
setConfig({ ...config, api_url: p.url, model: p.model });
setConn(null);
}
// ponytail: reset to the backend's default config. The Rust Default impl
// is the single source of truth; we just set an empty config and let the
// backend re-default on save — but we can't send a partial, so mirror the
// known defaults here and toast.
const DEFAULTS: LlmConfig = {
api_url: "http://localhost:11434",
api_key: "",
model: "llama3.2",
temperature: 0.7,
max_tokens: 512,
top_p: 0.9,
image_model: "x/flux2-klein:4b",
embed_model: "nomic-embed-text",
};
function resetDefaults() {
setConfig({ ...DEFAULTS });
setConn(null);
addToast("Reset to defaults — click Save to apply", "info");
}
if (!config) { if (!config) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-3"> <div className="flex flex-col items-center justify-center h-full gap-3">
@@ -60,6 +115,28 @@ export function SettingsPanel() {
return ( return (
<div className="flex flex-col gap-4 text-sm"> <div className="flex flex-col gap-4 text-sm">
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">LLM Connection</h3>
{/* Provider presets */}
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Provider</label>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.label}
type="button"
onClick={() => applyPreset(p)}
className={`rounded-lg px-2.5 py-1 text-xs border transition-colors cursor-pointer ${
config.api_url === p.url
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
>
{p.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium"> <label className="text-[var(--color-text-secondary)] text-xs font-medium">
API URL API URL
@@ -104,9 +181,51 @@ export function SettingsPanel() {
value={config.model} value={config.model}
onChange={(e) => setConfig({ ...config, model: e.target.value })} onChange={(e) => setConfig({ ...config, model: e.target.value })}
placeholder="llama3.2" placeholder="llama3.2"
list="model-list"
/> />
<datalist id="model-list">
{conn?.models.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div> </div>
{/* Connection test */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={testConnection}
disabled={testing}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
>
{testing ? "Testing…" : "Test connection"}
</button>
{conn && (
<span className={`text-xs ${conn.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
{conn.ok ? `${conn.models.length} models` : `${conn.error}`}
</span>
)}
</div>
{conn?.ok && conn.models.length > 0 && (
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
{conn.models.map((m) => (
<button
key={m}
type="button"
onClick={() => setConfig({ ...config, model: m })}
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
config.model === m
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
>
{m}
</button>
))}
</div>
)}
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Image Model</h3>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium"> <label className="text-[var(--color-text-secondary)] text-xs font-medium">
Image model Image model
@@ -119,6 +238,7 @@ export function SettingsPanel() {
/> />
</div> </div>
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Embedding Model (Lore RAG)</h3>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium"> <label className="text-[var(--color-text-secondary)] text-xs font-medium">
Embedding model (Lore RAG) Embedding model (Lore RAG)
@@ -131,6 +251,7 @@ export function SettingsPanel() {
/> />
</div> </div>
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Text Model</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-xs font-medium"> <label className="text-[var(--color-text-secondary)] text-xs font-medium">
@@ -166,13 +287,21 @@ export function SettingsPanel() {
</div> </div>
</div> </div>
<button <div className="flex gap-2">
onClick={saveConfig} <button
disabled={loading} onClick={saveConfig}
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" disabled={loading}
> 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"
{saved ? "✓ Saved" : loading ? "Saving…" : "Save Settings"} >
</button> {saved ? "✓ Saved" : loading ? "Saving…" : "Save Settings"}
</button>
<button
onClick={resetDefaults}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] transition-colors cursor-pointer"
>
Reset to defaults
</button>
</div>
</div> </div>
); );
} }
+76 -22
View File
@@ -1,5 +1,7 @@
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { bus, Events, type PlayAmbientPayload } from "../lib/bus";
import { usePersistentState } from "../lib/usePersistentState";
// Ambient sound types with their oscillator configs // Ambient sound types with their oscillator configs
const AMBIENT_SOUNDS = [ const AMBIENT_SOUNDS = [
@@ -27,7 +29,8 @@ const SFX_SOUNDS = [
export function Soundboard() { export function Soundboard() {
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set()); const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
const [volume, setVolume] = useState(0.5); const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false);
const audioCtxRef = useRef<AudioContext | null>(null); const audioCtxRef = useRef<AudioContext | null>(null);
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map()); const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
const { addToast } = useToast(); const { addToast } = useToast();
@@ -39,6 +42,45 @@ export function Soundboard() {
return audioCtxRef.current; return audioCtxRef.current;
}, []); }, []);
// ponytail: pre-warm the AudioContext on the first user gesture so the
// first ambient doesn't silently fail to autoplay (browser policy).
useEffect(() => {
const warm = () => {
const ctx = getAudioCtx();
if (ctx.state === "suspended") void ctx.resume();
window.removeEventListener("pointerdown", warm);
};
window.addEventListener("pointerdown", warm);
return () => window.removeEventListener("pointerdown", warm);
}, [getAudioCtx]);
// ponytail: master mute — ramps every active ambient to 0 (or back to
// volume) on toggle, and gates SFX. Cheaper than threading a master gain
// node through every code path.
useEffect(() => {
const ctx = audioCtxRef.current;
if (!ctx) return;
nodesRef.current.forEach((nodes) => {
try {
nodes.gain.gain.linearRampToValueAtTime(muted ? 0 : volume, ctx.currentTime + 0.2);
} catch {}
});
}, [muted, volume]);
// ponytail: listen for EncounterBuilder's "send to soundboard" event and
// flip the matching ambient on. Mirrors the AddCombatants flow on Initiative.
useEffect(() => {
return bus.on<PlayAmbientPayload>(Events.PlayAmbient, (payload) => {
setActiveAmbients((prev) => {
if (prev.has(payload.id)) return prev; // already playing
const next = new Set(prev);
next.add(payload.id);
return next;
});
addToast(`Ambient on: ${payload.source}`, "info");
});
}, [addToast]);
const toggleAmbient = useCallback((id: string) => { const toggleAmbient = useCallback((id: string) => {
setActiveAmbients((prev) => { setActiveAmbients((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -104,6 +146,7 @@ export function Soundboard() {
}, [volume, getAudioCtx]); }, [volume, getAudioCtx]);
const playSfx = useCallback((id: string) => { const playSfx = useCallback((id: string) => {
if (muted) return; // ponytail: master mute gates one-shot SFX too.
const ctx = getAudioCtx(); const ctx = getAudioCtx();
const sfx = SFX_SOUNDS.find((s) => s.id === id); const sfx = SFX_SOUNDS.find((s) => s.id === id);
if (!sfx) return; if (!sfx) return;
@@ -238,11 +281,11 @@ export function Soundboard() {
} }
addToast(`${sfx.icon} ${sfx.label}`, "info"); addToast(`${sfx.icon} ${sfx.label}`, "info");
}, [volume, getAudioCtx, addToast]); }, [volume, getAudioCtx, addToast, muted]);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Volume */} {/* Volume + master mute */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-[var(--color-text-secondary)] text-xs font-medium w-16">Volume</span> <span className="text-[var(--color-text-secondary)] text-xs font-medium w-16">Volume</span>
<input <input
@@ -257,6 +300,18 @@ export function Soundboard() {
<span className="text-[var(--color-text-dim)] text-xs font-mono w-8 text-right"> <span className="text-[var(--color-text-dim)] text-xs font-mono w-8 text-right">
{Math.round(volume * 100)}% {Math.round(volume * 100)}%
</span> </span>
<button
onClick={() => setMuted((m) => !m)}
className={`rounded-lg px-2 py-1 text-xs cursor-pointer border transition-colors ${
muted
? "bg-[var(--color-danger)]/20 border-[var(--color-danger)]/50 text-[var(--color-danger)]"
: "border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
title={muted ? "Unmute" : "Mute all"}
aria-label={muted ? "Unmute" : "Mute all"}
>
{muted ? "🔇" : "🔊"}
</button>
</div> </div>
{/* Ambient sounds */} {/* Ambient sounds */}
@@ -304,24 +359,23 @@ export function Soundboard() {
</div> </div>
</div> </div>
{/* Stop all */} {/* Stop all — always visible so the DM never hunts for it. */}
{activeAmbients.size > 0 && ( <button
<button onClick={() => {
onClick={() => { nodesRef.current.forEach((nodes) => {
nodesRef.current.forEach((nodes) => { try {
try { nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5); setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600); } catch {}
} catch {} });
}); nodesRef.current.clear();
nodesRef.current.clear(); setActiveAmbients(new Set());
setActiveAmbients(new Set()); }}
}} disabled={activeAmbients.size === 0}
className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer" className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
> >
Stop All Stop All{activeAmbients.size > 0 ? ` (${activeAmbients.size})` : ""}
</button> </button>
)}
</div> </div>
); );
} }
+17 -10
View File
@@ -6,25 +6,32 @@ interface Toast {
id: string; id: string;
message: string; message: string;
type: ToastType; type: ToastType;
sticky?: boolean;
} }
interface ToastStore { interface ToastStore {
toasts: Toast[]; toasts: Toast[];
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType, opts?: { sticky?: boolean }) => void;
removeToast: (id: string) => void; removeToast: (id: string) => void;
} }
let nextId = 0;
export const useToast = create<ToastStore>((set) => ({ export const useToast = create<ToastStore>((set) => ({
toasts: [], toasts: [],
addToast: (message, type = "info") => { addToast: (message, type = "info", opts = {}) => {
const id = String(nextId++); // ponytail: crypto.randomUUID for stable unique ids (the old module
set((state) => ({ toasts: [...state.toasts, { id, message, type }] })); // counter was shared across imports and brittle in tests).
// Auto-dismiss after 4 seconds const id = crypto.randomUUID();
setTimeout(() => { set((state) => {
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); // ponytail: cap visible at 3 — older ones get pushed off so a noisy
}, 4000); // session doesn't stack a wall of toasts over the content.
const next = [...state.toasts, { id, message, type, sticky: opts.sticky }];
return { toasts: next.slice(-3) };
});
if (!opts.sticky) {
setTimeout(() => {
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
}, 4000);
}
}, },
removeToast: (id) => { removeToast: (id) => {
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
+26 -11
View File
@@ -5,6 +5,7 @@ import { addToLore } from "../lib/lore";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations"; import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill"; import { usePrefillEffect } from "../lib/usePrefill";
import { usePersistentState } from "../lib/usePersistentState";
interface WorldResponse { interface WorldResponse {
name: string; name: string;
@@ -21,12 +22,18 @@ interface Props {
} }
export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) { export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
const [theme, setTheme] = useState("high fantasy"); // ponytail: persist theme + last world so a reload keeps the DM's work.
const [world, setWorld] = useState<WorldResponse | null>(null); const [theme, setTheme] = usePersistentState<string>("world.theme", "high fantasy");
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { addToast } = useToast(); const { addToast } = useToast();
// ponytail: genre presets as a quick-pick row.
const THEME_PRESETS = [
"high fantasy", "dark fantasy", "sword & sorcery",
"steampunk", "post-apocalyptic", "horror", "sci-fantasy",
];
usePrefillEffect(prefill ?? null, "world", () => onPrefillConsumed?.(), (g) => { usePrefillEffect(prefill ?? null, "world", () => onPrefillConsumed?.(), (g) => {
const parsed = parseLlmJson<WorldResponse>(g.data); const parsed = parseLlmJson<WorldResponse>(g.data);
if (parsed) { if (parsed) {
@@ -37,7 +44,6 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
async function generate() { async function generate() {
setLoading(true); setLoading(true);
setError("");
setWorld(null); setWorld(null);
try { try {
const result = await invoke<string>("generate", { const result = await invoke<string>("generate", {
@@ -75,11 +81,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`, `${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
); );
} else { } else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error"); addToast("LLM returned an unparseable response", "error");
} }
} catch (e) { } catch (e) {
setError(String(e));
addToast(`World generation failed: ${e}`, "error"); addToast(`World generation failed: ${e}`, "error");
} }
setLoading(false); setLoading(false);
@@ -91,6 +95,22 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
<label className="text-[var(--color-text-secondary)] text-xs font-medium"> <label className="text-[var(--color-text-secondary)] text-xs font-medium">
World Theme World Theme
</label> </label>
<div className="flex flex-wrap gap-1.5 mb-1">
{THEME_PRESETS.map((t) => (
<button
key={t}
type="button"
onClick={() => setTheme(t)}
className={`rounded-lg px-2.5 py-1 text-xs border transition-colors cursor-pointer ${
theme === t
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
>
{t}
</button>
))}
</div>
<input <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)] text-sm" 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"
value={theme} value={theme}
@@ -107,11 +127,6 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
</button> </button>
</div> </div>
{error && (
<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>
)}
{world && ( {world && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
+50 -9
View File
@@ -1,7 +1,26 @@
/* ponytail: Google Fonts CSS2 import single request, all families/weights. /* ponytail: fonts self-hosted as variable woff2 (latin subset) in public/fonts/
TODO: download woff2 to public/fonts/ and switch to @font-face for true for true offline-first a desktop app must not depend on Google's CDN. */
offline-first. Until then, this needs network on first run. */ @font-face {
@import url("https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;700&display=swap"); font-family: "Cinzel";
font-style: normal;
font-weight: 600 700;
font-display: swap;
src: url("/fonts/cinzel.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url("/fonts/inter.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/jetbrainsmono.woff2") format("woff2");
}
@import "tailwindcss"; @import "tailwindcss";
@@ -39,11 +58,6 @@
--font-mono: "JetBrains Mono", ui-monospace, monospace; --font-mono: "JetBrains Mono", ui-monospace, monospace;
} }
/* ─── Self-Hosted Fonts ─────────────────────────────────────── */
/* ponytail: Google Fonts are loaded at the top of this file (must precede
Tailwind per CSS spec). TODO: download woff2 to public/fonts/ and switch
to @font-face for true offline-first. */
/* ─── Base Styles ───────────────────────────────────────────── */ /* ─── Base Styles ───────────────────────────────────────────── */
* { * {
@@ -186,6 +200,33 @@ body,
to { opacity: 1; } to { opacity: 1; }
} }
/* ponytail: shimmer skeleton for image/art loading, and a thinking-dot
cycle for LLM waits. Replaces "Generating…" button text for 520s waits. */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg-surface) 25%,
var(--color-bg-card-hover) 50%,
var(--color-bg-surface) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes thinking {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
.thinking-dot {
animation: thinking 1.2s ease-in-out infinite;
}
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
/* ponytail: respect users who ask for less motion. framer-motion honors this /* ponytail: respect users who ask for less motion. framer-motion honors this
via its useReducedMotion hook, but the inline keyframes/glass-card transitions via its useReducedMotion hook, but the inline keyframes/glass-card transitions
still need this CSS guard. */ still need this CSS guard. */
+7
View File
@@ -43,6 +43,7 @@ export const bus = new EventBus();
export const Events = { export const Events = {
AddCombatants: "add-combatants", AddCombatants: "add-combatants",
PlayAmbient: "play-ambient",
} as const; } as const;
export type AddCombatantsPayload = { export type AddCombatantsPayload = {
@@ -50,3 +51,9 @@ export type AddCombatantsPayload = {
/** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */ /** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */
source: string; source: string;
}; };
export type PlayAmbientPayload = {
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */
id: string;
source: string;
};
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
import { LazyStore } from "@tauri-apps/plugin-store";
// ponytail: one store file for small UI state. Generated content (NPCs,
// encounters, etc.) goes through the generations backend instead; this is
// only for things that live in component state today (initiative, dice
// history, calendar events). Debounced save so a fast dice session doesn't
// write on every roll.
const store = new LazyStore("dm-pal-state.json");
function debounce<T extends (...args: never[]) => void>(fn: T, ms: number): T {
let t: ReturnType<typeof setTimeout> | undefined;
return ((...args: never[]) => {
if (t) clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
}) as T;
}
const save = debounce((key: string, value: unknown) => {
void store.set(key, value);
void store.save();
}, 400);
/**
* useState that persists to tauri-plugin-store. Loads on mount (returns the
* fallback until the async load completes), saves on every change (debounced).
* Reload = no more data loss for initiative / dice / calendar.
*/
export function usePersistentState<T>(
key: string,
fallback: T,
): [T, React.Dispatch<React.SetStateAction<T>>, boolean] {
const [value, setValue] = useState<T>(fallback);
const [loaded, setLoaded] = useState(false);
const skipSave = useRef(true);
// Load once on mount.
useEffect(() => {
let alive = true;
(async () => {
try {
const stored = await store.get<T>(key);
if (alive && stored !== null && stored !== undefined) {
setValue(stored);
}
} catch {
// store unavailable — keep fallback.
}
if (alive) setLoaded(true);
})();
return () => {
alive = false;
};
}, [key]);
// Save on change (skip the initial load-induced render).
useEffect(() => {
if (skipSave.current) {
skipSave.current = false;
return;
}
save(key, value);
}, [key, value]);
return [value, setValue, loaded];
}