ai-localize-reporting 2.0.0 → 2.0.3

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.
@@ -3,72 +3,1688 @@ import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { ensureDir } from "ai-localize-shared";
5
5
 
6
+ /**
7
+ * Generates a comprehensive, self-contained HTML analytics dashboard report.
8
+ *
9
+ * @param report - The report data object built by `buildReport()`
10
+ * @param outputPath - Full path to the output HTML file
11
+ */
6
12
  export function generateHtmlReport(report: Report, outputPath: string): void {
7
13
  ensureDir(path.dirname(outputPath));
8
- const html = `<!DOCTYPE html>
9
- <html lang="en">
14
+ const html = buildHtml(report);
15
+ fs.writeFileSync(outputPath, html, "utf-8");
16
+ }
17
+
18
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
19
+
20
+ function esc(s: string): string {
21
+ return s
22
+ .replace(/&/g, "&")
23
+ .replace(/</g, "<")
24
+ .replace(/>/g, ">")
25
+ .replace(/"/g, "&#34;")
26
+ .replace(/'/g, "'");
27
+ }
28
+
29
+ function escJs(s: string): string {
30
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
31
+ }
32
+
33
+ type BadgeVariant = "blue" | "red" | "green" | "orange" | "grey" | "purple" | "yellow" | "teal";
34
+
35
+ function badge(text: string, variant: BadgeVariant = "blue"): string {
36
+ return `<span class="badge badge-${variant}">${esc(text)}</span>`;
37
+ }
38
+
39
+ function severityBadge(count: number, warn = false): string {
40
+ if (count === 0) return badge("0", "green");
41
+ return badge(String(count), warn ? "orange" : "red");
42
+ }
43
+
44
+ // ─── AI Insights Computation ──────────────────────────────────────────────────
45
+
46
+ interface DuplicateGroup {
47
+ text: string;
48
+ count: number;
49
+ keys: string[];
50
+ files: string[];
51
+ }
52
+
53
+ interface InsightResult {
54
+ duplicates: DuplicateGroup[];
55
+ inconsistencies: Array<{ key: string; languages: string[]; hint: string }>;
56
+ namespaceHints: Array<{ namespace: string; count: number; suggestion: string }>;
57
+ coveragePct: number;
58
+ totalKeys: number;
59
+ duplicateKeyCount: number;
60
+ }
61
+
62
+ function computeInsights(report: Report): InsightResult {
63
+ const { details } = report;
64
+
65
+ // ── Duplicate text detection (same text → multiple keys)
66
+ const textMap = new Map<string, { keys: Set<string>; files: Set<string> }>();
67
+ for (const dt of details.detectedTexts) {
68
+ const t = dt.text.trim();
69
+ if (!textMap.has(t)) textMap.set(t, { keys: new Set(), files: new Set() });
70
+ textMap.get(t)!.keys.add(dt.suggestedKey);
71
+ textMap.get(t)!.files.add(dt.filePath);
72
+ }
73
+ const duplicates: DuplicateGroup[] = [];
74
+ for (const [text, { keys, files }] of textMap) {
75
+ if (keys.size > 1) {
76
+ duplicates.push({ text, count: keys.size, keys: [...keys], files: [...files] });
77
+ }
78
+ }
79
+ duplicates.sort((a, b) => b.count - a.count);
80
+
81
+ // ── Duplicate key detection (same key → multiple different texts)
82
+ const keyTextMap = new Map<string, Set<string>>();
83
+ for (const dt of details.detectedTexts) {
84
+ if (!keyTextMap.has(dt.suggestedKey)) keyTextMap.set(dt.suggestedKey, new Set());
85
+ keyTextMap.get(dt.suggestedKey)!.add(dt.text.trim());
86
+ }
87
+ let duplicateKeyCount = 0;
88
+ for (const [, texts] of keyTextMap) {
89
+ if (texts.size > 1) duplicateKeyCount++;
90
+ }
91
+
92
+ // ── Translation inconsistencies (missing keys grouped by language)
93
+ const byLang = new Map<string, string[]>();
94
+ for (const mk of details.missingKeys) {
95
+ if (mk.language) {
96
+ if (!byLang.has(mk.language)) byLang.set(mk.language, []);
97
+ byLang.get(mk.language)!.push(mk.key);
98
+ }
99
+ }
100
+ const inconsistencies = [...byLang.entries()].map(([lang, keys]) => ({
101
+ key: keys[0],
102
+ languages: [lang],
103
+ hint: `${keys.length} key${keys.length > 1 ? "s" : ""} missing in "${lang}"`,
104
+ }));
105
+
106
+ // ── Namespace cleanup hints
107
+ const nsCounts = new Map<string, number>();
108
+ for (const dt of details.detectedTexts) {
109
+ const ns = dt.suggestedKey.split(".")[0] || "default";
110
+ nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
111
+ }
112
+ const namespaceHints: InsightResult["namespaceHints"] = [];
113
+ for (const [ns, count] of nsCounts) {
114
+ if (count < 3) {
115
+ namespaceHints.push({ namespace: ns, count, suggestion: `Namespace "${ns}" has only ${count} key(s) — consider merging into a broader namespace.` });
116
+ }
117
+ }
118
+
119
+ // ── Coverage
120
+ const totalKeys = new Set(details.detectedTexts.map((d) => d.suggestedKey)).size;
121
+ const missing = details.missingKeys.length;
122
+ const coveragePct = totalKeys > 0 ? Math.round(((totalKeys - missing) / totalKeys) * 100) : 100;
123
+
124
+ return { duplicates, inconsistencies, namespaceHints, coveragePct, totalKeys, duplicateKeyCount };
125
+ }
126
+
127
+ // ─── Section builders ────────────────────────────────────────────────────────
128
+
129
+ function buildNavItem(id: string, icon: string, label: string, count: number | string, alert = false): string {
130
+ return `<a href="#${id}" class="nav-item${alert ? " nav-alert" : ""}" data-section="${id}">
131
+ <span class="nav-icon">${icon}</span>
132
+ <span class="nav-label">${esc(label)}</span>
133
+ <span class="nav-count">${count}</span>
134
+ </a>`;
135
+ }
136
+
137
+ function buildStatCard(value: number | string, label: string, hint: string, status: "ok" | "warn" | "err" | "neutral" | "info", icon: string): string {
138
+ return `<div class="stat-card stat-${status}">
139
+ <div class="stat-icon">${icon}</div>
140
+ <div class="stat-value">${value}</div>
141
+ <div class="stat-label">${esc(label)}</div>
142
+ <div class="stat-hint">${esc(hint)}</div>
143
+ </div>`;
144
+ }
145
+
146
+ function buildAccordion(id: string, title: string, subtitle: string, content: string, open = false, severity: "ok" | "warn" | "err" | "info" = "info"): string {
147
+ return `<div class="accordion${open ? " accordion-open" : ""}" id="acc-${id}">
148
+ <button class="accordion-trigger" aria-expanded="${open}" aria-controls="panel-${id}" onclick="toggleAccordion('${id}')">
149
+ <span class="accordion-icon severity-${severity}">&#9679;</span>
150
+ <span class="accordion-title">${title}</span>
151
+ <span class="accordion-subtitle">${subtitle}</span>
152
+ <span class="accordion-chevron">&#8964;</span>
153
+ </button>
154
+ <div class="accordion-panel" id="panel-${id}" role="region" ${open ? "" : 'hidden'}>
155
+ <div class="accordion-body">${content}</div>
156
+ </div>
157
+ </div>`;
158
+ }
159
+
160
+ // ─── Chart builder (pure SVG, no external deps) ──────────────────────────────
161
+
162
+ function buildCoverageDonut(pct: number): string {
163
+ const r = 52;
164
+ const circumference = 2 * Math.PI * r;
165
+ const dash = (pct / 100) * circumference;
166
+ const color = pct >= 80 ? "#22c55e" : pct >= 50 ? "#f59e0b" : "#ef4444";
167
+ return `<svg class="donut-chart" viewBox="0 0 120 120" aria-label="Coverage ${pct}%">
168
+ <circle cx="60" cy="60" r="${r}" fill="none" stroke="var(--border)" stroke-width="12"/>
169
+ <circle cx="60" cy="60" r="${r}" fill="none" stroke="${color}" stroke-width="12"
170
+ stroke-dasharray="${dash.toFixed(2)} ${circumference.toFixed(2)}"
171
+ stroke-dashoffset="${(circumference / 4).toFixed(2)}"
172
+ stroke-linecap="round" transform="rotate(-90 60 60)"/>
173
+ <text x="60" y="56" text-anchor="middle" font-size="20" font-weight="700" fill="${color}">${pct}%</text>
174
+ <text x="60" y="72" text-anchor="middle" font-size="10" fill="var(--text-muted)">coverage</text>
175
+ </svg>`;
176
+ }
177
+
178
+ function buildBarChart(data: Array<{ label: string; value: number; color?: string }>): string {
179
+ if (data.length === 0) return '<p class="empty-state">No data available.</p>';
180
+ const max = Math.max(...data.map((d) => d.value), 1);
181
+ const bars = data.map((d) => {
182
+ const pct = Math.round((d.value / max) * 100);
183
+ const color = d.color || "var(--accent)";
184
+ return `<div class="bar-row">
185
+ <span class="bar-label">${esc(d.label)}</span>
186
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
187
+ <span class="bar-value">${d.value}</span>
188
+ </div>`;
189
+ }).join("");
190
+ return `<div class="bar-chart">${bars}</div>`;
191
+ }
192
+
193
+ // ─── Table builder with search/sort/filter/pagination ───────────────────────
194
+
195
+ function buildSearchableTable(
196
+ tableId: string,
197
+ columns: Array<{ key: string; label: string; sortable?: boolean; width?: string }>,
198
+ rows: string[][],
199
+ pageSize = 50
200
+ ): string {
201
+ const thead = columns.map((c) =>
202
+ `<th data-col="${c.key}" ${c.sortable !== false ? `onclick="sortTable('${tableId}', '${c.key}')" class="sortable"` : ""} ${c.width ? `style="width:${c.width}"` : ""}>${c.label}${c.sortable !== false ? ' <span class="sort-icon">&#8597;</span>' : ""}</th>`
203
+ ).join("");
204
+
205
+ const tbody = rows.map((cells, _ri) =>
206
+ `<tr>${cells.map((cell, ci) => `<td data-col="${columns[ci]?.key || ci}">${cell}</td>`).join("")}</tr>`
207
+ ).join("\n");
208
+
209
+ return `<div class="table-wrapper" id="${tableId}-wrapper">
210
+ <div class="table-controls">
211
+ <div class="search-box">
212
+ <span class="search-icon">&#128269;</span>
213
+ <input type="text" class="table-search" placeholder="Search…" oninput="filterTable('${tableId}', this.value)" aria-label="Search table"/>
214
+ </div>
215
+ <div class="table-meta" id="${tableId}-meta"></div>
216
+ <div class="table-actions">
217
+ <button class="btn btn-sm" onclick="exportTableCsv('${tableId}')">&#8659; CSV</button>
218
+ <button class="btn btn-sm" onclick="exportTableJson('${tableId}')">&#8659; JSON</button>
219
+ </div>
220
+ </div>
221
+ <div class="table-scroll">
222
+ <table id="${tableId}" data-page-size="${pageSize}" data-page="1">
223
+ <thead><tr>${thead}</tr></thead>
224
+ <tbody>${tbody}</tbody>
225
+ </table>
226
+ </div>
227
+ <div class="table-pagination" id="${tableId}-pagination"></div>
228
+ </div>`;
229
+ }
230
+
231
+ // ─── Main HTML builder ───────────────────────────────────────────────────────
232
+
233
+ function buildHtml(report: Report): string {
234
+ const { timestamp, framework, duration, filesScanned, hardcodedTexts,
235
+ localeKeysGenerated, unusedKeys, missingTranslations, assets, details } = report;
236
+
237
+ const scanDate = new Date(timestamp).toLocaleString();
238
+ const insights = computeInsights(report);
239
+
240
+ // ── Coverage ring ──────────────────────────────────────────────────────────
241
+ const coverageDonut = buildCoverageDonut(insights.coveragePct);
242
+
243
+ // ── Namespace distribution chart ───────────────────────────────────────────
244
+ const nsCounts = new Map<string, number>();
245
+ for (const dt of details.detectedTexts) {
246
+ const ns = dt.suggestedKey.split(".")[0] || "default";
247
+ nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
248
+ }
249
+ const nsChartData = [...nsCounts.entries()]
250
+ .sort((a, b) => b[1] - a[1])
251
+ .slice(0, 10)
252
+ .map(([label, value]) => ({ label, value }));
253
+ const nsChart = buildBarChart(nsChartData);
254
+
255
+ // ── Context distribution ───────────────────────────────────────────────────
256
+ const ctxCounts = new Map<string, number>();
257
+ for (const dt of details.detectedTexts) {
258
+ ctxCounts.set(dt.context, (ctxCounts.get(dt.context) || 0) + 1);
259
+ }
260
+ const ctxChartData = [...ctxCounts.entries()]
261
+ .sort((a, b) => b[1] - a[1])
262
+ .map(([label, value]) => ({ label, value }));
263
+ const ctxChart = buildBarChart(ctxChartData);
264
+
265
+ // ── Summary cards ──────────────────────────────────────────────────────────
266
+ const summaryCards = `<div class="stats-grid">
267
+ ${buildStatCard(filesScanned, "Files Scanned", "Source files processed by AST scanner", "neutral", "&#128196;")}
268
+ ${buildStatCard(hardcodedTexts, "Hardcoded Texts", "Raw strings not yet in t()", hardcodedTexts > 0 ? "warn" : "ok", "&#128269;")}
269
+ ${buildStatCard(insights.totalKeys, "Unique Keys", "Deduplicated locale keys generated", "info", "&#128273;")}
270
+ ${buildStatCard(missingTranslations, "Missing Translations", "Keys absent in target languages", missingTranslations > 0 ? "err" : "ok", "&#10060;")}
271
+ ${buildStatCard(unusedKeys, "Unused Keys", "Keys in locale files not used in code", unusedKeys > 0 ? "warn" : "ok", "&#128465;")}
272
+ ${buildStatCard(insights.duplicateKeyCount, "Duplicate Keys", "Same key maps to different texts", insights.duplicateKeyCount > 0 ? "warn" : "ok", "&#128258;")}
273
+ ${buildStatCard(insights.coveragePct + "%", "Translation Coverage", "% of keys present in all languages", insights.coveragePct < 80 ? "err" : insights.coveragePct < 100 ? "warn" : "ok", "&#127919;")}
274
+ ${buildStatCard(assets.totalAssets, "Assets Found", "Static asset references in source", "neutral", "&#128230;")}
275
+ ${buildStatCard(assets.legacyCdnUrls, "Legacy CDN URLs", "Old CDN references not yet replaced", assets.legacyCdnUrls > 0 ? "warn" : "ok", "&#128279;")}
276
+ </div>`;
277
+
278
+ // ── Hardcoded texts section ────────────────────────────────────────────────
279
+ const hardcodedRows = details.detectedTexts.slice(0, 500).map((t) => [
280
+ `<code class="path-code">${esc(t.filePath.split("/").slice(-3).join("/"))}</code>`,
281
+ `<span class="line-num">${t.line}</span>`,
282
+ `<span class="text-preview">${esc(t.text.slice(0, 80))}${t.text.length > 80 ? "…" : ""}</span>`,
283
+ `<code class="key-code">${esc(t.suggestedKey)}</code>`,
284
+ badge(t.context, "blue"),
285
+ badge(t.nodeType, "grey"),
286
+ ]);
287
+
288
+ const hardcodedOverflowMsg = details.detectedTexts.length > 500
289
+ ? `<div class="overflow-notice">Showing 500 of ${details.detectedTexts.length} entries. Export to CSV/JSON for full list.</div>`
290
+ : "";
291
+
292
+ const hardcodedTable = details.detectedTexts.length > 0
293
+ ? buildSearchableTable("tbl-hardcoded", [
294
+ { key: "file", label: "File" },
295
+ { key: "line", label: "Line", width: "60px" },
296
+ { key: "text", label: "Text" },
297
+ { key: "key", label: "Suggested Key" },
298
+ { key: "context", label: "Context", width: "120px" },
299
+ { key: "node", label: "Node Type", width: "120px" },
300
+ ], hardcodedRows) + hardcodedOverflowMsg
301
+ : '<div class="empty-state-card">&#9989; No hardcoded texts detected. All strings are wrapped in translation calls.</div>';
302
+
303
+ const hardcodedContent = `
304
+ <div class="insight-legend">
305
+ Raw text strings found in source that are <strong>not yet wrapped</strong> in a translation call.
306
+ Run <code>ai-localize extract</code> to generate locale files and
307
+ <code>ai-localize full-migrate</code> to wrap them automatically.
308
+ </div>
309
+ ${hardcodedTable}`;
310
+
311
+ // ── Missing translations section ───────────────────────────────────────────
312
+ // Group by language
313
+ const missingByLang = new Map<string, typeof details.missingKeys>();
314
+ for (const mk of details.missingKeys) {
315
+ const lang = mk.language || "unknown";
316
+ if (!missingByLang.has(lang)) missingByLang.set(lang, []);
317
+ missingByLang.get(lang)!.push(mk);
318
+ }
319
+
320
+ let missingContent = "";
321
+ if (details.missingKeys.length === 0) {
322
+ missingContent = '<div class="empty-state-card">&#9989; All keys present in the default language exist in every target language file.</div>';
323
+ } else {
324
+ const missingTableRows = details.missingKeys.map((e) => [
325
+ `<code class="key-code">${esc(e.key)}</code>`,
326
+ e.language ? badge(e.language, "red") : "—",
327
+ e.filePath ? `<code class="path-code">${esc(e.filePath)}</code>` : "—",
328
+ `<span class="detail-text">${esc(e.message)}</span>`,
329
+ ]);
330
+ const langChips = [...missingByLang.entries()]
331
+ .map(([lang, keys]) => `<span class="chip chip-red" onclick="filterTable('tbl-missing', '${escJs(lang)}')">${esc(lang)} <strong>${keys.length}</strong></span>`)
332
+ .join(" ");
333
+ missingContent = `
334
+ <div class="insight-legend">
335
+ These locale keys exist in the <strong>default language</strong> but are <strong>absent</strong> in one or more target language files.
336
+ Ask your translators to fill in these entries. Running <code>ai-localize extract</code> seeds all target files with the source value.
337
+ </div>
338
+ <div class="chip-row">Filter by language: ${langChips}</div>
339
+ ${buildSearchableTable("tbl-missing", [
340
+ { key: "key", label: "Key" },
341
+ { key: "language", label: "Language", width: "100px" },
342
+ { key: "file", label: "Locale File" },
343
+ { key: "message", label: "Details" },
344
+ ], missingTableRows)}`;
345
+ }
346
+
347
+ // ── Unused keys section ────────────────────────────────────────────────────
348
+ const unusedContent = details.unusedKeysList.length === 0
349
+ ? '<div class="empty-state-card">&#9989; No unused translation keys detected. Your locale files are clean.</div>'
350
+ : `<div class="insight-legend">
351
+ Translation keys that exist in locale JSON files but are <strong>not referenced</strong> anywhere in scanned source code.
352
+ They are likely left over from removed features. Run <code>ai-localize cleanup</code> to remove them.
353
+ </div>
354
+ ${buildSearchableTable("tbl-unused", [
355
+ { key: "key", label: "Unused Key" },
356
+ ], details.unusedKeysList.map((k) => [`<code class="key-code">${esc(k)}</code>`]))}`;
357
+
358
+ // ── Assets section ─────────────────────────────────────────────────────────
359
+ const assetRows = details.assets.map((a) => [
360
+ `<code class="path-code">${esc(a.localPath.split("/").slice(-3).join("/"))}</code>`,
361
+ `<code class="key-code">${esc(a.s3Key)}</code>`,
362
+ `<a href="${esc(a.cloudfrontUrl)}" target="_blank" rel="noopener" class="cdn-link">${esc(a.cloudfrontUrl.slice(0, 60))}${a.cloudfrontUrl.length > 60 ? "…" : ""}</a>`,
363
+ `<span class="file-size">${(a.size / 1024).toFixed(1)} KB</span>`,
364
+ badge(a.contentType, "grey"),
365
+ ]);
366
+
367
+ const assetsContent = `
368
+ <div class="asset-summary-row">
369
+ ${buildStatCard(assets.totalAssets, "Total Assets", "All static asset references", "neutral", "&#128190;")}
370
+ ${buildStatCard(assets.uploadedAssets, "Uploaded", "Pushed to S3/CloudFront this run", "ok", "&#9989;")}
371
+ ${buildStatCard(assets.replacedUrls, "URLs Replaced", "Legacy CDN refs rewritten", "ok", "&#128257;")}
372
+ ${buildStatCard(assets.legacyCdnUrls, "Legacy URLs", "Old CDN refs still pending", assets.legacyCdnUrls > 0 ? "warn" : "ok", "&#9888;")}
373
+ </div>
374
+ ${details.assets.length > 0
375
+ ? buildSearchableTable("tbl-assets", [
376
+ { key: "path", label: "Local Path" },
377
+ { key: "s3key", label: "S3 Key" },
378
+ { key: "url", label: "CloudFront URL" },
379
+ { key: "size", label: "Size", width: "80px" },
380
+ { key: "type", label: "Content Type", width: "140px" },
381
+ ], assetRows)
382
+ : '<div class="empty-state-card">No assets uploaded in this run. Use <code>ai-localize upload-assets</code>.</div>'
383
+ }`;
384
+
385
+ // ── AI Insights section ────────────────────────────────────────────────────
386
+ let insightsContent = "";
387
+
388
+ // Duplicate texts
389
+ const dupTextContent = insights.duplicates.length === 0
390
+ ? '<div class="insight-item insight-ok">&#9989; No duplicate texts detected — each string maps to a unique key.</div>'
391
+ : `<div class="insight-legend">Same text found mapped to multiple different keys. Consider consolidating to reduce translator workload.</div>
392
+ ${buildSearchableTable("tbl-dup-texts", [
393
+ { key: "text", label: "Duplicated Text" },
394
+ { key: "count", label: "Key Count", width: "100px" },
395
+ { key: "keys", label: "Keys" },
396
+ { key: "files", label: "Files" },
397
+ ], insights.duplicates.slice(0, 100).map((d) => [
398
+ `<span class="text-preview">${esc(d.text.slice(0, 80))}</span>`,
399
+ severityBadge(d.count, true),
400
+ `<span class="key-list">${d.keys.map((k) => `<code class="key-code">${esc(k)}</code>`).join(" ")}</span>`,
401
+ `<span class="muted">${d.files.length} file${d.files.length > 1 ? "s" : ""}</span>`,
402
+ ]))}`;
403
+
404
+ // Translation inconsistencies
405
+ const inconsistencyContent = insights.inconsistencies.length === 0
406
+ ? '<div class="insight-item insight-ok">&#9989; No translation inconsistencies detected.</div>'
407
+ : `<div class="insight-legend">Languages with the most missing translations — highest-priority for your translators.</div>
408
+ ${buildBarChart(
409
+ [...missingByLang.entries()]
410
+ .sort((a, b) => b[1].length - a[1].length)
411
+ .map(([lang, keys]) => ({ label: lang, value: keys.length, color: "#ef4444" }))
412
+ )}`;
413
+
414
+ // Namespace cleanup
415
+ const nsCleanupContent = insights.namespaceHints.length === 0
416
+ ? '<div class="insight-item insight-ok">&#9989; All namespaces have healthy key counts.</div>'
417
+ : `<div class="insight-legend">Namespaces with very few keys that could be merged for cleaner locale files.</div>
418
+ <ul class="insight-list">
419
+ ${insights.namespaceHints.map((h) => `<li><span class="insight-ns">${esc(h.namespace)}</span> ${badge(String(h.count) + " keys", "orange")} — ${esc(h.suggestion)}</li>`).join("")}
420
+ </ul>`;
421
+
422
+ // Unused key details
423
+ const unusedInsightContent = unusedKeys === 0
424
+ ? '<div class="insight-item insight-ok">&#9989; No unused keys — your locale bundles are lean.</div>'
425
+ : `<div class="insight-legend">${unusedKeys} key${unusedKeys > 1 ? "s" : ""} in locale files with no source code references. Run <code>ai-localize cleanup</code> to remove them.</div>
426
+ ${buildBarChart(
427
+ details.unusedKeysList.slice(0, 10).map((k) => ({
428
+ label: k.length > 40 ? k.slice(0, 40) + "…" : k,
429
+ value: 1,
430
+ color: "#f59e0b",
431
+ }))
432
+ )}`;
433
+
434
+ insightsContent = `
435
+ <div class="insights-grid">
436
+ <div class="insight-card">
437
+ <h3 class="insight-heading">&#128258; Duplicate Text Detection</h3>
438
+ <p class="insight-count">${insights.duplicates.length} duplicate text group${insights.duplicates.length !== 1 ? "s" : ""} found</p>
439
+ ${dupTextContent}
440
+ </div>
441
+ <div class="insight-card">
442
+ <h3 class="insight-heading">&#127757; Translation Inconsistencies</h3>
443
+ <p class="insight-count">${missingTranslations} missing translation${missingTranslations !== 1 ? "s" : ""} across all languages</p>
444
+ ${inconsistencyContent}
445
+ </div>
446
+ <div class="insight-card">
447
+ <h3 class="insight-heading">&#128465; Unused Key Analysis</h3>
448
+ <p class="insight-count">${unusedKeys} unused key${unusedKeys !== 1 ? "s" : ""} detected</p>
449
+ ${unusedInsightContent}
450
+ </div>
451
+ <div class="insight-card">
452
+ <h3 class="insight-heading">&#127800; Namespace Cleanup Suggestions</h3>
453
+ <p class="insight-count">${insights.namespaceHints.length} namespace${insights.namespaceHints.length !== 1 ? "s" : ""} could be consolidated</p>
454
+ ${nsCleanupContent}
455
+ </div>
456
+ </div>`;
457
+
458
+ // ── Diff explainer ─────────────────────────────────────────────────────────
459
+ const diff = Math.abs(hardcodedTexts - localeKeysGenerated);
460
+ const diffExplainer = hardcodedTexts !== localeKeysGenerated
461
+ ? `<div class="info-banner info-banner-blue">
462
+ <strong>&#8505; Why do Hardcoded Texts (${hardcodedTexts}) and Keys Generated (${localeKeysGenerated}) differ?</strong>
463
+ <ul>
464
+ <li><strong>Hardcoded Texts</strong> = total raw string occurrences across all files (same string in 5 files = 5).</li>
465
+ <li><strong>Keys Generated</strong> = unique locale keys after deduplication. Identical strings share one key.</li>
466
+ <li>The difference (${diff}) = duplicate strings consolidated into shared keys.</li>
467
+ </ul>
468
+ </div>`
469
+ : `<div class="info-banner info-banner-green">
470
+ <strong>&#9989; Hardcoded Texts and Keys Generated match (${hardcodedTexts}).</strong>
471
+ Every detected string maps to a unique locale key.
472
+ </div>`;
473
+
474
+ // ── Charts overview ────────────────────────────────────────────────────────
475
+ const chartsSection = `<div class="charts-grid">
476
+ <div class="chart-card">
477
+ <h3 class="chart-title">&#127919; Translation Coverage</h3>
478
+ <div class="chart-body chart-donut-wrap">${coverageDonut}</div>
479
+ <p class="chart-caption">${insights.coveragePct}% of keys covered across all languages</p>
480
+ </div>
481
+ <div class="chart-card">
482
+ <h3 class="chart-title">&#128230; Keys by Namespace (Top 10)</h3>
483
+ <div class="chart-body">${nsChart}</div>
484
+ </div>
485
+ <div class="chart-card">
486
+ <h3 class="chart-title">&#127991; Texts by Context</h3>
487
+ <div class="chart-body">${ctxChart}</div>
488
+ </div>
489
+ </div>`;
490
+
491
+ // ── Navigation ─────────────────────────────────────────────────────────────
492
+ const nav = `<nav class="sidebar" id="sidebar" role="navigation" aria-label="Dashboard navigation">
493
+ <div class="sidebar-header">
494
+ <span class="sidebar-logo">&#127760;</span>
495
+ <span class="sidebar-brand">ai-localize</span>
496
+ <button class="sidebar-toggle" onclick="toggleSidebar()" aria-label="Toggle sidebar">&#9776;</button>
497
+ </div>
498
+ <div class="sidebar-meta">
499
+ <div class="sidebar-framework">${badge(framework, "blue")}</div>
500
+ <div class="sidebar-date">${scanDate}</div>
501
+ </div>
502
+ <div class="nav-group">
503
+ <div class="nav-group-label">Overview</div>
504
+ ${buildNavItem("overview", "&#128202;", "Summary", "")}
505
+ ${buildNavItem("charts", "&#128200;", "Charts", "")}
506
+ </div>
507
+ <div class="nav-group">
508
+ <div class="nav-group-label">Analysis</div>
509
+ ${buildNavItem("hardcoded", "&#128269;", "Hardcoded Texts", hardcodedTexts, hardcodedTexts > 0)}
510
+ ${buildNavItem("missing", "&#10060;", "Missing Translations", missingTranslations, missingTranslations > 0)}
511
+ ${buildNavItem("unused", "&#128465;", "Unused Keys", unusedKeys, unusedKeys > 0)}
512
+ ${buildNavItem("assets", "&#128230;", "Assets", assets.totalAssets)}
513
+ </div>
514
+ <div class="nav-group">
515
+ <div class="nav-group-label">AI Insights</div>
516
+ ${buildNavItem("insights", "&#129504;", "AI Insights", insights.duplicates.length + insights.namespaceHints.length, insights.duplicates.length > 0)}
517
+ </div>
518
+ <div class="nav-group">
519
+ <div class="nav-group-label">Export</div>
520
+ <button class="nav-item nav-btn" onclick="exportFullJson()">&#8659; Export JSON</button>
521
+ <button class="nav-item nav-btn" onclick="exportFullCsv()">&#8659; Export CSV</button>
522
+ <button class="nav-item nav-btn" onclick="window.print()">&#128424; Print / PDF</button>
523
+ </div>
524
+ </nav>`;
525
+
526
+ // ── Theme toggle ───────────────────────────────────────────────────────────
527
+ const themeToggle = `<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark/light mode" title="Toggle theme">
528
+ <span id="theme-icon">&#9790;</span>
529
+ </button>`;
530
+
531
+ // ── Full document ──────────────────────────────────────────────────────────
532
+ return `<!DOCTYPE html>
533
+ <html lang="en" data-theme="light">
10
534
  <head>
11
535
  <meta charset="UTF-8">
12
536
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
13
- <title>ai-localize Report - ${report.timestamp}</title>
14
- <style>
15
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 1200px; margin: 0 auto; padding: 24px; background: #f5f5f5; }
16
- h1 { color: #1a1a2e; } h2 { color: #16213e; border-bottom: 2px solid #0f3460; padding-bottom: 8px; }
17
- .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 24px 0; }
18
- .stat-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
19
- .stat-number { font-size: 2.5rem; font-weight: 700; color: #0f3460; }
20
- .stat-label { color: #666; font-size: 0.9rem; margin-top: 4px; }
21
- .error { color: #e53e3e; } .warning { color: #d69e2e; } .success { color: #38a169; }
22
- table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
23
- th { background: #0f3460; color: white; padding: 12px 16px; text-align: left; }
24
- td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; }
25
- tr:hover td { background: #f8f9ff; }
26
- .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
27
- .badge-blue { background: #ebf4ff; color: #2b6cb0; }
28
- .badge-red { background: #fff5f5; color: #c53030; }
29
- .badge-green { background: #f0fff4; color: #276749; }
30
- </style>
537
+ <title>ai-localize Dashboard &mdash; ${esc(scanDate)}</title>
538
+ <style>${CSS}</style>
31
539
  </head>
32
540
  <body>
33
- <h1>🌐 ai-localize Report</h1>
34
- <p><strong>Generated:</strong> ${report.timestamp} | <strong>Framework:</strong> ${report.framework} | <strong>Duration:</strong> ${report.duration}ms</p>
35
-
36
- <div class="stats">
37
- <div class="stat-card"><div class="stat-number">${report.filesScanned}</div><div class="stat-label">Files Scanned</div></div>
38
- <div class="stat-card"><div class="stat-number error">${report.hardcodedTexts}</div><div class="stat-label">Hardcoded Texts</div></div>
39
- <div class="stat-card"><div class="stat-number success">${report.localeKeysGenerated}</div><div class="stat-label">Keys Generated</div></div>
40
- <div class="stat-card"><div class="stat-number warning">${report.unusedKeys}</div><div class="stat-label">Unused Keys</div></div>
41
- <div class="stat-card"><div class="stat-number error">${report.missingTranslations}</div><div class="stat-label">Missing Translations</div></div>
42
- <div class="stat-card"><div class="stat-number">${report.assets.uploadedAssets}</div><div class="stat-label">Assets Uploaded</div></div>
43
- </div>
44
-
45
- ${report.details.detectedTexts.length > 0 ? `
46
- <h2>Hardcoded Texts (${report.details.detectedTexts.length})</h2>
47
- <table>
48
- <thead><tr><th>File</th><th>Line</th><th>Text</th><th>Suggested Key</th><th>Context</th></tr></thead>
49
- <tbody>
50
- ${report.details.detectedTexts.slice(0, 100).map((t) => `
51
- <tr>
52
- <td><code>${t.filePath.split("/").slice(-3).join("/")}</code></td>
53
- <td>${t.line}</td>
54
- <td>${t.text.slice(0, 60)}${t.text.length > 60 ? "..." : ""}</td>
55
- <td><span class="badge badge-blue">${t.suggestedKey}</span></td>
56
- <td><span class="badge badge-green">${t.context}</span></td>
57
- </tr>`).join("")}
58
- ${report.details.detectedTexts.length > 100 ? `<tr><td colspan="5" style="text-align:center;color:#666">...and ${report.details.detectedTexts.length - 100} more</td></tr>` : ""}
59
- </tbody>
60
- </table>` : ""}
61
-
62
- ${report.details.missingKeys.length > 0 ? `
63
- <h2>Missing Translations (${report.details.missingKeys.length})</h2>
64
- <table>
65
- <thead><tr><th>Key</th><th>Language</th><th>Message</th></tr></thead>
66
- <tbody>
67
- ${report.details.missingKeys.map((e) => `<tr><td><code>${e.key}</code></td><td><span class="badge badge-red">${e.language || ""}</span></td><td>${e.message}</td></tr>`).join("")}
68
- </tbody>
69
- </table>` : ""}
541
+ ${themeToggle}
542
+ ${nav}
543
+ <main class="main" id="main">
544
+ <header class="page-header">
545
+ <div class="page-header-left">
546
+ <h1 class="page-title">&#127760; Localization Analytics Dashboard</h1>
547
+ <div class="page-meta">
548
+ <span class="meta-chip">${badge(framework, "blue")}</span>
549
+ <span class="meta-item">&#128197; ${scanDate}</span>
550
+ <span class="meta-item">&#9201; ${duration}ms</span>
551
+ <span class="meta-item">&#128196; ${filesScanned} files</span>
552
+ </div>
553
+ </div>
554
+ <div class="page-header-right">
555
+ <button class="btn btn-primary" onclick="expandAll()">Expand All</button>
556
+ <button class="btn" onclick="collapseAll()">Collapse All</button>
557
+ </div>
558
+ </header>
559
+
560
+ <!-- Summary -->
561
+ <section id="overview" class="section">
562
+ <div class="section-title-row">
563
+ <h2 class="section-title">&#128202; Summary</h2>
564
+ </div>
565
+ ${summaryCards}
566
+ ${diffExplainer}
567
+ </section>
568
+
569
+ <!-- Charts -->
570
+ <section id="charts" class="section">
571
+ <div class="section-title-row">
572
+ <h2 class="section-title">&#128200; Analytics</h2>
573
+ </div>
574
+ ${chartsSection}
575
+ </section>
576
+
577
+ <!-- Hardcoded Texts -->
578
+ <section id="hardcoded" class="section">
579
+ <div class="section-title-row">
580
+ <h2 class="section-title">&#128269; Hardcoded Texts <span class="section-count ${hardcodedTexts > 0 ? "count-warn" : "count-ok"}">${hardcodedTexts}</span></h2>
581
+ </div>
582
+ ${buildAccordion("hardcoded-main",
583
+ hardcodedTexts > 0 ? "&#9888; " + hardcodedTexts + " hardcoded string" + (hardcodedTexts > 1 ? "s" : "") + " detected" : "&#9989; No hardcoded texts",
584
+ hardcodedTexts > 0 ? "Strings not yet wrapped in translation calls" : "All strings are properly localized",
585
+ hardcodedContent,
586
+ hardcodedTexts > 0,
587
+ hardcodedTexts > 0 ? "warn" : "ok"
588
+ )}
589
+ </section>
590
+
591
+ <!-- Missing Translations -->
592
+ <section id="missing" class="section">
593
+ <div class="section-title-row">
594
+ <h2 class="section-title">&#10060; Missing Translations <span class="section-count ${missingTranslations > 0 ? "count-err" : "count-ok"}">${missingTranslations}</span></h2>
595
+ </div>
596
+ ${buildAccordion("missing-main",
597
+ missingTranslations > 0 ? "&#10060; " + missingTranslations + " missing translation" + (missingTranslations > 1 ? "s" : "") + " found" : "&#9989; All translations present",
598
+ missingTranslations > 0 ? "Keys absent in one or more target language files" : "Every key is covered in all language files",
599
+ missingContent,
600
+ missingTranslations > 0,
601
+ missingTranslations > 0 ? "err" : "ok"
602
+ )}
603
+ </section>
604
+
605
+ <!-- Unused Keys -->
606
+ <section id="unused" class="section">
607
+ <div class="section-title-row">
608
+ <h2 class="section-title">&#128465; Unused Keys <span class="section-count ${unusedKeys > 0 ? "count-warn" : "count-ok"}">${unusedKeys}</span></h2>
609
+ </div>
610
+ ${buildAccordion("unused-main",
611
+ unusedKeys > 0 ? "&#9888; " + unusedKeys + " unused key" + (unusedKeys > 1 ? "s" : "") + " found" : "&#9989; No unused keys",
612
+ unusedKeys > 0 ? "Translation keys not referenced in source code" : "All keys are actively used",
613
+ unusedContent,
614
+ false,
615
+ unusedKeys > 0 ? "warn" : "ok"
616
+ )}
617
+ </section>
618
+
619
+ <!-- Assets -->
620
+ <section id="assets" class="section">
621
+ <div class="section-title-row">
622
+ <h2 class="section-title">&#128230; CDN Assets <span class="section-count count-neutral">${assets.totalAssets}</span></h2>
623
+ </div>
624
+ ${buildAccordion("assets-main",
625
+ "&#128230; Assets: " + assets.totalAssets + " found &middot; " + assets.uploadedAssets + " uploaded &middot; " + assets.legacyCdnUrls + " legacy URLs",
626
+ "S3/CloudFront asset migration status",
627
+ assetsContent,
628
+ false,
629
+ assets.legacyCdnUrls > 0 ? "warn" : "ok"
630
+ )}
631
+ </section>
632
+
633
+ <!-- AI Insights -->
634
+ <section id="insights" class="section">
635
+ <div class="section-title-row">
636
+ <h2 class="section-title">&#129504; AI Insights <span class="section-count ${insights.duplicates.length > 0 || insights.namespaceHints.length > 0 ? "count-warn" : "count-ok"}">${insights.duplicates.length + insights.namespaceHints.length}</span></h2>
637
+ </div>
638
+ <div class="insight-banner">
639
+ &#129504; <strong>Deterministic analysis</strong> — patterns identified from your actual locale data. No LLM/AI required.
640
+ </div>
641
+ ${insightsContent}
642
+ </section>
643
+
644
+ <footer class="page-footer">
645
+ Generated by <strong>ai-localize-core</strong> &mdash; deterministic, offline-capable i18n tooling &mdash; ${scanDate}
646
+ </footer>
647
+ </main>
70
648
 
649
+ <script>${JS(report, insights)}</script>
71
650
  </body>
72
651
  </html>`;
73
- fs.writeFileSync(outputPath, html, "utf-8");
74
652
  }
653
+
654
+ // ─── JavaScript ───────────────────────────────────────────────────────────────
655
+
656
+ function JS(report: Report, insights: InsightResult): string {
657
+ const reportJson = JSON.stringify({
658
+ timestamp: report.timestamp,
659
+ framework: report.framework,
660
+ filesScanned: report.filesScanned,
661
+ hardcodedTexts: report.hardcodedTexts,
662
+ localeKeysGenerated: report.localeKeysGenerated,
663
+ missingTranslations: report.missingTranslations,
664
+ unusedKeys: report.unusedKeys,
665
+ coveragePct: insights.coveragePct,
666
+ assets: report.assets,
667
+ details: {
668
+ detectedTexts: report.details.detectedTexts.map((t) => ({
669
+ filePath: t.filePath,
670
+ line: t.line,
671
+ column: t.column,
672
+ text: t.text,
673
+ suggestedKey: t.suggestedKey,
674
+ context: t.context,
675
+ nodeType: t.nodeType,
676
+ alreadyTranslated: t.alreadyTranslated,
677
+ })),
678
+ missingKeys: report.details.missingKeys.map((m) => ({
679
+ key: m.key,
680
+ language: m.language || '',
681
+ message: m.message,
682
+ filePath: m.filePath || '',
683
+ })),
684
+ unusedKeys: report.details.unusedKeysList,
685
+ assets: report.details.assets.map((a) => ({
686
+ localPath: a.localPath,
687
+ s3Key: a.s3Key,
688
+ cloudfrontUrl: a.cloudfrontUrl,
689
+ contentType: a.contentType,
690
+ sizeKb: (a.size / 1024).toFixed(1),
691
+ })),
692
+ },
693
+ }).replace(/<\/script>/g, "<\\/script>");
694
+
695
+ return `
696
+ (function() {
697
+ 'use strict';
698
+
699
+ // ── Theme ──────────────────────────────────────────────────────────────────
700
+ var THEME_KEY = 'ai-localize-theme';
701
+ function applyTheme(t) {
702
+ document.documentElement.setAttribute('data-theme', t);
703
+ var icon = document.getElementById('theme-icon');
704
+ if (icon) icon.textContent = t === 'dark' ? '\\u2600' : '\\u263E';
705
+ localStorage.setItem(THEME_KEY, t);
706
+ }
707
+ window.toggleTheme = function() {
708
+ var cur = document.documentElement.getAttribute('data-theme');
709
+ applyTheme(cur === 'dark' ? 'light' : 'dark');
710
+ };
711
+ var saved = localStorage.getItem(THEME_KEY);
712
+ if (saved) applyTheme(saved);
713
+ else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) applyTheme('dark');
714
+
715
+ // ── Sidebar ────────────────────────────────────────────────────────────────
716
+ window.toggleSidebar = function() {
717
+ document.getElementById('sidebar').classList.toggle('sidebar-collapsed');
718
+ document.getElementById('main').classList.toggle('main-expanded');
719
+ };
720
+
721
+ // ── Active nav ─────────────────────────────────────────────────────────────
722
+ var sections = document.querySelectorAll('section[id]');
723
+ var navItems = document.querySelectorAll('.nav-item[data-section]');
724
+ function onScroll() {
725
+ var scrollY = window.scrollY + 80;
726
+ var current = '';
727
+ sections.forEach(function(s) { if (scrollY >= s.offsetTop) current = s.id; });
728
+ navItems.forEach(function(a) {
729
+ a.classList.toggle('nav-active', a.getAttribute('data-section') === current);
730
+ });
731
+ }
732
+ window.addEventListener('scroll', onScroll, { passive: true });
733
+ onScroll();
734
+
735
+ // ── Accordion ─────────────────────────────────────────────────────────────
736
+ window.toggleAccordion = function(id) {
737
+ var acc = document.getElementById('acc-' + id);
738
+ var panel = document.getElementById('panel-' + id);
739
+ var btn = acc.querySelector('.accordion-trigger');
740
+ var open = acc.classList.toggle('accordion-open');
741
+ btn.setAttribute('aria-expanded', open);
742
+ if (open) panel.removeAttribute('hidden'); else panel.setAttribute('hidden', '');
743
+ };
744
+ window.expandAll = function() {
745
+ document.querySelectorAll('.accordion').forEach(function(acc) {
746
+ var id = acc.id.replace('acc-', '');
747
+ var panel = document.getElementById('panel-' + id);
748
+ var btn = acc.querySelector('.accordion-trigger');
749
+ acc.classList.add('accordion-open');
750
+ btn.setAttribute('aria-expanded', 'true');
751
+ if (panel) panel.removeAttribute('hidden');
752
+ });
753
+ };
754
+ window.collapseAll = function() {
755
+ document.querySelectorAll('.accordion').forEach(function(acc) {
756
+ var id = acc.id.replace('acc-', '');
757
+ var panel = document.getElementById('panel-' + id);
758
+ var btn = acc.querySelector('.accordion-trigger');
759
+ acc.classList.remove('accordion-open');
760
+ btn.setAttribute('aria-expanded', 'false');
761
+ if (panel) panel.setAttribute('hidden', '');
762
+ });
763
+ };
764
+
765
+ // ── Table: filter ─────────────────────────────────────────────────────────
766
+ window.filterTable = function(tableId, query) {
767
+ var tbl = document.getElementById(tableId);
768
+ if (!tbl) return;
769
+ var q = query.toLowerCase();
770
+ var rows = tbl.querySelectorAll('tbody tr');
771
+ var shown = 0;
772
+ rows.forEach(function(row) {
773
+ var match = row.textContent.toLowerCase().indexOf(q) !== -1;
774
+ row.style.display = match ? '' : 'none';
775
+ if (match) shown++;
776
+ });
777
+ var meta = document.getElementById(tableId + '-meta');
778
+ if (meta) meta.textContent = shown + ' / ' + rows.length + ' rows';
779
+ renderPagination(tableId);
780
+ };
781
+
782
+ // ── Table: sort ───────────────────────────────────────────────────────────
783
+ var sortState = {};
784
+ window.sortTable = function(tableId, col) {
785
+ var tbl = document.getElementById(tableId);
786
+ if (!tbl) return;
787
+ var dir = (sortState[tableId] === col + '_asc') ? 'desc' : 'asc';
788
+ sortState[tableId] = col + '_' + dir;
789
+ var colIndex = -1;
790
+ tbl.querySelectorAll('thead th').forEach(function(th, i) {
791
+ if (th.getAttribute('data-col') === col) colIndex = i;
792
+ th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = '\\u21C5');
793
+ });
794
+ if (colIndex === -1) return;
795
+ var th = tbl.querySelector('thead th[data-col="' + col + '"]');
796
+ if (th) th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = dir === 'asc' ? '\\u2191' : '\\u2193');
797
+ var tbody = tbl.querySelector('tbody');
798
+ var rows = Array.from(tbody.querySelectorAll('tr'));
799
+ rows.sort(function(a, b) {
800
+ var av = (a.cells[colIndex] || {}).textContent || '';
801
+ var bv = (b.cells[colIndex] || {}).textContent || '';
802
+ var an = parseFloat(av), bn = parseFloat(bv);
803
+ if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
804
+ return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
805
+ });
806
+ rows.forEach(function(r) { tbody.appendChild(r); });
807
+ renderPagination(tableId);
808
+ };
809
+
810
+ // ── Table: pagination ────────────────────────────────────────────────────
811
+ function renderPagination(tableId) {
812
+ var tbl = document.getElementById(tableId);
813
+ var pag = document.getElementById(tableId + '-pagination');
814
+ var meta = document.getElementById(tableId + '-meta');
815
+ if (!tbl || !pag) return;
816
+ var pageSize = parseInt(tbl.getAttribute('data-page-size') || '50', 10);
817
+ var rows = Array.from(tbl.querySelectorAll('tbody tr')).filter(function(r) { return r.style.display !== 'none'; });
818
+ var total = rows.length;
819
+ var pages = Math.ceil(total / pageSize);
820
+ var page = parseInt(tbl.getAttribute('data-page') || '1', 10);
821
+ if (page > pages) page = 1;
822
+ tbl.setAttribute('data-page', page);
823
+ rows.forEach(function(r, i) {
824
+ r.style.display = (i >= (page - 1) * pageSize && i < page * pageSize) ? '' : 'none';
825
+ });
826
+ if (meta) meta.textContent = total + ' row' + (total !== 1 ? 's' : '');
827
+ pag.innerHTML = '';
828
+ if (pages <= 1) return;
829
+ function btn(label, p, active, disabled) {
830
+ var b = document.createElement('button');
831
+ b.textContent = label;
832
+ b.className = 'pag-btn' + (active ? ' pag-active' : '') + (disabled ? ' pag-disabled' : '');
833
+ b.disabled = disabled;
834
+ b.onclick = function() { tbl.setAttribute('data-page', p); renderPagination(tableId); };
835
+ return b;
836
+ }
837
+ pag.appendChild(btn('\\u00AB', 1, false, page === 1));
838
+ pag.appendChild(btn('\\u2039', page - 1, false, page === 1));
839
+ var start = Math.max(1, page - 2), end = Math.min(pages, page + 2);
840
+ for (var i = start; i <= end; i++) pag.appendChild(btn(i, i, i === page, false));
841
+ pag.appendChild(btn('\\u203A', page + 1, false, page === pages));
842
+ pag.appendChild(btn('\\u00BB', pages, false, page === pages));
843
+ var info = document.createElement('span');
844
+ info.className = 'pag-info';
845
+ info.textContent = 'Page ' + page + ' of ' + pages;
846
+ pag.appendChild(info);
847
+ }
848
+
849
+ // Init all tables
850
+ document.querySelectorAll('table[id]').forEach(function(tbl) {
851
+ renderPagination(tbl.id);
852
+ var meta = document.getElementById(tbl.id + '-meta');
853
+ if (meta) {
854
+ var total = tbl.querySelectorAll('tbody tr').length;
855
+ meta.textContent = total + ' row' + (total !== 1 ? 's' : '');
856
+ }
857
+ });
858
+
859
+ // ── Export: table CSV ─────────────────────────────────────────────────────
860
+ window.exportTableCsv = function(tableId) {
861
+ var tbl = document.getElementById(tableId);
862
+ if (!tbl) return;
863
+ var rows = [Array.from(tbl.querySelectorAll('thead th')).map(function(th) { return '"' + th.textContent.replace(/"/g, '""').trim() + '"'; }).join(',')];
864
+ tbl.querySelectorAll('tbody tr').forEach(function(tr) {
865
+ rows.push(Array.from(tr.cells).map(function(td) { return '"' + td.textContent.replace(/"/g, '""').trim() + '"'; }).join(','));
866
+ });
867
+ downloadFile(tableId + '.csv', rows.join('\\n'), 'text/csv');
868
+ };
869
+
870
+ // ── Export: table JSON ────────────────────────────────────────────────────
871
+ window.exportTableJson = function(tableId) {
872
+ var tbl = document.getElementById(tableId);
873
+ if (!tbl) return;
874
+ var headers = Array.from(tbl.querySelectorAll('thead th')).map(function(th) { return th.textContent.trim().replace(/[\\u2191\\u2193\\u21C5]/g, '').trim(); });
875
+ var data = Array.from(tbl.querySelectorAll('tbody tr')).map(function(tr) {
876
+ var obj = {};
877
+ Array.from(tr.cells).forEach(function(td, i) { obj[headers[i] || i] = td.textContent.trim(); });
878
+ return obj;
879
+ });
880
+ downloadFile(tableId + '.json', JSON.stringify(data, null, 2), 'application/json');
881
+ };
882
+
883
+ // ── Export: full report JSON ───────────────────────────────────────────────
884
+ window.exportFullJson = function() {
885
+ var fullReport = ${reportJson};
886
+ downloadFile('ai-localize-report.json', JSON.stringify(fullReport, null, 2), 'application/json');
887
+ };
888
+
889
+ // ── Export: full report CSV (summary) ─────────────────────────────────────
890
+ // ── Export: full report CSV (all sections) ────────────────────────────────
891
+ window.exportFullCsv = function() {
892
+ var r = ${reportJson};
893
+ var q = function(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; };
894
+ var rows = [];
895
+
896
+ // Summary
897
+ rows.push('=== SUMMARY ===');
898
+ rows.push('"Metric","Value"');
899
+ rows.push(q('Framework') + ',' + q(r.framework));
900
+ rows.push(q('Generated') + ',' + q(r.timestamp));
901
+ rows.push(q('Files Scanned') + ',' + r.filesScanned);
902
+ rows.push(q('Hardcoded Texts') + ',' + r.hardcodedTexts);
903
+ rows.push(q('Keys Generated') + ',' + r.localeKeysGenerated);
904
+ rows.push(q('Missing Translations') + ',' + r.missingTranslations);
905
+ rows.push(q('Unused Keys') + ',' + r.unusedKeys);
906
+ rows.push(q('Coverage %') + ',' + r.coveragePct);
907
+ rows.push(q('Total Assets') + ',' + r.assets.totalAssets);
908
+ rows.push(q('Uploaded Assets') + ',' + r.assets.uploadedAssets);
909
+ rows.push(q('Replaced URLs') + ',' + r.assets.replacedUrls);
910
+ rows.push(q('Legacy CDN URLs') + ',' + r.assets.legacyCdnUrls);
911
+ rows.push('');
912
+
913
+ // Hardcoded Texts
914
+ rows.push('=== HARDCODED TEXTS ===');
915
+ rows.push('"File","Line","Column","Text","Suggested Key","Context","Node Type","Already Translated"');
916
+ (r.details.detectedTexts || []).forEach(function(t) {
917
+ rows.push([q(t.filePath), t.line, t.column, q(t.text), q(t.suggestedKey), q(t.context), q(t.nodeType), t.alreadyTranslated ? 'true' : 'false'].join(','));
918
+ });
919
+ rows.push('');
920
+
921
+ // Missing Translations
922
+ rows.push('=== MISSING TRANSLATIONS ===');
923
+ rows.push('"Key","Language","Locale File","Message"');
924
+ (r.details.missingKeys || []).forEach(function(m) {
925
+ rows.push([q(m.key), q(m.language), q(m.filePath), q(m.message)].join(','));
926
+ });
927
+ rows.push('');
928
+
929
+ // Unused Keys
930
+ rows.push('=== UNUSED KEYS ===');
931
+ rows.push('"Key"');
932
+ (r.details.unusedKeys || []).forEach(function(k) { rows.push(q(k)); });
933
+ rows.push('');
934
+
935
+ // CDN Assets
936
+ rows.push('=== CDN ASSETS ===');
937
+ rows.push('"Local Path","S3 Key","CloudFront URL","Content Type","Size (KB)"');
938
+ (r.details.assets || []).forEach(function(a) {
939
+ rows.push([q(a.localPath), q(a.s3Key), q(a.cloudfrontUrl), q(a.contentType), a.sizeKb].join(','));
940
+ });
941
+
942
+ downloadFile('ai-localize-full-report.csv', rows.join('\n'), 'text/csv');
943
+ };
944
+
945
+ function downloadFile(filename, content, mimeType) {
946
+ var a = document.createElement('a');
947
+ a.href = URL.createObjectURL(new Blob([content], { type: mimeType }));
948
+ a.download = filename;
949
+ a.click();
950
+ setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
951
+ }
952
+
953
+ // ── Keyboard navigation ───────────────────────────────────────────────────
954
+ document.addEventListener('keydown', function(e) {
955
+ if (e.key === 'Escape') {
956
+ document.querySelectorAll('.table-search').forEach(function(inp) { inp.value = ''; filterTable(inp.closest('.table-wrapper').querySelector('table').id, ''); });
957
+ }
958
+ if ((e.metaKey || e.ctrlKey) && e.key === 'd') { e.preventDefault(); window.toggleTheme(); }
959
+ });
960
+
961
+ // ── Smooth scroll for nav ─────────────────────────────────────────────────
962
+ document.querySelectorAll('a.nav-item[href^="#"]').forEach(function(a) {
963
+ a.addEventListener('click', function(e) {
964
+ e.preventDefault();
965
+ var target = document.querySelector(a.getAttribute('href'));
966
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
967
+ });
968
+ });
969
+
970
+ })();
971
+ `;
972
+ }
973
+
974
+ // ─── CSS ──────────────────────────────────────────────────────────────────────
975
+
976
+ const CSS = `
977
+ /* ── Reset & Variables ────────────────────────────────────────────────────── */
978
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
979
+
980
+ :root {
981
+ --sidebar-w: 240px;
982
+ --accent: #6366f1;
983
+ --accent-hover: #4f46e5;
984
+ --ok: #22c55e;
985
+ --warn: #f59e0b;
986
+ --err: #ef4444;
987
+ --info: #3b82f6;
988
+ --neutral: #6b7280;
989
+
990
+ --bg: #f8fafc;
991
+ --bg-surface: #ffffff;
992
+ --bg-elevated: #f1f5f9;
993
+ --border: #e2e8f0;
994
+ --border-subtle: #f1f5f9;
995
+ --text: #0f172a;
996
+ --text-muted: #64748b;
997
+ --text-subtle: #94a3b8;
998
+ --shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
999
+ --shadow-md: 0 4px 12px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
1000
+ --shadow-lg: 0 8px 24px rgba(0,0,0,.12);
1001
+ --radius: 10px;
1002
+ --radius-sm: 6px;
1003
+ --radius-lg: 14px;
1004
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif;
1005
+ --font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
1006
+ --transition: 0.18s ease;
1007
+ }
1008
+
1009
+ [data-theme="dark"] {
1010
+ --bg: #0c0e14;
1011
+ --bg-surface: #161b27;
1012
+ --bg-elevated: #1e2535;
1013
+ --border: #2d3748;
1014
+ --border-subtle: #1e2535;
1015
+ --text: #f1f5f9;
1016
+ --text-muted: #94a3b8;
1017
+ --text-subtle: #64748b;
1018
+ --shadow-sm: 0 1px 3px rgba(0,0,0,.3);
1019
+ --shadow-md: 0 4px 12px rgba(0,0,0,.4);
1020
+ --shadow-lg: 0 8px 24px rgba(0,0,0,.5);
1021
+ --accent: #818cf8;
1022
+ --accent-hover: #a5b4fc;
1023
+ }
1024
+
1025
+ html { scroll-behavior: smooth; }
1026
+ body {
1027
+ font-family: var(--font);
1028
+ background: var(--bg);
1029
+ color: var(--text);
1030
+ line-height: 1.6;
1031
+ font-size: 14px;
1032
+ display: flex;
1033
+ min-height: 100vh;
1034
+ }
1035
+
1036
+ /* ── Sidebar ─────────────────────────────────────────────────────────────── */
1037
+ .sidebar {
1038
+ position: fixed;
1039
+ top: 0; left: 0; bottom: 0;
1040
+ width: var(--sidebar-w);
1041
+ background: var(--bg-surface);
1042
+ border-right: 1px solid var(--border);
1043
+ display: flex;
1044
+ flex-direction: column;
1045
+ overflow-y: auto;
1046
+ overflow-x: hidden;
1047
+ z-index: 100;
1048
+ transition: width var(--transition);
1049
+ }
1050
+ .sidebar-collapsed { width: 52px; }
1051
+ .sidebar-collapsed .sidebar-brand,
1052
+ .sidebar-collapsed .sidebar-meta,
1053
+ .sidebar-collapsed .nav-label,
1054
+ .sidebar-collapsed .nav-count,
1055
+ .sidebar-collapsed .nav-group-label { display: none; }
1056
+ .sidebar-collapsed .nav-item { justify-content:center;padding:10px; }
1057
+ .sidebar-collapsed .sidebar-logo { display:none; }
1058
+ .sidebar-collapsed .sidebar-header { justify-content:center;padding:10px 6px; }
1059
+ .sidebar-collapsed .sidebar-toggle { display:flex !important;align-items:center;justify-content:center;width:36px;height:36px;border-radius:6px;background:var(--bg-elevated);border:1px solid var(--border);color:var(--accent) !important;font-size:18px;margin:0 auto; }
1060
+
1061
+ .sidebar-header {
1062
+ display: flex;
1063
+ align-items: center;
1064
+ gap: 10px;
1065
+ padding: 18px 16px 12px;
1066
+ border-bottom: 1px solid var(--border);
1067
+ flex-shrink: 0;
1068
+ }
1069
+ .sidebar-logo { font-size: 22px; flex-shrink: 0; }
1070
+ .sidebar-brand { font-weight: 700; font-size: 15px; color: var(--accent); flex: 1; }
1071
+ .sidebar-toggle {
1072
+ background: none; border: none; cursor: pointer;
1073
+ font-size: 18px; color: var(--text-muted); padding: 2px 4px;
1074
+ border-radius: var(--radius-sm);
1075
+ transition: color var(--transition);
1076
+ }
1077
+ .sidebar-toggle:hover { color: var(--accent); }
1078
+
1079
+ .sidebar-meta {
1080
+ padding: 10px 16px;
1081
+ border-bottom: 1px solid var(--border);
1082
+ flex-shrink: 0;
1083
+ }
1084
+ .sidebar-framework { margin-bottom: 4px; }
1085
+ .sidebar-date { font-size: 11px; color: var(--text-subtle); }
1086
+
1087
+ .nav-group { padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
1088
+ .nav-group-label {
1089
+ padding: 6px 16px 3px;
1090
+ font-size: 10px;
1091
+ font-weight: 700;
1092
+ letter-spacing: .08em;
1093
+ text-transform: uppercase;
1094
+ color: var(--text-subtle);
1095
+ }
1096
+ .nav-item {
1097
+ display: flex;
1098
+ align-items: center;
1099
+ gap: 10px;
1100
+ padding: 8px 16px;
1101
+ text-decoration: none;
1102
+ color: var(--text-muted);
1103
+ font-size: 13px;
1104
+ font-weight: 500;
1105
+ border-radius: 0;
1106
+ border: none;
1107
+ background: none;
1108
+ cursor: pointer;
1109
+ width: 100%;
1110
+ transition: background var(--transition), color var(--transition);
1111
+ }
1112
+ .nav-item:hover { background: var(--bg-elevated); color: var(--text); }
1113
+ .nav-active { background: var(--bg-elevated) !important; color: var(--accent) !important; font-weight: 600; border-left: 3px solid var(--accent); }
1114
+ .nav-alert { color: var(--warn) !important; }
1115
+ .nav-icon { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; }
1116
+ .nav-label { flex: 1; }
1117
+ .nav-count {
1118
+ font-size: 11px; font-weight: 700;
1119
+ background: var(--bg-elevated);
1120
+ padding: 1px 7px; border-radius: 10px;
1121
+ min-width: 24px; text-align: center;
1122
+ color: var(--text-muted);
1123
+ }
1124
+ .nav-btn { font-size: 12px; }
1125
+
1126
+ /* ── Main content ────────────────────────────────────────────────────────── */
1127
+ .main {
1128
+ margin-left: var(--sidebar-w);
1129
+ flex: 1;
1130
+ min-width: 0;
1131
+ padding: 24px 28px 48px;
1132
+ transition: margin-left var(--transition);
1133
+ }
1134
+ .main-expanded { margin-left: 52px; }
1135
+
1136
+ /* ── Theme toggle ────────────────────────────────────────────────────────── */
1137
+ .theme-toggle {
1138
+ position: fixed;
1139
+ top: 14px; right: 14px;
1140
+ z-index: 200;
1141
+ background: var(--bg-surface);
1142
+ border: 1px solid var(--border);
1143
+ border-radius: 50%;
1144
+ width: 38px; height: 38px;
1145
+ display: flex; align-items: center; justify-content: center;
1146
+ font-size: 18px;
1147
+ cursor: pointer;
1148
+ box-shadow: var(--shadow-md);
1149
+ transition: box-shadow var(--transition);
1150
+ }
1151
+ .theme-toggle:hover { box-shadow: var(--shadow-lg); }
1152
+
1153
+ /* ── Page header ─────────────────────────────────────────────────────────── */
1154
+ .page-header {
1155
+ display: flex;
1156
+ align-items: flex-start;
1157
+ justify-content: space-between;
1158
+ margin-bottom: 28px;
1159
+ gap: 16px;
1160
+ flex-wrap: wrap;
1161
+ }
1162
+ .page-title {
1163
+ font-size: 22px;
1164
+ font-weight: 700;
1165
+ color: var(--text);
1166
+ margin-bottom: 8px;
1167
+ letter-spacing: -.02em;
1168
+ }
1169
+ .page-meta {
1170
+ display: flex;
1171
+ flex-wrap: wrap;
1172
+ gap: 8px;
1173
+ align-items: center;
1174
+ }
1175
+ .meta-chip { display: inline-flex; align-items: center; }
1176
+ .meta-item {
1177
+ font-size: 12px;
1178
+ color: var(--text-muted);
1179
+ background: var(--bg-elevated);
1180
+ padding: 3px 10px;
1181
+ border-radius: 20px;
1182
+ border: 1px solid var(--border);
1183
+ }
1184
+ .page-header-right { display: flex; gap: 8px; flex-shrink: 0; }
1185
+
1186
+ /* ── Sections ────────────────────────────────────────────────────────────── */
1187
+ .section { margin-bottom: 32px; }
1188
+ .section-title-row {
1189
+ display: flex; align-items: center; justify-content: space-between;
1190
+ margin-bottom: 14px;
1191
+ }
1192
+ .section-title {
1193
+ font-size: 17px;
1194
+ font-weight: 700;
1195
+ color: var(--text);
1196
+ display: flex; align-items: center; gap: 10px;
1197
+ }
1198
+ .section-count {
1199
+ font-size: 12px;
1200
+ font-weight: 700;
1201
+ padding: 2px 10px;
1202
+ border-radius: 20px;
1203
+ }
1204
+ .count-ok { background: #dcfce7; color: #15803d; }
1205
+ .count-warn { background: #fef3c7; color: #92400e; }
1206
+ .count-err { background: #fee2e2; color: #991b1b; }
1207
+ .count-neutral { background: var(--bg-elevated); color: var(--text-muted); }
1208
+ [data-theme="dark"] .count-ok { background: #14532d; color: #86efac; }
1209
+ [data-theme="dark"] .count-warn { background: #451a03; color: #fcd34d; }
1210
+ [data-theme="dark"] .count-err { background: #450a0a; color: #fca5a5; }
1211
+
1212
+ /* ── Stat cards ──────────────────────────────────────────────────────────── */
1213
+ .stats-grid {
1214
+ display: grid;
1215
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
1216
+ gap: 14px;
1217
+ margin-bottom: 20px;
1218
+ }
1219
+ .stat-card {
1220
+ background: var(--bg-surface);
1221
+ border: 1px solid var(--border);
1222
+ border-radius: var(--radius-lg);
1223
+ padding: 18px 16px 14px;
1224
+ box-shadow: var(--shadow-sm);
1225
+ position: relative;
1226
+ overflow: hidden;
1227
+ transition: box-shadow var(--transition), transform var(--transition);
1228
+ }
1229
+ .stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
1230
+ .stat-card::before {
1231
+ content: '';
1232
+ position: absolute;
1233
+ top: 0; left: 0; right: 0;
1234
+ height: 3px;
1235
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1236
+ }
1237
+ .stat-ok::before { background: var(--ok); }
1238
+ .stat-warn::before { background: var(--warn); }
1239
+ .stat-err::before { background: var(--err); }
1240
+ .stat-info::before { background: var(--info); }
1241
+ .stat-neutral::before { background: var(--neutral); }
1242
+
1243
+ .stat-icon { font-size: 22px; margin-bottom: 8px; }
1244
+ .stat-value {
1245
+ font-size: 28px;
1246
+ font-weight: 800;
1247
+ line-height: 1;
1248
+ margin-bottom: 4px;
1249
+ color: var(--text);
1250
+ letter-spacing: -.03em;
1251
+ }
1252
+ .stat-ok .stat-value { color: var(--ok); }
1253
+ .stat-warn .stat-value { color: var(--warn); }
1254
+ .stat-err .stat-value { color: var(--err); }
1255
+ .stat-info .stat-value { color: var(--info); }
1256
+ .stat-neutral .stat-value { color: var(--neutral); }
1257
+
1258
+ .stat-label {
1259
+ font-size: 12px;
1260
+ font-weight: 700;
1261
+ color: var(--text);
1262
+ text-transform: uppercase;
1263
+ letter-spacing: .04em;
1264
+ margin-bottom: 3px;
1265
+ }
1266
+ .stat-hint { font-size: 11px; color: var(--text-subtle); }
1267
+
1268
+ /* ── Asset summary row ───────────────────────────────────────────────────── */
1269
+ .asset-summary-row {
1270
+ display: grid;
1271
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1272
+ gap: 12px;
1273
+ margin-bottom: 16px;
1274
+ }
1275
+
1276
+ /* ── Accordion ───────────────────────────────────────────────────────────── */
1277
+ .accordion {
1278
+ background: var(--bg-surface);
1279
+ border: 1px solid var(--border);
1280
+ border-radius: var(--radius);
1281
+ overflow: hidden;
1282
+ box-shadow: var(--shadow-sm);
1283
+ margin-bottom: 8px;
1284
+ }
1285
+ .accordion-trigger {
1286
+ display: flex;
1287
+ align-items: center;
1288
+ gap: 12px;
1289
+ width: 100%;
1290
+ padding: 14px 18px;
1291
+ background: none;
1292
+ border: none;
1293
+ cursor: pointer;
1294
+ text-align: left;
1295
+ transition: background var(--transition);
1296
+ }
1297
+ .accordion-trigger:hover { background: var(--bg-elevated); }
1298
+ .accordion-icon { font-size: 12px; flex-shrink: 0; }
1299
+ .severity-ok { color: var(--ok); }
1300
+ .severity-warn { color: var(--warn); }
1301
+ .severity-err { color: var(--err); }
1302
+ .severity-info { color: var(--info); }
1303
+ .accordion-title {
1304
+ font-size: 14px;
1305
+ font-weight: 600;
1306
+ color: var(--text);
1307
+ flex: 1;
1308
+ }
1309
+ .accordion-subtitle { font-size: 12px; color: var(--text-muted); margin-right: 12px; }
1310
+ .accordion-chevron {
1311
+ font-size: 18px;
1312
+ color: var(--text-muted);
1313
+ transition: transform var(--transition);
1314
+ }
1315
+ .accordion-open .accordion-chevron { transform: rotate(180deg); }
1316
+ .accordion-panel { border-top: 1px solid var(--border); }
1317
+ .accordion-body { padding: 18px; }
1318
+
1319
+ /* ── Charts ──────────────────────────────────────────────────────────────── */
1320
+ .charts-grid {
1321
+ display: grid;
1322
+ grid-template-columns: 180px 1fr 1fr;
1323
+ gap: 16px;
1324
+ margin-bottom: 4px;
1325
+ }
1326
+ @media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } }
1327
+
1328
+ .chart-card {
1329
+ background: var(--bg-surface);
1330
+ border: 1px solid var(--border);
1331
+ border-radius: var(--radius);
1332
+ padding: 18px;
1333
+ box-shadow: var(--shadow-sm);
1334
+ }
1335
+ .chart-title { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 14px; }
1336
+ .chart-body { min-height: 80px; }
1337
+ .chart-donut-wrap { display: flex; justify-content: center; align-items: center; }
1338
+ .chart-caption { font-size: 11px; color: var(--text-muted); margin-top: 10px; text-align: center; }
1339
+ .donut-chart { width: 120px; height: 120px; }
1340
+
1341
+ .bar-chart { display: flex; flex-direction: column; gap: 6px; }
1342
+ .bar-row { display: flex; align-items: center; gap: 8px; }
1343
+ .bar-label { font-size: 11px; color: var(--text-muted); width: 120px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1344
+ .bar-track { flex: 1; height: 8px; background: var(--bg-elevated); border-radius: 4px; overflow: hidden; }
1345
+ .bar-fill { height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; transition: width 0.5s ease; }
1346
+ .bar-value { font-size: 11px; font-weight: 700; color: var(--text-muted); width: 36px; text-align: right; flex-shrink: 0; }
1347
+
1348
+ /* ── Info banners ────────────────────────────────────────────────────────── */
1349
+ .info-banner {
1350
+ border-radius: var(--radius);
1351
+ padding: 14px 18px;
1352
+ font-size: 13px;
1353
+ margin-bottom: 16px;
1354
+ border-left: 4px solid;
1355
+ }
1356
+ .info-banner ul { margin: 8px 0 0 18px; }
1357
+ .info-banner li { margin-bottom: 4px; }
1358
+ .info-banner-blue { background: #eff6ff; border-color: var(--info); color: #1e40af; }
1359
+ .info-banner-green { background: #f0fdf4; border-color: var(--ok); color: #166534; }
1360
+ [data-theme="dark"] .info-banner-blue { background: #1e3a5f; color: #bfdbfe; }
1361
+ [data-theme="dark"] .info-banner-green { background: #14532d; color: #bbf7d0; }
1362
+
1363
+ /* ── Insight legend ──────────────────────────────────────────────────────── */
1364
+ .insight-legend {
1365
+ font-size: 13px;
1366
+ color: var(--text-muted);
1367
+ background: var(--bg-elevated);
1368
+ border-left: 3px solid var(--accent);
1369
+ padding: 10px 14px;
1370
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
1371
+ margin-bottom: 14px;
1372
+ }
1373
+ .insight-legend code {
1374
+ background: var(--bg-surface);
1375
+ border: 1px solid var(--border);
1376
+ padding: 1px 6px;
1377
+ border-radius: 4px;
1378
+ font-family: var(--font-mono);
1379
+ font-size: 12px;
1380
+ }
1381
+
1382
+ /* ── Chip filter row ─────────────────────────────────────────────────────── */
1383
+ .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; font-size: 12px; color: var(--text-muted); }
1384
+ .chip {
1385
+ display: inline-flex; align-items: center; gap: 4px;
1386
+ padding: 3px 10px; border-radius: 14px;
1387
+ font-size: 12px; font-weight: 600;
1388
+ cursor: pointer; border: 1px solid transparent;
1389
+ transition: box-shadow var(--transition);
1390
+ }
1391
+ .chip:hover { box-shadow: 0 0 0 2px var(--accent); }
1392
+ .chip-red { background: #fee2e2; color: #991b1b; }
1393
+ [data-theme="dark"] .chip-red { background: #450a0a; color: #fca5a5; }
1394
+
1395
+ /* ── Tables ──────────────────────────────────────────────────────────────── */
1396
+ .table-wrapper {
1397
+ background: var(--bg-surface);
1398
+ border: 1px solid var(--border);
1399
+ border-radius: var(--radius);
1400
+ overflow: hidden;
1401
+ box-shadow: var(--shadow-sm);
1402
+ }
1403
+ .table-controls {
1404
+ display: flex;
1405
+ align-items: center;
1406
+ gap: 10px;
1407
+ padding: 12px 16px;
1408
+ border-bottom: 1px solid var(--border);
1409
+ flex-wrap: wrap;
1410
+ }
1411
+ .search-box {
1412
+ display: flex;
1413
+ align-items: center;
1414
+ gap: 6px;
1415
+ background: var(--bg-elevated);
1416
+ border: 1px solid var(--border);
1417
+ border-radius: var(--radius-sm);
1418
+ padding: 5px 10px;
1419
+ flex: 1;
1420
+ min-width: 180px;
1421
+ max-width: 320px;
1422
+ }
1423
+ .search-icon { font-size: 13px; color: var(--text-subtle); flex-shrink: 0; }
1424
+ .table-search {
1425
+ border: none;
1426
+ background: transparent;
1427
+ outline: none;
1428
+ font-size: 13px;
1429
+ color: var(--text);
1430
+ width: 100%;
1431
+ font-family: var(--font);
1432
+ }
1433
+ .table-meta { font-size: 12px; color: var(--text-muted); flex: 1; min-width: 80px; }
1434
+ .table-actions { display: flex; gap: 6px; }
1435
+ .table-scroll { overflow-x: auto; }
1436
+
1437
+ table {
1438
+ width: 100%;
1439
+ border-collapse: collapse;
1440
+ font-size: 13px;
1441
+ }
1442
+ thead th {
1443
+ background: var(--bg-elevated);
1444
+ color: var(--text-muted);
1445
+ padding: 10px 14px;
1446
+ text-align: left;
1447
+ font-size: 11px;
1448
+ font-weight: 700;
1449
+ letter-spacing: .04em;
1450
+ text-transform: uppercase;
1451
+ border-bottom: 1px solid var(--border);
1452
+ white-space: nowrap;
1453
+ position: sticky;
1454
+ top: 0;
1455
+ z-index: 1;
1456
+ }
1457
+ th.sortable { cursor: pointer; user-select: none; }
1458
+ th.sortable:hover { background: var(--border); }
1459
+ .sort-icon { font-size: 11px; opacity: .5; }
1460
+ tbody td {
1461
+ padding: 9px 14px;
1462
+ border-bottom: 1px solid var(--border-subtle);
1463
+ vertical-align: top;
1464
+ color: var(--text);
1465
+ }
1466
+ tbody tr:last-child td { border-bottom: none; }
1467
+ tbody tr:hover td { background: var(--bg-elevated); }
1468
+
1469
+ .table-pagination {
1470
+ display: flex;
1471
+ align-items: center;
1472
+ gap: 4px;
1473
+ padding: 10px 16px;
1474
+ border-top: 1px solid var(--border);
1475
+ flex-wrap: wrap;
1476
+ }
1477
+ .pag-btn {
1478
+ background: var(--bg-elevated);
1479
+ border: 1px solid var(--border);
1480
+ border-radius: var(--radius-sm);
1481
+ padding: 4px 9px;
1482
+ font-size: 12px;
1483
+ cursor: pointer;
1484
+ color: var(--text);
1485
+ transition: background var(--transition);
1486
+ }
1487
+ .pag-btn:hover:not(.pag-disabled) { background: var(--accent); color: white; border-color: var(--accent); }
1488
+ .pag-active { background: var(--accent) !important; color: white !important; border-color: var(--accent) !important; font-weight: 700; }
1489
+ .pag-disabled { opacity: .4; cursor: not-allowed; }
1490
+ .pag-info { font-size: 11px; color: var(--text-muted); margin-left: 6px; }
1491
+
1492
+ /* ── Code & path styles ──────────────────────────────────────────────────── */
1493
+ .path-code {
1494
+ font-family: var(--font-mono);
1495
+ font-size: 11px;
1496
+ color: var(--text-muted);
1497
+ background: var(--bg-elevated);
1498
+ padding: 1px 6px;
1499
+ border-radius: 4px;
1500
+ word-break: break-all;
1501
+ }
1502
+ .key-code {
1503
+ font-family: var(--font-mono);
1504
+ font-size: 11px;
1505
+ color: var(--accent);
1506
+ background: var(--bg-elevated);
1507
+ padding: 1px 6px;
1508
+ border-radius: 4px;
1509
+ word-break: break-all;
1510
+ }
1511
+ .key-list { display: flex; flex-wrap: wrap; gap: 4px; }
1512
+ .text-preview { font-size: 12px; word-break: break-word; max-width: 260px; display: inline-block; }
1513
+ .line-num {
1514
+ font-family: var(--font-mono);
1515
+ font-size: 12px;
1516
+ color: var(--text-subtle);
1517
+ background: var(--bg-elevated);
1518
+ padding: 1px 6px;
1519
+ border-radius: 4px;
1520
+ }
1521
+ .file-size { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); }
1522
+ .detail-text { font-size: 12px; color: var(--text-muted); }
1523
+ .muted { color: var(--text-subtle); font-size: 12px; }
1524
+ .cdn-link { font-size: 11px; color: var(--accent); text-decoration: none; }
1525
+ .cdn-link:hover { text-decoration: underline; }
1526
+
1527
+ /* ── Badges ──────────────────────────────────────────────────────────────── */
1528
+ .badge {
1529
+ display: inline-flex;
1530
+ align-items: center;
1531
+ padding: 2px 8px;
1532
+ border-radius: 12px;
1533
+ font-size: 11px;
1534
+ font-weight: 600;
1535
+ white-space: nowrap;
1536
+ line-height: 1.5;
1537
+ }
1538
+ .badge-blue { background: #dbeafe; color: #1d4ed8; }
1539
+ .badge-red { background: #fee2e2; color: #b91c1c; }
1540
+ .badge-green { background: #dcfce7; color: #15803d; }
1541
+ .badge-orange { background: #ffedd5; color: #c2410c; }
1542
+ .badge-grey { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
1543
+ .badge-purple { background: #ede9fe; color: #6d28d9; }
1544
+ .badge-yellow { background: #fef9c3; color: #a16207; }
1545
+ .badge-teal { background: #ccfbf1; color: #0f766e; }
1546
+ [data-theme="dark"] .badge-blue { background: #1e3a5f; color: #93c5fd; }
1547
+ [data-theme="dark"] .badge-red { background: #450a0a; color: #fca5a5; }
1548
+ [data-theme="dark"] .badge-green { background: #14532d; color: #86efac; }
1549
+ [data-theme="dark"] .badge-orange { background: #431407; color: #fdba74; }
1550
+ [data-theme="dark"] .badge-purple { background: #2e1065; color: #c4b5fd; }
1551
+ [data-theme="dark"] .badge-yellow { background: #422006; color: #fde047; }
1552
+ [data-theme="dark"] .badge-teal { background: #042f2e; color: #5eead4; }
1553
+
1554
+ /* ── Buttons ─────────────────────────────────────────────────────────────── */
1555
+ .btn {
1556
+ display: inline-flex;
1557
+ align-items: center;
1558
+ gap: 5px;
1559
+ padding: 7px 14px;
1560
+ font-size: 13px;
1561
+ font-weight: 600;
1562
+ border-radius: var(--radius-sm);
1563
+ border: 1px solid var(--border);
1564
+ background: var(--bg-surface);
1565
+ color: var(--text);
1566
+ cursor: pointer;
1567
+ transition: background var(--transition), box-shadow var(--transition);
1568
+ font-family: var(--font);
1569
+ white-space: nowrap;
1570
+ }
1571
+ .btn:hover { background: var(--bg-elevated); box-shadow: var(--shadow-sm); }
1572
+ .btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
1573
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
1574
+ .btn-sm { padding: 4px 10px; font-size: 11px; }
1575
+
1576
+ /* ── AI Insights ─────────────────────────────────────────────────────────── */
1577
+ .insight-banner {
1578
+ background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
1579
+ border: 1px solid #c4b5fd;
1580
+ border-radius: var(--radius);
1581
+ padding: 12px 18px;
1582
+ font-size: 13px;
1583
+ color: #5b21b6;
1584
+ margin-bottom: 16px;
1585
+ }
1586
+ [data-theme="dark"] .insight-banner { background: linear-gradient(135deg, #1e1b4b 0%, #2e1065 100%); color: #c4b5fd; border-color: #4c1d95; }
1587
+ .insights-grid {
1588
+ display: grid;
1589
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
1590
+ gap: 16px;
1591
+ }
1592
+ .insight-card {
1593
+ background: var(--bg-surface);
1594
+ border: 1px solid var(--border);
1595
+ border-radius: var(--radius);
1596
+ padding: 18px;
1597
+ box-shadow: var(--shadow-sm);
1598
+ }
1599
+ .insight-heading {
1600
+ font-size: 14px;
1601
+ font-weight: 700;
1602
+ color: var(--text);
1603
+ margin-bottom: 4px;
1604
+ }
1605
+ .insight-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; }
1606
+ .insight-item { font-size: 13px; padding: 8px 0; }
1607
+ .insight-ok { color: var(--ok); font-weight: 500; }
1608
+ .insight-list { padding-left: 18px; list-style: disc; }
1609
+ .insight-list li { margin-bottom: 6px; font-size: 13px; color: var(--text-muted); }
1610
+ .insight-ns {
1611
+ font-family: var(--font-mono);
1612
+ font-size: 12px;
1613
+ color: var(--accent);
1614
+ background: var(--bg-elevated);
1615
+ padding: 1px 6px;
1616
+ border-radius: 4px;
1617
+ margin-right: 6px;
1618
+ }
1619
+
1620
+ /* ── Empty state ─────────────────────────────────────────────────────────── */
1621
+ .empty-state-card {
1622
+ background: var(--bg-elevated);
1623
+ border: 1px solid var(--border);
1624
+ border-radius: var(--radius);
1625
+ padding: 20px 24px;
1626
+ font-size: 13px;
1627
+ color: var(--text-muted);
1628
+ text-align: center;
1629
+ }
1630
+ .empty-state-card code {
1631
+ font-family: var(--font-mono);
1632
+ background: var(--bg-surface);
1633
+ border: 1px solid var(--border);
1634
+ padding: 1px 6px;
1635
+ border-radius: 4px;
1636
+ font-size: 12px;
1637
+ }
1638
+
1639
+ /* ── Overflow notice ─────────────────────────────────────────────────────── */
1640
+ .overflow-notice {
1641
+ font-size: 12px;
1642
+ color: var(--text-subtle);
1643
+ text-align: center;
1644
+ padding: 8px;
1645
+ background: var(--bg-elevated);
1646
+ border-top: 1px solid var(--border);
1647
+ border-radius: 0 0 var(--radius) var(--radius);
1648
+ font-style: italic;
1649
+ }
1650
+
1651
+ /* ── Footer ──────────────────────────────────────────────────────────────── */
1652
+ .page-footer {
1653
+ margin-top: 48px;
1654
+ padding-top: 18px;
1655
+ border-top: 1px solid var(--border);
1656
+ font-size: 12px;
1657
+ color: var(--text-subtle);
1658
+ text-align: center;
1659
+ }
1660
+
1661
+ /* ── Print ───────────────────────────────────────────────────────────────── */
1662
+ @media print {
1663
+ .sidebar, .theme-toggle, .table-controls, .table-pagination, .page-header-right { display: none !important; }
1664
+ .main { margin-left: 0 !important; padding: 16px; }
1665
+ .accordion-panel { display: block !important; }
1666
+ .accordion-panel[hidden] { display: block !important; }
1667
+ .stat-card, .chart-card, .accordion, .table-wrapper { break-inside: avoid; }
1668
+ body { background: white; color: black; }
1669
+ }
1670
+
1671
+ /* ── Responsive ──────────────────────────────────────────────────────────── */
1672
+ @media (max-width: 768px) {
1673
+ body { flex-direction: column; }
1674
+ .sidebar { position: relative; width: 100%; height: auto; flex-direction: row; flex-wrap: wrap; overflow: visible; border-right: none; border-bottom: 1px solid var(--border); }
1675
+ .main { margin-left: 0; padding: 16px; }
1676
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
1677
+ .insights-grid { grid-template-columns: 1fr; }
1678
+ .page-header { flex-direction: column; }
1679
+ .theme-toggle { top: 8px; right: 8px; }
1680
+ }
1681
+
1682
+ /* ── Scrollbar ───────────────────────────────────────────────────────────── */
1683
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
1684
+ ::-webkit-scrollbar-track { background: var(--bg); }
1685
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
1686
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-subtle); }
1687
+
1688
+ /* ── Focus ───────────────────────────────────────────────────────────────── */
1689
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1690
+ `;