explainmyrepo 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,835 @@
1
+ #!/usr/bin/env node
2
+ // make-diagrams.mjs — Station 4 structural-SVG rung (tools/ CONTRACT.md row 4)
3
+ //
4
+ // JOB (ADR-0005 Station 4 + INV-18 + DDD §13 INV-15): produce the structural-diagram SVGs as REAL,
5
+ // BEAUTIFUL vector diagrams — dark, layered/isometric, glassmorphic glowing cards on a dark gradient
6
+ // canvas — NOT raw ASCII as <text>, and NOT a flat freshman wireframe. The MANDATORY architecture
7
+ // diagram (grounded in the REAL kb dep-graph + symbols) is drawn as a LAYERED STACK (entry → core →
8
+ // foundation → external) of glowing glass slabs with glowing connectors; the MANDATORY process/data-
9
+ // flow diagram (grounded in the REAL kb entrypoints) is drawn as a STEPPED VERTICAL PATH with depth
10
+ // and glowing arrows. big-idea & aha-insight diagrams are drawn from brain-authored structure.
11
+ //
12
+ // STYLE (the fix for the owner's "flat boxes on light = garbage" note): dark gradient background
13
+ // (#0b1018 → #070a10) with a soft top spotlight + faint dot grid; glassmorphic translucent cards
14
+ // (semi-transparent fills + subtle light strokes + a glass sheen); colored glow via an SVG blur
15
+ // filter (a blurred accent aura is drawn behind each lit element); vibrant accent colours pulled from
16
+ // the brain's concept.palette (accent / accent-2 / accent-3) that read well on dark; white/light text;
17
+ // monospace technical eyebrow + caption. Methodology mirrors the ascii-to-svg skill (parse → elements
18
+ // → pixel positions → render shapes then connectors → xmllint validate) with a custom dark/glow style.
19
+ //
20
+ // ACCESSIBILITY: every SVG keeps an ASCII/textual fallback in <title>/<desc>, and build.json carries
21
+ // altText + asciiFallback next to each rendered SVG — the ASCII source is a FEATURE (for humans AND AI).
22
+ //
23
+ // CONTRACT: uniform `node tools/make-diagrams.mjs <build-dir>`; reads ONLY its declared slice
24
+ // (kb.depGraphPath/.entrypointsPath/.symbolsPath + visuals.<key>.{ascii,altText} + concept.palette);
25
+ // writes ONLY visuals.architectureDiagram/.flowDiagram/.bigIdeaDiagram/.insightDiagram + the four .svg;
26
+ // PURE (no network); FAIL LOUD (non-zero + clear reason, never a silent placeholder); idempotent.
27
+
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { execFileSync } from 'node:child_process';
31
+
32
+ const TOOL = 'make-diagrams';
33
+
34
+ function die(error) {
35
+ process.stdout.write(JSON.stringify({ ok: false, outputs: {}, error }) + '\n');
36
+ process.stderr.write(`${TOOL}: ${error}\n`);
37
+ process.exit(1);
38
+ }
39
+ const warn = (msg) => process.stderr.write(`${TOOL}: warning: ${msg}\n`);
40
+
41
+ function loadJson(file, label) {
42
+ let raw;
43
+ try { raw = fs.readFileSync(file, 'utf8'); }
44
+ catch (e) { die(`cannot read ${label} at ${file}: ${e.message}`); }
45
+ try { return JSON.parse(raw); }
46
+ catch (e) { die(`${label} at ${file} is not valid JSON: ${e.message}`); }
47
+ }
48
+
49
+ function resolveKbPath(p, buildDir) {
50
+ if (!p || typeof p !== 'string') return null;
51
+ const cands = path.isAbsolute(p) ? [p] : [path.resolve(process.cwd(), p), path.resolve(buildDir, p)];
52
+ for (const c of cands) { if (fs.existsSync(c)) return c; }
53
+ return null;
54
+ }
55
+
56
+ const escapeXml = (s) =>
57
+ String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
58
+ const clip = (s, n) => { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
59
+
60
+ // ── colour utilities ─────────────────────────────────────────────────────────────────────────────
61
+ function hx(h) {
62
+ h = String(h || '').trim().replace('#', '');
63
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
64
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return [124, 92, 255];
65
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
66
+ }
67
+ const toHex = (rgb) => '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
68
+ const mix = (a, b, t) => { const A = hx(a), B = hx(b); return toHex(A.map((v, i) => v + (B[i] - v) * t)); };
69
+ const tint = (hex, a) => { const [r, g, b] = hx(hex); return `rgba(${r},${g},${b},${a})`; };
70
+ // lift a possibly-dim brand colour so it pops on a near-black canvas
71
+ function vivid(hex) {
72
+ const [r, g, b] = hx(hex);
73
+ const max = Math.max(r, g, b);
74
+ if (max >= 150) return hex; // already bright enough
75
+ const k = 175 / Math.max(1, max); // scale up toward a luminous version
76
+ return toHex([r * k, g * k, b * k]);
77
+ }
78
+
79
+ // ── palette: vibrant accents (themed from concept.palette) on a FIXED dark canvas ─────────────────
80
+ function resolvePalette(concept) {
81
+ const p = concept && typeof concept === 'object' ? concept.palette : null;
82
+ const g = (k) => (p && typeof p[k] === 'string' && p[k].trim()) ? p[k].trim() : null;
83
+ // dark-friendly, luminous default spectrum: cyan · violet · emerald · pink · amber · blue
84
+ const DEF = ['#22d3ee', '#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa'];
85
+ const brand = [g('accent'), g('accent-2'), g('accent-3'), g('accent-4'), g('spectrum')].filter(Boolean).map(vivid);
86
+ const accents = (brand.length ? [...brand, ...DEF] : DEF).slice(0, 6);
87
+ return {
88
+ accents,
89
+ primary: accents[0],
90
+ bgTop: '#0b1018', bgMid: '#0a0e15', bgBot: '#070a10',
91
+ ink: '#f1f5f9', sub: '#aab6c8', muted: '#7c8aa0',
92
+ glass: 'rgba(255,255,255,0.05)', glassStroke: 'rgba(255,255,255,0.10)', edge: 'rgba(255,255,255,0.16)',
93
+ extern: '#7587a0',
94
+ };
95
+ }
96
+ let PAL = resolvePalette(null);
97
+ const accent = (i) => PAL.accents[((i % PAL.accents.length) + PAL.accents.length) % PAL.accents.length];
98
+
99
+ // ── text metrics + emit ───────────────────────────────────────────────────────────────────────────
100
+ const FH = 'ui-sans-serif,system-ui,-apple-system,&quot;Segoe UI&quot;,Roboto,sans-serif';
101
+ const FM = 'ui-monospace,SFMono-Regular,&quot;SF Mono&quot;,Menlo,Monaco,Consolas,monospace';
102
+ const measure = (s, size, { bold = false, mono = false } = {}) =>
103
+ String(s == null ? '' : s).length * size * (mono ? 0.6 : bold ? 0.6 : 0.55);
104
+ function txt(x, y, s, o = {}) {
105
+ const { size = 14, fill = PAL.ink, weight = 400, anchor = 'start', mono = false, ls, opacity, dom, extra } = o;
106
+ return `<text x="${x}" y="${y}" font-family="${mono ? FM : FH}" font-size="${size}" font-weight="${weight}"`
107
+ + ` fill="${fill}" text-anchor="${anchor}"${dom ? ` dominant-baseline="${dom}"` : ''}`
108
+ + `${ls != null ? ` letter-spacing="${ls}"` : ''}${opacity != null ? ` opacity="${opacity}"` : ''}${extra ? ` ${extra}` : ''}>${escapeXml(s)}</text>`;
109
+ }
110
+
111
+ // ── shared visual primitives (glassmorphic + glow) ─────────────────────────────────────────────────
112
+ function defs(pal) {
113
+ return ` <defs>
114
+ <linearGradient id="bg" x1="0" y1="0" x2="0.3" y2="1">
115
+ <stop offset="0" stop-color="${pal.bgTop}"/><stop offset="0.55" stop-color="${pal.bgMid}"/><stop offset="1" stop-color="${pal.bgBot}"/>
116
+ </linearGradient>
117
+ <radialGradient id="spot" cx="0.5" cy="0.02" r="0.8">
118
+ <stop offset="0" stop-color="${tint(pal.primary, 0.1)}"/><stop offset="0.5" stop-color="${tint(pal.primary, 0.025)}"/><stop offset="1" stop-color="rgba(0,0,0,0)"/>
119
+ </radialGradient>
120
+ <linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
121
+ <stop offset="0" stop-color="rgba(255,255,255,0.09)"/><stop offset="0.5" stop-color="rgba(255,255,255,0.015)"/><stop offset="1" stop-color="rgba(255,255,255,0)"/>
122
+ </linearGradient>
123
+ <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
124
+ <circle cx="1" cy="1" r="1" fill="rgba(255,255,255,0.014)"/>
125
+ </pattern>
126
+ <filter id="glow" x="-80%" y="-80%" width="260%" height="260%"><feGaussianBlur stdDeviation="11"/></filter>
127
+ <filter id="glowS" x="-150%" y="-150%" width="400%" height="400%"><feGaussianBlur stdDeviation="4"/></filter>
128
+ <filter id="cardSh" x="-40%" y="-50%" width="180%" height="210%">
129
+ <feDropShadow dx="0" dy="6" stdDeviation="11" flood-color="#000000" flood-opacity="0.34"/>
130
+ </filter>
131
+ </defs>`;
132
+ }
133
+
134
+ function background(W, H, pal) {
135
+ return [
136
+ ` <rect x="0" y="0" width="${W}" height="${H}" fill="url(#bg)"/>`,
137
+ ` <rect x="0" y="0" width="${W}" height="${H}" fill="url(#spot)"/>`,
138
+ ` <rect x="0" y="0" width="${W}" height="${H}" fill="url(#grid)"/>`,
139
+ ].join('\n');
140
+ }
141
+
142
+ // a glassmorphic rounded panel with a colored glow aura, a darker extruded base (depth), and a sheen.
143
+ function glassPanel(x, y, w, h, col, { r = 16, fillA = 0.16, depth = 10, aura = 0.5 } = {}) {
144
+ const parts = [];
145
+ if (aura) parts.push(` <rect x="${x - 5}" y="${y - 3}" width="${w + 10}" height="${h + 10}" rx="${r + 4}" fill="${col}" opacity="${(aura * 0.5).toFixed(3)}" filter="url(#glow)"/>`);
146
+ if (depth) parts.push(` <rect x="${x}" y="${y + depth}" width="${w}" height="${h}" rx="${r}" fill="${tint(mix(col, '#000000', 0.6), 0.85)}"/>`);
147
+ parts.push(` <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}" fill="${tint(col, fillA)}" stroke="${tint(col, 0.42)}" stroke-width="1.25" filter="url(#cardSh)"/>`);
148
+ parts.push(` <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}" fill="url(#sheen)"/>`);
149
+ // a whisper of a top-edge highlight (restraint — no glossy bevel)
150
+ parts.push(` <path d="M ${x + r} ${y + 1.25} H ${x + w - r}" stroke="rgba(255,255,255,0.14)" stroke-width="1" fill="none" stroke-linecap="round"/>`);
151
+ return parts.join('\n');
152
+ }
153
+
154
+ // a darker "glass chip" (component / token) that sits on top of a panel — readable white label
155
+ function glassChip(x, y, w, h, col, label, sub, { r = 12 } = {}) {
156
+ const cx = x + 18;
157
+ const parts = [];
158
+ parts.push(` <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}" fill="rgba(11,15,23,0.7)" stroke="${tint(col, 0.45)}" stroke-width="1.25" filter="url(#cardSh)"/>`);
159
+ parts.push(` <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}" fill="url(#sheen)" opacity="0.4"/>`);
160
+ // a calm node dot (small halo, not a flare)
161
+ parts.push(` <circle cx="${cx}" cy="${y + h / 2}" r="4.5" fill="${col}" opacity="0.4" filter="url(#glowS)"/>`);
162
+ parts.push(` <circle cx="${cx}" cy="${y + h / 2}" r="3.2" fill="${mix(col, '#ffffff', 0.35)}"/>`);
163
+ const tx = cx + 16;
164
+ if (sub) {
165
+ parts.push(txt(tx, y + h / 2 - 6, label, { size: 15.5, weight: 700, fill: PAL.ink }));
166
+ parts.push(txt(tx, y + h / 2 + 13, sub, { size: 11, mono: true, fill: PAL.sub }));
167
+ } else {
168
+ parts.push(txt(tx, y + h / 2, label, { size: 15.5, weight: 700, fill: PAL.ink, dom: 'central' }));
169
+ }
170
+ return parts.join('\n');
171
+ }
172
+
173
+ // glowing connector beam (vertical or horizontal) with a chevron arrowhead at the destination
174
+ function beam(x1, y1, x2, y2, col) {
175
+ const ang = Math.atan2(y2 - y1, x2 - x1);
176
+ const ax = x2 - Math.cos(ang) * 0, ay = y2 - Math.sin(ang) * 0;
177
+ const wing = 9;
178
+ const lx = ax - Math.cos(ang - 0.5) * wing, ly = ay - Math.sin(ang - 0.5) * wing;
179
+ const rx = ax - Math.cos(ang + 0.5) * wing, ry = ay - Math.sin(ang + 0.5) * wing;
180
+ const bright = mix(col, '#ffffff', 0.25);
181
+ return [
182
+ ` <line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="9" opacity="0.3" filter="url(#glow)" stroke-linecap="round"/>`,
183
+ ` <line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${bright}" stroke-width="2.4" stroke-linecap="round"/>`,
184
+ ` <path d="M ${lx.toFixed(1)} ${ly.toFixed(1)} L ${ax.toFixed(1)} ${ay.toFixed(1)} L ${rx.toFixed(1)} ${ry.toFixed(1)}" fill="none" stroke="${bright}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>`,
185
+ ].join('\n');
186
+ }
187
+
188
+ function header(cx, top, eyebrow, title, pal) {
189
+ const out = [];
190
+ out.push(txt(cx, top + 14, eyebrow, { size: 13, mono: true, fill: pal.primary, weight: 600, anchor: 'middle', ls: 3 }));
191
+ out.push(` <text x="${cx}" y="${top + 50}" font-family="${FH}" font-size="30" font-weight="800" fill="${pal.primary}" text-anchor="middle" opacity="0.45" filter="url(#glowS)">${escapeXml(title)}</text>`);
192
+ out.push(txt(cx, top + 50, title, { size: 30, weight: 800, fill: pal.ink, anchor: 'middle' }));
193
+ out.push(` <line x1="${cx - 34}" y1="${top + 68}" x2="${cx + 34}" y2="${top + 68}" stroke="${pal.primary}" stroke-width="2.5" stroke-linecap="round" opacity="0.85"/>`);
194
+ return out.join('\n');
195
+ }
196
+
197
+ function wrapSvg(W, H, body, title, desc, ascii) {
198
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-labelledby="d-title d-desc">
199
+ <title id="d-title">${escapeXml(title)}</title>
200
+ <desc id="d-desc">${escapeXml(desc)}</desc>
201
+ ${ascii ? ` <metadata><![CDATA[\n${String(ascii).replace(/]]>/g, ']]&gt;')}\n]]></metadata>\n` : ''}${defs(PAL)}
202
+ ${body}
203
+ </svg>
204
+ `;
205
+ }
206
+
207
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
208
+
209
+ // a directional dependency edge: a smooth bezier that LEAVES the source straight down and ARRIVES at
210
+ // the target straight down, so a downward chevron arrowhead is always correct. Glow underlay + bright core.
211
+ function curve(x1, y1, x2, y2, col, { w = 1.9, op = 0.82, glow = true } = {}) {
212
+ const dy = Math.max(18, (y2 - y1) * 0.26); // gentler bend = less awkward tracing
213
+ const tip = y2, base = y2 - 10; // stop the line at the arrowhead base, tip at the node
214
+ const d = `M ${x1.toFixed(1)} ${y1.toFixed(1)} C ${x1.toFixed(1)} ${(y1 + dy).toFixed(1)}, ${x2.toFixed(1)} ${(tip - dy).toFixed(1)}, ${x2.toFixed(1)} ${base.toFixed(1)}`;
215
+ const bright = mix(col, '#ffffff', 0.2);
216
+ const parts = [];
217
+ if (glow) parts.push(` <path d="${d}" fill="none" stroke="${col}" stroke-width="${w + 2}" opacity="0.06" filter="url(#glow)"/>`);
218
+ parts.push(` <path d="${d}" fill="none" stroke="${bright}" stroke-width="${w}" opacity="${op}" stroke-linecap="round"/>`);
219
+ const aw = 6, ah = 9.5; // crisp solid triangular arrowhead = unambiguous direction
220
+ parts.push(` <path d="M ${(x2 - aw).toFixed(1)} ${(tip - ah).toFixed(1)} L ${x2.toFixed(1)} ${tip.toFixed(1)} L ${(x2 + aw).toFixed(1)} ${(tip - ah).toFixed(1)} Z" fill="${bright}" opacity="${op}"/>`);
221
+ return parts.join('\n');
222
+ }
223
+
224
+ // ── ARCHITECTURE: a real layered-DAG dependency map — nodes placed by topological depth, every REAL
225
+ // module→module edge drawn as a directional connector, the most-depended-on module marked as the core ──
226
+ const AR = { CHIP_H: 62, CHIP_MINW: 150, CHIP_MAXW: 254, CHIP_GAP: 34,
227
+ ROW_GAP: 104, TOP: 144, BOTTOM: 96, LEFT: 156, RIGHT: 78, EXT_GAP: 66, EXT_H: 90 };
228
+
229
+ function archChipW(it) {
230
+ const lw = measure(clip(it.label, 26), 15, { bold: true });
231
+ const sw = it.sub ? measure(clip(it.sub, 30), 11, { mono: true }) : 0;
232
+ return clamp(Math.ceil(Math.max(lw, sw)) + 58, AR.CHIP_MINW, AR.CHIP_MAXW);
233
+ }
234
+
235
+ function bandName(i, total) {
236
+ if (total <= 1) return 'MODULES';
237
+ if (i === 0) return 'ENTRY';
238
+ if (i === total - 1) return 'FOUNDATION';
239
+ if (total === 3) return 'CORE';
240
+ if (total === 4) return i === 1 ? 'CORE' : 'SERVICES';
241
+ return `TIER ${i}`;
242
+ }
243
+
244
+ // even fan-out/fan-in: spread an edge's departure (or arrival) point across the node's edge so each
245
+ // connector gets its own port and the arrowheads never pile onto one ambiguous point
246
+ function portX(box, list, e) {
247
+ const n = list.length, idx = list.indexOf(e);
248
+ if (n <= 1) return box.cx;
249
+ const inset = Math.min(28, box.w * 0.34);
250
+ return (box.x + inset) + ((box.x + box.w - inset) - (box.x + inset)) * (idx / (n - 1));
251
+ }
252
+
253
+ function renderArchitecture(eyebrow, title, model, caption, pal) {
254
+ const rows = model.rows, edges = model.edges, total = rows.length;
255
+ const rowW = rows.map((r) => r.reduce((s, it) => s + archChipW(it), 0) + AR.CHIP_GAP * (r.length - 1));
256
+ const maxRowW = Math.max(AR.CHIP_MINW * 2 + AR.CHIP_GAP, ...rowW);
257
+ const extH = model.ext ? AR.EXT_GAP + AR.EXT_H : 0;
258
+ const cw = AR.LEFT + maxRowW + AR.RIGHT;
259
+ const ch = AR.TOP + total * AR.CHIP_H + (total - 1) * AR.ROW_GAP + extH + AR.BOTTOM;
260
+ const S = Math.max(cw, ch), ox = (S - cw) / 2, oy = (S - ch) / 2;
261
+ const centerX = AR.LEFT + maxRowW / 2;
262
+ const body = [background(S, S, pal), ` <g transform="translate(${ox.toFixed(1)},${oy.toFixed(1)})">`, header(centerX, 30, eyebrow, title, pal)];
263
+
264
+ // place every node and remember its anchor box
265
+ const pos = {};
266
+ rows.forEach((r, i) => {
267
+ const y = AR.TOP + i * (AR.CHIP_H + AR.ROW_GAP);
268
+ let x = centerX - rowW[i] / 2;
269
+ for (const it of r) {
270
+ const w = archChipW(it);
271
+ pos[it.name] = { x, y, w, cx: x + w / 2, top: y, bot: y + AR.CHIP_H, col: accent(i), it };
272
+ x += w + AR.CHIP_GAP;
273
+ }
274
+ });
275
+
276
+ // assign each node's edges to distinct ports (sorted toward their neighbour) before drawing
277
+ const outg = {}, inc = {};
278
+ for (const e of edges) { (outg[e.from] = outg[e.from] || []).push(e); (inc[e.to] = inc[e.to] || []).push(e); }
279
+ for (const k in outg) outg[k].sort((a, b) => (pos[a.to] ? pos[a.to].cx : 0) - (pos[b.to] ? pos[b.to].cx : 0));
280
+ for (const k in inc) inc[k].sort((a, b) => (pos[a.from] ? pos[a.from].cx : 0) - (pos[b.from] ? pos[b.from].cx : 0));
281
+
282
+ // REAL dependency edges drawn FIRST (behind the cards) — each module→module link, coloured by source
283
+ for (const e of edges) {
284
+ const a = pos[e.from], b = pos[e.to];
285
+ if (!a || !b) continue;
286
+ const sx = portX(a, outg[e.from], e), tx = portX(b, inc[e.to], e);
287
+ body.push(b.top > a.bot
288
+ ? curve(sx, a.bot + 3, tx, b.top - 3, a.col, { glow: false })
289
+ : curve(sx, a.bot + 3, tx, b.bot + 3, a.col, { w: 1.6, op: 0.55, glow: false }));
290
+ }
291
+
292
+ // left depth axis — a real "deeper = more foundational / more depended-on" gauge, not a bolted-on rail
293
+ const axX = AR.LEFT - 34;
294
+ const firstY = AR.TOP + AR.CHIP_H / 2, lastRowY = AR.TOP + (total - 1) * (AR.CHIP_H + AR.ROW_GAP) + AR.CHIP_H / 2, midY = (firstY + lastRowY) / 2;
295
+ body.push(` <line x1="${axX}" y1="${firstY}" x2="${axX}" y2="${lastRowY + 16}" stroke="rgba(255,255,255,0.13)" stroke-width="1.5" stroke-dasharray="2 6"/>`);
296
+ body.push(` <path d="M ${axX - 5} ${lastRowY + 10} L ${axX} ${lastRowY + 18} L ${axX + 5} ${lastRowY + 10} Z" fill="rgba(255,255,255,0.28)"/>`);
297
+ body.push(txt(26, midY, 'DEPENDENCY DEPTH', { size: 10.5, mono: true, weight: 700, fill: pal.muted, ls: 2, anchor: 'middle', dom: 'central', extra: `transform="rotate(-90 26 ${midY.toFixed(1)})"` }));
298
+ rows.forEach((r, i) => {
299
+ const y = AR.TOP + i * (AR.CHIP_H + AR.ROW_GAP) + AR.CHIP_H / 2, col = accent(i);
300
+ body.push(` <circle cx="${axX}" cy="${y}" r="4.5" fill="${col}" filter="url(#glowS)"/>`);
301
+ body.push(` <circle cx="${axX}" cy="${y}" r="3" fill="${mix(col, '#ffffff', 0.4)}"/>`);
302
+ body.push(txt(axX - 13, y, bandName(i, total), { size: 11.5, mono: true, weight: 700, fill: col, ls: 1.2, anchor: 'end', dom: 'central' }));
303
+ });
304
+
305
+ // nodes — the hub gets a crisp ring + faint accent wash (a clear focal point, NOT a blurred fog cloud)
306
+ for (const r of rows) for (const it of r) {
307
+ const p = pos[it.name];
308
+ body.push(glassChip(p.x, p.top, p.w, AR.CHIP_H, p.col, clip(it.label, 26), it.sub ? clip(it.sub, 30) : ''));
309
+ if (it.isHub) {
310
+ // a faint accent wash + a crisp ring mark the core, with a small inline CORE tag (no slapped-on pill)
311
+ body.push(` <rect x="${p.x}" y="${p.top}" width="${p.w}" height="${AR.CHIP_H}" rx="12" fill="${tint(p.col, 0.07)}"/>`);
312
+ body.push(` <rect x="${(p.x - 4).toFixed(1)}" y="${(p.top - 4).toFixed(1)}" width="${p.w + 8}" height="${AR.CHIP_H + 8}" rx="16" fill="none" stroke="${p.col}" stroke-width="1.5" opacity="0.7"/>`);
313
+ body.push(` <rect x="${(p.x + p.w - 52).toFixed(1)}" y="${(p.top - 11).toFixed(1)}" width="52" height="20" rx="6" fill="rgba(11,15,23,0.95)" stroke="${tint(p.col, 0.5)}" stroke-width="1"/>`);
314
+ body.push(` <path d="M ${(p.x + p.w - 40).toFixed(1)} ${(p.top - 1).toFixed(1)} l 3.5 -3.5 l 3.5 3.5 l -3.5 3.5 Z" fill="${p.col}"/>`);
315
+ body.push(txt(p.x + p.w - 30, p.top - 1, 'CORE', { size: 9.5, mono: true, weight: 800, fill: mix(p.col, '#ffffff', 0.3), ls: 1, dom: 'central' }));
316
+ }
317
+ }
318
+
319
+ // external-dependency band — a slim row in the SAME glass language (just dimmer), not a foreign grey slab
320
+ if (model.ext) {
321
+ const ey = AR.TOP + total * AR.CHIP_H + (total - 1) * AR.ROW_GAP + AR.EXT_GAP;
322
+ const eh = 64, ew = clamp(maxRowW, 340, 540), ex = centerX - ew / 2, lc = accent(total - 1);
323
+ body.push(` <rect x="${ex}" y="${ey}" width="${ew}" height="${eh}" rx="14" fill="rgba(11,15,23,0.55)" stroke="${tint(lc, 0.28)}" stroke-width="1.25"/>`);
324
+ body.push(` <rect x="${ex}" y="${ey}" width="4" height="${eh}" rx="2" fill="${tint(lc, 0.6)}"/>`);
325
+ body.push(txt(ex + 22, ey + 25, 'EXTERNAL PACKAGES', { size: 10.5, mono: true, weight: 700, fill: pal.muted, ls: 1.5 }));
326
+ body.push(txt(ex + 22, ey + 47, clip(model.ext.names.join(' · '), 52), { size: 13, weight: 500, fill: pal.sub }));
327
+ body.push(txt(ex + ew - 20, ey + 36, `${model.ext.count} deps`, { size: 11.5, mono: true, fill: pal.muted, anchor: 'end', dom: 'central' }));
328
+ }
329
+
330
+ // legend (explains the arrows + the CORE mark) above the stats caption. Centre it on the CANVAS
331
+ // (not the content column) and font-fit it to the canvas width, so the fixed ~577px legend string
332
+ // can never spill past the edges on a small repo whose square canvas is narrower than the legend
333
+ // (the chalk overflow bug: content-centred at x≈323 left only 245px on the right → "depende‑[d-on]" cut).
334
+ const legY = ch - AR.BOTTOM + 34;
335
+ const legCx = S / 2 - ox; // canvas centre, expressed inside this translate(ox,oy) group
336
+ const legAvail = S - 32; // usable width: a 16px margin each side
337
+ const fitMono = (s, base) => { const w = measure(s, base, { mono: true }); return w <= legAvail ? base : Math.max(9, +(base * legAvail / w).toFixed(2)); };
338
+ const legend = 'arrow points from a module to what it depends on ◆ CORE = most depended-on';
339
+ body.push(txt(legCx, legY, legend, { size: fitMono(legend, 12.5), mono: true, fill: pal.sub, anchor: 'middle' }));
340
+ if (caption) { const c = clip(caption, 96); body.push(txt(legCx, legY + 26, c, { size: fitMono(c, 12.5), mono: true, fill: pal.muted, anchor: 'middle' })); }
341
+ body.push(' </g>');
342
+ const desc = `${title}: ${rows.map((r, i) => `${bandName(i, total)} [${r.map((it) => it.name).join(', ')}]`).join(' → ')}; ${edges.length} real dependency edges, core = ${model.hub || 'n/a'}.`;
343
+ return { W: S, H: S, body: body.join('\n'), desc };
344
+ }
345
+
346
+ // ── FLOW: a real data-flow pipeline. Each stage card shows the transformation it performs (verb +
347
+ // command) and the data it consumes/produces (IN → OUT); plain arrows carry that data to the next
348
+ // stage — NO duplicate wire labels (the OUT chip already names what flows on, so we don't restate it) ─
349
+ const FL = { CARD_W: 664, CARD_H: 108, VGAP: 62, TOP: 150, BOTTOM: 92, TOK_H: 40 };
350
+
351
+ // a label that rides ON a connector wire — names the actual artifact handed from one stage to the next,
352
+ // so the pipeline visibly CARRIES data (the OUT of a stage becomes the input the next consumes)
353
+ function wireTag(cx, midY, text, col) {
354
+ const w = Math.ceil(measure(text, 11, { mono: true })) + 24, h = 22, x = cx - w / 2;
355
+ return [
356
+ ` <rect x="${x.toFixed(1)}" y="${(midY - h / 2).toFixed(1)}" width="${w}" height="${h}" rx="${h / 2}" fill="rgba(8,11,17,0.94)" stroke="${tint(col, 0.5)}" stroke-width="1"/>`,
357
+ txt(cx, midY, text, { size: 11, mono: true, fill: mix(col, '#ffffff', 0.4), anchor: 'middle', dom: 'central' }),
358
+ ].join('\n');
359
+ }
360
+
361
+ // a rounded glass "endpoint" pill for the SOURCE input and the final RESULT
362
+ function tokenPill(cx, y, kind, label, col) {
363
+ const kw = Math.ceil(measure(kind, 11, { mono: true })) + 16;
364
+ const w = kw + Math.ceil(measure(label, 12.5, { mono: true })) + 50;
365
+ const x = cx - w / 2;
366
+ return [
367
+ ` <rect x="${x.toFixed(1)}" y="${y}" width="${w}" height="${FL.TOK_H}" rx="${FL.TOK_H / 2}" fill="rgba(255,255,255,0.04)" stroke="${tint(col, 0.5)}" stroke-width="1.25"/>`,
368
+ ` <rect x="${x.toFixed(1)}" y="${y}" width="${w}" height="${FL.TOK_H}" rx="${FL.TOK_H / 2}" fill="url(#sheen)" opacity="0.4"/>`,
369
+ ` <circle cx="${(x + 18).toFixed(1)}" cy="${(y + FL.TOK_H / 2).toFixed(1)}" r="3.4" fill="${col}" filter="url(#glowS)"/>`,
370
+ txt(x + 30, y + FL.TOK_H / 2, kind, { size: 11, mono: true, weight: 800, fill: col, ls: 1.2, dom: 'central' }),
371
+ txt(x + 30 + kw, y + FL.TOK_H / 2, label, { size: 12.5, mono: true, fill: PAL.ink, dom: 'central' }),
372
+ ].join('\n');
373
+ }
374
+
375
+ function flowCard(x, y, col, n, s) {
376
+ const w = FL.CARD_W, h = FL.CARD_H;
377
+ const parts = [glassPanel(x, y, w, h, col, { r: 16, fillA: 0.07, depth: 6, aura: 0.16 })];
378
+ // number badge (subtle glow, not an arcade slab)
379
+ const bx = x + 50, by = y + h / 2;
380
+ parts.push(` <circle cx="${bx}" cy="${by}" r="22" fill="${col}" opacity="0.28" filter="url(#glowS)"/>`);
381
+ parts.push(` <circle cx="${bx}" cy="${by}" r="20" fill="${tint(col, 0.2)}" stroke="${col}" stroke-width="1.75"/>`);
382
+ parts.push(txt(bx, by + 1, String(n), { size: 20, weight: 800, fill: mix(col, '#ffffff', 0.55), anchor: 'middle', dom: 'central' }));
383
+ // stage name + the transformation verb
384
+ const tx = x + 88;
385
+ parts.push(txt(tx, y + 34, s.name, { size: 18, weight: 800, fill: PAL.ink, ls: 0.4 }));
386
+ parts.push(txt(tx, y + 55, clip(s.verb, 40), { size: 12.5, fill: PAL.sub }));
387
+ // the command that does it
388
+ const cy = y + 68, cw = w - (tx - x) - 214;
389
+ parts.push(` <rect x="${tx}" y="${cy}" width="${cw}" height="28" rx="7" fill="rgba(0,0,0,0.42)" stroke="rgba(255,255,255,0.08)" stroke-width="1"/>`);
390
+ parts.push(txt(tx + 12, cy + 19, '$ ' + clip(s.cmd, 36), { size: 12, mono: true, fill: mix(col, '#ffffff', 0.5) }));
391
+ // IN → OUT (the data this stage consumes and produces — the actual transformation)
392
+ const iox = x + w - 196;
393
+ parts.push(` <line x1="${iox - 18}" y1="${y + 18}" x2="${iox - 18}" y2="${y + h - 18}" stroke="rgba(255,255,255,0.10)" stroke-width="1"/>`);
394
+ parts.push(txt(iox, y + 42, 'IN', { size: 10.5, mono: true, weight: 700, fill: PAL.muted, ls: 1.5, dom: 'central' }));
395
+ parts.push(txt(iox + 34, y + 42, clip(s.in, 22), { size: 12, mono: true, fill: PAL.sub, dom: 'central' }));
396
+ // a downward transform-arrow (IN becomes OUT) — not a sideways chevron that reads like a shell prompt
397
+ const axc = iox + 7;
398
+ parts.push(` <path d="M ${axc} ${y + 50} V ${y + 60}" stroke="${tint(col, 0.55)}" stroke-width="1.4" stroke-linecap="round"/>`);
399
+ parts.push(` <path d="M ${axc - 3.5} ${y + 56} L ${axc} ${y + 60.5} L ${axc + 3.5} ${y + 56}" fill="none" stroke="${tint(col, 0.55)}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`);
400
+ parts.push(txt(iox + 34, y + h - 42, 'OUT', { size: 10.5, mono: true, weight: 700, fill: col, ls: 1.5, dom: 'central', anchor: 'start' }));
401
+ parts.push(txt(iox + 34, y + h - 24, clip(s.out, 22), { size: 12, mono: true, weight: 600, fill: mix(col, '#ffffff', 0.4), dom: 'central' }));
402
+ return parts.join('\n');
403
+ }
404
+
405
+ function renderFlow(eyebrow, title, model, caption, pal) {
406
+ const steps = model.steps, n = steps.length;
407
+ const cw = FL.CARD_W + 108;
408
+ const ch = FL.TOP + FL.TOK_H + (n + 1) * FL.VGAP + n * FL.CARD_H + FL.TOK_H + FL.BOTTOM;
409
+ const S = Math.max(cw, ch), ox = (S - cw) / 2, oy = (S - ch) / 2;
410
+ const cardX = (cw - FL.CARD_W) / 2, spine = cardX + FL.CARD_W / 2;
411
+ const body = [background(S, S, pal), ` <g transform="translate(${ox.toFixed(1)},${oy.toFixed(1)})">`, header(spine, 30, eyebrow, title, pal)];
412
+
413
+ let y = FL.TOP;
414
+ body.push(tokenPill(spine, y, 'SOURCE', model.source, accent(0)));
415
+ y += FL.TOK_H;
416
+ for (let i = 0; i < n; i++) {
417
+ const wcol = mix(accent(Math.max(0, i - 1)), accent(i), 0.5);
418
+ body.push(beam(spine, y + 4, spine, y + FL.VGAP - 4, wcol));
419
+ // the wire carries the artifact the previous stage produced (its OUT) into this one — data, moving
420
+ if (i >= 1) body.push(wireTag(spine, y + FL.VGAP / 2, clip(steps[i - 1].out, 22), wcol));
421
+ y += FL.VGAP;
422
+ body.push(flowCard(cardX, y, accent(i), i + 1, steps[i]));
423
+ y += FL.CARD_H;
424
+ }
425
+ body.push(beam(spine, y + 4, spine, y + FL.VGAP - 4, accent(n - 1)));
426
+ body.push(wireTag(spine, y + FL.VGAP / 2, clip(steps[n - 1].out, 22), accent(n - 1)));
427
+ y += FL.VGAP;
428
+ body.push(tokenPill(spine, y, 'RESULT', model.result, accent(n - 1)));
429
+
430
+ if (caption) body.push(txt(spine, ch - FL.BOTTOM + 46, clip(caption, 88), { size: 12, mono: true, fill: pal.muted, anchor: 'middle' }));
431
+ body.push(' </g>');
432
+ const desc = `${title}: ${model.source} ⟶ ${steps.map((s) => `${s.name} (${s.in} → ${s.out})`).join(' ⟶ ')} ⟶ ${model.result}.`;
433
+ return { W: S, H: S, body: body.join('\n'), desc };
434
+ }
435
+
436
+ // ── CONCEPT (big-idea / insight): a centered VERTICAL glowing flow of idea-cards ───────────────────
437
+ // Wide-but-short horizontal strips waste a forced-square canvas, so we stack the idea as a vertical
438
+ // path that fills a near-square — sequence items get glowing down-arrows; separate statements stack.
439
+ const C = { CARD_H: 78, VGAP: 50, GAP_PLAIN: 30, TOP: 132, BOTTOM: 72, PADX: 30, MINW: 280, MAXW: 580 };
440
+
441
+ function cwidth(label) { return Math.min(C.MAXW, Math.max(C.MINW, Math.ceil(measure(clip(label, 54), 18.5, { bold: true })) + C.PADX * 2)); }
442
+
443
+ // word-wrap a string to at most `maxChars` per line (used to keep a caption inside the canvas width)
444
+ function wrapText(text, maxChars) {
445
+ const cap = Math.max(8, maxChars);
446
+ const words = String(text).trim().split(/\s+/).filter(Boolean);
447
+ const out = [];
448
+ let cur = '';
449
+ for (const w of words) {
450
+ if (!cur) cur = w;
451
+ else if (cur.length + 1 + w.length <= cap) cur += ' ' + w;
452
+ else { out.push(cur); cur = w; }
453
+ }
454
+ if (cur) out.push(cur);
455
+ return out.length ? out : [''];
456
+ }
457
+
458
+ function renderConcept(eyebrow, title, rows, caption, pal) {
459
+ rows = rows.filter((r) => r && Array.isArray(r.items) && r.items.length);
460
+ if (!rows.length) rows = [{ items: [{ label: title }] }];
461
+ // flatten into vertical steps, marking which connect to the next with an arrow
462
+ const steps = [];
463
+ rows.forEach((r) => r.items.forEach((it, i) => steps.push({
464
+ label: it.label, colorIdx: it.colorIdx, arrow: !!(r.connectWithin && i < r.items.length - 1),
465
+ })));
466
+ const n = steps.length;
467
+ const maxW = Math.max(C.MINW, ...steps.map((s) => cwidth(s.label)));
468
+ const gaps = steps.slice(0, -1).reduce((t, s) => t + (s.arrow ? C.VGAP : C.GAP_PLAIN), 0);
469
+ const cw = maxW + 140;
470
+ const contentH = C.TOP + n * C.CARD_H + gaps; // canvas through the bottom of the last card
471
+
472
+ // PORTRAIT canvas — width = the card column (or the title, whichever is wider), NOT a forced square.
473
+ // A square left big dead side-margins around the narrow card column, which looked broken when the
474
+ // diagram was panned on a phone. Portrait keeps the cards flush to the frame on every device.
475
+ const titleMinW = Math.ceil(measure(title, 30, { bold: true })) + 120;
476
+ const W = Math.max(cw, titleMinW);
477
+ // caption: WRAP to the real canvas width (mono) so it can never spill past the edges, then size the
478
+ // bottom band to the wrapped line count.
479
+ const CAP_FS = 13, CAP_CW = CAP_FS * 0.6, CAP_LH = CAP_FS * 1.5;
480
+ const capCharCap = Math.max(16, Math.floor((W - 104) / CAP_CW));
481
+ let capLines = caption ? wrapText(caption, capCharCap) : [];
482
+ if (capLines.length > 3) { capLines = capLines.slice(0, 3); capLines[2] = clip(capLines[2] + ' …', capCharCap); }
483
+ const bottomPad = capLines.length ? Math.max(C.BOTTOM, 34 + capLines.length * CAP_LH + 22) : C.BOTTOM;
484
+ const H = contentH + bottomPad;
485
+ const ox = (W - cw) / 2, oy = 0; // centre the card column horizontally; portrait, so no vertical centring
486
+ const mid = cw / 2;
487
+
488
+ const body = [background(W, H, pal), ` <g transform="translate(${ox.toFixed(1)},${oy.toFixed(1)})">`, header(mid, 30, eyebrow, title, pal)];
489
+ let y = C.TOP;
490
+ const geo = steps.map((s, i) => { const g = { ...s, y, col: accent(s.colorIdx != null ? s.colorIdx : i) }; y += C.CARD_H + (s.arrow ? C.VGAP : C.GAP_PLAIN); return g; });
491
+ for (let i = 0; i < geo.length - 1; i++) if (geo[i].arrow) body.push(beam(mid, geo[i].y + C.CARD_H + 6, mid, geo[i + 1].y - 6, mix(geo[i].col, geo[i + 1].col, 0.5)));
492
+ for (const g of geo) {
493
+ const w = cwidth(g.label), x = mid - w / 2;
494
+ body.push(glassPanel(x, g.y, w, C.CARD_H, g.col, { r: 18, fillA: 0.18, depth: 8, aura: 0.5 }));
495
+ body.push(txt(mid, g.y + C.CARD_H / 2, clip(g.label, 54), { size: 18.5, weight: 700, fill: pal.ink, anchor: 'middle', dom: 'central' }));
496
+ }
497
+ // caption wrapped + stacked + centered below the last card
498
+ let cy = contentH + 34 + CAP_FS;
499
+ for (const cl of capLines) { body.push(txt(mid, cy, cl, { size: CAP_FS, mono: true, fill: pal.muted, anchor: 'middle' })); cy += CAP_LH; }
500
+ body.push(' </g>');
501
+ const desc = `${title}: ${rows.map((r) => r.items.map((it) => it.label).join(r.connectWithin ? ' → ' : ', ')).join(' / ')}`;
502
+ return { W, H, body: body.join('\n'), desc };
503
+ }
504
+
505
+ // (REMOVED) renderAsciiMono/wrapMono — these typeset the brain's ASCII VERBATIM as a picture of ASCII,
506
+ // which is slop. big-idea & insight are now DRAWN as real glass concept-cards via renderConcept (above)
507
+ // from a structured rows model (brain emits visuals.<key>.rows). Legacy .ascii is parsed by asciiRows()
508
+ // into that same structured model — so even old builds render as real cards, never as typeset ASCII.
509
+
510
+ function assertXmllintClean(svgPath, key) {
511
+ try { execFileSync('xmllint', ['--noout', svgPath], { stdio: ['ignore', 'ignore', 'pipe'] }); }
512
+ catch (e) {
513
+ if (e && e.code === 'ENOENT') die(`xmllint not found on PATH — cannot validate the ${key} SVG; refusing to ship an unverified diagram`);
514
+ const detail = e && e.stderr ? e.stderr.toString().trim() : (e ? e.message : 'unknown error');
515
+ die(`SVG validation failed for ${key} (${svgPath}): ${detail}`);
516
+ }
517
+ }
518
+
519
+ function symbolCountFor(node, sym) {
520
+ if (!sym || !sym.byCrate || !node) return null;
521
+ const bc = sym.byCrate, cands = [node.name];
522
+ if (node.manifest) { const dir = String(node.manifest).replace(/\\/g, '/').replace(/\/[^/]+$/, ''); if (dir) cands.push(dir); }
523
+ for (const c of cands) if (c && Object.prototype.hasOwnProperty.call(bc, c) && typeof bc[c] === 'number') return bc[c];
524
+ return null;
525
+ }
526
+
527
+ // ── ARCHITECTURE model from the REAL dep-graph: topological layers + the actual module→module edges ─
528
+ // Assign each module a depth = longest path from a source, so EVERY internal edge points downward and
529
+ // the wiring (who depends on whom, what's a hub, what's a shared leaf) is visible — not invented.
530
+ function longestPathLayers(names, edges) {
531
+ const succ = {}, indeg = {}, layer = {};
532
+ names.forEach((n) => { succ[n] = []; indeg[n] = 0; layer[n] = 0; });
533
+ for (const e of edges) { if (succ[e.from] && layer[e.to] != null) { succ[e.from].push(e.to); indeg[e.to]++; } }
534
+ const ind = { ...indeg };
535
+ const q = names.filter((n) => ind[n] === 0);
536
+ let seen = 0;
537
+ while (q.length) { const n = q.shift(); seen++; for (const m of succ[n]) { if (layer[m] < layer[n] + 1) layer[m] = layer[n] + 1; if (--ind[m] === 0) q.push(m); } }
538
+ if (seen < names.length) { for (let it = 0; it < names.length; it++) for (const e of edges) if (layer[e.to] < layer[e.from] + 1) layer[e.to] = layer[e.from] + 1; }
539
+ return layer;
540
+ }
541
+
542
+ // crossing-reduction: order each row by the average position of its neighbours in the adjacent row
543
+ function orderRows(rows, edges) {
544
+ const pos = {}; rows.forEach((r) => r.forEach((n, i) => { pos[n.name] = i; }));
545
+ const pred = {}, succ = {};
546
+ for (const e of edges) { (pred[e.to] = pred[e.to] || []).push(e.from); (succ[e.from] = succ[e.from] || []).push(e.to); }
547
+ const bary = (n, map) => { const ps = map[n.name]; if (!ps || !ps.length) return pos[n.name]; return ps.reduce((s, p) => s + (pos[p] ?? 0), 0) / ps.length; };
548
+ for (let pass = 0; pass < 3; pass++) {
549
+ for (let i = 1; i < rows.length; i++) { rows[i].sort((a, b) => bary(a, pred) - bary(b, pred)); rows[i].forEach((n, k) => { pos[n.name] = k; }); }
550
+ for (let i = rows.length - 2; i >= 0; i--) { rows[i].sort((a, b) => bary(a, succ) - bary(b, succ)); rows[i].forEach((n, k) => { pos[n.name] = k; }); }
551
+ }
552
+ }
553
+
554
+ function buildArchModel(dg, sym) {
555
+ const all = (Array.isArray(dg.nodes) ? dg.nodes : []).filter((n) => n && n.name);
556
+ const nameSet = new Set(all.map((n) => n.name));
557
+ const seenE = new Set();
558
+ let edges = (Array.isArray(dg.internalEdges) ? dg.internalEdges : [])
559
+ .filter((e) => e && e.from && e.to && e.from !== e.to && nameSet.has(e.from) && nameSet.has(e.to))
560
+ .filter((e) => { const k = e.from + '' + e.to; if (seenE.has(k)) return false; seenE.add(k); return true; });
561
+
562
+ // full-graph degree → keep the most-connected modules when there are too many to draw legibly
563
+ const fIn = {}, fOut = {};
564
+ for (const e of edges) { fIn[e.to] = (fIn[e.to] || 0) + 1; fOut[e.from] = (fOut[e.from] || 0) + 1; }
565
+ const fdeg = (n) => (fIn[n.name] || 0) + (fOut[n.name] || 0);
566
+ const CAP = 12;
567
+ let nodes = all, trimmed = 0;
568
+ if (all.length > CAP) {
569
+ nodes = [...all].sort((a, b) => fdeg(b) - fdeg(a) || (fIn[b.name] || 0) - (fIn[a.name] || 0)).slice(0, CAP);
570
+ const keep = new Set(nodes.map((n) => n.name));
571
+ edges = edges.filter((e) => keep.has(e.from) && keep.has(e.to));
572
+ trimmed = all.length - nodes.length;
573
+ }
574
+
575
+ // degrees on the shown subgraph drive layering, labels and hub detection
576
+ const inDeg = {}, outDeg = {};
577
+ nodes.forEach((n) => { inDeg[n.name] = 0; outDeg[n.name] = 0; });
578
+ for (const e of edges) { inDeg[e.to]++; outDeg[e.from]++; }
579
+ const plur = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`;
580
+ const symAnn = (n) => { const k = symbolCountFor(n, sym); return k != null ? `${k} sym` : ''; };
581
+
582
+ let rows;
583
+ if (!edges.length) {
584
+ // standalone: no internal edges — lay the modules out as a wrapped grid of independent components
585
+ const per = Math.min(4, Math.max(2, Math.ceil(Math.sqrt(nodes.length))));
586
+ rows = [];
587
+ for (let i = 0; i < nodes.length; i += per) rows.push(nodes.slice(i, i + per));
588
+ } else {
589
+ const layer = longestPathLayers(nodes.map((n) => n.name), edges);
590
+ const maxL = Math.max(0, ...nodes.map((n) => layer[n.name]));
591
+ rows = [];
592
+ for (let l = 0; l <= maxL; l++) rows.push([]);
593
+ nodes.forEach((n) => rows[layer[n.name]].push(n));
594
+ rows = rows.filter((r) => r.length);
595
+ orderRows(rows, edges);
596
+ }
597
+
598
+ // the hub = the module the most others depend on (ties → highest total degree)
599
+ let hub = null, hubScore = -1;
600
+ for (const n of nodes) { const sc = (inDeg[n.name] || 0) * 2 + (outDeg[n.name] || 0); if ((inDeg[n.name] || 0) > 0 && sc > hubScore) { hubScore = sc; hub = n.name; } }
601
+
602
+ // attach display metadata to each node
603
+ for (const r of rows) for (const it of r) {
604
+ const id = it.name, di = inDeg[id] || 0, dout = outDeg[id] || 0;
605
+ it.isHub = id === hub;
606
+ it.isEntry = di === 0 && dout > 0;
607
+ it.isLeaf = dout === 0 && di > 0;
608
+ const sub = it.isEntry
609
+ ? ['entry', dout ? `uses ${dout}` : '', symAnn(it)].filter(Boolean).join(' · ')
610
+ : [it.isLeaf ? 'shared' : null, plur(di, 'dependent'), symAnn(it)].filter(Boolean).join(' · ');
611
+ // display name drops a shared @scope/ prefix (keeps the distinguishing part legible); name stays canonical
612
+ it.label = id.replace(/^@[^/]+\//, ''); it.sub = sub || symAnn(it) || '';
613
+ }
614
+
615
+ const extNames = Array.isArray(dg.externalDepNames) ? dg.externalDepNames.slice(0, 5) : [];
616
+ const ext = extNames.length ? { names: extNames, count: dg.externalDepCount ?? extNames.length } : null;
617
+ return { rows, edges, ext, hub, trimmed };
618
+ }
619
+
620
+ // ── PROCESS / DATA-FLOW model from the entrypoints: each stage carries the artifact it consumes and
621
+ // produces (derived from the detected ecosystem), so the diagram shows DATA MOVING, not a command list ─
622
+ function ecoOf(dg) {
623
+ const e = (Array.isArray(dg.ecosystems) ? dg.ecosystems : []).map((s) => String(s).toLowerCase());
624
+ if (e.includes('node') || e.includes('npm')) return 'node';
625
+ if (e.includes('rust') || e.includes('cargo')) return 'rust';
626
+ if (e.includes('python') || e.includes('pip')) return 'python';
627
+ return 'generic';
628
+ }
629
+
630
+ function artifactModel(eco) {
631
+ const M = {
632
+ node: {
633
+ source: 'package.json + src/', sourceLabel: 'repo source',
634
+ install: { verb: 'resolve + download dependencies', in: 'package.json + lock', out: 'node_modules/' },
635
+ build: { verb: 'compile + bundle the source', in: 'src/ + node_modules/', out: 'dist/ bundle' },
636
+ run: { verb: 'execute the entry point', in: 'dist/ + CLI args', out: 'program output' },
637
+ verify: { verb: 'run the test suite', in: 'dist/ + test specs', out: 'pass / fail report' },
638
+ },
639
+ rust: {
640
+ source: 'Cargo.toml + src/', sourceLabel: 'repo source',
641
+ install: { verb: 'resolve the crate graph', in: 'Cargo.toml + lock', out: 'cargo cache' },
642
+ build: { verb: 'compile the workspace', in: 'src/ + crates', out: 'target/release/' },
643
+ run: { verb: 'execute the binary', in: 'target/ + args', out: 'program output' },
644
+ verify: { verb: 'run cargo test', in: 'crates + tests', out: 'pass / fail report' },
645
+ },
646
+ python: {
647
+ source: 'pyproject + pkg/', sourceLabel: 'repo source',
648
+ install: { verb: 'install dependencies', in: 'pyproject + lock', out: 'site-packages/' },
649
+ build: { verb: 'build the package', in: 'pkg/ source', out: 'wheel / dist' },
650
+ run: { verb: 'run the entry point', in: 'pkg + CLI args', out: 'program output' },
651
+ verify: { verb: 'run the test suite', in: 'pkg + tests', out: 'pass / fail report' },
652
+ },
653
+ generic: {
654
+ source: 'source tree', sourceLabel: 'repo source',
655
+ install: { verb: 'install dependencies', in: 'manifest', out: 'dependencies' },
656
+ build: { verb: 'build the artifacts', in: 'source', out: 'build output' },
657
+ run: { verb: 'run the program', in: 'build + args', out: 'program output' },
658
+ verify: { verb: 'run the tests', in: 'tests', out: 'pass / fail' },
659
+ },
660
+ };
661
+ return M[eco] || M.generic;
662
+ }
663
+
664
+ function buildFlowModel(ep, dg) {
665
+ const A = artifactModel(ecoOf(dg));
666
+ const pick = (cat) => { const c = (Array.isArray(ep.commands) ? ep.commands : []).find((x) => x && x.category === cat); return c ? c.cmd : null; };
667
+ const binNames = (Array.isArray(ep.binaries) ? ep.binaries : []).map((b) => b && b.name).filter(Boolean);
668
+ const installCmd = (Array.isArray(ep.install) && ep.install[0]) || pick('install');
669
+ const buildCmd = pick('build');
670
+ const runCmd = (binNames[0] && `${binNames[0]} ...`) || pick('run') || (Array.isArray(ep.quickstart) && ep.quickstart[0]) || null;
671
+ const testCmd = pick('test');
672
+ const steps = [];
673
+ if (installCmd) steps.push({ name: 'INSTALL', ...A.install, cmd: installCmd });
674
+ if (buildCmd) steps.push({ name: 'BUILD', ...A.build, cmd: buildCmd });
675
+ if (runCmd) steps.push({ name: 'RUN', ...A.run, cmd: runCmd });
676
+ if (testCmd) steps.push({ name: 'VERIFY', ...A.verify, cmd: testCmd });
677
+ if (!steps.length && Array.isArray(ep.quickstart) && ep.quickstart[0]) steps.push({ name: 'RUN', ...A.run, cmd: ep.quickstart[0] });
678
+ if (steps.length < 2) steps.push({ name: 'RESULT', verb: 'produces the entry artifact', in: steps[0] ? steps[0].out : 'build', out: binNames.length ? `bin: ${binNames.slice(0, 2).join(', ')}` : 'output', cmd: binNames[0] || 'run' });
679
+ const result = binNames.length ? `${binNames.slice(0, 2).join(', ')} ready` : steps[steps.length - 1].out;
680
+ return { steps, source: A.source, sourceLabel: A.sourceLabel, result };
681
+ }
682
+
683
+ // ── BIG-IDEA / INSIGHT: parse brain ASCII into colour-cycled chip rows + arrows ───────────────────
684
+ function asciiRows(ascii) {
685
+ const lines = String(ascii).replace(/\r\n?/g, '\n').split('\n').map((l) => l.trim()).filter(Boolean);
686
+ if (!lines.length) return { title: 'Diagram', rows: [] };
687
+ const title = lines[0];
688
+ const body = lines.slice(1).length ? lines.slice(1) : lines;
689
+ const rows = body.map((line) => {
690
+ const parts = line.split(/\s*(?:->|→|=>|\|>)\s*/).map((p) => p.replace(/^[[(<{]+|[\])>}]+$/g, '').trim()).filter(Boolean);
691
+ if (parts.length > 1) return { items: parts.map((p, i) => ({ label: p, colorIdx: i })), connectWithin: true };
692
+ return { items: [{ label: line.replace(/^[[(<{]+|[\])>}]+$/g, '').trim() || line }] };
693
+ });
694
+ return { title, rows };
695
+ }
696
+
697
+ const DIAGRAMS = [
698
+ { key: 'architectureDiagram', file: 'architecture.svg', title: 'Architecture', grounded: 'architecture' },
699
+ { key: 'flowDiagram', file: 'flow.svg', title: 'Process / Data Flow', grounded: 'flow' },
700
+ { key: 'bigIdeaDiagram', file: 'big-idea.svg', title: 'Big Idea', grounded: null },
701
+ { key: 'insightDiagram', file: 'insight.svg', title: 'The Insight', grounded: null },
702
+ ];
703
+
704
+ function defaultAltText(spec, dg, ep, name, fallbackDesc, archModel) {
705
+ if (spec.grounded === 'architecture') {
706
+ const ecos = (Array.isArray(dg.ecosystems) ? dg.ecosystems : []).join('/') || 'one ecosystem';
707
+ const hub = archModel && archModel.hub ? `, with ${archModel.hub} as the core module the most others depend on` : '';
708
+ return `${name} module dependency map: ${dg.componentCount ?? (Array.isArray(dg.nodes) ? dg.nodes.length : 0)} components across ${ecos} wired by ${dg.internalEdgeCount ?? 0} internal dependencies, drawn as a layered graph where each arrow points from a module to what it depends on (top entry points down to shared foundation libraries)${hub}.`;
709
+ }
710
+ if (spec.grounded === 'flow') {
711
+ const bins = (Array.isArray(ep.binaries) ? ep.binaries : []).map((b) => b && b.name).filter(Boolean).slice(0, 3).join(', ');
712
+ return `${name} data-flow pipeline: the repo source flows through install (→ dependencies), build (→ compiled artifacts), run the entry point${bins ? ` (${bins})` : ''}, and verify (→ pass/fail), with each stage's input and output artifact labelled so you can see what data changes at every step.`;
713
+ }
714
+ return fallbackDesc || `${spec.title} diagram for ${name}`;
715
+ }
716
+
717
+ function main() {
718
+ const argv = process.argv.slice(2);
719
+ if (argv.length !== 1 || !argv[0]) die('usage: node tools/make-diagrams.mjs <build-dir>');
720
+ const buildDir = path.resolve(argv[0]);
721
+ if (!fs.existsSync(buildDir) || !fs.statSync(buildDir).isDirectory()) die(`build directory does not exist: ${buildDir}`);
722
+
723
+ const buildJsonPath = path.join(buildDir, 'build.json');
724
+ if (!fs.existsSync(buildJsonPath)) die(`build.json not found in build dir: ${buildJsonPath}`);
725
+ const buildJson = loadJson(buildJsonPath, 'build.json');
726
+
727
+ const kb = buildJson.kb;
728
+ if (!kb || typeof kb !== 'object') die("build.json is missing the 'kb' slot (Station 1 must run before Station 4)");
729
+
730
+ const dgPath = resolveKbPath(kb.depGraphPath, buildDir);
731
+ if (!dgPath) die(`architecture diagram cannot be produced: kb.depGraphPath not found (${kb.depGraphPath ?? 'unset'}) — refusing to invent module structure`);
732
+ const dg = loadJson(dgPath, 'dep-graph');
733
+ if (!Array.isArray(dg.nodes) || dg.nodes.length === 0) die(`architecture diagram cannot be produced: dep-graph has no nodes (${dgPath})`);
734
+
735
+ const epPath = resolveKbPath(kb.entrypointsPath, buildDir);
736
+ if (!epPath) die(`flow diagram cannot be produced: kb.entrypointsPath not found (${kb.entrypointsPath ?? 'unset'}) — refusing to invent runtime flow`);
737
+ const ep = loadJson(epPath, 'entrypoints');
738
+ const hasFlow = (Array.isArray(ep.install) && ep.install.length) || (Array.isArray(ep.commands) && ep.commands.length)
739
+ || (Array.isArray(ep.binaries) && ep.binaries.length) || (Array.isArray(ep.quickstart) && ep.quickstart.length);
740
+ // A pure library (e.g. a Rust crate workspace) has no runtime entrypoints — skip the flow diagram rather than
741
+ // crash the build or invent a fake flow. The architecture diagram (grounded in the real dep-graph) still ships.
742
+ const skipFlow = !hasFlow;
743
+ if (skipFlow) warn(`no runtime entrypoints (install/commands/binaries/quickstart) in ${epPath} — library repo; skipping flow diagram, architecture diagram still produced`);
744
+
745
+ const symPath = resolveKbPath(kb.symbolsPath, buildDir);
746
+ let sym = null;
747
+ if (symPath) sym = loadJson(symPath, 'symbols');
748
+ else warn(`kb.symbolsPath not found (${kb.symbolsPath ?? 'unset'}) — architecture diagram will omit symbol counts`);
749
+
750
+ const name = (buildJson.understanding && buildJson.understanding.repoName)
751
+ || (buildJson.repo && buildJson.repo.name) || dg.metaName || ep.metaName || dg.target || 'this repo';
752
+
753
+ PAL = resolvePalette(buildJson.concept);
754
+ process.stderr.write(`${TOOL}: palette accents ${PAL.accents.slice(0, 3).join(', ')}${buildJson.concept && buildJson.concept.palette ? ' (themed from concept.palette)' : ' (vivid default)'} — dark/glow renderer\n`);
755
+
756
+ const assetsDir = path.join(buildDir, 'assets');
757
+ fs.mkdirSync(assetsDir, { recursive: true });
758
+
759
+ const visualsIn = (buildJson.visuals && typeof buildJson.visuals === 'object') ? buildJson.visuals : {};
760
+ const merged = {};
761
+
762
+ // captions (mono, lowercase, reference-style)
763
+ const ecos = (Array.isArray(dg.ecosystems) ? dg.ecosystems : []).join(' · ') || 'modules';
764
+ const archModel = buildArchModel(dg, sym);
765
+ const flowModel = skipFlow ? null : buildFlowModel(ep, dg);
766
+ const totalModules = dg.componentCount ?? dg.nodes.length;
767
+ const archCaption = `${totalModules} modules · ${dg.internalEdgeCount ?? archModel.edges.length} internal links · ${ecos}`
768
+ + (archModel.trimmed ? ` · showing the ${totalModules - archModel.trimmed} most-connected` : '');
769
+ const flowCaption = flowModel ? `${flowModel.steps.length} stages · derived from the project's ${ecos} entrypoints` : null;
770
+
771
+ for (const spec of DIAGRAMS) {
772
+ if (spec.grounded === 'flow' && skipFlow) continue; // library repo: no runtime flow to draw
773
+ const existing = (visualsIn[spec.key] && typeof visualsIn[spec.key] === 'object') ? visualsIn[spec.key] : {};
774
+ let rendered, asciiSrc = null, conceptBack = null;
775
+ if (spec.grounded === 'architecture') {
776
+ rendered = renderArchitecture(`${name.toUpperCase()} · DEPENDENCY MAP`, 'Module dependency map', archModel, archCaption, PAL);
777
+ } else if (spec.grounded === 'flow') {
778
+ rendered = renderFlow(`${name.toUpperCase()} · PIPELINE`, 'Data-flow pipeline', flowModel, flowCaption, PAL);
779
+ } else {
780
+ // big-idea / insight: DRAW real glass concept-cards from a structured rows model (renderConcept) —
781
+ // never typeset ASCII. Prefer the brain's structured .rows; otherwise parse a legacy .ascii source
782
+ // into the SAME model so older builds still render as real cards, not a picture of ASCII.
783
+ let rows = null;
784
+ if (Array.isArray(existing.rows) && existing.rows.length) {
785
+ rows = existing.rows.map((r) => ({
786
+ items: (Array.isArray(r.items) ? r.items : [])
787
+ .map((it, i) => (typeof it === 'string' ? { label: it, colorIdx: i } : { label: it && it.label, colorIdx: (it && it.colorIdx != null) ? it.colorIdx : i }))
788
+ .filter((it) => it.label && String(it.label).trim()),
789
+ connectWithin: r && r.connect !== false,
790
+ })).filter((r) => r.items.length);
791
+ }
792
+ if (!rows || !rows.length) {
793
+ const ascii = (typeof existing.ascii === 'string' && existing.ascii.trim()) ? existing.ascii
794
+ : (typeof existing.asciiFallback === 'string' && existing.asciiFallback.trim()) ? existing.asciiFallback : null;
795
+ if (!ascii) die(`missing structure for ${spec.key}: ${spec.title} needs visuals.${spec.key}.rows (preferred) or a legacy .ascii source — the brain must author it`);
796
+ asciiSrc = ascii;
797
+ rows = asciiRows(ascii).rows;
798
+ }
799
+ if (!rows.length) die(`could not build a concept model for ${spec.key}: no usable rows/items`);
800
+ const eyebrow = spec.key === 'bigIdeaDiagram' ? 'THE BIG IDEA' : 'THE INSIGHT';
801
+ const heading = (typeof existing.title === 'string' && existing.title.trim()) ? existing.title.trim()
802
+ : (spec.key === 'bigIdeaDiagram' ? 'How it all fits together' : 'The clever move');
803
+ // the brain's altText is the one-line TAKEAWAY — render it as the caption so the diagram tells a story
804
+ const cap = (typeof existing.altText === 'string' && existing.altText.trim()) ? existing.altText.trim() : null;
805
+ rendered = renderConcept(eyebrow, heading, rows, cap, PAL);
806
+ // round-trip the structured source + heading so re-running this station (e.g. a refine loop) redraws
807
+ // identically WITHOUT a fresh brain call — and never reverts to the generic title.
808
+ conceptBack = { rows: rows.map((r) => ({ items: r.items.map((it) => it.label), connect: r.connectWithin })), title: heading };
809
+ // textual fallback (accessibility / AI) — synthesize from the structured rows when there is no ASCII
810
+ if (!asciiSrc) asciiSrc = rows.map((r) => r.items.map((it) => it.label).join(r.connectWithin ? ' -> ' : ' · ')).join('\n');
811
+ }
812
+ const altText = (typeof existing.altText === 'string' && existing.altText.trim()) ? existing.altText : defaultAltText(spec, dg, ep, name, rendered.desc, archModel);
813
+ const svg = wrapSvg(rendered.W, rendered.H, rendered.body, `${name} — ${spec.title}`, altText, asciiSrc || rendered.desc);
814
+ const svgPath = path.join(assetsDir, spec.file);
815
+ fs.writeFileSync(svgPath, svg, 'utf8');
816
+ assertXmllintClean(svgPath, spec.key);
817
+ merged[spec.key] = { svgPath, altText, asciiFallback: asciiSrc || rendered.desc, format: 'svg-vector-dark', xmllintOK: true, ...(conceptBack || {}) };
818
+ }
819
+
820
+ buildJson.visuals = { ...visualsIn, ...merged };
821
+ fs.writeFileSync(buildJsonPath, JSON.stringify(buildJson, null, 2) + '\n', 'utf8');
822
+
823
+ const producedKeys = DIAGRAMS.map((d) => d.key).filter((k) => merged[k]);
824
+ const outputs = {
825
+ slot: 'visuals',
826
+ mergedKeys: producedKeys,
827
+ svgPaths: Object.fromEntries(producedKeys.map((k) => [k, merged[k].svgPath])),
828
+ groundedIn: { architecture: dgPath, flow: skipFlow ? null : epPath, symbols: symPath || null },
829
+ renderer: 'dark / glassmorphic / glowing (layered isometric)',
830
+ };
831
+ process.stdout.write(JSON.stringify({ ok: true, outputs, error: null }) + '\n');
832
+ process.exit(0);
833
+ }
834
+
835
+ main();