@vibecodeqa/cli 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -31,17 +31,22 @@ import { computeTrend, formatTrend } from "./trend.js";
31
31
  import { gradeFromScore } from "./types.js";
32
32
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
33
33
  const VERSION = pkg.version;
34
- const args = process.argv.slice(2);
35
- const flags = new Set(args.filter((a) => a.startsWith("--")));
36
- const cwd = resolve(args.find((a) => !a.startsWith("--")) || ".");
37
- const outputDir = join(cwd, ".vibe-check");
38
- const jsonOnly = flags.has("--json");
39
- const ciMode = flags.has("--ci");
40
- const skipTests = flags.has("--skip-tests");
41
- const watchMode = flags.has("--watch");
42
- const badgeMode = flags.has("--badge");
43
- const sarifMode = flags.has("--sarif");
44
- const uploadMode = flags.has("--upload");
34
+ function parseFlags() {
35
+ const args = process.argv.slice(2);
36
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
37
+ const cwd = resolve(args.find((a) => !a.startsWith("--")) || ".");
38
+ return {
39
+ cwd,
40
+ outputDir: join(cwd, ".vibe-check"),
41
+ jsonOnly: flags.has("--json"),
42
+ ciMode: flags.has("--ci"),
43
+ skipTests: flags.has("--skip-tests"),
44
+ watchMode: flags.has("--watch"),
45
+ badgeMode: flags.has("--badge"),
46
+ sarifMode: flags.has("--sarif"),
47
+ uploadMode: flags.has("--upload"),
48
+ };
49
+ }
45
50
  function color(grade) {
46
51
  if (grade === "A")
47
52
  return "\x1b[32m";
@@ -49,23 +54,7 @@ function color(grade) {
49
54
  return "\x1b[33m";
50
55
  return "\x1b[31m";
51
56
  }
52
- async function main() {
53
- const start = Date.now();
54
- if (!jsonOnly) {
55
- console.log("");
56
- console.log(` \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION}`);
57
- console.log(` \x1b[2m${cwd}\x1b[0m`);
58
- console.log("");
59
- }
60
- const stack = detectStack(cwd);
61
- if (!jsonOnly) {
62
- const parts = [stack.language, stack.framework, stack.bundler, stack.testRunner, stack.linter, stack.packageManager].filter((v) => v !== "none" && v !== "unknown");
63
- console.log(` stack: ${parts.join(" + ")}`);
64
- console.log("");
65
- }
66
- const checks = [];
67
- const isDart = stack.language === "dart";
68
- // All runners grouped by category
57
+ function runChecks(cwd, stack, skipTests, isDart, jsonOnly) {
69
58
  const runners = [
70
59
  // Foundations
71
60
  { name: "structure", fn: () => runStructure(cwd, stack) },
@@ -97,6 +86,7 @@ async function main() {
97
86
  { name: "doc-coherence", fn: () => runDocCoherence(cwd) },
98
87
  { name: "code-coherence", fn: () => runCodeCoherence(cwd) },
99
88
  ];
89
+ const checks = [];
100
90
  for (const runner of runners) {
101
91
  if (!jsonOnly)
102
92
  process.stdout.write(` ${runner.name.padEnd(14)}`);
@@ -113,20 +103,9 @@ async function main() {
113
103
  console.log(`${c}${label.padEnd(5)}${scoreStr}\x1b[0m \x1b[2m${result.duration}ms\x1b[0m${issueStr}`);
114
104
  }
115
105
  }
116
- const score = computeScore(checks);
117
- const grade = gradeFromScore(score);
118
- const duration = Date.now() - start;
119
- const totalIssues = checks.reduce((s, c) => s + c.issues.length, 0);
120
- const report = {
121
- version: VERSION,
122
- timestamp: new Date().toISOString(),
123
- score,
124
- grade,
125
- checks,
126
- meta: { cwd, node: process.version, duration, stack, ...detectRepoUrl(cwd) },
127
- };
128
- // Trend comparison (read previous report before overwriting)
129
- const trend = computeTrend(report, outputDir);
106
+ return checks;
107
+ }
108
+ async function writeOutputs(report, outputDir, flags) {
130
109
  if (!existsSync(outputDir))
131
110
  mkdirSync(outputDir, { recursive: true });
132
111
  // Save to history before overwriting current report
@@ -159,58 +138,127 @@ async function main() {
159
138
  writeFileSync(join(reportDir, filename), html);
160
139
  }
161
140
  // Badge SVG
162
- if (badgeMode) {
141
+ if (flags.badgeMode) {
163
142
  const { buildBadge } = await import("./report/svg.js");
164
- const badgeSvg = buildBadge(score, grade);
143
+ const badgeSvg = buildBadge(report.score, report.grade);
165
144
  writeFileSync(join(outputDir, "badge.svg"), badgeSvg);
166
145
  }
167
146
  // SARIF output for GitHub Code Scanning
168
- if (sarifMode) {
147
+ if (flags.sarifMode) {
169
148
  const { generateSARIF } = await import("./report/sarif.js");
170
149
  writeFileSync(join(outputDir, "report.sarif"), generateSARIF(report));
171
150
  }
172
- // Upload to VibeCode QA dashboard
173
- if (uploadMode) {
174
- const repo = report.meta.repoUrl?.replace(/^https:\/\/github\.com\//, "") || cwd.split("/").pop() || "project";
175
- const token = process.env.VCQA_TOKEN || "";
176
- try {
177
- const res = await fetch("https://api.vibecodeqa.online/api/reports", {
178
- method: "POST",
179
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
180
- body: JSON.stringify({ repo, report }),
181
- });
182
- if (res.ok) {
183
- const data = (await res.json());
184
- if (!jsonOnly)
185
- console.log(` \x1b[32m\u2713 Uploaded to dashboard\x1b[0m \x1b[2m(${data.totalReports || 1} reports)\x1b[0m`);
186
- }
187
- else if (!jsonOnly) {
188
- console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\x1b[0m \x1b[2m(set VCQA_TOKEN env var)\x1b[0m`);
189
- }
190
- }
191
- catch {
192
- if (!jsonOnly)
193
- console.log(` \x1b[33m\u26a0 Upload failed (network error)\x1b[0m`);
194
- }
195
- }
196
- if (jsonOnly) {
151
+ }
152
+ function printResults(report, trend, flags, outputDir) {
153
+ const { score, grade, checks } = report;
154
+ const totalIssues = checks.reduce((s, c) => s + c.issues.length, 0);
155
+ if (flags.jsonOnly) {
197
156
  console.log(JSON.stringify(report));
198
157
  }
199
158
  else {
200
159
  const gc = color(grade);
201
160
  console.log("");
202
- console.log(` ${gc}\x1b[1m${grade}\x1b[0m ${gc}${score}/100\x1b[0m \x1b[2m${checks.length} checks · ${totalIssues} issues · ${duration}ms\x1b[0m`);
161
+ console.log(` ${gc}\x1b[1m${grade}\x1b[0m ${gc}${score}/100\x1b[0m \x1b[2m${checks.length} checks · ${totalIssues} issues · ${report.meta.duration}ms\x1b[0m`);
203
162
  if (trend)
204
163
  console.log(formatTrend(trend));
205
164
  console.log("");
206
165
  console.log(` \x1b[2mReport: ${join(outputDir, "report/index.html")}\x1b[0m`);
207
166
  console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
208
- if (badgeMode)
167
+ if (flags.badgeMode)
209
168
  console.log(` \x1b[2mBadge: ${join(outputDir, "badge.svg")}\x1b[0m`);
210
- if (sarifMode)
169
+ if (flags.sarifMode)
211
170
  console.log(` \x1b[2mSARIF: ${join(outputDir, "report.sarif")}\x1b[0m`);
212
171
  console.log("");
213
172
  }
173
+ }
174
+ async function handleUpload(report, cwd, jsonOnly) {
175
+ const repo = report.meta.repoUrl?.replace(/^https:\/\/github\.com\//, "") || cwd.split("/").pop() || "project";
176
+ const token = process.env.VCQA_TOKEN || "";
177
+ try {
178
+ const res = await fetch("https://api.vibecodeqa.online/api/reports", {
179
+ method: "POST",
180
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
181
+ body: JSON.stringify({ repo, report }),
182
+ });
183
+ if (res.ok) {
184
+ const data = (await res.json());
185
+ if (!jsonOnly)
186
+ console.log(` \x1b[32m\u2713 Uploaded to dashboard\x1b[0m \x1b[2m(${data.totalReports || 1} reports)\x1b[0m`);
187
+ }
188
+ else if (!jsonOnly) {
189
+ console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\x1b[0m \x1b[2m(set VCQA_TOKEN env var)\x1b[0m`);
190
+ }
191
+ }
192
+ catch {
193
+ if (!jsonOnly)
194
+ console.log(` \x1b[33m\u26a0 Upload failed (network error)\x1b[0m`);
195
+ }
196
+ }
197
+ async function startWatch(cwd) {
198
+ const { watch } = await import("node:fs");
199
+ const srcDirs = ["src", "web/src"].map((d) => join(cwd, d)).filter((d) => existsSync(d));
200
+ if (srcDirs.length === 0) {
201
+ console.log(" \x1b[31mNo src/ directory to watch\x1b[0m");
202
+ process.exit(1);
203
+ }
204
+ console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m");
205
+ console.log("");
206
+ let debounce = null;
207
+ let running = false;
208
+ for (const dir of srcDirs) {
209
+ watch(dir, { recursive: true }, (_event, filename) => {
210
+ if (!filename || filename.includes("node_modules") || filename.includes(".vibe-check"))
211
+ return;
212
+ if (running)
213
+ return;
214
+ if (debounce)
215
+ clearTimeout(debounce);
216
+ debounce = setTimeout(async () => {
217
+ running = true;
218
+ console.log(` \x1b[2mChanged: ${filename} — re-scanning...\x1b[0m`);
219
+ await main().catch(() => { });
220
+ running = false;
221
+ }, 500);
222
+ });
223
+ }
224
+ // Keep process alive
225
+ await new Promise(() => { });
226
+ }
227
+ async function main() {
228
+ const flags = parseFlags();
229
+ const { cwd, outputDir, jsonOnly, ciMode, skipTests, watchMode } = flags;
230
+ const start = Date.now();
231
+ if (!jsonOnly) {
232
+ console.log("");
233
+ console.log(` \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION}`);
234
+ console.log(` \x1b[2m${cwd}\x1b[0m`);
235
+ console.log("");
236
+ }
237
+ const stack = detectStack(cwd);
238
+ if (!jsonOnly) {
239
+ const parts = [stack.language, stack.framework, stack.bundler, stack.testRunner, stack.linter, stack.packageManager].filter((v) => v !== "none" && v !== "unknown");
240
+ console.log(` stack: ${parts.join(" + ")}`);
241
+ console.log("");
242
+ }
243
+ const isDart = stack.language === "dart";
244
+ const checks = runChecks(cwd, stack, skipTests, isDart, jsonOnly);
245
+ const score = computeScore(checks);
246
+ const grade = gradeFromScore(score);
247
+ const duration = Date.now() - start;
248
+ const report = {
249
+ version: VERSION,
250
+ timestamp: new Date().toISOString(),
251
+ score,
252
+ grade,
253
+ checks,
254
+ meta: { cwd, node: process.version, duration, stack, ...detectRepoUrl(cwd) },
255
+ };
256
+ const trend = computeTrend(report, outputDir);
257
+ await writeOutputs(report, outputDir, flags);
258
+ printResults(report, trend, flags, outputDir);
259
+ if (flags.uploadMode) {
260
+ await handleUpload(report, cwd, jsonOnly);
261
+ }
214
262
  if (ciMode && score < 60) {
215
263
  process.exit(1);
216
264
  }
@@ -224,36 +272,8 @@ async function main() {
224
272
  /* failed to open browser */
225
273
  }
226
274
  }
227
- // Watch mode — re-run on file changes
228
275
  if (watchMode) {
229
- const { watch } = await import("node:fs");
230
- const srcDirs = ["src", "web/src"].map((d) => join(cwd, d)).filter((d) => existsSync(d));
231
- if (srcDirs.length === 0) {
232
- console.log(" \x1b[31mNo src/ directory to watch\x1b[0m");
233
- process.exit(1);
234
- }
235
- console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m");
236
- console.log("");
237
- let debounce = null;
238
- let running = false;
239
- for (const dir of srcDirs) {
240
- watch(dir, { recursive: true }, (_event, filename) => {
241
- if (!filename || filename.includes("node_modules") || filename.includes(".vibe-check"))
242
- return;
243
- if (running)
244
- return; // prevent concurrent re-runs (M5)
245
- if (debounce)
246
- clearTimeout(debounce);
247
- debounce = setTimeout(async () => {
248
- running = true;
249
- console.log(` \x1b[2mChanged: ${filename} — re-scanning...\x1b[0m`);
250
- await main().catch(() => { });
251
- running = false;
252
- }, 500);
253
- });
254
- }
255
- // Keep process alive
256
- await new Promise(() => { });
276
+ await startWatch(cwd);
257
277
  }
258
278
  }
259
279
  main().catch((err) => {
@@ -13,7 +13,7 @@
13
13
  import { getCheckMeta } from "../check-meta.js";
14
14
  import { det, e, fileLink, gc } from "./components.js";
15
15
  import { FAVICON_SVG } from "./favicon.js";
16
- import { categoryPage, filesPage, issuesPage, overviewPage } from "./pages.js";
16
+ import { categoryPage, filesPage, issuesPage, overviewPage, trendsPage } from "./pages.js";
17
17
  import { CSS } from "./styles.js";
18
18
  export const GROUPS = [
19
19
  { id: "foundations", label: "Foundations", file: "foundations.html", checks: ["structure", "lint", "types", "type-safety", "standards"] },
@@ -93,7 +93,7 @@ export function generatePages(report, historyDir) {
93
93
  const badge = premium
94
94
  ? `<span style="color:#6366f1">PRO</span>`
95
95
  : `<span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade} ${sk ? "" : c.score}</span>`;
96
- return `<a class="side-check" onclick="var t=document.querySelector('[data-sub=\\'${cs.id}-${c.name}\\']');if(t)sub(t,'${cs.id}')" title="${e(meta.label)}">${badge} ${e(meta.label)}</a>`;
96
+ return `<a class="side-check" onclick="let t=document.querySelector('[data-sub=\\'${cs.id}-${c.name}\\']');if(t)sub(t,'${cs.id}')" title="${e(meta.label)}">${badge} ${e(meta.label)}</a>`;
97
97
  })
98
98
  .join("") +
99
99
  `</div>` +
@@ -121,6 +121,13 @@ export function generatePages(report, historyDir) {
121
121
  `</div>` +
122
122
  sidebarViews(totalIssues, fileIssues.size);
123
123
  pages.set("files.html", w("files", filesSidebar, filesPage(topFiles, fileIssues, fl)));
124
+ // ── Trends page ──
125
+ const trendsSidebar = sidebarScore(report) +
126
+ `<div class="side-section"><div class="side-cat-title">History</div>` +
127
+ `<div class="side-stat"><span style="color:var(--text)">${historyDir ? "30" : "0"}</span> scans stored</div>` +
128
+ `</div>` +
129
+ sidebarViews(totalIssues, fileIssues.size);
130
+ pages.set("trends.html", w("trends", trendsSidebar, trendsPage(historyDir)));
124
131
  return pages;
125
132
  }
126
133
  export function generateHTML(report, historyDir) {
@@ -138,6 +145,7 @@ function wrap(proj, currentId, report, totalIssues, sidebar, content) {
138
145
  const navItems = [
139
146
  { id: "overview", label: "Overview", file: "index.html" },
140
147
  ...GROUPS.map((g) => ({ id: g.id, label: g.label, file: g.file })),
148
+ { id: "trends", label: "Trends", file: "trends.html" },
141
149
  { id: "issues", label: `Issues (${totalIssues})`, file: "issues.html" },
142
150
  { id: "files", label: "Files", file: "files.html" },
143
151
  ];
@@ -178,10 +186,10 @@ function sub(el,cat){
178
186
  document.querySelectorAll('.sp').forEach(s=>{s.classList.toggle('active',s.dataset.sub===id)});
179
187
  }
180
188
  document.addEventListener('click',function(ev){
181
- var btn=ev.target.closest('.cp-btn');
189
+ const btn=ev.target.closest('.cp-btn');
182
190
  if(!btn)return;
183
- var text=btn.dataset.prompt||'';
184
- try{navigator.clipboard.writeText(text)}catch(e){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta)}
191
+ const text=btn.dataset.prompt||'';
192
+ try{navigator.clipboard.writeText(text)}catch(e){const ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta)}
185
193
  btn.textContent='\\u2713';setTimeout(function(){btn.textContent='\\ud83d\\udccb'},1000);
186
194
  });
187
195
  </script>
@@ -23,4 +23,5 @@ export declare function filesPage(topFiles: FileEntry[], fileIssues: Map<string,
23
23
  warnings: number;
24
24
  checks: Set<string>;
25
25
  }>, fl: FL): string;
26
+ export declare function trendsPage(historyDir: string | undefined): string;
26
27
  export {};
@@ -220,3 +220,70 @@ export function filesPage(topFiles, fileIssues, fl) {
220
220
  }, new Set()).size} checks.</p>
221
221
  ${heatmapRows}`;
222
222
  }
223
+ // ── Trends page ──────────────────────────────────────────
224
+ export function trendsPage(historyDir) {
225
+ if (!historyDir) {
226
+ return `<h2>Trends</h2><p class="muted">No history data yet. Run the scanner multiple times to see trends.</p>`;
227
+ }
228
+ const history = loadHistory(historyDir);
229
+ if (history.length < 2) {
230
+ return `<h2>Trends</h2><p class="muted">Need at least 2 scans to show trends. Run the scanner again.</p>`;
231
+ }
232
+ // Overall score timeline (large)
233
+ const overallChart = buildTimeline(history.map((h) => ({ score: h.score, timestamp: h.timestamp })), { width: 700, height: 150 });
234
+ // Collect all check names from latest entry
235
+ const latest = history[history.length - 1];
236
+ const checkNames = [...latest.checkScores.keys()];
237
+ // Per-check mini charts
238
+ const checkCharts = checkNames
239
+ .map((name) => {
240
+ const data = history
241
+ .map((h) => ({ score: h.checkScores.get(name) ?? 0, timestamp: h.timestamp }))
242
+ .filter((d) => d.score > 0);
243
+ if (data.length < 2)
244
+ return "";
245
+ const current = data[data.length - 1].score;
246
+ const prev = data[data.length - 2].score;
247
+ const delta = current - prev;
248
+ const deltaStr = delta > 0 ? `<span style="color:var(--pass)">+${delta}</span>` : delta < 0 ? `<span style="color:var(--fail)">${delta}</span>` : `<span class="muted">=</span>`;
249
+ const color = current >= 90 ? "var(--pass)" : current >= 75 ? "#84cc16" : current >= 60 ? "var(--warn)" : "var(--fail)";
250
+ const chart = buildTimeline(data, { width: 300, height: 60 });
251
+ return `<div class="trend-card">
252
+ <div class="trend-header"><span class="trend-name">${name}</span><span class="trend-score" style="color:${color}">${current}</span>${deltaStr}</div>
253
+ <div class="trend-chart">${chart}</div>
254
+ </div>`;
255
+ })
256
+ .filter(Boolean)
257
+ .join("");
258
+ // Score delta table
259
+ const deltaRows = checkNames
260
+ .map((name) => {
261
+ const scores = history.map((h) => h.checkScores.get(name) ?? 0);
262
+ const first = scores.find((s) => s > 0) ?? 0;
263
+ const last = scores[scores.length - 1];
264
+ const delta = last - first;
265
+ if (first === 0 && last === 0)
266
+ return "";
267
+ return { name, first, last, delta };
268
+ })
269
+ .filter(Boolean)
270
+ .sort((a, b) => b.delta - a.delta);
271
+ const deltaTable = deltaRows
272
+ .map((r) => {
273
+ const clr = r.delta > 0 ? "var(--pass)" : r.delta < 0 ? "var(--fail)" : "var(--muted)";
274
+ return `<div class="trend-row"><span class="trend-row-name">${r.name}</span><span class="trend-row-val">${r.first}</span><span class="trend-row-arrow">\u2192</span><span class="trend-row-val">${r.last}</span><span class="trend-row-delta" style="color:${clr}">${r.delta > 0 ? "+" : ""}${r.delta}</span></div>`;
275
+ })
276
+ .join("");
277
+ return `
278
+ <h2>Trends</h2>
279
+ <p class="muted" style="font-size:0.78rem;margin-bottom:1.5rem">${history.length} scans from ${history[0].timestamp.split("T")[0]} to ${latest.timestamp.split("T")[0]}</p>
280
+
281
+ <h3>Overall Score</h3>
282
+ <div class="timeline">${overallChart}</div>
283
+
284
+ <h3 style="margin-top:2rem">Score Changes (first \u2192 latest)</h3>
285
+ <div class="trend-table">${deltaTable}</div>
286
+
287
+ <h3 style="margin-top:2rem">Per-Check Trends</h3>
288
+ <div class="trend-grid">${checkCharts}</div>`;
289
+ }
@@ -1,2 +1,2 @@
1
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;--side-w:200px;--top-h:42px}\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/* \u2500\u2500 Top nav \u2500\u2500 */\n.top{position:sticky;top:0;z-index:30;background:#0c0c0fdd;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 1.5rem;display:flex;align-items:center;height:var(--top-h)}\n.logo{font-weight:800;font-size:1rem;margin-right:1rem;flex-shrink:0;text-decoration:none;color:var(--text)}\n.logo span{color:var(--accent)}\n.nav-scroll{display:flex;align-items:center;gap:0;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;flex:1}\n.nav-scroll::-webkit-scrollbar{display:none}\n.tn{padding:0 0.7rem;font-size:0.78rem;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;transition:all 0.15s;white-space:nowrap;line-height:var(--top-h)}\n.tn:hover{color:var(--text)}\n.tn.active{color:var(--text);border-bottom-color:var(--accent)}\n.hamburger{display:none;background:none;border:none;color:var(--muted);font-size:1.3rem;cursor:pointer;padding:0 0.4rem;line-height:var(--top-h)}\n\n/* \u2500\u2500 Sidebar \u2500\u2500 */\n.side{position:fixed;top:var(--top-h);left:0;bottom:0;width:var(--side-w);background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.6rem 0;font-size:0.7rem;z-index:20}\n.side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}\n.side-section:last-child{border-bottom:none}\n.side-label{padding:0.2rem 0.8rem;font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:#444;font-weight:600}\n.side-score{font-size:1.4rem;font-weight:900;padding:0.2rem 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-cat-title{padding:0.3rem 0.8rem;font-size:0.65rem;text-transform:uppercase;letter-spacing:0.04em;color:var(--accent);font-weight:700}\n.side-check{display:block;padding:0.15rem 0.8rem 0.15rem 0.8rem;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;min-width:2.5rem;font-weight:700;font-size:0.6rem}\n.side-stat{padding:0.15rem 0.8rem;font-size:0.7rem;color:var(--muted)}\n.side-stat span{font-weight:800;font-size:0.8rem}\n.side-views{padding-top:0.3rem}\n.side-views .side-check{padding-left:0.8rem}\n\n/* \u2500\u2500 Content \u2500\u2500 */\n.content{margin-left:var(--side-w);padding:1.5rem 2rem;max-width:960px}\n\n/* \u2500\u2500 Overview \u2500\u2500 */\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(160px,1fr));gap:0.6rem;margin-bottom:2rem}\n.cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;transition:border-color 0.15s;text-decoration:none;color:var(--text);display:block}\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/* \u2500\u2500 Overview sections \u2500\u2500 */\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);text-decoration:none}\n.ov-link:hover{text-decoration:underline}\n\n/* \u2500\u2500 Timeline \u2500\u2500 */\n.timeline{margin:0.5rem 0;overflow-x:auto}\n.timeline svg{max-width:100%}\n\n/* \u2500\u2500 Bar chart \u2500\u2500 */\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/* \u2500\u2500 Category pages \u2500\u2500 */\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/* \u2500\u2500 Check detail \u2500\u2500 */\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/* \u2500\u2500 Issue list grouped by file \u2500\u2500 */\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.ir.info .is{color:var(--info);background:#6366f118}\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/* \u2500\u2500 All issues table \u2500\u2500 */\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/* \u2500\u2500 File health \u2500\u2500 */\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.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/* \u2500\u2500 Premium cards \u2500\u2500 */\n.pro-card{background:linear-gradient(135deg,#0f0f1a 0%,#13131f 100%);border:1px solid #2a2a3d;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}\n.pro-card::before{content:\"\";position:absolute;top:-50%;right:-50%;width:200%;height:200%;background:radial-gradient(circle,#6366f108 0%,transparent 70%);pointer-events:none}\n.pro-badge{display:inline-block;background:linear-gradient(135deg,#6366f1,#818cf8);color:#fff;font-size:0.6rem;font-weight:800;padding:0.15rem 0.5rem;border-radius:9999px;letter-spacing:0.06em;margin-bottom:0.6rem}\n.pro-desc{color:var(--muted);font-size:0.78rem;line-height:1.6;margin-bottom:0.8rem}\n.pro-cta{color:#6366f1;font-size:0.72rem;font-weight:600;margin-top:1rem}\n.sn-pro{opacity:0.7}\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;-webkit-overflow-scrolling:touch}\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\n/* \u2500\u2500 Mobile: hamburger collapses both navs \u2500\u2500 */\n@media(max-width:768px){\n.hamburger{display:block}\n.nav-scroll{display:none}\n.nav-scroll.open{display:flex;position:absolute;top:var(--top-h);left:0;right:0;background:var(--bg);border-bottom:1px solid var(--border);flex-wrap:wrap;padding:0.3rem 0.5rem;z-index:25}\n.side{display:none}\n.side.open{display:block;z-index:25}\n.top{padding:0 0.8rem}\n.logo{font-size:0.85rem;margin-right:0.5rem}\n.content{margin-left:0;padding:0.8rem}\n.cats{grid-template-columns:1fr 1fr}\n.dash{flex-direction:column;gap:1rem}\n.hero svg{width:80px;height:80px}\n.hg{font-size:2rem}\n.radar svg{max-width:180px}\n.bl{width:60px;font-size:0.62rem}\n.bv{width:30px;font-size:0.6rem}\n.it{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}\n.ff{width:120px;font-size:0.58rem}\n.hm-name{width:120px;font-size:0.58rem}\n.hm-checks{display:none}\n.ov-check{width:50px}\n.ov-loc{max-width:120px}\n.ir{font-size:0.6rem}\n.ch-head{flex-wrap:wrap}\n.ch-g{font-size:1.5rem}\n.info-panel{font-size:0.68rem;padding:0.5rem 0.6rem}\n.ip-row{flex-direction:column;gap:0.1rem}\n.kvs{gap:0.4rem}\n.kv{font-size:0.62rem;padding:0.2rem 0.4rem}\n.arch-svg svg{min-width:400px}\n}\n@media(max-width:480px){\n.cats{grid-template-columns:1fr}\n.tn{padding:0 0.4rem;font-size:0.65rem}\n.ff{width:90px}\n.hm-name{width:90px}\n.ov-check{display:none}\n}\n";
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;--side-w:200px;--top-h:42px}\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/* \u2500\u2500 Top nav \u2500\u2500 */\n.top{position:sticky;top:0;z-index:30;background:#0c0c0fdd;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 1.5rem;display:flex;align-items:center;height:var(--top-h)}\n.logo{font-weight:800;font-size:1rem;margin-right:1rem;flex-shrink:0;text-decoration:none;color:var(--text)}\n.logo span{color:var(--accent)}\n.nav-scroll{display:flex;align-items:center;gap:0;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;flex:1}\n.nav-scroll::-webkit-scrollbar{display:none}\n.tn{padding:0 0.7rem;font-size:0.78rem;color:var(--muted);text-decoration:none;border-bottom:2px solid transparent;transition:all 0.15s;white-space:nowrap;line-height:var(--top-h)}\n.tn:hover{color:var(--text)}\n.tn.active{color:var(--text);border-bottom-color:var(--accent)}\n.hamburger{display:none;background:none;border:none;color:var(--muted);font-size:1.3rem;cursor:pointer;padding:0 0.4rem;line-height:var(--top-h)}\n\n/* \u2500\u2500 Sidebar \u2500\u2500 */\n.side{position:fixed;top:var(--top-h);left:0;bottom:0;width:var(--side-w);background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.6rem 0;font-size:0.7rem;z-index:20}\n.side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}\n.side-section:last-child{border-bottom:none}\n.side-label{padding:0.2rem 0.8rem;font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:#444;font-weight:600}\n.side-score{font-size:1.4rem;font-weight:900;padding:0.2rem 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-cat-title{padding:0.3rem 0.8rem;font-size:0.65rem;text-transform:uppercase;letter-spacing:0.04em;color:var(--accent);font-weight:700}\n.side-check{display:block;padding:0.15rem 0.8rem 0.15rem 0.8rem;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;min-width:2.5rem;font-weight:700;font-size:0.6rem}\n.side-stat{padding:0.15rem 0.8rem;font-size:0.7rem;color:var(--muted)}\n.side-stat span{font-weight:800;font-size:0.8rem}\n.side-views{padding-top:0.3rem}\n.side-views .side-check{padding-left:0.8rem}\n\n/* \u2500\u2500 Content \u2500\u2500 */\n.content{margin-left:var(--side-w);padding:1.5rem 2rem;max-width:960px}\n\n/* \u2500\u2500 Overview \u2500\u2500 */\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(160px,1fr));gap:0.6rem;margin-bottom:2rem}\n.cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;transition:border-color 0.15s;text-decoration:none;color:var(--text);display:block}\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/* \u2500\u2500 Overview sections \u2500\u2500 */\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);text-decoration:none}\n.ov-link:hover{text-decoration:underline}\n\n/* \u2500\u2500 Timeline \u2500\u2500 */\n.timeline{margin:0.5rem 0;overflow-x:auto}\n.timeline svg{max-width:100%}\n\n/* \u2500\u2500 Bar chart \u2500\u2500 */\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/* \u2500\u2500 Category pages \u2500\u2500 */\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/* \u2500\u2500 Check detail \u2500\u2500 */\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/* \u2500\u2500 Issue list grouped by file \u2500\u2500 */\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.ir.info .is{color:var(--info);background:#6366f118}\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/* \u2500\u2500 All issues table \u2500\u2500 */\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/* \u2500\u2500 File health \u2500\u2500 */\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.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/* \u2500\u2500 Premium cards \u2500\u2500 */\n.pro-card{background:linear-gradient(135deg,#0f0f1a 0%,#13131f 100%);border:1px solid #2a2a3d;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}\n.pro-card::before{content:\"\";position:absolute;top:-50%;right:-50%;width:200%;height:200%;background:radial-gradient(circle,#6366f108 0%,transparent 70%);pointer-events:none}\n.pro-badge{display:inline-block;background:linear-gradient(135deg,#6366f1,#818cf8);color:#fff;font-size:0.6rem;font-weight:800;padding:0.15rem 0.5rem;border-radius:9999px;letter-spacing:0.06em;margin-bottom:0.6rem}\n.pro-desc{color:var(--muted);font-size:0.78rem;line-height:1.6;margin-bottom:0.8rem}\n.pro-cta{color:#6366f1;font-size:0.72rem;font-weight:600;margin-top:1rem}\n.sn-pro{opacity:0.7}\n\n/* \u2500\u2500 Trends page \u2500\u2500 */\n.trend-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem;margin-top:0.5rem}\n.trend-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:0.8rem}\n.trend-header{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem}\n.trend-name{font-size:0.78rem;font-weight:700;flex:1}\n.trend-score{font-size:1.1rem;font-weight:900}\n.trend-chart{overflow:hidden}\n.trend-chart svg{width:100%;height:60px}\n.trend-table{margin-bottom:1.5rem}\n.trend-row{display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border);font-size:0.75rem}\n.trend-row-name{flex:1;font-weight:600}\n.trend-row-val{width:2rem;text-align:center;color:var(--muted)}\n.trend-row-arrow{color:var(--muted);font-size:0.6rem}\n.trend-row-delta{width:2.5rem;text-align:right;font-weight:700}\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;-webkit-overflow-scrolling:touch}\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\n/* \u2500\u2500 Mobile: hamburger collapses both navs \u2500\u2500 */\n@media(max-width:768px){\n.hamburger{display:block}\n.nav-scroll{display:none}\n.nav-scroll.open{display:flex;position:absolute;top:var(--top-h);left:0;right:0;background:var(--bg);border-bottom:1px solid var(--border);flex-wrap:wrap;padding:0.3rem 0.5rem;z-index:25}\n.side{display:none}\n.side.open{display:block;z-index:25}\n.top{padding:0 0.8rem}\n.logo{font-size:0.85rem;margin-right:0.5rem}\n.content{margin-left:0;padding:0.8rem}\n.cats{grid-template-columns:1fr 1fr}\n.dash{flex-direction:column;gap:1rem}\n.hero svg{width:80px;height:80px}\n.hg{font-size:2rem}\n.radar svg{max-width:180px}\n.bl{width:60px;font-size:0.62rem}\n.bv{width:30px;font-size:0.6rem}\n.it{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}\n.ff{width:120px;font-size:0.58rem}\n.hm-name{width:120px;font-size:0.58rem}\n.hm-checks{display:none}\n.ov-check{width:50px}\n.ov-loc{max-width:120px}\n.ir{font-size:0.6rem}\n.ch-head{flex-wrap:wrap}\n.ch-g{font-size:1.5rem}\n.info-panel{font-size:0.68rem;padding:0.5rem 0.6rem}\n.ip-row{flex-direction:column;gap:0.1rem}\n.kvs{gap:0.4rem}\n.kv{font-size:0.62rem;padding:0.2rem 0.4rem}\n.arch-svg svg{min-width:400px}\n}\n@media(max-width:480px){\n.cats{grid-template-columns:1fr}\n.tn{padding:0 0.4rem;font-size:0.65rem}\n.ff{width:90px}\n.hm-name{width:90px}\n.ov-check{display:none}\n}\n";
@@ -152,6 +152,21 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
152
152
  .pro-cta{color:#6366f1;font-size:0.72rem;font-weight:600;margin-top:1rem}
153
153
  .sn-pro{opacity:0.7}
154
154
 
155
+ /* ── Trends page ── */
156
+ .trend-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem;margin-top:0.5rem}
157
+ .trend-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:0.8rem}
158
+ .trend-header{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem}
159
+ .trend-name{font-size:0.78rem;font-weight:700;flex:1}
160
+ .trend-score{font-size:1.1rem;font-weight:900}
161
+ .trend-chart{overflow:hidden}
162
+ .trend-chart svg{width:100%;height:60px}
163
+ .trend-table{margin-bottom:1.5rem}
164
+ .trend-row{display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border);font-size:0.75rem}
165
+ .trend-row-name{flex:1;font-weight:600}
166
+ .trend-row-val{width:2rem;text-align:center;color:var(--muted)}
167
+ .trend-row-arrow{color:var(--muted);font-size:0.6rem}
168
+ .trend-row-delta{width:2.5rem;text-align:right;font-weight:700}
169
+
155
170
  .footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}
156
171
  .footer a{color:var(--muted)}
157
172
  .flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
@@ -145,8 +145,7 @@ function buildGraph(files) {
145
145
  function parseImports(content) {
146
146
  const imports = [];
147
147
  const regex = /import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
148
- let match;
149
- while ((match = regex.exec(content)) !== null) {
148
+ for (const match of content.matchAll(regex)) {
150
149
  if (match[1].startsWith("."))
151
150
  imports.push(match[1]);
152
151
  }
@@ -13,21 +13,10 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { readDeps } from "../fs-utils.js";
15
15
  import { gradeFromScore } from "../types.js";
16
- export function runBestPractices(cwd) {
17
- const start = Date.now();
16
+ function checkCICD(cwd, has, read) {
18
17
  const issues = [];
19
18
  let practices = 0;
20
19
  let followed = 0;
21
- const has = (f) => existsSync(join(cwd, f));
22
- const read = (f) => {
23
- try {
24
- return readFileSync(join(cwd, f), "utf-8");
25
- }
26
- catch {
27
- return "";
28
- }
29
- };
30
- // ── 1. CI/CD Best Practices ──
31
20
  // Check for GitHub Actions workflows
32
21
  const hasWorkflows = has(".github/workflows");
33
22
  practices++;
@@ -99,7 +88,12 @@ export function runBestPractices(cwd) {
99
88
  rule: "no-ci",
100
89
  });
101
90
  }
102
- // ── 2. Supply Chain ──
91
+ return { practices, followed, issues };
92
+ }
93
+ function checkSupplyChain(has, read) {
94
+ const issues = [];
95
+ let practices = 0;
96
+ let followed = 0;
103
97
  // Lockfile committed
104
98
  practices++;
105
99
  const hasLockfile = has("pnpm-lock.yaml") || has("package-lock.json") || has("yarn.lock") || has("bun.lockb") || has("pubspec.lock");
@@ -136,7 +130,12 @@ export function runBestPractices(cwd) {
136
130
  });
137
131
  }
138
132
  }
139
- // ── 3. Repo Hygiene ──
133
+ return { practices, followed, issues };
134
+ }
135
+ function checkRepoHygiene(has) {
136
+ const issues = [];
137
+ let practices = 0;
138
+ let followed = 0;
140
139
  // SECURITY.md or security policy
141
140
  practices++;
142
141
  if (has("SECURITY.md") || has(".github/SECURITY.md")) {
@@ -165,7 +164,12 @@ export function runBestPractices(cwd) {
165
164
  rule: "contributing-guide",
166
165
  });
167
166
  }
168
- // ── 4. Developer Experience ──
167
+ return { practices, followed, issues };
168
+ }
169
+ function checkDevExperience(cwd, has) {
170
+ const issues = [];
171
+ let practices = 0;
172
+ let followed = 0;
169
173
  // .env.example
170
174
  practices++;
171
175
  const hasEnvFiles = has(".env") || has(".env.local") || has(".env.development");
@@ -204,7 +208,12 @@ export function runBestPractices(cwd) {
204
208
  rule: "automated-deps",
205
209
  });
206
210
  }
207
- // ── 5. Code Quality Tooling ──
211
+ return { practices, followed, issues };
212
+ }
213
+ function checkCodeQualityTooling(has, read) {
214
+ const issues = [];
215
+ let practices = 0;
216
+ let followed = 0;
208
217
  // Linter configured
209
218
  practices++;
210
219
  if (has("biome.json") ||
@@ -247,7 +256,13 @@ export function runBestPractices(cwd) {
247
256
  rule: "ts-strict-mode",
248
257
  });
249
258
  }
250
- // ── 6. Testing Best Practices ──
259
+ return { practices, followed, issues };
260
+ }
261
+ function checkTesting(has, read) {
262
+ const issues = [];
263
+ let practices = 0;
264
+ let followed = 0;
265
+ const pkg = read("package.json");
251
266
  // Test script exists
252
267
  practices++;
253
268
  if (pkg.includes('"test"') || has("pubspec.yaml")) {
@@ -268,7 +283,12 @@ export function runBestPractices(cwd) {
268
283
  rule: "coverage-config",
269
284
  });
270
285
  }
271
- // ── 7. Docker / Deployment ──
286
+ return { practices, followed, issues };
287
+ }
288
+ function checkDocker(has, read) {
289
+ const issues = [];
290
+ let practices = 0;
291
+ let followed = 0;
272
292
  // Dockerfile best practices (if Docker is used)
273
293
  if (has("Dockerfile") || has("docker-compose.yml") || has("docker-compose.yaml")) {
274
294
  practices++;
@@ -315,7 +335,13 @@ export function runBestPractices(cwd) {
315
335
  });
316
336
  }
317
337
  }
318
- // ── 8. Git Practices ──
338
+ return { practices, followed, issues };
339
+ }
340
+ function checkGitPractices(cwd, has, read) {
341
+ const issues = [];
342
+ let practices = 0;
343
+ let followed = 0;
344
+ const deps = readDeps(cwd);
319
345
  // .gitignore is comprehensive
320
346
  practices++;
321
347
  const gitignore = read(".gitignore");
@@ -344,7 +370,13 @@ export function runBestPractices(cwd) {
344
370
  rule: "conventional-commits",
345
371
  });
346
372
  }
347
- // ── 9. Monitoring & Observability ──
373
+ return { practices, followed, issues };
374
+ }
375
+ function checkMonitoring(cwd) {
376
+ const issues = [];
377
+ let practices = 0;
378
+ let followed = 0;
379
+ const deps = readDeps(cwd);
348
380
  // Error tracking (Sentry, Bugsnag, etc.) — only for apps/servers, not CLI tools
349
381
  const isApp = deps.react || deps.vue || deps.svelte || deps.express || deps.fastify || deps.hono || deps.next || deps.nuxt;
350
382
  if (isApp) {
@@ -360,7 +392,14 @@ export function runBestPractices(cwd) {
360
392
  });
361
393
  }
362
394
  }
363
- // ── 10. API & Configuration ──
395
+ return { practices, followed, issues };
396
+ }
397
+ function checkAPIConfig(cwd, read) {
398
+ const issues = [];
399
+ let practices = 0;
400
+ let followed = 0;
401
+ const deps = readDeps(cwd);
402
+ const pkg = read("package.json");
364
403
  // Environment validation (zod, joi, envalid)
365
404
  practices++;
366
405
  if (deps.zod || deps.joi || deps.envalid || deps["@t3-oss/env-core"] || deps["@t3-oss/env-nextjs"]) {
@@ -379,6 +418,39 @@ export function runBestPractices(cwd) {
379
418
  followed++;
380
419
  }
381
420
  }
421
+ return { practices, followed, issues };
422
+ }
423
+ export function runBestPractices(cwd) {
424
+ const start = Date.now();
425
+ const has = (f) => existsSync(join(cwd, f));
426
+ const read = (f) => {
427
+ try {
428
+ return readFileSync(join(cwd, f), "utf-8");
429
+ }
430
+ catch {
431
+ return "";
432
+ }
433
+ };
434
+ const categories = [
435
+ checkCICD(cwd, has, read),
436
+ checkSupplyChain(has, read),
437
+ checkRepoHygiene(has),
438
+ checkDevExperience(cwd, has),
439
+ checkCodeQualityTooling(has, read),
440
+ checkTesting(has, read),
441
+ checkDocker(has, read),
442
+ checkGitPractices(cwd, has, read),
443
+ checkMonitoring(cwd),
444
+ checkAPIConfig(cwd, read),
445
+ ];
446
+ let practices = 0;
447
+ let followed = 0;
448
+ const issues = [];
449
+ for (const cat of categories) {
450
+ practices += cat.practices;
451
+ followed += cat.followed;
452
+ issues.push(...cat.issues);
453
+ }
382
454
  // ── Score ──
383
455
  const pct = practices > 0 ? Math.round((followed / practices) * 100) : 100;
384
456
  const score = pct;
@@ -247,8 +247,7 @@ function extractExports(content) {
247
247
  /export\s+type\s+(\w+)/g,
248
248
  ];
249
249
  for (const pat of patterns) {
250
- let match;
251
- while ((match = pat.exec(content)) !== null) {
250
+ for (const match of content.matchAll(pat)) {
252
251
  exports.push(match[1]);
253
252
  }
254
253
  }
@@ -131,8 +131,7 @@ export function runContext(cwd) {
131
131
  function parseImports(content) {
132
132
  const imports = [];
133
133
  const regex = /import\s+(?:[\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
134
- let match;
135
- while ((match = regex.exec(content)) !== null) {
134
+ for (const match of content.matchAll(regex)) {
136
135
  const path = match[1];
137
136
  // Only count local imports (starting with . or /)
138
137
  if (path.startsWith(".") || path.startsWith("/")) {
@@ -28,7 +28,12 @@ const CODE_SMELLS = [
28
28
  message: "dangerouslySetInnerHTML bypasses React's XSS protection",
29
29
  },
30
30
  { name: "document.write", pattern: /document\.write\s*\(/, severity: "error", message: "document.write blocks rendering" },
31
- { name: "http:// URL", pattern: /['"]http:\/\/(?!localhost|127\.0\.0\.1)/, severity: "warning", message: "Non-HTTPS URL — use https://" },
31
+ {
32
+ name: "http:// URL",
33
+ pattern: /['"]http:\/\/(?!localhost|127\.0\.0\.1|www\.w3\.org|schemas?\.)/,
34
+ severity: "warning",
35
+ message: "Non-HTTPS URL — use https://",
36
+ },
32
37
  { name: "TODO/FIXME", pattern: /\b(TODO|FIXME|HACK|XXX)\b/, severity: "warning", message: "Unresolved TODO/FIXME comment" },
33
38
  {
34
39
  name: "magic number",
@@ -96,6 +101,9 @@ export function runStandards(cwd, stack) {
96
101
  continue;
97
102
  if (/\bpattern\s*:|name:\s*["']|message:\s*["']|description:\s*["']|risk:\s*["']|recommendation:\s*["']/.test(trimmed))
98
103
  continue;
104
+ // Skip string-only lines (check-meta descriptions, inline scripts)
105
+ if (/^\s*["'`].*["'`][,;]?\s*$/.test(line))
106
+ continue;
99
107
  for (const check of CODE_SMELLS) {
100
108
  // Skip console.log in CLI entry points (intentional output)
101
109
  if (check.name === "console.log" && (f.path.includes("cli.") || f.path.includes("bin/")))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Code health scanner for the AI coding era. 21 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {