@laitszkin/apollo-toolkit 3.11.0 → 3.11.2
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 +29 -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 +48 -15
- 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/init-project-html/scripts/architecture-bootstrap-render.js +16 -0
- package/init-project-html/scripts/architecture.js +22 -12
- 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
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// help / --help / -h usage
|
|
23
23
|
//
|
|
24
24
|
// Global flags:
|
|
25
|
-
// --project <root> project root
|
|
25
|
+
// --project <root> project root; creates resources/project-architecture/ if missing
|
|
26
26
|
// --spec <spec_dir> spec directory; mutations go to <spec_dir>/architecture_diff/atlas/
|
|
27
27
|
// --no-render skip auto-render after a mutation
|
|
28
28
|
// --no-open for open/diff: skip launching the browser
|
|
@@ -67,7 +67,7 @@ Verbs:
|
|
|
67
67
|
help show this help
|
|
68
68
|
|
|
69
69
|
Global flags:
|
|
70
|
-
--project <root> project root (default: nearest ancestor with resources/project-architecture/
|
|
70
|
+
--project <root> explicit project root (default: nearest ancestor with atlas markers, else cwd); missing directories under resources/project-architecture/ are created automatically
|
|
71
71
|
--spec <spec_dir> mutations write to <spec_dir>/architecture_diff/atlas/
|
|
72
72
|
--no-render skip auto-render after a mutation
|
|
73
73
|
--no-open for open/diff: skip launching the browser
|
|
@@ -77,6 +77,8 @@ Examples:
|
|
|
77
77
|
apltk architecture feature add --slug register --title "User registration" --story "..."
|
|
78
78
|
apltk architecture submodule add --feature register --slug api --kind api --role "HTTP endpoint"
|
|
79
79
|
apltk architecture function add --feature register --submodule api --name handlePost --side network --purpose "..."
|
|
80
|
+
apltk architecture variable add --feature register --submodule api --name token --type "string" --scope call --purpose "..."
|
|
81
|
+
apltk architecture dataflow add --feature register --submodule api --step "Validate body" --fn handlePost --reads "body" --writes "token"
|
|
80
82
|
apltk architecture --spec docs/plans/2026-05-11/add-2fa submodule set --feature register --slug api --role "..."
|
|
81
83
|
apltk architecture validate
|
|
82
84
|
apltk architecture diff
|
|
@@ -96,6 +98,10 @@ function openInBrowser(filePath) {
|
|
|
96
98
|
} catch (_e) { /* best effort */ }
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
function ensureResourcesLayout(projectRoot) {
|
|
102
|
+
fs.mkdirSync(path.join(projectRoot, ATLAS_REL), { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
99
105
|
function findProjectRoot(startDir) {
|
|
100
106
|
let dir = path.resolve(startDir);
|
|
101
107
|
while (true) {
|
|
@@ -152,10 +158,15 @@ function requireFlag(flags, name) {
|
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
function resolveProjectRoot(flags) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
const finish = (root) => {
|
|
162
|
+
ensureResourcesLayout(root);
|
|
163
|
+
return root;
|
|
164
|
+
};
|
|
165
|
+
if (flags.project) return finish(path.resolve(String(flags.project)));
|
|
166
|
+
const discovered = findProjectRoot(process.cwd());
|
|
167
|
+
if (discovered) return finish(discovered);
|
|
168
|
+
// No marker walking parents — use cwd and create resources/project-architecture/.
|
|
169
|
+
return finish(process.cwd());
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
function specOverlayDir(projectRoot, specFlag) {
|
|
@@ -449,13 +460,14 @@ async function verbDataflow(action, flags, projectRoot) {
|
|
|
449
460
|
sub.dataflow = sub.dataflow || [];
|
|
450
461
|
if (action === 'add') {
|
|
451
462
|
const step = String(requireFlag(flags, 'step'));
|
|
463
|
+
const item = buildDataflowItem(step, flags);
|
|
452
464
|
const atRaw = flags.at;
|
|
453
465
|
if (atRaw !== undefined) {
|
|
454
466
|
const at = Number(atRaw);
|
|
455
467
|
if (!Number.isFinite(at) || at < 0) throw new Error('--at must be a non-negative integer');
|
|
456
|
-
sub.dataflow.splice(at, 0,
|
|
468
|
+
sub.dataflow.splice(at, 0, item);
|
|
457
469
|
} else {
|
|
458
|
-
sub.dataflow.push(
|
|
470
|
+
sub.dataflow.push(item);
|
|
459
471
|
}
|
|
460
472
|
} else if (action === 'remove') {
|
|
461
473
|
if (flags.at !== undefined) {
|
|
@@ -464,7 +476,7 @@ async function verbDataflow(action, flags, projectRoot) {
|
|
|
464
476
|
sub.dataflow.splice(at, 1);
|
|
465
477
|
} else {
|
|
466
478
|
const step = String(requireFlag(flags, 'step'));
|
|
467
|
-
sub.dataflow = sub.dataflow.filter((s) => s !== step);
|
|
479
|
+
sub.dataflow = sub.dataflow.filter((s) => stepText(s) !== step);
|
|
468
480
|
}
|
|
469
481
|
} else if (action === 'reorder') {
|
|
470
482
|
const from = Number(requireFlag(flags, 'from'));
|
|
@@ -481,6 +493,31 @@ async function verbDataflow(action, flags, projectRoot) {
|
|
|
481
493
|
});
|
|
482
494
|
}
|
|
483
495
|
|
|
496
|
+
function stepText(item) {
|
|
497
|
+
return typeof item === 'string' ? item : (item && typeof item.step === 'string' ? item.step : '');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function parseNameList(raw) {
|
|
501
|
+
if (raw === undefined || raw === null) return undefined;
|
|
502
|
+
return String(raw)
|
|
503
|
+
.split(',')
|
|
504
|
+
.map((s) => s.trim())
|
|
505
|
+
.filter(Boolean);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function buildDataflowItem(step, flags) {
|
|
509
|
+
const fn = flags.fn === undefined ? undefined : String(flags.fn).trim();
|
|
510
|
+
const reads = parseNameList(flags.reads);
|
|
511
|
+
const writes = parseNameList(flags.writes);
|
|
512
|
+
const annotated = (fn && fn.length > 0) || (reads && reads.length > 0) || (writes && writes.length > 0);
|
|
513
|
+
if (!annotated) return step;
|
|
514
|
+
const item = { step };
|
|
515
|
+
if (fn) item.fn = fn;
|
|
516
|
+
if (reads && reads.length > 0) item.reads = reads;
|
|
517
|
+
if (writes && writes.length > 0) item.writes = writes;
|
|
518
|
+
return item;
|
|
519
|
+
}
|
|
520
|
+
|
|
484
521
|
async function verbError(action, flags, projectRoot) {
|
|
485
522
|
const featureSlug = String(requireFlag(flags, 'feature'));
|
|
486
523
|
const subSlug = String(requireFlag(flags, 'submodule'));
|
|
@@ -627,14 +664,10 @@ async function verbUndo(flags, projectRoot, io) {
|
|
|
627
664
|
async function verbOpen(flags, projectRoot, io) {
|
|
628
665
|
const atlas = path.join(projectRoot, ATLAS_INDEX_REL);
|
|
629
666
|
if (!fs.existsSync(atlas)) {
|
|
630
|
-
|
|
631
|
-
const baseDir = baseAtlasDir(projectRoot);
|
|
632
|
-
if (fs.existsSync(path.join(baseDir, stateLib.INDEX_FILE))) {
|
|
633
|
-
await runRender({ projectRoot, flags: { ...flags, spec: undefined } });
|
|
634
|
-
}
|
|
667
|
+
await runRender({ projectRoot, flags: { ...flags, spec: undefined } });
|
|
635
668
|
}
|
|
636
669
|
if (!fs.existsSync(atlas)) {
|
|
637
|
-
io.stderr.write(`Atlas not found: ${atlas}\n`);
|
|
670
|
+
io.stderr.write(`Atlas not found after render: ${atlas}\n`);
|
|
638
671
|
return 1;
|
|
639
672
|
}
|
|
640
673
|
io.stdout.write(`${atlas}\n`);
|
|
@@ -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.
|