@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 +1 -1
- package/dist/report/components.d.ts +10 -0
- package/dist/report/components.js +21 -0
- package/dist/report/html.d.ts +1 -1
- package/dist/report/html.js +24 -408
- package/dist/report/pages.d.ts +26 -0
- package/dist/report/pages.js +166 -0
- package/dist/report/styles.d.ts +2 -0
- package/dist/report/styles.js +130 -0
- package/dist/report/svg.d.ts +9 -6
- package/dist/report/svg.js +64 -20
- package/package.json +1 -1
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
}
|
package/dist/report/html.d.ts
CHANGED
|
@@ -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
|
|
12
|
+
export declare function generateHTML(report: VibeReport): string;
|
package/dist/report/html.js
CHANGED
|
@@ -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 {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
function e(s) {
|
|
16
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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: ["
|
|
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
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
87
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
-
${
|
|
107
|
+
${overview}
|
|
448
108
|
${catPages}
|
|
449
|
-
${
|
|
450
|
-
${
|
|
451
|
-
${
|
|
109
|
+
${issues}
|
|
110
|
+
${files}
|
|
111
|
+
${heatmap}
|
|
452
112
|
<div class="footer">Generated by <a href="https://vibecodeqa.online">VibeCode QA</a> v${report.version} — <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
|
+
`;
|
package/dist/report/svg.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
/** SVG
|
|
2
|
-
export
|
|
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
|
-
|
|
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;
|
package/dist/report/svg.js
CHANGED
|
@@ -1,23 +1,67 @@
|
|
|
1
|
-
/** SVG
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const x =
|
|
17
|
-
const y =
|
|
18
|
-
return {
|
|
19
|
-
});
|
|
20
|
-
|
|
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
|
}
|