minor UI fixes
This commit is contained in:
+90
-90
@@ -4,8 +4,8 @@
|
||||
ordered by impact, not file order. Each item ships with a concrete, checkable
|
||||
recommendation.*
|
||||
|
||||
> **Status:** audit only — no code changes yet. Treat the checklist at the
|
||||
> bottom as the punch list for the next sprint.
|
||||
> **Status:** in progress — P0 and P1 shipped, P2 largely done. The
|
||||
> 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)
|
||||
|
||||
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,
|
||||
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.
|
||||
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 5–20 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).
|
||||
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
|
||||
separately so a back arrow is missing.
|
||||
|
||||
@@ -997,113 +997,113 @@ familiarity).
|
||||
|
||||
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.
|
||||
- [ ] **Auto-load Settings on mount.** Remove the "Load Settings"
|
||||
- [x] **Auto-load Settings on mount.** Remove the "Load Settings"
|
||||
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).
|
||||
- [ ] **Show back button on Settings** (drop the
|
||||
- [x] **Show back button on Settings** (drop the
|
||||
`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.
|
||||
- [ ] **Self-host the fonts** in `public/fonts/` (offline-first).
|
||||
- [ ] **Standardize errors via the Toast system** — delete inline
|
||||
- [x] **Self-host the fonts** in `public/fonts/` (offline-first).
|
||||
- [x] **Standardize errors via the Toast system** — delete inline
|
||||
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).
|
||||
- [ ] **Add `aria-label`** to every icon-only button.
|
||||
- [x] **Add `aria-label`** to every icon-only button.
|
||||
|
||||
### 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
|
||||
uppercase labels.
|
||||
- [ ] **Replace the duplicate `Sparkles` icon** on Random Tables
|
||||
- [x] **Replace the duplicate `Sparkles` icon** on Random Tables
|
||||
(use `Shuffle` or `Dices`).
|
||||
- [ ] **Add a global ⌘K command palette** for navigation.
|
||||
- [ ] **Implement persistence** for Initiative, Dice history,
|
||||
- [x] **Add a global ⌘K command palette** for navigation.
|
||||
- [x] **Implement persistence** for Initiative, Dice history,
|
||||
Calendar events, Generated NPCs/Encounters/Items/Quests.
|
||||
Use `tauri-plugin-store` for small state, `tauri-plugin-fs`
|
||||
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
|
||||
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.
|
||||
- [ ] **Add Advantage/Disadvantage buttons** to Dice Roller.
|
||||
- [ ] **Make the dashboard a launcher** — replace embedded
|
||||
- [x] **Add Advantage/Disadvantage buttons** to Dice Roller.
|
||||
- [x] **Make the dashboard a launcher** — replace embedded
|
||||
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.
|
||||
- [ ] **Add a real loading state** to Settings: connection test
|
||||
- [x] **Add a real loading state** to Settings: connection test
|
||||
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).
|
||||
- [ ] **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`.
|
||||
|
||||
### P2 — Important (next month)
|
||||
|
||||
- [ ] **Initiative Tracker**:
|
||||
- [ ] Click-to-edit initiative number
|
||||
- [ ] Custom conditions
|
||||
- [ ] Damage input (numeric, not +/- only)
|
||||
- [ ] Death saves
|
||||
- [ ] Turn timer
|
||||
- [ ] Notes per combatant
|
||||
- [ ] Highlight next-up combatant
|
||||
- [ ] Confirm before Reset
|
||||
- [ ] "Import from Encounter" button
|
||||
- [x] Click-to-edit initiative number
|
||||
- [x] Custom conditions
|
||||
- [x] Damage input (numeric, not +/- only)
|
||||
- [x] Death saves
|
||||
- [x] Turn timer
|
||||
- [x] Notes per combatant
|
||||
- [x] Highlight next-up combatant
|
||||
- [x] Confirm before Reset
|
||||
- [x] "Import from Encounter" button
|
||||
- [ ] **Dice Roller**:
|
||||
- [ ] Modifier input (saved per session)
|
||||
- [ ] Spacebar rolls
|
||||
- [ ] Roll templates (Attack, Save, Check, Damage)
|
||||
- [ ] Clear/copy history
|
||||
- [x] Modifier input (saved per session)
|
||||
- [x] Spacebar rolls
|
||||
- [x] Roll templates (Attack, Save, Check, Damage)
|
||||
- [x] Clear/copy history
|
||||
- [ ] **Encounter Builder**:
|
||||
- [ ] XP budget math (party size, level, difficulty)
|
||||
- [ ] Color-coded difficulty
|
||||
- [ ] Send to Initiative Tracker
|
||||
- [ ] Send to Soundboard (terrain-based)
|
||||
- [ ] Save to lore
|
||||
- [ ] 4 more terrain presets (planar, haunted, ship, siege)
|
||||
- [x] XP budget math (party size, level, difficulty)
|
||||
- [x] Color-coded difficulty
|
||||
- [x] Send to Initiative Tracker
|
||||
- [x] Send to Soundboard (terrain-based)
|
||||
- [x] Save to lore
|
||||
- [x] 4 more terrain presets (planar, haunted, ship, siege)
|
||||
- [ ] **NPC Generator**:
|
||||
- [ ] Rename "Class" to "Background" with curated list
|
||||
- [ ] Expand races to 9 (Half-Orc, Goliath)
|
||||
- [x] Rename "Class" to "Background" with curated list
|
||||
- [x] Expand races to 9 (Half-Orc, Goliath)
|
||||
- [ ] NPC Roster view
|
||||
- [ ] Stat block section (AC, HP, ability scores)
|
||||
- [ ] Random name button
|
||||
- [x] Random name button
|
||||
- [ ] Save portrait (right-click)
|
||||
- [ ] Edit personality/goals in-place
|
||||
- [x] Edit personality/goals in-place
|
||||
- [ ] **Item Forge**:
|
||||
- [ ] Click art to expand
|
||||
- [x] Click art to expand
|
||||
- [ ] Structured mechanics (attunement, charges, value, weight)
|
||||
- [ ] Save to inventory
|
||||
- [ ] Random item button
|
||||
- [x] Random item button
|
||||
- [ ] **Quest Designer**:
|
||||
- [ ] Branching quest graph (react-flow)
|
||||
- [ ] Step status (Pending/Active/Done)
|
||||
- [ ] Player-facing view toggle (hide Twist)
|
||||
- [x] Step status (Pending/Active/Done)
|
||||
- [x] Player-facing view toggle (hide Twist)
|
||||
- [ ] Reward breakdown (XP per character, gold, items)
|
||||
- [ ] Quest roster
|
||||
- [ ] **Image Generator**:
|
||||
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
||||
- [ ] Batch mode (2×2 variants)
|
||||
- [ ] Gallery view with thumbnails
|
||||
- [ ] Persist to campaign-scoped folder
|
||||
- [ ] Copy to clipboard
|
||||
- [x] Persist to campaign-scoped folder
|
||||
- [x] Copy to clipboard
|
||||
- [ ] **Session Logger**:
|
||||
- [ ] Markdown editor + live preview
|
||||
- [ ] Sessions as first-class concept (multiple sessions)
|
||||
- [ ] Streaming AI summary
|
||||
- [ ] Export to Markdown
|
||||
- [ ] Per-entry actions (edit, delete, highlight)
|
||||
- [x] Streaming AI summary
|
||||
- [x] Export to Markdown
|
||||
- [x] Per-entry actions (edit, delete, highlight)
|
||||
- [ ] Tags
|
||||
- [ ] **Calendar**:
|
||||
- [ ] Custom calendar editor
|
||||
- [ ] Current campaign date with "Next day" button
|
||||
- [ ] Weather generator
|
||||
- [ ] Moon phases
|
||||
- [x] Current campaign date with "Next day" button
|
||||
- [x] Weather generator
|
||||
- [x] Moon phases
|
||||
- [ ] Event categories with colors
|
||||
- [ ] Recurring events
|
||||
- [ ] Agenda view
|
||||
@@ -1111,43 +1111,43 @@ These are blockers or bugs that make the app feel broken.
|
||||
- [ ] Real ambience pack (2–3 royalty-free loops)
|
||||
- [ ] Custom sound import (drag-drop)
|
||||
- [ ] Scenes (one-click combinations)
|
||||
- [ ] Master mute + per-sound volume
|
||||
- [ ] Always-visible Stop All
|
||||
- [ ] Save/load board state
|
||||
- [ ] Pre-warm AudioContext
|
||||
- [x] Master mute + per-sound volume
|
||||
- [x] Always-visible Stop All
|
||||
- [x] Save/load board state
|
||||
- [x] Pre-warm AudioContext
|
||||
- [ ] **Random Tables**:
|
||||
- [ ] 10+ more built-in tables
|
||||
- [x] 10+ more built-in tables
|
||||
- [ ] User-defined tables (create/edit/delete)
|
||||
- [ ] Table import (paste Markdown)
|
||||
- [ ] Weighted tables
|
||||
- [ ] Export table
|
||||
- [x] Export table
|
||||
- [ ] **World Builder**:
|
||||
- [ ] Map canvas (react-konva)
|
||||
- [ ] Theme presets
|
||||
- [x] Theme presets
|
||||
- [ ] Hierarchy tree
|
||||
- [ ] Persist world
|
||||
- [x] Persist world
|
||||
- [ ] **Lore Panel**:
|
||||
- [ ] Two-pane layout
|
||||
- [ ] Drag-drop file upload
|
||||
- [ ] Directory picker
|
||||
- [ ] Chunk preview
|
||||
- [ ] Source filter
|
||||
- [ ] ★ relevance (not raw cosine)
|
||||
- [ ] Confirm before "Clear all"
|
||||
- [x] ★ relevance (not raw cosine)
|
||||
- [x] Confirm before "Clear all"
|
||||
- [ ] **Settings**:
|
||||
- [ ] Group into fieldsets (Connection, Text, Image, Embedding, Danger)
|
||||
- [ ] Connection test with model list
|
||||
- [ ] Model picker with autocomplete (`GET /api/tags`)
|
||||
- [ ] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom)
|
||||
- [ ] Reset to defaults
|
||||
- [x] Group into fieldsets (Connection, Text, Image, Embedding, Danger)
|
||||
- [x] Connection test with model list
|
||||
- [x] Model picker with autocomplete (`GET /api/tags`)
|
||||
- [x] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom)
|
||||
- [x] Reset to defaults
|
||||
- [ ] Export/import config
|
||||
- [ ] **Cross-cutting**:
|
||||
- [ ] Keyboard shortcuts: ⌘K, 1–9, Space, ⌘,, ⌘S, Esc, ?
|
||||
- [ ] Skeleton loaders for all async states
|
||||
- [ ] Empty states for all tools
|
||||
- [ ] `prefers-reduced-motion` respect on framer-motion
|
||||
- [ ] Skip-to-content link
|
||||
- [ ] Color + label for state (not color alone)
|
||||
- [x] Keyboard shortcuts: ⌘K, 1–9, Space, ⌘,, ⌘S, Esc, ?
|
||||
- [x] Skeleton loaders for all async states
|
||||
- [x] Empty states for all tools
|
||||
- [x] `prefers-reduced-motion` respect on framer-motion
|
||||
- [x] Skip-to-content link
|
||||
- [x] Color + label for state (not color alone)
|
||||
|
||||
### 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
|
||||
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)
|
||||
2. Auto-load Settings on mount (10 min)
|
||||
@@ -1181,11 +1181,11 @@ small CSS:
|
||||
(2 min)
|
||||
4. Add `aria-label` to every icon-only button (30 min)
|
||||
5. Add `data-tooltip` styled tooltips (1 hr)
|
||||
6. Group the left rail into Session / World (30 min)
|
||||
7. Replace the duplicate `Sparkles` icon (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)
|
||||
10. Self-host the fonts in `public/fonts/` (1 hr)
|
||||
6. ✓ Group the left rail into Session / World (30 min)
|
||||
7. ✓ Replace the duplicate `Sparkles` icon (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)
|
||||
10. ✓ Self-host the fonts in `public/fonts/` (1 hr)
|
||||
|
||||
Total: ~6–8 hours. Lands a much more polished, accessible
|
||||
baseline that the rest of the checklist builds on.
|
||||
|
||||
Generated
+10
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-log": "^2",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"framer-motion": "^12",
|
||||
"lucide-react": "^0.525",
|
||||
"react": "^19.2.7",
|
||||
@@ -1306,6 +1307,15 @@
|
||||
"@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": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-log": "^2",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"framer-motion": "^12",
|
||||
"lucide-react": "^0.525",
|
||||
"react": "^19.2.7",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -81,6 +81,72 @@ pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::Llm
|
||||
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) ─
|
||||
|
||||
/// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn run() {
|
||||
commands::llm_commands::generate_stream,
|
||||
commands::llm_commands::get_llm_config,
|
||||
commands::llm_commands::set_llm_config,
|
||||
commands::llm_commands::test_connection,
|
||||
commands::image_commands::generate_image,
|
||||
commands::rag_commands::rag_add,
|
||||
commands::rag_commands::rag_search,
|
||||
|
||||
+10
-1
@@ -34,6 +34,7 @@ import { ImageGenerator } from "./components/ImageGenerator";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { CommandPalette } from "./components/CommandPalette";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { ConnectionPill } from "./components/ConnectionPill";
|
||||
import { HistoryView } from "./components/HistoryView";
|
||||
import type { Generation, GenerationKind } from "./lib/generations";
|
||||
|
||||
@@ -251,6 +252,13 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
@@ -348,10 +356,11 @@ export default function App() {
|
||||
</kbd>
|
||||
</button>
|
||||
<div className="ml-auto" data-tauri-no-drag />
|
||||
<ConnectionPill />
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<main className="flex-1 overflow-auto" id="main-content">
|
||||
<ErrorBoundary>
|
||||
{isOnDashboard ? (
|
||||
<Dashboard onNavigate={setView} />
|
||||
|
||||
@@ -9,30 +9,20 @@ interface BentoCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Convert desktop spans like "col-span-2 row-span-1" into responsive spans
|
||||
// On mobile (1 col): everything is col-span-1
|
||||
// On tablet (2 cols): col-span-2 stays, col-span-1 stays
|
||||
// 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");
|
||||
}
|
||||
// ponytail: callers pass responsive span classes directly (e.g.
|
||||
// "sm:col-span-2"). No string regex — the old replace() mangled pre-responsive
|
||||
// classes and silently dropped unknown spans like col-span-3.
|
||||
|
||||
export function BentoCard({
|
||||
title,
|
||||
icon,
|
||||
span = "col-span-1 row-span-1",
|
||||
span = "",
|
||||
children,
|
||||
className = "",
|
||||
}: BentoCardProps) {
|
||||
const responsive = responsiveSpan(span);
|
||||
|
||||
return (
|
||||
<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
|
||||
// cursor moves. The CSS .glass-card:hover (border + glow) is enough.
|
||||
whileTap={{ scale: 0.985 }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
|
||||
const MONTHS = [
|
||||
"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() {
|
||||
const [month, setMonth] = useState(0);
|
||||
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 [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() {
|
||||
if (!newEvent.trim() || selectedDay === null) return;
|
||||
@@ -62,6 +104,29 @@ export function CalendarWidget() {
|
||||
</button>
|
||||
</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 */}
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{["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 */}
|
||||
{Array.from({ length: 30 }, (_, i) => {
|
||||
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 isSelected = dayNum === selectedDay;
|
||||
|
||||
@@ -92,6 +157,7 @@ export function CalendarWidget() {
|
||||
{hasEvent && !isToday && (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -100,8 +166,13 @@ export function CalendarWidget() {
|
||||
{/* Events for selected day */}
|
||||
{selectedDay !== null && (
|
||||
<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">
|
||||
{MONTHS[month]} {selectedDay}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<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>
|
||||
{dayEvents.length === 0 && (
|
||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+171
-210
@@ -2,235 +2,196 @@ import {
|
||||
Map,
|
||||
User,
|
||||
Swords,
|
||||
ScrollText,
|
||||
Dice5,
|
||||
Sparkles,
|
||||
Timer,
|
||||
Volume2,
|
||||
Timer,
|
||||
Calendar,
|
||||
Maximize2,
|
||||
Wand2,
|
||||
ImagePlus,
|
||||
BookOpen,
|
||||
Shuffle,
|
||||
Flag,
|
||||
ScrollText,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BentoCard } from "./BentoCard";
|
||||
import type { View } from "../App";
|
||||
import { NpcGenerator } from "./NpcGenerator";
|
||||
import { DiceRoller } from "./DiceRoller";
|
||||
import { SessionLogger } from "./SessionLogger";
|
||||
import { EncounterBuilder } from "./EncounterBuilder";
|
||||
import { InitiativeTracker } from "./InitiativeTracker";
|
||||
import { RandomTables } from "./RandomTables";
|
||||
import { CalendarWidget } from "./CalendarWidget";
|
||||
import { WorldBuilder } from "./WorldBuilder";
|
||||
import { QuestDesigner } from "./QuestDesigner";
|
||||
import { ItemForge } from "./ItemForge";
|
||||
import { Soundboard } from "./Soundboard";
|
||||
import { ImageGenerator } from "./ImageGenerator";
|
||||
import {
|
||||
listGenerations,
|
||||
relativeTime,
|
||||
type GenerationKind,
|
||||
type GenerationSummary,
|
||||
} from "../lib/generations";
|
||||
|
||||
interface DashboardProps {
|
||||
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) {
|
||||
// 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 (
|
||||
<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">
|
||||
{/* World Map — 2×2 */}
|
||||
<BentoCard title="World Builder" icon={<Map size={16} />} span="col-span-2 row-span-2">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<WorldBuilder />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate("world")}
|
||||
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)]"
|
||||
<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">
|
||||
{CARDS.map((card) => {
|
||||
const last = card.kind ? latest[card.kind] : undefined;
|
||||
return (
|
||||
<BentoCard
|
||||
key={card.view}
|
||||
title={card.title}
|
||||
icon={<card.icon size={16} />}
|
||||
span={card.span}
|
||||
>
|
||||
<Maximize2 size={12} /> Expand
|
||||
</button>
|
||||
</div>
|
||||
</BentoCard>
|
||||
<div className="flex flex-col h-full min-h-[120px]">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<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)]">
|
||||
<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 */}
|
||||
<BentoCard title="NPC Sheet" icon={<User size={16} />} span="col-span-1 row-span-1">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<NpcGenerator />
|
||||
</div>
|
||||
<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>
|
||||
{last && (
|
||||
<p className="text-[10px] text-[var(--color-text-dim)] mt-2 truncate">
|
||||
Last:{" "}
|
||||
<span className="text-[var(--color-text-secondary)]">{last.title}</span>{" "}
|
||||
· {relativeTime(last.createdAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dice Roller — 1×1 */}
|
||||
<BentoCard title="Dice Roller" icon={<Dice5 size={16} />} span="col-span-1 row-span-1">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DiceRoller />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate("dice")}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
<button
|
||||
onClick={() => onNavigate(card.view)}
|
||||
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"
|
||||
aria-label={`Open ${card.title}`}
|
||||
>
|
||||
Open <ArrowRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</BentoCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface DieResult {
|
||||
@@ -56,11 +57,11 @@ function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[
|
||||
}
|
||||
|
||||
export function DiceRoller() {
|
||||
const [input, setInput] = useState("1d20");
|
||||
const [results, setResults] = useState<DieResult[]>([]);
|
||||
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
||||
const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []);
|
||||
const [lastTotal, setLastTotal] = useState<number | 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 roll = useCallback(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { addToLore } from "../lib/lore";
|
||||
import { useToast } from "./Toast";
|
||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
||||
import { bus, Events, type AddCombatantsPayload, type PlayAmbientPayload } from "../lib/bus";
|
||||
import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget";
|
||||
|
||||
interface Encounter {
|
||||
@@ -147,7 +147,6 @@ export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [terrain, setTerrain] = useState("Forest");
|
||||
const [encounter, setEncounter] = useState<Encounter | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { addToast } = useToast();
|
||||
|
||||
usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => {
|
||||
@@ -190,7 +189,6 @@ export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
|
||||
async function generate() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setEncounter(null);
|
||||
try {
|
||||
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)}`,
|
||||
);
|
||||
} else {
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`Encounter generation failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
||||
<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"}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs">
|
||||
{error}
|
||||
{!encounter && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||
<span>No encounter yet.</span>
|
||||
<span className="mt-1">Set the party + terrain, then Generate.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{encounter && (
|
||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -366,6 +379,13 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
>
|
||||
⚔ Send to Initiative Tracker
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Sparkles, ImageOff } from "lucide-react";
|
||||
import { ImageOff } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface Props {
|
||||
/** 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 [loading, setLoading] = useState(false);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setUnsupported(false);
|
||||
invoke<string>("generate_image", { req: { prompt } })
|
||||
.then((url) => {
|
||||
@@ -38,7 +39,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
||||
const msg = String(e);
|
||||
if (!cancelled) {
|
||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||
else setError(msg);
|
||||
else addToast(`Image failed: ${msg}`, "error");
|
||||
}
|
||||
})
|
||||
.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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return dataUrl ? (
|
||||
<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>
|
||||
) : null;
|
||||
}
|
||||
@@ -17,8 +17,8 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [model, setModel] = useState("");
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [variant, setVariant] = useState(0);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
|
||||
@@ -30,7 +30,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
async function generate() {
|
||||
if (!prompt.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setDataUrl(null);
|
||||
try {
|
||||
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||
@@ -40,12 +39,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
|
||||
});
|
||||
setDataUrl(url);
|
||||
setUnsupported(false);
|
||||
addToast("Image generated", "success");
|
||||
// ponytail: persist the PNG data URL so the DM can re-open past renders.
|
||||
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`Image generation failed: ${e}`, "error");
|
||||
const msg = String(e);
|
||||
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
|
||||
addToast(`Image generation failed: ${msg}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -56,8 +57,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
setTimeout(generate, 0);
|
||||
}
|
||||
|
||||
const unsupported = error.toLowerCase().includes("macos-only");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -115,24 +114,41 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !unsupported && (
|
||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataUrl && !loading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
||||
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<a
|
||||
href={dataUrl}
|
||||
download="dm-pal-image.png"
|
||||
className="text-center text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
||||
>
|
||||
⬇ Save PNG
|
||||
</a>
|
||||
<div className="flex justify-center gap-3">
|
||||
<a
|
||||
href={dataUrl}
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useToast } from "./Toast";
|
||||
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
|
||||
interface Combatant {
|
||||
id: string;
|
||||
@@ -10,6 +11,8 @@ interface Combatant {
|
||||
maxHp: number;
|
||||
conditions: string[];
|
||||
notes?: string;
|
||||
// ponytail: 5e death saves — 3 successes stabilize, 3 failures = dead.
|
||||
deathSaves?: { successes: number; failures: number };
|
||||
}
|
||||
|
||||
const CONDITIONS = [
|
||||
@@ -18,19 +21,33 @@ const CONDITIONS = [
|
||||
"Restrained", "Stunned", "Unconscious",
|
||||
];
|
||||
|
||||
const TURN_SECONDS = 60;
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
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 [initBonus, setInitBonus] = useState(0);
|
||||
const [hp, setHp] = useState(10);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [round, setRound] = useState(1);
|
||||
const [activeId, setActiveId] = usePersistentState<string | null>("initiative.activeId", null);
|
||||
const [round, setRound] = usePersistentState<number>("initiative.round", 1);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [hpInput, setHpInput] = useState<Record<string, string>>({});
|
||||
// (useToast is wired up in the bus handler below; silence unused-import.)
|
||||
useToast();
|
||||
// ponytail: per-combatant UI flags. Live in component state, not persisted.
|
||||
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.
|
||||
// We don't auto-switch view (the DM might be editing) but the toast tells
|
||||
@@ -153,8 +170,65 @@ export function InitiativeTracker() {
|
||||
} else {
|
||||
setActiveId(combatants[idx + 1].id);
|
||||
}
|
||||
setTimerLeft(TURN_SECONDS); // ponytail: reset the per-turn timer.
|
||||
}, [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(() => {
|
||||
setCombatants([]);
|
||||
setActiveId(null);
|
||||
@@ -179,7 +253,24 @@ export function InitiativeTracker() {
|
||||
<span className="text-[var(--color-gold-bright)] font-heading text-xs font-semibold">
|
||||
Round {round}
|
||||
</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
|
||||
onClick={nextTurn}
|
||||
disabled={combatants.length === 0}
|
||||
@@ -359,6 +450,45 @@ export function InitiativeTracker() {
|
||||
<span className="text-[9px] text-[var(--color-text-dim)] self-center">set</span>
|
||||
</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 */}
|
||||
{c.conditions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||
@@ -375,18 +505,78 @@ export function InitiativeTracker() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add condition — show ALL 13, not 5. */}
|
||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).map((cn) => (
|
||||
{/* Add condition — standard 13 + custom, plus a free-text input. */}
|
||||
{condEditing === c.id ? (
|
||||
<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
|
||||
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}`}
|
||||
onClick={() => setCondEditing(c.id)}
|
||||
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"
|
||||
title="Add a custom condition"
|
||||
>
|
||||
+{cn.slice(0, 3)}
|
||||
+…
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,6 @@ export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [item, setItem] = useState<ItemResponse | null>(null);
|
||||
const [rawResponse, setRawResponse] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [artNonce, setArtNonce] = useState(0);
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -50,7 +49,6 @@ export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
|
||||
async function generate() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setItem(null);
|
||||
setRawResponse(null);
|
||||
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)}`,
|
||||
);
|
||||
} else {
|
||||
setError("Could not parse LLM response as JSON. Raw response shown below.");
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`Item generation failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -114,6 +110,14 @@ The JSON must have exactly these keys:
|
||||
const mechanicalText = item ? flattenValue(item.mechanical) : "";
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -159,10 +163,19 @@ The JSON must have exactly these keys:
|
||||
>
|
||||
{loading ? "⚔ Forging…" : "⚔ Forge Item"}
|
||||
</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 && (
|
||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
||||
{error}
|
||||
{!item && !loading && !hasParseError && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||
<span>No item yet.</span>
|
||||
<span className="mt-1">Pick rarity + type, then Forge.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export function LorePanel() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<RagHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
@@ -59,6 +60,7 @@ export function LorePanel() {
|
||||
|
||||
async function clearAll() {
|
||||
await invoke("rag_clear", { source: null });
|
||||
setConfirmClear(false);
|
||||
loadSources();
|
||||
}
|
||||
|
||||
@@ -105,12 +107,30 @@ export function LorePanel() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
||||
{sources.length > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||
>
|
||||
clear all
|
||||
</button>
|
||||
confirmClear ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
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>
|
||||
{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">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">score {h.score.toFixed(3)}</span>
|
||||
{/* ponytail: relevance bar instead of a raw cosine score a
|
||||
DM can't read. Clamp 0–1, 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>
|
||||
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||
</li>
|
||||
|
||||
@@ -27,8 +27,10 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [name, setName] = useState("");
|
||||
const [npc, setNpc] = useState<NpcResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [portraitNonce, setPortraitNonce] = useState(0);
|
||||
// ponytail: in-place edit of personality/goals — add/remove without regenerating.
|
||||
const [newTrait, setNewTrait] = useState("");
|
||||
const [newGoal, setNewGoal] = useState("");
|
||||
const { addToast } = useToast();
|
||||
|
||||
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.
|
||||
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() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setNpc(null);
|
||||
try {
|
||||
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}`,
|
||||
);
|
||||
} else {
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`NPC generation failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -159,11 +177,6 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
</button>
|
||||
|
||||
{/* 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 && (
|
||||
<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>
|
||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ensureArray(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">
|
||||
{flattenValue(p)}
|
||||
{npc.personality.map((p, i) => (
|
||||
<span
|
||||
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>
|
||||
))}
|
||||
<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>
|
||||
<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">
|
||||
{ensureArray(npc.goals).map((g, i) => <li key={i}>{flattenValue(g)}</li>)}
|
||||
<ul className="text-[var(--color-text-primary)] text-xs mt-1 space-y-0.5">
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,9 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
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();
|
||||
|
||||
usePrefillEffect(prefill ?? null, "quest", () => onPrefillConsumed?.(), (g) => {
|
||||
@@ -45,7 +47,6 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
|
||||
async function generate() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setQuest(null);
|
||||
setCurrentStep(0);
|
||||
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}`,
|
||||
);
|
||||
} else {
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`Quest generation failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -125,14 +124,29 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
>
|
||||
{loading ? "✨ Designing…" : "✨ Design Quest"}
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
||||
{error}
|
||||
{!quest && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||
<span>No quest yet.</span>
|
||||
<span className="mt-1">Set the theme + scope, then Design Quest.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{quest && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Quest title & hook */}
|
||||
@@ -172,13 +186,16 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Step {currentStep + 1}: {quest.steps[currentStep].title}
|
||||
</h5>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="text-[var(--color-text-primary)] text-sm font-semibold">
|
||||
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">
|
||||
{quest.steps[currentStep].description}
|
||||
</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">
|
||||
<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>
|
||||
@@ -205,7 +222,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
|
||||
@@ -57,6 +57,128 @@ const BUILTIN_TABLES: RandomTable[] = [
|
||||
{ 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)",
|
||||
dice: "1d6",
|
||||
@@ -97,6 +219,16 @@ export function RandomTables() {
|
||||
|
||||
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 rollVal = rollDice(table.dice);
|
||||
const entry = table.entries.find((e) => rollVal >= e.min && rollVal <= e.max);
|
||||
@@ -123,13 +255,22 @@ export function RandomTables() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Roll button */}
|
||||
<button
|
||||
onClick={roll}
|
||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||
>
|
||||
🎲 Roll {table.dice}
|
||||
</button>
|
||||
{/* Roll + export */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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>
|
||||
<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 */}
|
||||
{customRoll !== null && (
|
||||
@@ -165,7 +306,16 @@ export function RandomTables() {
|
||||
{/* Roll history */}
|
||||
{log.length > 0 && (
|
||||
<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) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] py-0.5">
|
||||
<span className="text-[var(--color-text-dim)]">{l.tableName}</span>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { useToast } from "./Toast";
|
||||
import { addGeneration, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
|
||||
interface Props {
|
||||
prefill?: Generation | null;
|
||||
@@ -12,10 +13,13 @@ interface Props {
|
||||
|
||||
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [notes, setNotes] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [summary, setSummary] = usePersistentState<string>("session.summary", "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [entries, setEntries] = useState<{ text: string; time: string }[]>([]);
|
||||
// ponytail: streaming state — tokens arrive via the Channel and append to
|
||||
// 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();
|
||||
|
||||
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
|
||||
@@ -32,31 +36,65 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
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() {
|
||||
if (entries.length === 0) return;
|
||||
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 {
|
||||
const sessionText = entries
|
||||
.map((e) => `[${e.time}] ${e.text}`)
|
||||
.join("\n");
|
||||
const result = await invoke<string>("generate", {
|
||||
await invoke("generate_stream", {
|
||||
req: {
|
||||
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.",
|
||||
temperature: 0.7,
|
||||
max_tokens: 512,
|
||||
},
|
||||
channel,
|
||||
});
|
||||
setSummary(result);
|
||||
addToast("Session summary generated", "success");
|
||||
// ponytail: persist so the DM can re-open past summaries. The data is
|
||||
// raw text here, not JSON, so the extractTitle() fallback is fine.
|
||||
const result = streamed;
|
||||
const title = `Session ${new Date().toLocaleDateString()}`;
|
||||
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
|
||||
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`Summary failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -92,14 +130,29 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
>
|
||||
{loading ? "Summarizing…" : "✨ AI Summary"}
|
||||
</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 className="flex-1 overflow-y-auto">
|
||||
{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">
|
||||
{e.time}
|
||||
</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>
|
||||
@@ -108,13 +161,16 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
{/* AI Summary column */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<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">
|
||||
{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">
|
||||
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
||||
</span>
|
||||
|
||||
@@ -19,6 +19,8 @@ export function SettingsPanel() {
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: auto-load on mount so users don't see a gate before the form.
|
||||
@@ -47,6 +49,59 @@ export function SettingsPanel() {
|
||||
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) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
@@ -60,6 +115,28 @@ export function SettingsPanel() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||
API URL
|
||||
@@ -104,9 +181,51 @@ export function SettingsPanel() {
|
||||
value={config.model}
|
||||
onChange={(e) => setConfig({ ...config, model: e.target.value })}
|
||||
placeholder="llama3.2"
|
||||
list="model-list"
|
||||
/>
|
||||
<datalist id="model-list">
|
||||
{conn?.models.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</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">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||
Image model
|
||||
@@ -119,6 +238,7 @@ export function SettingsPanel() {
|
||||
/>
|
||||
</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">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||
Embedding model (Lore RAG)
|
||||
@@ -131,6 +251,7 @@ export function SettingsPanel() {
|
||||
/>
|
||||
</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="flex flex-col gap-1">
|
||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||
@@ -166,13 +287,21 @@ export function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useToast } from "./Toast";
|
||||
import { bus, Events, type PlayAmbientPayload } from "../lib/bus";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
|
||||
// Ambient sound types with their oscillator configs
|
||||
const AMBIENT_SOUNDS = [
|
||||
@@ -27,7 +29,8 @@ const SFX_SOUNDS = [
|
||||
|
||||
export function Soundboard() {
|
||||
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 nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
||||
const { addToast } = useToast();
|
||||
@@ -39,6 +42,45 @@ export function Soundboard() {
|
||||
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) => {
|
||||
setActiveAmbients((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -104,6 +146,7 @@ export function Soundboard() {
|
||||
}, [volume, getAudioCtx]);
|
||||
|
||||
const playSfx = useCallback((id: string) => {
|
||||
if (muted) return; // ponytail: master mute gates one-shot SFX too.
|
||||
const ctx = getAudioCtx();
|
||||
const sfx = SFX_SOUNDS.find((s) => s.id === id);
|
||||
if (!sfx) return;
|
||||
@@ -238,11 +281,11 @@ export function Soundboard() {
|
||||
}
|
||||
|
||||
addToast(`${sfx.icon} ${sfx.label}`, "info");
|
||||
}, [volume, getAudioCtx, addToast]);
|
||||
}, [volume, getAudioCtx, addToast, muted]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Volume */}
|
||||
{/* Volume + master mute */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium w-16">Volume</span>
|
||||
<input
|
||||
@@ -257,6 +300,18 @@ export function Soundboard() {
|
||||
<span className="text-[var(--color-text-dim)] text-xs font-mono w-8 text-right">
|
||||
{Math.round(volume * 100)}%
|
||||
</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>
|
||||
|
||||
{/* Ambient sounds */}
|
||||
@@ -304,24 +359,23 @@ export function Soundboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop all */}
|
||||
{activeAmbients.size > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
nodesRef.current.forEach((nodes) => {
|
||||
try {
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
|
||||
} catch {}
|
||||
});
|
||||
nodesRef.current.clear();
|
||||
setActiveAmbients(new Set());
|
||||
}}
|
||||
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"
|
||||
>
|
||||
⏹ Stop All
|
||||
</button>
|
||||
)}
|
||||
{/* Stop all — always visible so the DM never hunts for it. */}
|
||||
<button
|
||||
onClick={() => {
|
||||
nodesRef.current.forEach((nodes) => {
|
||||
try {
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
|
||||
} catch {}
|
||||
});
|
||||
nodesRef.current.clear();
|
||||
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 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
⏹ Stop All{activeAmbients.size > 0 ? ` (${activeAmbients.size})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
-10
@@ -6,25 +6,32 @@ interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
addToast: (message: string, type?: ToastType, opts?: { sticky?: boolean }) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export const useToast = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (message, type = "info") => {
|
||||
const id = String(nextId++);
|
||||
set((state) => ({ toasts: [...state.toasts, { id, message, type }] }));
|
||||
// Auto-dismiss after 4 seconds
|
||||
setTimeout(() => {
|
||||
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
||||
}, 4000);
|
||||
addToast: (message, type = "info", opts = {}) => {
|
||||
// ponytail: crypto.randomUUID for stable unique ids (the old module
|
||||
// counter was shared across imports and brittle in tests).
|
||||
const id = crypto.randomUUID();
|
||||
set((state) => {
|
||||
// ponytail: cap visible at 3 — older ones get pushed off so a noisy
|
||||
// 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) => {
|
||||
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { addToLore } from "../lib/lore";
|
||||
import { useToast } from "./Toast";
|
||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||
import { usePrefillEffect } from "../lib/usePrefill";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
|
||||
interface WorldResponse {
|
||||
name: string;
|
||||
@@ -21,12 +22,18 @@ interface Props {
|
||||
}
|
||||
|
||||
export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [theme, setTheme] = useState("high fantasy");
|
||||
const [world, setWorld] = useState<WorldResponse | null>(null);
|
||||
// ponytail: persist theme + last world so a reload keeps the DM's work.
|
||||
const [theme, setTheme] = usePersistentState<string>("world.theme", "high fantasy");
|
||||
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
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) => {
|
||||
const parsed = parseLlmJson<WorldResponse>(g.data);
|
||||
if (parsed) {
|
||||
@@ -37,7 +44,6 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
|
||||
async function generate() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setWorld(null);
|
||||
try {
|
||||
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}`,
|
||||
);
|
||||
} else {
|
||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
||||
addToast("LLM returned an unparseable response", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
addToast(`World generation failed: ${e}`, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
@@ -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">
|
||||
World Theme
|
||||
</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
|
||||
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}
|
||||
@@ -107,11 +127,6 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
</button>
|
||||
</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 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
+50
-9
@@ -1,7 +1,26 @@
|
||||
/* ponytail: Google Fonts CSS2 import — single request, all families/weights.
|
||||
TODO: download woff2 to public/fonts/ and switch to @font-face for true
|
||||
offline-first. Until then, this needs network on first run. */
|
||||
@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");
|
||||
/* ponytail: fonts self-hosted as variable woff2 (latin subset) in public/fonts/
|
||||
for true offline-first — a desktop app must not depend on Google's CDN. */
|
||||
@font-face {
|
||||
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";
|
||||
|
||||
@@ -39,11 +58,6 @@
|
||||
--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 ───────────────────────────────────────────── */
|
||||
|
||||
* {
|
||||
@@ -186,6 +200,33 @@ body,
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ponytail: shimmer skeleton for image/art loading, and a thinking-dot
|
||||
cycle for LLM waits. Replaces "Generating…" button text for 5–20s 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
|
||||
via its useReducedMotion hook, but the inline keyframes/glass-card transitions
|
||||
still need this CSS guard. */
|
||||
|
||||
@@ -43,6 +43,7 @@ export const bus = new EventBus();
|
||||
|
||||
export const Events = {
|
||||
AddCombatants: "add-combatants",
|
||||
PlayAmbient: "play-ambient",
|
||||
} as const;
|
||||
|
||||
export type AddCombatantsPayload = {
|
||||
@@ -50,3 +51,9 @@ export type AddCombatantsPayload = {
|
||||
/** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PlayAmbientPayload = {
|
||||
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */
|
||||
id: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user