onChange(resetValue)}
+ />
+ );
+}
diff --git a/playground/src/components/EditorPane.tsx b/playground/src/components/EditorPane.tsx
new file mode 100644
index 00000000..eafe3abb
--- /dev/null
+++ b/playground/src/components/EditorPane.tsx
@@ -0,0 +1,102 @@
+import { indentWithTab } from "@codemirror/commands";
+import { python } from "@codemirror/lang-python";
+import { Extension } from "@codemirror/state";
+import { EditorView, keymap } from "@codemirror/view";
+import { basicSetup } from "codemirror";
+import { useEffect, useRef } from "react";
+import { blueprintTheme } from "../editor-theme";
+
+interface Props {
+ initialCode: string;
+ extensions?: Extension[];
+ lineCount: number;
+ onChange: (code: string) => void;
+ onReplaceRef?: (replace: (code: string) => void) => void;
+ /** Mod-Enter: run the current code immediately (skips the debounce). */
+ onRunNow?: () => void;
+ /** Mod-S: share (intercepts the browser's save dialog). */
+ onShare?: () => void;
+}
+
+export default function EditorPane({
+ initialCode,
+ extensions = [],
+ lineCount,
+ onChange,
+ onReplaceRef,
+ onRunNow,
+ onShare,
+}: Props) {
+ const hostRef = useRef
(null);
+ // Keep the latest callbacks without remounting the view (avoids stale
+ // closures if prop identities change after mount).
+ const onChangeRef = useRef(onChange);
+ onChangeRef.current = onChange;
+ const onRunNowRef = useRef(onRunNow);
+ onRunNowRef.current = onRunNow;
+ const onShareRef = useRef(onShare);
+ onShareRef.current = onShare;
+
+ // NOTE: `extensions` and `initialCode` are intentionally captured once at
+ // mount — the App mounts EditorPane only after the catalog is loaded, so
+ // extensions are stable for the lifetime of the view.
+ useEffect(() => {
+ const view = new EditorView({
+ doc: initialCode,
+ parent: hostRef.current!,
+ extensions: [
+ // Playground-level bindings, ahead of basicSetup so they win:
+ // Mod-Enter = run now (skip debounce), Mod-S = share link instead of
+ // the browser's useless save dialog.
+ keymap.of([
+ {
+ key: "Mod-Enter",
+ preventDefault: true,
+ run: () => {
+ onRunNowRef.current?.();
+ return true;
+ },
+ },
+ {
+ key: "Mod-s",
+ preventDefault: true,
+ run: () => {
+ onShareRef.current?.();
+ return true;
+ },
+ },
+ ]),
+ basicSetup,
+ // basicSetup's default keymap doesn't bind Tab (that's deliberate
+ // upstream, so Tab still moves focus for a11y by default) — add it
+ // back explicitly so Tab indents code, matching every code editor's
+ // expected behavior here. autocompletion's own Tab-to-accept binding
+ // (wired in App.tsx via `extensions`, spread in below) still wins
+ // while a completion popup is open — CodeMirror gives it higher
+ // precedence internally — so this only fires when no popup is open.
+ keymap.of([indentWithTab]),
+ python(),
+ blueprintTheme,
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) onChangeRef.current(update.state.doc.toString());
+ }),
+ ...extensions,
+ ],
+ });
+ onReplaceRef?.((code) => {
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: code } });
+ });
+ return () => view.destroy();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+ main.py
+ {lineCount} lines
+
+
+
+ );
+}
diff --git a/playground/src/components/ErrorPanel.tsx b/playground/src/components/ErrorPanel.tsx
new file mode 100644
index 00000000..2a13bf7a
--- /dev/null
+++ b/playground/src/components/ErrorPanel.tsx
@@ -0,0 +1,13 @@
+interface Props {
+ error: string | null;
+ testId?: string;
+}
+
+export default function ErrorPanel({ error, testId = "error-panel" }: Props) {
+ if (!error) return null;
+ return (
+
+ {error}
+
+ );
+}
diff --git a/playground/src/components/ExamplesGallery.tsx b/playground/src/components/ExamplesGallery.tsx
new file mode 100644
index 00000000..fd934444
--- /dev/null
+++ b/playground/src/components/ExamplesGallery.tsx
@@ -0,0 +1,23 @@
+import { EXAMPLES } from "../examples";
+
+interface Props {
+ activeExample: string | null;
+ onSelect: (example: { title: string; code: string }) => void;
+}
+
+export default function ExamplesGallery({ activeExample, onSelect }: Props) {
+ return (
+
+ Examples
+ {EXAMPLES.map((example) => (
+ onSelect(example)}
+ >
+ {example.title}
+
+ ))}
+
+ );
+}
diff --git a/playground/src/components/ExportBar.tsx b/playground/src/components/ExportBar.tsx
new file mode 100644
index 00000000..a76784b0
--- /dev/null
+++ b/playground/src/components/ExportBar.tsx
@@ -0,0 +1,249 @@
+import { useEffect, useState } from "react";
+import { download, inlineIcons, svgToPngBlob } from "../export/exporter";
+import type { FocusEvent, KeyboardEvent } from "react";
+
+type DownloadFormat = "png" | "svg" | "jpeg";
+
+const MIN_DIM = 16;
+const MAX_DIM = 8192;
+const COPIED_TIMEOUT_MS = 2000;
+
+interface Props {
+ svgs: { name: string; svg: string }[];
+}
+
+function slug(name: string): string {
+ return name ? name.toLowerCase().replace(/\W+/g, "_") : "diagram";
+}
+
+// Parses a raw SIZE field's text into a positive pixel value. Empty (or
+// not-yet-a-number, e.g. mid-typing) means "no override" — resolveSize's
+// aspect-ratio-preserving default takes over for that axis. Clamping to
+// [MIN_DIM, MAX_DIM] happens separately on blur (see `clampDimText`).
+function parseDim(raw: string): number | undefined {
+ if (raw.trim() === "") return undefined;
+ const n = Number(raw);
+ return Number.isFinite(n) && n > 0 ? n : undefined;
+}
+
+function clampDimText(raw: string): string {
+ if (raw.trim() === "") return "";
+ const n = Math.round(Number(raw));
+ if (!Number.isFinite(n)) return "";
+ return String(Math.min(MAX_DIM, Math.max(MIN_DIM, n)));
+}
+
+// Small download-arrow icon rendered to the right of each format button's
+// label. Purely decorative — the button's accessible name still comes from
+// its text content ("PNG"/"SVG"/"JPEG"), which e2e depends on — so this
+// stays aria-hidden.
+function DownloadIcon() {
+ return (
+
+
+
+
+ );
+}
+
+// Small copy icon rendered LEFT of "Copy" (mockup order) — decorative and
+// aria-hidden for the same reason as `DownloadIcon`.
+function CopyIcon() {
+ return (
+
+
+
+
+ );
+}
+
+// Permanently-visible export bar docked under the editor (Mermaid-Live
+// style). Two full-width rows:
+// - fields row: `SIZE [W] × [H] px [Auto toggle]` stretched across the
+// full width (+ TARGET select when several diagrams exist). One Auto
+// switch governs BOTH dimensions: ON = inputs disabled/dimmed and the
+// exporter's aspect-preserving default (2x) applies; OFF = explicit
+// pixel inputs (either axis may be left empty for per-axis aspect
+// auto). Toggling Auto back on keeps the last typed values.
+// - actions row: PNG / SVG / JPEG download buttons (equal width) and a
+// compact "Copy Image" (clipboard PNG) button. Exports always use a
+// white background (the BACKGROUND option was dropped).
+export default function ExportBar({ svgs }: Props) {
+ const [auto, setAuto] = useState(true);
+ const [width, setWidth] = useState("");
+ const [height, setHeight] = useState("");
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ // Keep the selection valid as the diagram list changes (e.g. a script edit
+ // that drops from two `with Diagram(...)` blocks down to one).
+ useEffect(() => {
+ if (selectedIndex >= svgs.length && svgs.length > 0) setSelectedIndex(0);
+ }, [svgs.length, selectedIndex]);
+
+ const active = svgs[selectedIndex] ?? null;
+ const effectiveWidth = auto ? undefined : parseDim(width);
+ const effectiveHeight = auto ? undefined : parseDim(height);
+
+ function handleDimBlur(setter: (v: string) => void) {
+ return (e: FocusEvent) => setter(clampDimText(e.currentTarget.value));
+ }
+
+ function handleDimKeyDown(e: KeyboardEvent) {
+ // Commit + clamp on Enter rather than waiting for blur, so a keyboard
+ // user can confirm the value without tabbing away.
+ if (e.key === "Enter") e.currentTarget.blur();
+ }
+
+ async function handleDownload(format: DownloadFormat) {
+ if (!active) return;
+ setError(null);
+ setCopied(false);
+ try {
+ const fileSlug = slug(active.name);
+ if (format === "svg") {
+ // SVG is vector — SIZE is meaningless for it.
+ const inlined = await inlineIcons(active.svg);
+ download(`${fileSlug}.svg`, new Blob([inlined], { type: "image/svg+xml" }));
+ } else {
+ const mime = format === "jpeg" ? "image/jpeg" : "image/png";
+ const blob = await svgToPngBlob(active.svg, {
+ width: effectiveWidth,
+ height: effectiveHeight,
+ mime,
+ });
+ download(`${fileSlug}.${format === "jpeg" ? "jpg" : "png"}`, blob);
+ }
+ } catch (err) {
+ setError(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
+ async function handleCopyImage() {
+ if (!active) return;
+ setError(null);
+ try {
+ const blob = await svgToPngBlob(active.svg, {
+ width: effectiveWidth,
+ height: effectiveHeight,
+ mime: "image/png",
+ });
+ await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
+ setCopied(true);
+ setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS);
+ } catch (err) {
+ setCopied(false);
+ setError(`Copy failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
+ const message = error
+ ? { text: error, tone: "error" as const }
+ : copied
+ ? { text: "Copied!", tone: "success" as const }
+ : null;
+
+ return (
+
+
+
+
+ void handleDownload("png")}
+ >
+ PNG
+
+
+ void handleDownload("svg")}
+ >
+ SVG
+
+
+ void handleDownload("jpeg")}
+ >
+ JPEG
+
+
+ void handleCopyImage()}>
+
+ Copy Image
+
+ {message && {message.text} }
+
+
+ );
+}
diff --git a/playground/src/components/NodeSearch.tsx b/playground/src/components/NodeSearch.tsx
new file mode 100644
index 00000000..b1a71ca2
--- /dev/null
+++ b/playground/src/components/NodeSearch.tsx
@@ -0,0 +1,297 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { providerLabel } from "../search/providerLabel";
+import { searchCatalog, type SearchHit } from "../search/search";
+import { catalogTree } from "../search/tree";
+import type { Catalog } from "../types";
+
+interface Props {
+ catalog: Catalog | null;
+ onInsert: (importStmt: string) => void;
+ /** Resizable sidebar width in px (see DragHandle); falls back to CSS. */
+ width?: number;
+}
+
+interface MenuHit {
+ name: string;
+ importStmt: string;
+}
+
+interface MenuState {
+ x: number;
+ y: number;
+ hit: MenuHit;
+}
+
+interface HitRowProps {
+ icon: string;
+ name: string;
+ importStmt: string;
+ module?: string;
+ onInsert: (importStmt: string) => void;
+ onContextMenu: (x: number, y: number, hit: MenuHit) => void;
+ // Set only when rendered as a class row inside an expanded tree category
+ // (depth 2); the container carries the indent/rail, this only tweaks the
+ // row's own padding via the "is-tree-class" CSS class.
+ inTree?: boolean;
+}
+
+// Shared row for a single class: real node icon (bare, no wrapper box),
+// name, optional module path. Fixed layout that never shifts on hover —
+// used by both the flat search results and the expanded-category tree rows
+// so the two views stay visually unified. Left-click inserts the import;
+// right-click opens a small custom context menu (Copy/Insert) owned by the
+// parent NodeSearch.
+function HitRow({ icon, name, importStmt, module, onInsert, onContextMenu, inTree }: HitRowProps) {
+ return (
+ onInsert(importStmt)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onInsert(importStmt);
+ }
+ }}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ onContextMenu(e.clientX, e.clientY, { name, importStmt });
+ }}
+ >
+
+
+
+ {name}
+ {module !== undefined && {module} }
+
+ );
+}
+
+interface ContextMenuProps {
+ x: number;
+ y: number;
+ hit: MenuHit;
+ onInsert: (importStmt: string) => void;
+ onClose: () => void;
+}
+
+// Small custom right-click menu: Copy import / Insert import. Rendered via a
+// portal straight onto — several ancestors (e.g. `.node-search`'s
+// fade-slide entrance animation) end their animation with a lingering
+// `transform`, which per spec establishes a new containing block for any
+// `position: fixed` descendant. Left in place, that made the menu position
+// itself relative to the sidebar instead of the viewport (appearing far from
+// the click) and clipped it under `.node-search`'s `overflow: hidden` and
+// the editor's stacking context (covered instead of on top). Portaling to
+// `document.body` sidesteps all of that: `position: fixed` is now always
+// viewport-relative, and a high z-index guarantees it paints above the
+// editor. Closes on outside pointerdown, Esc, or scroll (capture-phase
+// listeners so scrolling inside the results list — which doesn't bubble —
+// still closes it); these still work unchanged since `rootRef` points at the
+// real portaled DOM node regardless of where in the tree it renders.
+function ContextMenu({ x, y, hit, onInsert, onClose }: ContextMenuProps) {
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ function handlePointerDown(e: PointerEvent) {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) onClose();
+ }
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+ function handleScroll() {
+ onClose();
+ }
+ document.addEventListener("pointerdown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("scroll", handleScroll, true);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("scroll", handleScroll, true);
+ };
+ }, [onClose]);
+
+ return createPortal(
+
+ {
+ void navigator.clipboard.writeText(hit.importStmt).catch(() => {});
+ onClose();
+ }}
+ >
+ Copy import
+
+ {
+ onInsert(hit.importStmt);
+ onClose();
+ }}
+ >
+ Insert import
+
+
,
+ document.body
+ );
+}
+
+const MENU_WIDTH = 160;
+const MENU_HEIGHT = 76;
+
+// Centered SVG chevron shared by both tree levels. Unlike the old text
+// glyphs ("▸"/"▾"), the triangle is geometrically centered in its box, so
+// the CSS 90° open-rotation spins in place instead of drifting (the glyph's
+// off-center metrics were visibly shifting the depth-1 caret mid-turn).
+function Chevron() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default function NodeSearch({ catalog, onInsert, width }: Props) {
+ const [query, setQuery] = useState("");
+ const [expanded, setExpanded] = useState>(new Set());
+ const [menu, setMenu] = useState(null);
+
+ const hits = useMemo(
+ () => (catalog ? searchCatalog(catalog, query) : []),
+ [catalog, query]
+ );
+ const tree = useMemo(() => (catalog ? catalogTree(catalog) : []), [catalog]);
+
+ function toggle(key: string) {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }
+
+ // One menu instance at a time: keep it fully on-screen by clamping against
+ // the viewport rather than letting it render past the right/bottom edge.
+ function openMenu(x: number, y: number, hit: MenuHit) {
+ const clampedX = Math.min(x, window.innerWidth - MENU_WIDTH - 8);
+ const clampedY = Math.min(y, window.innerHeight - MENU_HEIGHT - 8);
+ setMenu({ x: Math.max(8, clampedX), y: Math.max(8, clampedY), hit });
+ }
+
+ // Flat search-result row (non-empty query).
+ function hitRow(hit: SearchHit) {
+ return (
+
+ );
+ }
+
+ const isBlank = !query.trim();
+
+ return (
+
+
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ data-testid="node-search-input"
+ />
+
+ {isBlank ? (
+
+ {tree.map((provider) => {
+ const providerKey = `provider:${provider.provider}`;
+ const providerOpen = expanded.has(providerKey);
+ return (
+
+ toggle(providerKey)}
+ >
+
+ {providerLabel(provider.provider)}
+ {provider.count}
+
+ {providerOpen && (
+
+ {provider.categories.map((cat) => {
+ const categoryKey = `category:${cat.module}`;
+ const categoryOpen = expanded.has(categoryKey);
+ const label = cat.category || cat.module.split(".").pop() || cat.module;
+ return (
+
+ toggle(categoryKey)}
+ >
+
+ {label}
+ {cat.classes.length}
+
+ {categoryOpen && (
+
+ {cat.classes.map((cls) => (
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ ) : (
+
{hits.map((hit) => hitRow(hit))}
+ )}
+ {menu && (
+
setMenu(null)}
+ />
+ )}
+
+ );
+}
diff --git a/playground/src/components/PreviewPane.tsx b/playground/src/components/PreviewPane.tsx
new file mode 100644
index 00000000..98e60cd1
--- /dev/null
+++ b/playground/src/components/PreviewPane.tsx
@@ -0,0 +1,337 @@
+import type { KeyboardEvent, ReactNode } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { svgStats } from "../utils/svgStats";
+import { fitView, zoomAt, type ViewTransform } from "../utils/zoom";
+
+interface Props {
+ svgs: { name: string; svg: string }[];
+ loading: boolean;
+ renderMs: number | null;
+ onRenameDiagram?: (index: number, name: string) => void;
+ /** Rendered docked at the bottom of the pane, below the canvas
+ * (the ExportBar lives here per the design mockup). */
+ children?: ReactNode;
+}
+
+// True infinite canvas: the container is a fixed, overflow:hidden viewport;
+// all pan/zoom state lives in `view` (tx/ty/scale) and is applied as a
+// single CSS transform on the absolutely-positioned content layer. Unlike
+// the old scrollLeft/scrollTop-based pan, tx/ty are unbounded — content can
+// be dragged past the left/top edge (negative translate) with no clamping.
+export default function PreviewPane({ svgs, loading, renderMs, onRenameDiagram, children }: Props) {
+ const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 });
+ const containerRef = useRef(null);
+ const contentRef = useRef(null);
+ const viewRef = useRef(view);
+ viewRef.current = view;
+
+ // Click-to-edit for the fixed header's diagram name (always index 0 — the
+ // per-sheet `.sheet-label`s below stay read-only). `titleDraft` is local
+ // input state so typing doesn't touch `svgs`/App state until commit.
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
+ const [titleDraft, setTitleDraft] = useState("");
+ const titleInputRef = useRef(null);
+
+ useEffect(() => {
+ if (!isEditingTitle) return;
+ const input = titleInputRef.current;
+ input?.focus();
+ input?.select();
+ }, [isEditingTitle]);
+
+ function startEditingTitle() {
+ if (!onRenameDiagram) return;
+ setTitleDraft(svgs[0]?.name ?? "");
+ setIsEditingTitle(true);
+ }
+
+ function commitTitleEdit() {
+ setIsEditingTitle(false);
+ const nextName = titleDraft;
+ const previousName = svgs[0]?.name ?? "";
+ if (nextName !== previousName) onRenameDiagram?.(0, nextName);
+ }
+
+ function handleTitleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitTitleEdit();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setIsEditingTitle(false);
+ }
+ }
+
+ // Fits the content layer entirely inside the canvas viewport (scaled down
+ // for large diagrams, scaled up — capped at 2x — for tiny ones) and
+ // centers it on both axes. offsetWidth/Height read the content's
+ // untransformed layout size (CSS transforms don't affect layout), so this
+ // is correct regardless of the view's current scale. Used both for the
+ // initial/on-new-svgs sizing and for the "%" fit button.
+ const fitToView = useCallback(() => {
+ const container = containerRef.current;
+ const content = contentRef.current;
+ if (!container || !content) return;
+ const rect = container.getBoundingClientRect();
+ setView(fitView(rect.width, rect.height, content.offsetWidth, content.offsetHeight));
+ }, []);
+
+ useLayoutEffect(() => {
+ fitToView();
+ }, [svgs, fitToView]);
+
+ // ctrl+wheel (trackpad pinch) zooms at the cursor; a plain wheel pans
+ // (natural two-finger trackpad scroll). Must be a real (non-passive) DOM
+ // listener — not React's onWheel — so preventDefault() actually stops the
+ // browser's own page-zoom/scroll for the gesture.
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ function handleWheel(e: WheelEvent) {
+ e.preventDefault();
+ const rect = container!.getBoundingClientRect();
+ if (e.ctrlKey) {
+ const factor = Math.exp(-e.deltaY * 0.01);
+ setView((v) => zoomAt(v, e.clientX - rect.left, e.clientY - rect.top, factor));
+ } else {
+ setView((v) => ({ ...v, tx: v.tx - e.deltaX, ty: v.ty - e.deltaY }));
+ }
+ }
+ container.addEventListener("wheel", handleWheel, { passive: false });
+ return () => container.removeEventListener("wheel", handleWheel);
+ }, []);
+
+ // Two-pointer touch pinch: track active pointers' positions, and on each
+ // move with exactly two active pointers, derive a scale factor from the
+ // change in distance between them and zoom at their midpoint.
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const pointers = new Map();
+ let lastDistance: number | null = null;
+
+ function distance(): number {
+ const [a, b] = [...pointers.values()];
+ return Math.hypot(a.x - b.x, a.y - b.y);
+ }
+ function midpoint(): { x: number; y: number } {
+ const [a, b] = [...pointers.values()];
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
+ }
+
+ function handlePointerDown(e: PointerEvent) {
+ if (e.pointerType !== "touch") return;
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ lastDistance = pointers.size === 2 ? distance() : null;
+ }
+ function handlePointerMove(e: PointerEvent) {
+ if (!pointers.has(e.pointerId)) return;
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (pointers.size !== 2) return;
+ const dist = distance();
+ if (lastDistance === null || lastDistance === 0) {
+ lastDistance = dist;
+ return;
+ }
+ const factor = dist / lastDistance;
+ const { x, y } = midpoint();
+ const rect = container!.getBoundingClientRect();
+ setView((v) => zoomAt(v, x - rect.left, y - rect.top, factor));
+ lastDistance = dist;
+ }
+ function handlePointerEnd(e: PointerEvent) {
+ pointers.delete(e.pointerId);
+ lastDistance = pointers.size === 2 ? distance() : null;
+ }
+
+ container.addEventListener("pointerdown", handlePointerDown);
+ container.addEventListener("pointermove", handlePointerMove);
+ container.addEventListener("pointerup", handlePointerEnd);
+ container.addEventListener("pointercancel", handlePointerEnd);
+ container.addEventListener("pointerleave", handlePointerEnd);
+ return () => {
+ container.removeEventListener("pointerdown", handlePointerDown);
+ container.removeEventListener("pointermove", handlePointerMove);
+ container.removeEventListener("pointerup", handlePointerEnd);
+ container.removeEventListener("pointercancel", handlePointerEnd);
+ container.removeEventListener("pointerleave", handlePointerEnd);
+ };
+ }, []);
+
+ // One-pointer drag-to-pan: tx/ty move freely with the pointer — no bounds,
+ // so dragging toward the left/top can carry content into negative
+ // translate space (this is exactly what fixes "can't pan left/up past the
+ // edge" from the old scrollLeft/scrollTop approach). A 3px move threshold
+ // keeps incidental clicks (zoom buttons, etc.) from starting a drag; those
+ // targets are also excluded outright. If a second pointer comes down
+ // mid-drag, pan disengages immediately and hands off to the pinch effect.
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ let draggingId: number | null = null;
+ let engaged = false;
+ let originX = 0;
+ let originY = 0;
+ let originTx = 0;
+ let originTy = 0;
+
+ function disengage() {
+ if (draggingId !== null && container!.hasPointerCapture(draggingId)) {
+ container!.releasePointerCapture(draggingId);
+ }
+ container!.classList.remove("is-panning");
+ draggingId = null;
+ engaged = false;
+ }
+
+ function handlePointerDown(e: PointerEvent) {
+ if (draggingId !== null) {
+ disengage();
+ return;
+ }
+ if (e.button !== 0) return;
+ if ((e.target as Element).closest("button, a, input, select, [role=menu]")) return;
+ draggingId = e.pointerId;
+ engaged = false;
+ originX = e.clientX;
+ originY = e.clientY;
+ originTx = viewRef.current.tx;
+ originTy = viewRef.current.ty;
+ container!.setPointerCapture(draggingId);
+ }
+
+ function handlePointerMove(e: PointerEvent) {
+ if (draggingId === null || e.pointerId !== draggingId) return;
+ const dx = e.clientX - originX;
+ const dy = e.clientY - originY;
+ if (!engaged) {
+ if (Math.hypot(dx, dy) < 3) return;
+ engaged = true;
+ container!.classList.add("is-panning");
+ }
+ setView((v) => ({ ...v, tx: originTx + dx, ty: originTy + dy }));
+ }
+
+ function handlePointerEnd(e: PointerEvent) {
+ if (draggingId !== e.pointerId) return;
+ disengage();
+ }
+
+ container.addEventListener("pointerdown", handlePointerDown);
+ container.addEventListener("pointermove", handlePointerMove);
+ container.addEventListener("pointerup", handlePointerEnd);
+ container.addEventListener("pointercancel", handlePointerEnd);
+ return () => {
+ container.removeEventListener("pointerdown", handlePointerDown);
+ container.removeEventListener("pointermove", handlePointerMove);
+ container.removeEventListener("pointerup", handlePointerEnd);
+ container.removeEventListener("pointercancel", handlePointerEnd);
+ };
+ }, []);
+
+ // Zoom segment (−/+) buttons zoom around the container's center.
+ function zoomByFactor(factor: number) {
+ const container = containerRef.current;
+ if (!container) return;
+ const rect = container.getBoundingClientRect();
+ setView((v) => zoomAt(v, rect.width / 2, rect.height / 2, factor));
+ }
+
+ const pct = Math.round(view.scale * 100);
+ const first = svgs[0];
+ const firstStats = first ? svgStats(first.svg) : null;
+ const showSheetLabels = svgs.length > 1;
+
+ return (
+
+
+
+ {isEditingTitle ? (
+ setTitleDraft(e.target.value)}
+ onBlur={commitTitleEdit}
+ onKeyDown={handleTitleKeyDown}
+ />
+ ) : (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ startEditingTitle();
+ }
+ }
+ : undefined
+ }
+ title={onRenameDiagram ? "Click to rename" : undefined}
+ >
+ {first?.name || "diagram"}
+ {onRenameDiagram && (
+
+ ✎
+
+ )}
+
+ )}
+ {firstStats && (
+
+ · {firstStats.nodes} nodes · {firstStats.edges} edges
+
+ )}
+
+
+
+ zoomByFactor(1 / 1.2)} aria-label="Zoom out">
+ −
+
+
+ {pct}%
+
+ zoomByFactor(1.2)} aria-label="Zoom in">
+ +
+
+
+
+
+ {/* testid lives on the canvas (not the pane root) so e2e's
+ `preview svg` matches only rendered diagram SVGs — the ExportBar
+ docked below carries its own decorative icon
s. */}
+
+
+ {svgs.map(({ name, svg }, i) => (
+
+ {showSheetLabels && {name || "diagram"}
}
+
+
+ ))}
+
+ {loading &&
Rendering… }
+ {renderMs != null &&
rendered in {renderMs}ms }
+
+ {children}
+
+ );
+}
diff --git a/playground/src/components/Toolbar.tsx b/playground/src/components/Toolbar.tsx
new file mode 100644
index 00000000..e733e1c0
--- /dev/null
+++ b/playground/src/components/Toolbar.tsx
@@ -0,0 +1,145 @@
+import { useEffect, useState } from "react";
+import { formatStars } from "../utils/format";
+import { toggleTheme } from "../utils/theme";
+
+interface Props {
+ status: string;
+ onShare: () => void;
+ shared: boolean;
+}
+
+function statusVariant(status: string): "ready" | "busy" | "error" {
+ if (status === "Ready") return "ready";
+ if (status.startsWith("Failed")) return "error";
+ return "busy";
+}
+
+const STARS_CACHE_KEY = "dgp-gh-stars";
+const STARS_TTL_MS = 60 * 60 * 1000; // 1h
+const STARS_API_URL = "https://api.github.com/repos/mingrammer/diagrams";
+// The badge must always render — when the API is unreachable (e.g. rate
+// limited) and no cache exists yet, fall back to this approximate count;
+// it self-corrects on the next successful fetch.
+const FALLBACK_STARS = 42_500;
+
+interface StarsCache {
+ count: number;
+ ts: number;
+}
+
+function readCachedStars(ignoreTtl = false): number | null {
+ try {
+ const raw = localStorage.getItem(STARS_CACHE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as StarsCache;
+ if (typeof parsed.count !== "number" || typeof parsed.ts !== "number") return null;
+ if (!ignoreTtl && Date.now() - parsed.ts > STARS_TTL_MS) return null;
+ return parsed.count;
+ } catch {
+ return null;
+ }
+}
+
+// GitHub's official octocat mark, inlined so it renders crisp at 16px with
+// no extra request and follows `currentColor` in both themes.
+function GitHubMark() {
+ return (
+
+
+
+ );
+}
+
+// Small filled star, used ahead of the formatted count.
+function StarMark() {
+ return (
+
+
+
+ );
+}
+
+export default function Toolbar({ status, onShare, shared }: Props) {
+ const isLoading = status === "Rendering…";
+ const variant = statusVariant(status);
+ // Stale-while-error: fresh cache → stale cache (expired TTL) → baked-in
+ // fallback, so the count is always visible; a successful fetch replaces it.
+ const [stars, setStars] = useState(
+ () => readCachedStars() ?? readCachedStars(true) ?? FALLBACK_STARS
+ );
+
+ useEffect(() => {
+ if (readCachedStars() !== null) return; // fresh cache already applied above
+ let cancelled = false;
+ fetch(STARS_API_URL)
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+ })
+ .then((data: { stargazers_count?: unknown }) => {
+ if (cancelled) return;
+ const count = data.stargazers_count;
+ if (typeof count !== "number") return;
+ setStars(count);
+ localStorage.setItem(STARS_CACHE_KEY, JSON.stringify({ count, ts: Date.now() } satisfies StarsCache));
+ })
+ .catch(() => {
+ // Fetch failed (network, rate limit, bad shape): keep whatever count
+ // is already displayed — a fresh/stale cached value or the baked-in
+ // fallback — rather than surfacing an error.
+ });
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ {/* The real diagrams project logo (copied from assets/img/diagrams.png)
+ on a white chip so its dark strokes stay legible in dark theme. */}
+
+
+
+ {/* The page's only h1 — the app is a single tool, and crawlers that do
+ run JS should still find a real heading. */}
+ Diagrams Playground
+
+
+ {status}
+
+
+
+
+ );
+}
diff --git a/playground/src/editor-theme.ts b/playground/src/editor-theme.ts
new file mode 100644
index 00000000..f487e450
--- /dev/null
+++ b/playground/src/editor-theme.ts
@@ -0,0 +1,98 @@
+import type { Extension } from "@codemirror/state";
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
+import { EditorView } from "@codemirror/view";
+import { tags as t } from "@lezer/highlight";
+
+// Blueprint engineering drawing theme for CodeMirror.
+// Static module-level constant — imported once into EditorPane's static
+// extension list at mount time (see HARD CONSTRAINT #4 in the redesign spec).
+//
+// Every color below is a var(--cm-*)/var(--syn-*) reference into app.css's
+// per-theme token blocks (:root[data-theme="dark"|"light"]). CodeMirror's
+// theme values are plain CSS strings, so var() resolves live against
+// document.documentElement's data-theme attribute — flipping the theme
+// re-paints the editor instantly with no remount required.
+
+const blueprintEditorTheme = EditorView.theme(
+ {
+ "&": {
+ backgroundColor: "var(--cm-bg)",
+ color: "var(--cm-fg)",
+ height: "100%",
+ },
+ ".cm-content": {
+ caretColor: "var(--cm-caret)",
+ fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
+ fontSize: "13px",
+ },
+ ".cm-cursor, .cm-dropCursor": {
+ borderLeftColor: "var(--cm-caret)",
+ borderLeftWidth: "2px",
+ },
+ // `!important` is required here: @codemirror/view's own baseTheme (part
+ // of basicSetup) ships a same-named `&dark.cm-focused > .cm-scroller >
+ // .cm-selectionLayer .cm-selectionBackground` rule with strictly higher
+ // selector specificity (it targets the internal DOM chain, not just the
+ // class) that otherwise wins the cascade and paints an opaque `#233`
+ // regardless of this token's value or insertion order — that mismatch,
+ // not the token color itself, was the actual cause of "selection too
+ // dark/opaque" (confirmed via getComputedStyle: it read `rgb(34,51,51)`
+ // — CodeMirror's literal default — in both app themes before this fix).
+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
+ backgroundColor: "var(--cm-selection) !important",
+ },
+ ".cm-activeLine": {
+ backgroundColor: "var(--cm-active-line)",
+ },
+ ".cm-gutters": {
+ backgroundColor: "var(--cm-gutter-bg)",
+ color: "var(--cm-gutter-fg)",
+ border: "none",
+ borderRight: "1px solid var(--cm-gutter-border)",
+ },
+ ".cm-activeLineGutter": {
+ backgroundColor: "var(--cm-active-gutter-bg)",
+ color: "var(--cm-active-gutter-fg)",
+ },
+ ".cm-lineNumbers .cm-gutterElement": {
+ fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
+ },
+ ".cm-selectionMatch": {
+ backgroundColor: "var(--cm-selection-match)",
+ },
+ ".cm-matchingBracket, .cm-nonmatchingBracket": {
+ backgroundColor: "var(--cm-matching-bracket-bg)",
+ outline: "1px solid var(--cm-matching-bracket-outline)",
+ },
+ ".cm-tooltip": {
+ backgroundColor: "var(--cm-tooltip-bg)",
+ border: "1px solid var(--cm-tooltip-border)",
+ color: "var(--cm-tooltip-fg)",
+ fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
+ },
+ ".cm-tooltip-autocomplete ul li[aria-selected]": {
+ backgroundColor: "var(--cm-autocomplete-selected-bg)",
+ color: "var(--cm-autocomplete-selected-fg)",
+ },
+ },
+ { dark: true }
+);
+
+const blueprintHighlightStyle = HighlightStyle.define([
+ { tag: t.keyword, color: "var(--syn-keyword)" },
+ { tag: [t.string, t.special(t.string)], color: "var(--syn-string)" },
+ { tag: [t.comment, t.lineComment, t.blockComment, t.docComment], color: "var(--syn-comment)", fontStyle: "italic" },
+ {
+ tag: [t.function(t.variableName), t.function(t.definition(t.variableName)), t.className, t.definition(t.className)],
+ color: "var(--syn-function)",
+ },
+ { tag: [t.number, t.integer, t.float], color: "var(--syn-number)" },
+ { tag: [t.operator, t.punctuation, t.bracket], color: "var(--syn-operator)" },
+ { tag: t.variableName, color: "var(--syn-variable)" },
+ { tag: t.propertyName, color: "var(--syn-property)" },
+ { tag: [t.bool, t.null], color: "var(--syn-bool)" },
+ { tag: t.definition(t.variableName), color: "var(--syn-variable)" },
+ { tag: t.atom, color: "var(--syn-atom)" },
+]);
+
+export const blueprintTheme: Extension = [blueprintEditorTheme, syntaxHighlighting(blueprintHighlightStyle)];
diff --git a/playground/src/examples.ts b/playground/src/examples.ts
new file mode 100644
index 00000000..a738e455
--- /dev/null
+++ b/playground/src/examples.ts
@@ -0,0 +1,80 @@
+export const DEFAULT_CODE = `from diagrams import Diagram
+from diagrams.aws.compute import EC2
+from diagrams.aws.database import RDS
+from diagrams.aws.network import ELB
+
+with Diagram("Web Service", show=False):
+ ELB("lb") >> EC2("web") >> RDS("userdb")
+`;
+
+export const EXAMPLES: { title: string; code: string }[] = [
+ { title: "Web Service", code: DEFAULT_CODE },
+ {
+ title: "Grouped Workers",
+ code: `from diagrams import Diagram
+from diagrams.aws.compute import EC2
+from diagrams.aws.database import RDS
+from diagrams.aws.network import ELB
+
+with Diagram("Grouped Workers", show=False, direction="TB"):
+ ELB("lb") >> [EC2("worker1"),
+ EC2("worker2"),
+ EC2("worker3"),
+ EC2("worker4"),
+ EC2("worker5")] >> RDS("events")
+`,
+ },
+ {
+ title: "Clustered Web Services",
+ code: `from diagrams import Cluster, Diagram
+from diagrams.aws.compute import ECS
+from diagrams.aws.database import ElastiCache, RDS
+from diagrams.aws.network import ELB, Route53
+
+with Diagram("Clustered Web Services", show=False):
+ dns = Route53("dns")
+ lb = ELB("lb")
+
+ with Cluster("Services"):
+ svc_group = [ECS("web1"), ECS("web2"), ECS("web3")]
+
+ with Cluster("DB Cluster"):
+ db_primary = RDS("userdb")
+ db_primary - [RDS("userdb ro")]
+
+ memcached = ElastiCache("memcached")
+
+ dns >> lb >> svc_group
+ svc_group >> db_primary
+ svc_group >> memcached
+`,
+ },
+ {
+ title: "Event Processing (K8s + OnPrem)",
+ code: `from diagrams import Cluster, Diagram
+from diagrams.aws.compute import ECS, EKS, Lambda
+from diagrams.aws.database import Redshift
+from diagrams.aws.integration import SQS
+from diagrams.aws.storage import S3
+
+with Diagram("Event Processing", show=False):
+ source = EKS("k8s source")
+
+ with Cluster("Event Flows"):
+ with Cluster("Event Workers"):
+ workers = [ECS("worker1"), ECS("worker2"), ECS("worker3")]
+
+ queue = SQS("event queue")
+
+ with Cluster("Processing"):
+ handlers = [Lambda("proc1"), Lambda("proc2"), Lambda("proc3")]
+
+ store = S3("events store")
+ dw = Redshift("analytics")
+
+ source >> workers >> queue >> handlers
+ handlers >> store
+ handlers >> dw
+`,
+ },
+];
diff --git a/playground/src/export/exporter.test.ts b/playground/src/export/exporter.test.ts
new file mode 100644
index 00000000..5f6020ae
--- /dev/null
+++ b/playground/src/export/exporter.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi } from "vitest";
+import { inlineIcons, resolveSize } from "./exporter";
+
+const PNG_BYTES = new Uint8Array([137, 80, 78, 71]);
+
+function fakeFetch(): typeof fetch {
+ return vi.fn(async () => new Response(PNG_BYTES.buffer, { status: 200 })) as unknown as typeof fetch;
+}
+
+describe("inlineIcons", () => {
+ it("replaces icons/ hrefs with data URIs", async () => {
+ const svg = ` `;
+ const result = await inlineIcons(svg, fakeFetch());
+ expect(result).toContain("data:image/png;base64,iVBORw==".slice(0, 30));
+ expect(result).not.toContain("icons/aws");
+ });
+
+ it("fetches each unique icon once", async () => {
+ const fetcher = fakeFetch();
+ const svg = ` `;
+ await inlineIcons(svg, fetcher);
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ });
+
+ it("leaves svg without icons untouched", async () => {
+ const svg = "hi ";
+ expect(await inlineIcons(svg, fakeFetch())).toBe(svg);
+ });
+
+ it("rejects when an icon fetch returns non-ok", async () => {
+ const fetcher = vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch;
+ const svg = ` `;
+ await expect(inlineIcons(svg, fetcher)).rejects.toThrow("HTTP 404");
+ });
+
+ it("uses svg mime for .svg icons", async () => {
+ const svg = ` `;
+ const result = await inlineIcons(svg, fakeFetch());
+ expect(result).toContain("data:image/svg+xml;base64,");
+ });
+});
+
+describe("resolveSize", () => {
+ it("defaults to 2x the natural size when both axes are unset", () => {
+ expect(resolveSize(100, 50)).toEqual({ w: 200, h: 100 });
+ });
+
+ it("treats invalid (non-finite / non-positive) axes as unset, falling back to the 2x default", () => {
+ expect(resolveSize(200, 100, 0, Number.NaN)).toEqual({ w: 400, h: 200 });
+ });
+
+ it("derives height from the natural aspect ratio when only width is given", () => {
+ expect(resolveSize(200, 100, 50)).toEqual({ w: 50, h: 25 });
+ });
+
+ it("derives width from the natural aspect ratio when only height is given", () => {
+ expect(resolveSize(200, 100, undefined, 25)).toEqual({ w: 50, h: 25 });
+ });
+
+ it("uses both axes exactly, allowing aspect distortion", () => {
+ expect(resolveSize(200, 100, 300, 50)).toEqual({ w: 300, h: 50 });
+ });
+
+ it("clamps each axis to the 16px minimum independently after computation", () => {
+ // width=5 -> derived height = round(5 * 100 / 200) = 3, both below 16.
+ expect(resolveSize(200, 100, 5)).toEqual({ w: 16, h: 16 });
+ });
+
+ it("clamps each axis to the 8192px maximum independently after computation", () => {
+ expect(resolveSize(200, 100, 20_000)).toEqual({ w: 8192, h: 8192 });
+ });
+
+ it("falls back to the (clamped) 2x default when the natural size is degenerate, ignoring width/height", () => {
+ expect(resolveSize(0, 100, 300, 150)).toEqual({ w: 16, h: 200 });
+ expect(resolveSize(-10, -20)).toEqual({ w: 16, h: 16 });
+ });
+});
diff --git a/playground/src/export/exporter.ts b/playground/src/export/exporter.ts
new file mode 100644
index 00000000..0f1caa27
--- /dev/null
+++ b/playground/src/export/exporter.ts
@@ -0,0 +1,116 @@
+const ICON_HREF = /(xlink:href|href)="(icons\/[^"]+)"/g;
+
+async function toDataUri(url: string, fetcher: typeof fetch): Promise {
+ const res = await fetcher(url);
+ if (!res.ok) throw new Error(`Failed to fetch icon ${url}: HTTP ${res.status}`);
+ const mime = url.endsWith(".svg") ? "image/svg+xml" : "image/png";
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ let binary = "";
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return `data:${mime};base64,${btoa(binary)}`;
+}
+
+export async function inlineIcons(svg: string, fetcher: typeof fetch = fetch): Promise {
+ const urls = new Set();
+ for (const match of svg.matchAll(ICON_HREF)) urls.add(match[2]);
+ if (!urls.size) return svg;
+ const dataUris = new Map();
+ await Promise.all(
+ [...urls].map(async (url) => dataUris.set(url, await toDataUri(url, fetcher)))
+ );
+ return svg.replace(ICON_HREF, (_full, attr, url) => `${attr}="${dataUris.get(url)}"`);
+}
+
+export interface OutputSize {
+ w: number;
+ h: number;
+}
+
+const MIN_OUTPUT_SIZE = 16;
+const MAX_OUTPUT_SIZE = 8192;
+
+function clampAxis(n: number): number {
+ return Math.min(MAX_OUTPUT_SIZE, Math.max(MIN_OUTPUT_SIZE, Math.round(n)));
+}
+
+// Resolves the raster output size for an export from the optional
+// user-provided WIDTH/HEIGHT fields (both in px):
+// - neither set (or not a positive finite number) -> defaults to 2x the
+// diagram's natural size, matching the export bar's previous default
+// output.
+// - only one axis set -> the other axis is derived from the diagram's
+// natural aspect ratio, so the diagram is never stretched by accident.
+// - both axes set -> used exactly as given; the user explicitly asked for
+// both dimensions, so aspect distortion is allowed.
+// A degenerate natural size (<=0 on either axis, e.g. before an has
+// finished loading) can't produce a meaningful aspect ratio, so it's guarded
+// by always falling back to the default-2x branch, which is itself then
+// clamped, so the result is still a valid, positive canvas size.
+// Every result is rounded and clamped per-axis to [MIN_OUTPUT_SIZE,
+// MAX_OUTPUT_SIZE] so callers can hand it straight to a .
+export function resolveSize(naturalW: number, naturalH: number, width?: number, height?: number): OutputSize {
+ const validWidth = width !== undefined && Number.isFinite(width) && width > 0;
+ const validHeight = height !== undefined && Number.isFinite(height) && height > 0;
+ const naturalOk = naturalW > 0 && naturalH > 0;
+
+ let w: number;
+ let h: number;
+ if (naturalOk && validWidth && validHeight) {
+ w = width as number;
+ h = height as number;
+ } else if (naturalOk && validWidth) {
+ w = width as number;
+ h = ((width as number) * naturalH) / naturalW;
+ } else if (naturalOk && validHeight) {
+ h = height as number;
+ w = ((height as number) * naturalW) / naturalH;
+ } else {
+ w = naturalW * 2;
+ h = naturalH * 2;
+ }
+ return { w: clampAxis(w), h: clampAxis(h) };
+}
+
+export interface PngExportOptions {
+ width?: number;
+ height?: number;
+ mime?: "image/png" | "image/jpeg";
+}
+
+export async function svgToPngBlob(svg: string, options?: PngExportOptions): Promise {
+ const { mime = "image/png", width, height } = options ?? {};
+ const inlined = await inlineIcons(svg);
+ const svgBlob = new Blob([inlined], { type: "image/svg+xml" });
+ const url = URL.createObjectURL(svgBlob);
+ try {
+ const img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = () => resolve();
+ img.onerror = () => reject(new Error("Failed to load SVG for export"));
+ img.src = url;
+ });
+ const { w, h } = resolveSize(img.naturalWidth, img.naturalHeight, width, height);
+ const canvas = document.createElement("canvas");
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext("2d")!;
+ ctx.fillStyle = "white";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.scale(w / img.naturalWidth, h / img.naturalHeight);
+ ctx.drawImage(img, 0, 0);
+ return await new Promise((resolve, reject) =>
+ canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), mime)
+ );
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+export function download(filename: string, blob: Blob): void {
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/playground/src/main.tsx b/playground/src/main.tsx
new file mode 100644
index 00000000..d05d5fd2
--- /dev/null
+++ b/playground/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./app.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/playground/src/renderer/render.test.ts b/playground/src/renderer/render.test.ts
new file mode 100644
index 00000000..8042857f
--- /dev/null
+++ b/playground/src/renderer/render.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { renderDot } from "./render";
+
+describe("renderDot", () => {
+ it("renders plain dot to svg", async () => {
+ const svg = await renderDot("digraph { a -> b }");
+ expect(svg).toContain(" {
+ const dot = `digraph {
+ n [label="web" height="1.9" image="/site-packages/resources/aws/compute/ec2.png" shape=none fixedsize=true width="1.4"]
+ }`;
+ const svg = await renderDot(dot);
+ expect(svg).toContain(`icons/aws/compute/ec2.png`);
+ }, 30_000);
+
+ it("strips script elements injected via labels", async () => {
+ const svg = await renderDot(`digraph { a [label="<x>"] }`);
+ expect(svg).not.toContain("