@laitszkin/apollo-toolkit 3.11.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.
- package/CHANGELOG.md +17 -0
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/init-project-html/SKILL.md +56 -20
- package/init-project-html/agents/openai.yaml +6 -6
- package/init-project-html/lib/atlas/assets/architecture.css +27 -6
- package/init-project-html/lib/atlas/assets/viewer.client.js +124 -81
- package/init-project-html/lib/atlas/cli.js +31 -3
- package/init-project-html/lib/atlas/layout.js +112 -11
- package/init-project-html/lib/atlas/render.js +131 -33
- package/init-project-html/lib/atlas/schema.js +39 -2
- package/init-project-html/references/TEMPLATE_SPEC.md +26 -8
- package/init-project-html/sample-demo/resources/project-architecture/assets/architecture.css +27 -6
- package/init-project-html/sample-demo/resources/project-architecture/assets/viewer.client.js +124 -81
- package/init-project-html/sample-demo/resources/project-architecture/atlas/features/get-invite-codes.yaml +17 -4
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-code-generator.html +23 -7
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-issuance-service.html +45 -13
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/postgresql.html +28 -10
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/public-api.html +33 -13
- package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/web-get-invite-ui.html +28 -10
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/postgresql.html +28 -10
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/public-api.html +28 -10
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/registration-service.html +38 -17
- package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/web-register-ui.html +29 -11
- package/init-project-html/sample-demo/resources/project-architecture/index.html +100 -76
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/package.json +1 -1
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/spec-to-project-html/SKILL.md +25 -16
- package/spec-to-project-html/agents/openai.yaml +5 -5
- package/spec-to-project-html/references/TEMPLATE_SPEC.md +2 -0
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
|
@@ -11,18 +11,111 @@
|
|
|
11
11
|
|
|
12
12
|
const ELK = require('elkjs');
|
|
13
13
|
|
|
14
|
+
// Default fallback box. The actual width/height for each sub-module
|
|
15
|
+
// is computed per node by measureSubmodule() so the role/description
|
|
16
|
+
// fits without overflowing the rectangle.
|
|
14
17
|
const SUB_WIDTH = 240;
|
|
15
18
|
const SUB_HEIGHT = 92;
|
|
19
|
+
|
|
20
|
+
// Box-sizing knobs (intrinsic SVG coordinates).
|
|
21
|
+
const SUB_WIDTH_MIN = 220;
|
|
22
|
+
const SUB_WIDTH_MAX = 360;
|
|
23
|
+
const SUB_HEIGHT_MIN = 92;
|
|
24
|
+
const SUB_HEIGHT_MAX = 220;
|
|
25
|
+
const SUB_SIDE_PAD = 16;
|
|
26
|
+
const SUB_TOP_PAD = 14;
|
|
27
|
+
const SUB_BOTTOM_PAD = 14;
|
|
28
|
+
const TITLE_LINE = 22; // slug line
|
|
29
|
+
const KIND_LINE = 16; // kind chip line
|
|
30
|
+
const ROLE_LINE = 16; // each role line
|
|
31
|
+
const KIND_GAP = 4;
|
|
32
|
+
const ROLE_GAP = 8;
|
|
33
|
+
const MAX_ROLE_LINES = 4;
|
|
34
|
+
|
|
16
35
|
const CLUSTER_PAD_TOP = 60;
|
|
17
36
|
const CLUSTER_PAD_SIDE = 24;
|
|
18
37
|
const CLUSTER_PAD_BOTTOM = 28;
|
|
19
38
|
const EDGE_LABEL_HEIGHT = 18;
|
|
20
39
|
|
|
40
|
+
const KIND_LABELS = {
|
|
41
|
+
ui: 'UI',
|
|
42
|
+
api: 'API',
|
|
43
|
+
service: 'service',
|
|
44
|
+
db: 'database',
|
|
45
|
+
'pure-fn': 'pure function',
|
|
46
|
+
queue: 'queue',
|
|
47
|
+
external: 'external',
|
|
48
|
+
};
|
|
49
|
+
|
|
21
50
|
function estimateLabelWidth(text) {
|
|
22
51
|
if (!text) return 0;
|
|
23
52
|
return Math.min(220, Math.max(40, String(text).length * 7 + 16));
|
|
24
53
|
}
|
|
25
54
|
|
|
55
|
+
// Approximate render width of a string in the target font. The 0.6
|
|
56
|
+
// factor is a deliberate over-estimate for our sans-serif stack so
|
|
57
|
+
// the chosen width almost always leaves a little breathing room.
|
|
58
|
+
function approxTextWidth(text, fontPx) {
|
|
59
|
+
return String(text || '').length * fontPx * 0.6;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function wrapToLines(text, maxChars) {
|
|
63
|
+
if (!text) return [];
|
|
64
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
65
|
+
const lines = [];
|
|
66
|
+
let current = '';
|
|
67
|
+
for (const word of words) {
|
|
68
|
+
if (!current) { current = word; continue; }
|
|
69
|
+
if ((current.length + 1 + word.length) <= maxChars) current = `${current} ${word}`;
|
|
70
|
+
else { lines.push(current); current = word; }
|
|
71
|
+
}
|
|
72
|
+
if (current) lines.push(current);
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// measureSubmodule picks a width + height that fit the slug, the kind
|
|
77
|
+
// chip, and the wrapped role text. Both layout.js (when telling elkjs
|
|
78
|
+
// how much room each node needs) and render.js (when actually drawing
|
|
79
|
+
// the text inside the box) call it so the rendered text never spills
|
|
80
|
+
// outside the rectangle the layout engine reserved.
|
|
81
|
+
function measureSubmodule(sub) {
|
|
82
|
+
const slug = (sub && sub.slug) || '';
|
|
83
|
+
const kindLabel = KIND_LABELS[sub && sub.kind] || (sub && sub.kind) || 'service';
|
|
84
|
+
const role = (sub && sub.role) || '';
|
|
85
|
+
|
|
86
|
+
const slugW = approxTextWidth(slug, 14);
|
|
87
|
+
const kindW = approxTextWidth(kindLabel, 11);
|
|
88
|
+
const baseInner = Math.max(slugW, kindW);
|
|
89
|
+
|
|
90
|
+
// Aim to keep the role within ~3 lines: choose a width whose text
|
|
91
|
+
// area can hold ceil(roleLen / 3) characters, then clamp.
|
|
92
|
+
const roleLen = role.length;
|
|
93
|
+
let chosenInner;
|
|
94
|
+
if (!role) {
|
|
95
|
+
chosenInner = baseInner;
|
|
96
|
+
} else {
|
|
97
|
+
const targetCharsPerLine = Math.max(20, Math.ceil(roleLen / 3));
|
|
98
|
+
chosenInner = Math.max(baseInner, targetCharsPerLine * 11 * 0.55);
|
|
99
|
+
}
|
|
100
|
+
const width = Math.max(SUB_WIDTH_MIN, Math.min(SUB_WIDTH_MAX, Math.ceil(chosenInner + SUB_SIDE_PAD * 2)));
|
|
101
|
+
|
|
102
|
+
// With the chosen width fixed, wrap the role for real and count lines.
|
|
103
|
+
const innerW = width - SUB_SIDE_PAD * 2;
|
|
104
|
+
const charsPerLine = Math.max(12, Math.floor(innerW / (11 * 0.55)));
|
|
105
|
+
let roleLines = role ? wrapToLines(role, charsPerLine) : [];
|
|
106
|
+
if (roleLines.length > MAX_ROLE_LINES) {
|
|
107
|
+
roleLines = roleLines.slice(0, MAX_ROLE_LINES);
|
|
108
|
+
const last = roleLines[MAX_ROLE_LINES - 1];
|
|
109
|
+
roleLines[MAX_ROLE_LINES - 1] = last.length > 3 ? `${last.slice(0, -1)}…` : `${last}…`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const roleBlock = roleLines.length > 0 ? ROLE_GAP + roleLines.length * ROLE_LINE : 0;
|
|
113
|
+
const intrinsicH = SUB_TOP_PAD + TITLE_LINE + KIND_GAP + KIND_LINE + roleBlock + SUB_BOTTOM_PAD;
|
|
114
|
+
const height = Math.max(SUB_HEIGHT_MIN, Math.min(SUB_HEIGHT_MAX, Math.ceil(intrinsicH)));
|
|
115
|
+
|
|
116
|
+
return { width, height, roleLines, kindLabel };
|
|
117
|
+
}
|
|
118
|
+
|
|
26
119
|
function endpointId(endpoint, ownerFeature) {
|
|
27
120
|
if (typeof endpoint === 'string') {
|
|
28
121
|
return `submodule::${ownerFeature}::${endpoint}`;
|
|
@@ -53,17 +146,20 @@ function buildGraph(state) {
|
|
|
53
146
|
'elk.layered.spacing.nodeNodeBetweenLayers': '36',
|
|
54
147
|
'elk.nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]',
|
|
55
148
|
},
|
|
56
|
-
children: (feature.submodules || []).map((sub) =>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
149
|
+
children: (feature.submodules || []).map((sub) => {
|
|
150
|
+
const box = measureSubmodule(sub);
|
|
151
|
+
return {
|
|
152
|
+
id: `submodule::${feature.slug}::${sub.slug}`,
|
|
153
|
+
width: box.width,
|
|
154
|
+
height: box.height,
|
|
155
|
+
labels: [{
|
|
156
|
+
id: `submodule::${feature.slug}::${sub.slug}::label`,
|
|
157
|
+
text: sub.slug,
|
|
158
|
+
width: estimateLabelWidth(sub.slug),
|
|
159
|
+
height: 18,
|
|
160
|
+
}],
|
|
161
|
+
};
|
|
162
|
+
}),
|
|
67
163
|
}));
|
|
68
164
|
|
|
69
165
|
let nextEdgeId = 0;
|
|
@@ -223,7 +319,12 @@ async function layoutMacro(state) {
|
|
|
223
319
|
module.exports = {
|
|
224
320
|
SUB_WIDTH,
|
|
225
321
|
SUB_HEIGHT,
|
|
322
|
+
SUB_WIDTH_MIN,
|
|
323
|
+
SUB_WIDTH_MAX,
|
|
324
|
+
SUB_HEIGHT_MIN,
|
|
325
|
+
SUB_HEIGHT_MAX,
|
|
226
326
|
layoutMacro,
|
|
227
327
|
assertNoOverlap,
|
|
228
328
|
buildGraph,
|
|
329
|
+
measureSubmodule,
|
|
229
330
|
};
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const fs = require('node:fs');
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
|
|
16
|
-
const { layoutMacro } = require('./layout');
|
|
16
|
+
const { layoutMacro, measureSubmodule } = require('./layout');
|
|
17
17
|
|
|
18
18
|
const KIND_LABEL = {
|
|
19
19
|
ui: 'UI',
|
|
@@ -115,21 +115,27 @@ function renderMacroSvg(layout, state) {
|
|
|
115
115
|
|
|
116
116
|
for (const sub of layout.submodules) {
|
|
117
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 =
|
|
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
|
+
|
|
120
123
|
const cx = sub.x + sub.width / 2;
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
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
|
+
|
|
124
128
|
const href = `features/${sub.featureSlug}/${sub.slug}.html`;
|
|
125
|
-
|
|
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>`);
|
|
126
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" />`);
|
|
127
|
-
parts.push(` <text class="m-node__title" x="${cx.toFixed(2)}" y="${
|
|
128
|
-
parts.push(` <text class="m-node__kind" x="${cx.toFixed(2)}" y="${
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
parts.push(` <text class="m-node__role" x="${cx.toFixed(2)}" y="${
|
|
132
|
-
}
|
|
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
|
+
});
|
|
133
139
|
parts.push(' </a>');
|
|
134
140
|
}
|
|
135
141
|
|
|
@@ -286,33 +292,111 @@ ${rows.map((r) => ` <tr>${r.map((c) => `<td>${htmlEscape(c == null ? ''
|
|
|
286
292
|
</table>`;
|
|
287
293
|
}
|
|
288
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
|
+
|
|
289
306
|
function renderInternalDataflowSvg(steps) {
|
|
290
307
|
if (!steps || steps.length === 0) {
|
|
291
308
|
return '<p class="sub-dataflow__empty">No internal dataflow steps recorded.</p>';
|
|
292
309
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
+
|
|
297
341
|
const parts = [];
|
|
298
|
-
parts.push(`<svg class="sub-dataflow__svg" viewBox="0 0 ${
|
|
299
|
-
parts.push(' <defs
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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>`);
|
|
308
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
|
+
|
|
309
391
|
parts.push(' </g>');
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
const
|
|
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;
|
|
314
397
|
parts.push(` <line class="sub-dataflow__arrow" x1="${x}" y1="${aY}" x2="${x}" y2="${bY}" marker-end="url(#sub-arrow)" />`);
|
|
315
398
|
}
|
|
399
|
+
cursorY += boxH + gap;
|
|
316
400
|
});
|
|
317
401
|
parts.push('</svg>');
|
|
318
402
|
return parts.join('\n');
|
|
@@ -329,7 +413,9 @@ function wrapText(text, maxChars) {
|
|
|
329
413
|
else { lines.push(current); current = word; }
|
|
330
414
|
}
|
|
331
415
|
if (current) lines.push(current);
|
|
332
|
-
|
|
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);
|
|
333
419
|
}
|
|
334
420
|
|
|
335
421
|
function renderSubmodulePage({ feature, sub, outDir }) {
|
|
@@ -361,7 +447,18 @@ function renderSubmodulePage({ feature, sub, outDir }) {
|
|
|
361
447
|
</section>
|
|
362
448
|
<section class="sub-dataflow" aria-label="Internal data flow">
|
|
363
449
|
<h2>Internal data flow</h2>
|
|
364
|
-
${
|
|
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)}
|
|
365
462
|
</section>
|
|
366
463
|
<section class="sub-errors" aria-label="Errors">
|
|
367
464
|
<h2>Errors</h2>
|
|
@@ -370,6 +467,7 @@ function renderSubmodulePage({ feature, sub, outDir }) {
|
|
|
370
467
|
: '<p class="sub-section__empty">No errors recorded.</p>'}
|
|
371
468
|
</section>
|
|
372
469
|
</main>
|
|
470
|
+
<script src="${assetRel}/viewer.client.js" defer></script>
|
|
373
471
|
</body>
|
|
374
472
|
</html>`;
|
|
375
473
|
|
|
@@ -128,10 +128,47 @@ function validateSubmodule(sub, errors, where) {
|
|
|
128
128
|
}
|
|
129
129
|
if (sub && sub.dataflow) {
|
|
130
130
|
if (!Array.isArray(sub.dataflow)) {
|
|
131
|
-
errors.push(`${where}: "dataflow" must be an array
|
|
131
|
+
errors.push(`${where}: "dataflow" must be an array`);
|
|
132
132
|
} else {
|
|
133
|
+
const fnNames = new Set((sub.functions || []).map((f) => f && f.name).filter(Boolean));
|
|
134
|
+
const varNames = new Set((sub.variables || []).map((v) => v && v.name).filter(Boolean));
|
|
133
135
|
sub.dataflow.forEach((step, i) => {
|
|
134
|
-
|
|
136
|
+
const stepWhere = `${where}.dataflow[${i}]`;
|
|
137
|
+
if (typeof step === 'string') {
|
|
138
|
+
if (!step.trim()) errors.push(`${stepWhere}: step text must be non-empty`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!step || typeof step !== 'object') {
|
|
142
|
+
errors.push(`${stepWhere}: must be a string or an object with "step"`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!isNonEmptyString(step.step)) {
|
|
146
|
+
errors.push(`${stepWhere}: "step" must be a non-empty string`);
|
|
147
|
+
}
|
|
148
|
+
if (step.fn !== undefined) {
|
|
149
|
+
if (typeof step.fn !== 'string' || !step.fn.trim()) {
|
|
150
|
+
errors.push(`${stepWhere}: "fn" must be a non-empty string when present`);
|
|
151
|
+
} else if (!fnNames.has(step.fn)) {
|
|
152
|
+
errors.push(`${stepWhere}: "fn" references unknown function "${step.fn}" — declare it via \`function add\` first`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const field of ['reads', 'writes']) {
|
|
156
|
+
if (step[field] === undefined) continue;
|
|
157
|
+
if (!Array.isArray(step[field])) {
|
|
158
|
+
errors.push(`${stepWhere}: "${field}" must be an array of variable names`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
step[field].forEach((name, j) => {
|
|
162
|
+
const refWhere = `${stepWhere}.${field}[${j}]`;
|
|
163
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
164
|
+
errors.push(`${refWhere}: variable name must be a non-empty string`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!varNames.has(name)) {
|
|
168
|
+
errors.push(`${refWhere}: unknown variable "${name}" — declare it via \`variable add\` first`);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
135
172
|
});
|
|
136
173
|
}
|
|
137
174
|
}
|
|
@@ -60,7 +60,7 @@ CLI: `apltk architecture feature add --slug <kebab> --title "..." --story "..."
|
|
|
60
60
|
| role | string | no | Own responsibility in one sentence. Renders as macro node footnote + feature card subtitle. |
|
|
61
61
|
| functions | array of function row | no | Renders the `sub-io` table. |
|
|
62
62
|
| variables | array of variable row | no | Renders the `sub-vars` table. |
|
|
63
|
-
| dataflow | array of string
|
|
63
|
+
| dataflow | array of dataflow step (string OR object — see below) | no | Renders the `sub-dataflow` internal flow SVG. |
|
|
64
64
|
| errors | array of error row | no | Renders the `sub-errors` table. |
|
|
65
65
|
|
|
66
66
|
CLI: `apltk architecture submodule add --feature X --slug Y --kind api --role "..."`
|
|
@@ -90,9 +90,22 @@ CLI: `apltk architecture variable add --feature X --submodule Y --name v --type
|
|
|
90
90
|
|
|
91
91
|
### `dataflow` step
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
Each step is either a plain string OR an object with one required and three optional fields. The renderer arranges them top-to-bottom inside a pan/zoom viewport; `--fn` becomes a function pill at the top of the step box, `--reads` becomes a green chip on the bottom-left, `--writes` becomes an orange chip on the bottom-right.
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
| Field | Type | Required | Notes |
|
|
96
|
+
| ------- | ---- | -------- | ----- |
|
|
97
|
+
| step | string | yes | The action / observation in one short sentence. |
|
|
98
|
+
| fn | string | no | Name of a `function` already declared in the SAME sub-module. `validate` fails otherwise. Use it to surface function-to-function transitions inside the sub-module. |
|
|
99
|
+
| reads | array of variable names | no | Each name must be a `variable` declared in the SAME sub-module. Shows up as `← reads: …` chip. |
|
|
100
|
+
| writes | array of variable names | no | Same constraint as `reads`. Shows up as `→ writes: …` chip. |
|
|
101
|
+
|
|
102
|
+
Appended at the tail by default; pass `--at N` to insert at index N. Reorder with `apltk architecture dataflow reorder --feature X --submodule Y --from i --to j`. `--reads` / `--writes` accept comma-separated lists (`--reads "a, b"`).
|
|
103
|
+
|
|
104
|
+
CLI:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
apltk architecture dataflow add --feature X --submodule Y --step "..." [--fn <declared-fn>] [--reads "v1,v2"] [--writes "v3,v4"]
|
|
108
|
+
```
|
|
96
109
|
|
|
97
110
|
### `error` row
|
|
98
111
|
|
|
@@ -125,13 +138,18 @@ These are emitted automatically by `lib/atlas/render.js`. Agents do **not** writ
|
|
|
125
138
|
| `.atlas-header`, `.atlas-summary`, `.atlas-canvas`, `.atlas-submodule-index`, `.atlas-legend` | macro |
|
|
126
139
|
| `.m-cluster`, `.m-cluster__title`, `.m-node`, `.m-node--<kind>`, `.m-node__title/__kind/__role`, `.m-edge`, `.m-edge--<kind>`, `.m-edge__label` | macro SVG |
|
|
127
140
|
| `.feature-header`, `.feature-story`, `.submodule-nav`, `.submodule-card`, `.feature-edges` | feature page |
|
|
128
|
-
| `.submodule-header`, `.submodule-role`, `.sub-io`, `.sub-vars`, `.sub-dataflow`, `.sub-errors`, `.submodule-kind--<kind>` | sub-module page |
|
|
141
|
+
| `.submodule-header`, `.submodule-role`, `.sub-io`, `.sub-vars`, `.sub-dataflow`, `.sub-dataflow__canvas`, `.sub-dataflow__toolbar`, `.sub-dataflow__viewport`, `.sub-dataflow__step`, `.sub-dataflow__badge`, `.sub-dataflow__fn-text`, `.sub-dataflow__chip--reads`, `.sub-dataflow__chip--writes`, `.sub-errors`, `.submodule-kind--<kind>` | sub-module page |
|
|
142
|
+
|
|
143
|
+
## Pan/zoom
|
|
144
|
+
|
|
145
|
+
The CLI copies `assets/viewer.client.js` into the atlas. Two viewports exist:
|
|
129
146
|
|
|
130
|
-
|
|
147
|
+
- macro `index.html` — one `[data-pan-zoom-viewport]` wrapping the atlas SVG.
|
|
148
|
+
- each sub-module page — one `[data-pan-zoom-viewport]` wrapping the sub-dataflow SVG (when the sub-module has any `dataflow` step).
|
|
131
149
|
|
|
132
|
-
|
|
150
|
+
For every viewport the script wires:
|
|
133
151
|
|
|
134
152
|
- mouse wheel zoom around the cursor,
|
|
135
153
|
- click + drag to pan,
|
|
136
|
-
- `+` / `−` / `Fit` buttons on the toolbar,
|
|
137
|
-
- keyboard `←` `→` `↑` `↓` (pan), `+` `=` (zoom in), `−` `_` (zoom out), `0` (reset).
|
|
154
|
+
- `+` / `−` / `Fit` buttons on the toolbar (scoped to the surrounding `[data-pan-zoom-container]`),
|
|
155
|
+
- keyboard `←` `→` `↑` `↓` (pan), `+` `=` (zoom in), `−` `_` (zoom out), `0` (reset) — applied to the page's primary viewport.
|
package/init-project-html/sample-demo/resources/project-architecture/assets/architecture.css
CHANGED
|
@@ -74,11 +74,17 @@ p { line-height: 1.55; color: var(--text); }
|
|
|
74
74
|
/* ---- SVG macro ---- */
|
|
75
75
|
.m-cluster__bg { fill: rgba(15, 23, 42, 0.55); stroke: var(--border); stroke-width: 1; }
|
|
76
76
|
.m-cluster__title { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 14px; fill: var(--text); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
77
|
-
.m-node
|
|
78
|
-
.m-node
|
|
77
|
+
.m-node { cursor: pointer; }
|
|
78
|
+
.m-node rect { fill: var(--panel-soft); stroke: var(--border); stroke-width: 1; transition: stroke 120ms ease, fill 120ms ease; }
|
|
79
|
+
.m-node:hover rect,
|
|
80
|
+
.m-node:focus rect,
|
|
81
|
+
.m-node:focus-visible rect { stroke: var(--accent); stroke-width: 1.6; fill: rgba(56, 189, 248, 0.08); }
|
|
82
|
+
.m-node:focus { outline: none; }
|
|
79
83
|
.m-node__title { font-size: 13px; font-weight: 600; fill: var(--text); }
|
|
80
84
|
.m-node__kind { font-size: 11px; fill: var(--muted); }
|
|
81
85
|
.m-node__role { font-size: 11px; fill: var(--muted); }
|
|
86
|
+
.m-node:hover .m-node__title,
|
|
87
|
+
.m-node:focus .m-node__title { fill: var(--accent); }
|
|
82
88
|
|
|
83
89
|
.m-node--ui rect { stroke: var(--kind-ui); }
|
|
84
90
|
.m-node--api rect { stroke: var(--kind-api); }
|
|
@@ -133,8 +139,23 @@ p { line-height: 1.55; color: var(--text); }
|
|
|
133
139
|
|
|
134
140
|
.sub-section__empty { color: var(--muted); font-style: italic; font-size: 13px; }
|
|
135
141
|
|
|
136
|
-
.sub-
|
|
137
|
-
.sub-
|
|
138
|
-
.sub-
|
|
139
|
-
.sub-
|
|
142
|
+
.sub-dataflow__canvas { position: relative; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
|
|
143
|
+
.sub-dataflow__toolbar { position: absolute; top: 16px; right: 16px; display: flex; gap: 4px; z-index: 2; }
|
|
144
|
+
.sub-dataflow__toolbar button { background: var(--panel-soft); color: var(--text); border: 1px solid var(--border); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
145
|
+
.sub-dataflow__toolbar button:hover { border-color: var(--accent); color: var(--accent); }
|
|
146
|
+
.sub-dataflow__viewport { width: 100%; max-height: 60vh; overflow: hidden; border-radius: 8px; background: #0b1220; }
|
|
147
|
+
.sub-dataflow__viewport.is-grabbing { cursor: grabbing; }
|
|
148
|
+
.sub-dataflow__viewport:not(.is-grabbing) { cursor: grab; }
|
|
149
|
+
.sub-dataflow__svg { width: 100%; height: auto; max-height: 60vh; display: block; user-select: none; touch-action: none; }
|
|
150
|
+
.sub-dataflow__badge { fill: var(--panel-soft); stroke: var(--accent); stroke-width: 1.4; }
|
|
151
|
+
.sub-dataflow__badge-text { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 13px; font-weight: 600; fill: var(--accent); }
|
|
152
|
+
.sub-dataflow__step .sub-dataflow__box { fill: var(--panel-soft); stroke: var(--border); stroke-width: 1; }
|
|
153
|
+
.sub-dataflow__step:hover .sub-dataflow__box { stroke: var(--accent); }
|
|
154
|
+
.sub-dataflow__text { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 14px; fill: var(--text); }
|
|
155
|
+
.sub-dataflow__arrow { stroke: var(--muted); stroke-width: 1.6; }
|
|
156
|
+
.sub-dataflow__fn-bg { fill: rgba(56, 189, 248, 0.12); stroke: var(--accent); stroke-width: 1; }
|
|
157
|
+
.sub-dataflow__fn-text { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; font-weight: 600; fill: var(--accent); letter-spacing: 0.02em; }
|
|
158
|
+
.sub-dataflow__chip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; font-weight: 500; }
|
|
159
|
+
.sub-dataflow__chip--reads { fill: var(--kind-service); }
|
|
160
|
+
.sub-dataflow__chip--writes { fill: var(--kind-db); }
|
|
140
161
|
.sub-dataflow__empty { color: var(--muted); font-style: italic; }
|