@laitszkin/apollo-toolkit 3.10.0 → 3.11.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  3. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  4. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  5. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  6. package/generate-spec/SKILL.md +17 -15
  7. package/generate-spec/agents/openai.yaml +1 -1
  8. package/generate-spec/references/TEMPLATE_SPEC.md +103 -84
  9. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  10. package/init-project-html/SKILL.md +117 -125
  11. package/init-project-html/agents/openai.yaml +18 -9
  12. package/init-project-html/lib/atlas/assets/architecture.css +161 -0
  13. package/init-project-html/lib/atlas/assets/viewer.client.js +136 -0
  14. package/init-project-html/lib/atlas/cli.js +1023 -0
  15. package/init-project-html/lib/atlas/layout.js +330 -0
  16. package/init-project-html/lib/atlas/render.js +583 -0
  17. package/init-project-html/lib/atlas/schema.js +347 -0
  18. package/init-project-html/lib/atlas/state.js +402 -0
  19. package/init-project-html/references/TEMPLATE_SPEC.md +140 -83
  20. package/init-project-html/sample-demo/resources/project-architecture/assets/architecture.css +160 -1058
  21. package/init-project-html/sample-demo/resources/project-architecture/assets/viewer.client.js +136 -0
  22. package/init-project-html/sample-demo/resources/project-architecture/atlas/atlas.index.yaml +34 -0
  23. package/init-project-html/sample-demo/resources/project-architecture/atlas/features/get-invite-codes.yaml +172 -0
  24. package/init-project-html/sample-demo/resources/project-architecture/atlas/features/invite-code-registration.yaml +160 -0
  25. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/index.html +67 -52
  26. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-code-generator.html +64 -163
  27. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-issuance-service.html +102 -196
  28. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/postgresql.html +82 -163
  29. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/public-api.html +88 -150
  30. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/web-get-invite-ui.html +83 -138
  31. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/index.html +61 -51
  32. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/postgresql.html +84 -159
  33. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/public-api.html +81 -143
  34. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/registration-service.html +98 -188
  35. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/web-register-ui.html +83 -138
  36. package/init-project-html/sample-demo/resources/project-architecture/index.html +256 -335
  37. package/init-project-html/scripts/architecture.js +65 -247
  38. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  39. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  40. package/package.json +6 -2
  41. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  42. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  43. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  44. package/spec-to-project-html/SKILL.md +74 -67
  45. package/spec-to-project-html/agents/openai.yaml +14 -8
  46. package/spec-to-project-html/references/TEMPLATE_SPEC.md +98 -83
  47. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
