openreport 0.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/openreport.ts +6 -0
  4. package/package.json +61 -0
  5. package/src/agents/api-documentation.ts +66 -0
  6. package/src/agents/architecture-analyst.ts +46 -0
  7. package/src/agents/code-quality-reviewer.ts +59 -0
  8. package/src/agents/dependency-analyzer.ts +51 -0
  9. package/src/agents/onboarding-guide.ts +59 -0
  10. package/src/agents/orchestrator.ts +41 -0
  11. package/src/agents/performance-analyzer.ts +57 -0
  12. package/src/agents/registry.ts +50 -0
  13. package/src/agents/security-auditor.ts +61 -0
  14. package/src/agents/test-coverage-analyst.ts +58 -0
  15. package/src/agents/todo-generator.ts +50 -0
  16. package/src/app/App.tsx +151 -0
  17. package/src/app/theme.ts +54 -0
  18. package/src/cli.ts +145 -0
  19. package/src/commands/init.ts +81 -0
  20. package/src/commands/interactive.tsx +29 -0
  21. package/src/commands/list.ts +53 -0
  22. package/src/commands/run.ts +168 -0
  23. package/src/commands/view.tsx +52 -0
  24. package/src/components/generation/AgentStatusItem.tsx +125 -0
  25. package/src/components/generation/AgentStatusList.tsx +70 -0
  26. package/src/components/generation/ProgressSummary.tsx +107 -0
  27. package/src/components/generation/StreamingOutput.tsx +154 -0
  28. package/src/components/layout/Container.tsx +24 -0
  29. package/src/components/layout/Footer.tsx +52 -0
  30. package/src/components/layout/Header.tsx +50 -0
  31. package/src/components/report/MarkdownRenderer.tsx +50 -0
  32. package/src/components/report/ReportCard.tsx +31 -0
  33. package/src/components/report/ScrollableView.tsx +164 -0
  34. package/src/config/cli-detection.ts +130 -0
  35. package/src/config/cli-model.ts +397 -0
  36. package/src/config/cli-prompt-formatter.ts +129 -0
  37. package/src/config/defaults.ts +79 -0
  38. package/src/config/loader.ts +168 -0
  39. package/src/config/ollama.ts +48 -0
  40. package/src/config/providers.ts +199 -0
  41. package/src/config/resolve-provider.ts +62 -0
  42. package/src/config/saver.ts +50 -0
  43. package/src/config/schema.ts +51 -0
  44. package/src/errors.ts +34 -0
  45. package/src/hooks/useReportGeneration.ts +199 -0
  46. package/src/hooks/useTerminalSize.ts +35 -0
  47. package/src/ingestion/context-selector.ts +247 -0
  48. package/src/ingestion/file-tree.ts +227 -0
  49. package/src/ingestion/token-budget.ts +52 -0
  50. package/src/pipeline/agent-runner.ts +360 -0
  51. package/src/pipeline/combiner.ts +199 -0
  52. package/src/pipeline/context.ts +108 -0
  53. package/src/pipeline/extraction.ts +153 -0
  54. package/src/pipeline/progress.ts +192 -0
  55. package/src/pipeline/runner.ts +526 -0
  56. package/src/report/html-renderer.ts +294 -0
  57. package/src/report/html-script.ts +123 -0
  58. package/src/report/html-styles.ts +1127 -0
  59. package/src/report/md-to-html.ts +153 -0
  60. package/src/report/open-browser.ts +22 -0
  61. package/src/schemas/findings.ts +48 -0
  62. package/src/schemas/report.ts +64 -0
  63. package/src/screens/ConfigScreen.tsx +271 -0
  64. package/src/screens/GenerationScreen.tsx +278 -0
  65. package/src/screens/HistoryScreen.tsx +108 -0
  66. package/src/screens/HomeScreen.tsx +143 -0
  67. package/src/screens/ViewerScreen.tsx +82 -0
  68. package/src/storage/metadata.ts +69 -0
  69. package/src/storage/report-store.ts +128 -0
  70. package/src/tools/get-file-tree.ts +157 -0
  71. package/src/tools/get-git-info.ts +123 -0
  72. package/src/tools/glob.ts +48 -0
  73. package/src/tools/grep.ts +149 -0
  74. package/src/tools/index.ts +30 -0
  75. package/src/tools/list-directory.ts +57 -0
  76. package/src/tools/read-file.ts +52 -0
  77. package/src/tools/read-package-json.ts +48 -0
  78. package/src/tools/run-command.ts +154 -0
  79. package/src/tools/shared-ignore.ts +58 -0
  80. package/src/types/index.ts +127 -0
  81. package/src/types/marked-terminal.d.ts +17 -0
  82. package/src/utils/debug.ts +25 -0
  83. package/src/utils/file-utils.ts +77 -0
  84. package/src/utils/format.ts +56 -0
  85. package/src/utils/grade-colors.ts +43 -0
  86. package/src/utils/project-detector.ts +296 -0
