Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65aee44d6b | |||
| abf3668081 | |||
| 35ec227531 | |||
| d2eea494fe | |||
| 639d768bf1 |
@@ -15,6 +15,9 @@ dist-ssr
|
||||
# Rust / Tauri
|
||||
src-tauri/target/
|
||||
|
||||
# Apple signing credentials (never commit)
|
||||
scripts/apple-signing.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -25,3 +28,4 @@ src-tauri/target/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
secrets/
|
||||
|
||||
@@ -1,32 +1,129 @@
|
||||
# React + TypeScript + Vite
|
||||
# DM-Pal
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
An offline-first desktop toolkit for Dungeon Masters, powered by a local LLM.
|
||||
Built with **Tauri v2** + **Rust** on the back end and **React 19** + **TypeScript**
|
||||
on the front. Every generator is grounded in your own world bible via a local
|
||||
RAG index — no data leaves your machine unless you point it at a remote API.
|
||||
|
||||
Currently, two official plugins are available:
|
||||

|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
## What's in the box
|
||||
|
||||
## React Compiler
|
||||
DM-Pal packs every tool a DM reaches for at and between the table, grouped
|
||||
into **Session** (live) and **World** (prep) tools:
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
- **Initiative Tracker** — combatants, HP, conditions, death saves, turn timer
|
||||
- **Dice Roller** — notation parsing, advantage/disadvantage, roll templates
|
||||
- **Encounter Builder** — AI-generated encounters with a 5e XP budget
|
||||
- **NPC Generator** — portraits, personality, goals, stat blocks
|
||||
- **Quest Designer** — multi-step quests with twists and reward breakdown
|
||||
- **Item Forge** — magic items with art and structured mechanics
|
||||
- **Image Generator** — portraits, maps, scene art (macOS, via Ollama)
|
||||
- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary
|
||||
- **Soundboard** — synthesized ambience/SFX with one-click scenes
|
||||
- **World Builder** — generated regions, landmarks, a draggable-pin map
|
||||
- **Lore (RAG)** — index your world bible, ground every generation in it
|
||||
- **Calendar** — custom fantasy calendars, weather, moon phases, events
|
||||
- **Random Tables** — built-in and custom tables, weighted rolls
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
A **⌘K command palette**, **History view** (re-open any past generation), and
|
||||
persistent state round it out — reload loses nothing.
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
## Quick start
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
### Prerequisites
|
||||
|
||||
- **Rust** + **Cargo** — https://rustup.rs
|
||||
- **Node.js** 20+ — https://nodejs.org
|
||||
- **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`)
|
||||
|
||||
### Install & run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
On first launch open **Settings (⌘,)** and confirm the API URL, then pull a
|
||||
text model (e.g. `llama3.2`) and an embedding model (e.g. `nomic-embed-text`):
|
||||
|
||||
```bash
|
||||
ollama pull llama3.2
|
||||
ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
Optionally, for image generation (macOS / Apple Silicon only):
|
||||
|
||||
```bash
|
||||
ollama pull x/flux2-klein:4b
|
||||
```
|
||||
|
||||
## Where your data lives
|
||||
|
||||
All campaign data stays on disk under your OS app-data dir (default
|
||||
`$APPDATA/dm-toolkit/`, configurable in Settings):
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `lore.db` | RAG chunks + embeddings (SQLite) |
|
||||
| `generations.db` | History of every generated NPC/encounter/item/quest/… |
|
||||
| `images/` | Cached generated PNGs, keyed by prompt hash |
|
||||
| `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) |
|
||||
|
||||
Settings → **Data location** lets you relocate everything to an external drive
|
||||
and migrates existing data for you.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
React 19 + TypeScript ──invoke()/Channel──▶ Rust (Tauri v2)
|
||||
Zustand · framer-motion LLM (Ollama / OpenAI-compatible)
|
||||
react-konva (world map) SQLite (rusqlite, bundled)
|
||||
Tailwind v4 + glassmorphism RAG (brute-force cosine → sqlite-vec path)
|
||||
```
|
||||
|
||||
Each tool is a self-contained component mounted behind a single `renderView`
|
||||
switch; the dashboard is a launcher of at-a-glance tiles. The design system is
|
||||
dark-only (navy + Cinzel + gold), self-hosts its fonts for true offline use,
|
||||
and meets WCAG AA contrast.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server (frontend only)
|
||||
npm run tauri dev # Full app, hot-reload
|
||||
npm run build # tsc -b && vite build
|
||||
npm run lint # oxlint
|
||||
node scripts/check-worldmap.ts # world-map layout self-check
|
||||
node scripts/check-encounter-budget.ts # XP-budget self-check
|
||||
node scripts/check-dice.ts # dice-notation parser self-check
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/ React front end
|
||||
components/ one file per tool
|
||||
lib/ pure logic + persistence hooks
|
||||
src-tauri/src/ Rust back end
|
||||
commands/ Tauri IPC commands (llm, image, rag, data, generation)
|
||||
llm/ Ollama/OpenAI client + config
|
||||
rag/ embedding + cosine search
|
||||
generations/ history store
|
||||
docs/ plan + UI/UX review
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [`docs/plan.md`](docs/plan.md) for the full plan and `docs/ui-ux-improvements.md`
|
||||
for the living UI/UX review with a prioritized checklist.
|
||||
|
||||
Planned / in progress: first-run model wizard, quest branching graph, world
|
||||
hierarchy tree, real ambience packs, GitHub Actions CI, code signing, and an
|
||||
auto-updater.
|
||||
|
||||
## License
|
||||
|
||||
TBD. Built-in rule references use only 5e SRD / OGL / CC-BY content. Model files
|
||||
ship under their own licenses (e.g. Meta's Llama license) — accept them in
|
||||
Ollama before pulling.
|
||||
+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!**
|
||||
|
||||
Happy crafting — may your dice always land favorably! 🎲
|
||||
@@ -7,12 +7,12 @@ recommendation.*
|
||||
> **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative,
|
||||
> Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger,
|
||||
> Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/
|
||||
> reward fields shipped; remaining P2 items are larger-effort or backend-
|
||||
> gated: image-gen advanced params (Ollama API doesn't support them),
|
||||
> soundboard scenes + real ambience packs, world hierarchy tree, lore
|
||||
> directory picker (needs tauri-plugin-dialog), NPC roster / item inventory /
|
||||
> quest roster (History view covers re-opening). The checklist below is
|
||||
> updated as items land.
|
||||
> reward fields shipped; soundboard scenes + image gallery/batch shipped;
|
||||
> remaining P2 items are larger-effort or backend-gated: image-gen advanced
|
||||
> params (Ollama API doesn't support them), soundboard real ambience packs +
|
||||
> custom sound import, world hierarchy tree, lore directory picker (needs
|
||||
> tauri-plugin-dialog), NPC roster / item inventory / quest roster (History
|
||||
> view covers re-opening). The checklist below is updated as items land.
|
||||
|
||||
---
|
||||
|
||||
@@ -1095,8 +1095,8 @@ These are blockers or bugs that make the app feel broken.
|
||||
- [ ] Quest roster (History view covers re-opening past quests)
|
||||
- [ ] **Image Generator**:
|
||||
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
||||
- [ ] Batch mode (2×2 variants)
|
||||
- [ ] Gallery view with thumbnails
|
||||
- [x] Batch mode (2×2 variants)
|
||||
- [x] Gallery view with thumbnails
|
||||
- [x] Persist to campaign-scoped folder
|
||||
- [x] Copy to clipboard
|
||||
- [x] **Session Logger**:
|
||||
@@ -1117,7 +1117,7 @@ These are blockers or bugs that make the app feel broken.
|
||||
- [ ] **Soundboard**:
|
||||
- [ ] Real ambience pack (2–3 royalty-free loops)
|
||||
- [ ] Custom sound import (drag-drop)
|
||||
- [ ] Scenes (one-click combinations)
|
||||
- [x] Scenes (one-click combinations)
|
||||
- [x] Master mute + per-sound volume
|
||||
- [x] Always-visible Stop All
|
||||
- [x] Save/load board state
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dm-pal",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copy to scripts/apple-signing.env and fill in. This file is gitignored.
|
||||
# Required for a signed + notarized DMG that Gatekeeper accepts on download.
|
||||
#
|
||||
# 1. APPLE_SIGNING_IDENTITY: the exact string from `security find-identity -v -p codesigning`
|
||||
# that starts with "Developer ID Application: ...". You must create that certificate
|
||||
# first at https://developer.apple.com/account/resources/certificates/list
|
||||
# (Cert type = "Developer ID Application", NOT "Apple Development").
|
||||
# 2. APPLE_ID: your Apple ID email.
|
||||
# 3. APPLE_PASSWORD: an app-specific password from https://account.apple.com
|
||||
# (Sign-In and Security -> App-Specific Passwords). NOT your normal password.
|
||||
# 4. APPLE_TEAM_ID: 10-char Team ID from https://developer.apple.com/account#MembershipDetailsCard
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (4759TW4SDC)"
|
||||
export APPLE_ID="james.twose2711@gmail.com"
|
||||
export APPLE_PASSWORD="xxxx-xxxx-xxxx-xxxx"
|
||||
export APPLE_TEAM_ID="4759TW4SDC"
|
||||
@@ -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,8 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload the built macOS artifacts to a Gitea release. No runners, no CI.
|
||||
# Auth comes from the macOS keychain (same creds `git` uses to push).
|
||||
# Usage: ./scripts/gitea-release.sh [v0.1.0]
|
||||
# Requires: jq, curl, and a prior `npm run tauri build`.
|
||||
# Usage: ./scripts/gitea-release.sh [v0.2.0]
|
||||
# No arg = auto patch-bump (0.1.0 -> 0.1.1). Explicit arg syncs version files.
|
||||
# Requires: jq, curl, npm run tauri build deps. If scripts/apple-signing.env is
|
||||
# present, builds a signed + notarized DMG automatically; else ad-hoc (unsigned).
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="https://gitea.jms.rocks"
|
||||
@@ -23,11 +25,49 @@ host=$HOST" 2>/dev/null || true)
|
||||
printf 'Authorization: Basic %s' "$(printf '%s:%s' "$user" "$pass" | base64)"
|
||||
}
|
||||
|
||||
TAG="${1:-v$(jq -r .version package.json)}"
|
||||
# Resolve the release tag. No arg => auto-bump patch from the current version.
|
||||
# An explicit arg (v0.2.0) wins and also syncs both version files to it, so the
|
||||
# built DMG's embedded version always matches the release tag.
|
||||
# ponytail: patch-only auto-bump; pass v0.2.0 / v1.0.0 for minor/major.
|
||||
if [ -n "${1:-}" ]; then
|
||||
VER="${1#v}"
|
||||
else
|
||||
VER=$(node -e 'const v=require("./package.json").version;const [a,b,c]=v.split(".").map(Number);process.stdout.write(`${a}.${b}.${c+1}`)')
|
||||
fi
|
||||
TAG="v$VER"
|
||||
node -e '
|
||||
const fs=require("fs"),ver=process.argv[1];
|
||||
for(const f of ["package.json","src-tauri/tauri.conf.json"]){
|
||||
const t=fs.readFileSync(f,"utf8");
|
||||
const out=t.replace(/("version"\s*:\s*")\d+\.\d+\.\d+(")/,`$1${ver}$2`);
|
||||
if(out===t)throw new Error("version not replaced in "+f);
|
||||
fs.writeFileSync(f,out);
|
||||
}
|
||||
' "$VER"
|
||||
echo "release $TAG (version files synced)"
|
||||
git add package.json src-tauri/tauri.conf.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: release $TAG" -q
|
||||
echo "committed version bump"
|
||||
fi
|
||||
|
||||
# If Apple signing creds are present, do a signed + notarized build now so the
|
||||
# uploaded DMG passes Gatekeeper on a browser download. Without these the DMG
|
||||
# is only ad-hoc signed and macOS shows it as "damaged" when downloaded.
|
||||
ENV_FILE="$(dirname "$0")/apple-signing.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
echo "Building signed + notarized DMG ..."
|
||||
npm run tauri build -- --bundles app,dmg
|
||||
else
|
||||
echo "warn: scripts/apple-signing.env missing — building unsigned (ad-hoc) DMG."
|
||||
echo " Browser downloads will be blocked by Gatekeeper. See apple-signing.env.example."
|
||||
npm run tauri build -- --bundles app,dmg
|
||||
fi
|
||||
|
||||
DMG=$(ls -t src-tauri/target/release/bundle/dmg/*.dmg | head -1)
|
||||
APP_BUNDLE=$(ls -d src-tauri/target/release/bundle/macos/*.app | head -1)
|
||||
[ -f "$DMG" ] || { echo "no dmg found — run 'npm run tauri build' first"; exit 1; }
|
||||
[ -f "$DMG" ] || { echo "no dmg found — build failed"; exit 1; }
|
||||
[ -d "$APP_BUNDLE" ] || { echo "no .app found"; exit 1; }
|
||||
|
||||
# ponytail: ship the dmg as the installer; also zip the .app for unmanaged
|
||||
@@ -52,4 +92,12 @@ for f in "$DMG" "$ZIP"; do
|
||||
-H "$AUTH" -F "attachment=@$f" -o /dev/null
|
||||
done
|
||||
|
||||
# ponytail: guard against shipping an unsigned bundle by mistake — if creds
|
||||
# were configured, confirm the app is actually Developer-ID signed.
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
codesign -dv --verbose=2 "$APP_BUNDLE" 2>&1 | grep -q "Authority=Developer ID" \
|
||||
|| { echo "error: APPLE creds set but app is not Developer-ID signed — notarization failed or skipped"; exit 1; }
|
||||
echo "verified: Developer ID signature present"
|
||||
fi
|
||||
|
||||
echo "done -> $GITEA_URL/$OWNER_REPO/releases/tag/$TAG"
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::commands::emit_busy;
|
||||
use crate::llm::AppState;
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
// ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key.
|
||||
|
||||
@@ -74,8 +76,18 @@ pub enum ImageEvent {
|
||||
#[tauri::command]
|
||||
pub async fn generate_image(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: ImageRequest,
|
||||
) -> Result<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")) {
|
||||
return Err("image generation is macOS-only via Ollama (for now)".into());
|
||||
}
|
||||
@@ -100,6 +112,7 @@ pub async fn generate_image(
|
||||
#[tauri::command]
|
||||
pub async fn generate_image_stream(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: ImageRequest,
|
||||
channel: Channel<ImageEvent>,
|
||||
) -> Result<(), String> {
|
||||
@@ -124,6 +137,9 @@ pub async fn generate_image_stream(
|
||||
// and progress flows through the channel. Errors become ImageEvent::Error.
|
||||
let channel = std::sync::Arc::new(channel);
|
||||
let ch = channel.clone();
|
||||
// ponytail: only emit busy around the actual generation (not cache hits),
|
||||
// and always balance it in the spawn — even on error.
|
||||
emit_busy(&app, true);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await {
|
||||
Ok(png_bytes) => {
|
||||
@@ -134,6 +150,7 @@ pub async fn generate_image_stream(
|
||||
let _ = channel.send(ImageEvent::Error(e));
|
||||
}
|
||||
}
|
||||
emit_busy(&app, false);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
use crate::commands::emit_busy;
|
||||
use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse};
|
||||
use serde_json::json;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::AppHandle;
|
||||
|
||||
// ─── Simple (non-streaming) generate ─────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result<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 guard = state.config.lock().map_err(|e| e.to_string())?;
|
||||
guard.clone()
|
||||
@@ -32,6 +48,7 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -
|
||||
#[tauri::command]
|
||||
pub async fn generate_stream(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app: AppHandle,
|
||||
req: GenerateRequest,
|
||||
channel: Channel<LlmEvent>,
|
||||
) -> Result<(), String> {
|
||||
@@ -43,6 +60,7 @@ pub async fn generate_stream(
|
||||
let max_tokens = req.max_tokens.unwrap_or(config.max_tokens);
|
||||
let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await;
|
||||
|
||||
emit_busy(&app, true);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// For now, we do a non-streaming call and emit the full response as one token
|
||||
// Real SSE streaming from Ollama/OpenAI can be added later
|
||||
@@ -61,6 +79,8 @@ pub async fn generate_stream(
|
||||
let _ = channel.send(LlmEvent::Error(e));
|
||||
}
|
||||
}
|
||||
// ponytail: always balance the busy counter, even on error.
|
||||
emit_busy(&app, false);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -3,3 +3,20 @@ pub mod image_commands;
|
||||
pub mod rag_commands;
|
||||
pub mod generation_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 });
|
||||
}
|
||||
@@ -75,3 +75,93 @@ pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result<V
|
||||
.map(|(id, preview)| Ok(RagChunk { id, preview }))
|
||||
.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(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
commands::llm_commands::generate,
|
||||
commands::llm_commands::generate_stream,
|
||||
commands::llm_commands::get_llm_config,
|
||||
@@ -69,6 +68,7 @@ pub fn run() {
|
||||
commands::rag_commands::rag_list,
|
||||
commands::rag_commands::rag_clear,
|
||||
commands::rag_commands::rag_chunks,
|
||||
commands::rag_commands::rag_add_directory,
|
||||
commands::generation_commands::generation_add,
|
||||
commands::generation_commands::generation_list,
|
||||
commands::generation_commands::generation_get,
|
||||
@@ -83,7 +83,6 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! Welcome to DM-Pal ⚔", name)
|
||||
}
|
||||
// ponytail: removed the scaffold `greet` command + its frontend component —
|
||||
// nothing in the shell referenced it. Kept here as a marker so the
|
||||
// invoke_handler list above stays in sync.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "DM-Pal",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"identifier": "com.dm-pal.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -29,6 +29,10 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"macOS": {
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": true
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+72
-54
@@ -33,8 +33,10 @@ import { LorePanel } from "./components/LorePanel";
|
||||
import { ImageGenerator } from "./components/ImageGenerator";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
import { CommandPalette } from "./components/CommandPalette";
|
||||
import { ShortcutHelp } from "./components/ShortcutHelp";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { ConnectionPill } from "./components/ConnectionPill";
|
||||
import { GeneratingIndicator } from "./components/GeneratingIndicator";
|
||||
import { HistoryView } from "./components/HistoryView";
|
||||
import type { Generation, GenerationKind } from "./lib/generations";
|
||||
|
||||
@@ -56,17 +58,47 @@ export type View =
|
||||
| "settings"
|
||||
| "history";
|
||||
|
||||
// ponytail: tools that accept a prefill from history. Mirrors the kinds.
|
||||
const PREFILLABLE: Partial<Record<View, GenerationKind>> = {
|
||||
npcs: "npc",
|
||||
encounter: "encounter",
|
||||
world: "world",
|
||||
items: "item",
|
||||
quest: "quest",
|
||||
session: "session",
|
||||
image: "image",
|
||||
// ponytail: ONE source of truth per tool — kind (for history prefill),
|
||||
// the prefill-aware component, and the preferred max-width. Adding a tool
|
||||
// is a single entry here; the KIND_TO_VIEW inverse and renderView derive
|
||||
// from it. Replaces the old PREFILLABLE / viewMaxWidth / PREFILLABLE_TOOLS
|
||||
// triple that drifted whenever a tool was added.
|
||||
type PrefillComponent = React.ComponentType<{
|
||||
prefill?: Generation | null;
|
||||
onPrefillConsumed?: () => void;
|
||||
}>;
|
||||
|
||||
interface ToolMeta {
|
||||
kind?: GenerationKind;
|
||||
Comp?: PrefillComponent;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
const TOOL_META: Partial<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";
|
||||
|
||||
interface NavItem {
|
||||
@@ -100,19 +132,16 @@ function renderView(
|
||||
onPrefillConsumed: () => void,
|
||||
rehydrate: (kind: GenerationKind, data: Generation) => void,
|
||||
): React.ReactNode {
|
||||
// ponytail: history view renders its own list/detail panes, so it doesn't
|
||||
// use the prefill or wrapper pattern.
|
||||
// ponytail: history renders its own list/detail panes — no prefill wrapper.
|
||||
if (view === "history") {
|
||||
return <HistoryView onRehydrate={rehydrate} />;
|
||||
}
|
||||
// Tools that accept prefill.
|
||||
if (view === "npcs") return <NpcGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "encounter") return <EncounterBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "world") return <WorldBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
if (view === "items") return <ItemForge 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} />;
|
||||
// ponytail: prefill-aware tools come straight from TOOL_META — one entry
|
||||
// per tool instead of a parallel if-chain that drifted with the maps.
|
||||
const meta = TOOL_META[view];
|
||||
if (meta?.Comp) {
|
||||
return <meta.Comp prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
||||
}
|
||||
switch (view) {
|
||||
case "dice":
|
||||
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() {
|
||||
const [view, setView] = useState<View>("dashboard");
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
// ponytail: prefill is set when the user clicks "Open in tool" in History.
|
||||
// The targeted tool reads it via usePrefillEffect and calls onConsumed
|
||||
// to clear it. State lives here so the rehydrate→switch→consume dance
|
||||
@@ -173,9 +174,7 @@ export default function App() {
|
||||
const [prefill, setPrefill] = useState<Generation | null>(null);
|
||||
|
||||
function rehydrate(kind: GenerationKind, data: Generation) {
|
||||
const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find(
|
||||
(v) => PREFILLABLE[v] === kind,
|
||||
);
|
||||
const target = KIND_TO_VIEW[kind];
|
||||
if (!target) return;
|
||||
setPrefill(data);
|
||||
setView(target);
|
||||
@@ -198,6 +197,21 @@ export default function App() {
|
||||
setPaletteOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && helpOpen) {
|
||||
setHelpOpen(false);
|
||||
return;
|
||||
}
|
||||
// ponytail: `?` opens the shortcut cheatsheet — the rail is icon-only,
|
||||
// so without this the digit shortcuts are undiscoverable. Not while
|
||||
// typing in an input (Shift+/ on a US layout yields `?`).
|
||||
if (
|
||||
e.key === "?" &&
|
||||
!(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
setHelpOpen((o) => !o);
|
||||
return;
|
||||
}
|
||||
// Digit shortcuts jump to nav items, but not while typing in an input.
|
||||
if (
|
||||
!paletteOpen &&
|
||||
@@ -221,7 +235,7 @@ export default function App() {
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [paletteOpen]);
|
||||
}, [paletteOpen, helpOpen]);
|
||||
|
||||
const isOnDashboard = view === "dashboard";
|
||||
const groupedNav = useMemo(
|
||||
@@ -245,7 +259,7 @@ export default function App() {
|
||||
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<item.icon size={item.view === "tables" ? 18 : 18} />
|
||||
<item.icon size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -356,6 +370,7 @@ export default function App() {
|
||||
</kbd>
|
||||
</button>
|
||||
<div className="ml-auto" data-tauri-no-drag />
|
||||
<GeneratingIndicator />
|
||||
<ConnectionPill />
|
||||
</header>
|
||||
|
||||
@@ -367,7 +382,7 @@ export default function App() {
|
||||
) : view === "history" ? (
|
||||
<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" ? (
|
||||
<div className="glass-card p-6">
|
||||
<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 */}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface DieResult {
|
||||
notation: string;
|
||||
rolls: number[];
|
||||
total: number;
|
||||
modifier: number;
|
||||
advantage?: "adv" | "dis" | null;
|
||||
keptRoll?: number;
|
||||
}
|
||||
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
|
||||
|
||||
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
|
||||
|
||||
@@ -23,38 +15,13 @@ const TEMPLATES: { label: string; die: string }[] = [
|
||||
{ label: "Damage", die: "d8" },
|
||||
];
|
||||
|
||||
type Mode = "normal" | "adv" | "dis";
|
||||
|
||||
function rollDie(sides: number): number {
|
||||
return Math.floor(Math.random() * sides) + 1;
|
||||
}
|
||||
|
||||
function parseNotation(
|
||||
notation: string,
|
||||
): { count: number; sides: number; modifier: number } | null {
|
||||
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
count: parseInt(match[1] || "1"),
|
||||
sides: parseInt(match[2]),
|
||||
modifier: parseInt(match[3] || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
// ponytail: advantage/disadvantage is a 5e concept. We implement it as
|
||||
// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only
|
||||
// meaningful for d20 rolls; for other dice we just roll normally.
|
||||
function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } {
|
||||
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
|
||||
return { rolls, kept: rolls[0] ?? 0 };
|
||||
}
|
||||
if (mode === "adv") {
|
||||
const hi = Math.max(rolls[0], rolls[1]);
|
||||
return { rolls, kept: hi };
|
||||
}
|
||||
const lo = Math.min(rolls[0], rolls[1]);
|
||||
return { rolls, kept: lo };
|
||||
}
|
||||
// ponytail: parseNotation + applyMode live in src/lib/dice.ts so they ship
|
||||
// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
|
||||
// types are imported from there too.
|
||||
|
||||
export function DiceRoller() {
|
||||
const [input, setInput] = usePersistentState<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 {
|
||||
History as HistoryIcon,
|
||||
@@ -44,6 +44,33 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: gallery thumbnails — fetch each image generation's data URL on
|
||||
// demand when the image filter is active. N queries against local SQLite
|
||||
// is fine for a campaign's worth of images; add a list-with-data command
|
||||
// if it ever gets slow. Ref is the source of truth; state mirrors it to
|
||||
// trigger re-renders when a thumbnail lands.
|
||||
const thumbsRef = useRef<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 () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -101,6 +128,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
try {
|
||||
await deleteGeneration(id);
|
||||
setSummaries((prev) => prev.filter((s) => s.id !== id));
|
||||
thumbsRef.current.delete(id);
|
||||
setThumbs(new Map(thumbsRef.current));
|
||||
if (selected?.id === id) setSelected(null);
|
||||
} catch (e) {
|
||||
addToast(`Delete failed: ${e}`, "error");
|
||||
@@ -114,6 +143,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
const n = await clearAllGenerations();
|
||||
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
|
||||
setSummaries([]);
|
||||
thumbsRef.current.clear();
|
||||
setThumbs(new Map(thumbsRef.current));
|
||||
setSelected(null);
|
||||
setConfirmClear(false);
|
||||
} catch (e) {
|
||||
@@ -232,6 +263,37 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeKind === "image" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filtered.map((s) => {
|
||||
const src = thumbs.get(s.id);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
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
|
||||
? "border-[var(--color-gold-bright)]"
|
||||
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
title={s.title}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={s.title} className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-text-dim)]" />
|
||||
</div>
|
||||
)}
|
||||
<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}
|
||||
@@ -255,6 +317,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [variants, setVariants] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [variant, setVariant] = useState(0);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
@@ -31,6 +32,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
if (!prompt.trim()) return;
|
||||
setLoading(true);
|
||||
setDataUrl(null);
|
||||
setVariants([]);
|
||||
try {
|
||||
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||
// so "regenerate" actually produces a new image instead of the cached one.
|
||||
@@ -51,6 +53,44 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
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() {
|
||||
setVariant((v) => v + 1);
|
||||
// run after state update flushes
|
||||
@@ -90,6 +130,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
>
|
||||
{loading ? "✨ Generating…" : "✨ Generate Image"}
|
||||
</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 && (
|
||||
<button
|
||||
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 && (
|
||||
<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)]">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 { addToLore } from "../lib/lore";
|
||||
|
||||
@@ -25,6 +26,7 @@ export function LorePanel() {
|
||||
const [text, setText] = useState("");
|
||||
const [sources, setSources] = useState<RagSource[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addingDir, setAddingDir] = useState(false);
|
||||
const [msg, setMsg] = 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");
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
@@ -168,6 +198,16 @@ export function LorePanel() {
|
||||
onChange={onFiles}
|
||||
/>
|
||||
</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>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+110
-32
@@ -27,10 +27,16 @@ const SFX_SOUNDS = [
|
||||
{ id: "footsteps", label: "Steps", icon: "👣" },
|
||||
];
|
||||
|
||||
type Scene = { name: string; ambients: string[] };
|
||||
|
||||
export function Soundboard() {
|
||||
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
||||
const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
|
||||
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 nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
||||
const { addToast } = useToast();
|
||||
@@ -81,26 +87,10 @@ export function Soundboard() {
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const toggleAmbient = useCallback((id: string) => {
|
||||
setActiveAmbients((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
// Stop the sound
|
||||
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 {
|
||||
next.add(id);
|
||||
// Start the sound
|
||||
// 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 next;
|
||||
|
||||
if (!sound) return;
|
||||
const ctx = getAudioCtx();
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.value = 0; // Start silent, fade in
|
||||
@@ -111,7 +101,6 @@ export function Soundboard() {
|
||||
filter.frequency.value = sound.filterFreq;
|
||||
|
||||
let source: OscillatorNode | AudioBufferSourceNode;
|
||||
|
||||
if (sound.type === "brown") {
|
||||
// Brown noise via buffer
|
||||
const bufferSize = ctx.sampleRate * 2;
|
||||
@@ -128,7 +117,6 @@ export function Soundboard() {
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
} else {
|
||||
// Oscillator
|
||||
source = ctx.createOscillator();
|
||||
source.type = sound.type;
|
||||
source.frequency.value = sound.freq;
|
||||
@@ -138,12 +126,50 @@ export function Soundboard() {
|
||||
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) => {
|
||||
setActiveAmbients((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
stopAmbient(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
startAmbient(id);
|
||||
}
|
||||
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) => {
|
||||
if (muted) return; // ponytail: master mute gates one-shot SFX too.
|
||||
@@ -359,18 +385,70 @@ export function Soundboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop all — always visible so the DM never hunts for it. */}
|
||||
{/* 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={() => {
|
||||
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());
|
||||
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. */}
|
||||
<button
|
||||
onClick={stopAll}
|
||||
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"
|
||||
>
|
||||
|
||||
+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)",
|
||||
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