@markbrutx/promptbook-viewer 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>promptbook viewer</title>
8
- <script type="module" crossorigin src="/assets/index-BwIAKPNq.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BCBuW76o.css">
8
+ <script type="module" crossorigin src="/assets/index-_hu1t2tv.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DCrrFaqQ.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markbrutx/promptbook-viewer",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Storybook-for-prompts: a local web app that renders a prompts folder via @markbrutx/promptbook-core.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,12 @@
25
25
  ".": {
26
26
  "types": "./dist/index.d.ts",
27
27
  "import": "./dist/index.js"
28
- }
28
+ },
29
+ "./web": {
30
+ "types": "./src/web/mount.tsx",
31
+ "default": "./src/web/mount.tsx"
32
+ },
33
+ "./web/styles.css": "./src/web/styles.css"
29
34
  },
30
35
  "main": "./dist/index.js",
31
36
  "types": "./dist/index.d.ts",
@@ -43,7 +48,7 @@
43
48
  "check": "biome check ."
44
49
  },
45
50
  "dependencies": {
46
- "@markbrutx/promptbook-core": "^0.3.0"
51
+ "@markbrutx/promptbook-core": "^0.4.1"
47
52
  },
48
53
  "devDependencies": {
49
54
  "@biomejs/biome": "latest",
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export type {
8
8
  AnnotationsResponse,
9
9
  BookResponse,
10
10
  BooksResponse,
11
+ CodePromptSummary,
11
12
  CompositionSummary,
12
13
  FragmentSummary,
13
14
  LintResponse,
package/src/web/App.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { api } from "./api.js";
2
+ import type { Api } from "./api.js";
3
3
  import { Addons } from "./components/Addons.js";
4
4
  import { Canvas } from "./components/Canvas.js";
5
5
  import { CodePromptView } from "./components/CodePromptView.js";
@@ -47,7 +47,17 @@ function controlKeys(composition: CompositionSummary | undefined): string[] {
47
47
  return [...keys];
48
48
  }
49
49
 
50
- export function App() {
50
+ /** Props passed to the viewer's root component. */
51
+ export interface AppProps {
52
+ /** API surface the viewer talks to. The CLI viewer wires the default fetch
53
+ * implementation; the static demo site supplies an in-browser one. */
54
+ api: Api;
55
+ /** Subscribe to hot-reload events. When omitted, no live-reload wiring runs
56
+ * (e.g. the static-mount path). The unsubscribe callback is returned. */
57
+ subscribeReload?: (callback: (changedBook: string | undefined) => void) => () => void;
58
+ }
59
+
60
+ export function App({ api, subscribeReload }: AppProps) {
51
61
  const [books, setBooks] = useState<WorkspaceBook[]>([]);
52
62
  const [activeBook, setActiveBook] = useState<string | null>(null);
53
63
  const [book, setBook] = useState<BookResponse | null>(null);
@@ -75,28 +85,34 @@ export function App() {
75
85
  setBooks([]);
76
86
  return [];
77
87
  }
78
- }, []);
88
+ }, [api.books]);
79
89
 
80
- const loadAnnotations = useCallback(async (which: string | null) => {
81
- try {
82
- const { annotations: next } = await api.annotations(which);
83
- setAnnotations(next);
84
- } catch {
85
- setAnnotations([]);
86
- }
87
- }, []);
90
+ const loadAnnotations = useCallback(
91
+ async (which: string | null) => {
92
+ try {
93
+ const { annotations: next } = await api.annotations(which);
94
+ setAnnotations(next);
95
+ } catch {
96
+ setAnnotations([]);
97
+ }
98
+ },
99
+ [api.annotations],
100
+ );
88
101
 
89
- const loadBook = useCallback(async (which: string | null) => {
90
- try {
91
- const next = await api.book(which);
92
- setBook(next);
93
- setError(null);
94
- return next;
95
- } catch (e) {
96
- setError((e as Error).message);
97
- return null;
98
- }
99
- }, []);
102
+ const loadBook = useCallback(
103
+ async (which: string | null) => {
104
+ try {
105
+ const next = await api.book(which);
106
+ setBook(next);
107
+ setError(null);
108
+ return next;
109
+ } catch (e) {
110
+ setError((e as Error).message);
111
+ return null;
112
+ }
113
+ },
114
+ [api.book],
115
+ );
100
116
 
