@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +29 -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/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  7. package/init-project-html/SKILL.md +56 -20
  8. package/init-project-html/agents/openai.yaml +6 -6
  9. package/init-project-html/lib/atlas/assets/architecture.css +27 -6
  10. package/init-project-html/lib/atlas/assets/viewer.client.js +124 -81
  11. package/init-project-html/lib/atlas/cli.js +48 -15
  12. package/init-project-html/lib/atlas/layout.js +112 -11
  13. package/init-project-html/lib/atlas/render.js +131 -33
  14. package/init-project-html/lib/atlas/schema.js +39 -2
  15. package/init-project-html/references/TEMPLATE_SPEC.md +26 -8
  16. package/init-project-html/sample-demo/resources/project-architecture/assets/architecture.css +27 -6
  17. package/init-project-html/sample-demo/resources/project-architecture/assets/viewer.client.js +124 -81
  18. package/init-project-html/sample-demo/resources/project-architecture/atlas/features/get-invite-codes.yaml +17 -4
  19. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-code-generator.html +23 -7
  20. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/invite-issuance-service.html +45 -13
  21. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/postgresql.html +28 -10
  22. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/public-api.html +33 -13
  23. package/init-project-html/sample-demo/resources/project-architecture/features/get-invite-codes/web-get-invite-ui.html +28 -10
  24. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/postgresql.html +28 -10
  25. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/public-api.html +28 -10
  26. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/registration-service.html +38 -17
  27. package/init-project-html/sample-demo/resources/project-architecture/features/invite-code-registration/web-register-ui.html +29 -11
  28. package/init-project-html/sample-demo/resources/project-architecture/index.html +100 -76
  29. package/init-project-html/scripts/architecture-bootstrap-render.js +16 -0
  30. package/init-project-html/scripts/architecture.js +22 -12
  31. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  32. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  33. package/package.json +1 -1
  34. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  35. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  36. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  37. package/spec-to-project-html/SKILL.md +25 -16
  38. package/spec-to-project-html/agents/openai.yaml +5 -5
  39. package/spec-to-project-html/references/TEMPLATE_SPEC.md +2 -0
  40. 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 (default: nearest ancestor with resources/project-architecture/)
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
- if (flags.project) return path.resolve(String(flags.project));
156
- const root = findProjectRoot(process.cwd());
157
- if (!root) throw new Error('Could not find resources/project-architecture/. Pass --project <root> or run inside the project.');
158
- return root;
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, step);
468
+ sub.dataflow.splice(at, 0, item);
457
469
  } else {
458
- sub.dataflow.push(step);
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
- // Try to render fresh if state exists
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
- id: `submodule::${feature.slug}::${sub.slug}`,
58
- width: SUB_WIDTH,
59
- height: SUB_HEIGHT,
60
- labels: [{
61
- id: `submodule::${feature.slug}::${sub.slug}::label`,
62
- text: sub.slug,
63
- width: estimateLabelWidth(sub.slug),
64
- height: 18,
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 = (meta && meta.kind) || 'service';
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 labelY = sub.y + 28;
122
- const kindLabelY = sub.y + 52;
123
- const role = meta && meta.role ? meta.role : '';
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
- parts.push(` <a class="m-node m-node--${kind}" href="${htmlEscape(href)}" data-feature="${htmlEscape(sub.featureSlug)}" data-submodule="${htmlEscape(sub.slug)}">`);
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="${labelY.toFixed(2)}" text-anchor="middle">${htmlEscape(sub.slug)}</text>`);
128
- parts.push(` <text class="m-node__kind" x="${cx.toFixed(2)}" y="${kindLabelY.toFixed(2)}" text-anchor="middle">${htmlEscape(KIND_LABEL[kind] || kind)}</text>`);
129
- if (role) {
130
- const truncated = role.length > 38 ? `${role.slice(0, 36)}…` : role;
131
- parts.push(` <text class="m-node__role" x="${cx.toFixed(2)}" y="${(sub.y + sub.height - 14).toFixed(2)}" text-anchor="middle">${htmlEscape(truncated)}</text>`);
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
- const boxW = 360;
294
- const boxH = 56;
295
- const gap = 28;
296
- const totalH = steps.length * boxH + (steps.length - 1) * gap + 40;
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 ${boxW + 40} ${totalH}" role="img" aria-label="Internal dataflow">`);
299
- parts.push(' <defs><marker id="sub-arrow" 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></defs>');
300
- steps.forEach((step, i) => {
301
- const y = 20 + i * (boxH + gap);
302
- parts.push(` <g class="sub-dataflow__step">`);
303
- parts.push(` <rect x="20" y="${y}" width="${boxW}" height="${boxH}" rx="8" ry="8" />`);
304
- const lines = wrapText(step, 52);
305
- lines.forEach((line, idx) => {
306
- const ly = y + 24 + idx * 16;
307
- parts.push(` <text x="${20 + boxW / 2}" y="${ly}" text-anchor="middle">${htmlEscape(line)}</text>`);
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
- if (i < steps.length - 1) {
311
- const aY = y + boxH;
312
- const bY = aY + gap;
313
- const x = 20 + boxW / 2;
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
- return lines.slice(0, 3);
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
- ${renderInternalDataflowSvg(sub.dataflow)}
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 of step strings`);
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
- if (typeof step !== 'string') errors.push(`${where}.dataflow[${i}]: must be a string`);
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 (ordered) | no | Renders the `sub-dataflow` internal flow SVG. |
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
- A simple ordered string. 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`.
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
- CLI: `apltk architecture dataflow add --feature X --submodule Y --step "..."`
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
- ## Pan/zoom on the macro
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
- The CLI copies `assets/viewer.client.js` into the atlas. The macro page mounts a `[data-pan-zoom-viewport]` element wrapping the SVG; the script wires:
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.