kaskd-lens 0.1.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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +66 -0
  4. data/lib/kaskd/lens/html_report.rb +1519 -0
  5. data/lib/kaskd/lens/version.rb +7 -0
  6. data/lib/kaskd/lens.rb +74 -0
  7. data/lib/kaskd-lens.rb +4 -0
  8. data/vendor/assets/vis-network.min.js +34 -0
  9. data/vendor/bundle/ruby/3.2.0/bin/rake +29 -0
  10. data/vendor/bundle/ruby/3.2.0/cache/kaskd-0.1.1.gem +0 -0
  11. data/vendor/bundle/ruby/3.2.0/cache/rake-13.3.1.gem +0 -0
  12. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/LICENSE +21 -0
  13. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/README.md +197 -0
  14. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/lib/kaskd/analyzer.rb +156 -0
  15. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/lib/kaskd/blast_radius.rb +90 -0
  16. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/lib/kaskd/configuration.rb +28 -0
  17. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/lib/kaskd/test_finder.rb +136 -0
  18. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/lib/kaskd/tree_renderer.rb +81 -0
  19. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/lib/kaskd/version.rb +5 -0
  20. data/vendor/bundle/ruby/3.2.0/gems/kaskd-0.1.1/lib/kaskd.rb +75 -0
  21. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/History.rdoc +2454 -0
  22. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/MIT-LICENSE +21 -0
  23. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/README.rdoc +155 -0
  24. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/command_line_usage.rdoc +171 -0
  25. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/example/Rakefile1 +38 -0
  26. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/example/Rakefile2 +35 -0
  27. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/example/a.c +6 -0
  28. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/example/b.c +6 -0
  29. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/example/main.c +11 -0
  30. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/glossary.rdoc +42 -0
  31. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/jamis.rb +592 -0
  32. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/proto_rake.rdoc +127 -0
  33. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/rake.1 +156 -0
  34. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/rakefile.rdoc +622 -0
  35. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/doc/rational.rdoc +151 -0
  36. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/exe/rake +27 -0
  37. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/application.rb +854 -0
  38. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/backtrace.rb +25 -0
  39. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/clean.rb +78 -0
  40. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/cloneable.rb +17 -0
  41. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/cpu_counter.rb +122 -0
  42. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/default_loader.rb +15 -0
  43. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/dsl_definition.rb +196 -0
  44. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/early_time.rb +22 -0
  45. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/ext/core.rb +26 -0
  46. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/ext/string.rb +176 -0
  47. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/file_creation_task.rb +25 -0
  48. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/file_list.rb +435 -0
  49. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/file_task.rb +58 -0
  50. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/file_utils.rb +132 -0
  51. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/file_utils_ext.rb +134 -0
  52. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/invocation_chain.rb +57 -0
  53. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/invocation_exception_mixin.rb +17 -0
  54. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/late_time.rb +18 -0
  55. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/linked_list.rb +112 -0
  56. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/loaders/makefile.rb +54 -0
  57. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/multi_task.rb +14 -0
  58. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/name_space.rb +38 -0
  59. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/packagetask.rb +222 -0
  60. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/phony.rb +16 -0
  61. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/private_reader.rb +21 -0
  62. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/promise.rb +100 -0
  63. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/pseudo_status.rb +30 -0
  64. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/rake_module.rb +67 -0
  65. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/rake_test_loader.rb +27 -0
  66. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/rule_recursion_overflow_error.rb +20 -0
  67. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/scope.rb +43 -0
  68. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/task.rb +434 -0
  69. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/task_argument_error.rb +8 -0
  70. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/task_arguments.rb +113 -0
  71. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/task_manager.rb +331 -0
  72. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/tasklib.rb +12 -0
  73. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/testtask.rb +189 -0
  74. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/thread_history_display.rb +49 -0
  75. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/thread_pool.rb +157 -0
  76. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/trace_output.rb +23 -0
  77. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/version.rb +10 -0
  78. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake/win32.rb +51 -0
  79. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/lib/rake.rb +68 -0
  80. data/vendor/bundle/ruby/3.2.0/gems/rake-13.3.1/rake.gemspec +101 -0
  81. data/vendor/bundle/ruby/3.2.0/specifications/kaskd-0.1.1.gemspec +22 -0
  82. data/vendor/bundle/ruby/3.2.0/specifications/rake-13.3.1.gemspec +26 -0
  83. metadata +144 -0