101
117
  // Discover the workspace's books, then activate the first one.
102
118
  useEffect(() => {
@@ -127,25 +143,20 @@ export function App() {
127
143
  }, [activeBook, loadBook, loadAnnotations]);
128
144
 
129
145
  // Hot-reload: refetch the book list, and the active book's tree when it (or
130
- // an unknown path) changed. The new book object is a fresh reference, so the
131
- // resolve/used-in effects below re-run automatically; the selection persists.
146
+ // an unknown path) changed. The CLI viewer wires this to its SSE stream; the
147
+ // static demo site omits it entirely (no live reload). The selection persists.
132
148
  useEffect(() => {
133
- const source = new EventSource("/api/events");
134
- source.addEventListener("reload", (event) => {
135
- let changed: string | undefined;
136
- try {
137
- changed = (JSON.parse((event as MessageEvent).data) as { book?: string }).book;
138
- } catch {
139
- changed = undefined;
140
- }
149
+ if (subscribeReload === undefined) {
150
+ return;
151
+ }
152
+ return subscribeReload((changed) => {
141
153
  void loadBooks();
142
154
  const active = activeBookRef.current;
143
155
  if (active !== null && (changed === undefined || changed === active)) {
144
156
  void loadBook(active);
145
157
  }
146
158
  });
147
- return () => source.close();
148
- }, [loadBook, loadBooks]);
159
+ }, [loadBook, loadBooks, subscribeReload]);
149
160
 
150
161
  const compositions = book?.compositions ?? [];
151
162
  const selectedComposition =
@@ -176,7 +187,7 @@ export function App() {
176
187
  setError((e as Error).message);
177
188
  }
178
189
  });
179
- }, [book, selection, context, activeBook]);
190
+ }, [book, selection, context, activeBook, api.lint, api.resolve]);
180
191
 
181
192
  useEffect(() => {
182
193
  if (book === null || selection?.kind !== "fragment") {
@@ -187,7 +198,7 @@ export function App() {
187
198
  .usedIn(activeBook, selection.id)
188
199
  .then(setUsedIn)
189
200
  .catch(() => setUsedIn(null));
190
- }, [book, selection, activeBook]);
201
+ }, [book, selection, activeBook, api.usedIn]);
191
202
 
192
203
  // Resolve the comparison variant for the Diff panel.
193
204
  useEffect(() => {
@@ -200,7 +211,7 @@ export function App() {
200
211
  .resolve(activeBook, selection.composition, ctx)
201
212
  .then(setCompareResolved)
202
213
  .catch(() => setCompareResolved(null));
203
- }, [selection, compareVariant, selectedComposition, activeBook]);
214
+ }, [selection, compareVariant, selectedComposition, activeBook, api.resolve]);
204
215
 
205
216
  const selectBook = useCallback((name: string) => {
206
217
  setActiveBook(name);
@@ -238,7 +249,7 @@ export function App() {
238
249
  });
239
250
  await loadAnnotations(activeBook);
240
251
  },
241
- [selection, context, activeBook, loadAnnotations],
252
+ [selection, context, activeBook, loadAnnotations, api.annotate],
242
253
  );
243
254
 
244
255
  const resolveAnnotation = useCallback(
@@ -246,7 +257,7 @@ export function App() {
246
257
  await api.resolveAnnotation(activeBook, id);
247
258
  await loadAnnotations(activeBook);
248
259
  },
249
- [activeBook, loadAnnotations],
260
+ [activeBook, loadAnnotations, api.resolveAnnotation],
250
261
  );
251
262
 
252
263
  // Annotations belonging to exactly the variant on screen (composition + context).
