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.
- package/README.md +165 -0
- package/assets/design-system/design-system.css +833 -0
- package/assets/design-system/theme-example.css +83 -0
- package/bin/explainmyrepo.mjs +115 -0
- package/kb/ask-kb.mjs +1487 -0
- package/kb/build-kb.mjs +353 -0
- package/kb/corpus-rules.mjs +341 -0
- package/kb/dep-graph.mjs +184 -0
- package/kb/entrypoints.mjs +207 -0
- package/kb/extract-symbols.mjs +322 -0
- package/kb/index-primer.mjs +255 -0
- package/kb/kb-mcp-server.mjs +186 -0
- package/kb/kb.config.mjs +1362 -0
- package/kb/make-dropin.mjs +224 -0
- package/kb/resolve-deps.mjs +126 -0
- package/package.json +52 -0
- package/src/brain.mjs +298 -0
- package/src/build-context.mjs +66 -0
- package/src/claude.mjs +97 -0
- package/src/env.mjs +77 -0
- package/src/orchestrator.mjs +419 -0
- package/src/run-tool.mjs +49 -0
- package/tools/CONTRACT.md +301 -0
- package/tools/assemble-page.mjs +631 -0
- package/tools/build-kb.mjs +159 -0
- package/tools/clone-repo.mjs +161 -0
- package/tools/deploy.mjs +160 -0
- package/tools/generate-image.mjs +280 -0
- package/tools/make-diagrams.mjs +835 -0
- package/tools/make-favicon.mjs +145 -0
- package/tools/make-pack.mjs +295 -0
- package/tools/make-social-card.mjs +198 -0
- package/tools/notify.mjs +327 -0
- package/tools/publish-repo.mjs +156 -0
- package/tools/quality-grade.mjs +746 -0
- package/tools/readme-enhance.mjs +310 -0
- package/tools/repo-seo.mjs +143 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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,"Segoe UI",Roboto,sans-serif';
|
|
101
|
+
const FM = 'ui-monospace,SFMono-Regular,"SF Mono",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, ']]>')}\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();
|