plankit-cli 1.4.0 → 1.6.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.
@@ -0,0 +1,674 @@
1
+ /**
2
+ * Embedded responsive HTML/CSS/JS client for PlanKit Web Dashboard.
3
+ * Single-file, zero-dependency, dark-themed modern dashboard.
4
+ */
5
+
6
+ export function getClientHtml() {
7
+ return `<!DOCTYPE html>
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="UTF-8" />
11
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
12
+ <title>PlanKit Dashboard</title>
13
+ <style>
14
+ :root {
15
+ --bg: #090d16;
16
+ --card-bg: #111827;
17
+ --border: #1f2937;
18
+ --border-focus: #3b82f6;
19
+ --text: #f3f4f6;
20
+ --text-muted: #9ca3af;
21
+ --primary: #3b82f6;
22
+ --primary-hover: #2563eb;
23
+ --success: #10b981;
24
+ --warning: #f59e0b;
25
+ --danger: #ef4444;
26
+ --badge-bg: #1e293b;
27
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
28
+ }
29
+ * { box-sizing: border-box; margin: 0; padding: 0; }
30
+ body {
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34
+ line-height: 1.5;
35
+ padding-bottom: 60px;
36
+ }
37
+ header {
38
+ background: #0f172a;
39
+ border-bottom: 1px solid var(--border);
40
+ padding: 16px 32px;
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: space-between;
44
+ position: sticky;
45
+ top: 0;
46
+ z-index: 100;
47
+ }
48
+ .brand {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 12px;
52
+ font-size: 1.25rem;
53
+ font-weight: 700;
54
+ letter-spacing: -0.025em;
55
+ }
56
+ .brand-badge {
57
+ background: var(--primary);
58
+ color: #fff;
59
+ font-size: 0.75rem;
60
+ padding: 2px 8px;
61
+ border-radius: 9999px;
62
+ font-family: var(--font-mono);
63
+ }
64
+ .header-actions {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 12px;
68
+ }
69
+ button {
70
+ background: var(--primary);
71
+ color: #fff;
72
+ border: none;
73
+ padding: 8px 16px;
74
+ border-radius: 6px;
75
+ font-weight: 600;
76
+ font-size: 0.875rem;
77
+ cursor: pointer;
78
+ transition: background 0.15s ease;
79
+ display: inline-flex;
80
+ align-items: center;
81
+ gap: 6px;
82
+ }
83
+ button:hover { background: var(--primary-hover); }
84
+ button.secondary {
85
+ background: var(--card-bg);
86
+ border: 1px solid var(--border);
87
+ color: var(--text);
88
+ }
89
+ button.secondary:hover { background: #1e293b; }
90
+
91
+ .nav-tabs {
92
+ display: flex;
93
+ gap: 8px;
94
+ background: #0f172a;
95
+ padding: 8px 32px 0 32px;
96
+ border-bottom: 1px solid var(--border);
97
+ }
98
+ .tab {
99
+ padding: 10px 18px;
100
+ color: var(--text-muted);
101
+ cursor: pointer;
102
+ border-bottom: 2px solid transparent;
103
+ font-size: 0.9rem;
104
+ font-weight: 500;
105
+ }
106
+ .tab.active {
107
+ color: var(--primary);
108
+ border-bottom-color: var(--primary);
109
+ font-weight: 600;
110
+ }
111
+
112
+ main {
113
+ max-width: 1300px;
114
+ margin: 32px auto;
115
+ padding: 0 32px;
116
+ }
117
+ .tab-content { display: none; }
118
+ .tab-content.active { display: block; }
119
+
120
+ .stats-grid {
121
+ display: grid;
122
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
123
+ gap: 20px;
124
+ margin-bottom: 32px;
125
+ }
126
+ .stat-card {
127
+ background: var(--card-bg);
128
+ border: 1px solid var(--border);
129
+ border-radius: 10px;
130
+ padding: 20px;
131
+ }
132
+ .stat-title {
133
+ color: var(--text-muted);
134
+ font-size: 0.85rem;
135
+ font-weight: 500;
136
+ margin-bottom: 8px;
137
+ }
138
+ .stat-value {
139
+ font-size: 2rem;
140
+ font-weight: 700;
141
+ letter-spacing: -0.025em;
142
+ display: flex;
143
+ align-items: baseline;
144
+ gap: 8px;
145
+ }
146
+ .stat-sub {
147
+ font-size: 0.85rem;
148
+ color: var(--text-muted);
149
+ }
150
+
151
+ .card {
152
+ background: var(--card-bg);
153
+ border: 1px solid var(--border);
154
+ border-radius: 10px;
155
+ padding: 24px;
156
+ margin-bottom: 24px;
157
+ }
158
+ .card-header {
159
+ display: flex;
160
+ justify-content: space-between;
161
+ align-items: center;
162
+ margin-bottom: 20px;
163
+ }
164
+ .card-title {
165
+ font-size: 1.15rem;
166
+ font-weight: 600;
167
+ }
168
+
169
+ .feature-list {
170
+ display: flex;
171
+ flex-direction: column;
172
+ gap: 16px;
173
+ }
174
+ .feature-item {
175
+ background: #0f172a;
176
+ border: 1px solid var(--border);
177
+ border-radius: 8px;
178
+ padding: 16px 20px;
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 12px;
182
+ }
183
+ .feature-item-header {
184
+ display: flex;
185
+ justify-content: space-between;
186
+ align-items: center;
187
+ }
188
+ .feature-name {
189
+ font-size: 1.1rem;
190
+ font-weight: 600;
191
+ font-family: var(--font-mono);
192
+ color: var(--text);
193
+ }
194
+ .progress-bar-container {
195
+ background: #1e293b;
196
+ height: 8px;
197
+ border-radius: 9999px;
198
+ overflow: hidden;
199
+ margin: 6px 0;
200
+ }
201
+ .progress-bar-fill {
202
+ background: var(--primary);
203
+ height: 100%;
204
+ transition: width 0.3s ease;
205
+ }
206
+ .phases-grid {
207
+ display: grid;
208
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
209
+ gap: 12px;
210
+ margin-top: 8px;
211
+ }
212
+ .phase-pill {
213
+ background: var(--badge-bg);
214
+ border: 1px solid var(--border);
215
+ padding: 8px 12px;
216
+ border-radius: 6px;
217
+ font-size: 0.825rem;
218
+ display: flex;
219
+ justify-content: space-between;
220
+ align-items: center;
221
+ cursor: pointer;
222
+ }
223
+ .phase-pill:hover { border-color: var(--primary); }
224
+ .badge {
225
+ font-size: 0.7rem;
226
+ font-weight: 600;
227
+ padding: 2px 6px;
228
+ border-radius: 4px;
229
+ font-family: var(--font-mono);
230
+ }
231
+ .badge-done { background: rgba(16, 185, 129, 0.2); color: var(--success); }
232
+ .badge-pending { background: rgba(245, 158, 11, 0.2); color: var(--warning); }
233
+
234
+ .tokens-grid {
235
+ display: grid;
236
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
237
+ gap: 16px;
238
+ }
239
+ .token-card {
240
+ background: #0f172a;
241
+ border: 1px solid var(--border);
242
+ border-radius: 8px;
243
+ overflow: hidden;
244
+ cursor: pointer;
245
+ transition: transform 0.15s ease, border-color 0.15s ease;
246
+ }
247
+ .token-card:hover {
248
+ transform: translateY(-2px);
249
+ border-color: var(--primary);
250
+ }
251
+ .token-swatch {
252
+ height: 70px;
253
+ width: 100%;
254
+ border-bottom: 1px solid var(--border);
255
+ }
256
+ .token-info {
257
+ padding: 10px 12px;
258
+ }
259
+ .token-hex {
260
+ font-family: var(--font-mono);
261
+ font-size: 0.85rem;
262
+ font-weight: 600;
263
+ }
264
+ .token-count {
265
+ font-size: 0.75rem;
266
+ color: var(--text-muted);
267
+ }
268
+
269
+ table {
270
+ width: 100%;
271
+ border-collapse: collapse;
272
+ font-size: 0.875rem;
273
+ }
274
+ th, td {
275
+ text-align: left;
276
+ padding: 10px 14px;
277
+ border-bottom: 1px solid var(--border);
278
+ }
279
+ th {
280
+ color: var(--text-muted);
281
+ font-weight: 600;
282
+ font-size: 0.775rem;
283
+ text-transform: uppercase;
284
+ letter-spacing: 0.05em;
285
+ }
286
+
287
+ /* Modal */
288
+ .modal-overlay {
289
+ position: fixed;
290
+ inset: 0;
291
+ background: rgba(0, 0, 0, 0.75);
292
+ display: none;
293
+ align-items: center;
294
+ justify-content: center;
295
+ z-index: 200;
296
+ padding: 24px;
297
+ }
298
+ .modal-overlay.open { display: flex; }
299
+ .modal {
300
+ background: var(--card-bg);
301
+ border: 1px solid var(--border);
302
+ border-radius: 12px;
303
+ width: 100%;
304
+ max-width: 800px;
305
+ max-height: 85vh;
306
+ display: flex;
307
+ flex-direction: column;
308
+ overflow: hidden;
309
+ }
310
+ .modal-header {
311
+ padding: 16px 24px;
312
+ border-bottom: 1px solid var(--border);
313
+ display: flex;
314
+ justify-content: space-between;
315
+ align-items: center;
316
+ }
317
+ .modal-body {
318
+ padding: 24px;
319
+ overflow-y: auto;
320
+ font-family: var(--font-mono);
321
+ font-size: 0.85rem;
322
+ white-space: pre-wrap;
323
+ background: #090d16;
324
+ color: #e5e7eb;
325
+ }
326
+ .close-btn {
327
+ background: transparent;
328
+ color: var(--text-muted);
329
+ font-size: 1.25rem;
330
+ padding: 4px;
331
+ }
332
+ .close-btn:hover { color: #fff; background: transparent; }
333
+
334
+ .toast {
335
+ position: fixed;
336
+ bottom: 24px;
337
+ right: 24px;
338
+ background: #1e293b;
339
+ border: 1px solid var(--primary);
340
+ color: #fff;
341
+ padding: 12px 20px;
342
+ border-radius: 8px;
343
+ font-size: 0.875rem;
344
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
345
+ display: none;
346
+ z-index: 300;
347
+ }
348
+ </style>
349
+ </head>
350
+ <body>
351
+
352
+ <header>
353
+ <div class="brand">
354
+ <span>PlanKit</span>
355
+ <span class="brand-badge">Dashboard</span>
356
+ </div>
357
+ <div class="header-actions">
358
+ <button class="secondary" id="btnScan">⚡ Run Health Scan</button>
359
+ </div>
360
+ </header>
361
+
362
+ <div class="nav-tabs">
363
+ <div class="tab active" data-target="tabFeatures">Feature Matrix</div>
364
+ <div class="tab" data-target="tabHealth">Architecture Health</div>
365
+ <div class="tab" data-target="tabTokens">Design Tokens</div>
366
+ <div class="tab" data-target="tabHotspots">Hotspots Backlog</div>
367
+ </div>
368
+
369
+ <main>
370
+ <div class="stats-grid">
371
+ <div class="stat-card">
372
+ <div class="stat-title">Active Features</div>
373
+ <div class="stat-value" id="statActive">0</div>
374
+ <div class="stat-sub" id="statArchived">0 archived</div>
375
+ </div>
376
+ <div class="stat-card">
377
+ <div class="stat-title">Repository Health</div>
378
+ <div class="stat-value" id="statScore">-- <span class="stat-sub" id="statGrade"></span></div>
379
+ <div class="stat-sub" id="statFiles">0 files scanned</div>
380
+ </div>
381
+ <div class="stat-card">
382
+ <div class="stat-title">Vue Script Setup Ratio</div>
383
+ <div class="stat-value" id="statVueRatio">--%</div>
384
+ <div class="stat-sub" id="statVueTs">TypeScript: --%</div>
385
+ </div>
386
+ <div class="stat-card">
387
+ <div class="stat-title">Mined Design Tokens</div>
388
+ <div class="stat-value" id="statTokens">0</div>
389
+ <div class="stat-sub">Color palette candidates</div>
390
+ </div>
391
+ </div>
392
+
393
+ <!-- TAB 1: FEATURES -->
394
+ <section class="tab-content active" id="tabFeatures">
395
+ <div class="card">
396
+ <div class="card-header">
397
+ <div class="card-title">Active Feature Workspaces (artifacts/current/)</div>
398
+ </div>
399
+ <div class="feature-list" id="featureList">
400
+ <div style="color: var(--text-muted);">Loading features...</div>
401
+ </div>
402
+ </div>
403
+ </section>
404
+
405
+ <!-- TAB 2: HEALTH -->
406
+ <section class="tab-content" id="tabHealth">
407
+ <div class="card">
408
+ <div class="card-header">
409
+ <div class="card-title">Architecture Modernization Scorecard</div>
410
+ </div>
411
+ <div id="healthBreakdown">Loading health metrics...</div>
412
+ </div>
413
+ </section>
414
+
415
+ <!-- TAB 3: TOKENS -->
416
+ <section class="tab-content" id="tabTokens">
417
+ <div class="card">
418
+ <div class="card-header">
419
+ <div class="card-title">Discovered Design Tokens (Colors & Spacing)</div>
420
+ <span style="font-size: 0.8rem; color: var(--text-muted);">Click swatch to copy hex</span>
421
+ </div>
422
+ <div class="tokens-grid" id="tokensGrid">
423
+ <div style="color: var(--text-muted);">Scanning for tokens...</div>
424
+ </div>
425
+ </div>
426
+ </section>
427
+
428
+ <!-- TAB 4: HOTSPOTS -->
429
+ <section class="tab-content" id="tabHotspots">
430
+ <div class="card">
431
+ <div class="card-header">
432
+ <div class="card-title">Modernization Hotspots & Anti-Patterns</div>
433
+ </div>
434
+ <table>
435
+ <thead>
436
+ <tr>
437
+ <th>File</th>
438
+ <th>Type</th>
439
+ <th>Issue Description</th>
440
+ </tr>
441
+ </thead>
442
+ <tbody id="hotspotsTable">
443
+ <tr><td colspan="3" style="color: var(--text-muted);">No hotspots detected.</td></tr>
444
+ </tbody>
445
+ </table>
446
+ </div>
447
+ </section>
448
+ </main>
449
+
450
+ <!-- Modal for viewing Spec or Output -->
451
+ <div class="modal-overlay" id="fileModal">
452
+ <div class="modal">
453
+ <div class="modal-header">
454
+ <h3 id="modalTitle" style="font-size: 1rem; font-family: var(--font-mono);">Document</h3>
455
+ <button class="close-btn" id="modalClose">&times;</button>
456
+ </div>
457
+ <div class="modal-body" id="modalContent"></div>
458
+ </div>
459
+ </div>
460
+
461
+ <div class="toast" id="toast">Copied to clipboard!</div>
462
+
463
+ <script>
464
+ let dashboardData = null;
465
+
466
+ async function loadData() {
467
+ try {
468
+ const res = await fetch('/api/data');
469
+ dashboardData = await res.json();
470
+ renderAll();
471
+ } catch (err) {
472
+ console.error('Failed to load dashboard data:', err);
473
+ }
474
+ }
475
+
476
+ function renderAll() {
477
+ if (!dashboardData) return;
478
+
479
+ const { activeFeatures, archivedCount, healthReport } = dashboardData;
480
+
481
+ // Stats
482
+ document.getElementById('statActive').innerText = activeFeatures.length;
483
+ document.getElementById('statArchived').innerText = \`\${archivedCount} archived\`;
484
+
485
+ if (healthReport) {
486
+ document.getElementById('statScore').innerHTML = \`\${healthReport.score.total} <span class="stat-sub" style="font-size: 1rem;">/ 100 (\${healthReport.score.grade})</span>\`;
487
+ document.getElementById('statFiles').innerText = \`\${healthReport.totalFilesScanned} files scanned\`;
488
+
489
+ if (healthReport.vue) {
490
+ document.getElementById('statVueRatio').innerText = \`\${healthReport.vue.scriptSetupPercent}%\`;
491
+ document.getElementById('statVueTs').innerText = \`TypeScript: \${healthReport.vue.tsPercent}%\`;
492
+ }
493
+
494
+ if (healthReport.designTokens) {
495
+ document.getElementById('statTokens').innerText = healthReport.designTokens.frequentColors.length;
496
+ }
497
+ }
498
+
499
+ // Feature list
500
+ renderFeatures(activeFeatures);
501
+ // Health breakdown
502
+ renderHealth(healthReport);
503
+ // Tokens
504
+ renderTokens(healthReport ? healthReport.designTokens : null);
505
+ // Hotspots
506
+ renderHotspots(healthReport ? healthReport.hotspots : []);
507
+ }
508
+
509
+ function renderFeatures(features) {
510
+ const container = document.getElementById('featureList');
511
+ if (!features || features.length === 0) {
512
+ container.innerHTML = '<div style="color: var(--text-muted); padding: 20px 0;">No active features in artifacts/current/. Plan a feature with <code>plankit plan &lt;feature&gt;</code>.</div>';
513
+ return;
514
+ }
515
+
516
+ container.innerHTML = features.map(feat => {
517
+ const phasesHtml = feat.phases.map(p => {
518
+ const badgeClass = p.hasOutput ? 'badge-done' : 'badge-pending';
519
+ const label = p.hasOutput ? 'DONE' : p.status;
520
+ return \`
521
+ <div class="phase-pill" onclick="viewDocument('\${feat.name}', \${p.number}, '\${p.hasOutput ? 'output' : 'spec'}')">
522
+ <span>Phase \${p.number}</span>
523
+ <span class="badge \${badgeClass}">\${label}</span>
524
+ </div>
525
+ \`;
526
+ }).join('');
527
+
528
+ return \`
529
+ <div class="feature-item">
530
+ <div class="feature-item-header">
531
+ <span class="feature-name">\${feat.name}</span>
532
+ <span style="font-size: 0.85rem; color: var(--text-muted);">\${feat.completedCount} / \${feat.phaseCount} completed (\${feat.progressPercent}%)</span>
533
+ </div>
534
+ <div class="progress-bar-container">
535
+ <div class="progress-bar-fill" style="width: \${feat.progressPercent}%;"></div>
536
+ </div>
537
+ <div class="phases-grid">
538
+ \${phasesHtml}
539
+ </div>
540
+ </div>
541
+ \`;
542
+ }).join('');
543
+ }
544
+
545
+ function renderHealth(health) {
546
+ const container = document.getElementById('healthBreakdown');
547
+ if (!health) {
548
+ container.innerHTML = '<div style="color: var(--text-muted);">No health scan available. Click "Run Health Scan" above.</div>';
549
+ return;
550
+ }
551
+
552
+ let html = \`
553
+ <div style="display: flex; gap: 32px; align-items: center; margin-bottom: 24px;">
554
+ <div style="font-size: 3.5rem; font-weight: 800; color: \${health.score.total >= 80 ? 'var(--success)' : 'var(--warning)'}">
555
+ \${health.score.total}<span style="font-size: 1.5rem; color: var(--text-muted);">/100</span>
556
+ </div>
557
+ <div>
558
+ <div style="font-size: 1.1rem; font-weight: 600;">Overall Grade: \${health.score.grade}</div>
559
+ <div style="color: var(--text-muted); font-size: 0.875rem;">Last scanned: \${new Date(health.timestamp).toLocaleTimeString()}</div>
560
+ </div>
561
+ </div>
562
+ \`;
563
+
564
+ if (health.vue) {
565
+ html += \`
566
+ <h4 style="margin-bottom: 12px; font-size: 0.95rem;">Vue SFC Metrics</h4>
567
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px;">
568
+ <div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
569
+ <div style="font-size: 0.75rem; color: var(--text-muted);">Preferred (Modern)</div>
570
+ <div style="font-size: 1.25rem; font-weight: 700; color: var(--success);">\${health.vue.maturity.preferred}</div>
571
+ </div>
572
+ <div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
573
+ <div style="font-size: 0.75rem; color: var(--text-muted);">Transitional</div>
574
+ <div style="font-size: 1.25rem; font-weight: 700; color: var(--warning);">\${health.vue.maturity.transitional}</div>
575
+ </div>
576
+ <div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
577
+ <div style="font-size: 0.75rem; color: var(--text-muted);">Legacy</div>
578
+ <div style="font-size: 1.25rem; font-weight: 700; color: var(--danger);">\${health.vue.maturity.legacy}</div>
579
+ </div>
580
+ </div>
581
+ \`;
582
+ }
583
+
584
+ container.innerHTML = html;
585
+ }
586
+
587
+ function renderTokens(tokens) {
588
+ const container = document.getElementById('tokensGrid');
589
+ if (!tokens || !tokens.frequentColors || tokens.frequentColors.length === 0) {
590
+ container.innerHTML = '<div style="color: var(--text-muted);">No repeated design token candidates discovered yet.</div>';
591
+ return;
592
+ }
593
+
594
+ container.innerHTML = tokens.frequentColors.map(t => \`
595
+ <div class="token-card" onclick="copyToken('\${t.color}')">
596
+ <div class="token-swatch" style="background-color: \${t.color};"></div>
597
+ <div class="token-info">
598
+ <div class="token-hex">\${t.color}</div>
599
+ <div class="token-count">used in \${t.count} rules</div>
600
+ </div>
601
+ </div>
602
+ \`).join('');
603
+ }
604
+
605
+ function renderHotspots(hotspots) {
606
+ const tbody = document.getElementById('hotspotsTable');
607
+ if (!hotspots || hotspots.length === 0) {
608
+ tbody.innerHTML = '<tr><td colspan="3" style="color: var(--success); padding: 16px;">✓ Clean! Zero critical legacy hotspots or architectural violations detected.</td></tr>';
609
+ return;
610
+ }
611
+
612
+ tbody.innerHTML = hotspots.map(h => \`
613
+ <tr>
614
+ <td style="font-family: var(--font-mono); font-weight: 600;">\${h.file}</td>
615
+ <td><span class="badge badge-pending">\${h.type}</span></td>
616
+ <td>\${h.message}</td>
617
+ </tr>
618
+ \`).join('');
619
+ }
620
+
621
+ async function viewDocument(feature, phase, type) {
622
+ try {
623
+ const url = \`/api/\${type}?feature=\${encodeURIComponent(feature)}&phase=\${phase}\`;
624
+ const res = await fetch(url);
625
+ const data = await res.json();
626
+ document.getElementById('modalTitle').innerText = \`\${feature} - Phase \${phase} (\${type.toUpperCase()})\`;
627
+ document.getElementById('modalContent').innerText = data.content || '(empty document)';
628
+ document.getElementById('fileModal').classList.add('open');
629
+ } catch (err) {
630
+ console.error('Failed to view document:', err);
631
+ }
632
+ }
633
+
634
+ function copyToken(text) {
635
+ navigator.clipboard.writeText(text);
636
+ const toast = document.getElementById('toast');
637
+ toast.innerText = \`Copied \${text} to clipboard!\`;
638
+ toast.style.display = 'block';
639
+ setTimeout(() => { toast.style.display = 'none'; }, 2000);
640
+ }
641
+
642
+ // Event Listeners
643
+ document.querySelectorAll('.tab').forEach(tab => {
644
+ tab.addEventListener('click', () => {
645
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
646
+ document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
647
+ tab.classList.add('active');
648
+ document.getElementById(tab.dataset.target).classList.add('active');
649
+ });
650
+ });
651
+
652
+ document.getElementById('modalClose').addEventListener('click', () => {
653
+ document.getElementById('fileModal').classList.remove('open');
654
+ });
655
+
656
+ document.getElementById('btnScan').addEventListener('click', async () => {
657
+ const btn = document.getElementById('btnScan');
658
+ btn.innerText = 'Scanning...';
659
+ btn.disabled = true;
660
+ try {
661
+ await fetch('/api/scan', { method: 'POST' });
662
+ await loadData();
663
+ } finally {
664
+ btn.innerText = '⚡ Run Health Scan';
665
+ btn.disabled = false;
666
+ }
667
+ });
668
+
669
+ loadData();
670
+ setInterval(loadData, 5000); // auto-refresh every 5s
671
+ </script>
672
+ </body>
673
+ </html>`;
674
+ }