diff --git a/src/App.tsx b/src/App.tsx
index f25fcc8..0b6028a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -19,6 +19,7 @@ import {
DataSourceDialog,
RevealManager,
ReactiveStatManager,
+ CommandLinkManager,
} from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal";
@@ -158,6 +159,7 @@ const App: Component = () => {
>
+
diff --git a/src/components/CommandLinkManager.tsx b/src/components/CommandLinkManager.tsx
new file mode 100644
index 0000000..4861f03
--- /dev/null
+++ b/src/components/CommandLinkManager.tsx
@@ -0,0 +1,224 @@
+/**
+ * CommandLinkManager — mounts as a child of and intercepts
+ * clicks on command-like links (>roll, >spark, >link, >stat) to send
+ * them as journal stream messages instead of navigating.
+ *
+ * Uses capture-phase event delegation so it fires before the Article
+ * component's own link interception (which would otherwise navigate).
+ */
+
+import { Component, onCleanup } from "solid-js";
+import { useArticleDom } from "./Article";
+import { useJournalStream, sendMessage } from "./stores/journalStream";
+import { useJournalCompletions } from "./journal/completions";
+import { parseInput } from "./journal/command-parser";
+import { resolveRollPayload } from "./journal/types/roll";
+import { resolveSparkPayload } from "./journal/types/spark";
+import {
+ resolveStatRoll,
+ resolveTemplateSet,
+ canModifyStat,
+ fullKey,
+ findStatDef,
+} from "./journal/stat-helpers";
+
+/** Commands that get intercepted as stream messages. */
+const COMMAND_PREFIXES = [">roll", ">spark", ">link", ">stat"];
+
+/**
+ * Extract the command text from an href.
+ * Percent-encoded spaces (%20) are decoded automatically by decodeURIComponent.
+ */
+function hrefToCommand(href: string): string | null {
+ // Exclude external URLs and pure anchors
+ if (/^(https?:|\/\/)/i.test(href)) return null;
+ if (href === "#" || href.startsWith("#!")) return null;
+
+ // Decode percent-encoding (%20 → space, etc.)
+ let cleaned = decodeURIComponent(href);
+
+ // Strip trailing .md extension (markdown links may include it)
+ if (cleaned.endsWith(".md")) {
+ cleaned = cleaned.slice(0, -3);
+ }
+
+ // Only consider links starting with a command prefix
+ if (!COMMAND_PREFIXES.some((p) => cleaned.startsWith(p))) return null;
+
+ return cleaned.trim();
+}
+
+// We need unwrap helper for sendMessage results
+function unwrap(r: { success: true; msg: R } | { success: false; error: string }) {
+ return r.success
+ ? ({ ok: true, err: undefined } as const)
+ : ({ ok: false, err: r.error } as const);
+}
+
+export const CommandLinkManager: Component = () => {
+ const contentDom = useArticleDom();
+ const stream = useJournalStream();
+ const comp = useJournalCompletions();
+
+ const isObserver = () => stream.myRole === "observer";
+ const isPlayer = () => stream.myRole === "player";
+
+ // ---- Click interceptor (capture phase) ----
+
+ const onClick = (e: MouseEvent) => {
+ const anchor = (e.target as HTMLElement).closest("a[href]");
+ if (!anchor) return;
+
+ const href = anchor.getAttribute("href");
+ if (!href) return;
+
+ const command = hrefToCommand(href);
+ if (!command) return;
+
+ // This is a command link — intercept it
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+
+ dispatchCommand(command);
+ };
+
+ const dom = contentDom();
+ if (dom) {
+ // Use capture phase to beat the Article component's bubble-phase handler
+ dom.addEventListener("click", onClick, true);
+ }
+
+ onCleanup(() => {
+ const d = contentDom();
+ if (d) d.removeEventListener("click", onClick, true);
+ });
+
+ // ---- Command dispatch ----
+
+ function dispatchCommand(raw: string) {
+ // Convert > prefix to / so parseInput understands it
+ const command = raw.startsWith(">") ? "/" + raw.slice(1) : raw;
+
+ // Observers: everything goes as chat
+ if (isObserver()) {
+ sendMessage("chat", { text: command });
+ return;
+ }
+
+ const parsed = parseInput(command);
+ if (parsed.error) return;
+
+ // Players: chat + stat commands only
+ if (isPlayer()) {
+ if (parsed.type === "chat") {
+ sendMessage("chat", { text: raw });
+ return;
+ }
+ if (parsed.type === "stat") {
+ dispatchStat(parsed.payload as Record);
+ return;
+ }
+ return;
+ }
+
+ // GM: all commands
+ if (parsed.type === "roll") {
+ const p = resolveRollPayload(
+ parsed.payload as { notation: string; label?: string },
+ );
+ sendMessage("roll", p);
+ } else if (parsed.type === "spark") {
+ void (async () => {
+ try {
+ const key = (parsed.payload as { key: string }).key;
+ const match = comp.data.sparkTables.find((s) => s.slug === key);
+ const csvPath = match?.csvPath ?? "";
+ const remix = match?.remix ?? false;
+ const p = await resolveSparkPayload({ key, csvPath, remix });
+ sendMessage("spark", p);
+ } catch {
+ // silently ignore spark table errors from links
+ }
+ })();
+ } else if (parsed.type === "stat") {
+ dispatchStat(parsed.payload as Record);
+ } else {
+ sendMessage(parsed.type, parsed.payload);
+ }
+ }
+
+ /** Resolve bare key -> full key (copied from JournalInput for standalone use). */
+ function resolveKey(
+ inputKey: string,
+ statDefs: typeof comp.data.stats,
+ playerName: string,
+ ): string {
+ if (statDefs.some((d) => fullKey(d, playerName) === inputKey)) return inputKey;
+ const def = statDefs.find((d) => d.key === inputKey);
+ if (def) return fullKey(def, playerName);
+ return inputKey;
+ }
+
+ function dispatchStat(payload: Record) {
+ const p = payload as { action?: string; key?: string; value?: string };
+
+ if (p.action === "roll" && p.key) {
+ const resolved = resolveStatRoll(
+ p.key,
+ comp.data.stats,
+ stream.stats,
+ stream.myName,
+ comp.data.statTemplates,
+ );
+ if (resolved.error) return;
+
+ const result = sendMessage("stat", {
+ action: "set",
+ key: resolved.fullKey,
+ value: resolved.value,
+ });
+ if (!unwrap(result).ok) return;
+
+ if (resolved.modifiers) {
+ for (const [mk, mv] of Object.entries(resolved.modifiers)) {
+ sendMessage("stat", { action: "set", key: mk, value: mv });
+ }
+ }
+ return;
+ }
+
+ if (!p.action || !p.key) return;
+
+ const fk = resolveKey(p.key, comp.data.stats, stream.myName);
+
+ if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
+ return;
+ }
+
+ sendMessage("stat", { action: p.action, key: fk, value: p.value });
+
+ if (p.action === "set" && p.value) {
+ const def = findStatDef(fk, comp.data.stats, stream.myName);
+ if (def) {
+ const modifiers = resolveTemplateSet(
+ def,
+ p.value,
+ comp.data.stats,
+ stream.stats,
+ stream.myName,
+ comp.data.statTemplates,
+ );
+ if (modifiers) {
+ for (const [mk, mv] of Object.entries(modifiers)) {
+ sendMessage("stat", { action: "set", key: mk, value: mv });
+ }
+ }
+ }
+ }
+ }
+
+ return null; // renders nothing — purely event-based
+};
+
+export default CommandLinkManager;
diff --git a/src/components/index.ts b/src/components/index.ts
index b455d86..ce2f4e3 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -15,6 +15,7 @@ import "./md-yarn-spinner";
export { Article } from "./Article";
export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager";
+export { CommandLinkManager } from "./CommandLinkManager";
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar";