@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.
- package/dist/check-meta.js +25 -5
- package/dist/cli.js +13 -0
- package/dist/report/components.d.ts +10 -0
- package/dist/report/components.js +21 -0
- package/dist/report/html.d.ts +7 -5
- package/dist/report/html.js +45 -422
- package/dist/report/pages.d.ts +25 -0
- package/dist/report/pages.js +210 -0
- package/dist/report/styles.d.ts +2 -0
- package/dist/report/styles.js +156 -0
- package/dist/report/svg.d.ts +26 -6
- package/dist/report/svg.js +163 -20
- package/dist/runners/accessibility.d.ts +3 -0
- package/dist/runners/accessibility.js +85 -0
- package/dist/runners/react.d.ts +3 -0
- package/dist/runners/react.js +81 -0
- package/package.json +2 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** Page renderers for the HTML report. */
|
|
2
|
+
import { getCheckMeta } from "../check-meta.js";
|
|
3
|
+
import { loadHistory } from "../history.js";
|
|
4
|
+
import { generateArchSVG } from "../runners/architecture.js";
|
|
5
|
+
import { e, gc, pc } from "./components.js";
|
|
6
|
+
import { buildPyramid, buildRadar, buildRing, buildTimeline } from "./svg.js";
|
|
7
|
+
// ── Overview ──────────────────────────────────────────────────────────
|
|
8
|
+
export function overviewPage(report, active, totalIssues, catScores, allChecks, topFiles, fl, historyDir) {
|
|
9
|
+
// Hero: score ring + grade
|
|
10
|
+
const hero = `<div class="hero">
|
|
11
|
+
${buildRing(report.score, gc(report.grade))}
|
|
12
|
+
<div class="hc">
|
|
13
|
+
<span class="hg" style="color:${gc(report.grade)}">${report.grade}</span>
|
|
14
|
+
<span class="hs" style="color:${gc(report.grade)}">${report.score}/100</span>
|
|
15
|
+
<span class="hd">${active.length} checks \u00b7 ${totalIssues} issues \u00b7 ${report.meta.duration}ms</span>
|
|
16
|
+
</div>
|
|
17
|
+
</div>`;
|
|
18
|
+
// Radar chart
|
|
19
|
+
const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
|
|
20
|
+
// Category cards
|
|
21
|
+
const catCards = catScores
|
|
22
|
+
.map((cs) => {
|
|
23
|
+
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
24
|
+
const mini = cs.checks
|
|
25
|
+
.map((c) => {
|
|
26
|
+
const sk = c.details.skipped;
|
|
27
|
+
return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "\u2014" : c.grade}</span>`;
|
|
28
|
+
})
|
|
29
|
+
.join("");
|
|
30
|
+
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>`;
|
|
31
|
+
})
|
|
32
|
+
.join("");
|
|
33
|
+
// Score timeline (from history)
|
|
34
|
+
let timelineSection = "";
|
|
35
|
+
if (historyDir) {
|
|
36
|
+
const history = loadHistory(historyDir);
|
|
37
|
+
if (history.length >= 2) {
|
|
38
|
+
const timelineSvg = buildTimeline(history.map((h) => ({ score: h.score, timestamp: h.timestamp })));
|
|
39
|
+
timelineSection = `<div class="ov-section"><h3>Score Timeline</h3><div class="timeline">${timelineSvg}</div></div>`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// All checks bar chart
|
|
43
|
+
const barChart = active
|
|
44
|
+
.sort((a, b) => a.score - b.score)
|
|
45
|
+
.map((c) => {
|
|
46
|
+
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>`;
|
|
47
|
+
})
|
|
48
|
+
.join("");
|
|
49
|
+
// Top issues preview (10 most severe)
|
|
50
|
+
const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
|
|
51
|
+
const sortedIssues = allIssues
|
|
52
|
+
.sort((a, b) => (a.severity === "error" ? 0 : a.severity === "warning" ? 1 : 2) - (b.severity === "error" ? 0 : b.severity === "warning" ? 1 : 2))
|
|
53
|
+
.slice(0, 10);
|
|
54
|
+
let topIssuesHtml = "";
|
|
55
|
+
if (sortedIssues.length > 0) {
|
|
56
|
+
const rows = sortedIssues
|
|
57
|
+
.map((i) => {
|
|
58
|
+
const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
|
|
59
|
+
return `<div class="ov-issue ${i.severity}"><span class="is">${i.severity[0].toUpperCase()}</span><span class="ov-check">${e(i.check)}</span>${loc ? `<span class="ov-loc">${loc}</span>` : ""}<span class="ov-msg">${e(i.message)}</span></div>`;
|
|
60
|
+
})
|
|
61
|
+
.join("");
|
|
62
|
+
const viewAll = allIssues.length > 10 ? `<a class="ov-link" onclick="go('issues')">View all ${allIssues.length} issues \u2192</a>` : "";
|
|
63
|
+
topIssuesHtml = `<div class="ov-section"><h3>Top Issues</h3>${rows}${viewAll}</div>`;
|
|
64
|
+
}
|
|
65
|
+
// File hotspots preview (top 5)
|
|
66
|
+
let fileHotspotsHtml = "";
|
|
67
|
+
if (topFiles.length > 0) {
|
|
68
|
+
const fileRows = topFiles.slice(0, 5).map((f) => {
|
|
69
|
+
const pct = Math.min(100, f.total * 5);
|
|
70
|
+
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></div>`;
|
|
71
|
+
}).join("");
|
|
72
|
+
const viewAll = topFiles.length > 5 ? `<a class="ov-link" onclick="go('files')">View all ${topFiles.length} files \u2192</a>` : "";
|
|
73
|
+
fileHotspotsHtml = `<div class="ov-section"><h3>File Hotspots</h3>${fileRows}${viewAll}</div>`;
|
|
74
|
+
}
|
|
75
|
+
// Stack badges
|
|
76
|
+
const stackHtml = Object.entries(report.meta.stack)
|
|
77
|
+
.filter(([, v]) => v !== "none" && v !== "unknown")
|
|
78
|
+
.map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
|
|
79
|
+
.join("");
|
|
80
|
+
return `<div id="p-overview" class="page active">
|
|
81
|
+
<div class="dash">
|
|
82
|
+
${hero}
|
|
83
|
+
<div class="radar">${radarSvg}</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="cats">${catCards}</div>
|
|
86
|
+
${timelineSection}
|
|
87
|
+
<div class="ov-section"><h3>All Checks</h3><div class="bars">${barChart}</div></div>
|
|
88
|
+
${topIssuesHtml}
|
|
89
|
+
${fileHotspotsHtml}
|
|
90
|
+
<div class="stack">${stackHtml}</div>
|
|
91
|
+
</div>`;
|
|
92
|
+
}
|
|
93
|
+
// ── Category dimension pages ──────────────────────────────────────────
|
|
94
|
+
export function categoryPages(catScores, fl) {
|
|
95
|
+
let catPagesHtml = "";
|
|
96
|
+
for (const cs of catScores) {
|
|
97
|
+
const subNav = cs.checks
|
|
98
|
+
.map((c, i) => {
|
|
99
|
+
const sk = c.details.skipped;
|
|
100
|
+
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>`;
|
|
101
|
+
})
|
|
102
|
+
.join("");
|
|
103
|
+
const subPages = cs.checks
|
|
104
|
+
.map((c, i) => {
|
|
105
|
+
const meta = getCheckMeta(c.name);
|
|
106
|
+
const sk = c.details.skipped;
|
|
107
|
+
const detailsFiltered = Object.entries(c.details)
|
|
108
|
+
.filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
|
|
109
|
+
.map(([k, v]) => {
|
|
110
|
+
const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
111
|
+
return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
|
|
112
|
+
})
|
|
113
|
+
.join("");
|
|
114
|
+
// Group issues by file
|
|
115
|
+
const byFile = new Map();
|
|
116
|
+
const noFile = [];
|
|
117
|
+
for (const iss of c.issues) {
|
|
118
|
+
const f = iss.file?.split(":")[0];
|
|
119
|
+
if (f) {
|
|
120
|
+
const arr = byFile.get(f) || [];
|
|
121
|
+
arr.push(iss);
|
|
122
|
+
byFile.set(f, arr);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
noFile.push(iss);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let issuesHtml = "";
|
|
129
|
+
for (const [file, issues] of byFile) {
|
|
130
|
+
issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
|
|
131
|
+
for (const iss of issues) {
|
|
132
|
+
const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
|
|
133
|
+
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>`;
|
|
134
|
+
}
|
|
135
|
+
issuesHtml += `</div>`;
|
|
136
|
+
}
|
|
137
|
+
if (noFile.length > 0) {
|
|
138
|
+
issuesHtml += `<div class="fg"><div class="fn">General</div>`;
|
|
139
|
+
for (const iss of noFile) {
|
|
140
|
+
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>`;
|
|
141
|
+
}
|
|
142
|
+
issuesHtml += `</div>`;
|
|
143
|
+
}
|
|
144
|
+
return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
|
|
145
|
+
<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>
|
|
146
|
+
${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>` : ""}
|
|
147
|
+
${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
|
|
148
|
+
${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
|
|
149
|
+
${c.name === "testing" && !sk && c.details.pyramid ? `<div class="arch-svg">${buildPyramid(c.details.pyramid)}</div>` : ""}
|
|
150
|
+
${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
|
|
151
|
+
${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
|
|
152
|
+
</div>`;
|
|
153
|
+
})
|
|
154
|
+
.join("");
|
|
155
|
+
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
156
|
+
catPagesHtml += `<div id="p-${cs.id}" class="page">
|
|
157
|
+
<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>
|
|
158
|
+
<div class="bar2"><div class="bf2" style="width:${cs.avg}%;background:${clr}"></div></div>
|
|
159
|
+
<div class="sub-nav">${subNav}</div>
|
|
160
|
+
${subPages}
|
|
161
|
+
</div>`;
|
|
162
|
+
}
|
|
163
|
+
return catPagesHtml;
|
|
164
|
+
}
|
|
165
|
+
// ── Issues view (cross-cutting) ──────────────────────────────────────
|
|
166
|
+
export function issuesPage(allChecks, totalIssues, fl) {
|
|
167
|
+
const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
|
|
168
|
+
const errorCount = allIssues.filter((i) => i.severity === "error").length;
|
|
169
|
+
const warnCount = allIssues.filter((i) => i.severity === "warning").length;
|
|
170
|
+
const infoCount = allIssues.filter((i) => i.severity === "info").length;
|
|
171
|
+
const issueRows = allIssues
|
|
172
|
+
.sort((a, b) => (a.severity === "error" ? 0 : a.severity === "warning" ? 1 : 2) - (b.severity === "error" ? 0 : b.severity === "warning" ? 1 : 2))
|
|
173
|
+
.slice(0, 200)
|
|
174
|
+
.map((i) => {
|
|
175
|
+
const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
|
|
176
|
+
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>`;
|
|
177
|
+
})
|
|
178
|
+
.join("");
|
|
179
|
+
return `<div id="p-issues" class="page">
|
|
180
|
+
<h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
|
|
181
|
+
<div class="isf"><span style="color:var(--fail)">${errorCount} errors</span> \u00b7 <span style="color:var(--warn)">${warnCount} warnings</span>${infoCount > 0 ? ` \u00b7 <span style="color:var(--info)">${infoCount} info</span>` : ""}</div>
|
|
182
|
+
<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>
|
|
183
|
+
${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
|
|
184
|
+
</div>`;
|
|
185
|
+
}
|
|
186
|
+
// ── Files view (merged file map + heatmap) ───────────────────────────
|
|
187
|
+
export function filesPage(topFiles, fileIssues, fl) {
|
|
188
|
+
if (topFiles.length === 0) {
|
|
189
|
+
return `<div id="p-files" class="page"><h2>File Health</h2><p style="color:var(--muted)">No file-level issues found.</p></div>`;
|
|
190
|
+
}
|
|
191
|
+
// Heatmap (visual density bars)
|
|
192
|
+
const maxIssues = Math.max(...topFiles.map((f) => f.total));
|
|
193
|
+
const heatmapRows = topFiles
|
|
194
|
+
.slice(0, 30)
|
|
195
|
+
.map((f) => {
|
|
196
|
+
const intensity = maxIssues > 0 ? f.total / maxIssues : 0;
|
|
197
|
+
const r = Math.round(239 * intensity);
|
|
198
|
+
const g = Math.round(68 * (1 - intensity) + 197 * (f.errors === 0 ? 0.3 : 0));
|
|
199
|
+
const color = `rgb(${r},${g},30)`;
|
|
200
|
+
const barW = Math.max(4, Math.round(intensity * 200));
|
|
201
|
+
return `<div class="hm-row"><span class="hm-name">${fl(f.file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${f.total} issues (${f.checks.join(", ")})"></div><span class="hm-count">${f.errors}E ${f.warnings}W</span><span class="hm-checks">${f.checks.join(", ")}</span></div>`;
|
|
202
|
+
})
|
|
203
|
+
.join("");
|
|
204
|
+
return `<div id="p-files" class="page">
|
|
205
|
+
<h2>File Health</h2>
|
|
206
|
+
<p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">${fileIssues.size} files with issues across ${topFiles.reduce((s, f) => { for (const c of f.checks)
|
|
207
|
+
s.add(c); return s; }, new Set()).size} checks. Bar color: red = errors, orange = warnings only. Width = relative issue density.</p>
|
|
208
|
+
${heatmapRows}
|
|
209
|
+
</div>`;
|
|
210
|
+
}
|
|
@@ -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 \u2014 split into dimensions (left) and views (right) */\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;flex-shrink:0}\n.logo span{color:var(--accent)}\n.nav-dims{display:flex;align-items:center;gap:0}\n.nav-views{margin-left:auto;display:flex;align-items:center;gap:0;border-left:1px solid var(--border);padding-left:0.3rem}\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.tn-view{font-size:0.72rem;opacity:0.8}\n.tn-view.active{opacity:1}\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.side-views{padding-top:0.5rem}\n.side-views-label{padding:0.2rem 0.8rem;font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:#444;font-weight:600}\n.side-views .side-check{padding-left:0.8rem}\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\n/* Overview sections */\n.ov-section{margin-bottom:1.5rem}\n.ov-issue{font-size:0.68rem;font-family:\"SF Mono\",monospace;padding:0.2rem 0;display:flex;gap:0.4rem;align-items:baseline;border-bottom:1px solid var(--border)}\n.ov-issue .is{flex-shrink:0}\n.ov-issue.error .is{color:var(--fail)}\n.ov-issue.warning .is{color:var(--warn)}\n.ov-check{color:var(--muted);width:70px;flex-shrink:0;font-size:0.62rem}\n.ov-loc{color:var(--accent);flex-shrink:0;font-size:0.62rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.ov-msg{flex:1;word-break:break-word}\n.ov-link{display:block;margin-top:0.5rem;font-size:0.72rem;color:var(--accent);cursor:pointer;text-decoration:none}\n.ov-link:hover{text-decoration:underline}\n\n/* Timeline */\n.timeline{margin:0.5rem 0;overflow-x:auto}\n.timeline svg{max-width:100%}\n\n/* Bar chart */\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:90px;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;margin-top:1rem}\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;flex-wrap:wrap}\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 health (merged file map + 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.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;min-width:50px}\n.hm-checks{font-size:0.58rem;color:#555;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\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.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}.nav-views{display:none}}\n";
|
|
@@ -0,0 +1,156 @@
|
|
|
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 — split into dimensions (left) and views (right) */
|
|
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;flex-shrink:0}
|
|
11
|
+
.logo span{color:var(--accent)}
|
|
12
|
+
.nav-dims{display:flex;align-items:center;gap:0}
|
|
13
|
+
.nav-views{margin-left:auto;display:flex;align-items:center;gap:0;border-left:1px solid var(--border);padding-left:0.3rem}
|
|
14
|
+
.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}
|
|
15
|
+
.tn:hover{color:var(--text)}
|
|
16
|
+
.tn.active{color:var(--text);border-bottom-color:var(--accent)}
|
|
17
|
+
.tn-view{font-size:0.72rem;opacity:0.8}
|
|
18
|
+
.tn-view.active{opacity:1}
|
|
19
|
+
|
|
20
|
+
/* Sidebar */
|
|
21
|
+
.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}
|
|
22
|
+
.side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}
|
|
23
|
+
.side-section:last-child{border-bottom:none}
|
|
24
|
+
.side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}
|
|
25
|
+
.side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}
|
|
26
|
+
.side-cat:hover{background:#14141a}
|
|
27
|
+
.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}
|
|
28
|
+
.side-check:hover{color:var(--text);background:#14141a}
|
|
29
|
+
.side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}
|
|
30
|
+
.side-views{padding-top:0.5rem}
|
|
31
|
+
.side-views-label{padding:0.2rem 0.8rem;font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:#444;font-weight:600}
|
|
32
|
+
.side-views .side-check{padding-left:0.8rem}
|
|
33
|
+
|
|
34
|
+
/* Content */
|
|
35
|
+
.content{max-width:900px;margin-left:200px;padding:2rem}
|
|
36
|
+
.page{display:none;animation:fadeIn 0.15s}
|
|
37
|
+
.page.active{display:block}
|
|
38
|
+
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
|
39
|
+
|
|
40
|
+
/* Overview */
|
|
41
|
+
.dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}
|
|
42
|
+
.hero{display:flex;align-items:center;gap:1rem}
|
|
43
|
+
.hero svg{width:100px;height:100px}
|
|
44
|
+
.hc{display:flex;flex-direction:column}
|
|
45
|
+
.hg{font-size:2.5rem;font-weight:900;line-height:1}
|
|
46
|
+
.hs{font-size:1rem;font-weight:600}
|
|
47
|
+
.hd{font-size:0.68rem;color:var(--muted)}
|
|
48
|
+
.radar{flex:1;display:flex;justify-content:center}
|
|
49
|
+
.radar svg{max-width:240px;width:100%}
|
|
50
|
+
.cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}
|
|
51
|
+
.cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}
|
|
52
|
+
.cc:hover{border-color:var(--accent)}
|
|
53
|
+
.cc-s{font-size:1.8rem;font-weight:900}
|
|
54
|
+
.cc-l{font-size:0.75rem;color:var(--muted)}
|
|
55
|
+
.cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}
|
|
56
|
+
.mc{font-size:0.65rem;font-weight:800}
|
|
57
|
+
h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}
|
|
58
|
+
|
|
59
|
+
/* Overview sections */
|
|
60
|
+
.ov-section{margin-bottom:1.5rem}
|
|
61
|
+
.ov-issue{font-size:0.68rem;font-family:"SF Mono",monospace;padding:0.2rem 0;display:flex;gap:0.4rem;align-items:baseline;border-bottom:1px solid var(--border)}
|
|
62
|
+
.ov-issue .is{flex-shrink:0}
|
|
63
|
+
.ov-issue.error .is{color:var(--fail)}
|
|
64
|
+
.ov-issue.warning .is{color:var(--warn)}
|
|
65
|
+
.ov-check{color:var(--muted);width:70px;flex-shrink:0;font-size:0.62rem}
|
|
66
|
+
.ov-loc{color:var(--accent);flex-shrink:0;font-size:0.62rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
67
|
+
.ov-msg{flex:1;word-break:break-word}
|
|
68
|
+
.ov-link{display:block;margin-top:0.5rem;font-size:0.72rem;color:var(--accent);cursor:pointer;text-decoration:none}
|
|
69
|
+
.ov-link:hover{text-decoration:underline}
|
|
70
|
+
|
|
71
|
+
/* Timeline */
|
|
72
|
+
.timeline{margin:0.5rem 0;overflow-x:auto}
|
|
73
|
+
.timeline svg{max-width:100%}
|
|
74
|
+
|
|
75
|
+
/* Bar chart */
|
|
76
|
+
.bars{margin-bottom:1.5rem}
|
|
77
|
+
.brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}
|
|
78
|
+
.bl{width:90px;text-align:right;color:var(--muted);flex-shrink:0}
|
|
79
|
+
.bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
|
|
80
|
+
.bf{height:100%;border-radius:2px}
|
|
81
|
+
.bv{width:36px;font-weight:700;font-size:0.68rem}
|
|
82
|
+
.stack{display:flex;gap:0.35rem;flex-wrap:wrap;margin-top:1rem}
|
|
83
|
+
.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)}
|
|
84
|
+
|
|
85
|
+
/* Category pages */
|
|
86
|
+
.cat-head{margin-bottom:0.3rem}
|
|
87
|
+
.bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
|
|
88
|
+
.bf2{height:100%;border-radius:2px}
|
|
89
|
+
.sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem;flex-wrap:wrap}
|
|
90
|
+
.sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
|
|
91
|
+
.sn:hover{color:var(--text)}
|
|
92
|
+
.sn.active{color:var(--text);border-bottom-color:var(--accent)}
|
|
93
|
+
.sp{display:none}.sp.active{display:block}
|
|
94
|
+
|
|
95
|
+
/* Check detail */
|
|
96
|
+
.ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}
|
|
97
|
+
.ch-g{font-size:2rem;font-weight:900}
|
|
98
|
+
.ch-s{display:block;font-size:0.7rem;color:var(--muted)}
|
|
99
|
+
.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}
|
|
100
|
+
.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}
|
|
101
|
+
.ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}
|
|
102
|
+
.ip-row:last-child{margin-bottom:0}
|
|
103
|
+
.ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}
|
|
104
|
+
.skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}
|
|
105
|
+
.kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}
|
|
106
|
+
.kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}
|
|
107
|
+
.k{color:var(--muted);margin-right:0.3rem}
|
|
108
|
+
.v{font-weight:600}
|
|
109
|
+
|
|
110
|
+
/* Issue list grouped by file */
|
|
111
|
+
.iss-list{margin-top:1rem}
|
|
112
|
+
.fg{margin-bottom:0.8rem}
|
|
113
|
+
.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}
|
|
114
|
+
.fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}
|
|
115
|
+
.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}
|
|
116
|
+
.is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}
|
|
117
|
+
.ir.error .is{color:var(--fail);background:#ef444418}
|
|
118
|
+
.ir.warning .is{color:var(--warn);background:#eab30818}
|
|
119
|
+
.il{color:var(--accent);min-width:2rem;flex-shrink:0}
|
|
120
|
+
.im{flex:1;word-break:break-word}
|
|
121
|
+
.iru{color:#555;font-size:0.55rem}
|
|
122
|
+
|
|
123
|
+
/* All issues table */
|
|
124
|
+
.isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}
|
|
125
|
+
.it{width:100%;border-collapse:collapse;font-size:0.68rem}
|
|
126
|
+
.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)}
|
|
127
|
+
.it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:"SF Mono",monospace;font-size:0.62rem}
|
|
128
|
+
.it tr.error .is2{color:var(--fail)}
|
|
129
|
+
.it tr.warning .is2{color:var(--warn)}
|
|
130
|
+
.is2{font-weight:800;width:1rem}
|
|
131
|
+
.ic2{color:var(--muted);width:70px}
|
|
132
|
+
.il2{color:var(--muted)}
|
|
133
|
+
.iru2{color:#555;font-size:0.58rem}
|
|
134
|
+
|
|
135
|
+
/* File health (merged file map + heatmap) */
|
|
136
|
+
.fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}
|
|
137
|
+
.ff{width:200px;font-family:"SF Mono",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
138
|
+
.fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
|
|
139
|
+
.fbf{height:100%;border-radius:2px}
|
|
140
|
+
.fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}
|
|
141
|
+
.fcs{font-size:0.6rem;color:#555}
|
|
142
|
+
.hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}
|
|
143
|
+
.hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:"SF Mono",monospace;font-size:0.65rem}
|
|
144
|
+
.hm-bar{height:14px;border-radius:3px;min-width:4px}
|
|
145
|
+
.hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0;min-width:50px}
|
|
146
|
+
.hm-checks{font-size:0.58rem;color:#555;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
147
|
+
|
|
148
|
+
.footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}
|
|
149
|
+
.footer a{color:var(--muted)}
|
|
150
|
+
.flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
|
|
151
|
+
.arch-svg{margin:1rem 0;overflow-x:auto}
|
|
152
|
+
.arch-svg svg{border-radius:8px}
|
|
153
|
+
.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}
|
|
154
|
+
.ir:hover .cp-btn{opacity:0.6}
|
|
155
|
+
@media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}.nav-views{display:none}}
|
|
156
|
+
`;
|
package/dist/report/svg.d.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
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
|
+
/** Score timeline — larger chart showing score history over last N runs. */
|
|
8
|
+
export declare function buildTimeline(entries: {
|
|
9
|
+
score: number;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
}[], opts?: {
|
|
12
|
+
width?: number;
|
|
13
|
+
height?: number;
|
|
14
|
+
}): string;
|
|
15
|
+
/** Testing pyramid — proportional triangle showing test layer distribution. */
|
|
16
|
+
export declare function buildPyramid(layers: {
|
|
17
|
+
unit: number;
|
|
18
|
+
integration: number;
|
|
19
|
+
component: number;
|
|
20
|
+
e2e: number;
|
|
21
|
+
}): string;
|
|
22
|
+
/** Badge SVG — shields.io-style badge for README embedding. */
|
|
23
|
+
export declare function buildBadge(score: number, grade: string): string;
|
|
24
|
+
/** Sparkline — mini line chart for trend display. */
|
|
25
|
+
export declare function buildSparkline(values: number[], opts?: {
|
|
3
26
|
width?: number;
|
|
4
27
|
height?: number;
|
|
5
28
|
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;
|
|
29
|
+
}): string;
|
package/dist/report/svg.js
CHANGED
|
@@ -1,23 +1,166 @@
|
|
|
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
|
+
/** Score timeline — larger chart showing score history over last N runs. */
|
|
46
|
+
export function buildTimeline(entries, opts) {
|
|
47
|
+
const width = opts?.width ?? 600;
|
|
48
|
+
const height = opts?.height ?? 120;
|
|
49
|
+
const pad = { top: 20, right: 20, bottom: 25, left: 35 };
|
|
50
|
+
const w = width - pad.left - pad.right;
|
|
51
|
+
const h = height - pad.top - pad.bottom;
|
|
52
|
+
if (entries.length === 0)
|
|
53
|
+
return "";
|
|
54
|
+
// Y axis: 0-100 always
|
|
55
|
+
const yScale = (v) => pad.top + h - (v / 100) * h;
|
|
56
|
+
const xScale = (i) => pad.left + (entries.length === 1 ? w / 2 : (i / (entries.length - 1)) * w);
|
|
57
|
+
// Grid lines at 25, 50, 75
|
|
58
|
+
let grid = "";
|
|
59
|
+
for (const v of [25, 50, 75]) {
|
|
60
|
+
const y = yScale(v).toFixed(1);
|
|
61
|
+
grid += `<line x1="${pad.left}" y1="${y}" x2="${pad.left + w}" y2="${y}" stroke="#1e1e24" stroke-width="0.7"/>`;
|
|
62
|
+
grid += `<text x="${pad.left - 6}" y="${y}" text-anchor="end" dominant-baseline="middle" fill="#555" font-size="8">${v}</text>`;
|
|
63
|
+
}
|
|
64
|
+
// Score line + dots
|
|
65
|
+
const points = entries.map((e, i) => `${xScale(i).toFixed(1)},${yScale(e.score).toFixed(1)}`).join(" ");
|
|
66
|
+
// Grade colors per dot
|
|
67
|
+
const dots = entries
|
|
68
|
+
.map((e, i) => {
|
|
69
|
+
const color = e.score >= 90 ? "#22c55e" : e.score >= 75 ? "#84cc16" : e.score >= 60 ? "#eab308" : e.score >= 40 ? "#f97316" : "#ef4444";
|
|
70
|
+
return `<circle cx="${xScale(i).toFixed(1)}" cy="${yScale(e.score).toFixed(1)}" r="3" fill="${color}"><title>${e.timestamp.split("T")[0]} — ${e.score}</title></circle>`;
|
|
71
|
+
})
|
|
72
|
+
.join("");
|
|
73
|
+
// X-axis labels (first, middle, last)
|
|
74
|
+
let xLabels = "";
|
|
75
|
+
const labelIndices = entries.length <= 3 ? entries.map((_, i) => i) : [0, Math.floor(entries.length / 2), entries.length - 1];
|
|
76
|
+
for (const i of labelIndices) {
|
|
77
|
+
const label = entries[i].timestamp.split("T")[0].slice(5); // MM-DD
|
|
78
|
+
xLabels += `<text x="${xScale(i).toFixed(1)}" y="${height - 4}" text-anchor="middle" fill="#555" font-size="7">${label}</text>`;
|
|
79
|
+
}
|
|
80
|
+
// Gradient fill under the line
|
|
81
|
+
const areaPoints = `${xScale(0).toFixed(1)},${yScale(0).toFixed(1)} ${points} ${xScale(entries.length - 1).toFixed(1)},${yScale(0).toFixed(1)}`;
|
|
82
|
+
return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
83
|
+
<defs><linearGradient id="tlg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#818cf8" stop-opacity="0.3"/><stop offset="100%" stop-color="#818cf8" stop-opacity="0.02"/></linearGradient></defs>
|
|
84
|
+
${grid}
|
|
85
|
+
<polygon points="${areaPoints}" fill="url(#tlg)"/>
|
|
86
|
+
<polyline points="${points}" fill="none" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
87
|
+
${dots}${xLabels}
|
|
88
|
+
</svg>`;
|
|
89
|
+
}
|
|
90
|
+
/** Testing pyramid — proportional triangle showing test layer distribution. */
|
|
91
|
+
export function buildPyramid(layers) {
|
|
92
|
+
const total = layers.unit + layers.integration + layers.component + layers.e2e;
|
|
93
|
+
if (total === 0)
|
|
94
|
+
return "";
|
|
95
|
+
const w = 200, h = 160;
|
|
96
|
+
const cx = w / 2;
|
|
97
|
+
// Pyramid: e2e at top (smallest), unit at bottom (largest)
|
|
98
|
+
// Each layer gets proportional height
|
|
99
|
+
const items = [
|
|
100
|
+
{ label: "E2E", count: layers.e2e, color: "#ef4444" },
|
|
101
|
+
{ label: "Component", count: layers.component, color: "#f97316" },
|
|
102
|
+
{ label: "Integration", count: layers.integration, color: "#eab308" },
|
|
103
|
+
{ label: "Unit", count: layers.unit, color: "#22c55e" },
|
|
104
|
+
];
|
|
105
|
+
const layerH = (h - 20) / 4;
|
|
106
|
+
let svg = "";
|
|
107
|
+
for (let i = 0; i < 4; i++) {
|
|
108
|
+
const item = items[i];
|
|
109
|
+
const y = 10 + i * layerH;
|
|
110
|
+
// Trapezoid: wider at bottom
|
|
111
|
+
const topW = ((i + 0.5) / 4) * (w - 40);
|
|
112
|
+
const botW = ((i + 1.5) / 4) * (w - 40);
|
|
113
|
+
const opacity = item.count > 0 ? 1 : 0.2;
|
|
114
|
+
const x1t = cx - topW / 2, x2t = cx + topW / 2;
|
|
115
|
+
const x1b = cx - botW / 2, x2b = cx + botW / 2;
|
|
116
|
+
svg += `<polygon points="${x1t},${y} ${x2t},${y} ${x2b},${y + layerH} ${x1b},${y + layerH}" fill="${item.color}" opacity="${opacity * 0.25}" stroke="${item.color}" stroke-opacity="${opacity * 0.6}" stroke-width="1"/>`;
|
|
117
|
+
svg += `<text x="${cx}" y="${y + layerH / 2 + 3}" text-anchor="middle" fill="${item.count > 0 ? "#e5e5e5" : "#555"}" font-size="9" font-weight="600">${item.label} (${item.count})</text>`;
|
|
118
|
+
}
|
|
119
|
+
return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${svg}</svg>`;
|
|
120
|
+
}
|
|
121
|
+
/** Badge SVG — shields.io-style badge for README embedding. */
|
|
122
|
+
export function buildBadge(score, grade) {
|
|
123
|
+
const color = score >= 90 ? "#22c55e" : score >= 75 ? "#84cc16" : score >= 60 ? "#eab308" : score >= 40 ? "#f97316" : "#ef4444";
|
|
124
|
+
const label = "vcqa";
|
|
125
|
+
const value = `${grade} ${score}`;
|
|
126
|
+
const labelW = 36, valueW = 44, totalW = labelW + valueW;
|
|
127
|
+
const h = 20, r = 3;
|
|
128
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${h}">
|
|
129
|
+
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
|
130
|
+
<clipPath id="r"><rect width="${totalW}" height="${h}" rx="${r}" fill="#fff"/></clipPath>
|
|
131
|
+
<g clip-path="url(#r)">
|
|
132
|
+
<rect width="${labelW}" height="${h}" fill="#555"/>
|
|
133
|
+
<rect x="${labelW}" width="${valueW}" height="${h}" fill="${color}"/>
|
|
134
|
+
<rect width="${totalW}" height="${h}" fill="url(#s)"/>
|
|
135
|
+
</g>
|
|
136
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
137
|
+
<text x="${labelW / 2}" y="14" fill="#010101" fill-opacity=".3">${label}</text>
|
|
138
|
+
<text x="${labelW / 2}" y="13">${label}</text>
|
|
139
|
+
<text x="${labelW + valueW / 2}" y="14" fill="#010101" fill-opacity=".3">${value}</text>
|
|
140
|
+
<text x="${labelW + valueW / 2}" y="13">${value}</text>
|
|
141
|
+
</g>
|
|
142
|
+
</svg>`;
|
|
143
|
+
}
|
|
144
|
+
/** Sparkline — mini line chart for trend display. */
|
|
145
|
+
export function buildSparkline(values, opts) {
|
|
146
|
+
const width = opts?.width ?? 120;
|
|
147
|
+
const height = opts?.height ?? 30;
|
|
148
|
+
const color = opts?.color ?? "#818cf8";
|
|
4
149
|
if (values.length === 0)
|
|
5
150
|
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>`;
|
|
151
|
+
if (values.length === 1) {
|
|
152
|
+
const y = (height / 2).toFixed(1);
|
|
153
|
+
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>`;
|
|
154
|
+
}
|
|
155
|
+
const max = Math.max(...values, 1);
|
|
156
|
+
const min = Math.min(...values, 0);
|
|
157
|
+
const range = max - min || 1;
|
|
158
|
+
const step = width / (values.length - 1);
|
|
159
|
+
const points = values.map((v, i) => `${(i * step).toFixed(1)},${(height - ((v - min) / range) * (height - 4) - 2).toFixed(1)}`).join(" ");
|
|
160
|
+
const dots = values.map((v, i) => {
|
|
161
|
+
const x = (i * step).toFixed(1);
|
|
162
|
+
const y = (height - ((v - min) / range) * (height - 4) - 2).toFixed(1);
|
|
163
|
+
return `<circle cx="${x}" cy="${y}" r="1.5" fill="${color}"/>`;
|
|
164
|
+
}).join("");
|
|
165
|
+
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
166
|
}
|