@ghostlygawd/codeweb 0.9.0

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 (81) hide show
  1. package/.claude-plugin/plugin.json +30 -0
  2. package/CHANGELOG.md +480 -0
  3. package/LICENSE +21 -0
  4. package/README.md +573 -0
  5. package/agents/codeweb-dissector.md +56 -0
  6. package/agents/codeweb-domain-mapper.md +63 -0
  7. package/commands/codeweb.md +57 -0
  8. package/hooks/hooks.json +47 -0
  9. package/hooks/post-edit-diff.mjs +83 -0
  10. package/hooks/pre-edit-impact.mjs +94 -0
  11. package/hooks/session-brief.mjs +53 -0
  12. package/package.json +64 -0
  13. package/scripts/annotate.mjs +46 -0
  14. package/scripts/bench-ts-engine.mjs +59 -0
  15. package/scripts/bench.mjs +133 -0
  16. package/scripts/break-cycles.mjs +0 -0
  17. package/scripts/brief.mjs +28 -0
  18. package/scripts/build-report.mjs +182 -0
  19. package/scripts/campaign.mjs +61 -0
  20. package/scripts/check-consistency.mjs +45 -0
  21. package/scripts/ci-gate.mjs +86 -0
  22. package/scripts/cluster3.mjs +112 -0
  23. package/scripts/codemod.mjs +130 -0
  24. package/scripts/context-pack.mjs +118 -0
  25. package/scripts/deadcode.mjs +96 -0
  26. package/scripts/diff.mjs +0 -0
  27. package/scripts/explain.mjs +87 -0
  28. package/scripts/extract-symbols.mjs +1307 -0
  29. package/scripts/find-similar.mjs +86 -0
  30. package/scripts/find.mjs +50 -0
  31. package/scripts/fitness.mjs +90 -0
  32. package/scripts/grammars/PROVENANCE.md +36 -0
  33. package/scripts/grammars/tree-sitter-c-sharp.wasm +0 -0
  34. package/scripts/grammars/tree-sitter-go.wasm +0 -0
  35. package/scripts/grammars/tree-sitter-java.wasm +0 -0
  36. package/scripts/grammars/tree-sitter-python.wasm +0 -0
  37. package/scripts/grammars/tree-sitter-rust.wasm +0 -0
  38. package/scripts/grammars/tree-sitter-typescript.wasm +0 -0
  39. package/scripts/hotspots.mjs +53 -0
  40. package/scripts/lib/annotations.mjs +51 -0
  41. package/scripts/lib/bench-core.mjs +178 -0
  42. package/scripts/lib/brief-core.mjs +92 -0
  43. package/scripts/lib/campaign.mjs +73 -0
  44. package/scripts/lib/claims-check.mjs +44 -0
  45. package/scripts/lib/cli.mjs +140 -0
  46. package/scripts/lib/complexity.mjs +68 -0
  47. package/scripts/lib/dup-check.mjs +51 -0
  48. package/scripts/lib/find-core.mjs +85 -0
  49. package/scripts/lib/gate-md.mjs +50 -0
  50. package/scripts/lib/graph-ops.mjs +319 -0
  51. package/scripts/lib/hotspots.mjs +34 -0
  52. package/scripts/lib/query-core.mjs +85 -0
  53. package/scripts/lib/reading-order.mjs +53 -0
  54. package/scripts/lib/reliance.mjs +143 -0
  55. package/scripts/lib/risk.mjs +18 -0
  56. package/scripts/lib/shingles.mjs +39 -0
  57. package/scripts/lib/skeleton.mjs +41 -0
  58. package/scripts/lib/stats.mjs +92 -0
  59. package/scripts/lib/ts-engine.mjs +605 -0
  60. package/scripts/mcp-server.mjs +370 -0
  61. package/scripts/optimize.mjs +152 -0
  62. package/scripts/overlap.mjs +380 -0
  63. package/scripts/placement.mjs +93 -0
  64. package/scripts/query.mjs +94 -0
  65. package/scripts/reading-order.mjs +36 -0
  66. package/scripts/refresh.mjs +73 -0
  67. package/scripts/release-utils.mjs +158 -0
  68. package/scripts/release.mjs +62 -0
  69. package/scripts/report-template.html +730 -0
  70. package/scripts/review.mjs +112 -0
  71. package/scripts/risk.mjs +77 -0
  72. package/scripts/run.mjs +96 -0
  73. package/scripts/screenshot.mjs +104 -0
  74. package/scripts/simulate-edit.mjs +70 -0
  75. package/scripts/stats.mjs +31 -0
  76. package/scripts/trend.mjs +147 -0
  77. package/scripts/version-sync.mjs +19 -0
  78. package/skills/codebase-anatomy/SKILL.md +174 -0
  79. package/skills/codebase-anatomy/references/engine-detection.md +49 -0
  80. package/skills/codebase-anatomy/references/graph-schema.md +120 -0
  81. package/skills/codebase-anatomy/references/overlap-heuristics.md +52 -0
