@markbrutx/promptbook-viewer 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/server/annotations.d.ts +30 -0
- package/dist/server/annotations.d.ts.map +1 -0
- package/dist/server/annotations.js +62 -0
- package/dist/server/annotations.js.map +1 -0
- package/dist/server/api.d.ts +16 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +134 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/book-source.d.ts +20 -0
- package/dist/server/book-source.d.ts.map +1 -0
- package/dist/server/book-source.js +32 -0
- package/dist/server/book-source.js.map +1 -0
- package/dist/server/responses.d.ts +12 -0
- package/dist/server/responses.d.ts.map +1 -0
- package/dist/server/responses.js +106 -0
- package/dist/server/responses.js.map +1 -0
- package/dist/server/segments.d.ts +12 -0
- package/dist/server/segments.d.ts.map +1 -0
- package/dist/server/segments.js +24 -0
- package/dist/server/segments.js.map +1 -0
- package/dist/server/server.d.ts +24 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +71 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/static.d.ts +8 -0
- package/dist/server/static.d.ts.map +1 -0
- package/dist/server/static.js +55 -0
- package/dist/server/static.js.map +1 -0
- package/dist/server/used-in.d.ts +9 -0
- package/dist/server/used-in.d.ts.map +1 -0
- package/dist/server/used-in.js +19 -0
- package/dist/server/used-in.js.map +1 -0
- package/dist/shared/types.d.ts +113 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/assets/index-B2Wxtb-f.css +1 -0
- package/dist/web/assets/index-C8f_6lr_.js +51 -0
- package/dist/web/index.html +13 -0
- package/package.json +47 -0
- package/src/index.ts +19 -0
- package/src/server/annotations.ts +96 -0
- package/src/server/api.ts +164 -0
- package/src/server/book-source.ts +54 -0
- package/src/server/responses.ts +127 -0
- package/src/server/segments.ts +26 -0
- package/src/server/server.ts +96 -0
- package/src/server/static.ts +60 -0
- package/src/server/used-in.ts +23 -0
- package/src/shared/types.ts +137 -0
- package/src/web/App.tsx +307 -0
- package/src/web/annotations.ts +58 -0
- package/src/web/api.ts +44 -0
- package/src/web/colors.ts +21 -0
- package/src/web/components/Addons.tsx +109 -0
- package/src/web/components/Canvas.tsx +180 -0
- package/src/web/components/CodePromptView.tsx +87 -0
- package/src/web/components/Controls.tsx +71 -0
- package/src/web/components/Diff.tsx +30 -0
- package/src/web/components/FragmentView.tsx +54 -0
- package/src/web/components/Sidebar.tsx +178 -0
- package/src/web/diff.ts +51 -0
- package/src/web/env.d.ts +3 -0
- package/src/web/format.ts +8 -0
- package/src/web/index.html +12 -0
- package/src/web/main.tsx +15 -0
- package/src/web/selection.ts +5 -0
- package/src/web/styles.css +484 -0
- package/src/web/tree.ts +134 -0
- package/src/web/types.ts +17 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { kvLabel } from "../format.js";
|
|
2
|
+
import type { LintResponse, Trace, When } from "../types.js";
|
|
3
|
+
|
|
4
|
+
interface AddonsProps {
|
|
5
|
+
trace: Trace;
|
|
6
|
+
lint: LintResponse | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function whenLabel(when: When): string {
|
|
10
|
+
return Object.keys(when).length === 0 ? "always" : kvLabel(when);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Lint findings panel: severity-tagged messages with the fragment/rule origin. */
|
|
14
|
+
function LintPanel({ lint }: { lint: LintResponse | null }) {
|
|
15
|
+
if (lint === null) {
|
|
16
|
+
return <p className="muted">—</p>;
|
|
17
|
+
}
|
|
18
|
+
if (lint.findings.length === 0) {
|
|
19
|
+
return <p className="muted">No findings ({lint.tokens} tokens).</p>;
|
|
20
|
+
}
|
|
21
|
+
return (
|
|
22
|
+
<ul className="findings">
|
|
23
|
+
{lint.findings.map((finding, index) => (
|
|
24
|
+
<li key={`${finding.ruleId}:${index}`} className={`finding ${finding.severity}`}>
|
|
25
|
+
<span className="finding-sev">{finding.severity}</span>
|
|
26
|
+
<span className="finding-rule">{finding.ruleId}</span>
|
|
27
|
+
<span>{finding.message}</span>
|
|
28
|
+
{finding.fragmentId ? <code className="finding-frag">{finding.fragmentId}</code> : null}
|
|
29
|
+
</li>
|
|
30
|
+
))}
|
|
31
|
+
</ul>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Explain trace panel: which rules fired and why, plus the net effects. */
|
|
36
|
+
function ExplainPanel({ trace }: { trace: Trace }) {
|
|
37
|
+
return (
|
|
38
|
+
<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>
|
|
52
|
+
|
|
53
|
+
<h4>Final order</h4>
|
|
54
|
+
<p className="order">{trace.finalOrder.join(" → ") || "(empty)"}</p>
|
|
55
|
+
|
|
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>
|
|
70
|
+
) : null}
|
|
71
|
+
|
|
72
|
+
{trace.unmatchedAxes.length > 0 ? (
|
|
73
|
+
<p className="warn">
|
|
74
|
+
<strong>unmatched axes:</strong>{" "}
|
|
75
|
+
{trace.unmatchedAxes.map((axis) => `${axis.key}=${axis.value}`).join(", ")}
|
|
76
|
+
</p>
|
|
77
|
+
) : null}
|
|
78
|
+
{trace.warnings.length > 0 ? (
|
|
79
|
+
<ul className="warnings">
|
|
80
|
+
{trace.warnings.map((warning) => (
|
|
81
|
+
<li key={warning} className="warn">
|
|
82
|
+
{warning}
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
) : null}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Right-hand Addons rail: tokens, lint, and the explain trace. */
|
|
92
|
+
export function Addons({ trace, lint }: AddonsProps) {
|
|
93
|
+
return (
|
|
94
|
+
<aside className="addons">
|
|
95
|
+
<section>
|
|
96
|
+
<h3>Tokens</h3>
|
|
97
|
+
<p className="tokens">{lint ? `~${lint.tokens}` : "—"}</p>
|
|
98
|
+
</section>
|
|
99
|
+
<section>
|
|
100
|
+
<h3>Lint</h3>
|
|
101
|
+
<LintPanel lint={lint} />
|
|
102
|
+
</section>
|
|
103
|
+
<section>
|
|
104
|
+
<h3>Explain</h3>
|
|
105
|
+
<ExplainPanel trace={trace} />
|
|
106
|
+
</section>
|
|
107
|
+
</aside>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { type AnchorSpec, markAnchors } from "../annotations.js";
|
|
3
|
+
import { fragmentAccent, fragmentColor } from "../colors.js";
|
|
4
|
+
import type { Annotation, Segment } from "../types.js";
|
|
5
|
+
|
|
6
|
+
interface CanvasProps {
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
segments: Segment[];
|
|
10
|
+
tokens?: number;
|
|
11
|
+
/** Open annotations for this variant, highlighted inside their fragment. */
|
|
12
|
+
annotations: Annotation[];
|
|
13
|
+
/** Persist a new annotation for the selected fragment text. */
|
|
14
|
+
onAnnotate: (fragmentId: string, anchorText: string, comment: string) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A pending selection awaiting a comment, anchored near the selected text. */
|
|
18
|
+
interface Draft {
|
|
19
|
+
fragmentId: string;
|
|
20
|
+
anchorText: string;
|
|
21
|
+
top: number;
|
|
22
|
+
left: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read the current text selection, if it lands inside a segment's body. */
|
|
26
|
+
function selectionDraft(): Draft | null {
|
|
27
|
+
const selection = window.getSelection();
|
|
28
|
+
if (selection === null || selection.isCollapsed) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const anchorText = selection.toString().trim();
|
|
32
|
+
if (anchorText === "") {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const node = selection.anchorNode;
|
|
36
|
+
const element = node instanceof Element ? node : (node?.parentElement ?? null);
|
|
37
|
+
const host = element?.closest<HTMLElement>("[data-fragment-id]") ?? null;
|
|
38
|
+
const fragmentId = host?.dataset.fragmentId;
|
|
39
|
+
if (fragmentId === undefined) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
|
43
|
+
return { fragmentId, anchorText, top: rect.bottom, left: rect.left };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The assembled prompt, each segment tinted by its source fragment. The
|
|
48
|
+
* segments concatenate (with blank lines between them) into the exact resolved
|
|
49
|
+
* text, so what is shown here is byte-for-byte the prompt the model would see.
|
|
50
|
+
* Selecting text opens a comment popover; existing annotations are highlighted.
|
|
51
|
+
*/
|
|
52
|
+
export function Canvas({ title, subtitle, segments, tokens, annotations, onAnnotate }: CanvasProps) {
|
|
53
|
+
const ids = [...new Set(segments.map((s) => s.fragmentId))];
|
|
54
|
+
const [draft, setDraft] = useState<Draft | null>(null);
|
|
55
|
+
const [comment, setComment] = useState("");
|
|
56
|
+
const [saving, setSaving] = useState(false);
|
|
57
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
58
|
+
|
|
59
|
+
// A document-level listener keeps interaction handlers off the static prompt
|
|
60
|
+
// markup (and selections outside a segment are simply ignored).
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const onMouseUp = (): void => {
|
|
63
|
+
const next = selectionDraft();
|
|
64
|
+
if (next !== null) {
|
|
65
|
+
setDraft(next);
|
|
66
|
+
setComment("");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
70
|
+
return () => document.removeEventListener("mouseup", onMouseUp);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (draft !== null) {
|
|
75
|
+
textareaRef.current?.focus();
|
|
76
|
+
}
|
|
77
|
+
}, [draft]);
|
|
78
|
+
|
|
79
|
+
// Group anchors by fragment once per annotations change, so each segment does
|
|
80
|
+
// a map lookup instead of scanning the whole list on every render.
|
|
81
|
+
const anchorsByFragment = useMemo(() => {
|
|
82
|
+
const byFragment = new Map<string, AnchorSpec[]>();
|
|
83
|
+
for (const a of annotations) {
|
|
84
|
+
const list = byFragment.get(a.anchor.fragmentId) ?? [];
|
|
85
|
+
list.push({ id: a.id, anchorText: a.anchor.anchorText });
|
|
86
|
+
byFragment.set(a.anchor.fragmentId, list);
|
|
87
|
+
}
|
|
88
|
+
return byFragment;
|
|
89
|
+
}, [annotations]);
|
|
90
|
+
|
|
91
|
+
const submit = async (): Promise<void> => {
|
|
92
|
+
if (draft === null || comment.trim() === "") {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setSaving(true);
|
|
96
|
+
try {
|
|
97
|
+
await onAnnotate(draft.fragmentId, draft.anchorText, comment.trim());
|
|
98
|
+
setDraft(null);
|
|
99
|
+
setComment("");
|
|
100
|
+
} finally {
|
|
101
|
+
setSaving(false);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<main className="canvas">
|
|
107
|
+
<header className="canvas-head">
|
|
108
|
+
<div>
|
|
109
|
+
<h1 className="canvas-title">{title}</h1>
|
|
110
|
+
{subtitle ? <p className="muted">{subtitle}</p> : null}
|
|
111
|
+
</div>
|
|
112
|
+
{tokens !== undefined ? <span className="badge">~{tokens} tokens</span> : null}
|
|
113
|
+
</header>
|
|
114
|
+
|
|
115
|
+
{ids.length > 0 ? (
|
|
116
|
+
<ul className="legend">
|
|
117
|
+
{ids.map((id) => (
|
|
118
|
+
<li key={id}>
|
|
119
|
+
<span className="swatch" style={{ background: fragmentAccent(id) }} />
|
|
120
|
+
{id}
|
|
121
|
+
</li>
|
|
122
|
+
))}
|
|
123
|
+
</ul>
|
|
124
|
+
) : null}
|
|
125
|
+
|
|
126
|
+
<div className="prompt">
|
|
127
|
+
{segments.length === 0 ? (
|
|
128
|
+
<p className="muted">No fragments rendered for this context.</p>
|
|
129
|
+
) : (
|
|
130
|
+
segments.map((segment) => (
|
|
131
|
+
<pre
|
|
132
|
+
key={segment.fragmentId}
|
|
133
|
+
className="segment"
|
|
134
|
+
data-fragment-id={segment.fragmentId}
|
|
135
|
+
style={{
|
|
136
|
+
background: fragmentColor(segment.fragmentId),
|
|
137
|
+
borderColor: fragmentAccent(segment.fragmentId),
|
|
138
|
+
}}
|
|
139
|
+
title={segment.fragmentId}
|
|
140
|
+
>
|
|
141
|
+
<span className="segment-tag">{segment.fragmentId}</span>
|
|
142
|
+
{annotations.length === 0
|
|
143
|
+
? segment.text
|
|
144
|
+
: markAnchors(segment.text, anchorsByFragment.get(segment.fragmentId) ?? []).map(
|
|
145
|
+
(run, index) =>
|
|
146
|
+
run.annotationId !== undefined ? (
|
|
147
|
+
<mark key={index} className="annot-mark">
|
|
148
|
+
{run.text}
|
|
149
|
+
</mark>
|
|
150
|
+
) : (
|
|
151
|
+
<span key={index}>{run.text}</span>
|
|
152
|
+
),
|
|
153
|
+
)}
|
|
154
|
+
</pre>
|
|
155
|
+
))
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{draft !== null ? (
|
|
160
|
+
<div className="annot-popover" style={{ top: draft.top, left: draft.left }}>
|
|
161
|
+
<p className="annot-quote">“{draft.anchorText}”</p>
|
|
162
|
+
<textarea
|
|
163
|
+
ref={textareaRef}
|
|
164
|
+
value={comment}
|
|
165
|
+
placeholder="Comment for the agent…"
|
|
166
|
+
onChange={(event) => setComment(event.target.value)}
|
|
167
|
+
/>
|
|
168
|
+
<div className="annot-actions">
|
|
169
|
+
<button type="button" className="link" onClick={() => setDraft(null)}>
|
|
170
|
+
Cancel
|
|
171
|
+
</button>
|
|
172
|
+
<button type="button" disabled={saving || comment.trim() === ""} onClick={() => void submit()}>
|
|
173
|
+
Save
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
) : null}
|
|
178
|
+
</main>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { kvLabel } from "../format.js";
|
|
3
|
+
import type { CodePromptSummary } from "../types.js";
|
|
4
|
+
import { Diff } from "./Diff.js";
|
|
5
|
+
|
|
6
|
+
interface CodePromptViewProps {
|
|
7
|
+
codePrompt: CodePromptSummary;
|
|
8
|
+
/** The sample label currently shown. */
|
|
9
|
+
sample: string;
|
|
10
|
+
onSelectSample: (label: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A code-prompt node: a builder-backed prompt the core never executes. The
|
|
15
|
+
* canvas shows a captured snapshot of its output (no fragment breakdown, since
|
|
16
|
+
* the builder is opaque) with a "code" badge, lets you switch samples, and
|
|
17
|
+
* diffs two snapshots. Honest about being computed, not declarative.
|
|
18
|
+
*/
|
|
19
|
+
export function CodePromptView({ codePrompt, sample, onSelectSample }: CodePromptViewProps) {
|
|
20
|
+
const [compare, setCompare] = useState("");
|
|
21
|
+
const samples = codePrompt.samples;
|
|
22
|
+
const current = samples.find((s) => s.label === sample) ?? samples[0];
|
|
23
|
+
const other = samples.find((s) => s.label === compare);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<main className="canvas">
|
|
27
|
+
<header className="canvas-head">
|
|
28
|
+
<div>
|
|
29
|
+
<h1 className="canvas-title">
|
|
30
|
+
{codePrompt.name} <span className="badge badge-code">code</span>
|
|
31
|
+
</h1>
|
|
32
|
+
{codePrompt.description ? <p className="muted">{codePrompt.description}</p> : null}
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
{samples.length === 0 || current === undefined ? (
|
|
37
|
+
<p className="muted">No samples captured for this code-prompt.</p>
|
|
38
|
+
) : (
|
|
39
|
+
<>
|
|
40
|
+
<div className="code-tabs">
|
|
41
|
+
{samples.map((s) => (
|
|
42
|
+
<button
|
|
43
|
+
key={s.label}
|
|
44
|
+
type="button"
|
|
45
|
+
className={`code-tab${s.label === current.label ? " active" : ""}`}
|
|
46
|
+
onClick={() => onSelectSample(s.label)}
|
|
47
|
+
>
|
|
48
|
+
{s.label}
|
|
49
|
+
</button>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="prompt">
|
|
54
|
+
<pre className="segment code-sample">
|
|
55
|
+
<span className="segment-tag">
|
|
56
|
+
code{current.context ? ` · ${kvLabel(current.context)}` : ""}
|
|
57
|
+
</span>
|
|
58
|
+
{current.output}
|
|
59
|
+
</pre>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<section className="code-diff">
|
|
63
|
+
<h3>Diff</h3>
|
|
64
|
+
<select value={compare} onChange={(event) => setCompare(event.target.value)}>
|
|
65
|
+
<option value="">Compare with…</option>
|
|
66
|
+
{samples
|
|
67
|
+
.filter((s) => s.label !== current.label)
|
|
68
|
+
.map((s) => (
|
|
69
|
+
<option key={s.label} value={s.label}>
|
|
70
|
+
{s.label}
|
|
71
|
+
</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
{other !== undefined ? (
|
|
75
|
+
<Diff
|
|
76
|
+
leftLabel={other.label}
|
|
77
|
+
rightLabel={current.label}
|
|
78
|
+
leftText={other.output}
|
|
79
|
+
rightText={current.output}
|
|
80
|
+
/>
|
|
81
|
+
) : null}
|
|
82
|
+
</section>
|
|
83
|
+
</>
|
|
84
|
+
)}
|
|
85
|
+
</main>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Context, ContextValue } from "../types.js";
|
|
3
|
+
|
|
4
|
+
interface ControlsProps {
|
|
5
|
+
/** Keys worth offering: the union of rule `when` keys and current context. */
|
|
6
|
+
keys: string[];
|
|
7
|
+
context: Context;
|
|
8
|
+
onChange: (context: Context) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NUMERIC = /^-?\d+(?:\.\d+)?$/;
|
|
12
|
+
|
|
13
|
+
/** Coerce a raw input string like the CLI's `--ctx`: bool, number, else string. */
|
|
14
|
+
function coerce(raw: string): ContextValue {
|
|
15
|
+
if (raw === "true") return true;
|
|
16
|
+
if (raw === "false") return false;
|
|
17
|
+
if (NUMERIC.test(raw)) return Number(raw);
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Context pickers = Storybook Controls. Editing a field re-resolves the variant
|
|
23
|
+
* live. Keys come from the composition's `when` conditions plus whatever the
|
|
24
|
+
* selected variant set; an extra row adds ad-hoc keys.
|
|
25
|
+
*/
|
|
26
|
+
export function Controls({ keys, context, onChange }: ControlsProps) {
|
|
27
|
+
const [newKey, setNewKey] = useState("");
|
|
28
|
+
|
|
29
|
+
const setValue = (key: string, raw: string): void => {
|
|
30
|
+
const next: Context = { ...context };
|
|
31
|
+
if (raw === "") {
|
|
32
|
+
delete next[key];
|
|
33
|
+
} else {
|
|
34
|
+
next[key] = coerce(raw);
|
|
35
|
+
}
|
|
36
|
+
onChange(next);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const offered = [...new Set([...keys, ...Object.keys(context)])].sort();
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="controls">
|
|
43
|
+
{offered.length === 0 ? <p className="muted">No context axes.</p> : null}
|
|
44
|
+
{offered.map((key) => (
|
|
45
|
+
<label key={key} className="control-row">
|
|
46
|
+
<span className="control-key">{key}</span>
|
|
47
|
+
<input
|
|
48
|
+
value={context[key] === undefined ? "" : String(context[key])}
|
|
49
|
+
placeholder="(unset)"
|
|
50
|
+
onChange={(event) => setValue(key, event.target.value)}
|
|
51
|
+
/>
|
|
52
|
+
</label>
|
|
53
|
+
))}
|
|
54
|
+
|
|
55
|
+
<form
|
|
56
|
+
className="control-add"
|
|
57
|
+
onSubmit={(event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
const key = newKey.trim();
|
|
60
|
+
if (key !== "") {
|
|
61
|
+
onChange({ ...context, [key]: "" });
|
|
62
|
+
setNewKey("");
|
|
63
|
+
}
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<input value={newKey} placeholder="add axis…" onChange={(event) => setNewKey(event.target.value)} />
|
|
67
|
+
<button type="submit">+</button>
|
|
68
|
+
</form>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { diffLines } from "../diff.js";
|
|
2
|
+
|
|
3
|
+
interface DiffProps {
|
|
4
|
+
leftLabel: string;
|
|
5
|
+
rightLabel: string;
|
|
6
|
+
leftText: string;
|
|
7
|
+
rightText: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MARK: Record<string, string> = { equal: " ", add: "+", remove: "-" };
|
|
11
|
+
|
|
12
|
+
/** Line diff of two variants of the same composition. */
|
|
13
|
+
export function Diff({ leftLabel, rightLabel, leftText, rightText }: DiffProps) {
|
|
14
|
+
const rows = diffLines(leftText, rightText);
|
|
15
|
+
return (
|
|
16
|
+
<div className="diff">
|
|
17
|
+
<p className="muted">
|
|
18
|
+
− {leftLabel} + {rightLabel}
|
|
19
|
+
</p>
|
|
20
|
+
<pre className="diff-body">
|
|
21
|
+
{rows.map((row, index) => (
|
|
22
|
+
<div key={`${index}:${row.text}`} className={`diff-row ${row.type}`}>
|
|
23
|
+
<span className="diff-mark">{MARK[row.type]}</span>
|
|
24
|
+
{row.text}
|
|
25
|
+
</div>
|
|
26
|
+
))}
|
|
27
|
+
</pre>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { fragmentAccent } from "../colors.js";
|
|
2
|
+
import type { FragmentSummary, UsedInResponse } from "../types.js";
|
|
3
|
+
|
|
4
|
+
interface FragmentViewProps {
|
|
5
|
+
fragment: FragmentSummary;
|
|
6
|
+
usedIn: UsedInResponse | null;
|
|
7
|
+
onSelectVariant: (composition: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Inspect a fragment: its body, metadata, and where it is used (the graph). */
|
|
11
|
+
export function FragmentView({ fragment, usedIn, onSelectVariant }: FragmentViewProps) {
|
|
12
|
+
return (
|
|
13
|
+
<main className="canvas">
|
|
14
|
+
<header className="canvas-head">
|
|
15
|
+
<div>
|
|
16
|
+
<h1 className="canvas-title">
|
|
17
|
+
<span className="swatch" style={{ background: fragmentAccent(fragment.id) }} />
|
|
18
|
+
{fragment.id}
|
|
19
|
+
</h1>
|
|
20
|
+
<p className="muted">
|
|
21
|
+
{fragment.kind ?? "—"}
|
|
22
|
+
{fragment.tags.length > 0 ? ` · ${fragment.tags.join(", ")}` : ""} · {fragment.sourceFile}
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<pre className="segment" style={{ background: "#f6f8fa" }}>
|
|
28
|
+
{fragment.body}
|
|
29
|
+
</pre>
|
|
30
|
+
|
|
31
|
+
<section className="used-in">
|
|
32
|
+
<h3>Used in</h3>
|
|
33
|
+
{usedIn === null || usedIn.references.length === 0 ? (
|
|
34
|
+
<p className="muted">Not referenced by any composition.</p>
|
|
35
|
+
) : (
|
|
36
|
+
<ul className="used-list">
|
|
37
|
+
{usedIn.references.map((ref, index) => (
|
|
38
|
+
<li key={`${ref.composition}:${ref.role}:${index}`}>
|
|
39
|
+
<button type="button" className="link" onClick={() => onSelectVariant(ref.composition)}>
|
|
40
|
+
{ref.composition}
|
|
41
|
+
</button>
|
|
42
|
+
<span className="muted">
|
|
43
|
+
{" "}
|
|
44
|
+
{ref.role}
|
|
45
|
+
{ref.ruleIndex !== undefined ? ` (rule #${ref.ruleIndex})` : ""}
|
|
46
|
+
</span>
|
|
47
|
+
</li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
)}
|
|
51
|
+
</section>
|
|
52
|
+
</main>
|
|
53
|
+
);
|
|
54
|
+
}
|