refactor(diagram): the triplet is one gstack-render call

SVG, PNG and excalidraw from one invocation over the content-addressed bundle staged under /tmp/gstack-render; every diagram type gets an excalidraw export; gstack-render picks the engine and prints ENGINE=; the diagram E2E gates on either engine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sina
2026-09-05 16:48:37 -04:00
co-authored by Claude Fable 5.1
parent f74d3a1a63
commit c86590f0db
3 changed files with 136 additions and 77 deletions
+72 -51
View File
@@ -35,21 +35,24 @@ Every run emits a **triplet**, never a dead pixel dump:
| `<slug>.excalidraw` | editable scene — open it at excalidraw.com, move a box, keep working |
| `<slug>.svg` + `<slug>.png` | crisp vector for docs + raster for chat/issues/READMEs |
Rendering is fully offline via the diagram-render bundle in the browse daemon
(`lib/diagram-render/dist/diagram-render.html`). No CDN, no network.
Rendering is fully offline: the diagram-render bundle
(`lib/diagram-render/dist/diagram-render.html`) is one self-contained page, and
`gstack-render` opens it from a loopback server on this machine — in the Aside
browser when Aside is running, otherwise in gstack's own headless browser. Its
first output line says which (`ENGINE=aside` or `ENGINE=browse`); the triplet
is identical either way. No CDN, no network.
## Step 1 — Author the diagram
Write mermaid for the user's request. Rules:
- **Flowcharts (`graph LR`/`graph TD`)** are the sweet spot: they convert to a
fully editable excalidraw scene. Prefer `graph LR` for pipelines/flows,
`graph TD` for hierarchies.
- Sequence, state, gantt, and other mermaid types render to SVG/PNG fine, but
the official converter only supports flowcharts — for those types the
`.excalidraw` artifact is skipped and you MUST tell the user:
"sequence diagrams render but aren't excalidraw-editable yet (upstream
converter limitation — flowcharts are)."
- **Flowcharts (`graph LR`/`graph TD`) and sequence diagrams** convert to a
fully editable excalidraw scene (real boxes, arrows, and text). Prefer
`graph LR` for pipelines/flows, `graph TD` for hierarchies.
- State, class, gantt, and the other mermaid types render to SVG/PNG fine and
still get an `.excalidraw`, but the converter exports them as ONE image
element: it opens at excalidraw.com and can be moved and annotated, not
edited box by box. Tell the user that when you deliver one.
- Keep node labels short; put detail in edge labels. 5-15 nodes is the
readable range. If the user's ask needs more, split into multiple diagrams
and say why.
@@ -60,8 +63,11 @@ Decide the output directory: `./diagrams/` when the cwd is a git repo
## Step 2 — Stage the render bundle (once per session)
The staged copy is content-addressed (same convention as make-pdf's pre-pass),
so concurrent sessions and mixed gstack versions never clobber each other:
`gstack-render` serves the bundle's directory on 127.0.0.1 for each render
(Aside refuses `file://`, and both engines get the same origin). Stage the bundle under
gstack's own render staging directory, `/tmp/gstack-render/`, content-addressed
by bundle sha: the served directory holds nothing but gstack bundles, and
concurrent sessions or mixed gstack versions never clobber each other.
```bash
BUNDLE=""
@@ -70,50 +76,60 @@ for c in "$HOME/.claude/skills/gstack/lib/diagram-render/dist/diagram-render.htm
[ -f "$c" ] && BUNDLE="$c" && break
done
[ -z "$BUNDLE" ] && echo "BUNDLE_MISSING — run: cd ~/.claude/skills/gstack && bun run build:diagram-render" && exit 1
mkdir -p /tmp/gstack-render
SHA=$(shasum -a 256 "$BUNDLE" | cut -c1-16)
STAGED="/tmp/gstack-diagram-render-$SHA.html"
STAGED="/tmp/gstack-render/gstack-diagram-render-$SHA.html"
[ -f "$STAGED" ] && shasum -a 256 "$STAGED" | grep -q "^$SHA" || { cp "$BUNDLE" "$STAGED.$$" && mv "$STAGED.$$" "$STAGED"; }
TAB=$($B newtab --json | sed -n 's/.*"tabId":\s*\([0-9]*\).*/\1/p')
[ -z "$TAB" ] && echo "TAB_OPEN_FAILED — daemon busy? check browse status" && exit 1
$B load-html "$STAGED" --tab-id "$TAB"
$B wait '#done' --tab-id "$TAB"
echo "RENDER_TAB_READY: tab $TAB"
echo "STAGED: $STAGED"
```
Remember `$TAB` — **every** `$B js` / `$B wait` / `$B closetab` below MUST pass
`--tab-id $TAB`. Without it, calls hit whatever tab is active, which may be a
live /qa or /scrape session sharing the daemon.
If `BUNDLE_MISSING`: stop and show the user the build command. Do not improvise
a CDN fallback — offline is the contract.
Remember the `STAGED:` path — every render below opens it (it stands in for
`<staged>`). If `BUNDLE_MISSING`: stop and show the user the build command.
Do not improvise a CDN fallback — offline is the contract.
## Step 3 — Render the triplet
Write the mermaid source to `<outdir>/<slug>.mmd` first (Write tool). The page
cannot read files itself, so ship the source in via **base64** — never splice
file contents into a JS template literal (backticks, `${`, and backslashes in
the source would be interpreted and corrupt it):
Write the mermaid source to `<outdir>/<slug>.mmd` first (Write tool). ONE
`gstack-render` call renders the whole triplet: it opens the staged bundle in
the browser, waits for the page to finish loading (`#done`), runs the `--eval`
expressions in order inside that page, and writes each result to the `--out`
path that follows it. The page cannot read files itself, so ship the source in
via **base64** — never splice file contents into a JS template literal
(backticks, `${`, and backslashes in the source would be interpreted and
corrupt it):
```bash
# SVG (always). atob() decodes the base64 inside the page.
$B js --tab-id "$TAB" "window.__renderMermaid('diagram-1', atob('$(base64 < <outdir>/<slug>.mmd | tr -d '\n')')).then(s => { window.__svg = s; return 'SVG OK ' + s.length })"
$B js --tab-id "$TAB" "window.__svg" --out <outdir>/<slug>.svg
# PNG at 300dpi of a 6.5in placement (1950px)
$B js --tab-id "$TAB" "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png
# Editable scene (flowcharts only)
$B js --tab-id "$TAB" "window.__mermaidToExcalidraw(atob('$(base64 < <outdir>/<slug>.mmd | tr -d '\n')')).then(j => { window.__scene = j; return 'SCENE OK ' + JSON.parse(j).elements.length + ' elements' })"
$B js --tab-id "$TAB" "window.__scene" --out <outdir>/<slug>.excalidraw
SRC=$(base64 < <outdir>/<slug>.mmd | tr -d '\n')
bun run ~/.claude/skills/gstack/bin/gstack-render.ts "<staged>" --wait-selector '#done' \
--eval "window.__renderMermaid('diagram-1', atob('$SRC')).then(s => (window.__svg = s))" --out <outdir>/<slug>.svg \
--eval "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png \
--eval "window.__mermaidToExcalidraw(atob('$SRC')).then(j => (window.__scene = j))" --out <outdir>/<slug>.excalidraw
```
Always run all three `--eval`/`--out` pairs, whatever the diagram type. The PNG
is 1950px wide (300dpi of a 6.5in placement). Success prints one `OK <path>`
line per artifact. Read the output for two other lines:
- A hard `ERROR:` line (e.g. `ERROR: render script did not finish: Error: Parse
error on line 4: ...`) is a mermaid parse error. Nothing was copied out. Show
the error to the user, fix the `.mmd`, and retry — do not hand the user a
broken source file.
- A `PAGE_ERRORS=[...]` entry containing `Error processing Mermaid diagram`
means the excalidraw converter fell back to a single image element (Step 1's
state/class/gantt case). The triplet is complete and correct; deliver the
`.excalidraw` anyway with the note that it is not element-editable. Any OTHER
`PAGE_ERRORS` text: read it before trusting the output.
Note: `atob()` yields Latin-1; for sources with non-ASCII labels use
`decodeURIComponent(escape(atob('…')))` to recover UTF-8 exactly.
If the mermaid render returns an error, show the parse error to the user, fix
the mermaid, and retry — do not hand the user a broken source file. If
`__mermaidToExcalidraw` fails on a non-flowchart type, skip the `.excalidraw`
artifact and deliver the rest with the limitation note from Step 1.
`gstack-render` picks the browser itself: Aside when it is running, otherwise
gstack's own headless browser. Only when it prints `NEEDS_ASIDE` or
`ASIDE_NOT_RUNNING` followed by `ERROR: no browser available` is there nothing
to render with — Aside (macOS 15+, aside.com) is not open and gstack's browser
is not built. Tell the user to open Aside, or to run `./setup` in the gstack
repo to build the fallback, and stop. Never install Aside for them, and never
substitute a CDN or another renderer.
## Step 4 — Show and deliver
@@ -129,23 +145,28 @@ and export without touching the mermaid — base64 transport again, since scene
JSON is full of quotes and backslashes:
```bash
$B js --tab-id "$TAB" "window.__excalidrawToSvg(atob('$(base64 < <outdir>/<slug>.excalidraw | tr -d '\n')')).then(s => { window.__svg = s; return 'OK' })"
$B js --tab-id "$TAB" "window.__svg" --out <outdir>/<slug>.svg
$B js --tab-id "$TAB" "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png
SCENE=$(base64 < <outdir>/<slug>.excalidraw | tr -d '\n')
bun run ~/.claude/skills/gstack/bin/gstack-render.ts "<staged>" --wait-selector '#done' \
--eval "window.__excalidrawToSvg(atob('$SCENE')).then(s => (window.__svg = s))" --out <outdir>/<slug>.svg \
--eval "window.__rasterize(window.__svg, 1950)" --out <outdir>/<slug>.png
```
This path prints one benign `PAGE_ERRORS` entry — excalidraw's font subsetter
falls back from a worker to the main thread inside the single-file bundle
(`WorkerInTheMainChunkError`). The SVG/PNG are correct; ignore that one.
## Rules
- **Never ship the triplet without rendering it.** A `.mmd` file alone is not
a diagram. If rendering is impossible (bundle missing, browse down), say so
and stop.
- **Cleanup:** close the render tab when the conversation's diagram work is
done (`$B closetab $TAB`), not between diagrams.
a diagram. If rendering is impossible (bundle missing, no browser available),
say so and stop.
- For diagrams destined for a PDF: remind the user that `make-pdf` renders
` ```mermaid ` fences natively — embedding the `.mmd` in their markdown is
better than embedding the PNG.
## Completion status
- DONE — triplet (or SVG/PNG pair + limitation note) delivered and shown.
- BLOCKED — bundle or browse unavailable; build/setup command surfaced.
- DONE — triplet delivered and shown (with the image-only note for
non-flowchart, non-sequence types).
- BLOCKED — bundle missing or no browser available; the build command, "open
Aside", or "run ./setup" surfaced.
+10 -6
View File
@@ -2,9 +2,13 @@
Offline diagram rendering for make-pdf and /diagram. One self-contained HTML
page (`dist/diagram-render.html`, ~9MB) bundles mermaid, the excalidraw export
utilities, and the official mermaid→excalidraw converter. The browse daemon
loads it with `load-html`; callers drive it through `browse js` and pull bytes
back with `js --out`.
utilities, and the official mermaid→excalidraw converter. Callers open it
through `lib/aside-render.ts` (the TypeScript API make-pdf embeds) or
`bin/gstack-render.ts` (the CLI the /diagram skill runs) — in the Aside browser
when it is running, otherwise in gstack's own headless browser (the browse
daemon; `ENGINE=` on the CLI's first line says which). Either way the page's
directory is served on 127.0.0.1 for one render, `--eval` calls the page API,
and `--out` copies each result out (strings verbatim, data URLs as bytes).
The built page is **committed** (eng-review D2): rendering works with zero
network at install time and render time, and there is no npm supply-chain
@@ -16,15 +20,15 @@ fails CI if `dist/` is edited by hand or falls out of sync with `BUILD_INFO.json
| Function | In → Out |
|---|---|
| `__renderMermaid(id, text)` | mermaid text → SVG string. `id` must be unique per fence (`mermaid-fence-<n>`) — it namespaces every internal SVG id. |
| `__mermaidToExcalidraw(text)` | mermaid text → `.excalidraw` scene JSON (flowcharts fully; other types degrade upstream). |
| `__mermaidToExcalidraw(text)` | mermaid text → `.excalidraw` scene JSON (flowcharts and sequence diagrams as editable elements; other types fall back to one image element and log `Error processing Mermaid diagram` to the console). |
| `__excalidrawToSvg(sceneJson)` | scene JSON → SVG string (Excalifont embedded, offline). |
| `__rasterize(svg, targetWidthPx)` | SVG → PNG data URL. Callers own DPI math: `targetWidthPx = placed width (in) × 300`. Throws on tainted canvas. |
| `__downscaleRaster(dataUri, targetWidthPx, mime)` | raster data URI → smaller data URI at `targetWidthPx` (same mime). make-pdf uses it to normalize oversized photos to print resolution. |
| `__mountForScreenshot(svg, px)` | taint-proof fallback: mounts SVG at `#raster-stage` for `browse screenshot --selector`. |
| `__mountForScreenshot(svg, px)` | taint-proof fallback: mounts SVG at `#raster-stage` for `gstack-render --screenshot out.png --selector '#raster-stage'`. |
| `__probeImage(src)` | data URI/URL → `{width, height}` JSON. |
| `__bundleInfo` | `{ name, deps }` — pinned dependency versions baked at build. |
Readiness: poll until `#status` text is `ready` (or `browse wait '#done'`).
Readiness: poll until `#status` text is `ready` (or `gstack-render ... --wait-selector '#done'`).
Page errors accumulate in `window.__errors`.
## Updating
+54 -20
View File
@@ -1,50 +1,74 @@
/**
* /diagram skill E2E (paid, claude -p).
*
* Two tests with deliberately different tiers (eng-review D5):
* gstack renders local HTML through `bin/gstack-render.ts`: the Aside browser
* when it is running, otherwise gstack's own browse daemon. The /diagram skill
* runs ONE gstack-render call per triplet (staged bundle, three --eval/--out
* pairs) and never picks the engine itself — so the test needs SOME browser:
* Aside on a Mac, or a browse binary (evals.yml's `bun run build` compiles one
* and PLAYWRIGHT_BROWSERS_PATH survives the hermetic env). With neither, the
* whole file self-skips — never fails.
*
* Two tests with deliberately different tiers (eng-review D5, CLAUDE.md rules):
*
* diagram-triplet (gate) — deterministic functional contract: from an
* English ask, the agent following the skill emits a parseable triplet
* .mmd source, .excalidraw scene with elements, SVG markup, PNG bytes.
* No quality judgment; either the artifacts exist and parse or they don't.
* English ask, the agent following the skill emits a parseable triplet
* (.mmd source, .excalidraw scene with elements, SVG markup, PNG bytes) and
* did it through gstack-render (a Bash tool call names it). No quality
* judgment; either the artifacts exist and parse or they don't.
*
* diagram-authoring-quality (periodic) — LLM-judged benchmark of the
* authored mermaid itself (faithfulness to the ask, label quality,
* readable size). Non-deterministic by nature → never blocks merge.
* authored mermaid itself (faithfulness to the ask, label quality, readable
* size). Non-deterministic by nature → never blocks merge.
*
* Per the extract-don't-copy fixture rule, the prompt embeds only the skill's
* working section (from "# /diagram" onward), not the full generated SKILL.md
* with its preamble.
*/
import { describe, expect } from 'bun:test';
import { expect } from 'bun:test';
import { CAPTURE_MS } from './helpers/eval-budgets';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { runSkillTest } from './helpers/session-runner';
import { runSkillTest, type SkillTestResult } from './helpers/session-runner';
import {
ROOT, browseBin, runId,
ROOT, runId,
describeIfSelected, testConcurrentIfSelected,
logCost,
} from './helpers/e2e-helpers';
import { asideAvailable } from './helpers/aside-available';
import { resolveBrowseBin } from '../lib/aside-render';
import { callJudge } from './helpers/llm-judge';
// --- Whole-file gate: a browser gstack-render can drive. Skip, never fail. ---
const browserOk = asideAvailable() || resolveBrowseBin() !== null;
if (process.env.EVALS && !browserOk) {
process.stderr.write('\nskill-e2e-diagram: SKIPPED — no browser: Aside is not running and no browse binary resolves (bun run build)\n');
}
/** describeIfSelected, forced to describe.skip when no browser is available. */
const describeDiagram = (name: string, keys: string[], fn: () => void) =>
describeIfSelected(name, keys, fn, browserOk ? undefined : []);
const BUNDLE = path.join(ROOT, 'lib', 'diagram-render', 'dist', 'diagram-render.html');
const RENDER = path.join(ROOT, 'bin', 'gstack-render.ts');
/** Extract the working section of the generated skill doc (post-preamble). */
function skillExtract(): string {
const full = fs.readFileSync(path.join(ROOT, 'diagram', 'SKILL.md'), 'utf-8');
const start = full.indexOf('# /diagram');
if (start < 0) throw new Error('diagram/SKILL.md missing "# /diagram" section — regenerate skill docs');
if (!full.includes('gstack-render')) throw new Error('diagram/SKILL.md does not render through gstack-render (stale generated doc) — run `bun run gen:skill-docs`');
return full.slice(start);
}
function setupDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.writeFileSync(path.join(dir, 'diagram-skill.md'), skillExtract());
// Pre-stage the bundle so the test is hermetic (no global install needed in
// CI); the prompt tells the agent discovery is already done.
// Pre-stage the bundle so the test is hermetic (no global install needed);
// gstack-render serves this directory on loopback for the render.
fs.copyFileSync(BUNDLE, path.join(dir, 'diagram-render.html'));
fs.mkdirSync(path.join(dir, 'out'));
return dir;
@@ -53,23 +77,30 @@ function setupDir(prefix: string): string {
function basePrompt(dir: string, ask: string): string {
return `You have the /diagram skill instructions at ./diagram-skill.md — read them and follow Steps 1-4.
Environment notes (already set up — skip Step 2's bundle discovery):
- The browse binary is at ${browseBin} — use it wherever the skill says $B.
- The render bundle is ALREADY staged at ./diagram-render.html in this directory; load it with: ${browseBin} load-html ./diagram-render.html
Environment notes (already set up — skip Step 2's bundle discovery and staging):
- gstack-render picks the browser itself (its first output line is ENGINE=aside or ENGINE=browse); either is fine. Do not probe for or start a browser yourself.
- The render bundle is ALREADY staged at ${dir}/diagram-render.html — use that path wherever the skill says <staged>.
- gstack-render lives at ${RENDER}; run it as \`bun run ${RENDER}\` instead of the ~/.claude/skills/gstack/bin/gstack-render.ts path the skill shows.
- Write all four artifacts into ./out/ with the slug "flow" (out/flow.mmd, out/flow.excalidraw, out/flow.svg, out/flow.png).
- Do not open any other applications. Do not use the Read tool on the PNG (no inline display needed here).
The diagram to create: ${ask}`;
}
describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-quality'], () => {
/** The skill's render contract: at least one Bash tool call ran gstack-render. */
function expectRenderedViaGstackRender(result: SkillTestResult): void {
const ran = result.toolCalls.some((c) => c.tool === 'Bash' && JSON.stringify(c.input ?? {}).includes('gstack-render'));
expect(ran).toBe(true);
}
describeDiagram('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-quality'], () => {
testConcurrentIfSelected('diagram-triplet', async () => {
const dir = setupDir('diagram-triplet-');
try {
const result = await runSkillTest({
prompt: basePrompt(
dir,
'a flowchart (graph LR) of a 4-stage pipeline: markdown → prepass → Chromium → PDF.',
'a flowchart (graph LR) of a 4-stage pipeline: markdown → prepass → browser render → PDF.',
),
workingDirectory: dir,
maxTurns: 25,
@@ -80,6 +111,7 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
});
logCost('diagram triplet', result);
expect(result.exitReason).toBe('success');
expectRenderedViaGstackRender(result);
// The deterministic contract: all four artifacts exist and parse.
const mmd = fs.readFileSync(path.join(dir, 'out', 'flow.mmd'), 'utf-8');
@@ -107,7 +139,7 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
const result = await runSkillTest({
prompt: basePrompt(
dir,
'how gstack renders diagrams in PDFs: markdown containing mermaid fences goes through a pre-pass that extracts the fences, renders them in a browse daemon tab using an offline bundle, substitutes the SVG back in, inlines local images, and prints via Chromium. Failures become visible diagnostic blocks.',
'how gstack renders diagrams in PDFs: markdown containing mermaid fences goes through a pre-pass that extracts the fences, renders them in a browser (Aside, or gstack\'s own headless fallback) using an offline bundle, substitutes the SVG back in, inlines local images, and prints the PDF through the same browser. Failures become visible diagnostic blocks.',
),
workingDirectory: dir,
maxTurns: 25,
@@ -118,6 +150,7 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
});
logCost('diagram authoring quality', result);
expect(result.exitReason).toBe('success');
expectRenderedViaGstackRender(result);
const mmd = fs.readFileSync(path.join(dir, 'out', 'flow.mmd'), 'utf-8');
const svg = fs.readFileSync(path.join(dir, 'out', 'flow.svg'), 'utf-8');
@@ -127,9 +160,10 @@ describeIfSelected('/diagram skill E2E', ['diagram-triplet', 'diagram-authoring-
`You are judging the quality of an agent-authored mermaid diagram.
THE ASK: a diagram of gstack's PDF diagram-rendering flow — mermaid fences are
extracted by a pre-pass, rendered in a browse tab via an offline bundle,
substituted back as SVG, images inlined, printed by Chromium, with render
failures becoming visible diagnostic blocks.
extracted by a pre-pass, rendered in a browser (Aside, or gstack's own headless
fallback) via an offline bundle, substituted back as SVG, images inlined,
printed to PDF through the same browser, with render failures becoming visible
diagnostic blocks.
THE AUTHORED MERMAID:
\`\`\`mermaid