Ver código fonte

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.
Colby McHenry 1 semana atrás
pai
commit
8ac0138940

+ 4 - 0
CHANGELOG.md

@@ -46,6 +46,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   Nothing polls: the viewer watches for these two things and is told about them. If it loses touch with the server it retries a few times with a growing delay, then stops and says "Not live" in the top bar rather than hammering a port that isn't answering.
 
+- **Take a flow or a map with you: copy it as an image, or save it as an SVG.** The Flow strip and the Map both gained **Copy image** and **Download SVG**. Copy image puts a PNG on your clipboard, ready to paste into a pull-request comment or a chat — the fastest way to say "here is what your change actually touches" without asking anyone to install anything. Download SVG saves a file for a README: it is real text rather than a bitmap, so it stays sharp at any size and the symbol names in it are selectable.
+
+  Both render the light theme whichever one you are reading in, because the image is going to be read on somebody else's screen, and both carry a caption saying what the picture is. What comes out is exactly what is on screen — the same hops, the same dashed dynamic-dispatch links with their wiring sites, the same "where the graph stops" block, the same modules dimmed or brought forward by your selection — because the image is drawn from the same measurements the screen is, not photographed off it. An eight-hop strip comes out around half a megabyte, well inside what GitHub takes inline.
+
 - **"Where does anything start?" has a screen now.** The **Entry points** tab in `codegraph ui` (or press `e`) is the first thing worth opening on a codebase you have never seen. Every route with the symbol that serves it and the `file:line` you will find it at, grouped by the file the URL is registered in — your router, not your handlers — and headed with the framework CodeGraph detected it from. Under that, the files that actually *do* something when they load (a CLI, a worker entry, a build script), the tests ranked by how much of the project each one exercises, and the symbols the most code depends on.
 
   None of it is guessed from a filename: a file "runs something" because the graph recorded a call from the file itself, and a project with fewer than three routes simply has no Routes section rather than an empty one. Every list says how much of itself it is showing, and says "at least" wherever the real total can only be a floor.

+ 1 - 0
README.md

@@ -350,6 +350,7 @@ What you get on that screen:
 - **Ask for a path.** Type "how does execute reach getFile" (or `execute -> getFile`) and you get the **flow**: one card per hop, each opened at the line that makes the next call. Hops that no static edge records — a callback, an interface dispatch, a React re-render — are drawn dashed and name where the handler was wired. "Read as flow" turns a walk you did by hand into the same strip.
 - **And when the path runs out, it says where.** A flow that doesn't get there ends in "Where the graph stops": the kind of dispatch that ended it (a computed member call, a `getattr`, a reflective invoke, a message bus), its line, the key when the source spells one out, and a shortlist of what could be on the other side — plus the name-only matches CodeGraph refused to follow, with their confidence. Nothing is guessed, and a flow that does connect never shows it.
 - **The map**: the whole project at module granularity, laid out from the graph with dependencies pointing down — never drawn by hand, and the same picture every time. Cycles are listed rather than straightened away.
+- **Take the picture with you.** A flow strip or a map can be copied as an image straight into a pull-request comment, or saved as an SVG for a README — always in the light theme, whichever one you are reading in, with a caption saying what the picture is. The SVG is real text, so it stays sharp at any size and the names in it are selectable.
 - **It keeps up.** Save a file and a banner appears within about a third of a second saying the index hasn't caught up yet — and the screen switches to the file's current source rather than a body sliced at lines it no longer has. When something re-indexes, whatever is on screen refetches itself and says "Index updated · reloaded". A symbol that moved because you added a line above it is followed, not lost. Nothing polls: the viewer watches, and if it loses touch with the server it retries a few times and then says so instead of hammering it.
 
 Options: `--port <n>` to pin a port (without it the viewer takes 4747, or the next free one),

+ 531 - 0
__tests__/ui-export-svg.test.ts