@@ -293,7 +304,7 @@ export function App() {
293
304
  title={selection.composition}
294
305
  subtitle={selection.variant}
295
306
  segments={resolved.segments}
296
- tokens={lint?.tokens}
307
+ {...(lint?.tokens !== undefined ? { tokens: lint.tokens } : {})}
297
308
  annotations={variantAnnotations}
298
309
  onAnnotate={addAnnotation}
299
310
  />
package/src/web/api.ts CHANGED
@@ -10,6 +10,22 @@ import type {
10
10
  UsedInResponse,
11
11
  } from "./types.js";
12
12
 
13
+ /**
14
+ * The viewer's UI talks to the world through this small interface only. The
15
+ * CLI viewer wires the fetch-based implementation below; the static demo site
16
+ * passes an in-browser implementation that resolves against a bundled book.
17
+ */
18
+ export interface Api {
19
+ books(): Promise<BooksResponse>;
20
+ book(book: string | null): Promise<BookResponse>;
21
+ resolve(book: string | null, prompt: string, context: Context): Promise<ResolveResponse>;
22
+ lint(book: string | null, prompt: string, context: Context): Promise<LintResponse>;
23
+ usedIn(book: string | null, fragmentId: string): Promise<UsedInResponse>;
24
+ annotations(book: string | null): Promise<AnnotationsResponse>;
25
+ annotate(book: string | null, body: AnnotateRequest): Promise<Annotation>;
26
+ resolveAnnotation(book: string | null, id: string): Promise<{ id: string; removed: boolean }>;
27
+ }
28
+
13
29
  async function getJson<T>(path: string): Promise<T> {
14
30
  const res = await fetch(path);
15
31
  if (!res.ok) {
@@ -19,11 +35,14 @@ async function getJson<T>(path: string): Promise<T> {
19
35
  }
20
36
 
21
37
  async function sendJson<T>(method: "POST" | "DELETE", path: string, body?: unknown): Promise<T> {
22
- const res = await fetch(path, {
38
+ const init: RequestInit = {
23
39
  method,
24
40
  headers: { "content-type": "application/json" },
25
- body: body === undefined ? undefined : JSON.stringify(body),
26
- });
41
+ };
42
+ if (body !== undefined) {
43
+ init.body = JSON.stringify(body);
44
+ }
45
+ const res = await fetch(path, init);
27
46
  if (!res.ok) {
28
47
  const payload = (await res.json().catch(() => ({}))) as { error?: string };
29
48
  throw new Error(payload.error ?? `${path} → ${res.status}`);
@@ -40,21 +59,36 @@ function scoped(path: string, book: string | null): string {
40
59
  return `${path}${sep}book=${encodeURIComponent(book)}`;
41
60
  }
42
61
 
43
- export const api = {
62
+ /** The fetch-based default API the CLI viewer uses. */
63
+ export const api: Api = {
44
64
  books: () => getJson<BooksResponse>("/api/books"),
45
- book: (book: string | null) => getJson<BookResponse>(scoped("/api/book", book)),
46
- resolve: (book: string | null, prompt: string, context: Context) =>
65
+ book: (book) => getJson<BookResponse>(scoped("/api/book", book)),
66
+ resolve: (book, prompt, context) =>
47
67
  sendJson<ResolveResponse>("POST", scoped("/api/resolve", book), { prompt, context }),
48
- lint: (book: string | null, prompt: string, context: Context) =>
68
+ lint: (book, prompt, context) =>
49
69
  sendJson<LintResponse>("POST", scoped("/api/lint", book), { prompt, context }),
50
- usedIn: (book: string | null, fragmentId: string) =>
70
+ usedIn: (book, fragmentId) =>
51
71
  getJson<UsedInResponse>(scoped(`/api/used-in/${encodeURIComponent(fragmentId)}`, book)),
52
- annotations: (book: string | null) => getJson<AnnotationsResponse>(scoped("/api/annotations", book)),
53
- annotate: (book: string | null, body: AnnotateRequest) =>
54
- sendJson<Annotation>("POST", scoped("/api/annotate", book), body),
55
- resolveAnnotation: (book: string | null, id: string) =>
72
+ annotations: (book) => getJson<AnnotationsResponse>(scoped("/api/annotations", book)),
73
+ annotate: (book, body) => sendJson<Annotation>("POST", scoped("/api/annotate", book), body),
74
+ resolveAnnotation: (book, id) =>
56
75
  sendJson<{ id: string; removed: boolean }>(
57
76
  "DELETE",
58
77
  scoped(`/api/annotations/${encodeURIComponent(id)}`, book),
59
78
  ),
60
79
  };
80
+
81
+ /** Subscribe to the CLI viewer's SSE hot-reload stream. */
82
+ export function subscribeFetchReload(callback: (changedBook: string | undefined) => void): () => void {
83
+ const source = new EventSource("/api/events");
84
+ source.addEventListener("reload", (event) => {
85
+ let changed: string | undefined;
86
+ try {
87
+ changed = (JSON.parse((event as MessageEvent).data) as { book?: string }).book;
88
+ } catch {
89
+ changed = undefined;
90
+ }
91
+ callback(changed);
92
+ });
93
+ return () => source.close();
94
+ }
package/src/web/colors.ts CHANGED
@@ -10,12 +10,12 @@ function hueOf(id: string): number {
10
10
  return (hash + 360) % 360;
11
11
  }
12
12
 
13
- /** Soft background tint for a fragment's segment. */
13
+ /** Soft background tint for a fragment's segment. Dark mode native. */
14
14
  export function fragmentColor(id: string): string {
15
- return `hsl(${hueOf(id)}deg 70% 88%)`;
15
+ return `hsl(${hueOf(id)}deg 55% 14%)`;
16
16
  }
17
17
 
18
18
  /** Saturated variant of {@link fragmentColor} for borders/legend swatches. */
19
19
  export function fragmentAccent(id: string): string {
20
- return `hsl(${hueOf(id)}deg 60% 45%)`;
20
+ return `hsl(${hueOf(id)}deg 70% 62%)`;
21
21
  }
@@ -1,5 +1,6 @@
1
+ import { useState } from "react";
1
2
  import { kvLabel } from "../format.js";
2
- import type { LintResponse, Trace, When } from "../types.js";
3
+ import type { LintResponse, RuleTrace, Trace, When } from "../types.js";
3
4
 
4
5
  interface AddonsProps {
5
6
  trace: Trace;
@@ -13,7 +14,7 @@ function whenLabel(when: When): string {
13
14
  /** Lint findings panel: severity-tagged messages with the fragment/rule origin. */
14
15
  function LintPanel({ lint }: { lint: LintResponse | null }) {
15
16
  if (lint === null) {
16
- return <p className="muted">—</p>;
17
+ return <p className="muted">no data</p>;
17
18
  }
18
19
  if (lint.findings.length === 0) {
19
20
  return <p className="muted">No findings ({lint.tokens} tokens).</p>;
@@ -32,46 +33,107 @@ function LintPanel({ lint }: { lint: LintResponse | null }) {
32
33
  );
33
34
  }
34
35
 
35
- /** Explain trace panel: which rules fired and why, plus the net effects. */
36
+ function FiredRule({ rule }: { rule: RuleTrace }) {
37
+ return (
38
+ <li className="rule rule-fired">
39
+ <span className="rule-mark" aria-hidden>
40
+
41
+ </span>
42
+ <div className="rule-body">
43
+ <div className="rule-head">
44
+ <span className="rule-action">{rule.action}</span>
45
+ <span className="rule-when">when {whenLabel(rule.when)}</span>
46
+ </div>
47
+ {rule.effect ? <p className="rule-effect">{rule.effect}</p> : null}
48
+ </div>
49
+ </li>
50
+ );
51
+ }
52
+
53
+ function SkippedRule({ rule }: { rule: RuleTrace }) {
54
+ return (
55
+ <li className="rule rule-skipped">
56
+ <span className="rule-mark" aria-hidden>
57
+ ·
58
+ </span>
59
+ <div className="rule-body">
60
+ <div className="rule-head">
61
+ <span className="rule-action">{rule.action}</span>
62
+ <span className="rule-when">when {whenLabel(rule.when)}</span>
63
+ </div>
64
+ {rule.reason ? <p className="rule-reason">{rule.reason}</p> : null}
65
+ </div>
66
+ </li>
67
+ );
68
+ }
69
+
70
+ /** Explain trace panel: which rules fired, what they did, and (collapsed) the
71
+ * rules that did not match — kept out of the way so the trace reads as a
72
+ * trace, not as a list of errors. */
36
73
  function ExplainPanel({ trace }: { trace: Trace }) {
74
+ const fired = trace.rules.filter((r) => r.fired);
75
+ const skipped = trace.rules.filter((r) => !r.fired);
76
+ const [showSkipped, setShowSkipped] = useState(false);
77
+
37
78
  return (
38
79
  <div className="explain">
39
- <h4>Rules</h4>
40
- <ul className="rules">
41
- {trace.rules.map((rule) => (
42
- <li key={rule.index} className={rule.fired ? "fired" : "skipped"}>
43
- <span className="rule-mark">{rule.fired ? "✓" : "·"}</span>
44
- <span className="rule-action">{rule.action}</span>
45
- <span className="muted">when {whenLabel(rule.when)}</span>
46
- {rule.effect ? <div className="rule-effect">{rule.effect}</div> : null}
47
- {rule.reason ? <div className="rule-reason">{rule.reason}</div> : null}
48
- </li>
49
- ))}
50
- {trace.rules.length === 0 ? <li className="muted">No rules.</li> : null}
51
- </ul>
80
+ <h4>Fired rules ({fired.length})</h4>
81
+ {fired.length === 0 ? (
82
+ <p className="muted small">No rules matched this context, so the base order is the final order.</p>
83
+ ) : (
84
+ <ul className="rules">
85
+ {fired.map((rule) => (
86
+ <FiredRule key={rule.index} rule={rule} />
87
+ ))}
88
+ </ul>
89
+ )}
90
+
91
+ {skipped.length > 0 ? (
92
+ <details
93
+ className="skipped-block"
94
+ open={showSkipped}
95
+ onToggle={(event) => setShowSkipped((event.target as HTMLDetailsElement).open)}
96
+ >
97
+ <summary className="skipped-summary">
98
+ {skipped.length} rule{skipped.length === 1 ? "" : "s"} didn't match
99
+ </summary>
100
+ <ul className="rules">
101
+ {skipped.map((rule) => (
102
+ <SkippedRule key={rule.index} rule={rule} />
103
+ ))}
104
+ </ul>
105
+ </details>
106
+ ) : null}
52
107
 
53
108
  <h4>Final order</h4>
54
109
  <p className="order">{trace.finalOrder.join(" → ") || "(empty)"}</p>
55
110
 
56
- {trace.replaced.length > 0 ? (
57
- <p>
58
- <strong>replaced:</strong> {trace.replaced.map((r) => `${r.from}→${r.to}`).join(", ")}
59
- </p>
60
- ) : null}
61
- {trace.added.length > 0 ? (
62
- <p>
63
- <strong>added:</strong> {trace.added.map((a) => a.id).join(", ")}
64
- </p>
65
- ) : null}
66
- {trace.forbidden.length > 0 ? (
67
- <p>
68
- <strong>forbidden:</strong> {trace.forbidden.map((f) => f.id).join(", ")}
69
- </p>
111
+ {trace.replaced.length > 0 || trace.added.length > 0 || trace.forbidden.length > 0 ? (
112
+ <dl className="effects">
113
+ {trace.replaced.length > 0 ? (
114
+ <>
115
+ <dt>replaced</dt>
116
+ <dd>{trace.replaced.map((r) => `${r.from} ${r.to}`).join(", ")}</dd>
117
+ </>
118
+ ) : null}
119
+ {trace.added.length > 0 ? (
120
+ <>
121
+ <dt>added</dt>
122
+ <dd>{trace.added.map((a) => a.id).join(", ")}</dd>
123
+ </>
124
+ ) : null}
125
+ {trace.forbidden.length > 0 ? (
126
+ <>
127
+ <dt>forbidden</dt>
128
+ <dd>{trace.forbidden.map((f) => f.id).join(", ")}</dd>
129
+ </>
130
+ ) : null}
131
+ </dl>
70
132
  ) : null}
71
133
 
72
134
  {trace.unmatchedAxes.length > 0 ? (
73
- <p className="warn">
74
- <strong>unmatched axes:</strong>{" "}
135
+ <p className="notice">
136
+ <strong>Context axes some rule names but none matched:</strong>{" "}
75
137
  {trace.unmatchedAxes.map((axis) => `${axis.key}=${axis.value}`).join(", ")}
76
138
  </p>
77
139
  ) : null}
@@ -94,7 +156,7 @@ export function Addons({ trace, lint }: AddonsProps) {
94
156
  <aside className="addons">
95
157
  <section>
96
158
  <h3>Tokens</h3>
97
- <p className="tokens">{lint ? `~${lint.tokens}` : ""}</p>
159
+ <p className="tokens">{lint ? `~${lint.tokens}` : "·"}</p>
98
160
  </section>
99
161
  <section>
100
162
  <h3>Lint</h3>
@@ -18,13 +18,13 @@ export function FragmentView({ fragment, usedIn, onSelectVariant }: FragmentView
18
18
  {fragment.id}
19
19
  </h1>
20
20
  <p className="muted">
21
- {fragment.kind ?? ""}
21
+ {fragment.kind ?? "·"}
22
22
  {fragment.tags.length > 0 ? ` · ${fragment.tags.join(", ")}` : ""} · {fragment.sourceFile}
23
23
  </p>
24
24
  </div>
25
25
  </header>
26
26
 
27
- <pre className="segment" style={{ background: "#f6f8fa" }}>
27
+ <pre className="segment" style={{ background: "var(--panel)" }}>
28
28
  {fragment.body}
29
29
  </pre>
30
30
 
package/src/web/main.tsx CHANGED
@@ -1,6 +1,5 @@
1
- import { StrictMode } from "react";
2
- import { createRoot } from "react-dom/client";
3
- import { App } from "./App.js";
1
+ import { api, subscribeFetchReload } from "./api.js";
2
+ import { mountWebApp } from "./mount.js";
4
3
  import "./styles.css";
5
4
 
6
5
  const container = document.getElementById("root");
@@ -8,8 +7,4 @@ if (container === null) {
8
7
  throw new Error("missing #root element");
9
8
  }
10
9
 
11
- createRoot(container).render(
12
- <StrictMode>
13
- <App />
14
- </StrictMode>,
15
- );
10
+ mountWebApp({ container, api, subscribeReload: subscribeFetchReload });
@@ -0,0 +1,27 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { App, type AppProps } from "./App.js";
4
+
5
+ export type { App, AppProps } from "./App.js";
6
+ export type { Api } from "./api.js";
7
+
8
+ /** Arguments to {@link mountWebApp}. */
9
+ export interface MountOptions extends AppProps {
10
+ /** DOM node to mount the React tree into. */
11
+ container: Element | DocumentFragment;
12
+ /** Wrap in `<StrictMode>`. Defaults to true. */
13
+ strict?: boolean;
14
+ }
15
+
16
+ /** Mount the viewer UI into `container` with the supplied API. */
17
+ export function mountWebApp(options: MountOptions): { unmount: () => void; root: Root } {
18
+ const { container, api, subscribeReload, strict = true } = options;
19
+ const root = createRoot(container);
20
+ const appProps: AppProps = subscribeReload !== undefined ? { api, subscribeReload } : { api };
21
+ const tree = <App {...appProps} />;
22
+ root.render(strict ? <StrictMode>{tree}</StrictMode> : tree);
23
+ return {
24
+ root,
25
+ unmount: () => root.unmount(),
26
+ };
27
+ }
Binary file