@@ -0,0 +1,1519 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kaskd
6
+ module Lens
7
+ # Generates a standalone HTML file with the interactive service graph viewer.
8
+ #
9
+ # The output is a single HTML file with:
10
+ # - vis-network.min.js inlined (no CDN dependency)
11
+ # - The analysis data embedded as window.__KASKD_DATA__
12
+ # - Full interactive UI: search, pack filter, blast radius, depth slider,
13
+ # detail overlay with tabs, path tracing, dark/light themes, copy diagram,
14
+ # export JSON, navigation history
15
+ class HtmlReport
16
+ # @param result [Hash] output from Kaskd.analyze
17
+ # { services: Hash, generated_at: String, total: Integer }
18
+ def initialize(result)
19
+ @result = result
20
+ end
21
+
22
+ # @return [String] the complete HTML document
23
+ def render
24
+ vis_js = load_vis_network_js
25
+ data_json = JSON.generate(@result)
26
+
27
+ build_html(vis_js, data_json)
28
+ end
29
+
30
+ private
31
+
32
+ def load_vis_network_js
33
+ path = File.expand_path("../../../vendor/assets/vis-network.min.js", __dir__)
34
+ File.read(path)
35
+ end
36
+
37
+ def build_html(vis_js, data_json)
38
+ <<~HTML
39
+ <!DOCTYPE html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="UTF-8">
43
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
44
+ <title>Service Graph — Kaskd Lens</title>
45
+ <script>#{vis_js}</script>
46
+ <style>#{css}</style>
47
+ </head>
48
+ <body>
49
+
50
+ <div id="overlay">
51
+ <div class="spin"></div>
52
+ <p id="overlay-msg">Loading graph…</p>
53
+ </div>
54
+
55
+ <header>
56
+ <h1>&#11041; Service Graph</h1>
57
+ <span class="badge" id="badge-total">—</span>
58
+ <span class="badge" id="badge-cache">—</span>
59
+ <span class="spacer"></span>
60
+ <div class="theme-toggle" id="theme-toggle" title="Toggle light/dark theme">
61
+ <span class="icon" id="icon-moon">&#9790;</span>
62
+ <div class="toggle-track">
63
+ <div class="toggle-knob"></div>
64
+ </div>
65
+ <span class="icon dim" id="icon-sun">&#9788;</span>
66
+ </div>
67
+ </header>
68
+
69
+ <div class="main">
70
+ <!-- Sidebar -->
71
+ <div class="sidebar">
72
+ <div class="pack-filter">
73
+ <select id="pack-select">
74
+ <option value="">All packs</option>
75
+ </select>
76
+ </div>
77
+ <div class="search-wrap">
78
+ <div class="si">
79
+ <input type="text" id="search" placeholder="Search service…" autocomplete="off">
80
+ </div>
81
+ </div>
82
+ <div id="service-list"></div>
83
+ </div>
84
+
85
+ <!-- Detail overlay panel -->
86
+ <div id="detail-overlay">
87
+ <div class="overlay-header">
88
+ <span class="overlay-title" id="detail-overlay-title">Details</span>
89
+ <button id="btn-close-detail" title="Close panel">&#10005;</button>
90
+ </div>
91
+ <div class="tabs">
92
+ <div class="tab active" data-tab="affected">
93
+ &#8593; Affected <span class="tab-count" id="tc-affected">0</span>
94
+ </div>
95
+ <div class="tab" data-tab="deps">
96
+ &#8595; Dependencies <span class="tab-count" id="tc-deps">0</span>
97
+ </div>
98
+ <div class="tab" data-tab="info">
99
+ &#8505; Info
100
+ </div>
101
+ </div>
102
+ <div class="tab-body active" id="tb-affected"></div>
103
+ <div class="tab-body" id="tb-deps"></div>
104
+ <div class="tab-body" id="tb-info"></div>
105
+ </div>
106
+ <button id="btn-toggle-detail" title="Toggle detail panel"><span class="arrow">&#9654;</span></button>
107
+
108
+ <!-- Right -->
109
+ <div class="right-panel">
110
+ <!-- Info bar -->
111
+ <div id="info-bar">
112
+ <div class="nav-group">
113
+ <button class="nav-btn" id="btn-back" title="Go back" disabled>&#9664;</button>
114
+ <button class="nav-btn" id="btn-forward" title="Go forward" disabled>&#9654;</button>
115
+ </div>
116
+ <div>
117
+ <div id="info-name"></div>
118
+ <div id="info-file"></div>
119
+ </div>
120
+ <span class="spacer"></span>
121
+ <button class="btn-export" id="btn-export" title="Download dependency report as JSON">&#8615; Export JSON</button>
122
+ <div class="blast-badge" title="Services that would be affected if this one changes">
123
+ <div>
124
+ <div class="num" id="blast-num">0</div>
125
+ <div class="lbl">affected</div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+
130
+ <!-- Graph -->
131
+ <div class="graph-area">
132
+ <div id="vis-graph"></div>
133
+
134
+ <div id="graph-empty">
135
+ <svg width="60" height="60" viewBox="0 0 60 60" fill="none" stroke="#4a6080" stroke-width="1.5">
136
+ <circle cx="30" cy="10" r="7"/>
137
+ <circle cx="10" cy="46" r="7"/>
138
+ <circle cx="50" cy="46" r="7"/>
139
+ <line x1="30" y1="17" x2="19" y2="39"/>
140
+ <line x1="30" y1="17" x2="41" y2="39"/>
141
+ </svg>
142
+ <p>Select a service to see its transitive blast radius</p>
143
+ </div>
144
+
145
+ <button id="btn-copy-diagram" title="Copy diagram to clipboard">
146
+ <span class="copy-icon">&#128203;</span> Copy diagram
147
+ </button>
148
+
149
+ <div id="depth-ctrl">
150
+ Depth
151
+ <input type="range" id="depth-slider" min="1" max="6" value="3">
152
+ <span id="depth-val">3</span>
153
+ </div>
154
+
155
+ <div id="legend">
156
+ <div class="leg"><div class="leg-dot" style="background:#f0f6fc;border:1px solid #8b949e"></div> Selected</div>
157
+ <div class="leg"><div class="leg-dot" style="background:#bd561d"></div> Affected level 1 (above)</div>
158
+ <div class="leg"><div class="leg-dot" style="background:#5e2208"></div> Affected level 2+ (above)</div>
159
+ <div class="leg"><div class="leg-dot" style="background:#1f6feb"></div> Dependency level 1 (below)</div>
160
+ <div class="leg"><div class="leg-dot" style="background:#09357e"></div> Dependency level 2+ (below)</div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+
166
+ <div class="toast" id="toast"></div>
167
+
168
+ <script>
169
+ window.__KASKD_DATA__ = #{data_json};
170
+ #{javascript}
171
+ </script>
172
+ </body>
173
+ </html>
174
+ HTML
175
+ end
176
+
177
+ # rubocop:disable Metrics/MethodLength
178
+ def css
179
+ <<~CSS
180
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
181
+
182
+ /* ── Dark theme (default) ─────────────────────────────────── */
183
+ :root, [data-theme="dark"] {
184
+ --bg-deep: #0d1117;
185
+ --bg-dark: #161b22;
186
+ --bg-card: #21262d;
187
+ --bg-hover: #1c2128;
188
+ --border: #30363d;
189
+ --border-hi: #6e7681;
190
+ --text-base: #e6edf3;
191
+ --text-muted: #8b949e;
192
+ --text-dim: #484f58;
193
+ --accent: #2f81f7;
194
+ --accent-hi: #58a6ff;
195
+ --orange: #f0883e;
196
+ --overlay-bg: rgba(13, 17, 23, .9);
197
+ --svc-name: #cdd9e5;
198
+ --selected-bg: #f0f6fc;
199
+ --selected-border:#cdd9e5;
200
+ --selected-font: #0d1117;
201
+ --node-shadow: rgba(0,0,0,0.4);
202
+ --legend-bg: rgba(22, 27, 34, .96);
203
+ --chip-blue-bg: #1f3d6e;
204
+ --chip-blue-fg: #79c0ff;
205
+ --chip-blue-bdr: #1f6feb;
206
+ --chip-green-bg: #5c2200;
207
+ --chip-green-fg: #ffa657;
208
+ --chip-green-bdr:#bd561d;
209
+ --scrollbar-thumb: var(--border);
210
+ }
211
+
212
+ /* ── Light theme ──────────────────────────────────────────── */
213
+ [data-theme="light"] {
214
+ --bg-deep: #f6f8fa;
215
+ --bg-dark: #ffffff;
216
+ --bg-card: #f0f3f6;
217
+ --bg-hover: #eaeef2;
218
+ --border: #d0d7de;
219
+ --border-hi: #8c959f;
220
+ --text-base: #1f2328;
221
+ --text-muted: #656d76;
222
+ --text-dim: #8c959f;
223
+ --accent: #0969da;
224
+ --accent-hi: #0550ae;
225
+ --orange: #bc4c00;
226
+ --overlay-bg: rgba(246, 248, 250, .92);
227
+ --svc-name: #1f2328;
228
+ --selected-bg: #0969da;
229
+ --selected-border:#0550ae;
230
+ --selected-font: #ffffff;
231
+ --node-shadow: rgba(0,0,0,0.12);
232
+ --legend-bg: rgba(255, 255, 255, .96);
233
+ --chip-blue-bg: #ddf4ff;
234
+ --chip-blue-fg: #0550ae;
235
+ --chip-blue-bdr: #54aeff;
236
+ --chip-green-bg: #fff1e5;
237
+ --chip-green-fg: #bc4c00;
238
+ --chip-green-bdr:#d4a72c;
239
+ --scrollbar-thumb: #c1c8cf;
240
+ }
241
+
242
+ body {
243
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
244
+ font-size: 13px;
245
+ background: var(--bg-deep);
246
+ color: var(--text-base);
247
+ height: 100vh;
248
+ display: flex;
249
+ flex-direction: column;
250
+ overflow: hidden;
251
+ }
252
+
253
+ /* ── Header ──────────────────────────────────────────────── */
254
+ header {
255
+ background: var(--bg-dark);
256
+ border-bottom: 1px solid var(--border);
257
+ padding: 10px 18px;
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 12px;
261
+ flex-shrink: 0;
262
+ }
263
+ header h1 { font-size: 15px; font-weight: 700; color: var(--text-base); letter-spacing: -0.3px; }
264
+ .badge {
265
+ background: var(--bg-card);
266
+ border: 1px solid var(--border);
267
+ color: var(--text-muted);
268
+ font-size: 11px;
269
+ padding: 2px 8px;
270
+ border-radius: 20px;
271
+ }
272
+ header .spacer { flex: 1; }
273
+
274
+ /* ── Layout ──────────────────────────────────────────────── */
275
+ .main { display: flex; flex: 1; overflow: hidden; position: relative; }
276
+
277
+ /* ── Sidebar ─────────────────────────────────────────────── */
278
+ .sidebar {
279
+ width: 320px;
280
+ flex-shrink: 0;
281
+ background: var(--bg-dark);
282
+ border-right: 1px solid var(--border);
283
+ display: flex;
284
+ flex-direction: column;
285
+ overflow: hidden;
286
+ }
287
+ .search-wrap {
288
+ padding: 12px;
289
+ border-bottom: 1px solid var(--border);
290
+ flex-shrink: 0;
291
+ }
292
+ .search-wrap input {
293
+ width: 100%;
294
+ background: var(--bg-deep);
295
+ border: 1px solid var(--border);
296
+ border-radius: 8px;
297
+ color: var(--text-base);
298
+ padding: 7px 10px 7px 30px;
299
+ font-size: 12px;
300
+ outline: none;
301
+ transition: border-color .15s;
302
+ }
303
+ .search-wrap input:focus { border-color: var(--accent); }
304
+ .search-wrap .si { position: relative; }
305
+ .search-wrap .si::before {
306
+ content: "\\2315";
307
+ position: absolute;
308
+ left: 9px;
309
+ top: 50%;
310
+ transform: translateY(-50%);
311
+ color: var(--text-dim);
312
+ font-size: 16px;
313
+ line-height: 1;
314
+ }
315
+
316
+ #service-list { flex: 1; overflow-y: auto; padding: 4px 0; }
317
+ #service-list::-webkit-scrollbar { width: 3px; }
318
+ #service-list::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 2px; }
319
+
320
+ .svc-item {
321
+ padding: 8px 12px;
322
+ cursor: pointer;
323
+ border-left: 2px solid transparent;
324
+ transition: background .1s, border-color .1s;
325
+ }
326
+ .svc-item:hover { background: var(--bg-hover); border-left-color: var(--border-hi); }
327
+ .svc-item.active { background: var(--bg-hover); border-left-color: var(--accent-hi); }
328
+ .svc-item .name {
329
+ font-size: 12px;
330
+ color: var(--svc-name);
331
+ font-family: "SF Mono", "Fira Code", monospace;
332
+ white-space: nowrap;
333
+ overflow: hidden;
334
+ text-overflow: ellipsis;
335
+ }
336
+ .svc-item .desc {
337
+ font-size: 11px;
338
+ color: var(--text-muted);
339
+ margin-top: 2px;
340
+ white-space: nowrap;
341
+ overflow: hidden;
342
+ text-overflow: ellipsis;
343
+ }
344
+ .svc-item .chips {
345
+ display: flex;
346
+ gap: 4px;
347
+ margin-top: 3px;
348
+ flex-wrap: wrap;
349
+ }
350
+ .chip {
351
+ font-size: 9px;
352
+ font-weight: 600;
353
+ padding: 1px 5px;
354
+ border-radius: 3px;
355
+ letter-spacing: .03em;
356
+ text-transform: uppercase;
357
+ }
358
+ .chip-blue { background: var(--chip-blue-bg); color: var(--chip-blue-fg); border: 1px solid var(--chip-blue-bdr); }
359
+ .chip-green { background: var(--chip-green-bg); color: var(--chip-green-fg); border: 1px solid var(--chip-green-bdr); }
360
+
361
+ .empty-msg { padding: 24px 16px; text-align: center; color: var(--text-dim); font-size: 12px; }
362
+
363
+ /* ── Right panel ─────────────────────────────────────────── */
364
+ .right-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
365
+
366
+ /* ── Info bar ────────────────────────────────────────────── */
367
+ #info-bar {
368
+ background: var(--bg-card);
369
+ border-bottom: 1px solid var(--border);
370
+ padding: 10px 16px;
371
+ display: none;
372
+ align-items: center;
373
+ gap: 12px;
374
+ flex-shrink: 0;
375
+ }
376
+ #info-bar.visible { display: flex; }
377
+ #info-name {
378
+ font-family: "SF Mono", "Fira Code", monospace;
379
+ font-size: 13px;
380
+ font-weight: 700;
381
+ color: var(--text-base);
382
+ max-width: 340px;
383
+ overflow: hidden;
384
+ text-overflow: ellipsis;
385
+ white-space: nowrap;
386
+ }
387
+ #info-file {
388
+ font-size: 10px;
389
+ color: var(--text-muted);
390
+ font-family: monospace;
391
+ white-space: nowrap;
392
+ overflow: hidden;
393
+ text-overflow: ellipsis;
394
+ flex: 1;
395
+ }
396
+ .blast-badge {
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 6px;
400
+ background: #3d1f00;
401
+ border: 1px solid #bd561d;
402
+ border-radius: 6px;
403
+ padding: 4px 12px;
404
+ flex-shrink: 0;
405
+ }
406
+ .blast-badge .num {
407
+ font-size: 20px;
408
+ font-weight: 800;
409
+ color: #ffa657;
410
+ line-height: 1;
411
+ }
412
+ .blast-badge .lbl { font-size: 10px; color: #c46212; text-transform: uppercase; letter-spacing: .05em; }
413
+
414
+ /* ── Nav buttons ─────────────────────────────────────────── */
415
+ .nav-group {
416
+ display: flex;
417
+ gap: 2px;
418
+ flex-shrink: 0;
419
+ }
420
+ .nav-btn {
421
+ background: var(--bg-deep);
422
+ border: 1px solid var(--border);
423
+ color: var(--text-muted);
424
+ width: 28px;
425
+ height: 28px;
426
+ border-radius: 6px;
427
+ cursor: pointer;
428
+ font-size: 14px;
429
+ display: flex;
430
+ align-items: center;
431
+ justify-content: center;
432
+ transition: color .15s, border-color .15s, opacity .15s;
433
+ }
434
+ .nav-btn:hover:not(:disabled) { color: var(--accent-hi); border-color: var(--accent-hi); }
435
+ .nav-btn:disabled { opacity: .3; cursor: default; }
436
+
437
+ /* ── Export button ────────────────────────────────────────── */
438
+ .btn-export {
439
+ background: var(--bg-deep);
440
+ border: 1px solid var(--border);
441
+ color: var(--text-muted);
442
+ padding: 4px 10px;
443
+ border-radius: 6px;
444
+ cursor: pointer;
445
+ font-size: 11px;
446
+ display: flex;
447
+ align-items: center;
448
+ gap: 5px;
449
+ transition: color .15s, border-color .15s;
450
+ flex-shrink: 0;
451
+ }
452
+ .btn-export:hover { color: var(--accent-hi); border-color: var(--accent-hi); }
453
+
454
+ /* ── Graph ───────────────────────────────────────────────── */
455
+ .graph-area { flex: 1; position: relative; min-height: 0; }
456
+ #vis-graph { width: 100%; height: 100%; }
457
+ #graph-empty {
458
+ position: absolute;
459
+ inset: 0;
460
+ display: flex;
461
+ flex-direction: column;
462
+ align-items: center;
463
+ justify-content: center;
464
+ gap: 14px;
465
+ color: var(--text-dim);
466
+ pointer-events: none;
467
+ }
468
+ #graph-empty svg { opacity: .25; }
469
+ #graph-empty p { font-size: 12px; }
470
+
471
+ /* legend */
472
+ #legend {
473
+ position: absolute;
474
+ bottom: 14px;
475
+ right: 14px;
476
+ background: var(--legend-bg);
477
+ border: 1px solid var(--border);
478
+ border-radius: 8px;
479
+ padding: 10px 12px;
480
+ font-size: 11px;
481
+ display: none;
482
+ gap: 8px;
483
+ flex-direction: column;
484
+ }
485
+ #legend.visible { display: flex; }
486
+ .leg { display: flex; align-items: center; gap: 8px; color: var(--text-muted); }
487
+ .leg-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
488
+
489
+ /* depth control */
490
+ #depth-ctrl {
491
+ position: absolute;
492
+ top: 12px;
493
+ right: 12px;
494
+ background: var(--legend-bg);
495
+ border: 1px solid var(--border);
496
+ border-radius: 8px;
497
+ padding: 8px 12px;
498
+ display: none;
499
+ align-items: center;
500
+ gap: 8px;
501
+ font-size: 11px;
502
+ color: var(--text-muted);
503
+ }
504
+ #depth-ctrl.visible { display: flex; }
505
+ #depth-slider { width: 80px; accent-color: var(--accent); }
506
+ #depth-val { color: var(--accent-hi); font-weight: 700; min-width: 14px; }
507
+
508
+ /* ── Detail tabs (inside overlay) ───────────────────────── */
509
+ #detail-overlay .tabs {
510
+ display: flex;
511
+ border-bottom: 1px solid var(--border);
512
+ flex-shrink: 0;
513
+ }
514
+ .tab {
515
+ padding: 8px 16px;
516
+ cursor: pointer;
517
+ font-size: 11px;
518
+ font-weight: 600;
519
+ color: var(--text-muted);
520
+ border-bottom: 2px solid transparent;
521
+ transition: color .1s, border-color .1s;
522
+ text-transform: uppercase;
523
+ letter-spacing: .05em;
524
+ display: flex;
525
+ align-items: center;
526
+ gap: 6px;
527
+ }
528
+ .tab:hover { color: var(--text-base); }
529
+ .tab.active { color: var(--text-base); border-bottom-color: var(--accent-hi); }
530
+ .tab-count {
531
+ background: var(--bg-card);
532
+ border: 1px solid var(--border);
533
+ border-radius: 20px;
534
+ padding: 0 5px;
535
+ font-size: 10px;
536
+ color: var(--text-muted);
537
+ }
538
+ .tab-body { flex: 1; overflow-y: auto; padding: 8px 12px; display: none; }
539
+ .tab-body::-webkit-scrollbar { width: 3px; }
540
+ .tab-body::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); }
541
+ .tab-body.active { display: block; }
542
+
543
+ /* depth group */
544
+ .depth-group { margin-bottom: 12px; }
545
+ .depth-label {
546
+ font-size: 10px;
547
+ font-weight: 700;
548
+ text-transform: uppercase;
549
+ letter-spacing: .07em;
550
+ padding: 3px 6px;
551
+ border-radius: 4px;
552
+ margin-bottom: 5px;
553
+ display: inline-flex;
554
+ align-items: center;
555
+ gap: 5px;
556
+ }
557
+ .depth-label.blue-1 { background: #1f6feb; color: #fff; }
558
+ .depth-label.blue-2 { background: #1158cb; color: #cae8ff; }
559
+ .depth-label.blue-3 { background: #0d45a5; color: #79c0ff; }
560
+ .depth-label.blue-n { background: #09357e; color: #58a6ff; }
561
+ .depth-label.green-1 { background: #bd561d; color: #fff; }
562
+ .depth-label.green-2 { background: #9b4215; color: #ffd8b1; }
563
+ .depth-label.green-3 { background: #7c310e; color: #ffa657; }
564
+ .depth-label.green-n { background: #5e2208; color: #f0883e; }
565
+
566
+ .dep-item {
567
+ display: flex;
568
+ align-items: center;
569
+ gap: 7px;
570
+ padding: 3px 4px;
571
+ border-radius: 4px;
572
+ cursor: pointer;
573
+ transition: background .1s;
574
+ }
575
+ .dep-item:hover { background: var(--bg-hover); }
576
+ .dep-item .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
577
+ .dep-item .nm {
578
+ font-family: "SF Mono", "Fira Code", monospace;
579
+ font-size: 11px;
580
+ color: var(--svc-name);
581
+ }
582
+ .dep-item:hover .nm { color: var(--text-base); }
583
+
584
+ .desc-info { padding: 8px 4px; font-size: 12px; color: var(--text-muted); line-height: 1.55; }
585
+ .dep-desc {
586
+ font-size: 11px;
587
+ color: var(--text-muted);
588
+ margin-top: 2px;
589
+ line-height: 1.4;
590
+ white-space: normal;
591
+ }
592
+
593
+ /* ── Path trace ──────────────────────────────────────────── */
594
+ .path-trace {
595
+ font-size: 10px;
596
+ color: var(--text-dim);
597
+ margin-top: 2px;
598
+ font-family: "SF Mono", "Fira Code", monospace;
599
+ white-space: nowrap;
600
+ overflow: hidden;
601
+ text-overflow: ellipsis;
602
+ }
603
+ .path-trace .arrow { color: var(--text-dim); margin: 0 2px; }
604
+ .path-trace .hop { color: var(--text-muted); }
605
+ .path-trace .direct { color: var(--text-dim); font-style: italic; }
606
+
607
+ /* ── Pack filter ─────────────────────────────────────────── */
608
+ .pack-filter {
609
+ padding: 8px 12px;
610
+ border-bottom: 1px solid var(--border);
611
+ flex-shrink: 0;
612
+ }
613
+ .pack-filter select {
614
+ width: 100%;
615
+ background: var(--bg-deep);
616
+ border: 1px solid var(--border);
617
+ border-radius: 6px;
618
+ color: var(--text-base);
619
+ padding: 5px 8px;
620
+ font-size: 11px;
621
+ outline: none;
622
+ cursor: pointer;
623
+ }
624
+ .pack-filter select:focus { border-color: var(--accent); }
625
+ .pack-filter select option { background: var(--bg-dark); }
626
+
627
+ /* ── Theme toggle ────────────────────────────────────────── */
628
+ .theme-toggle {
629
+ display: flex;
630
+ align-items: center;
631
+ gap: 6px;
632
+ cursor: pointer;
633
+ user-select: none;
634
+ }
635
+ .theme-toggle .icon { font-size: 14px; line-height: 1; transition: opacity .2s; }
636
+ .theme-toggle .icon.dim { opacity: .35; }
637
+ .toggle-track {
638
+ width: 36px;
639
+ height: 20px;
640
+ background: var(--bg-card);
641
+ border: 1px solid var(--border);
642
+ border-radius: 20px;
643
+ position: relative;
644
+ transition: background .2s, border-color .2s;
645
+ }
646
+ .toggle-track .toggle-knob {
647
+ width: 16px;
648
+ height: 16px;
649
+ border-radius: 50%;
650
+ background: var(--accent-hi);
651
+ position: absolute;
652
+ top: 1px;
653
+ left: 1px;
654
+ transition: transform .25s cubic-bezier(.4,.0,.2,1), background .2s;
655
+ }
656
+ [data-theme="light"] .toggle-track .toggle-knob { transform: translateX(16px); }
657
+ [data-theme="light"] .toggle-track { background: var(--accent); border-color: var(--accent); }
658
+ [data-theme="light"] .toggle-track .toggle-knob { background: #ffffff; }
659
+
660
+ /* ── Sidebar detail overlay ──────────────────────────────── */
661
+ #detail-overlay {
662
+ position: absolute;
663
+ top: 0;
664
+ left: 320px;
665
+ width: 380px;
666
+ height: 100%;
667
+ background: var(--bg-dark);
668
+ border-left: 1px solid var(--border);
669
+ box-shadow: 4px 0 24px rgba(0,0,0,.3);
670
+ z-index: 50;
671
+ display: none;
672
+ flex-direction: column;
673
+ overflow: hidden;
674
+ transition: transform .25s cubic-bezier(.4,.0,.2,1), opacity .2s;
675
+ }
676
+ [data-theme="light"] #detail-overlay { box-shadow: 4px 0 24px rgba(0,0,0,.08); }
677
+ #detail-overlay.visible { display: flex; }
678
+
679
+ #detail-overlay .overlay-header {
680
+ display: flex;
681
+ align-items: center;
682
+ padding: 10px 12px;
683
+ border-bottom: 1px solid var(--border);
684
+ gap: 8px;
685
+ flex-shrink: 0;
686
+ }
687
+ #detail-overlay .overlay-header .overlay-title {
688
+ font-size: 12px;
689
+ font-weight: 700;
690
+ color: var(--text-base);
691
+ flex: 1;
692
+ overflow: hidden;
693
+ text-overflow: ellipsis;
694
+ white-space: nowrap;
695
+ font-family: "SF Mono", "Fira Code", monospace;
696
+ }
697
+ #btn-close-detail {
698
+ background: none;
699
+ border: 1px solid var(--border);
700
+ color: var(--text-muted);
701
+ width: 24px;
702
+ height: 24px;
703
+ border-radius: 4px;
704
+ cursor: pointer;
705
+ font-size: 14px;
706
+ display: flex;
707
+ align-items: center;
708
+ justify-content: center;
709
+ transition: color .15s, border-color .15s;
710
+ flex-shrink: 0;
711
+ }
712
+ #btn-close-detail:hover { color: var(--text-base); border-color: var(--border-hi); }
713
+
714
+ #btn-toggle-detail {
715
+ position: absolute;
716
+ top: 50%;
717
+ left: 320px;
718
+ transform: translateY(-50%);
719
+ width: 28px;
720
+ height: 56px;
721
+ background: var(--bg-dark);
722
+ border: 1px solid var(--border);
723
+ border-left: none;
724
+ border-radius: 0 8px 8px 0;
725
+ cursor: pointer;
726
+ display: none;
727
+ align-items: center;
728
+ justify-content: center;
729
+ color: var(--text-muted);
730
+ font-size: 13px;
731
+ z-index: 51;
732
+ transition: color .15s, background .15s, left .25s;
733
+ }
734
+ #btn-toggle-detail:hover { color: var(--accent-hi); background: var(--bg-card); }
735
+ #btn-toggle-detail.visible { display: flex; }
736
+ #btn-toggle-detail.shifted { left: 700px; }
737
+ #btn-toggle-detail .arrow { transition: transform .2s; }
738
+ #btn-toggle-detail.shifted .arrow { transform: rotate(180deg); }
739
+
740
+ /* ── Copy diagram button ─────────────────────────────────── */
741
+ #btn-copy-diagram {
742
+ position: absolute;
743
+ top: 12px;
744
+ left: 12px;
745
+ background: var(--legend-bg);
746
+ border: 1px solid var(--border);
747
+ border-radius: 8px;
748
+ padding: 6px 12px;
749
+ cursor: pointer;
750
+ font-size: 11px;
751
+ color: var(--text-muted);
752
+ display: none;
753
+ align-items: center;
754
+ gap: 6px;
755
+ transition: color .15s, border-color .15s;
756
+ z-index: 10;
757
+ }
758
+ #btn-copy-diagram:hover { color: var(--accent-hi); border-color: var(--accent-hi); }
759
+ #btn-copy-diagram.visible { display: flex; }
760
+ #btn-copy-diagram .copy-icon { font-size: 13px; }
761
+ #btn-copy-diagram.copied { color: #3fb950; border-color: #3fb950; }
762
+ #btn-copy-diagram.copied::after { content: none; }
763
+
764
+ /* ── Toast notification ──────────────────────────────────── */
765
+ .toast {
766
+ position: fixed;
767
+ bottom: 20px;
768
+ left: 50%;
769
+ transform: translateX(-50%) translateY(80px);
770
+ background: var(--bg-card);
771
+ border: 1px solid var(--border);
772
+ color: var(--text-base);
773
+ padding: 8px 20px;
774
+ border-radius: 8px;
775
+ font-size: 12px;
776
+ z-index: 200;
777
+ opacity: 0;
778
+ transition: transform .3s cubic-bezier(.4,.0,.2,1), opacity .3s;
779
+ pointer-events: none;
780
+ }
781
+ .toast.show {
782
+ transform: translateX(-50%) translateY(0);
783
+ opacity: 1;
784
+ }
785
+ #overlay {
786
+ position: fixed;
787
+ inset: 0;
788
+ background: var(--overlay-bg);
789
+ display: flex;
790
+ flex-direction: column;
791
+ align-items: center;
792
+ justify-content: center;
793
+ gap: 16px;
794
+ z-index: 100;
795
+ }
796
+ #overlay.hidden { display: none; }
797
+ .spin {
798
+ width: 34px; height: 34px;
799
+ border: 3px solid var(--border);
800
+ border-top-color: var(--accent);
801
+ border-radius: 50%;
802
+ animation: spin .7s linear infinite;
803
+ }
804
+ @keyframes spin { to { transform: rotate(360deg); } }
805
+ #overlay p { color: var(--text-muted); font-size: 12px; }
806
+ CSS
807
+ end
808
+ # rubocop:enable Metrics/MethodLength
809
+
810
+ # rubocop:disable Metrics/MethodLength
811
+ def javascript
812
+ <<~'JS'
813
+ (function () {
814
+ 'use strict';
815
+
816
+ // ── Load data from embedded JSON ──────────────────────────────────────────
817
+ var DATA = window.__KASKD_DATA__;
818
+
819
+ // ── State ──────────────────────────────────────────────────────────────────
820
+ let services = {};
821
+ let revIndex = {};
822
+ let network = null;
823
+ let selected = null;
824
+ let maxDepth = 3;
825
+ let activePack = '';
826
+
827
+ // Navigation history
828
+ let navHistory = [];
829
+ let navIndex = -1;
830
+ let navLock = false;
831
+
832
+ // ── Colors by depth ────────────────────────────────────────────────────────
833
+ const USER_COLORS = [
834
+ { bg: '#bd561d', border: '#ffa657', font: '#ffffff', dot: '#ffa657' },
835
+ { bg: '#9b4215', border: '#f0883e', font: '#ffd8b1', dot: '#f0883e' },
836
+ { bg: '#7c310e', border: '#d4641a', font: '#ffc680', dot: '#d4641a' },
837
+ { bg: '#5e2208', border: '#bd561d', font: '#ffa657', dot: '#bd561d' },
838
+ ];
839
+ const DEP_COLORS = [
840
+ { bg: '#1f6feb', border: '#79c0ff', font: '#ffffff', dot: '#79c0ff' },
841
+ { bg: '#1158cb', border: '#58a6ff', font: '#cae8ff', dot: '#58a6ff' },
842
+ { bg: '#0d45a5', border: '#2f81f7', font: '#a5d3ff', dot: '#2f81f7' },
843
+ { bg: '#09357e', border: '#1f6feb', font: '#79c0ff', dot: '#1f6feb' },
844
+ ];
845
+ const DEPTH_LABEL_CLASS = {
846
+ users: ['green-1','green-2','green-3','green-n'],
847
+ deps: ['blue-1','blue-2','blue-3','blue-n'],
848
+ };
849
+
850
+ function userColor(d) { return USER_COLORS[Math.min(d - 1, USER_COLORS.length - 1)]; }
851
+ function depColor(d) { return DEP_COLORS[Math.min(d - 1, DEP_COLORS.length - 1)]; }
852
+
853
+ // ── BFS with path tracking ─────────────────────────────────────────────────
854
+ function bfs(startName, neighborsFn, maxD, filter) {
855
+ const dist = {};
856
+ const prev = {};
857
+ const queue = [startName];
858
+ dist[startName] = 0;
859
+ while (queue.length) {
860
+ const cur = queue.shift();
861
+ if (dist[cur] >= maxD) continue;
862
+ (neighborsFn(cur) || []).forEach(nb => {
863
+ if (dist[nb] === undefined && (!filter || filter(nb))) {
864
+ dist[nb] = dist[cur] + 1;
865
+ prev[nb] = cur;
866
+ queue.push(nb);
867
+ }
868
+ });
869
+ }
870
+ delete dist[startName];
871
+ delete prev[startName];
872
+ return { dist, prev };
873
+ }
874
+
875
+ function getPath(name, prev) {
876
+ const path = [name];
877
+ let cur = name;
878
+ let guard = 0;
879
+ while (prev[cur] && guard++ < 20) {
880
+ cur = prev[cur];
881
+ path.unshift(cur);
882
+ }
883
+ return path;
884
+ }
885
+
886
+ function packFilter() {
887
+ if (!activePack) return null;
888
+ return nb => !services[nb] || services[nb].pack === activePack;
889
+ }
890
+
891
+ function getAffected(name, maxD) {
892
+ return bfs(name, n => revIndex[n] || [], maxD, packFilter());
893
+ }
894
+
895
+ function getDeps(name, maxD) {
896
+ return bfs(name, n => (services[n]?.dependencies || []), maxD, packFilter());
897
+ }
898
+
899
+ // ── Build reverse index ────────────────────────────────────────────────────
900
+ function buildRevIndex() {
901
+ revIndex = {};
902
+ Object.values(services).forEach(s => {
903
+ (s.dependencies || []).forEach(dep => {
904
+ if (!revIndex[dep]) revIndex[dep] = [];
905
+ revIndex[dep].push(s.class_name);
906
+ });
907
+ });
908
+ }
909
+
910
+ // ── Pack selector ──────────────────────────────────────────────────────────
911
+ function populatePacks() {
912
+ const packs = [...new Set(Object.values(services).map(s => s.pack).filter(Boolean))].sort();
913
+ const sel = document.getElementById('pack-select');
914
+ const current = sel.value;
915
+ sel.innerHTML = '<option value="">All packs</option>' +
916
+ packs.map(p => `<option value="${esc(p)}"${p === current ? ' selected' : ''}>${esc(p)}</option>`).join('');
917
+ }
918
+
919
+ // ── Initialize from embedded data ──────────────────────────────────────────
920
+ function loadData() {
921
+ services = DATA.services || {};
922
+ buildRevIndex();
923
+ populatePacks();
924
+ updateBadges(DATA);
925
+ renderList('');
926
+ hideOverlay();
927
+ }
928
+
929
+ function hideOverlay() {
930
+ document.getElementById('overlay').classList.add('hidden');
931
+ }
932
+
933
+ // ── Badges ─────────────────────────────────────────────────────────────────
934
+ function updateBadges(data) {
935
+ document.getElementById('badge-total').textContent = `${data.total} services`;
936
+ var d = new Date(data.generated_at);
937
+ document.getElementById('badge-cache').textContent =
938
+ `Generated ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
939
+ }
940
+
941
+ // ── List ───────────────────────────────────────────────────────────────────
942
+ function renderList(q) {
943
+ const el = document.getElementById('service-list');
944
+ const lq = q.toLowerCase();
945
+ const all = Object.values(services)
946
+ .filter(s => (!lq || s.class_name.toLowerCase().includes(lq)) &&
947
+ (!activePack || s.pack === activePack))
948
+ .sort((a, b) => a.class_name.localeCompare(b.class_name))
949
+ .slice(0, 250);
950
+
951
+ if (!all.length) {
952
+ el.innerHTML = `<div class="empty-msg">No results for "<b>${esc(q)}</b>"</div>`;
953
+ return;
954
+ }
955
+
956
+ el.innerHTML = all.map(s => {
957
+ const deps = (s.dependencies || []).length;
958
+ const users = (revIndex[s.class_name] || []).length;
959
+ return `
960
+ <div class="svc-item${s.class_name === selected ? ' active' : ''}" data-n="${esc(s.class_name)}">
961
+ <div class="name" title="${esc(s.class_name)}">${esc(s.class_name)}</div>
962
+ ${s.description ? `<div class="desc">${esc(s.description)}</div>` : ''}
963
+ <div class="chips">
964
+ ${deps ? `<span class="chip chip-blue">&#8595; ${deps}</span>` : ''}
965
+ ${users ? `<span class="chip chip-green">&#8593; ${users}</span>` : ''}
966
+ </div>
967
+ </div>`;
968
+ }).join('');
969
+
970
+ el.querySelectorAll('.svc-item').forEach(item =>
971
+ item.addEventListener('click', () => selectService(item.dataset.n))
972
+ );
973
+ }
974
+
975
+ // ── Select service ─────────────────────────────────────────────────────────
976
+ function selectService(name) {
977
+ selected = name;
978
+ const svc = services[name];
979
+ if (!svc) return;
980
+
981
+ if (!navLock) {
982
+ navHistory = navHistory.slice(0, navIndex + 1);
983
+ navHistory.push(name);
984
+ navIndex = navHistory.length - 1;
985
+ }
986
+ updateNavButtons();
987
+
988
+ document.querySelectorAll('.svc-item').forEach(el =>
989
+ el.classList.toggle('active', el.dataset.n === name)
990
+ );
991
+ document.querySelector('.svc-item.active')?.scrollIntoView({ block: 'nearest' });
992
+
993
+ const { dist: affectedDist, prev: affectedPrev } = getAffected(name, maxDepth);
994
+ const { dist: depsDist, prev: depsPrev } = getDeps(name, maxDepth);
995
+
996
+ showInfoBar(svc, Object.keys(affectedDist).length);
997
+ renderGraph(name, affectedDist, depsDist, affectedPrev, depsPrev);
998
+ renderAffectedTab(affectedDist, affectedPrev, name);
999
+ renderDepsTab(depsDist, depsPrev, name);
1000
+ renderInfoTab(svc);
1001
+ showDetailOverlay(svc.class_name);
1002
+ }
1003
+
1004
+ // ── Navigation ─────────────────────────────────────────────────────────────
1005
+ function navigateBack() {
1006
+ if (navIndex <= 0) return;
1007
+ navIndex--;
1008
+ navLock = true;
1009
+ selectService(navHistory[navIndex]);
1010
+ navLock = false;
1011
+ }
1012
+
1013
+ function navigateForward() {
1014
+ if (navIndex >= navHistory.length - 1) return;
1015
+ navIndex++;
1016
+ navLock = true;
1017
+ selectService(navHistory[navIndex]);
1018
+ navLock = false;
1019
+ }
1020
+
1021
+ function updateNavButtons() {
1022
+ document.getElementById('btn-back').disabled = navIndex <= 0;
1023
+ document.getElementById('btn-forward').disabled = navIndex >= navHistory.length - 1;
1024
+ }
1025
+
1026
+ // ── Info bar ───────────────────────────────────────────────────────────────
1027
+ function showInfoBar(svc, blastCount) {
1028
+ document.getElementById('info-name').textContent = svc.class_name;
1029
+ document.getElementById('info-file').textContent = svc.file || '';
1030
+ document.getElementById('blast-num').textContent = blastCount;
1031
+ document.getElementById('info-bar').classList.add('visible');
1032
+ }
1033
+
1034
+ // ── Graph ──────────────────────────────────────────────────────────────────
1035
+ function renderGraph(name, affected, deps, affectedPrev, depsPrev) {
1036
+ document.getElementById('graph-empty').style.display = 'none';
1037
+ document.getElementById('legend').classList.add('visible');
1038
+ document.getElementById('depth-ctrl').classList.add('visible');
1039
+ document.getElementById('btn-copy-diagram').classList.add('visible');
1040
+
1041
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
1042
+ const selBg = isLight ? '#0969da' : '#f0f6fc';
1043
+ const selBdr = isLight ? '#0550ae' : '#cdd9e5';
1044
+ const selFont = isLight ? '#ffffff' : '#0d1117';
1045
+ const selHiBg = isLight ? '#0550ae' : '#ffffff';
1046
+ const selHiBdr = isLight ? '#0969da' : '#e6edf3';
1047
+ const shadowColor = isLight ? 'rgba(0,0,0,0.12)' : 'rgba(0,0,0,0.4)';
1048
+
1049
+ const nodes = new vis.DataSet();
1050
+ const edges = new vis.DataSet();
1051
+
1052
+ // Selected node at level 0
1053
+ nodes.add({
1054
+ id: name,
1055
+ label: nodeLabel(name, 0),
1056
+ title: name,
1057
+ shape: 'ellipse',
1058
+ size: 32,
1059
+ level: 0,
1060
+ color: { background: selBg, border: selBdr,
1061
+ highlight: { background: selHiBg, border: selHiBdr } },
1062
+ font: { color: selFont, size: 13, bold: true, face: 'SF Mono, Fira Code, monospace' },
1063
+ borderWidth: 2,
1064
+ });
1065
+
1066
+ // Affected nodes go ABOVE (negative levels)
1067
+ Object.entries(affected).forEach(([n, d]) => {
1068
+ const c = userColor(d);
1069
+ const size = d === 1 ? 20 : 14;
1070
+ const path = getPath(n, affectedPrev);
1071
+ const pathStr = path.length > 1
1072
+ ? path.map(shortLabel).join(' \u2192 ')
1073
+ : `directly uses ${shortLabel(name)}`;
1074
+ nodes.add({
1075
+ id: n, label: nodeLabel(n, d),
1076
+ title: `${n}\nAffected (level ${d})\n${pathStr}${services[n]?.description ? '\n\n' + services[n].description : ''}`,
1077
+ shape: 'box', size,
1078
+ level: -d,
1079
+ color: { background: c.bg, border: c.border,
1080
+ highlight: { background: c.border, border: '#fff' } },
1081
+ font: { color: c.font, size: 12, face: 'SF Mono, Fira Code, monospace' },
1082
+ borderWidth: d === 1 ? 2 : 1,
1083
+ });
1084
+ const parent = affectedPrev[n] || name;
1085
+ edges.add({
1086
+ from: n, to: parent, arrows: 'to',
1087
+ color: { color: c.border, opacity: Math.max(0.3, 1 - (d - 1) * 0.2) },
1088
+ width: Math.max(0.5, 2 - d * 0.3),
1089
+ dashes: d > 1,
1090
+ title: `${n} \u2192 ${parent} (level ${d})`,
1091
+ });
1092
+ });
1093
+
1094
+ // Dependency nodes go BELOW (positive levels)
1095
+ Object.entries(deps).forEach(([n, d]) => {
1096
+ if (nodes.get(n)) return;
1097
+ const c = depColor(d);
1098
+ const size = d === 1 ? 20 : 14;
1099
+ const path = getPath(n, depsPrev);
1100
+ const pathStr = path.length > 1
1101
+ ? path.map(shortLabel).join(' \u2192 ')
1102
+ : `${shortLabel(name)} directly uses ${shortLabel(n)}`;
1103
+ nodes.add({
1104
+ id: n, label: nodeLabel(n, d),
1105
+ title: `${n}\nDependency (level ${d})\n${pathStr}${services[n]?.description ? '\n\n' + services[n].description : ''}`,
1106
+ shape: 'box', size,
1107
+ level: d,
1108
+ color: { background: c.bg, border: c.border,
1109
+ highlight: { background: c.border, border: '#fff' } },
1110
+ font: { color: c.font, size: 12, face: 'SF Mono, Fira Code, monospace' },
1111
+ borderWidth: d === 1 ? 2 : 1,
1112
+ });
1113
+ const parent = depsPrev[n] || name;
1114
+ edges.add({
1115
+ from: parent, to: n, arrows: 'to',
1116
+ color: { color: c.border, opacity: Math.max(0.3, 1 - (d - 1) * 0.2) },
1117
+ width: Math.max(0.5, 2 - d * 0.3),
1118
+ dashes: d > 1,
1119
+ title: `${parent} \u2192 ${n} (level ${d})`,
1120
+ });
1121
+ });
1122
+
1123
+ const container = document.getElementById('vis-graph');
1124
+ if (network) { network.destroy(); network = null; }
1125
+
1126
+ const totalNodes = nodes.length;
1127
+ const levelSep = totalNodes > 40 ? 120 : totalNodes > 20 ? 150 : 180;
1128
+ const nodeSep = totalNodes > 40 ? 100 : totalNodes > 20 ? 130 : 160;
1129
+
1130
+ network = new vis.Network(container, { nodes, edges }, {
1131
+ layout: {
1132
+ hierarchical: {
1133
+ enabled: true,
1134
+ direction: 'DU',
1135
+ sortMethod: 'directed',
1136
+ levelSeparation: levelSep,
1137
+ nodeSpacing: nodeSep,
1138
+ treeSpacing: 200,
1139
+ blockShifting: true,
1140
+ edgeMinimization: true,
1141
+ parentCentralization: true,
1142
+ },
1143
+ },
1144
+ physics: {
1145
+ enabled: true,
1146
+ hierarchicalRepulsion: {
1147
+ centralGravity: 0.0,
1148
+ springLength: 120,
1149
+ springConstant: 0.01,
1150
+ nodeDistance: nodeSep + 20,
1151
+ damping: 0.09,
1152
+ avoidOverlap: 0.8,
1153
+ },
1154
+ stabilization: { iterations: 150, fit: true },
1155
+ },
1156
+ interaction: { hover: true, tooltipDelay: 150, zoomView: true },
1157
+ edges: {
1158
+ smooth: { type: 'cubicBezier', forceDirection: 'vertical', roundness: 0.4 },
1159
+ selectionWidth: 2,
1160
+ },
1161
+ nodes: { shadow: { enabled: true, size: 6, color: shadowColor } },
1162
+ });
1163
+
1164
+ network.on('click', p => {
1165
+ if (p.nodes.length && p.nodes[0] !== selected) selectService(p.nodes[0]);
1166
+ });
1167
+
1168
+ network.once('stabilizationIterationsDone', () => {
1169
+ network.fit({ animation: { duration: 300, easingFunction: 'easeInOutQuad' } });
1170
+ });
1171
+ }
1172
+
1173
+ // ── Tabs ───────────────────────────────────────────────────────────────────
1174
+ function renderAffectedTab(affectedDist, affectedPrev, rootName) {
1175
+ const grouped = groupByDepth(affectedDist);
1176
+ const total = Object.keys(affectedDist).length;
1177
+ document.getElementById('tc-affected').textContent = total;
1178
+ const el = document.getElementById('tb-affected');
1179
+ if (!total) {
1180
+ el.innerHTML = '<div class="desc-info">No service depends on this one.</div>';
1181
+ return;
1182
+ }
1183
+ el.innerHTML = Object.entries(grouped).map(([d, names]) => {
1184
+ const cls = DEPTH_LABEL_CLASS.users[Math.min(+d - 1, 3)];
1185
+ const dot = USER_COLORS[Math.min(+d - 1, 3)].dot;
1186
+ return `
1187
+ <div class="depth-group">
1188
+ <div class="depth-label ${cls}">Level ${d} <span style="opacity:.6">(${names.length})</span></div>
1189
+ ${names.map(n => depItemHtml(n, dot, getPath(n, affectedPrev), rootName, 'affected')).join('')}
1190
+ </div>`;
1191
+ }).join('');
1192
+ el.querySelectorAll('.dep-item').forEach(i =>
1193
+ i.addEventListener('click', () => selectService(i.dataset.n))
1194
+ );
1195
+ }
1196
+
1197
+ function renderDepsTab(depsDist, depsPrev, rootName) {
1198
+ const grouped = groupByDepth(depsDist);
1199
+ const total = Object.keys(depsDist).length;
1200
+ document.getElementById('tc-deps').textContent = total;
1201
+ const el = document.getElementById('tb-deps');
1202
+ if (!total) {
1203
+ el.innerHTML = '<div class="desc-info">This service has no direct dependencies.</div>';
1204
+ return;
1205
+ }
1206
+ el.innerHTML = Object.entries(grouped).map(([d, names]) => {
1207
+ const cls = DEPTH_LABEL_CLASS.deps[Math.min(+d - 1, 3)];
1208
+ const dot = DEP_COLORS[Math.min(+d - 1, 3)].dot;
1209
+ return `
1210
+ <div class="depth-group">
1211
+ <div class="depth-label ${cls}">Level ${d} <span style="opacity:.6">(${names.length})</span></div>
1212
+ ${names.map(n => depItemHtml(n, dot, getPath(n, depsPrev), rootName, 'deps')).join('')}
1213
+ </div>`;
1214
+ }).join('');
1215
+ el.querySelectorAll('.dep-item').forEach(i =>
1216
+ i.addEventListener('click', () => selectService(i.dataset.n))
1217
+ );
1218
+ }
1219
+
1220
+ function renderInfoTab(svc) {
1221
+ const desc = svc.description || (svc.parent ? `Inherits from ${svc.parent}` : '');
1222
+ document.getElementById('tb-info').innerHTML = `
1223
+ <div class="desc-info">
1224
+ ${desc ? esc(desc) : '<span style="color:var(--text-dim)">No description documented.</span>'}
1225
+ ${svc.parent ? `<br><br><span style="color:var(--text-muted)">Inherits from: <code style="color:#93c5fd">${esc(svc.parent)}</code></span>` : ''}
1226
+ </div>`;
1227
+ }
1228
+
1229
+ // ── Helpers ────────────────────────────────────────────────────────────────
1230
+ function groupByDepth(distMap) {
1231
+ const g = {};
1232
+ Object.entries(distMap)
1233
+ .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
1234
+ .forEach(([n, d]) => { (g[d] = g[d] || []).push(n); });
1235
+ return g;
1236
+ }
1237
+
1238
+ function depItemHtml(name, dotColor, path, rootName, type) {
1239
+ let pathHtml = '';
1240
+ if (path.length <= 2) {
1241
+ const label = type === 'affected'
1242
+ ? `directly uses <b>${shortLabel(rootName)}</b>`
1243
+ : `directly used by <b>${shortLabel(rootName)}</b>`;
1244
+ pathHtml = `<div class="path-trace direct">${label}</div>`;
1245
+ } else {
1246
+ const hops = path.slice(1, -1).map(n => `<span class="hop">${esc(shortLabel(n))}</span>`);
1247
+ pathHtml = `<div class="path-trace">via <span class="arrow">\u2192</span> ${hops.join(' <span class="arrow">\u2192</span> ')}</div>`;
1248
+ }
1249
+ const desc = services[name]?.description;
1250
+ return `
1251
+ <div class="dep-item" data-n="${esc(name)}" title="${esc(name)}">
1252
+ <div class="dot" style="background:${dotColor};margin-top:3px;align-self:flex-start"></div>
1253
+ <div style="overflow:hidden;min-width:0">
1254
+ <span class="nm">${esc(name)}</span>
1255
+ ${pathHtml}
1256
+ ${desc ? `<div class="dep-desc">${esc(desc)}</div>` : ''}
1257
+ </div>
1258
+ </div>`;
1259
+ }
1260
+
1261
+ function shortLabel(name) {
1262
+ const parts = name.split('::');
1263
+ if (parts.length <= 2) return name;
1264
+ return parts.slice(-2).join('::');
1265
+ }
1266
+
1267
+ function nodeLabel(name) {
1268
+ const parts = name.split('::');
1269
+ if (parts.length === 1) return name;
1270
+ const ns = parts.slice(0, -1).join('::');
1271
+ const klass = parts[parts.length - 1];
1272
+ return ns + '\n' + klass;
1273
+ }
1274
+
1275
+ function esc(s) {
1276
+ return String(s)
1277
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;')
1278
+ .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1279
+ }
1280
+
1281
+ // ── Detail overlay ─────────────────────────────────────────────────────────
1282
+ let detailVisible = false;
1283
+
1284
+ function showDetailOverlay(name) {
1285
+ const overlay = document.getElementById('detail-overlay');
1286
+ const toggleBtn = document.getElementById('btn-toggle-detail');
1287
+ document.getElementById('detail-overlay-title').textContent = shortLabel(name);
1288
+ overlay.classList.add('visible');
1289
+ toggleBtn.classList.add('visible');
1290
+ toggleBtn.classList.add('shifted');
1291
+ detailVisible = true;
1292
+ }
1293
+
1294
+ function hideDetailOverlay() {
1295
+ document.getElementById('detail-overlay').classList.remove('visible');
1296
+ document.getElementById('btn-toggle-detail').classList.remove('shifted');
1297
+ detailVisible = false;
1298
+ }
1299
+
1300
+ function toggleDetailOverlay() {
1301
+ if (detailVisible) {
1302
+ hideDetailOverlay();
1303
+ } else if (selected) {
1304
+ showDetailOverlay(shortLabel(selected));
1305
+ }
1306
+ }
1307
+
1308
+ // ── Theme ──────────────────────────────────────────────────────────────────
1309
+ function getStoredTheme() {
1310
+ try { return localStorage.getItem('kaskd-lens-theme'); } catch(e) { return null; }
1311
+ }
1312
+
1313
+ function setTheme(theme) {
1314
+ document.documentElement.setAttribute('data-theme', theme);
1315
+ try { localStorage.setItem('kaskd-lens-theme', theme); } catch(e) {}
1316
+ const moonIcon = document.getElementById('icon-moon');
1317
+ const sunIcon = document.getElementById('icon-sun');
1318
+ if (theme === 'light') {
1319
+ moonIcon.classList.add('dim');
1320
+ sunIcon.classList.remove('dim');
1321
+ } else {
1322
+ moonIcon.classList.remove('dim');
1323
+ sunIcon.classList.add('dim');
1324
+ }
1325
+ if (selected && services[selected]) {
1326
+ const { dist: affectedDist, prev: affectedPrev } = getAffected(selected, maxDepth);
1327
+ const { dist: depsDist, prev: depsPrev } = getDeps(selected, maxDepth);
1328
+ renderGraph(selected, affectedDist, depsDist, affectedPrev, depsPrev);
1329
+ }
1330
+ }
1331
+
1332
+ function toggleTheme() {
1333
+ const current = document.documentElement.getAttribute('data-theme') || 'dark';
1334
+ setTheme(current === 'dark' ? 'light' : 'dark');
1335
+ }
1336
+
1337
+ // Initialize theme
1338
+ (function initTheme() {
1339
+ var stored = getStoredTheme();
1340
+ setTheme(stored || 'dark');
1341
+ })();
1342
+
1343
+ // ── Copy diagram ───────────────────────────────────────────────────────────
1344
+ function showToast(msg, duration) {
1345
+ const toast = document.getElementById('toast');
1346
+ toast.textContent = msg;
1347
+ toast.classList.add('show');
1348
+ setTimeout(() => toast.classList.remove('show'), duration || 2500);
1349
+ }
1350
+
1351
+ function copyDiagram() {
1352
+ if (!network) return;
1353
+ const btn = document.getElementById('btn-copy-diagram');
1354
+
1355
+ const srcCanvas = document.querySelector('#vis-graph canvas');
1356
+ if (!srcCanvas) {
1357
+ showToast('No diagram to copy');
1358
+ return;
1359
+ }
1360
+
1361
+ const isLight = document.documentElement.getAttribute('data-theme') === 'light';
1362
+ const bgColor = isLight ? '#f6f8fa' : '#0d1117';
1363
+
1364
+ const outCanvas = document.createElement('canvas');
1365
+ outCanvas.width = srcCanvas.width;
1366
+ outCanvas.height = srcCanvas.height;
1367
+ const ctx = outCanvas.getContext('2d');
1368
+
1369
+ ctx.fillStyle = bgColor;
1370
+ ctx.fillRect(0, 0, outCanvas.width, outCanvas.height);
1371
+ ctx.drawImage(srcCanvas, 0, 0);
1372
+
1373
+ outCanvas.toBlob(function(blob) {
1374
+ if (!blob) {
1375
+ showToast('Failed to capture diagram');
1376
+ return;
1377
+ }
1378
+
1379
+ if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
1380
+ navigator.clipboard.write([
1381
+ new ClipboardItem({ 'image/png': blob })
1382
+ ]).then(function() {
1383
+ btn.classList.add('copied');
1384
+ btn.innerHTML = '<span class="copy-icon">&#10003;</span> Copied!';
1385
+ showToast('Diagram copied to clipboard');
1386
+ setTimeout(function() {
1387
+ btn.classList.remove('copied');
1388
+ btn.innerHTML = '<span class="copy-icon">&#128203;</span> Copy diagram';
1389
+ }, 2000);
1390
+ }).catch(function() {
1391
+ fallbackDownload(blob);
1392
+ });
1393
+ } else {
1394
+ fallbackDownload(blob);
1395
+ }
1396
+ }, 'image/png');
1397
+ }
1398
+
1399
+ function fallbackDownload(blob) {
1400
+ const url = URL.createObjectURL(blob);
1401
+ const a = document.createElement('a');
1402
+ a.href = url;
1403
+ a.download = 'service-graph-' + (selected || 'diagram') + '.png';
1404
+ document.body.appendChild(a);
1405
+ a.click();
1406
+ document.body.removeChild(a);
1407
+ URL.revokeObjectURL(url);
1408
+ showToast('Diagram downloaded as PNG (clipboard unavailable)');
1409
+ }
1410
+
1411
+ // ── Export JSON report ──────────────────────────────────────────────────────
1412
+ function exportJSON() {
1413
+ if (!selected || !services[selected]) return;
1414
+
1415
+ const svc = services[selected];
1416
+ const { dist: affectedDist, prev: affectedPrev } = getAffected(selected, maxDepth);
1417
+ const { dist: depsDist, prev: depsPrev } = getDeps(selected, maxDepth);
1418
+
1419
+ const report = {
1420
+ generated_at: new Date().toISOString(),
1421
+ service: {
1422
+ class_name: svc.class_name,
1423
+ file: svc.file || null,
1424
+ pack: svc.pack || null,
1425
+ description: svc.description || null,
1426
+ parent: svc.parent || null,
1427
+ direct_dependencies: svc.dependencies || [],
1428
+ },
1429
+ analysis: {
1430
+ max_depth: maxDepth,
1431
+ pack_filter: activePack || null,
1432
+ },
1433
+ affected: Object.entries(affectedDist)
1434
+ .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
1435
+ .map(([name, depth]) => ({
1436
+ class_name: name,
1437
+ depth: depth,
1438
+ path: getPath(name, affectedPrev),
1439
+ file: services[name]?.file || null,
1440
+ pack: services[name]?.pack || null,
1441
+ })),
1442
+ dependencies: Object.entries(depsDist)
1443
+ .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
1444
+ .map(([name, depth]) => ({
1445
+ class_name: name,
1446
+ depth: depth,
1447
+ path: getPath(name, depsPrev),
1448
+ file: services[name]?.file || null,
1449
+ pack: services[name]?.pack || null,
1450
+ })),
1451
+ summary: {
1452
+ total_affected: Object.keys(affectedDist).length,
1453
+ total_dependencies: Object.keys(depsDist).length,
1454
+ },
1455
+ };
1456
+
1457
+ const json = JSON.stringify(report, null, 2);
1458
+ const blob = new Blob([json], { type: 'application/json' });
1459
+ const url = URL.createObjectURL(blob);
1460
+ const a = document.createElement('a');
1461
+ a.href = url;
1462
+ a.download = selected.replace(/::/g, '-').toLowerCase() + '-report.json';
1463
+ document.body.appendChild(a);
1464
+ a.click();
1465
+ document.body.removeChild(a);
1466
+ URL.revokeObjectURL(url);
1467
+ showToast('Report downloaded');
1468
+ }
1469
+
1470
+ // ── Events ─────────────────────────────────────────────────────────────────
1471
+ document.getElementById('pack-select').addEventListener('change', e => {
1472
+ activePack = e.target.value;
1473
+ renderList(document.getElementById('search').value);
1474
+ if (selected) selectService(selected);
1475
+ });
1476
+
1477
+ document.getElementById('search').addEventListener('input', e => renderList(e.target.value));
1478
+
1479
+ document.querySelectorAll('.tab').forEach(tab => {
1480
+ tab.addEventListener('click', () => {
1481
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1482
+ document.querySelectorAll('.tab-body').forEach(b => b.classList.remove('active'));
1483
+ tab.classList.add('active');
1484
+ document.getElementById('tb-' + tab.dataset.tab).classList.add('active');
1485
+ });
1486
+ });
1487
+
1488
+ document.getElementById('depth-slider').addEventListener('input', e => {
1489
+ maxDepth = +e.target.value;
1490
+ document.getElementById('depth-val').textContent = maxDepth;
1491
+ if (selected) selectService(selected);
1492
+ });
1493
+
1494
+ // Theme toggle
1495
+ document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
1496
+
1497
+ // Detail overlay
1498
+ document.getElementById('btn-close-detail').addEventListener('click', hideDetailOverlay);
1499
+ document.getElementById('btn-toggle-detail').addEventListener('click', toggleDetailOverlay);
1500
+
1501
+ // Copy diagram
1502
+ document.getElementById('btn-copy-diagram').addEventListener('click', copyDiagram);
1503
+
1504
+ // Navigation
1505
+ document.getElementById('btn-back').addEventListener('click', navigateBack);
1506
+ document.getElementById('btn-forward').addEventListener('click', navigateForward);
1507
+
1508
+ // Export
1509
+ document.getElementById('btn-export').addEventListener('click', exportJSON);
1510
+
1511
+ // ── Init ───────────────────────────────────────────────────────────────────
1512
+ loadData();
1513
+ })();
1514
+ JS
1515
+ end
1516
+ # rubocop:enable Metrics/MethodLength
1517
+ end
1518
+ end
1519
+ end