viberadar 0.2.2 → 0.3.1

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.
@@ -7,141 +7,217 @@
7
7
  <style>
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
 
10
+ :root {
11
+ --bg: #0d1117;
12
+ --bg-card: #161b22;
13
+ --bg-hover: #1c2230;
14
+ --border: #21262d;
15
+ --text: #e6edf3;
16
+ --muted: #7d8590;
17
+ --dim: #484f58;
18
+ --blue: #58a6ff;
19
+ --green: #3fb950;
20
+ --red: #f85149;
21
+ --yellow: #e3b341;
22
+ }
23
+
10
24
  body {
11
- font-family: 'Segoe UI', system-ui, sans-serif;
12
- background: #0d1117;
13
- color: #e6edf3;
14
- min-height: 100vh;
25
+ font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ height: 100vh;
29
+ overflow: hidden;
30
+ display: flex;
31
+ flex-direction: column;
15
32
  }
16
33
 
34
+ /* ── Header ──────────────────────────────────────────────────────────────── */
17
35
  header {
18
36
  display: flex;
19
37
  align-items: center;
20
- gap: 12px;
21
- padding: 16px 24px;
22
- border-bottom: 1px solid #21262d;
23
- background: #161b22;
24
- }
25
-
26
- header h1 {
27
- font-size: 20px;
28
- font-weight: 700;
29
- letter-spacing: -0.5px;
30
- }
31
-
32
- header .project-name {
33
- font-size: 13px;
34
- color: #7d8590;
35
- margin-left: auto;
36
- }
37
-
38
- header .scanned-at {
39
- font-size: 12px;
40
- color: #484f58;
38
+ gap: 10px;
39
+ padding: 12px 20px;
40
+ background: var(--bg-card);
41
+ border-bottom: 1px solid var(--border);
42
+ flex-shrink: 0;
43
+ z-index: 10;
41
44
  }
45
+ header h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
46
+ .header-project { margin-left: auto; font-size: 13px; color: var(--muted); }
47
+ .header-time { font-size: 12px; color: var(--dim); }
42
48
 
49
+ /* ── Stats bar ───────────────────────────────────────────────────────────── */
43
50
  .stats-bar {
44
51
  display: flex;
45
- gap: 24px;
46
- padding: 14px 24px;
47
- background: #161b22;
48
- border-bottom: 1px solid #21262d;
52
+ gap: 28px;
53
+ padding: 12px 20px;
54
+ background: var(--bg-card);
55
+ border-bottom: 1px solid var(--border);
56
+ flex-shrink: 0;
49
57
  flex-wrap: wrap;
50
58
  }
59
+ .stat { display: flex; flex-direction: column; gap: 2px; }
60
+ .stat-value { font-size: 20px; font-weight: 700; color: var(--blue); }
61
+ .stat-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
51
62
 
52
- .stat {
53
- display: flex;
54
- flex-direction: column;
55
- gap: 2px;
56
- }
57
-
58
- .stat-value {
59
- font-size: 22px;
60
- font-weight: 700;
61
- color: #58a6ff;
62
- }
63
-
64
- .stat-label {
65
- font-size: 11px;
66
- color: #7d8590;
67
- text-transform: uppercase;
68
- letter-spacing: 0.5px;
69
- }
70
-
71
- .main {
72
- display: grid;
73
- grid-template-columns: 260px 1fr;
74
- height: calc(100vh - 110px);
75
- }
63
+ /* ── Layout ──────────────────────────────────────────────────────────────── */
64
+ .layout { display: flex; flex: 1; overflow: hidden; }
76
65
 
66
+ /* ── Sidebar ─────────────────────────────────────────────────────────────── */
77
67
  .sidebar {
78
- border-right: 1px solid #21262d;
68
+ width: 220px;
69
+ flex-shrink: 0;
70
+ border-right: 1px solid var(--border);
79
71
  overflow-y: auto;
80
- padding: 12px 0;
72
+ padding: 14px 10px;
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 14px;
81
76
  }
82
77
 
