ai-localize-reporting 2.0.1 → 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.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Standalone preview generator — run with:
3
+ * node packages/reporting/preview/make-preview.mjs
4
+ *
5
+ * Writes: packages/reporting/preview/report-preview.html
6
+ * Then opens it in the default browser.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { execSync } from 'child_process';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ // ─── ANSI helpers (for terminal output) ──────────────────────────────────────
17
+ const c = (code, t) => `\x1b[${code}m${t}\x1b[0m`;
18
+ const bold = (t) => c('1', t);
19
+ const cyan = (t) => c('96', t);
20
+ const green = (t) => c('92', t);
21
+ const yellow = (t) => c('93', t);
22
+ const red = (t) => c('91', t);
23
+ const dim = (t) => c('2', t);
24
+
25
+ // ─── Mock report data ─────────────────────────────────────────────────────────
26
+ const mockReport = {
27
+ timestamp: new Date().toISOString(),
28
+ duration: 2340,
29
+ framework: 'react-vite',
30
+ filesScanned: 148,
31
+ hardcodedTexts: 37,
32
+ localeKeysGenerated: 29,
33
+ unusedKeys: 4,
34
+ missingTranslations: 12,
35
+ languages: ['en', 'fr', 'de', 'es', 'ja'],
36
+ assets: {
37
+ totalAssets: 23,
38
+ uploadedAssets: 18,
39
+ replacedUrls: 15,
40
+ legacyCdnUrls: 5,
41
+ },
42
+ details: {
43
+ detectedTexts: [
44
+ { filePath: '/app/src/components/Button/PrimaryButton.tsx', line: 14, column: 8, text: 'Submit', suggestedKey: 'common.submit', context: 'button', nodeType: 'JSXAttribute', alreadyTranslated: false },
45
+ { filePath: '/app/src/components/Button/PrimaryButton.tsx', line: 22, column: 12, text: 'Cancel', suggestedKey: 'common.cancel', context: 'button', nodeType: 'JSXAttribute', alreadyTranslated: false },
46
+ { filePath: '/app/src/pages/Dashboard/Overview.tsx', line: 31, column: 6, text: 'Welcome back!', suggestedKey: 'dashboard.welcomeBack', context: 'heading', nodeType: 'JSXText', alreadyTranslated: false },
47
+ { filePath: '/app/src/pages/Dashboard/Overview.tsx', line: 45, column: 10, text: 'Total Revenue', suggestedKey: 'dashboard.totalRevenue', context: 'table-header', nodeType: 'JSXText', alreadyTranslated: false },
48
+ { filePath: '/app/src/pages/Dashboard/Overview.tsx', line: 67, column: 14, text: 'No data available', suggestedKey: 'dashboard.noData', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
49
+ { filePath: '/app/src/components/Modal/ConfirmModal.tsx', line: 8, column: 4, text: 'Are you sure?', suggestedKey: 'modal.confirmTitle', context: 'modal', nodeType: 'JSXText', alreadyTranslated: false },
50
+ { filePath: '/app/src/components/Modal/ConfirmModal.tsx', line: 19, column: 8, text: 'This action cannot be undone.', suggestedKey: 'modal.confirmBody', context: 'modal', nodeType: 'JSXText', alreadyTranslated: false },
51
+ { filePath: '/app/src/components/Modal/ConfirmModal.tsx', line: 32, column: 12, text: 'Delete', suggestedKey: 'common.delete', context: 'button', nodeType: 'JSXAttribute', alreadyTranslated: false },
52
+ { filePath: '/app/src/forms/LoginForm.tsx', line: 12, column: 6, text: 'Email address', suggestedKey: 'auth.emailLabel', context: 'label', nodeType: 'JSXText', alreadyTranslated: false },
53
+ { filePath: '/app/src/forms/LoginForm.tsx', line: 18, column: 6, text: 'Password', suggestedKey: 'auth.passwordLabel', context: 'label', nodeType: 'JSXText', alreadyTranslated: false },
54
+ { filePath: '/app/src/forms/LoginForm.tsx', line: 24, column: 10, text: 'Enter your email', suggestedKey: 'auth.emailPlaceholder', context: 'placeholder', nodeType: 'JSXAttribute', alreadyTranslated: false },
55
+ { filePath: '/app/src/forms/LoginForm.tsx', line: 30, column: 10, text: 'Enter your password', suggestedKey: 'auth.passwordPlaceholder', context: 'placeholder', nodeType: 'JSXAttribute', alreadyTranslated: false },
56
+ { filePath: '/app/src/forms/LoginForm.tsx', line: 56, column: 8, text: 'Sign in', suggestedKey: 'auth.signIn', context: 'button', nodeType: 'JSXAttribute', alreadyTranslated: false },
57
+ { filePath: '/app/src/forms/LoginForm.tsx', line: 60, column: 12, text: 'Forgot password?', suggestedKey: 'auth.forgotPassword', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
58
+ { filePath: '/app/src/components/Nav/Sidebar.tsx', line: 22, column: 6, text: 'Dashboard', suggestedKey: 'nav.dashboard', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
59
+ { filePath: '/app/src/components/Nav/Sidebar.tsx', line: 30, column: 6, text: 'Settings', suggestedKey: 'nav.settings', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
60
+ { filePath: '/app/src/components/Nav/Sidebar.tsx', line: 38, column: 6, text: 'Users', suggestedKey: 'nav.users', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
61
+ { filePath: '/app/src/components/Nav/Sidebar.tsx', line: 46, column: 6, text: 'Reports', suggestedKey: 'nav.reports', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
62
+ { filePath: '/app/src/components/Nav/Sidebar.tsx', line: 54, column: 6, text: 'Log out', suggestedKey: 'nav.logout', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
63
+ { filePath: '/app/src/pages/Settings/ProfileSettings.tsx', line: 11, column: 4, text: 'Profile Settings', suggestedKey: 'settings.profileTitle', context: 'heading', nodeType: 'JSXText', alreadyTranslated: false },
64
+ { filePath: '/app/src/pages/Settings/ProfileSettings.tsx', line: 28, column: 8, text: 'First name', suggestedKey: 'settings.firstName', context: 'label', nodeType: 'JSXText', alreadyTranslated: false },
65
+ { filePath: '/app/src/pages/Settings/ProfileSettings.tsx', line: 36, column: 8, text: 'Last name', suggestedKey: 'settings.lastName', context: 'label', nodeType: 'JSXText', alreadyTranslated: false },
66
+ { filePath: '/app/src/pages/Settings/ProfileSettings.tsx', line: 44, column: 8, text: 'Save changes', suggestedKey: 'common.saveChanges', context: 'button', nodeType: 'JSXAttribute', alreadyTranslated: false },
67
+ { filePath: '/app/src/components/Toast/ToastManager.tsx', line: 9, column: 6, text: 'Changes saved successfully', suggestedKey: 'toast.saveSuccess', context: 'toast', nodeType: 'string-literal', alreadyTranslated: false },
68
+ { filePath: '/app/src/components/Toast/ToastManager.tsx', line: 15, column: 6, text: 'An error occurred. Please try again.', suggestedKey: 'toast.genericError', context: 'toast', nodeType: 'string-literal', alreadyTranslated: false },
69
+ { filePath: '/app/src/components/Toast/ToastManager.tsx', line: 21, column: 6, text: 'Network connection lost', suggestedKey: 'toast.networkError', context: 'toast', nodeType: 'string-literal', alreadyTranslated: false },
70
+ { filePath: '/app/src/pages/Users/UserTable.tsx', line: 17, column: 6, text: 'Name', suggestedKey: 'users.columnName', context: 'table-header', nodeType: 'JSXText', alreadyTranslated: false },
71
+ { filePath: '/app/src/pages/Users/UserTable.tsx', line: 24, column: 6, text: 'Email', suggestedKey: 'users.columnEmail', context: 'table-header', nodeType: 'JSXText', alreadyTranslated: false },
72
+ { filePath: '/app/src/pages/Users/UserTable.tsx', line: 31, column: 6, text: 'Role', suggestedKey: 'users.columnRole', context: 'table-header', nodeType: 'JSXText', alreadyTranslated: false },
73
+ { filePath: '/app/src/pages/Users/UserTable.tsx', line: 38, column: 6, text: 'Status', suggestedKey: 'users.columnStatus', context: 'table-header', nodeType: 'JSXText', alreadyTranslated: false },
74
+ { filePath: '/app/src/pages/Users/UserTable.tsx', line: 68, column: 10, text: 'No users found', suggestedKey: 'users.emptyState', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
75
+ { filePath: '/app/src/components/Alert/ErrorBanner.tsx', line: 7, column: 4, text: 'Something went wrong', suggestedKey: 'alert.errorTitle', context: 'alert', nodeType: 'JSXText', alreadyTranslated: false },
76
+ { filePath: '/app/src/components/Alert/ErrorBanner.tsx', line: 13, column: 8, text: 'Please refresh the page', suggestedKey: 'alert.refreshHint', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
77
+ { filePath: '/app/src/components/Tooltip/HelpTooltip.tsx', line: 5, column: 6, text: 'Click for more information', suggestedKey: 'tooltip.helpInfo', context: 'tooltip', nodeType: 'JSXAttribute', alreadyTranslated: false },
78
+ { filePath: '/app/src/components/DataGrid/DataGrid.tsx', line: 42, column: 8, text: 'Loading data\u2026', suggestedKey: 'grid.loading', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
79
+ { filePath: '/app/src/components/DataGrid/DataGrid.tsx', line: 58, column: 8, text: 'No results match your search', suggestedKey: 'grid.noResults', context: 'jsx-text', nodeType: 'JSXText', alreadyTranslated: false },
80
+ // duplicate text scenarios (Submit/Cancel in a different namespace)
81
+ { filePath: '/app/src/pages/Reports/ReportHeader.tsx', line: 10, column: 6, text: 'Submit', suggestedKey: 'reports.submit', context: 'button', nodeType: 'JSXAttribute', alreadyTranslated: false },
82
+ { filePath: '/app/src/pages/Reports/ReportHeader.tsx', line: 22, column: 8, text: 'Cancel', suggestedKey: 'reports.cancel', context: 'button', nodeType: 'JSXAttribute', alreadyTranslated: false },
83
+ ],
84
+ missingKeys: [
85
+ { type: 'missing-key', key: 'common.submit', language: 'fr', message: 'Key missing in fr locale', filePath: '/app/locales/fr/common.json' },
86
+ { type: 'missing-key', key: 'common.cancel', language: 'fr', message: 'Key missing in fr locale', filePath: '/app/locales/fr/common.json' },
87
+ { type: 'missing-key', key: 'auth.signIn', language: 'fr', message: 'Key missing in fr locale', filePath: '/app/locales/fr/auth.json' },
88
+ { type: 'missing-key', key: 'dashboard.welcomeBack', language: 'de', message: 'Key missing in de locale', filePath: '/app/locales/de/dashboard.json' },
89
+ { type: 'missing-key', key: 'dashboard.totalRevenue', language: 'de', message: 'Key missing in de locale', filePath: '/app/locales/de/dashboard.json' },
90
+ { type: 'missing-key', key: 'modal.confirmTitle', language: 'de', message: 'Key missing in de locale', filePath: '/app/locales/de/modal.json' },
91
+ { type: 'missing-key', key: 'modal.confirmBody', language: 'de', message: 'Key missing in de locale', filePath: '/app/locales/de/modal.json' },
92
+ { type: 'missing-key', key: 'nav.logout', language: 'es', message: 'Key missing in es locale', filePath: '/app/locales/es/nav.json' },
93
+ { type: 'missing-key', key: 'settings.profileTitle', language: 'es', message: 'Key missing in es locale', filePath: '/app/locales/es/settings.json' },
94
+ { type: 'missing-key', key: 'toast.genericError', language: 'ja', message: 'Key missing in ja locale', filePath: '/app/locales/ja/toast.json' },
95
+ { type: 'missing-key', key: 'toast.saveSuccess', language: 'ja', message: 'Key missing in ja locale', filePath: '/app/locales/ja/toast.json' },
96
+ { type: 'missing-key', key: 'users.emptyState', language: 'ja', message: 'Key missing in ja locale', filePath: '/app/locales/ja/users.json' },
97
+ ],
98
+ unusedKeysList: [
99
+ 'common.oldButton',
100
+ 'dashboard.legacyWidget',
101
+ 'auth.ssoLogin',
102
+ 'settings.betaFeature',
103
+ ],
104
+ assets: [
105
+ { localPath: '/app/public/images/logo.png', s3Key: 'assets/images/logo.png', hash: 'abc123', cloudfrontUrl: 'https://d1abc123.cloudfront.net/assets/images/logo.png', contentType: 'image/png', size: 24576 },
106
+ { localPath: '/app/public/images/hero-banner.webp', s3Key: 'assets/images/hero-banner.webp', hash: 'def456', cloudfrontUrl: 'https://d1abc123.cloudfront.net/assets/images/hero-banner.webp', contentType: 'image/webp', size: 102400 },
107
+ { localPath: '/app/public/fonts/Inter-Regular.woff2', s3Key: 'assets/fonts/Inter-Regular.woff2', hash: 'ghi789', cloudfrontUrl: 'https://d1abc123.cloudfront.net/assets/fonts/Inter-Regular.woff2', contentType: 'font/woff2', size: 65536 },
108
+ { localPath: '/app/public/fonts/Inter-Bold.woff2', s3Key: 'assets/fonts/Inter-Bold.woff2', hash: 'jkl012', cloudfrontUrl: 'https://d1abc123.cloudfront.net/assets/fonts/Inter-Bold.woff2', contentType: 'font/woff2', size: 69120 },
109
+ { localPath: '/app/public/icons/favicon.svg', s3Key: 'assets/icons/favicon.svg', hash: 'mno345', cloudfrontUrl: 'https://d1abc123.cloudfront.net/assets/icons/favicon.svg', contentType: 'image/svg+xml', size: 4096 },
110
+ { localPath: '/app/public/videos/onboarding.mp4', s3Key: 'assets/videos/onboarding.mp4', hash: 'pqr678', cloudfrontUrl: 'https://d1abc123.cloudfront.net/assets/videos/onboarding.mp4', contentType: 'video/mp4', size: 5242880 },
111
+ ],
112
+ },
113
+ };
114
+
115
+ // ─── Inline the reporter logic ────────────────────────────────────────────────
116
+ // We duplicate the buildHtml logic here as pure JS so we don't need TS compilation.
117
+
118
+ function esc(s) {
119
+ return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '&#34;').replace(/'/g, ''');
120
+ }
121
+ function escJs(s) {
122
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '');
123
+ }
124
+ function badge(text, variant = 'blue') {
125
+ return `<span class="badge badge-${variant}">${esc(text)}</span>`;
126
+ }
127
+ function severityBadge(count, warn = false) {
128
+ if (count === 0) return badge('0', 'green');
129
+ return badge(String(count), warn ? 'orange' : 'red');
130
+ }
131
+
132
+ function computeInsights(report) {
133
+ const { details } = report;
134
+ const textMap = new Map();
135
+ for (const dt of details.detectedTexts) {
136
+ const t = dt.text.trim();
137
+ if (!textMap.has(t)) textMap.set(t, { keys: new Set(), files: new Set() });
138
+ textMap.get(t).keys.add(dt.suggestedKey);
139
+ textMap.get(t).files.add(dt.filePath);
140
+ }
141
+ const duplicates = [];
142
+ for (const [text, { keys, files }] of textMap) {
143
+ if (keys.size > 1) duplicates.push({ text, count: keys.size, keys: [...keys], files: [...files] });
144
+ }
145
+ duplicates.sort((a, b) => b.count - a.count);
146
+
147
+ const keyTextMap = new Map();
148
+ for (const dt of details.detectedTexts) {
149
+ if (!keyTextMap.has(dt.suggestedKey)) keyTextMap.set(dt.suggestedKey, new Set());
150
+ keyTextMap.get(dt.suggestedKey).add(dt.text.trim());
151
+ }
152
+ let duplicateKeyCount = 0;
153
+ for (const [, texts] of keyTextMap) { if (texts.size > 1) duplicateKeyCount++; }
154
+
155
+ const byLang = new Map();
156
+ for (const mk of details.missingKeys) {
157
+ if (mk.language) { if (!byLang.has(mk.language)) byLang.set(mk.language, []); byLang.get(mk.language).push(mk.key); }
158
+ }
159
+ const inconsistencies = [...byLang.entries()].map(([lang, keys]) => ({ key: keys[0], languages: [lang], hint: `${keys.length} key${keys.length > 1 ? 's' : ''} missing in "${lang}"` }));
160
+
161
+ const nsCounts = new Map();
162
+ for (const dt of details.detectedTexts) {
163
+ const ns = dt.suggestedKey.split('.')[0] || 'default';
164
+ nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
165
+ }
166
+ const namespaceHints = [];
167
+ for (const [ns, count] of nsCounts) {
168
+ if (count < 3) namespaceHints.push({ namespace: ns, count, suggestion: `Namespace "${ns}" has only ${count} key(s) — consider merging into a broader namespace.` });
169
+ }
170
+
171
+ const totalKeys = new Set(details.detectedTexts.map((d) => d.suggestedKey)).size;
172
+ const missing = details.missingKeys.length;
173
+ const coveragePct = totalKeys > 0 ? Math.round(((totalKeys - missing) / totalKeys) * 100) : 100;
174
+
175
+ return { duplicates, inconsistencies, namespaceHints, coveragePct, totalKeys, duplicateKeyCount };
176
+ }
177
+
178
+ function buildNavItem(id, icon, label, count, alert = false) {
179
+ return `<a href="#${id}" class="nav-item${alert ? ' nav-alert' : ''}" data-section="${id}">
180
+ <span class="nav-icon">${icon}</span>
181
+ <span class="nav-label">${esc(label)}</span>
182
+ <span class="nav-count">${count}</span>
183
+ </a>`;
184
+ }
185
+
186
+ function buildStatCard(value, label, hint, status, icon) {
187
+ return `<div class="stat-card stat-${status}">
188
+ <div class="stat-icon">${icon}</div>
189
+ <div class="stat-value">${value}</div>
190
+ <div class="stat-label">${esc(label)}</div>
191
+ <div class="stat-hint">${esc(hint)}</div>
192
+ </div>`;
193
+ }
194
+
195
+ function buildAccordion(id, title, subtitle, content, open = false, severity = 'info') {
196
+ return `<div class="accordion${open ? ' accordion-open' : ''}" id="acc-${id}">
197
+ <button class="accordion-trigger" aria-expanded="${open}" aria-controls="panel-${id}" onclick="toggleAccordion('${id}')">
198
+ <span class="accordion-icon severity-${severity}">&#9679;</span>
199
+ <span class="accordion-title">${title}</span>
200
+ <span class="accordion-subtitle">${subtitle}</span>
201
+ <span class="accordion-chevron">&#8964;</span>
202
+ </button>
203
+ <div class="accordion-panel" id="panel-${id}" role="region" ${open ? '' : 'hidden'}>
204
+ <div class="accordion-body">${content}</div>
205
+ </div>
206
+ </div>`;
207
+ }
208
+
209
+ function buildCoverageDonut(pct) {
210
+ const r = 52;
211
+ const circumference = 2 * Math.PI * r;
212
+ const dash = (pct / 100) * circumference;
213
+ const color = pct >= 80 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444';
214
+ return `<svg class="donut-chart" viewBox="0 0 120 120" aria-label="Coverage ${pct}%">
215
+ <circle cx="60" cy="60" r="${r}" fill="none" stroke="var(--border)" stroke-width="12"/>
216
+ <circle cx="60" cy="60" r="${r}" fill="none" stroke="${color}" stroke-width="12"
217
+ stroke-dasharray="${dash.toFixed(2)} ${circumference.toFixed(2)}"
218
+ stroke-dashoffset="${(circumference / 4).toFixed(2)}"
219
+ stroke-linecap="round" transform="rotate(-90 60 60)"/>
220
+ <text x="60" y="56" text-anchor="middle" font-size="20" font-weight="700" fill="${color}">${pct}%</text>
221
+ <text x="60" y="72" text-anchor="middle" font-size="10" fill="var(--text-muted)">coverage</text>
222
+ </svg>`;
223
+ }
224
+
225
+ function buildBarChart(data) {
226
+ if (data.length === 0) return '<p class="empty-state">No data available.</p>';
227
+ const max = Math.max(...data.map((d) => d.value), 1);
228
+ return `<div class="bar-chart">${data.map((d) => {
229
+ const pct = Math.round((d.value / max) * 100);
230
+ const color = d.color || 'var(--accent)';
231
+ return `<div class="bar-row">
232
+ <span class="bar-label">${esc(d.label)}</span>
233
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
234
+ <span class="bar-value">${d.value}</span>
235
+ </div>`;
236
+ }).join('')}</div>`;
237
+ }
238
+
239
+ function buildSearchableTable(tableId, columns, rows, pageSize = 50) {
240
+ const thead = columns.map((col) =>
241
+ `<th data-col="${col.key}" ${col.sortable !== false ? `onclick="sortTable('${tableId}', '${col.key}')" class="sortable"` : ''} ${col.width ? `style="width:${col.width}"` : ''}>${col.label}${col.sortable !== false ? ' <span class="sort-icon">&#8597;</span>' : ''}</th>`
242
+ ).join('');
243
+ const tbody = rows.map((cells) =>
244
+ `<tr>${cells.map((cell, ci) => `<td data-col="${columns[ci]?.key || ci}">${cell}</td>`).join('')}</tr>`
245
+ ).join('\n');
246
+ return `<div class="table-wrapper" id="${tableId}-wrapper">
247
+ <div class="table-controls">
248
+ <div class="search-box">
249
+ <span class="search-icon">&#128269;</span>
250
+ <input type="text" class="table-search" placeholder="Search&hellip;" oninput="filterTable('${tableId}', this.value)" aria-label="Search table"/>
251
+ </div>
252
+ <div class="table-meta" id="${tableId}-meta"></div>
253
+ <div class="table-actions">
254
+ <button class="btn btn-sm" onclick="exportTableCsv('${tableId}')">&#8659; CSV</button>
255
+ <button class="btn btn-sm" onclick="exportTableJson('${tableId}')">&#8659; JSON</button>
256
+ </div>
257
+ </div>
258
+ <div class="table-scroll">
259
+ <table id="${tableId}" data-page-size="${pageSize}" data-page="1">
260
+ <thead><tr>${thead}</tr></thead>
261
+ <tbody>${tbody}</tbody>
262
+ </table>
263
+ </div>
264
+ <div class="table-pagination" id="${tableId}-pagination"></div>
265
+ </div>`;
266
+ }
267
+
268
+ function buildHtml(report) {
269
+ const { timestamp, framework, duration, filesScanned, hardcodedTexts,
270
+ localeKeysGenerated, unusedKeys, missingTranslations, assets, details } = report;
271
+ const scanDate = new Date(timestamp).toLocaleString();
272
+ const ins = computeInsights(report);
273
+
274
+ const coverageDonut = buildCoverageDonut(ins.coveragePct);
275
+
276
+ const nsCounts = new Map();
277
+ for (const dt of details.detectedTexts) {
278
+ const ns = dt.suggestedKey.split('.')[0] || 'default';
279
+ nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
280
+ }
281
+ const nsChart = buildBarChart([...nsCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([label, value]) => ({ label, value })));
282
+
283
+ const ctxCounts = new Map();
284
+ for (const dt of details.detectedTexts) ctxCounts.set(dt.context, (ctxCounts.get(dt.context) || 0) + 1);
285
+ const ctxChart = buildBarChart([...ctxCounts.entries()].sort((a, b) => b[1] - a[1]).map(([label, value]) => ({ label, value })));
286
+
287
+ const summaryCards = `<div class="stats-grid">
288
+ ${buildStatCard(filesScanned, 'Files Scanned', 'Source files processed by AST scanner', 'neutral', '&#128196;')}
289
+ ${buildStatCard(hardcodedTexts, 'Hardcoded Texts', 'Raw strings not yet in t()', hardcodedTexts > 0 ? 'warn' : 'ok', '&#128269;')}
290
+ ${buildStatCard(ins.totalKeys, 'Unique Keys', 'Deduplicated locale keys generated', 'info', '&#128273;')}
291
+ ${buildStatCard(missingTranslations, 'Missing Translations', 'Keys absent in target languages', missingTranslations > 0 ? 'err' : 'ok', '&#10060;')}
292
+ ${buildStatCard(unusedKeys, 'Unused Keys', 'Keys in locale files not used in code', unusedKeys > 0 ? 'warn' : 'ok', '&#128465;')}
293
+ ${buildStatCard(ins.duplicateKeyCount, 'Duplicate Keys', 'Same key maps to different texts', ins.duplicateKeyCount > 0 ? 'warn' : 'ok', '&#128258;')}
294
+ ${buildStatCard(ins.coveragePct + '%', 'Translation Coverage', '% of keys present in all languages', ins.coveragePct < 80 ? 'err' : ins.coveragePct < 100 ? 'warn' : 'ok', '&#127919;')}
295
+ ${buildStatCard(assets.totalAssets, 'Assets Found', 'Static asset references in source', 'neutral', '&#128230;')}
296
+ ${buildStatCard(assets.legacyCdnUrls, 'Legacy CDN URLs', 'Old CDN references not yet replaced', assets.legacyCdnUrls > 0 ? 'warn' : 'ok', '&#128279;')}
297
+ </div>`;
298
+
299
+ const hardcodedRows = details.detectedTexts.slice(0, 500).map((t) => [
300
+ `<code class="path-code">${esc(t.filePath.split('/').slice(-3).join('/'))}</code>`,
301
+ `<span class="line-num">${t.line}</span>`,
302
+ `<span class="text-preview">${esc(t.text.slice(0, 80))}${t.text.length > 80 ? '&hellip;' : ''}</span>`,
303
+ `<code class="key-code">${esc(t.suggestedKey)}</code>`,
304
+ badge(t.context, 'blue'),
305
+ badge(t.nodeType, 'grey'),
306
+ ]);
307
+
308
+ const hardcodedTable = details.detectedTexts.length > 0
309
+ ? buildSearchableTable('tbl-hardcoded', [
310
+ { key: 'file', label: 'File' },
311
+ { key: 'line', label: 'Line', width: '60px' },
312
+ { key: 'text', label: 'Text' },
313
+ { key: 'key', label: 'Suggested Key' },
314
+ { key: 'context', label: 'Context', width: '120px' },
315
+ { key: 'node', label: 'Node Type', width: '120px' },
316
+ ], hardcodedRows)
317
+ : '<div class="empty-state-card">&#9989; No hardcoded texts detected.</div>';
318
+
319
+ const hardcodedContent = `
320
+ <div class="insight-legend">
321
+ Raw text strings found in source that are <strong>not yet wrapped</strong> in a translation call.
322
+ Run <code>ai-localize extract</code> to generate locale files and
323
+ <code>ai-localize full-migrate</code> to wrap them automatically.
324
+ </div>
325
+ ${hardcodedTable}`;
326
+
327
+ const missingByLang = new Map();
328
+ for (const mk of details.missingKeys) {
329
+ const lang = mk.language || 'unknown';
330
+ if (!missingByLang.has(lang)) missingByLang.set(lang, []);
331
+ missingByLang.get(lang).push(mk);
332
+ }
333
+
334
+ let missingContent = '';
335
+ if (details.missingKeys.length === 0) {
336
+ missingContent = '<div class="empty-state-card">&#9989; All translations present.</div>';
337
+ } else {
338
+ const missingTableRows = details.missingKeys.map((e) => [
339
+ `<code class="key-code">${esc(e.key)}</code>`,
340
+ e.language ? badge(e.language, 'red') : '&mdash;',
341
+ e.filePath ? `<code class="path-code">${esc(e.filePath)}</code>` : '&mdash;',
342
+ `<span class="detail-text">${esc(e.message)}</span>`,
343
+ ]);
344
+ const langChips = [...missingByLang.entries()]
345
+ .map(([lang, keys]) => `<span class="chip chip-red" onclick="filterTable('tbl-missing', '${escJs(lang)}')">${esc(lang)} <strong>${keys.length}</strong></span>`)
346
+ .join(' ');
347
+ missingContent = `
348
+ <div class="insight-legend">
349
+ These locale keys exist in the <strong>default language</strong> but are <strong>absent</strong> in one or more target language files.
350
+ Running <code>ai-localize extract</code> seeds all target files with the source value.
351
+ </div>
352
+ <div class="chip-row">Filter by language: ${langChips}</div>
353
+ ${buildSearchableTable('tbl-missing', [
354
+ { key: 'key', label: 'Key' },
355
+ { key: 'language', label: 'Language', width: '100px' },
356
+ { key: 'file', label: 'Locale File' },
357
+ { key: 'message', label: 'Details' },
358
+ ], missingTableRows)}`;
359
+ }
360
+
361
+ const unusedContent = details.unusedKeysList.length === 0
362
+ ? '<div class="empty-state-card">&#9989; No unused translation keys detected.</div>'
363
+ : `<div class="insight-legend">
364
+ Translation keys that exist in locale JSON files but are <strong>not referenced</strong> anywhere in scanned source code.
365
+ Run <code>ai-localize cleanup</code> to remove them.
366
+ </div>
367
+ ${buildSearchableTable('tbl-unused', [{ key: 'key', label: 'Unused Key' }], details.unusedKeysList.map((k) => [`<code class="key-code">${esc(k)}</code>`]))}`;
368
+
369
+ const assetRows = details.assets.map((a) => [
370
+ `<code class="path-code">${esc(a.localPath.split('/').slice(-3).join('/'))}</code>`,
371
+ `<code class="key-code">${esc(a.s3Key)}</code>`,
372
+ `<a href="${esc(a.cloudfrontUrl)}" target="_blank" rel="noopener" class="cdn-link">${esc(a.cloudfrontUrl.slice(0, 60))}${a.cloudfrontUrl.length > 60 ? '&hellip;' : ''}</a>`,
373
+ `<span class="file-size">${(a.size / 1024).toFixed(1)} KB</span>`,
374
+ badge(a.contentType, 'grey'),
375
+ ]);
376
+
377
+ const assetsContent = `
378
+ <div class="asset-summary-row">
379
+ ${buildStatCard(assets.totalAssets, 'Total Assets', 'All static asset references', 'neutral', '&#128190;')}
380
+ ${buildStatCard(assets.uploadedAssets, 'Uploaded', 'Pushed to S3/CloudFront this run', 'ok', '&#9989;')}
381
+ ${buildStatCard(assets.replacedUrls, 'URLs Replaced', 'Legacy CDN refs rewritten', 'ok', '&#128257;')}
382
+ ${buildStatCard(assets.legacyCdnUrls, 'Legacy URLs', 'Old CDN refs still pending', assets.legacyCdnUrls > 0 ? 'warn' : 'ok', '&#9888;')}
383
+ </div>
384
+ ${details.assets.length > 0
385
+ ? buildSearchableTable('tbl-assets', [
386
+ { key: 'path', label: 'Local Path' },
387
+ { key: 's3key', label: 'S3 Key' },
388
+ { key: 'url', label: 'CloudFront URL' },
389
+ { key: 'size', label: 'Size', width: '80px' },
390
+ { key: 'type', label: 'Content Type', width: '140px' },
391
+ ], assetRows)
392
+ : '<div class="empty-state-card">No assets uploaded in this run.</div>'
393
+ }`;
394
+
395
+ // Insights
396
+ const dupTextContent = ins.duplicates.length === 0
397
+ ? '<div class="insight-item insight-ok">&#9989; No duplicate texts detected.</div>'
398
+ : `<div class="insight-legend">Same text found mapped