@vibecodeqa/cli 0.15.0 → 0.17.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.
@@ -1,41 +1,26 @@
1
1
  /** Generate a multi-page navigable HTML report.
2
2
  *
3
3
  * Architecture:
4
- * Top nav: Overview | Foundations | Quality | Testing | Security | Issues
5
- * Sub-nav: Tabs for each check within a category
6
- * Detail: Per-check stats + full issue list grouped by file
7
- * File map: Cross-check heatmap of issues per file
4
+ * Primary nav: Overview | Foundations | Quality | Testing | Security | Architecture | AI Readiness
5
+ * Secondary nav: Issues (N) | Files (right-aligned, visually distinct)
6
+ * Sidebar: Score + dimension tree + view links
7
+ * Overview: Dashboard with score, radar, timeline, category cards, top issues, file hotspots
8
+ * Dimensions: Sub-tabs for each check within a category
9
+ * Views: Cross-cutting data slices (issues table, file health map)
8
10
  *
9
- * All in one self-contained HTML file using hash routing + show/hide.
11
+ * All in one self-contained HTML file using show/hide navigation.
10
12
  */
11
13
  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
- }
14
+ import { e, fileLink, gc } from "./components.js";
15
+ import { categoryPages, filesPage, issuesPage, overviewPage } from "./pages.js";
16
+ import { CSS } from "./styles.js";
32
17
  const GROUPS = [
33
18
  { id: "foundations", label: "Foundations", checks: ["structure", "lint", "types", "type-safety", "standards"] },
34
- { id: "quality", label: "Quality", checks: ["error-handling", "complexity", "duplication", "error-handling", "docs"] },
19
+ { id: "quality", label: "Quality", checks: ["complexity", "duplication", "error-handling", "react", "accessibility", "docs"] },
35
20
  { id: "testing", label: "Testing", checks: ["testing"] },
36
21
  { id: "arch", label: "Architecture", checks: ["architecture"] },
37
22
  { id: "security", label: "Security", checks: ["secrets", "security", "dependencies"] },
38
- { id: "llm", label: "LLM Readiness", checks: ["confusion", "context"] },
23
+ { id: "llm", label: "AI Readiness", checks: ["confusion", "context"] },
39
24
  ];
