feat(ui): copy the flow or the map as an image, for a PR comment or a README (CG-55)

"Copy image" and "Download SVG" on the Flow strip's header and in the Map's
side panel. The image is the distribution loop: a flow pasted into a review, a
map pasted into a README, read by somebody with no viewer open.

The exporter serialises the LAYOUT OBJECT rather than scraping the DOM — no
html-to-image, no foreignObject, no new dependency. buildFlowLayout and
buildMapLayout already compute every rectangle, port and curve before a
component renders, so the image and the screen come from one piece of
arithmetic and cannot drift apart, and the whole exporter is a pure function a
test runs with no browser. Output is presentation-only SVG (rect, line, path,
polygon, text, tspan, clipPath) — no script, no external reference, no data:
URL — which is what GitHub's sanitiser accepts in a README.

Light theme is forced whatever the viewer is set to: a dark strip on GitHub's
white comment background reads as a mistake, not a preference. 24px of paper
around the drawing, a caption naming the path or the root at the bottom left,
a CodeGraph mark at the bottom right.

Fonts travel as family stacks, not bytes (spec). An SVG loaded as an image may
not fetch a webfont, so a raster falls back to the platform's own monospace —
every fallback in the stack advances at ~0.6em like IBM Plex Mono, so the code
grid survives and only the letterforms change. Text is truncated
arithmetically with an ellipsis and clipped as well, so a wider fallback
cannot spill a source line out of a card.

`scale` multiplies only the root width/height while the viewBox stays in CSS
pixels, so the raster draws an image whose intrinsic size is already 2x
instead of upscaling a 1x bitmap. The clipboard write uses the ClipboardItem
promise form (Safari discards the gesture across an await) and falls back to
downloading the PNG, saying which happened rather than claiming a copy it did
not make.

Measured on this repo: execute -> rowToFileRecord (8 hops) exports 3690x253
CSS px, 491 kB PNG at 2x / 38 kB SVG; the 16-module map reproduces the canvas
exactly — 16 boxes, 52 links, 9 layer rules, both band labels, and with
src/index.ts selected 15 links and 4 dimmed boxes.
This commit is contained in:
Colby McHenry
2026-08-27 05:41:34 -05:00
parent 94f4e287e6
commit 8ac0138940
12 changed files with 1837 additions and 1 deletions
+29
View File
@@ -19,6 +19,8 @@
import FlowCard from '../components/flow/FlowCard.svelte';
import FlowLink from '../components/flow/FlowLink.svelte';
import FlowEndCap from '../components/flow/FlowEndCap.svelte';
import ExportButtons from '../components/ExportButtons.svelte';
import { exportFilename, flowSvg } from '../lib/export-svg';
import { fetchFlow, type WireFlow, type WireFlowPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { navigate, symbolHref } from '../lib/router.svelte';
@@ -223,6 +225,30 @@
}
return 'The longest call path among the symbols you named, the same one codegraph_explore leads with.';
}
/**
* The strip as it stands, for a PR comment or a README.
*
* Built from `layout` — the same object the canvas is drawing — so the image
* cannot say something the screen does not. The caption names the path,
* because an image pasted into a review has lost the header that did.
*/
const exportLabel = $derived(
showAll && flows.length > 1
? `all ${flows.length} paths`
: (activeFlow?.label ?? 'flow')
);
function buildSvg(scale: number): string {
if (layout === null) throw new Error('There is no strip to export yet.');
const hops = activeFlow?.hops.length ?? 0;
return flowSvg(layout, {
scale,
activeFlowId: picked,
showAll,
caption: showAll ? exportLabel : `${exportLabel}${hops > 1 ? ` · ${hops} hops` : ''}`,
});
}
</script>
<div class="flowview">
@@ -251,6 +277,9 @@
{#if payload}
<p class="note">{note(payload)}</p>
{/if}
{#if layout !== null}
<ExportButtons build={buildSvg} filename={exportFilename('flow', exportLabel)} />
{/if}
</header>
<div class="fstage">
+22
View File
@@ -18,6 +18,7 @@
import ModuleNode from '../components/map/ModuleNode.svelte';
import ModuleEdge from '../components/map/ModuleEdge.svelte';
import MapSidePanel from '../components/map/MapSidePanel.svelte';
import { exportFilename, mapSvg } from '../lib/export-svg';
import { fetchMap, type WireMapPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
import { mapHref, navigate } from '../lib/router.svelte';
@@ -167,6 +168,25 @@
navigate(mapHref({ root: next, depth, tests }));
}
/**
* The map as it stands, for a README.
*
* Serialised from `layout` — the object the canvas is drawing — so the file
* carries the same layering, the same hidden thin links and the same
* selection the reader is looking at. SVG rather than PNG is the point here:
* a forty-module map is a wide, mostly-empty drawing that scales, and GitHub
* renders SVG in a README.
*/
function buildSvg(scale: number): string {
if (layout === null) throw new Error('There is no map to export yet.');
const root = payload?.root ?? '';
return mapSvg(layout, {
scale,
selected,
caption: `${root || 'the project'} · ${layout.nodes.length} modules${selected ? ` · ${selected} selected` : ''}`,
});
}
function setTests(next: boolean): void {
selected = null;
navigate(mapHref({ root, depth, tests: next }));
@@ -267,6 +287,8 @@
{selected}
includeTests={tests}
files={selectedFiles}
buildSvg={buildSvg}
exportName={exportFilename('map', payload.root ?? '')}
onToggleTests={setTests}
onSelectRoot={setRoot}
onSelect={(id) => (selected = id)}