@@ -0,0 +1,294 @@
1
+ import type { FullReport, SubReport, Finding } from "../types/index.js";
2
+ import { formatDuration, formatTokens } from "../utils/format.js";
3
+ import { SEVERITY_ORDER } from "../schemas/findings.js";
4
+ import { getGradeHexColor } from "../utils/grade-colors.js";
5
+ import { getStyles } from "./html-styles.js";
6
+ import { getScript } from "./html-script.js";
7
+ import { esc, mdToHtml } from "./md-to-html.js";
8
+
9
+ export function renderReportToHtml(report: FullReport): string {
10
+ const { metadata, executiveSummary, findingSummary, subReports } = report;
11
+ const allFindings = report.allFindings ?? subReports.flatMap((r) => r.findings);
12
+ const sortedFindings = [...allFindings].sort((a, b) => {
13
+ return (SEVERITY_ORDER[a.severity] ?? 4) - (SEVERITY_ORDER[b.severity] ?? 4);
14
+ });
15
+ const totalFindings =
16
+ findingSummary.critical +
17
+ findingSummary.warning +
18
+ findingSummary.info +
19
+ findingSummary.suggestion;
20
+ const totalTokens = metadata.tokens.input + metadata.tokens.output;
21
+ const nonce = crypto.randomUUID();
22
+ const gradeColor = getGradeHexColor(metadata.grade || "B");
23
+
24
+ return `<!DOCTYPE html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="UTF-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
29
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'nonce-${nonce}' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; script-src 'nonce-${nonce}'; img-src data:; base-uri 'none'; form-action 'none'">
30
+ <title>${esc(metadata.projectName)} - OpenReport</title>
31
+ <link rel="preconnect" href="https://fonts.googleapis.com">
32
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
33
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
34
+ <style nonce="${nonce}">${getStyles()}
35
+ .grade-badge{--grade-color:${gradeColor}}.grade-glow{background:${gradeColor}}</style>
36
+ </head>
37
+ <body>
38
+ ${renderSidebar(report, allFindings)}
39
+ <main>
40
+ ${renderHeader(report, totalTokens)}
41
+ ${renderFindingSummaryTable(findingSummary, totalFindings)}
42
+ <section id="executive-summary">
43
+ <h2><span class="section-index">01</span>Executive Summary</h2>
44
+ <div class="summary-text">${mdToHtml(executiveSummary)}</div>
45
+ </section>
46
+ ${subReports.map((sr, idx) => sr.agentId === "todo-generator" ? renderTodoReport(sr, idx + 2) : renderSubReport(sr, idx + 2)).join("\n")}
47
+ ${sortedFindings.length > 0 ? renderFindingsAppendix(sortedFindings, subReports.length + 2) : ""}
48
+ </main>
49
+ <div class="scanline-overlay" aria-hidden="true"></div>
50
+ <script nonce="${nonce}">${getScript()}</script>
51
+ </body>
52
+ </html>`;
53
+ }
54
+
55
+ // -- Section renderers ────────────────────────────────────────────────────
56
+
57
+ function renderSidebar(report: FullReport, allFindings: Finding[]): string {
58
+ const links = [
59
+ { id: "executive-summary", label: "Executive Summary" },
60
+ { id: "finding-summary", label: "Finding Summary" },
61
+ ...report.subReports.map((sr) => ({
62
+ id: slugify(sr.title),
63
+ label: sr.title,
64
+ })),
65
+ ];
66
+ if (allFindings.length > 0) {
67
+ links.push({ id: "all-findings", label: "All Findings" });
68
+ }
69
+
70
+ return `<nav class="sidebar" id="sidebar">
71
+ <div class="sidebar-header">
72
+ <span class="brand"><span class="brand-bracket">[</span>OPEN<span class="brand-accent">REPORT</span><span class="brand-bracket">]</span></span>
73
+ <button class="theme-toggle" id="theme-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
74
+ <svg class="theme-icon theme-icon--dark" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.5"/></svg>
75
+ <svg class="theme-icon theme-icon--light" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M14 9.68A6.5 6.5 0 0 1 6.32 2 6.5 6.5 0 1 0 14 9.68z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
76
+ </button>
77
+ </div>
78
+ <div class="sidebar-project">
79
+ <span class="sidebar-project-label">PROJECT</span>
80
+ <span class="sidebar-project-name">${esc(report.metadata.projectName)}</span>
81
+ </div>
82
+ <div class="toc-label">NAVIGATION</div>
83
+ <ul class="toc">
84
+ ${links.map((l, idx) => `<li><a href="#${l.id}"><span class="toc-index">${String(idx + 1).padStart(2, "0")}</span>${esc(l.label)}</a></li>`).join("\n ")}
85
+ </ul>
86
+ <div class="sidebar-footer">
87
+ <div class="sidebar-footer-row">
88
+ <span class="meta-label">MODEL</span>
89
+ <span class="meta-value">${esc(report.metadata.model)}</span>
90
+ </div>
91
+ <div class="sidebar-footer-row">
92
+ <span class="meta-label">DATE</span>
93
+ <span class="meta-value">${new Date(report.metadata.createdAt).toLocaleDateString()}</span>
94
+ </div>
95
+ </div>
96
+ </nav>
97
+ <button class="menu-toggle" onclick="document.getElementById('sidebar').classList.toggle('open')" aria-label="Toggle navigation">
98
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
99
+ </button>`;
100
+ }
101
+
102
+ function renderHeader(report: FullReport, totalTokens: number): string {
103
+ const { metadata } = report;
104
+
105
+ return `<header>
106
+ <div class="header-top">
107
+ <div class="report-title">
108
+ <h1>${esc(metadata.projectName)}</h1>
109
+ <span class="report-type">${esc(metadata.type)}</span>
110
+ </div>
111
+ <div class="header-badges">
112
+ <div class="grade-badge">
113
+ <span class="grade-glow" aria-hidden="true"></span>
114
+ <span class="grade-letter">${esc(metadata.grade || "\u2014")}</span>
115
+ <span class="grade-label">GRADE</span>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ <div class="header-meta">
120
+ <div class="meta-chip">
121
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M5 6h6M5 8.5h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
122
+ <span>${esc(metadata.model)}</span>
123
+ </div>
124
+ <div class="meta-chip">
125
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/><path d="M8 4.5V8l2.5 2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
126
+ <span>${formatDuration(metadata.duration)}</span>
127
+ </div>
128
+ <div class="meta-chip">
129
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M4 4h8v8H4z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 1v3M10 1v3M6 12v3M10 12v3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
130
+ <span>${formatTokens(totalTokens)} tokens</span>
131
+ </div>
132
+ <div class="meta-chip">
133
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M2 6h12" stroke="currentColor" stroke-width="1.2"/><path d="M5.5 1.5v3M10.5 1.5v3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
134
+ <span>${new Date(metadata.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</span>
135
+ </div>
136
+ </div>
137
+ </header>`;
138
+ }
139
+
140
+ function renderFindingSummaryTable(
141
+ summary: { critical: number; warning: number; info: number; suggestion: number },
142
+ total: number
143
+ ): string {
144
+ return `<section id="finding-summary">
145
+ <h2><span class="section-index">00</span>Finding Summary</h2>
146
+ <div class="severity-cards">
147
+ <div class="severity-card critical">
148
+ <div class="severity-card-inner">
149
+ <span class="severity-icon" aria-hidden="true">
150
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 2L2 18h16L10 2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10 8v4M10 14.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
151
+ </span>
152
+ <span class="severity-count">${summary.critical}</span>
153
+ <span class="severity-label">Critical</span>
154
+ </div>
155
+ </div>
156
+ <div class="severity-card warning">
157
+ <div class="severity-card-inner">
158
+ <span class="severity-icon" aria-hidden="true">
159
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5"/><path d="M10 6v5M10 13.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
160
+ </span>
161
+ <span class="severity-count">${summary.warning}</span>
162
+ <span class="severity-label">Warning</span>
163
+ </div>
164
+ </div>
165
+ <div class="severity-card info">
166
+ <div class="severity-card-inner">
167
+ <span class="severity-icon" aria-hidden="true">
168
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5"/><path d="M10 9v5M10 6.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
169
+ </span>
170
+ <span class="severity-count">${summary.info}</span>
171
+ <span class="severity-label">Info</span>
172
+ </div>
173
+ </div>
174
+ <div class="severity-card suggestion">
175
+ <div class="severity-card-inner">
176
+ <span class="severity-icon" aria-hidden="true">
177
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="7" r="5" stroke="currentColor" stroke-width="1.5"/><path d="M7.5 12v2a2.5 2.5 0 005 0v-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
178
+ </span>
179
+ <span class="severity-count">${summary.suggestion}</span>
180
+ <span class="severity-label">Suggestion</span>
181
+ </div>
182
+ </div>
183
+ <div class="severity-card total">
184
+ <div class="severity-card-inner">
185
+ <span class="severity-icon" aria-hidden="true">
186
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="3" y="3" width="14" height="14" rx="3" stroke="currentColor" stroke-width="1.5"/><path d="M7 10h6M10 7v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
187
+ </span>
188
+ <span class="severity-count">${total}</span>
189
+ <span class="severity-label">Total</span>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </section>`;
194
+ }
195
+
196
+ function renderSubReport(sub: SubReport, sectionNum: number): string {
197
+ const id = slugify(sub.title);
198
+ return `<section id="${id}" class="sub-report">
199
+ <h2><span class="section-index">${String(sectionNum).padStart(2, "0")}</span>${esc(sub.title)}</h2>
200
+ <p class="sub-summary">${esc(sub.summary)}</p>
201
+ ${sub.sections.map((s) => `<div class="report-section">
202
+ <h3><span class="h3-marker" aria-hidden="true">//</span> ${esc(s.heading)}</h3>
203
+ ${mdToHtml(s.content)}
204
+ </div>`).join("\n ")}
205
+ ${sub.findings.length > 0
206
+ ? `<div class="findings-section">
207
+ <h3><span class="h3-marker" aria-hidden="true">//</span> Findings <span class="findings-count">${sub.findings.length}</span></h3>
208
+ ${sub.findings.map(renderFinding).join("\n ")}
209
+ </div>`
210
+ : ""}
211
+ </section>`;
212
+ }
213
+
214
+ function renderTodoReport(sub: SubReport, sectionNum: number): string {
215
+ const id = slugify(sub.title);
216
+
217
+ // Classify sections by priority for color coding
218
+ const priorityClass = (heading: string): string => {
219
+ const h = heading.toLowerCase();
220
+ if (h.includes("critical") || h.includes("immediate")) return "priority-critical";
221
+ if (h.includes("quick win") || h.includes("low effort") || h.includes("high impact")) return "priority-quickwins";
222
+ if (h.includes("planned") || h.includes("improvement")) return "priority-planned";
223
+ if (h.includes("nice") || h.includes("optional") || h.includes("summary")) return "priority-nicetohave";
224
+ return "";
225
+ };
226
+
227
+ return `<section id="${id}" class="sub-report todo-section">
228
+ <div class="todo-header">
229
+ <h2><span class="section-index">${String(sectionNum).padStart(2, "0")}</span>${esc(sub.title)}</h2>
230
+ <button class="todo-copy-btn" onclick="copyTodo()" title="Copy todo list as markdown">
231
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" stroke="currentColor" stroke-width="1.5"/></svg>
232
+ Copy Markdown
233
+ </button>
234
+ </div>
235
+ <p class="sub-summary">${esc(sub.summary)}</p>
236
+ ${sub.sections.map((s) => {
237
+ const pClass = priorityClass(s.heading);
238
+ return `<div class="report-section todo-priority-group">
239
+ ${pClass ? `<div class="todo-priority-label ${pClass}">${esc(s.heading)}</div>` : `<h3><span class="h3-marker" aria-hidden="true">//</span> ${esc(s.heading)}</h3>`}
240
+ ${mdToHtml(s.content)}
241
+ </div>`;
242
+ }).join("\n ")}
243
+ <template id="todo-raw">${esc(buildTodoMarkdown(sub))}</template>
244
+ </section>`;
245
+ }
246
+
247
+ function buildTodoMarkdown(sub: SubReport): string {
248
+ const parts: string[] = [];
249
+ parts.push(`# ${sub.title}\n\n`);
250
+ parts.push(`> ${sub.summary}\n\n`);
251
+ for (const section of sub.sections) {
252
+ parts.push(`## ${section.heading}\n\n`);
253
+ parts.push(section.content);
254
+ parts.push("\n\n");
255
+ }
256
+ return parts.join("");
257
+ }
258
+
259
+ function renderFinding(finding: Finding): string {
260
+ const sev = finding.severity;
261
+ return `<details class="finding finding-${sev}">
262
+ <summary>
263
+ <span class="finding-chevron" aria-hidden="true">
264
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
265
+ </span>
266
+ <span class="badge badge-${sev}">${sev.toUpperCase()}</span>
267
+ <span class="finding-title">${esc(finding.title)}</span>
268
+ ${finding.effort ? `<span class="effort effort-${finding.effort}"><span class="effort-dot"></span>${finding.effort}</span>` : ""}
269
+ </summary>
270
+ <div class="finding-body">
271
+ <p>${esc(finding.description)}</p>
272
+ ${finding.files.length > 0 ? `<div class="finding-files"><span class="finding-files-label">FILES</span>${finding.files.map((f) => `<code class="inline">${esc(f)}</code>`).join("")}</div>` : ""}
273
+ ${finding.codeSnippet ? `<pre><code>${esc(finding.codeSnippet)}</code></pre>` : ""}
274
+ <div class="finding-recommendation"><span class="finding-rec-label">RECOMMENDATION</span>${esc(finding.recommendation)}</div>
275
+ </div>
276
+ </details>`;
277
+ }
278
+
279
+ function renderFindingsAppendix(sortedFindings: Finding[], sectionNum: number): string {
280
+ return `<section id="all-findings">
281
+ <h2><span class="section-index">${String(sectionNum).padStart(2, "0")}</span>All Findings by Severity</h2>
282
+ ${sortedFindings.map(renderFinding).join("\n ")}
283
+ </section>`;
284
+ }
285
+
286
+ // -- Utils ──────────────────────────────────────────────────────────────
287
+
288
+ function slugify(text: string): string {
289
+ return text
290
+ .toLowerCase()
291
+ .replace(/[^a-z0-9]+/g, "-")
292
+ .replace(/(^-|-$)/g, "");
293
+ }
294
+
@@ -0,0 +1,123 @@
1
+ export const SCRIPT = `
2
+ (function() {
3
+ 'use strict';
4
+
5
+ /* ── Theme Toggle ─────────────────────────────────────── */
6
+ window.toggleTheme = function() {
7
+ document.body.classList.toggle('light-theme');
8
+ var isLight = document.body.classList.contains('light-theme');
9
+ localStorage.setItem('openreport-theme', isLight ? 'light' : 'dark');
10
+ };
11
+
12
+ // Restore saved theme
13
+ if (localStorage.getItem('openreport-theme') === 'light') {
14
+ document.body.classList.add('light-theme');
15
+ }
16
+
17
+ /* ── Active TOC Highlighting ──────────────────────────── */
18
+ var tocLinks = document.querySelectorAll('.toc a');
19
+ var sections = [];
20
+ tocLinks.forEach(function(link) {
21
+ var id = link.getAttribute('href').slice(1);
22
+ var el = document.getElementById(id);
23
+ if (el) sections.push({ el: el, link: link });
24
+ });
25
+
26
+ // Use IntersectionObserver for performant scroll tracking
27
+ var currentActive = null;
28
+ var observer = new IntersectionObserver(function(entries) {
29
+ entries.forEach(function(entry) {
30
+ if (entry.isIntersecting) {
31
+ if (currentActive) currentActive.classList.remove('active');
32
+ var match = sections.find(function(s) { return s.el === entry.target; });
33
+ if (match) {
34
+ match.link.classList.add('active');
35
+ currentActive = match.link;
36
+ }
37
+ }
38
+ });
39
+ }, { rootMargin: '-10% 0px -75% 0px' });
40
+
41
+ sections.forEach(function(s) { observer.observe(s.el); });
42
+
43
+ /* ── Smooth Scroll Offset ────────────────────────────── */
44
+ tocLinks.forEach(function(a) {
45
+ a.addEventListener('click', function(e) {
46
+ e.preventDefault();
47
+ var id = this.getAttribute('href').slice(1);
48
+ var target = document.getElementById(id);
49
+ if (target) {
50
+ var y = target.getBoundingClientRect().top + window.pageYOffset - 24;
51
+ window.scrollTo({ top: y, behavior: 'smooth' });
52
+ }
53
+ // Close mobile sidebar
54
+ document.getElementById('sidebar').classList.remove('open');
55
+ });
56
+ });
57
+
58
+ /* ── Print Handling ──────────────────────────────────── */
59
+ window.addEventListener('beforeprint', function() {
60
+ document.querySelectorAll('details').forEach(function(d) { d.open = true; });
61
+ });
62
+
63
+ /* ── Keyboard Navigation ─────────────────────────────── */
64
+ document.addEventListener('keydown', function(e) {
65
+ // Escape closes mobile sidebar
66
+ if (e.key === 'Escape') {
67
+ document.getElementById('sidebar').classList.remove('open');
68
+ }
69
+ // 't' toggles theme (when not in input)
70
+ if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
71
+ var tag = document.activeElement.tagName.toLowerCase();
72
+ if (tag !== 'input' && tag !== 'textarea' && tag !== 'select') {
73
+ window.toggleTheme();
74
+ }
75
+ }
76
+ });
77
+
78
+ /* ── Toggle Checklist Items ─────────────────────────── */
79
+ window.toggleCheck = function(li) {
80
+ var cb = li.querySelector('.checkbox');
81
+ if (!cb) return;
82
+ var isChecked = li.classList.toggle('checked');
83
+ if (isChecked) {
84
+ cb.classList.add('checkbox-checked');
85
+ cb.innerHTML = '&#10003;';
86
+ } else {
87
+ cb.classList.remove('checkbox-checked');
88
+ cb.innerHTML = '';
89
+ }
90
+ };
91
+
92
+ /* ── Copy Todo Markdown ─────────────────────────────── */
93
+ window.copyTodo = function() {
94
+ var tpl = document.getElementById('todo-raw');
95
+ if (!tpl) return;
96
+ var text = tpl.innerHTML
97
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
98
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'");
99
+ navigator.clipboard.writeText(text).then(function() {
100
+ var btn = document.querySelector('.todo-copy-btn');
101
+ if (btn) {
102
+ var orig = btn.innerHTML;
103
+ btn.textContent = 'Copied!';
104
+ setTimeout(function() { btn.innerHTML = orig; }, 2000);
105
+ }
106
+ });
107
+ };
108
+
109
+ /* ── Close sidebar on outside click (mobile) ─────────── */
110
+ document.addEventListener('click', function(e) {
111
+ var sidebar = document.getElementById('sidebar');
112
+ var toggle = document.querySelector('.menu-toggle');
113
+ if (sidebar.classList.contains('open') &&
114
+ !sidebar.contains(e.target) &&
115
+ !toggle.contains(e.target)) {
116
+ sidebar.classList.remove('open');
117
+ }
118
+ });
119
+
120
+ })();
121
+ `;
122
+
123
+ export function getScript(): string { return SCRIPT; }