83
- .filter-group {
84
- padding: 8px 16px;
78
+ .view-tabs {
79
+ display: flex;
80
+ background: var(--bg);
81
+ border-radius: 6px;
82
+ padding: 3px;
83
+ gap: 2px;
85
84
  }
85
+ .view-tab {
86
+ flex: 1;
87
+ padding: 5px 0;
88
+ text-align: center;
89
+ font-size: 12px;
90
+ font-weight: 600;
91
+ border-radius: 4px;
92
+ cursor: pointer;
93
+ color: var(--muted);
94
+ transition: background 0.15s, color 0.15s;
95
+ user-select: none;
96
+ }
97
+ .view-tab.active { background: var(--bg-card); color: var(--text); }
98
+ .view-tab.disabled { opacity: 0.4; pointer-events: none; }
86
99
 
87
- .filter-group label {
88
- font-size: 11px;
89
- color: #7d8590;
100
+ .sidebar-label {
101
+ font-size: 10px;
102
+ color: var(--muted);
90
103
  text-transform: uppercase;
91
104
  letter-spacing: 0.5px;
92
- display: block;
93
- margin-bottom: 6px;
105
+ padding: 0 6px;
94
106
  }
95
107
 
96
- .filter-group input {
108
+ .search-input {
97
109
  width: 100%;
98
- padding: 6px 10px;
99
- background: #21262d;
100
- border: 1px solid #30363d;
110
+ padding: 7px 10px;
111
+ background: var(--bg);
112
+ border: 1px solid var(--border);
101
113
  border-radius: 6px;
102
- color: #e6edf3;
114
+ color: var(--text);
103
115
  font-size: 13px;
104
116
  outline: none;
105
117
  }
118
+ .search-input:focus { border-color: var(--blue); }
119
+ .search-input::placeholder { color: var(--dim); }
120
+
121
+ .type-filter {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 7px;
125
+ padding: 5px 8px;
126
+ border-radius: 4px;
127
+ cursor: pointer;
128
+ font-size: 12px;
129
+ color: var(--muted);
130
+ transition: background 0.1s, color 0.1s;
131
+ }
132
+ .type-filter:hover { background: var(--border); color: var(--text); }
133
+ .type-filter.active { background: var(--border); color: var(--text); }
134
+ .type-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
135
+ .type-count { margin-left: auto; font-size: 11px; color: var(--dim); }
136
+
137
+ /* ── Content area ────────────────────────────────────────────────────────── */
138
+ .content { flex: 1; overflow-y: auto; padding: 18px 20px; }
106
139
 
107
- .filter-group input:focus {
108
- border-color: #58a6ff;
140
+ /* ── Feature cards ───────────────────────────────────────────────────────── */
141
+ .features-grid {
142
+ display: grid;
143
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
144
+ gap: 12px;
109
145
  }
110
146
 
111
- .type-filters {
112
- padding: 8px 16px;
147
+ .feature-card {
148
+ background: var(--bg-card);
149
+ border: 1px solid var(--border);
150
+ border-radius: 8px;
151
+ cursor: pointer;
113
152
  display: flex;
114
- flex-direction: column;
115
- gap: 4px;
153
+ overflow: hidden;
154
+ transition: background 0.15s, transform 0.1s, border-color 0.15s;
116
155
  }
156
+ .feature-card:hover { background: var(--bg-hover); transform: translateY(-1px); }
157
+ .feature-card.active { background: var(--bg-hover); border-color: #30363d; }
117
158
 
118
- .type-filter {
159
+ .feature-accent { width: 4px; flex-shrink: 0; }
160
+
161
+ .feature-body { padding: 14px 16px; flex: 1; min-width: 0; }
162
+
163
+ .feature-title {
164
+ font-size: 14px;
165
+ font-weight: 600;
166
+ margin-bottom: 5px;
119
167
  display: flex;
120
168
  align-items: center;
169
+ justify-content: space-between;
121
170
  gap: 8px;
122
- padding: 4px 6px;
123
- border-radius: 4px;
124
- cursor: pointer;
125
- font-size: 13px;
126
- color: #7d8590;
127
- transition: background 0.1s;
128
171
  }
172
+ .feature-file-count { font-size: 11px; color: var(--muted); white-space: nowrap; flex-shrink: 0; }
129
173
 
130
- .type-filter:hover { background: #21262d; }
131
- .type-filter.active { color: #e6edf3; background: #21262d; }
174
+ .feature-desc {
175
+ font-size: 12px;
176
+ color: var(--muted);
177
+ margin-bottom: 12px;
178
+ overflow: hidden;
179
+ display: -webkit-box;
180
+ -webkit-line-clamp: 2;
181
+ -webkit-box-orient: vertical;
182
+ line-height: 1.4;
183
+ }
132
184
 
133
- .type-dot {
134
- width: 8px;
135
- height: 8px;
136
- border-radius: 50%;
137
- flex-shrink: 0;
185
+ .feature-progress-wrap { display: flex; align-items: center; gap: 8px; }
186
+ .feature-progress-bar {
187
+ flex: 1;
188
+ height: 5px;
189
+ background: var(--border);
190
+ border-radius: 3px;
191
+ overflow: hidden;
138
192
  }
193
+ .feature-progress-fill { height: 100%; border-radius: 3px; transition: width 0.4s; }
194
+ .feature-progress-label { font-size: 11px; color: var(--muted); white-space: nowrap; }
139
195
 
140
- .content {
141
- overflow-y: auto;
142
- padding: 16px;
196
+ /* ── No config banner ────────────────────────────────────────────────────── */
197
+ .no-config {
198
+ display: flex;
199
+ flex-direction: column;
200
+ align-items: center;
201
+ justify-content: center;
202
+ gap: 16px;
203
+ text-align: center;
204
+ padding: 60px 20px;
205
+ color: var(--muted);
206
+ }
207
+ .no-config-icon { font-size: 48px; }
208
+ .no-config h2 { font-size: 18px; color: var(--text); }
209
+ .no-config p { font-size: 14px; max-width: 420px; line-height: 1.6; }
210
+ .no-config-cmd {
211
+ background: var(--bg-card);
212
+ border: 1px solid var(--border);
213
+ border-radius: 8px;
214
+ padding: 12px 28px;
215
+ font-family: monospace;
216
+ font-size: 15px;
217
+ color: var(--blue);
143
218
  }
144
219
 
220
+ /* ── Module grid (Files view) ────────────────────────────────────────────── */
145
221
  .module-grid {
146
222
  display: grid;
147
223
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -149,240 +225,165 @@
149
225
  }
150
226
 
151
227
  .module-card {
152
- background: #161b22;
153
- border: 1px solid #21262d;
228
+ background: var(--bg-card);
229
+ border: 1px solid var(--border);
154
230
  border-radius: 8px;
155
231
  padding: 12px;
156
232
  cursor: pointer;
157
233
  transition: border-color 0.15s, transform 0.1s;
158
234
  }
159
-
160
- .module-card:hover {
161
- border-color: #58a6ff;
162
- transform: translateY(-1px);
163
- }
164
-
165
- .module-card.selected {
166
- border-color: #58a6ff;
167
- background: #1c2230;
168
- }
169
-
170
- .module-card-header {
171
- display: flex;
172
- align-items: flex-start;
173
- gap: 8px;
174
- margin-bottom: 8px;
175
- }
235
+ .module-card:hover { border-color: var(--blue); transform: translateY(-1px); }
236
+ .module-card.active { border-color: var(--blue); background: var(--bg-hover); }
176
237
 
177
238
  .module-name {
178
239
  font-size: 13px;
179
240
  font-weight: 600;
180
- word-break: break-all;
181
- flex: 1;
182
- }
183
-
184
- .badge {
185
- font-size: 10px;
186
- padding: 2px 6px;
187
- border-radius: 10px;
188
- font-weight: 600;
189
- white-space: nowrap;
190
- flex-shrink: 0;
191
- }
192
-
193
- .badge-green { background: #1a3a2a; color: #3fb950; }
194
- .badge-red { background: #3a1a1a; color: #f85149; }
195
- .badge-gray { background: #21262d; color: #7d8590; }
196
-
197
- .module-path {
198
- font-size: 11px;
199
- color: #484f58;
200
- margin-bottom: 8px;
241
+ margin-bottom: 4px;
201
242
  word-break: break-all;
202
243
  }
244
+ .module-path { font-size: 11px; color: var(--dim); margin-bottom: 8px; word-break: break-all; }
245
+ .module-meta { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--muted); }
203
246
 
204
- .coverage-bar {
205
- height: 4px;
206
- border-radius: 2px;
207
- background: #21262d;
208
- overflow: hidden;
209
- margin-top: 6px;
210
- }
247
+ /* ── Badges ──────────────────────────────────────────────────────────────── */
248
+ .badge { font-size: 10px; padding: 2px 6px; border-radius: 10px; font-weight: 600; white-space: nowrap; }
249
+ .badge-green { background: #1a3a2a; color: var(--green); }
250
+ .badge-red { background: #3a1a1a; color: var(--red); }
251
+ .badge-gray { background: var(--border); color: var(--muted); }
211
252
 
212
- .coverage-fill {
213
- height: 100%;
214
- border-radius: 2px;
215
- transition: width 0.3s;
216
- }
253
+ .cov-bar { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 8px; }
254
+ .cov-fill { height: 100%; border-radius: 2px; }
217
255
 
218
- .detail-panel {
256
+ /* ── Right panel ─────────────────────────────────────────────────────────── */
257
+ .panel {
219
258
  position: fixed;
220
- right: 0;
221
- top: 0;
222
- width: 380px;
259
+ top: 0; right: 0;
260
+ width: 420px;
223
261
  height: 100vh;
224
- background: #161b22;
225
- border-left: 1px solid #21262d;
226
- padding: 20px;
262
+ background: var(--bg-card);
263
+ border-left: 1px solid var(--border);
227
264
  overflow-y: auto;
228
265
  transform: translateX(100%);
229
- transition: transform 0.2s;
266
+ transition: transform 0.2s ease;
230
267
  z-index: 100;
268
+ padding: 20px;
231
269
  }
270
+ .panel.open { transform: translateX(0); }
232
271
 
233
- .detail-panel.open {
234
- transform: translateX(0);
235
- }
236
-
237
- .detail-close {
272
+ .panel-close {
238
273
  position: absolute;
239
- top: 16px;
240
- right: 16px;
274
+ top: 16px; right: 16px;
241
275
  background: none;
242
276
  border: none;
243
- color: #7d8590;
277
+ color: var(--muted);
244
278
  cursor: pointer;
245
- font-size: 18px;
246
- padding: 4px;
247
- }
248
-
249
- .detail-close:hover { color: #e6edf3; }
250
-
251
- .detail-title {
252
279
  font-size: 16px;
253
- font-weight: 700;
254
- margin-bottom: 4px;
255
- padding-right: 32px;
256
- }
257
-
258
- .detail-path {
259
- font-size: 11px;
260
- color: #484f58;
261
- margin-bottom: 16px;
262
- word-break: break-all;
263
- }
264
-
265
- .detail-section {
266
- margin-bottom: 16px;
280
+ padding: 5px 8px;
281
+ border-radius: 4px;
282
+ line-height: 1;
267
283
  }
284
+ .panel-close:hover { background: var(--border); color: var(--text); }
268
285
 
269
- .detail-section-title {
270
- font-size: 11px;
271
- text-transform: uppercase;
272
- letter-spacing: 0.5px;
273
- color: #7d8590;
274
- margin-bottom: 8px;
275
- }
286
+ .panel-title { font-size: 17px; font-weight: 700; padding-right: 36px; margin-bottom: 4px; }
287
+ .panel-subtitle { font-size: 12px; color: var(--muted); margin-bottom: 20px; line-height: 1.5; }
276
288
 
277
- .detail-row {
289
+ .panel-stats {
278
290
  display: flex;
279
- justify-content: space-between;
280
- font-size: 13px;
281
- padding: 4px 0;
282
- border-bottom: 1px solid #21262d;
291
+ gap: 0;
292
+ margin-bottom: 20px;
293
+ padding-bottom: 16px;
294
+ border-bottom: 1px solid var(--border);
283
295
  }
296
+ .panel-stat { flex: 1; text-align: center; }
297
+ .panel-stat-val { font-size: 22px; font-weight: 700; }
298
+ .panel-stat-lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
284
299
 
285
- .detail-row:last-child { border-bottom: none; }
300
+ .panel-section { margin-bottom: 20px; }
301
+ .panel-section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); margin-bottom: 8px; }
286
302
 
287
- .coverage-metric {
288
- display: grid;
289
- grid-template-columns: 1fr 1fr;
303
+ .file-list { display: flex; flex-direction: column; gap: 3px; }
304
+ .file-item {
305
+ display: flex;
306
+ align-items: flex-start;
290
307
  gap: 8px;
291
- }
292
-
293
- .coverage-metric-item {
294
- background: #21262d;
308
+ padding: 7px 10px;
309
+ background: var(--bg);
295
310
  border-radius: 6px;
296
- padding: 8px;
297
- text-align: center;
311
+ font-size: 12px;
298
312
  }
313
+ .file-item-icon { font-size: 11px; flex-shrink: 0; padding-top: 2px; }
314
+ .file-item-name { font-weight: 600; color: var(--text); word-break: break-all; line-height: 1.3; }
315
+ .file-item-dir { color: var(--dim); font-size: 11px; word-break: break-all; }
299
316
 
300
- .coverage-metric-value {
301
- font-size: 20px;
302
- font-weight: 700;
303
- }
317
+ .cov-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
318
+ .cov-metric { background: var(--bg); border-radius: 6px; padding: 10px; text-align: center; }
319
+ .cov-metric-val { font-size: 20px; font-weight: 700; }
320
+ .cov-metric-lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; }
304
321
 
305
- .coverage-metric-label {
306
- font-size: 10px;
307
- color: #7d8590;
308
- text-transform: uppercase;
309
- }
310
-
311
- .deps-list {
322
+ .detail-row {
312
323
  display: flex;
313
- flex-direction: column;
314
- gap: 4px;
324
+ justify-content: space-between;
325
+ align-items: center;
326
+ font-size: 13px;
327
+ padding: 6px 0;
328
+ border-bottom: 1px solid var(--border);
315
329
  }
330
+ .detail-row:last-child { border-bottom: none; }
331
+ .detail-row-right { font-size: 11px; color: var(--muted); text-align: right; max-width: 60%; word-break: break-all; }
316
332
 
317
333
  .dep-item {
318
334
  font-size: 12px;
319
- color: #7d8590;
335
+ color: var(--muted);
320
336
  padding: 3px 8px;
321
- background: #21262d;
337
+ background: var(--bg);
322
338
  border-radius: 4px;
323
339
  font-family: monospace;
324
340
  }
325
341
 
326
- .loading {
327
- display: flex;
328
- align-items: center;
329
- justify-content: center;
330
- height: 200px;
331
- color: #7d8590;
332
- font-size: 14px;
333
- }
334
-
335
- .type-colors {
336
- component: '#58a6ff';
337
- service: '#d2a8ff';
338
- util: '#ffa657';
339
- test: '#3fb950';
340
- config: '#f85149';
341
- other: '#484f58';
342
- }
342
+ /* ── Misc ────────────────────────────────────────────────────────────────── */
343
+ .loading { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 14px; }
344
+ .empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 14px; }
343
345
  </style>
344
346
  </head>
345
347
  <body>
346
348
 
347
349
  <header>
348
- <span style="font-size:22px">🔭</span>
350
+ <span style="font-size:20px">🔭</span>
349
351
  <h1>VibeRadar</h1>
350
- <span class="project-name" id="projectName">—</span>
351
- <span class="scanned-at" id="scannedAt"></span>
352
+ <span class="header-project" id="projectName">—</span>
353
+ <span class="header-time" id="scannedAt"></span>
352
354
  </header>
353
355
 
354
- <div class="stats-bar">
355
- <div class="stat"><span class="stat-value" id="statModules">—</span><span class="stat-label">Modules</span></div>
356
- <div class="stat"><span class="stat-value" id="statWithTests">—</span><span class="stat-label">With Tests</span></div>
357
- <div class="stat"><span class="stat-value" id="statCoverage">—</span><span class="stat-label">Avg Coverage</span></div>
358
- <div class="stat"><span class="stat-value" id="statTests">—</span><span class="stat-label">Test Files</span></div>
359
- </div>
360
-
361
- <div class="main">
362
- <div class="sidebar">
363
- <div class="filter-group">
364
- <label>Search</label>
365
- <input id="searchInput" type="text" placeholder="Filter modules..." />
366
- </div>
356
+ <div class="stats-bar" id="statsBar"></div>
367
357
 
368
- <div style="padding: 8px 16px; font-size:11px; color:#7d8590; text-transform:uppercase; letter-spacing:0.5px; margin-top:8px;">
369
- Type
358
+ <div class="layout">
359
+ <aside class="sidebar">
360
+ <div class="view-tabs" id="viewTabs">
361
+ <div class="view-tab" data-view="features">Features</div>
362
+ <div class="view-tab" data-view="files">Files</div>
370
363
  </div>
371
- <div class="type-filters" id="typeFilters"></div>
372
- </div>
364
+ <input class="search-input" id="searchInput" type="text" placeholder="Search…" />
365
+ <div id="sidebarExtra"></div>
366
+ </aside>
373
367
 
374
- <div class="content">
375
- <div class="loading" id="loading">Loading...</div>
376
- <div class="module-grid" id="moduleGrid" style="display:none"></div>
377
- </div>
368
+ <main class="content" id="content">
369
+ <div class="loading" id="loading">Loading…</div>
370
+ </main>
378
371
  </div>
379
372
 
380
- <div class="detail-panel" id="detailPanel">
381
- <button class="detail-close" id="detailClose">✕</button>
382
- <div id="detailContent"></div>
373
+ <div class="panel" id="panel">
374
+ <button class="panel-close" id="panelClose">✕</button>
375
+ <div id="panelContent"></div>
383
376
  </div>
384
377
 
385
378
  <script>
379
+ // ─── State ────────────────────────────────────────────────────────────────────
380
+ let D = null;
381
+ let view = 'features';
382
+ let searchQuery = '';
383
+ let activeTypes = new Set();
384
+ let activePanelKey = null;
385
+
386
+ // ─── Color helpers ────────────────────────────────────────────────────────────
386
387
  const TYPE_COLORS = {
387
388
  component: '#58a6ff',
388
389
  service: '#d2a8ff',
@@ -392,193 +393,361 @@ const TYPE_COLORS = {
392
393
  other: '#484f58',
393
394
  };
394
395
 
395
- let allModules = [];
396
- let activeTypes = new Set();
397
- let searchQuery = '';
398
- let selectedModule = null;
399
-
400
- function coverageColor(pct) {
401
- if (pct === undefined) return '#484f58';
402
- if (pct >= 80) return '#3fb950';
403
- if (pct >= 50) return '#e3b341';
396
+ function covColor(pct) {
397
+ if (pct == null) return '#484f58';
398
+ if (pct >= 70) return '#3fb950';
399
+ if (pct >= 30) return '#e3b341';
404
400
  return '#f85149';
405
401
  }
406
402
 
407
- function formatSize(bytes) {
408
- if (bytes < 1024) return bytes + ' B';
409
- return (bytes / 1024).toFixed(1) + ' KB';
403
+ function fmt(b) {
404
+ return b < 1024 ? b + ' B' : (b / 1024).toFixed(1) + ' KB';
405
+ }
406
+
407
+ function pluralFiles(n) {
408
+ const m10 = n % 10, m100 = n % 100;
409
+ if (m100 >= 11 && m100 <= 14) return 'файлов';
410
+ if (m10 === 1) return 'файл';
411
+ if (m10 >= 2 && m10 <= 4) return 'файла';
412
+ return 'файлов';
413
+ }
414
+
415
+ // ─── Init ─────────────────────────────────────────────────────────────────────
416
+ async function init() {
417
+ try {
418
+ const res = await fetch('/api/data');
419
+ D = await res.json();
420
+
421
+ document.getElementById('projectName').textContent = D.projectName;
422
+ document.getElementById('scannedAt').textContent =
423
+ new Date(D.scannedAt).toLocaleTimeString();
424
+
425
+ view = D.hasConfig ? 'features' : 'files';
426
+
427
+ if (!D.hasConfig) {
428
+ document.querySelector('[data-view="features"]').classList.add('disabled');
429
+ }
430
+
431
+ document.getElementById('loading').style.display = 'none';
432
+ renderStats();
433
+ renderSidebar();
434
+ renderContent();
435
+ } catch (err) {
436
+ document.getElementById('loading').textContent = '❌ Failed to load: ' + err.message;
437
+ }
438
+ }
439
+
440
+ // ─── Stats ────────────────────────────────────────────────────────────────────
441
+ function renderStats() {
442
+ const src = D.modules.filter(m => m.type !== 'test');
443
+ const tested = src.filter(m => m.hasTests).length;
444
+ const testFiles = D.modules.filter(m => m.type === 'test').length;
445
+ const pct = src.length ? Math.round(tested / src.length * 100) : 0;
446
+
447
+ let items;
448
+ if (D.hasConfig && D.features) {
449
+ const unmapped = src.filter(m => !m.featureKeys || m.featureKeys.length === 0).length;
450
+ items = [
451
+ { v: D.features.length, l: 'Features' },
452
+ { v: src.length, l: 'Source Files' },
453
+ { v: pct + '%', l: 'With Tests' },
454
+ { v: testFiles, l: 'Test Files' },
455
+ unmapped > 0 ? { v: unmapped, l: 'Unmapped', c: '#e3b341' } : null,
456
+ ].filter(Boolean);
457
+ } else {
458
+ items = [
459
+ { v: D.modules.length, l: 'Modules' },
460
+ { v: tested, l: 'With Tests' },
461
+ { v: pct + '%', l: 'Test Coverage' },
462
+ { v: testFiles, l: 'Test Files' },
463
+ ];
464
+ }
465
+
466
+ document.getElementById('statsBar').innerHTML = items.map(s =>
467
+ `<div class="stat">
468
+ <span class="stat-value"${s.c ? ` style="color:${s.c}"` : ''}>${s.v}</span>
469
+ <span class="stat-label">${s.l}</span>
470
+ </div>`
471
+ ).join('');
410
472
  }
411
473
 
412
- function renderTypeFilters() {
413
- const types = [...new Set(allModules.map(m => m.type))].sort();
414
- const container = document.getElementById('typeFilters');
415
- container.innerHTML = '';
416
-
417
- const allBtn = document.createElement('div');
418
- allBtn.className = 'type-filter' + (activeTypes.size === 0 ? ' active' : '');
419
- allBtn.innerHTML = `<span class="type-dot" style="background:#7d8590"></span> All`;
420
- allBtn.onclick = () => { activeTypes.clear(); renderAll(); };
421
- container.appendChild(allBtn);
422
-
423
- types.forEach(type => {
424
- const count = allModules.filter(m => m.type === type).length;
425
- const btn = document.createElement('div');
426
- btn.className = 'type-filter' + (activeTypes.has(type) ? ' active' : '');
427
- btn.innerHTML = `<span class="type-dot" style="background:${TYPE_COLORS[type] || '#484f58'}"></span> ${type} <span style="margin-left:auto;color:#484f58">${count}</span>`;
428
- btn.onclick = () => {
429
- if (activeTypes.has(type)) activeTypes.delete(type);
430
- else activeTypes.add(type);
431
- renderAll();
474
+ // ─── Sidebar ──────────────────────────────────────────────────────────────────
475
+ function renderSidebar() {
476
+ document.querySelectorAll('.view-tab').forEach(t =>
477
+ t.classList.toggle('active', t.dataset.view === view)
478
+ );
479
+
480
+ const extra = document.getElementById('sidebarExtra');
481
+ if (view !== 'files') { extra.innerHTML = ''; return; }
482
+
483
+ const types = [...new Set(D.modules.map(m => m.type))].sort();
484
+ extra.innerHTML = `
485
+ <div class="sidebar-label">Type</div>
486
+ <div>
487
+ <div class="type-filter ${activeTypes.size === 0 ? 'active' : ''}" data-t="all">
488
+ <span class="type-dot" style="background:#7d8590"></span>
489
+ All
490
+ <span class="type-count">${D.modules.length}</span>
491
+ </div>
492
+ ${types.map(t => `
493
+ <div class="type-filter ${activeTypes.has(t) ? 'active' : ''}" data-t="${t}">
494
+ <span class="type-dot" style="background:${TYPE_COLORS[t] || '#484f58'}"></span>
495
+ ${t}
496
+ <span class="type-count">${D.modules.filter(m => m.type === t).length}</span>
497
+ </div>
498
+ `).join('')}
499
+ </div>`;
500
+
501
+ extra.querySelectorAll('.type-filter').forEach(el => {
502
+ el.onclick = () => {
503
+ const t = el.dataset.t;
504
+ if (t === 'all') activeTypes.clear();
505
+ else if (activeTypes.has(t)) activeTypes.delete(t);
506
+ else activeTypes.add(t);
507
+ renderSidebar();
508
+ renderContent();
432
509
  };
433
- container.appendChild(btn);
434
510
  });
435
511
  }
436
512
 
437
- function getFilteredModules() {
438
- return allModules.filter(m => {
513
+ // ─── Content ──────────────────────────────────────────────────────────────────
514
+ function renderContent() {
515
+ const c = document.getElementById('content');
516
+ view === 'features' ? renderFeatureCards(c) : renderModuleGrid(c);
517
+ }
518
+
519
+ function renderFeatureCards(c) {
520
+ if (!D.hasConfig || !D.features) {
521
+ c.innerHTML = `
522
+ <div class="no-config">
523
+ <div class="no-config-icon">🗺️</div>
524
+ <h2>Карта фич не настроена</h2>
525
+ <p>Запусти команду ниже, вставь промпт в AI-агента —<br>он создаст <code>viberadar.config.json</code> с описанием твоих фич</p>
526
+ <div class="no-config-cmd">npx viberadar init</div>
527
+ <p style="font-size:13px;color:#484f58">После создания конфига перезапусти VibeRadar</p>
528
+ </div>`;
529
+ return;
530
+ }
531
+
532
+ const q = searchQuery.toLowerCase();
533
+ const list = D.features.filter(f =>
534
+ !q || f.label.toLowerCase().includes(q) || f.description.toLowerCase().includes(q)
535
+ );
536
+
537
+ if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
538
+
539
+ c.innerHTML = '<div class="features-grid" id="featGrid"></div>';
540
+ const grid = document.getElementById('featGrid');
541
+
542
+ list.forEach(f => {
543
+ const pct = f.fileCount > 0 ? Math.round(f.testedCount / f.fileCount * 100) : 0;
544
+ const isActive = activePanelKey === f.key;
545
+
546
+ const card = document.createElement('div');
547
+ card.className = 'feature-card' + (isActive ? ' active' : '');
548
+ card.innerHTML = `
549
+ <div class="feature-accent" style="background:${f.color}"></div>
550
+ <div class="feature-body">
551
+ <div class="feature-title">
552
+ <span>${f.label}</span>
553
+ <span class="feature-file-count">${f.fileCount} ${pluralFiles(f.fileCount)}</span>
554
+ </div>
555
+ ${f.description ? `<div class="feature-desc">${f.description}</div>` : ''}
556
+ <div class="feature-progress-wrap">
557
+ <div class="feature-progress-bar">
558
+ <div class="feature-progress-fill" style="width:${pct}%;background:${f.color}"></div>
559
+ </div>
560
+ <span class="feature-progress-label" style="color:${covColor(pct)}">${f.testedCount}/${f.fileCount} ✓</span>
561
+ </div>
562
+ </div>`;
563
+ card.onclick = () => openFeaturePanel(f.key);
564
+ grid.appendChild(card);
565
+ });
566
+ }
567
+
568
+ function renderModuleGrid(c) {
569
+ const q = searchQuery.toLowerCase();
570
+ const list = D.modules.filter(m => {
439
571
  if (activeTypes.size > 0 && !activeTypes.has(m.type)) return false;
440
- if (searchQuery && !m.name.toLowerCase().includes(searchQuery) && !m.relativePath.toLowerCase().includes(searchQuery)) return false;
572
+ if (q && !m.name.toLowerCase().includes(q) && !m.relativePath.toLowerCase().includes(q)) return false;
441
573
  return true;
442
574
  });
443
- }
444
575
 
445
- function renderModules() {
446
- const grid = document.getElementById('moduleGrid');
447
- const filtered = getFilteredModules();
448
- grid.innerHTML = '';
576
+ if (!list.length) { c.innerHTML = '<div class="empty">Ничего не найдено</div>'; return; }
449
577
 
450
- filtered.forEach(m => {
451
- const card = document.createElement('div');
452
- card.className = 'module-card' + (selectedModule?.id === m.id ? ' selected' : '');
578
+ c.innerHTML = '<div class="module-grid" id="modGrid"></div>';
579
+ const grid = document.getElementById('modGrid');
453
580
 
581
+ list.forEach(m => {
454
582
  const cov = m.coverage?.lines;
455
- const covText = cov !== undefined ? cov.toFixed(0) + '%' : 'No data';
456
- const badgeClass = m.hasTests ? 'badge-green' : 'badge-red';
457
- const badgeText = m.hasTests ? ' tested' : '✗ no test';
458
-
583
+ const isActive = activePanelKey === m.id;
584
+ const card = document.createElement('div');
585
+ card.className = 'module-card' + (isActive ? ' active' : '');
459
586
  card.innerHTML = `
460
- <div class="module-card-header">
461
- <span class="module-name">${m.name}</span>
462
- <span class="badge ${badgeClass}">${badgeText}</span>
463
- </div>
587
+ <div class="module-name">${m.name}</div>
464
588
  <div class="module-path">${m.relativePath}</div>
465
- <div style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#7d8590">
589
+ <div class="module-meta">
466
590
  <span style="display:flex;align-items:center;gap:4px">
467
- <span class="type-dot" style="background:${TYPE_COLORS[m.type] || '#484f58'}"></span>
468
- ${m.type}
591
+ <span class="type-dot" style="background:${TYPE_COLORS[m.type] || '#484f58'}"></span>${m.type}
469
592
  </span>
470
- <span>${formatSize(m.size)}</span>
593
+ <span class="badge ${m.hasTests ? 'badge-green' : 'badge-red'}">${m.hasTests ? '✓' : '✗'}</span>
471
594
  </div>
472
- ${cov !== undefined ? `
473
- <div class="coverage-bar">
474
- <div class="coverage-fill" style="width:${cov}%;background:${coverageColor(cov)}"></div>
475
- </div>
476
- ` : ''}
477
- `;
478
-
479
- card.onclick = () => openDetail(m);
595
+ ${cov != null ? `<div class="cov-bar"><div class="cov-fill" style="width:${cov}%;background:${covColor(cov)}"></div></div>` : ''}`;
596
+ card.onclick = () => openModulePanel(m);
480
597
  grid.appendChild(card);
481
598
  });
482
599
  }
483
600
 
484
- function renderAll() {
485
- renderTypeFilters();
486
- renderModules();
601
+ // ─── Panels ───────────────────────────────────────────────────────────────────
602
+ function openFeaturePanel(key) {
603
+ activePanelKey = key;
604
+ renderContent();
605
+
606
+ const feat = D.features.find(f => f.key === key);
607
+ const mods = D.modules.filter(m => m.featureKeys && m.featureKeys.includes(key));
608
+ const src = mods.filter(m => m.type !== 'test');
609
+ const tst = mods.filter(m => m.type === 'test');
610
+ const testedSrc = src.filter(m => m.hasTests).length;
611
+ const pct = src.length > 0 ? Math.round(testedSrc / src.length * 100) : 0;
612
+
613
+ document.getElementById('panelContent').innerHTML = `
614
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
615
+ <div style="width:12px;height:12px;border-radius:50%;background:${feat.color};flex-shrink:0"></div>
616
+ <div class="panel-title">${feat.label}</div>
617
+ </div>
618
+ ${feat.description ? `<div class="panel-subtitle">${feat.description}</div>` : ''}
619
+
620
+ <div class="panel-stats">
621
+ <div class="panel-stat">
622
+ <div class="panel-stat-val">${src.length}</div>
623
+ <div class="panel-stat-lbl">Файлов</div>
624
+ </div>
625
+ <div class="panel-stat">
626
+ <div class="panel-stat-val" style="color:${covColor(pct)}">${pct}%</div>
627
+ <div class="panel-stat-lbl">С тестами</div>
628
+ </div>
629
+ <div class="panel-stat">
630
+ <div class="panel-stat-val">${tst.length}</div>
631
+ <div class="panel-stat-lbl">Тест-файлов</div>
632
+ </div>
633
+ </div>
634
+
635
+ <div class="panel-section">
636
+ <div class="panel-section-label">Исходные файлы (${src.length})</div>
637
+ <div class="file-list">
638
+ ${src.length === 0
639
+ ? '<div style="font-size:13px;color:var(--dim);padding:8px 0">Нет файлов — возможно паттерны не совпадают</div>'
640
+ : src.map(m => fileItem(m)).join('')
641
+ }
642
+ </div>
643
+ </div>
644
+
645
+ ${tst.length > 0 ? `
646
+ <div class="panel-section">
647
+ <div class="panel-section-label">Тест-файлы (${tst.length})</div>
648
+ <div class="file-list">${tst.map(m => fileItem(m, true)).join('')}</div>
649
+ </div>` : ''}`;
650
+
651
+ document.getElementById('panel').classList.add('open');
652
+ }
653
+
654
+ function fileItem(m, isTest = false) {
655
+ const parts = m.relativePath.replace(/\\/g, '/').split('/');
656
+ const name = parts[parts.length - 1];
657
+ const dir = parts.slice(0, -1).join('/');
658
+ const icon = isTest ? '🧪' : (m.hasTests ? '✅' : '⬜');
659
+ return `
660
+ <div class="file-item">
661
+ <span class="file-item-icon">${icon}</span>
662
+ <div>
663
+ <div class="file-item-name">${name}</div>
664
+ ${dir ? `<div class="file-item-dir">${dir}</div>` : ''}
665
+ </div>
666
+ </div>`;
487
667
  }
488
668
 
489
- function openDetail(m) {
490
- selectedModule = m;
491
- renderModules();
669
+ function openModulePanel(m) {
670
+ activePanelKey = m.id;
671
+ renderContent();
492
672
 
493
673
  const cov = m.coverage;
494
- const panel = document.getElementById('detailPanel');
495
- const content = document.getElementById('detailContent');
496
-
497
- content.innerHTML = `
498
- <div class="detail-title">${m.name}</div>
499
- <div class="detail-path">${m.relativePath}</div>
500
-
501
- <div class="detail-section">
502
- <div class="detail-section-title">Info</div>
503
- <div class="detail-row"><span>Type</span><span style="color:${TYPE_COLORS[m.type]}">${m.type}</span></div>
504
- <div class="detail-row"><span>Size</span><span>${formatSize(m.size)}</span></div>
505
- <div class="detail-row"><span>Tests</span><span class="${m.hasTests ? 'badge badge-green' : 'badge badge-red'}">${m.hasTests ? '✓ ' + (m.testFile || 'tested') : '✗ none'}</span></div>
506
- ${m.testFile ? `<div class="detail-row"><span>Test file</span><span style="font-size:11px;color:#7d8590;text-align:right">${m.testFile}</span></div>` : ''}
674
+ const featureLabels = m.featureKeys && m.featureKeys.length > 0
675
+ ? m.featureKeys.map(k => D.features?.find(f => f.key === k)?.label || k).join(', ')
676
+ : null;
677
+
678
+ document.getElementById('panelContent').innerHTML = `
679
+ <div class="panel-title">${m.name}</div>
680
+ <div class="panel-subtitle">${m.relativePath}</div>
681
+
682
+ <div class="panel-section">
683
+ <div class="panel-section-label">Информация</div>
684
+ <div class="detail-row">
685
+ <span>Тип</span>
686
+ <span style="color:${TYPE_COLORS[m.type] || '#484f58'}">${m.type}</span>
687
+ </div>
688
+ <div class="detail-row">
689
+ <span>Размер</span>
690
+ <span>${fmt(m.size)}</span>
691
+ </div>
692
+ <div class="detail-row">
693
+ <span>Тесты</span>
694
+ <span class="badge ${m.hasTests ? 'badge-green' : 'badge-red'}">${m.hasTests ? '✓ есть' : '✗ нет'}</span>
695
+ </div>
696
+ ${m.testFile ? `<div class="detail-row"><span>Тест-файл</span><span class="detail-row-right">${m.testFile}</span></div>` : ''}
697
+ ${featureLabels ? `<div class="detail-row"><span>Фичи</span><span class="detail-row-right">${featureLabels}</span></div>` : ''}
507
698
  </div>
508
699
 
509
700
  ${cov ? `
510
- <div class="detail-section">
511
- <div class="detail-section-title">Coverage</div>
512
- <div class="coverage-metric">
701
+ <div class="panel-section">
702
+ <div class="panel-section-label">Coverage</div>
703
+ <div class="cov-grid">
513
704
  ${['lines','statements','functions','branches'].map(k => `
514
- <div class="coverage-metric-item">
515
- <div class="coverage-metric-value" style="color:${coverageColor(cov[k])}">${cov[k]?.toFixed(1)}%</div>
516
- <div class="coverage-metric-label">${k}</div>
517
- </div>
518
- `).join('')}
705
+ <div class="cov-metric">
706
+ <div class="cov-metric-val" style="color:${covColor(cov[k])}">${cov[k]?.toFixed(1)}%</div>
707
+ <div class="cov-metric-lbl">${k}</div>
708
+ </div>`).join('')}
519
709
  </div>
520
- </div>
521
- ` : `
522
- <div class="detail-section">
523
- <div class="detail-section-title">Coverage</div>
524
- <div style="font-size:13px;color:#484f58">No coverage data. Run tests with coverage to see metrics.</div>
525
- </div>
526
- `}
710
+ </div>` : ''}
527
711
 
528
712
  ${m.dependencies.length > 0 ? `
529
- <div class="detail-section">
530
- <div class="detail-section-title">Local Imports (${m.dependencies.length})</div>
531
- <div class="deps-list">
713
+ <div class="panel-section">
714
+ <div class="panel-section-label">Импорты (${m.dependencies.length})</div>
715
+ <div style="display:flex;flex-direction:column;gap:4px">
532
716
  ${m.dependencies.map(d => `<div class="dep-item">${d}</div>`).join('')}
533
717
  </div>
534
- </div>
535
- ` : ''}
536
- `;
718
+ </div>` : ''}`;
537
719
 
538
- panel.classList.add('open');
720
+ document.getElementById('panel').classList.add('open');
539
721
  }
540
722
 
541
- document.getElementById('detailClose').onclick = () => {
542
- document.getElementById('detailPanel').classList.remove('open');
543
- selectedModule = null;
544
- renderModules();
545
- };
723
+ function closePanel() {
724
+ activePanelKey = null;
725
+ document.getElementById('panel').classList.remove('open');
726
+ renderContent();
727
+ }
546
728
 
547
- document.getElementById('searchInput').oninput = (e) => {
729
+ // ─── Events ───────────────────────────────────────────────────────────────────
730
+ document.querySelectorAll('.view-tab').forEach(tab => {
731
+ tab.onclick = () => {
732
+ if (tab.classList.contains('disabled')) return;
733
+ view = tab.dataset.view;
734
+ activePanelKey = null;
735
+ searchQuery = '';
736
+ activeTypes.clear();
737
+ document.getElementById('searchInput').value = '';
738
+ document.getElementById('panel').classList.remove('open');
739
+ renderSidebar();
740
+ renderContent();
741
+ };
742
+ });
743
+
744
+ document.getElementById('searchInput').oninput = e => {
548
745
  searchQuery = e.target.value.toLowerCase();
549
- renderModules();
746
+ renderContent();
550
747
  };
551
748
 
552
- async function init() {
553
- try {
554
- const res = await fetch('/api/data');
555
- const data = await res.json();
556
-
557
- allModules = data.modules;
558
-
559
- document.getElementById('projectName').textContent = data.projectName;
560
- document.getElementById('scannedAt').textContent = new Date(data.scannedAt).toLocaleTimeString();
561
-
562
- const testCount = allModules.filter(m => m.type === 'test').length;
563
- const withTests = allModules.filter(m => m.hasTests && m.type !== 'test').length;
564
- const covModules = allModules.filter(m => m.coverage?.lines !== undefined);
565
- const avgCov = covModules.length
566
- ? (covModules.reduce((s, m) => s + m.coverage.lines, 0) / covModules.length).toFixed(0) + '%'
567
- : '—';
568
-
569
- document.getElementById('statModules').textContent = allModules.length;
570
- document.getElementById('statWithTests').textContent = withTests;
571
- document.getElementById('statCoverage').textContent = avgCov;
572
- document.getElementById('statTests').textContent = testCount;
573
-
574
- document.getElementById('loading').style.display = 'none';
575
- document.getElementById('moduleGrid').style.display = 'grid';
576
-
577
- renderAll();
578
- } catch (err) {
579
- document.getElementById('loading').textContent = 'Failed to load data: ' + err.message;
580
- }
581
- }
749
+ document.getElementById('panelClose').onclick = closePanel;
750
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closePanel(); });
582
751
 
583
752
  init();
584
753
  </script>