qodfy 0.2.10 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/dist/index.js +554 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,15 +24,18 @@ Print machine-readable JSON:
24
24
  npx qodfy scan --json
25
25
  ```
26
26
 
27
- Write JSON or Markdown reports:
27
+ Write JSON, Markdown, or HTML reports:
28
28
 
29
29
  ```bash
30
30
  npx qodfy scan --json --output qodfy-report.json
31
31
  npx qodfy scan --report qodfy-report.md
32
+ npx qodfy scan --html qodfy-report.html
32
33
  ```
33
34
 
34
35
  The Markdown report is the **Qodfy Launch Report**: a senior-engineer-style review with a launch status, executive summary, top priorities, what looks good, and per-issue context (what Qodfy found, why it matters, evidence, suggested fix, and an AI fix prompt).
35
36
 
37
+ The HTML report is a standalone, self-contained file (inline CSS, no external CDN, no JavaScript) that you can open directly in a browser to share with teammates or clients.
38
+
36
39
  ## What Qodfy Checks Today
37
40
 
38
41
  Qodfy scans locally and looks for common launch-readiness risks:
@@ -85,6 +88,7 @@ qodfy scan --path <project-path>
85
88
  qodfy scan --json
86
89
  qodfy scan --json --output qodfy-report.json
87
90
  qodfy scan --report qodfy-report.md
91
+ qodfy scan --html qodfy-report.html
88
92
  qodfy --help
89
93
  qodfy --version
90
94
  ```
package/dist/index.js CHANGED
@@ -11,11 +11,11 @@ import {
11
11
  scanProject,
12
12
  validScanChecks
13
13
  } from "@qodfy/core";
14
- var CLI_VERSION = "0.2.10";
14
+ var CLI_VERSION = "0.3.0";
15
15
  var DEFAULT_MAX_ISSUES = 5;
16
16
  var program = new Command();
17
17
  program.name("qodfy").description("Launch readiness scanner for AI-built apps.").version(CLI_VERSION);
