working through a review
This commit is contained in:
+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,29 +263,62 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
{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}
|
||||
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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+141
-63
@@ -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,69 +87,89 @@ export function Soundboard() {
|
||||
});
|
||||
}, [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) => {
|
||||
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);
|
||||
}
|
||||
stopAmbient(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
// Start the sound
|
||||
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 });
|
||||
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>
|
||||
|
||||
{/* 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. */}
|
||||
<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());
|
||||
}}
|
||||
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