diff --git a/README.md b/README.md
index d6af7e3..e67f982 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,129 @@
-# React + TypeScript + Vite
+# DM-Pal
-This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
+An offline-first desktop toolkit for Dungeon Masters, powered by a local LLM.
+Built with **Tauri v2** + **Rust** on the back end and **React 19** + **TypeScript**
+on the front. Every generator is grounded in your own world bible via a local
+RAG index — no data leaves your machine unless you point it at a remote API.
-Currently, two official plugins are available:
+
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+## What's in the box
-## React Compiler
+DM-Pal packs every tool a DM reaches for at and between the table, grouped
+into **Session** (live) and **World** (prep) tools:
-The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+- **Initiative Tracker** — combatants, HP, conditions, death saves, turn timer
+- **Dice Roller** — notation parsing, advantage/disadvantage, roll templates
+- **Encounter Builder** — AI-generated encounters with a 5e XP budget
+- **NPC Generator** — portraits, personality, goals, stat blocks
+- **Quest Designer** — multi-step quests with twists and reward breakdown
+- **Item Forge** — magic items with art and structured mechanics
+- **Image Generator** — portraits, maps, scene art (macOS, via Ollama)
+- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary
+- **Soundboard** — synthesized ambience/SFX with one-click scenes
+- **World Builder** — generated regions, landmarks, a draggable-pin map
+- **Lore (RAG)** — index your world bible, ground every generation in it
+- **Calendar** — custom fantasy calendars, weather, moon phases, events
+- **Random Tables** — built-in and custom tables, weighted rolls
-## Expanding the Oxlint configuration
+A **⌘K command palette**, **History view** (re-open any past generation), and
+persistent state round it out — reload loses nothing.
-If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
+## Quick start
-```json
-{
- "$schema": "./node_modules/oxlint/configuration_schema.json",
- "plugins": ["react", "typescript", "oxc"],
- "options": {
- "typeAware": true
- },
- "rules": {
- "react/rules-of-hooks": "error",
- "react/only-export-components": ["warn", { "allowConstantExport": true }]
- }
-}
+### Prerequisites
+
+- **Rust** + **Cargo** — https://rustup.rs
+- **Node.js** 20+ — https://nodejs.org
+- **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`)
+
+### Install & run
+
+```bash
+npm install
+npm run tauri dev
```
-See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+On first launch open **Settings (⌘,)** and confirm the API URL, then pull a
+text model (e.g. `llama3.2`) and an embedding model (e.g. `nomic-embed-text`):
+
+```bash
+ollama pull llama3.2
+ollama pull nomic-embed-text
+```
+
+Optionally, for image generation (macOS / Apple Silicon only):
+
+```bash
+ollama pull x/flux2-klein:4b
+```
+
+## Where your data lives
+
+All campaign data stays on disk under your OS app-data dir (default
+`$APPDATA/dm-toolkit/`, configurable in Settings):
+
+| File | Contents |
+|------|----------|
+| `lore.db` | RAG chunks + embeddings (SQLite) |
+| `generations.db` | History of every generated NPC/encounter/item/quest/… |
+| `images/` | Cached generated PNGs, keyed by prompt hash |
+| `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) |
+
+Settings → **Data location** lets you relocate everything to an external drive
+and migrates existing data for you.
+
+## Architecture
+
+```
+React 19 + TypeScript ──invoke()/Channel──▶ Rust (Tauri v2)
+ Zustand · framer-motion LLM (Ollama / OpenAI-compatible)
+ react-konva (world map) SQLite (rusqlite, bundled)
+ Tailwind v4 + glassmorphism RAG (brute-force cosine → sqlite-vec path)
+```
+
+Each tool is a self-contained component mounted behind a single `renderView`
+switch; the dashboard is a launcher of at-a-glance tiles. The design system is
+dark-only (navy + Cinzel + gold), self-hosts its fonts for true offline use,
+and meets WCAG AA contrast.
+
+## Scripts
+
+```bash
+npm run dev # Vite dev server (frontend only)
+npm run tauri dev # Full app, hot-reload
+npm run build # tsc -b && vite build
+npm run lint # oxlint
+node scripts/check-worldmap.ts # world-map layout self-check
+node scripts/check-encounter-budget.ts # XP-budget self-check
+node scripts/check-dice.ts # dice-notation parser self-check
+```
+
+## Project layout
+
+```
+src/ React front end
+ components/ one file per tool
+ lib/ pure logic + persistence hooks
+src-tauri/src/ Rust back end
+ commands/ Tauri IPC commands (llm, image, rag, data, generation)
+ llm/ Ollama/OpenAI client + config
+ rag/ embedding + cosine search
+ generations/ history store
+docs/ plan + UI/UX review
+```
+
+## Roadmap
+
+See [`docs/plan.md`](docs/plan.md) for the full plan and `docs/ui-ux-improvements.md`
+for the living UI/UX review with a prioritized checklist.
+
+Planned / in progress: first-run model wizard, quest branching graph, world
+hierarchy tree, real ambience packs, GitHub Actions CI, code signing, and an
+auto-updater.
+
+## License
+
+TBD. Built-in rule references use only 5e SRD / OGL / CC-BY content. Model files
+ship under their own licenses (e.g. Meta's Llama license) — accept them in
+Ollama before pulling.
\ No newline at end of file
diff --git a/docs/plan.md b/docs/plan.md
index a2b5404..84475a9 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -911,6 +911,155 @@ A consolidated checklist covering all phases:
---
+## 17. Post-Review Action Items (Fresh Pass)
+
+*Findings from a fresh codebase review (shell, components, backend, git
+history). Ordered by impact within each tier. Each item ships as a concrete,
+checkable change — no redesigns.*
+
+### 17.1 Repo hygiene (do first — cheap)
+
+- [x] **Rewrite `README.md`.** It is still the Vite template boilerplate
+ ("React + TypeScript + Vite") and describes nothing about DM-Pal.
+ Replace with: one-paragraph pitch, screenshot, `npm run tauri dev`
+ quickstart, prerequisites (Ollama), where data lives, license note.
+ Highest-ROI 20-minute task in the repo.
+- [x] **Scrub PII from `scripts/apple-signing.env.example`.** It contains a
+ real Apple ID email (`james.twose2711@gmail.com`) in a tracked file.
+ Replace with `you@example.com`.
+- [x] **Delete dead scaffold: `src/components/Greet.tsx` + the `greet`
+ Tauri command in `src-tauri/src/lib.rs`.** Leftover from
+ `npm create tauri-app`; nothing in the shell references Greet.
+ Verify with `rg Greet` first.
+- [x] **Fix pointless ternary in `App.tsx` NavButton:**
+ `` — both
+ branches are 18. Just `size={18}`.
+- [x] **Collapse the three nav→kind maps in `App.tsx`** (`PREFILLABLE`,
+ `PREFILLABLE_TOOLS`, `viewMaxWidth`) into a single
+ `Record` and derive the inverse
+ map for `rehydrate`. Today adding a tool means editing five places;
+ this makes it one.
+
+### 17.2 Tests & CI (Milestone 7, currently unshipped)
+
+- [x] **`src/lib/encounter-budget.ts` self-check.** Non-trivial combat
+ math with no test. Add an `assert`-based `demo()` / one small
+ `test_encounter_budget.ts` so a tweaked XP number fails loudly.
+- [x] **Dice-parser test.** `DiceRoller` parses `NdX±M`, advantage
+ (`kh1`/`kl1`), templates — a parser bug = wrong rolls at the table.
+ One `test_dice.ts` asserting `{count, sides, mod, keep}` for
+ `4d6+3`, `2d20kh1`, `1d20-2` is the smallest thing that catches
+ regressions. (Rust side has good `generations` + image-base64
+ tests; frontend has almost none — only `worldMap.ts`.)
+- [ ] **Gitea Actions CI** (not GitHub — repo ships via `scripts/gitea-release.sh`).
+ A `.gitea/workflows/ci.yml` running `oxlint`, `tsc -b`, the three
+ `node scripts/check-*.ts` self-checks, and `cargo test` on each push.
+ Even lint-only is better than none. Same YAML syntax as GitHub
+ Actions, just a different directory.
+- [ ] **Code signing + notarization** for macOS and Windows.
+- [ ] **Auto-updater** via `tauri-plugin-updater` (+ delta updates for
+ model packs), then package with `npm run tauri build`.
+
+### 17.3 Features still missing (verified unchecked in §16)
+
+- [ ] **Quest branching graph (`reactflow`).** Quests are a linear
+ carousel today. Branching quests with conditional edges ("if they
+ spare the bandit → step 3; if they kill him → step 5") are what
+ make a quest *designer* vs a quest *outliner*. The single biggest
+ remaining "wow" gap in the generators.
+- [ ] **World hierarchy tree** (continent → country → region → city).
+ Today `WorldBuilder` emits a flat `regions[]` + `landmarks[]`
+ pinned on a map, with no nesting or "drill into a region to
+ generate its sub-regions." A collapsible tree on the left of the
+ existing two-pane layout turns World Builder from a one-shot
+ generator into a living campaign bible.
+- [x] **Lore directory picker.** Doc said "needs tauri-plugin-dialog" —
+ but the plugin is **already installed and used** in
+ `SettingsPanel`. This is unblocked: add an "Add directory…" button
+ in `LorePanel` that calls `open({ directory: true })` and walks
+ `*.md`/`*.txt`. Low effort, high value for DMs with an existing
+ world-bible folder.
+- [ ] **Real ambience packs + custom sound import (Soundboard).** Ship
+ 2–3 CC0 loops (Freesound/Pixabay) in `public/sounds/`, keep
+ synthesis as fallback, and add drag-an-MP3 onto a tile. The
+ difference between "demo" and "session-ready."
+- [ ] **First-run model download / license wizard.** Today a new user
+ must hand-configure API URL + model name with no guidance. A
+ one-time wizard on first launch (detect Ollama → list
+ `GET /api/tags` → pick text + image + embed model → save) collapses
+ the "open Settings, stare at empty form, give up" funnel. Reuse
+ the existing connection-test + model-list plumbing from
+ `SettingsPanel`.
+- [ ] **Image-gen into World Builder (map) + Encounter Builder**
+ (battle map + loot), per §16 item 10 — still only wired into NPC +
+ Item Forge.
+- [ ] **Handout renderer** (Markdown → PDF), still pending from §15.
+
+### 17.4 UX refinements (beyond the existing ui-ux-improvements.md)
+
+- [ ] **Campaign concept.** Everything is one global store
+ (`dm-pal-state.json`) + two global SQLite DBs; a DM running two
+ campaigns can't separate them. A lightweight "active campaign"
+ selector in the title bar that namespaces store keys + DB files
+ (`//`) unlocks multi-campaign without a schema
+ migration. The data-dir relocation machinery already exists —
+ generalize it per-campaign.
+- [x] **`?` shortcut help overlay.** Checklist marks `?` as shipped but
+ there's no visible cheatsheet — shortcuts are documented only
+ here. A small `?`-triggered modal listing
+ `⌘K / 1–9 / Space / ⌘, / ⌘S / Esc` is the difference between
+ "shortcuts exist" and "shortcuts are discoverable." ~40 lines.
+- [x] **Global streaming indicator.** `ConnectionPill` shows
+ LLM/image/lore status but there's no "an LLM call is in flight"
+ signal. During a 15s image gen the only feedback is a skeleton in
+ one card. A subtle gold pulse on the pill (or a thin top-of-window
+ progress bar) tells the DM something is working even after they've
+ navigated away from the generating tool.
+- [x] **Consistent image-gen macOS gating.** `ImageGenerator` shows a
+ clear "macOS only" message, but the `GeneratedImage` ✨ buttons
+ inside NPC/Item silently fall back. Show the same inline
+ "macOS only" notice there so a Windows DM isn't left wondering why
+ nothing happens.
+- [ ] **Finish the empty-state set for Initiative** (and verify the
+ others) — one `
` + CTA each, already done elsewhere.
+- [ ] **i18n** — extract strings to a `t()` helper (P3, only if shipping
+ beyond EN).
+- [ ] **Draggable bento layout** (`react-grid-layout`) with persisted
+ layout per campaign (P3).
+- [ ] **Console mode** for the dashboard — embed 2–3 chosen tools as
+ live-session cards (P3).
+- [ ] **Player view** — a web app showing the DM's screen (initiative,
+ dice) to phones on the local network (P3).
+- [ ] **Compendium integration** — pull monster stat blocks from a
+ local SRD JSON (P3).
+- [ ] **Voice-to-text** for session notes (Whisper) (P3).
+- [ ] **Macros** — named dice-roll buttons (P3).
+- [ ] **Session replay** — record rolls/initiative/soundboard state
+ and replay (P3).
+
+### 17.5 Suggested order of attack
+
+| # | Item | Effort | Impact |
+|---|------|--------|--------|
+| 1 | Rewrite README | 20 min | High |
+| 2 | Delete Greet + `greet` cmd, fix `18:18` ternary | 10 min | Low |
+| 3 | Collapse the 3 nav→kind maps into one | 30 min | Medium |
+| 4 | `encounter-budget` + dice-parser self-tests | 1 hr | Medium |
+| 5 | Lore directory picker (dialog already installed) | 1 hr | High |
+| 6 | First-run model wizard (reuse connection test) | 3 hrs | High |
+| 7 | Quest branching graph (`reactflow`) | 4 hrs | High |
+| 8 | World hierarchy tree | 3 hrs | High |
+| 9 | `?` help overlay + global streaming indicator | 1 hr | Medium |
+| 10 | Real ambience packs + sound import | 2 hrs | Medium |
+| 11 | GitHub Actions CI | 1 hr | Medium |
+| 12 | Campaign namespace selector | 4 hrs | High |
+
+Items 1–5 form a single low-risk PR: README, dead-code cleanup, map
+consolidation, two self-tests, lore directory picker — all independently
+shippable in one session.
+
+---
+
**You're now ready to spin up your own AI-powered Dungeon Master toolkit!**
Happy crafting — may your dice always land favorably! 🎲
\ No newline at end of file
diff --git a/docs/ui-ux-improvements.md b/docs/ui-ux-improvements.md
index 833410a..95df220 100644
--- a/docs/ui-ux-improvements.md
+++ b/docs/ui-ux-improvements.md
@@ -7,12 +7,12 @@ recommendation.*
> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative,
> Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger,
> Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/
-> reward fields shipped; remaining P2 items are larger-effort or backend-
-> gated: image-gen advanced params (Ollama API doesn't support them),
-> soundboard scenes + real ambience packs, world hierarchy tree, lore
-> directory picker (needs tauri-plugin-dialog), NPC roster / item inventory /
-> quest roster (History view covers re-opening). The checklist below is
-> updated as items land.
+> reward fields shipped; soundboard scenes + image gallery/batch shipped;
+> remaining P2 items are larger-effort or backend-gated: image-gen advanced
+> params (Ollama API doesn't support them), soundboard real ambience packs +
+> custom sound import, world hierarchy tree, lore directory picker (needs
+> tauri-plugin-dialog), NPC roster / item inventory / quest roster (History
+> view covers re-opening). The checklist below is updated as items land.
---
@@ -1095,8 +1095,8 @@ These are blockers or bugs that make the app feel broken.
- [ ] Quest roster (History view covers re-opening past quests)
- [ ] **Image Generator**:
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
- - [ ] Batch mode (2×2 variants)
- - [ ] Gallery view with thumbnails
+ - [x] Batch mode (2×2 variants)
+ - [x] Gallery view with thumbnails
- [x] Persist to campaign-scoped folder
- [x] Copy to clipboard
- [x] **Session Logger**:
@@ -1117,7 +1117,7 @@ These are blockers or bugs that make the app feel broken.
- [ ] **Soundboard**:
- [ ] Real ambience pack (2–3 royalty-free loops)
- [ ] Custom sound import (drag-drop)
- - [ ] Scenes (one-click combinations)
+ - [x] Scenes (one-click combinations)
- [x] Master mute + per-sound volume
- [x] Always-visible Stop All
- [x] Save/load board state
diff --git a/scripts/check-dice.ts b/scripts/check-dice.ts
new file mode 100644
index 0000000..3b93133
--- /dev/null
+++ b/scripts/check-dice.ts
@@ -0,0 +1,5 @@
+// ponytail: runnable self-check for the dice-notation parser. Not part of
+// the app build (scripts/ is outside tsconfig.app's include). Run with:
+// node scripts/check-dice.ts
+import { demo } from "../src/lib/dice.ts";
+demo();
\ No newline at end of file
diff --git a/scripts/check-encounter-budget.ts b/scripts/check-encounter-budget.ts
new file mode 100644
index 0000000..32d6350
--- /dev/null
+++ b/scripts/check-encounter-budget.ts
@@ -0,0 +1,5 @@
+// ponytail: runnable self-check for the encounter XP-budget math. Not part
+// of the app build (scripts/ is outside tsconfig.app's include). Run with:
+// node scripts/check-encounter-budget.ts
+import { demo } from "../src/lib/encounter-budget.ts";
+demo();
\ No newline at end of file
diff --git a/src-tauri/src/commands/image_commands.rs b/src-tauri/src/commands/image_commands.rs
index 7498adb..bad9324 100644
--- a/src-tauri/src/commands/image_commands.rs
+++ b/src-tauri/src/commands/image_commands.rs
@@ -1,9 +1,11 @@
+use crate::commands::emit_busy;
use crate::llm::AppState;
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use tauri::ipc::Channel;
+use tauri::AppHandle;
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
@@ -74,8 +76,18 @@ pub enum ImageEvent {
#[tauri::command]
pub async fn generate_image(
state: tauri::State<'_, AppState>,
+ app: AppHandle,
req: ImageRequest,
) -> Result {
+ // ponytail: emit a global busy signal so the shell shows a working
+ // indicator even if the DM navigates away. Paired with the false below.
+ emit_busy(&app, true);
+ let result = generate_image_inner(state, req).await;
+ emit_busy(&app, false);
+ result
+}
+
+async fn generate_image_inner(state: tauri::State<'_, AppState>, req: ImageRequest) -> Result {
if cfg!(not(target_os = "macos")) {
return Err("image generation is macOS-only via Ollama (for now)".into());
}
@@ -100,6 +112,7 @@ pub async fn generate_image(
#[tauri::command]
pub async fn generate_image_stream(
state: tauri::State<'_, AppState>,
+ app: AppHandle,
req: ImageRequest,
channel: Channel,
) -> Result<(), String> {
@@ -124,6 +137,9 @@ pub async fn generate_image_stream(
// and progress flows through the channel. Errors become ImageEvent::Error.
let channel = std::sync::Arc::new(channel);
let ch = channel.clone();
+ // ponytail: only emit busy around the actual generation (not cache hits),
+ // and always balance it in the spawn — even on error.
+ emit_busy(&app, true);
tauri::async_runtime::spawn(async move {
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await {
Ok(png_bytes) => {
@@ -134,6 +150,7 @@ pub async fn generate_image_stream(
let _ = channel.send(ImageEvent::Error(e));
}
}
+ emit_busy(&app, false);
});
Ok(())
diff --git a/src-tauri/src/commands/llm_commands.rs b/src-tauri/src/commands/llm_commands.rs
index 19376f4..d84ecb0 100644
--- a/src-tauri/src/commands/llm_commands.rs
+++ b/src-tauri/src/commands/llm_commands.rs
@@ -1,11 +1,27 @@
+use crate::commands::emit_busy;
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
use serde_json::json;
use tauri::ipc::Channel;
+use tauri::AppHandle;
// ─── Simple (non-streaming) generate ─────────────────────────
#[tauri::command]
-pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result {
+pub async fn generate(
+ state: tauri::State<'_, AppState>,
+ app: AppHandle,
+ req: GenerateRequest,
+) -> Result {
+ // ponytail: emit a global busy signal so the shell can show a working
+ // indicator even after the DM navigates away. Paired with the false
+ // emit below — every return path balances the counter.
+ emit_busy(&app, true);
+ let result = generate_inner(state, req).await;
+ emit_busy(&app, false);
+ result
+}
+
+async fn generate_inner(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result {
let config = {
let guard = state.config.lock().map_err(|e| e.to_string())?;
guard.clone()
@@ -32,6 +48,7 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
#[tauri::command]
pub async fn generate_stream(
state: tauri::State<'_, AppState>,
+ app: AppHandle,
req: GenerateRequest,
channel: Channel,
) -> Result<(), String> {
@@ -43,6 +60,7 @@ pub async fn generate_stream(
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
+ emit_busy(&app, true);
tauri::async_runtime::spawn(async move {
// For now, we do a non-streaming call and emit the full response as one token
// Real SSE streaming from Ollama/OpenAI can be added later
@@ -61,6 +79,8 @@ pub async fn generate_stream(
let _ = channel.send(LlmEvent::Error(e));
}
}
+ // ponytail: always balance the busy counter, even on error.
+ emit_busy(&app, false);
});
Ok(())
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 945cc72..4f90f1b 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -2,4 +2,21 @@ pub mod llm_commands;
pub mod image_commands;
pub mod rag_commands;
pub mod generation_commands;
-pub mod data_commands;
\ No newline at end of file
+pub mod data_commands;
+
+use serde::Serialize;
+use tauri::{AppHandle, Emitter};
+
+// ponytail: a global "generation in flight" signal. Every generate command
+// emits busy:true at start and busy:false at end so the shell can show a
+// working indicator even after the DM navigates away from the generating
+// tool. The frontend maintains a counter (true = +1, false = -1) so
+// concurrent generations balance out.
+#[derive(Serialize, Clone)]
+pub struct GenBusy {
+ pub busy: bool,
+}
+
+pub fn emit_busy(app: &AppHandle, busy: bool) {
+ let _ = app.emit("gen-busy", GenBusy { busy });
+}
\ No newline at end of file
diff --git a/src-tauri/src/commands/rag_commands.rs b/src-tauri/src/commands/rag_commands.rs
index 1f6e149..89bd6e3 100644
--- a/src-tauri/src/commands/rag_commands.rs
+++ b/src-tauri/src/commands/rag_commands.rs
@@ -74,4 +74,94 @@ pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result,
+}
+
+/// Walk a directory (recursive, skipping hidden entries) and index every
+/// `.md`/`.markdown`/`.txt` file as its own lore source, named by its path
+/// relative to the picked root. Done in Rust rather than the webview `fs`
+/// plugin so a DM can pick an external world-bible folder without needing an
+/// fs scope permission for that path. ponytail: one embed batch per file is
+/// fine here — directory import is a one-off prep action, not a hot path.
+#[tauri::command]
+pub async fn rag_add_directory(
+ state: tauri::State<'_, AppState>,
+ req: RagAddDirRequest,
+) -> Result {
+ let config = state.config.lock().map_err(|e| e.to_string())?.clone();
+ let root = std::path::PathBuf::from(&req.path);
+ if !root.is_dir() {
+ return Err(format!("not a directory: {}", req.path));
+ }
+ let mut files = 0usize;
+ let mut chunks = 0usize;
+ let mut skipped: Vec = Vec::new();
+ let mut stack = vec![root.clone()];
+ while let Some(dir) = stack.pop() {
+ let entries = match std::fs::read_dir(&dir) {
+ Ok(e) => e,
+ Err(e) => {
+ skipped.push(format!("{}: {e}", dir.display()));
+ continue;
+ }
+ };
+ for entry in entries.flatten() {
+ let p = entry.path();
+ if entry.file_name().to_string_lossy().starts_with('.') {
+ continue;
+ }
+ if p.is_dir() {
+ stack.push(p);
+ continue;
+ }
+ let is_text = matches!(
+ p.extension()
+ .and_then(|e| e.to_str())
+ .map(|s| s.to_ascii_lowercase())
+ .as_deref(),
+ Some("md") | Some("markdown") | Some("txt")
+ );
+ if !is_text {
+ continue;
+ }
+ let text = match std::fs::read_to_string(&p) {
+ Ok(t) => t,
+ Err(e) => {
+ skipped.push(format!("{}: {e}", p.display()));
+ continue;
+ }
+ };
+ // ponytail: source = path relative to the picked root, extension
+ // stripped, so two files with the same name in different folders
+ // don't silently merge into one lore source.
+ let rel = p.strip_prefix(&root).unwrap_or(&p);
+ let source = rel.with_extension("").to_string_lossy().to_string();
+ let source = if source.is_empty() {
+ p.file_stem()
+ .map(|s| s.to_string_lossy().to_string())
+ .unwrap_or_default()
+ } else {
+ source
+ };
+ match state.rag.add_document(&config, &source, &text).await {
+ Ok(n) => {
+ files += 1;
+ chunks += n;
+ }
+ Err(e) => skipped.push(format!("{}: {e}", p.display())),
+ }
+ }
+ }
+ Ok(RagAddDirReport { files, chunks, skipped })
}
\ No newline at end of file
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index f5c1c9e..ca0a181 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -56,7 +56,6 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
- greet,
commands::llm_commands::generate,
commands::llm_commands::generate_stream,
commands::llm_commands::get_llm_config,
@@ -69,6 +68,7 @@ pub fn run() {
commands::rag_commands::rag_list,
commands::rag_commands::rag_clear,
commands::rag_commands::rag_chunks,
+ commands::rag_commands::rag_add_directory,
commands::generation_commands::generation_add,
commands::generation_commands::generation_list,
commands::generation_commands::generation_get,
@@ -83,7 +83,6 @@ pub fn run() {
.expect("error while running tauri application");
}
-#[tauri::command]
-fn greet(name: &str) -> String {
- format!("Hello, {}! Welcome to DM-Pal ⚔", name)
-}
\ No newline at end of file
+// ponytail: removed the scaffold `greet` command + its frontend component —
+// nothing in the shell referenced it. Kept here as a marker so the
+// invoke_handler list above stays in sync.
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 749ef63..2e96b07 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -33,8 +33,10 @@ import { LorePanel } from "./components/LorePanel";
import { ImageGenerator } from "./components/ImageGenerator";
import { ToastContainer } from "./components/Toast";
import { CommandPalette } from "./components/CommandPalette";
+import { ShortcutHelp } from "./components/ShortcutHelp";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { ConnectionPill } from "./components/ConnectionPill";
+import { GeneratingIndicator } from "./components/GeneratingIndicator";
import { HistoryView } from "./components/HistoryView";
import type { Generation, GenerationKind } from "./lib/generations";
@@ -56,17 +58,47 @@ export type View =
| "settings"
| "history";
-// ponytail: tools that accept a prefill from history. Mirrors the kinds.
-const PREFILLABLE: Partial> = {
- npcs: "npc",
- encounter: "encounter",
- world: "world",
- items: "item",
- quest: "quest",
- session: "session",
- image: "image",
+// ponytail: ONE source of truth per tool — kind (for history prefill),
+// the prefill-aware component, and the preferred max-width. Adding a tool
+// is a single entry here; the KIND_TO_VIEW inverse and renderView derive
+// from it. Replaces the old PREFILLABLE / viewMaxWidth / PREFILLABLE_TOOLS
+// triple that drifted whenever a tool was added.
+type PrefillComponent = React.ComponentType<{
+ prefill?: Generation | null;
+ onPrefillConsumed?: () => void;
+}>;
+
+interface ToolMeta {
+ kind?: GenerationKind;
+ Comp?: PrefillComponent;
+ maxWidth?: string;
+}
+
+const TOOL_META: Partial> = {
+ npcs: { kind: "npc", Comp: NpcGenerator, maxWidth: "max-w-4xl" },
+ encounter: { kind: "encounter", Comp: EncounterBuilder, maxWidth: "max-w-4xl" },
+ quest: { kind: "quest", Comp: QuestDesigner, maxWidth: "max-w-4xl" },
+ items: { kind: "item", Comp: ItemForge, maxWidth: "max-w-4xl" },
+ world: { kind: "world", Comp: WorldBuilder, maxWidth: "max-w-6xl" },
+ session: { kind: "session", Comp: SessionLogger, maxWidth: "max-w-6xl" },
+ image: { kind: "image", Comp: ImageGenerator, maxWidth: "max-w-6xl" },
+ lore: { maxWidth: "max-w-6xl" },
+ sound: { maxWidth: "max-w-6xl" },
+ dice: { maxWidth: "max-w-2xl" },
+ settings: { maxWidth: "max-w-2xl" },
+ initiative: { maxWidth: "max-w-2xl" },
+ tables: { maxWidth: "max-w-2xl" },
+ calendar: { maxWidth: "max-w-2xl" },
};
+// ponytail: kind → view inverse, derived once so rehydrate() is a lookup.
+const KIND_TO_VIEW: Partial> = {};
+for (const [v, m] of Object.entries(TOOL_META)) {
+ if (m.kind) KIND_TO_VIEW[m.kind] = v as View;
+}
+
+const DEFAULT_MAX_WIDTH = "max-w-2xl";
+
type NavGroup = "session" | "world";
interface NavItem {
@@ -100,19 +132,16 @@ function renderView(
onPrefillConsumed: () => void,
rehydrate: (kind: GenerationKind, data: Generation) => void,
): React.ReactNode {
- // ponytail: history view renders its own list/detail panes, so it doesn't
- // use the prefill or wrapper pattern.
+ // ponytail: history renders its own list/detail panes — no prefill wrapper.
if (view === "history") {
return ;
}
- // Tools that accept prefill.
- if (view === "npcs") return ;
- if (view === "encounter") return ;
- if (view === "world") return ;
- if (view === "items") return ;
- if (view === "quest") return ;
- if (view === "session") return ;
- if (view === "image") return ;
+ // ponytail: prefill-aware tools come straight from TOOL_META — one entry
+ // per tool instead of a parallel if-chain that drifted with the maps.
+ const meta = TOOL_META[view];
+ if (meta?.Comp) {
+ return ;
+ }
switch (view) {
case "dice":
return ;
@@ -133,39 +162,11 @@ function renderView(
}
}
-// ponytail: per-tool preferred max-width. Wider tools stop fighting the rail.
-const viewMaxWidth: Partial> = {
- dice: "max-w-2xl",
- settings: "max-w-2xl",
- npcs: "max-w-4xl",
- encounter: "max-w-4xl",
- quest: "max-w-4xl",
- items: "max-w-4xl",
- world: "max-w-6xl",
- lore: "max-w-6xl",
- image: "max-w-6xl",
- sound: "max-w-6xl",
- session: "max-w-6xl",
- initiative: "max-w-2xl",
- tables: "max-w-2xl",
- calendar: "max-w-2xl",
-};
-
-// ponytail: tools that accept a prefill. The map mirrors `PREFILLABLE` above
-// and exists so rehydrate() can find the right View for a given kind.
-const PREFILLABLE_TOOLS = {
- npcs: NpcGenerator,
- encounter: EncounterBuilder,
- world: WorldBuilder,
- items: ItemForge,
- quest: QuestDesigner,
- session: SessionLogger,
- image: ImageGenerator,
-} as const;
export default function App() {
const [view, setView] = useState("dashboard");
const [paletteOpen, setPaletteOpen] = useState(false);
+ const [helpOpen, setHelpOpen] = useState(false);
// ponytail: prefill is set when the user clicks "Open in tool" in History.
// The targeted tool reads it via usePrefillEffect and calls onConsumed
// to clear it. State lives here so the rehydrate→switch→consume dance
@@ -173,9 +174,7 @@ export default function App() {
const [prefill, setPrefill] = useState(null);
function rehydrate(kind: GenerationKind, data: Generation) {
- const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find(
- (v) => PREFILLABLE[v] === kind,
- );
+ const target = KIND_TO_VIEW[kind];
if (!target) return;
setPrefill(data);
setView(target);
@@ -198,6 +197,21 @@ export default function App() {
setPaletteOpen(false);
return;
}
+ if (e.key === "Escape" && helpOpen) {
+ setHelpOpen(false);
+ return;
+ }
+ // ponytail: `?` opens the shortcut cheatsheet — the rail is icon-only,
+ // so without this the digit shortcuts are undiscoverable. Not while
+ // typing in an input (Shift+/ on a US layout yields `?`).
+ if (
+ e.key === "?" &&
+ !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
+ ) {
+ e.preventDefault();
+ setHelpOpen((o) => !o);
+ return;
+ }
// Digit shortcuts jump to nav items, but not while typing in an input.
if (
!paletteOpen &&
@@ -221,7 +235,7 @@ export default function App() {
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [paletteOpen]);
+ }, [paletteOpen, helpOpen]);
const isOnDashboard = view === "dashboard";
const groupedNav = useMemo(
@@ -245,7 +259,7 @@ export default function App() {
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
}`}
>
-
+
);
}
@@ -356,6 +370,7 @@ export default function App() {
+
@@ -367,7 +382,7 @@ export default function App() {
) : view === "history" ? (
rehydrate(k, d)} />
) : (
-
diff --git a/src/components/DiceRoller.tsx b/src/components/DiceRoller.tsx
index 9a9d096..6370dbb 100644
--- a/src/components/DiceRoller.tsx
+++ b/src/components/DiceRoller.tsx
@@ -1,15 +1,7 @@
import { useEffect, useState, useCallback } from "react";
import { usePersistentState } from "../lib/usePersistentState";
import { useToast } from "./Toast";
-
-interface DieResult {
- notation: string;
- rolls: number[];
- total: number;
- modifier: number;
- advantage?: "adv" | "dis" | null;
- keptRoll?: number;
-}
+import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
@@ -23,38 +15,13 @@ const TEMPLATES: { label: string; die: string }[] = [
{ label: "Damage", die: "d8" },
];
-type Mode = "normal" | "adv" | "dis";
-
function rollDie(sides: number): number {
return Math.floor(Math.random() * sides) + 1;
}
-function parseNotation(
- notation: string,
-): { count: number; sides: number; modifier: number } | null {
- const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
- if (!match) return null;
- return {
- count: parseInt(match[1] || "1"),
- sides: parseInt(match[2]),
- modifier: parseInt(match[3] || "0"),
- };
-}
-
-// ponytail: advantage/disadvantage is a 5e concept. We implement it as
-// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only
-// meaningful for d20 rolls; for other dice we just roll normally.
-function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } {
- if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
- return { rolls, kept: rolls[0] ?? 0 };
- }
- if (mode === "adv") {
- const hi = Math.max(rolls[0], rolls[1]);
- return { rolls, kept: hi };
- }
- const lo = Math.min(rolls[0], rolls[1]);
- return { rolls, kept: lo };
-}
+// ponytail: parseNotation + applyMode live in src/lib/dice.ts so they ship
+// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
+// types are imported from there too.
export function DiceRoller() {
const [input, setInput] = usePersistentState("dice.input", "1d20");
diff --git a/src/components/GeneratingIndicator.tsx b/src/components/GeneratingIndicator.tsx
new file mode 100644
index 0000000..37e21ba
--- /dev/null
+++ b/src/components/GeneratingIndicator.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from "react";
+import { listen } from "@tauri-apps/api/event";
+import { Sparkles } from "lucide-react";
+
+interface GenBusyPayload {
+ busy: boolean;
+}
+
+// ponytail: a title-bar "something is generating" signal. The Rust generate
+// commands emit `gen-busy` true at start / false at end; we keep a counter so
+// concurrent generations (NPC + image at once) balance to zero only when all
+// finish. Lets the DM navigate away from a 15s image gen and still see it's
+// working — the only per-card feedback vanishes the moment they switch views.
+export function GeneratingIndicator() {
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ let count = 0;
+ let unlisten: (() => void) | undefined;
+ let alive = true;
+
+ listen("gen-busy", (event) => {
+ if (!alive) return;
+ count += event.payload.busy ? 1 : -1;
+ if (count < 0) count = 0; // guard against a stray false with no matching true
+ setBusy(count > 0);
+ }).then((u) => {
+ unlisten = u;
+ });
+
+ return () => {
+ alive = false;
+ unlisten?.();
+ };
+ }, []);
+
+ if (!busy) return null;
+
+ return (
+
+
+ Generating…
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Greet.tsx b/src/components/Greet.tsx
deleted file mode 100644
index 957a0f8..0000000
--- a/src/components/Greet.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useState } from "react";
-import { invoke } from "@tauri-apps/api/core";
-
-export function Greet() {
- const [greeting, setGreeting] = useState("");
- const [name, setName] = useState("");
-
- async function greet() {
- // Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/
- setGreeting(await invoke("greet", { name }));
- }
-
- return (
-
-
- {greeting && (
-
- {greeting}
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx
index c1392be..27e5e22 100644
--- a/src/components/HistoryView.tsx
+++ b/src/components/HistoryView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useMemo, useCallback } from "react";
+import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
History as HistoryIcon,
@@ -44,6 +44,33 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
const [confirmClear, setConfirmClear] = useState(false);
const { addToast } = useToast();
+ // ponytail: gallery thumbnails — fetch each image generation's data URL on
+ // demand when the image filter is active. N queries against local SQLite
+ // is fine for a campaign's worth of images; add a list-with-data command
+ // if it ever gets slow. Ref is the source of truth; state mirrors it to
+ // trigger re-renders when a thumbnail lands.
+ const thumbsRef = useRef