tech-debt-visualizer 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/llm.js CHANGED
@@ -1,12 +1,23 @@
1
1
  /**
2
2
  * LLM integration: debt explanations, per-file cleanliness, and overall assessment.
3
3
  * Supports OpenAI, OpenRouter (OpenAI-compatible), and Google Gemini.
4
+ *
5
+ * No time limits: requests run until the API returns. Truncation is only from token limits.
6
+ * Override with LLMConfig.maxTokens or --llm-max-tokens. Defaults are generous to avoid cut-off:
7
+ * - Debt item insights (explainDebtItem): config.maxTokens ?? DEFAULT_MAX_TOKENS (2048)
8
+ * - Per-file assessment (assessFileCleanliness): config.maxTokens ?? DEFAULT_MAX_TOKENS_FILE (8192)
9
+ * - Overall assessment (assessOverallCleanliness): config.maxTokens ?? DEFAULT_MAX_TOKENS_OVERALL (8192)
10
+ * - enrichDebtWithInsights: passes config.maxTokens ?? DEFAULT_MAX_TOKENS to each item
4
11
  */
5
12
  const OPENAI_DEFAULT_MODEL = "gpt-4o-mini";
6
- const OPENROUTER_DEFAULT_MODEL = "google/gemini-2.0-flash-001";
7
- const GEMINI_DEFAULT_MODEL = "gemini-1.5-flash";
8
- const DEFAULT_MAX_TOKENS = 300;
9
- const DEFAULT_MAX_TOKENS_OVERALL = 500;
13
+ const OPENROUTER_DEFAULT_MODEL = "google/gemini-2.5-flash";
14
+ const GEMINI_DEFAULT_MODEL = "gemini-2.5-flash";
15
+ /** Default for enrichDebtWithInsights (debt item insights). Override with config.maxTokens. */
16
+ const DEFAULT_MAX_TOKENS = 2048;
17
+ /** Default for assessFileCleanliness. */
18
+ const DEFAULT_MAX_TOKENS_FILE = 8192;
19
+ /** Default for assessOverallCleanliness. Override with config.maxTokens. */
20
+ const DEFAULT_MAX_TOKENS_OVERALL = 8192;
10
21
  const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
11
22
  const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta";
12
23
  /** Extract first markdown code block and the prose before it. */
@@ -18,11 +29,70 @@ function parseCodeBlockAndProse(response) {
18
29
  const code = match[2]?.trim();
19
30
  return { prose, code: code || undefined };
20
31
  }