@@ -0,0 +1,531 @@
+/**
+ * The SVG exporter (CG-55) — `ui/src/lib/export-svg.ts`.
+ *
+ * The export exists to leave the app, so the properties worth pinning are the
+ * ones a reader on the other side depends on:
+ *
+ * - it is **well-formed XML**, or GitHub's sanitiser drops it and the reader
+ *   sees a broken-image icon with no explanation;
+ * - it carries the **light** tokens whatever the viewer was set to, because a
+ *   dark image on a white comment background reads as a mistake;
+ * - it says the **same thing the screen does** — same cards, same hops, same
+ *   dashed hops, same hidden thin links — because the whole point of exporting
+ *   from the layout object rather than the DOM is that the two cannot diverge;
+ * - it fits the drawing, with nothing running off the edge of the canvas.
+ *
+ * Everything here is pure. The raster step needs a browser and is verified
+ * over CDP against a live `codegraph ui`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  EXPORT_COLORS,
+  EXPORT_PADDING,
+  MARK_TEXT,
+  capRows,
+  esc,
+  exportFilename,
+  flowSvg,
+  mapSvg,
+  truncate,
+  wrapText,
+} from '../ui/src/lib/export-svg';
+import { buildFlowLayout } from '../ui/src/lib/flow-model';
+import { buildMapLayout } from '../ui/src/lib/map-model';
+import type {
+  WireFlow,
+  WireFlowBoundary,
+  WireFlowEdge,
+  WireFlowHop,
+  WireMapLink,
+  WireMapModule,
+  WireNodeRef,
+} from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- builders -- */
+
+function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
+  return {
+    kind: 'calls',
+    label: 'calls',
+    upward: false,
+    uncertain: false,
+    synthesized: false,
+    line: 42,
+    ...over,
+  };
+}
+
+function ref(name: string): WireNodeRef {
+  return {
+    id: `method:${name}`,
+    kind: 'method',
+    name,
+    qualifiedName: name,
+    file: `src/deep/${name}.ts`,
+    line: 10,
+    endLine: 40,
+    language: 'typescript',
+    test: false,
+  };
+}
+
+function hop(
+  name: string,
+  opts: { lines?: string[]; edge?: WireFlowEdge | null; callLine?: number } = {}
+): WireFlowHop {
+  const lines = opts.lines ?? ['  const a = 1;', '  return other(a);'];
+  return {
+    node: ref(name),
+    edge: opts.edge === undefined ? edge() : opts.edge,
+    callRef:
+      opts.callLine === undefined
+        ? null
+        : { line: opts.callLine, col: 9, name: 'other', targetId: 'method:other', backwards: false },
+    source: {
+      file: `src/deep/${name}.ts`,
+      language: 'typescript',
+      from: 7,
+      to: 6 + lines.length,
+      lines,
+      drift: false,
+    },
+  };
+}
+
+function flow(id: string, names: string[], over: Partial<WireFlow> = {}): WireFlow {
+  return {
+    id,
+    label: `${names[0]} → ${names[names.length - 1]}`,
+    hops: names.map((name, i) =>
+      hop(name, { edge: i === 0 ? null : edge(), callLine: i === 0 ? 8 : undefined })
+    ),
+    boundary: null,
+    partial: false,
+    ...over,
+  };
+}
+
+function boundary(over: Partial<WireFlowBoundary> = {}): WireFlowBoundary {
+  return {
+    node: ref('routeAny'),
+    sites: [
+      {
+        form: 'computed-call',
+        label: 'computed member call',
+        snippet: 'return table[name](payload);',
+        line: 61,
+        key: 'save',
+        keyIsType: false,
+        moreSites: 0,
+        candidates: [{ node: ref('onSave'), display: 'onSave', named: true }],
+        candidateNote: null,
+      },
+    ],
+    uncertain: { total: 0, shown: 0, truncated: false, items: [] },
+    further: { total: 0, shown: 0, truncated: false, items: [] },
+    missed: [],
+    ...over,
+  };
+}
+
+function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
+  return {
+    id,
+    label: id.slice(id.lastIndexOf('/') + 1) || id,
+    files: over.files ?? 3,
+    symbols: over.symbols ?? 30,
+    languages: over.languages ?? [{ language: 'typescript', files: 3 }],
+    test: over.test ?? false,
+    facade: over.facade ?? false,
+    fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
+  };
+}
+
+function link(source: string, target: string, count: number, declared = count): WireMapLink {
+  return { source, target, count, declared, byKind: [{ kind: 'calls', count }], topPairs: [] };
+}
+
+/* ------------------------------------------------------------ utilities -- */
+
+/**
+ * Parse the SVG the way a consumer does.
+ *
+ * `DOMParser` is not in Node, so this is a hand-rolled well-formedness check:
+ * every tag balanced, every attribute quoted, no stray `<` or `&` in text. That
+ * is exactly the class of bug an un-escaped symbol name (`Map<K,V>`, `a && b`)
+ * would introduce, and it is the one that makes GitHub refuse the file.
+ */
+function assertWellFormed(svg: string): void {
+  const stack: string[] = [];
+  const tag = /<(\/?)([a-zA-Z:]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
+  let at = 0;
+  let match: RegExpExecArray | null;
+  while ((match = tag.exec(svg)) !== null) {
+    const between = svg.slice(at, match.index);
+    expect(between, `unescaped < or & in text: ${JSON.stringify(between)}`).not.toMatch(
+      /[<]|&(?!(amp|lt|gt|quot|apos|#\d+);)/
+    );
+    at = match.index + match[0].length;
+    const [, closing, name, attrs, selfClosing] = match;
+    // Every attribute is name="value" with a balanced pair of quotes.
+    const quotes = (attrs as string).split('"').length - 1;
+    expect(quotes % 2, `unbalanced quotes in <${name} ${attrs}>`).toBe(0);
+    if (closing === '/') {
+      expect(stack.pop(), 'closing tag with no opener').toBe(name);
+    } else if (selfClosing !== '/') {
+      stack.push(name as string);
+    }
+  }
+  expect(stack, 'unclosed tags').toEqual([]);
+}
+
+function viewBox(svg: string): { width: number; height: number } {
+  const box = /viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/.exec(svg);
+  expect(box, 'no viewBox').toBeTruthy();
+  return { width: Number(box![1]), height: Number(box![2]) };
+}
+
+function rootSize(svg: string): { width: number; height: number } {
+  const w = /<svg[^>]*\bwidth="(\d+)"/.exec(svg);
+  const h = /<svg[^>]*\bheight="(\d+)"/.exec(svg);
+  return { width: Number(w![1]), height: Number(h![1]) };
+}
+
+/** Every x/y coordinate that appears on a drawn element, for a bounds check. */
+function coords(svg: string): Array<{ x: number; y: number }> {
+  const out: Array<{ x: number; y: number }> = [];
+  const re = /x="(-?\d+(?:\.\d+)?)"\s+y="(-?\d+(?:\.\d+)?)"/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(svg)) !== null) out.push({ x: Number(m[1]), y: Number(m[2]) });
+  return out;
+}
+
+/* ------------------------------------------------------------ primitives -- */
+
+describe('esc', () => {
+  it('escapes everything XML would choke on', () => {
+    expect(esc('Map<K, V> & "co"')).toBe('Map&lt;K, V&gt; &amp; &quot;co&quot;');
+  });
+});
+
+describe('truncate', () => {
+  it('leaves a string that fits alone, and ellipses one that does not', () => {
+    expect(truncate('short', 400, 12)).toBe('short');
+    // 12px mono advances at 7.2px, so 36px holds five characters.
+    expect(truncate('abcdefgh', 36, 12)).toBe('abcd…');
+  });
+
+  it('does not emit a lone ellipsis when there is no room at all', () => {
+    expect(truncate('abcdefgh', 7, 12)).toBe('');
+  });
+});
+
+describe('wrapText', () => {
+  it('breaks on words, never mid-word', () => {
+    expect(wrapText('the quick brown fox jumps over', 12)).toEqual([
+      'the quick',
+      'brown fox',
+      'jumps over',
+    ]);
+  });
+
+  it('keeps an over-long word on its own line rather than losing it', () => {
+    expect(wrapText('aa supercalifragilistic bb', 8)).toEqual(['aa', 'supercalifragilistic', 'bb']);
+  });
+});
+
+describe('exportFilename', () => {
+  it('slugs a flow label into something a filesystem accepts', () => {
+    expect(exportFilename('flow', 'execute → getFile')).toBe('codegraph-flow-execute-getfile');
+    expect(exportFilename('map', 'src/')).toBe('codegraph-map-src');
+    expect(exportFilename('map', '')).toBe('codegraph-map');
+  });
+});
+
+/* ------------------------------------------------------------ flow strip -- */
+
+describe('flowSvg', () => {
+  const layout = buildFlowLayout([flow('f1', ['execute', 'openFile', 'rowToFileRecord'])], 'f1');
+
+  it('is well-formed XML with a viewBox and the mark', () => {
+    const svg = flowSvg(layout);
+    assertWellFormed(svg);
+    expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(true);
+    expect(svg.trimEnd().endsWith('</svg>')).toBe(true);
+    expect(svg).toContain(`>${MARK_TEXT}</text>`);
+  });
+
+  it('paints the light paper whatever the viewer was set to', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
+    expect(svg).toContain(EXPORT_COLORS.ink);
+    // No token from the dark set appears anywhere in the file: dark paper,
+    // dark ink, dark accent. An export follows the reader's page, not ours.
+    for (const dark of ['#1c1a14', '#f3f1ea', '#d48b96', '#34322a']) {
+      expect(svg, dark).not.toContain(dark);
+    }
+  });
+
+  it('names every hop on the strip, once each', () => {
+    const svg = flowSvg(layout);
+    for (const name of ['execute', 'openFile', 'rowToFileRecord']) {
+      expect(svg.split(`>${name}<`).length - 1, name).toBe(1);
+    }
+  });
+
+  it('keeps fonts as stacks and embeds nothing', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain("'IBM Plex Mono'");
+    expect(svg).not.toContain('@font-face');
+    expect(svg).not.toContain('base64');
+  });
+
+  it('scales only the root size — the geometry is identical', () => {
+    const one = flowSvg(layout, { scale: 1 });
+    const two = flowSvg(layout, { scale: 2 });
+    expect(viewBox(two)).toEqual(viewBox(one));
+    expect(rootSize(two).width).toBe(rootSize(one).width * 2);
+    expect(rootSize(two).height).toBe(rootSize(one).height * 2);
+    // Same drawing, two envelopes: everything between the root tags matches.
+    expect(two.slice(two.indexOf('\n'))).toBe(one.slice(one.indexOf('\n')));
+  });
+
+  it('fits the drawing inside the canvas with the padding on every side', () => {
+    const svg = flowSvg(layout);
+    const box = viewBox(svg);
+    const cards = layout.cards;
+    const spanX = Math.max(...cards.map((c) => c.x + c.width)) - Math.min(...cards.map((c) => c.x));
+    expect(box.width).toBeGreaterThanOrEqual(spanX + EXPORT_PADDING * 2);
+    for (const { x, y } of coords(svg)) {
+      expect(x).toBeGreaterThanOrEqual(-1);
+      expect(y).toBeGreaterThanOrEqual(-1);
+    }
+  });
+
+  it('carries the edge label and the line the call was recorded at', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain('>calls</text>');
+    expect(svg).toContain('>line 42</text>');
+  });
+
+  it('dashes a synthesized hop exactly as the strip does', () => {
+    const synthesized = flow('f2', ['a', 'b']);
+    synthesized.hops[1]!.edge = edge({
+      synthesized: true,
+      label: 'via callback · registered at src/wire.ts:88',
+    });
+    const svg = flowSvg(buildFlowLayout([synthesized], 'f2'));
+    expect(svg).toContain('stroke-dasharray="5 3"');
+    // The wiring site is the evidence for a hop nobody can see in the source.
+    expect(svg).toContain('wire.ts:88');
+  });
+
+  it('tints the call line and underlines the identifier the graph resolved', () => {
+    const one = flow('f3', ['execute', 'other']);
+    one.hops[0]!.callRef = {
+      line: 8,
+      col: 9,
+      name: 'other',
+      targetId: 'method:other',
+      backwards: false,
+    };
+    const svg = flowSvg(buildFlowLayout([one], 'f3'));
+    expect(svg).toContain(`fill="${EXPORT_COLORS.accentSoft}"`);
+    expect(svg).toContain(`<tspan fill="${EXPORT_COLORS.accent}">other</tspan>`);
+    expect(svg).toContain(`stroke="${EXPORT_COLORS.accentLine}"`);
+  });
+
+  it('preserves the indentation of every source line', () => {
+    const svg = flowSvg(layout);
+    expect(svg).toContain('xml:space="preserve"');
+    expect(svg).toContain('<tspan>  </tspan>');
+  });
+
+  it('escapes source that would otherwise break the document', () => {
+    const nasty = flow('f4', ['render']);
+    nasty.hops[0]!.source!.lines = ['const x = a < b && c > d;', 'type T = Map<K, "v">;'];
+    const svg = flowSvg(buildFlowLayout([nasty], 'f4'));
+    assertWellFormed(svg);
+    expect(svg).toContain('&lt;');
+    expect(svg).toContain('&amp;&amp;');
+  });
+
+  it('draws the end cap dashed, with the site, the key and the candidate', () => {
+    const capped = flow('f5', ['dispatch'], { boundary: null });
+    capped.boundary = boundary({ node: capped.hops[0]!.node });
+    const svg = flowSvg(buildFlowLayout([capped], 'f5'));
+    expect(svg).toContain('Where the graph stops.');
+    expect(svg).toContain('computed member call at line 61');
+    expect(svg).toContain('>key save</text>');
+    expect(svg).toContain('1 candidate target');
+    // The dotted link into a cap, and the cap's own dashed border.
+    expect(svg).toContain('stroke-dasharray="2 4"');
+    expect(svg).toContain('>end of</text>');
+    // …and no arrowhead on it: the absence of a continuation is the finding.
+    expect(svg.match(/<polygon/g)).toBeNull();
+  });
+
+  it('gives the cap room for the lines it really wraps to', () => {
+    const long = boundary({
+      sites: [
+        {
+          form: 'computed-call',
+          label: 'reflective invoke through a registry of handlers',
+          snippet: 'x',
+          line: 61,
+          key: null,
+          keyIsType: false,
+          moreSites: 3,
+          candidates: [],
+          candidateNote: 'the key is too generic to shortlist against',
+        },
+      ],
+    });
+    const rows = capRows({
+      id: 'cap:x',
+      anchorId: 'x',
+      boundary: long,
+      x: 0,
+      y: 0,
+      width: 240,
+      height: 10,
+      flows: ['f'],
+    });
+    // Every row is inside the cap's own text column…
+    for (const row of rows.rows) expect(row.text.length).toBeLessThanOrEqual(32);
+    // …and the height accounts for all of them.
+    expect(rows.height).toBeGreaterThan(rows.rows.length * 15);
+  });
+
+  it('dims the paths that are not the picked one when several are drawn', () => {
+    const both = [flow('a', ['start', 'left', 'end']), flow('b', ['start', 'right', 'end'])];
+    const svg = flowSvg(buildFlowLayout(both, 'a'), { activeFlowId: 'a', showAll: true });
+    expect(svg).toContain('opacity="0.4"');
+    // The picked path keeps the accent border; the other does not.
+    expect(svg).toContain(`stroke="${EXPORT_COLORS.accent}"`);
+    expect(svg).toContain('>right</text>');
+  });
+
+  it('writes the caption next to the mark', () => {
+    const svg = flowSvg(layout, { caption: 'execute → rowToFileRecord · 3 hops' });
+    expect(svg).toContain('execute → rowToFileRecord · 3 hops');
+    assertWellFormed(svg);
+  });
+});
+
+/* -------------------------------------------------------------------- map -- */
+
+describe('mapSvg', () => {
+  const payload = {
+    modules: [
+      mod('src/bin'),
+      mod('src/mcp'),
+      mod('src/db', { symbols: 1218, files: 54 }),
+      mod('__tests__', { test: true }),
+    ],
+    links: [
+      link('src/bin', 'src/mcp', 30),
+      link('src/mcp', 'src/db', 22),
+      link('src/bin', 'src/db', 2),
+      link('__tests__', 'src/db', 40),
+    ],
+  };
+  const layout = buildMapLayout(payload, { includeTests: false });
+
+  it('is well-formed, light, and marked', () => {
+    const svg = mapSvg(layout);
+    assertWellFormed(svg);
+    expect(svg).toContain(`fill="${EXPORT_COLORS.paper}"`);
+    expect(svg).toContain(`>${MARK_TEXT}</text>`);
+  });
+
+  it('draws every module box with its name and its counts', () => {
+    const svg = mapSvg(layout);
+    expect(svg).toContain('>src/bin</text>');
+    expect(svg).toContain('>src/db</text>');
+    expect(svg).toContain('>1218 symbols · 54 files</text>');
+    // Tests were filtered out of the layout, so they are not in the image.
+    expect(svg).not.toContain('>__tests__</text>');
+  });
+
+  it('names the top and bottom bands', () => {
+    const svg = mapSvg(layout);
+    expect(svg).toContain('>entry points</text>');
+    expect(svg).toContain('>foundations — depend on nothing below</text>');
+  });
+
+  it('hides the same thin links the canvas hides', () => {
+    const svg = mapSvg(layout);
+    // src/bin → src/db carries 2, under MIN_WEIGHT: one path per visible link
+    // plus one per layer rule is not a count worth asserting, so check the
+    // stroke widths instead — a hidden link contributes none.
+    const drawn = svg.match(/<path /g)?.length ?? 0;
+    expect(drawn).toBe(layout.edges.filter((e) => !e.thin && !e.back).length);
+  });
+
+  it('brings a selected module’s thin links out, as the canvas does', () => {
+    const svg = mapSvg(layout, { selected: 'src/bin' });
+    const drawn = svg.match(/<path /g)?.length ?? 0;
+    expect(drawn).toBe(
+      layout.edges.filter((e) => e.source === 'src/bin' || e.target === 'src/bin').length
+    );
+  });
+
+  it('dims a module the selection does not touch, and only that one', () => {
+    // src/bin reaches both other modules, so a fixture needs a fourth module
+    // standing apart before dimming has anything to say.
+    const apart = buildMapLayout(
+      { modules: [...payload.modules, mod('site')], links: payload.links },
+      { includeTests: false }
+    );
+    const svg = mapSvg(apart, { selected: 'src/bin' });
+    // Exactly one box goes grey: its rule and its two lines of text.
+    expect(svg.split(`stroke="${EXPORT_COLORS.ink4}"`).length - 1).toBe(1);
+    expect(svg.split(`fill="${EXPORT_COLORS.ink4}"`).length - 1).toBe(2);
+  });
+
+  it('scales the root only', () => {
+    const one = mapSvg(layout, { scale: 1 });
+    const two = mapSvg(layout, { scale: 2 });
+    expect(viewBox(two)).toEqual(viewBox(one));
+    expect(rootSize(two).width).toBe(rootSize(one).width * 2);
+  });
+
+  it('keeps every drawn coordinate inside the canvas', () => {
+    const svg = mapSvg(layout);
+    const box = viewBox(svg);
+    for (const { x, y } of coords(svg)) {
+      expect(x).toBeGreaterThanOrEqual(-1);
+      expect(y).toBeGreaterThanOrEqual(-1);
+      expect(x).toBeLessThanOrEqual(box.width + 1);
+      expect(y).toBeLessThanOrEqual(box.height + 1);
+    }
+    // Layer rules are the one thing that spans the whole picture, and the one
+    // that used to run off the right-hand edge: they follow the boxes, not the
+    // canvas' own padded width.
+    const rules = [...svg.matchAll(/x1="(-?[\d.]+)"[^>]*x2="(-?[\d.]+)"/g)];
+    expect(rules.length).toBeGreaterThan(0);
+    for (const [, x1, x2] of rules) {
+      expect(Number(x1)).toBeGreaterThanOrEqual(0);
+      expect(Number(x2)).toBeLessThanOrEqual(box.width);
+    }
+  });
+
+  it('marks a test module dashed when it is included', () => {
+    const withTests = buildMapLayout(payload, { includeTests: true });
+    const svg = mapSvg(withTests);
+    expect(svg).toContain('>__tests__</text>');
+    expect(svg).toContain('stroke-dasharray="4 3"');
+  });
+
+  it('survives a module id that needs escaping', () => {
+    const odd = buildMapLayout(
+      { modules: [mod('src/<odd> & co'), mod('src/db')], links: [link('src/<odd> & co', 'src/db', 9)] },
+      { includeTests: false }
+    );
+    const svg = mapSvg(odd);
+    assertWellFormed(svg);
+    expect(svg).toContain('&lt;odd&gt; &amp; co');
+  });
+});

