Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/import-chapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ describe("chapter import", () => {
const prologue = {
motivation: "Dashboards would break during deploys.",
outcome: "Dashboards stay up during deploys now.",
diagram: "graph LR;\n Deploy-->Cache-->Dashboard",
keyChanges: [
{
summary: "Deploy-safe dashboard rendering",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/runs.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ describe("runs API", () => {
const prologue = {
motivation: "Slow page loads on large repos.",
outcome: "Pages load fast now.",
diagram: null,
keyChanges: [
{ summary: "Pagination added to repo list", description: "Limits to 50 repos per page" },
],
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,61 @@ describe("ChaptersFileSchema", () => {
expect(result.prologue?.motivation).toBe(prologue.motivation);
});

it("defaults the prologue diagram to null when omitted", () => {
const prologue = {
motivation: null,
outcome: null,
keyChanges: [{ summary: "Tightens validation", description: "Rejects malformed input" }],
focusAreas: [
{
type: "data-integrity",
severity: "info",
title: "Input validation",
description: "Confirm the new constraints match the data model",
locations: ["src/schema.ts"],
},
],
complexity: { level: "low", reasoning: "Schema-only change" },
};
const result = ChaptersFileSchema.parse(makeFixture({ prologue }));
expect(result.prologue?.diagram).toBeNull();
});

it("preserves a Mermaid diagram on the prologue", () => {
const prologue = {
motivation: null,
outcome: null,
diagram: "graph TD;\n A-->B",
keyChanges: [{ summary: "Adds a pipeline", description: "Wires producers to consumers" }],
focusAreas: [
{
type: "architecture",
severity: "info",
title: "New data flow",
description: "Confirm the pipeline ordering is correct",
locations: ["src/pipeline.ts"],
},
],
complexity: { level: "medium", reasoning: "New control flow across modules" },
};
const result = ChaptersFileSchema.parse(makeFixture({ prologue }));
expect(result.prologue?.diagram).toBe(prologue.diagram);
});

it("rejects a non-string prologue diagram", () => {
const prologue = {
motivation: null,
outcome: null,
diagram: 42,
keyChanges: [{ summary: "x", description: "y" }],
focusAreas: [
{ type: "architecture", severity: "info", title: "t", description: "d", locations: [] },
],
complexity: { level: "low", reasoning: "r" },
};
expectInvalidAt(makeFixture({ prologue }), "prologue.diagram");
});

it("accepts a file without a prologue (backward compatibility)", () => {
const result = ChaptersFileSchema.parse(makeFixture());
expect(result.prologue).toBeUndefined();
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/prologue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type Complexity = z.infer<typeof ComplexitySchema>;
export const PrologueSchema = z.object({
motivation: z.string().nullable(),
outcome: z.string().nullable(),
/** Mermaid diagram source (without code fences), or null when prose alone is clear. */
diagram: z.string().nullable().default(null),
keyChanges: z.array(PrologueKeyChangeSchema),
focusAreas: z.array(FocusAreaSchema),
complexity: ComplexitySchema,
Expand Down
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.568.0",
"mermaid": "^11.12.3",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hotkeys-hook": "^5.3.0",
"react-markdown": "^10.1.0",
"react-zoom-pan-pinch": "^3.7.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/components/prologue/prologue-section.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FocusArea, FocusAreaSeverity, Prologue } from "@stagereview/types/prologue";
import { FOCUS_AREA_SEVERITY } from "@stagereview/types/prologue";
import { AlertTriangle } from "lucide-react";
import { MermaidDiagram } from "@/components/shared/mermaid-diagram";
import { cn } from "@/lib/utils";

const SEVERITY_COLORS: Record<string, string> = {
Expand Down Expand Up @@ -43,6 +44,8 @@ function PrologueDisplay({ prologue }: { prologue: Prologue }) {
</section>
)}

{prologue.diagram && <MermaidDiagram chart={prologue.diagram} />}

<section>
<h3 className="mb-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Key Changes
Expand Down
179 changes: 179 additions & 0 deletions packages/web/src/components/shared/mermaid-diagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Minus, Plus, Scan, X } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { renderMermaidDiagram } from "@/lib/mermaid-renderer";
import { useTheme } from "@/lib/theme";
import { cn } from "@/lib/utils";

function prepareSvgForDialog(svgHtml: string): string {
const div = document.createElement("div");
div.innerHTML = svgHtml;
const svgEl = div.querySelector("svg");
if (!svgEl) return svgHtml;

const w = svgEl.getAttribute("width");
const h = svgEl.getAttribute("height");
if (!svgEl.hasAttribute("viewBox") && w && h) {
svgEl.setAttribute("viewBox", `0 0 ${parseFloat(w)} ${parseFloat(h)}`);
}

svgEl.removeAttribute("width");
svgEl.removeAttribute("height");
svgEl.style.removeProperty("max-width");
svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet");

return div.innerHTML;
}

interface MermaidDiagramProps {
chart: string;
}

const ZOOM_CONTROLS_CLASSES =
"cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";

export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const { appTheme } = useTheme();
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
const titleId = useId();
const prevChartRef = useRef(chart);
const pointerDownTargetRef = useRef<EventTarget | null>(null);
const dialogSvg = useMemo(() => (svg ? prepareSvgForDialog(svg) : null), [svg]);

useEffect(() => {
let cancelled = false;

if (prevChartRef.current !== chart) {
prevChartRef.current = chart;
setSvg(null);
setError(false);
}

renderMermaidDiagram(chart, appTheme)
.then(({ svg }) => {
if (cancelled) return;
setSvg(svg);
setError(false);
})
.catch(() => {
if (!cancelled) setError(true);
});

return () => {
cancelled = true;
};
}, [chart, appTheme]);
Comment thread
dastratakos marked this conversation as resolved.

if (error) {
return (
<pre className="my-2 overflow-x-auto rounded-md border border-border/50 bg-muted/30 p-3 text-xs text-muted-foreground">
{chart}
</pre>
);
}

return (
<>
<div className="group relative my-3">
<div
className={cn(
"flex max-h-64 justify-center overflow-hidden [&_svg]:max-w-full",
!svg && "h-24 animate-pulse rounded-md bg-muted/30",
)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: mermaid.render() produces safe SVG
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
/>
{svg && (
<button
type="button"
onClick={() => dialogRef.current?.showModal()}
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"
>
<span className="mb-3 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-sm">
View full diagram
</span>
</button>
)}
</div>

{/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop click-to-close is a standard dialog pattern */}
<dialog
ref={dialogRef}
aria-labelledby={titleId}
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"
onPointerDown={(e) => {
pointerDownTargetRef.current = e.target;
}}
onClick={(e) => {
if (e.target === e.currentTarget && pointerDownTargetRef.current === e.currentTarget)
e.currentTarget.close();
}}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 id={titleId} className="text-sm font-semibold">
Diagram
</h2>
<button
type="button"
aria-label="Close diagram"
className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => dialogRef.current?.close()}
>
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1">
{dialogSvg && (
<TransformWrapper
initialScale={1}
minScale={0.5}
maxScale={4}
centerOnInit
wheel={{ step: 0.08 }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<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">
<button
type="button"
aria-label="Zoom out"
className={ZOOM_CONTROLS_CLASSES}
onClick={() => zoomOut()}
>
<Minus className="size-4" />
</button>
<button
type="button"
aria-label="Reset zoom"
className={ZOOM_CONTROLS_CLASSES}
onClick={() => resetTransform()}
>
<Scan className="size-4" />
</button>
<button
type="button"
aria-label="Zoom in"
className={ZOOM_CONTROLS_CLASSES}
onClick={() => zoomIn()}
>
<Plus className="size-4" />
</button>
</div>
<TransformComponent wrapperClass="!h-full !w-full" contentClass="!h-full !w-full">
<div
className="flex h-full w-full items-center justify-center p-6 [&_svg]:max-h-full [&_svg]:max-w-full"
// biome-ignore lint/security/noDangerouslySetInnerHtml: mermaid.render() produces safe SVG
dangerouslySetInnerHTML={{ __html: dialogSvg }}
/>
</TransformComponent>
</>
)}
</TransformWrapper>
)}
</div>
</dialog>
</>
);
}
17 changes: 17 additions & 0 deletions packages/web/src/lib/__tests__/format-prologue-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { formatPrologueAsMarkdown } from "../format-prologue-markdown";
const basePrologue: Prologue = {
motivation: "Reviews were hard to follow.",
outcome: "Reviews read like a story now.",
diagram: null,
keyChanges: [
{ summary: "Adds a sidebar", description: "Prologue and description tabs" },
{ summary: "Reworks the list", description: "" },
Expand Down Expand Up @@ -57,4 +58,20 @@ describe("formatPrologueAsMarkdown", () => {
expect(md).not.toContain("## Why this change?");
expect(md).not.toContain("## What it does");
});

it("renders the diagram as a fenced mermaid block when present", () => {
const md = formatPrologueAsMarkdown({ ...basePrologue, diagram: "graph TD;\n A-->B" });
expect(md).toContain("## Diagram\n```mermaid\ngraph TD;\n A-->B\n```");
});

it("omits the Diagram section when diagram is null", () => {
const md = formatPrologueAsMarkdown(basePrologue);
expect(md).not.toContain("## Diagram");
});

it("widens the diagram fence so backtick runs in the content can't break out", () => {
const diagram = 'graph TD;\n A["```"]-->B';
const md = formatPrologueAsMarkdown({ ...basePrologue, diagram });
expect(md).toContain(`## Diagram\n\`\`\`\`mermaid\n${diagram}\n\`\`\`\``);
});
});
16 changes: 16 additions & 0 deletions packages/web/src/lib/format-prologue-markdown.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { FOCUS_AREA_SEVERITY, type Prologue } from "@stagereview/types/prologue";

/**
* Picks a backtick fence longer than any backtick run inside the content (min 3),
* per CommonMark, so content containing ``` can't break out of the code block.
*/
function codeFence(content: string): string {
const longestRun = (content.match(/`+/g) ?? []).reduce(
(max, run) => Math.max(max, run.length),
0,
);
return "`".repeat(Math.max(3, longestRun + 1));
}

/** Renders the prologue as portable Markdown for the "Copy prologue" action. */
export function formatPrologueAsMarkdown(prologue: Prologue): string {
const sections: string[] = ["# Prologue"];

if (prologue.motivation) sections.push(`## Why this change?\n${prologue.motivation}`);
if (prologue.outcome) sections.push(`## What it does\n${prologue.outcome}`);
if (prologue.diagram) {
const fence = codeFence(prologue.diagram);
sections.push(`## Diagram\n${fence}mermaid\n${prologue.diagram}\n${fence}`);
}

if (prologue.keyChanges.length > 0) {
const bullets = prologue.keyChanges
Expand Down
41 changes: 41 additions & 0 deletions packages/web/src/lib/mermaid-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { default as MermaidAPI } from "mermaid";
import { APP_THEME, type AppTheme } from "@/lib/theme";

let loadPromise: Promise<typeof MermaidAPI> | null = null;
let currentTheme: AppTheme | null = null;

let renderCounter = 0;

// Quote unquoted [label] nodes containing mermaid-special characters (@ # < >)
// while preserving multi-bracket shapes like [[subroutine]], [(cylinder)], [/parallelogram/].
function sanitizeMermaidSource(source: string): string {
return source.replace(/(?<!\[)\[(?![[(/\\])([^[\]"]*[@#<>][^[\]"]*)\](?!\])/g, '["$1"]');
}

async function getMermaidInstance(theme: AppTheme): Promise<typeof MermaidAPI> {
if (!loadPromise) {
loadPromise = import("mermaid").then((mod) => mod.default);
}

const instance = await loadPromise;

if (currentTheme !== theme) {
currentTheme = theme;
instance.initialize({
startOnLoad: false,
suppressErrorRendering: true,
theme: theme === APP_THEME.DARK ? "dark" : "default",
});
}

return instance;
}

export async function renderMermaidDiagram(
source: string,
theme: AppTheme,
): Promise<{ svg: string }> {
const instance = await getMermaidInstance(theme);
const id = `mermaid-diagram-${++renderCounter}`;
return instance.render(id, sanitizeMermaidSource(source));
}
Loading
Loading