18
- program.command("scan").description("Scan a project for launch readiness issues.").option("-p, --path <path>", "Project path to scan", process.cwd()).option("--max-issues <number>", "Maximum number of issues to display", String(DEFAULT_MAX_ISSUES)).option("--prompts", "Show safe copy-paste fix prompts for displayed issues").option("--prompt <issue-id>", "Show the safe AI fix prompt for one issue").option("--checks <checks>", "Comma-separated checks to run").option("--all", "Run all checks without prompting").option("--no-interactive", "Skip interactive prompts and run the recommended scan").option("--json", "Print a machine-readable JSON report").option("--output <file>", "Write the JSON report to a file").option("--report <file>", "Write a human-readable Markdown report to a file").action(async (options) => {
18
+ program.command("scan").description("Scan a project for launch readiness issues.").option("-p, --path <path>", "Project path to scan", process.cwd()).option("--max-issues <number>", "Maximum number of issues to display", String(DEFAULT_MAX_ISSUES)).option("--prompts", "Show safe copy-paste fix prompts for displayed issues").option("--prompt <issue-id>", "Show the safe AI fix prompt for one issue").option("--checks <checks>", "Comma-separated checks to run").option("--all", "Run all checks without prompting").option("--no-interactive", "Skip interactive prompts and run the recommended scan").option("--json", "Print a machine-readable JSON report").option("--output <file>", "Write the JSON report to a file").option("--report <file>", "Write a human-readable Markdown report to a file").option("--html <file>", "Write a standalone HTML report to a file").action(async (options) => {
19
19
  const outputOptionsResult = validateScanOutputOptions(options);
20
20
  if (!outputOptionsResult.ok) {
21
21
  printScanError(outputOptionsResult.reason, !isOutputMode(options));
@@ -64,6 +64,11 @@ program.command("scan").description("Scan a project for launch readiness issues.
64
64
  console.log(`Qodfy report saved to ${options.report}`);
65
65
  return;
66
66
  }
67
+ if (options.html) {
68
+ await writeReportFile(options.html, renderHtmlReport(outputReport));
69
+ console.log(`Qodfy HTML report saved to ${options.html}`);
70
+ return;
71
+ }
67
72
  if (options.prompt) {
68
73
  printPromptFromReport(report, options.prompt);
69
74
  return;
@@ -151,16 +156,34 @@ function validateScanOutputOptions(options) {
151
156
  reason: "Use either --json or --report for one scan command, not both."
152
157
  };
153
158
  }
154
- if ((options.json || options.report) && options.prompt) {
159
+ if (options.json && options.html) {
155
160
  return {
156
161
  ok: false,
157
- reason: "Use qodfy prompt <issue-id> for fix prompts, or run qodfy scan --json/--report for reports."
162
+ reason: "Use either --json or --html for one scan command, not both."
163
+ };
164
+ }
165
+ if (options.report && options.html) {
166
+ return {
167
+ ok: false,
168
+ reason: "Use either --report or --html for one scan command, not both."
169
+ };
170
+ }
171
+ if (options.html && options.output) {
172
+ return {
173
+ ok: false,
174
+ reason: "--output is only used with --json. For HTML, just pass --html <file>."
175
+ };
176
+ }
177
+ if ((options.json || options.report || options.html) && options.prompt) {
178
+ return {
179
+ ok: false,
180
+ reason: "Use qodfy prompt <issue-id> for fix prompts, or run qodfy scan --json/--report/--html for reports."
158
181
  };
159
182
  }
160
183
  return { ok: true };
161
184
  }
162
185
  function isOutputMode(options) {
163
- return Boolean(options.json || options.output || options.report);
186
+ return Boolean(options.json || options.output || options.report || options.html);
164
187
  }
165
188
  async function resolveScanMode(options) {
166
189
  if (options.checks) {
@@ -540,6 +563,532 @@ function appendMarkdownFooter(lines) {
540
563
  lines.push("", "## Generated by Qodfy", "");
541
564
  lines.push("Qodfy scans locally and does not print secret values in reports.");
542
565
  }
566
+ function escapeHtml(value) {
567
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
568
+ }
569
+ function renderHtmlReport(report) {
570
+ const projectName = path.basename(report.projectPath) || report.projectPath;
571
+ const statusLabel = getStatusLabel(report.score);
572
+ const statusTone = getStatusTone(report.score);
573
+ const criticalCount = countIssuesBySeverity(report.issues, "critical");
574
+ const warningCount = countIssuesBySeverity(report.issues, "warning");
575
+ const infoCount = countIssuesBySeverity(report.issues, "info");
576
+ const executiveSummary = getExecutiveSummary(report);
577
+ const priorities = getTopPriorities(report.issues);
578
+ const observations = getWhatLooksGood(report);
579
+ const sortedIssues = getSortedDisplayIssues(report.issues);
580
+ const severityOrder = ["critical", "warning", "info"];
581
+ const summaryCards = [
582
+ { label: "Critical issues", value: criticalCount, tone: criticalCount > 0 ? "critical" : "neutral" },
583
+ { label: "Warnings", value: warningCount, tone: warningCount > 0 ? "warning" : "neutral" },
584
+ { label: "Info", value: infoCount, tone: infoCount > 0 ? "info" : "neutral" },
585
+ { label: "Files scanned", value: report.stats.totalFiles, tone: "neutral" },
586
+ { label: "API routes", value: report.stats.apiRoutes, tone: "neutral" },
587
+ { label: "Scan duration", value: formatDuration(report.stats.durationMs), tone: "neutral" }
588
+ ];
589
+ const summaryCardsHtml = summaryCards.map(
590
+ (card) => ` <div class="stat-card stat-${card.tone}">
591
+ <div class="stat-label">${escapeHtml(card.label)}</div>
592
+ <div class="stat-value">${escapeHtml(String(card.value))}</div>
593
+ </div>`
594
+ ).join("\n");
595
+ const prioritiesHtml = priorities.length === 0 ? `<p class="muted">No urgent priorities found. Review warnings below before launch.</p>` : `<ol class="priority-list">
596
+ ${priorities.map((priority) => ` <li>${escapeHtml(priority)}</li>`).join("\n")}
597
+ </ol>`;
598
+ const observationsHtml = observations.length === 0 ? `<p class="muted">No positive observations to highlight from this scan.</p>` : `<ul class="observation-list">
599
+ ${observations.map((observation) => ` <li>${escapeHtml(observation)}</li>`).join("\n")}
600
+ </ul>`;
601
+ const issueSectionsHtml = report.issues.length === 0 ? `<p class="muted">No issues found.</p>` : severityOrder.map((severity) => renderHtmlSeveritySection(severity, sortedIssues.filter((issue) => issue.severity === severity))).filter(Boolean).join("\n");
602
+ return `<!doctype html>
603
+ <html lang="en">
604
+ <head>
605
+ <meta charset="utf-8" />
606
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
607
+ <meta name="generator" content="Qodfy" />
608
+ <title>Qodfy Launch Readiness Report</title>
609
+ <style>${getHtmlReportStyles()}</style>
610
+ </head>
611
+ <body>
612
+ <main class="page">
613
+ <header class="hero">
614
+ <p class="eyebrow">Qodfy</p>
615
+ <h1>Launch Readiness Report</h1>
616
+ <dl class="hero-meta">
617
+ <div><dt>Project</dt><dd>${escapeHtml(projectName)}</dd></div>
618
+ <div><dt>Path</dt><dd><code>${escapeHtml(report.projectPath)}</code></dd></div>
619
+ <div><dt>Scan mode</dt><dd>${escapeHtml(report.scanMode)}</dd></div>
620
+ <div><dt>Generated</dt><dd>${escapeHtml(report.generatedAt)}</dd></div>
621
+ </dl>
622
+ <div class="score-card score-${statusTone}">
623
+ <div class="score-value">${escapeHtml(String(report.score))}<span class="score-max">/100</span></div>
624
+ <div class="score-status">${escapeHtml(statusLabel)}</div>
625
+ </div>
626
+ </header>
627
+
628
+ <section class="stats" aria-label="Scan summary">
629
+ ${summaryCardsHtml}
630
+ </section>
631
+
632
+ <section class="card" aria-labelledby="executive-summary">
633
+ <h2 id="executive-summary">Executive Summary</h2>
634
+ <p>${escapeHtml(executiveSummary)}</p>
635
+ </section>
636
+
637
+ <section class="card" aria-labelledby="top-priorities">
638
+ <h2 id="top-priorities">Top Priorities</h2>
639
+ ${prioritiesHtml}
640
+ </section>
641
+
642
+ <section class="card" aria-labelledby="what-looks-good">
643
+ <h2 id="what-looks-good">What Looks Good</h2>
644
+ ${observationsHtml}
645
+ </section>
646
+
647
+ <section aria-labelledby="issues-by-priority">
648
+ <h2 id="issues-by-priority">Issues by Priority</h2>
649
+ ${issueSectionsHtml}
650
+ </section>
651
+
652
+ <section class="card" aria-labelledby="next-steps">
653
+ <h2 id="next-steps">Recommended Next Steps</h2>
654
+ <ul>
655
+ <li>Fix critical issues first.</li>
656
+ <li>Review warnings before launch.</li>
657
+ <li>Re-run Qodfy after changes.</li>
658
+ <li>Use <code>qodfy prompt &lt;issue-id&gt;</code> for focused AI repair prompts.</li>
659
+ </ul>
660
+ </section>
661
+
662
+ <footer class="footer">
663
+ <p><strong>Generated by Qodfy.</strong></p>
664
+ <p class="muted">Qodfy scans locally and does not print secret values in reports.</p>
665
+ </footer>
666
+ </main>
667
+ </body>
668
+ </html>
669
+ `;
670
+ }
671
+ function renderHtmlSeveritySection(severity, issues) {
672
+ if (issues.length === 0) {
673
+ return "";
674
+ }
675
+ const heading = severity === "critical" ? "Critical" : severity === "warning" ? "Warnings" : "Info";
676
+ const groupsHtml = categoryOrder.map((category) => {
677
+ const categoryIssues = issues.filter((issue) => issue.category === category);
678
+ if (categoryIssues.length === 0) {
679
+ return "";
680
+ }
681
+ const cardsHtml = categoryIssues.map(renderHtmlIssueCard).join("\n");
682
+ return ` <div class="category-group">
683
+ <h4 class="category-heading">${escapeHtml(categoryLabels[category])}</h4>
684
+ ${cardsHtml}
685
+ </div>`;
686
+ }).filter(Boolean).join("\n");
687
+ return ` <section class="severity-section severity-${severity}" aria-label="${escapeHtml(heading)} issues">
688
+ <h3 class="severity-heading"><span class="severity-dot"></span>${escapeHtml(heading)} <span class="severity-count">(${issues.length})</span></h3>
689
+ ${groupsHtml}
690
+ </section>`;
691
+ }
692
+ function renderHtmlIssueCard(issue) {
693
+ const evidenceHtml = renderHtmlEvidenceList(issue.evidence);
694
+ const contextHtml = renderHtmlEvidenceList(issue.context);
695
+ const tests = getAfterFixTests(issue);
696
+ const testsHtml = tests.length === 0 ? `<p class="muted">No specific tests suggested.</p>` : `<ul>
697
+ ${tests.map((test) => ` <li>${escapeHtml(test)}</li>`).join("\n")}
698
+ </ul>`;
699
+ const fileLine = issue.file ? `<div class="meta-row"><span class="meta-label">File</span><code class="meta-value">${escapeHtml(issue.file)}</code></div>` : "";
700
+ const suggestion = issue.suggestion ?? "No specific suggestion provided for this rule yet.";
701
+ const fixPrompt = issue.fixPrompt ?? "No AI fix prompt available for this rule yet.";
702
+ return ` <article class="issue-card issue-${issue.severity}" aria-labelledby="issue-${escapeHtml(issue.id)}-title">
703
+ <header class="issue-header">
704
+ <div class="issue-badges">
705
+ <span class="badge badge-${issue.severity}">${escapeHtml(issue.severity.toUpperCase())}</span>
706
+ <span class="badge badge-confidence badge-confidence-${issue.confidence}">Confidence: ${escapeHtml(issue.confidence)}</span>
707
+ <span class="badge badge-category">${escapeHtml(categoryLabels[issue.category])}</span>
708
+ </div>
709
+ <h4 id="issue-${escapeHtml(issue.id)}-title" class="issue-title">${escapeHtml(issue.title)}</h4>
710
+ <div class="issue-meta">
711
+ <div class="meta-row"><span class="meta-label">ID</span><code class="meta-value">${escapeHtml(issue.id)}</code></div>
712
+ ${fileLine}
713
+ </div>
714
+ </header>
715
+
716
+ <section class="issue-section">
717
+ <h5>What Qodfy found</h5>
718
+ <p>${escapeHtml(issue.message)}</p>
719
+ </section>
720
+
721
+ <section class="issue-section">
722
+ <h5>Why it matters</h5>
723
+ <p>${escapeHtml(getWhyItMatters(issue))}</p>
724
+ </section>
725
+
726
+ <section class="issue-section">
727
+ <h5>Evidence</h5>
728
+ ${evidenceHtml}
729
+ </section>
730
+
731
+ <section class="issue-section">
732
+ <h5>Context</h5>
733
+ ${contextHtml}
734
+ </section>
735
+
736
+ <section class="issue-section">
737
+ <h5>Suggested fix</h5>
738
+ <p>${escapeHtml(suggestion)}</p>
739
+ </section>
740
+
741
+ <section class="issue-section">
742
+ <h5>AI Fix Prompt</h5>
743
+ <pre class="code-block"><code>${escapeHtml(fixPrompt)}</code></pre>
744
+ </section>
745
+
746
+ <section class="issue-section">
747
+ <h5>After fixing, test this</h5>
748
+ ${testsHtml}
749
+ </section>
750
+ </article>`;
751
+ }
752
+ function renderHtmlEvidenceList(items) {
753
+ if (!items || items.length === 0) {
754
+ return `<p class="muted">None.</p>`;
755
+ }
756
+ const listItems = items.map((item) => {
757
+ const detail = item.detail ? `: <code>${escapeHtml(item.detail)}</code>` : "";
758
+ return ` <li><strong>${escapeHtml(item.label)}</strong>${detail}</li>`;
759
+ }).join("\n");
760
+ return `<ul class="evidence-list">
761
+ ${listItems}
762
+ </ul>`;
763
+ }
764
+ function getStatusTone(score) {
765
+ if (score >= 90) {
766
+ return "ready";
767
+ }
768
+ if (score >= 75) {
769
+ return "almost";
770
+ }
771
+ if (score >= 50) {
772
+ return "needs-fixes";
773
+ }
774
+ return "not-ready";
775
+ }
776
+ function getHtmlReportStyles() {
777
+ return `
778
+ *, *::before, *::after { box-sizing: border-box; }
779
+ html { -webkit-text-size-adjust: 100%; }
780
+ body {
781
+ margin: 0;
782
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
783
+ background: #f6f7fb;
784
+ color: #1f2330;
785
+ line-height: 1.55;
786
+ }
787
+ code, pre {
788
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
789
+ }
790
+ .page {
791
+ max-width: 960px;
792
+ margin: 0 auto;
793
+ padding: 32px 20px 64px;
794
+ }
795
+ .eyebrow {
796
+ margin: 0 0 4px;
797
+ font-size: 12px;
798
+ font-weight: 600;
799
+ letter-spacing: 0.12em;
800
+ text-transform: uppercase;
801
+ color: #5b6478;
802
+ }
803
+ h1 {
804
+ margin: 0 0 16px;
805
+ font-size: 28px;
806
+ font-weight: 700;
807
+ letter-spacing: -0.01em;
808
+ }
809
+ h2 {
810
+ margin: 0 0 12px;
811
+ font-size: 20px;
812
+ font-weight: 700;
813
+ letter-spacing: -0.005em;
814
+ }
815
+ h3 {
816
+ margin: 0 0 12px;
817
+ font-size: 16px;
818
+ font-weight: 700;
819
+ }
820
+ h4 {
821
+ margin: 0 0 8px;
822
+ font-size: 16px;
823
+ font-weight: 600;
824
+ }
825
+ h5 {
826
+ margin: 16px 0 6px;
827
+ font-size: 13px;
828
+ font-weight: 700;
829
+ text-transform: uppercase;
830
+ letter-spacing: 0.06em;
831
+ color: #5b6478;
832
+ }
833
+ p { margin: 0 0 12px; }
834
+ p:last-child { margin-bottom: 0; }
835
+ ul, ol { margin: 0 0 12px; padding-left: 22px; }
836
+ ul li, ol li { margin: 4px 0; }
837
+ .muted { color: #6b7384; }
838
+ .hero {
839
+ background: #ffffff;
840
+ border: 1px solid #e6e8ef;
841
+ border-radius: 16px;
842
+ padding: 24px;
843
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
844
+ margin-bottom: 20px;
845
+ display: grid;
846
+ gap: 16px;
847
+ }
848
+ .hero-meta {
849
+ margin: 0;
850
+ display: grid;
851
+ grid-template-columns: repeat(2, minmax(0, 1fr));
852
+ gap: 12px 24px;
853
+ }
854
+ .hero-meta div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
855
+ .hero-meta dt {
856
+ font-size: 11px;
857
+ font-weight: 600;
858
+ letter-spacing: 0.08em;
859
+ text-transform: uppercase;
860
+ color: #5b6478;
861
+ }
862
+ .hero-meta dd {
863
+ margin: 0;
864
+ font-size: 14px;
865
+ color: #1f2330;
866
+ word-break: break-word;
867
+ }
868
+ .hero-meta code {
869
+ font-size: 12px;
870
+ background: #f1f3f8;
871
+ padding: 2px 6px;
872
+ border-radius: 6px;
873
+ }
874
+ .score-card {
875
+ border-radius: 12px;
876
+ padding: 16px 20px;
877
+ display: flex;
878
+ align-items: center;
879
+ justify-content: space-between;
880
+ gap: 16px;
881
+ border: 1px solid transparent;
882
+ }
883
+ .score-card .score-value {
884
+ font-size: 36px;
885
+ font-weight: 700;
886
+ letter-spacing: -0.02em;
887
+ }
888
+ .score-card .score-max {
889
+ font-size: 18px;
890
+ font-weight: 500;
891
+ color: #5b6478;
892
+ margin-left: 2px;
893
+ }
894
+ .score-card .score-status {
895
+ font-size: 14px;
896
+ font-weight: 600;
897
+ padding: 6px 10px;
898
+ border-radius: 999px;
899
+ background: rgba(255,255,255,0.6);
900
+ }
901
+ .score-ready { background: #ecfdf5; border-color: #a7f3d0; color: #065f46; }
902
+ .score-ready .score-status { background: #d1fae5; color: #065f46; }
903
+ .score-almost { background: #f0f9ff; border-color: #bae6fd; color: #075985; }
904
+ .score-almost .score-status { background: #e0f2fe; color: #075985; }
905
+ .score-needs-fixes { background: #fffbeb; border-color: #fcd34d; color: #92400e; }
906
+ .score-needs-fixes .score-status { background: #fef3c7; color: #92400e; }
907
+ .score-not-ready { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
908
+ .score-not-ready .score-status { background: #fee2e2; color: #991b1b; }
909
+ .stats {
910
+ display: grid;
911
+ grid-template-columns: repeat(3, minmax(0, 1fr));
912
+ gap: 12px;
913
+ margin-bottom: 20px;
914
+ }
915
+ .stat-card {
916
+ background: #ffffff;
917
+ border: 1px solid #e6e8ef;
918
+ border-radius: 12px;
919
+ padding: 14px 16px;
920
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
921
+ }
922
+ .stat-label {
923
+ font-size: 11px;
924
+ font-weight: 600;
925
+ letter-spacing: 0.08em;
926
+ text-transform: uppercase;
927
+ color: #5b6478;
928
+ }
929
+ .stat-value {
930
+ margin-top: 4px;
931
+ font-size: 22px;
932
+ font-weight: 700;
933
+ letter-spacing: -0.01em;
934
+ }
935
+ .stat-critical .stat-value { color: #b91c1c; }
936
+ .stat-warning .stat-value { color: #b45309; }
937
+ .stat-info .stat-value { color: #1d4ed8; }
938
+ .card {
939
+ background: #ffffff;
940
+ border: 1px solid #e6e8ef;
941
+ border-radius: 12px;
942
+ padding: 20px;
943
+ margin-bottom: 16px;
944
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
945
+ }
946
+ .priority-list, .observation-list { margin-bottom: 0; }
947
+ .severity-section {
948
+ background: #ffffff;
949
+ border: 1px solid #e6e8ef;
950
+ border-radius: 14px;
951
+ padding: 20px;
952
+ margin-bottom: 16px;
953
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
954
+ }
955
+ .severity-heading {
956
+ display: flex;
957
+ align-items: center;
958
+ gap: 10px;
959
+ font-size: 18px;
960
+ margin-bottom: 16px;
961
+ }
962
+ .severity-dot {
963
+ width: 10px;
964
+ height: 10px;
965
+ border-radius: 999px;
966
+ display: inline-block;
967
+ background: #94a3b8;
968
+ }
969
+ .severity-critical .severity-dot { background: #dc2626; }
970
+ .severity-warning .severity-dot { background: #d97706; }
971
+ .severity-info .severity-dot { background: #2563eb; }
972
+ .severity-count { color: #6b7384; font-weight: 500; }
973
+ .category-group { margin-top: 16px; }
974
+ .category-group:first-of-type { margin-top: 0; }
975
+ .category-heading {
976
+ font-size: 12px;
977
+ font-weight: 700;
978
+ letter-spacing: 0.08em;
979
+ text-transform: uppercase;
980
+ color: #5b6478;
981
+ margin-bottom: 10px;
982
+ }
983
+ .issue-card {
984
+ border: 1px solid #e6e8ef;
985
+ border-radius: 12px;
986
+ padding: 18px 20px;
987
+ margin-bottom: 12px;
988
+ background: #fbfbfd;
989
+ border-left-width: 4px;
990
+ }
991
+ .issue-critical { border-left-color: #dc2626; }
992
+ .issue-warning { border-left-color: #d97706; }
993
+ .issue-info { border-left-color: #2563eb; }
994
+ .issue-header { margin-bottom: 8px; }
995
+ .issue-badges {
996
+ display: flex;
997
+ flex-wrap: wrap;
998
+ gap: 6px;
999
+ margin-bottom: 8px;
1000
+ }
1001
+ .badge {
1002
+ display: inline-flex;
1003
+ align-items: center;
1004
+ gap: 6px;
1005
+ font-size: 11px;
1006
+ font-weight: 700;
1007
+ letter-spacing: 0.04em;
1008
+ padding: 3px 8px;
1009
+ border-radius: 999px;
1010
+ background: #eef0f5;
1011
+ color: #1f2330;
1012
+ text-transform: uppercase;
1013
+ }
1014
+ .badge-critical { background: #fee2e2; color: #991b1b; }
1015
+ .badge-warning { background: #fef3c7; color: #92400e; }
1016
+ .badge-info { background: #dbeafe; color: #1e40af; }
1017
+ .badge-confidence {
1018
+ background: #eef0f5;
1019
+ color: #334155;
1020
+ text-transform: none;
1021
+ font-weight: 600;
1022
+ letter-spacing: 0;
1023
+ }
1024
+ .badge-confidence-high { background: #dcfce7; color: #166534; }
1025
+ .badge-confidence-medium { background: #fef3c7; color: #92400e; }
1026
+ .badge-confidence-low { background: #e0e7ff; color: #3730a3; }
1027
+ .badge-category {
1028
+ background: #eef0f5;
1029
+ color: #475569;
1030
+ text-transform: none;
1031
+ font-weight: 600;
1032
+ letter-spacing: 0;
1033
+ }
1034
+ .issue-title {
1035
+ margin: 4px 0 8px;
1036
+ font-size: 16px;
1037
+ font-weight: 700;
1038
+ color: #0f172a;
1039
+ }
1040
+ .issue-meta {
1041
+ display: grid;
1042
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1043
+ gap: 6px 16px;
1044
+ }
1045
+ .meta-row { display: flex; gap: 6px; align-items: baseline; min-width: 0; }
1046
+ .meta-label {
1047
+ font-size: 11px;
1048
+ font-weight: 700;
1049
+ letter-spacing: 0.06em;
1050
+ text-transform: uppercase;
1051
+ color: #5b6478;
1052
+ }
1053
+ .meta-value {
1054
+ font-size: 12px;
1055
+ background: #f1f3f8;
1056
+ padding: 2px 6px;
1057
+ border-radius: 6px;
1058
+ word-break: break-all;
1059
+ }
1060
+ .issue-section { margin-top: 8px; }
1061
+ .issue-section p { margin: 0; }
1062
+ .evidence-list { margin: 0; }
1063
+ .code-block {
1064
+ margin: 0;
1065
+ padding: 12px 14px;
1066
+ background: #0f172a;
1067
+ color: #e2e8f0;
1068
+ border-radius: 10px;
1069
+ overflow: auto;
1070
+ font-size: 12.5px;
1071
+ line-height: 1.55;
1072
+ white-space: pre-wrap;
1073
+ word-break: break-word;
1074
+ }
1075
+ .footer {
1076
+ margin-top: 24px;
1077
+ padding: 20px;
1078
+ border-radius: 12px;
1079
+ background: #ffffff;
1080
+ border: 1px solid #e6e8ef;
1081
+ text-align: center;
1082
+ }
1083
+ @media (max-width: 720px) {
1084
+ .page { padding: 20px 14px 48px; }
1085
+ h1 { font-size: 24px; }
1086
+ .hero-meta, .stats, .issue-meta { grid-template-columns: 1fr; }
1087
+ .stats { gap: 8px; }
1088
+ .score-card { flex-direction: column; align-items: flex-start; }
1089
+ }
1090
+ `;
1091
+ }
543
1092
  function appendMarkdownIssue(lines, issue) {
544
1093
  lines.push(`### ${issue.title}`);
545
1094
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qodfy",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "description": "Open-source launch readiness scanner for AI-built apps.",
5
5
  "keywords": [
6
6
  "qodfy",