@@ -0,0 +1,583 @@
1
+ 'use strict';
2
+
3
+ // render.js — declarative atlas → HTML/SVG.
4
+ //
5
+ // Three page types:
6
+ // 1. Macro `index.html` (atlas-summary + atlas SVG with clusters + cross-feature edges + submodule index)
7
+ // 2. Feature `features/<slug>/index.html` (feature story + sub-module navigation)
8
+ // 3. Sub-module `features/<slug>/<sub>.html` (sub-io + sub-vars + sub-dataflow + sub-errors)
9
+ //
10
+ // Render output is deterministic so tests can snapshot it. Assets
11
+ // (architecture.css + viewer.client.js) are copied to <outDir>/assets/.
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const { layoutMacro, measureSubmodule } = require('./layout');
17
+
18
+ const KIND_LABEL = {
19
+ ui: 'UI',
20
+ api: 'API',
21
+ service: 'Service',
22
+ db: 'DB',
23
+ 'pure-fn': 'Pure fn',
24
+ queue: 'Queue',
25
+ external: 'External',
26
+ };
27
+
28
+ function htmlEscape(value) {
29
+ return String(value == null ? '' : value)
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&#39;');
35
+ }
36
+
37
+ function relAssetPath(fromPagePath, outDir) {
38
+ const fromDir = path.dirname(fromPagePath);
39
+ const rel = path.relative(fromDir, path.join(outDir, 'assets'));
40
+ return (rel === '' ? '.' : rel).split(path.sep).join('/');
41
+ }
42
+
43
+ function pagePathFor(kind, { featureSlug, submoduleSlug } = {}) {
44
+ if (kind === 'macro') return 'index.html';
45
+ if (kind === 'feature') return `features/${featureSlug}/index.html`;
46
+ if (kind === 'submodule') return `features/${featureSlug}/${submoduleSlug}.html`;
47
+ throw new Error(`unknown page kind: ${kind}`);
48
+ }
49
+
50
+ function head({ title, assetRel, pageKind }) {
51
+ return [
52
+ '<!DOCTYPE html>',
53
+ `<html lang="en" data-atlas-page="${pageKind}">`,
54
+ '<head>',
55
+ ' <meta charset="utf-8">',
56
+ ` <title>${htmlEscape(title)}</title>`,
57
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">',
58
+ ` <link rel="stylesheet" href="${assetRel}/architecture.css">`,
59
+ '</head>',
60
+ ].join('\n');
61
+ }
62
+
63
+ function renderEdgePath(edge) {
64
+ const segments = [];
65
+ for (const section of edge.sections || []) {
66
+ const pts = [section.startPoint, ...(section.bendPoints || []), section.endPoint];
67
+ if (pts.length === 0) continue;
68
+ segments.push(pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' '));
69
+ }
70
+ return segments.join(' ');
71
+ }
72
+
73
+ function edgeKindFor(stateEdge) {
74
+ return stateEdge && stateEdge.kind ? stateEdge.kind : 'call';
75
+ }
76
+
77
+ function findEdgeMeta(state, edgeId) {
78
+ for (const feature of state.features || []) {
79
+ for (const e of feature.edges || []) {
80
+ if (e.id === edgeId) return e;
81
+ }
82
+ }
83
+ for (const e of state.edges || []) {
84
+ if (e.id === edgeId) return e;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function renderMacroSvg(layout, state) {
90
+ if (layout.empty) {
91
+ return '<svg class="atlas-svg" viewBox="0 0 320 160" role="img" aria-label="Atlas is empty"><text x="160" y="80" text-anchor="middle" fill="currentColor">Atlas has no features yet</text></svg>';
92
+ }
93
+ const pad = 24;
94
+ const vbW = Math.max(320, Math.ceil(layout.width + pad * 2));
95
+ const vbH = Math.max(160, Math.ceil(layout.height + pad * 2));
96
+ const parts = [];
97
+ parts.push(`<svg class="atlas-svg" viewBox="0 0 ${vbW} ${vbH}" role="img" aria-label="Project architecture atlas" data-atlas-svg="macro">`);
98
+ parts.push(' <defs>');
99
+ for (const kind of ['call', 'return', 'data-row', 'failure']) {
100
+ parts.push(` <marker id="arrow-${kind}" class="m-arrow m-arrow--${kind}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 Z" /></marker>`);
101
+ }
102
+ parts.push(' </defs>');
103
+ parts.push(` <g transform="translate(${pad},${pad})">`);
104
+
105
+ for (const feat of layout.features) {
106
+ parts.push(` <g class="m-cluster" data-feature="${htmlEscape(feat.slug)}">`);
107
+ parts.push(` <rect class="m-cluster__bg" x="${feat.x.toFixed(2)}" y="${feat.y.toFixed(2)}" width="${feat.width.toFixed(2)}" height="${feat.height.toFixed(2)}" rx="14" ry="14" />`);
108
+ const titleX = feat.x + feat.width / 2;
109
+ const titleY = feat.y + 26;
110
+ const featureState = (state.features || []).find((f) => f.slug === feat.slug);
111
+ const title = (featureState && featureState.title) || feat.slug;
112
+ parts.push(` <text class="m-cluster__title" x="${titleX.toFixed(2)}" y="${titleY.toFixed(2)}" text-anchor="middle">${htmlEscape(title)}</text>`);
113
+ parts.push(' </g>');
114
+ }
115
+
116
+ for (const sub of layout.submodules) {
117
+ const subState = ((state.features || []).find((f) => f.slug === sub.featureSlug) || {}).submodules || [];
118
+ const meta = subState.find((s) => s.slug === sub.slug) || {};
119
+ const kind = meta.kind || 'service';
120
+ const role = meta.role || '';
121
+ const measured = measureSubmodule({ slug: sub.slug, kind, role });
122
+
123
+ const cx = sub.x + sub.width / 2;
124
+ const titleY = sub.y + 14 + 16; // SUB_TOP_PAD (14) + ascent for the title line
125
+ const kindY = titleY + 4 + 12; // KIND_GAP + kind ascent
126
+ const roleStartY = kindY + 8 + 12; // ROLE_GAP + first role line ascent
127
+
128
+ const href = `features/${sub.featureSlug}/${sub.slug}.html`;
129
+ const tooltip = role ? `${sub.slug} — ${role}` : sub.slug;
130
+ parts.push(` <a class="m-node m-node--${kind}" href="${htmlEscape(href)}" data-feature="${htmlEscape(sub.featureSlug)}" data-submodule="${htmlEscape(sub.slug)}" tabindex="0" aria-label="${htmlEscape(tooltip)} — open sub-module page">`);
131
+ parts.push(` <title>${htmlEscape(tooltip)}</title>`);
132
+ parts.push(` <rect x="${sub.x.toFixed(2)}" y="${sub.y.toFixed(2)}" width="${sub.width.toFixed(2)}" height="${sub.height.toFixed(2)}" rx="10" ry="10" />`);
133
+ parts.push(` <text class="m-node__title" x="${cx.toFixed(2)}" y="${titleY.toFixed(2)}" text-anchor="middle">${htmlEscape(sub.slug)}</text>`);
134
+ parts.push(` <text class="m-node__kind" x="${cx.toFixed(2)}" y="${kindY.toFixed(2)}" text-anchor="middle">${htmlEscape(measured.kindLabel || KIND_LABEL[kind] || kind)}</text>`);
135
+ measured.roleLines.forEach((line, idx) => {
136
+ const ly = roleStartY + idx * 16;
137
+ parts.push(` <text class="m-node__role" x="${cx.toFixed(2)}" y="${ly.toFixed(2)}" text-anchor="middle">${htmlEscape(line)}</text>`);
138
+ });
139
+ parts.push(' </a>');
140
+ }
141
+
142
+ for (const edge of layout.edges) {
143
+ const meta = findEdgeMeta(state, edge.id);
144
+ const kind = edgeKindFor(meta);
145
+ const d = renderEdgePath(edge);
146
+ if (!d) continue;
147
+ parts.push(` <g class="m-edge m-edge--${kind}" data-edge="${htmlEscape(edge.id)}">`);
148
+ parts.push(` <path d="${d}" fill="none" marker-end="url(#arrow-${kind})" />`);
149
+ for (const label of edge.labels || []) {
150
+ if (!label.text) continue;
151
+ const lx = label.x + (label.width || 0) / 2;
152
+ const ly = label.y + (label.height || 0) / 2 + 4;
153
+ parts.push(` <text class="m-edge__label" x="${lx.toFixed(2)}" y="${ly.toFixed(2)}" text-anchor="middle">${htmlEscape(label.text)}</text>`);
154
+ }
155
+ parts.push(' </g>');
156
+ }
157
+
158
+ parts.push(' </g>');
159
+ parts.push('</svg>');
160
+ return parts.join('\n');
161
+ }
162
+
163
+ function renderAtlasSubmoduleIndex(state) {
164
+ const items = [];
165
+ for (const feature of state.features || []) {
166
+ for (const sub of feature.submodules || []) {
167
+ items.push({
168
+ feature: feature.slug,
169
+ featureTitle: feature.title || feature.slug,
170
+ sub: sub.slug,
171
+ kind: sub.kind,
172
+ role: sub.role,
173
+ });
174
+ }
175
+ }
176
+ if (items.length === 0) return '';
177
+ const rows = items.map((it) => ` <li class="atlas-submodule-index__item">
178
+ <a href="features/${htmlEscape(it.feature)}/${htmlEscape(it.sub)}.html">
179
+ <span class="atlas-submodule-index__feature">${htmlEscape(it.featureTitle)}</span>
180
+ <span class="atlas-submodule-index__sub">${htmlEscape(it.sub)}</span>
181
+ <span class="atlas-submodule-index__kind atlas-submodule-index__kind--${htmlEscape(it.kind)}">${htmlEscape(KIND_LABEL[it.kind] || it.kind)}</span>
182
+ </a>
183
+ ${it.role ? `<p class="atlas-submodule-index__role">${htmlEscape(it.role)}</p>` : ''}
184
+ </li>`).join('\n');
185
+ return ` <ul class="atlas-submodule-index">
186
+ ${rows}
187
+ </ul>`;
188
+ }
189
+
190
+ function renderMacro({ state, layout, outDir }) {
191
+ const pageRel = pagePathFor('macro');
192
+ const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
193
+ const svg = renderMacroSvg(layout, state);
194
+ const title = (state.meta && state.meta.title) || 'Project architecture';
195
+ const summary = (state.meta && state.meta.summary) || '';
196
+ const submoduleIndex = renderAtlasSubmoduleIndex(state);
197
+
198
+ const body = `<body>
199
+ <header class="atlas-header">
200
+ <h1>${htmlEscape(title)}</h1>
201
+ ${summary ? `<p class="atlas-summary">${htmlEscape(summary)}</p>` : ''}
202
+ </header>
203
+ <main class="atlas-main">
204
+ <section class="atlas-canvas" aria-label="Macro architecture diagram">
205
+ <div class="atlas-canvas__toolbar" role="toolbar" aria-label="Diagram controls">
206
+ <button type="button" data-pan-zoom="zoom-in" aria-label="Zoom in">+</button>
207
+ <button type="button" data-pan-zoom="zoom-out" aria-label="Zoom out">−</button>
208
+ <button type="button" data-pan-zoom="fit" aria-label="Reset view">Fit</button>
209
+ </div>
210
+ <div class="atlas-canvas__viewport" data-pan-zoom-viewport>
211
+ ${svg}
212
+ </div>
213
+ <ol class="atlas-legend" aria-label="Edge legend">
214
+ <li><span class="legend-swatch legend-swatch--call"></span>call</li>
215
+ <li><span class="legend-swatch legend-swatch--return"></span>return</li>
216
+ <li><span class="legend-swatch legend-swatch--data-row"></span>data-row</li>
217
+ <li><span class="legend-swatch legend-swatch--failure"></span>failure</li>
218
+ </ol>
219
+ </section>
220
+ <section class="atlas-index" aria-label="Submodule index">
221
+ <h2>Submodule index</h2>
222
+ ${submoduleIndex}
223
+ </section>
224
+ </main>
225
+ <script src="${assetRel}/viewer.client.js" defer></script>
226
+ </body>
227
+ </html>`;
228
+
229
+ return `${head({ title, assetRel, pageKind: 'macro' })}\n${body}\n`;
230
+ }
231
+
232
+ function renderSubmoduleCard(featureSlug, sub) {
233
+ const kindLabel = KIND_LABEL[sub.kind] || sub.kind;
234
+ const link = `${sub.slug}.html`;
235
+ return ` <li class="submodule-card">
236
+ <a class="submodule-card__link" href="${htmlEscape(link)}">
237
+ <span class="submodule-card__name">${htmlEscape(sub.slug)}</span>
238
+ <span class="submodule-card__kind submodule-card__kind--${htmlEscape(sub.kind)}">${htmlEscape(kindLabel)}</span>
239
+ </a>
240
+ ${sub.role ? `<p class="submodule-card__role">${htmlEscape(sub.role)}</p>` : ''}
241
+ </li>`;
242
+ }
243
+
244
+ function renderFeaturePage({ feature, outDir }) {
245
+ const pageRel = pagePathFor('feature', { featureSlug: feature.slug });
246
+ const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
247
+ const title = feature.title || feature.slug;
248
+ const subNav = (feature.submodules || []).map((s) => renderSubmoduleCard(feature.slug, s)).join('\n');
249
+ const dependsOn = Array.isArray(feature.dependsOn) ? feature.dependsOn : [];
250
+ const dependsList = dependsOn.length > 0
251
+ ? `<p class="feature-depends">Depends on: ${dependsOn.map((d) => `<a href="../${htmlEscape(d)}/index.html">${htmlEscape(d)}</a>`).join(', ')}</p>`
252
+ : '';
253
+ const intraEdges = (feature.edges || []).filter((e) => e.kind && e.label);
254
+
255
+ const body = `<body>
256
+ <header class="feature-header">
257
+ <nav class="feature-breadcrumb"><a href="../../index.html">← Atlas</a></nav>
258
+ <h1>${htmlEscape(title)}</h1>
259
+ ${dependsList}
260
+ </header>
261
+ <main class="feature-main">
262
+ ${feature.story ? `<section class="feature-story"><p>${htmlEscape(feature.story)}</p></section>` : ''}
263
+ <section class="feature-submodules" aria-label="Submodules">
264
+ <h2>Submodules</h2>
265
+ <ul class="submodule-nav">
266
+ ${subNav}
267
+ </ul>
268
+ </section>
269
+ ${intraEdges.length > 0 ? `<section class="feature-edges" aria-label="Intra-feature edges">
270
+ <h2>Intra-feature edges</h2>
271
+ <ul class="feature-edges__list">
272
+ ${intraEdges.map((e) => {
273
+ const from = typeof e.from === 'string' ? e.from : (e.from && e.from.submodule);
274
+ const to = typeof e.to === 'string' ? e.to : (e.to && e.to.submodule);
275
+ return ` <li class="feature-edges__item feature-edges__item--${htmlEscape(e.kind)}"><span class="feature-edges__endpoints">${htmlEscape(from)} → ${htmlEscape(to)}</span><span class="feature-edges__kind">${htmlEscape(e.kind)}</span><span class="feature-edges__label">${htmlEscape(e.label || '')}</span></li>`;
276
+ }).join('\n')}
277
+ </ul>
278
+ </section>` : ''}
279
+ </main>
280
+ </body>
281
+ </html>`;
282
+
283
+ return `${head({ title, assetRel, pageKind: 'feature' })}\n${body}\n`;
284
+ }
285
+
286
+ function renderSubmoduleTable(headers, rows) {
287
+ return `<table class="sub-table">
288
+ <thead><tr>${headers.map((h) => `<th scope="col">${htmlEscape(h)}</th>`).join('')}</tr></thead>
289
+ <tbody>
290
+ ${rows.map((r) => ` <tr>${r.map((c) => `<td>${htmlEscape(c == null ? '' : c)}</td>`).join('')}</tr>`).join('\n')}
291
+ </tbody>
292
+ </table>`;
293
+ }
294
+
295
+ function normalizeDataflowStep(item) {
296
+ if (typeof item === 'string') return { step: item, fn: '', reads: [], writes: [] };
297
+ if (!item || typeof item !== 'object') return { step: '', fn: '', reads: [], writes: [] };
298
+ return {
299
+ step: typeof item.step === 'string' ? item.step : '',
300
+ fn: typeof item.fn === 'string' ? item.fn.trim() : '',
301
+ reads: Array.isArray(item.reads) ? item.reads.filter((v) => typeof v === 'string' && v.trim()) : [],
302
+ writes: Array.isArray(item.writes) ? item.writes.filter((v) => typeof v === 'string' && v.trim()) : [],
303
+ };
304
+ }
305
+
306
+ function renderInternalDataflowSvg(steps) {
307
+ if (!steps || steps.length === 0) {
308
+ return '<p class="sub-dataflow__empty">No internal dataflow steps recorded.</p>';
309
+ }
310
+
311
+ // Each step renders as a box with three optional zones: a top fn pill
312
+ // (which function executes this step), the step description in the
313
+ // middle, and a bottom row of variable chips (← reads / → writes).
314
+ // The surrounding viewport handles zoom/pan, so we size boxes to the
315
+ // content rather than the viewport.
316
+ const boxW = 520;
317
+ const lineHeight = 20;
318
+ const innerPadY = 18;
319
+ const fnRowH = 32; // fn pill + spacing
320
+ const chipsRowH = 26; // chips row + spacing
321
+ const minBoxH = 72;
322
+ const gap = 44;
323
+ const padLeft = 80; // room for the left-side step-number badge
324
+ const padTop = 32;
325
+ const padBottom = 32;
326
+ const padRight = 28;
327
+
328
+ const normalized = steps.map(normalizeDataflowStep);
329
+ const layouts = normalized.map((s) => {
330
+ const lines = wrapText(s.step, 60);
331
+ const hasFn = s.fn.length > 0;
332
+ const hasChips = s.reads.length > 0 || s.writes.length > 0;
333
+ const textBlockH = lines.length * lineHeight;
334
+ const boxH = Math.max(minBoxH, innerPadY * 2 + (hasFn ? fnRowH : 0) + textBlockH + (hasChips ? chipsRowH : 0));
335
+ return { lines, hasFn, hasChips, boxH };
336
+ });
337
+
338
+ const totalH = padTop + layouts.reduce((a, l) => a + l.boxH, 0) + (normalized.length - 1) * gap + padBottom;
339
+ const totalW = padLeft + boxW + padRight;
340
+
341
+ const parts = [];
342
+ parts.push(`<svg class="sub-dataflow__svg" data-atlas-svg="sub-dataflow" viewBox="0 0 ${totalW} ${totalH}" role="img" aria-label="Internal dataflow">`);
343
+ parts.push(' <defs>');
344
+ parts.push(' <marker id="sub-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 Z" /></marker>');
345
+ parts.push(' </defs>');
346
+
347
+ let cursorY = padTop;
348
+ normalized.forEach((s, i) => {
349
+ const layout = layouts[i];
350
+ const boxX = padLeft;
351
+ const boxY = cursorY;
352
+ const boxH = layout.boxH;
353
+ const badgeCx = padLeft - 38;
354
+ const badgeCy = boxY + boxH / 2;
355
+
356
+ parts.push(' <g class="sub-dataflow__step">');
357
+ parts.push(` <circle class="sub-dataflow__badge" cx="${badgeCx}" cy="${badgeCy}" r="18" />`);
358
+ parts.push(` <text class="sub-dataflow__badge-text" x="${badgeCx}" y="${badgeCy + 5}" text-anchor="middle">${i + 1}</text>`);
359
+ parts.push(` <rect class="sub-dataflow__box" x="${boxX}" y="${boxY}" width="${boxW}" height="${boxH}" rx="14" ry="14" />`);
360
+
361
+ if (layout.hasFn) {
362
+ const fnLabel = `fn ${s.fn}`;
363
+ const pillX = boxX + 14;
364
+ const pillY = boxY + 14;
365
+ const pillW = Math.max(72, fnLabel.length * 7.4 + 20);
366
+ parts.push(` <rect class="sub-dataflow__fn-bg" x="${pillX}" y="${pillY}" width="${pillW}" height="20" rx="10" ry="10" />`);
367
+ parts.push(` <text class="sub-dataflow__fn-text" x="${pillX + 10}" y="${pillY + 14}">${htmlEscape(fnLabel)}</text>`);
368
+ }
369
+
370
+ const topUsed = layout.hasFn ? fnRowH : 0;
371
+ const bottomUsed = layout.hasChips ? chipsRowH : 0;
372
+ const textZoneH = boxH - topUsed - bottomUsed;
373
+ const textBlockH = layout.lines.length * lineHeight;
374
+ const textStartY = boxY + topUsed + (textZoneH - textBlockH) / 2 + lineHeight - 4;
375
+ layout.lines.forEach((line, idx) => {
376
+ parts.push(` <text class="sub-dataflow__text" x="${boxX + boxW / 2}" y="${textStartY + idx * lineHeight}" text-anchor="middle">${htmlEscape(line)}</text>`);
377
+ });
378
+
379
+ if (layout.hasChips) {
380
+ const chipY = boxY + boxH - 12;
381
+ if (s.reads.length > 0) {
382
+ const text = `← reads: ${s.reads.join(', ')}`;
383
+ parts.push(` <text class="sub-dataflow__chip sub-dataflow__chip--reads" x="${boxX + 14}" y="${chipY}">${htmlEscape(text)}</text>`);
384
+ }
385
+ if (s.writes.length > 0) {
386
+ const text = `→ writes: ${s.writes.join(', ')}`;
387
+ parts.push(` <text class="sub-dataflow__chip sub-dataflow__chip--writes" x="${boxX + boxW - 14}" y="${chipY}" text-anchor="end">${htmlEscape(text)}</text>`);
388
+ }
389
+ }
390
+
391
+ parts.push(' </g>');
392
+
393
+ if (i < normalized.length - 1) {
394
+ const aY = boxY + boxH + 6;
395
+ const bY = aY + gap - 14;
396
+ const x = boxX + boxW / 2;
397
+ parts.push(` <line class="sub-dataflow__arrow" x1="${x}" y1="${aY}" x2="${x}" y2="${bY}" marker-end="url(#sub-arrow)" />`);
398
+ }
399
+ cursorY += boxH + gap;
400
+ });
401
+ parts.push('</svg>');
402
+ return parts.join('\n');
403
+ }
404
+
405
+ function wrapText(text, maxChars) {
406
+ if (!text) return [''];
407
+ const words = String(text).split(/\s+/);
408
+ const lines = [];
409
+ let current = '';
410
+ for (const word of words) {
411
+ if (!current) { current = word; continue; }
412
+ if ((current.length + 1 + word.length) <= maxChars) current = `${current} ${word}`;
413
+ else { lines.push(current); current = word; }
414
+ }
415
+ if (current) lines.push(current);
416
+ // Allow up to 4 lines so long error/rollback notes stay readable; the
417
+ // surrounding viewport handles scroll/zoom for anything beyond.
418
+ return lines.slice(0, 4);
419
+ }
420
+
421
+ function renderSubmodulePage({ feature, sub, outDir }) {
422
+ const pageRel = pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug });
423
+ const assetRel = relAssetPath(path.join(outDir, pageRel), outDir);
424
+ const title = `${feature.title || feature.slug} · ${sub.slug}`;
425
+ const ioRows = (sub.functions || []).map((fn) => [fn.name, fn.in || '', fn.out || '', fn.side || '', fn.purpose || '']);
426
+ const varRows = (sub.variables || []).map((v) => [v.name, v.type || '', v.scope || '', v.purpose || '']);
427
+ const errRows = (sub.errors || []).map((e) => [e.name, e.when || '', e.means || '']);
428
+
429
+ const body = `<body>
430
+ <header class="submodule-header">
431
+ <nav class="submodule-breadcrumb"><a href="../../index.html">← Atlas</a> · <a href="index.html">← ${htmlEscape(feature.title || feature.slug)}</a></nav>
432
+ <h1>${htmlEscape(sub.slug)} <small class="submodule-kind submodule-kind--${htmlEscape(sub.kind)}">${htmlEscape(KIND_LABEL[sub.kind] || sub.kind)}</small></h1>
433
+ ${sub.role ? `<p class="submodule-role">${htmlEscape(sub.role)}</p>` : ''}
434
+ </header>
435
+ <main class="submodule-main">
436
+ <section class="sub-io" aria-label="Function I/O">
437
+ <h2>Function I/O</h2>
438
+ ${ioRows.length > 0
439
+ ? renderSubmoduleTable(['Name', 'In', 'Out', 'Side', 'Purpose'], ioRows)
440
+ : '<p class="sub-section__empty">No functions recorded.</p>'}
441
+ </section>
442
+ <section class="sub-vars" aria-label="Variables">
443
+ <h2>Variables</h2>
444
+ ${varRows.length > 0
445
+ ? renderSubmoduleTable(['Name', 'Type', 'Scope', 'Purpose'], varRows)
446
+ : '<p class="sub-section__empty">No variables recorded.</p>'}
447
+ </section>
448
+ <section class="sub-dataflow" aria-label="Internal data flow">
449
+ <h2>Internal data flow</h2>
450
+ ${(sub.dataflow && sub.dataflow.length > 0)
451
+ ? `<div class="sub-dataflow__canvas" data-pan-zoom-container>
452
+ <div class="sub-dataflow__toolbar" role="toolbar" aria-label="Diagram controls">
453
+ <button type="button" data-pan-zoom="zoom-in" aria-label="Zoom in">+</button>
454
+ <button type="button" data-pan-zoom="zoom-out" aria-label="Zoom out">−</button>
455
+ <button type="button" data-pan-zoom="fit" aria-label="Reset view">Fit</button>
456
+ </div>
457
+ <div class="sub-dataflow__viewport" data-pan-zoom-viewport>
458
+ ${renderInternalDataflowSvg(sub.dataflow)}
459
+ </div>
460
+ </div>`
461
+ : renderInternalDataflowSvg(sub.dataflow)}
462
+ </section>
463
+ <section class="sub-errors" aria-label="Errors">
464
+ <h2>Errors</h2>
465
+ ${errRows.length > 0
466
+ ? renderSubmoduleTable(['Name', 'When', 'Means'], errRows)
467
+ : '<p class="sub-section__empty">No errors recorded.</p>'}
468
+ </section>
469
+ </main>
470
+ <script src="${assetRel}/viewer.client.js" defer></script>
471
+ </body>
472
+ </html>`;
473
+
474
+ return `${head({ title, assetRel, pageKind: 'submodule' })}\n${body}\n`;
475
+ }
476
+
477
+ function copyAssets(outDir) {
478
+ const assetsDir = path.join(outDir, 'assets');
479
+ fs.mkdirSync(assetsDir, { recursive: true });
480
+ const srcCss = path.join(__dirname, 'assets', 'architecture.css');
481
+ const srcJs = path.join(__dirname, 'assets', 'viewer.client.js');
482
+ fs.copyFileSync(srcCss, path.join(assetsDir, 'architecture.css'));
483
+ fs.copyFileSync(srcJs, path.join(assetsDir, 'viewer.client.js'));
484
+ }
485
+
486
+ // renderAll({outDir, state, scope?}) writes every page for the
487
+ // resolved state. When scope is provided, only the listed pages are
488
+ // emitted; this is how spec mode generates the proposed-after subset.
489
+ async function renderAll({ outDir, state, scope = null, removedPaths = [] }) {
490
+ fs.mkdirSync(outDir, { recursive: true });
491
+ copyAssets(outDir);
492
+
493
+ const layout = await layoutMacro(state);
494
+
495
+ const shouldEmit = (kind, slug, subSlug) => {
496
+ if (!scope) return true;
497
+ if (kind === 'macro') return scope.macro === true;
498
+ if (kind === 'feature') return scope.features && scope.features.has(slug);
499
+ if (kind === 'submodule') {
500
+ return (scope.submodules || []).some((s) => s.feature === slug && s.submodule === subSlug);
501
+ }
502
+ return false;
503
+ };
504
+
505
+ const written = [];
506
+
507
+ if (shouldEmit('macro')) {
508
+ const html = renderMacro({ state, layout, outDir });
509
+ const file = path.join(outDir, pagePathFor('macro'));
510
+ fs.mkdirSync(path.dirname(file), { recursive: true });
511
+ fs.writeFileSync(file, html, 'utf8');
512
+ written.push(pagePathFor('macro'));
513
+ }
514
+
515
+ for (const feature of state.features || []) {
516
+ if (shouldEmit('feature', feature.slug)) {
517
+ const html = renderFeaturePage({ feature, outDir });
518
+ const file = path.join(outDir, pagePathFor('feature', { featureSlug: feature.slug }));
519
+ fs.mkdirSync(path.dirname(file), { recursive: true });
520
+ fs.writeFileSync(file, html, 'utf8');
521
+ written.push(pagePathFor('feature', { featureSlug: feature.slug }));
522
+ }
523
+ for (const sub of feature.submodules || []) {
524
+ if (shouldEmit('submodule', feature.slug, sub.slug)) {
525
+ const html = renderSubmodulePage({ feature, sub, outDir });
526
+ const file = path.join(outDir, pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug }));
527
+ fs.mkdirSync(path.dirname(file), { recursive: true });
528
+ fs.writeFileSync(file, html, 'utf8');
529
+ written.push(pagePathFor('submodule', { featureSlug: feature.slug, submoduleSlug: sub.slug }));
530
+ }
531
+ }
532
+ }
533
+
534
+ if (removedPaths && removedPaths.length > 0) {
535
+ const lines = ['# Pages removed by this spec. Used by `apltk architecture diff`.', ...removedPaths];
536
+ fs.writeFileSync(path.join(outDir, '_removed.txt'), `${lines.join('\n')}\n`, 'utf8');
537
+ } else {
538
+ const file = path.join(outDir, '_removed.txt');
539
+ if (fs.existsSync(file)) fs.rmSync(file);
540
+ }
541
+
542
+ return { written, layout };
543
+ }
544
+
545
+ function scopeFromDiff(diff) {
546
+ const submodules = [];
547
+ for (const item of diff.modifiedSubmodules || []) submodules.push(item);
548
+ for (const item of diff.addedSubmodules || []) submodules.push(item);
549
+ const features = new Set([...(diff.modifiedFeatures || []), ...(diff.addedFeatures || [])]);
550
+ // If only a submodule changed but its feature isn't otherwise modified,
551
+ // the feature index page does not need to re-emit. The submodule page
552
+ // itself still needs the feature title/role for breadcrumb, which is
553
+ // pulled from state at render time, so omitting the feature page is safe.
554
+ return {
555
+ macro: diff.macroChanged === true,
556
+ features,
557
+ submodules,
558
+ };
559
+ }
560
+
561
+ function removedPagePathsFromDiff(diff) {
562
+ const paths = [];
563
+ for (const slug of diff.removedFeatures || []) {
564
+ paths.push(pagePathFor('feature', { featureSlug: slug }));
565
+ }
566
+ for (const item of diff.removedSubmodules || []) {
567
+ paths.push(pagePathFor('submodule', { featureSlug: item.feature, submoduleSlug: item.submodule }));
568
+ }
569
+ return paths;
570
+ }
571
+
572
+ module.exports = {
573
+ KIND_LABEL,
574
+ htmlEscape,
575
+ pagePathFor,
576
+ renderAll,
577
+ renderMacro,
578
+ renderFeaturePage,
579
+ renderSubmodulePage,
580
+ copyAssets,
581
+ scopeFromDiff,
582
+ removedPagePathsFromDiff,
583
+ };