@vibecodeqa/cli 0.43.0 โ†’ 0.44.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
@@ -8,6 +8,7 @@ import { runInit } from "./commands/init.js";
8
8
  import { validateCwd } from "./commands/shared.js";
9
9
  import { loadConfig } from "./config.js";
10
10
  import { scan } from "./core.js";
11
+ import { computeDelta } from "./delta.js";
11
12
  import { detectStack, detectWorkspace } from "./detect.js";
12
13
  import { postPRComment } from "./pr-comment.js";
13
14
  import { generatePages } from "./report/html.js";
@@ -134,11 +135,31 @@ function printHeader(cwd, stack, workspace) {
134
135
  }
135
136
  console.log("");
136
137
  }
137
- function generateMarkdown(report, trend) {
138
+ function generateMarkdown(report, trend, prevReport) {
138
139
  const { score, grade, checks } = report;
139
140
  const gradeEmoji = grade === "A" ? "๐ŸŸข" : grade === "B" ? "๐ŸŸก" : grade === "C" ? "๐ŸŸ " : "๐Ÿ”ด";
140
141
  let md = `# ${gradeEmoji} VibeCode QA: ${grade} ${score}/100\n\n`;
141
- if (trend) {
142
+ // Delta section โ€” shows what changed with specific fixed/new issues
143
+ if (prevReport) {
144
+ const delta = computeDelta(prevReport, report);
145
+ const arrow = delta.scoreDelta > 0 ? "๐Ÿ“ˆ" : delta.scoreDelta < 0 ? "๐Ÿ“‰" : "โžก๏ธ";
146
+ md += `${arrow} **${delta.scoreDelta > 0 ? "+" : ""}${delta.scoreDelta}** vs previous`;
147
+ if (delta.fixed.length > 0)
148
+ md += ` ยท **${delta.fixed.length} fixed**`;
149
+ if (delta.introduced.length > 0)
150
+ md += ` ยท ${delta.introduced.length} new`;
151
+ md += "\n\n";
152
+ // Per-check changes
153
+ const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta);
154
+ if (changed.length > 0) {
155
+ for (const c of changed.slice(0, 8)) {
156
+ const a = c.delta > 0 ? "+" : "";
157
+ md += `- ${c.delta > 0 ? "โœ…" : "โš ๏ธ"} ${c.name}: ${c.before} โ†’ ${c.after} (${a}${c.delta})\n`;
158
+ }
159
+ md += "\n";
160
+ }
161
+ }
162
+ else if (trend) {
142
163
  const arrow = trend.scoreDelta > 0 ? "๐Ÿ“ˆ" : trend.scoreDelta < 0 ? "๐Ÿ“‰" : "โžก๏ธ";
143
164
  md += `${arrow} **${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta}** vs previous`;
144
165
  if (trend.fixedIssues > 0)
@@ -269,7 +290,7 @@ function getChangedFiles(cwd, base) {
269
290
  }
270
291
  }
271
292
  // โ”€โ”€ Report output โ”€โ”€
272
- async function writeOutputs(report, outputDir, flags) {
293
+ async function writeOutputs(report, outputDir, flags, prevReport) {
273
294
  mkdirSync(outputDir, { recursive: true });
274
295
  // Always write JSON
275
296
  writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
@@ -289,7 +310,7 @@ async function writeOutputs(report, outputDir, flags) {
289
310
  const reportDir = join(outputDir, "report");
290
311
  mkdirSync(reportDir, { recursive: true });
291
312
  const historyDir = join(outputDir, "history");
292
- const pages = generatePages(report, historyDir);
313
+ const pages = generatePages(report, historyDir, prevReport);
293
314
  for (const [filename, html] of pages) {
294
315
  writeFileSync(join(reportDir, filename), html);
295
316
  }
@@ -518,10 +539,19 @@ async function main() {
518
539
  }
519
540
  }
520
541
  const trend = computeTrend(report, outputDir);
521
- await writeOutputs(report, outputDir, flags);
542
+ // Load previous report BEFORE writeOutputs overwrites it (for delta in markdown/PR)
543
+ let prevReport;
544
+ const prevReportPath = join(outputDir, "report.json");
545
+ if (existsSync(prevReportPath)) {
546
+ try {
547
+ prevReport = JSON.parse(readFileSync(prevReportPath, "utf-8"));
548
+ }
549
+ catch { /* corrupt */ }
550
+ }
551
+ await writeOutputs(report, outputDir, flags, prevReport);
522
552
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !quietMode && !ciMode && !watchMode;
523
553
  if (flags.markdownMode) {
524
- console.log(generateMarkdown(report, trend));
554
+ console.log(generateMarkdown(report, trend, prevReport));
525
555
  }
526
556
  else {
527
557
  await printResults(report, trend, flags, outputDir, interactive);
@@ -531,7 +561,7 @@ async function main() {
531
561
  if (flags.uploadMode)
532
562
  await handleUpload(report, cwd, quietMode);
533
563
  if (flags.prComment) {
534
- const posted = await postPRComment(report, trend, cwd);
564
+ const posted = await postPRComment(report, trend, cwd, prevReport);
535
565
  if (!quietMode) {
536
566
  if (posted)
537
567
  console.log(" \x1b[32m\u2713 PR comment posted\x1b[0m");
@@ -1,4 +1,4 @@
1
- /** vcqa fix โ€” auto-fix + AI-powered fixing. */
1
+ /** vcqa fix โ€” auto-fix + AI-powered fixing with delta report. */
2
2
  export declare function runFix(cwd: string, opts?: {
3
3
  ai?: boolean;
4
4
  dryRun?: boolean;
@@ -1,10 +1,10 @@
1
- /** vcqa fix โ€” auto-fix + AI-powered fixing. */
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ /** vcqa fix โ€” auto-fix + AI-powered fixing with delta report. */
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { aiFixIssues, collectFixableIssues } from "../ai-fix.js";
5
5
  import { scan } from "../core.js";
6
+ import { computeDelta, formatDeltaMarkdown } from "../delta.js";
6
7
  import { detectStack } from "../detect.js";
7
- import { gradeFromScore } from "../types.js";
8
8
  import { suggestFix, validateCwd } from "./shared.js";
9
9
  export async function runFix(cwd, opts = {}) {
10
10
  console.log("");
@@ -12,9 +12,14 @@ export async function runFix(cwd, opts = {}) {
12
12
  console.log(` \x1b[2m${cwd}\x1b[0m`);
13
13
  console.log("");
14
14
  validateCwd(cwd);
15
+ // 0. Baseline scan (before fixes)
16
+ console.log(" \x1b[1mBaseline scan...\x1b[0m");
17
+ const beforeReport = await scan(cwd, { skipTests: true });
18
+ console.log(` \x1b[2mBaseline: ${beforeReport.grade} ${beforeReport.score}/100\x1b[0m`);
19
+ console.log("");
15
20
  const stack = detectStack(cwd);
16
21
  let fixed = 0;
17
- // 0. Auto-fix structure issues (missing files)
22
+ // 1. Auto-fix structure issues (missing files)
18
23
  if (!existsSync(join(cwd, ".gitignore"))) {
19
24
  writeFileSync(join(cwd, ".gitignore"), "node_modules\ndist\n.vibe-check\ncoverage\n.env\n.env.local\n");
20
25
  console.log(" \x1b[32m\u2713 Created .gitignore\x1b[0m");
@@ -42,7 +47,7 @@ export async function runFix(cwd, opts = {}) {
42
47
  }
43
48
  catch { /* can't parse tsconfig */ }
44
49
  }
45
- // 1. Run linter auto-fix
50
+ // 2. Run linter auto-fix
46
51
  if (stack.linter === "biome") {
47
52
  console.log(" \x1b[1mFormatting with Biome...\x1b[0m");
48
53
  const { execSync } = await import("node:child_process");
@@ -65,67 +70,88 @@ export async function runFix(cwd, opts = {}) {
65
70
  console.log(" \x1b[33mESLint had issues (some may be unfixable)\x1b[0m");
66
71
  }
67
72
  }
68
- // 2. Scan for remaining issues
69
- console.log("");
70
- console.log(" \x1b[1mScanning for remaining issues...\x1b[0m");
71
- console.log("");
72
- const report = await scan(cwd, { skipTests: true });
73
- const { checks } = report;
74
- const score = report.score;
75
- // AI-powered fix mode
73
+ // 3. AI-powered fix mode
76
74
  if (opts.ai) {
77
- const aiIssues = collectFixableIssues(checks, suggestFix, opts.checkFilter);
75
+ console.log("");
76
+ console.log(" \x1b[1mScanning for AI-fixable issues...\x1b[0m");
77
+ const midReport = await scan(cwd, { skipTests: true });
78
+ const aiIssues = collectFixableIssues(midReport.checks, suggestFix, opts.checkFilter);
78
79
  if (aiIssues.length === 0) {
79
80
  console.log(" \x1b[2mNo fixable issues found.\x1b[0m");
80
81
  }
81
82
  else {
82
83
  console.log(` \x1b[1mAI fixing ${Math.min(aiIssues.length, 10)} issues${opts.dryRun ? " (dry run)" : ""}...\x1b[0m`);
83
84
  console.log("");
84
- const results = await aiFixIssues(cwd, aiIssues, { dryRun: opts.dryRun || false });
85
- const applied = results.filter((r) => r.applied).length;
86
- if (applied > 0) {
87
- console.log("");
88
- console.log(" \x1b[1mRe-scanning...\x1b[0m");
89
- const reReport = await scan(cwd, { skipTests: true });
90
- const delta = reReport.score - score;
91
- console.log(` Score: \x1b[${reReport.score >= 75 ? "32" : reReport.score >= 60 ? "33" : "31"}m${reReport.grade} ${reReport.score}/100\x1b[0m${delta > 0 ? ` \x1b[32m(+${delta})\x1b[0m` : ""}`);
92
- console.log(` \x1b[32m${applied} AI fix(es) applied.\x1b[0m Re-run \x1b[1mnpx @vibecodeqa/cli\x1b[0m for full report.`);
93
- }
94
- else {
95
- const grade = gradeFromScore(score);
96
- console.log(`\n Score: \x1b[${score >= 75 ? "32" : score >= 60 ? "33" : "31"}m${grade} ${score}/100\x1b[0m`);
97
- if (opts.dryRun)
98
- console.log(" \x1b[2mDry run โ€” no files modified. Remove --dry-run to apply.\x1b[0m");
99
- }
85
+ await aiFixIssues(cwd, aiIssues, { dryRun: opts.dryRun || false });
100
86
  }
101
- console.log("");
102
- return;
103
87
  }
104
- // Non-AI mode: show fix suggestions
105
- const fixable = [];
106
- for (const c of checks) {
107
- for (const iss of c.issues) {
108
- if (!iss.file || typeof iss.file !== "string" || !iss.line)
109
- continue;
110
- const fix = suggestFix(c.name, iss.rule || "", iss.message);
111
- if (fix)
112
- fixable.push({ check: c.name, file: iss.file, line: iss.line, message: iss.message, fix });
88
+ // 4. Final scan + delta report
89
+ console.log("");
90
+ console.log(" \x1b[1mFinal scan...\x1b[0m");
91
+ const afterReport = await scan(cwd, { skipTests: true });
92
+ const delta = computeDelta(beforeReport, afterReport);
93
+ // Print delta summary
94
+ const scoreColor = delta.scoreDelta > 0 ? "32" : delta.scoreDelta < 0 ? "31" : "2";
95
+ const arrow = delta.scoreDelta > 0 ? "\u2191" : delta.scoreDelta < 0 ? "\u2193" : "=";
96
+ console.log("");
97
+ console.log(` \x1b[1mScore:\x1b[0m \x1b[2m${beforeReport.grade} ${beforeReport.score}\x1b[0m \u2192 \x1b[${afterReport.score >= 75 ? "32" : afterReport.score >= 60 ? "33" : "31"}m${afterReport.grade} ${afterReport.score}\x1b[0m \x1b[${scoreColor}m(${arrow}${Math.abs(delta.scoreDelta)})\x1b[0m`);
98
+ if (delta.fixed.length > 0) {
99
+ console.log(` \x1b[32m${delta.fixed.length} issues fixed\x1b[0m`);
100
+ // Show top fixed by check
101
+ const byCheck = new Map();
102
+ for (const f of delta.fixed)
103
+ byCheck.set(f.check, (byCheck.get(f.check) || 0) + 1);
104
+ for (const [check, count] of [...byCheck.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)) {
105
+ console.log(` \x1b[32m\u2713\x1b[0m ${check}: ${count} fixed`);
113
106
  }
114
107
  }
115
- const top = fixable.slice(0, 10);
116
- if (top.length > 0) {
117
- console.log(` \x1b[1m${top.length} issues with fix suggestions:\x1b[0m`);
108
+ if (delta.introduced.length > 0) {
109
+ console.log(` \x1b[31m${delta.introduced.length} new issues\x1b[0m`);
110
+ }
111
+ // Show per-check score changes
112
+ const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta);
113
+ if (changed.length > 0) {
118
114
  console.log("");
119
- for (const f of top) {
120
- console.log(` \x1b[2m${f.file}:${f.line}\x1b[0m`);
121
- console.log(` ${f.message}`);
122
- console.log(` \x1b[32mFix: ${f.fix}\x1b[0m`);
115
+ for (const c of changed.slice(0, 5)) {
116
+ const color = c.delta > 0 ? "32" : "31";
117
+ console.log(` \x1b[${color}m${c.delta > 0 ? "+" : ""}${c.delta}\x1b[0m ${c.name} (${c.before} \u2192 ${c.after})`);
118
+ }
119
+ }
120
+ // Save delta markdown
121
+ if (!opts.dryRun) {
122
+ const outDir = join(cwd, ".vibe-check");
123
+ mkdirSync(outDir, { recursive: true });
124
+ const md = formatDeltaMarkdown(delta);
125
+ writeFileSync(join(outDir, "delta.md"), md);
126
+ console.log("");
127
+ console.log(` \x1b[2mDelta report: .vibe-check/delta.md\x1b[0m`);
128
+ }
129
+ else {
130
+ console.log("");
131
+ console.log(" \x1b[2mDry run โ€” no files modified. Remove --dry-run to apply.\x1b[0m");
132
+ }
133
+ // Non-AI mode: show remaining fix suggestions
134
+ if (!opts.ai) {
135
+ const fixable = [];
136
+ for (const c of afterReport.checks) {
137
+ for (const iss of c.issues) {
138
+ if (!iss.file || typeof iss.file !== "string" || !iss.line)
139
+ continue;
140
+ const fix = suggestFix(c.name, iss.rule || "", iss.message);
141
+ if (fix)
142
+ fixable.push({ check: c.name, file: iss.file, line: iss.line, message: iss.message, fix });
143
+ }
144
+ }
145
+ const top = fixable.slice(0, 5);
146
+ if (top.length > 0) {
123
147
  console.log("");
148
+ console.log(` \x1b[1mRemaining fixes available:\x1b[0m \x1b[2mrun \x1b[0m\x1b[1mvcqa fix --ai\x1b[0m`);
149
+ for (const f of top) {
150
+ console.log(` \x1b[2m${f.file}:${f.line}\x1b[0m ${f.fix}`);
151
+ }
152
+ if (fixable.length > 5)
153
+ console.log(` \x1b[2m+${fixable.length - 5} more\x1b[0m`);
124
154
  }
125
155
  }
126
- const grade = gradeFromScore(score);
127
- console.log(` Score after fix: \x1b[${score >= 75 ? "32" : score >= 60 ? "33" : "31"}m${grade} ${score}/100\x1b[0m`);
128
- if (fixed > 0)
129
- console.log(` \x1b[32m${fixed} auto-fix(es) applied.\x1b[0m Re-run \x1b[1mnpx @vibecodeqa/cli\x1b[0m for full report.`);
130
156
  console.log("");
131
157
  }
package/dist/core.d.ts CHANGED
@@ -22,6 +22,7 @@ export interface ScanOptions {
22
22
  export declare function scan(cwd: string, options?: ScanOptions): Promise<VibeReport>;
23
23
  export { CHECK_META, getCheckMeta, type CheckMeta };
24
24
  export { computeScore } from "./score.js";
25
+ export { computeDelta, formatDeltaMarkdown, type ScanDelta } from "./delta.js";
25
26
  export { loadConfig, type VcqaConfig } from "./config.js";
26
27
  export { detectStack, detectWorkspace } from "./detect.js";
27
28
  export { gradeFromScore } from "./types.js";
package/dist/core.js CHANGED
@@ -66,7 +66,7 @@ export async function scan(cwd, options = {}) {
66
66
  { name: "lint", fn: () => runLint(resolvedCwd, stack, workspace) },
67
67
  { name: "types", fn: () => runTypeCheck(resolvedCwd, isDart, workspace) },
68
68
  { name: "type-safety", fn: () => runTypeSafety(resolvedCwd, isDart) },
69
- { name: "standards", fn: () => runStandards(resolvedCwd, stack) },
69
+ { name: "standards", fn: () => runStandards(resolvedCwd, stack, workspace) },
70
70
  { name: "complexity", fn: () => runComplexity(resolvedCwd) },
71
71
  { name: "duplication", fn: () => runDuplication(resolvedCwd) },
72
72
  { name: "error-handling", fn: () => runErrorHandling(resolvedCwd, stack) },
@@ -176,6 +176,7 @@ export async function scan(cwd, options = {}) {
176
176
  // โ”€โ”€ Re-exports โ”€โ”€
177
177
  export { CHECK_META, getCheckMeta };
178
178
  export { computeScore } from "./score.js";
179
+ export { computeDelta, formatDeltaMarkdown } from "./delta.js";
179
180
  export { loadConfig } from "./config.js";
180
181
  export { detectStack, detectWorkspace } from "./detect.js";
181
182
  export { gradeFromScore } from "./types.js";
@@ -0,0 +1,45 @@
1
+ /** Delta report โ€” structured diff between two scans.
2
+ *
3
+ * Used by `vcqa fix` to show before/after, and by the Actions page
4
+ * to display "what changed since last scan."
5
+ */
6
+ import type { Issue, VibeReport } from "./types.js";
7
+ export interface DeltaIssue {
8
+ check: string;
9
+ severity: Issue["severity"];
10
+ message: string;
11
+ file?: string;
12
+ line?: number;
13
+ rule?: string;
14
+ }
15
+ export interface CheckDelta {
16
+ name: string;
17
+ label: string;
18
+ before: number;
19
+ after: number;
20
+ delta: number;
21
+ fixed: DeltaIssue[];
22
+ introduced: DeltaIssue[];
23
+ }
24
+ export interface ScanDelta {
25
+ before: {
26
+ score: number;
27
+ grade: string;
28
+ timestamp: string;
29
+ issueCount: number;
30
+ };
31
+ after: {
32
+ score: number;
33
+ grade: string;
34
+ timestamp: string;
35
+ issueCount: number;
36
+ };
37
+ scoreDelta: number;
38
+ checks: CheckDelta[];
39
+ fixed: DeltaIssue[];
40
+ introduced: DeltaIssue[];
41
+ }
42
+ /** Compute a structured delta between two scan reports. */
43
+ export declare function computeDelta(before: VibeReport, after: VibeReport): ScanDelta;
44
+ /** Format a delta as a markdown report. */
45
+ export declare function formatDeltaMarkdown(delta: ScanDelta): string;
package/dist/delta.js ADDED
@@ -0,0 +1,158 @@
1
+ /** Delta report โ€” structured diff between two scans.
2
+ *
3
+ * Used by `vcqa fix` to show before/after, and by the Actions page
4
+ * to display "what changed since last scan."
5
+ */
6
+ /** Fingerprint an issue for stable matching (ignores line numbers which shift after edits). */
7
+ function issueKey(check, iss) {
8
+ const file = typeof iss.file === "string" ? iss.file.split(":")[0] : "";
9
+ return `${check}|${iss.rule || ""}|${file}|${iss.message}`;
10
+ }
11
+ /** Compute a structured delta between two scan reports. */
12
+ export function computeDelta(before, after) {
13
+ const beforeIssueCount = before.checks.reduce((s, c) => s + c.issues.length, 0);
14
+ const afterIssueCount = after.checks.reduce((s, c) => s + c.issues.length, 0);
15
+ const checks = [];
16
+ const allFixed = [];
17
+ const allIntroduced = [];
18
+ for (const afterCheck of after.checks) {
19
+ const beforeCheck = before.checks.find((c) => c.name === afterCheck.name);
20
+ const beforeScore = beforeCheck?.score ?? 0;
21
+ // Build multiset of issue keys for before and after
22
+ const beforeKeys = new Map();
23
+ const afterKeys = new Map();
24
+ if (beforeCheck) {
25
+ for (const iss of beforeCheck.issues) {
26
+ const key = issueKey(afterCheck.name, iss);
27
+ const entry = beforeKeys.get(key);
28
+ if (entry)
29
+ entry.count++;
30
+ else
31
+ beforeKeys.set(key, { count: 1, issue: iss });
32
+ }
33
+ }
34
+ for (const iss of afterCheck.issues) {
35
+ const key = issueKey(afterCheck.name, iss);
36
+ const entry = afterKeys.get(key);
37
+ if (entry)
38
+ entry.count++;
39
+ else
40
+ afterKeys.set(key, { count: 1, issue: iss });
41
+ }
42
+ const fixed = [];
43
+ const introduced = [];
44
+ // Fixed: in before but not in after (or count decreased)
45
+ for (const [key, bEntry] of beforeKeys) {
46
+ const aEntry = afterKeys.get(key);
47
+ const aCount = aEntry?.count ?? 0;
48
+ const diff = bEntry.count - aCount;
49
+ for (let i = 0; i < diff; i++) {
50
+ const di = {
51
+ check: afterCheck.name,
52
+ severity: bEntry.issue.severity,
53
+ message: bEntry.issue.message,
54
+ file: typeof bEntry.issue.file === "string" ? bEntry.issue.file : undefined,
55
+ line: bEntry.issue.line,
56
+ rule: bEntry.issue.rule,
57
+ };
58
+ fixed.push(di);
59
+ allFixed.push(di);
60
+ }
61
+ }
62
+ // Introduced: in after but not in before (or count increased)
63
+ for (const [key, aEntry] of afterKeys) {
64
+ const bEntry = beforeKeys.get(key);
65
+ const bCount = bEntry?.count ?? 0;
66
+ const diff = aEntry.count - bCount;
67
+ for (let i = 0; i < diff; i++) {
68
+ const di = {
69
+ check: afterCheck.name,
70
+ severity: aEntry.issue.severity,
71
+ message: aEntry.issue.message,
72
+ file: typeof aEntry.issue.file === "string" ? aEntry.issue.file : undefined,
73
+ line: aEntry.issue.line,
74
+ rule: aEntry.issue.rule,
75
+ };
76
+ introduced.push(di);
77
+ allIntroduced.push(di);
78
+ }
79
+ }
80
+ checks.push({
81
+ name: afterCheck.name,
82
+ label: afterCheck.name,
83
+ before: beforeScore,
84
+ after: afterCheck.score,
85
+ delta: afterCheck.score - beforeScore,
86
+ fixed,
87
+ introduced,
88
+ });
89
+ }
90
+ return {
91
+ before: { score: before.score, grade: before.grade, timestamp: before.timestamp, issueCount: beforeIssueCount },
92
+ after: { score: after.score, grade: after.grade, timestamp: after.timestamp, issueCount: afterIssueCount },
93
+ scoreDelta: after.score - before.score,
94
+ checks: checks.filter((c) => c.delta !== 0 || c.fixed.length > 0 || c.introduced.length > 0),
95
+ fixed: allFixed,
96
+ introduced: allIntroduced,
97
+ };
98
+ }
99
+ /** Format a delta as a markdown report. */
100
+ export function formatDeltaMarkdown(delta) {
101
+ const arrow = delta.scoreDelta > 0 ? "+" : "";
102
+ const emoji = delta.scoreDelta > 0 ? "improvement" : delta.scoreDelta < 0 ? "regression" : "no change";
103
+ let md = `# VibeCode QA โ€” Delta Report\n\n`;
104
+ md += `| | Before | After | Delta |\n|---|---|---|---|\n`;
105
+ md += `| **Score** | ${delta.before.grade} ${delta.before.score} | ${delta.after.grade} ${delta.after.score} | ${arrow}${delta.scoreDelta} (${emoji}) |\n`;
106
+ md += `| **Issues** | ${delta.before.issueCount} | ${delta.after.issueCount} | ${delta.fixed.length} fixed, ${delta.introduced.length} new |\n\n`;
107
+ // Per-check changes
108
+ const changed = delta.checks.filter((c) => c.delta !== 0);
109
+ if (changed.length > 0) {
110
+ md += `## Check Changes\n\n`;
111
+ md += `| Check | Before | After | Delta |\n|---|---|---|---|\n`;
112
+ for (const c of changed.sort((a, b) => b.delta - a.delta)) {
113
+ const a = c.delta > 0 ? "+" : "";
114
+ md += `| ${c.name} | ${c.before} | ${c.after} | ${a}${c.delta} |\n`;
115
+ }
116
+ md += "\n";
117
+ }
118
+ // Fixed issues
119
+ if (delta.fixed.length > 0) {
120
+ md += `## Fixed (${delta.fixed.length})\n\n`;
121
+ // Group by check
122
+ const byCheck = new Map();
123
+ for (const f of delta.fixed) {
124
+ const arr = byCheck.get(f.check) || [];
125
+ arr.push(f);
126
+ byCheck.set(f.check, arr);
127
+ }
128
+ for (const [check, issues] of byCheck) {
129
+ md += `### ${check} (${issues.length} fixed)\n`;
130
+ for (const iss of issues.slice(0, 10)) {
131
+ md += `- ${iss.file ? `\`${iss.file}\`` : ""} ${iss.message}\n`;
132
+ }
133
+ if (issues.length > 10)
134
+ md += `- ...and ${issues.length - 10} more\n`;
135
+ md += "\n";
136
+ }
137
+ }
138
+ // New issues
139
+ if (delta.introduced.length > 0) {
140
+ md += `## New Issues (${delta.introduced.length})\n\n`;
141
+ const byCheck = new Map();
142
+ for (const f of delta.introduced) {
143
+ const arr = byCheck.get(f.check) || [];
144
+ arr.push(f);
145
+ byCheck.set(f.check, arr);
146
+ }
147
+ for (const [check, issues] of byCheck) {
148
+ md += `### ${check} (${issues.length} new)\n`;
149
+ for (const iss of issues.slice(0, 10)) {
150
+ md += `- ${iss.file ? `\`${iss.file}\`` : ""} ${iss.message}\n`;
151
+ }
152
+ if (issues.length > 10)
153
+ md += `- ...and ${issues.length - 10} more\n`;
154
+ md += "\n";
155
+ }
156
+ }
157
+ return md;
158
+ }
package/dist/detect.js CHANGED
@@ -89,7 +89,7 @@ export function detectStack(cwd, workspace) {
89
89
  const linter = allDeps["@biomejs/biome"] ? "biome" : allDeps.eslint ? "eslint" : "none";
90
90
  const packageManager = has("pnpm-lock.yaml")
91
91
  ? "pnpm"
92
- : has("bun.lockb")
92
+ : has("bun.lockb") || has("bun.lock")
93
93
  ? "bun"
94
94
  : has("yarn.lock")
95
95
  ? "yarn"
@@ -167,7 +167,7 @@ export function detectWorkspace(cwd) {
167
167
  if (parsed.workspaces) {
168
168
  const ws = Array.isArray(parsed.workspaces) ? parsed.workspaces : parsed.workspaces.packages || [];
169
169
  if (ws.length > 0) {
170
- tool = has("bun.lockb") ? "bun" : has("yarn.lock") ? "yarn" : "npm";
170
+ tool = has("bun.lockb") || has("bun.lock") ? "bun" : has("yarn.lock") ? "yarn" : "npm";
171
171
  globs = ws;
172
172
  }
173
173
  }
@@ -1,4 +1,4 @@
1
1
  /** Post scan results as a GitHub PR comment. Upserts to avoid duplicates. */
2
2
  import type { TrendDelta } from "./trend.js";
3
3
  import type { VibeReport } from "./types.js";
4
- export declare function postPRComment(report: VibeReport, trend: TrendDelta | null, cwd: string): Promise<boolean>;
4
+ export declare function postPRComment(report: VibeReport, trend: TrendDelta | null, cwd: string, prevReport?: VibeReport): Promise<boolean>;
@@ -1,15 +1,16 @@
1
1
  /** Post scan results as a GitHub PR comment. Upserts to avoid duplicates. */
2
2
  import { execSync } from "node:child_process";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
+ import { computeDelta } from "./delta.js";
4
5
  const MARKER = "<!-- vcqa-report -->";
5
- export async function postPRComment(report, trend, cwd) {
6
+ export async function postPRComment(report, trend, cwd, prevReport) {
6
7
  const pr = detectPR(cwd);
7
8
  if (!pr)
8
9
  return false;
9
10
  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
10
11
  if (!token)
11
12
  return false;
12
- const body = buildCommentBody(report, trend);
13
+ const body = buildCommentBody(report, trend, prevReport);
13
14
  // Try to find existing vcqa comment to update
14
15
  const existingId = await findExistingComment(pr, token);
15
16
  if (existingId) {
@@ -60,12 +61,30 @@ function detectPR(cwd) {
60
61
  }
61
62
  return null;
62
63
  }
63
- function buildCommentBody(report, trend) {
64
+ function buildCommentBody(report, trend, prevReport) {
64
65
  const grade = report.grade;
65
66
  const score = report.score;
66
67
  const gradeEmoji = grade === "A" ? "๐ŸŸข" : grade === "B" ? "๐ŸŸก" : grade === "C" ? "๐ŸŸ " : "๐Ÿ”ด";
67
68
  let body = `${MARKER}\n## ${gradeEmoji} VibeCode QA: **${grade}** ${score}/100\n\n`;
68
- if (trend) {
69
+ if (prevReport) {
70
+ const delta = computeDelta(prevReport, report);
71
+ const arrow = delta.scoreDelta > 0 ? "๐Ÿ“ˆ" : delta.scoreDelta < 0 ? "๐Ÿ“‰" : "โžก๏ธ";
72
+ body += `${arrow} **${delta.scoreDelta > 0 ? "+" : ""}${delta.scoreDelta}** vs previous`;
73
+ if (delta.fixed.length > 0)
74
+ body += ` ยท **${delta.fixed.length} fixed**`;
75
+ if (delta.introduced.length > 0)
76
+ body += ` ยท ${delta.introduced.length} new`;
77
+ body += "\n\n";
78
+ const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta);
79
+ if (changed.length > 0) {
80
+ for (const c of changed.slice(0, 6)) {
81
+ const a = c.delta > 0 ? "+" : "";
82
+ body += `- ${c.delta > 0 ? "โœ…" : "โš ๏ธ"} ${c.name}: ${c.before} โ†’ ${c.after} (${a}${c.delta})\n`;
83
+ }
84
+ body += "\n";
85
+ }
86
+ }
87
+ else if (trend) {
69
88
  const arrow = trend.scoreDelta > 0 ? "๐Ÿ“ˆ" : trend.scoreDelta < 0 ? "๐Ÿ“‰" : "โžก๏ธ";
70
89
  body += `${arrow} **${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta}** vs previous`;
71
90
  if (trend.fixedIssues > 0)
@@ -17,5 +17,5 @@ export declare const GROUPS: {
17
17
  file: string;
18
18
  checks: string[];
19
19
  }[];
20
- export declare function generatePages(report: VibeReport, historyDir?: string): Map<string, string>;
20
+ export declare function generatePages(report: VibeReport, historyDir?: string, prevReport?: VibeReport): Map<string, string>;
21
21
  export declare function generateHTML(report: VibeReport, historyDir?: string): string;
@@ -11,9 +11,10 @@
11
11
  * Mobile: Hamburger toggles both top nav dropdown and sidebar panel.
12
12
  */
13
13
  import { getCheckMeta } from "../check-meta.js";
14
+ import { computeDelta } from "../delta.js";
14
15
  import { det, e, fileLink, gc } from "./components.js";
15
16
  import { FAVICON_SVG } from "./favicon.js";
16
- import { categoryPage, featureMapPage, filesPage, issuesPage, overviewPage, trendsPage } from "./pages.js";
17
+ import { actionsPage, categoryPage, featureMapPage, filesPage, issuesPage, overviewPage, trendsPage } from "./pages.js";
17
18
  import { CSS } from "./styles.js";
18
19
  export const GROUPS = [
19
20
  { id: "foundations", label: "Foundations", file: "foundations.html", checks: ["structure", "lint", "types", "type-safety", "standards"] },
@@ -29,7 +30,7 @@ export const GROUPS = [
29
30
  { id: "llm", label: "AI Readiness", file: "ai-readiness.html", checks: ["confusion", "context"] },
30
31
  { id: "ai", label: "AI Analysis", file: "ai-analysis.html", checks: ["doc-coherence", "code-coherence", "comment-staleness", "dead-patterns", "test-audit"] },
31
32
  ];
32
- export function generatePages(report, historyDir) {
33
+ export function generatePages(report, historyDir, prevReport) {
33
34
  const pages = new Map();
34
35
  const allChecks = report.checks;
35
36
  const checkMap = new Map(allChecks.map((c) => [c.name, c]));
@@ -107,6 +108,9 @@ export function generatePages(report, historyDir) {
107
108
  // Feature Map (Pro page โ€” reads dead-patterns check details)
108
109
  const deadPatternsCheck = checkMap.get("dead-patterns");
109
110
  pages.set("feature-map.html", w("feature-map", featureMapPage(deadPatternsCheck, fl)));
111
+ // Compute delta from previous scan
112
+ const scanDelta = prevReport ? computeDelta(prevReport, report) : undefined;
113
+ pages.set("actions.html", w("actions", actionsPage(allChecks, fl, report.meta.stack.linter, scanDelta)));
110
114
  pages.set("issues.html", w("issues", issuesPage(allChecks, totalIssues, fl)));
111
115
  pages.set("files.html", w("files", filesPage(topFiles, fileIssues, fl)));
112
116
  pages.set("trends.html", w("trends", trendsPage(historyDir)));
@@ -131,6 +135,7 @@ function wrap(proj, currentId, report, totalIssues, sidebar, content) {
131
135
  { id: "checks", label: "Checks", file: GROUPS[0].file, active: isCheckPage },
132
136
  { id: "feature-map", label: "Feature Map", file: "feature-map.html" },
133
137
  { id: "trends", label: "Trends", file: "trends.html" },
138
+ { id: "actions", label: "Actions", file: "actions.html" },
134
139
  { id: "issues", label: `Issues (${totalIssues})`, file: "issues.html" },
135
140
  { id: "files", label: "Files", file: "files.html" },
136
141
  ];
@@ -1,4 +1,5 @@
1
1
  /** Page renderers for the HTML report. */
2
+ import type { ScanDelta } from "../delta.js";
2
3
  import type { CheckResult, VibeReport } from "../types.js";
3
4
  export interface CatScore {
4
5
  id: string;
@@ -25,4 +26,5 @@ export declare function filesPage(topFiles: FileEntry[], fileIssues: Map<string,
25
26
  }>, fl: FL): string;
26
27
  export declare function trendsPage(historyDir: string | undefined): string;
27
28
  export declare function featureMapPage(deadPatternsCheck: CheckResult | undefined, fl: FL): string;
29
+ export declare function actionsPage(allChecks: CheckResult[], fl: FL, linter: string, delta?: ScanDelta): string;
28
30
  export {};
@@ -2,6 +2,7 @@
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getCheckMeta } from "../check-meta.js";
5
+ import { suggestFix } from "../commands/shared.js";
5
6
  import { buildCoverageMapInput, generateCoverageMap } from "../diagrams/coverage.js";
6
7
  import { loadHistory } from "../history.js";
7
8
  import { generateArchSVG, generateDSM, generateLayerDiagram, generatePackageDiagram, generateSequenceDiagram, } from "../runners/architecture.js";
@@ -479,3 +480,169 @@ export function featureMapPage(deadPatternsCheck, fl) {
479
480
 
480
481
  <div class="fm-grid">${cards}</div>`;
481
482
  }
483
+ export function actionsPage(allChecks, fl, linter, delta) {
484
+ // Classify issues into auto-fixable, AI-fixable, manual
485
+ const autoFixes = [];
486
+ const aiFixes = [];
487
+ const manualGroups = [];
488
+ // Auto-fixable: lint issues (biome/eslint can auto-fix), structure issues
489
+ const AUTO_RULES = new Set(["missing-file", "missing-lockfile", "ts-strict", "env-not-ignored", "no-ci"]);
490
+ // Group AI-fixable by their fix suggestion
491
+ const fixGroups = new Map();
492
+ for (const c of allChecks) {
493
+ if (det(c).skipped || det(c).comingSoon)
494
+ continue;
495
+ const meta = getCheckMeta(c.name);
496
+ const manualIssues = [];
497
+ for (const iss of c.issues) {
498
+ const file = typeof iss.file === "string" ? iss.file : undefined;
499
+ // Auto-fixable: lint issues or known auto-fix rules
500
+ if (c.name === "lint" || AUTO_RULES.has(iss.rule || "")) {
501
+ autoFixes.push({ check: c.name, file, line: iss.line, message: iss.message, rule: iss.rule });
502
+ continue;
503
+ }
504
+ // AI-fixable: has a suggestFix mapping
505
+ const fix = suggestFix(c.name, iss.rule || "", iss.message);
506
+ if (fix) {
507
+ const key = `${c.name}::${fix}`;
508
+ const group = fixGroups.get(key) || { check: c.name, meta, fix, items: [] };
509
+ group.items.push({ file, line: iss.line, message: iss.message, rule: iss.rule });
510
+ fixGroups.set(key, group);
511
+ continue;
512
+ }
513
+ // Manual
514
+ manualIssues.push({ file, line: iss.line, message: iss.message });
515
+ }
516
+ if (manualIssues.length > 0 && c.score < 90) {
517
+ manualGroups.push({ meta, score: c.score, issues: manualIssues });
518
+ }
519
+ }
520
+ for (const g of fixGroups.values())
521
+ aiFixes.push(g);
522
+ aiFixes.sort((a, b) => b.items.length - a.items.length);
523
+ manualGroups.sort((a, b) => a.score - b.score);
524
+ const totalAuto = autoFixes.length;
525
+ const totalAI = aiFixes.reduce((s, g) => s + g.items.length, 0);
526
+ const totalManual = manualGroups.reduce((s, g) => s + g.issues.length, 0);
527
+ // Build auto-fix section
528
+ let autoSection = "";
529
+ if (totalAuto > 0) {
530
+ const linterName = linter === "biome" ? "Biome" : linter === "eslint" ? "ESLint" : "linter";
531
+ autoSection = `
532
+ <div class="act-section">
533
+ <h3><span class="act-icon" style="background:#22c55e20;color:var(--pass)">&#9889;</span> Quick Fixes <span class="act-count">${totalAuto}</span></h3>
534
+ <p class="act-desc">Auto-fixable with one command. Run <code>npx @vibecodeqa/cli fix</code></p>
535
+ <div class="act-cmd"><code>npx @vibecodeqa/cli fix</code></div>
536
+ <details class="act-details"><summary>${totalAuto} issues (${linterName} formatting, missing files, config)</summary>
537
+ <table class="act-table"><tbody>${autoFixes
538
+ .slice(0, 50)
539
+ .map((i) => `<tr><td class="act-check">${e(i.check)}</td><td>${i.file ? fl(i.file.split(":")[0], i.line) : ""}</td><td>${e(i.message)}</td></tr>`)
540
+ .join("")}</tbody></table>
541
+ ${totalAuto > 50 ? `<p class="muted">+${totalAuto - 50} more</p>` : ""}
542
+ </details>
543
+ </div>`;
544
+ }
545
+ // Build AI-fix section
546
+ let aiSection = "";
547
+ if (totalAI > 0) {
548
+ const groupRows = aiFixes
549
+ .slice(0, 20)
550
+ .map((g) => {
551
+ const topItems = g.items
552
+ .slice(0, 3)
553
+ .map((i) => `<div class="act-item">${i.file ? fl(i.file.split(":")[0], i.line) : ""} <span class="muted">${e(i.message)}</span></div>`)
554
+ .join("");
555
+ const more = g.items.length > 3 ? `<div class="act-item muted">+${g.items.length - 3} more</div>` : "";
556
+ return `<div class="act-card"><div class="act-card-head"><span class="act-check">${e(g.meta.label)}</span><span class="act-count">${g.items.length}</span></div><div class="act-fix">${e(g.fix)}</div>${topItems}${more}</div>`;
557
+ })
558
+ .join("");
559
+ aiSection = `
560
+ <div class="act-section">
561
+ <h3><span class="act-icon" style="background:#6366f120;color:var(--info)">&#10024;</span> AI Fixes <span class="act-count">${totalAI}</span></h3>
562
+ <p class="act-desc">Fixable with AI assistance. Run <code>npx @vibecodeqa/cli fix --ai</code></p>
563
+ <div class="act-cmd"><code>npx @vibecodeqa/cli fix --ai</code></div>
564
+ <div class="act-grid">${groupRows}</div>
565
+ </div>`;
566
+ }
567
+ // Build manual section
568
+ let manualSection = "";
569
+ if (manualGroups.length > 0) {
570
+ const groupRows = manualGroups
571
+ .slice(0, 15)
572
+ .map((g) => {
573
+ const clr = gc(g.score >= 90 ? "A" : g.score >= 75 ? "B" : g.score >= 60 ? "C" : g.score >= 40 ? "D" : "F");
574
+ const topIssues = g.issues
575
+ .slice(0, 3)
576
+ .map((i) => `<div class="act-item">${i.file ? fl(i.file.split(":")[0], i.line) : ""} <span class="muted">${e(i.message)}</span></div>`)
577
+ .join("");
578
+ const more = g.issues.length > 3 ? `<div class="act-item muted">+${g.issues.length - 3} more</div>` : "";
579
+ return `<div class="act-card"><div class="act-card-head"><span class="act-check">${e(g.meta.label)}</span><span style="color:${clr}">${g.score}/100</span></div><div class="act-rec">${e(g.meta.recommendation)}</div>${topIssues}${more}</div>`;
580
+ })
581
+ .join("");
582
+ manualSection = `
583
+ <div class="act-section">
584
+ <h3><span class="act-icon" style="background:#eab30820;color:var(--warn)">&#128736;</span> Manual Actions <span class="act-count">${totalManual}</span></h3>
585
+ <p class="act-desc">Require code changes โ€” grouped by check with specific recommendations.</p>
586
+ <div class="act-grid">${groupRows}</div>
587
+ </div>`;
588
+ }
589
+ const noActions = totalAuto === 0 && totalAI === 0 && totalManual === 0;
590
+ // Delta section โ€” shows what changed since last scan
591
+ let deltaSection = "";
592
+ if (delta) {
593
+ const dColor = delta.scoreDelta > 0 ? "var(--pass)" : delta.scoreDelta < 0 ? "var(--fail)" : "var(--muted)";
594
+ const dArrow = delta.scoreDelta > 0 ? "&#9650;" : delta.scoreDelta < 0 ? "&#9660;" : "&#9644;";
595
+ // Per-check changes
596
+ const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta);
597
+ const checkDeltas = changed
598
+ .slice(0, 10)
599
+ .map((c) => {
600
+ const cc = c.delta > 0 ? "var(--pass)" : "var(--fail)";
601
+ return `<span class="delta-chip" style="color:${cc}">${c.name} ${c.delta > 0 ? "+" : ""}${c.delta}</span>`;
602
+ })
603
+ .join("");
604
+ // Fixed issues list
605
+ let fixedList = "";
606
+ if (delta.fixed.length > 0) {
607
+ const byCheck = new Map();
608
+ for (const f of delta.fixed)
609
+ byCheck.set(f.check, (byCheck.get(f.check) || 0) + 1);
610
+ fixedList = `<div class="delta-fixed"><strong style="color:var(--pass)">Fixed (${delta.fixed.length}):</strong> ${[...byCheck.entries()].sort((a, b) => b[1] - a[1]).map(([c, n]) => `${c} (${n})`).join(", ")}</div>`;
611
+ }
612
+ let newList = "";
613
+ if (delta.introduced.length > 0) {
614
+ const byCheck = new Map();
615
+ for (const f of delta.introduced)
616
+ byCheck.set(f.check, (byCheck.get(f.check) || 0) + 1);
617
+ newList = `<div class="delta-new"><strong style="color:var(--fail)">New (${delta.introduced.length}):</strong> ${[...byCheck.entries()].sort((a, b) => b[1] - a[1]).map(([c, n]) => `${c} (${n})`).join(", ")}</div>`;
618
+ }
619
+ deltaSection = `
620
+ <div class="delta-banner">
621
+ <div class="delta-head">
622
+ <span class="delta-title">What Changed</span>
623
+ <span class="delta-score">${delta.before.grade} ${delta.before.score} &rarr; ${delta.after.grade} ${delta.after.score}</span>
624
+ <span class="delta-arrow" style="color:${dColor}">${dArrow} ${delta.scoreDelta > 0 ? "+" : ""}${delta.scoreDelta}</span>
625
+ </div>
626
+ <div class="delta-stats">
627
+ <span style="color:var(--pass)">${delta.fixed.length} fixed</span>
628
+ <span style="color:var(--fail)">${delta.introduced.length} new</span>
629
+ <span style="color:var(--muted)">${delta.after.issueCount} remaining</span>
630
+ </div>
631
+ ${checkDeltas ? `<div class="delta-checks">${checkDeltas}</div>` : ""}
632
+ ${fixedList}${newList}
633
+ </div>`;
634
+ }
635
+ return `
636
+ <h2>Recommended Actions</h2>
637
+ <p class="muted" style="margin-bottom:1.5rem">${noActions ? "No actions needed โ€” all checks passed." : `${totalAuto + totalAI + totalManual} issues across ${totalAuto > 0 ? "3" : totalAI > 0 ? "2" : "1"} fix categories.`}</p>
638
+
639
+ ${deltaSection}
640
+
641
+ <div class="act-summary">
642
+ <div class="act-stat"><span class="act-stat-n" style="color:var(--pass)">${totalAuto}</span><span class="act-stat-l">Auto-fixable</span></div>
643
+ <div class="act-stat"><span class="act-stat-n" style="color:var(--info)">${totalAI}</span><span class="act-stat-l">AI-fixable</span></div>
644
+ <div class="act-stat"><span class="act-stat-n" style="color:var(--warn)">${totalManual}</span><span class="act-stat-l">Manual</span></div>
645
+ </div>
646
+
647
+ ${autoSection}${aiSection}${manualSection}`;
648
+ }
@@ -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;--nav-bg:#0c0c0fdd;--side-bg:#0c0c0f;--hover:#14141a;--dim:#555;--card-alt:#0d0d12}\n[data-theme=\"light\"]{--bg:#f5f5f7;--card:#ffffff;--border:#e2e4e9;--text:#1a1a2e;--muted:#64748b;--pass:#16a34a;--fail:#dc2626;--warn:#ca8a04;--info:#4f46e5;--accent:#4f46e5;--nav-bg:#ffffffee;--side-bg:#fafafa;--hover:#eef0f5;--dim:#94a3b8;--card-alt:#f0f0f5}\nhtml{font-size:17px}\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:var(--nav-bg);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:0.5rem;flex-shrink:0;text-decoration:none;color:var(--text)}\n.logo span{color:var(--accent)}\n.nav-project{font-size:0.72rem;color:var(--muted);font-weight:600;margin-right:1rem;padding:0.2rem 0.5rem;background:var(--card);border:1px solid var(--border);border-radius:4px;flex-shrink:0}\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:var(--side-bg);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:var(--dim);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(--muted);font-weight:600;cursor:pointer;text-decoration:none;font-size:0.72rem}\n.side-cat:hover{background:var(--hover);color:var(--text)}\n.side-cat-active{color:var(--text);font-weight:700;border-left:2px solid var(--accent);padding-left:calc(0.8rem - 2px)}\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 Workspace / Repo structure \u2500\u2500 */\n.ws-info{display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.5rem;font-size:0.72rem;color:var(--muted)}\n.ws-badge{background:var(--accent);color:#fff;padding:0.15rem 0.5rem;border-radius:4px;font-size:0.65rem;font-weight:700}\n.ws-pkgs{display:flex;flex-direction:column;gap:0.15rem}\n.ws-pkg{display:flex;gap:0.6rem;align-items:center;font-size:0.68rem;padding:0.15rem 0.4rem;background:var(--card);border-radius:4px}\n.ws-path{font-family:monospace;color:var(--text);min-width:140px}\n.ws-name{color:var(--muted);flex:1}\n.ws-flags{color:var(--muted);font-size:0.6rem}\n.ws-more{font-size:0.62rem;color:var(--muted);padding:0.2rem 0.4rem}\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:1.5rem;overflow:hidden}\n.bf2{height:100%;border-radius:2px}\n.check-section{margin-bottom:2.5rem;padding-top:0.5rem;border-top:1px solid var(--border)}\n.check-section:first-of-type{border-top:none}\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:var(--card-alt);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:var(--dim);font-size:0.55rem}\n\n/* \u2500\u2500 Source code snippets \u2500\u2500 */\n.src-block{background:var(--card-alt);border:1px solid var(--border);border-radius:6px;margin:0.3rem 0 0.5rem 0.5rem;padding:0.3rem 0;font-family:\"SF Mono\",Menlo,monospace;font-size:0.62rem;line-height:1.6;overflow-x:auto}\n.src-ln{padding:0 0.5rem;white-space:pre}\n.src-hl{padding:0 0.5rem;white-space:pre;background:#eab30815;border-left:2px solid var(--warn)}\n.src-num{color:var(--dim);margin-right:0.5rem;user-select:none;display:inline-block;min-width:2.5rem;text-align:right}\n.src-prompt{padding:0.3rem 0.5rem;border-top:1px solid var(--border);display:flex;justify-content:flex-end}\n.src-fix-btn{background:var(--card);border:1px solid var(--border);color:var(--accent);font-size:0.62rem;padding:0.2rem 0.6rem;border-radius:4px;cursor:pointer;font-family:inherit}\n.src-fix-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}\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:var(--dim);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:var(--dim);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.muted{color:var(--muted)}\n.deeper-tools code{background:var(--border);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.62rem;color:var(--accent);margin-right:0.3rem}\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\n/* \u2500\u2500 Feature Map (Pro) \u2500\u2500 */\n.fm-header{margin-bottom:1.5rem}\n.fm-header h2{display:flex;align-items:center;gap:0.6rem}\n.fm-stats{display:flex;gap:1.5rem;margin-bottom:2rem;padding:1rem 1.2rem;background:var(--card);border:1px solid var(--border);border-radius:12px}\n.fm-stat{display:flex;flex-direction:column;align-items:center}\n.fm-stat-n{font-size:1.6rem;font-weight:900;line-height:1.2}\n.fm-stat-l{font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:600}\n.fm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}\n.fm-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.2rem;transition:border-color 0.15s}\n.fm-card:hover{border-color:#333}\n.fm-card-issue{border-color:#eab30830;background:linear-gradient(135deg,var(--card) 0%,#1a1a0f 100%)}\n.fm-card-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.4rem}\n.fm-card-label{font-weight:800;font-size:0.95rem}\n.fm-card-desc{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;line-height:1.4}\n.fm-card-dir{font-size:0.65rem;color:var(--dim);font-family:\"SF Mono\",monospace;margin-bottom:0.6rem}\n.fm-card-badge{font-size:0.6rem;font-weight:700;padding:0.15rem 0.5rem;border-radius:9999px;white-space:nowrap}\n.fm-ok{background:#22c55e18;color:var(--pass)}\n.fm-warn{background:#eab30818;color:var(--warn)}\n.fm-info{background:#6366f118;color:var(--info)}\n.fm-card-files{display:flex;flex-direction:column;gap:0.15rem;margin-bottom:0.6rem}\n.fm-file{font-size:0.68rem;color:var(--muted);font-family:\"SF Mono\",monospace}\n.fm-file a{color:var(--accent);text-decoration:none}\n.fm-file a:hover{text-decoration:underline}\n.fm-more{color:var(--dim);font-style:italic}\n.fm-findings{margin-top:0.6rem;padding-top:0.6rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:0.3rem}\n.fm-finding{display:flex;align-items:baseline;gap:0.4rem;font-size:0.68rem;line-height:1.4}\n.fm-f-sev{font-weight:800;font-size:0.6rem;width:1rem;flex-shrink:0}\n.fm-f-warn .fm-f-sev{color:var(--warn)}\n.fm-f-info .fm-f-sev{color:var(--info)}\n.fm-f-loc{color:var(--dim);font-family:\"SF Mono\",monospace;flex-shrink:0}\n.fm-f-loc a{color:var(--accent);text-decoration:none}\n.fm-f-msg{color:var(--text)}\n.fm-f-rule{color:var(--dim);font-size:0.6rem;font-family:\"SF Mono\",monospace}\n\n/* Teaser (no Pro key) */\n.fm-teaser{margin-top:1.5rem}\n.fm-teaser-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-bottom:2rem}\n.fm-card-blur{filter:blur(3px);opacity:0.5;pointer-events:none;user-select:none}\n.fm-cta{text-align:center;padding:2rem;background:linear-gradient(135deg,#0f0f1a,#13131f);border:1px solid #2a2a3d;border-radius:12px}\n.fm-cta code{background:#1a1a2e;padding:0.2rem 0.5rem;border-radius:4px;font-size:0.8rem}\n@media(max-width:640px){\n.fm-grid,.fm-teaser-grid{grid-template-columns:1fr}\n.fm-stats{flex-wrap:wrap;gap:1rem}\n}\n\n/* \u2500\u2500 Preferences panel \u2500\u2500 */\n.prefs-btn{background:none;border:1px solid var(--border);color:var(--muted);font-size:0.72rem;cursor:pointer;padding:0.2rem 0.5rem;border-radius:6px;margin-left:auto;flex-shrink:0;font-family:inherit;line-height:1.4}\n.prefs-btn:hover{color:var(--text);border-color:var(--dim)}\n.prefs-panel{display:none;position:absolute;top:var(--top-h);right:1rem;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:0.8rem 1rem;z-index:40;min-width:200px;box-shadow:0 8px 30px #0008}\n.prefs-panel.open{display:block}\n.prefs-label{font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--dim);font-weight:600;margin-bottom:0.3rem}\n.prefs-label:not(:first-child){margin-top:0.7rem}\n.prefs-row{display:flex;gap:0.3rem}\n.prefs-opt{background:var(--card-alt);border:1px solid var(--border);color:var(--muted);font-size:0.68rem;padding:0.25rem 0.6rem;border-radius:6px;cursor:pointer;font-family:inherit;transition:all 0.1s}\n.prefs-opt:hover{color:var(--text);border-color:var(--dim)}\n.prefs-opt.active{background:var(--accent);color:#fff;border-color:var(--accent)}\n[data-theme=\"light\"] .prefs-panel{box-shadow:0 8px 30px #0002}\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;--nav-bg:#0c0c0fdd;--side-bg:#0c0c0f;--hover:#14141a;--dim:#555;--card-alt:#0d0d12}\n[data-theme=\"light\"]{--bg:#f5f5f7;--card:#ffffff;--border:#e2e4e9;--text:#1a1a2e;--muted:#64748b;--pass:#16a34a;--fail:#dc2626;--warn:#ca8a04;--info:#4f46e5;--accent:#4f46e5;--nav-bg:#ffffffee;--side-bg:#fafafa;--hover:#eef0f5;--dim:#94a3b8;--card-alt:#f0f0f5}\nhtml{font-size:17px}\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:var(--nav-bg);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:0.5rem;flex-shrink:0;text-decoration:none;color:var(--text)}\n.logo span{color:var(--accent)}\n.nav-project{font-size:0.72rem;color:var(--muted);font-weight:600;margin-right:1rem;padding:0.2rem 0.5rem;background:var(--card);border:1px solid var(--border);border-radius:4px;flex-shrink:0}\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:var(--side-bg);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:var(--dim);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(--muted);font-weight:600;cursor:pointer;text-decoration:none;font-size:0.72rem}\n.side-cat:hover{background:var(--hover);color:var(--text)}\n.side-cat-active{color:var(--text);font-weight:700;border-left:2px solid var(--accent);padding-left:calc(0.8rem - 2px)}\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 Workspace / Repo structure \u2500\u2500 */\n.ws-info{display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;margin-bottom:0.5rem;font-size:0.72rem;color:var(--muted)}\n.ws-badge{background:var(--accent);color:#fff;padding:0.15rem 0.5rem;border-radius:4px;font-size:0.65rem;font-weight:700}\n.ws-pkgs{display:flex;flex-direction:column;gap:0.15rem}\n.ws-pkg{display:flex;gap:0.6rem;align-items:center;font-size:0.68rem;padding:0.15rem 0.4rem;background:var(--card);border-radius:4px}\n.ws-path{font-family:monospace;color:var(--text);min-width:140px}\n.ws-name{color:var(--muted);flex:1}\n.ws-flags{color:var(--muted);font-size:0.6rem}\n.ws-more{font-size:0.62rem;color:var(--muted);padding:0.2rem 0.4rem}\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:1.5rem;overflow:hidden}\n.bf2{height:100%;border-radius:2px}\n.check-section{margin-bottom:2.5rem;padding-top:0.5rem;border-top:1px solid var(--border)}\n.check-section:first-of-type{border-top:none}\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:var(--card-alt);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:var(--dim);font-size:0.55rem}\n\n/* \u2500\u2500 Source code snippets \u2500\u2500 */\n.src-block{background:var(--card-alt);border:1px solid var(--border);border-radius:6px;margin:0.3rem 0 0.5rem 0.5rem;padding:0.3rem 0;font-family:\"SF Mono\",Menlo,monospace;font-size:0.62rem;line-height:1.6;overflow-x:auto}\n.src-ln{padding:0 0.5rem;white-space:pre}\n.src-hl{padding:0 0.5rem;white-space:pre;background:#eab30815;border-left:2px solid var(--warn)}\n.src-num{color:var(--dim);margin-right:0.5rem;user-select:none;display:inline-block;min-width:2.5rem;text-align:right}\n.src-prompt{padding:0.3rem 0.5rem;border-top:1px solid var(--border);display:flex;justify-content:flex-end}\n.src-fix-btn{background:var(--card);border:1px solid var(--border);color:var(--accent);font-size:0.62rem;padding:0.2rem 0.6rem;border-radius:4px;cursor:pointer;font-family:inherit}\n.src-fix-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}\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:var(--dim);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:var(--dim);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.muted{color:var(--muted)}\n.deeper-tools code{background:var(--border);padding:0.1rem 0.4rem;border-radius:4px;font-size:0.62rem;color:var(--accent);margin-right:0.3rem}\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\n/* \u2500\u2500 Feature Map (Pro) \u2500\u2500 */\n.fm-header{margin-bottom:1.5rem}\n.fm-header h2{display:flex;align-items:center;gap:0.6rem}\n.fm-stats{display:flex;gap:1.5rem;margin-bottom:2rem;padding:1rem 1.2rem;background:var(--card);border:1px solid var(--border);border-radius:12px}\n.fm-stat{display:flex;flex-direction:column;align-items:center}\n.fm-stat-n{font-size:1.6rem;font-weight:900;line-height:1.2}\n.fm-stat-l{font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:600}\n.fm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}\n.fm-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.2rem;transition:border-color 0.15s}\n.fm-card:hover{border-color:#333}\n.fm-card-issue{border-color:#eab30830;background:linear-gradient(135deg,var(--card) 0%,#1a1a0f 100%)}\n.fm-card-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.4rem}\n.fm-card-label{font-weight:800;font-size:0.95rem}\n.fm-card-desc{font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem;line-height:1.4}\n.fm-card-dir{font-size:0.65rem;color:var(--dim);font-family:\"SF Mono\",monospace;margin-bottom:0.6rem}\n.fm-card-badge{font-size:0.6rem;font-weight:700;padding:0.15rem 0.5rem;border-radius:9999px;white-space:nowrap}\n.fm-ok{background:#22c55e18;color:var(--pass)}\n.fm-warn{background:#eab30818;color:var(--warn)}\n.fm-info{background:#6366f118;color:var(--info)}\n.fm-card-files{display:flex;flex-direction:column;gap:0.15rem;margin-bottom:0.6rem}\n.fm-file{font-size:0.68rem;color:var(--muted);font-family:\"SF Mono\",monospace}\n.fm-file a{color:var(--accent);text-decoration:none}\n.fm-file a:hover{text-decoration:underline}\n.fm-more{color:var(--dim);font-style:italic}\n.fm-findings{margin-top:0.6rem;padding-top:0.6rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:0.3rem}\n.fm-finding{display:flex;align-items:baseline;gap:0.4rem;font-size:0.68rem;line-height:1.4}\n.fm-f-sev{font-weight:800;font-size:0.6rem;width:1rem;flex-shrink:0}\n.fm-f-warn .fm-f-sev{color:var(--warn)}\n.fm-f-info .fm-f-sev{color:var(--info)}\n.fm-f-loc{color:var(--dim);font-family:\"SF Mono\",monospace;flex-shrink:0}\n.fm-f-loc a{color:var(--accent);text-decoration:none}\n.fm-f-msg{color:var(--text)}\n.fm-f-rule{color:var(--dim);font-size:0.6rem;font-family:\"SF Mono\",monospace}\n\n/* Teaser (no Pro key) */\n.fm-teaser{margin-top:1.5rem}\n.fm-teaser-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-bottom:2rem}\n.fm-card-blur{filter:blur(3px);opacity:0.5;pointer-events:none;user-select:none}\n.fm-cta{text-align:center;padding:2rem;background:linear-gradient(135deg,#0f0f1a,#13131f);border:1px solid #2a2a3d;border-radius:12px}\n.fm-cta code{background:#1a1a2e;padding:0.2rem 0.5rem;border-radius:4px;font-size:0.8rem}\n@media(max-width:640px){\n.fm-grid,.fm-teaser-grid{grid-template-columns:1fr}\n.fm-stats{flex-wrap:wrap;gap:1rem}\n}\n\n/* \u2500\u2500 Preferences panel \u2500\u2500 */\n.prefs-btn{background:none;border:1px solid var(--border);color:var(--muted);font-size:0.72rem;cursor:pointer;padding:0.2rem 0.5rem;border-radius:6px;margin-left:auto;flex-shrink:0;font-family:inherit;line-height:1.4}\n.prefs-btn:hover{color:var(--text);border-color:var(--dim)}\n.prefs-panel{display:none;position:absolute;top:var(--top-h);right:1rem;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:0.8rem 1rem;z-index:40;min-width:200px;box-shadow:0 8px 30px #0008}\n.prefs-panel.open{display:block}\n.prefs-label{font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--dim);font-weight:600;margin-bottom:0.3rem}\n.prefs-label:not(:first-child){margin-top:0.7rem}\n.prefs-row{display:flex;gap:0.3rem}\n.prefs-opt{background:var(--card-alt);border:1px solid var(--border);color:var(--muted);font-size:0.68rem;padding:0.25rem 0.6rem;border-radius:6px;cursor:pointer;font-family:inherit;transition:all 0.1s}\n.prefs-opt:hover{color:var(--text);border-color:var(--dim)}\n.prefs-opt.active{background:var(--accent);color:#fff;border-color:var(--accent)}\n[data-theme=\"light\"] .prefs-panel{box-shadow:0 8px 30px #0002}\n\n/* \u2500\u2500 Actions page \u2500\u2500 */\n.act-summary{display:flex;gap:1.5rem;margin-bottom:2rem}\n.act-stat{text-align:center}\n.act-stat-n{display:block;font-size:1.8rem;font-weight:700;line-height:1.2}\n.act-stat-l{font-size:0.72rem;color:var(--muted)}\n.act-section{margin-bottom:2.5rem}\n.act-section h3{display:flex;align-items:center;gap:0.5rem;font-size:1rem;margin-bottom:0.3rem}\n.act-icon{display:inline-flex;align-items:center;justify-content:center;width:1.6rem;height:1.6rem;border-radius:8px;font-size:0.9rem}\n.act-count{font-size:0.72rem;color:var(--muted);font-weight:400}\n.act-desc{font-size:0.78rem;color:var(--muted);margin-bottom:0.8rem}\n.act-cmd{margin-bottom:1rem}\n.act-cmd code{display:inline-block;background:var(--card);border:1px solid var(--border);padding:0.3rem 0.8rem;border-radius:6px;font-size:0.78rem;color:var(--accent)}\n.act-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0.8rem}\n.act-card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:0.8rem 1rem}\n.act-card-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:0.4rem}\n.act-check{font-size:0.78rem;font-weight:600}\n.act-fix{font-size:0.78rem;color:var(--pass);margin-bottom:0.5rem;padding:0.3rem 0.5rem;background:var(--pass)10;border-radius:4px}\n.act-rec{font-size:0.75rem;color:var(--muted);margin-bottom:0.5rem;line-height:1.4}\n.act-item{font-size:0.72rem;color:var(--dim);padding:0.15rem 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.act-details{margin-top:0.5rem}\n.act-details summary{font-size:0.75rem;color:var(--muted);cursor:pointer}\n.act-table{width:100%;font-size:0.72rem;margin-top:0.3rem}\n.act-table td{padding:0.2rem 0.4rem;border-bottom:1px solid var(--border)}\n.act-table .act-check{color:var(--muted);white-space:nowrap}\n@media(max-width:600px){.act-summary{flex-direction:column;gap:0.8rem;align-items:center}.act-grid{grid-template-columns:1fr}}\n\n/* \u2500\u2500 Delta banner \u2500\u2500 */\n.delta-banner{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:1.2rem 1.5rem;margin-bottom:2rem}\n.delta-head{display:flex;align-items:center;gap:1rem;margin-bottom:0.5rem}\n.delta-title{font-weight:700;font-size:0.9rem}\n.delta-score{font-size:0.85rem;color:var(--muted)}\n.delta-arrow{font-weight:800;font-size:1rem}\n.delta-stats{display:flex;gap:1.2rem;font-size:0.78rem;margin-bottom:0.5rem}\n.delta-checks{display:flex;flex-wrap:wrap;gap:0.4rem;margin-bottom:0.5rem}\n.delta-chip{font-size:0.7rem;padding:0.15rem 0.5rem;background:var(--card-alt);border:1px solid var(--border);border-radius:4px;white-space:nowrap}\n.delta-fixed,.delta-new{font-size:0.75rem;color:var(--muted);margin-top:0.3rem}\n";
@@ -294,4 +294,41 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
294
294
  .prefs-opt:hover{color:var(--text);border-color:var(--dim)}
295
295
  .prefs-opt.active{background:var(--accent);color:#fff;border-color:var(--accent)}
296
296
  [data-theme="light"] .prefs-panel{box-shadow:0 8px 30px #0002}
297
+
298
+ /* โ”€โ”€ Actions page โ”€โ”€ */
299
+ .act-summary{display:flex;gap:1.5rem;margin-bottom:2rem}
300
+ .act-stat{text-align:center}
301
+ .act-stat-n{display:block;font-size:1.8rem;font-weight:700;line-height:1.2}
302
+ .act-stat-l{font-size:0.72rem;color:var(--muted)}
303
+ .act-section{margin-bottom:2.5rem}
304
+ .act-section h3{display:flex;align-items:center;gap:0.5rem;font-size:1rem;margin-bottom:0.3rem}
305
+ .act-icon{display:inline-flex;align-items:center;justify-content:center;width:1.6rem;height:1.6rem;border-radius:8px;font-size:0.9rem}
306
+ .act-count{font-size:0.72rem;color:var(--muted);font-weight:400}
307
+ .act-desc{font-size:0.78rem;color:var(--muted);margin-bottom:0.8rem}
308
+ .act-cmd{margin-bottom:1rem}
309
+ .act-cmd code{display:inline-block;background:var(--card);border:1px solid var(--border);padding:0.3rem 0.8rem;border-radius:6px;font-size:0.78rem;color:var(--accent)}
310
+ .act-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0.8rem}
311
+ .act-card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:0.8rem 1rem}
312
+ .act-card-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:0.4rem}
313
+ .act-check{font-size:0.78rem;font-weight:600}
314
+ .act-fix{font-size:0.78rem;color:var(--pass);margin-bottom:0.5rem;padding:0.3rem 0.5rem;background:var(--pass)10;border-radius:4px}
315
+ .act-rec{font-size:0.75rem;color:var(--muted);margin-bottom:0.5rem;line-height:1.4}
316
+ .act-item{font-size:0.72rem;color:var(--dim);padding:0.15rem 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
317
+ .act-details{margin-top:0.5rem}
318
+ .act-details summary{font-size:0.75rem;color:var(--muted);cursor:pointer}
319
+ .act-table{width:100%;font-size:0.72rem;margin-top:0.3rem}
320
+ .act-table td{padding:0.2rem 0.4rem;border-bottom:1px solid var(--border)}
321
+ .act-table .act-check{color:var(--muted);white-space:nowrap}
322
+ @media(max-width:600px){.act-summary{flex-direction:column;gap:0.8rem;align-items:center}.act-grid{grid-template-columns:1fr}}
323
+
324
+ /* โ”€โ”€ Delta banner โ”€โ”€ */
325
+ .delta-banner{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:1.2rem 1.5rem;margin-bottom:2rem}
326
+ .delta-head{display:flex;align-items:center;gap:1rem;margin-bottom:0.5rem}
327
+ .delta-title{font-weight:700;font-size:0.9rem}
328
+ .delta-score{font-size:0.85rem;color:var(--muted)}
329
+ .delta-arrow{font-weight:800;font-size:1rem}
330
+ .delta-stats{display:flex;gap:1.2rem;font-size:0.78rem;margin-bottom:0.5rem}
331
+ .delta-checks{display:flex;flex-wrap:wrap;gap:0.4rem;margin-bottom:0.5rem}
332
+ .delta-chip{font-size:0.7rem;padding:0.15rem 0.5rem;background:var(--card-alt);border:1px solid var(--border);border-radius:4px;white-space:nowrap}
333
+ .delta-fixed,.delta-new{font-size:0.75rem;color:var(--muted);margin-top:0.3rem}
297
334
  `;
@@ -173,7 +173,7 @@ function checkSupplyChain(has, read) {
173
173
  let followed = 0;
174
174
  // Lockfile committed
175
175
  practices++;
176
- const hasLockfile = has("pnpm-lock.yaml") || has("package-lock.json") || has("yarn.lock") || has("bun.lockb") || has("pubspec.lock");
176
+ const hasLockfile = has("pnpm-lock.yaml") || has("package-lock.json") || has("yarn.lock") || has("bun.lockb") || has("bun.lock") || has("pubspec.lock");
177
177
  if (hasLockfile) {
178
178
  followed++;
179
179
  }
@@ -117,12 +117,9 @@ export function runConfusion(cwd) {
117
117
  const dirGroups = new Map();
118
118
  for (const f of files) {
119
119
  const dir = f.path.includes("/") ? f.path.replace(/\/[^/]+$/, "") : ".";
120
- // Use workspace package prefix for grouping (packages/X, apps/X, etc.)
121
- const pkg = f.path.match(/^(packages|apps|libs|modules|internal)\/[^/]+/)?.[0];
122
- const groupKey = pkg || dir;
123
- const group = dirGroups.get(groupKey) || [];
120
+ const group = dirGroups.get(dir) || [];
124
121
  group.push(f);
125
- dirGroups.set(groupKey, group);
122
+ dirGroups.set(dir, group);
126
123
  }
127
124
  // Also do a global comparison but only for files in the same top-level group
128
125
  for (const group of dirGroups.values()) {
@@ -130,11 +127,12 @@ export function runConfusion(cwd) {
130
127
  for (let j = i + 1; j < group.length; j++) {
131
128
  const a = group[i].base;
132
129
  const b = group[j].base;
133
- // Near-identical (Levenshtein โ‰ค 2, but skip very short names where distance 1 is expected)
130
+ // Near-identical (Levenshtein โ‰ค 2, but require longer names for distance 2)
134
131
  // Early exit: length diff > 2 means edit distance > 2
135
132
  if (a !== b && a.length >= 3 && b.length >= 3 && Math.abs(a.length - b.length) <= 2) {
136
133
  const dist = levenshtein(a, b);
137
- if (dist <= 2) {
134
+ const minLen = Math.min(a.length, b.length);
135
+ if (dist === 1 || (dist === 2 && minLen >= 5)) {
138
136
  fileConfusability++;
139
137
  issues.push({
140
138
  severity: "warning",
@@ -179,8 +177,8 @@ export function runConfusion(cwd) {
179
177
  rule: "generic-name",
180
178
  });
181
179
  }
182
- // Match standalone variable assignments with generic names
183
- const varMatch = line.match(/^(?:export\s+)?(?:const|let)\s+(\w+)\s*=/);
180
+ // Match exported variable assignments with generic names (local vars are fine)
181
+ const varMatch = line.match(/^export\s+(?:const|let)\s+(\w+)\s*=/);
184
182
  if (varMatch && GENERIC_NAMES.has(varMatch[1].toLowerCase()) && varMatch[1].length <= 6) {
185
183
  genericNames++;
186
184
  issues.push({
@@ -193,7 +191,7 @@ export function runConfusion(cwd) {
193
191
  }
194
192
  }
195
193
  }
196
- // โ”€โ”€ 3. Export name collisions โ”€โ”€
194
+ // โ”€โ”€ 3. Export name collisions (within same package scope) โ”€โ”€
197
195
  const exportMap = new Map();
198
196
  for (const f of files) {
199
197
  for (const exp of f.exports) {
@@ -204,13 +202,26 @@ export function runConfusion(cwd) {
204
202
  }
205
203
  for (const [name, paths] of exportMap) {
206
204
  if (paths.length > 1) {
207
- exportCollisions++;
208
- issues.push({
209
- severity: "error",
210
- message: `Export collision: "${name}" exported from ${paths.length} files โ€” LLMs may reference the wrong one`,
211
- file: paths.join(", "),
212
- rule: "export-collision",
213
- });
205
+ // In monorepos, only flag collisions within the same package
206
+ const pkgOf = (p) => p.match(/^(packages|apps|libs|modules|internal)\/[^/]+/)?.[0] || ".";
207
+ const pkgGroups = new Map();
208
+ for (const p of paths) {
209
+ const pkg = pkgOf(p);
210
+ const arr = pkgGroups.get(pkg) || [];
211
+ arr.push(p);
212
+ pkgGroups.set(pkg, arr);
213
+ }
214
+ for (const groupPaths of pkgGroups.values()) {
215
+ if (groupPaths.length > 1) {
216
+ exportCollisions++;
217
+ issues.push({
218
+ severity: "error",
219
+ message: `Export collision: "${name}" exported from ${groupPaths.length} files โ€” LLMs may reference the wrong one`,
220
+ file: groupPaths.join(", "),
221
+ rule: "export-collision",
222
+ });
223
+ }
224
+ }
214
225
  }
215
226
  }
216
227
  // โ”€โ”€ 4. Ambiguous abbreviations in filenames and exports โ”€โ”€
@@ -13,7 +13,9 @@ export function runLint(cwd, stack, workspace) {
13
13
  // Determine the target path for linting
14
14
  // Monorepos with root config: lint "." (biome/eslint will find all files)
15
15
  // Single-package: lint "src/"
16
- const lintTarget = workspace?.isMonorepo ? "." : "src/";
16
+ const lintTarget = workspace?.isMonorepo ? "."
17
+ : existsSync(join(cwd, "src")) ? "src/"
18
+ : ".";
17
19
  if (stack.linter === "biome") {
18
20
  const { stdout } = run(`npx biome check ${lintTarget} --reporter=json 2>/dev/null || true`, cwd);
19
21
  try {
@@ -23,6 +25,9 @@ export function runLint(cwd, stack, workspace) {
23
25
  // biome path can be string or {file: "..."} depending on version
24
26
  const rawPath = d.location?.path;
25
27
  const file = typeof rawPath === "string" ? rawPath : rawPath?.file || undefined;
28
+ // Skip generated output files
29
+ if (file && (file.includes(".vibe-check/") || file.includes("node_modules/")))
30
+ continue;
26
31
  issues.push({
27
32
  severity: d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info",
28
33
  message: d.description || d.message || "lint issue",
@@ -1,3 +1,3 @@
1
1
  /** Code standards check โ€” naming conventions, anti-patterns, config hygiene. */
2
- import type { CheckResult, StackInfo } from "../types.js";
3
- export declare function runStandards(cwd: string, stack: StackInfo): CheckResult;
2
+ import type { CheckResult, StackInfo, WorkspaceInfo } from "../types.js";
3
+ export declare function runStandards(cwd: string, stack: StackInfo, workspace?: WorkspaceInfo): CheckResult;
@@ -42,7 +42,7 @@ const CODE_SMELLS = [
42
42
  message: "Large magic number โ€” consider a named constant",
43
43
  },
44
44
  ];
45
- export function runStandards(cwd, stack) {
45
+ export function runStandards(cwd, stack, workspace) {
46
46
  const start = Date.now();
47
47
  const issues = [];
48
48
  // Detect CLI projects โ€” console.log is intentional in CLI tools
@@ -59,15 +59,22 @@ export function runStandards(cwd, stack) {
59
59
  const files = getProductionFiles(cwd);
60
60
  // โ”€โ”€ File naming conventions โ”€โ”€
61
61
  let namingViolations = 0;
62
+ // Detect dominant convention for tsx/jsx files before flagging
63
+ const tsxFiles = files.filter((f) => {
64
+ const ext = extname(basename(f.path));
65
+ return ext === ".tsx" || ext === ".jsx";
66
+ });
67
+ const pascalCount = tsxFiles.filter((f) => /^[A-Z]/.test(basename(f.path).replace(extname(basename(f.path)), ""))).length;
68
+ const usesKebabConvention = tsxFiles.length >= 3 && pascalCount < tsxFiles.length / 2;
62
69
  for (const f of files) {
63
70
  const name = basename(f.path);
64
71
  const ext = extname(name);
65
72
  const base = name.replace(ext, "");
66
- // React components should be PascalCase
73
+ // React components should be PascalCase โ€” but only flag if PascalCase is the project convention
67
74
  if ((ext === ".tsx" || ext === ".jsx") && /^[A-Z]/.test(base)) {
68
75
  // PascalCase component file โ€” correct
69
76
  }
70
- else if ((ext === ".tsx" || ext === ".jsx") && /^[a-z]/.test(base) && base !== "main" && base !== "index") {
77
+ else if (!usesKebabConvention && (ext === ".tsx" || ext === ".jsx") && /^[a-z]/.test(base) && base !== "main" && base !== "index") {
71
78
  // lowercase tsx file that's not main/index โ€” check if it exports a component
72
79
  if (/export (default )?(function|const) [A-Z]/.test(f.content)) {
73
80
  namingViolations++;
@@ -140,6 +147,12 @@ export function runStandards(cwd, stack) {
140
147
  // tsconfig maturity
141
148
  if (stack.language === "typescript") {
142
149
  const tsconfigPaths = ["tsconfig.json", "tsconfig.app.json", "tsconfig.base.json"];
150
+ // In monorepos, also check each workspace package's tsconfig
151
+ if (workspace?.isMonorepo) {
152
+ for (const pkg of workspace.packages) {
153
+ tsconfigPaths.push(join(pkg.path, "tsconfig.json"));
154
+ }
155
+ }
143
156
  let strictFound = false;
144
157
  let compilerOpts = {};
145
158
  for (const p of tsconfigPaths) {
@@ -211,8 +224,8 @@ export function runStandards(cwd, stack) {
211
224
  const totalFiles = files.length || 1;
212
225
  const errorPenalty = Math.min(40, (errors / totalFiles) * 150);
213
226
  const warningPenalty = Math.min(30, (warnings / totalFiles) * 80);
214
- // fileSizePenalty is already exponential โ€” normalize by file count, cap at 95
215
- const largePenalty = Math.min(95, (fileSizePenalty / totalFiles) * 5);
227
+ // fileSizePenalty is already exponential โ€” normalize by file count, cap at 80
228
+ const largePenalty = Math.min(80, (fileSizePenalty / totalFiles) * 3);
216
229
  const score = Math.max(0, Math.min(100, Math.round(100 - errorPenalty - warningPenalty - largePenalty)));
217
230
  return {
218
231
  name: "standards",
@@ -61,7 +61,7 @@ export function runStructure(cwd, stack, workspace) {
61
61
  }
62
62
  }
63
63
  // Check for lockfile โ€” in monorepos, lockfiles may be in packages
64
- const lockfiles = isDart ? ["pubspec.lock"] : ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"];
64
+ const lockfiles = isDart ? ["pubspec.lock"] : ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb", "bun.lock"];
65
65
  let hasLock = lockfiles.some((f) => existsSync(join(cwd, f)));
66
66
  if (!hasLock && workspace?.isMonorepo) {
67
67
  hasLock = workspace.packages.some((p) => lockfiles.some((f) => existsSync(join(cwd, p.path, f))));
@@ -33,7 +33,7 @@ function tryStylelint(cwd) {
33
33
  if (!hasStandard)
34
34
  return null;
35
35
  }
36
- const results = runJSON("npx stylelint --formatter json \"src/**/*.{css,scss}\" 2>/dev/null || true", cwd, 30_000);
36
+ const results = runJSON("npx stylelint --formatter json \"**/*.{css,scss}\" --ignore-pattern node_modules 2>/dev/null || true", cwd, 30_000);
37
37
  if (!results || !Array.isArray(results))
38
38
  return null;
39
39
  const issues = [];
@@ -47,7 +47,9 @@ function countPatterns(content) {
47
47
  // โ”€โ”€ File discovery โ”€โ”€
48
48
  function findTestFiles(cwd, srcRoots) {
49
49
  const files = [];
50
- const dirs = srcRoots ? [...srcRoots, "e2e", "playwright"] : ["src", "web/src", "lib", "test", "tests", "__tests__", "e2e", "playwright"];
50
+ const dirs = srcRoots ? [...srcRoots, "e2e", "playwright"]
51
+ : existsSync(join(cwd, "src")) ? ["src", "web/src", "lib", "test", "tests", "__tests__", "e2e", "playwright"]
52
+ : [".", "test", "tests", "__tests__", "e2e", "playwright"];
51
53
  const seen = new Set();
52
54
  for (const dir of dirs) {
53
55
  const full = join(cwd, dir);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.43.0",
4
- "description": "Code health scanner for the AI coding era. 25 checks, zero config, full report.",
3
+ "version": "0.44.0",
4
+ "description": "Code health scanner for the AI coding era. 34 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "vcqa": "./dist/cli.js",