@decantr/cli 2.1.0 → 2.1.2

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,1202 @@
1
+ import {
2
+ createProjectHealthReport
3
+ } from "./chunk-DPFORHLL.js";
4
+ import "./chunk-LLQCXOHK.js";
5
+ import {
6
+ sendStudioHealthRefreshedTelemetry,
7
+ sendStudioStartedTelemetry
8
+ } from "./chunk-JYEEXSUX.js";
9
+
10
+ // src/commands/studio.ts
11
+ import { readFileSync } from "fs";
12
+ import { createServer } from "http";
13
+ import { isAbsolute, resolve } from "path";
14
+ var GREEN = "\x1B[32m";
15
+ var CYAN = "\x1B[36m";
16
+ var RESET = "\x1B[0m";
17
+ var PROJECT_HEALTH_SCHEMA_URL = "https://decantr.ai/schemas/project-health-report.v1.json";
18
+ function sendJson(res, status, value) {
19
+ const body = JSON.stringify(value, null, 2);
20
+ res.writeHead(status, {
21
+ "Content-Type": "application/json; charset=utf-8",
22
+ "Cache-Control": "no-store"
23
+ });
24
+ res.end(body);
25
+ }
26
+ function sendHtml(res, body) {
27
+ res.writeHead(200, {
28
+ "Content-Type": "text/html; charset=utf-8",
29
+ "Cache-Control": "no-store"
30
+ });
31
+ res.end(body);
32
+ }
33
+ function sendNotFound(res) {
34
+ sendJson(res, 404, { error: "not_found" });
35
+ }
36
+ function resolveReportPath(projectRoot, reportPath) {
37
+ if (!reportPath) return void 0;
38
+ return isAbsolute(reportPath) ? reportPath : resolve(projectRoot, reportPath);
39
+ }
40
+ function isRecord(value) {
41
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
42
+ }
43
+ function readProjectHealthReport(reportPath) {
44
+ const parsed = JSON.parse(readFileSync(reportPath, "utf-8"));
45
+ if (!isRecord(parsed) || parsed.$schema !== PROJECT_HEALTH_SCHEMA_URL) {
46
+ throw new Error("Report file is not a Decantr Project Health JSON document.");
47
+ }
48
+ if (typeof parsed.generatedAt !== "string" || typeof parsed.projectRoot !== "string" || !["healthy", "warning", "error"].includes(String(parsed.status)) || typeof parsed.score !== "number" || !isRecord(parsed.summary) || !isRecord(parsed.routes) || !isRecord(parsed.packs) || !isRecord(parsed.ci) || !Array.isArray(parsed.findings)) {
49
+ throw new Error("Report file is not a Decantr Project Health JSON document.");
50
+ }
51
+ return parsed;
52
+ }
53
+ function studioHtml(reportMode = false) {
54
+ return `<!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <title>Decantr Project Health</title>
60
+ <style>
61
+ :root {
62
+ color-scheme: dark;
63
+ --bg: #101014;
64
+ --panel: #171821;
65
+ --panel-2: #20212b;
66
+ --panel-soft: #13141a;
67
+ --line: rgba(245,242,235,0.12);
68
+ --line-soft: rgba(245,242,235,0.07);
69
+ --text: #f5f2eb;
70
+ --muted: #ada7bd;
71
+ --muted-2: #817b90;
72
+ --good: #5ee2a0;
73
+ --warn: #f2bd61;
74
+ --bad: #ff6f7d;
75
+ --accent: #8ed3ff;
76
+ --ink: #0c0c10;
77
+ }
78
+ * { box-sizing: border-box; }
79
+ body {
80
+ margin: 0;
81
+ background: var(--bg);
82
+ color: var(--text);
83
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
84
+ line-height: 1.45;
85
+ }
86
+ button, input { font: inherit; }
87
+ .shell { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; }
88
+ header {
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ gap: 1rem;
93
+ padding: 0.85rem 1rem;
94
+ border-bottom: 1px solid var(--line);
95
+ background: rgba(16,16,20,0.84);
96
+ backdrop-filter: blur(18px);
97
+ position: sticky;
98
+ top: 0;
99
+ z-index: 2;
100
+ }
101
+ h1 { margin: 0; font-size: 1rem; letter-spacing: 0; }
102
+ .subtle { color: var(--muted); font-size: 0.875rem; }
103
+ .meta-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; }
104
+ .button {
105
+ border: 1px solid var(--line-soft);
106
+ background: rgba(245,242,235,0.045);
107
+ color: var(--text);
108
+ border-radius: 8px;
109
+ padding: 0.55rem 0.8rem;
110
+ cursor: pointer;
111
+ display: inline-flex;
112
+ align-items: center;
113
+ gap: 0.45rem;
114
+ }
115
+ .button.strong {
116
+ border-color: rgba(142,211,255,0.42);
117
+ background: linear-gradient(120deg, rgba(142,211,255,0.16), rgba(142,211,255,0.04));
118
+ }
119
+ .button:hover { border-color: var(--accent); }
120
+ .button:focus-visible, .tab:focus-visible, .action-tab:focus-visible, .finding-choice:focus-visible, input:focus-visible, select:focus-visible {
121
+ outline: 2px solid var(--accent);
122
+ outline-offset: 2px;
123
+ }
124
+ main { display: grid; grid-template-columns: 15rem 1fr; min-height: 0; }
125
+ nav {
126
+ border-right: 1px solid var(--line);
127
+ padding: 1rem;
128
+ background: rgba(24,24,32,0.66);
129
+ }
130
+ .tab {
131
+ width: 100%;
132
+ text-align: left;
133
+ margin: 0 0 0.35rem;
134
+ border: 1px solid transparent;
135
+ border-radius: 8px;
136
+ padding: 0.7rem 0.8rem;
137
+ color: var(--muted);
138
+ background: transparent;
139
+ cursor: pointer;
140
+ }
141
+ .tab[aria-selected="true"] {
142
+ color: var(--text);
143
+ border-color: transparent;
144
+ background: rgba(245,242,235,0.06);
145
+ }
146
+ .content { padding: 1.15rem 1rem; overflow: auto; }
147
+ #overview.stack { gap: 1.15rem; }
148
+ .grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.6rem; }
149
+ .status-strip {
150
+ display: grid;
151
+ grid-template-columns: repeat(4, minmax(0, 1fr));
152
+ border-top: 1px solid var(--line-soft);
153
+ border-bottom: 1px solid var(--line-soft);
154
+ }
155
+ .grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; }
156
+ .grid-3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.75rem; }
157
+ .card {
158
+ border: 1px solid var(--line-soft);
159
+ background: rgba(245,242,235,0.025);
160
+ border-radius: 8px;
161
+ padding: 1rem;
162
+ }
163
+ .panel {
164
+ border: 1px solid var(--line-soft);
165
+ background: rgba(245,242,235,0.018);
166
+ border-radius: 8px;
167
+ padding: 0.95rem;
168
+ }
169
+ .hero {
170
+ border: 0;
171
+ border-left: 4px solid var(--line);
172
+ background: linear-gradient(110deg, rgba(245,242,235,0.055), rgba(245,242,235,0.015) 62%, transparent);
173
+ border-radius: 8px;
174
+ padding: 1.15rem;
175
+ display: grid;
176
+ gap: 0.85rem;
177
+ }
178
+ .hero-error { border-left-color: var(--bad); }
179
+ .hero-warning { border-left-color: var(--warn); }
180
+ .hero-healthy { border-left-color: var(--good); }
181
+ .hero h2 { margin: 0; font-size: 1.55rem; line-height: 1.18; letter-spacing: 0; }
182
+ .hero p { margin: 0; color: var(--muted); max-width: 72rem; }
183
+ .hero-summary {
184
+ display: grid;
185
+ grid-template-columns: minmax(0, 1fr) auto;
186
+ gap: 1rem;
187
+ align-items: end;
188
+ }
189
+ .hero-primary { display: grid; gap: 0.75rem; }
190
+ .hero-priority {
191
+ min-width: min(26rem, 100%);
192
+ border-left: 1px solid var(--line);
193
+ padding-left: 1rem;
194
+ display: grid;
195
+ gap: 0.25rem;
196
+ }
197
+ .hero-priority strong { font-size: 0.98rem; }
198
+ .stat {
199
+ min-height: 4rem;
200
+ border: 0;
201
+ border-left: 1px solid var(--line-soft);
202
+ background: transparent;
203
+ border-radius: 0;
204
+ padding: 0.7rem 0.9rem;
205
+ display: grid;
206
+ align-content: center;
207
+ gap: 0.25rem;
208
+ }
209
+ .stat:first-child { border-left: 0; }
210
+ .tone-error {
211
+ border-color: rgba(255,111,125,0.24);
212
+ background:
213
+ linear-gradient(110deg, rgba(255,111,125,0.105), rgba(255,111,125,0.035) 38%, rgba(255,111,125,0) 74%),
214
+ var(--panel);
215
+ }
216
+ .tone-warn {
217
+ border-color: rgba(242,189,97,0.24);
218
+ background:
219
+ linear-gradient(110deg, rgba(242,189,97,0.105), rgba(242,189,97,0.034) 38%, rgba(242,189,97,0) 74%),
220
+ var(--panel);
221
+ }
222
+ .tone-info {
223
+ border-color: rgba(142,211,255,0.18);
224
+ background:
225
+ linear-gradient(110deg, rgba(142,211,255,0.07), rgba(142,211,255,0.024) 38%, rgba(142,211,255,0) 74%),
226
+ var(--panel);
227
+ }
228
+ .stat.tone-error, .stat.tone-warn, .stat.tone-info {
229
+ background: transparent;
230
+ }
231
+ .stat.tone-error { --tone-bg: linear-gradient(110deg, rgba(255,111,125,0.12), rgba(255,111,125,0.02) 70%, transparent); }
232
+ .stat.tone-warn { --tone-bg: linear-gradient(110deg, rgba(242,189,97,0.12), rgba(242,189,97,0.02) 70%, transparent); }
233
+ .stat.tone-info { --tone-bg: linear-gradient(110deg, rgba(142,211,255,0.08), rgba(142,211,255,0.02) 70%, transparent); }
234
+ .metric { font-size: 1.42rem; font-weight: 720; line-height: 1.05; }
235
+ .label { color: var(--muted-2); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0; }
236
+ .icon {
237
+ width: 1rem;
238
+ height: 1rem;
239
+ flex: 0 0 auto;
240
+ stroke: currentColor;
241
+ stroke-width: 2;
242
+ stroke-linecap: round;
243
+ stroke-linejoin: round;
244
+ fill: none;
245
+ }
246
+ .icon-title {
247
+ display: inline-flex;
248
+ align-items: center;
249
+ gap: 0.42rem;
250
+ }
251
+ .stat .label, .section-title, .action-title {
252
+ display: inline-flex;
253
+ align-items: center;
254
+ gap: 0.45rem;
255
+ }
256
+ .button .icon, .action-tab .icon {
257
+ width: 0.95rem;
258
+ height: 0.95rem;
259
+ }
260
+ .section-title { margin: 0; font-size: 0.95rem; letter-spacing: 0; }
261
+ .section-kicker { color: var(--muted-2); font-size: 0.8rem; margin: 0.1rem 0 0; }
262
+ .section-head {
263
+ display: flex;
264
+ align-items: baseline;
265
+ justify-content: space-between;
266
+ gap: 1rem;
267
+ margin-bottom: 0.1rem;
268
+ }
269
+ .status-healthy { color: var(--good); }
270
+ .status-warning { color: var(--warn); }
271
+ .status-error { color: var(--bad); }
272
+ table { width: 100%; border-collapse: collapse; }
273
+ th, td { border-bottom: 1px solid var(--line); padding: 0.7rem; text-align: left; vertical-align: top; }
274
+ th { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; }
275
+ code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
276
+ pre {
277
+ white-space: pre-wrap;
278
+ border: 1px solid var(--line);
279
+ border-radius: 8px;
280
+ padding: 1rem;
281
+ background: var(--ink);
282
+ overflow: auto;
283
+ }
284
+ .pill {
285
+ display: inline-flex;
286
+ align-items: center;
287
+ gap: 0.25rem;
288
+ border: 0;
289
+ border-radius: 999px;
290
+ padding: 0.2rem 0.55rem;
291
+ color: var(--text);
292
+ background: rgba(255,255,255,0.045);
293
+ font-size: 0.78rem;
294
+ }
295
+ .pill-error { border-color: rgba(255,111,125,0.48); color: var(--bad); }
296
+ .pill-warn { border-color: rgba(242,189,97,0.48); color: var(--warn); }
297
+ .pill-info { border-color: rgba(142,211,255,0.48); color: var(--accent); }
298
+ .stack { display: grid; gap: 0.75rem; align-content: start; }
299
+ .split { display: grid; grid-template-columns: minmax(0, 0.95fr) minmax(28rem, 1.05fr); gap: 1.35rem; align-items: start; }
300
+ .finding-list { display: grid; gap: 0.5rem; }
301
+ .finding-card { display: grid; gap: 0.5rem; }
302
+ .finding-card.compact { gap: 0.45rem; }
303
+ .finding-card.compact .finding-message {
304
+ color: var(--muted);
305
+ font-size: 0.9rem;
306
+ display: -webkit-box;
307
+ -webkit-line-clamp: 2;
308
+ -webkit-box-orient: vertical;
309
+ overflow: hidden;
310
+ }
311
+ .finding-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
312
+ .finding-title {
313
+ margin: 0;
314
+ font-size: 0.95rem;
315
+ letter-spacing: 0;
316
+ display: flex;
317
+ align-items: center;
318
+ gap: 0.45rem;
319
+ }
320
+ .finding-message {
321
+ margin: 0;
322
+ color: var(--text);
323
+ max-width: 82ch;
324
+ }
325
+ .finding-fix { margin: 0; color: var(--muted); font-size: 0.88rem; }
326
+ .finding-choice {
327
+ width: 100%;
328
+ text-align: left;
329
+ color: var(--text);
330
+ border: 0;
331
+ border-left: 3px solid transparent;
332
+ border-bottom: 1px solid var(--line-soft);
333
+ background: transparent;
334
+ border-radius: 0;
335
+ padding: 0.78rem 0.35rem 0.78rem 0.75rem;
336
+ display: grid;
337
+ gap: 0.45rem;
338
+ cursor: pointer;
339
+ }
340
+ .finding-choice[aria-pressed="true"] {
341
+ border-color: rgba(142,211,255,0.58);
342
+ background: rgba(142,211,255,0.045);
343
+ box-shadow: none;
344
+ }
345
+ .finding-choice.tone-error, .finding-choice.tone-warn, .finding-choice.tone-info { background: transparent; }
346
+ .finding-choice.tone-error { border-left-color: rgba(255,111,125,0.58); }
347
+ .finding-choice.tone-warn { border-left-color: rgba(242,189,97,0.58); }
348
+ .finding-choice.tone-info { border-left-color: rgba(142,211,255,0.5); }
349
+ .finding-choice[aria-pressed="true"].tone-error,
350
+ .finding-choice[aria-pressed="true"].tone-warn,
351
+ .finding-choice[aria-pressed="true"].tone-info { background: rgba(142,211,255,0.045); }
352
+ .finding-choice p {
353
+ margin: 0;
354
+ color: var(--muted);
355
+ font-size: 0.88rem;
356
+ display: -webkit-box;
357
+ -webkit-line-clamp: 2;
358
+ -webkit-box-orient: vertical;
359
+ overflow: hidden;
360
+ }
361
+ .choice-action { color: var(--accent); font-size: 0.82rem; }
362
+ .toolbar { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
363
+ .action-panel {
364
+ padding: 0;
365
+ overflow: hidden;
366
+ border: 0;
367
+ background: transparent;
368
+ }
369
+ .action-step {
370
+ display: grid;
371
+ gap: 0.55rem;
372
+ padding: 0;
373
+ border-top: 0;
374
+ }
375
+ .action-step:first-child { border-top: 0; }
376
+ .action-title { margin: 0; font-size: 0.98rem; letter-spacing: 0; }
377
+ .action-tabs {
378
+ display: flex;
379
+ gap: 0.2rem;
380
+ border-bottom: 1px solid var(--line-soft);
381
+ background: transparent;
382
+ }
383
+ .action-tab {
384
+ border: 0;
385
+ border-bottom: 2px solid transparent;
386
+ background: transparent;
387
+ color: var(--muted);
388
+ border-radius: 0;
389
+ padding: 0.5rem 0.7rem;
390
+ cursor: pointer;
391
+ display: inline-flex;
392
+ align-items: center;
393
+ gap: 0.4rem;
394
+ }
395
+ .action-tab[aria-selected="true"] {
396
+ border-bottom-color: var(--accent);
397
+ background: transparent;
398
+ color: var(--text);
399
+ }
400
+ .action-body { display: grid; gap: 0.75rem; }
401
+ .action-copy { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
402
+ .action-copy .copy-status { min-width: 3.5rem; }
403
+ .prompt-preview {
404
+ max-height: 18rem;
405
+ margin: 0;
406
+ font-size: 0.84rem;
407
+ line-height: 1.5;
408
+ background: rgba(12,12,16,0.62);
409
+ border: 0;
410
+ border-radius: 8px;
411
+ padding: 0.85rem;
412
+ overflow: auto;
413
+ display: grid;
414
+ gap: 0.8rem;
415
+ }
416
+ .prompt-section {
417
+ display: grid;
418
+ gap: 0.4rem;
419
+ padding-bottom: 0.75rem;
420
+ border-bottom: 1px solid var(--line-soft);
421
+ }
422
+ .prompt-section:last-child {
423
+ padding-bottom: 0;
424
+ border-bottom: 0;
425
+ }
426
+ .prompt-heading {
427
+ color: var(--muted-2);
428
+ font-size: 0.72rem;
429
+ text-transform: uppercase;
430
+ letter-spacing: 0;
431
+ }
432
+ .prompt-preview p {
433
+ margin: 0;
434
+ color: var(--muted);
435
+ }
436
+ .prompt-meta {
437
+ display: grid;
438
+ grid-template-columns: repeat(2, minmax(0, 1fr));
439
+ gap: 0.45rem;
440
+ }
441
+ .prompt-meta-item {
442
+ border-top: 1px solid var(--line-soft);
443
+ border-radius: 0;
444
+ padding: 0.5rem 0;
445
+ background: transparent;
446
+ }
447
+ .prompt-meta-item strong {
448
+ display: block;
449
+ color: var(--muted-2);
450
+ font-size: 0.7rem;
451
+ text-transform: uppercase;
452
+ margin-bottom: 0.2rem;
453
+ }
454
+ .prompt-message {
455
+ grid-column: 1 / -1;
456
+ }
457
+ .prompt-preview ul {
458
+ margin: 0;
459
+ padding-left: 1.1rem;
460
+ color: var(--muted);
461
+ }
462
+ .prompt-preview li + li { margin-top: 0.35rem; }
463
+ .prompt-preview code {
464
+ border: 1px solid var(--line-soft);
465
+ border-radius: 5px;
466
+ background: rgba(245,242,235,0.06);
467
+ padding: 0.08rem 0.28rem;
468
+ color: var(--text);
469
+ }
470
+ .prompt-command-list {
471
+ list-style: none;
472
+ padding: 0;
473
+ display: grid;
474
+ gap: 0.45rem;
475
+ }
476
+ .prompt-command-list code {
477
+ display: block;
478
+ padding: 0.45rem 0.55rem;
479
+ background: var(--ink);
480
+ overflow-wrap: anywhere;
481
+ }
482
+ .explain-block {
483
+ border: 0;
484
+ border-left: 2px solid var(--line-soft);
485
+ border-radius: 0;
486
+ padding: 0.35rem 0 0.35rem 0.75rem;
487
+ background: transparent;
488
+ display: grid;
489
+ gap: 0.35rem;
490
+ }
491
+ .explain-block p { margin: 0; color: var(--muted); }
492
+ .verify-list { display: grid; gap: 0.45rem; }
493
+ .verify-list code {
494
+ display: block;
495
+ border: 1px solid var(--line-soft);
496
+ border-radius: 8px;
497
+ background: rgba(12,12,16,0.72);
498
+ padding: 0.45rem 0.55rem;
499
+ overflow-wrap: anywhere;
500
+ }
501
+ input, select {
502
+ border: 1px solid var(--line);
503
+ background: var(--panel-2);
504
+ color: var(--text);
505
+ border-radius: 8px;
506
+ padding: 0.55rem 0.65rem;
507
+ min-height: 2.35rem;
508
+ }
509
+ input { min-width: min(22rem, 100%); }
510
+ details {
511
+ border: 1px solid var(--line);
512
+ border-radius: 8px;
513
+ padding: 0.7rem;
514
+ background: rgba(255,255,255,0.02);
515
+ }
516
+ summary { cursor: pointer; color: var(--muted); }
517
+ .command-block { display: grid; gap: 0.35rem; }
518
+ .command-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
519
+ .command-row code {
520
+ flex: 1 1 18rem;
521
+ min-width: 0;
522
+ border: 1px solid var(--line);
523
+ border-radius: 8px;
524
+ background: var(--ink);
525
+ padding: 0.45rem 0.55rem;
526
+ overflow-wrap: anywhere;
527
+ }
528
+ .command-help { margin: 0; color: var(--muted-2); font-size: 0.82rem; }
529
+ .overview-rail {
530
+ position: sticky;
531
+ top: 5rem;
532
+ background: rgba(245,242,235,0.026);
533
+ border-left: 1px solid var(--line-soft);
534
+ border-radius: 8px;
535
+ padding: 0.85rem 0.9rem 0.95rem;
536
+ }
537
+ .quick-line {
538
+ display: grid;
539
+ grid-template-columns: repeat(3, minmax(0, 1fr));
540
+ gap: 0.6rem;
541
+ }
542
+ .signal {
543
+ border-top: 1px solid var(--line-soft);
544
+ padding-top: 0.65rem;
545
+ display: grid;
546
+ gap: 0.2rem;
547
+ }
548
+ .signal strong { font-size: 1rem; }
549
+ .mini-map { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; }
550
+ .source-item {
551
+ min-height: 4.25rem;
552
+ border: 1px solid var(--line-soft);
553
+ border-radius: 8px;
554
+ padding: 0.7rem;
555
+ display: grid;
556
+ gap: 0.2rem;
557
+ align-content: start;
558
+ background: rgba(245,242,235,0.02);
559
+ }
560
+ .details-band {
561
+ border: 0;
562
+ border-top: 1px solid var(--line-soft);
563
+ border-radius: 0;
564
+ padding: 0.85rem 0 0;
565
+ background: transparent;
566
+ }
567
+ .details-band summary { color: var(--text); }
568
+ .details-content { margin-top: 0.75rem; display: grid; gap: 0.75rem; }
569
+ .copy-status { min-height: 1.2rem; }
570
+ .empty { color: var(--muted); }
571
+ .hidden { display: none; }
572
+ @media (max-width: 1100px) {
573
+ .split { grid-template-columns: 1fr; }
574
+ .overview-rail { position: static; }
575
+ }
576
+ @media (max-width: 760px) {
577
+ main { grid-template-columns: 1fr; }
578
+ nav { border-right: 0; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.35rem; }
579
+ .grid, .grid-2, .grid-3, .split, .mini-map, .quick-line, .status-strip, .hero-summary, .action-tabs { grid-template-columns: 1fr; }
580
+ .overview-rail { position: static; }
581
+ .hero-priority { border-left: 0; border-top: 1px solid var(--line); padding-left: 0; padding-top: 0.85rem; }
582
+ .finding-head { display: grid; }
583
+ }
584
+ </style>
585
+ </head>
586
+ <body>
587
+ <div class="shell">
588
+ <header>
589
+ <div>
590
+ <h1>Decantr Project Health</h1>
591
+ <div class="meta-row">
592
+ <span id="mode" class="pill">Loading mode</span>
593
+ <span id="project" class="subtle">Loading local contract state...</span>
594
+ </div>
595
+ </div>
596
+ <button id="refresh" class="button" type="button">Refresh</button>
597
+ </header>
598
+ <main>
599
+ <nav aria-label="Project Health Views">
600
+ <button class="tab" type="button" data-tab="overview" aria-selected="true">Overview</button>
601
+ <button class="tab" type="button" data-tab="routes">Routes</button>
602
+ <button class="tab" type="button" data-tab="drift">Drift</button>
603
+ <button class="tab" type="button" data-tab="findings">Findings</button>
604
+ <button class="tab" type="button" data-tab="remediation">Remediation</button>
605
+ <button class="tab" type="button" data-tab="ci">CI</button>
606
+ <button class="tab" type="button" data-tab="packs">Packs</button>
607
+ </nav>
608
+ <section class="content">
609
+ <div id="overview" class="view stack"></div>
610
+ <div id="routes" class="view stack hidden"></div>
611
+ <div id="drift" class="view stack hidden"></div>
612
+ <div id="findings" class="view stack hidden"></div>
613
+ <div id="remediation" class="view stack hidden"></div>
614
+ <div id="ci" class="view stack hidden"></div>
615
+ <div id="packs" class="view stack hidden"></div>
616
+ </section>
617
+ </main>
618
+ </div>
619
+ <script>
620
+ let report = null;
621
+ let remediationFindingId = null;
622
+ let overviewFindingId = null;
623
+ let overviewActionMode = 'ai';
624
+ const studioMode = ${JSON.stringify(reportMode ? "report" : "project")};
625
+ const findingFilters = { severity: 'all', source: 'all', query: '' };
626
+ const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
627
+ const SOURCE_ORDER = { check: 0, interaction: 1, runtime: 2, pack: 3, brownfield: 4, audit: 5 };
628
+ const SOURCE_LABELS = {
629
+ check: 'Contract',
630
+ interaction: 'Interactions',
631
+ runtime: 'Runtime',
632
+ pack: 'Packs',
633
+ brownfield: 'Brownfield',
634
+ audit: 'Audit'
635
+ };
636
+ const SOURCE_DESCRIPTIONS = {
637
+ check: 'Essence and guard rules',
638
+ interaction: 'Declared UI behaviors',
639
+ runtime: 'Built app evidence',
640
+ pack: 'Generated context files',
641
+ brownfield: 'Observed app drift',
642
+ audit: 'Verifier audit findings'
643
+ };
644
+ const ICONS = {
645
+ alert: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line>',
646
+ warning: '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>',
647
+ info: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>',
648
+ list: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>',
649
+ file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="14" y2="17"></line>',
650
+ sparkles: '<path d="m12 3-1.9 5.1L5 10l5.1 1.9L12 17l1.9-5.1L19 10l-5.1-1.9Z"></path><path d="M5 3v4"></path><path d="M3 5h4"></path><path d="M19 17v4"></path><path d="M17 19h4"></path>',
651
+ pencil: '<path d="M21.2 6.8 17.2 2.8a2 2 0 0 0-2.8 0L3 14.2V21h6.8L21.2 9.6a2 2 0 0 0 0-2.8Z"></path><path d="m14 5 5 5"></path>',
652
+ terminal: '<polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line>',
653
+ check: '<path d="M20 6 9 17l-5-5"></path>',
654
+ route: '<circle cx="6" cy="19" r="3"></circle><circle cx="18" cy="5" r="3"></circle><path d="M6 16V8a3 3 0 0 1 3-3h6"></path>',
655
+ package: '<path d="m21 8-9-5-9 5 9 5 9-5Z"></path><path d="M3 8v8l9 5 9-5V8"></path><path d="M12 13v8"></path>',
656
+ copy: '<rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>',
657
+ target: '<circle cx="12" cy="12" r="8"></circle><circle cx="12" cy="12" r="3"></circle><path d="M12 2v3"></path><path d="M12 19v3"></path><path d="M2 12h3"></path><path d="M19 12h3"></path>'
658
+ };
659
+ const tabs = [...document.querySelectorAll('.tab')];
660
+ const views = [...document.querySelectorAll('.view')];
661
+ function esc(value) {
662
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
663
+ }
664
+ function attr(value) {
665
+ return esc(value).replace(/\\n/g, '&#10;');
666
+ }
667
+ function icon(name) {
668
+ return '<svg class="icon" aria-hidden="true" viewBox="0 0 24 24">' + (ICONS[name] || ICONS.info) + '</svg>';
669
+ }
670
+ function iconLabel(name, label) {
671
+ return '<span class="icon-title">' + icon(name) + esc(label) + '</span>';
672
+ }
673
+ function severityIcon(severity) {
674
+ return severity === 'error' ? 'alert' : severity === 'warn' ? 'warning' : 'info';
675
+ }
676
+ function metric(label, value, cls = '', iconName = '') {
677
+ const tone = cls.includes('status-error')
678
+ ? ' tone-error'
679
+ : cls.includes('status-warning')
680
+ ? ' tone-warn'
681
+ : cls.includes('status-healthy')
682
+ ? ' tone-info'
683
+ : '';
684
+ return '<div class="stat' + tone + '"><div class="label">' + (iconName ? iconLabel(iconName, label) : esc(label)) + '</div><div class="metric ' + cls + '">' + esc(value) + '</div></div>';
685
+ }
686
+ function table(headers, rows) {
687
+ if (!rows.length) return '<div class="card empty">No rows to show.</div>';
688
+ return '<table><thead><tr>' + headers.map((h) => '<th>' + esc(h) + '</th>').join('') + '</tr></thead><tbody>' +
689
+ rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table>';
690
+ }
691
+ function findings() {
692
+ return Array.isArray(report?.findings) ? report.findings : [];
693
+ }
694
+ function sortedFindings() {
695
+ return [...findings()].sort((a, b) => {
696
+ const severity = (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9);
697
+ if (severity !== 0) return severity;
698
+ const source = (SOURCE_ORDER[a.source] ?? 9) - (SOURCE_ORDER[b.source] ?? 9);
699
+ if (source !== 0) return source;
700
+ return String(a.id).localeCompare(String(b.id));
701
+ });
702
+ }
703
+ function countBySource() {
704
+ return findings().reduce((counts, finding) => {
705
+ counts[finding.source] = (counts[finding.source] || 0) + 1;
706
+ return counts;
707
+ }, {});
708
+ }
709
+ function severityPill(severity) {
710
+ return '<span class="pill pill-' + esc(severity) + '">' + esc(severity) + '</span>';
711
+ }
712
+ function statusNarrative() {
713
+ const errors = report.summary.errorCount || 0;
714
+ const warnings = report.summary.warnCount || 0;
715
+ if (report.status === 'error') {
716
+ return {
717
+ title: errors + ' blocking issue' + (errors === 1 ? '' : 's') + ' will fail the default CI gate.',
718
+ body: 'Fix the highest-severity finding first, then rerun Project Health to confirm the contract, runtime, and generated packs agree.'
719
+ };
720
+ }
721
+ if (report.status === 'warning') {
722
+ return {
723
+ title: warnings + ' warning' + (warnings === 1 ? '' : 's') + ' need review before this feels production-clean.',
724
+ body: 'The default error-only CI gate can pass, but the project still has drift or incomplete evidence worth resolving.'
725
+ };
726
+ }
727
+ return {
728
+ title: 'No blocking drift detected.',
729
+ body: 'Project Health found no errors or warnings. Keep the CI gate active so future changes stay aligned with the Decantr contract.'
730
+ };
731
+ }
732
+ function runtimeStatus() {
733
+ if (!report.summary.runtimeAuditChecked) return 'not checked';
734
+ return report.summary.runtimePassed ? 'passed' : 'failed';
735
+ }
736
+ function formatAge(value) {
737
+ if (!value) return 'unknown';
738
+ const date = new Date(value);
739
+ if (Number.isNaN(date.getTime())) return value;
740
+ const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
741
+ if (seconds < 60) return seconds + 's ago';
742
+ const minutes = Math.floor(seconds / 60);
743
+ if (minutes < 60) return minutes + 'm ago';
744
+ const hours = Math.floor(minutes / 60);
745
+ if (hours < 48) return hours + 'h ago';
746
+ return Math.floor(hours / 24) + 'd ago';
747
+ }
748
+ function promptCommandFor(finding) {
749
+ return 'decantr health --prompt ' + finding.id;
750
+ }
751
+ function commandCard(label, command, copyKind, copyId, options = {}) {
752
+ const help = options.help ? '<p class="command-help">' + esc(options.help) + '</p>' : '';
753
+ const buttonText = options.buttonText || 'Copy command';
754
+ return '<div class="command-block"><div class="label">' + esc(label) + '</div><div class="command-row"><code>' + esc(command) + '</code><button class="button" type="button" data-copy-' + copyKind + '="' + attr(copyId) + '">' + icon('copy') + esc(buttonText) + '</button><span class="subtle copy-status" aria-live="polite"></span></div>' + help + '</div>';
755
+ }
756
+ function toneClassFor(finding) {
757
+ return finding.severity === 'error' ? ' tone-error' : finding.severity === 'warn' ? ' tone-warn' : ' tone-info';
758
+ }
759
+ function copyPromptButtons(finding, compact = false) {
760
+ return '<div class="action-copy">' +
761
+ '<button class="button strong" type="button" data-copy-prompt="' + attr(finding.id) + '">' + icon('sparkles') + 'Copy AI prompt</button>' +
762
+ '<button class="button" type="button" data-copy-command="' + attr(finding.id) + '">' + icon('terminal') + (compact ? 'Copy command' : 'Copy terminal command') + '</button>' +
763
+ '<span class="subtle copy-status" aria-live="polite"></span>' +
764
+ '</div>';
765
+ }
766
+ function remediationText(finding) {
767
+ return finding.remediation?.summary || finding.suggestedFix || 'Resolve this finding and rerun Project Health.';
768
+ }
769
+ function verifyList(finding) {
770
+ const commands = Array.isArray(finding.remediation?.commands) && finding.remediation.commands.length
771
+ ? finding.remediation.commands
772
+ : ['decantr health'];
773
+ return '<div class="verify-list">' + commands.map((command) => '<code>' + esc(command) + '</code>').join('') + '</div>';
774
+ }
775
+ function selectedOverviewFinding(ordered) {
776
+ if (!ordered.length) return null;
777
+ if (!overviewFindingId || !ordered.some((finding) => finding.id === overviewFindingId)) {
778
+ overviewFindingId = ordered[0].id;
779
+ }
780
+ return ordered.find((finding) => finding.id === overviewFindingId) || ordered[0];
781
+ }
782
+ function findingChoice(finding, selected) {
783
+ return '<button class="finding-choice' + toneClassFor(finding) + '" type="button" data-select-finding="' + attr(finding.id) + '" aria-pressed="' + String(selected) + '">' +
784
+ '<div class="finding-head"><div><div class="meta-row">' + severityPill(finding.severity) + '<span class="pill">' + esc(SOURCE_LABELS[finding.source] || finding.source) + '</span></div><h3 class="finding-title">' + icon(severityIcon(finding.severity)) + esc(finding.id) + '</h3></div><span class="choice-action">' + (selected ? 'Selected' : 'Review fix') + '</span></div>' +
785
+ '<p>' + esc(finding.message) + '</p>' +
786
+ '</button>';
787
+ }
788
+ function actionTabs() {
789
+ const tabs = [
790
+ ['ai', 'AI Prompt', 'sparkles'],
791
+ ['manual', 'Manual Fix', 'pencil'],
792
+ ['commands', 'Commands', 'terminal']
793
+ ];
794
+ return '<div class="action-tabs" role="tablist" aria-label="Fix methods">' + tabs.map(([id, label, iconName]) =>
795
+ '<button class="action-tab" type="button" data-action-mode="' + attr(id) + '" aria-selected="' + String(overviewActionMode === id) + '">' + icon(iconName) + esc(label) + '</button>'
796
+ ).join('') + '</div>';
797
+ }
798
+ function inlineCode(value) {
799
+ const tick = String.fromCharCode(96);
800
+ const codeSpanPattern = new RegExp('(' + tick + '[^' + tick + ']*' + tick + ')', 'g');
801
+ return String(value ?? '').split(codeSpanPattern).map((part) => {
802
+ if (part.startsWith(tick) && part.endsWith(tick)) {
803
+ return '<code>' + esc(part.slice(1, -1)) + '</code>';
804
+ }
805
+ return esc(part);
806
+ }).join('');
807
+ }
808
+ function promptList(items, commandList = false) {
809
+ if (!items.length) return '';
810
+ return '<ul' + (commandList ? ' class="prompt-command-list"' : '') + '>' + items.map((item) => '<li>' + (commandList ? '<code>' + esc(item) + '</code>' : inlineCode(item)) + '</li>').join('') + '</ul>';
811
+ }
812
+ function promptParagraphs(lines) {
813
+ return lines.map((line) => '<p>' + inlineCode(line) + '</p>').join('');
814
+ }
815
+ function formatPromptPreview(prompt) {
816
+ const lines = String(prompt || '').split(/\\r?\\n/).map((line) => line.trim());
817
+ const purpose = [];
818
+ const details = [];
819
+ const evidence = [];
820
+ const guidance = [];
821
+ const commands = [];
822
+ let mode = 'purpose';
823
+ for (const line of lines) {
824
+ if (!line) continue;
825
+ if (line === 'Evidence:') {
826
+ mode = 'evidence';
827
+ continue;
828
+ }
829
+ if (line === 'After the fix, run:') {
830
+ mode = 'commands';
831
+ continue;
832
+ }
833
+ if (/^(Finding|Source|Severity|Category|Message|Suggested fix):/.test(line)) {
834
+ mode = 'details';
835
+ details.push(line);
836
+ continue;
837
+ }
838
+ if (mode === 'evidence' && line.startsWith('- ')) {
839
+ evidence.push(line.slice(2));
840
+ continue;
841
+ }
842
+ if (mode === 'commands' && line.startsWith('- ')) {
843
+ commands.push(line.slice(2));
844
+ continue;
845
+ }
846
+ if (mode === 'details') mode = 'guidance';
847
+ if (mode === 'purpose') purpose.push(line);
848
+ else guidance.push(line);
849
+ }
850
+ const detailHtml = details.map((line) => {
851
+ const separator = line.indexOf(':');
852
+ const key = separator === -1 ? 'Detail' : line.slice(0, separator);
853
+ const value = separator === -1 ? line : line.slice(separator + 1).trim();
854
+ const messageClass = key === 'Message' || key === 'Suggested fix' ? ' prompt-message' : '';
855
+ return '<div class="prompt-meta-item' + messageClass + '"><strong>' + esc(key) + '</strong><span>' + inlineCode(value) + '</span></div>';
856
+ }).join('');
857
+ return '<div class="prompt-preview">' +
858
+ (purpose.length ? '<section class="prompt-section"><div class="prompt-heading">Purpose</div>' + promptParagraphs(purpose) + '</section>' : '') +
859
+ (details.length ? '<section class="prompt-section"><div class="prompt-heading">Finding details</div><div class="prompt-meta">' + detailHtml + '</div></section>' : '') +
860
+ (evidence.length ? '<section class="prompt-section"><div class="prompt-heading">Evidence</div>' + promptList(evidence) + '</section>' : '') +
861
+ (guidance.length ? '<section class="prompt-section"><div class="prompt-heading">Instructions</div>' + promptParagraphs(guidance) + '</section>' : '') +
862
+ (commands.length ? '<section class="prompt-section"><div class="prompt-heading">Verification</div>' + promptList(commands, true) + '</section>' : '') +
863
+ '</div>';
864
+ }
865
+ function aiPromptPanel(finding) {
866
+ const prompt = finding.remediation?.prompt || 'No AI repair prompt is available for this finding.';
867
+ return '<div class="action-body"><p class="subtle">Review the prompt first. Copy it into the assistant that will edit the project.</p>' + formatPromptPreview(prompt) + copyPromptButtons(finding) + commandCard('Generate in terminal', promptCommandFor(finding), 'command', finding.id, { help: 'This command prints the same prompt. It does not edit files.', buttonText: 'Copy command' }) + '</div>';
868
+ }
869
+ function manualFixPanel(finding) {
870
+ const evidence = Array.isArray(finding.evidence) ? finding.evidence : [];
871
+ return '<div class="action-body"><div class="explain-block"><div class="label">What happened</div><p>' + esc(finding.message) + '</p></div><div class="explain-block"><div class="label">Suggested fix</div><p>' + esc(remediationText(finding)) + '</p></div>' +
872
+ (evidence.length ? '<details><summary>Evidence (' + evidence.length + ')</summary><ul>' + evidence.slice(0, 5).map((entry) => '<li>' + esc(entry) + '</li>').join('') + (evidence.length > 5 ? '<li>' + esc('+' + (evidence.length - 5) + ' more') + '</li>' : '') + '</ul></details>' : '') +
873
+ '</div>';
874
+ }
875
+ function commandPanel(finding, defaultGateFails) {
876
+ return '<div class="action-body">' +
877
+ commandCard('Print AI prompt', promptCommandFor(finding), 'command', finding.id, { help: 'Use this when you want the prompt in your terminal instead of copying it from Studio.' }) +
878
+ '<div class="explain-block"><div class="label">Check your work</div>' + verifyList(finding) + '<p>Run these after the source edit, then refresh Studio.</p></div>' +
879
+ '<div class="explain-block"><div class="label">Will CI pass?</div><p>' + (defaultGateFails ? 'Not yet. The default error-only gate will fail until blocking findings are resolved.' : 'Yes for the default error-only gate, based on this report.') + '</p></div>' +
880
+ '</div>';
881
+ }
882
+ function actionBody(finding, defaultGateFails) {
883
+ if (overviewActionMode === 'manual') return manualFixPanel(finding);
884
+ if (overviewActionMode === 'commands') return commandPanel(finding, defaultGateFails);
885
+ return aiPromptPanel(finding);
886
+ }
887
+ function actionDock(finding, defaultGateFails) {
888
+ if (!finding) {
889
+ return '<div class="panel action-panel">' +
890
+ '<div class="action-step"><div class="label">No active fix</div><h3 class="action-title">No remediation needed.</h3><p class="subtle">Keep the CI gate active so future changes stay aligned with the Decantr contract.</p>' + commandCard('CI gate', report.ci.recommendedCommand, 'literal', report.ci.recommendedCommand) + '</div>' +
891
+ '</div>';
892
+ }
893
+ return '<div class="panel action-panel">' +
894
+ '<div class="action-step"><h3 class="action-title">' + icon('target') + 'Start with ' + esc(finding.id) + '.</h3>' + actionTabs() + actionBody(finding, defaultGateFails) + '</div>' +
895
+ '</div>';
896
+ }
897
+ function findingSummary(finding, options = {}) {
898
+ const promptCommand = promptCommandFor(finding);
899
+ const evidence = Array.isArray(finding.evidence) ? finding.evidence : [];
900
+ const toneClass = toneClassFor(finding);
901
+ const frameClass = (options.compact ? 'panel finding-card compact' : 'card finding-card') + toneClass;
902
+ return '<div class="' + frameClass + '">' +
903
+ '<div class="finding-head"><div><div class="meta-row">' + severityPill(finding.severity) + '<span class="pill">' + esc(SOURCE_LABELS[finding.source] || finding.source) + '</span></div><h3 class="finding-title">' + esc(finding.id) + '</h3></div>' +
904
+ (options.compact ? '' : copyPromptButtons(finding)) +
905
+ '</div>' +
906
+ '<p class="finding-message">' + esc(finding.message) + '</p>' +
907
+ (finding.suggestedFix ? '<p class="finding-fix">Fix: ' + esc(finding.suggestedFix) + '</p>' : '') +
908
+ (!options.compact && evidence.length ? '<details><summary>Evidence (' + evidence.length + ')</summary><ul>' + evidence.map((entry) => '<li>' + esc(entry) + '</li>').join('') + '</ul></details>' : '') +
909
+ (options.compact ? copyPromptButtons(finding, true) : commandCard('AI prompt command', promptCommand, 'command', finding.id, { help: 'Prints a scoped prompt for your coding assistant; it does not edit files.' })) +
910
+ '</div>';
911
+ }
912
+ function renderOverview() {
913
+ const narrative = statusNarrative();
914
+ const ordered = sortedFindings();
915
+ const topBlockers = ordered.slice(0, 5);
916
+ const selected = selectedOverviewFinding(ordered);
917
+ const hasFindings = Boolean(selected);
918
+ const sourceCounts = countBySource();
919
+ const defaultGateFails = (report.summary.errorCount || 0) > 0;
920
+ const sourceCards = Object.keys(SOURCE_LABELS).map((source) =>
921
+ '<div class="card source-item"><div class="label">' + esc(SOURCE_LABELS[source]) + '</div><div class="metric">' + esc(sourceCounts[source] || 0) + '</div><div class="subtle">' + esc(SOURCE_DESCRIPTIONS[source]) + '</div></div>'
922
+ ).join('');
923
+ document.getElementById('overview').innerHTML =
924
+ '<section class="hero hero-' + esc(report.status) + '">' +
925
+ '<div class="hero-summary"><div class="hero-primary"><div class="meta-row">' + severityPill(report.status === 'healthy' ? 'info' : report.status === 'warning' ? 'warn' : 'error') + '<span class="pill">' + esc(report.score) + '/100</span><span class="pill">Generated ' + esc(formatAge(report.generatedAt)) + '</span></div>' +
926
+ '<h2>' + esc(narrative.title) + '</h2><p>' + esc(narrative.body) + '</p></div>' +
927
+ '<div class="hero-priority"><div class="label">' + (hasFindings ? 'Fix first' : 'Status') + '</div><strong>' + esc(selected?.id || 'All clear') + '</strong><span class="subtle">' + (selected ? esc(SOURCE_LABELS[selected.source] || selected.source) : 'Keep the CI gate active') + '</span></div></div>' +
928
+ '</section>' +
929
+ '<div class="status-strip">' +
930
+ metric('Errors', report.summary.errorCount, 'status-error', 'alert') +
931
+ metric('Warnings', report.summary.warnCount, 'status-warning', 'warning') +
932
+ metric('Findings', report.summary.findingCount, '', 'list') +
933
+ metric('Pages', report.summary.pageCount, '', 'file') +
934
+ '</div>' +
935
+ '<div class="split">' +
936
+ '<section class="stack"><div class="section-head"><div><h2 class="section-title">' + icon(hasFindings ? 'target' : 'check') + (hasFindings ? 'Fix first' : 'All clear') + '</h2><p class="section-kicker">' + (hasFindings ? 'Pick one issue; the guide updates on the right.' : 'No findings need remediation right now.') + '</p></div><span class="pill">' + esc(topBlockers.length) + ' shown</span></div>' +
937
+ (topBlockers.length ? '<div class="finding-list">' + topBlockers.map((finding) => findingChoice(finding, selected?.id === finding.id)).join('') + '</div>' : '<div class="panel">No blockers. Project is healthy.</div>') +
938
+ '</section>' +
939
+ '<aside class="overview-rail stack"><div class="section-head"><div><h2 class="section-title">' + icon(hasFindings ? 'target' : 'check') + (hasFindings ? 'Recommended path' : 'Keep watch') + '</h2><p class="section-kicker">' + (hasFindings ? 'Review, copy, then verify.' : 'No remediation needed.') + '</p></div></div>' + actionDock(selected, defaultGateFails) + '</aside>' +
940
+ '</div>' +
941
+ '<details class="details-band"><summary>' + iconLabel('info', 'Project details') + '</summary><div class="details-content"><div class="quick-line"><div class="signal"><span class="label">' + iconLabel('route', 'Routes') + '</span><strong>' + esc((report.routes.declared || []).length) + '</strong><span class="subtle">declared</span></div><div class="signal"><span class="label">' + iconLabel('check', 'Runtime') + '</span><strong>' + esc(runtimeStatus()) + '</strong><span class="subtle">' + esc(report.routes.runtimeMatched) + ' matched</span></div><div class="signal"><span class="label">' + iconLabel('package', 'Packs') + '</span><strong>' + esc(report.packs.manifestPresent ? 'present' : 'missing') + '</strong><span class="subtle">' + esc(formatAge(report.packs.generatedAt)) + '</span></div></div><div><h2 class="section-title">' + icon('list') + 'Health map</h2><div class="mini-map">' + sourceCards + '</div></div><div class="panel"><div class="label">Workflow</div><p>' + esc(report.summary.workflowMode || 'unknown') + ' / ' + esc(report.summary.adoptionMode || 'unknown') + '</p><p class="subtle">Essence ' + esc(report.summary.essenceVersion || 'missing') + ' | packs generated ' + esc(formatAge(report.packs.generatedAt)) + '</p></div></div></details>';
942
+ }
943
+ function renderRoutes() {
944
+ const declared = report.routes.declared || [];
945
+ const checked = report.routes.runtimeChecked || [];
946
+ const coverage = report.routes.runtimeCoverageOk === null ? 'not checked' : report.routes.runtimeCoverageOk ? 'covered' : 'needs attention';
947
+ document.getElementById('routes').innerHTML =
948
+ '<div class="grid-3">' +
949
+ metric('Declared', declared.length) +
950
+ metric('Runtime checked', checked.length) +
951
+ metric('Matched', report.routes.runtimeMatched) +
952
+ '</div>' +
953
+ '<div class="card"><div class="label">Coverage</div><p>' + esc(coverage) + '</p><p class="subtle">Runtime audit: ' + esc(runtimeStatus()) + '</p></div>' +
954
+ (report.routes.issues.length ? '<div class="card"><div class="label">Route Issues</div><ul>' + report.routes.issues.map((issue) => '<li>' + esc(issue) + '</li>').join('') + '</ul></div>' : '<div class="card">No route-specific issues.</div>') +
955
+ table(['Declared Route'], declared.map((route) => ['<code>' + esc(route) + '</code>']));
956
+ }
957
+ function renderDrift() {
958
+ const drift = findings().filter((finding) => finding.source === 'brownfield' || finding.id.includes('drift'));
959
+ document.getElementById('drift').innerHTML = drift.length
960
+ ? table(['Severity', 'Source', 'Message'], drift.map((finding) => [severityPill(finding.severity), esc(SOURCE_LABELS[finding.source] || finding.source), esc(finding.message)]))
961
+ : '<div class="card">No drift findings.</div>';
962
+ }
963
+ function filteredFindings() {
964
+ const query = findingFilters.query.trim().toLowerCase();
965
+ return sortedFindings().filter((finding) => {
966
+ if (findingFilters.severity !== 'all' && finding.severity !== findingFilters.severity) return false;
967
+ if (findingFilters.source !== 'all' && finding.source !== findingFilters.source) return false;
968
+ if (!query) return true;
969
+ return [finding.id, finding.message, finding.category, finding.source].some((value) => String(value || '').toLowerCase().includes(query));
970
+ });
971
+ }
972
+ function renderFindings() {
973
+ const rows = filteredFindings();
974
+ document.getElementById('findings').innerHTML =
975
+ '<div class="toolbar"><input id="finding-search" type="search" placeholder="Search findings" value="' + attr(findingFilters.query) + '"><select id="finding-severity"><option value="all">All severities</option><option value="error">Errors</option><option value="warn">Warnings</option><option value="info">Info</option></select><select id="finding-source"><option value="all">All sources</option>' + Object.keys(SOURCE_LABELS).map((source) => '<option value="' + attr(source) + '">' + esc(SOURCE_LABELS[source]) + '</option>').join('') + '</select></div>' +
976
+ (rows.length ? '<div class="finding-list">' + rows.map((finding) => findingSummary(finding)).join('') + '</div>' : '<div class="card">No findings match the current filters.</div>');
977
+ const severity = document.getElementById('finding-severity');
978
+ const source = document.getElementById('finding-source');
979
+ const search = document.getElementById('finding-search');
980
+ severity.value = findingFilters.severity;
981
+ source.value = findingFilters.source;
982
+ search.addEventListener('input', () => { findingFilters.query = search.value; renderFindings(); });
983
+ severity.addEventListener('change', () => { findingFilters.severity = severity.value; renderFindings(); });
984
+ source.addEventListener('change', () => { findingFilters.source = source.value; renderFindings(); });
985
+ }
986
+ function renderRemediation() {
987
+ const ordered = sortedFindings();
988
+ if (!ordered.length) {
989
+ document.getElementById('remediation').innerHTML = '<div class="card">No remediation needed.</div>';
990
+ return;
991
+ }
992
+ if (!remediationFindingId || !ordered.some((finding) => finding.id === remediationFindingId)) {
993
+ remediationFindingId = ordered[0].id;
994
+ }
995
+ const active = ordered.find((finding) => finding.id === remediationFindingId) || ordered[0];
996
+ document.getElementById('remediation').innerHTML =
997
+ '<div class="toolbar"><select id="remediation-select">' + ordered.map((finding) => '<option value="' + attr(finding.id) + '">' + esc(finding.severity + ' - ' + finding.id) + '</option>').join('') + '</select>' + copyPromptButtons(active) + '</div>' +
998
+ '<div class="card stack"><div class="meta-row">' + severityPill(active.severity) + '<span class="pill">' + esc(SOURCE_LABELS[active.source] || active.source) + '</span></div><h2 class="section-title">' + esc(active.id) + '</h2><p>' + esc(remediationText(active)) + '</p><p class="subtle">This is the full AI repair prompt. Copy it into the assistant doing the implementation, or run the terminal command to print it.</p>' + commandCard('Terminal command', promptCommandFor(active), 'command', active.id, { help: 'The command only prints this prompt; it does not modify source files.' }) + '<pre>' + esc(active.remediation?.prompt || '') + '</pre></div>';
999
+ const select = document.getElementById('remediation-select');
1000
+ select.value = active.id;
1001
+ select.addEventListener('change', () => { remediationFindingId = select.value; renderRemediation(); });
1002
+ }
1003
+ function renderCi() {
1004
+ document.getElementById('ci').innerHTML =
1005
+ '<div class="grid-3">' +
1006
+ metric('Default gate', report.ci.failOn) +
1007
+ metric('Would fail', (report.summary.errorCount || 0) > 0 ? 'yes' : 'no', (report.summary.errorCount || 0) > 0 ? 'status-error' : 'status-healthy') +
1008
+ metric('Status', report.status, 'status-' + report.status) +
1009
+ '</div>' +
1010
+ '<div class="card stack"><div class="label">Local command</div>' + commandCard('Local', 'decantr health', 'literal', 'decantr health') + '</div>' +
1011
+ '<div class="card stack"><div class="label">Pull request gate</div>' + commandCard('CI', report.ci.recommendedCommand, 'literal', report.ci.recommendedCommand) + '</div>' +
1012
+ '<div class="card stack"><div class="label">JSON artifact</div><p class="subtle">Use this artifact with <code>decantr studio --report decantr-health.json</code> for customer-controlled reporting.</p>' + commandCard('Artifact', 'decantr health --json --output decantr-health.json', 'literal', 'decantr health --json --output decantr-health.json') + '</div>';
1013
+ }
1014
+ function renderPacks() {
1015
+ document.getElementById('packs').innerHTML =
1016
+ '<div class="grid">' +
1017
+ metric('Manifest', report.packs.manifestPresent ? 'present' : 'missing', report.packs.manifestPresent ? '' : 'status-error') +
1018
+ metric('Review', report.packs.reviewPackPresent ? 'present' : 'missing', report.packs.reviewPackPresent ? '' : 'status-warning') +
1019
+ metric('Scaffold', report.packs.scaffoldPackPresent ? 'present' : 'missing', report.packs.scaffoldPackPresent ? '' : 'status-warning') +
1020
+ metric('Pages', report.packs.pagePackCount) +
1021
+ '</div><div class="grid-3">' +
1022
+ metric('Sections', report.packs.sectionPackCount) +
1023
+ metric('Mutations', report.packs.mutationPackCount) +
1024
+ metric('Generated', formatAge(report.packs.generatedAt)) +
1025
+ '</div>';
1026
+ }
1027
+ function render() {
1028
+ if (!report) return;
1029
+ document.getElementById('project').textContent = report.projectRoot;
1030
+ document.getElementById('mode').textContent = studioMode === 'report' ? 'Report mode' : 'Project mode';
1031
+ renderOverview();
1032
+ renderRoutes();
1033
+ renderDrift();
1034
+ renderFindings();
1035
+ renderRemediation();
1036
+ renderCi();
1037
+ renderPacks();
1038
+ }
1039
+ async function copyText(value, target) {
1040
+ const status = target.parentElement?.querySelector('.copy-status') || target.closest('.command-block, .action-copy, .card, .panel')?.querySelector('.copy-status');
1041
+ const fallbackCopy = () => {
1042
+ const field = document.createElement('textarea');
1043
+ field.value = value;
1044
+ field.setAttribute('readonly', '');
1045
+ field.style.position = 'fixed';
1046
+ field.style.opacity = '0';
1047
+ document.body.appendChild(field);
1048
+ field.select();
1049
+ const copied = document.execCommand('copy');
1050
+ field.remove();
1051
+ return copied;
1052
+ };
1053
+ let copied = false;
1054
+ try {
1055
+ if (navigator.clipboard?.writeText) {
1056
+ await navigator.clipboard.writeText(value);
1057
+ copied = true;
1058
+ } else {
1059
+ copied = fallbackCopy();
1060
+ }
1061
+ } catch {
1062
+ try {
1063
+ copied = fallbackCopy();
1064
+ } catch {
1065
+ copied = false;
1066
+ }
1067
+ }
1068
+ if (status) status.textContent = copied ? 'Copied' : 'Copy failed';
1069
+ }
1070
+ async function load(refresh = false) {
1071
+ const response = await fetch(refresh ? '/api/refresh' : '/api/health', { method: refresh ? 'POST' : 'GET' });
1072
+ if (!response.ok) throw new Error((await response.json()).message || 'Failed to load health report');
1073
+ report = await response.json();
1074
+ render();
1075
+ }
1076
+ tabs.forEach((tab) => tab.addEventListener('click', () => {
1077
+ tabs.forEach((item) => item.setAttribute('aria-selected', String(item === tab)));
1078
+ views.forEach((view) => view.classList.toggle('hidden', view.id !== tab.dataset.tab));
1079
+ }));
1080
+ document.getElementById('refresh').addEventListener('click', () => load(true));
1081
+ document.addEventListener('click', (event) => {
1082
+ const button = event.target.closest('button');
1083
+ if (!button) return;
1084
+ if (button.dataset.copyCommand) {
1085
+ copyText('decantr health --prompt ' + button.dataset.copyCommand, button);
1086
+ } else if (button.dataset.copyPrompt) {
1087
+ const finding = findings().find((item) => item.id === button.dataset.copyPrompt);
1088
+ copyText(finding?.remediation?.prompt || '', button);
1089
+ } else if (button.dataset.copyLiteral) {
1090
+ copyText(button.dataset.copyLiteral, button);
1091
+ } else if (button.dataset.selectFinding) {
1092
+ overviewFindingId = button.dataset.selectFinding;
1093
+ overviewActionMode = 'ai';
1094
+ renderOverview();
1095
+ } else if (button.dataset.actionMode) {
1096
+ overviewActionMode = button.dataset.actionMode;
1097
+ renderOverview();
1098
+ }
1099
+ });
1100
+ load().catch((error) => {
1101
+ document.getElementById('overview').innerHTML = '<div class="card status-error">Failed to load health report: ' + esc(error.message) + '</div>';
1102
+ });
1103
+ </script>
1104
+ </body>
1105
+ </html>`;
1106
+ }
1107
+ function createStudioRequestHandler(projectRoot, options = {}) {
1108
+ const reportPath = resolveReportPath(projectRoot, options.report);
1109
+ const loadReport = () => reportPath ? readProjectHealthReport(reportPath) : createProjectHealthReport(projectRoot);
1110
+ return async function handleStudioRequest(req, res) {
1111
+ const url = new URL(req.url ?? "/", "http://localhost");
1112
+ try {
1113
+ if (req.method === "GET" && url.pathname === "/") {
1114
+ sendHtml(res, studioHtml(Boolean(reportPath)));
1115
+ return;
1116
+ }
1117
+ if (req.method === "GET" && url.pathname === "/api/health") {
1118
+ sendJson(res, 200, await loadReport());
1119
+ return;
1120
+ }
1121
+ if (req.method === "POST" && url.pathname === "/api/refresh") {
1122
+ const startedAt = Date.now();
1123
+ const report = await loadReport();
1124
+ void sendStudioHealthRefreshedTelemetry({
1125
+ durationMs: Date.now() - startedAt,
1126
+ projectRoot,
1127
+ report,
1128
+ trigger: "api-refresh"
1129
+ });
1130
+ sendJson(res, 200, report);
1131
+ return;
1132
+ }
1133
+ sendNotFound(res);
1134
+ } catch (e) {
1135
+ sendJson(res, 500, { error: "health_report_failed", message: e.message });
1136
+ }
1137
+ };
1138
+ }
1139
+ async function startStudioServer(projectRoot = process.cwd(), options = {}) {
1140
+ const host = options.host ?? "127.0.0.1";
1141
+ const port = options.port ?? 4319;
1142
+ const server = createServer(createStudioRequestHandler(projectRoot, options));
1143
+ await new Promise((resolve2, reject) => {
1144
+ server.once("error", reject);
1145
+ server.listen(port, host, () => {
1146
+ server.off("error", reject);
1147
+ resolve2();
1148
+ });
1149
+ });
1150
+ const address = server.address();
1151
+ const actualPort = typeof address === "object" && address ? address.port : port;
1152
+ return { server, url: `http://${host}:${actualPort}` };
1153
+ }
1154
+ async function cmdStudio(projectRoot = process.cwd(), options = {}) {
1155
+ const handle = await startStudioServer(projectRoot, options);
1156
+ const url = new URL(handle.url);
1157
+ void sendStudioStartedTelemetry({
1158
+ host: url.hostname,
1159
+ port: Number.parseInt(url.port, 10),
1160
+ projectRoot
1161
+ });
1162
+ console.log(`${GREEN}Decantr Studio is running.${RESET}`);
1163
+ console.log(`${CYAN}${handle.url}${RESET}`);
1164
+ if (options.report) {
1165
+ console.log("Report mode enabled. Refresh re-reads the local Project Health JSON file.");
1166
+ }
1167
+ console.log("Press Ctrl+C to stop.");
1168
+ }
1169
+ function parseStudioArgs(args) {
1170
+ const options = {};
1171
+ for (let index = 1; index < args.length; index += 1) {
1172
+ const arg = args[index];
1173
+ if (arg === "--host" && args[index + 1]) {
1174
+ options.host = args[++index];
1175
+ } else if (arg.startsWith("--host=")) {
1176
+ options.host = arg.split("=")[1];
1177
+ } else if (arg === "--port" && args[index + 1]) {
1178
+ options.port = Number.parseInt(args[++index], 10);
1179
+ } else if (arg.startsWith("--port=")) {
1180
+ options.port = Number.parseInt(arg.split("=")[1], 10);
1181
+ } else if (arg === "--report") {
1182
+ const value = args[index + 1];
1183
+ if (!value || value.startsWith("--")) throw new Error("Missing --report value.");
1184
+ options.report = value;
1185
+ index += 1;
1186
+ } else if (arg.startsWith("--report=")) {
1187
+ const value = arg.slice("--report=".length);
1188
+ if (!value) throw new Error("Missing --report value.");
1189
+ options.report = value;
1190
+ }
1191
+ }
1192
+ if (options.port !== void 0 && (!Number.isInteger(options.port) || options.port < 0)) {
1193
+ throw new Error("Invalid --port value.");
1194
+ }
1195
+ return options;
1196
+ }
1197
+ export {
1198
+ cmdStudio,
1199
+ createStudioRequestHandler,
1200
+ parseStudioArgs,
1201
+ startStudioServer
1202
+ };