@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5

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 (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +379 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +841 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,798 @@
1
+ import { worstVerdict, protectionVisual } from '../lib/protection-visual.js';
2
+ import { el, clear } from '../lib/dom.js';
3
+ import { flowPathNodeIds } from '../lib/flow-path.js';
4
+
5
+ export const ZONE_ORDER = Object.freeze(['Public Internet', 'Application Layer', 'Service Layer', 'Data Layer', 'External Zone']);
6
+
7
+ export function zoneForNode(node) {
8
+ switch (node.kind) {
9
+ case 'source':
10
+ return 'Public Internet';
11
+ case 'api':
12
+ return 'Application Layer';
13
+ case 'process':
14
+ case 'transform':
15
+ return 'Service Layer';
16
+ case 'store':
17
+ case 'log':
18
+ case 'queue':
19
+ case 'sink':
20
+ return 'Data Layer';
21
+ case 'external':
22
+ case 'unresolved':
23
+ return 'External Zone';
24
+ default:
25
+ // boundary, or any future kind not yet mapped: a safe internal default
26
+ // rather than silently dropping the node from every zone.
27
+ return 'Service Layer';
28
+ }
29
+ }
30
+
31
+ export function resolveSelection(graph, selectedId) {
32
+ const empty = { active: false, nodeIds: new Set(), edgeIds: new Set(), flow: null };
33
+ if (!selectedId) return empty;
34
+
35
+ const flow = graph.flows.find((f) => f.id === selectedId);
36
+ if (flow) {
37
+ const edgeIds = new Set(flow.edgeIds);
38
+ const nodeIds = new Set();
39
+ for (const edgeId of flow.edgeIds) {
40
+ const edge = graph.edges.find((e) => e.id === edgeId);
41
+ if (edge) {
42
+ nodeIds.add(edge.from);
43
+ nodeIds.add(edge.to);
44
+ }
45
+ }
46
+ nodeIds.add(flow.source);
47
+ nodeIds.add(flow.sink);
48
+ return { active: true, nodeIds, edgeIds, flow };
49
+ }
50
+
51
+ const node = graph.nodes.find((n) => n.id === selectedId);
52
+ if (node) {
53
+ const edgeIds = new Set(graph.edges.filter((e) => e.from === selectedId || e.to === selectedId).map((e) => e.id));
54
+ return { active: true, nodeIds: new Set([selectedId]), edgeIds, flow: null };
55
+ }
56
+
57
+ const edge = graph.edges.find((e) => e.id === selectedId);
58
+ if (edge) {
59
+ return { active: true, nodeIds: new Set([edge.from, edge.to]), edgeIds: new Set([selectedId]), flow: null };
60
+ }
61
+
62
+ return empty;
63
+ }
64
+
65
+ function edgeVerdict(edge) {
66
+ return worstVerdict([edge.protection.transit.verdict, edge.protection.atRest.verdict, edge.protection.handling.verdict]);
67
+ }
68
+
69
+ export function computeFlowSummary(graph, flow) {
70
+ const edges = flow.edgeIds.map((id) => graph.edges.find((e) => e.id === id)).filter(Boolean);
71
+ const dataElement = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
72
+ const sourceNode = graph.nodes.find((n) => n.id === flow.source);
73
+ const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
74
+
75
+ const pathNodeIds = flowPathNodeIds(graph, flow);
76
+ const externalRecipients = graph.nodes
77
+ .filter((n) => pathNodeIds.has(n.id) && n.externality?.value === 'external')
78
+ .map((n) => n.label);
79
+ // 'unknown' externality is NOT safe to fold into "no external recipients" —
80
+ // it means the scanner could not resolve the destination (e.g. a dynamic
81
+ // URL expression), which is a distinct risk from a confirmed-internal
82
+ // recipient. Tracked separately so callers can't mistake "we don't know"
83
+ // for "we checked and it's fine" (I4, final whole-branch review).
84
+ const unknownRecipients = graph.nodes
85
+ .filter((n) => pathNodeIds.has(n.id) && n.externality?.value === 'unknown')
86
+ .map((n) => n.label);
87
+
88
+ let protectedCount = 0;
89
+ let unprotectedCount = 0;
90
+ let unknownCount = 0;
91
+ for (const e of edges) {
92
+ const v = edgeVerdict(e);
93
+ if (v === 'protected') protectedCount += 1;
94
+ else if (v === 'unprotected' || v === 'mixed') unprotectedCount += 1;
95
+ else unknownCount += 1;
96
+ }
97
+
98
+ return {
99
+ flowId: flow.id,
100
+ dataElementName: dataElement?.name ?? 'unknown field',
101
+ dataClasses: dataElement?.dataClasses ?? [],
102
+ sourceLabel: sourceNode?.label ?? 'unknown source',
103
+ destinationLabel: sinkNode?.label ?? 'unknown destination',
104
+ protectedCount,
105
+ unprotectedCount,
106
+ unknownCount,
107
+ externalRecipients,
108
+ unknownRecipients,
109
+ transitVerdict: worstVerdict(edges.map((e) => e.protection.transit.verdict)),
110
+ atRestVerdict: worstVerdict(edges.map((e) => e.protection.atRest.verdict)),
111
+ handlingVerdict: worstVerdict(edges.map((e) => e.protection.handling.verdict)),
112
+ protectionSummary: flow.protectionSummary,
113
+ policyVerdict: flow.policyVerdict,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * @param {object} graph
119
+ * @param {object} state
120
+ * @param {{nodeIds: Set<string>, edgeIds: Set<string>} | null} [focusSelection] -
121
+ * Milestone 3, sub-project M3-UX-Query, Task 4. An optional pre-computed
122
+ * selection override — the SAME `{nodeIds, edgeIds}` shape every
123
+ * `lib/focus-controls.js` function already returns. When present (non-
124
+ * null), it is used AS the `selection` variable directly, bypassing
125
+ * `resolveSelection(graph, state.selectedId)` entirely for this render —
126
+ * a focus control's own multi-node result has no single canonical
127
+ * `selectedId` to look up. When omitted/null (the default, and what every
128
+ * pre-existing caller/test already passes), behavior is unchanged:
129
+ * `resolveSelection` runs exactly as it always has.
130
+ */
131
+ export function computeArchitectureViewModel(graph, state, focusSelection = null) {
132
+ const selection = focusSelection
133
+ ? { active: true, nodeIds: focusSelection.nodeIds, edgeIds: focusSelection.edgeIds, flow: null }
134
+ : resolveSelection(graph, state.selectedId);
135
+
136
+ const zones = ZONE_ORDER.map((name) => ({
137
+ name,
138
+ nodeIds: graph.nodes.filter((n) => zoneForNode(n) === name).map((n) => n.id),
139
+ }));
140
+
141
+ const nodes = graph.nodes.map((n) => ({
142
+ id: n.id,
143
+ label: n.label,
144
+ kind: n.kind,
145
+ subtype: n.subtype,
146
+ zone: zoneForNode(n),
147
+ selected: selection.nodeIds.has(n.id),
148
+ dimmed: selection.active && !selection.nodeIds.has(n.id),
149
+ }));
150
+
151
+ const edges = graph.edges.map((e) => ({
152
+ id: e.id,
153
+ from: e.from,
154
+ to: e.to,
155
+ verdict: edgeVerdict(e),
156
+ selected: selection.edgeIds.has(e.id),
157
+ dimmed: selection.active && !selection.edgeIds.has(e.id),
158
+ }));
159
+
160
+ const flowSummary = selection.flow ? computeFlowSummary(graph, selection.flow) : null;
161
+
162
+ return { zones, nodes, edges, flowSummary };
163
+ }
164
+
165
+ // Renders computeFlowSummary()'s output into the shell's context rail
166
+ // (frontend/src/shell.js's getContextRailEl()). This is a plain HTML panel,
167
+ // not part of the SVG canvas, so it's built via el() (lib/dom.js) — NOT
168
+ // svgEl() — matching Privacy View / Trace View's convention of using el()
169
+ // for anything outside the <svg> tree.
170
+ export function renderFlowSummary(flowSummary, contextRailEl) {
171
+ clear(contextRailEl);
172
+ if (!flowSummary) return;
173
+
174
+ const dims = [
175
+ ['Transit', flowSummary.transitVerdict],
176
+ ['At rest', flowSummary.atRestVerdict],
177
+ ['Handling', flowSummary.handlingVerdict],
178
+ ].map(([label, verdict]) => {
179
+ const v = protectionVisual(verdict);
180
+ return el('div', {}, `${v.glyph} ${label}: ${v.label}`);
181
+ });
182
+
183
+ const recipientsLine = (label, names) => (names.length > 0 ? el('div', {}, `${label}: ${names.join(', ')}`) : null);
184
+
185
+ contextRailEl.appendChild(
186
+ el('div', { class: 'flow-summary' }, [
187
+ el('h4', {}, flowSummary.dataElementName),
188
+ el('div', {}, flowSummary.dataClasses.join(', ')),
189
+ el('div', {}, `${flowSummary.sourceLabel} → ${flowSummary.destinationLabel}`),
190
+ el('div', {}, `${flowSummary.protectedCount} protected · ${flowSummary.unprotectedCount} unprotected · ${flowSummary.unknownCount} unknown`),
191
+ recipientsLine('External recipients', flowSummary.externalRecipients),
192
+ recipientsLine('Unknown-externality recipients', flowSummary.unknownRecipients),
193
+ ...dims,
194
+ ].filter(Boolean)),
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Per-zone level-of-detail clustering (PRD §21: "no more than 2,000
200
+ * visible elements after level-of-detail clustering"). A currently-
201
+ * SELECTED node always stays individually visible, bypassing `budget`
202
+ * entirely — clustering must never hide the thing the user is looking
203
+ * at. `budget` is the max number of individually-visible node SLOTS for
204
+ * a zone, INCLUDING the cluster glyph's own slot when clustering is
205
+ * needed (so `budget=4` with 10 nodes shows 3 real nodes + 1 cluster
206
+ * glyph, never 4 real nodes + a cluster that would then be a 5th
207
+ * element). Node order (for which unselected nodes stay visible) is
208
+ * graph order — a defensible, simple tie-break, not sorted by anything
209
+ * PRD-significant.
210
+ *
211
+ * @param {Array<{name: string, nodeIds: string[]}>} zones
212
+ * @param {Array<{id: string, kind: string, zone: string, selected: boolean}>} nodes
213
+ * @param {number} budget
214
+ */
215
+ export function computeClusteredLayout(zones, nodes, budget) {
216
+ const nodesById = new Map(nodes.map((n) => [n.id, n]));
217
+
218
+ return zones.map((zone) => {
219
+ const selectedIds = zone.nodeIds.filter((id) => nodesById.get(id)?.selected);
220
+ const unselectedIds = zone.nodeIds.filter((id) => !nodesById.get(id)?.selected);
221
+
222
+ if (zone.nodeIds.length <= budget) {
223
+ return { name: zone.name, visibleNodeIds: [...zone.nodeIds], cluster: null };
224
+ }
225
+
226
+ // Selected nodes never count against the budget or get clustered.
227
+ // The remaining budget (after reserving 1 slot for the cluster
228
+ // glyph itself) goes to unselected nodes in graph order.
229
+ const slotsForUnselected = Math.max(0, budget - 1);
230
+ const visibleUnselected = unselectedIds.slice(0, slotsForUnselected);
231
+ const clusteredIds = unselectedIds.slice(slotsForUnselected);
232
+
233
+ if (clusteredIds.length === 0) {
234
+ // Selected nodes alone pushed us over budget, or the unselected
235
+ // set fit exactly — no real overflow to cluster.
236
+ return { name: zone.name, visibleNodeIds: [...selectedIds, ...visibleUnselected], cluster: null };
237
+ }
238
+
239
+ const kindSummary = [...new Set(clusteredIds.map((id) => nodesById.get(id)?.kind).filter(Boolean))].sort().join(', ');
240
+
241
+ return {
242
+ name: zone.name,
243
+ visibleNodeIds: [...selectedIds, ...visibleUnselected],
244
+ cluster: {
245
+ id: `cluster:${zone.name}`,
246
+ count: clusteredIds.length,
247
+ kindSummary,
248
+ memberIds: clusteredIds,
249
+ },
250
+ };
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Redirects an edge's endpoint to its zone's cluster glyph when that
256
+ * endpoint's node was clustered away (computeClusteredLayout), then
257
+ * groups edges sharing the same real (from, to) VISIBLE-endpoint pair
258
+ * into one aggregate, reusing worstVerdict — the SAME aggregation
259
+ * primitive edgeVerdict() already uses per-edge, applied here per-group.
260
+ * An edge whose both endpoints resolve to the SAME cluster (entirely
261
+ * "inside" one collapsed group) is dropped — it adds no information a
262
+ * single cluster glyph doesn't already summarize.
263
+ *
264
+ * @param {Array<{id,from,to,verdict,selected,dimmed}>} edges
265
+ * @param {ReturnType<typeof computeClusteredLayout>} clusteredZones
266
+ */
267
+ export function aggregateEdgesForClusters(edges, clusteredZones) {
268
+ const visibleIdFor = new Map();
269
+ for (const zone of clusteredZones) {
270
+ for (const id of zone.visibleNodeIds) visibleIdFor.set(id, id);
271
+ if (zone.cluster) {
272
+ for (const memberId of zone.cluster.memberIds) visibleIdFor.set(memberId, zone.cluster.id);
273
+ }
274
+ }
275
+
276
+ const groups = new Map(); // key: `${visibleFrom}->${visibleTo}` -> edges[]
277
+ for (const edge of edges) {
278
+ const visibleFrom = visibleIdFor.get(edge.from) ?? edge.from;
279
+ const visibleTo = visibleIdFor.get(edge.to) ?? edge.to;
280
+ if (visibleFrom === visibleTo) continue; // dropped: collapsed self-loop
281
+ const key = `${visibleFrom}->${visibleTo}`;
282
+ if (!groups.has(key)) groups.set(key, []);
283
+ groups.get(key).push(edge);
284
+ }
285
+
286
+ return [...groups.entries()].map(([key, group]) => {
287
+ const [from, to] = key.split('->');
288
+ if (group.length === 1) return { ...group[0], from, to, constituentCount: 1 };
289
+ return {
290
+ id: `agg:${key}`, // deterministic — same (from,to) pair always yields the same id
291
+ from,
292
+ to,
293
+ verdict: worstVerdict(group.map((e) => e.verdict)),
294
+ selected: group.some((e) => e.selected),
295
+ dimmed: group.every((e) => e.dimmed),
296
+ constituentCount: group.length,
297
+ };
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Default viewport on first mount: show the entire content bounds
303
+ * unchanged (decision 5: reset only on a fresh view-mount, not every
304
+ * rerender — the caller owns when this gets called again).
305
+ *
306
+ * @param {{x: number, y: number, width: number, height: number}} contentBounds
307
+ */
308
+ export function computeFitAllViewport(contentBounds) {
309
+ return { ...contentBounds };
310
+ }
311
+
312
+ /**
313
+ * Pure zoom reducer — no DOM access. `svgX`/`svgY` are the cursor
314
+ * position already converted to SVG-coordinate space by the caller
315
+ * (real browser code, using getScreenCTM()/getBoundingClientRect() —
316
+ * see Task 3). Zoom factor is a fixed, disclosed constant per wheel
317
+ * "tick" rather than proportional to raw deltaY magnitude (real trackpad/
318
+ * mouse-wheel deltaY values vary wildly across devices/browsers — a
319
+ * fixed-step zoom avoids over- or under-reacting to a single event).
320
+ *
321
+ * The centered-on-cursor property is algebraic, not approximate: `newX`
322
+ * is derived by solving `(svgX - newX) / newWidth === fracX` for `newX`,
323
+ * so the cursor's fractional position within the viewport is IDENTICAL
324
+ * before and after the zoom, for any resulting `newWidth`/`newHeight`
325
+ * (including after clamping) — not just for the unclamped case.
326
+ */
327
+ const ZOOM_STEP = 0.1; // 10% per wheel tick
328
+
329
+ export function applyWheelZoom(viewport, { deltaY, svgX, svgY }, bounds) {
330
+ const factor = deltaY < 0 ? 1 - ZOOM_STEP : 1 + ZOOM_STEP;
331
+ const newWidth = Math.min(bounds.maxWidth, Math.max(bounds.minWidth, viewport.width * factor));
332
+ const newHeight = Math.min(bounds.maxWidth, Math.max(bounds.minWidth, viewport.height * factor)); // aspect-locked to width's own clamp, since this view's aspect ratio is fixed by zone-column layout
333
+ // Keep (svgX, svgY) fixed under the cursor: the point's own relative
334
+ // position within the viewport (0..1 fraction) must be identical
335
+ // before and after.
336
+ const fracX = (svgX - viewport.x) / viewport.width;
337
+ const fracY = (svgY - viewport.y) / viewport.height;
338
+ return {
339
+ x: svgX - fracX * newWidth,
340
+ y: svgY - fracY * newHeight,
341
+ width: newWidth,
342
+ height: newHeight,
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Pans by an SVG-space delta, clamped so the viewport cannot be dragged
348
+ * entirely off `contentBounds` — a sliver of content always remains
349
+ * visible at the boundary, rather than the viewport being allowed to
350
+ * drift into empty space with nothing on screen.
351
+ */
352
+ export function applyDragPan(viewport, { dxSvg, dySvg }, contentBounds) {
353
+ const minX = contentBounds.x - viewport.width; // allow dragging until only a sliver of content remains visible, never fully past it
354
+ const maxX = contentBounds.x + contentBounds.width;
355
+ const minY = contentBounds.y - viewport.height;
356
+ const maxY = contentBounds.y + contentBounds.height;
357
+ return {
358
+ x: Math.min(maxX, Math.max(minX, viewport.x + dxSvg)),
359
+ y: Math.min(maxY, Math.max(minY, viewport.y + dySvg)),
360
+ width: viewport.width,
361
+ height: viewport.height,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Viewport-culling predicate for level-of-detail rendering at scale:
367
+ * which node ids fall within `viewportRect` expanded by `margin` on all
368
+ * sides (the margin avoids visible pop-in as a node crosses the exact
369
+ * edge). `nodePositions` is the SAME `Map<id, {x, y}>` shape
370
+ * `renderArchitectureView` already builds locally — reuse it there,
371
+ * don't rebuild it.
372
+ */
373
+ export function visibleNodeIds(nodePositions, viewportRect, margin) {
374
+ const minX = viewportRect.x - margin;
375
+ const maxX = viewportRect.x + viewportRect.width + margin;
376
+ const minY = viewportRect.y - margin;
377
+ const maxY = viewportRect.y + viewportRect.height + margin;
378
+ const result = new Set();
379
+ for (const [id, pos] of nodePositions) {
380
+ if (pos.x >= minX && pos.x <= maxX && pos.y >= minY && pos.y <= maxY) result.add(id);
381
+ }
382
+ return result;
383
+ }
384
+
385
+ const SVG_NS = 'http://www.w3.org/2000/svg';
386
+ const ZONE_WIDTH = 220;
387
+ const ZONE_PADDING = 12;
388
+ const NODE_HEIGHT = 44;
389
+ const NODE_GAP = 16;
390
+ const NODE_WIDTH = ZONE_WIDTH - ZONE_PADDING * 2;
391
+
392
+ // PRD §21: "no more than 2,000 visible elements after level-of-detail
393
+ // clustering." Each node is 3 SVG elements (rect+2 text, see renderNode);
394
+ // each rendered edge is ~2 (path+text, see renderEdge); 5 zones contribute
395
+ // 2 chrome elements each (bg rect + label text) = 10; a cluster glyph
396
+ // itself costs the same 3 elements as a real node. Budget conservatively:
397
+ // reserve 20% of the 2,000 target for edges/chrome, split the rest evenly
398
+ // across 5 zones.
399
+ const VISIBLE_ELEMENT_BUDGET = 2000;
400
+ const ZONE_CHROME_ELEMENTS = ZONE_ORDER.length * 2;
401
+ const EDGE_ELEMENT_RESERVE_FRACTION = 0.2;
402
+ const NODE_ELEMENTS_PER_NODE = 3;
403
+ function computeZoneNodeBudget() {
404
+ const budgetForNodes = (VISIBLE_ELEMENT_BUDGET - ZONE_CHROME_ELEMENTS) * (1 - EDGE_ELEMENT_RESERVE_FRACTION);
405
+ return Math.max(3, Math.floor(budgetForNodes / ZONE_ORDER.length / NODE_ELEMENTS_PER_NODE));
406
+ }
407
+ // Sanity-checked (this session, real numbers): with the constants above this
408
+ // evaluates to 106 — well under the ~1,000-per-zone average a 5,000-node
409
+ // graph split across 5 zones would produce (so clustering actually engages
410
+ // at PRD reference scale), and far above the <10 floor that would clutter
411
+ // every modest fixture with a cluster glyph.
412
+
413
+ // Module-local, NOT persisted to lib/state.js's URL hash — real UI state,
414
+ // not meaningfully shareable (scoping doc decision 5).
415
+ //
416
+ // Simplification of the brief's own Step 3.5 (documented in the task-3
417
+ // context, not just here): `renderArchitectureView`'s caller (app.js) has
418
+ // no "this is a fresh view mount, not a same-view rerender" signal today,
419
+ // and adding one is out of this task's file list (architecture-view.js
420
+ // only). Instead: currentViewport starts null and is set to a fit-all
421
+ // viewport ONLY the very first time this module ever renders. It is never
422
+ // auto-reset again afterward — only via the user's own "0" key, a cluster
423
+ // expansion (see expandedZones' onClick below — re-fitting there is a
424
+ // deliberate, real necessity, not a copy-paste of the mount rule: without
425
+ // it, newly-revealed nodes from an expanded cluster could land outside the
426
+ // still-small pre-expansion viewport and be viewport-culled right back out
427
+ // of the DOM, silently undoing the click), or a page reload. This means
428
+ // pan/zoom position is preserved across ordinary view switches away from
429
+ // and back to Architecture View — simpler, fully local to this file, and
430
+ // does not violate AC-16 (which requires selection/filters/header/coverage
431
+ // state to survive a view switch, and says nothing about pan/zoom).
432
+ let currentViewport = null;
433
+ // Module-local set of zone names whose per-zone budget is lifted to
434
+ // Infinity by a user click on that zone's cluster glyph (see
435
+ // computeEffectiveClusteredLayout below). Kept as a Set, not folded into
436
+ // currentViewport, so it survives independently of pan/zoom resets.
437
+ const expandedZones = new Set();
438
+ // Module-local, keyed the same way `currentViewport` is: drag state must
439
+ // survive a rerender (renderArchitectureView tears down and rebuilds the
440
+ // entire <svg> tree on every pan/zoom-driven rerender, including the ones
441
+ // fired mid-drag from `mousemove`), or a real mouse-drag gesture would
442
+ // silently stop after its first `mousemove` event — the old <svg> (and any
443
+ // per-render-closure-local drag state) is gone, and no second `mousedown`
444
+ // ever fires to restart it.
445
+ let dragState = null;
446
+
447
+ const CULL_MARGIN = 100; // SVG units; see visibleNodeIds' own margin param
448
+ const KEYBOARD_PAN_STEP = 40; // SVG units per arrow-key press
449
+ const MIN_VIEWPORT_WIDTH = 150; // SVG units; deepest zoom-in via wheel/keyboard
450
+
451
+ // computeClusteredLayout (Task 1) intentionally takes one `budget` number
452
+ // applied uniformly to every zone — already tested against a plain number,
453
+ // and Task 3 must not change that signature. To let ONE zone's budget be
454
+ // lifted (cluster-glyph click, "show me everything in this zone"), split
455
+ // the zones into "expanded" (budget=Infinity) and "everyone else" (the
456
+ // real zoneNodeBudget), call computeClusteredLayout once per group, and
457
+ // merge back in the original zone order.
458
+ function computeEffectiveClusteredLayout(zones, nodes, budget, expandedZoneNames) {
459
+ const expanded = zones.filter((z) => expandedZoneNames.has(z.name));
460
+ const collapsed = zones.filter((z) => !expandedZoneNames.has(z.name));
461
+ const expandedResult = expanded.length > 0 ? computeClusteredLayout(expanded, nodes, Infinity) : [];
462
+ const collapsedResult = collapsed.length > 0 ? computeClusteredLayout(collapsed, nodes, budget) : [];
463
+ const byName = new Map([...expandedResult, ...collapsedResult].map((z) => [z.name, z]));
464
+ return zones.map((z) => byName.get(z.name));
465
+ }
466
+
467
+ // Real screen-to-SVG-space coordinate conversion, via the standard
468
+ // getScreenCTM()/createSVGPoint() DOM APIs — NOT available on test/dom-
469
+ // shim.js's FakeElement (it has no CSSOM/layout engine to compute a CTM
470
+ // against), so every call site guards with `supportsPointerConversion`
471
+ // first and skips wiring entirely when it's false. Real interaction is
472
+ // only provable in a real browser regardless (Step 6).
473
+ function screenToSvgPoint(svgElement, clientX, clientY) {
474
+ const pt = svgElement.createSVGPoint();
475
+ pt.x = clientX;
476
+ pt.y = clientY;
477
+ return pt.matrixTransform(svgElement.getScreenCTM().inverse());
478
+ }
479
+
480
+ function zoomBoundsFor(contentBounds) {
481
+ return { minWidth: MIN_VIEWPORT_WIDTH, maxWidth: Math.max(contentBounds.width, MIN_VIEWPORT_WIDTH) };
482
+ }
483
+
484
+ // Builds an element in the SVG namespace (createElementNS), unlike `el()`
485
+ // (lib/dom.js) which always calls createElement and produces an HTML-
486
+ // namespaced element — a foreign element inside an <svg> tree that neither
487
+ // paints nor paints its children (C1, final whole-branch review). Event
488
+ // handlers are wired the same way `el()` does (addEventListener, not
489
+ // setAttribute); everything else — including `class` — goes through plain
490
+ // setAttribute, which is correct for SVG elements. Do NOT set `.className`
491
+ // here: it's a read-only SVGAnimatedString on SVG elements, and assigning to
492
+ // it is a silent no-op that would drop every CSS class.
493
+ export function svgEl(tag, attrs = {}) {
494
+ const node = document.createElementNS(SVG_NS, tag);
495
+ for (const [key, value] of Object.entries(attrs)) {
496
+ if (value === undefined || value === null || value === false) continue;
497
+ if (key.startsWith('on') && typeof value === 'function') {
498
+ node.addEventListener(key.slice(2).toLowerCase(), value);
499
+ } else {
500
+ node.setAttribute(key, String(value));
501
+ }
502
+ }
503
+ return node;
504
+ }
505
+
506
+ /**
507
+ * @param {ReturnType<typeof computeArchitectureViewModel>} viewModel
508
+ * @param {HTMLElement} canvasEl
509
+ * @param {(id: string) => void} onSelect
510
+ */
511
+ export function renderArchitectureView(viewModel, canvasEl, onSelect) {
512
+ // Real, disclosed finding from this session's own manual browser smoke
513
+ // check (Step 6): every pan/zoom-driven rerender tears down and rebuilds
514
+ // the ENTIRE <svg> from scratch. A real browser does NOT transfer focus
515
+ // to a freshly-inserted replacement element, so without this, a single
516
+ // keyboard-driven zoom/pan keystroke would work but a SECOND rapid one
517
+ // (e.g. holding "-") would silently do nothing — the old, focused <svg>
518
+ // is already gone, and nothing ever refocuses the new one. Recorded here
519
+ // (not just observed and left alone) because this is exactly the class
520
+ // of bug a manual-only check without direct focus inspection would miss
521
+ // (see app.js's own comment on the analogous Task-5/Task-7 lesson).
522
+ const previouslyFocusedSvg = typeof document !== 'undefined' && document.activeElement === canvasEl.firstChild ? canvasEl.firstChild : null;
523
+ clear(canvasEl);
524
+
525
+ function rerender() {
526
+ renderArchitectureView(viewModel, canvasEl, onSelect);
527
+ }
528
+
529
+ const zoneNodeBudget = computeZoneNodeBudget();
530
+ const clusteredZones = computeEffectiveClusteredLayout(viewModel.zones, viewModel.nodes, zoneNodeBudget, expandedZones);
531
+ const nodesById = new Map(viewModel.nodes.map((n) => [n.id, n]));
532
+
533
+ const zoneCount = clusteredZones.length;
534
+ const maxRowsInAZone = Math.max(1, ...clusteredZones.map((z) => z.visibleNodeIds.length + (z.cluster ? 1 : 0)));
535
+ const height = Math.max(480, maxRowsInAZone * (NODE_HEIGHT + NODE_GAP) + 80);
536
+ const width = zoneCount * ZONE_WIDTH;
537
+ const contentBounds = { x: 0, y: 0, width, height };
538
+
539
+ if (currentViewport === null) {
540
+ currentViewport = computeFitAllViewport(contentBounds);
541
+ }
542
+
543
+ const svg = svgEl('svg', {
544
+ class: 'arch-view',
545
+ viewBox: `${currentViewport.x} ${currentViewport.y} ${currentViewport.width} ${currentViewport.height}`,
546
+ role: 'img',
547
+ tabindex: '0',
548
+ 'aria-label': 'Architecture view: trust zones, nodes, and data-flow edges. Arrow keys pan, plus/minus zoom, 0 resets.',
549
+ });
550
+
551
+ // Pass 1: lay out every INDIVIDUALLY-VISIBLE node (post-clustering) and
552
+ // each zone's own cluster glyph, recording positions for all of them —
553
+ // viewport culling (pass 2 below) needs every position up front, since
554
+ // an edge's endpoint may resolve to either a node or a cluster glyph.
555
+ const nodePositions = new Map();
556
+ const pendingNodes = []; // {node, x, y}
557
+ const pendingClusters = []; // {zone, x, y}
558
+ clusteredZones.forEach((zone, zoneIndex) => {
559
+ const zoneX = zoneIndex * ZONE_WIDTH;
560
+ svg.appendChild(svgEl('rect', { class: 'arch-zone-bg', x: zoneX, y: 0, width: ZONE_WIDTH, height, rx: 4 }));
561
+ const zoneLabel = svgEl('text', { class: 'arch-zone-label', x: zoneX + ZONE_PADDING, y: 24 });
562
+ zoneLabel.textContent = zone.name;
563
+ svg.appendChild(zoneLabel);
564
+
565
+ let row = 0;
566
+ for (const nodeId of zone.visibleNodeIds) {
567
+ const node = nodesById.get(nodeId);
568
+ const y = 48 + row * (NODE_HEIGHT + NODE_GAP);
569
+ const x = zoneX + ZONE_PADDING;
570
+ nodePositions.set(nodeId, { x: x + NODE_WIDTH / 2, y: y + NODE_HEIGHT / 2 });
571
+ pendingNodes.push({ node, x, y });
572
+ row += 1;
573
+ }
574
+ if (zone.cluster) {
575
+ const y = 48 + row * (NODE_HEIGHT + NODE_GAP);
576
+ const x = zoneX + ZONE_PADDING;
577
+ nodePositions.set(zone.cluster.id, { x: x + NODE_WIDTH / 2, y: y + NODE_HEIGHT / 2 });
578
+ pendingClusters.push({ zone, x, y });
579
+ }
580
+ });
581
+
582
+ // Pass 2: viewport culling. A currently-selected node always renders
583
+ // regardless of the viewport, same "never hide the thing the user is
584
+ // looking at" principle clustering already applies to the budget.
585
+ const visible = visibleNodeIds(nodePositions, currentViewport, CULL_MARGIN);
586
+ for (const { node, x, y } of pendingNodes) {
587
+ if (!visible.has(node.id) && !node.selected) continue;
588
+ svg.appendChild(renderNode(node, x, y, onSelect));
589
+ }
590
+ for (const { zone, x, y } of pendingClusters) {
591
+ if (!visible.has(zone.cluster.id)) continue;
592
+ svg.appendChild(renderClusterGlyph(zone.cluster, x, y, () => {
593
+ expandedZones.add(zone.name);
594
+ // A just-expanded cluster's newly-individual nodes can land outside
595
+ // the current (pre-expansion) viewport; re-fit so the user actually
596
+ // sees what they just asked to see, rather than having it culled
597
+ // straight back out of the DOM. See currentViewport's own comment.
598
+ currentViewport = null;
599
+ rerender();
600
+ }));
601
+ }
602
+
603
+ // Edges: only edges actually touched by clustering (an endpoint that got
604
+ // folded into a cluster glyph) go through aggregateEdgesForClusters.
605
+ // Real, disclosed finding this session: aggregateEdgesForClusters groups
606
+ // PURELY by post-redirect (from, to) pair — with NO clustering involved
607
+ // at all, feeding it every edge unconditionally would ALSO merge two
608
+ // genuinely distinct real edges that happen to share the same (from, to)
609
+ // node pair (the flagship fixture has exactly this: masked_log's and
610
+ // raw_log's own log-write edges both run process->log with different
611
+ // verdicts), silently collapsing them to one worst-verdict edge and
612
+ // regressing AC-17's "raw vs masked render distinct verdicts" golden
613
+ // test. Splitting the input here (not touching aggregateEdgesForClusters
614
+ // itself, which Task 1 already shipped and tested) keeps every
615
+ // untouched edge exactly as before, and only reroutes+aggregates the
616
+ // ones clustering actually affected.
617
+ const clusteredMemberIds = new Set();
618
+ for (const zone of clusteredZones) {
619
+ if (zone.cluster) for (const memberId of zone.cluster.memberIds) clusteredMemberIds.add(memberId);
620
+ }
621
+ const edgesTouchedByClustering = viewModel.edges.filter((e) => clusteredMemberIds.has(e.from) || clusteredMemberIds.has(e.to));
622
+ const edgesUntouchedByClustering = viewModel.edges.filter((e) => !clusteredMemberIds.has(e.from) && !clusteredMemberIds.has(e.to));
623
+ const aggregatedEdges = edgesTouchedByClustering.length > 0 ? aggregateEdgesForClusters(edgesTouchedByClustering, clusteredZones) : [];
624
+ const allRenderableEdges = [...edgesUntouchedByClustering, ...aggregatedEdges];
625
+
626
+ // Culled the same way as nodes — an edge with either endpoint visible
627
+ // (or itself selected) still renders; dimmed edges are drawn first so a
628
+ // highlighted edge always renders on top.
629
+ const sortedEdges = [...allRenderableEdges].sort((a, b) => Number(a.selected) - Number(b.selected));
630
+ for (const edge of sortedEdges) {
631
+ const from = nodePositions.get(edge.from);
632
+ const to = nodePositions.get(edge.to);
633
+ if (!from || !to) continue; // an edge whose endpoint isn't rendered (shouldn't happen) is safely skipped, not a crash
634
+ if (!edge.selected && !visible.has(edge.from) && !visible.has(edge.to)) continue;
635
+ svg.appendChild(renderEdge(edge, from, to, onSelect));
636
+ }
637
+
638
+ const supportsPointerConversion = typeof svg.getScreenCTM === 'function' && typeof svg.createSVGPoint === 'function';
639
+ if (supportsPointerConversion) {
640
+ svg.addEventListener('wheel', (evt) => {
641
+ evt.preventDefault();
642
+ const { x: svgX, y: svgY } = screenToSvgPoint(svg, evt.clientX, evt.clientY);
643
+ currentViewport = applyWheelZoom(currentViewport, { deltaY: evt.deltaY, svgX, svgY }, zoomBoundsFor(contentBounds));
644
+ rerender();
645
+ });
646
+ svg.addEventListener('mousedown', (evt) => {
647
+ const p = screenToSvgPoint(svg, evt.clientX, evt.clientY);
648
+ dragState = { lastX: p.x, lastY: p.y };
649
+ });
650
+ svg.addEventListener('mousemove', (evt) => {
651
+ if (!dragState) return;
652
+ const p = screenToSvgPoint(svg, evt.clientX, evt.clientY);
653
+ // Content under the cursor should stay under the cursor: shift the
654
+ // viewport by the NEGATIVE of the cursor's own SVG-space delta.
655
+ const dxSvg = dragState.lastX - p.x;
656
+ const dySvg = dragState.lastY - p.y;
657
+ dragState = { lastX: p.x, lastY: p.y };
658
+ currentViewport = applyDragPan(currentViewport, { dxSvg, dySvg }, contentBounds);
659
+ rerender();
660
+ });
661
+ svg.addEventListener('mouseup', () => { dragState = null; });
662
+ svg.addEventListener('mouseleave', () => { dragState = null; });
663
+ }
664
+
665
+ svg.addEventListener('keydown', (evt) => {
666
+ switch (evt.key) {
667
+ case 'ArrowUp':
668
+ evt.preventDefault();
669
+ currentViewport = applyDragPan(currentViewport, { dxSvg: 0, dySvg: -KEYBOARD_PAN_STEP }, contentBounds);
670
+ break;
671
+ case 'ArrowDown':
672
+ evt.preventDefault();
673
+ currentViewport = applyDragPan(currentViewport, { dxSvg: 0, dySvg: KEYBOARD_PAN_STEP }, contentBounds);
674
+ break;
675
+ case 'ArrowLeft':
676
+ evt.preventDefault();
677
+ currentViewport = applyDragPan(currentViewport, { dxSvg: -KEYBOARD_PAN_STEP, dySvg: 0 }, contentBounds);
678
+ break;
679
+ case 'ArrowRight':
680
+ evt.preventDefault();
681
+ currentViewport = applyDragPan(currentViewport, { dxSvg: KEYBOARD_PAN_STEP, dySvg: 0 }, contentBounds);
682
+ break;
683
+ case '+':
684
+ case '=':
685
+ evt.preventDefault();
686
+ currentViewport = applyWheelZoom(
687
+ currentViewport,
688
+ { deltaY: -1, svgX: currentViewport.x + currentViewport.width / 2, svgY: currentViewport.y + currentViewport.height / 2 },
689
+ zoomBoundsFor(contentBounds),
690
+ );
691
+ break;
692
+ case '-':
693
+ evt.preventDefault();
694
+ currentViewport = applyWheelZoom(
695
+ currentViewport,
696
+ { deltaY: 1, svgX: currentViewport.x + currentViewport.width / 2, svgY: currentViewport.y + currentViewport.height / 2 },
697
+ zoomBoundsFor(contentBounds),
698
+ );
699
+ break;
700
+ case '0':
701
+ evt.preventDefault();
702
+ currentViewport = computeFitAllViewport(contentBounds);
703
+ break;
704
+ default:
705
+ return;
706
+ }
707
+ rerender();
708
+ });
709
+
710
+ canvasEl.appendChild(svg);
711
+ if (previouslyFocusedSvg && typeof svg.focus === 'function') svg.focus();
712
+ }
713
+
714
+ function renderNode(node, x, y, onSelect) {
715
+ const group = svgEl('g', {
716
+ class: 'arch-node',
717
+ 'data-selected': String(node.selected),
718
+ 'data-dimmed': String(node.dimmed),
719
+ tabindex: '0',
720
+ role: 'button',
721
+ 'aria-label': `${node.label}, ${node.kind}${node.selected ? ', selected' : ''}`,
722
+ onClick: () => onSelect(node.id),
723
+ onKeydown: (evt) => {
724
+ if (evt.key === 'Enter' || evt.key === ' ') {
725
+ evt.preventDefault();
726
+ onSelect(node.id);
727
+ }
728
+ },
729
+ });
730
+ group.appendChild(svgEl('rect', { class: 'arch-node-box', x, y, width: NODE_WIDTH, height: NODE_HEIGHT }));
731
+ const glyph = svgEl('text', { class: 'arch-node-glyph', x: x + 8, y: y + 16 });
732
+ glyph.textContent = node.kind.slice(0, 3).toUpperCase();
733
+ group.appendChild(glyph);
734
+ const label = svgEl('text', { class: 'arch-node-label', x: x + 8, y: y + 34 });
735
+ label.textContent = node.label;
736
+ group.appendChild(label);
737
+ return group;
738
+ }
739
+
740
+ function renderEdge(edge, from, to, onSelect) {
741
+ const visual = protectionVisual(edge.verdict);
742
+ const midX = (from.x + to.x) / 2;
743
+ const midY = (from.y + to.y) / 2;
744
+ const path = svgEl('path', {
745
+ d: `M ${from.x} ${from.y} L ${to.x} ${to.y}`,
746
+ style: `stroke: var(${visual.colorVar})`,
747
+ });
748
+ const group = svgEl('g', {
749
+ class: 'arch-edge',
750
+ 'data-selected': String(edge.selected),
751
+ 'data-dimmed': String(edge.dimmed),
752
+ tabindex: '0',
753
+ role: 'button',
754
+ 'aria-label': `Edge, protection ${visual.label}${edge.selected ? ', selected' : ''}`,
755
+ onClick: () => onSelect(edge.id),
756
+ onKeydown: (evt) => {
757
+ if (evt.key === 'Enter' || evt.key === ' ') {
758
+ evt.preventDefault();
759
+ onSelect(edge.id);
760
+ }
761
+ },
762
+ });
763
+ path.classList.add(`arch-edge-linestyle-${visual.lineStyle === 'solid' ? 'solid' : visual.lineStyle}`);
764
+ group.appendChild(path);
765
+ const glyph = svgEl('text', { class: 'arch-edge-glyph', x: midX, y: midY - 4, fill: `var(${visual.colorVar})` });
766
+ glyph.textContent = visual.glyph;
767
+ group.appendChild(glyph);
768
+ return group;
769
+ }
770
+
771
+ // The cluster glyph mirrors renderNode()'s own structure/pattern (a
772
+ // clickable <g> with a box + two text children) so it reads as "one more
773
+ // node-shaped thing" rather than a visually distinct control. `onExpand`
774
+ // is renderArchitectureView's own closure — adds this zone to
775
+ // `expandedZones` and re-renders.
776
+ function renderClusterGlyph(cluster, x, y, onExpand) {
777
+ const group = svgEl('g', {
778
+ class: 'arch-node-cluster',
779
+ tabindex: '0',
780
+ role: 'button',
781
+ 'aria-label': `${cluster.count} more ${cluster.kindSummary || 'nodes'} folded into this cluster. Activate to expand.`,
782
+ onClick: onExpand,
783
+ onKeydown: (evt) => {
784
+ if (evt.key === 'Enter' || evt.key === ' ') {
785
+ evt.preventDefault();
786
+ onExpand();
787
+ }
788
+ },
789
+ });
790
+ group.appendChild(svgEl('rect', { class: 'arch-node-cluster-box', x, y, width: NODE_WIDTH, height: NODE_HEIGHT }));
791
+ const count = svgEl('text', { class: 'arch-node-cluster-count', x: x + 8, y: y + 20 });
792
+ count.textContent = `+${cluster.count}`;
793
+ group.appendChild(count);
794
+ const kind = svgEl('text', { class: 'arch-node-cluster-kind', x: x + 8, y: y + 36 });
795
+ kind.textContent = cluster.kindSummary;
796
+ group.appendChild(kind);
797
+ return group;
798
+ }