Skip to content

Commit df669aa

Browse files
authored
feat: add prologue diagram field at parity with hosted Stage (#69)
* feat: add prologue diagram field at parity with hosted Stage Adds an optional Mermaid `diagram` to the prologue, vendored from hosted Stage's MermaidDiagram (lazy-loaded mermaid + zoom/pan dialog). Wires the field through the shared schema, prologue display, markdown export, and the stage-chapters skill prompt. * fix: widen diagram code fence to avoid backtick collisions in prologue markdown
1 parent ca4d94a commit df669aa

12 files changed

Lines changed: 1163 additions & 0 deletions

File tree

packages/cli/src/__tests__/import-chapters.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ describe("chapter import", () => {
190190
const prologue = {
191191
motivation: "Dashboards would break during deploys.",
192192
outcome: "Dashboards stay up during deploys now.",
193+
diagram: "graph LR;\n Deploy-->Cache-->Dashboard",
193194
keyChanges: [
194195
{
195196
summary: "Deploy-safe dashboard rendering",

packages/cli/src/__tests__/runs.routes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ describe("runs API", () => {
187187
const prologue = {
188188
motivation: "Slow page loads on large repos.",
189189
outcome: "Pages load fast now.",
190+
diagram: null,
190191
keyChanges: [
191192
{ summary: "Pagination added to repo list", description: "Limits to 50 repos per page" },
192193
],

packages/cli/src/__tests__/schema.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,61 @@ describe("ChaptersFileSchema", () => {
174174
expect(result.prologue?.motivation).toBe(prologue.motivation);
175175
});
176176

177+
it("defaults the prologue diagram to null when omitted", () => {
178+
const prologue = {
179+
motivation: null,
180+
outcome: null,
181+
keyChanges: [{ summary: "Tightens validation", description: "Rejects malformed input" }],
182+
focusAreas: [
183+
{
184+
type: "data-integrity",
185+
severity: "info",
186+
title: "Input validation",
187+
description: "Confirm the new constraints match the data model",
188+
locations: ["src/schema.ts"],
189+
},
190+
],
191+
complexity: { level: "low", reasoning: "Schema-only change" },
192+
};
193+
const result = ChaptersFileSchema.parse(makeFixture({ prologue }));
194+
expect(result.prologue?.diagram).toBeNull();
195+
});
196+
197+
it("preserves a Mermaid diagram on the prologue", () => {
198+
const prologue = {
199+
motivation: null,
200+
outcome: null,
201+
diagram: "graph TD;\n A-->B",
202+
keyChanges: [{ summary: "Adds a pipeline", description: "Wires producers to consumers" }],
203+
focusAreas: [
204+
{
205+
type: "architecture",
206+
severity: "info",
207+
title: "New data flow",
208+
description: "Confirm the pipeline ordering is correct",
209+
locations: ["src/pipeline.ts"],
210+
},
211+
],
212+
complexity: { level: "medium", reasoning: "New control flow across modules" },
213+
};
214+
const result = ChaptersFileSchema.parse(makeFixture({ prologue }));
215+
expect(result.prologue?.diagram).toBe(prologue.diagram);
216+
});
217+
218+
it("rejects a non-string prologue diagram", () => {
219+
const prologue = {
220+
motivation: null,
221+
outcome: null,
222+
diagram: 42,
223+
keyChanges: [{ summary: "x", description: "y" }],
224+
focusAreas: [
225+
{ type: "architecture", severity: "info", title: "t", description: "d", locations: [] },
226+
],
227+
complexity: { level: "low", reasoning: "r" },
228+
};
229+
expectInvalidAt(makeFixture({ prologue }), "prologue.diagram");
230+
});
231+
177232
it("accepts a file without a prologue (backward compatibility)", () => {
178233
const result = ChaptersFileSchema.parse(makeFixture());
179234
expect(result.prologue).toBeUndefined();

packages/types/src/prologue.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export type Complexity = z.infer<typeof ComplexitySchema>;
5252
export const PrologueSchema = z.object({
5353
motivation: z.string().nullable(),
5454
outcome: z.string().nullable(),
55+
/** Mermaid diagram source (without code fences), or null when prose alone is clear. */
56+
diagram: z.string().nullable().default(null),
5557
keyChanges: z.array(PrologueKeyChangeSchema),
5658
focusAreas: z.array(FocusAreaSchema),
5759
complexity: ComplexitySchema,

packages/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@
2828
"clsx": "^2.1.1",
2929
"date-fns": "^4.1.0",
3030
"lucide-react": "^0.568.0",
31+
"mermaid": "^11.12.3",
3132
"radix-ui": "^1.4.3",
3233
"react": "^19.2.3",
3334
"react-dom": "^19.2.3",
3435
"react-hotkeys-hook": "^5.3.0",
3536
"react-markdown": "^10.1.0",
37+
"react-zoom-pan-pinch": "^3.7.0",
3638
"rehype-raw": "^7.0.0",
3739
"rehype-sanitize": "^6.0.0",
3840
"remark-gfm": "^4.0.1",

packages/web/src/components/prologue/prologue-section.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FocusArea, FocusAreaSeverity, Prologue } from "@stagereview/types/prologue";
22
import { FOCUS_AREA_SEVERITY } from "@stagereview/types/prologue";
33
import { AlertTriangle } from "lucide-react";
4+
import { MermaidDiagram } from "@/components/shared/mermaid-diagram";
45
import { cn } from "@/lib/utils";
56

67
const SEVERITY_COLORS: Record<string, string> = {
@@ -43,6 +44,8 @@ function PrologueDisplay({ prologue }: { prologue: Prologue }) {
4344
</section>
4445
)}
4546

47+
{prologue.diagram && <MermaidDiagram chart={prologue.diagram} />}
48+
4649
<section>
4750
<h3 className="mb-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
4851
Key Changes
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Minus, Plus, Scan, X } from "lucide-react";
2+
import { useEffect, useId, useMemo, useRef, useState } from "react";
3+
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
4+
import { renderMermaidDiagram } from "@/lib/mermaid-renderer";
5+
import { useTheme } from "@/lib/theme";
6+
import { cn } from "@/lib/utils";
7+
8+
function prepareSvgForDialog(svgHtml: string): string {
9+
const div = document.createElement("div");
10+
div.innerHTML = svgHtml;
11+
const svgEl = div.querySelector("svg");
12+
if (!svgEl) return svgHtml;
13+
14+
const w = svgEl.getAttribute("width");
15+
const h = svgEl.getAttribute("height");
16+
if (!svgEl.hasAttribute("viewBox") && w && h) {
17+
svgEl.setAttribute("viewBox", `0 0 ${parseFloat(w)} ${parseFloat(h)}`);
18+
}
19+
20+
svgEl.removeAttribute("width");
21+
svgEl.removeAttribute("height");
22+
svgEl.style.removeProperty("max-width");
23+
svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet");
24+
25+
return div.innerHTML;
26+
}
27+
28+
interface MermaidDiagramProps {
29+
chart: string;
30+
}
31+
32+
const ZOOM_CONTROLS_CLASSES =
33+
"cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
34+
35+
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
36+
const { appTheme } = useTheme();
37+
const [svg, setSvg] = useState<string | null>(null);
38+
const [error, setError] = useState(false);
39+
const dialogRef = useRef<HTMLDialogElement>(null);
40+
const titleId = useId();
41+
const prevChartRef = useRef(chart);
42+
const pointerDownTargetRef = useRef<EventTarget | null>(null);
43+
const dialogSvg = useMemo(() => (svg ? prepareSvgForDialog(svg) : null), [svg]);
44+
45+
useEffect(() => {
46+
let cancelled = false;
47+
48+
if (prevChartRef.current !== chart) {
49+
prevChartRef.current = chart;
50+
setSvg(null);
51+
setError(false);
52+
}
53+
54+
renderMermaidDiagram(chart, appTheme)
55+
.then(({ svg }) => {
56+
if (cancelled) return;
57+
setSvg(svg);
58+
setError(false);
59+
})
60+
.catch(() => {
61+
if (!cancelled) setError(true);
62+
});
63+
64+
return () => {
65+
cancelled = true;
66+
};
67+
}, [chart, appTheme]);
68+
69+
if (error) {
70+
return (
71+
<pre className="my-2 overflow-x-auto rounded-md border border-border/50 bg-muted/30 p-3 text-xs text-muted-foreground">
72+
{chart}
73+
</pre>
74+
);
75+
}
76+
77+
return (
78+
<>
79+
<div className="group relative my-3">
80+
<div
81+
className={cn(
82+
"flex max-h-64 justify-center overflow-hidden [&_svg]:max-w-full",
83+
!svg && "h-24 animate-pulse rounded-md bg-muted/30",
84+
)}
85+
// biome-ignore lint/security/noDangerouslySetInnerHtml: mermaid.render() produces safe SVG
86+
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
87+
/>
88+
{svg && (
89+
<button
90+
type="button"
91+
onClick={() => dialogRef.current?.showModal()}
92+
className="absolute inset-0 flex cursor-pointer items-end justify-center bg-gradient-to-t from-background/80 to-transparent opacity-0 outline-none transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
93+
>
94+
<span className="mb-3 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-sm">
95+
View full diagram
96+
</span>
97+
</button>
98+
)}
99+
</div>
100+
101+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop click-to-close is a standard dialog pattern */}
102+
<dialog
103+
ref={dialogRef}
104+
aria-labelledby={titleId}
105+
className="fixed inset-0 m-auto hidden h-[90vh] w-[90vw] flex-col overflow-hidden rounded-xl border border-border bg-background p-0 text-foreground shadow-lg open:flex backdrop:bg-black/60 backdrop:backdrop-blur-sm"
106+
onPointerDown={(e) => {
107+
pointerDownTargetRef.current = e.target;
108+
}}
109+
onClick={(e) => {
110+
if (e.target === e.currentTarget && pointerDownTargetRef.current === e.currentTarget)
111+
e.currentTarget.close();
112+
}}
113+
>
114+
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
115+
<h2 id={titleId} className="text-sm font-semibold">
116+
Diagram
117+
</h2>
118+
<button
119+
type="button"
120+
aria-label="Close diagram"
121+
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
122+
onClick={() => dialogRef.current?.close()}
123+
>
124+
<X className="size-4" />
125+
</button>
126+
</div>
127+
<div className="min-h-0 flex-1">
128+
{dialogSvg && (
129+
<TransformWrapper
130+
initialScale={1}
131+
minScale={0.5}
132+
maxScale={4}
133+
centerOnInit
134+
wheel={{ step: 0.08 }}
135+
>
136+
{({ zoomIn, zoomOut, resetTransform }) => (
137+
<>
138+
<div className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-lg border border-border bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-sm">
139+
<button
140+
type="button"
141+
aria-label="Zoom out"
142+
className={ZOOM_CONTROLS_CLASSES}
143+
onClick={() => zoomOut()}
144+
>
145+
<Minus className="size-4" />
146+
</button>
147+
<button
148+
type="button"
149+
aria-label="Reset zoom"
150+
className={ZOOM_CONTROLS_CLASSES}
151+
onClick={() => resetTransform()}
152+
>
153+
<Scan className="size-4" />
154+
</button>
155+
<button
156+
type="button"
157+
aria-label="Zoom in"
158+
className={ZOOM_CONTROLS_CLASSES}
159+
onClick={() => zoomIn()}
160+
>
161+
<Plus className="size-4" />
162+
</button>
163+
</div>
164+
<TransformComponent wrapperClass="!h-full !w-full" contentClass="!h-full !w-full">
165+
<div
166+
className="flex h-full w-full items-center justify-center p-6 [&_svg]:max-h-full [&_svg]:max-w-full"
167+
// biome-ignore lint/security/noDangerouslySetInnerHtml: mermaid.render() produces safe SVG
168+
dangerouslySetInnerHTML={{ __html: dialogSvg }}
169+
/>
170+
</TransformComponent>
171+
</>
172+
)}
173+
</TransformWrapper>
174+
)}
175+
</div>
176+
</dialog>
177+
</>
178+
);
179+
}

packages/web/src/lib/__tests__/format-prologue-markdown.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { formatPrologueAsMarkdown } from "../format-prologue-markdown";
1010
const basePrologue: Prologue = {
1111
motivation: "Reviews were hard to follow.",
1212
outcome: "Reviews read like a story now.",
13+
diagram: null,
1314
keyChanges: [
1415
{ summary: "Adds a sidebar", description: "Prologue and description tabs" },
1516
{ summary: "Reworks the list", description: "" },
@@ -57,4 +58,20 @@ describe("formatPrologueAsMarkdown", () => {
5758
expect(md).not.toContain("## Why this change?");
5859
expect(md).not.toContain("## What it does");
5960
});
61+
62+
it("renders the diagram as a fenced mermaid block when present", () => {
63+
const md = formatPrologueAsMarkdown({ ...basePrologue, diagram: "graph TD;\n A-->B" });
64+
expect(md).toContain("## Diagram\n```mermaid\ngraph TD;\n A-->B\n```");
65+
});
66+
67+
it("omits the Diagram section when diagram is null", () => {
68+
const md = formatPrologueAsMarkdown(basePrologue);
69+
expect(md).not.toContain("## Diagram");
70+
});
71+
72+
it("widens the diagram fence so backtick runs in the content can't break out", () => {
73+
const diagram = 'graph TD;\n A["```"]-->B';
74+
const md = formatPrologueAsMarkdown({ ...basePrologue, diagram });
75+
expect(md).toContain(`## Diagram\n\`\`\`\`mermaid\n${diagram}\n\`\`\`\``);
76+
});
6077
});

packages/web/src/lib/format-prologue-markdown.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
11
import { FOCUS_AREA_SEVERITY, type Prologue } from "@stagereview/types/prologue";
22

3+
/**
4+
* Picks a backtick fence longer than any backtick run inside the content (min 3),
5+
* per CommonMark, so content containing ``` can't break out of the code block.
6+
*/
7+
function codeFence(content: string): string {
8+
const longestRun = (content.match(/`+/g) ?? []).reduce(
9+
(max, run) => Math.max(max, run.length),
10+
0,
11+
);
12+
return "`".repeat(Math.max(3, longestRun + 1));
13+
}
14+
315
/** Renders the prologue as portable Markdown for the "Copy prologue" action. */
416
export function formatPrologueAsMarkdown(prologue: Prologue): string {
517
const sections: string[] = ["# Prologue"];
618

719
if (prologue.motivation) sections.push(`## Why this change?\n${prologue.motivation}`);
820
if (prologue.outcome) sections.push(`## What it does\n${prologue.outcome}`);
21+
if (prologue.diagram) {
22+
const fence = codeFence(prologue.diagram);
23+
sections.push(`## Diagram\n${fence}mermaid\n${prologue.diagram}\n${fence}`);
24+
}
925

1026
if (prologue.keyChanges.length > 0) {
1127
const bullets = prologue.keyChanges
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { default as MermaidAPI } from "mermaid";
2+
import { APP_THEME, type AppTheme } from "@/lib/theme";
3+
4+
let loadPromise: Promise<typeof MermaidAPI> | null = null;
5+
let currentTheme: AppTheme | null = null;
6+
7+
let renderCounter = 0;
8+
9+
// Quote unquoted [label] nodes containing mermaid-special characters (@ # < >)
10+
// while preserving multi-bracket shapes like [[subroutine]], [(cylinder)], [/parallelogram/].
11+
function sanitizeMermaidSource(source: string): string {
12+
return source.replace(/(?<!\[)\[(?![[(/\\])([^[\]"]*[@#<>][^[\]"]*)\](?!\])/g, '["$1"]');
13+
}
14+
15+
async function getMermaidInstance(theme: AppTheme): Promise<typeof MermaidAPI> {
16+
if (!loadPromise) {
17+
loadPromise = import("mermaid").then((mod) => mod.default);
18+
}
19+
20+
const instance = await loadPromise;
21+
22+
if (currentTheme !== theme) {
23+
currentTheme = theme;
24+
instance.initialize({
25+
startOnLoad: false,
26+
suppressErrorRendering: true,
27+
theme: theme === APP_THEME.DARK ? "dark" : "default",
28+
});
29+
}
30+
31+
return instance;
32+
}
33+
34+
export async function renderMermaidDiagram(
35+
source: string,
36+
theme: AppTheme,
37+
): Promise<{ svg: string }> {
38+
const instance = await getMermaidInstance(theme);
39+
const id = `mermaid-diagram-${++renderCounter}`;
40+
return instance.render(id, sanitizeMermaidSource(source));
41+
}

0 commit comments

Comments
 (0)