tech-debt-visualizer 0.2.1 → 0.2.3

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
@@ -68,6 +68,8 @@ So: **static metrics + git → score & tier → optional LLM explanations and co
68
68
  | **Global (after publish)** | `npm install -g tech-debt-visualizer` then `tech-debt analyze [path]` |
69
69
  | **No install (after publish)** | `npx tech-debt-visualizer analyze [path]` |
70
70
 
71
+ Use **`tech-debt-visualizer`** in the command (not `tech-debt`); `npx tech-debt` runs a different npm package. From this repo you can also run `npm run analyze` or `node dist/cli.js analyze .`.
72
+
71
73
  Requires **Node 18+**.
72
74
 
73
75
  ---
package/dist/llm.js CHANGED
@@ -70,9 +70,10 @@ function parseFileAssessmentResponse(raw) {
70
70
  }
71
71
  /** Resolve provider and auth from config + env. Explicit baseURL = OpenAI-compatible; else key format or env picks provider. */
72
72
  export function resolveLLMConfig(config = {}) {
73
+ const trim = (s) => (typeof s === "string" ? s.trim() : undefined) || undefined;
73
74
  const explicitBase = (config.baseURL ?? process.env.OPENAI_BASE_URL)?.replace(/\/$/, "");
74
- const cliKey = config.apiKey;
75
- const openaiKey = cliKey ?? process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_API_KEY;
75
+ const cliKey = trim(config.apiKey);
76
+ const openaiKey = trim(cliKey ?? process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_API_KEY);
76
77
  if (explicitBase && openaiKey) {
77
78
  return {
78
79
  provider: "openai",
@@ -99,20 +100,20 @@ export function resolveLLMConfig(config = {}) {
99
100
  };
100
101
  }
101
102
  }
102
- if (cliKey ?? process.env.OPENROUTER_API_KEY) {
103
- const key = cliKey ?? process.env.OPENROUTER_API_KEY;
103
+ const openRouterKey = trim(cliKey ?? process.env.OPENROUTER_API_KEY);
104
+ if (openRouterKey) {
104
105
  return {
105
106
  provider: "openrouter",
106
- apiKey: key,
107
+ apiKey: openRouterKey,
107
108
  baseURL: config.baseURL ?? process.env.OPENROUTER_BASE_URL ?? OPENROUTER_BASE,
108
109
  model: config.model ?? process.env.OPENROUTER_MODEL ?? OPENROUTER_DEFAULT_MODEL,
109
110
  };
110
111
  }
111
- if (cliKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_GENAI_API_KEY) {
112
- const key = cliKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_GENAI_API_KEY;
112
+ const geminiKey = trim(cliKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_GENAI_API_KEY);
113
+ if (geminiKey) {
113
114
  return {
114
115
  provider: "gemini",
115
- apiKey: key,
116
+ apiKey: geminiKey,
116
117
  baseURL: GEMINI_BASE,
117
118
  model: config.model ?? process.env.GEMINI_MODEL ?? GEMINI_DEFAULT_MODEL,
118
119
  };
@@ -1,141 +1,245 @@
1
- /* Technical Debt Report - theme and layout */
1
+ /* Technical Debt Report — Grafana-style dashboard */
2
2
  :root {
3
- --bg: #fff;
4
- --surface: #fff;
5
- --border: #ccc;
6
- --text: #222;
7
- --text-muted: #666;
8
- --link: #0568c2;
3
+ --bg: #0b0c0e;
4
+ --bg-elevated: #111214;
5
+ --surface: #181b1f;
6
+ --surface-hover: #1e2127;
7
+ --border: #2c3239;
8
+ --border-subtle: #252a30;
9
+ --text: #e8e9ea;
10
+ --text-muted: #8b9199;
11
+ --link: #5794f2;
12
+ --radius: 4px;
13
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
9
14
  }
10
15
 
11
- [data-theme="dark"] {
12
- --bg: #1a1a1a;
13
- --surface: #252525;
14
- --border: #404040;
15
- --text: #e0e0e0;
16
- --text-muted: #999;
17
- --link: #6eb3f7;
16
+ [data-theme="light"] {
17
+ --bg: #f4f5f5;
18
+ --bg-elevated: #fff;
19
+ --surface: #fff;
20
+ --surface-hover: #f9fafb;
21
+ --border: #d8d9da;
22
+ --border-subtle: #e8e9ea;
23
+ --text: #1e2022;
24
+ --text-muted: #6b7077;
25
+ --link: #3274d9;
26
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
18
27
  }
19
28
 
20
29
  * { box-sizing: border-box; }
21
30
 
22
31
  body {
23
32
  margin: 0;
24
- font-family: system-ui, sans-serif;
33
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
25
34
  background: var(--bg);
26
35
  color: var(--text);
27
36
  min-height: 100vh;
28
- font-size: 14px;
37
+ font-size: 13px;
29
38
  line-height: 1.5;
39
+ -webkit-font-smoothing: antialiased;
30
40
  }
31
41
 
32
- .container {
33
- max-width: 900px;
34
- margin: 0 auto;
35
- padding: 1.5rem 1.25rem;
42
+ body.dashboard-page {
43
+ padding-bottom: 2rem;
36
44
  }
37
45
 
38
- /* Banner */
39
- .no-llm-banner {
40
- width: 100%;
41
- background: var(--surface);
46
+ /* ——— Dashboard header (Grafana-style top bar) ——— */
47
+ .dashboard-header {
48
+ display: flex;
49
+ align-items: flex-start;
50
+ justify-content: space-between;
51
+ flex-wrap: wrap;
52
+ gap: 1rem;
53
+ padding: 1rem 1.5rem;
54
+ background: var(--bg-elevated);
42
55
  border-bottom: 1px solid var(--border);
43
- padding: 0.5rem 1rem;
44
- text-align: center;
56
+ position: sticky;
57
+ top: 0;
58
+ z-index: 10;
45
59
  }
46
60
 
47
- .no-llm-banner .no-llm-cta {
61
+ .dashboard-header-left {
62
+ display: flex;
63
+ align-items: flex-start;
64
+ gap: 1rem;
65
+ min-width: 0;
66
+ flex: 1;
67
+ }
68
+
69
+ .dashboard-score-badge {
70
+ flex-shrink: 0;
71
+ line-height: 0;
72
+ }
73
+
74
+ .dashboard-score-badge .score-badge-svg {
75
+ width: 56px;
76
+ height: auto;
77
+ display: block;
78
+ }
79
+
80
+ .dashboard-score-badge .score-badge-num { font-size: 22px; }
81
+ .dashboard-score-badge .score-badge-of { font-size: 9px; }
82
+
83
+ .dashboard-hero {
84
+ min-width: 0;
85
+ }
86
+
87
+ .dashboard-title {
88
+ margin: 0 0 0.25rem;
89
+ font-size: 1.125rem;
90
+ font-weight: 600;
91
+ letter-spacing: -0.01em;
92
+ color: var(--text);
93
+ }
94
+
95
+ .dashboard-blurb {
96
+ margin: 0 0 0.35rem;
48
97
  font-size: 14px;
49
- font-weight: normal;
98
+ line-height: 1.45;
50
99
  color: var(--text);
51
- margin: 0;
100
+ max-width: 42em;
52
101
  }
53
102
 
54
- /* Hero / score */
55
- .hero-caption {
103
+ .dashboard-meta {
56
104
  font-size: 12px;
57
105
  color: var(--text-muted);
58
- margin: 0 0 0.5rem;
59
- text-align: center;
60
106
  }
61
107
 
62
- .hero {
63
- text-align: center;
64
- margin-bottom: 1.5rem;
108
+ .dashboard-header-right {
109
+ display: flex;
110
+ flex-direction: column;
111
+ align-items: flex-end;
112
+ gap: 0.25rem;
113
+ flex-shrink: 0;
65
114
  }
66
115
 
67
- .score-badge {
68
- display: inline-block;
69
- margin-bottom: 0.75rem;
70
- line-height: 0;
116
+ .dashboard-stats {
117
+ font-size: 12px;
118
+ color: var(--text-muted);
119
+ white-space: nowrap;
71
120
  }
72
121
 
73
- .score-badge-svg {
74
- display: block;
75
- width: 160px;
76
- height: auto;
77
- max-width: min(160px, 40vw);
122
+ .dashboard-date {
123
+ font-size: 11px;
124
+ color: var(--text-muted);
125
+ opacity: 0.9;
78
126
  }
79
127
 
80
- .score-badge-svg .score-badge-num {
81
- font-family: system-ui, -apple-system, sans-serif;
82
- font-size: 56px;
83
- font-weight: 800;
84
- letter-spacing: -0.02em;
128
+ /* ——— Main content & grid ——— */
129
+ .dashboard-main {
130
+ max-width: 1200px;
131
+ margin: 0 auto;
132
+ padding: 1.25rem 1.5rem;
85
133
  }
86
134
 
87
- .score-badge-svg .score-badge-of {
88
- font-family: system-ui, -apple-system, sans-serif;
89
- font-size: 14px;
90
- font-weight: 700;
91
- letter-spacing: 0.08em;
92
- text-transform: uppercase;
93
- opacity: 0.95;
135
+ .dashboard-grid {
136
+ display: grid;
137
+ gap: 1rem;
138
+ margin-bottom: 1rem;
94
139
  }
95
140
 
96
- .hero .score-label { font-size: 1.1rem; font-weight: bold; color: var(--text); margin: 0 0 0.2rem; }
97
- .hero .score-desc { font-size: 13px; color: var(--text-muted); margin: 0 0 0.35rem; max-width: 360px; margin-left: auto; margin-right: auto; }
98
- .hero .report-meta { font-size: 12px; color: var(--text-muted); }
99
-
100
- .hero.tier-1 { --tier-bg: #c00; --tier-num: #fff; }
101
- .hero.tier-2 { --tier-bg: #e85d00; --tier-num: #fff; }
102
- .hero.tier-3 { --tier-bg: #b8860b; --tier-num: #fff; }
103
- .hero.tier-4 { --tier-bg: #069; --tier-num: #fff; }
104
- .hero.tier-5 { --tier-bg: #0a6b0a; --tier-num: #fff; }
141
+ .dashboard-grid-half {
142
+ grid-template-columns: repeat(2, 1fr);
143
+ }
105
144
 
106
- /* Summary cards */
107
- .summary-cards {
108
- display: grid;
109
- grid-template-columns: repeat(4, 1fr);
110
- gap: 0.75rem;
111
- margin-bottom: 1.5rem;
145
+ @media (max-width: 900px) {
146
+ .dashboard-grid-half { grid-template-columns: 1fr; }
112
147
  }
113
148
 
114
- @media (max-width: 640px) {
115
- .summary-cards { grid-template-columns: repeat(2, 1fr); }
149
+ @media (max-width: 560px) {
150
+ .dashboard-header { padding: 0.75rem 1rem; }
151
+ .dashboard-main { padding: 1rem; }
116
152
  }
117
153
 
118
- .card {
154
+ /* ——— Panels (Grafana-style) ——— */
155
+ .panel {
119
156
  background: var(--surface);
120
157
  border: 1px solid var(--border);
158
+ border-radius: var(--radius);
159
+ box-shadow: var(--shadow);
160
+ overflow: hidden;
161
+ }
162
+
163
+ .panel-header {
121
164
  padding: 0.75rem 1rem;
165
+ border-bottom: 1px solid var(--border-subtle);
166
+ background: rgba(0, 0, 0, 0.03);
167
+ }
168
+
169
+ [data-theme="light"] .panel-header {
170
+ background: rgba(0, 0, 0, 0.02);
171
+ }
172
+
173
+ .panel-title {
174
+ margin: 0;
175
+ font-size: 13px;
176
+ font-weight: 600;
177
+ color: var(--text);
178
+ letter-spacing: -0.01em;
179
+ }
180
+
181
+ .panel-desc {
182
+ margin: 0.25rem 0 0;
183
+ font-size: 12px;
184
+ color: var(--text-muted);
185
+ line-height: 1.4;
186
+ }
187
+
188
+ .panel-body {
189
+ padding: 1rem;
122
190
  }
123
191
 
124
- .card .value { font-size: 1.35rem; font-weight: bold; color: var(--text); }
125
- .card .label { font-size: 12px; color: var(--text-muted); margin-top: 0.15rem; }
192
+ .panel-body-center {
193
+ text-align: center;
194
+ }
195
+
196
+ /* ——— Heatmap panel ——— */
197
+ .panel-heatmap .panel-header-heatmap {
198
+ display: flex;
199
+ flex-wrap: wrap;
200
+ align-items: baseline;
201
+ gap: 0.75rem 1.25rem;
202
+ }
203
+
204
+ .panel-heatmap .panel-title { margin-right: 0.5rem; }
205
+ .panel-heatmap .panel-desc { margin: 0.15rem 0 0; flex-basis: 100%; }
206
+
207
+ .legend-inline {
208
+ margin: 0;
209
+ margin-left: auto;
210
+ }
126
211
 
127
- /* Sections */
128
- .section {
212
+ .panel-body-heatmap {
213
+ padding: 1rem;
214
+ padding-top: 0.5rem;
215
+ }
216
+
217
+ .panel-body-heatmap #treemap {
218
+ border-radius: var(--radius);
219
+ }
220
+
221
+ /* LLM panel accent */
222
+ .panel-llm {
223
+ border-left: 3px solid var(--link);
224
+ }
225
+
226
+ /* ——— Banner ——— */
227
+ .no-llm-banner {
228
+ width: 100%;
129
229
  background: var(--surface);
130
- border: 1px solid var(--border);
131
- padding: 1rem 1.25rem;
132
- margin-bottom: 1rem;
230
+ border-bottom: 1px solid var(--border);
231
+ padding: 0.5rem 1rem;
232
+ text-align: center;
133
233
  }
134
234
 
135
- .section h2 { font-size: 1rem; margin: 0 0 0.75rem; color: var(--text); font-weight: bold; }
136
- .section-desc { font-size: 13px; color: var(--text-muted); margin: -0.35rem 0 0.75rem; line-height: 1.4; }
235
+ .no-llm-banner .no-llm-cta {
236
+ font-size: 13px;
237
+ font-weight: normal;
238
+ color: var(--text-muted);
239
+ margin: 0;
240
+ }
137
241
 
138
- /* Treemap */
242
+ /* ——— Treemap ——— */
139
243
  #treemap {
140
244
  display: flex;
141
245
  flex-wrap: wrap;
@@ -154,10 +258,14 @@ body {
154
258
  overflow: hidden;
155
259
  text-overflow: ellipsis;
156
260
  white-space: nowrap;
157
- border: 1px solid rgba(0,0,0,0.1);
261
+ border: 1px solid rgba(0, 0, 0, 0.15);
262
+ border-radius: 2px;
158
263
  }
159
264
 
160
- .treemap-cell:hover { outline: 2px solid var(--text); outline-offset: 1px; }
265
+ .treemap-cell:hover {
266
+ outline: 2px solid var(--link);
267
+ outline-offset: 1px;
268
+ }
161
269
 
162
270
  .treemap-cell[data-severity="critical"] { background: #c00; color: #fff; border-color: #900; }
163
271
  .treemap-cell[data-severity="high"] { background: #e85d00; color: #fff; border-color: #b84a00; }
@@ -165,37 +273,87 @@ body {
165
273
  .treemap-cell[data-severity="low"] { background: #0a6b0a; color: #fff; border-color: #064906; }
166
274
  .treemap-cell[data-severity="none"] { background: var(--border); color: var(--text-muted); border-color: var(--border); }
167
275
 
168
- /* Legend */
169
- .legend { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; font-size: 12px; color: var(--text-muted); }
170
- .legend span { display: inline-flex; align-items: center; gap: 0.25rem; }
171
- .legend .swatch { width: 10px; height: 10px; }
276
+ .legend {
277
+ display: flex;
278
+ flex-wrap: wrap;
279
+ gap: 1rem;
280
+ align-items: center;
281
+ margin-bottom: 0.75rem;
282
+ font-size: 12px;
283
+ color: var(--text-muted);
284
+ }
285
+
286
+ .legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
287
+ .legend .swatch { width: 10px; height: 10px; border-radius: 2px; }
172
288
  .legend .swatch-crit { background: #c00; }
173
289
  .legend .swatch-high { background: #e85d00; }
174
290
  .legend .swatch-med { background: #b8860b; }
175
291
  .legend .swatch-low { background: #0a6b0a; }
176
292
  .legend .swatch-none { background: var(--border); }
177
293
 
178
- /* Debt list */
179
- .debt-list { list-style: none; padding: 0; margin: 0; }
294
+ /* ——— Priority lists ——— */
295
+ .priority-list {
296
+ list-style: none;
297
+ padding: 0;
298
+ margin: 0;
299
+ font-size: 12px;
300
+ }
301
+
302
+ .priority-list li {
303
+ padding: 0.4rem 0;
304
+ border-bottom: 1px solid var(--border-subtle);
305
+ color: var(--text);
306
+ font-family: ui-monospace, monospace;
307
+ }
308
+
309
+ .priority-list li:last-child { border-bottom: none; }
310
+
311
+ /* ——— Debt list ——— */
312
+ .debt-list {
313
+ list-style: none;
314
+ padding: 0;
315
+ margin: 0;
316
+ }
180
317
 
181
318
  .debt-list li {
182
- border-bottom: 1px solid var(--border);
183
- padding: 0.6rem 0;
319
+ border-bottom: 1px solid var(--border-subtle);
320
+ padding: 0.65rem 0;
184
321
  cursor: pointer;
322
+ transition: background 0.12s ease;
185
323
  }
186
324
 
187
325
  .debt-list li:last-child { border-bottom: none; }
188
- .debt-list li:hover { background: var(--bg); }
189
- .debt-list .title { font-weight: bold; margin-bottom: 0.2rem; }
190
- .debt-list .meta { font-size: 12px; color: var(--text-muted); display: block; margin-top: 0.2rem; }
326
+ .debt-list li:hover { background: var(--surface-hover); }
327
+
328
+ .debt-list .title { font-weight: 600; margin-bottom: 0.2rem; }
329
+ .debt-list .meta { font-size: 12px; color: var(--text-muted); display: block; margin-top: 0.25rem; font-family: ui-monospace, monospace; }
330
+ .debt-list-explanation {
331
+ font-size: 12px;
332
+ color: var(--text-muted);
333
+ margin: 0.35rem 0 0;
334
+ line-height: 1.4;
335
+ display: -webkit-box;
336
+ -webkit-line-clamp: 2;
337
+ -webkit-box-orient: vertical;
338
+ overflow: hidden;
339
+ }
191
340
  .debt-list .insight { font-size: 13px; color: var(--text-muted); margin-top: 0.25rem; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
192
- .debt-list-ratings { display: flex; align-items: center; gap: 1rem; margin: 0.25rem 0; flex-wrap: wrap; }
341
+ .debt-list-ratings { display: flex; align-items: center; gap: 1rem; margin: 0.25rem 0 0; flex-wrap: wrap; }
193
342
  .debt-list-rating { display: inline-flex; align-items: center; gap: 0.35rem; }
194
343
  .debt-list-rating-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); }
195
- .debt-list-llm-none { color: var(--text-muted); font-size: 13px; }
344
+ .debt-list-llm-none { color: var(--text-muted); font-size: 12px; }
196
345
 
197
346
  /* Badges */
198
- .badge { display: inline-block; padding: 0.15em 0.4em; font-size: 11px; font-weight: bold; }
347
+ .badge {
348
+ display: inline-block;
349
+ padding: 0.15em 0.45em;
350
+ font-size: 10px;
351
+ font-weight: 600;
352
+ text-transform: uppercase;
353
+ letter-spacing: 0.03em;
354
+ border-radius: 3px;
355
+ }
356
+
199
357
  .badge-critical { background: #c00; color: #fff; }
200
358
  .badge-high { background: #e85d00; color: #fff; }
201
359
  .badge-medium { background: #b8860b; color: #fff; }
@@ -203,11 +361,11 @@ body {
203
361
  .badge-none { background: var(--border); color: var(--text-muted); }
204
362
  .badge-llm { background: var(--link); color: #fff; }
205
363
 
206
- /* Detail modal */
364
+ /* ——— Detail modal ——— */
207
365
  #detail {
208
366
  position: fixed;
209
367
  inset: 0;
210
- background: rgba(0,0,0,0.5);
368
+ background: rgba(0, 0, 0, 0.55);
211
369
  display: none;
212
370
  align-items: center;
213
371
  justify-content: center;
@@ -220,7 +378,9 @@ body {
220
378
  #detail .panel {
221
379
  background: var(--surface);
222
380
  border: 1px solid var(--border);
223
- max-width: 520px;
381
+ border-radius: var(--radius);
382
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
383
+ max-width: 540px;
224
384
  width: 100%;
225
385
  max-height: 90vh;
226
386
  overflow-y: auto;
@@ -229,7 +389,7 @@ body {
229
389
  -webkit-overflow-scrolling: touch;
230
390
  }
231
391
 
232
- #detail .panel h3 { margin: 0 0 0.35rem; font-size: 1rem; }
392
+ #detail .panel h3 { margin: 0 0 0.35rem; font-size: 14px; font-weight: 600; }
233
393
  #detail .panel .file { font-family: ui-monospace, monospace; font-size: 13px; color: var(--link); }
234
394
  #detail .panel .close-hint { margin-top: 0.75rem; font-size: 12px; color: var(--text-muted); }
235
395
 
@@ -250,6 +410,10 @@ body {
250
410
  #detail .panel .detail-explanation .detail-llm-label { margin-top: 0.5rem; margin-bottom: 0.25rem; }
251
411
  #detail .panel .detail-explanation .detail-no-llm { font-style: italic; color: var(--text-muted); }
252
412
  #detail .panel .detail-explanation .detail-static-desc { margin-top: 0.5rem; font-size: 12px; color: var(--text-muted); }
413
+ #detail .panel .detail-explanation .detail-issues-list { margin: 0.25rem 0 0 0; padding-left: 1.25rem; list-style: none; }
414
+ #detail .panel .detail-explanation .detail-issues-list > li { margin-bottom: 0.35rem; }
415
+ #detail .panel .detail-explanation .detail-issues-list .detail-issue-desc { margin: 0.15rem 0 0 1rem; font-size: 11px; opacity: 0.9; }
416
+ #detail .panel .detail-explanation .detail-issues-list .detail-line { color: var(--text-muted); font-size: 11px; }
253
417
 
254
418
  #detail .panel .file-assessment {
255
419
  margin-top: 0.75rem;
@@ -265,14 +429,14 @@ body {
265
429
  #detail .panel .file-assessment strong { color: var(--text); font-size: 12px; }
266
430
  #detail .panel .suggested-code { margin-top: 0.75rem; }
267
431
 
268
- /* Code blocks - shared */
432
+ /* ——— Code blocks ——— */
269
433
  .code-block {
270
434
  margin: 0.5rem 0;
271
435
  padding: 0.75rem 1rem;
272
436
  background: var(--bg);
273
437
  border: 1px solid var(--border);
274
- border-radius: 6px;
275
- font-size: 13px;
438
+ border-radius: var(--radius);
439
+ font-size: 12px;
276
440
  overflow-x: auto;
277
441
  overflow-y: auto;
278
442
  max-height: 20em;
@@ -280,43 +444,36 @@ body {
280
444
 
281
445
  .code-block .lang-label {
282
446
  display: block;
283
- font-size: 11px;
447
+ font-size: 10px;
284
448
  font-family: system-ui, sans-serif;
285
449
  color: var(--text-muted);
286
450
  margin-bottom: 0.5rem;
287
451
  text-transform: uppercase;
288
- letter-spacing: 0.03em;
452
+ letter-spacing: 0.04em;
289
453
  }
290
454
 
291
455
  .code-block pre { margin: 0; white-space: pre; overflow-x: auto; min-width: min-content; }
292
456
  .code-block code {
293
- font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
457
+ font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
294
458
  font-size: 12px;
295
459
  line-height: 1.5;
296
460
  color: var(--text);
297
461
  display: block;
298
462
  }
299
463
 
300
- #detail .panel .code-block {
301
- max-height: 16em;
302
- }
303
-
464
+ #detail .panel .code-block { max-height: 16em; }
304
465
  #detail .panel .suggested-code .code-block { margin: 0.35rem 0 0; background: var(--surface); }
305
466
  #detail .panel .file-assessment .code-block { margin: 0.5rem 0 0; background: var(--surface); }
306
467
 
307
- /* LLM sections */
308
- .llm-overall { border-left: 3px solid var(--link); padding-left: 0.5rem; margin-left: -0.5rem; }
309
- .llm-overall h2 { color: var(--text); font-size: 1rem; }
310
- .llm-overall .llm-overall-text { margin: 0; color: var(--text); line-height: 1.5; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
468
+ /* ——— LLM output ——— */
469
+ .llm-output .llm-prose {
470
+ margin: 0 0 0.75rem;
471
+ line-height: 1.55;
472
+ white-space: pre-wrap;
473
+ word-break: break-word;
474
+ color: var(--text);
475
+ }
311
476
 
312
- .llm-output .llm-prose { margin: 0 0 0.75rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
313
477
  .llm-output .llm-prose:last-child { margin-bottom: 0; }
314
478
 
315
- .llm-next-steps h2 { color: var(--text); font-size: 1rem; }
316
- .llm-next-steps ul { margin: 0; padding-left: 1.25rem; color: var(--text); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
317
-
318
- /* Priority matrix */
319
- .priority-matrix { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 0.75rem; }
320
- .priority-matrix .quadrant { padding: 0.75rem; border: 1px solid var(--border); }
321
- .priority-matrix .quadrant h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: bold; }
322
- .priority-matrix .quadrant p { margin: 0; font-size: 13px; color: var(--text-muted); }
479
+ .panel-llm .panel-body .llm-output { margin: 0; }
@@ -166,58 +166,128 @@ document.getElementById("q2").innerHTML = highImpact
166
166
  .map(function (d) { return '<li style="font-size:0.8rem">' + escapeHtml(d.file) + "</li>"; })
167
167
  .join("");
168
168
 
169
- // Debt list: static and LLM ratings side by side (LLM = file's rating, one per file)
170
- var sev = { critical: 4, high: 3, medium: 2, low: 1 };
169
+ // Debt list: every file rated above none by static OR LLM; show both ratings and short explanation
170
+ var sev = { critical: 4, high: 3, medium: 2, low: 1, none: 0 };
171
171
  var list = document.getElementById("debtList");
172
- DATA.debtItems.sort(function (a, b) {
173
- return (sev[b.severity] || 0) - (sev[a.severity] || 0);
174
- }).forEach(function (d) {
175
- var li = document.createElement("li");
176
- var fileM = DATA.fileMetrics.find(function (m) { return m.file === d.file; });
177
- var fileLlmSeverity = fileM && fileM.llmSeverity ? fileM.llmSeverity : null;
178
- var staticBadge = '<span class="badge badge-' + d.severity + '" title="Static analysis">' + d.severity + "</span>";
179
- var llmRating = fileLlmSeverity
180
- ? '<span class="badge badge-' + fileLlmSeverity + '" title="LLM rating for this file">' + fileLlmSeverity + "</span>"
172
+
173
+ function severityNum(s) { return sev[s] || 0; }
174
+ function fileWorstStatic(items) {
175
+ if (!items || !items.length) return 0;
176
+ return Math.max.apply(null, items.map(function (d) { return severityNum(d.severity); }));
177
+ }
178
+ function fileWorstLlm(metric) {
179
+ if (!metric || !metric.llmSeverity || metric.llmSeverity === "none") return 0;
180
+ return severityNum(metric.llmSeverity);
181
+ }
182
+
183
+ var filesFromStatic = new Set(debtByFile.keys());
184
+ var filesFromLlm = new Set();
185
+ DATA.fileMetrics.forEach(function (m) {
186
+ if (m.llmSeverity && m.llmSeverity !== "none") filesFromLlm.add(m.file);
187
+ });
188
+ var fileSet = new Set([].concat(Array.from(filesFromStatic), Array.from(filesFromLlm)));
189
+
190
+ var filesWithDebt = Array.from(fileSet).sort(function (fa, fb) {
191
+ var itemsA = debtByFile.get(fa) || [];
192
+ var itemsB = debtByFile.get(fb) || [];
193
+ var metricA = DATA.fileMetrics.find(function (m) { return m.file === fa; });
194
+ var metricB = DATA.fileMetrics.find(function (m) { return m.file === fb; });
195
+ var worstA = Math.max(fileWorstStatic(itemsA), fileWorstLlm(metricA));
196
+ var worstB = Math.max(fileWorstStatic(itemsB), fileWorstLlm(metricB));
197
+ if (worstB !== worstA) return worstB - worstA;
198
+ return fa.localeCompare(fb);
199
+ });
200
+
201
+ function firstLine(text) {
202
+ if (!text || !String(text).trim()) return "";
203
+ return String(text).trim().split(/\n/)[0].trim().slice(0, 120);
204
+ }
205
+
206
+ filesWithDebt.forEach(function (file) {
207
+ var items = debtByFile.get(file) || [];
208
+ var fileM = DATA.fileMetrics.find(function (m) { return m.file === file; });
209
+ var worstSeverityVal = items.length ? items.reduce(function (best, d) {
210
+ return severityNum(d.severity) > severityNum(best.severity) ? d : best;
211
+ }, items[0]) : null;
212
+ var staticSeverity = worstSeverityVal ? worstSeverityVal.severity : null;
213
+ var fileLlmSeverity = fileM && fileM.llmSeverity && fileM.llmSeverity !== "none" ? fileM.llmSeverity : null;
214
+
215
+ var staticBadge = staticSeverity
216
+ ? '<span class="badge badge-' + staticSeverity + '" title="Static (worst of ' + items.length + ')">' + staticSeverity + "</span>"
181
217
  : '<span class="debt-list-llm-none">—</span>';
218
+ var llmBadge = fileLlmSeverity
219
+ ? '<span class="badge badge-' + fileLlmSeverity + '" title="LLM">' + fileLlmSeverity + "</span>"
220
+ : '<span class="debt-list-llm-none">—</span>';
221
+
222
+ var explanation = "";
223
+ if (items.length && worstSeverityVal) {
224
+ explanation = escapeHtml(firstLine(worstSeverityVal.title || worstSeverityVal.description || ""));
225
+ if (items.length > 1) explanation += " (+" + (items.length - 1) + " more)";
226
+ }
227
+ if (fileM && (fileM.llmAssessment || fileM.llmRawAssessment)) {
228
+ var llmBlurb = firstLine(stripTrailingSeverityAndScore(fileM.llmRawAssessment || fileM.llmAssessment || ""));
229
+ if (llmBlurb) explanation = explanation ? explanation + " · " + escapeHtml(llmBlurb) : escapeHtml(llmBlurb);
230
+ }
231
+ if (!explanation) explanation = "Rated by static or LLM.";
232
+
182
233
  var ratingsRow =
183
234
  '<div class="debt-list-ratings">' +
184
235
  '<span class="debt-list-rating"><span class="debt-list-rating-label">Static</span> ' + staticBadge + "</span>" +
185
- '<span class="debt-list-rating"><span class="debt-list-rating-label">LLM</span> ' + llmRating + "</span>" +
236
+ '<span class="debt-list-rating"><span class="debt-list-rating-label">LLM</span> ' + llmBadge + "</span>" +
186
237
  "</div>";
238
+ var li = document.createElement("li");
187
239
  li.innerHTML =
188
- '<span class="title">' +
189
- escapeHtml(d.title) +
190
- "</span> " +
240
+ '<span class="title">' + escapeHtml(file.split("/").pop() || file) + "</span> " +
191
241
  ratingsRow +
192
- '<span class="meta">' +
193
- escapeHtml(d.file) +
194
- (d.line ? ":" + d.line : "") +
195
- "</span>";
196
- li.addEventListener("click", function () { showDetail(d.file, [d]); });
242
+ '<span class="meta">' + escapeHtml(file) + "</span>" +
243
+ '<p class="debt-list-explanation">' + explanation + "</p>";
244
+ li.addEventListener("click", function () { showDetail(file, items); });
197
245
  list.appendChild(li);
198
246
  });
199
247
 
200
248
  function showDetail(file, items) {
201
249
  var panel = document.getElementById("detail");
202
- var item = items.length ? items[0] : null;
203
250
  var fileMetric = DATA.fileMetrics.find(function (m) { return m.file === file; });
251
+ var worstItem = items.length ? items.reduce(function (best, d) {
252
+ return severityNum(d.severity) > severityNum(best.severity) ? d : best;
253
+ }, items[0]) : null;
254
+ var staticSeverity = worstItem ? worstItem.severity : null;
204
255
 
205
- document.getElementById("detailTitle").textContent = item ? item.title : "No debt items";
256
+ var titleText = items.length === 1
257
+ ? (items[0].title || "Debt item")
258
+ : items.length > 1
259
+ ? items.length + " static issues"
260
+ : (fileMetric && fileMetric.llmSeverity ? "LLM assessment" : "File details");
261
+ document.getElementById("detailTitle").textContent = titleText;
206
262
  document.getElementById("detailFile").textContent = file;
207
263
 
208
264
  var explanationEl = document.getElementById("detailExplanation");
209
265
  var parts = [];
210
- var staticSev = item ? item.severity : "—";
211
- var llmSev = fileMetric && fileMetric.llmSeverity ? fileMetric.llmSeverity : "—";
212
266
  parts.push(
213
267
  '<div class="detail-severities">' +
214
- '<span class="detail-sev"><strong>Static</strong> <span class="badge badge-' + (item ? item.severity : "low") + '">' + staticSev + "</span></span> " +
268
+ '<span class="detail-sev"><strong>Static</strong> ' +
269
+ (staticSeverity ? '<span class="badge badge-' + staticSeverity + '">' + staticSeverity + "</span> (worst of " + items.length + ")" : "<span class=\"debt-list-llm-none\">\u2014</span>") +
270
+ "</span> " +
215
271
  '<span class="detail-sev"><strong>LLM</strong> ' +
216
- (fileMetric && fileMetric.llmSeverity ? '<span class="badge badge-' + fileMetric.llmSeverity + '">' + fileMetric.llmSeverity + "</span>" : "<span class=\"debt-list-llm-none\">—</span>") +
272
+ (fileMetric && fileMetric.llmSeverity && fileMetric.llmSeverity !== "none" ? '<span class="badge badge-' + fileMetric.llmSeverity + '">' + fileMetric.llmSeverity + "</span>" : "<span class=\"debt-list-llm-none\">\u2014</span>") +
217
273
  "</span></div>"
218
274
  );
219
- if (item && item.description)
220
- parts.push('<div class="detail-static-desc"><strong>Static description</strong><br>' + escapeHtml(item.description).replace(/\n/g, "<br>") + "</div>");
275
+ parts.push('<div class="detail-static-desc"><strong>Static issues</strong><ul class="detail-issues-list">');
276
+ if (items.length) {
277
+ items.forEach(function (item) {
278
+ parts.push(
279
+ '<li><span class="badge badge-' + item.severity + '">' + item.severity + "</span> " +
280
+ escapeHtml(item.title || "Issue") +
281
+ (item.line ? " <span class=\"detail-line\">line " + item.line + "</span>" : "")
282
+ );
283
+ if (item.description)
284
+ parts.push('<div class="detail-issue-desc">' + escapeHtml(item.description).replace(/\n/g, "<br>") + "</div>");
285
+ parts.push("</li>");
286
+ });
287
+ } else {
288
+ parts.push("<li class=\"detail-no-llm\">No static issues for this file.</li>");
289
+ }
290
+ parts.push("</ul></div>");
221
291
  parts.push('<div class="detail-llm-label"><strong>LLM assessment</strong></div>');
222
292
  if (fileMetric && (fileMetric.llmRawAssessment || fileMetric.llmAssessment)) {
223
293
  if (fileMetric.llmRawAssessment)
@@ -78,18 +78,22 @@ function buildHtml(run, title, darkMode, css, script) {
78
78
  };
79
79
  const tierColor = tierColors[cleanliness.tier] ?? "#666";
80
80
  const scoreBadgeSvg = buildScoreBadgeSvg(cleanliness.tier, tierColor);
81
- const llmOverallSection = run.llmOverallAssessment || run.llmOverallRaw
81
+ const llmPanelHtml = run.llmOverallAssessment || run.llmOverallRaw
82
82
  ? `
83
- <div class="section llm-overall">
84
- <h2>LLM overall assessment</h2>
85
- <div class="llm-output">${run.llmOverallAssessment
83
+ <div class="panel panel-llm">
84
+ <div class="panel-header">
85
+ <h2 class="panel-title">LLM overall assessment</h2>
86
+ </div>
87
+ <div class="panel-body">
88
+ <div class="llm-output">${run.llmOverallAssessment
86
89
  ? renderLlmOutputToHtml(run.llmOverallAssessment)
87
90
  : '<div class="llm-prose">' +
88
91
  escapeHtml(stripTrailingSeverityAndScore(run.llmOverallRaw ?? "")).replace(/\n/g, "<br>") +
89
92
  "</div>"}</div>
90
- </div>
91
- `
93
+ </div>
94
+ </div>`
92
95
  : "";
96
+ const statsLine = `${run.fileMetrics.length} files · ${run.debtItems.length} items · ${highCriticalCount} high/crit · ${hotspotCount} hotspots`;
93
97
  return `<!DOCTYPE html>
94
98
  <html lang="en" data-theme="${theme}">
95
99
  <head>
@@ -104,54 +108,74 @@ function buildHtml(run, title, darkMode, css, script) {
104
108
  <meta name="twitter:description" content="${escapeHtml(cleanliness.label)}: ${escapeHtml(cleanliness.description)}" />
105
109
  <style>${css}</style>
106
110
  </head>
107
- <body>
111
+ <body class="dashboard-page">
108
112
  ${!hasLlm ? `<div class="no-llm-banner"><p class="no-llm-cta">Analysis run without LLM — for full results, run with LLM</p></div>` : ""}
109
- <div class="container">
110
- <p class="hero-caption">Technical Debt Cleanliness Score</p>
111
- <div class="hero tier-${cleanliness.tier}">
112
- <div class="score-badge" aria-label="Score ${cleanliness.tier} of 5">${scoreBadgeSvg}</div>
113
- <div class="score-label">${escapeHtml(cleanliness.label)}</div>
114
- <p class="score-desc">${escapeHtml(cleanliness.description)}</p>
115
- <p class="report-meta">${escapeHtml(run.repoPath)} · ${run.completedAt ?? run.startedAt}</p>
113
+ <header class="dashboard-header">
114
+ <div class="dashboard-header-left">
115
+ <div class="dashboard-score-badge tier-${cleanliness.tier}" aria-label="Score ${cleanliness.tier} of 5">${scoreBadgeSvg}</div>
116
+ <div class="dashboard-hero">
117
+ <h1 class="dashboard-title">${escapeHtml(title)}</h1>
118
+ <p class="dashboard-blurb">${escapeHtml(cleanliness.description)}</p>
119
+ <span class="dashboard-meta">${escapeHtml(run.repoPath)}</span>
120
+ </div>
116
121
  </div>
117
-
118
- <div class="summary-cards">
119
- <div class="card"><div class="value">${run.fileMetrics.length}</div><div class="label">Files</div></div>
120
- <div class="card"><div class="value">${run.debtItems.length}</div><div class="label">Debt items</div></div>
121
- <div class="card"><div class="value">${highCriticalCount}</div><div class="label">High / Critical</div></div>
122
- <div class="card"><div class="value">${hotspotCount}</div><div class="label">Hotspots</div></div>
122
+ <div class="dashboard-header-right">
123
+ <span class="dashboard-stats">${statsLine}</span>
124
+ <span class="dashboard-date">${run.completedAt ?? run.startedAt}</span>
123
125
  </div>
126
+ </header>
124
127
 
125
- ${llmOverallSection}
128
+ <main class="dashboard-main">
129
+ ${llmPanelHtml}
126
130
 
127
- <div class="section">
128
- <h2>Files by debt</h2>
129
- <p class="section-desc">Size = complexity + churn. Color = LLM severity per file (when LLM used). Click for details.</p>
130
- <div class="legend">
131
- <span><span class="swatch swatch-crit"></span> Critical</span>
132
- <span><span class="swatch swatch-high"></span> High</span>
133
- <span><span class="swatch swatch-med"></span> Medium</span>
134
- <span><span class="swatch swatch-low"></span> Low</span>
135
- <span><span class="swatch swatch-none"></span> No debt</span>
131
+ <div class="panel panel-heatmap">
132
+ <div class="panel-header panel-header-heatmap">
133
+ <h2 class="panel-title">Files by debt</h2>
134
+ <p class="panel-desc">Size = complexity + churn. Color = severity (static or LLM). Click for details.</p>
135
+ <div class="legend legend-inline">
136
+ <span><span class="swatch swatch-crit"></span> Critical</span>
137
+ <span><span class="swatch swatch-high"></span> High</span>
138
+ <span><span class="swatch swatch-med"></span> Medium</span>
139
+ <span><span class="swatch swatch-low"></span> Low</span>
140
+ <span><span class="swatch swatch-none"></span> None</span>
141
+ </div>
142
+ </div>
143
+ <div class="panel-body panel-body-heatmap">
144
+ <div id="treemap"></div>
136
145
  </div>
137
- <div id="treemap"></div>
138
146
  </div>
139
147
 
140
- <div class="section">
141
- <h2>Prioritized recommendations</h2>
142
- <p class="section-desc">Focus on high-impact items first.</p>
143
- <div class="priority-matrix">
144
- <div class="quadrant"><h4>High impact, easier</h4><p>High severity in smaller files.</p><ul id="q1"></ul></div>
145
- <div class="quadrant"><h4>High impact, harder</h4><p>Critical or hotspot files.</p><ul id="q2"></ul></div>
148
+ <div class="dashboard-grid dashboard-grid-half">
149
+ <div class="panel">
150
+ <div class="panel-header">
151
+ <h2 class="panel-title">High impact, easier</h2>
152
+ <p class="panel-desc">High severity in smaller files.</p>
153
+ </div>
154
+ <div class="panel-body">
155
+ <ul id="q1" class="priority-list"></ul>
156
+ </div>
157
+ </div>
158
+ <div class="panel">
159
+ <div class="panel-header">
160
+ <h2 class="panel-title">High impact, harder</h2>
161
+ <p class="panel-desc">Critical or hotspot files.</p>
162
+ </div>
163
+ <div class="panel-body">
164
+ <ul id="q2" class="priority-list"></ul>
165
+ </div>
146
166
  </div>
147
167
  </div>
148
168
 
149
- <div class="section">
150
- <h2>All debt items</h2>
151
- <p class="section-desc">Static (analysis) and LLM ratings shown side by side. Click a row for details.</p>
152
- <ul class="debt-list" id="debtList"></ul>
169
+ <div class="panel">
170
+ <div class="panel-header">
171
+ <h2 class="panel-title">Files with debt (static or LLM)</h2>
172
+ <p class="panel-desc">Every file rated above none by static analysis or LLM. Click a row for full ratings and explanations.</p>
173
+ </div>
174
+ <div class="panel-body">
175
+ <ul class="debt-list" id="debtList"></ul>
176
+ </div>
153
177
  </div>
154
- </div>
178
+ </main>
155
179
 
156
180
  <div id="detail">
157
181
  <div class="panel">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tech-debt-visualizer",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Language-agnostic CLI that analyzes repos and generates interactive technical debt visualizations with AI-powered insights",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",