@vibecodeqa/cli 0.15.0 → 0.16.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/cli.js CHANGED
@@ -131,7 +131,7 @@ async function main() {
131
131
  }
132
132
  }
133
133
  writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
134
- writeFileSync(join(outputDir, "report.html"), generateHTML(report, historyDir));
134
+ writeFileSync(join(outputDir, "report.html"), generateHTML(report));
135
135
  if (jsonOnly) {
136
136
  console.log(JSON.stringify(report));
137
137
  }
@@ -0,0 +1,10 @@
1
+ /** Reusable HTML components and helpers for the report. */
2
+ import type { Priority } from "../check-meta.js";
3
+ /** HTML-escape a string. */
4
+ export declare function e(s: string): string;
5
+ /** Make a file path a clickable GitHub link if repoUrl is available. */
6
+ export declare function fileLink(path: string, line: number | undefined, repoUrl: string | null, branch: string): string;
7
+ /** Grade color. */
8
+ export declare function gc(grade: string): string;
9
+ /** Priority color. */
10
+ export declare function pc(p: Priority): string;
@@ -0,0 +1,21 @@
1
+ /** Reusable HTML components and helpers for the report. */
2
+ /** HTML-escape a string. */
3
+ export function e(s) {
4
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5
+ }
6
+ /** Make a file path a clickable GitHub link if repoUrl is available. */
7
+ export function fileLink(path, line, repoUrl, branch) {
8
+ const clean = path.split(":")[0];
9
+ if (!repoUrl || !/^https?:\/\//.test(repoUrl))
10
+ return e(path);
11
+ const href = `${repoUrl}/blob/${branch}/${clean}${line ? `#L${line}` : ""}`;
12
+ return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
13
+ }
14
+ /** Grade color. */
15
+ export function gc(grade) {
16
+ return { A: "#22c55e", B: "#84cc16", C: "#eab308", D: "#f97316", F: "#ef4444" }[grade] || "#6b7280";
17
+ }
18
+ /** Priority color. */
19
+ export function pc(p) {
20
+ return { critical: "#ef4444", high: "#f97316", medium: "#eab308", low: "#6b7280" }[p];
21
+ }
@@ -9,4 +9,4 @@
9
9
  * All in one self-contained HTML file using hash routing + show/hide.
10
10
  */
11
11
  import type { VibeReport } from "../types.js";
12
- export declare function generateHTML(report: VibeReport, historyDir?: string): string;
12
+ export declare function generateHTML(report: VibeReport): string;
@@ -9,35 +9,18 @@
9
9
  * All in one self-contained HTML file using hash routing + show/hide.
10
10
  */
11
11
  import { getCheckMeta } from "../check-meta.js";
12
- import { loadHistory, scoreDeltaBadge } from "../history.js";
13
- import { generateArchSVG } from "../runners/architecture.js";
14
- import { buildSparkline } from "./svg.js";
15
- function e(s) {
16
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
17
- }
18
- /** Make a file path a clickable GitHub link if repoUrl is available. */
19
- function fileLink(path, line, repoUrl, branch) {
20
- const clean = path.split(":")[0];
21
- if (!repoUrl || !/^https?:\/\//.test(repoUrl))
22
- return e(path);
23
- const href = `${repoUrl}/blob/${branch}/${clean}${line ? `#L${line}` : ""}`;
24
- return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
25
- }
26
- function gc(grade) {
27
- return { A: "#22c55e", B: "#84cc16", C: "#eab308", D: "#f97316", F: "#ef4444" }[grade] || "#6b7280";
28
- }
29
- function pc(p) {
30
- return { critical: "#ef4444", high: "#f97316", medium: "#eab308", low: "#6b7280" }[p];
31
- }
12
+ import { e, fileLink, gc } from "./components.js";
13
+ import { categoryPages, filesPage, heatmapPage, issuesPage, overviewPage } from "./pages.js";
14
+ import { CSS } from "./styles.js";
32
15
  const GROUPS = [
33
16
  { id: "foundations", label: "Foundations", checks: ["structure", "lint", "types", "type-safety", "standards"] },
34
- { id: "quality", label: "Quality", checks: ["error-handling", "complexity", "duplication", "error-handling", "docs"] },
17
+ { id: "quality", label: "Quality", checks: ["complexity", "duplication", "error-handling", "docs"] },
35
18
  { id: "testing", label: "Testing", checks: ["testing"] },
36
19
  { id: "arch", label: "Architecture", checks: ["architecture"] },
37
20
  { id: "security", label: "Security", checks: ["secrets", "security", "dependencies"] },
38
21
  { id: "llm", label: "LLM Readiness", checks: ["confusion", "context"] },
39
22
  ];
40
- export function generateHTML(report, historyDir) {
23
+ export function generateHTML(report) {
41
24
  const allChecks = report.checks;
42
25
  const checkMap = new Map(allChecks.map((c) => [c.name, c]));
43
26
  const active = allChecks.filter((c) => !c.details.skipped);
@@ -73,8 +56,7 @@ export function generateHTML(report, historyDir) {
73
56
  const avg = scored.length > 0 ? Math.round(scored.reduce((s, c) => s + c.score, 0) / scored.length) : 0;
74
57
  return { ...g, avg, checks };
75
58
  });
76
- // ── Build pages ──
77
- // Top nav
59
+ // ── Top nav ──
78
60
  const topNavItems = [
79
61
  { id: "overview", label: "Overview" },
80
62
  ...GROUPS.map((g) => ({ id: g.id, label: g.label })),
@@ -83,343 +65,32 @@ export function generateHTML(report, historyDir) {
83
65
  { id: "heatmap", label: "Heatmap" },
84
66
  ];
85
67
  const topNav = topNavItems.map((t) => `<a class="tn" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`).join("");
86
- // Overview page
87
- const ringPct = report.score;
88
- const barChart = active
89
- .sort((a, b) => a.score - b.score)
90
- .map((c) => {
91
- return `<div class="brow"><span class="bl">${e(c.name)}</span><div class="bb"><div class="bf" style="width:${c.score}%;background:${gc(c.grade)}"></div></div><span class="bv" style="color:${gc(c.grade)}">${c.grade} ${c.score}</span></div>`;
92
- })
93
- .join("");
94
- const catCards = catScores
68
+ // ── Sidebar ──
69
+ const sidebar = catScores
95
70
  .map((cs) => {
96
71
  const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
97
- const mini = cs.checks
72
+ return `<div class="side-section"><a class="side-cat" onclick="go('${cs.id}')">${cs.label} <span style="color:${clr}">${cs.avg}</span></a>${cs.checks
98
73
  .map((c) => {
99
74
  const sk = c.details.skipped;
100
- return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "—" : c.grade}</span>`;
101
- })
102
- .join("");
103
- return `<div class="cc" onclick="go('${cs.id}')"><div class="cc-s" style="color:${clr}">${cs.avg}</div><div class="cc-l">${cs.label}</div><div class="cc-m">${mini}</div></div>`;
104
- })
105
- .join("");
106
- const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
107
- // ── Trend sparklines from history ──
108
- let trendSection = "";
109
- if (historyDir) {
110
- const history = loadHistory(historyDir);
111
- if (history.length >= 2) {
112
- const scores = history.map((h) => h.score);
113
- const badge = scoreDeltaBadge(history);
114
- const badgeHtml = badge
115
- ? `<span class="trend-badge" style="color:${badge.delta > 0 ? "var(--pass)" : badge.delta < 0 ? "var(--fail)" : "var(--muted)"}">${e(badge.label)}</span>`
116
- : "";
117
- // Composite score sparkline
118
- const mainSparkline = buildSparkline(scores, { width: 120, height: 30, color: "#818cf8" });
119
- // Per-check mini sparklines
120
- const checkNames = [...new Set(history.flatMap((h) => [...h.checkScores.keys()]))];
121
- const checkSparklines = checkNames
122
- .map((name) => {
123
- const vals = history.map((h) => h.checkScores.get(name) ?? 0);
124
- const current = vals[vals.length - 1];
125
- const prev = vals.length >= 2 ? vals[vals.length - 2] : current;
126
- const delta = current - prev;
127
- const dColor = delta > 0 ? "var(--pass)" : "var(--fail)";
128
- const dSign = delta > 0 ? "+" : "";
129
- const deltaStr = delta !== 0 ? `<span style="color:${dColor};font-size:0.58rem">${dSign}${delta}</span>` : "";
130
- const svg = buildSparkline(vals, { width: 60, height: 20, color: "#6b7280", dotRadius: 1.5 });
131
- return `<div class="ts-check"><span class="ts-name">${e(name)}</span>${svg}${deltaStr}</div>`;
132
- })
133
- .join("");
134
- trendSection = `<div class="trend-section">
135
- <h3>Trend <span style="font-size:0.65rem;font-weight:400;color:var(--muted)">${history.length} runs</span></h3>
136
- <div class="ts-main"><div class="ts-spark">${mainSparkline}</div><div class="ts-info"><span class="ts-score">${report.score}</span>${badgeHtml}</div></div>
137
- <div class="ts-checks">${checkSparklines}</div>
138
- </div>`;
139
- }
140
- }
141
- const overviewPage = `<div id="p-overview" class="page active">
142
- <div class="dash">
143
- <div class="hero">
144
- ${buildRing(ringPct, gc(report.grade))}
145
- <div class="hc"><span class="hg" style="color:${gc(report.grade)}">${report.grade}</span><span class="hs" style="color:${gc(report.grade)}">${report.score}/100</span><span class="hd">${active.length} checks · ${totalIssues} issues · ${report.meta.duration}ms</span></div>
146
- </div>
147
- <div class="radar">${radarSvg}</div>
148
- </div>
149
- <div class="cats">${catCards}</div>
150
- ${trendSection}
151
- <h3>All Checks</h3>
152
- <div class="bars">${barChart}</div>
153
- <div class="stack">${Object.entries(report.meta.stack)
154
- .filter(([, v]) => v !== "none" && v !== "unknown")
155
- .map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
156
- .join("")}</div>
157
- </div>`;
158
- // Category pages (with sub-nav tabs for each check)
159
- let catPages = "";
160
- for (const cs of catScores) {
161
- const subNav = cs.checks
162
- .map((c, i) => {
163
- const sk = c.details.skipped;
164
- return `<a class="sn${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}" onclick="sub(this,'${cs.id}')">${e(c.name)} <span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span></a>`;
165
- })
166
- .join("");
167
- const subPages = cs.checks
168
- .map((c, i) => {
169
75
  const meta = getCheckMeta(c.name);
170
- const sk = c.details.skipped;
171
- const detailsFiltered = Object.entries(c.details)
172
- .filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
173
- .map(([k, v]) => {
174
- const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
175
- return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
176
- })
177
- .join("");
178
- // Group issues by file
179
- const byFile = new Map();
180
- const noFile = [];
181
- for (const iss of c.issues) {
182
- const f = iss.file?.split(":")[0];
183
- if (f) {
184
- const arr = byFile.get(f) || [];
185
- arr.push(iss);
186
- byFile.set(f, arr);
187
- }
188
- else {
189
- noFile.push(iss);
190
- }
191
- }
192
- let issuesHtml = "";
193
- for (const [file, issues] of byFile) {
194
- issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
195
- for (const iss of issues) {
196
- const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
197
- issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span>${iss.line ? `<span class="il">${iss.line}</span>` : ""}<span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}<button class="cp-btn" data-prompt="${e(prompt)}" title="Copy fix prompt">📋</button></div>`;
198
- }
199
- issuesHtml += `</div>`;
200
- }
201
- if (noFile.length > 0) {
202
- issuesHtml += `<div class="fg"><div class="fn">General</div>`;
203
- for (const iss of noFile) {
204
- issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span><span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}</div>`;
205
- }
206
- issuesHtml += `</div>`;
207
- }
208
- return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
209
- <div class="ch-head"><span class="ch-g" style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span><div><b>${e(meta.label)}</b><span class="ch-s">${sk ? "skipped" : `${c.score}/100`} · weight ${meta.weight}% · ${c.duration}ms · ${c.issues.length} issues</span></div><span class="pri" style="color:${pc(meta.priority)}">${meta.priority}</span></div>
210
- ${meta.description ? `<div class="info-panel"><div class="ip-row"><span class="ip-label">What</span><span>${e(meta.description)}</span></div><div class="ip-row"><span class="ip-label">Risk</span><span>${e(meta.risk)}</span></div><div class="ip-row"><span class="ip-label">Fix</span><span>${e(meta.recommendation)}</span></div></div>` : ""}
211
- ${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
212
- ${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
213
- ${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
214
- ${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
215
- </div>`;
76
+ return `<a class="side-check" onclick="go('${cs.id}')" title="${e(meta.label)}"><span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade}</span> ${e(meta.label)}</a>`;
216
77
  })
217
- .join("");
218
- const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
219
- catPages += `<div id="p-${cs.id}" class="page">
220
- <div class="cat-head"><span style="color:${clr};font-size:1.8rem;font-weight:900">${cs.avg}</span><span style="color:${clr}">/100</span><span style="color:var(--muted);margin-left:0.5rem">${cs.label}</span></div>
221
- <div class="bar2"><div class="bf2" style="width:${cs.avg}%;background:${clr}"></div></div>
222
- <div class="sub-nav">${subNav}</div>
223
- ${subPages}
224
- </div>`;
225
- }
226
- // All Issues page
227
- const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
228
- const issueRows = allIssues
229
- .slice(0, 200)
230
- .map((i) => {
231
- const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
232
- return `<tr class="${i.severity}"><td class="is2">${i.severity[0].toUpperCase()}</td><td class="ic2">${e(i.check)}</td><td class="il2">${loc}</td><td>${e(i.message)}</td><td class="iru2">${e(i.rule || "")}</td></tr>`;
233
- })
234
- .join("");
235
- const issuesPage = `<div id="p-issues" class="page">
236
- <h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
237
- <div class="isf">${allIssues.filter((i) => i.severity === "error").length} errors · ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
238
- <table class="it"><thead><tr><th></th><th>Check</th><th>Location</th><th>Message</th><th>Rule</th></tr></thead><tbody>${issueRows}</tbody></table>
239
- ${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
240
- </div>`;
241
- // File heatmap page
242
- const fileRows = topFiles
243
- .map((f) => {
244
- const pct = Math.min(100, f.total * 5);
245
- return `<div class="fr"><span class="ff">${fl(f.file)}</span><div class="fb"><div class="fbf" style="width:${pct}%;background:${f.errors > 0 ? "var(--fail)" : "var(--warn)"}"></div></div><span class="fv">${f.errors}E ${f.warnings}W</span><span class="fcs">${f.checks.join(", ")}</span></div>`;
78
+ .join("")}</div>`;
246
79
  })
247
80
  .join("");
248
- const filesPage = `<div id="p-files" class="page">
249
- <h2>File Heatmap</h2>
250
- <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Top ${topFiles.length} files by total issues across all checks</p>
251
- ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
252
- </div>`;
253
- // Codebase heatmap — each file = row of pixels, color = issue density
254
- const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
255
- let heatmapHtml = "";
256
- if (heatmapFiles.length > 0) {
257
- const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
258
- heatmapHtml = heatmapFiles
259
- .map(([file, d]) => {
260
- const total = d.errors + d.warnings;
261
- const intensity = maxIssues > 0 ? total / maxIssues : 0;
262
- const r = Math.round(239 * intensity); // red channel
263
- const g = Math.round(68 * (1 - intensity) + 197 * (d.errors === 0 ? 0.3 : 0)); // green
264
- const color = `rgb(${r},${g},30)`;
265
- const barW = Math.max(4, Math.round(intensity * 200));
266
- const checks = [...d.checks].join(", ");
267
- return `<div class="hm-row"><span class="hm-name">${fl(file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${total} issues (${checks})"></div><span class="hm-count">${d.errors}E ${d.warnings}W</span></div>`;
268
- })
269
- .join("");
270
- }
271
- const heatmapPage = `<div id="p-heatmap" class="page">
272
- <h2>Code Heatmap</h2>
273
- <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Visual density of issues per file. Red = errors, orange = warnings. Bar width = relative issue count.</p>
274
- ${heatmapHtml || '<p style="color:var(--muted)">No issues to visualize.</p>'}
275
- </div>`;
81
+ // ── Assemble pages ──
82
+ const overview = overviewPage(report, active, totalIssues, catScores);
83
+ const catPages = categoryPages(catScores, fl);
84
+ const issues = issuesPage(allChecks, totalIssues, fl);
85
+ const files = filesPage(topFiles, fl);
86
+ const heatmap = heatmapPage(fileIssues, fl);
276
87
  return `<!DOCTYPE html>
277
88
  <html lang="en">
278
89
  <head>
279
90
  <meta charset="utf-8">
280
91
  <meta name="viewport" content="width=device-width,initial-scale=1">
281
- <title>VibeCode QA ${e(proj)}</title>
282
- <style>
283
- :root{--bg:#09090b;--card:#111115;--border:#1e1e24;--text:#e5e5e5;--muted:#6b7280;--pass:#22c55e;--fail:#ef4444;--warn:#eab308;--info:#6366f1;--accent:#818cf8}
284
- *{margin:0;padding:0;box-sizing:border-box}
285
- body{font-family:"Inter",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
286
- code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
287
-
288
- /* Top nav */
289
- .top{position:sticky;top:0;z-index:20;background:#0c0c0fcc;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;align-items:center;gap:0}
290
- .logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0}
291
- .logo span{color:var(--accent)}
292
- .tn{padding:0.7rem 0.8rem;font-size:0.78rem;color:var(--muted);text-decoration:none;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}
293
- .tn:hover{color:var(--text)}
294
- .tn.active{color:var(--text);border-bottom-color:var(--accent)}
295
-
296
- /* Sidebar */
297
- .side{position:fixed;top:42px;left:0;bottom:0;width:200px;background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.8rem 0;font-size:0.7rem;z-index:10}
298
- .side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}
299
- .side-section:last-child{border-bottom:none}
300
- .side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}
301
- .side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}
302
- .side-cat:hover{background:#14141a}
303
- .side-check{display:block;padding:0.2rem 0.8rem 0.2rem 1.2rem;color:var(--muted);cursor:pointer;text-decoration:none;font-size:0.65rem}
304
- .side-check:hover{color:var(--text);background:#14141a}
305
- .side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}
306
-
307
- /* Content */
308
- .content{max-width:900px;margin-left:200px;padding:2rem}
309
- .page{display:none;animation:fadeIn 0.15s}
310
- .page.active{display:block}
311
- @keyframes fadeIn{from{opacity:0}to{opacity:1}}
312
-
313
- /* Overview */
314
- .dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}
315
- .hero{display:flex;align-items:center;gap:1rem}
316
- .hero svg{width:100px;height:100px}
317
- .hc{display:flex;flex-direction:column}
318
- .hg{font-size:2.5rem;font-weight:900;line-height:1}
319
- .hs{font-size:1rem;font-weight:600}
320
- .hd{font-size:0.68rem;color:var(--muted)}
321
- .radar{flex:1;display:flex;justify-content:center}
322
- .radar svg{max-width:240px;width:100%}
323
- .cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}
324
- .cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}
325
- .cc:hover{border-color:var(--accent)}
326
- .cc-s{font-size:1.8rem;font-weight:900}
327
- .cc-l{font-size:0.75rem;color:var(--muted)}
328
- .cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}
329
- .mc{font-size:0.65rem;font-weight:800}
330
- h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}
331
- .bars{margin-bottom:1.5rem}
332
- .brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}
333
- .bl{width:80px;text-align:right;color:var(--muted);flex-shrink:0}
334
- .bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
335
- .bf{height:100%;border-radius:2px}
336
- .bv{width:36px;font-weight:700;font-size:0.68rem}
337
- .stack{display:flex;gap:0.35rem;flex-wrap:wrap}
338
- .stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}
339
-
340
- /* Trend sparklines */
341
- .trend-section{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:1rem;margin-bottom:1.5rem}
342
- .trend-section h3{margin-bottom:0.6rem}
343
- .ts-main{display:flex;align-items:center;gap:1rem;margin-bottom:0.8rem}
344
- .ts-spark{flex-shrink:0}
345
- .ts-info{display:flex;align-items:baseline;gap:0.5rem}
346
- .ts-score{font-size:1.4rem;font-weight:900;color:var(--text)}
347
- .trend-badge{font-size:0.72rem;font-weight:600}
348
- .ts-checks{display:flex;flex-wrap:wrap;gap:0.5rem 1rem}
349
- .ts-check{display:flex;align-items:center;gap:0.3rem;font-size:0.65rem}
350
- .ts-name{color:var(--muted);width:70px;text-align:right;flex-shrink:0}
351
-
352
- /* Category pages */
353
- .cat-head{margin-bottom:0.3rem}
354
- .bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
355
- .bf2{height:100%;border-radius:2px}
356
- .sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}
357
- .sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
358
- .sn:hover{color:var(--text)}
359
- .sn.active{color:var(--text);border-bottom-color:var(--accent)}
360
- .sp{display:none}.sp.active{display:block}
361
-
362
- /* Check detail */
363
- .ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}
364
- .ch-g{font-size:2rem;font-weight:900}
365
- .ch-s{display:block;font-size:0.7rem;color:var(--muted)}
366
- .pri{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:9999px;border:1px solid currentColor;flex-shrink:0}
367
- .info-panel{background:#0d0d12;border:1px solid var(--border);border-radius:0.5rem;padding:0.7rem 0.9rem;margin-bottom:1rem;font-size:0.72rem;line-height:1.6}
368
- .ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}
369
- .ip-row:last-child{margin-bottom:0}
370
- .ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}
371
- .skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}
372
- .kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}
373
- .kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}
374
- .k{color:var(--muted);margin-right:0.3rem}
375
- .v{font-weight:600}
376
-
377
- /* Issue list grouped by file */
378
- .iss-list{margin-top:1rem}
379
- .fg{margin-bottom:0.8rem}
380
- .fn{font-size:0.72rem;font-weight:600;font-family:"SF Mono",monospace;padding:0.3rem 0;border-bottom:1px solid var(--border);margin-bottom:0.2rem;display:flex;align-items:center;gap:0.5rem}
381
- .fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}
382
- .ir{font-size:0.65rem;font-family:"SF Mono",monospace;padding:0.12rem 0 0.12rem 0.5rem;display:flex;gap:0.4rem;align-items:baseline}
383
- .is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}
384
- .ir.error .is{color:var(--fail);background:#ef444418}
385
- .ir.warning .is{color:var(--warn);background:#eab30818}
386
- .il{color:var(--accent);min-width:2rem;flex-shrink:0}
387
- .im{flex:1;word-break:break-word}
388
- .iru{color:#555;font-size:0.55rem}
389
-
390
- /* All issues table */
391
- .isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}
392
- .it{width:100%;border-collapse:collapse;font-size:0.68rem}
393
- .it th{text-align:left;padding:0.35rem 0.4rem;color:var(--muted);font-size:0.62rem;text-transform:uppercase;border-bottom:1px solid var(--border)}
394
- .it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:"SF Mono",monospace;font-size:0.62rem}
395
- .it tr.error .is2{color:var(--fail)}
396
- .it tr.warning .is2{color:var(--warn)}
397
- .is2{font-weight:800;width:1rem}
398
- .ic2{color:var(--muted);width:70px}
399
- .il2{color:var(--muted)}
400
- .iru2{color:#555;font-size:0.58rem}
401
-
402
- /* File heatmap */
403
- .fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}
404
- .ff{width:200px;font-family:"SF Mono",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
405
- .fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
406
- .fbf{height:100%;border-radius:2px}
407
- .fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}
408
- .fcs{font-size:0.6rem;color:#555}
409
-
410
- .footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}
411
- .footer a{color:var(--muted)}
412
- .flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
413
- .arch-svg{margin:1rem 0;overflow-x:auto}
414
- .hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}
415
- .hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:"SF Mono",monospace;font-size:0.65rem}
416
- .hm-bar{height:14px;border-radius:3px;min-width:4px}
417
- .hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}
418
- .arch-svg svg{border-radius:8px}
419
- .cp-btn{background:none;border:none;cursor:pointer;font-size:0.6rem;opacity:0.3;padding:0 0.2rem;flex-shrink:0}.cp-btn:hover{opacity:1}
420
- .ir:hover .cp-btn{opacity:0.6}
421
- @media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}
422
- </style>
92
+ <title>VibeCode QA \u2014 ${e(proj)}</title>
93
+ <style>${CSS}</style>
423
94
  </head>
424
95
  <body>
425
96
 
@@ -430,25 +101,14 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
430
101
 
431
102
  <aside class="side">
432
103
  <div class="side-section">Score<div class="side-score" style="color:${gc(report.grade)}">${report.grade} ${report.score}</div></div>
433
- ${catScores
434
- .map((cs) => {
435
- const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
436
- return `<div class="side-section"><a class="side-cat" onclick="go('${cs.id}')">${cs.label} <span style="color:${clr}">${cs.avg}</span></a>${cs.checks
437
- .map((c) => {
438
- const sk = c.details.skipped;
439
- const meta = getCheckMeta(c.name);
440
- return `<a class="side-check" onclick="go('${cs.id}')" title="${e(meta.label)}"><span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span> ${e(meta.label)}</a>`;
441
- })
442
- .join("")}</div>`;
443
- })
444
- .join("")}
104
+ ${sidebar}
445
105
  </aside>
446
106
  <div class="content">
447
- ${overviewPage}
107
+ ${overview}
448
108
  ${catPages}
449
- ${issuesPage}
450
- ${filesPage}
451
- ${heatmapPage}
109
+ ${issues}
110
+ ${files}
111
+ ${heatmap}
452
112
  <div class="footer">Generated by <a href="https://vibecodeqa.online">VibeCode QA</a> v${report.version} &mdash; <code>npx @vibecodeqa/cli</code></div>
453
113
  </div>
454
114
 
@@ -476,47 +136,3 @@ document.querySelector('.tn').classList.add('active');
476
136
  </script>
477
137
  </body></html>`;
478
138
  }
479
- // ── SVG builders ──
480
- function buildRing(score, color) {
481
- const r = 42;
482
- const c = 2 * Math.PI * r;
483
- const off = c - (score / 100) * c;
484
- return `<svg viewBox="0 0 100 100" style="width:100px;height:100px"><circle cx="50" cy="50" r="${r}" fill="none" stroke="#1e1e24" stroke-width="7"/><circle cx="50" cy="50" r="${r}" fill="none" stroke="${color}" stroke-width="7" stroke-dasharray="${c}" stroke-dashoffset="${off}" stroke-linecap="round" transform="rotate(-90 50 50)"/></svg>`;
485
- }
486
- function buildRadar(items) {
487
- const n = items.length;
488
- if (n < 3)
489
- return "";
490
- const cx = 120, cy = 120, r = 90;
491
- const step = (2 * Math.PI) / n;
492
- let grid = "";
493
- for (const pct of [25, 50, 75, 100]) {
494
- const rr = (pct / 100) * r;
495
- const pts = items
496
- .map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`)
497
- .join(" ");
498
- grid += `<polygon points="${pts}" fill="none" stroke="#1e1e24" stroke-width="0.7"/>`;
499
- }
500
- let axes = "";
501
- for (let i = 0; i < n; i++) {
502
- const a = i * step - Math.PI / 2;
503
- axes += `<line x1="${cx}" y1="${cy}" x2="${cx + r * Math.cos(a)}" y2="${cy + r * Math.sin(a)}" stroke="#1e1e24" stroke-width="0.7"/>`;
504
- const lx = cx + (r + 16) * Math.cos(a);
505
- const ly = cy + (r + 16) * Math.sin(a);
506
- axes += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#6b7280" font-size="9" font-weight="600">${items[i].label}</text>`;
507
- }
508
- const dataPts = items
509
- .map((c, i) => {
510
- const a = i * step - Math.PI / 2;
511
- const rr = (c.score / 100) * r;
512
- return `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
513
- })
514
- .join(" ");
515
- let dots = "";
516
- for (let i = 0; i < n; i++) {
517
- const a = i * step - Math.PI / 2;
518
- const rr = (items[i].score / 100) * r;
519
- dots += `<circle cx="${cx + rr * Math.cos(a)}" cy="${cy + rr * Math.sin(a)}" r="3.5" fill="#818cf8"/>`;
520
- }
521
- return `<svg viewBox="0 0 240 240">${grid}${axes}<polygon points="${dataPts}" fill="#818cf825" stroke="#818cf8" stroke-width="1.5"/>${dots}</svg>`;
522
- }
@@ -0,0 +1,26 @@
1
+ /** Page renderers for the HTML report. */
2
+ import type { CheckResult, VibeReport } from "../types.js";
3
+ export interface CatScore {
4
+ id: string;
5
+ label: string;
6
+ checks: CheckResult[];
7
+ avg: number;
8
+ }
9
+ export interface FileEntry {
10
+ file: string;
11
+ total: number;
12
+ errors: number;
13
+ warnings: number;
14
+ checks: string[];
15
+ }
16
+ type FL = (path: string, line?: number) => string;
17
+ export declare function overviewPage(report: VibeReport, active: CheckResult[], totalIssues: number, catScores: CatScore[]): string;
18
+ export declare function categoryPages(catScores: CatScore[], fl: FL): string;
19
+ export declare function issuesPage(allChecks: CheckResult[], totalIssues: number, fl: FL): string;
20
+ export declare function filesPage(topFiles: FileEntry[], fl: FL): string;
21
+ export declare function heatmapPage(fileIssues: Map<string, {
22
+ errors: number;
23
+ warnings: number;
24
+ checks: Set<string>;
25
+ }>, fl: FL): string;
26
+ export {};
@@ -0,0 +1,166 @@
1
+ /** Page renderers for the HTML report. */
2
+ import { getCheckMeta } from "../check-meta.js";
3
+ import { generateArchSVG } from "../runners/architecture.js";
4
+ import { e, gc, pc } from "./components.js";
5
+ import { buildRadar, buildRing } from "./svg.js";
6
+ export function overviewPage(report, active, totalIssues, catScores) {
7
+ const ringPct = report.score;
8
+ const barChart = active
9
+ .sort((a, b) => a.score - b.score)
10
+ .map((c) => {
11
+ return `<div class="brow"><span class="bl">${e(c.name)}</span><div class="bb"><div class="bf" style="width:${c.score}%;background:${gc(c.grade)}"></div></div><span class="bv" style="color:${gc(c.grade)}">${c.grade} ${c.score}</span></div>`;
12
+ })
13
+ .join("");
14
+ const catCards = catScores
15
+ .map((cs) => {
16
+ const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
17
+ const mini = cs.checks
18
+ .map((c) => {
19
+ const sk = c.details.skipped;
20
+ return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "\u2014" : c.grade}</span>`;
21
+ })
22
+ .join("");
23
+ return `<div class="cc" onclick="go('${cs.id}')"><div class="cc-s" style="color:${clr}">${cs.avg}</div><div class="cc-l">${cs.label}</div><div class="cc-m">${mini}</div></div>`;
24
+ })
25
+ .join("");
26
+ const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
27
+ return `<div id="p-overview" class="page active">
28
+ <div class="dash">
29
+ <div class="hero">
30
+ ${buildRing(ringPct, gc(report.grade))}
31
+ <div class="hc"><span class="hg" style="color:${gc(report.grade)}">${report.grade}</span><span class="hs" style="color:${gc(report.grade)}">${report.score}/100</span><span class="hd">${active.length} checks \u00b7 ${totalIssues} issues \u00b7 ${report.meta.duration}ms</span></div>
32
+ </div>
33
+ <div class="radar">${radarSvg}</div>
34
+ </div>
35
+ <div class="cats">${catCards}</div>
36
+ <h3>All Checks</h3>
37
+ <div class="bars">${barChart}</div>
38
+ <div class="stack">${Object.entries(report.meta.stack)
39
+ .filter(([, v]) => v !== "none" && v !== "unknown")
40
+ .map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
41
+ .join("")}</div>
42
+ </div>`;
43
+ }
44
+ export function categoryPages(catScores, fl) {
45
+ let catPagesHtml = "";
46
+ for (const cs of catScores) {
47
+ const subNav = cs.checks
48
+ .map((c, i) => {
49
+ const sk = c.details.skipped;
50
+ return `<a class="sn${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}" onclick="sub(this,'${cs.id}')">${e(c.name)} <span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade}</span></a>`;
51
+ })
52
+ .join("");
53
+ const subPages = cs.checks
54
+ .map((c, i) => {
55
+ const meta = getCheckMeta(c.name);
56
+ const sk = c.details.skipped;
57
+ const detailsFiltered = Object.entries(c.details)
58
+ .filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
59
+ .map(([k, v]) => {
60
+ const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
61
+ return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
62
+ })
63
+ .join("");
64
+ // Group issues by file
65
+ const byFile = new Map();
66
+ const noFile = [];
67
+ for (const iss of c.issues) {
68
+ const f = iss.file?.split(":")[0];
69
+ if (f) {
70
+ const arr = byFile.get(f) || [];
71
+ arr.push(iss);
72
+ byFile.set(f, arr);
73
+ }
74
+ else {
75
+ noFile.push(iss);
76
+ }
77
+ }
78
+ let issuesHtml = "";
79
+ for (const [file, issues] of byFile) {
80
+ issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
81
+ for (const iss of issues) {
82
+ const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
83
+ issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span>${iss.line ? `<span class="il">${iss.line}</span>` : ""}<span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}<button class="cp-btn" data-prompt="${e(prompt)}" title="Copy fix prompt">\ud83d\udccb</button></div>`;
84
+ }
85
+ issuesHtml += `</div>`;
86
+ }
87
+ if (noFile.length > 0) {
88
+ issuesHtml += `<div class="fg"><div class="fn">General</div>`;
89
+ for (const iss of noFile) {
90
+ issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span><span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}</div>`;
91
+ }
92
+ issuesHtml += `</div>`;
93
+ }
94
+ return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
95
+ <div class="ch-head"><span class="ch-g" style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade}</span><div><b>${e(meta.label)}</b><span class="ch-s">${sk ? "skipped" : `${c.score}/100`} \u00b7 weight ${meta.weight}% \u00b7 ${c.duration}ms \u00b7 ${c.issues.length} issues</span></div><span class="pri" style="color:${pc(meta.priority)}">${meta.priority}</span></div>
96
+ ${meta.description ? `<div class="info-panel"><div class="ip-row"><span class="ip-label">What</span><span>${e(meta.description)}</span></div><div class="ip-row"><span class="ip-label">Risk</span><span>${e(meta.risk)}</span></div><div class="ip-row"><span class="ip-label">Fix</span><span>${e(meta.recommendation)}</span></div></div>` : ""}
97
+ ${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
98
+ ${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
99
+ ${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
100
+ ${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
101
+ </div>`;
102
+ })
103
+ .join("");
104
+ const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
105
+ catPagesHtml += `<div id="p-${cs.id}" class="page">
106
+ <div class="cat-head"><span style="color:${clr};font-size:1.8rem;font-weight:900">${cs.avg}</span><span style="color:${clr}">/100</span><span style="color:var(--muted);margin-left:0.5rem">${cs.label}</span></div>
107
+ <div class="bar2"><div class="bf2" style="width:${cs.avg}%;background:${clr}"></div></div>
108
+ <div class="sub-nav">${subNav}</div>
109
+ ${subPages}
110
+ </div>`;
111
+ }
112
+ return catPagesHtml;
113
+ }
114
+ export function issuesPage(allChecks, totalIssues, fl) {
115
+ const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
116
+ const issueRows = allIssues
117
+ .slice(0, 200)
118
+ .map((i) => {
119
+ const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
120
+ return `<tr class="${i.severity}"><td class="is2">${i.severity[0].toUpperCase()}</td><td class="ic2">${e(i.check)}</td><td class="il2">${loc}</td><td>${e(i.message)}</td><td class="iru2">${e(i.rule || "")}</td></tr>`;
121
+ })
122
+ .join("");
123
+ return `<div id="p-issues" class="page">
124
+ <h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
125
+ <div class="isf">${allIssues.filter((i) => i.severity === "error").length} errors \u00b7 ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
126
+ <table class="it"><thead><tr><th></th><th>Check</th><th>Location</th><th>Message</th><th>Rule</th></tr></thead><tbody>${issueRows}</tbody></table>
127
+ ${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
128
+ </div>`;
129
+ }
130
+ export function filesPage(topFiles, fl) {
131
+ const fileRows = topFiles
132
+ .map((f) => {
133
+ const pct = Math.min(100, f.total * 5);
134
+ return `<div class="fr"><span class="ff">${fl(f.file)}</span><div class="fb"><div class="fbf" style="width:${pct}%;background:${f.errors > 0 ? "var(--fail)" : "var(--warn)"}"></div></div><span class="fv">${f.errors}E ${f.warnings}W</span><span class="fcs">${f.checks.join(", ")}</span></div>`;
135
+ })
136
+ .join("");
137
+ return `<div id="p-files" class="page">
138
+ <h2>File Heatmap</h2>
139
+ <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Top ${topFiles.length} files by total issues across all checks</p>
140
+ ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
141
+ </div>`;
142
+ }
143
+ export function heatmapPage(fileIssues, fl) {
144
+ const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
145
+ let heatmapHtml = "";
146
+ if (heatmapFiles.length > 0) {
147
+ const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
148
+ heatmapHtml = heatmapFiles
149
+ .map(([file, d]) => {
150
+ const total = d.errors + d.warnings;
151
+ const intensity = maxIssues > 0 ? total / maxIssues : 0;
152
+ const r = Math.round(239 * intensity); // red channel
153
+ const g = Math.round(68 * (1 - intensity) + 197 * (d.errors === 0 ? 0.3 : 0)); // green
154
+ const color = `rgb(${r},${g},30)`;
155
+ const barW = Math.max(4, Math.round(intensity * 200));
156
+ const checks = [...d.checks].join(", ");
157
+ return `<div class="hm-row"><span class="hm-name">${fl(file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${total} issues (${checks})"></div><span class="hm-count">${d.errors}E ${d.warnings}W</span></div>`;
158
+ })
159
+ .join("");
160
+ }
161
+ return `<div id="p-heatmap" class="page">
162
+ <h2>Code Heatmap</h2>
163
+ <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Visual density of issues per file. Red = errors, orange = warnings. Bar width = relative issue count.</p>
164
+ ${heatmapHtml || '<p style="color:var(--muted)">No issues to visualize.</p>'}
165
+ </div>`;
166
+ }
@@ -0,0 +1,2 @@
1
+ /** All CSS for the HTML report, extracted for maintainability. */
2
+ export declare const CSS = "\n:root{--bg:#09090b;--card:#111115;--border:#1e1e24;--text:#e5e5e5;--muted:#6b7280;--pass:#22c55e;--fail:#ef4444;--warn:#eab308;--info:#6366f1;--accent:#818cf8}\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:\"Inter\",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}\ncode{font-family:\"SF Mono\",Menlo,monospace;font-size:0.85em}\n\n/* Top nav */\n.top{position:sticky;top:0;z-index:20;background:#0c0c0fcc;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;align-items:center;gap:0}\n.logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0}\n.logo span{color:var(--accent)}\n.tn{padding:0.7rem 0.8rem;font-size:0.78rem;color:var(--muted);text-decoration:none;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}\n.tn:hover{color:var(--text)}\n.tn.active{color:var(--text);border-bottom-color:var(--accent)}\n\n/* Sidebar */\n.side{position:fixed;top:42px;left:0;bottom:0;width:200px;background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.8rem 0;font-size:0.7rem;z-index:10}\n.side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}\n.side-section:last-child{border-bottom:none}\n.side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}\n.side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}\n.side-cat:hover{background:#14141a}\n.side-check{display:block;padding:0.2rem 0.8rem 0.2rem 1.2rem;color:var(--muted);cursor:pointer;text-decoration:none;font-size:0.65rem}\n.side-check:hover{color:var(--text);background:#14141a}\n.side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}\n\n/* Content */\n.content{max-width:900px;margin-left:200px;padding:2rem}\n.page{display:none;animation:fadeIn 0.15s}\n.page.active{display:block}\n@keyframes fadeIn{from{opacity:0}to{opacity:1}}\n\n/* Overview */\n.dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}\n.hero{display:flex;align-items:center;gap:1rem}\n.hero svg{width:100px;height:100px}\n.hc{display:flex;flex-direction:column}\n.hg{font-size:2.5rem;font-weight:900;line-height:1}\n.hs{font-size:1rem;font-weight:600}\n.hd{font-size:0.68rem;color:var(--muted)}\n.radar{flex:1;display:flex;justify-content:center}\n.radar svg{max-width:240px;width:100%}\n.cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}\n.cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}\n.cc:hover{border-color:var(--accent)}\n.cc-s{font-size:1.8rem;font-weight:900}\n.cc-l{font-size:0.75rem;color:var(--muted)}\n.cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}\n.mc{font-size:0.65rem;font-weight:800}\nh3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}\n.bars{margin-bottom:1.5rem}\n.brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}\n.bl{width:80px;text-align:right;color:var(--muted);flex-shrink:0}\n.bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}\n.bf{height:100%;border-radius:2px}\n.bv{width:36px;font-weight:700;font-size:0.68rem}\n.stack{display:flex;gap:0.35rem;flex-wrap:wrap}\n.stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}\n\n/* Category pages */\n.cat-head{margin-bottom:0.3rem}\n.bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}\n.bf2{height:100%;border-radius:2px}\n.sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}\n.sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}\n.sn:hover{color:var(--text)}\n.sn.active{color:var(--text);border-bottom-color:var(--accent)}\n.sp{display:none}.sp.active{display:block}\n\n/* Check detail */\n.ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}\n.ch-g{font-size:2rem;font-weight:900}\n.ch-s{display:block;font-size:0.7rem;color:var(--muted)}\n.pri{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:9999px;border:1px solid currentColor;flex-shrink:0}\n.info-panel{background:#0d0d12;border:1px solid var(--border);border-radius:0.5rem;padding:0.7rem 0.9rem;margin-bottom:1rem;font-size:0.72rem;line-height:1.6}\n.ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}\n.ip-row:last-child{margin-bottom:0}\n.ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}\n.skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}\n.kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}\n.kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}\n.k{color:var(--muted);margin-right:0.3rem}\n.v{font-weight:600}\n\n/* Issue list grouped by file */\n.iss-list{margin-top:1rem}\n.fg{margin-bottom:0.8rem}\n.fn{font-size:0.72rem;font-weight:600;font-family:\"SF Mono\",monospace;padding:0.3rem 0;border-bottom:1px solid var(--border);margin-bottom:0.2rem;display:flex;align-items:center;gap:0.5rem}\n.fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}\n.ir{font-size:0.65rem;font-family:\"SF Mono\",monospace;padding:0.12rem 0 0.12rem 0.5rem;display:flex;gap:0.4rem;align-items:baseline}\n.is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}\n.ir.error .is{color:var(--fail);background:#ef444418}\n.ir.warning .is{color:var(--warn);background:#eab30818}\n.il{color:var(--accent);min-width:2rem;flex-shrink:0}\n.im{flex:1;word-break:break-word}\n.iru{color:#555;font-size:0.55rem}\n\n/* All issues table */\n.isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}\n.it{width:100%;border-collapse:collapse;font-size:0.68rem}\n.it th{text-align:left;padding:0.35rem 0.4rem;color:var(--muted);font-size:0.62rem;text-transform:uppercase;border-bottom:1px solid var(--border)}\n.it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:\"SF Mono\",monospace;font-size:0.62rem}\n.it tr.error .is2{color:var(--fail)}\n.it tr.warning .is2{color:var(--warn)}\n.is2{font-weight:800;width:1rem}\n.ic2{color:var(--muted);width:70px}\n.il2{color:var(--muted)}\n.iru2{color:#555;font-size:0.58rem}\n\n/* File heatmap */\n.fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}\n.ff{width:200px;font-family:\"SF Mono\",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}\n.fbf{height:100%;border-radius:2px}\n.fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}\n.fcs{font-size:0.6rem;color:#555}\n\n.footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}\n.footer a{color:var(--muted)}\n.flink{color:var(--accent);text-decoration:none;font-family:\"SF Mono\",monospace}.flink:hover{text-decoration:underline}\n.arch-svg{margin:1rem 0;overflow-x:auto}\n.hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}\n.hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:\"SF Mono\",monospace;font-size:0.65rem}\n.hm-bar{height:14px;border-radius:3px;min-width:4px}\n.hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}\n.arch-svg svg{border-radius:8px}\n.cp-btn{background:none;border:none;cursor:pointer;font-size:0.6rem;opacity:0.3;padding:0 0.2rem;flex-shrink:0}.cp-btn:hover{opacity:1}\n.ir:hover .cp-btn{opacity:0.6}\n@media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}\n";
@@ -0,0 +1,130 @@
1
+ /** All CSS for the HTML report, extracted for maintainability. */
2
+ export const CSS = `
3
+ :root{--bg:#09090b;--card:#111115;--border:#1e1e24;--text:#e5e5e5;--muted:#6b7280;--pass:#22c55e;--fail:#ef4444;--warn:#eab308;--info:#6366f1;--accent:#818cf8}
4
+ *{margin:0;padding:0;box-sizing:border-box}
5
+ body{font-family:"Inter",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
6
+ code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
7
+
8
+ /* Top nav */
9
+ .top{position:sticky;top:0;z-index:20;background:#0c0c0fcc;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;align-items:center;gap:0}
10
+ .logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0}
11
+ .logo span{color:var(--accent)}
12
+ .tn{padding:0.7rem 0.8rem;font-size:0.78rem;color:var(--muted);text-decoration:none;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}
13
+ .tn:hover{color:var(--text)}
14
+ .tn.active{color:var(--text);border-bottom-color:var(--accent)}
15
+
16
+ /* Sidebar */
17
+ .side{position:fixed;top:42px;left:0;bottom:0;width:200px;background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.8rem 0;font-size:0.7rem;z-index:10}
18
+ .side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}
19
+ .side-section:last-child{border-bottom:none}
20
+ .side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}
21
+ .side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}
22
+ .side-cat:hover{background:#14141a}
23
+ .side-check{display:block;padding:0.2rem 0.8rem 0.2rem 1.2rem;color:var(--muted);cursor:pointer;text-decoration:none;font-size:0.65rem}
24
+ .side-check:hover{color:var(--text);background:#14141a}
25
+ .side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}
26
+
27
+ /* Content */
28
+ .content{max-width:900px;margin-left:200px;padding:2rem}
29
+ .page{display:none;animation:fadeIn 0.15s}
30
+ .page.active{display:block}
31
+ @keyframes fadeIn{from{opacity:0}to{opacity:1}}
32
+
33
+ /* Overview */
34
+ .dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}
35
+ .hero{display:flex;align-items:center;gap:1rem}
36
+ .hero svg{width:100px;height:100px}
37
+ .hc{display:flex;flex-direction:column}
38
+ .hg{font-size:2.5rem;font-weight:900;line-height:1}
39
+ .hs{font-size:1rem;font-weight:600}
40
+ .hd{font-size:0.68rem;color:var(--muted)}
41
+ .radar{flex:1;display:flex;justify-content:center}
42
+ .radar svg{max-width:240px;width:100%}
43
+ .cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}
44
+ .cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}
45
+ .cc:hover{border-color:var(--accent)}
46
+ .cc-s{font-size:1.8rem;font-weight:900}
47
+ .cc-l{font-size:0.75rem;color:var(--muted)}
48
+ .cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}
49
+ .mc{font-size:0.65rem;font-weight:800}
50
+ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}
51
+ .bars{margin-bottom:1.5rem}
52
+ .brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}
53
+ .bl{width:80px;text-align:right;color:var(--muted);flex-shrink:0}
54
+ .bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
55
+ .bf{height:100%;border-radius:2px}
56
+ .bv{width:36px;font-weight:700;font-size:0.68rem}
57
+ .stack{display:flex;gap:0.35rem;flex-wrap:wrap}
58
+ .stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}
59
+
60
+ /* Category pages */
61
+ .cat-head{margin-bottom:0.3rem}
62
+ .bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
63
+ .bf2{height:100%;border-radius:2px}
64
+ .sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}
65
+ .sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
66
+ .sn:hover{color:var(--text)}
67
+ .sn.active{color:var(--text);border-bottom-color:var(--accent)}
68
+ .sp{display:none}.sp.active{display:block}
69
+
70
+ /* Check detail */
71
+ .ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}
72
+ .ch-g{font-size:2rem;font-weight:900}
73
+ .ch-s{display:block;font-size:0.7rem;color:var(--muted)}
74
+ .pri{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:9999px;border:1px solid currentColor;flex-shrink:0}
75
+ .info-panel{background:#0d0d12;border:1px solid var(--border);border-radius:0.5rem;padding:0.7rem 0.9rem;margin-bottom:1rem;font-size:0.72rem;line-height:1.6}
76
+ .ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}
77
+ .ip-row:last-child{margin-bottom:0}
78
+ .ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}
79
+ .skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}
80
+ .kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}
81
+ .kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}
82
+ .k{color:var(--muted);margin-right:0.3rem}
83
+ .v{font-weight:600}
84
+
85
+ /* Issue list grouped by file */
86
+ .iss-list{margin-top:1rem}
87
+ .fg{margin-bottom:0.8rem}
88
+ .fn{font-size:0.72rem;font-weight:600;font-family:"SF Mono",monospace;padding:0.3rem 0;border-bottom:1px solid var(--border);margin-bottom:0.2rem;display:flex;align-items:center;gap:0.5rem}
89
+ .fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}
90
+ .ir{font-size:0.65rem;font-family:"SF Mono",monospace;padding:0.12rem 0 0.12rem 0.5rem;display:flex;gap:0.4rem;align-items:baseline}
91
+ .is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}
92
+ .ir.error .is{color:var(--fail);background:#ef444418}
93
+ .ir.warning .is{color:var(--warn);background:#eab30818}
94
+ .il{color:var(--accent);min-width:2rem;flex-shrink:0}
95
+ .im{flex:1;word-break:break-word}
96
+ .iru{color:#555;font-size:0.55rem}
97
+
98
+ /* All issues table */
99
+ .isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}
100
+ .it{width:100%;border-collapse:collapse;font-size:0.68rem}
101
+ .it th{text-align:left;padding:0.35rem 0.4rem;color:var(--muted);font-size:0.62rem;text-transform:uppercase;border-bottom:1px solid var(--border)}
102
+ .it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:"SF Mono",monospace;font-size:0.62rem}
103
+ .it tr.error .is2{color:var(--fail)}
104
+ .it tr.warning .is2{color:var(--warn)}
105
+ .is2{font-weight:800;width:1rem}
106
+ .ic2{color:var(--muted);width:70px}
107
+ .il2{color:var(--muted)}
108
+ .iru2{color:#555;font-size:0.58rem}
109
+
110
+ /* File heatmap */
111
+ .fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}
112
+ .ff{width:200px;font-family:"SF Mono",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
113
+ .fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
114
+ .fbf{height:100%;border-radius:2px}
115
+ .fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}
116
+ .fcs{font-size:0.6rem;color:#555}
117
+
118
+ .footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}
119
+ .footer a{color:var(--muted)}
120
+ .flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
121
+ .arch-svg{margin:1rem 0;overflow-x:auto}
122
+ .hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}
123
+ .hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:"SF Mono",monospace;font-size:0.65rem}
124
+ .hm-bar{height:14px;border-radius:3px;min-width:4px}
125
+ .hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}
126
+ .arch-svg svg{border-radius:8px}
127
+ .cp-btn{background:none;border:none;cursor:pointer;font-size:0.6rem;opacity:0.3;padding:0 0.2rem;flex-shrink:0}.cp-btn:hover{opacity:1}
128
+ .ir:hover .cp-btn{opacity:0.6}
129
+ @media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}
130
+ `;
@@ -1,9 +1,12 @@
1
- /** SVG sparkline builder simple polyline with dots. */
2
- export interface SparklineOptions {
1
+ /** SVG builders for the HTML report (score ring, radar chart). */
2
+ export declare function buildRing(score: number, color: string): string;
3
+ export declare function buildRadar(items: {
4
+ label: string;
5
+ score: number;
6
+ }[]): string;
7
+ /** Sparkline — mini line chart for trend display. */
8
+ export declare function buildSparkline(values: number[], opts?: {
3
9
  width?: number;
4
10
  height?: number;
5
11
  color?: string;
6
- dotRadius?: number;
7
- }
8
- /** Build an inline SVG sparkline from an array of values (0-100). */
9
- export declare function buildSparkline(values: number[], opts?: SparklineOptions): string;
12
+ }): string;
@@ -1,23 +1,67 @@
1
- /** SVG sparkline builder simple polyline with dots. */
2
- /** Build an inline SVG sparkline from an array of values (0-100). */
3
- export function buildSparkline(values, opts = {}) {
1
+ /** SVG builders for the HTML report (score ring, radar chart). */
2
+ export function buildRing(score, color) {
3
+ const r = 42;
4
+ const c = 2 * Math.PI * r;
5
+ const off = c - (score / 100) * c;
6
+ return `<svg viewBox="0 0 100 100" style="width:100px;height:100px"><circle cx="50" cy="50" r="${r}" fill="none" stroke="#1e1e24" stroke-width="7"/><circle cx="50" cy="50" r="${r}" fill="none" stroke="${color}" stroke-width="7" stroke-dasharray="${c}" stroke-dashoffset="${off}" stroke-linecap="round" transform="rotate(-90 50 50)"/></svg>`;
7
+ }
8
+ export function buildRadar(items) {
9
+ const n = items.length;
10
+ if (n < 3)
11
+ return "";
12
+ const cx = 120, cy = 120, r = 90;
13
+ const step = (2 * Math.PI) / n;
14
+ let grid = "";
15
+ for (const pct of [25, 50, 75, 100]) {
16
+ const rr = (pct / 100) * r;
17
+ const pts = items
18
+ .map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`)
19
+ .join(" ");
20
+ grid += `<polygon points="${pts}" fill="none" stroke="#1e1e24" stroke-width="0.7"/>`;
21
+ }
22
+ let axes = "";
23
+ for (let i = 0; i < n; i++) {
24
+ const a = i * step - Math.PI / 2;
25
+ axes += `<line x1="${cx}" y1="${cy}" x2="${cx + r * Math.cos(a)}" y2="${cy + r * Math.sin(a)}" stroke="#1e1e24" stroke-width="0.7"/>`;
26
+ const lx = cx + (r + 16) * Math.cos(a);
27
+ const ly = cy + (r + 16) * Math.sin(a);
28
+ axes += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#6b7280" font-size="9" font-weight="600">${items[i].label}</text>`;
29
+ }
30
+ const dataPts = items
31
+ .map((c, i) => {
32
+ const a = i * step - Math.PI / 2;
33
+ const rr = (c.score / 100) * r;
34
+ return `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
35
+ })
36
+ .join(" ");
37
+ let dots = "";
38
+ for (let i = 0; i < n; i++) {
39
+ const a = i * step - Math.PI / 2;
40
+ const rr = (items[i].score / 100) * r;
41
+ dots += `<circle cx="${cx + rr * Math.cos(a)}" cy="${cy + rr * Math.sin(a)}" r="3.5" fill="#818cf8"/>`;
42
+ }
43
+ return `<svg viewBox="0 0 240 240">${grid}${axes}<polygon points="${dataPts}" fill="#818cf825" stroke="#818cf8" stroke-width="1.5"/>${dots}</svg>`;
44
+ }
45
+ /** Sparkline — mini line chart for trend display. */
46
+ export function buildSparkline(values, opts) {
47
+ const width = opts?.width ?? 120;
48
+ const height = opts?.height ?? 30;
49
+ const color = opts?.color ?? "#818cf8";
4
50
  if (values.length === 0)
5
51
  return "";
6
- const w = opts.width ?? 120;
7
- const h = opts.height ?? 30;
8
- const color = opts.color ?? "#818cf8";
9
- const dotR = opts.dotRadius ?? 2;
10
- const padX = dotR + 1;
11
- const padY = dotR + 1;
12
- const plotW = w - padX * 2;
13
- const plotH = h - padY * 2;
14
- // Map values to SVG coordinates
15
- const points = values.map((v, i) => {
16
- const x = values.length === 1 ? w / 2 : padX + (i / (values.length - 1)) * plotW;
17
- const y = padY + plotH - (Math.min(100, Math.max(0, v)) / 100) * plotH;
18
- return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
19
- });
20
- const polyline = points.map((p) => `${p.x},${p.y}`).join(" ");
21
- const dots = points.map((p) => `<circle cx="${p.x}" cy="${p.y}" r="${dotR}" fill="${color}"/>`).join("");
22
- return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" style="display:block"><polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>${dots}</svg>`;
52
+ if (values.length === 1) {
53
+ const y = (height / 2).toFixed(1);
54
+ return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><polyline points="${(width / 2).toFixed(1)},${y}" fill="none" stroke="${color}" stroke-width="1.5"/><circle cx="${(width / 2).toFixed(1)}" cy="${y}" r="1.5" fill="${color}"/></svg>`;
55
+ }
56
+ const max = Math.max(...values, 1);
57
+ const min = Math.min(...values, 0);
58
+ const range = max - min || 1;
59
+ const step = width / (values.length - 1);
60
+ const points = values.map((v, i) => `${(i * step).toFixed(1)},${(height - ((v - min) / range) * (height - 4) - 2).toFixed(1)}`).join(" ");
61
+ const dots = values.map((v, i) => {
62
+ const x = (i * step).toFixed(1);
63
+ const y = (height - ((v - min) / range) * (height - 4) - 2).toFixed(1);
64
+ return `<circle cx="${x}" cy="${y}" r="1.5" fill="${color}"/>`;
65
+ }).join("");
66
+ return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>${dots}</svg>`;
23
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Code health scanner for the AI coding era. 15 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {