plankit-cli 1.5.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.
package/README.md CHANGED
@@ -215,6 +215,28 @@ plankit ui --no-tui
215
215
 
216
216
  ---
217
217
 
218
+ ## Visual web dashboard companion (`plankit serve`)
219
+
220
+ Launch an interactive, local browser companion on your choice of port:
221
+
222
+ ```bash
223
+ # Launch on default port 4200 (prompts if run interactively without flag)
224
+ plankit serve
225
+
226
+ # Specify custom port
227
+ plankit serve --port 5000
228
+ plankit web -p 8080
229
+ ```
230
+
231
+ ### Features:
232
+ - **Feature Matrix & Progress Board**: Visual cards with completion percentages and modal document viewers for specs and outputs.
233
+ - **Architecture Health Scorecard**: Live breakdown of Vue `<script setup>` ratio, TypeScript ratio, Pinia stores, and .NET Clean Architecture violations.
234
+ - **Design Token Palette**: Interactive color swatches with one-click hex copying and rule usage counts mined across the repository.
235
+ - **Hotspots Backlog**: Filterable table of god components, deprecated deep selectors, mixins, and legacy code.
236
+ - **Zero Dependencies**: Powered by native `node:http`, starts in under 20ms.
237
+
238
+ ---
239
+
218
240
  ## Framework command suites
219
241
 
220
242
  During `plankit init`, PlanKit prompts you to choose optional framework-specific command suites (or pass `--framework <name>` / `--frameworks <names>`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plankit-cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "PlanKit Command Suite CLI for AI Coding Assistants (Antigravity, OpenCode, Codex, Cursor)",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  } from './commandBundles.js';
26
26
  import { runAnalysis, formatReport } from './analyzer/engine.js';
27
27
  import { loadDashboardData, renderStaticDashboard, runInteractiveDashboard } from './dashboard/dashboard.js';
28
+ import { startServer } from './server/server.js';
28
29
 
29
30
  export async function runCli(args, context = {}) {
30
31
  const io = createIo(context);
@@ -61,6 +62,10 @@ export async function runCli(args, context = {}) {
61
62
  case 'dashboard':
62
63
  await showDashboard(parsed, io);
63
64
  break;
65
+ case 'serve':
66
+ case 'web':
67
+ await startWebServer(parsed, io);
68
+ break;
64
69
  case 'archive':
65
70
  archiveFeature(parsed, io);
66
71
  break;
@@ -419,6 +424,56 @@ async function showDashboard(parsed, io) {
419
424
  });
420
425
  }
421
426
 
