-
-
- {group.label}
-
-
-
-
- {({ fullKey: fk, def }) => {
- const val = () => values()[fk];
- const comp = () => computedValue(fk);
- const hasModifiers = () => {
- for (const [mfk, md] of defMap()) {
- if (
- md.type === "modifier" &&
- md.target &&
- fullKeyTarget(md.target, md, def)
- ) {
- return true;
- }
- }
- return false;
- };
- const canRoll = () =>
- def.roll ||
- def.formula ||
- def.type === "enum" ||
- def.type === "template";
+
+
- return (
-
-
-
- {modifierLabel(def, statDefs())}
-
-
- {def.key}
-
-
-
- →{def.target}
-
-
-
+ {/* Sheet picker — only show if sheets are available */}
+
0}>
+
+ {/* Dropdown trigger */}
+
-
-
- {def.default ?? "—"}
-
- }
- >
-
- {val()}
-
-
-
-
-
- = {comp()}
-
-
-
-
-
-
-
-
- );
- }}
-
-
+ {/* Dropdown menu */}
+
+
+
+
+
+ {(sheet) => (
+
+ )}
+
- )}
-
+
+
+ {/* Backdrop to close picker */}
+
+ setPickerOpen(false)}
+ />
+
+
);
-};
+};
\ No newline at end of file
diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts
index 4a463a7..1f9969f 100644
--- a/src/components/journal/completions.ts
+++ b/src/components/journal/completions.ts
@@ -22,13 +22,14 @@ import {
parseStatModifiers,
} from "../../cli/completions/stat-parser";
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
+import type { StatSheet } from "../../cli/completions/types";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
parseSparkTableCsv,
} from "../../cli/completions/block-scanner";
-export type { StatDef, StatTemplate };
+export type { StatDef, StatTemplate, StatSheet };
// ------------------- Types (mirrors CLI) -------------------
@@ -58,6 +59,7 @@ export interface JournalCompletions {
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
+ statSheets: StatSheet[];
}
export type CompletionsState =
@@ -87,6 +89,7 @@ async function tryServer(): Promise
{
statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates
: [],
+ statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
};
} catch {
return null;
@@ -169,7 +172,46 @@ async function scanClientSide(): Promise {
}
}
- return { dice, links, sparkTables, stats, statTemplates };
+ return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };
+}
+
+// ------------------- Stat sheet helpers -------------------
+
+function extractSvgTitle(svg: string): string | null {
+ const m = /]*>([\s\S]*?)<\/title>/i.exec(svg);
+ return m ? m[1].trim() : null;
+}
+
+function labelFromId(id: string): string {
+ return id
+ .replace(/[-_]/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+async function scanStatSheets(): Promise {
+ const paths = await getPathsByExtension("svg");
+ const sheets: StatSheet[] = [];
+
+ for (const filePath of paths) {
+ if (!/\.sheet\.svg$/i.test(filePath)) continue;
+ try {
+ const content = await getIndexedData(filePath);
+ if (!content) continue;
+ const fileName = filePath.split("/").filter(Boolean).pop() || filePath;
+ const id = fileName.replace(/\.sheet\.svg$/i, "");
+ const title = extractSvgTitle(content);
+ sheets.push({
+ id,
+ label: title || labelFromId(id),
+ svg: content,
+ source: filePath,
+ });
+ } catch {
+ // skip unreadable files
+ }
+ }
+
+ return sheets;
}
// ------------------- Init (runs eagerly at import time) -------------------
@@ -186,7 +228,8 @@ const _initPromise: Promise = (async () => {
// 2. Fall back to client-side scan (dev/browser mode)
try {
const data = await scanClientSide();
- if (data.dice.length > 0 || data.links.length > 0) {
+ data.statSheets = await scanStatSheets();
+ if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
setCompletionsState({ status: "loaded", data });
} else {
setCompletionsState({ status: "empty" });
@@ -237,6 +280,7 @@ export function useJournalCompletions(): {
sparkTables: [],
stats: [],
statTemplates: [],
+ statSheets: [],
},
};
}
diff --git a/src/components/journal/stat-sheet.ts b/src/components/journal/stat-sheet.ts
new file mode 100644
index 0000000..8fb21c8
--- /dev/null
+++ b/src/components/journal/stat-sheet.ts
@@ -0,0 +1,64 @@
+/**
+ * Stat sheet template engine — parses *.sheet.svg files and extracts
+ * elements containing ${key} template patterns.
+ *
+ * The SVG element is parsed once via DOMParser. The caller (StatsView)
+ * reactively updates the text nodes whenever the stats store changes.
+ */
+
+/** Regex matching ${key} template patterns (captures the key name). */
+const TEMPLATE_RE = /\$\{(\w+)\}/g;
+
+/** A single template binding in a element. */
+export interface TextTemplate {
+ el: SVGTextElement;
+ template: string;
+ keys: string[];
+}
+
+/** The result of parsing a stat sheet SVG string. */
+export interface ParsedSheet {
+ svgElement: SVGSVGElement;
+ templates: TextTemplate[];
+}
+
+/**
+ * Parse an SVG string into a live DOM element and extract all text
+ * elements that contain ${key} template patterns.
+ */
+export function parseSheet(svgString: string): ParsedSheet {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(svgString, "image/svg+xml");
+ const svgElement = doc.documentElement as unknown as SVGSVGElement;
+
+ if (!svgElement || svgElement.tagName !== "svg") {
+ return {
+ svgElement: document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "svg",
+ ) as SVGSVGElement,
+ templates: [],
+ };
+ }
+
+ const templates: TextTemplate[] = [];
+ const textEls = svgElement.querySelectorAll("text");
+
+ for (const el of textEls) {
+ const content = el.textContent;
+ if (!content) continue;
+
+ TEMPLATE_RE.lastIndex = 0;
+ const keys: string[] = [];
+ let m: RegExpExecArray | null;
+ while ((m = TEMPLATE_RE.exec(content)) !== null) {
+ keys.push(m[1]);
+ }
+
+ if (keys.length > 0) {
+ templates.push({ el: el as SVGTextElement, template: content, keys });
+ }
+ }
+
+ return { svgElement, templates };
+}
\ No newline at end of file
diff --git a/src/data-loader/file-index.ts b/src/data-loader/file-index.ts
index 7e60ab4..9009121 100644
--- a/src/data-loader/file-index.ts
+++ b/src/data-loader/file-index.ts
@@ -50,7 +50,7 @@ function ensureIndexLoaded(): Promise {
try {
const context = import.meta.webpackContext("../../content", {
recursive: true,
- regExp: /\.md|\.yarn|\.csv$/i,
+ regExp: /\.md|\.yarn|\.csv|\.svg$/i,
});
const keys = context.keys();
const index: FileIndex = {};
@@ -94,7 +94,7 @@ async function scanDirectory(
prefix = "",
): Promise {
const index: FileIndex = {};
- const acceptedExt = /\.(md|yarn|csv)$/i;
+ const acceptedExt = /\.(md|yarn|csv|svg)$/i;
for await (const [name, entry] of (handle as any).entries()) {
if (entry.kind === "directory") {