40
25
  export function generateHTML(report, historyDir) {
41
26
  const allChecks = report.checks;
@@ -46,13 +31,13 @@ export function generateHTML(report, historyDir) {
46
31
  const fl = (path, line) => fileLink(path, line, ru, br);
47
32
  const totalIssues = allChecks.reduce((s, c) => s + c.issues.length, 0);
48
33
  const proj = report.meta.cwd.split("/").pop() || "project";
49
- // ── File heatmap: aggregate issues per file across all checks ──
34
+ // ── Aggregate file issues across all checks ──
50
35
  const fileIssues = new Map();
51
36
  for (const c of allChecks) {
52
37
  for (const iss of c.issues) {
53
38
  if (!iss.file)
54
39
  continue;
55
- const f = iss.file.split(":")[0]; // strip :line from composite file fields
40
+ const f = iss.file.split(":")[0];
56
41
  const entry = fileIssues.get(f) || { errors: 0, warnings: 0, checks: new Set() };
57
42
  if (iss.severity === "error")
58
43
  entry.errors++;
@@ -65,7 +50,7 @@ export function generateHTML(report, historyDir) {
65
50
  const topFiles = [...fileIssues.entries()]
66
51
  .map(([file, d]) => ({ file, total: d.errors + d.warnings, errors: d.errors, warnings: d.warnings, checks: [...d.checks] }))
67
52
  .sort((a, b) => b.total - a.total)
68
- .slice(0, 20);
53
+ .slice(0, 30);
69
54
  // ── Category averages ──
70
55
  const catScores = GROUPS.map((g) => {
71
56
  const checks = g.checks.map((n) => checkMap.get(n)).filter(Boolean);
@@ -73,382 +58,64 @@ export function generateHTML(report, historyDir) {
73
58
  const avg = scored.length > 0 ? Math.round(scored.reduce((s, c) => s + c.score, 0) / scored.length) : 0;
74
59
  return { ...g, avg, checks };
75
60
  });
76
- // ── Build pages ──
77
- // Top nav
78
- const topNavItems = [
61
+ // ── Primary nav (dimensions) ──
62
+ const dimNavItems = [
79
63
  { id: "overview", label: "Overview" },
80
64
  ...GROUPS.map((g) => ({ id: g.id, label: g.label })),
81
- { id: "issues", label: `Issues (${totalIssues})` },
82
- { id: "files", label: "File Map" },
83
- { id: "heatmap", label: "Heatmap" },
84
65
  ];
85
- 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
- })
66
+ const dimNav = dimNavItems.map((t) => `<a class="tn" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`).join("");
67
+ // ── Secondary nav (data views, right-aligned) ──
68
+ const viewNav = [
69
+ { id: "issues", label: `Issues (${totalIssues})` },
70
+ { id: "files", label: "Files" },
71
+ ]
72
+ .map((t) => `<a class="tn tn-view" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`)
93
73
  .join("");
94
- const catCards = catScores
74
+ // ── Sidebar ──
75
+ const sidebarDims = catScores
95
76
  .map((cs) => {
96
77
  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
78
+ 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
79
  .map((c) => {
99
80
  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
81
  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>`;
82
+ 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
83
  })
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>`;
84
+ .join("")}</div>`;
246
85
  })
247
86
  .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>`;
87
+ const sidebarViews = `<div class="side-section side-views"><div class="side-views-label">Views</div><a class="side-check" onclick="go('issues')">Issues <span style="color:var(--muted)">${totalIssues}</span></a><a class="side-check" onclick="go('files')">Files <span style="color:var(--muted)">${fileIssues.size}</span></a></div>`;
88
+ // ── Assemble pages ──
89
+ const overview = overviewPage(report, active, totalIssues, catScores, allChecks, topFiles, fl, historyDir);
90
+ const catPages = categoryPages(catScores, fl);
91
+ const issues = issuesPage(allChecks, totalIssues, fl);
92
+ const files = filesPage(topFiles, fileIssues, fl);
276
93
  return `<!DOCTYPE html>
277
94
  <html lang="en">
278
95
  <head>
279
96
  <meta charset="utf-8">
280
97
  <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>
98
+ <title>VibeCode QA \u2014 ${e(proj)}</title>
99
+ <style>${CSS}</style>
423
100
  </head>
424
101
  <body>
425
102
 
426
103
  <nav class="top">
427
104
  <div class="logo"><span>VibeCode</span> QA</div>
428
- ${topNav}
105
+ <div class="nav-dims">${dimNav}</div>
106
+ <div class="nav-views">${viewNav}</div>
429
107
  </nav>
430
108
 
431
109
  <aside class="side">
432
110
  <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("")}
111
+ ${sidebarDims}
112
+ ${sidebarViews}
445
113
  </aside>
446
114
  <div class="content">
447
- ${overviewPage}
115
+ ${overview}
448
116
  ${catPages}
449
- ${issuesPage}
450
- ${filesPage}
451
- ${heatmapPage}
117
+ ${issues}
118
+ ${files}
452
119
  <div class="footer">Generated by <a href="https://vibecodeqa.online">VibeCode QA</a> v${report.version} &mdash; <code>npx @vibecodeqa/cli</code></div>
453
120
  </div>
454
121
 
@@ -464,7 +131,7 @@ function sub(el,cat){
464
131
  el.classList.add('active');
465
132
  document.querySelectorAll('#p-'+cat+' .sp').forEach(s=>{s.classList.toggle('active',s.dataset.sub===id)});
466
133
  }
467
- // Copy-prompt buttons — read from data-attribute (no inline JS with user data)
134
+ // Copy-prompt buttons
468
135
  document.addEventListener('click',function(ev){
469
136
  var btn=ev.target.closest('.cp-btn');
470
137
  if(!btn)return;
@@ -476,47 +143,3 @@ document.querySelector('.tn').classList.add('active');
476
143
  </script>
477
144
  </body></html>`;
478
145
  }
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,25 @@
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[], allChecks: CheckResult[], topFiles: FileEntry[], fl: FL, historyDir?: string): 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[], fileIssues: Map<string, {
21
+ errors: number;
22
+ warnings: number;
23
+ checks: Set<string>;
24
+ }>, fl: FL): string;
25
+ export {};