427
+ async function startWebServer(parsed, io) {
428
+ const { config } = loadConfig(io.cwd);
429
+ let port = null;
430
+
431
+ const rawPort = parsed.flags.port || parsed.flags.p;
432
+ if (rawPort !== undefined) {
433
+ const parsedNum = Number.parseInt(rawPort, 10);
434
+ if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
435
+ throw new Error(`Invalid port "${rawPort}". Please provide a valid port between 1 and 65535.`);
436
+ }
437
+ port = parsedNum;
438
+ } else if (io.isTTY && io.promptChoice) {
439
+ const answer = await io.promptChoice('Enter port for PlanKit Web Dashboard (default: 4200): ', ['4200']);
440
+ if (answer && answer.trim()) {
441
+ const parsedNum = Number.parseInt(answer.trim(), 10);
442
+ if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
443
+ throw new Error(`Invalid port "${answer}". Please provide a valid port between 1 and 65535.`);
444
+ }
445
+ port = parsedNum;
446
+ } else {
447
+ port = 4200;
448
+ }
449
+ } else {
450
+ port = 4200;
451
+ }
452
+
453
+ io.stdout(`Starting PlanKit Web Dashboard on port ${port}...`);
454
+
455
+ const { server, port: actualPort } = await startServer({
456
+ cwd: io.cwd,
457
+ config,
458
+ port,
459
+ io
460
+ });
461
+
462
+ const url = `http://localhost:${actualPort}`;
463
+ io.stdout('');
464
+ io.stdout('======================================================');
465
+ io.stdout(` PlanKit Web Dashboard running at: ${url}`);
466
+ io.stdout('======================================================');
467
+ io.stdout('Press Ctrl+C to stop.');
468
+
469
+ // Keep process alive if interactive/standalone
470
+ if (!parsed.flags['no-keep-alive'] && parsed.flags['keep-alive'] !== false) {
471
+ await new Promise(() => {});
472
+ } else {
473
+ server.close();
474
+ }
475
+ }
476
+
422
477
  function moveFeatureToArchive(cwd, config, featureName, dryRun = false) {
423
478
  const sourceDir = requireFeatureDir(cwd, config, featureName);
424
479
  const targetDir = safeJoin(cwd, config.artifactsDir, 'archived', featureName);
@@ -790,6 +845,7 @@ Commands:
790
845
  status [--json] Show active and archived features
791
846
  scan [dir] Run static analysis and generate artifacts/.plankit-index.json
792
847
  ui Launch interactive feature progress dashboard
848
+ serve [--port <n>] Launch visual local web dashboard companion (alias: web)
793
849
  archive <feature> Move a feature to archived without running verification
794
850
  version Show the installed PlankKit CLI version
795
851
  help Show this help message
@@ -802,6 +858,7 @@ Options:
802
858
  --framework <name> Select framework command suite during init
803
859
  (angular, vue, dotnet, none, all; default: none)
804
860
  --frameworks <list> Comma-separated list of framework suites during init
861
+ --port <number> Specify port for web dashboard (default: 4200)
805
862
  --threshold <score> Fail scan if health score is below threshold
806
863
  --json Output scan report or status as JSON
807
864
  --no-tui Render static summary table instead of interactive dashboard
@@ -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
+ }
@@ -0,0 +1,155 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { URL } from 'node:url';
5
+ import { loadDashboardData } from '../dashboard/dashboard.js';
6
+ import { runAnalysis } from '../analyzer/engine.js';
7
+ import { getClientHtml } from './clientHtml.js';
8
+ import { safeJoin } from '../config.js';
9
+
10
+ /**
11
+ * Native, zero-dependency HTTP server for PlanKit Web Dashboard.
12
+ */
13
+
14
+ export function createWebServer({ cwd, config, io }) {
15
+ const server = http.createServer(async (req, res) => {
16
+ try {
17
+ const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
18
+ const pathname = parsedUrl.pathname;
19
+
20
+ // CORS headers for local development
21
+ res.setHeader('Access-Control-Allow-Origin', '*');
22
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
23
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
24
+
25
+ if (req.method === 'OPTIONS') {
26
+ res.writeHead(204);
27
+ res.end();
28
+ return;
29
+ }
30
+
31
+ // Root HTML page
32
+ if (pathname === '/' || pathname === '/index.html') {
33
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
34
+ res.end(getClientHtml());
35
+ return;
36
+ }
37
+
38
+ // API: Combined data (active features, archived count, and latest health scan)
39
+ if (pathname === '/api/data' && req.method === 'GET') {
40
+ const dashboardData = loadDashboardData(cwd, config);
41
+
42
+ let healthReport = null;
43
+ const indexPath = path.join(cwd, config.artifactsDir || 'artifacts', '.plankit-index.json');
44
+ if (fs.existsSync(indexPath)) {
45
+ try {
46
+ healthReport = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
47
+ } catch {
48
+ healthReport = null;
49
+ }
50
+ }
51
+
52
+ // If no index exists yet, run a fast scan
53
+ if (!healthReport) {
54
+ healthReport = await runAnalysis(cwd, { artifactsDir: config.artifactsDir });
55
+ }
56
+
57
+ res.writeHead(200, { 'Content-Type': 'application/json' });
58
+ res.end(JSON.stringify({
59
+ activeFeatures: dashboardData.activeFeatures,
60
+ archivedCount: dashboardData.archivedCount,
61
+ healthReport
62
+ }));
63
+ return;
64
+ }
65
+
66
+ // API: Trigger live re-scan
67
+ if (pathname === '/api/scan' && req.method === 'POST') {
68
+ const report = await runAnalysis(cwd, { artifactsDir: config.artifactsDir });
69
+ res.writeHead(200, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify(report));
71
+ return;
72
+ }
73
+
74
+ // API: View Phase Spec Markdown
75
+ if (pathname === '/api/spec' && req.method === 'GET') {
76
+ const feature = parsedUrl.searchParams.get('feature');
77
+ const phase = parsedUrl.searchParams.get('phase');
78
+ if (!feature || !phase) {
79
+ res.writeHead(400, { 'Content-Type': 'application/json' });
80
+ res.end(JSON.stringify({ error: 'Missing feature or phase parameter' }));
81
+ return;
82
+ }
83
+
84
+ const phasesDir = safeJoin(cwd, config.artifactsDir || 'artifacts', 'current', feature, 'phases');
85
+ const prefix = `phase-${phase}-`;
86
+ const file = fs.existsSync(phasesDir)
87
+ ? fs.readdirSync(phasesDir).find((n) => n.startsWith(prefix) && n.endsWith('.md'))
88
+ : null;
89
+
90
+ if (!file) {
91
+ res.writeHead(404, { 'Content-Type': 'application/json' });
92
+ res.end(JSON.stringify({ error: `Spec for Phase ${phase} not found` }));
93
+ return;
94
+ }
95
+
96
+ const content = fs.readFileSync(path.join(phasesDir, file), 'utf8');
97
+ res.writeHead(200, { 'Content-Type': 'application/json' });
98
+ res.end(JSON.stringify({ file, content }));
99
+ return;
100
+ }
101
+
102
+ // API: View Phase Output Report Markdown
103
+ if (pathname === '/api/output' && req.method === 'GET') {
104
+ const feature = parsedUrl.searchParams.get('feature');
105
+ const phase = parsedUrl.searchParams.get('phase');
106
+ if (!feature || !phase) {
107
+ res.writeHead(400, { 'Content-Type': 'application/json' });
108
+ res.end(JSON.stringify({ error: 'Missing feature or phase parameter' }));
109
+ return;
110
+ }
111
+
112
+ const outputPath = safeJoin(cwd, config.artifactsDir || 'artifacts', 'current', feature, 'outputs', `phase-${phase}-output.md`);
113
+ if (!fs.existsSync(outputPath)) {
114
+ res.writeHead(404, { 'Content-Type': 'application/json' });
115
+ res.end(JSON.stringify({ error: `Output report for Phase ${phase} not found` }));
116
+ return;
117
+ }
118
+
119
+ const content = fs.readFileSync(outputPath, 'utf8');
120
+ res.writeHead(200, { 'Content-Type': 'application/json' });
121
+ res.end(JSON.stringify({ file: path.basename(outputPath), content }));
122
+ return;
123
+ }
124
+
125
+ // 404
126
+ res.writeHead(404, { 'Content-Type': 'application/json' });
127
+ res.end(JSON.stringify({ error: 'Not Found' }));
128
+ } catch (err) {
129
+ res.writeHead(500, { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify({ error: err.message }));
131
+ }
132
+ });
133
+
134
+ return server;
135
+ }
136
+
137
+ export function startServer({ cwd, config, port = 4200, io }) {
138
+ return new Promise((resolve, reject) => {
139
+ const server = createWebServer({ cwd, config, io });
140
+
141
+ server.once('error', (err) => {
142
+ if (err.code === 'EADDRINUSE') {
143
+ reject(new Error(`Port ${port} is already in use. Try passing a different port with --port <number>.`));
144
+ } else {
145
+ reject(err);
146
+ }
147
+ });
148
+
149
+ server.listen(port, () => {
150
+ const address = server.address();
151
+ const actualPort = typeof address === 'object' ? address.port : port;
152
+ resolve({ server, port: actualPort });
153
+ });
154
+ });
155
+ }