21
- /** Resolve provider and auth from config + env. OpenRouter and Gemini take precedence when their keys are set. */
32
+ /** Parse trailing "severity" line and "score" line from LLM text; return assessment + optional score/severity. */
33
+ function parseSeverityAndScore(lines) {
34
+ let assessment = lines.join("\n").trim();
35
+ let score;
36
+ let severity;
37
+ if (lines.length > 0) {
38
+ const lastLine = lines[lines.length - 1];
39
+ const scoreMatch = lastLine.match(/^\s*(\d{1,3})\s*$/);
40
+ if (scoreMatch) {
41
+ score = Math.min(100, Math.max(0, parseInt(scoreMatch[1], 10)));
42
+ const rest = lines.length > 1 ? lines.slice(0, -1) : [];
43
+ if (rest.length > 0) {
44
+ const severityLine = rest[rest.length - 1];
45
+ const sevMatch = severityLine.match(/^\s*(critical|high|medium|low|none)\s*$/i);
46
+ if (sevMatch) {
47
+ severity = sevMatch[1].toLowerCase();
48
+ rest.pop();
49
+ }
50
+ assessment = rest.join("\n").trim();
51
+ }
52
+ else {
53
+ assessment = "";
54
+ }
55
+ }
56
+ }
57
+ return { assessment: assessment || lines.join("\n").trim(), score, severity };
58
+ }
59
+ /** Parse per-file assessment: prose, fileScore 0–100, severity (critical|high|medium|low|none), optional code block. */
60
+ function parseFileAssessmentResponse(raw) {
61
+ const { prose, code } = parseCodeBlockAndProse(raw);
62
+ const lines = prose.split(/\n/).map((l) => l.trim()).filter(Boolean);
63
+ const parsed = parseSeverityAndScore(lines);
64
+ return {
65
+ assessment: parsed.assessment || prose,
66
+ fileScore: parsed.score,
67
+ severity: parsed.severity,
68
+ code: code || undefined,
69
+ };
70
+ }
71
+ /** Resolve provider and auth from config + env. When --llm-key is used, provider is inferred from key format so a Gemini key is not sent to OpenRouter. */
22
72
  export function resolveLLMConfig(config = {}) {
23
- const openRouterKey = config.apiKey ?? process.env.OPENROUTER_API_KEY;
24
- const geminiKey = config.apiKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_GENAI_API_KEY;
25
- const openaiKey = config.apiKey ?? process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_API_KEY;
73
+ const cliKey = config.apiKey;
74
+ const openRouterKey = cliKey ?? process.env.OPENROUTER_API_KEY;
75
+ const geminiKey = cliKey ?? process.env.GEMINI_API_KEY ?? process.env.GOOGLE_GENAI_API_KEY;
76
+ const openaiKey = cliKey ?? process.env.OPENAI_API_KEY ?? process.env.ANTHROPIC_API_KEY;
77
+ // When a single key is passed (e.g. --llm-key), pick provider by key format so we don't send a Gemini key to OpenRouter (401 "No cookie auth").
78
+ if (cliKey) {
79
+ if (cliKey.startsWith("AIza")) {
80
+ return {
81
+ provider: "gemini",
82
+ apiKey: cliKey,
83
+ baseURL: GEMINI_BASE,
84
+ model: config.model ?? process.env.GEMINI_MODEL ?? GEMINI_DEFAULT_MODEL,
85
+ };
86
+ }
87
+ if (cliKey.startsWith("sk-")) {
88
+ return {
89
+ provider: "openai",
90
+ apiKey: cliKey,
91
+ baseURL: config.baseURL ?? process.env.OPENAI_BASE_URL ?? "",
92
+ model: config.model ?? process.env.OPENAI_MODEL ?? OPENAI_DEFAULT_MODEL,
93
+ };
94
+ }
95
+ }
26
96
  if (openRouterKey) {
27
97
  return {
28
98
  provider: "openrouter",
@@ -73,13 +143,17 @@ async function openAICompatibleCompletion(prompt, opts) {
73
143
  max_tokens: opts.maxTokens,
74
144
  }),
75
145
  });
76
- if (!res.ok)
146
+ const bodyText = await res.text();
147
+ if (!res.ok) {
148
+ process.stderr.write(`[LLM] OpenAI-compatible API error ${res.status}: ${bodyText.slice(0, 200)}${bodyText.length > 200 ? "..." : ""}\n`);
77
149
  return null;
78
- const data = (await res.json());
150
+ }
151
+ const data = JSON.parse(bodyText);
79
152
  const text = data.choices?.[0]?.message?.content?.trim();
80
153
  return text || null;
81
154
  }
82
- catch {
155
+ catch (e) {
156
+ process.stderr.write(`[LLM] Request failed: ${e instanceof Error ? e.message : String(e)}\n`);
83
157
  return null;
84
158
  }
85
159
  }
@@ -98,17 +172,22 @@ async function geminiCompletion(prompt, opts) {
98
172
  },
99
173
  }),
100
174
  });
101
- if (!res.ok)
175
+ const bodyText = await res.text();
176
+ if (!res.ok) {
177
+ process.stderr.write(`[LLM] Gemini API error ${res.status}: ${bodyText.slice(0, 300)}${bodyText.length > 300 ? "..." : ""}\n`);
102
178
  return null;
103
- const data = (await res.json());
179
+ }
180
+ const data = JSON.parse(bodyText);
104
181
  const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
105
182
  return text || null;
106
183
  }
107
- catch {
184
+ catch (e) {
185
+ process.stderr.write(`[LLM] Gemini request failed: ${e instanceof Error ? e.message : String(e)}\n`);
108
186
  return null;
109
187
  }
110
188
  }