@@ -0,0 +1,730 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>codeweb — system map</title>
7
+ <style>
8
+ :root{
9
+ /* Crafted Dark — warm ink + electric chartreuse; mirrors the site tokens + brand.json.
10
+ The ACCENT is brand/UI only (selection, focus, links) — never a data series.
11
+ Data colors are the validated 8-slot categorical set (fixed order, CVD-checked against
12
+ this surface: worst adjacent ΔE 8.4 protan / 19.3 normal, all ≥3:1) + reserved status
13
+ colors for severity. No web fonts; system stacks only. */
14
+ --bg:#100E14; --panel:#1A1820; --panel2:#232029; --line:#322E3A;
15
+ --fg:#ECECEE; --muted:#9C99A6; --accent:#C6F24E; --accentFg:#0A090D;
16
+ --ring:rgba(255,255,255,.10);
17
+ /* categorical slots (fixed order — assigned to areas by size rank, 9+ fold to neutral) */
18
+ --s1:#3987e5; --s2:#008300; --s3:#d55181; --s4:#c98500;
19
+ --s5:#199e70; --s6:#d95926; --s7:#9085e9; --s8:#e66767; --sX:#7d7a87;
20
+ /* status (reserved for severity — never a series) */
21
+ --stWarn:#fab219; --stSerious:#ec835a; --stCritical:#d03b3b; --stGood:#0ca30c;
22
+ }
23
+ *{box-sizing:border-box}
24
+ html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);
25
+ font:13px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,Arial,sans-serif}
26
+ #top{height:48px;display:flex;align-items:center;gap:6px;padding:0 14px;
27
+ border-bottom:1px solid var(--line);background:var(--panel)}
28
+ #top b{font-size:15px;letter-spacing:.5px;margin-right:8px}
29
+ #top .meta{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:46vw}
30
+ #top .spacer{flex:1}
31
+ .tab{background:transparent;color:var(--muted);border:1px solid transparent;border-radius:7px;
32
+ padding:6px 12px;cursor:pointer;font-size:13px}
33
+ .tab:hover{color:var(--fg)}
34
+ .tab.on{color:var(--fg);background:var(--panel2);border-color:var(--line)}
35
+ #wrap{display:flex;height:calc(100% - 48px)}
36
+ #main{flex:1;position:relative;min-width:0;overflow:hidden}
37
+ .view{position:absolute;inset:0;overflow:auto;padding:16px 18px;display:none}
38
+ .view.on{display:block}
39
+ #view-graph.on,#view-treemap.on{padding:0}
40
+ #detail{width:340px;border-left:1px solid var(--line);background:var(--panel);overflow:auto;padding:14px}
41
+ #detail h3{margin:0 0 4px;font-size:14px;word-break:break-all}
42
+ #detail .kv{color:var(--muted);font-size:12px;margin:3px 0}
43
+ #detail .sec{margin-top:14px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}
44
+ #detail .lst div{padding:3px 0;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
45
+ #detail .lst div:hover{color:var(--accent)}
46
+ .empty{color:var(--muted);padding:24px;text-align:center}
47
+ code{background:var(--panel2);padding:1px 5px;border-radius:4px;font-size:11px}
48
+ /* findings */
49
+ .fsec{margin-bottom:26px}
50
+ .fhead{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
51
+ .fhead h2{margin:0;font-size:15px}
52
+ .fhead .sub{color:var(--muted);font-size:12px}
53
+ table.f{width:100%;max-width:980px;border-collapse:collapse;font-size:13px}
54
+ table.f td{padding:6px 8px;border-bottom:1px solid var(--line);cursor:pointer}
55
+ table.f td:first-child{width:44px}
56
+ table.f tr:hover td{background:var(--panel2)}
57
+ .badge{display:inline-block;min-width:22px;text-align:center;padding:1px 7px;border-radius:11px;font-size:11px;font-weight:700;color:var(--accentFg);font-variant-numeric:tabular-nums}
58
+ .b-hi{background:var(--stCritical);color:#fff} .b-med{background:var(--stSerious)} .b-low{background:var(--stWarn)}
59
+ .b-acc{background:var(--panel2);color:var(--fg);box-shadow:inset 0 0 0 1px var(--ring)}
60
+ .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}
61
+ .dim{color:var(--muted)}
62
+ .pill{font-size:10px;color:var(--muted);border:1px solid var(--line);border-radius:10px;padding:0 6px;margin-left:4px}
63
+ /* matrix */
64
+ table.m{border-collapse:separate;border-spacing:2px;font-size:11px}
65
+ table.m th{padding:4px 8px;color:var(--muted);font-weight:600;text-align:right;white-space:nowrap}
66
+ table.m th.rot{height:104px;vertical-align:bottom;text-align:center}
67
+ table.m th.rot span{writing-mode:vertical-rl;transform:rotate(180deg);white-space:nowrap}
68
+ table.m td{width:36px;height:36px;text-align:center;border-radius:4px;cursor:pointer;color:var(--fg);font-weight:600;font-variant-numeric:tabular-nums;background:var(--panel)}
69
+ table.m td:hover{box-shadow:inset 0 0 0 2px var(--accent)}
70
+ table.m td.diag{box-shadow:inset 0 0 0 1px var(--ring)}
71
+ table.m td.zero{color:var(--line);background:var(--panel)!important;cursor:default;box-shadow:none}
72
+ .legend{color:var(--muted);font-size:12px;margin:12px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
73
+ .chip{display:inline-block;width:11px;height:11px;border-radius:3px;vertical-align:-1px;box-shadow:inset 0 0 0 1px var(--ring)}
74
+ /* treemap */
75
+ #tm{position:absolute;inset:0;padding:6px}
76
+ .tmbox{position:absolute;overflow:hidden;border:2px solid var(--bg);border-radius:4px;cursor:pointer;font-size:10px}
77
+ .tmbox:hover{outline:2px solid var(--accent);z-index:5}
78
+ .tmdom{border:1px solid var(--line);border-radius:6px;background:var(--panel);cursor:default}
79
+ .tmlabel{position:absolute;top:1px;left:4px;right:3px;color:#ECECEE;font-size:10px;text-shadow:0 1px 2px rgba(0,0,0,.8);pointer-events:none;white-space:nowrap;overflow:hidden}
80
+ .tmdomlabel{position:absolute;font-size:11px;font-weight:600;color:var(--fg);pointer-events:none;white-space:nowrap;overflow:hidden;display:flex;align-items:center;gap:5px}
81
+ #tmLegend{position:absolute;right:14px;bottom:12px;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:11px;color:var(--muted);display:flex;align-items:center;gap:7px;z-index:6}
82
+ #tmLegend .bar{width:90px;height:8px;border-radius:4px;background:linear-gradient(90deg,#292331,#e66767)}
83
+ /* graph */
84
+ #cv{display:block;background:radial-gradient(circle at 50% 38%,#16131d,#100E14);touch-action:none}
85
+ #gsearch{position:absolute;top:10px;left:10px;background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:6px;padding:6px 9px;width:220px}
86
+ #gsearch:focus{outline:none;border-color:var(--accent)}
87
+ #gctrl{position:absolute;top:10px;right:10px;display:flex;gap:6px}
88
+ #gctrl button{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 9px;cursor:pointer;font-size:12px}
89
+ #gctrl button:hover{border-color:var(--accent)}
90
+ #gLegend{position:absolute;left:12px;bottom:12px;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:11px;color:var(--muted);z-index:6}
91
+ /* header brand */
92
+ #logo{width:20px;height:20px;flex:none;margin-right:2px}
93
+ /* mobile: the desktop app layout (fixed 340px sidebar, viewport-locked) becomes a vertical stack */
94
+ @media (max-width: 760px){
95
+ html,body{height:auto;min-height:100%}
96
+ body{overflow-x:hidden}
97
+ #top{height:auto;min-height:48px;flex-wrap:wrap;padding:8px 12px;gap:6px;position:sticky;top:0;z-index:20}
98
+ #top b{margin-right:4px}
99
+ #top .meta{display:none}
100
+ #top .spacer{display:none}
101
+ .tab{padding:6px 10px;font-size:12px}
102
+ #wrap{flex-direction:column;height:auto}
103
+ #main{flex:none;height:64vh;min-height:340px}
104
+ #detail{width:auto;border-left:0;border-top:1px solid var(--line);max-height:none}
105
+ .view{padding:12px 14px}
106
+ #view-graph.on,#view-treemap.on,#view-matrix.on{padding:0}
107
+ #view-matrix.on{padding:12px;overflow:auto}
108
+ table.m{font-size:10px}
109
+ table.m td{width:24px;height:24px}
110
+ #gsearch{width:150px;font-size:12px}
111
+ }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <div id="top">
116
+ <svg id="logo" viewBox="0 0 24 24" aria-hidden="true"><g fill="none" stroke="#C6F24E" stroke-width="1.6"><path d="M6 8 L18 6 M6 8 L12 18 M18 6 L12 18" opacity=".55"/></g><circle cx="6" cy="8" r="3" fill="#C6F24E"/><circle cx="18" cy="6" r="2.4" fill="#9085e9"/><circle cx="12" cy="18" r="2.4" fill="#199e70"/></svg>
117
+ <b>codeweb</b>
118
+ <span class="meta" id="metaTarget"></span>
119
+ <span class="meta" id="metaStats"></span>
120
+ <span class="spacer"></span>
121
+ <button id="roleFilter" title="Hide tests, fixtures, examples and generated code from every view" aria-pressed="true" hidden></button>
122
+ <div role="tablist" aria-label="Report views" style="display:contents">
123
+ <button class="tab on" role="tab" aria-selected="true" data-view="findings">Findings</button>
124
+ <button class="tab" role="tab" aria-selected="false" data-view="matrix">Matrix</button>
125
+ <button class="tab" role="tab" aria-selected="false" data-view="treemap">Treemap</button>
126
+ <button class="tab" role="tab" aria-selected="false" data-view="graph">Graph</button>
127
+ </div>
128
+ </div>
129
+ <div id="wrap">
130
+ <div id="main">
131
+ <div class="view on" id="view-findings"></div>
132
+ <div class="view" id="view-matrix"></div>
133
+ <div class="view" id="view-treemap"><div id="tm"></div><div id="tmLegend"><span>duplication</span><span class="bar"></span><span>0 → 100%</span></div></div>
134
+ <div class="view" id="view-graph">
135
+ <canvas id="cv" role="img" aria-label="Force-directed dependency graph"></canvas>
136
+ <input id="gsearch" placeholder="search symbols… (Enter jumps)" autocomplete="off" aria-label="Search symbols; press Enter to jump to the next match, Escape to clear">
137
+ <span id="gcount" class="meta" style="position:absolute;top:16px;left:245px" aria-live="polite"></span>
138
+ <div id="gctrl"><button id="gFit">Fit</button><button id="gToggle"></button></div>
139
+ <div id="gLegend">● area — size = symbols, click to expand it · ○ symbol — size = loc · scroll zooms</div>
140
+ </div>
141
+ </div>
142
+ <div id="detail" aria-live="polite"><div class="empty">Pick an item to inspect it.</div></div>
143
+ </div>
144
+
145
+ <script id="graph-data" type="application/json">__GRAPH_DATA__</script>
146
+
147
+ <script>
148
+ (function () {
149
+ "use strict";
150
+ var G;
151
+ try { G = JSON.parse(document.getElementById('graph-data').textContent); }
152
+ catch (e) { document.body.innerHTML = '<p style="color:#f88;padding:24px">Failed to parse graph data: ' + e.message + '</p>'; return; }
153
+ G.nodes = G.nodes || []; G.edges = G.edges || []; G.domains = G.domains || []; G.overlaps = G.overlaps || []; G.meta = G.meta || {};
154
+ G.nodes.forEach(function (n) { if (!n.domain) n.domain = 'unassigned'; });
155
+
156
+ // ---------- code roles ----------
157
+ // Product code vs supporting code (tests / fixtures / examples / benches / generated). The
158
+ // extractor stamps node.role; older graphs fall back to a path heuristic here. Rankings and
159
+ // views default to PRODUCT scope — on a monorepo the map otherwise drowns in playground noise.
160
+ function deriveRole(file) {
161
+ var f = file || '';
162
+ if (/(^|\/)(tests?|__tests__)\//.test(f) || /(\.test\.|\.spec\.|_test\.)/.test(f)) return 'test';
163
+ if (/(^|\/)(fixtures?|__fixtures__|__mocks__|mocks?)\//.test(f)) return 'fixture';
164
+ if (/(^|\/)(examples?|samples?|demos?|playgrounds?|playground|sandbox|e2e)\//.test(f)) return 'example';
165
+ if (/(^|\/)(benchmarks?|bench|perf)\//.test(f)) return 'bench';
166
+ if (/\.(min|bundle)\.[cm]?js$/.test(f) || /(^|\/)(generated|__generated__)\//.test(f)) return 'generated';
167
+ return 'product';
168
+ }
169
+ G.nodes.forEach(function (n) { if (!n.role) n.role = deriveRole(n.file); });
170
+ var nonProduct = G.nodes.filter(function (n) { return n.role !== 'product'; }).length;
171
+ var productOnly = nonProduct > 0; // default: product scope whenever there is anything to hide
172
+
173
+ // ---------- indexes (full graph — the detail panel always shows the whole truth) ----------
174
+ var nodeById = new Map(G.nodes.map(function (n) { return [n.id, n]; }));
175
+ var outAdj = new Map(), inAdj = new Map(), inDeg = new Map(), outDeg = new Map();
176
+ G.nodes.forEach(function (n) { outAdj.set(n.id, []); inAdj.set(n.id, []); inDeg.set(n.id, 0); outDeg.set(n.id, 0); });
177
+ G.edges.forEach(function (e) {
178
+ if (!nodeById.has(e.from) || !nodeById.has(e.to)) return;
179
+ outAdj.get(e.from).push(e.to); inAdj.get(e.to).push(e.from);
180
+ outDeg.set(e.from, outDeg.get(e.from) + 1); inDeg.set(e.to, inDeg.get(e.to) + 1);
181
+ });
182
+
183
+ // The validated categorical set (fixed order — the ORDER is the CVD-safety mechanism).
184
+ // Areas get slots by size rank; a 9th+ area folds to neutral (labels still carry identity).
185
+ var domColor = {};
186
+ var DOMC = ['#3987e5', '#008300', '#d55181', '#c98500', '#199e70', '#d95926', '#9085e9', '#e66767'];
187
+ var NEUTRAL = '#7d7a87';
188
+ function colorOf(nm) { return domColor[nm] || NEUTRAL; }
189
+ // darker step of the same hue — node rings, treemap header chips
190
+ function shade(hex, f) {
191
+ var n = parseInt(hex.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
192
+ return 'rgb(' + Math.round(r * f) + ',' + Math.round(g * f) + ',' + Math.round(b * f) + ')';
193
+ }
194
+ var domSummary = {}; G.domains.forEach(function (d) { domSummary[d.name] = d; });
195
+
196
+ // ---------- active scope + derived metrics (recomputed when the role filter toggles) ----------
197
+ var AN = [], AE = [], activeIds = null, domainNames = [], duplicates = [], patterns = [], dupNodeIds = new Set(), hotspots = [], dead = [];
198
+ var ENTRY = /^(main|index|cli|run|start|default|setup|init|activate|handler|middleware|module)$/i;
199
+ function domCount(nm) { return AN.filter(function (n) { return n.domain === nm; }).length; }
200
+ function computeDerived() {
201
+ AN = productOnly ? G.nodes.filter(function (n) { return n.role === 'product'; }) : G.nodes;
202
+ activeIds = new Set(AN.map(function (n) { return n.id; }));
203
+ AE = G.edges.filter(function (e) { return activeIds.has(e.from) && activeIds.has(e.to); });
204
+
205
+ domainNames = Array.from(new Set(AN.map(function (n) { return n.domain; })));
206
+ domainNames = domainNames.filter(function (nm) { return domCount(nm) > 0; }).sort(function (a, b) { return domCount(b) - domCount(a); });
207
+ domainNames.forEach(function (nm, i) { if (!domColor[nm]) domColor[nm] = DOMC[i] || NEUTRAL; });
208
+
209
+ // duplicates: same symbol name defined in >=2 files — split off INTERFACE PATTERNS (many
210
+ // same-named implementations that nothing in-repo calls: framework hooks/contracts like a
211
+ // bundler plugin's resolveId(). "Merge these" is wrong advice for those.)
212
+ var byName = new Map();
213
+ AN.forEach(function (n) {
214
+ if (!['function', 'method', 'class'].includes(n.kind)) return;
215
+ if (!byName.has(n.label)) byName.set(n.label, []); byName.get(n.label).push(n);
216
+ });
217
+ duplicates = []; patterns = [];
218
+ byName.forEach(function (arr, name) {
219
+ var files = new Set(arr.map(function (n) { return n.file; }));
220
+ if (files.size >= 2 && name.length > 3) {
221
+ var doms = new Set(arr.map(function (n) { return n.domain; }));
222
+ var uncalled = arr.filter(function (n) { return inDeg.get(n.id) === 0; }).length;
223
+ var g = { name: name, files: files.size, count: arr.length, nodes: arr, domains: Array.from(doms) };
224
+ if (files.size >= 4 && uncalled / arr.length >= 0.75) patterns.push(g); else duplicates.push(g);
225
+ }
226
+ });
227
+ duplicates.sort(function (a, b) { return b.files - a.files; });
228
+ patterns.sort(function (a, b) { return b.files - a.files; });
229
+ dupNodeIds = new Set(); duplicates.forEach(function (d) { d.nodes.forEach(function (n) { dupNodeIds.add(n.id); }); });
230
+
231
+ hotspots = AN.slice().filter(function (n) { return inDeg.get(n.id) > 0; })
232
+ .sort(function (a, b) { return inDeg.get(b.id) - inDeg.get(a.id); }).slice(0, 30);
233
+
234
+ dead = AN.filter(function (n) {
235
+ return inDeg.get(n.id) === 0 && ['function', 'method'].includes(n.kind) && !ENTRY.test(n.label) && (n.loc || 0) >= 3;
236
+ }).sort(function (a, b) { return (b.loc || 0) - (a.loc || 0); });
237
+ }
238
+ computeDerived();
239
+
240
+ // ---------- meta bar + role filter ----------
241
+ function renderMeta() {
242
+ document.getElementById('metaTarget').textContent = (G.meta.target || '') + (G.meta.mode ? ' · ' + G.meta.mode : '');
243
+ var el = document.getElementById('metaStats');
244
+ el.textContent = [
245
+ AN.length + ' symbols' + (productOnly && nonProduct ? ' (+' + nonProduct + ' hidden)' : ''),
246
+ AE.length + ' edges', domainNames.length + ' areas', duplicates.length + ' dup-groups'
247
+ ].filter(Boolean).join(' · ');
248
+ el.title = G.meta.engine ? 'extraction engine: ' + G.meta.engine : ''; // internals live in the tooltip, not the masthead
249
+ }
250
+ renderMeta();
251
+ var roleBtn = document.getElementById('roleFilter');
252
+ if (nonProduct > 0) {
253
+ roleBtn.hidden = false;
254
+ var syncRoleBtn = function () {
255
+ roleBtn.textContent = productOnly ? 'Product only ✓' : 'All code';
256
+ roleBtn.setAttribute('aria-pressed', String(productOnly));
257
+ roleBtn.className = 'tab' + (productOnly ? ' on' : '');
258
+ };
259
+ syncRoleBtn();
260
+ roleBtn.onclick = function () {
261
+ productOnly = !productOnly; syncRoleBtn();
262
+ computeDerived(); renderMeta();
263
+ rendered = {}; // every view re-renders against the new scope
264
+ var cur = document.querySelector('.tab[role="tab"].on').getAttribute('data-view');
265
+ if (gInit) { gLayout(); syncToggle(); }
266
+ activate(cur);
267
+ };
268
+ }
269
+
270
+ // ---------- detail panel ----------
271
+ var detail = document.getElementById('detail');
272
+ detail.addEventListener('click', function (ev) {
273
+ var a = ev.target.closest('[data-set-root]');
274
+ if (!a) return;
275
+ ev.preventDefault();
276
+ var v = prompt('Absolute project root for editor links (stored only in this browser):', editorRoot());
277
+ if (v == null) return;
278
+ try { localStorage.setItem('cwEditorRoot', v.trim().replace(/\/+$/, '')); } catch (e) { /* private mode */ }
279
+ if (lastNodeId) showNode(lastNodeId);
280
+ });
281
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
282
+ // First paint is an OVERVIEW, not an empty panel — the 340px column always earns its width.
283
+ // Spec J: the report is where a human decides to act — close the loop with a real editor link.
284
+ // The shipped HTML never embeds the absolute source path (standing privacy invariant: reports
285
+ // are shareable), so the VIEWER supplies their project root once — stored in localStorage,
286
+ // client-side only — and every node gets a vscode://file/<root>/<file>:<line> link. Without a
287
+ // root: a copyable file:line plus the one-click affordance to set it. Nothing silently missing.
288
+ var lastNodeId = null;
289
+ function editorRoot() { try { return localStorage.getItem('cwEditorRoot') || ''; } catch (e) { return ''; } }
290
+ function editorLink(n) {
291
+ if (!n || !n.file) return '';
292
+ var line = n.line || 1, root = editorRoot();
293
+ if (root) {
294
+ var abs = root.replace(/\/+$/, '') + '/' + n.file;
295
+ return '<div class="kv"><a href="vscode://file/' + esc(abs) + ':' + line + '" style="color:var(--accent)">Open in editor</a> · <a href="#" data-set-root style="color:var(--muted)">root ✎</a></div>';
296
+ }
297
+ return '<div class="kv">editor: <code>' + esc(n.file + ':' + line) + '</code> · <a href="#" data-set-root style="color:var(--muted)">set root for editor links</a></div>';
298
+ }
299
+ function detailEmpty() {
300
+ var topDoms = domainNames.slice(0, 5).map(function (nm) {
301
+ return '<div class="kv"><span class="chip" style="background:' + colorOf(nm) + '"></span> ' + esc(nm) + ' <span class="dim">· ' + domCount(nm) + ' symbols</span></div>';
302
+ }).join('');
303
+ detail.innerHTML = '<h3>' + esc(G.meta.target || 'this repo') + '</h3>' +
304
+ '<div class="kv">' + AN.length + ' symbols · ' + AE.length + ' edges · ' + domainNames.length + ' areas</div>' +
305
+ '<div class="sec">largest areas</div>' + topDoms +
306
+ '<div class="sec">worth a look</div>' +
307
+ '<div class="kv">' + duplicates.length + ' duplication group(s) · ' + hotspots.length + ' hotspot(s) · ' + dead.length + ' dead-code candidate(s)</div>' +
308
+ '<div class="sec">how to read this</div>' +
309
+ '<div class="kv">Click any row, cell, or node to inspect it here. In the graph, click an area bubble to expand it; search + Enter jumps to a symbol. <code>#s=&lt;id&gt;</code> deep-links.</div>';
310
+ }
311
+ function nodeRow(id) { var t = nodeById.get(id); return '<div data-node="' + esc(id) + '">' + esc(t ? (t.label || id) : id) + ' <span class="dim mono">' + esc(t ? t.file : '') + '</span></div>'; }
312
+ function bindNodeRows(scope) {
313
+ (scope || detail).querySelectorAll('[data-node]').forEach(function (el) {
314
+ var go = function () { showNode(el.getAttribute('data-node')); };
315
+ el.onclick = go;
316
+ el.setAttribute('tabindex', '0');
317
+ el.onkeydown = function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); go(); } };
318
+ });
319
+ }
320
+ function showNode(id) {
321
+ var n = nodeById.get(id); if (!n) return;
322
+ lastNodeId = id;
323
+ try { history.replaceState(null, '', '#s=' + encodeURIComponent(id)); } catch (e) { /* file:// quirk */ }
324
+ var outs = outAdj.get(id) || [], ins = inAdj.get(id) || [];
325
+ detail.innerHTML = '<h3>' + esc(n.label) + '</h3>' +
326
+ '<div class="kv">' + esc(n.kind || '') + (n.role && n.role !== 'product' ? ' · ' + esc(n.role) : '') + (n.exports ? ' · exported' : '') + (dupNodeIds.has(id) ? ' · <span style="color:var(--hi)">duplicated</span>' : '') + '</div>' +
327
+ '<div class="kv"><span style="display:inline-block;width:10px;height:10px;border-radius:3px;background:' + colorOf(n.domain) + '"></span> ' + esc(n.domain) + '</div>' +
328
+ (n.file ? '<div class="kv"><code>' + esc(n.file) + (n.line ? ':' + n.line : '') + '</code>' + (n.loc ? ' · ' + n.loc + ' loc' : '') + '</div>' : '') +
329
+ editorLink(n) +
330
+ (n.summary ? '<div class="kv">' + esc(n.summary) + '</div>' : '') +
331
+ '<div class="sec">used by (' + ins.length + ')</div><div class="lst" id="d_in"></div>' +
332
+ '<div class="sec">calls (' + outs.length + ')</div><div class="lst" id="d_out"></div>';
333
+ document.getElementById('d_in').innerHTML = ins.slice(0, 60).map(nodeRow).join('') || '<div class="empty">— (nothing calls this)</div>';
334
+ document.getElementById('d_out').innerHTML = outs.slice(0, 60).map(nodeRow).join('') || '<div class="empty">—</div>';
335
+ bindNodeRows();
336
+ }
337
+ function showDup(d) {
338
+ detail.innerHTML = '<h3>' + esc(d.name) + '()</h3>' +
339
+ '<div class="kv">defined in <b>' + d.files + ' files</b> · ' + d.domains.length + ' area(s)</div>' +
340
+ '<div class="kv">' + esc(d.domains.join(', ')) + '</div>' +
341
+ '<div class="sec">every definition</div><div class="lst" id="d_dup"></div>';
342
+ document.getElementById('d_dup').innerHTML = d.nodes.map(function (n) { return nodeRow(n.id); }).join('');
343
+ bindNodeRows();
344
+ }
345
+
346
+ // ---------- FINDINGS ----------
347
+ function sev(files) { return files >= 6 ? 'hi' : files >= 4 ? 'med' : 'low'; }
348
+ function section(title, sub, n, body) {
349
+ return '<div class="fsec"><div class="fhead"><h2>' + esc(title) + '</h2><span class="sub">' + esc(sub) + '</span><span class="pill">' + n + '</span></div>' + body + '</div>';
350
+ }
351
+ function renderFindings() {
352
+ var v = document.getElementById('view-findings');
353
+ var dupRows = duplicates.slice(0, 40).map(function (d, i) {
354
+ return '<tr data-dup="' + i + '"><td><span class="badge b-' + sev(d.files) + '">' + d.files + '</span></td>' +
355
+ '<td class="mono">' + esc(d.name) + '()</td>' +
356
+ '<td class="dim">' + esc(d.domains.slice(0, 4).join(', ')) + (d.domains.length > 4 ? '…' : '') + '</td>' +
357
+ '<td class="dim">defined in ' + d.files + ' files</td></tr>';
358
+ }).join('');
359
+ var hotRows = hotspots.map(function (n) {
360
+ return '<tr data-node="' + esc(n.id) + '"><td><span class="badge b-acc">' + inDeg.get(n.id) + '</span></td>' +
361
+ '<td class="mono">' + esc(n.label) + '</td><td class="dim mono">' + esc(n.file) + '</td>' +
362
+ '<td class="dim">used by ' + inDeg.get(n.id) + '</td></tr>';
363
+ }).join('');
364
+ var deadRows = dead.slice(0, 40).map(function (n) {
365
+ return '<tr data-node="' + esc(n.id) + '"><td class="mono">' + esc(n.label) + '</td>' +
366
+ '<td class="dim mono">' + esc(n.file) + (n.line ? ':' + n.line : '') + '</td>' +
367
+ '<td class="dim">' + (n.loc || 0) + ' loc' + (n.exports ? ' · exported' : '') + '</td></tr>';
368
+ }).join('');
369
+ var patRows = patterns.slice(0, 20).map(function (d, i) {
370
+ return '<tr data-pat="' + i + '"><td><span class="badge b-low">' + d.files + '</span></td>' +
371
+ '<td class="mono">' + esc(d.name) + '()</td>' +
372
+ '<td class="dim">' + esc(d.domains.slice(0, 4).join(', ')) + (d.domains.length > 4 ? '…' : '') + '</td>' +
373
+ '<td class="dim">' + d.files + ' implementations, none called in-repo</td></tr>';
374
+ }).join('');
375
+ v.innerHTML =
376
+ section('Duplication', 'merge these — same function defined in many files', duplicates.length,
377
+ dupRows ? '<table class="f">' + dupRows + '</table>' : '<div class="empty">No duplication found 🎉</div>') +
378
+ (patterns.length ? section('Interface patterns', 'same-name implementations of a framework contract (nothing in-repo calls them) — NOT copy-paste; don’t merge', patterns.length,
379
+ '<table class="f">' + patRows + '</table>') : '') +
380
+ section('Hotspots', 'most depended-on code — change with care', hotspots.length,
381
+ hotRows ? '<table class="f">' + hotRows + '</table>' : '<div class="empty">—</div>') +
382
+ section('Likely dead code', 'nothing in this graph calls these', dead.length,
383
+ deadRows ? '<table class="f">' + deadRows + '</table>' : '<div class="empty">—</div>');
384
+ v.querySelectorAll('tr[data-dup]').forEach(function (tr) { tr.onclick = function () { showDup(duplicates[+tr.getAttribute('data-dup')]); }; });
385
+ v.querySelectorAll('tr[data-pat]').forEach(function (tr) { tr.onclick = function () { showDup(patterns[+tr.getAttribute('data-pat')]); }; });
386
+ v.querySelectorAll('tr[data-dup],tr[data-pat],tr[data-node]').forEach(function (tr) {
387
+ tr.setAttribute('tabindex', '0');
388
+ tr.onkeydown = function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); tr.onclick(); } };
389
+ });
390
+ bindNodeRows(v);
391
+ }
392
+
393
+ // ---------- MATRIX ----------
394
+ function renderMatrix() {
395
+ var v = document.getElementById('view-matrix');
396
+ var idx = {}; domainNames.forEach(function (d, i) { idx[d] = i; });
397
+ var M = domainNames.map(function () { return domainNames.map(function () { return 0; }); });
398
+ AE.forEach(function (e) {
399
+ var a = nodeById.get(e.from), b = nodeById.get(e.to); if (!a || !b) return;
400
+ if (idx[a.domain] == null || idx[b.domain] == null) return;
401
+ M[idx[a.domain]][idx[b.domain]] += (e.weight || 1);
402
+ });
403
+ var max = 1; M.forEach(function (r, i) { r.forEach(function (val, j) { if (i !== j && val > max) max = val; }); });
404
+ var head = '<th></th>' + domainNames.map(function (d) { return '<th class="rot"><span>' + esc(d) + '</span></th>'; }).join('');
405
+ var rows = domainNames.map(function (da, i) {
406
+ var cells = domainNames.map(function (db, j) {
407
+ var val = M[i][j];
408
+ if (val === 0) return '<td class="zero' + (i === j ? ' diag' : '') + '">·</td>';
409
+ var t = Math.min(1, Math.log(val + 1) / Math.log(max + 1));
410
+ var bg = i === j ? 'rgba(25,158,112,' + (0.28 + t * 0.55) + ')' : 'rgba(57,135,229,' + (0.2 + t * 0.72) + ')';
411
+ return '<td class="' + (i === j ? 'diag' : '') + '" style="background:' + bg + '" data-a="' + i + '" data-b="' + j + '">' + val + '</td>';
412
+ }).join('');
413
+ return '<tr><th>' + esc(da) + '</th>' + cells + '</tr>';
414
+ }).join('');
415
+ v.innerHTML = '<div class="fhead"><h2>Area coupling</h2><span class="sub">rows call columns</span></div>' +
416
+ '<table class="m"><tr>' + head + '</tr>' + rows + '</table>' +
417
+ '<div class="legend"><span class="chip" style="background:rgba(57,135,229,.8)"></span> cross-area calls — tangling' +
418
+ '<span class="chip" style="background:rgba(25,158,112,.8)"></span> diagonal — internal cohesion' +
419
+ '<span>· big off-diagonal numbers = merge candidates or missing interface · click a cell for its edges</span></div>';
420
+ v.querySelectorAll('td[data-a]').forEach(function (td) {
421
+ td.onclick = function () { showCoupling(domainNames[+td.getAttribute('data-a')], domainNames[+td.getAttribute('data-b')]); };
422
+ });
423
+ }
424
+ function showCoupling(a, b) {
425
+ var es = G.edges.filter(function (e) { var x = nodeById.get(e.from), y = nodeById.get(e.to); return x && y && x.domain === a && y.domain === b; });
426
+ detail.innerHTML = '<h3>' + esc(a) + ' → ' + esc(b) + '</h3><div class="kv">' + es.length + ' call edge(s)</div>' +
427
+ '<div class="sec">edges</div><div class="lst" id="d_e"></div>';
428
+ document.getElementById('d_e').innerHTML = es.slice(0, 80).map(function (e) {
429
+ return '<div data-node="' + esc(e.from) + '">' + esc(nodeById.get(e.from).label) + ' <span class="dim">→ ' + esc(nodeById.get(e.to).label) + '</span></div>';
430
+ }).join('') || '<div class="empty">—</div>';
431
+ bindNodeRows();
432
+ }
433
+
434
+ // ---------- TREEMAP ----------
435
+ function bisect(items, x, y, w, h, out) {
436
+ if (!items.length) return;
437
+ if (items.length === 1) { out.push(Object.assign({}, items[0], { x: x, y: y, w: w, h: h })); return; }
438
+ var total = items.reduce(function (s, i) { return s + i.value; }, 0), acc = 0, k = 0;
439
+ for (; k < items.length - 1; k++) { if (acc + items[k].value > total / 2) break; acc += items[k].value; }
440
+ // always leave >=1 item on each side: a dominant last item (or all-zero values) would otherwise
441
+ // put every item back into `a` and recurse forever -> RangeError on large/degenerate graphs.
442
+ var cut = Math.min(k + 1, items.length - 1);
443
+ var a = items.slice(0, cut), b = items.slice(cut);
444
+ var frac = total > 0 ? a.reduce(function (s, i) { return s + i.value; }, 0) / total : cut / items.length;
445
+ if (w >= h) { bisect(a, x, y, w * frac, h, out); bisect(b, x + w * frac, y, w * (1 - frac), h, out); }
446
+ else { bisect(a, x, y, w, h * frac, out); bisect(b, x, y + h * frac, w, h * (1 - frac), out); }
447
+ }
448
+ // Sequential single-hue ramp (dark slate -> risk red), lightness monotonic — density is a
449
+ // MAGNITUDE, so lightness carries it (CVD-safe by construction; the old green->red pair
450
+ // measured deutan ΔE 2.2 — invisible to red-green-deficient viewers).
451
+ function dupColor(density) {
452
+ var t = Math.max(0, Math.min(1, density));
453
+ var r = Math.round(41 + (230 - 41) * t), g = Math.round(35 + (103 - 35) * t), b = Math.round(49 + (103 - 49) * t);
454
+ return 'rgb(' + r + ',' + g + ',' + b + ')';
455
+ }
456
+ function renderTreemap() {
457
+ var tm = document.getElementById('tm'); tm.innerHTML = '';
458
+ var Wd = tm.clientWidth || 800, Hd = tm.clientHeight || 600;
459
+ var doms = domainNames.map(function (d) {
460
+ var ns = AN.filter(function (n) { return n.domain === d; });
461
+ return { name: d, value: ns.reduce(function (s, n) { return s + (n.loc || 1); }, 0), nodes: ns };
462
+ }).filter(function (d) { return d.value > 0; }).sort(function (a, b) { return b.value - a.value; });
463
+ var drects = []; bisect(doms.map(function (d) { return { value: d.value, ref: d }; }), 0, 0, Wd, Hd, drects);
464
+ drects.forEach(function (dr) {
465
+ var d = dr.ref;
466
+ var border = document.createElement('div');
467
+ border.className = 'tmbox tmdom'; border.style.left = dr.x + 'px'; border.style.top = dr.y + 'px';
468
+ border.style.width = dr.w + 'px'; border.style.height = dr.h + 'px';
469
+ tm.appendChild(border);
470
+ var fmap = new Map();
471
+ d.nodes.forEach(function (n) { if (!fmap.has(n.file)) fmap.set(n.file, []); fmap.get(n.file).push(n); });
472
+ var files = Array.from(fmap.entries()).map(function (kv) {
473
+ var loc = kv[1].reduce(function (s, n) { return s + (n.loc || 1); }, 0);
474
+ var dups = kv[1].filter(function (n) { return dupNodeIds.has(n.id); }).length;
475
+ return { value: loc, file: kv[0], nodes: kv[1], density: kv[1].length ? dups / kv[1].length : 0 };
476
+ }).sort(function (a, b) { return b.value - a.value; });
477
+ var pad = 18, frects = [];
478
+ bisect(files.map(function (f) { return { value: f.value, ref: f }; }), dr.x + 2, dr.y + pad, Math.max(1, dr.w - 4), Math.max(1, dr.h - pad - 2), frects);
479
+ frects.forEach(function (fr) {
480
+ var f = fr.ref, box = document.createElement('div');
481
+ box.className = 'tmbox'; box.style.left = fr.x + 'px'; box.style.top = fr.y + 'px';
482
+ box.style.width = fr.w + 'px'; box.style.height = fr.h + 'px';
483
+ box.style.background = dupColor(f.density);
484
+ box.title = f.file + ' (' + f.value + ' loc, ' + Math.round(f.density * 100) + '% duplicated)';
485
+ if (fr.w > 46 && fr.h > 14) { var lab = document.createElement('div'); lab.className = 'tmlabel'; lab.textContent = f.file.split('/').pop(); box.appendChild(lab); }
486
+ box.onclick = function () { showFile(f); };
487
+ tm.appendChild(box);
488
+ });
489
+ var dl = document.createElement('div'); dl.className = 'tmdomlabel'; dl.style.left = (dr.x + 6) + 'px'; dl.style.top = (dr.y + 2) + 'px'; dl.style.maxWidth = Math.max(20, dr.w - 10) + 'px';
490
+ dl.innerHTML = '<span class="chip" style="background:' + colorOf(d.name) + '"></span>' + esc(d.name) + ' <span class="dim">· ' + d.nodes.length + '</span>'; tm.appendChild(dl);
491
+ });
492
+ }
493
+ function showFile(f) {
494
+ detail.innerHTML = '<h3 class="mono">' + esc(f.file) + '</h3>' +
495
+ '<div class="kv">' + f.nodes.length + ' symbols · ' + f.value + ' loc · ' + Math.round(f.density * 100) + '% duplicated</div>' +
496
+ '<div class="sec">symbols</div><div class="lst" id="d_f"></div>';
497
+ document.getElementById('d_f').innerHTML = f.nodes.map(function (n) {
498
+ return '<div data-node="' + esc(n.id) + '">' + (dupNodeIds.has(n.id) ? '⚠ ' : '') + esc(n.label) + ' <span class="dim">' + (n.loc || 0) + ' loc</span></div>';
499
+ }).join('');
500
+ bindNodeRows();
501
+ }
502
+
503
+ // ---------- GRAPH ----------
504
+ // Focus + context: every area starts as a bubble; clicking one expands ITS symbols in place
505
+ // while the rest stay collapsed — the all-at-once hairball is structurally impossible unless
506
+ // explicitly asked for ("Expand all"). Layout is deterministic (seeded spiral, no random), so
507
+ // the same repo always draws the same map. Positions survive re-layout for continuity.
508
+ var gInit = false, expanded = new Set(), W = null, sim = null, selectedNode = null, searchTerm = '', lastLit = null;
509
+ var lastPos = new Map(); // id -> {x,y} carried across layouts
510
+ var cv = document.getElementById('cv'), ctx = cv.getContext('2d'), DPR = window.devicePixelRatio || 1, cam = { x: 0, y: 0, k: 1 };
511
+ var UIFONT = 'system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif'; // canvas needs a REAL stack (bare ui-sans-serif falls back to serif)
512
+ function allExpanded() { return domainNames.every(function (d) { return expanded.has(d); }); }
513
+ function gResize() { var r = cv.parentElement.getBoundingClientRect(); cv.width = r.width * DPR; cv.height = r.height * DPR; cv.style.width = r.width + 'px'; cv.style.height = r.height + 'px'; }
514
+ function mapEnd(n) { return expanded.has(n.domain) ? n.id : 'd:' + n.domain; }
515
+ function buildW() {
516
+ var wn = [];
517
+ domainNames.forEach(function (nm) {
518
+ if (expanded.has(nm)) {
519
+ AN.forEach(function (n) { if (n.domain === nm) wn.push({ id: n.id, label: n.label, domain: nm, sym: true, r: Math.max(3.5, Math.min(16, 3 + Math.sqrt(n.loc || 6))) }); });
520
+ } else {
521
+ var c = domCount(nm);
522
+ wn.push({ id: 'd:' + nm, label: nm, domain: nm, bubble: true, count: c, r: Math.max(14, Math.min(56, 9 + Math.sqrt(c) * 4.6)) });
523
+ }
524
+ });
525
+ var agg = new Map();
526
+ AE.forEach(function (e) {
527
+ var a = nodeById.get(e.from), b = nodeById.get(e.to); if (!a || !b) return;
528
+ var f = mapEnd(a), t = mapEnd(b); if (f === t) return;
529
+ var k = f + '␟' + t;
530
+ var cur = agg.get(k); if (cur) cur.weight++; else agg.set(k, { from: f, to: t, weight: 1, cross: a.domain !== b.domain });
531
+ });
532
+ var we = Array.from(agg.values());
533
+ return { nodes: wn, edges: we, idx: new Map(wn.map(function (n) { return [n.id, n]; })) };
534
+ }
535
+ function gLayout() {
536
+ if (W) W.nodes.forEach(function (nd) { lastPos.set(nd.id, { x: nd.x, y: nd.y }); });
537
+ W = buildW();
538
+ var n = W.nodes.length, R = Math.max(140, Math.sqrt(n) * 60);
539
+ W.nodes.forEach(function (nd, i) {
540
+ var prev = lastPos.get(nd.id) || lastPos.get('d:' + nd.domain); // new symbols hatch from their area's bubble
541
+ if (prev) { nd.x = prev.x + (nd.sym ? Math.cos(i * 2.39996) * 14 : 0); nd.y = prev.y + (nd.sym ? Math.sin(i * 2.39996) * 14 : 0); }
542
+ else { var a = i * 2.39996, rad = R * Math.sqrt(i / Math.max(1, n)); nd.x = Math.cos(a) * rad; nd.y = Math.sin(a) * rad; }
543
+ nd.vx = 0; nd.vy = 0;
544
+ });
545
+ sim = { alpha: 1, running: false }; gFit(); gStart();
546
+ }
547
+ var REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
548
+ function gStart() {
549
+ if (!sim) return;
550
+ if (REDUCED) { // settle synchronously — no animation for motion-sensitive users
551
+ sim.alpha = 1;
552
+ for (var it = 0; it < 260 && sim.alpha > 0.02; it++) { gStep(); sim.alpha *= 0.985; }
553
+ gFit(); gDraw(); sim.running = false; return;
554
+ }
555
+ sim.alpha = Math.max(sim.alpha, 0.7); if (!sim.running) { sim.running = true; requestAnimationFrame(gTick); }
556
+ }
557
+ function gTick() { if (!sim || !document.getElementById('view-graph').classList.contains('on')) { if (sim) sim.running = false; return; } gStep(); gDraw(); sim.alpha *= 0.985; if (sim.alpha > 0.02) requestAnimationFrame(gTick); else sim.running = false; }
558
+ function gStep() {
559
+ var ns = W.nodes, es = W.edges, a = sim.alpha, i, j;
560
+ for (i = 0; i < ns.length; i++) { ns[i].fx = 0; ns[i].fy = 0; }
561
+ for (i = 0; i < ns.length; i++) {
562
+ var A = ns[i];
563
+ for (j = i + 1; j < ns.length; j++) {
564
+ var B = ns[j], dx = A.x - B.x, dy = A.y - B.y, d2 = dx * dx + dy * dy || 0.01, d = Math.sqrt(d2);
565
+ var repK = 900 + (A.r + B.r) * 55; // bigger bodies push harder — bubbles keep their space
566
+ var f = repK / d2;
567
+ A.fx += dx / d * f; A.fy += dy / d * f; B.fx -= dx / d * f; B.fy -= dy / d * f;
568
+ }
569
+ }
570
+ for (i = 0; i < es.length; i++) {
571
+ var e = es[i], P = W.idx.get(e.from), Q = W.idx.get(e.to); if (!P || !Q) continue;
572
+ var L = P.bubble && Q.bubble ? 180 : (P.bubble || Q.bubble) ? 120 : 52;
573
+ var ex = Q.x - P.x, ey = Q.y - P.y, ed = Math.sqrt(ex * ex + ey * ey) || 0.01, df = (ed - L) / ed * 0.03;
574
+ P.fx += ex * df; P.fy += ey * df; Q.fx -= ex * df; Q.fy -= ey * df;
575
+ }
576
+ for (i = 0; i < ns.length; i++) { var nd = ns[i]; nd.fx += -nd.x * 0.015; nd.fy += -nd.y * 0.015; nd.vx = (nd.vx + nd.fx * a) * 0.86; nd.vy = (nd.vy + nd.fy * a) * 0.86; nd.x += nd.vx; nd.y += nd.vy; }
577
+ }
578
+ function gFit() { var ns = W.nodes; if (!ns.length) return; var a = 1e9, b = 1e9, c = -1e9, d = -1e9; ns.forEach(function (n) { a = Math.min(a, n.x); b = Math.min(b, n.y); c = Math.max(c, n.x); d = Math.max(d, n.y); }); var w = cv.width / DPR, h = cv.height / DPR; cam.k = Math.max(0.05, Math.min((w - 70) / Math.max(1, c - a), (h - 70) / Math.max(1, d - b), 2.5)); cam.x = w / 2 - (a + c) / 2 * cam.k; cam.y = h / 2 - (b + d) / 2 * cam.k; }
579
+ function X(x) { return x * cam.k + cam.x; } function Y(y) { return y * cam.k + cam.y; }
580
+ function gDraw() {
581
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0); ctx.clearRect(0, 0, cv.width, cv.height);
582
+ // highlight set: search matches, or the selected node + its neighbors (neighbors inside a
583
+ // collapsed bubble light the bubble — the connection is never invisible)
584
+ var hl = null, hlDom = null;
585
+ if (searchTerm) {
586
+ var t = searchTerm.toLowerCase();
587
+ hl = new Set(); hlDom = new Set();
588
+ AN.forEach(function (n) { if (n.label.toLowerCase().indexOf(t) >= 0) { hl.add(n.id); hlDom.add(n.domain); } });
589
+ } else if (selectedNode) {
590
+ hl = new Set([selectedNode]); hlDom = new Set();
591
+ var lightUp = function (id) { var n = nodeById.get(id); if (!n) return; hl.add(mapEnd(n)); hlDom.add(n.domain); };
592
+ (outAdj.get(selectedNode) || []).forEach(lightUp); (inAdj.get(selectedNode) || []).forEach(lightUp);
593
+ }
594
+ var lit = function (nd) { return !hl || hl.has(nd.id) || (nd.bubble && hlDom && hlDom.has(nd.domain)); };
595
+ lastLit = hl ? new Set(W.nodes.filter(lit).map(function (n) { return n.id; })) : null;
596
+ // edges: quadratic curves, weight -> alpha/width; cross-area edges wear the tangle color
597
+ W.edges.forEach(function (e) {
598
+ var A = W.idx.get(e.from), B = W.idx.get(e.to); if (!A || !B) return;
599
+ var on = !hl || (lit(A) && lit(B));
600
+ var wgt = Math.min(1, Math.log(e.weight + 1) / 3.4);
601
+ // between two collapsed bubbles EVERY edge is cross-area — orange would be noise there;
602
+ // the tangle color only means something once symbols are on screen
603
+ var tangle = e.cross && !(A.bubble && B.bubble);
604
+ ctx.strokeStyle = !on ? 'rgba(125,122,135,0.05)'
605
+ : tangle ? 'rgba(236,131,90,' + (0.25 + wgt * 0.4) + ')'
606
+ : 'rgba(156,153,166,' + (0.14 + wgt * 0.34) + ')';
607
+ ctx.lineWidth = A.bubble && B.bubble ? Math.max(0.8, Math.min(6, Math.log(e.weight + 1) * 1.4)) : 1;
608
+ var x1 = X(A.x), y1 = Y(A.y), x2 = X(B.x), y2 = Y(B.y);
609
+ var mx = (x1 + x2) / 2, my = (y1 + y2) / 2, dx = x2 - x1, dy = y2 - y1;
610
+ ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(mx - dy * 0.09, my + dx * 0.09, x2, y2); ctx.stroke();
611
+ });
612
+ // nodes: fill + darker ring of the same hue; selection wears the brand accent
613
+ W.nodes.forEach(function (nd) {
614
+ var vis = lit(nd);
615
+ var c = colorOf(nd.domain);
616
+ ctx.globalAlpha = vis ? 1 : 0.14;
617
+ ctx.beginPath(); ctx.arc(X(nd.x), Y(nd.y), nd.r, 0, 6.2832);
618
+ ctx.fillStyle = c; ctx.fill();
619
+ ctx.lineWidth = nd.bubble ? 2 : 1.4; ctx.strokeStyle = shade(c, 0.5); ctx.stroke();
620
+ if (selectedNode === nd.id) { ctx.lineWidth = 2.5; ctx.strokeStyle = '#C6F24E'; ctx.beginPath(); ctx.arc(X(nd.x), Y(nd.y), nd.r + 2.5, 0, 6.2832); ctx.stroke(); }
621
+ var showLabel = vis && (nd.bubble || nd.r > 7.5 || cam.k > 1.7 || (hl && hl.has(nd.id) && hl.size < 40));
622
+ if (showLabel) {
623
+ var lx = X(nd.x) + nd.r + 4, ly = Y(nd.y) + 3.5;
624
+ ctx.font = (nd.bubble ? '600 12px ' : '500 10.5px ') + UIFONT;
625
+ ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(16,14,20,0.85)'; ctx.lineJoin = 'round';
626
+ var txt = nd.bubble ? nd.label + ' · ' + nd.count : nd.label;
627
+ ctx.strokeText(txt, lx, ly);
628
+ ctx.fillStyle = nd.bubble ? '#ECECEE' : '#D9D7E0'; ctx.fillText(txt, lx, ly);
629
+ }
630
+ ctx.globalAlpha = 1;
631
+ });
632
+ }
633
+ function gPick(mx, my) { var best = null, bd = 1e9; W.nodes.forEach(function (nd) { var dx = X(nd.x) - mx, dy = Y(nd.y) - my, d = Math.sqrt(dx * dx + dy * dy); if (d < nd.r + 4 && d < bd) { bd = d; best = nd; } }); return best; }
634
+ var gdrag = null, gmoved = false;
635
+ cv.addEventListener('pointerdown', function (ev) { var r = cv.getBoundingClientRect(); gmoved = false; gdrag = { mx: ev.clientX - r.left, my: ev.clientY - r.top, cx: cam.x, cy: cam.y }; try { cv.setPointerCapture(ev.pointerId); } catch (e) {} });
636
+ window.addEventListener('pointermove', function (ev) { if (!gdrag) return; var r = cv.getBoundingClientRect(), mx = ev.clientX - r.left, my = ev.clientY - r.top; if (Math.abs(mx - gdrag.mx) + Math.abs(my - gdrag.my) > 3) gmoved = true; cam.x = gdrag.cx + (mx - gdrag.mx); cam.y = gdrag.cy + (my - gdrag.my); gDraw(); });
637
+ window.addEventListener('pointerup', function (ev) { if (gdrag && !gmoved && document.getElementById('view-graph').classList.contains('on')) { var r = cv.getBoundingClientRect(), nd = gPick(ev.clientX - r.left, ev.clientY - r.top); if (nd) { if (nd.bubble) { expanded.add(nd.domain); gLayout(); syncToggle(); } else { selectedNode = nd.id; showNode(nd.id); gDraw(); } } else { selectedNode = null; gDraw(); } } gdrag = null; });
638
+ cv.addEventListener('wheel', function (ev) { ev.preventDefault(); var r = cv.getBoundingClientRect(), mx = ev.clientX - r.left, my = ev.clientY - r.top, f = ev.deltaY < 0 ? 1.12 : 0.89, nk = Math.max(0.05, Math.min(6, cam.k * f)); cam.x = mx - (mx - cam.x) * (nk / cam.k); cam.y = my - (my - cam.y) * (nk / cam.k); cam.k = nk; gDraw(); }, { passive: false });
639
+ document.getElementById('gFit').onclick = function () { gFit(); gDraw(); };
640
+ var gToggle = document.getElementById('gToggle');
641
+ function syncToggle() { gToggle.textContent = allExpanded() ? 'Collapse to areas' : (expanded.size ? 'Collapse (' + expanded.size + ' open)' : 'Expand all symbols'); }
642
+ gToggle.onclick = function () { if (expanded.size) expanded = new Set(); else domainNames.forEach(function (d) { expanded.add(d); }); selectedNode = null; gLayout(); syncToggle(); };
643
+ // Search NAVIGATES: live typing dims non-matches; Enter cycles matches (centering + selecting
644
+ // each), Escape clears. The count chip narrates n/N for screen readers too.
645
+ var gsearchEl = document.getElementById('gsearch'), gcountEl = document.getElementById('gcount');
646
+ var searchHits = [], searchIx = -1;
647
+ function centerOn(nd) { var w = cv.width / DPR, h = cv.height / DPR; cam.k = Math.max(cam.k, 0.9); cam.x = w / 2 - nd.x * cam.k; cam.y = h / 2 - nd.y * cam.k; }
648
+ function refreshHits() {
649
+ // search SYMBOLS (the active scope), not whatever bubbles the current view draws — a domain
650
+ // view must still find normalizePath and expand to it on Enter.
651
+ var t = searchTerm.toLowerCase();
652
+ searchHits = t ? AN.filter(function (n) { return n.label.toLowerCase().indexOf(t) >= 0; }) : [];
653
+ searchIx = -1;
654
+ gcountEl.textContent = searchTerm ? (searchHits.length ? searchHits.length + ' match(es) — Enter jumps' : 'no matches') : '';
655
+ }
656
+ gsearchEl.addEventListener('input', function (e) { searchTerm = e.target.value.trim(); refreshHits(); gDraw(); });
657
+ gsearchEl.addEventListener('keydown', function (e) {
658
+ if (e.key === 'Escape') { gsearchEl.value = ''; searchTerm = ''; refreshHits(); selectedNode = null; gDraw(); return; }
659
+ if (e.key !== 'Enter' || !searchHits.length) return;
660
+ e.preventDefault();
661
+ searchIx = (searchIx + 1) % searchHits.length;
662
+ var hit = searchHits[searchIx];
663
+ if (!expanded.has(hit.domain)) { expanded.add(hit.domain); gLayout(); syncToggle(); } // reveal, don't refuse
664
+ var nd = W.idx.get(hit.id);
665
+ if (nd) { centerOn(nd); selectedNode = hit.id; showNode(hit.id); }
666
+ gcountEl.textContent = (searchIx + 1) + '/' + searchHits.length + ' ' + hit.label;
667
+ gDraw();
668
+ });
669
+
670
+ // ---------- tab routing ----------
671
+ var rendered = {};
672
+ function activate(view) {
673
+ document.querySelectorAll('.tab[role="tab"]').forEach(function (t) { var on = t.getAttribute('data-view') === view; t.classList.toggle('on', on); t.setAttribute('aria-selected', String(on)); });
674
+ document.querySelectorAll('.view').forEach(function (v) { v.classList.toggle('on', v.id === 'view-' + view); });
675
+ if (view === 'findings' && !rendered.findings) { renderFindings(); rendered.findings = true; }
676
+ if (view === 'matrix' && !rendered.matrix) { renderMatrix(); rendered.matrix = true; }
677
+ if (view === 'treemap') { renderTreemap(); }
678
+ if (view === 'graph') { gResize(); if (!gInit) { gInit = true; syncToggle(); gLayout(); } else gDraw(); }
679
+ }
680
+ document.querySelectorAll('.tab[role="tab"]').forEach(function (t) { t.onclick = function () { activate(t.getAttribute('data-view')); }; });
681
+ window.addEventListener('resize', function () { if (document.getElementById('view-treemap').classList.contains('on')) renderTreemap(); if (document.getElementById('view-graph').classList.contains('on')) { gResize(); gDraw(); } });
682
+
683
+ // boot
684
+ renderFindings(); rendered.findings = true; detailEmpty();
685
+
686
+ // staging hook for scripts/screenshot.mjs — drives the report into a DEMONSTRATIVE state
687
+ // (top hotspot selected, its area expanded, neighbors lit). Not part of the UI.
688
+ window.__codewebStage = {
689
+ topHotspot: function () {
690
+ var top = hotspots[0]; if (!top) return null;
691
+ activate('graph'); gResize(); if (!gInit) { gInit = true; syncToggle(); gLayout(); }
692
+ if (!expanded.has(top.domain)) { expanded.add(top.domain); gLayout(); syncToggle(); }
693
+ var nd = W.idx.get(top.id);
694
+ if (nd) { selectedNode = top.id; cam.k = Math.max(cam.k, 1.2); centerOn(nd); showNode(top.id); gDraw(); }
695
+ return top.id;
696
+ },
697
+ // screen-space bounding box of the drawn graph (CSS px) — lets the screenshot pipeline crop
698
+ // to content instead of shipping empty canvas
699
+ graphBBox: function () {
700
+ if (!W || !W.nodes.length) return null;
701
+ var a = 1e9, b = 1e9, c = -1e9, d = -1e9, counted = 0;
702
+ W.nodes.forEach(function (n) {
703
+ if (lastLit && !lastLit.has(n.id)) return; // with a selection/search, crop to what's LIT
704
+ counted++;
705
+ a = Math.min(a, X(n.x) - n.r); b = Math.min(b, Y(n.y) - n.r);
706
+ c = Math.max(c, X(n.x) + n.r + 130); d = Math.max(d, Y(n.y) + n.r); // +130: room for the widest label
707
+ });
708
+ return counted ? { x: a, y: b, w: c - a, h: d - b } : null;
709
+ }
710
+ };
711
+
712
+ // deep link: #s=<node id> selects the symbol on load (shareable findings)
713
+ (function () {
714
+ var m = /^#s=(.+)$/.exec(location.hash || '');
715
+ if (!m) return;
716
+ var id = decodeURIComponent(m[1]);
717
+ if (!nodeById.has(id)) return;
718
+ activate('graph');
719
+ // after the first layout pass, expand JUST the target's area, then center + select
720
+ setTimeout(function () {
721
+ var tn = nodeById.get(id);
722
+ if (tn && !expanded.has(tn.domain)) { expanded.add(tn.domain); gLayout(); syncToggle(); }
723
+ var nd = W && W.idx.get(id);
724
+ if (nd) { selectedNode = id; centerOn(nd); showNode(id); gDraw(); }
725
+ }, REDUCED ? 50 : 900);
726
+ })();
727
+ })();
728
+ </script>
729
+ </body>
730
+ </html>