working through a review
This commit is contained in:
@@ -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)
|
## What's in the box
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## 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
|
### Prerequisites
|
||||||
{
|
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
- **Rust** + **Cargo** — https://rustup.rs
|
||||||
"plugins": ["react", "typescript", "oxc"],
|
- **Node.js** 20+ — https://nodejs.org
|
||||||
"options": {
|
- **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`)
|
||||||
"typeAware": true
|
|
||||||
},
|
### Install & run
|
||||||
"rules": {
|
|
||||||
"react/rules-of-hooks": "error",
|
```bash
|
||||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
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.
|
||||||
+149
@@ -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:**
|
||||||
|
`<item.icon size={item.view === "tables" ? 18 : 18} />` — 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<View, { kind?, Comp?, maxWidth }>` 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
|
||||||
|
(`<dataDir>/<campaign>/`) 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 `<p>` + 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!**
|
**You're now ready to spin up your own AI-powered Dungeon Master toolkit!**
|
||||||
|
|
||||||
Happy crafting — may your dice always land favorably! 🎲
|
Happy crafting — may your dice always land favorably! 🎲
|
||||||
@@ -7,12 +7,12 @@ recommendation.*
|
|||||||
> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative,
|
> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative,
|
||||||
> Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger,
|
> Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger,
|
||||||
> Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/
|
> Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/
|
||||||
> reward fields shipped; remaining P2 items are larger-effort or backend-
|
> reward fields shipped; soundboard scenes + image gallery/batch shipped;
|
||||||
> gated: image-gen advanced params (Ollama API doesn't support them),
|
> remaining P2 items are larger-effort or backend-gated: image-gen advanced
|
||||||
> soundboard scenes + real ambience packs, world hierarchy tree, lore
|
> params (Ollama API doesn't support them), soundboard real ambience packs +
|
||||||
> directory picker (needs tauri-plugin-dialog), NPC roster / item inventory /
|
> custom sound import, world hierarchy tree, lore directory picker (needs
|
||||||
> quest roster (History view covers re-opening). The checklist below is
|
> tauri-plugin-dialog), NPC roster / item inventory / quest roster (History
|
||||||
> updated as items land.
|
> 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)
|
- [ ] Quest roster (History view covers re-opening past quests)
|
||||||
- [ ] **Image Generator**:
|
- [ ] **Image Generator**:
|
||||||
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
||||||
- [ ] Batch mode (2×2 variants)
|
- [x] Batch mode (2×2 variants)
|
||||||
- [ ] Gallery view with thumbnails
|
- [x] Gallery view with thumbnails
|
||||||
- [x] Persist to campaign-scoped folder
|
- [x] Persist to campaign-scoped folder
|
||||||
- [x] Copy to clipboard
|
- [x] Copy to clipboard
|
||||||
- [x] **Session Logger**:
|
- [x] **Session Logger**:
|
||||||
@@ -1117,7 +1117,7 @@ These are blockers or bugs that make the app feel broken.
|
|||||||
- [ ] **Soundboard**:
|
- [ ] **Soundboard**:
|
||||||
- [ ] Real ambience pack (2–3 royalty-free loops)
|
- [ ] Real ambience pack (2–3 royalty-free loops)
|
||||||
- [ ] Custom sound import (drag-drop)
|
- [ ] Custom sound import (drag-drop)
|
||||||
- [ ] Scenes (one-click combinations)
|
- [x] Scenes (one-click combinations)
|
||||||
- [x] Master mute + per-sound volume
|
- [x] Master mute + per-sound volume
|
||||||
- [x] Always-visible Stop All
|
- [x] Always-visible Stop All
|
||||||
- [x] Save/load board state
|
- [x] Save/load board state
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
use crate::commands::emit_busy;
|
||||||
use crate::llm::AppState;
|
use crate::llm::AppState;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::hash_map::DefaultHasher;
|
use std::collections::hash_map::DefaultHasher;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
|
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
|
||||||
|
|
||||||
@@ -74,8 +76,18 @@ pub enum ImageEvent {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_image(
|
pub async fn generate_image(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
|
app: AppHandle,
|
||||||
req: ImageRequest,
|
req: ImageRequest,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
// 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<String, String> {
|
||||||
if cfg!(not(target_os = "macos")) {
|
if cfg!(not(target_os = "macos")) {
|
||||||
return Err("image generation is macOS-only via Ollama (for now)".into());
|
return Err("image generation is macOS-only via Ollama (for now)".into());
|
||||||
}
|
}
|
||||||
@@ -100,6 +112,7 @@ pub async fn generate_image(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_image_stream(
|
pub async fn generate_image_stream(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
|
app: AppHandle,
|
||||||
req: ImageRequest,
|
req: ImageRequest,
|
||||||
channel: Channel<ImageEvent>,
|
channel: Channel<ImageEvent>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -124,6 +137,9 @@ pub async fn generate_image_stream(
|
|||||||
// and progress flows through the channel. Errors become ImageEvent::Error.
|
// and progress flows through the channel. Errors become ImageEvent::Error.
|
||||||
let channel = std::sync::Arc::new(channel);
|
let channel = std::sync::Arc::new(channel);
|
||||||
let ch = channel.clone();
|
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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await {
|
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await {
|
||||||
Ok(png_bytes) => {
|
Ok(png_bytes) => {
|
||||||
@@ -134,6 +150,7 @@ pub async fn generate_image_stream(
|
|||||||
let _ = channel.send(ImageEvent::Error(e));
|
let _ = channel.send(ImageEvent::Error(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
emit_busy(&app, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
|
use crate::commands::emit_busy;
|
||||||
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
|
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
// ─── Simple (non-streaming) generate ─────────────────────────
|
// ─── Simple (non-streaming) generate ─────────────────────────
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<String, String> {
|
pub async fn generate(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app: AppHandle,
|
||||||
|
req: GenerateRequest,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// 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<String, String> {
|
||||||
let config = {
|
let config = {
|
||||||
let guard = state.config.lock().map_err(|e| e.to_string())?;
|
let guard = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
guard.clone()
|
guard.clone()
|
||||||
@@ -32,6 +48,7 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn generate_stream(
|
pub async fn generate_stream(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
|
app: AppHandle,
|
||||||
req: GenerateRequest,
|
req: GenerateRequest,
|
||||||
channel: Channel<LlmEvent>,
|
channel: Channel<LlmEvent>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -43,6 +60,7 @@ pub async fn generate_stream(
|
|||||||
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
|
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
|
||||||
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
|
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
|
||||||
|
|
||||||
|
emit_busy(&app, true);
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// For now, we do a non-streaming call and emit the full response as one token
|
// 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
|
// 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));
|
let _ = channel.send(LlmEvent::Error(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ponytail: always balance the busy counter, even on error.
|
||||||
|
emit_busy(&app, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -2,4 +2,21 @@ pub mod llm_commands;
|
|||||||
pub mod image_commands;
|
pub mod image_commands;
|
||||||
pub mod rag_commands;
|
pub mod rag_commands;
|
||||||
pub mod generation_commands;
|
pub mod generation_commands;
|
||||||
pub mod data_commands;
|
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 });
|
||||||
|
}
|
||||||
@@ -74,4 +74,94 @@ pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result<V
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, preview)| Ok(RagChunk { id, preview }))
|
.map(|(id, preview)| Ok(RagChunk { id, preview }))
|
||||||
.collect()
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RagAddDirRequest {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RagAddDirReport {
|
||||||
|
pub files: usize,
|
||||||
|
pub chunks: usize,
|
||||||
|
pub skipped: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<RagAddDirReport, String> {
|
||||||
|
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<String> = 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 })
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,6 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
greet,
|
|
||||||
commands::llm_commands::generate,
|
commands::llm_commands::generate,
|
||||||
commands::llm_commands::generate_stream,
|
commands::llm_commands::generate_stream,
|
||||||
commands::llm_commands::get_llm_config,
|
commands::llm_commands::get_llm_config,
|
||||||
@@ -69,6 +68,7 @@ pub fn run() {
|
|||||||
commands::rag_commands::rag_list,
|
commands::rag_commands::rag_list,
|
||||||
commands::rag_commands::rag_clear,
|
commands::rag_commands::rag_clear,
|
||||||
commands::rag_commands::rag_chunks,
|
commands::rag_commands::rag_chunks,
|
||||||
|
commands::rag_commands::rag_add_directory,
|
||||||
commands::generation_commands::generation_add,
|
commands::generation_commands::generation_add,
|
||||||
commands::generation_commands::generation_list,
|
commands::generation_commands::generation_list,
|
||||||
commands::generation_commands::generation_get,
|
commands::generation_commands::generation_get,
|
||||||
@@ -83,7 +83,6 @@ pub fn run() {
|
|||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
// ponytail: removed the scaffold `greet` command + its frontend component —
|
||||||
fn greet(name: &str) -> String {
|
// nothing in the shell referenced it. Kept here as a marker so the
|
||||||
format!("Hello, {}! Welcome to DM-Pal ⚔", name)
|
// invoke_handler list above stays in sync.
|
||||||
}
|
|
||||||
+72
-54
@@ -33,8 +33,10 @@ import { LorePanel } from "./components/LorePanel";
|
|||||||
import { ImageGenerator } from "./components/ImageGenerator";
|
import { ImageGenerator } from "./components/ImageGenerator";
|
||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
import { CommandPalette } from "./components/CommandPalette";
|
import { CommandPalette } from "./components/CommandPalette";
|
||||||
|
import { ShortcutHelp } from "./components/ShortcutHelp";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { ConnectionPill } from "./components/ConnectionPill";
|
import { ConnectionPill } from "./components/ConnectionPill";
|
||||||
|
import { GeneratingIndicator } from "./components/GeneratingIndicator";
|
||||||
import { HistoryView } from "./components/HistoryView";
|
import { HistoryView } from "./components/HistoryView";
|
||||||
import type { Generation, GenerationKind } from "./lib/generations";
|
import type { Generation, GenerationKind } from "./lib/generations";
|
||||||
|
|
||||||
@@ -56,17 +58,47 @@ export type View =
|
|||||||
| "settings"
|
| "settings"
|
||||||
| "history";
|
| "history";
|
||||||
|
|
||||||
// ponytail: tools that accept a prefill from history. Mirrors the kinds.
|
// ponytail: ONE source of truth per tool — kind (for history prefill),
|
||||||
const PREFILLABLE: Partial<Record<View, GenerationKind>> = {
|
// the prefill-aware component, and the preferred max-width. Adding a tool
|
||||||
npcs: "npc",
|
// is a single entry here; the KIND_TO_VIEW inverse and renderView derive
|
||||||
encounter: "encounter",
|
// from it. Replaces the old PREFILLABLE / viewMaxWidth / PREFILLABLE_TOOLS
|
||||||
world: "world",
|
// triple that drifted whenever a tool was added.
|
||||||
items: "item",
|
type PrefillComponent = React.ComponentType<{
|
||||||
quest: "quest",
|
prefill?: Generation | null;
|
||||||
session: "session",
|
onPrefillConsumed?: () => void;
|
||||||
image: "image",
|
}>;
|
||||||
|
|
||||||
|
interface ToolMeta {
|
||||||
|
kind?: GenerationKind;
|
||||||
|
Comp?: PrefillComponent;
|
||||||
|
maxWidth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_META: Partial<Record<View, ToolMeta>> = {
|
||||||
|
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<Record<GenerationKind, View>> = {};
|
||||||
|
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";
|
type NavGroup = "session" | "world";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -100,19 +132,16 @@ function renderView(
|
|||||||
onPrefillConsumed: () => void,
|
onPrefillConsumed: () => void,
|
||||||
rehydrate: (kind: GenerationKind, data: Generation) => void,
|
rehydrate: (kind: GenerationKind, data: Generation) => void,
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
// ponytail: history view renders its own list/detail panes, so it doesn't
|
// ponytail: history renders its own list/detail panes — no prefill wrapper.
|
||||||
// use the prefill or wrapper pattern.
|
|
||||||
if (view === "history") {
|
if (view === "history") {
|
||||||
return <HistoryView onRehydrate={rehydrate} />;
|
return <HistoryView onRehydrate={rehydrate} />;
|
||||||
}
|
}
|
||||||
// Tools that accept prefill.
|
// ponytail: prefill-aware tools come straight from TOOL_META — one entry
|
||||||
if (view === "npcs") return <NpcGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
// per tool instead of a parallel if-chain that drifted with the maps.
|
||||||
if (view === "encounter") return <EncounterBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
const meta = TOOL_META[view];
|
||||||
if (view === "world") return <WorldBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
if (meta?.Comp) {
|
||||||
if (view === "items") return <ItemForge prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
return <meta.Comp prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||||
if (view === "quest") return <QuestDesigner prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
}
|
||||||
if (view === "session") return <SessionLogger prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
||||||
if (view === "image") return <ImageGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "dice":
|
case "dice":
|
||||||
return <DiceRoller />;
|
return <DiceRoller />;
|
||||||
@@ -133,39 +162,11 @@ function renderView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: per-tool preferred max-width. Wider tools stop fighting the rail.
|
|
||||||
const viewMaxWidth: Partial<Record<View, string>> = {
|
|
||||||
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() {
|
export default function App() {
|
||||||
const [view, setView] = useState<View>("dashboard");
|
const [view, setView] = useState<View>("dashboard");
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
// ponytail: prefill is set when the user clicks "Open in tool" in History.
|
// ponytail: prefill is set when the user clicks "Open in tool" in History.
|
||||||
// The targeted tool reads it via usePrefillEffect and calls onConsumed
|
// The targeted tool reads it via usePrefillEffect and calls onConsumed
|
||||||
// to clear it. State lives here so the rehydrate→switch→consume dance
|
// to clear it. State lives here so the rehydrate→switch→consume dance
|
||||||
@@ -173,9 +174,7 @@ export default function App() {
|
|||||||
const [prefill, setPrefill] = useState<Generation | null>(null);
|
const [prefill, setPrefill] = useState<Generation | null>(null);
|
||||||
|
|
||||||
function rehydrate(kind: GenerationKind, data: Generation) {
|
function rehydrate(kind: GenerationKind, data: Generation) {
|
||||||
const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find(
|
const target = KIND_TO_VIEW[kind];
|
||||||
(v) => PREFILLABLE[v] === kind,
|
|
||||||
);
|
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
setPrefill(data);
|
setPrefill(data);
|
||||||
setView(target);
|
setView(target);
|
||||||
@@ -198,6 +197,21 @@ export default function App() {
|
|||||||
setPaletteOpen(false);
|
setPaletteOpen(false);
|
||||||
return;
|
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.
|
// Digit shortcuts jump to nav items, but not while typing in an input.
|
||||||
if (
|
if (
|
||||||
!paletteOpen &&
|
!paletteOpen &&
|
||||||
@@ -221,7 +235,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [paletteOpen]);
|
}, [paletteOpen, helpOpen]);
|
||||||
|
|
||||||
const isOnDashboard = view === "dashboard";
|
const isOnDashboard = view === "dashboard";
|
||||||
const groupedNav = useMemo(
|
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)]"
|
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={item.view === "tables" ? 18 : 18} />
|
<item.icon size={18} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -356,6 +370,7 @@ export default function App() {
|
|||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div className="ml-auto" data-tauri-no-drag />
|
<div className="ml-auto" data-tauri-no-drag />
|
||||||
|
<GeneratingIndicator />
|
||||||
<ConnectionPill />
|
<ConnectionPill />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -367,7 +382,7 @@ export default function App() {
|
|||||||
) : view === "history" ? (
|
) : view === "history" ? (
|
||||||
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
||||||
) : (
|
) : (
|
||||||
<div className={`${viewMaxWidth[view] ?? "max-w-2xl"} mx-auto p-6`}>
|
<div className={`${TOOL_META[view]?.maxWidth ?? DEFAULT_MAX_WIDTH} mx-auto p-6`}>
|
||||||
{view === "settings" ? (
|
{view === "settings" ? (
|
||||||
<div className="glass-card p-6">
|
<div className="glass-card p-6">
|
||||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||||
@@ -395,6 +410,9 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ponytail: `?` shortcut cheatsheet — discoverability for the icon-only rail. */}
|
||||||
|
<ShortcutHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||||
|
|
||||||
{/* Toast notifications */}
|
{/* Toast notifications */}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { usePersistentState } from "../lib/usePersistentState";
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
|
||||||
interface DieResult {
|
|
||||||
notation: string;
|
|
||||||
rolls: number[];
|
|
||||||
total: number;
|
|
||||||
modifier: number;
|
|
||||||
advantage?: "adv" | "dis" | null;
|
|
||||||
keptRoll?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
|
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
|
||||||
|
|
||||||
@@ -23,38 +15,13 @@ const TEMPLATES: { label: string; die: string }[] = [
|
|||||||
{ label: "Damage", die: "d8" },
|
{ label: "Damage", die: "d8" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type Mode = "normal" | "adv" | "dis";
|
|
||||||
|
|
||||||
function rollDie(sides: number): number {
|
function rollDie(sides: number): number {
|
||||||
return Math.floor(Math.random() * sides) + 1;
|
return Math.floor(Math.random() * sides) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNotation(
|
// ponytail: parseNotation + applyMode live in src/lib/dice.ts so they ship
|
||||||
notation: string,
|
// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
|
||||||
): { count: number; sides: number; modifier: number } | null {
|
// types are imported from there too.
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DiceRoller() {
|
export function DiceRoller() {
|
||||||
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
||||||
|
|||||||
@@ -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<GenBusyPayload>("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 (
|
||||||
|
<span
|
||||||
|
data-tauri-no-drag
|
||||||
|
className="ml-2 flex items-center gap-1.5 text-[var(--color-gold-bright)] text-xs select-none"
|
||||||
|
title="A generation is in progress"
|
||||||
|
aria-label="Generation in progress"
|
||||||
|
>
|
||||||
|
<Sparkles size={12} className="thinking-dot" />
|
||||||
|
<span className="hidden sm:inline thinking-dot">Generating…</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
||||||
<form
|
|
||||||
className="flex gap-2 w-full max-w-md"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
greet();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-[var(--font-sans)]"
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Enter a name…"
|
|
||||||
value={name}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Greet
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{greeting && (
|
|
||||||
<p className="text-[var(--color-text-secondary)] text-sm font-[var(--font-mono)]">
|
|
||||||
{greeting}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
@@ -44,6 +44,33 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
|||||||
const [confirmClear, setConfirmClear] = useState(false);
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
const { addToast } = useToast();
|
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<Map<number, string>>(new Map());
|
||||||
|
const [thumbs, setThumbs] = useState<Map<number, string>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeKind !== "image") return;
|
||||||
|
const missing = summaries
|
||||||
|
.filter((s) => s.kind === "image" && !thumbsRef.current.has(s.id))
|
||||||
|
.map((s) => s.id);
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
for (const id of missing) {
|
||||||
|
try {
|
||||||
|
const g = await getGeneration(id);
|
||||||
|
if (g && g.kind === "image" && g.data) thumbsRef.current.set(id, g.data);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (alive) setThumbs(new Map(thumbsRef.current));
|
||||||
|
})();
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [activeKind, summaries]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -101,6 +128,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
|||||||
try {
|
try {
|
||||||
await deleteGeneration(id);
|
await deleteGeneration(id);
|
||||||
setSummaries((prev) => prev.filter((s) => s.id !== id));
|
setSummaries((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
thumbsRef.current.delete(id);
|
||||||
|
setThumbs(new Map(thumbsRef.current));
|
||||||
if (selected?.id === id) setSelected(null);
|
if (selected?.id === id) setSelected(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(`Delete failed: ${e}`, "error");
|
addToast(`Delete failed: ${e}`, "error");
|
||||||
@@ -114,6 +143,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
|||||||
const n = await clearAllGenerations();
|
const n = await clearAllGenerations();
|
||||||
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
|
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
|
||||||
setSummaries([]);
|
setSummaries([]);
|
||||||
|
thumbsRef.current.clear();
|
||||||
|
setThumbs(new Map(thumbsRef.current));
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setConfirmClear(false);
|
setConfirmClear(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -232,29 +263,62 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((s) => (
|
{activeKind === "image" ? (
|
||||||
<button
|
<div className="grid grid-cols-2 gap-2">
|
||||||
key={s.id}
|
{filtered.map((s) => {
|
||||||
onClick={() => onSelect(s.id)}
|
const src = thumbs.get(s.id);
|
||||||
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
return (
|
||||||
selected?.id === s.id
|
<button
|
||||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
key={s.id}
|
||||||
: "hover:bg-[var(--color-bg-card)]"
|
onClick={() => onSelect(s.id)}
|
||||||
}`}
|
className={`group relative rounded-lg overflow-hidden border bg-[var(--color-bg-deep)] aspect-square cursor-pointer transition-colors ${
|
||||||
>
|
selected?.id === s.id
|
||||||
<div className="flex items-center justify-between gap-2">
|
? "border-[var(--color-gold-bright)]"
|
||||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
|
||||||
{KIND_LABELS[s.kind]}
|
}`}
|
||||||
</span>
|
title={s.title}
|
||||||
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
>
|
||||||
{relativeTime(s.createdAt)}
|
{src ? (
|
||||||
</span>
|
<img src={src} alt={s.title} className="w-full h-full object-cover" loading="lazy" />
|
||||||
</div>
|
) : (
|
||||||
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
{s.title}
|
<Loader2 size={16} className="animate-spin text-[var(--color-text-dim)]" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
)}
|
||||||
))}
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-1.5 py-1">
|
||||||
|
<span className="text-[10px] text-white/90 truncate block">{s.title}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onSelect(s.id)}
|
||||||
|
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
||||||
|
selected?.id === s.id
|
||||||
|
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||||
|
: "hover:bg-[var(--color-bg-card)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||||
|
{KIND_LABELS[s.kind]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
||||||
|
{relativeTime(s.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
||||||
|
{s.title}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
|
const [variants, setVariants] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [variant, setVariant] = useState(0);
|
const [variant, setVariant] = useState(0);
|
||||||
const [unsupported, setUnsupported] = useState(false);
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
@@ -31,6 +32,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
if (!prompt.trim()) return;
|
if (!prompt.trim()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDataUrl(null);
|
setDataUrl(null);
|
||||||
|
setVariants([]);
|
||||||
try {
|
try {
|
||||||
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||||
// so "regenerate" actually produces a new image instead of the cached one.
|
// so "regenerate" actually produces a new image instead of the cached one.
|
||||||
@@ -51,6 +53,44 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: batch = 4 concurrent generates with distinct variation tags so
|
||||||
|
// the backend cache yields 4 different images. Ollama serializes them
|
||||||
|
// server-side anyway, so concurrency is just cleaner code than a loop with
|
||||||
|
// awaits. Each variant is persisted so the gallery gets all four.
|
||||||
|
async function generateBatch() {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setVariants([]);
|
||||||
|
setDataUrl(null);
|
||||||
|
setUnsupported(false);
|
||||||
|
const tags = [1, 2, 3, 4];
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
tags.map((t) =>
|
||||||
|
invoke<string>("generate_image", {
|
||||||
|
req: { prompt: `${prompt}\n\n(variation ${t})`, model: model.trim() || null },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const ok: string[] = [];
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === "fulfilled" && r.value) {
|
||||||
|
ok.push(r.value);
|
||||||
|
void addGeneration({ kind: "image", title: prompt, data: r.value, source: model.trim() || DEFAULT_MODEL });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok.length === 0) {
|
||||||
|
const firstErr = results.find((r) => r.status === "rejected");
|
||||||
|
const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : "";
|
||||||
|
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
|
||||||
|
addToast(`Batch failed: ${msg || "no images returned"}`, "error");
|
||||||
|
} else {
|
||||||
|
setVariants(ok);
|
||||||
|
setDataUrl(ok[0]);
|
||||||
|
addToast(`Generated ${ok.length} variants`, "success");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
function regenerate() {
|
function regenerate() {
|
||||||
setVariant((v) => v + 1);
|
setVariant((v) => v + 1);
|
||||||
// run after state update flushes
|
// run after state update flushes
|
||||||
@@ -90,6 +130,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
>
|
>
|
||||||
{loading ? "✨ Generating…" : "✨ Generate Image"}
|
{loading ? "✨ Generating…" : "✨ Generate Image"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={generateBatch}
|
||||||
|
disabled={loading || !prompt.trim()}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-2 text-sm hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
title="Generate 4 variants and pick the best"
|
||||||
|
>
|
||||||
|
{loading ? "…" : "4×"}
|
||||||
|
</button>
|
||||||
{dataUrl && !loading && (
|
{dataUrl && !loading && (
|
||||||
<button
|
<button
|
||||||
onClick={regenerate}
|
onClick={regenerate}
|
||||||
@@ -115,6 +163,28 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{variants.length > 1 && !loading && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Variants — click to select</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2 w-full max-w-md mx-auto">
|
||||||
|
{variants.map((v, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setDataUrl(v)}
|
||||||
|
className={`rounded-lg overflow-hidden border aspect-square cursor-pointer transition-colors ${
|
||||||
|
dataUrl === v
|
||||||
|
? "border-[var(--color-gold-bright)]"
|
||||||
|
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
|
||||||
|
}`}
|
||||||
|
title={`Variant ${i + 1}`}
|
||||||
|
>
|
||||||
|
<img src={v} alt={`Variant ${i + 1}`} className="w-full h-full object-cover" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{dataUrl && !loading && (
|
{dataUrl && !loading && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { Trash2, ChevronDown, ChevronRight, FileUp } from "lucide-react";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { Trash2, ChevronDown, ChevronRight, FileUp, FolderOpen } from "lucide-react";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addToLore } from "../lib/lore";
|
import { addToLore } from "../lib/lore";
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function LorePanel() {
|
|||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [sources, setSources] = useState<RagSource[]>([]);
|
const [sources, setSources] = useState<RagSource[]>([]);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [addingDir, setAddingDir] = useState(false);
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -117,6 +119,34 @@ export function LorePanel() {
|
|||||||
if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info");
|
if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: pick a folder and let Rust walk it (recursive, .md/.txt only).
|
||||||
|
// Doing the walk in Rust sidesteps the webview fs scope — an external
|
||||||
|
// world-bible folder needs no fs permission grant this way.
|
||||||
|
async function addDirectory() {
|
||||||
|
const selected = await open({ directory: true, multiple: false });
|
||||||
|
if (!selected || typeof selected !== "string") return;
|
||||||
|
setAddingDir(true);
|
||||||
|
try {
|
||||||
|
const report = await invoke<{ files: number; chunks: number; skipped: string[] }>(
|
||||||
|
"rag_add_directory",
|
||||||
|
{ req: { path: selected } },
|
||||||
|
);
|
||||||
|
await loadSources();
|
||||||
|
if (report.files === 0) {
|
||||||
|
addToast("No .md / .txt files found in that folder", "info");
|
||||||
|
} else {
|
||||||
|
addToast(
|
||||||
|
`Indexed ${report.files} file${report.files === 1 ? "" : "s"} · ${report.chunks} chunks`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const s of report.skipped) console.warn("lore dir import skipped:", s);
|
||||||
|
} catch (e) {
|
||||||
|
addToast(`Folder import failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
setAddingDir(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
@@ -168,6 +198,16 @@ export function LorePanel() {
|
|||||||
onChange={onFiles}
|
onChange={onFiles}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{/* ponytail: directory import — walks every .md/.txt recursively in
|
||||||
|
Rust (no webview fs-scope needed for an external folder). */}
|
||||||
|
<button
|
||||||
|
onClick={addDirectory}
|
||||||
|
disabled={addingDir}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-sm text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
{addingDir ? "Indexing…" : "Add folder…"}
|
||||||
|
</button>
|
||||||
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Keyboard } from "lucide-react";
|
||||||
|
import { navItems } from "../App";
|
||||||
|
|
||||||
|
interface ShortcutHelpProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: a single source of truth for the global shortcuts, shown via the
|
||||||
|
// `?` key. Tool-local shortcuts (space-to-roll in Dice) live in their tool's
|
||||||
|
// own help text — this is only the app-shell set the DM can't otherwise
|
||||||
|
// discover because the rail is icon-only.
|
||||||
|
const GLOBAL: { keys: string; label: string }[] = [
|
||||||
|
{ keys: "⌘K", label: "Command palette — jump to any tool" },
|
||||||
|
{ keys: "⌘H", label: "History — re-open a past generation" },
|
||||||
|
{ keys: "⌘,", label: "Settings" },
|
||||||
|
{ keys: "Esc", label: "Close palette / overlay" },
|
||||||
|
{ keys: "?", label: "This help" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ShortcutHelp({ open, onClose }: ShortcutHelpProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
// ponytail: digit shortcuts come from navItems (single source of truth), so
|
||||||
|
// this list can't drift from the rail. Only items with a shortcut show.
|
||||||
|
const digits = navItems.filter((n) => n.shortcut);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 flex items-start justify-center pt-24 px-4"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/40" aria-hidden="true" />
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md glass-card p-4 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Keyboard size={16} className="text-[var(--color-gold-bright)]" />
|
||||||
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold tracking-wide">
|
||||||
|
Keyboard shortcuts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global shortcuts */}
|
||||||
|
<ul className="flex flex-col gap-1.5 mb-3">
|
||||||
|
{GLOBAL.map((s) => (
|
||||||
|
<li key={s.keys} className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">{s.label}</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
|
||||||
|
{s.keys}
|
||||||
|
</kbd>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Digit shortcuts — derived from the rail so they can't drift. */}
|
||||||
|
{digits.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-[var(--color-border-subtle)] pt-2 mb-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)]">
|
||||||
|
Jump to tool
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||||
|
{digits.map((n) => (
|
||||||
|
<li key={n.view} className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)] truncate">{n.label}</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
|
||||||
|
{n.shortcut}
|
||||||
|
</kbd>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-3 pt-2 border-t border-[var(--color-border-subtle)] text-[10px] text-[var(--color-text-dim)]">
|
||||||
|
<span>In a tool? Each one lists its own shortcuts inline.</span>
|
||||||
|
<span>esc to close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+141
-63
@@ -27,10 +27,16 @@ const SFX_SOUNDS = [
|
|||||||
{ id: "footsteps", label: "Steps", icon: "👣" },
|
{ id: "footsteps", label: "Steps", icon: "👣" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type Scene = { name: string; ambients: string[] };
|
||||||
|
|
||||||
export function Soundboard() {
|
export function Soundboard() {
|
||||||
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
||||||
const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
|
const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
|
||||||
const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false);
|
const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false);
|
||||||
|
// ponytail: scenes store only the set of ambient ids + a name; per-scene
|
||||||
|
// volume is deferred (global volume is enough until a DM actually asks).
|
||||||
|
const [scenes, setScenes] = usePersistentState<Scene[]>("sound.scenes", []);
|
||||||
|
const [newSceneName, setNewSceneName] = useState("");
|
||||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
@@ -81,69 +87,89 @@ export function Soundboard() {
|
|||||||
});
|
});
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
|
// Shared start/stop so scenes and the toggle button route through one path.
|
||||||
|
const startAmbient = useCallback((id: string) => {
|
||||||
|
const sound = AMBIENT_SOUNDS.find((s) => s.id === id);
|
||||||
|
if (!sound) return;
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
gainNode.gain.value = 0; // Start silent, fade in
|
||||||
|
gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1);
|
||||||
|
|
||||||
|
const filter = ctx.createBiquadFilter();
|
||||||
|
filter.type = "lowpass";
|
||||||
|
filter.frequency.value = sound.filterFreq;
|
||||||
|
|
||||||
|
let source: OscillatorNode | AudioBufferSourceNode;
|
||||||
|
if (sound.type === "brown") {
|
||||||
|
// Brown noise via buffer
|
||||||
|
const bufferSize = ctx.sampleRate * 2;
|
||||||
|
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
const white = Math.random() * 2 - 1;
|
||||||
|
data[i] = (last + 0.02 * white) / 1.02;
|
||||||
|
last = data[i];
|
||||||
|
data[i] *= 3.5; // Normalize
|
||||||
|
}
|
||||||
|
source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.loop = true;
|
||||||
|
} else {
|
||||||
|
source = ctx.createOscillator();
|
||||||
|
source.type = sound.type;
|
||||||
|
source.frequency.value = sound.freq;
|
||||||
|
}
|
||||||
|
|
||||||
|
source.connect(filter);
|
||||||
|
filter.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
source.start();
|
||||||
|
nodesRef.current.set(id, { source, gain: gainNode, filter });
|
||||||
|
}, [volume, getAudioCtx]);
|
||||||
|
|
||||||
|
const stopAmbient = useCallback((id: string) => {
|
||||||
|
const nodes = nodesRef.current.get(id);
|
||||||
|
if (!nodes) return;
|
||||||
|
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||||
|
setTimeout(() => {
|
||||||
|
try { nodes.source.stop(); } catch {}
|
||||||
|
nodesRef.current.delete(id);
|
||||||
|
}, 600);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopAll = useCallback(() => {
|
||||||
|
nodesRef.current.forEach((_, id) => stopAmbient(id));
|
||||||
|
nodesRef.current.clear();
|
||||||
|
setActiveAmbients(new Set());
|
||||||
|
}, [stopAmbient]);
|
||||||
|
|
||||||
const toggleAmbient = useCallback((id: string) => {
|
const toggleAmbient = useCallback((id: string) => {
|
||||||
setActiveAmbients((prev) => {
|
setActiveAmbients((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
// Stop the sound
|
stopAmbient(id);
|
||||||
const nodes = nodesRef.current.get(id);
|
|
||||||
if (nodes) {
|
|
||||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
|
||||||
setTimeout(() => {
|
|
||||||
nodes.source.stop();
|
|
||||||
nodesRef.current.delete(id);
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
next.add(id);
|
next.add(id);
|
||||||
// Start the sound
|
startAmbient(id);
|
||||||
const sound = AMBIENT_SOUNDS.find((s) => s.id === id);
|
|
||||||
if (!sound) return next;
|
|
||||||
|
|
||||||
const ctx = getAudioCtx();
|
|
||||||
const gainNode = ctx.createGain();
|
|
||||||
gainNode.gain.value = 0; // Start silent, fade in
|
|
||||||
gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1);
|
|
||||||
|
|
||||||
const filter = ctx.createBiquadFilter();
|
|
||||||
filter.type = "lowpass";
|
|
||||||
filter.frequency.value = sound.filterFreq;
|
|
||||||
|
|
||||||
let source: OscillatorNode | AudioBufferSourceNode;
|
|
||||||
|
|
||||||
if (sound.type === "brown") {
|
|
||||||
// Brown noise via buffer
|
|
||||||
const bufferSize = ctx.sampleRate * 2;
|
|
||||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
|
||||||
const data = buffer.getChannelData(0);
|
|
||||||
let last = 0;
|
|
||||||
for (let i = 0; i < bufferSize; i++) {
|
|
||||||
const white = Math.random() * 2 - 1;
|
|
||||||
data[i] = (last + 0.02 * white) / 1.02;
|
|
||||||
last = data[i];
|
|
||||||
data[i] *= 3.5; // Normalize
|
|
||||||
}
|
|
||||||
source = ctx.createBufferSource();
|
|
||||||
source.buffer = buffer;
|
|
||||||
source.loop = true;
|
|
||||||
} else {
|
|
||||||
// Oscillator
|
|
||||||
source = ctx.createOscillator();
|
|
||||||
source.type = sound.type;
|
|
||||||
source.frequency.value = sound.freq;
|
|
||||||
}
|
|
||||||
|
|
||||||
source.connect(filter);
|
|
||||||
filter.connect(gainNode);
|
|
||||||
gainNode.connect(ctx.destination);
|
|
||||||
source.start();
|
|
||||||
|
|
||||||
nodesRef.current.set(id, { source, gain: gainNode, filter });
|
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [volume, getAudioCtx]);
|
}, [startAmbient, stopAmbient]);
|
||||||
|
|
||||||
|
// Apply a saved scene: stop everything, then start the scene's ambients.
|
||||||
|
const applyScene = useCallback((scene: Scene) => {
|
||||||
|
stopAll();
|
||||||
|
// Defer starts one tick so the stop ramps clear first; ambients are
|
||||||
|
// independent ids so a small delay keeps the fade clean.
|
||||||
|
setTimeout(() => {
|
||||||
|
setActiveAmbients(new Set(scene.ambients));
|
||||||
|
scene.ambients.forEach((id) => startAmbient(id));
|
||||||
|
}, 50);
|
||||||
|
addToast(`Scene: ${scene.name}`, "info");
|
||||||
|
}, [stopAll, startAmbient, addToast]);
|
||||||
|
|
||||||
const playSfx = useCallback((id: string) => {
|
const playSfx = useCallback((id: string) => {
|
||||||
if (muted) return; // ponytail: master mute gates one-shot SFX too.
|
if (muted) return; // ponytail: master mute gates one-shot SFX too.
|
||||||
@@ -359,18 +385,70 @@ export function Soundboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scenes — one-click named combinations of the ambients above. */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
|
||||||
|
🎭 Scenes
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{scenes.map((scene, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="group flex items-center gap-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] pl-2.5 pr-1 py-1 transition-colors"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => applyScene(scene)}
|
||||||
|
className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer"
|
||||||
|
title={`Play: ${scene.ambients.map((id) => AMBIENT_SOUNDS.find((s) => s.id === id)?.label).filter(Boolean).join(", ")}`}
|
||||||
|
>
|
||||||
|
{scene.name}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setScenes((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-xs px-1 cursor-pointer"
|
||||||
|
aria-label={`Delete scene ${scene.name}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{scenes.length === 0 && (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-dim)]">No scenes yet — turn some ambients on and save the combo.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 mt-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||||
|
value={newSceneName}
|
||||||
|
onChange={(e) => setNewSceneName(e.target.value)}
|
||||||
|
placeholder={activeAmbients.size ? "Scene name (e.g. Tavern)" : "Turn ambients on first"}
|
||||||
|
disabled={activeAmbients.size === 0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && newSceneName.trim() && activeAmbients.size) {
|
||||||
|
setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
|
||||||
|
setNewSceneName("");
|
||||||
|
addToast(`Saved scene: ${newSceneName.trim()}`, "success");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!newSceneName.trim() || activeAmbients.size === 0) return;
|
||||||
|
setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
|
||||||
|
setNewSceneName("");
|
||||||
|
addToast(`Saved scene: ${newSceneName.trim()}`, "success");
|
||||||
|
}}
|
||||||
|
disabled={!newSceneName.trim() || activeAmbients.size === 0}
|
||||||
|
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Save scene
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stop all — always visible so the DM never hunts for it. */}
|
{/* Stop all — always visible so the DM never hunts for it. */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={stopAll}
|
||||||
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}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
// ponytail: dice notation parsing + advantage/disadvantage, extracted from
|
||||||
|
// DiceRoller so the math is testable without the component. Pure functions,
|
||||||
|
// no React, no DOM.
|
||||||
|
|
||||||
|
export type Mode = "normal" | "adv" | "dis";
|
||||||
|
|
||||||
|
export interface ParsedNotation {
|
||||||
|
count: number;
|
||||||
|
sides: number;
|
||||||
|
modifier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: one rolled result, shaped for the history list + breakdown view.
|
||||||
|
// Re-exported by DiceRoller so the component owns the display but not the
|
||||||
|
// shape.
|
||||||
|
export interface DieResult {
|
||||||
|
notation: string;
|
||||||
|
rolls: number[];
|
||||||
|
total: number;
|
||||||
|
modifier: number;
|
||||||
|
advantage?: Mode | null;
|
||||||
|
keptRoll?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a dice notation string: `[count]d<sides>[+/-modifier]`.
|
||||||
|
* Case-insensitive, trims whitespace. Returns null on a bad expression.
|
||||||
|
* Examples: `d20`, `1d20`, `2d6+3`, `d100-2`, `4d8`.
|
||||||
|
*/
|
||||||
|
export function parseNotation(
|
||||||
|
notation: string,
|
||||||
|
): ParsedNotation | 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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply advantage/disadvantage to a 2-roll d20 set. For non-d20 or non-pair
|
||||||
|
* rolls, the first roll is kept unchanged. Returns the rolls to display plus
|
||||||
|
* the single value to add to the modifier.
|
||||||
|
*/
|
||||||
|
export 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") {
|
||||||
|
return { rolls, kept: Math.max(rolls[0], rolls[1]) };
|
||||||
|
}
|
||||||
|
return { rolls, kept: Math.min(rolls[0], rolls[1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Self-check (run: node scripts/check-dice.ts) ──────────────
|
||||||
|
// ponytail: the smallest thing that fails if the parser breaks. No framework
|
||||||
|
// — plain asserts. Covers the notations a DM types every session plus the
|
||||||
|
// adv/dis keep rule and the boundary cases that silently break (negative
|
||||||
|
// mods, bare `d20`, `d100`, rejects garbage).
|
||||||
|
function assert(cond: boolean, msg: string): void {
|
||||||
|
if (!cond) throw new Error(`dice self-check failed: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demo(): void {
|
||||||
|
// Basic shapes.
|
||||||
|
assert(parseNotation("d20")!.count === 1, "bare d20 → count 1");
|
||||||
|
assert(parseNotation("1d20")!.sides === 20, "1d20 sides");
|
||||||
|
assert(parseNotation("2d6+3")!.count === 2, "2d6+3 count");
|
||||||
|
assert(parseNotation("2d6+3")!.sides === 6, "2d6+3 sides");
|
||||||
|
assert(parseNotation("2d6+3")!.modifier === 3, "2d6+3 mod +3");
|
||||||
|
assert(parseNotation("1d20-2")!.modifier === -2, "negative modifier");
|
||||||
|
assert(parseNotation("d100")!.sides === 100, "d100 sides");
|
||||||
|
|
||||||
|
// Case + whitespace tolerance.
|
||||||
|
assert(parseNotation(" 4D8 ")!.count === 4, "uppercase + whitespace");
|
||||||
|
assert(parseNotation("3d8-5")!.modifier === -5, "3d8-5 mod -5");
|
||||||
|
|
||||||
|
// Garbage rejected.
|
||||||
|
assert(parseNotation("hello") === null, "words rejected");
|
||||||
|
assert(parseNotation("2d") === null, "missing sides rejected");
|
||||||
|
assert(parseNotation("d20+abc") === null, "non-numeric mod rejected");
|
||||||
|
assert(parseNotation("") === null, "empty rejected");
|
||||||
|
|
||||||
|
// Advantage keeps the higher of two d20s; disadvantage the lower.
|
||||||
|
assert(applyMode([5, 17], 20, "adv").kept === 17, "adv keeps high");
|
||||||
|
assert(applyMode([5, 17], 20, "dis").kept === 5, "dis keeps low");
|
||||||
|
// Non-d20 rolls ignore mode entirely.
|
||||||
|
assert(applyMode([4, 6], 6, "adv").kept === 4, "non-d20 adv keeps first");
|
||||||
|
// Single roll (no comparison possible) keeps the first.
|
||||||
|
assert(applyMode([12], 20, "adv").kept === 12, "single roll keeps first");
|
||||||
|
// Normal mode keeps the first regardless of count.
|
||||||
|
assert(applyMode([8, 15], 20, "normal").kept === 8, "normal keeps first");
|
||||||
|
|
||||||
|
console.log("dice self-check passed ✓");
|
||||||
|
}
|
||||||
@@ -62,3 +62,45 @@ export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
|
|||||||
Hard: "var(--color-gold-bright)",
|
Hard: "var(--color-gold-bright)",
|
||||||
Deadly: "var(--color-danger)",
|
Deadly: "var(--color-danger)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Self-check (run: node scripts/check-encounter-budget.ts) ────
|
||||||
|
// ponytail: the smallest thing that fails if the budget math breaks. No
|
||||||
|
// framework — plain asserts. Verifies the per-level table scales, the
|
||||||
|
// party-size multiplier, level clamping, and the XP→difficulty bucketing
|
||||||
|
// (the boundary the DMG relies on: a budget-equal XP is the *next* tier).
|
||||||
|
function assert(cond: boolean, msg: string): void {
|
||||||
|
if (!cond) throw new Error(`encounter-budget self-check failed: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demo(): void {
|
||||||
|
// Level 5, 4 PCs — DMG p.82 per-character: Easy 250 / Med 500 / Hard 750 / Deadly 1100.
|
||||||
|
const b = encounterBudget(5, 4);
|
||||||
|
assert(b.easy === 1000, "L5×4 easy = 250×4");
|
||||||
|
assert(b.medium === 2000, "L5×4 medium = 500×4");
|
||||||
|
assert(b.hard === 3000, "L5×4 hard = 750×4");
|
||||||
|
assert(b.deadly === 4400, "L5×4 deadly = 1100×4");
|
||||||
|
|
||||||
|
// Solo L1 character.
|
||||||
|
const solo = encounterBudget(1, 1);
|
||||||
|
assert(solo.easy === 25 && solo.deadly === 100, "L1×1 matches table");
|
||||||
|
|
||||||
|
// Level clamps to 1..20; size clamps to >=1.
|
||||||
|
const low = encounterBudget(0, 1);
|
||||||
|
assert(low.easy === 25, "level clamps up to 1");
|
||||||
|
const high = encounterBudget(99, 1);
|
||||||
|
assert(high.deadly === 12700, "level clamps down to 20");
|
||||||
|
const zero = encounterBudget(5, 0);
|
||||||
|
assert(zero.medium === 500, "size clamps up to 1");
|
||||||
|
|
||||||
|
// difficultyForXp: budget-equal XP lands in the *next* tier up (>=, not >),
|
||||||
|
// and anything below the easy floor is still Easy (never undefined).
|
||||||
|
const d = encounterBudget(5, 4);
|
||||||
|
assert(difficultyForXp(999, d) === "Easy", "below easy floor stays Easy");
|
||||||
|
assert(difficultyForXp(1000, d) === "Easy", "== easy budget is Easy");
|
||||||
|
assert(difficultyForXp(2000, d) === "Medium", "== medium budget is Medium");
|
||||||
|
assert(difficultyForXp(3000, d) === "Hard", "== hard budget is Hard");
|
||||||
|
assert(difficultyForXp(4400, d) === "Deadly", "== deadly budget is Deadly");
|
||||||
|
assert(difficultyForXp(9999, d) === "Deadly", "over deadly stays Deadly");
|
||||||
|
|
||||||
|
console.log("encounter-budget self-check passed ✓");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user