+ 32 - 0
docs/design/codegraph-ui-design-spec.md

@@ -285,6 +285,38 @@ Three banner variants, because what follows the dash is what the screen actually
 
 Measured: banner 360 ms after a save; toast 440 ms after `codegraph sync` returns; 0 requests in 4 idle seconds.
 
+### 3.9 Export (CG-55)
+"Copy image" and "Download SVG" on the Flow strip's header and in the Map's side panel. The image renders the **light** theme
+whatever the viewer is set to, at **2x** device pixels for the raster, with **24px** of `--paper` padding around the drawing and a
+"CodeGraph" mark in 11px `--mono` `--ink-3` at the bottom right; a caption in the same type sits at the bottom left, naming the path
+or the root. SVG keeps fonts as `font-family` **stacks** (no embedding) and inlines the token colours as literal hex. PNG for an
+8-hop strip stays under 1 MB.
+
+**As built.** The exporter (`ui/src/lib/export-svg.ts`) **serialises the layout object**, it does not scrape the DOM — no
+`html-to-image`, no `foreignObject`, no new dependency. `buildFlowLayout` and `buildMapLayout` already compute every rectangle, port
+and curve before anything renders, so the image and the screen come from one piece of arithmetic and cannot drift apart; the export
+is a pure function testable with no browser. The price, and the thing to know before changing a card's padding: the *visual* rules
+(paddings, baselines, type sizes) are stated twice — in the component's `<style>` and in the exporter — while the *placing* numbers
+(heights, widths, columns) are imported from the layout models and stated once.
+
+- Output is presentation-only SVG (`rect`, `line`, `path`, `polygon`, `text`, `tspan`, `clipPath`) — no script, no `foreignObject`,
+  no external reference, no `data:` URL — which is what GitHub's sanitiser will accept in a README.
+- `scale` multiplies only the root `width`/`height`; the `viewBox` stays in CSS pixels, so the raster step draws an image whose
+  *intrinsic* size is already 2x rather than upscaling a 1x bitmap.
+- Fonts fall back through the stack in a raster (an SVG loaded as an image may not fetch a webfont). Every fallback in the mono
+  stack advances at ~0.6em like IBM Plex Mono, so the code grid survives; only the letterforms change. Embedding would add ~90 kB of
+  base64 to every export.
+- Text is truncated arithmetically with an ellipsis — the twin of the components' `text-overflow` — and clipped as well, so a wider
+  fallback font cannot spill a source line out of a card.
+- The end cap measures its own wrapped lines rather than trusting `endCapHeight`'s character estimate: a `min-height` box on screen
+  can grow, an image cannot.
+- The clipboard write is attempted with 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 repository: `execute -> rowToFileRecord` (8 hops) exports 3690x253 CSS px, **491 kB** PNG at 2x / 38 kB SVG; the
+16-module map exports 566x1077 and reproduces the on-screen picture exactly (16 boxes, 52 links, 9 layer rules, both band labels;
+with `src/index.ts` selected, 15 links and 4 dimmed boxes, matching the canvas).
+
 ## 4. Libraries and versions
 - Svelte 5 (≥ 5.25) + Vite (workspace `ui/`), Svelte Flow `@xyflow/svelte` ^1.6 for the Map and Flow canvases only (custom nodes/edges,
   hidden handles for port spreading, local selection state — the pattern in docker-app's `StackGraph.svelte`); `@dagrejs/dagre` only as a

+ 10 - 0
site/src/content/docs/guides/viewer.md

@@ -107,6 +107,16 @@ It is honest about what it leaves out. Links carrying only a handful of referenc
 
 The map opens on your project's source directory. The picker switches to any other top-level folder or the whole repository, the checkbox brings test modules in, and `?depth=2` in the address splits a large folder into its sub-folders — the useful setting on a monorepo. What you are looking at lives in the URL, so the view is shareable.
 
+## Take the picture with you
+
+The flow strip and the map both carry **Copy image** and **Download SVG**.
+
+Copy image puts a PNG on the clipboard, ready to paste into a pull-request comment or a chat — the fastest way to say "this is what your change touches" without asking anyone to install something. Download SVG saves a file for a README: it is real text rather than a bitmap, so it stays sharp at any size and the symbol names in it are selectable and searchable.
+
+Both render the **light** theme whatever you are reading in, because the image is going to be read on somebody else's screen. Both carry a caption saying what the picture is — the path, or the root and how many modules — and a small CodeGraph mark in the corner. What you export is exactly what is on screen: the same hops, the same dashed dynamic-dispatch links, the same modules dimmed or brought forward by your selection, the same links hidden for being thin.
+
+An eight-hop strip comes out around half a megabyte, well inside what GitHub accepts inline.
+
 ## Options
 
 | | |

+ 24 - 1
ui/README.md

@@ -46,9 +46,11 @@ src/
   lib/flow-model.ts       the Flow strip's card/link geometry + the end cap — a DAG (pure)
   lib/filecode-model.ts   the whole-file view: fixed line height, arcs, paging (pure)
   lib/entry-model.ts      the entry-points panel: rows, file groups, flow arming (pure)
+  lib/export-svg.ts       the Flow strip and the Map as a standalone SVG (pure)
+  lib/export-image.ts     rasterising that SVG to PNG, clipboard and download
   lib/live.svelte.ts      /api/events: two counters every screen refreshes from
   lib/toast.svelte.ts     the one transient note ("Index updated · reloaded")
-  components/             TopBar, TrailBar, KindGlyph, DriftBanner, Toast, map/, flow/, symbol/, file/, entry/
+  components/             TopBar, TrailBar, KindGlyph, DriftBanner, Toast, ExportButtons, map/, flow/, symbol/, file/, entry/
   views/                  one component per route
 ```
 
@@ -56,6 +58,27 @@ Fonts (Archivo Variable, IBM Plex Mono) are vendored through `@fontsource*` and
 emitted into `dist/viewer/assets`: a local reader must work offline and must not
 announce the project to a font CDN.
 
+## Export
+
+The Flow strip's header and the Map's side panel carry **Copy image** (a PNG on
+the clipboard) and **Download SVG** (a file for a README). Both render the
+**light** theme whatever the viewer is set to — an image is read on somebody
+else's screen — with 24px of paper around the drawing, a caption naming the path
+or the root, and a "CodeGraph" mark in the corner.
+
+`export-svg.ts` **serialises the layout object**; it does not scrape the DOM.
+`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 disagree — and the exporter is a pure function
+that a test can run with no browser at all. The output is presentation-only SVG
+(no script, no `foreignObject`, no external reference), which is what GitHub
+will render in a README.
+
+Fonts travel as `font-family` stacks rather than embedded bytes. 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.
+
 ## Routes
 
 | hash | view |

+ 101 - 0
ui/src/components/ExportButtons.svelte

@@ -0,0 +1,101 @@
+<!--
+  Take the picture with you (design spec §3.9).
+
+  Two buttons, because there are exactly two destinations: a PR comment, which
+  wants a PNG on the clipboard, and a README, which wants an SVG on disk. Both
+  render the light theme whatever the viewer is set to — an image is read on
+  somebody else's screen, and a dark strip on GitHub's white comment background
+  reads as a mistake rather than a preference.
+
+  The SVG is built lazily, at click time, from the layout the canvas is already
+  drawing. Nothing is measured, nothing is scraped, and the button costs nothing
+  until it is pressed.
+-->
+<script lang="ts">
+  import { copyPngToClipboard, downloadSvg, svgToPng, PNG_SCALE } from '../lib/export-image';
+  import { toast } from '../lib/toast.svelte';
+
+  interface Props {
+    /** Builds the SVG at a given device-pixel scale. */
+    build: (scale: number) => string;
+    /** File stem for the download, without an extension. */
+    filename: string;
+    disabled?: boolean;
+  }
+
+  let { build, filename, disabled = false }: Props = $props();
+  let busy = $state(false);
+
+  async function copyImage(): Promise<void> {
+    if (busy) return;
+    busy = true;
+    try {
+      const where = await copyPngToClipboard(
+        () => svgToPng(build(PNG_SCALE)),
+        `${filename}.png`
+      );
+      toast.show(
+        where === 'copied'
+          ? 'Image copied · paste it into a comment'
+          : 'Clipboard unavailable · image saved instead'
+      );
+    } catch (error) {
+      toast.show(error instanceof Error ? error.message : 'The image could not be made.');
+    } finally {
+      busy = false;
+    }
+  }
+
+  function saveSvg(): void {
+    try {
+      downloadSvg(build(1), `${filename}.svg`);
+      toast.show('SVG saved');
+    } catch (error) {
+      toast.show(error instanceof Error ? error.message : 'The file could not be saved.');
+    }
+  }
+</script>
+
+<div class="exp">
+  <button type="button" onclick={copyImage} disabled={disabled || busy}>
+    {busy ? 'Rendering…' : 'Copy image'}
+  </button>
+  <button type="button" onclick={saveSvg} {disabled}>Download SVG</button>
+</div>
+
+<style>
+  .exp {
+    display: flex;
+    flex: 0 0 auto;
+    /* Pushed to the trailing edge of a flex header; inert in a block panel. */
+    margin-left: auto;
+    gap: 6px;
+  }
+
+  .exp button {
+    padding: 3px 8px;
+    background: var(--paper-2);
+    border: 1px solid var(--rule-soft);
+    border-radius: 0;
+    color: var(--ink-2);
+    cursor: pointer;
+    font: 12.5px var(--sans);
+    white-space: nowrap;
+  }
+
+  .exp button:hover:not(:disabled) {
+    background: var(--press);
+    border-color: var(--ink-3);
+    color: var(--ink);
+  }
+
+  .exp button:disabled {
+    color: var(--ink-4);
+    cursor: default;
+  }
+
+  .exp button:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 1px;
+  }
+</style>