111
- export async function enrichDebtWithInsights(items, fileContents, config = {}) {
189
+ /** Optional progress callback: (completedBatches, totalBatches) after each batch. */
190
+ export async function enrichDebtWithInsights(items, fileContents, config = {}, onProgress) {
112
191
  const resolved = resolveLLMConfig(config);
113
192
  if (!resolved)
114
193
  return items;
@@ -116,6 +195,7 @@ export async function enrichDebtWithInsights(items, fileContents, config = {}) {
116
195
  const maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
117
196
  const enriched = [];
118
197
  const batchSize = 5;
198
+ const totalBatches = Math.ceil(items.length / batchSize);
119
199
  for (let i = 0; i < items.length; i += batchSize) {
120
200
  const batch = items.slice(i, i + batchSize);
121
201
  const results = await Promise.allSettled(batch.map((item) => explainDebtItem(item, fileContents.get(item.file) ?? "", {
@@ -134,12 +214,15 @@ export async function enrichDebtWithInsights(items, fileContents, config = {}) {
134
214
  ...item,
135
215
  insight: v.insight,
136
216
  suggestedCode: v.suggestedCode,
217
+ llmSeverity: v.severity,
218
+ llmRawResponse: v.raw,
137
219
  });
138
220
  }
139
221
  else {
140
222
  enriched.push(item);
141
223
  }
142
224
  }
225
+ onProgress?.(Math.floor((i + batchSize) / batchSize), totalBatches);
143
226
  }
144
227
  return enriched;
145
228
  }
@@ -147,14 +230,8 @@ async function explainDebtItem(item, fileContent, opts) {
147
230
  const snippet = item.line
148
231
  ? fileContent.split("\n").slice(Math.max(0, item.line - 3), (item.endLine ?? item.line) + 2).join("\n")
149
232
  : fileContent.slice(0, 1500);
150
- const prompt = `You are a senior engineer reviewing technical debt. For this item:
151
-
152
- 1) In 1-2 sentences: why it matters for maintainability or risk, and what to do.
153
- 2) If a concrete code simplification or refactor is possible (e.g. reduce branching, extract function, simplify condition), provide ONLY the refactored/simplified code in a markdown code block. Use the same language as the snippet. If no code change is needed or the fix is trivial (e.g. "add a comment"), omit the code block.
154
-
155
- Debt: ${item.title}
156
- Category: ${item.category}
157
- Description: ${item.description}
233
+ const prompt = `Technical debt item: ${item.title} (${item.category})
234
+ ${item.description}
158
235
  ${item.metrics ? `Metrics: ${JSON.stringify(item.metrics)}` : ""}
159
236
 
160
237
  Relevant code:
@@ -162,47 +239,68 @@ Relevant code:
162
239
  ${snippet}
163
240
  \`\`\`
164
241
 
165
- Reply format: Short explanation first, then optionally a code block with the suggested refactor. No other preamble.`;
166
- const raw = await chat(prompt, { ...opts, maxTokens: 500 });
242
+ Output only:
243
+ 1. A two- to four-sentence summary of the issues: why it matters and what to do. No code block unless absolutely necessary to demonstrate.
244
+ 2. On the next line write only one word: critical, high, medium, low, or none (how severe this debt item is; none = not significant).
245
+ No preamble.`;
246
+ const raw = await chat(prompt, opts);
167
247
  if (!raw)
168
248
  return null;
169
249
  const { prose, code } = parseCodeBlockAndProse(raw);
170
- return { insight: prose || item.description, suggestedCode: code };
250
+ const lines = prose.split(/\n/).map((l) => l.trim()).filter(Boolean);
251
+ let severity;
252
+ let assessment = prose;
253
+ if (lines.length > 0) {
254
+ const lastLine = lines[lines.length - 1];
255
+ const sevMatch = lastLine.match(/^\s*(critical|high|medium|low|none)\s*$/i);
256
+ if (sevMatch) {
257
+ severity = sevMatch[1].toLowerCase();
258
+ const rest = lines.slice(0, -1).join("\n").trim();
259
+ assessment = rest || prose;
260
+ }
261
+ }
262
+ return {
263
+ insight: assessment || item.description,
264
+ suggestedCode: code,
265
+ severity,
266
+ raw,
267
+ };
171
268
  }
172
- /** Per-file: LLM assesses cleanliness and suggests optimizations with cross-file context. */
269
+ /** Per-file: LLM gives a short summary, a 0–100 debt score, and optionally one refactor. One request per file; call in parallel from CLI. */
173
270
  export async function assessFileCleanliness(filePath, content, metrics, config = {}, repoContext) {
174
271
  const resolved = resolveLLMConfig(config);
175
272
  if (!resolved)
176
273
  return null;
177
- const snippet = content.length > 4000 ? content.slice(0, 4000) + "\n\n[... truncated]" : content;
178
- const otherFiles = repoContext?.filePaths?.filter((p) => p !== filePath).slice(0, 80) ?? [];
179
- const repoContextBlock = otherFiles.length > 0
180
- ? `\nRepository context (other files in this run): ${otherFiles.join(", ")}.\nWhen suggesting optimizations, you may reference other files (e.g. extract to a shared module, reuse from another file, or move code between files). Explain why each suggestion helps.\n`
181
- : "";
182
- const prompt = `You are a senior engineer assessing code cleanliness and possible optimizations for this file.
183
-
184
- 1) In 1-3 sentences: how clean and maintainable is it, and one or two concrete improvements (or "Looks good" if fine). Explain why each improvement matters.
185
- 2) If one specific optimization is possible (e.g. simplify a function, reduce nesting, extract a helper, or a cross-file refactor like moving code to a shared module), provide ONLY that refactored snippet in a markdown code block. Same language as the file. Briefly say why it helps. If no clear code change applies, omit the code block.
186
- ${repoContextBlock}
187
- File: ${filePath}
188
- Metrics: complexity ${metrics.cyclomaticComplexity ?? "?"}, lines ${metrics.lineCount}, ${metrics.hasDocumentation ? "has docs" : "no module docs"}${metrics.hotspotScore != null ? `, hotspot ${metrics.hotspotScore.toFixed(2)}` : ""}.
274
+ const snippet = content.length > 3500 ? content.slice(0, 3500) + "\n\n[... truncated]" : content;
275
+ const prompt = `File: ${filePath}
276
+ Metrics: complexity ${metrics.cyclomaticComplexity ?? "?"}, lines ${metrics.lineCount}, ${metrics.hasDocumentation ? "has docs" : "no module docs"}${metrics.hotspotScore != null ? `, hotspot ${metrics.hotspotScore.toFixed(2)}` : ""}
189
277
 
190
278
  Code:
191
279
  \`\`\`
192
280
  ${snippet}
193
281
  \`\`\`
194
282
 
195
- Reply: short assessment first (with brief "why"), then optionally a code block with the suggested refactor. No preamble.`;
283
+ Output only:
284
+ 1. A two- to four-sentence summary of the issues (how clean/maintainable, main concerns). No code block unless absolutely necessary to demonstrate.
285
+ 2. On the next line write only one word: critical, high, medium, low, or none (this file's technical debt severity; none = no significant debt).
286
+ 3. On the line after that write only a number 0-100 (100 = most technical debt).
287
+ No preamble.`;
196
288
  const raw = await chat(prompt, {
197
289
  ...resolved,
198
- maxTokens: config.maxTokens ?? 500,
290
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS_FILE,
199
291
  });
200
292
  if (!raw)
201
293
  return null;
202
- const { prose, code } = parseCodeBlockAndProse(raw);
203
- return { assessment: prose, suggestedCode: code };
294
+ const parsed = parseFileAssessmentResponse(raw);
295
+ return {
296
+ assessment: parsed.assessment,
297
+ suggestedCode: parsed.code,
298
+ fileScore: parsed.fileScore,
299
+ severity: parsed.severity,
300
+ raw,
301
+ };
204
302
  }
205
- /** Overall: LLM assesses the whole codebase cleanliness in a short paragraph. */
303
+ /** Overall: LLM assesses the whole codebase and optionally a 0–100 debt score. */
206
304
  export async function assessOverallCleanliness(run, config = {}) {
207
305
  const resolved = resolveLLMConfig(config);
208
306
  if (!resolved)
@@ -215,52 +313,25 @@ export async function assessOverallCleanliness(run, config = {}) {
215
313
  .sort((a, b) => (b.hotspotScore ?? 0) - (a.hotspotScore ?? 0))
216
314
  .slice(0, 12)
217
315
  .map((m) => m.file);
218
- const prompt = `You are a senior engineer giving a brief overall assessment of a codebase's technical debt and cleanliness.
219
-
220
- Summary:
221
- - ${fileCount} files analyzed
222
- - ${debtCount} debt items (${criticalHigh} critical/high)
223
- - ${hotspots} hotspot files (high churn + complexity)
224
- - Top files by risk: ${topFiles.join(", ")}
225
-
226
- In one short paragraph (3-5 sentences), assess overall cleanliness: main strengths or concerns, and the single most important thing to improve. Be direct and actionable. No preamble.`;
227
- return chat(prompt, {
228
- ...resolved,
229
- maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS_OVERALL,
230
- });
231
- }
232
- /** LLM suggests 3–5 prioritized next steps (actionable bullets). */
233
- export async function suggestNextSteps(run, config = {}) {
234
- const resolved = resolveLLMConfig(config);
235
- if (!resolved)
236
- return null;
237
- const fileCount = run.fileMetrics.length;
238
- const debtCount = run.debtItems.length;
239
- const bySeverity = run.debtItems.reduce((acc, d) => {
240
- acc[d.severity] = (acc[d.severity] ?? 0) + 1;
241
- return acc;
242
- }, {});
243
- const severityOrd = (s) => ({ critical: 4, high: 3, medium: 2, low: 1 }[s] ?? 0);
244
- const topItems = run.debtItems
245
- .sort((a, b) => severityOrd(b.severity) - severityOrd(a.severity))
246
- .slice(0, 15)
247
- .map((d) => `${d.severity}: ${d.title} (${d.file}${d.line ? `:${d.line}` : ""})`);
248
- const prompt = `You are a senior engineer. Given this technical debt summary, suggest 3–5 concrete, prioritized next steps the team should take to reduce debt. Be specific (files, types of fixes). Output ONLY a short bullet list: one action per line, starting each line with "- " or "• ". No preamble or explanation.
316
+ const prompt = `Codebase summary: ${fileCount} files, ${debtCount} debt items (${criticalHigh} critical/high), ${hotspots} hotspots. Top risk files: ${topFiles.join(", ")}
249
317
 
250
- Summary: ${fileCount} files, ${debtCount} debt items. By severity: ${JSON.stringify(bySeverity)}.
251
- Sample items: ${topItems.join("; ")}
252
-
253
- List 3–5 next steps:`;
318
+ Output only:
319
+ 1. A two- to four-sentence summary of the main issues (strengths, concerns, single most important improvement). No code block unless absolutely necessary to demonstrate.
320
+ 2. On the next line write only one word: critical, high, medium, low, or none (overall codebase technical debt severity).
321
+ 3. On the line after that write only a number 0-100 (100 = most debt).
322
+ No preamble.`;
254
323
  const raw = await chat(prompt, {
255
324
  ...resolved,
256
- maxTokens: 300,
325
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS_OVERALL,
257
326
  });
258
327
  if (!raw)
259
328
  return null;
260
- const bullets = raw
261
- .split(/\n/)
262
- .map((s) => s.replace(/^[\s\-•*]+\s*/, "").trim())
263
- .filter((s) => s.length > 0)
264
- .slice(0, 5);
265
- return bullets.length > 0 ? bullets : null;
329
+ const lines = raw.trim().split(/\n/).map((l) => l.trim()).filter(Boolean);
330
+ const parsed = parseSeverityAndScore(lines);
331
+ return {
332
+ assessment: parsed.assessment || raw.trim(),
333
+ score: parsed.score,
334
+ severity: parsed.severity,
335
+ raw,
336
+ };
266
337
  }
@@ -0,0 +1,322 @@
1
+ /* Technical Debt Report - theme and layout */
2
+ :root {
3
+ --bg: #fff;
4
+ --surface: #fff;
5
+ --border: #ccc;
6
+ --text: #222;
7
+ --text-muted: #666;
8
+ --link: #0568c2;
9
+ }
10
+
11
+ [data-theme="dark"] {
12
+ --bg: #1a1a1a;
13
+ --surface: #252525;
14
+ --border: #404040;
15
+ --text: #e0e0e0;
16
+ --text-muted: #999;
17
+ --link: #6eb3f7;
18
+ }
19
+
20
+ * { box-sizing: border-box; }
21
+
22
+ body {
23
+ margin: 0;
24
+ font-family: system-ui, sans-serif;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ min-height: 100vh;
28
+ font-size: 14px;
29
+ line-height: 1.5;
30
+ }
31
+
32
+ .container {
33
+ max-width: 900px;
34
+ margin: 0 auto;
35
+ padding: 1.5rem 1.25rem;
36
+ }
37
+
38
+ /* Banner */
39
+ .no-llm-banner {
40
+ width: 100%;
41
+ background: var(--surface);
42
+ border-bottom: 1px solid var(--border);
43
+ padding: 0.5rem 1rem;
44
+ text-align: center;
45
+ }
46
+
47
+ .no-llm-banner .no-llm-cta {
48
+ font-size: 14px;
49
+ font-weight: normal;
50
+ color: var(--text);
51
+ margin: 0;
52
+ }
53
+
54
+ /* Hero / score */
55
+ .hero-caption {
56
+ font-size: 12px;
57
+ color: var(--text-muted);
58
+ margin: 0 0 0.5rem;
59
+ text-align: center;
60
+ }
61
+
62
+ .hero {
63
+ text-align: center;
64
+ margin-bottom: 1.5rem;
65
+ }
66
+
67
+ .score-badge {
68
+ display: inline-block;
69
+ margin-bottom: 0.75rem;
70
+ line-height: 0;
71
+ }
72
+
73
+ .score-badge-svg {
74
+ display: block;
75
+ width: 160px;
76
+ height: auto;
77
+ max-width: min(160px, 40vw);
78
+ }
79
+
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;
85
+ }
86
+
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;
94
+ }
95
+
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; }
105
+
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;
112
+ }
113
+
114
+ @media (max-width: 640px) {
115
+ .summary-cards { grid-template-columns: repeat(2, 1fr); }
116
+ }
117
+
118
+ .card {
119
+ background: var(--surface);
120
+ border: 1px solid var(--border);
121
+ padding: 0.75rem 1rem;
122
+ }
123
+
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; }
126
+
127
+ /* Sections */
128
+ .section {
129
+ background: var(--surface);
130
+ border: 1px solid var(--border);
131
+ padding: 1rem 1.25rem;
132
+ margin-bottom: 1rem;
133
+ }
134
+
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; }
137
+
138
+ /* Treemap */
139
+ #treemap {
140
+ display: flex;
141
+ flex-wrap: wrap;
142
+ gap: 4px;
143
+ min-height: 160px;
144
+ padding: 0.25rem 0;
145
+ }
146
+
147
+ .treemap-cell {
148
+ display: flex;
149
+ align-items: flex-end;
150
+ min-width: 64px;
151
+ padding: 6px 8px;
152
+ font-size: 11px;
153
+ cursor: pointer;
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ white-space: nowrap;
157
+ border: 1px solid rgba(0,0,0,0.1);
158
+ }
159
+
160
+ .treemap-cell:hover { outline: 2px solid var(--text); outline-offset: 1px; }
161
+
162
+ .treemap-cell[data-severity="critical"] { background: #c00; color: #fff; border-color: #900; }
163
+ .treemap-cell[data-severity="high"] { background: #e85d00; color: #fff; border-color: #b84a00; }
164
+ .treemap-cell[data-severity="medium"] { background: #b8860b; color: #fff; border-color: #8b6914; }
165
+ .treemap-cell[data-severity="low"] { background: #0a6b0a; color: #fff; border-color: #064906; }
166
+ .treemap-cell[data-severity="none"] { background: var(--border); color: var(--text-muted); border-color: var(--border); }
167
+
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; }
172
+ .legend .swatch-crit { background: #c00; }
173
+ .legend .swatch-high { background: #e85d00; }
174
+ .legend .swatch-med { background: #b8860b; }
175
+ .legend .swatch-low { background: #0a6b0a; }
176
+ .legend .swatch-none { background: var(--border); }
177
+
178
+ /* Debt list */
179
+ .debt-list { list-style: none; padding: 0; margin: 0; }
180
+
181
+ .debt-list li {
182
+ border-bottom: 1px solid var(--border);
183
+ padding: 0.6rem 0;
184
+ cursor: pointer;
185
+ }
186
+
187
+ .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; }
191
+ .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; }
193
+ .debt-list-rating { display: inline-flex; align-items: center; gap: 0.35rem; }
194
+ .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; }
196
+
197
+ /* Badges */
198
+ .badge { display: inline-block; padding: 0.15em 0.4em; font-size: 11px; font-weight: bold; }
199
+ .badge-critical { background: #c00; color: #fff; }
200
+ .badge-high { background: #e85d00; color: #fff; }
201
+ .badge-medium { background: #b8860b; color: #fff; }
202
+ .badge-low { background: #0a6b0a; color: #fff; }
203
+ .badge-none { background: var(--border); color: var(--text-muted); }
204
+ .badge-llm { background: var(--link); color: #fff; }
205
+
206
+ /* Detail modal */
207
+ #detail {
208
+ position: fixed;
209
+ inset: 0;
210
+ background: rgba(0,0,0,0.5);
211
+ display: none;
212
+ align-items: center;
213
+ justify-content: center;
214
+ z-index: 100;
215
+ padding: 1rem;
216
+ }
217
+
218
+ #detail.show { display: flex; }
219
+
220
+ #detail .panel {
221
+ background: var(--surface);
222
+ border: 1px solid var(--border);
223
+ max-width: 520px;
224
+ width: 100%;
225
+ max-height: 90vh;
226
+ overflow-y: auto;
227
+ overflow-x: hidden;
228
+ padding: 1.25rem;
229
+ -webkit-overflow-scrolling: touch;
230
+ }
231
+
232
+ #detail .panel h3 { margin: 0 0 0.35rem; font-size: 1rem; }
233
+ #detail .panel .file { font-family: ui-monospace, monospace; font-size: 13px; color: var(--link); }
234
+ #detail .panel .close-hint { margin-top: 0.75rem; font-size: 12px; color: var(--text-muted); }
235
+
236
+ #detail .panel .explanation,
237
+ #detail .panel .detail-explanation {
238
+ margin-top: 0.75rem;
239
+ line-height: 1.5;
240
+ color: var(--text-muted);
241
+ font-size: 13px;
242
+ white-space: pre-wrap;
243
+ word-break: break-word;
244
+ }
245
+
246
+ #detail .panel .detail-explanation.has-llm-output { white-space: normal; }
247
+ #detail .panel .detail-explanation .llm-output { margin-top: 0.25rem; }
248
+ #detail .panel .detail-explanation .detail-severities { margin-bottom: 0.5rem; }
249
+ #detail .panel .detail-explanation .detail-sev { margin-right: 1rem; }
250
+ #detail .panel .detail-explanation .detail-llm-label { margin-top: 0.5rem; margin-bottom: 0.25rem; }
251
+ #detail .panel .detail-explanation .detail-no-llm { font-style: italic; color: var(--text-muted); }
252
+ #detail .panel .detail-explanation .detail-static-desc { margin-top: 0.5rem; font-size: 12px; color: var(--text-muted); }
253
+
254
+ #detail .panel .file-assessment {
255
+ margin-top: 0.75rem;
256
+ padding-top: 0.75rem;
257
+ border-top: 1px solid var(--border);
258
+ font-size: 13px;
259
+ color: var(--text-muted);
260
+ line-height: 1.5;
261
+ white-space: pre-wrap;
262
+ word-break: break-word;
263
+ }
264
+
265
+ #detail .panel .file-assessment strong { color: var(--text); font-size: 12px; }
266
+ #detail .panel .suggested-code { margin-top: 0.75rem; }
267
+
268
+ /* Code blocks - shared */
269
+ .code-block {
270
+ margin: 0.5rem 0;
271
+ padding: 0.75rem 1rem;
272
+ background: var(--bg);
273
+ border: 1px solid var(--border);
274
+ border-radius: 6px;
275
+ font-size: 13px;
276
+ overflow-x: auto;
277
+ overflow-y: auto;
278
+ max-height: 20em;
279
+ }
280
+
281
+ .code-block .lang-label {
282
+ display: block;
283
+ font-size: 11px;
284
+ font-family: system-ui, sans-serif;
285
+ color: var(--text-muted);
286
+ margin-bottom: 0.5rem;
287
+ text-transform: uppercase;
288
+ letter-spacing: 0.03em;
289
+ }
290
+
291
+ .code-block pre { margin: 0; white-space: pre; overflow-x: auto; min-width: min-content; }
292
+ .code-block code {
293
+ font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
294
+ font-size: 12px;
295
+ line-height: 1.5;
296
+ color: var(--text);
297
+ display: block;
298
+ }
299
+
300
+ #detail .panel .code-block {
301
+ max-height: 16em;
302
+ }
303
+
304
+ #detail .panel .suggested-code .code-block { margin: 0.35rem 0 0; background: var(--surface); }
305
+ #detail .panel .file-assessment .code-block { margin: 0.5rem 0 0; background: var(--surface); }
306
+
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; }
311
+
312
+ .llm-output .llm-prose { margin: 0 0 0.75rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
313
+ .llm-output .llm-prose:last-child { margin-bottom: 0; }
314
+
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); }