+ 11 - 0
ui/src/components/map/MapSidePanel.svelte

@@ -11,6 +11,7 @@
   can audit is a diagram that gets believed too much.
 -->
 <script lang="ts">
+  import ExportButtons from '../ExportButtons.svelte';
   import { fileHref } from '../../lib/router.svelte';
   import { plural } from '../../lib/symbol-model';
   import type { WireMapLink, WireMapPayload } from '../../lib/api';
@@ -25,6 +26,10 @@
     onToggleTests: (value: boolean) => void;
     onSelectRoot: (root: string) => void;
     onSelect: (id: string | null) => void;
+    /** Builds the map as an SVG at a given device-pixel scale. */
+    buildSvg: (scale: number) => string;
+    /** File stem for a downloaded map, without an extension. */
+    exportName: string;
   }
 
   let {
@@ -36,6 +41,8 @@
     onToggleTests,
     onSelectRoot,
     onSelect,
+    buildSvg,
+    exportName,
   }: Props = $props();
 
   const selectedModule = $derived(
@@ -70,6 +77,10 @@
     calls, imports and type references cross the link.
   </p>
 
+  <!-- The map is the thing people paste into a README, so the way out sits
+       directly under the sentence explaining what it is. -->
+  <ExportButtons build={buildSvg} filename={exportName} />
+
   <label class="field">
     <span>Showing</span>
     <select

+ 106 - 0
ui/src/lib/export-image.ts

@@ -0,0 +1,106 @@
+/**
+ * Getting an SVG string out of the browser: as a PNG on the clipboard, or as a
+ * file on disk.
+ *
+ * `export-svg.ts` does the drawing and has no browser in it at all; everything
+ * that needs a `document` is here, and it is deliberately thin — a canvas, an
+ * `<img>`, and two ways of handing the result over.
+ *
+ * ## Why an `<img>` and not `foreignObject`
+ *
+ * A canvas rasterises an SVG by loading it as an image, which is a strictly
+ * sandboxed context: no scripts, no network, and **no webfonts**. That is why
+ * the export draws real `<text>` in a font *stack* rather than embedding
+ * anything — the raster falls back to the platform's own monospace, which
+ * advances at the same ~0.6em, so the code grid survives. It is also why the
+ * canvas is never tainted and `toBlob` works: nothing external is referenced.
+ *
+ * The scale trick matters. The SVG is asked for at `scale`, which multiplies
+ * only the root `width`/`height` while the `viewBox` stays in CSS pixels — so
+ * the image's *intrinsic* size is already 2x and `drawImage` copies it 1:1
+ * instead of upscaling a 1x bitmap. Text comes out rasterised at 2x, not blurry.
+ */
+
+/** Device-pixel multiplier for the PNG (design spec §3.9). */
+export const PNG_SCALE = 2;
+
+export function svgDataUrl(svg: string): string {
+  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+/** The SVG's own root width/height, which the raster canvas has to match. */
+export function svgPixelSize(svg: string): { width: number; height: number } {
+  const width = Number(/\bwidth="(\d+(?:\.\d+)?)"/.exec(svg)?.[1] ?? 0);
+  const height = Number(/\bheight="(\d+(?:\.\d+)?)"/.exec(svg)?.[1] ?? 0);
+  return { width: Math.max(1, Math.round(width)), height: Math.max(1, Math.round(height)) };
+}
+
+function loadImage(url: string): Promise<HTMLImageElement> {
+  return new Promise((resolve, reject) => {
+    const img = new Image();
+    img.onload = () => resolve(img);
+    img.onerror = () => reject(new Error('The image could not be rendered.'));
+    img.src = url;
+  });
+}
+
+/** Rasterise an already-scaled SVG string to a PNG blob. */
+export async function svgToPng(svg: string): Promise<Blob> {
+  const { width, height } = svgPixelSize(svg);
+  const img = await loadImage(svgDataUrl(svg));
+  const canvas = document.createElement('canvas');
+  canvas.width = width;
+  canvas.height = height;
+  const ctx = canvas.getContext('2d');
+  if (ctx === null) throw new Error('This browser would not give up a 2D canvas.');
+  ctx.drawImage(img, 0, 0, width, height);
+  return await new Promise<Blob>((resolve, reject) => {
+    canvas.toBlob(
+      (blob) => (blob ? resolve(blob) : reject(new Error('The image could not be encoded.'))),
+      'image/png'
+    );
+  });
+}
+
+export function downloadBlob(blob: Blob, filename: string): void {
+  const url = URL.createObjectURL(blob);
+  const anchor = document.createElement('a');
+  anchor.href = url;
+  anchor.download = filename;
+  document.body.appendChild(anchor);
+  anchor.click();
+  anchor.remove();
+  // Revoked on the next tick: Safari has not finished with the URL when click()
+  // returns, and a revoked object URL downloads a zero-byte file.
+  setTimeout(() => URL.revokeObjectURL(url), 10_000);
+}
+
+export function downloadSvg(svg: string, filename: string): void {
+  downloadBlob(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), filename);
+}
+
+/**
+ * Put a PNG on the clipboard, falling back to a download.
+ *
+ * `ClipboardItem` is constructed with the *promise*, not the awaited blob:
+ * Safari discards the user gesture across an `await`, and a copy that silently
+ * does nothing is the worst outcome of the three. Returns what actually
+ * happened so the caller can say so rather than claim a copy it did not make.
+ */
+export async function copyPngToClipboard(
+  render: () => Promise<Blob>,
+  filename: string
+): Promise<'copied' | 'downloaded'> {
+  const write = navigator.clipboard?.write;
+  if (typeof write === 'function' && typeof ClipboardItem === 'function') {
+    try {
+      await navigator.clipboard.write([new ClipboardItem({ 'image/png': render() })]);
+      return 'copied';
+    } catch {
+      // Denied permission, an unfocused document, or a browser that will not
+      // take a promise. Fall through — the reader still gets their image.
+    }
+  }
+  downloadBlob(await render(), filename);
+  return 'downloaded';
+}

+ 966 - 0
ui/src/lib/export-svg.ts

@@ -0,0 +1,966 @@
+/**
+ * The Flow strip and the Map, as a standalone SVG (design spec §3.9).
+ *
+ * This is the distribution loop: a flow pasted into a PR review, a map pasted
+ * into a README. Both have to survive leaving the app — the reader has no
+ * viewer, no hover, no side panel and no way to ask a follow-up question, so
+ * what the picture says has to be everything it claims.
+ *
+ * ## Serialised from the layout, never scraped from the DOM
+ *
+ * The obvious way to do this is `html-to-image`: walk the rendered nodes, inline
+ * every computed style, foreignObject the result. It was not taken. The strip
+ * and the map are already **pure functions of a layout object** — `buildFlowLayout`
+ * and `buildMapLayout` compute every rectangle, port and curve before anything
+ * renders, and both are unit-tested without a browser. Serialising that object
+ * gives an export that:
+ *
+ * - cannot disagree with the screen, because the same arithmetic produced both;
+ * - contains real `<text>`, so an SVG in a README is selectable and scales,
+ *   rather than a `foreignObject` GitHub's sanitiser drops on sight;
+ * - costs no dependency, and works in a test with no DOM at all.
+ *
+ * The price is that every visual rule the components carry in CSS has to be
+ * restated here as numbers. That is the one thing to know before changing a
+ * card's padding or a module box's type scale: **it is stated twice**, in the
+ * component's `<style>` and in this file, and the two have to move together.
+ * The measurements that actually place things — heights, widths, columns — are
+ * NOT restated; they are imported from the layout models.
+ *
+ * ## Light, always
+ *
+ * An image pasted into a PR is read by people whose editors are set both ways,
+ * and a dark-mode screenshot on GitHub's white comment background reads as a
+ * mistake. So the export inlines the light token set as literal hex regardless
+ * of the viewer's theme — there is no `prefers-color-scheme` in a file someone
+ * else opens.
+ *
+ * ## Fonts are stacks, not bytes
+ *
+ * Per the spec: no embedding. The consequence is honest and worth stating —
+ * an exported SVG opened on a machine without IBM Plex Mono falls back through
+ * the stack to the platform's own monospace, and a PNG rasterised through an
+ * `<img>` always does, because an SVG loaded as an image may not fetch a
+ * webfont. Every fallback in the mono stack advances at ~0.6em like Plex Mono
+ * does, so the monospace grid the code windows depend on survives the swap;
+ * only the letterforms change. Embedding Plex Mono would add ~90 kB of base64
+ * to every export and put us over the PNG budget for nothing.
+ *
+ * Tested in `__tests__/ui-export-svg.test.ts`.
+ */
+
+import {
+  CARD_WIDTH,
+  CODE_LINE_HEIGHT,
+  CODE_PADDING,
+  END_CAP_LINE,
+  END_CAP_PADDING,
+  END_CAP_ROW,
+  END_CAP_GAP,
+  HEADER_HEIGHT,
+  endCapText,
+  type FlowCardLayout,
+  type FlowEndCapLayout,
+  type FlowLayout,
+  type FlowLinkLayout,
+} from './flow-model';
+import {
+  isEdgeVisible,
+  moduleMetaLabel,
+  type MapEdgeLayout,
+  type MapLayout,
+  type MapNodeLayout,
+} from './map-model';
+import { tokensByLine, type Token } from './highlight';
+import { assignRefs, basename, type LineRef } from './symbol-model';
+import { kindLetter, FILLED_KINDS } from './kinds';
+
+/* ---------------------------------------------------------------- tokens -- */
+
+/**
+ * The light token set (`ui/src/app.css`, the bare `:root` block), as literal
+ * hex. Copied deliberately rather than read from `getComputedStyle`: an export
+ * must not depend on a stylesheet having loaded, and must not follow the
+ * reader's theme into a dark image on a white page.
+ */
+export const EXPORT_COLORS = {
+  paper: '#f7f6f2',
+  paper2: '#f1efe8',
+  press: '#e8e6dd',
+  ink: '#16150f',
+  ink2: '#56544a',
+  ink3: '#87847a',
+  ink4: '#b4b1a5',
+  ruleSoft: '#d6d3c8',
+  ruleFaint: '#e6e3d9',
+  accent: '#7a2230',
+  accentSoft: '#f0e3e5',
+  accentLine: '#d9b3b9',
+  codeComment: '#6a675d',
+} as const;
+
+export const MONO_STACK =
+  "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace";
+export const SANS_STACK =
+  "'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif";
+
+/** Clear space between the drawing and the edge of the image (design spec §3.9). */
+export const EXPORT_PADDING = 24;
+/** Space between the bottom of the drawing and the mark under it. */
+export const MARK_GAP = 14;
+export const MARK_SIZE = 11;
+export const MARK_TEXT = 'CodeGraph';
+/** Device-pixel multiplier for a rasterised export. */
+export const EXPORT_SCALE = 2;
+
+/** Advance of a monospace character, as a fraction of the font size. */
+const MONO_ADVANCE = 0.6;
+/** Rough advance of the sans stack, for truncation guards only. */
+const SANS_ADVANCE = 0.53;
+
+const monoWidth = (chars: number, size: number): number => chars * size * MONO_ADVANCE;
+
+/* ------------------------------------------------------------ primitives -- */
+
+export function esc(text: string): string {
+  return text
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
+interface TextOptions {
+  x: number;
+  y: number;
+  size: number;
+  fill: string;
+  family?: string;
+  weight?: number;
+  anchor?: 'start' | 'middle' | 'end';
+  /** Keep runs of spaces — every code line needs this. */
+  preserve?: boolean;
+}
+
+function textOpen(o: TextOptions): string {
+  const parts = [
+    `x="${round(o.x)}"`,
+    `y="${round(o.y)}"`,
+    `font-family="${o.family ?? SANS_STACK}"`,
+    `font-size="${o.size}"`,
+    `fill="${o.fill}"`,
+  ];
+  if (o.weight && o.weight !== 400) parts.push(`font-weight="${o.weight}"`);
+  if (o.anchor && o.anchor !== 'start') parts.push(`text-anchor="${o.anchor}"`);
+  if (o.preserve) parts.push('xml:space="preserve"');
+  return `<text ${parts.join(' ')}>`;
+}
+
+function textEl(o: TextOptions, content: string): string {
+  return `${textOpen(o)}${content}</text>`;
+}
+
+/** Numbers are rounded to a tenth: an SVG full of 17 decimals is unreadable. */
+function round(n: number): number {
+  return Math.round(n * 10) / 10;
+}
+
+function rect(
+  x: number,
+  y: number,
+  w: number,
+  h: number,
+  attrs: {
+    fill?: string;
+    stroke?: string;
+    strokeWidth?: number;
+    dash?: string;
+  } = {}
+): string {
+  const parts = [
+    `x="${round(x)}"`,
+    `y="${round(y)}"`,
+    `width="${round(w)}"`,
+    `height="${round(h)}"`,
+    `fill="${attrs.fill ?? 'none'}"`,
+  ];
+  if (attrs.stroke) {
+    parts.push(`stroke="${attrs.stroke}"`, `stroke-width="${attrs.strokeWidth ?? 1}"`);
+    if (attrs.dash) parts.push(`stroke-dasharray="${attrs.dash}"`);
+  }
+  return `<rect ${parts.join(' ')} />`;
+}
+
+/**
+ * Cut a string to what fits, with an ellipsis — the arithmetic twin of the
+ * components' `text-overflow: ellipsis`.
+ *
+ * Done here rather than left to a clip path because a clip cuts mid-glyph and
+ * says nothing about having cut; the ellipsis is the same admission the screen
+ * makes. Clip paths are still applied over the top, as insurance against a
+ * fallback font that advances wider than the stack's first choice.
+ */
+export function truncate(text: string, maxWidth: number, size: number, advance = MONO_ADVANCE): string {
+  const per = size * advance;
+  const fits = Math.floor(maxWidth / per);
+  if (fits >= text.length) return text;
+  if (fits <= 1) return text.slice(0, Math.max(0, fits));
+  return `${text.slice(0, fits - 1)}…`;
+}
+
+/**
+ * Greedy word wrap at a character count.
+ *
+ * The end cap's height was estimated by `endCapHeight` at
+ * `ceil(length / END_CAP_CHARS)` lines, which is a *character* count and can
+ * come out one line short of a real word wrap. The export therefore measures
+ * its caps from these lines rather than from that estimate — the screen can let
+ * a `min-height` box grow, an image cannot.
+ */
+export function wrapText(text: string, chars: number): string[] {
+  const words = text.split(/\s+/).filter(Boolean);
+  if (words.length === 0) return [''];
+  const lines: string[] = [];
+  let line = '';
+  for (const word of words) {
+    if (line.length === 0) line = word;
+    else if (line.length + 1 + word.length <= chars) line = `${line} ${word}`;
+    else {
+      lines.push(line);
+      line = word;
+    }
+  }
+  lines.push(line);
+  return lines;
+}
+
+/* ------------------------------------------------------------- the frame -- */
+
+export interface ExportOptions {
+  /**
+   * Device-pixel multiplier written into the root `width`/`height`.
+   *
+   * The `viewBox` always stays in CSS pixels, so the geometry inside is
+   * identical at every scale — 1 for a file someone will open, 2 for the raster
+   * step, which then draws the image at its own intrinsic size and gets crisp
+   * text instead of an upscaled bitmap.
+   */
+  scale?: number;
+  /** A line of context under the drawing, left of the mark. */
+  caption?: string | null;
+}
+
+interface Frame {
+  /** Tight bounds of the drawing, before the export's own padding. */
+  minX: number;
+  minY: number;
+  width: number;
+  height: number;
+}
+
+/**
+ * Wrap a drawing in paper, padding and the mark.
+ *
+ * `body` is emitted inside a translate that moves the drawing's own origin to
+ * the padded corner, so every caller can keep working in layout coordinates.
+ */
+function document_(frame: Frame, defs: string, body: string, options: ExportOptions): string {
+  const scale = options.scale ?? 1;
+  const markRow = MARK_GAP + MARK_SIZE;
+  const width = Math.max(1, Math.round(frame.width + EXPORT_PADDING * 2));
+  const height = Math.max(1, Math.round(frame.height + EXPORT_PADDING * 2 + markRow));
+  const markY = height - EXPORT_PADDING;
+
+  const caption =
+    options.caption == null || options.caption === ''
+      ? ''
+      : textEl(
+          {
+            x: EXPORT_PADDING,
+            y: markY,
+            size: MARK_SIZE,
+            fill: EXPORT_COLORS.ink3,
+            family: MONO_STACK,
+          },
+          esc(
+            truncate(
+              options.caption,
+              width - EXPORT_PADDING * 2 - monoWidth(MARK_TEXT.length + 3, MARK_SIZE),
+              MARK_SIZE
+            )
+          )
+        );
+
+  return [
+    `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(width * scale)}" height="${Math.round(height * scale)}" viewBox="0 0 ${width} ${height}" font-family="${SANS_STACK}">`,
+    defs === '' ? '' : `<defs>${defs}</defs>`,
+    rect(0, 0, width, height, { fill: EXPORT_COLORS.paper }),
+    `<g transform="translate(${round(EXPORT_PADDING - frame.minX)},${round(EXPORT_PADDING - frame.minY)})">`,
+    body,
+    '</g>',
+    caption,
+    textEl(
+      {
+        x: width - EXPORT_PADDING,
+        y: markY,
+        size: MARK_SIZE,
+        fill: EXPORT_COLORS.ink3,
+        family: MONO_STACK,
+        anchor: 'end',
+      },
+      MARK_TEXT
+    ),
+    '</svg>',
+  ]
+    .filter(Boolean)
+    .join('\n');
+}
+
+/* ------------------------------------------------------------ flow strip -- */
+
+/** Card header: glyph box, name, `file:line` — mirrors `FlowCard.svelte`. */
+const CARD_PAD_X = 12;
+const GLYPH_SIZE = 16;
+const GLYPH_TOP = 9;
+const HEAD_BASELINE = 21.5;
+const NAME_SIZE = 13;
+const LOC_SIZE = 11;
+/** `.ln` grid: `40px | 1fr | 6px`, line numbers right-aligned 10px inside. */
+const GUTTER = 40;
+const CODE_RIGHT = 6;
+const CODE_SIZE = 12;
+const CODE_BASELINE = 13.5;
+const LINE_NO_SIZE = 11;
+/** `.flabel text` — 11px mono, stacked 13px apart above the connector. */
+const LINK_LABEL_SIZE = 11;
+
+export interface FlowExportOptions extends ExportOptions {
+  /** The picked flow's id — its cards keep the accent border, as on screen. */
+  activeFlowId?: string | null;
+  /** More than one path is drawn, so off-path cards dim. */
+  showAll?: boolean;
+}
+
+/** Where a link leaves and arrives: the vertical middle of each card's side. */
+function portOf(node: { x: number; y: number; width: number; height: number }): {
+  right: [number, number];
+  left: [number, number];
+} {
+  return {
+    right: [node.x + node.width, node.y + node.height / 2],
+    left: [node.x, node.y + node.height / 2],
+  };
+}
+
+function flowCardSvg(card: FlowCardLayout, dimmed: boolean, current: boolean): string {
+  const hop = card.hop;
+  const source = hop.source;
+  const out: string[] = [];
+  const clip = `c${card.column}-${Math.round(card.y)}`;
+
+  out.push(
+    rect(card.x, card.y, card.width, card.height, {
+      fill: EXPORT_COLORS.paper,
+      stroke: current ? EXPORT_COLORS.accent : EXPORT_COLORS.ruleSoft,
+    })
+  );
+
+  // --- header --------------------------------------------------------------
+  const letter = kindLetter(hop.node.kind);
+  const gx = card.x + CARD_PAD_X;
+  const gy = card.y + GLYPH_TOP;
+  out.push(
+    rect(gx, gy, GLYPH_SIZE, GLYPH_SIZE, {
+      fill: FILLED_KINDS.has(hop.node.kind) ? EXPORT_COLORS.press : 'none',
+      stroke: EXPORT_COLORS.ink3,
+      dash: hop.node.kind === 'file' ? '2 2' : undefined,
+    })
+  );
+  if (letter !== '') {
+    const glyphSize = letter.length > 1 ? 8.5 : 9.5;
+    out.push(
+      textEl(
+        {
+          x: gx + GLYPH_SIZE / 2,
+          y: gy + GLYPH_SIZE / 2 + glyphSize * 0.36,
+          size: glyphSize,
+          fill: EXPORT_COLORS.ink2,
+          family: MONO_STACK,
+          weight: 500,
+          anchor: 'middle',
+        },
+        esc(letter)
+      )
+    );
+  }
+
+  const loc = `${basename(hop.node.file)}:${hop.node.line}`;
+  const locWidth = monoWidth(loc.length, LOC_SIZE);
+  const nameX = gx + GLYPH_SIZE + 8;
+  const nameRoom = card.x + card.width - CARD_PAD_X - locWidth - 8 - nameX;
+  out.push(
+    textEl(
+      {
+        x: nameX,
+        y: card.y + HEAD_BASELINE,
+        size: NAME_SIZE,
+        fill: EXPORT_COLORS.ink,
+        family: MONO_STACK,
+        weight: 600,
+      },
+      esc(truncate(hop.node.name, nameRoom, NAME_SIZE))
+    ),
+    textEl(
+      {
+        x: card.x + card.width - CARD_PAD_X,
+        y: card.y + HEAD_BASELINE,
+        size: LOC_SIZE,
+        fill: EXPORT_COLORS.ink3,
+        family: MONO_STACK,
+        anchor: 'end',
+      },
+      esc(loc)
+    ),
+    `<line x1="${round(card.x)}" y1="${round(card.y + HEADER_HEIGHT)}" x2="${round(card.x + card.width)}" y2="${round(card.y + HEADER_HEIGHT)}" stroke="${EXPORT_COLORS.ruleFaint}" stroke-width="1" />`
+  );
+
+  // --- source window -------------------------------------------------------
+  const lines = source?.lines ?? [];
+  if (lines.length === 0) {
+    const why = source?.drift
+      ? 'Changed on disk after the last index sync — source is not shown.'
+      : (source?.reason ?? 'Source outside this slice or this index.');
+    out.push(
+      textEl(
+        {
+          x: card.x + CARD_PAD_X,
+          y: card.y + HEADER_HEIGHT + 6 + CODE_BASELINE,
+          size: CODE_SIZE,
+          fill: EXPORT_COLORS.ink3,
+        },
+        esc(truncate(why, card.width - CARD_PAD_X * 2, CODE_SIZE, SANS_ADVANCE))
+      )
+    );
+    return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
+  }
+
+  const refs = new Map<number, LineRef[]>();
+  const callRef = hop.callRef;
+  if (callRef) {
+    refs.set(callRef.line, [
+      {
+        ident: callRef.name,
+        col: callRef.col,
+        targetId: callRef.targetId,
+        uncertain: false,
+        outside: false,
+        title: '',
+      },
+    ]);
+  }
+  const tokens = tokensByLine(lines, source?.from ?? 1, source?.highlight);
+  const textX = card.x + GUTTER;
+  const textRoom = card.width - GUTTER - CODE_RIGHT;
+  const maxChars = Math.floor(textRoom / (CODE_SIZE * MONO_ADVANCE));
+
+  const body: string[] = [];
+  lines.forEach((text, offset) => {
+    const n = (source?.from ?? 1) + offset;
+    const top = card.y + HEADER_HEIGHT + CODE_PADDING / 2 + offset * CODE_LINE_HEIGHT;
+    const lineTokens: Token[] = tokens.get(n) ?? [{ cls: 'other', text, col: 0 }];
+    const claimed = assignRefs(lineTokens, refs.get(n) ?? []);
+    if (n === callRef?.line || n === card.stopLine) {
+      body.push(
+        rect(card.x, top, card.width, CODE_LINE_HEIGHT, { fill: EXPORT_COLORS.accentSoft })
+      );
+    }
+    body.push(
+      textEl(
+        {
+          x: card.x + GUTTER - 10,
+          y: top + CODE_BASELINE,
+          size: LINE_NO_SIZE,
+          fill: EXPORT_COLORS.ink4,
+          family: MONO_STACK,
+          anchor: 'end',
+        },
+        String(n)
+      )
+    );
+
+    // One <text> per line with a tspan per token: monospace flows naturally, so
+    // nothing has to be positioned by column — which is also what keeps the
+    // indentation intact under `xml:space="preserve"`.
+    const spans: string[] = [];
+    let used = 0;
+    let underline: { from: number; length: number } | null = null;
+    lineTokens.forEach((token, index) => {
+      if (used >= maxChars) return;
+      const room = maxChars - used;
+      const cut = token.text.length > room;
+      const shown = cut ? `${token.text.slice(0, Math.max(0, room - 1))}…` : token.text;
+      const ref = claimed.get(index) ?? null;
+      if (ref) underline = { from: used, length: shown.length };
+      spans.push(tokenSpan(shown, token, ref !== null));
+      used += token.text.length;
+    });
+    body.push(
+      textEl(
+        {
+          x: textX,
+          y: top + CODE_BASELINE,
+          size: CODE_SIZE,
+          fill: EXPORT_COLORS.ink,
+          family: MONO_STACK,
+          preserve: true,
+        },
+        spans.join('')
+      )
+    );
+    // The call site's underline, drawn rather than declared: `text-decoration`
+    // on a tspan is not reliably honoured by SVG rasterisers, and this is the
+    // one piece of colour in the window.
+    if (underline !== null) {
+      const u = underline as { from: number; length: number };
+      const x1 = textX + monoWidth(u.from, CODE_SIZE);
+      body.push(
+        `<line x1="${round(x1)}" y1="${round(top + CODE_BASELINE + 3)}" x2="${round(x1 + monoWidth(u.length, CODE_SIZE))}" y2="${round(top + CODE_BASELINE + 3)}" stroke="${EXPORT_COLORS.accentLine}" stroke-width="1" />`
+      );
+    }
+  });
+
+  out.push(`<g clip-path="url(#${clip})">${body.join('')}</g>`);
+  return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
+}
+
+/** A code token as a tspan — the near-monochrome ramp of design spec §2.2. */
+function tokenSpan(text: string, token: Token, isRef: boolean): string {
+  if (text === '') return '';
+  const escaped = esc(text);
+  if (isRef) return `<tspan fill="${EXPORT_COLORS.accent}">${escaped}</tspan>`;
+  switch (token.cls) {
+    case 'comment':
+      return `<tspan fill="${EXPORT_COLORS.codeComment}">${escaped}</tspan>`;
+    case 'string':
+    case 'number':
+      return `<tspan fill="${EXPORT_COLORS.ink2}">${escaped}</tspan>`;
+    case 'keyword':
+      return `<tspan font-weight="500">${escaped}</tspan>`;
+    default:
+      return `<tspan>${escaped}</tspan>`;
+  }
+}
+
+/** The cap's text as drawn rows, and the height they actually need. */
+export interface CapRows {
+  rows: Array<{ text: string; kind: 'lead' | 'form' | 'mono' | 'soft' | 'body'; gapBefore: boolean }>;
+  height: number;
+}
+
+const END_CAP_CHARS_SANS = 32;
+const END_CAP_CHARS_MONO = 26;
+
+export function capRows(cap: FlowEndCapLayout): CapRows {
+  const text = endCapText(cap.boundary);
+  const rows: CapRows['rows'] = [];
+  const push = (
+    value: string,
+    kind: CapRows['rows'][number]['kind'],
+    gapBefore = false,
+    chars = END_CAP_CHARS_SANS
+  ): void => {
+    wrapText(value, chars).forEach((line, i) =>
+      rows.push({ text: line, kind, gapBefore: gapBefore && i === 0 })
+    );
+  };
+
+  // On screen the bold lead and the sentence after it share one paragraph. At
+  // 32 characters a line the lead fills one on its own anyway, so the export
+  // gives it its own row and keeps the bold ink without a mid-line tspan.
+  push('Where the graph stops.', 'lead');
+  push(text.intro, 'body');
+  for (const site of text.sites) {
+    push(site.headline, 'form', true);
+    if (site.key !== null) push(`key ${site.key}`, 'mono', false, END_CAP_CHARS_MONO);
+    for (const note of site.notes) push(note, 'soft');
+    if (site.candidateHeading !== null) {
+      push(site.candidateHeading, 'soft');
+      for (const candidate of site.candidates) {
+        push(
+          `${candidate.display}  ${basename(candidate.node.file)}:${candidate.node.line}`,
+          'mono',
+          false,
+          END_CAP_CHARS_MONO
+        );
+      }
+    } else if (site.candidateNote !== null) {
+      push(site.candidateNote, 'soft');
+    }
+  }
+  if (text.quiet !== null) push(text.quiet, 'soft', true);
+  if (text.uncertainHeading !== null) {
+    push(text.uncertainHeading, 'soft', true);
+    for (const next of text.uncertain) {
+      push(
+        `${next.node.name}  ${next.confidence === null ? '' : next.confidence.toFixed(2)}`,
+        'mono',
+        false,
+        END_CAP_CHARS_MONO
+      );
+    }
+  }
+  if (text.further !== null) push(text.further, 'body', true);
+  if (text.missed !== null) push(text.missed, 'body', true);
+
+  let height = END_CAP_PADDING * 2;
+  for (const row of rows) {
+    if (row.gapBefore) height += END_CAP_GAP;
+    height += row.kind === 'mono' ? END_CAP_ROW : END_CAP_LINE;
+  }
+  return { rows, height: Math.round(height) };
+}
+
+function flowCapSvg(cap: FlowEndCapLayout, dimmed: boolean): string {
+  const { rows, height } = capRows(cap);
+  const out: string[] = [
+    rect(cap.x, cap.y, cap.width, Math.max(cap.height, height), {
+      fill: EXPORT_COLORS.paper,
+      stroke: EXPORT_COLORS.ruleSoft,
+      dash: '3 3',
+    }),
+  ];
+  let y = cap.y + END_CAP_PADDING;
+  const x = cap.x + END_CAP_PADDING;
+  const room = cap.width - END_CAP_PADDING * 2;
+  for (const row of rows) {
+    if (row.gapBefore) y += END_CAP_GAP;
+    const mono = row.kind === 'mono';
+    const step = mono ? END_CAP_ROW : END_CAP_LINE;
+    const fill =
+      row.kind === 'form' || row.kind === 'lead'
+        ? EXPORT_COLORS.ink
+        : row.kind === 'soft'
+          ? EXPORT_COLORS.ink3
+          : EXPORT_COLORS.ink2;
+    out.push(
+      textEl(
+        {
+          x,
+          y: y + step * 0.75,
+          size: mono ? 11.5 : 12,
+          fill,
+          family: mono ? MONO_STACK : SANS_STACK,
+          weight: row.kind === 'lead' ? 600 : 400,
+        },
+        esc(truncate(row.text, room, mono ? 11.5 : 12, mono ? MONO_ADVANCE : SANS_ADVANCE))
+      )
+    );
+    y += step;
+  }
+  return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
+}
+
+function flowLinkSvg(
+  link: FlowLinkLayout,
+  from: { x: number; y: number; width: number; height: number },
+  to: { x: number; y: number; width: number; height: number },
+  dimmed: boolean
+): string {
+  const [sx, sy] = portOf(from).right;
+  const [tx, ty] = portOf(to).left;
+  const path =
+    Math.abs(sy - ty) < 0.5
+      ? `M${round(sx)},${round(sy)} L${round(tx)},${round(ty)}`
+      : `M${round(sx)},${round(sy)} C${round((sx + tx) / 2)},${round(sy)} ${round((sx + tx) / 2)},${round(ty)} ${round(tx)},${round(ty)}`;
+
+  const out: string[] = [
+    `<path d="${path}" fill="none" stroke="${EXPORT_COLORS.ink3}" stroke-width="1"${link.dash ? ` stroke-dasharray="${link.dash}"` : ''} />`,
+  ];
+  if (!link.cap) {
+    out.push(
+      `<polygon points="${round(tx - 10)},${round(ty - 4)} ${round(tx - 2)},${round(ty)} ${round(tx - 10)},${round(ty + 4)}" fill="${EXPORT_COLORS.ink3}" />`
+    );
+  }
+  const labelX = (sx + tx) / 2;
+  const labelY = (sy + ty) / 2;
+  link.labelLines.forEach((line, i) => {
+    out.push(
+      textEl(
+        {
+          x: labelX,
+          y: labelY - 8 - (link.labelLines.length - 1 - i) * 13,
+          size: LINK_LABEL_SIZE,
+          fill: EXPORT_COLORS.ink3,
+          family: MONO_STACK,
+          anchor: 'middle',
+        },
+        esc(line)
+      )
+    );
+  });
+  if (link.lineLabel) {
+    out.push(
+      textEl(
+        {
+          x: labelX,
+          y: labelY + 17,
+          size: LINK_LABEL_SIZE,
+          fill: EXPORT_COLORS.ink3,
+          family: MONO_STACK,
+          anchor: 'middle',
+        },
+        esc(link.lineLabel)
+      )
+    );
+  }
+  return `<g${dimmed ? ' opacity="0.4"' : ''}>${out.join('')}</g>`;
+}
+
+/** The Flow strip as a standalone SVG. */
+export function flowSvg(layout: FlowLayout, options: FlowExportOptions = {}): string {
+  const showAll = options.showAll ?? false;
+  const active = options.activeFlowId ?? null;
+  const onActive = (flows: string[]): boolean => active === null || flows.includes(active);
+
+  const boxes = new Map<string, { x: number; y: number; width: number; height: number }>();
+  for (const card of layout.cards) boxes.set(card.id, card);
+  const capHeights = new Map<string, number>();
+  for (const cap of layout.endCaps) {
+    const height = Math.max(cap.height, capRows(cap).height);
+    capHeights.set(cap.id, height);
+    boxes.set(cap.id, { x: cap.x, y: cap.y, width: cap.width, height });
+  }
+
+  const body: string[] = [];
+  const defs: string[] = [];
+  for (const link of layout.links) {
+    const from = boxes.get(link.source);
+    const to = boxes.get(link.target);
+    if (!from || !to) continue;
+    body.push(flowLinkSvg(link, from, to, showAll && !onActive(link.flows)));
+  }
+  for (const cap of layout.endCaps) {
+    body.push(flowCapSvg(cap, showAll && !onActive(cap.flows)));
+  }
+  for (const card of layout.cards) {
+    defs.push(
+      `<clipPath id="c${card.column}-${Math.round(card.y)}">${rect(card.x, card.y + HEADER_HEIGHT, card.width, card.height - HEADER_HEIGHT)}</clipPath>`
+    );
+    body.push(
+      flowCardSvg(card, showAll && card.step < 0, showAll && card.step >= 0)
+    );
+  }
+
+  // Tight bounds over everything drawn, including the label stacks that sit
+  // above and below a connector — the layout's own width/height cover the cards
+  // but not a two-line synthesized label on the top row.
+  let minX = Infinity;
+  let minY = Infinity;
+  let maxX = -Infinity;
+  let maxY = -Infinity;
+  const grow = (x: number, y: number, w = 0, h = 0): void => {
+    minX = Math.min(minX, x);
+    minY = Math.min(minY, y);
+    maxX = Math.max(maxX, x + w);
+    maxY = Math.max(maxY, y + h);
+  };
+  for (const box of boxes.values()) grow(box.x, box.y, box.width, box.height);
+  for (const link of layout.links) {
+    const from = boxes.get(link.source);
+    const to = boxes.get(link.target);
+    if (!from || !to) continue;
+    const y = (from.y + from.height / 2 + to.y + to.height / 2) / 2;
+    grow((from.x + from.width + to.x) / 2, y - 8 - link.labelLines.length * 13);
+    if (link.lineLabel) grow((from.x + from.width + to.x) / 2, y + 21);
+  }
+  if (!Number.isFinite(minX)) {
+    minX = 0;
+    minY = 0;
+    maxX = 1;
+    maxY = 1;
+  }
+
+  return document_(
+    { minX, minY, width: maxX - minX, height: maxY - minY },
+    defs.join(''),
+    body.join('\n'),
+    options
+  );
+}
+
+/* -------------------------------------------------------------------- map -- */
+
+const MODULE_NAME_SIZE = 13;
+const MODULE_META_SIZE = 11;
+const MODULE_PAD_X = 9;
+const LAYER_LABEL_SIZE = 12;
+/** How far a layer rule runs past the boxes it sits under. */
+const LAYER_RULE_BLEED = 12;
+
+export interface MapExportOptions extends ExportOptions {
+  /** The selected module, so the export draws the same edges the screen does. */
+  selected?: string | null;
+}
+
+function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): string {
+  const module = node.module;
+  const strokeWidth = selected ? 2 : 1;
+  const out: string[] = [
+    rect(node.x, node.y, node.width, node.height, {
+      fill: selected ? EXPORT_COLORS.press : EXPORT_COLORS.paper,
+      stroke: dimmed
+        ? EXPORT_COLORS.ink4
+        : module.test
+          ? EXPORT_COLORS.ink3
+          : EXPORT_COLORS.ink,
+      strokeWidth,
+      dash: module.test ? '4 3' : undefined,
+    }),
+  ];
+  const room = node.width - MODULE_PAD_X * 2;
+  out.push(
+    textEl(
+      {
+        x: node.x + MODULE_PAD_X,
+        y: node.y + 17,
+        size: MODULE_NAME_SIZE,
+        fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink,
+        family: MONO_STACK,
+        weight: 500,
+      },
+      esc(truncate(module.id, room, MODULE_NAME_SIZE))
+    ),
+    textEl(
+      {
+        x: node.x + MODULE_PAD_X,
+        y: node.y + 31.5,
+        size: MODULE_META_SIZE,
+        fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink3,
+      },
+      esc(truncate(moduleMetaLabel(module), room, MODULE_META_SIZE, SANS_ADVANCE))
+    )
+  );
+  return out.join('');
+}
+
+/** A port along a box's edge: `x = left + width x (i+1)/(n+1)`. */
+function portX(node: MapNodeLayout, handles: readonly string[], id: string): number {
+  const index = handles.indexOf(id);
+  const total = handles.length;
+  if (index < 0 || total === 0) return node.x + node.width / 2;
+  return node.x + (node.width * (index + 1)) / (total + 1);
+}
+
+function mapEdgeSvg(
+  edge: MapEdgeLayout,
+  from: MapNodeLayout,
+  to: MapNodeLayout,
+  hot: boolean
+): string {
+  const sx = portX(from, from.sourceHandles, edge.id);
+  const sy = from.y + from.height;
+  const tx = portX(to, to.targetHandles, edge.id);
+  const ty = to.y;
+  const midY = (sy + ty) / 2;
+  const path = `M${round(sx)},${round(sy)} C${round(sx)},${round(midY)} ${round(tx)},${round(midY)} ${round(tx)},${round(ty)}`;
+  if (edge.back) {
+    return `<path d="${path}" fill="none" stroke="${EXPORT_COLORS.accent}" stroke-opacity="0.6" stroke-dasharray="4 3" stroke-width="${round(edge.width)}" />`;
+  }
+  return `<path d="${path}" fill="none" stroke="${EXPORT_COLORS.ink}" stroke-opacity="${hot ? 0.95 : 0.28}" stroke-width="${round(edge.width)}" />`;
+}
+
+/** The Map as a standalone SVG. */
+export function mapSvg(layout: MapLayout, options: MapExportOptions = {}): string {
+  const selected = options.selected ?? null;
+  const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
+  const neighbours =
+    selected === null
+      ? null
+      : new Set<string>([
+          selected,
+          ...layout.edges.flatMap((e) =>
+            e.source === selected ? [e.target] : e.target === selected ? [e.source] : []
+          ),
+        ]);
+
+  // Bounds come from the boxes, and the layer rules are then drawn to fit THEM
+  // — not to `layout.width`, which carries the canvas' own generous padding and
+  // would run the hairlines past the edge of the image.
+  let minX = Infinity;
+  let minY = Infinity;
+  let maxX = -Infinity;
+  let maxY = -Infinity;
+  for (const node of layout.nodes) {
+    minX = Math.min(minX, node.x);
+    minY = Math.min(minY, node.y);
+    maxX = Math.max(maxX, node.x + node.width);
+    maxY = Math.max(maxY, node.y + node.height);
+  }
+  if (!Number.isFinite(minX)) {
+    minX = 0;
+    minY = 0;
+    maxX = 1;
+    maxY = 1;
+  }
+  const ruleLeft = minX - LAYER_RULE_BLEED;
+  const ruleRight = maxX + LAYER_RULE_BLEED;
+
+  const body: string[] = [];
+
+  // Layer rules first: they sit behind the boxes they explain, exactly as the
+  // canvas' back viewport portal puts them.
+  for (const row of layout.layers) {
+    body.push(
+      `<line x1="${round(ruleLeft)}" y1="${round(row.y)}" x2="${round(ruleRight)}" y2="${round(row.y)}" stroke="${EXPORT_COLORS.ruleFaint}" stroke-width="1" />`
+    );
+    if (row.label !== null) {
+      const y = row.index === 0 ? row.y + 40 : row.y - 36;
+      body.push(
+        textEl(
+          { x: ruleLeft, y, size: LAYER_LABEL_SIZE, fill: EXPORT_COLORS.ink3 },
+          esc(row.label)
+        )
+      );
+      minY = Math.min(minY, y - LAYER_LABEL_SIZE);
+      maxY = Math.max(maxY, y + 4);
+    }
+  }
+
+  for (const edge of layout.edges) {
+    if (!isEdgeVisible(edge, selected)) continue;
+    const from = nodes.get(edge.source);
+    const to = nodes.get(edge.target);
+    if (!from || !to) continue;
+    body.push(mapEdgeSvg(edge, from, to, selected !== null && !edge.back));
+  }
+  for (const node of layout.nodes) {
+    body.push(
+      mapNodeSvg(node, selected === node.id, neighbours !== null && !neighbours.has(node.id))
+    );
+  }
+
+  return document_(
+    { minX: ruleLeft, minY, width: ruleRight - ruleLeft, height: maxY - minY },
+    '',
+    body.join('\n'),
+    options
+  );
+}
+
+/* ------------------------------------------------------------- filenames -- */
+
+/**
+ * A safe file stem — `codegraph-flow-execute-getfile`. No extension: the caller
+ * adds one, because the same picture goes out as both `.svg` and `.png`.
+ */
+export function exportFilename(kind: 'flow' | 'map', label: string): string {
+  const slug = label
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 60)
+    .replace(/-+$/g, '');
+  return `codegraph-${kind}${slug ? `-${slug}` : ''}`;
+}
+
+export { CARD_WIDTH };

+ 29 - 0
ui/src/views/FlowView.svelte

@@ -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 - 0
ui/src/views/MapView.svelte

@@ -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)}