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.
- package/CHANGELOG.md +40 -0
- package/dist/index.d.mts +1 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1967 -143
- package/dist/index.mjs +1967 -143
- package/package.json +2 -2
- package/preview/fix-exports.js +147 -0
- package/preview/fix-preview-exports.js +166 -0
- package/preview/generate-preview.ts +105 -0
- package/preview/make-preview.cjs +564 -0
- package/preview/make-preview.mjs +398 -0
- package/preview/report-preview.html +831 -0
- package/src/cli-reporter.ts +597 -25
- package/src/html-reporter.ts +1645 -291
- package/tsconfig.json +6 -1
package/dist/index.js
CHANGED
|
@@ -74,13 +74,155 @@ function generateHtmlReport(report, outputPath) {
|
|
|
74
74
|
fs.writeFileSync(outputPath, html, "utf-8");
|
|
75
75
|
}
|
|
76
76
|
function esc(s) {
|
|
77
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
77
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
78
78
|
}
|
|
79
|
-
function
|
|
80
|
-
return
|
|
79
|
+
function escJs(s) {
|
|
80
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
81
81
|
}
|
|
82
|
-
function
|
|
83
|
-
return
|
|
82
|
+
function badge(text, variant = "blue") {
|
|
83
|
+
return `<span class="badge badge-${variant}">${esc(text)}</span>`;
|
|
84
|
+
}
|
|
85
|
+
function severityBadge(count, warn = false) {
|
|
86
|
+
if (count === 0) return badge("0", "green");
|
|
87
|
+
return badge(String(count), warn ? "orange" : "red");
|
|
88
|
+
}
|
|
89
|
+
function computeInsights(report) {
|
|
90
|
+
const { details } = report;
|
|
91
|
+
const textMap = /* @__PURE__ */ new Map();
|
|
92
|
+
for (const dt of details.detectedTexts) {
|
|
93
|
+
const t = dt.text.trim();
|
|
94
|
+
if (!textMap.has(t)) textMap.set(t, { keys: /* @__PURE__ */ new Set(), files: /* @__PURE__ */ new Set() });
|
|
95
|
+
textMap.get(t).keys.add(dt.suggestedKey);
|
|
96
|
+
textMap.get(t).files.add(dt.filePath);
|
|
97
|
+
}
|
|
98
|
+
const duplicates = [];
|
|
99
|
+
for (const [text, { keys, files }] of textMap) {
|
|
100
|
+
if (keys.size > 1) {
|
|
101
|
+
duplicates.push({ text, count: keys.size, keys: [...keys], files: [...files] });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
duplicates.sort((a, b) => b.count - a.count);
|
|
105
|
+
const keyTextMap = /* @__PURE__ */ new Map();
|
|
106
|
+
for (const dt of details.detectedTexts) {
|
|
107
|
+
if (!keyTextMap.has(dt.suggestedKey)) keyTextMap.set(dt.suggestedKey, /* @__PURE__ */ new Set());
|
|
108
|
+
keyTextMap.get(dt.suggestedKey).add(dt.text.trim());
|
|
109
|
+
}
|
|
110
|
+
let duplicateKeyCount = 0;
|
|
111
|
+
for (const [, texts] of keyTextMap) {
|
|
112
|
+
if (texts.size > 1) duplicateKeyCount++;
|
|
113
|
+
}
|
|
114
|
+
const byLang = /* @__PURE__ */ new Map();
|
|
115
|
+
for (const mk of details.missingKeys) {
|
|
116
|
+
if (mk.language) {
|
|
117
|
+
if (!byLang.has(mk.language)) byLang.set(mk.language, []);
|
|
118
|
+
byLang.get(mk.language).push(mk.key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const inconsistencies = [...byLang.entries()].map(([lang, keys]) => ({
|
|
122
|
+
key: keys[0],
|
|
123
|
+
languages: [lang],
|
|
124
|
+
hint: `${keys.length} key${keys.length > 1 ? "s" : ""} missing in "${lang}"`
|
|
125
|
+
}));
|
|
126
|
+
const nsCounts = /* @__PURE__ */ new Map();
|
|
127
|
+
for (const dt of details.detectedTexts) {
|
|
128
|
+
const ns = dt.suggestedKey.split(".")[0] || "default";
|
|
129
|
+
nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
|
|
130
|
+
}
|
|
131
|
+
const namespaceHints = [];
|
|
132
|
+
for (const [ns, count] of nsCounts) {
|
|
133
|
+
if (count < 3) {
|
|
134
|
+
namespaceHints.push({ namespace: ns, count, suggestion: `Namespace "${ns}" has only ${count} key(s) \u2014 consider merging into a broader namespace.` });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const totalKeys = new Set(details.detectedTexts.map((d) => d.suggestedKey)).size;
|
|
138
|
+
const missing = details.missingKeys.length;
|
|
139
|
+
const coveragePct = totalKeys > 0 ? Math.round((totalKeys - missing) / totalKeys * 100) : 100;
|
|
140
|
+
return { duplicates, inconsistencies, namespaceHints, coveragePct, totalKeys, duplicateKeyCount };
|
|
141
|
+
}
|
|
142
|
+
function buildNavItem(id, icon, label, count, alert = false) {
|
|
143
|
+
return `<a href="#${id}" class="nav-item${alert ? " nav-alert" : ""}" data-section="${id}">
|
|
144
|
+
<span class="nav-icon">${icon}</span>
|
|
145
|
+
<span class="nav-label">${esc(label)}</span>
|
|
146
|
+
<span class="nav-count">${count}</span>
|
|
147
|
+
</a>`;
|
|
148
|
+
}
|
|
149
|
+
function buildStatCard(value, label, hint, status, icon) {
|
|
150
|
+
return `<div class="stat-card stat-${status}">
|
|
151
|
+
<div class="stat-icon">${icon}</div>
|
|
152
|
+
<div class="stat-value">${value}</div>
|
|
153
|
+
<div class="stat-label">${esc(label)}</div>
|
|
154
|
+
<div class="stat-hint">${esc(hint)}</div>
|
|
155
|
+
</div>`;
|
|
156
|
+
}
|
|
157
|
+
function buildAccordion(id, title, subtitle, content, open = false, severity = "info") {
|
|
158
|
+
return `<div class="accordion${open ? " accordion-open" : ""}" id="acc-${id}">
|
|
159
|
+
<button class="accordion-trigger" aria-expanded="${open}" aria-controls="panel-${id}" onclick="toggleAccordion('${id}')">
|
|
160
|
+
<span class="accordion-icon severity-${severity}">●</span>
|
|
161
|
+
<span class="accordion-title">${title}</span>
|
|
162
|
+
<span class="accordion-subtitle">${subtitle}</span>
|
|
163
|
+
<span class="accordion-chevron">⌄</span>
|
|
164
|
+
</button>
|
|
165
|
+
<div class="accordion-panel" id="panel-${id}" role="region" ${open ? "" : "hidden"}>
|
|
166
|
+
<div class="accordion-body">${content}</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>`;
|
|
169
|
+
}
|
|
170
|
+
function buildCoverageDonut(pct) {
|
|
171
|
+
const r = 52;
|
|
172
|
+
const circumference = 2 * Math.PI * r;
|
|
173
|
+
const dash = pct / 100 * circumference;
|
|
174
|
+
const color = pct >= 80 ? "#22c55e" : pct >= 50 ? "#f59e0b" : "#ef4444";
|
|
175
|
+
return `<svg class="donut-chart" viewBox="0 0 120 120" aria-label="Coverage ${pct}%">
|
|
176
|
+
<circle cx="60" cy="60" r="${r}" fill="none" stroke="var(--border)" stroke-width="12"/>
|
|
177
|
+
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color}" stroke-width="12"
|
|
178
|
+
stroke-dasharray="${dash.toFixed(2)} ${circumference.toFixed(2)}"
|
|
179
|
+
stroke-dashoffset="${(circumference / 4).toFixed(2)}"
|
|
180
|
+
stroke-linecap="round" transform="rotate(-90 60 60)"/>
|
|
181
|
+
<text x="60" y="56" text-anchor="middle" font-size="20" font-weight="700" fill="${color}">${pct}%</text>
|
|
182
|
+
<text x="60" y="72" text-anchor="middle" font-size="10" fill="var(--text-muted)">coverage</text>
|
|
183
|
+
</svg>`;
|
|
184
|
+
}
|
|
185
|
+
function buildBarChart(data) {
|
|
186
|
+
if (data.length === 0) return '<p class="empty-state">No data available.</p>';
|
|
187
|
+
const max = Math.max(...data.map((d) => d.value), 1);
|
|
188
|
+
const bars = data.map((d) => {
|
|
189
|
+
const pct = Math.round(d.value / max * 100);
|
|
190
|
+
const color = d.color || "var(--accent)";
|
|
191
|
+
return `<div class="bar-row">
|
|
192
|
+
<span class="bar-label">${esc(d.label)}</span>
|
|
193
|
+
<div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
|
194
|
+
<span class="bar-value">${d.value}</span>
|
|
195
|
+
</div>`;
|
|
196
|
+
}).join("");
|
|
197
|
+
return `<div class="bar-chart">${bars}</div>`;
|
|
198
|
+
}
|
|
199
|
+
function buildSearchableTable(tableId, columns, rows, pageSize = 50) {
|
|
200
|
+
const thead = columns.map(
|
|
201
|
+
(c2) => `<th data-col="${c2.key}" ${c2.sortable !== false ? `onclick="sortTable('${tableId}', '${c2.key}')" class="sortable"` : ""} ${c2.width ? `style="width:${c2.width}"` : ""}>${c2.label}${c2.sortable !== false ? ' <span class="sort-icon">↕</span>' : ""}</th>`
|
|
202
|
+
).join("");
|
|
203
|
+
const tbody = rows.map(
|
|
204
|
+
(cells, _ri) => `<tr>${cells.map((cell, ci) => `<td data-col="${columns[ci]?.key || ci}">${cell}</td>`).join("")}</tr>`
|
|
205
|
+
).join("\n");
|
|
206
|
+
return `<div class="table-wrapper" id="${tableId}-wrapper">
|
|
207
|
+
<div class="table-controls">
|
|
208
|
+
<div class="search-box">
|
|
209
|
+
<span class="search-icon">🔍</span>
|
|
210
|
+
<input type="text" class="table-search" placeholder="Search\u2026" oninput="filterTable('${tableId}', this.value)" aria-label="Search table"/>
|
|
211
|
+
</div>
|
|
212
|
+
<div class="table-meta" id="${tableId}-meta"></div>
|
|
213
|
+
<div class="table-actions">
|
|
214
|
+
<button class="btn btn-sm" onclick="exportTableCsv('${tableId}')">⇓ CSV</button>
|
|
215
|
+
<button class="btn btn-sm" onclick="exportTableJson('${tableId}')">⇓ JSON</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="table-scroll">
|
|
219
|
+
<table id="${tableId}" data-page-size="${pageSize}" data-page="1">
|
|
220
|
+
<thead><tr>${thead}</tr></thead>
|
|
221
|
+
<tbody>${tbody}</tbody>
|
|
222
|
+
</table>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="table-pagination" id="${tableId}-pagination"></div>
|
|
225
|
+
</div>`;
|
|
84
226
|
}
|
|
85
227
|
function buildHtml(report) {
|
|
86
228
|
const {
|
|
@@ -96,151 +238,1833 @@ function buildHtml(report) {
|
|
|
96
238
|
details
|
|
97
239
|
} = report;
|
|
98
240
|
const scanDate = new Date(timestamp).toLocaleString();
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
).
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
"
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
241
|
+
const insights = computeInsights(report);
|
|
242
|
+
const coverageDonut = buildCoverageDonut(insights.coveragePct);
|
|
243
|
+
const nsCounts = /* @__PURE__ */ new Map();
|
|
244
|
+
for (const dt of details.detectedTexts) {
|
|
245
|
+
const ns = dt.suggestedKey.split(".")[0] || "default";
|
|
246
|
+
nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
|
|
247
|
+
}
|
|
248
|
+
const nsChartData = [...nsCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([label, value]) => ({ label, value }));
|
|
249
|
+
const nsChart = buildBarChart(nsChartData);
|
|
250
|
+
const ctxCounts = /* @__PURE__ */ new Map();
|
|
251
|
+
for (const dt of details.detectedTexts) {
|
|
252
|
+
ctxCounts.set(dt.context, (ctxCounts.get(dt.context) || 0) + 1);
|
|
253
|
+
}
|
|
254
|
+
const ctxChartData = [...ctxCounts.entries()].sort((a, b) => b[1] - a[1]).map(([label, value]) => ({ label, value }));
|
|
255
|
+
const ctxChart = buildBarChart(ctxChartData);
|
|
256
|
+
const summaryCards = `<div class="stats-grid">
|
|
257
|
+
${buildStatCard(filesScanned, "Files Scanned", "Source files processed by AST scanner", "neutral", "📄")}
|
|
258
|
+
${buildStatCard(hardcodedTexts, "Hardcoded Texts", "Raw strings not yet in t()", hardcodedTexts > 0 ? "warn" : "ok", "🔍")}
|
|
259
|
+
${buildStatCard(insights.totalKeys, "Unique Keys", "Deduplicated locale keys generated", "info", "🔑")}
|
|
260
|
+
${buildStatCard(missingTranslations, "Missing Translations", "Keys absent in target languages", missingTranslations > 0 ? "err" : "ok", "❌")}
|
|
261
|
+
${buildStatCard(unusedKeys, "Unused Keys", "Keys in locale files not used in code", unusedKeys > 0 ? "warn" : "ok", "🗑")}
|
|
262
|
+
${buildStatCard(insights.duplicateKeyCount, "Duplicate Keys", "Same key maps to different texts", insights.duplicateKeyCount > 0 ? "warn" : "ok", "🔂")}
|
|
263
|
+
${buildStatCard(insights.coveragePct + "%", "Translation Coverage", "% of keys present in all languages", insights.coveragePct < 80 ? "err" : insights.coveragePct < 100 ? "warn" : "ok", "🎯")}
|
|
264
|
+
${buildStatCard(assets.totalAssets, "Assets Found", "Static asset references in source", "neutral", "📦")}
|
|
265
|
+
${buildStatCard(assets.legacyCdnUrls, "Legacy CDN URLs", "Old CDN references not yet replaced", assets.legacyCdnUrls > 0 ? "warn" : "ok", "🔗")}
|
|
266
|
+
</div>`;
|
|
267
|
+
const hardcodedRows = details.detectedTexts.slice(0, 500).map((t) => [
|
|
268
|
+
`<code class="path-code">${esc(t.filePath.split("/").slice(-3).join("/"))}</code>`,
|
|
269
|
+
`<span class="line-num">${t.line}</span>`,
|
|
270
|
+
`<span class="text-preview">${esc(t.text.slice(0, 80))}${t.text.length > 80 ? "\u2026" : ""}</span>`,
|
|
271
|
+
`<code class="key-code">${esc(t.suggestedKey)}</code>`,
|
|
272
|
+
badge(t.context, "blue"),
|
|
273
|
+
badge(t.nodeType, "grey")
|
|
274
|
+
]);
|
|
275
|
+
const hardcodedOverflowMsg = details.detectedTexts.length > 500 ? `<div class="overflow-notice">Showing 500 of ${details.detectedTexts.length} entries. Export to CSV/JSON for full list.</div>` : "";
|
|
276
|
+
const hardcodedTable = details.detectedTexts.length > 0 ? buildSearchableTable("tbl-hardcoded", [
|
|
277
|
+
{ key: "file", label: "File" },
|
|
278
|
+
{ key: "line", label: "Line", width: "60px" },
|
|
279
|
+
{ key: "text", label: "Text" },
|
|
280
|
+
{ key: "key", label: "Suggested Key" },
|
|
281
|
+
{ key: "context", label: "Context", width: "120px" },
|
|
282
|
+
{ key: "node", label: "Node Type", width: "120px" }
|
|
283
|
+
], hardcodedRows) + hardcodedOverflowMsg : '<div class="empty-state-card">✅ No hardcoded texts detected. All strings are wrapped in translation calls.</div>';
|
|
284
|
+
const hardcodedContent = `
|
|
285
|
+
<div class="insight-legend">
|
|
286
|
+
Raw text strings found in source that are <strong>not yet wrapped</strong> in a translation call.
|
|
287
|
+
Run <code>ai-localize extract</code> to generate locale files and
|
|
288
|
+
<code>ai-localize full-migrate</code> to wrap them automatically.
|
|
289
|
+
</div>
|
|
290
|
+
${hardcodedTable}`;
|
|
291
|
+
const missingByLang = /* @__PURE__ */ new Map();
|
|
292
|
+
for (const mk of details.missingKeys) {
|
|
293
|
+
const lang = mk.language || "unknown";
|
|
294
|
+
if (!missingByLang.has(lang)) missingByLang.set(lang, []);
|
|
295
|
+
missingByLang.get(lang).push(mk);
|
|
296
|
+
}
|
|
297
|
+
let missingContent = "";
|
|
298
|
+
if (details.missingKeys.length === 0) {
|
|
299
|
+
missingContent = '<div class="empty-state-card">✅ All keys present in the default language exist in every target language file.</div>';
|
|
300
|
+
} else {
|
|
301
|
+
const missingTableRows = details.missingKeys.map((e) => [
|
|
302
|
+
`<code class="key-code">${esc(e.key)}</code>`,
|
|
303
|
+
e.language ? badge(e.language, "red") : "\u2014",
|
|
304
|
+
e.filePath ? `<code class="path-code">${esc(e.filePath)}</code>` : "\u2014",
|
|
305
|
+
`<span class="detail-text">${esc(e.message)}</span>`
|
|
306
|
+
]);
|
|
307
|
+
const langChips = [...missingByLang.entries()].map(([lang, keys]) => `<span class="chip chip-red" onclick="filterTable('tbl-missing', '${escJs(lang)}')">${esc(lang)} <strong>${keys.length}</strong></span>`).join(" ");
|
|
308
|
+
missingContent = `
|
|
309
|
+
<div class="insight-legend">
|
|
310
|
+
These locale keys exist in the <strong>default language</strong> but are <strong>absent</strong> in one or more target language files.
|
|
311
|
+
Ask your translators to fill in these entries. Running <code>ai-localize extract</code> seeds all target files with the source value.
|
|
312
|
+
</div>
|
|
313
|
+
<div class="chip-row">Filter by language: ${langChips}</div>
|
|
314
|
+
${buildSearchableTable("tbl-missing", [
|
|
315
|
+
{ key: "key", label: "Key" },
|
|
316
|
+
{ key: "language", label: "Language", width: "100px" },
|
|
317
|
+
{ key: "file", label: "Locale File" },
|
|
318
|
+
{ key: "message", label: "Details" }
|
|
319
|
+
], missingTableRows)}`;
|
|
320
|
+
}
|
|
321
|
+
const unusedContent = details.unusedKeysList.length === 0 ? '<div class="empty-state-card">✅ No unused translation keys detected. Your locale files are clean.</div>' : `<div class="insight-legend">
|
|
322
|
+
Translation keys that exist in locale JSON files but are <strong>not referenced</strong> anywhere in scanned source code.
|
|
323
|
+
They are likely left over from removed features. Run <code>ai-localize cleanup</code> to remove them.
|
|
324
|
+
</div>
|
|
325
|
+
${buildSearchableTable("tbl-unused", [
|
|
326
|
+
{ key: "key", label: "Unused Key" }
|
|
327
|
+
], details.unusedKeysList.map((k) => [`<code class="key-code">${esc(k)}</code>`]))}`;
|
|
328
|
+
const assetRows = details.assets.map((a) => [
|
|
329
|
+
`<code class="path-code">${esc(a.localPath.split("/").slice(-3).join("/"))}</code>`,
|
|
330
|
+
`<code class="key-code">${esc(a.s3Key)}</code>`,
|
|
331
|
+
`<a href="${esc(a.cloudfrontUrl)}" target="_blank" rel="noopener" class="cdn-link">${esc(a.cloudfrontUrl.slice(0, 60))}${a.cloudfrontUrl.length > 60 ? "\u2026" : ""}</a>`,
|
|
332
|
+
`<span class="file-size">${(a.size / 1024).toFixed(1)} KB</span>`,
|
|
333
|
+
badge(a.contentType, "grey")
|
|
334
|
+
]);
|
|
335
|
+
const assetsContent = `
|
|
336
|
+
<div class="asset-summary-row">
|
|
337
|
+
${buildStatCard(assets.totalAssets, "Total Assets", "All static asset references", "neutral", "💾")}
|
|
338
|
+
${buildStatCard(assets.uploadedAssets, "Uploaded", "Pushed to S3/CloudFront this run", "ok", "✅")}
|
|
339
|
+
${buildStatCard(assets.replacedUrls, "URLs Replaced", "Legacy CDN refs rewritten", "ok", "🔁")}
|
|
340
|
+
${buildStatCard(assets.legacyCdnUrls, "Legacy URLs", "Old CDN refs still pending", assets.legacyCdnUrls > 0 ? "warn" : "ok", "⚠")}
|
|
341
|
+
</div>
|
|
342
|
+
${details.assets.length > 0 ? buildSearchableTable("tbl-assets", [
|
|
343
|
+
{ key: "path", label: "Local Path" },
|
|
344
|
+
{ key: "s3key", label: "S3 Key" },
|
|
345
|
+
{ key: "url", label: "CloudFront URL" },
|
|
346
|
+
{ key: "size", label: "Size", width: "80px" },
|
|
347
|
+
{ key: "type", label: "Content Type", width: "140px" }
|
|
348
|
+
], assetRows) : '<div class="empty-state-card">No assets uploaded in this run. Use <code>ai-localize upload-assets</code>.</div>'}`;
|
|
349
|
+
let insightsContent = "";
|
|
350
|
+
const dupTextContent = insights.duplicates.length === 0 ? '<div class="insight-item insight-ok">✅ No duplicate texts detected \u2014 each string maps to a unique key.</div>' : `<div class="insight-legend">Same text found mapped to multiple different keys. Consider consolidating to reduce translator workload.</div>
|
|
351
|
+
${buildSearchableTable("tbl-dup-texts", [
|
|
352
|
+
{ key: "text", label: "Duplicated Text" },
|
|
353
|
+
{ key: "count", label: "Key Count", width: "100px" },
|
|
354
|
+
{ key: "keys", label: "Keys" },
|
|
355
|
+
{ key: "files", label: "Files" }
|
|
356
|
+
], insights.duplicates.slice(0, 100).map((d) => [
|
|
357
|
+
`<span class="text-preview">${esc(d.text.slice(0, 80))}</span>`,
|
|
358
|
+
severityBadge(d.count, true),
|
|
359
|
+
`<span class="key-list">${d.keys.map((k) => `<code class="key-code">${esc(k)}</code>`).join(" ")}</span>`,
|
|
360
|
+
`<span class="muted">${d.files.length} file${d.files.length > 1 ? "s" : ""}</span>`
|
|
361
|
+
]))}`;
|
|
362
|
+
const inconsistencyContent = insights.inconsistencies.length === 0 ? '<div class="insight-item insight-ok">✅ No translation inconsistencies detected.</div>' : `<div class="insight-legend">Languages with the most missing translations \u2014 highest-priority for your translators.</div>
|
|
363
|
+
${buildBarChart(
|
|
364
|
+
[...missingByLang.entries()].sort((a, b) => b[1].length - a[1].length).map(([lang, keys]) => ({ label: lang, value: keys.length, color: "#ef4444" }))
|
|
365
|
+
)}`;
|
|
366
|
+
const nsCleanupContent = insights.namespaceHints.length === 0 ? '<div class="insight-item insight-ok">✅ All namespaces have healthy key counts.</div>' : `<div class="insight-legend">Namespaces with very few keys that could be merged for cleaner locale files.</div>
|
|
367
|
+
<ul class="insight-list">
|
|
368
|
+
${insights.namespaceHints.map((h) => `<li><span class="insight-ns">${esc(h.namespace)}</span> ${badge(String(h.count) + " keys", "orange")} \u2014 ${esc(h.suggestion)}</li>`).join("")}
|
|
369
|
+
</ul>`;
|
|
370
|
+
const unusedInsightContent = unusedKeys === 0 ? '<div class="insight-item insight-ok">✅ No unused keys \u2014 your locale bundles are lean.</div>' : `<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>
|
|
371
|
+
${buildBarChart(
|
|
372
|
+
details.unusedKeysList.slice(0, 10).map((k) => ({
|
|
373
|
+
label: k.length > 40 ? k.slice(0, 40) + "\u2026" : k,
|
|
374
|
+
value: 1,
|
|
375
|
+
color: "#f59e0b"
|
|
376
|
+
}))
|
|
377
|
+
)}`;
|
|
378
|
+
insightsContent = `
|
|
379
|
+
<div class="insights-grid">
|
|
380
|
+
<div class="insight-card">
|
|
381
|
+
<h3 class="insight-heading">🔂 Duplicate Text Detection</h3>
|
|
382
|
+
<p class="insight-count">${insights.duplicates.length} duplicate text group${insights.duplicates.length !== 1 ? "s" : ""} found</p>
|
|
383
|
+
${dupTextContent}
|
|
384
|
+
</div>
|
|
385
|
+
<div class="insight-card">
|
|
386
|
+
<h3 class="insight-heading">🌍 Translation Inconsistencies</h3>
|
|
387
|
+
<p class="insight-count">${missingTranslations} missing translation${missingTranslations !== 1 ? "s" : ""} across all languages</p>
|
|
388
|
+
${inconsistencyContent}
|
|
389
|
+
</div>
|
|
390
|
+
<div class="insight-card">
|
|
391
|
+
<h3 class="insight-heading">🗑 Unused Key Analysis</h3>
|
|
392
|
+
<p class="insight-count">${unusedKeys} unused key${unusedKeys !== 1 ? "s" : ""} detected</p>
|
|
393
|
+
${unusedInsightContent}
|
|
394
|
+
</div>
|
|
395
|
+
<div class="insight-card">
|
|
396
|
+
<h3 class="insight-heading">🌸 Namespace Cleanup Suggestions</h3>
|
|
397
|
+
<p class="insight-count">${insights.namespaceHints.length} namespace${insights.namespaceHints.length !== 1 ? "s" : ""} could be consolidated</p>
|
|
398
|
+
${nsCleanupContent}
|
|
399
|
+
</div>
|
|
400
|
+
</div>`;
|
|
134
401
|
const diff = Math.abs(hardcodedTexts - localeKeysGenerated);
|
|
135
|
-
const diffExplainer = hardcodedTexts !== localeKeysGenerated ?
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
402
|
+
const diffExplainer = hardcodedTexts !== localeKeysGenerated ? `<div class="info-banner info-banner-blue">
|
|
403
|
+
<strong>ℹ Why do Hardcoded Texts (${hardcodedTexts}) and Keys Generated (${localeKeysGenerated}) differ?</strong>
|
|
404
|
+
<ul>
|
|
405
|
+
<li><strong>Hardcoded Texts</strong> = total raw string occurrences across all files (same string in 5 files = 5).</li>
|
|
406
|
+
<li><strong>Keys Generated</strong> = unique locale keys after deduplication. Identical strings share one key.</li>
|
|
407
|
+
<li>The difference (${diff}) = duplicate strings consolidated into shared keys.</li>
|
|
408
|
+
</ul>
|
|
409
|
+
</div>` : `<div class="info-banner info-banner-green">
|
|
410
|
+
<strong>✅ Hardcoded Texts and Keys Generated match (${hardcodedTexts}).</strong>
|
|
411
|
+
Every detected string maps to a unique locale key.
|
|
412
|
+
</div>`;
|
|
413
|
+
const chartsSection = `<div class="charts-grid">
|
|
414
|
+
<div class="chart-card">
|
|
415
|
+
<h3 class="chart-title">🎯 Translation Coverage</h3>
|
|
416
|
+
<div class="chart-body chart-donut-wrap">${coverageDonut}</div>
|
|
417
|
+
<p class="chart-caption">${insights.coveragePct}% of keys covered across all languages</p>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="chart-card">
|
|
420
|
+
<h3 class="chart-title">📦 Keys by Namespace (Top 10)</h3>
|
|
421
|
+
<div class="chart-body">${nsChart}</div>
|
|
422
|
+
</div>
|
|
423
|
+
<div class="chart-card">
|
|
424
|
+
<h3 class="chart-title">🏷 Texts by Context</h3>
|
|
425
|
+
<div class="chart-body">${ctxChart}</div>
|
|
426
|
+
</div>
|
|
427
|
+
</div>`;
|
|
428
|
+
const nav = `<nav class="sidebar" id="sidebar" role="navigation" aria-label="Dashboard navigation">
|
|
429
|
+
<div class="sidebar-header">
|
|
430
|
+
<span class="sidebar-logo">🌐</span>
|
|
431
|
+
<span class="sidebar-brand">ai-localize</span>
|
|
432
|
+
<button class="sidebar-toggle" onclick="toggleSidebar()" aria-label="Toggle sidebar">☰</button>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="sidebar-meta">
|
|
435
|
+
<div class="sidebar-framework">${badge(framework, "blue")}</div>
|
|
436
|
+
<div class="sidebar-date">${scanDate}</div>
|
|
437
|
+
</div>
|
|
438
|
+
<div class="nav-group">
|
|
439
|
+
<div class="nav-group-label">Overview</div>
|
|
440
|
+
${buildNavItem("overview", "📊", "Summary", "")}
|
|
441
|
+
${buildNavItem("charts", "📈", "Charts", "")}
|
|
442
|
+
</div>
|
|
443
|
+
<div class="nav-group">
|
|
444
|
+
<div class="nav-group-label">Analysis</div>
|
|
445
|
+
${buildNavItem("hardcoded", "🔍", "Hardcoded Texts", hardcodedTexts, hardcodedTexts > 0)}
|
|
446
|
+
${buildNavItem("missing", "❌", "Missing Translations", missingTranslations, missingTranslations > 0)}
|
|
447
|
+
${buildNavItem("unused", "🗑", "Unused Keys", unusedKeys, unusedKeys > 0)}
|
|
448
|
+
${buildNavItem("assets", "📦", "Assets", assets.totalAssets)}
|
|
449
|
+
</div>
|
|
450
|
+
<div class="nav-group">
|
|
451
|
+
<div class="nav-group-label">AI Insights</div>
|
|
452
|
+
${buildNavItem("insights", "🧠", "AI Insights", insights.duplicates.length + insights.namespaceHints.length, insights.duplicates.length > 0)}
|
|
453
|
+
</div>
|
|
454
|
+
<div class="nav-group">
|
|
455
|
+
<div class="nav-group-label">Export</div>
|
|
456
|
+
<button class="nav-item nav-btn" onclick="exportFullJson()">⇓ Export JSON</button>
|
|
457
|
+
<button class="nav-item nav-btn" onclick="exportFullCsv()">⇓ Export CSV</button>
|
|
458
|
+
<button class="nav-item nav-btn" onclick="window.print()">🖨 Print / PDF</button>
|
|
459
|
+
</div>
|
|
460
|
+
</nav>`;
|
|
461
|
+
const themeToggle = `<button class="theme-toggle" id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark/light mode" title="Toggle theme">
|
|
462
|
+
<span id="theme-icon">☾</span>
|
|
463
|
+
</button>`;
|
|
464
|
+
return `<!DOCTYPE html>
|
|
465
|
+
<html lang="en" data-theme="light">
|
|
466
|
+
<head>
|
|
467
|
+
<meta charset="UTF-8">
|
|
468
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
469
|
+
<title>ai-localize Dashboard — ${esc(scanDate)}</title>
|
|
470
|
+
<style>${CSS}</style>
|
|
471
|
+
</head>
|
|
472
|
+
<body>
|
|
473
|
+
${themeToggle}
|
|
474
|
+
${nav}
|
|
475
|
+
<main class="main" id="main">
|
|
476
|
+
<header class="page-header">
|
|
477
|
+
<div class="page-header-left">
|
|
478
|
+
<h1 class="page-title">🌐 Localization Analytics Dashboard</h1>
|
|
479
|
+
<div class="page-meta">
|
|
480
|
+
<span class="meta-chip">${badge(framework, "blue")}</span>
|
|
481
|
+
<span class="meta-item">📅 ${scanDate}</span>
|
|
482
|
+
<span class="meta-item">⏱ ${duration}ms</span>
|
|
483
|
+
<span class="meta-item">📄 ${filesScanned} files</span>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
<div class="page-header-right">
|
|
487
|
+
<button class="btn btn-primary" onclick="expandAll()">Expand All</button>
|
|
488
|
+
<button class="btn" onclick="collapseAll()">Collapse All</button>
|
|
489
|
+
</div>
|
|
490
|
+
</header>
|
|
491
|
+
|
|
492
|
+
<!-- Summary -->
|
|
493
|
+
<section id="overview" class="section">
|
|
494
|
+
<div class="section-title-row">
|
|
495
|
+
<h2 class="section-title">📊 Summary</h2>
|
|
496
|
+
</div>
|
|
497
|
+
${summaryCards}
|
|
498
|
+
${diffExplainer}
|
|
499
|
+
</section>
|
|
500
|
+
|
|
501
|
+
<!-- Charts -->
|
|
502
|
+
<section id="charts" class="section">
|
|
503
|
+
<div class="section-title-row">
|
|
504
|
+
<h2 class="section-title">📈 Analytics</h2>
|
|
505
|
+
</div>
|
|
506
|
+
${chartsSection}
|
|
507
|
+
</section>
|
|
508
|
+
|
|
509
|
+
<!-- Hardcoded Texts -->
|
|
510
|
+
<section id="hardcoded" class="section">
|
|
511
|
+
<div class="section-title-row">
|
|
512
|
+
<h2 class="section-title">🔍 Hardcoded Texts <span class="section-count ${hardcodedTexts > 0 ? "count-warn" : "count-ok"}">${hardcodedTexts}</span></h2>
|
|
513
|
+
</div>
|
|
514
|
+
${buildAccordion(
|
|
515
|
+
"hardcoded-main",
|
|
516
|
+
hardcodedTexts > 0 ? "⚠ " + hardcodedTexts + " hardcoded string" + (hardcodedTexts > 1 ? "s" : "") + " detected" : "✅ No hardcoded texts",
|
|
517
|
+
hardcodedTexts > 0 ? "Strings not yet wrapped in translation calls" : "All strings are properly localized",
|
|
518
|
+
hardcodedContent,
|
|
519
|
+
hardcodedTexts > 0,
|
|
520
|
+
hardcodedTexts > 0 ? "warn" : "ok"
|
|
521
|
+
)}
|
|
522
|
+
</section>
|
|
523
|
+
|
|
524
|
+
<!-- Missing Translations -->
|
|
525
|
+
<section id="missing" class="section">
|
|
526
|
+
<div class="section-title-row">
|
|
527
|
+
<h2 class="section-title">❌ Missing Translations <span class="section-count ${missingTranslations > 0 ? "count-err" : "count-ok"}">${missingTranslations}</span></h2>
|
|
528
|
+
</div>
|
|
529
|
+
${buildAccordion(
|
|
530
|
+
"missing-main",
|
|
531
|
+
missingTranslations > 0 ? "❌ " + missingTranslations + " missing translation" + (missingTranslations > 1 ? "s" : "") + " found" : "✅ All translations present",
|
|
532
|
+
missingTranslations > 0 ? "Keys absent in one or more target language files" : "Every key is covered in all language files",
|
|
533
|
+
missingContent,
|
|
534
|
+
missingTranslations > 0,
|
|
535
|
+
missingTranslations > 0 ? "err" : "ok"
|
|
536
|
+
)}
|
|
537
|
+
</section>
|
|
538
|
+
|
|
539
|
+
<!-- Unused Keys -->
|
|
540
|
+
<section id="unused" class="section">
|
|
541
|
+
<div class="section-title-row">
|
|
542
|
+
<h2 class="section-title">🗑 Unused Keys <span class="section-count ${unusedKeys > 0 ? "count-warn" : "count-ok"}">${unusedKeys}</span></h2>
|
|
543
|
+
</div>
|
|
544
|
+
${buildAccordion(
|
|
545
|
+
"unused-main",
|
|
546
|
+
unusedKeys > 0 ? "⚠ " + unusedKeys + " unused key" + (unusedKeys > 1 ? "s" : "") + " found" : "✅ No unused keys",
|
|
547
|
+
unusedKeys > 0 ? "Translation keys not referenced in source code" : "All keys are actively used",
|
|
548
|
+
unusedContent,
|
|
549
|
+
false,
|
|
550
|
+
unusedKeys > 0 ? "warn" : "ok"
|
|
551
|
+
)}
|
|
552
|
+
</section>
|
|
553
|
+
|
|
554
|
+
<!-- Assets -->
|
|
555
|
+
<section id="assets" class="section">
|
|
556
|
+
<div class="section-title-row">
|
|
557
|
+
<h2 class="section-title">📦 CDN Assets <span class="section-count count-neutral">${assets.totalAssets}</span></h2>
|
|
558
|
+
</div>
|
|
559
|
+
${buildAccordion(
|
|
560
|
+
"assets-main",
|
|
561
|
+
"📦 Assets: " + assets.totalAssets + " found · " + assets.uploadedAssets + " uploaded · " + assets.legacyCdnUrls + " legacy URLs",
|
|
562
|
+
"S3/CloudFront asset migration status",
|
|
563
|
+
assetsContent,
|
|
564
|
+
false,
|
|
565
|
+
assets.legacyCdnUrls > 0 ? "warn" : "ok"
|
|
566
|
+
)}
|
|
567
|
+
</section>
|
|
568
|
+
|
|
569
|
+
<!-- AI Insights -->
|
|
570
|
+
<section id="insights" class="section">
|
|
571
|
+
<div class="section-title-row">
|
|
572
|
+
<h2 class="section-title">🧠 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>
|
|
573
|
+
</div>
|
|
574
|
+
<div class="insight-banner">
|
|
575
|
+
🧠 <strong>Deterministic analysis</strong> \u2014 patterns identified from your actual locale data. No LLM/AI required.
|
|
576
|
+
</div>
|
|
577
|
+
${insightsContent}
|
|
578
|
+
</section>
|
|
579
|
+
|
|
580
|
+
<footer class="page-footer">
|
|
581
|
+
Generated by <strong>ai-localize-core</strong> — deterministic, offline-capable i18n tooling — ${scanDate}
|
|
582
|
+
</footer>
|
|
583
|
+
</main>
|
|
584
|
+
|
|
585
|
+
<script>${JS(report, insights)}</script>
|
|
586
|
+
</body>
|
|
587
|
+
</html>`;
|
|
139
588
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
589
|
+
function JS(report, insights) {
|
|
590
|
+
const reportJson = JSON.stringify({
|
|
591
|
+
timestamp: report.timestamp,
|
|
592
|
+
framework: report.framework,
|
|
593
|
+
filesScanned: report.filesScanned,
|
|
594
|
+
hardcodedTexts: report.hardcodedTexts,
|
|
595
|
+
localeKeysGenerated: report.localeKeysGenerated,
|
|
596
|
+
missingTranslations: report.missingTranslations,
|
|
597
|
+
unusedKeys: report.unusedKeys,
|
|
598
|
+
coveragePct: insights.coveragePct,
|
|
599
|
+
assets: report.assets,
|
|
600
|
+
details: {
|
|
601
|
+
detectedTexts: report.details.detectedTexts.map((t) => ({
|
|
602
|
+
filePath: t.filePath,
|
|
603
|
+
line: t.line,
|
|
604
|
+
column: t.column,
|
|
605
|
+
text: t.text,
|
|
606
|
+
suggestedKey: t.suggestedKey,
|
|
607
|
+
context: t.context,
|
|
608
|
+
nodeType: t.nodeType,
|
|
609
|
+
alreadyTranslated: t.alreadyTranslated
|
|
610
|
+
})),
|
|
611
|
+
missingKeys: report.details.missingKeys.map((m) => ({
|
|
612
|
+
key: m.key,
|
|
613
|
+
language: m.language || "",
|
|
614
|
+
message: m.message,
|
|
615
|
+
filePath: m.filePath || ""
|
|
616
|
+
})),
|
|
617
|
+
unusedKeys: report.details.unusedKeysList,
|
|
618
|
+
assets: report.details.assets.map((a) => ({
|
|
619
|
+
localPath: a.localPath,
|
|
620
|
+
s3Key: a.s3Key,
|
|
621
|
+
cloudfrontUrl: a.cloudfrontUrl,
|
|
622
|
+
contentType: a.contentType,
|
|
623
|
+
sizeKb: (a.size / 1024).toFixed(1)
|
|
624
|
+
}))
|
|
625
|
+
}
|
|
626
|
+
}).replace(/<\/script>/g, "<\\/script>");
|
|
627
|
+
return `
|
|
628
|
+
(function() {
|
|
629
|
+
'use strict';
|
|
630
|
+
|
|
631
|
+
// \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
632
|
+
var THEME_KEY = 'ai-localize-theme';
|
|
633
|
+
function applyTheme(t) {
|
|
634
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
635
|
+
var icon = document.getElementById('theme-icon');
|
|
636
|
+
if (icon) icon.textContent = t === 'dark' ? '\\u2600' : '\\u263E';
|
|
637
|
+
localStorage.setItem(THEME_KEY, t);
|
|
638
|
+
}
|
|
639
|
+
window.toggleTheme = function() {
|
|
640
|
+
var cur = document.documentElement.getAttribute('data-theme');
|
|
641
|
+
applyTheme(cur === 'dark' ? 'light' : 'dark');
|
|
642
|
+
};
|
|
643
|
+
var saved = localStorage.getItem(THEME_KEY);
|
|
644
|
+
if (saved) applyTheme(saved);
|
|
645
|
+
else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) applyTheme('dark');
|
|
646
|
+
|
|
647
|
+
// \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
648
|
+
window.toggleSidebar = function() {
|
|
649
|
+
document.getElementById('sidebar').classList.toggle('sidebar-collapsed');
|
|
650
|
+
document.getElementById('main').classList.toggle('main-expanded');
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// \u2500\u2500 Active nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
654
|
+
var sections = document.querySelectorAll('section[id]');
|
|
655
|
+
var navItems = document.querySelectorAll('.nav-item[data-section]');
|
|
656
|
+
function onScroll() {
|
|
657
|
+
var scrollY = window.scrollY + 80;
|
|
658
|
+
var current = '';
|
|
659
|
+
sections.forEach(function(s) { if (scrollY >= s.offsetTop) current = s.id; });
|
|
660
|
+
navItems.forEach(function(a) {
|
|
661
|
+
a.classList.toggle('nav-active', a.getAttribute('data-section') === current);
|
|
662
|
+
});
|
|
214
663
|
}
|
|
664
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
665
|
+
onScroll();
|
|
666
|
+
|
|
667
|
+
// \u2500\u2500 Accordion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
668
|
+
window.toggleAccordion = function(id) {
|
|
669
|
+
var acc = document.getElementById('acc-' + id);
|
|
670
|
+
var panel = document.getElementById('panel-' + id);
|
|
671
|
+
var btn = acc.querySelector('.accordion-trigger');
|
|
672
|
+
var open = acc.classList.toggle('accordion-open');
|
|
673
|
+
btn.setAttribute('aria-expanded', open);
|
|
674
|
+
if (open) panel.removeAttribute('hidden'); else panel.setAttribute('hidden', '');
|
|
675
|
+
};
|
|
676
|
+
window.expandAll = function() {
|
|
677
|
+
document.querySelectorAll('.accordion').forEach(function(acc) {
|
|
678
|
+
var id = acc.id.replace('acc-', '');
|
|
679
|
+
var panel = document.getElementById('panel-' + id);
|
|
680
|
+
var btn = acc.querySelector('.accordion-trigger');
|
|
681
|
+
acc.classList.add('accordion-open');
|
|
682
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
683
|
+
if (panel) panel.removeAttribute('hidden');
|
|
684
|
+
});
|
|
685
|
+
};
|
|
686
|
+
window.collapseAll = function() {
|
|
687
|
+
document.querySelectorAll('.accordion').forEach(function(acc) {
|
|
688
|
+
var id = acc.id.replace('acc-', '');
|
|
689
|
+
var panel = document.getElementById('panel-' + id);
|
|
690
|
+
var btn = acc.querySelector('.accordion-trigger');
|
|
691
|
+
acc.classList.remove('accordion-open');
|
|
692
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
693
|
+
if (panel) panel.setAttribute('hidden', '');
|
|
694
|
+
});
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// \u2500\u2500 Table: filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
698
|
+
window.filterTable = function(tableId, query) {
|
|
699
|
+
var tbl = document.getElementById(tableId);
|
|
700
|
+
if (!tbl) return;
|
|
701
|
+
var q = query.toLowerCase();
|
|
702
|
+
var rows = tbl.querySelectorAll('tbody tr');
|
|
703
|
+
var shown = 0;
|
|
704
|
+
rows.forEach(function(row) {
|
|
705
|
+
var match = row.textContent.toLowerCase().indexOf(q) !== -1;
|
|
706
|
+
row.style.display = match ? '' : 'none';
|
|
707
|
+
if (match) shown++;
|
|
708
|
+
});
|
|
709
|
+
var meta = document.getElementById(tableId + '-meta');
|
|
710
|
+
if (meta) meta.textContent = shown + ' / ' + rows.length + ' rows';
|
|
711
|
+
renderPagination(tableId);
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// \u2500\u2500 Table: sort \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
715
|
+
var sortState = {};
|
|
716
|
+
window.sortTable = function(tableId, col) {
|
|
717
|
+
var tbl = document.getElementById(tableId);
|
|
718
|
+
if (!tbl) return;
|
|
719
|
+
var dir = (sortState[tableId] === col + '_asc') ? 'desc' : 'asc';
|
|
720
|
+
sortState[tableId] = col + '_' + dir;
|
|
721
|
+
var colIndex = -1;
|
|
722
|
+
tbl.querySelectorAll('thead th').forEach(function(th, i) {
|
|
723
|
+
if (th.getAttribute('data-col') === col) colIndex = i;
|
|
724
|
+
th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = '\\u21C5');
|
|
725
|
+
});
|
|
726
|
+
if (colIndex === -1) return;
|
|
727
|
+
var th = tbl.querySelector('thead th[data-col="' + col + '"]');
|
|
728
|
+
if (th) th.querySelector('.sort-icon') && (th.querySelector('.sort-icon').textContent = dir === 'asc' ? '\\u2191' : '\\u2193');
|
|
729
|
+
var tbody = tbl.querySelector('tbody');
|
|
730
|
+
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
731
|
+
rows.sort(function(a, b) {
|
|
732
|
+
var av = (a.cells[colIndex] || {}).textContent || '';
|
|
733
|
+
var bv = (b.cells[colIndex] || {}).textContent || '';
|
|
734
|
+
var an = parseFloat(av), bn = parseFloat(bv);
|
|
735
|
+
if (!isNaN(an) && !isNaN(bn)) return dir === 'asc' ? an - bn : bn - an;
|
|
736
|
+
return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
|
737
|
+
});
|
|
738
|
+
rows.forEach(function(r) { tbody.appendChild(r); });
|
|
739
|
+
renderPagination(tableId);
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// \u2500\u2500 Table: pagination \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
743
|
+
function renderPagination(tableId) {
|
|
744
|
+
var tbl = document.getElementById(tableId);
|
|
745
|
+
var pag = document.getElementById(tableId + '-pagination');
|
|
746
|
+
var meta = document.getElementById(tableId + '-meta');
|
|
747
|
+
if (!tbl || !pag) return;
|
|
748
|
+
var pageSize = parseInt(tbl.getAttribute('data-page-size') || '50', 10);
|
|
749
|
+
var rows = Array.from(tbl.querySelectorAll('tbody tr')).filter(function(r) { return r.style.display !== 'none'; });
|
|
750
|
+
var total = rows.length;
|
|
751
|
+
var pages = Math.ceil(total / pageSize);
|
|
752
|
+
var page = parseInt(tbl.getAttribute('data-page') || '1', 10);
|
|
753
|
+
if (page > pages) page = 1;
|
|
754
|
+
tbl.setAttribute('data-page', page);
|
|
755
|
+
rows.forEach(function(r, i) {
|
|
756
|
+
r.style.display = (i >= (page - 1) * pageSize && i < page * pageSize) ? '' : 'none';
|
|
757
|
+
});
|
|
758
|
+
if (meta) meta.textContent = total + ' row' + (total !== 1 ? 's' : '');
|
|
759
|
+
pag.innerHTML = '';
|
|
760
|
+
if (pages <= 1) return;
|
|
761
|
+
function btn(label, p, active, disabled) {
|
|
762
|
+
var b = document.createElement('button');
|
|
763
|
+
b.textContent = label;
|
|
764
|
+
b.className = 'pag-btn' + (active ? ' pag-active' : '') + (disabled ? ' pag-disabled' : '');
|
|
765
|
+
b.disabled = disabled;
|
|
766
|
+
b.onclick = function() { tbl.setAttribute('data-page', p); renderPagination(tableId); };
|
|
767
|
+
return b;
|
|
768
|
+
}
|
|
769
|
+
pag.appendChild(btn('\\u00AB', 1, false, page === 1));
|
|
770
|
+
pag.appendChild(btn('\\u2039', page - 1, false, page === 1));
|
|
771
|
+
var start = Math.max(1, page - 2), end = Math.min(pages, page + 2);
|
|
772
|
+
for (var i = start; i <= end; i++) pag.appendChild(btn(i, i, i === page, false));
|
|
773
|
+
pag.appendChild(btn('\\u203A', page + 1, false, page === pages));
|
|
774
|
+
pag.appendChild(btn('\\u00BB', pages, false, page === pages));
|
|
775
|
+
var info = document.createElement('span');
|
|
776
|
+
info.className = 'pag-info';
|
|
777
|
+
info.textContent = 'Page ' + page + ' of ' + pages;
|
|
778
|
+
pag.appendChild(info);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Init all tables
|
|
782
|
+
document.querySelectorAll('table[id]').forEach(function(tbl) {
|
|
783
|
+
renderPagination(tbl.id);
|
|
784
|
+
var meta = document.getElementById(tbl.id + '-meta');
|
|
785
|
+
if (meta) {
|
|
786
|
+
var total = tbl.querySelectorAll('tbody tr').length;
|
|
787
|
+
meta.textContent = total + ' row' + (total !== 1 ? 's' : '');
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// \u2500\u2500 Export: table CSV \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
792
|
+
window.exportTableCsv = function(tableId) {
|
|
793
|
+
var tbl = document.getElementById(tableId);
|
|
794
|
+
if (!tbl) return;
|
|
795
|
+
var rows = [Array.from(tbl.querySelectorAll('thead th')).map(function(th) { return '"' + th.textContent.replace(/"/g, '""').trim() + '"'; }).join(',')];
|
|
796
|
+
tbl.querySelectorAll('tbody tr').forEach(function(tr) {
|
|
797
|
+
rows.push(Array.from(tr.cells).map(function(td) { return '"' + td.textContent.replace(/"/g, '""').trim() + '"'; }).join(','));
|
|
798
|
+
});
|
|
799
|
+
downloadFile(tableId + '.csv', rows.join('\\n'), 'text/csv');
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
// \u2500\u2500 Export: table JSON \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
803
|
+
window.exportTableJson = function(tableId) {
|
|
804
|
+
var tbl = document.getElementById(tableId);
|
|
805
|
+
if (!tbl) return;
|
|
806
|
+
var headers = Array.from(tbl.querySelectorAll('thead th')).map(function(th) { return th.textContent.trim().replace(/[\\u2191\\u2193\\u21C5]/g, '').trim(); });
|
|
807
|
+
var data = Array.from(tbl.querySelectorAll('tbody tr')).map(function(tr) {
|
|
808
|
+
var obj = {};
|
|
809
|
+
Array.from(tr.cells).forEach(function(td, i) { obj[headers[i] || i] = td.textContent.trim(); });
|
|
810
|
+
return obj;
|
|
811
|
+
});
|
|
812
|
+
downloadFile(tableId + '.json', JSON.stringify(data, null, 2), 'application/json');
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// \u2500\u2500 Export: full report JSON \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
816
|
+
window.exportFullJson = function() {
|
|
817
|
+
var fullReport = ${reportJson};
|
|
818
|
+
downloadFile('ai-localize-report.json', JSON.stringify(fullReport, null, 2), 'application/json');
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// \u2500\u2500 Export: full report CSV (summary) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
822
|
+
// \u2500\u2500 Export: full report CSV (all sections) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
823
|
+
window.exportFullCsv = function() {
|
|
824
|
+
var r = ${reportJson};
|
|
825
|
+
var q = function(s) { return '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; };
|
|
826
|
+
var rows = [];
|
|
827
|
+
|
|
828
|
+
// Summary
|
|
829
|
+
rows.push('=== SUMMARY ===');
|
|
830
|
+
rows.push('"Metric","Value"');
|
|
831
|
+
rows.push(q('Framework') + ',' + q(r.framework));
|
|
832
|
+
rows.push(q('Generated') + ',' + q(r.timestamp));
|
|
833
|
+
rows.push(q('Files Scanned') + ',' + r.filesScanned);
|
|
834
|
+
rows.push(q('Hardcoded Texts') + ',' + r.hardcodedTexts);
|
|
835
|
+
rows.push(q('Keys Generated') + ',' + r.localeKeysGenerated);
|
|
836
|
+
rows.push(q('Missing Translations') + ',' + r.missingTranslations);
|
|
837
|
+
rows.push(q('Unused Keys') + ',' + r.unusedKeys);
|
|
838
|
+
rows.push(q('Coverage %') + ',' + r.coveragePct);
|
|
839
|
+
rows.push(q('Total Assets') + ',' + r.assets.totalAssets);
|
|
840
|
+
rows.push(q('Uploaded Assets') + ',' + r.assets.uploadedAssets);
|
|
841
|
+
rows.push(q('Replaced URLs') + ',' + r.assets.replacedUrls);
|
|
842
|
+
rows.push(q('Legacy CDN URLs') + ',' + r.assets.legacyCdnUrls);
|
|
843
|
+
rows.push('');
|
|
844
|
+
|
|
845
|
+
// Hardcoded Texts
|
|
846
|
+
rows.push('=== HARDCODED TEXTS ===');
|
|
847
|
+
rows.push('"File","Line","Column","Text","Suggested Key","Context","Node Type","Already Translated"');
|
|
848
|
+
(r.details.detectedTexts || []).forEach(function(t) {
|
|
849
|
+
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(','));
|
|
850
|
+
});
|
|
851
|
+
rows.push('');
|
|
852
|
+
|
|
853
|
+
// Missing Translations
|
|
854
|
+
rows.push('=== MISSING TRANSLATIONS ===');
|
|
855
|
+
rows.push('"Key","Language","Locale File","Message"');
|
|
856
|
+
(r.details.missingKeys || []).forEach(function(m) {
|
|
857
|
+
rows.push([q(m.key), q(m.language), q(m.filePath), q(m.message)].join(','));
|
|
858
|
+
});
|
|
859
|
+
rows.push('');
|
|
860
|
+
|
|
861
|
+
// Unused Keys
|
|
862
|
+
rows.push('=== UNUSED KEYS ===');
|
|
863
|
+
rows.push('"Key"');
|
|
864
|
+
(r.details.unusedKeys || []).forEach(function(k) { rows.push(q(k)); });
|
|
865
|
+
rows.push('');
|
|
866
|
+
|
|
867
|
+
// CDN Assets
|
|
868
|
+
rows.push('=== CDN ASSETS ===');
|
|
869
|
+
rows.push('"Local Path","S3 Key","CloudFront URL","Content Type","Size (KB)"');
|
|
870
|
+
(r.details.assets || []).forEach(function(a) {
|
|
871
|
+
rows.push([q(a.localPath), q(a.s3Key), q(a.cloudfrontUrl), q(a.contentType), a.sizeKb].join(','));
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
downloadFile('ai-localize-full-report.csv', rows.join('
|
|
875
|
+
'), 'text/csv');
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
function downloadFile(filename, content, mimeType) {
|
|
879
|
+
var a = document.createElement('a');
|
|
880
|
+
a.href = URL.createObjectURL(new Blob([content], { type: mimeType }));
|
|
881
|
+
a.download = filename;
|
|
882
|
+
a.click();
|
|
883
|
+
setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// \u2500\u2500 Keyboard navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
887
|
+
document.addEventListener('keydown', function(e) {
|
|
888
|
+
if (e.key === 'Escape') {
|
|
889
|
+
document.querySelectorAll('.table-search').forEach(function(inp) { inp.value = ''; filterTable(inp.closest('.table-wrapper').querySelector('table').id, ''); });
|
|
890
|
+
}
|
|
891
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'd') { e.preventDefault(); window.toggleTheme(); }
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// \u2500\u2500 Smooth scroll for nav \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
895
|
+
document.querySelectorAll('a.nav-item[href^="#"]').forEach(function(a) {
|
|
896
|
+
a.addEventListener('click', function(e) {
|
|
897
|
+
e.preventDefault();
|
|
898
|
+
var target = document.querySelector(a.getAttribute('href'));
|
|
899
|
+
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
})();
|
|
904
|
+
`;
|
|
905
|
+
}
|
|
906
|
+
var CSS = `
|
|
907
|
+
/* \u2500\u2500 Reset & Variables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
908
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
909
|
+
|
|
910
|
+
:root {
|
|
911
|
+
--sidebar-w: 240px;
|
|
912
|
+
--accent: #6366f1;
|
|
913
|
+
--accent-hover: #4f46e5;
|
|
914
|
+
--ok: #22c55e;
|
|
915
|
+
--warn: #f59e0b;
|
|
916
|
+
--err: #ef4444;
|
|
917
|
+
--info: #3b82f6;
|
|
918
|
+
--neutral: #6b7280;
|
|
919
|
+
|
|
920
|
+
--bg: #f8fafc;
|
|
921
|
+
--bg-surface: #ffffff;
|
|
922
|
+
--bg-elevated: #f1f5f9;
|
|
923
|
+
--border: #e2e8f0;
|
|
924
|
+
--border-subtle: #f1f5f9;
|
|
925
|
+
--text: #0f172a;
|
|
926
|
+
--text-muted: #64748b;
|
|
927
|
+
--text-subtle: #94a3b8;
|
|
928
|
+
--shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
|
|
929
|
+
--shadow-md: 0 4px 12px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
|
|
930
|
+
--shadow-lg: 0 8px 24px rgba(0,0,0,.12);
|
|
931
|
+
--radius: 10px;
|
|
932
|
+
--radius-sm: 6px;
|
|
933
|
+
--radius-lg: 14px;
|
|
934
|
+
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif;
|
|
935
|
+
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
|
|
936
|
+
--transition: 0.18s ease;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
[data-theme="dark"] {
|
|
940
|
+
--bg: #0c0e14;
|
|
941
|
+
--bg-surface: #161b27;
|
|
942
|
+
--bg-elevated: #1e2535;
|
|
943
|
+
--border: #2d3748;
|
|
944
|
+
--border-subtle: #1e2535;
|
|
945
|
+
--text: #f1f5f9;
|
|
946
|
+
--text-muted: #94a3b8;
|
|
947
|
+
--text-subtle: #64748b;
|
|
948
|
+
--shadow-sm: 0 1px 3px rgba(0,0,0,.3);
|
|
949
|
+
--shadow-md: 0 4px 12px rgba(0,0,0,.4);
|
|
950
|
+
--shadow-lg: 0 8px 24px rgba(0,0,0,.5);
|
|
951
|
+
--accent: #818cf8;
|
|
952
|
+
--accent-hover: #a5b4fc;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
html { scroll-behavior: smooth; }
|
|
956
|
+
body {
|
|
957
|
+
font-family: var(--font);
|
|
958
|
+
background: var(--bg);
|
|
959
|
+
color: var(--text);
|
|
960
|
+
line-height: 1.6;
|
|
961
|
+
font-size: 14px;
|
|
962
|
+
display: flex;
|
|
963
|
+
min-height: 100vh;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/* \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
967
|
+
.sidebar {
|
|
968
|
+
position: fixed;
|
|
969
|
+
top: 0; left: 0; bottom: 0;
|
|
970
|
+
width: var(--sidebar-w);
|
|
971
|
+
background: var(--bg-surface);
|
|
972
|
+
border-right: 1px solid var(--border);
|
|
973
|
+
display: flex;
|
|
974
|
+
flex-direction: column;
|
|
975
|
+
overflow-y: auto;
|
|
976
|
+
overflow-x: hidden;
|
|
977
|
+
z-index: 100;
|
|
978
|
+
transition: width var(--transition);
|
|
979
|
+
}
|
|
980
|
+
.sidebar-collapsed { width: 52px; }
|
|
981
|
+
.sidebar-collapsed .sidebar-brand,
|
|
982
|
+
.sidebar-collapsed .sidebar-meta,
|
|
983
|
+
.sidebar-collapsed .nav-label,
|
|
984
|
+
.sidebar-collapsed .nav-count,
|
|
985
|
+
.sidebar-collapsed .nav-group-label { display: none; }
|
|
986
|
+
.sidebar-collapsed .nav-item { justify-content:center;padding:10px; }
|
|
987
|
+
.sidebar-collapsed .sidebar-logo { display:none; }
|
|
988
|
+
.sidebar-collapsed .sidebar-header { justify-content:center;padding:10px 6px; }
|
|
989
|
+
.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; }
|
|
990
|
+
|
|
991
|
+
.sidebar-header {
|
|
992
|
+
display: flex;
|
|
993
|
+
align-items: center;
|
|
994
|
+
gap: 10px;
|
|
995
|
+
padding: 18px 16px 12px;
|
|
996
|
+
border-bottom: 1px solid var(--border);
|
|
997
|
+
flex-shrink: 0;
|
|
998
|
+
}
|
|
999
|
+
.sidebar-logo { font-size: 22px; flex-shrink: 0; }
|
|
1000
|
+
.sidebar-brand { font-weight: 700; font-size: 15px; color: var(--accent); flex: 1; }
|
|
1001
|
+
.sidebar-toggle {
|
|
1002
|
+
background: none; border: none; cursor: pointer;
|
|
1003
|
+
font-size: 18px; color: var(--text-muted); padding: 2px 4px;
|
|
1004
|
+
border-radius: var(--radius-sm);
|
|
1005
|
+
transition: color var(--transition);
|
|
1006
|
+
}
|
|
1007
|
+
.sidebar-toggle:hover { color: var(--accent); }
|
|
1008
|
+
|
|
1009
|
+
.sidebar-meta {
|
|
1010
|
+
padding: 10px 16px;
|
|
1011
|
+
border-bottom: 1px solid var(--border);
|
|
1012
|
+
flex-shrink: 0;
|
|
1013
|
+
}
|
|
1014
|
+
.sidebar-framework { margin-bottom: 4px; }
|
|
1015
|
+
.sidebar-date { font-size: 11px; color: var(--text-subtle); }
|
|
1016
|
+
|
|
1017
|
+
.nav-group { padding: 8px 0; border-bottom: 1px solid var(--border-subtle); }
|
|
1018
|
+
.nav-group-label {
|
|
1019
|
+
padding: 6px 16px 3px;
|
|
1020
|
+
font-size: 10px;
|
|
1021
|
+
font-weight: 700;
|
|
1022
|
+
letter-spacing: .08em;
|
|
1023
|
+
text-transform: uppercase;
|
|
1024
|
+
color: var(--text-subtle);
|
|
1025
|
+
}
|
|
1026
|
+
.nav-item {
|
|
1027
|
+
display: flex;
|
|
1028
|
+
align-items: center;
|
|
1029
|
+
gap: 10px;
|
|
1030
|
+
padding: 8px 16px;
|
|
1031
|
+
text-decoration: none;
|
|
1032
|
+
color: var(--text-muted);
|
|
1033
|
+
font-size: 13px;
|
|
1034
|
+
font-weight: 500;
|
|
1035
|
+
border-radius: 0;
|
|
1036
|
+
border: none;
|
|
1037
|
+
background: none;
|
|
1038
|
+
cursor: pointer;
|
|
1039
|
+
width: 100%;
|
|
1040
|
+
transition: background var(--transition), color var(--transition);
|
|
1041
|
+
}
|
|
1042
|
+
.nav-item:hover { background: var(--bg-elevated); color: var(--text); }
|
|
1043
|
+
.nav-active { background: var(--bg-elevated) !important; color: var(--accent) !important; font-weight: 600; border-left: 3px solid var(--accent); }
|
|
1044
|
+
.nav-alert { color: var(--warn) !important; }
|
|
1045
|
+
.nav-icon { font-size: 16px; flex-shrink: 0; width: 20px; text-align: center; }
|
|
1046
|
+
.nav-label { flex: 1; }
|
|
1047
|
+
.nav-count {
|
|
1048
|
+
font-size: 11px; font-weight: 700;
|
|
1049
|
+
background: var(--bg-elevated);
|
|
1050
|
+
padding: 1px 7px; border-radius: 10px;
|
|
1051
|
+
min-width: 24px; text-align: center;
|
|
1052
|
+
color: var(--text-muted);
|
|
1053
|
+
}
|
|
1054
|
+
.nav-btn { font-size: 12px; }
|
|
1055
|
+
|
|
1056
|
+
/* \u2500\u2500 Main content \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1057
|
+
.main {
|
|
1058
|
+
margin-left: var(--sidebar-w);
|
|
1059
|
+
flex: 1;
|
|
1060
|
+
min-width: 0;
|
|
1061
|
+
padding: 24px 28px 48px;
|
|
1062
|
+
transition: margin-left var(--transition);
|
|
1063
|
+
}
|
|
1064
|
+
.main-expanded { margin-left: 52px; }
|
|
1065
|
+
|
|
1066
|
+
/* \u2500\u2500 Theme toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1067
|
+
.theme-toggle {
|
|
1068
|
+
position: fixed;
|
|
1069
|
+
top: 14px; right: 14px;
|
|
1070
|
+
z-index: 200;
|
|
1071
|
+
background: var(--bg-surface);
|
|
1072
|
+
border: 1px solid var(--border);
|
|
1073
|
+
border-radius: 50%;
|
|
1074
|
+
width: 38px; height: 38px;
|
|
1075
|
+
display: flex; align-items: center; justify-content: center;
|
|
1076
|
+
font-size: 18px;
|
|
1077
|
+
cursor: pointer;
|
|
1078
|
+
box-shadow: var(--shadow-md);
|
|
1079
|
+
transition: box-shadow var(--transition);
|
|
1080
|
+
}
|
|
1081
|
+
.theme-toggle:hover { box-shadow: var(--shadow-lg); }
|
|
1082
|
+
|
|
1083
|
+
/* \u2500\u2500 Page header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1084
|
+
.page-header {
|
|
1085
|
+
display: flex;
|
|
1086
|
+
align-items: flex-start;
|
|
1087
|
+
justify-content: space-between;
|
|
1088
|
+
margin-bottom: 28px;
|
|
1089
|
+
gap: 16px;
|
|
1090
|
+
flex-wrap: wrap;
|
|
1091
|
+
}
|
|
1092
|
+
.page-title {
|
|
1093
|
+
font-size: 22px;
|
|
1094
|
+
font-weight: 700;
|
|
1095
|
+
color: var(--text);
|
|
1096
|
+
margin-bottom: 8px;
|
|
1097
|
+
letter-spacing: -.02em;
|
|
1098
|
+
}
|
|
1099
|
+
.page-meta {
|
|
1100
|
+
display: flex;
|
|
1101
|
+
flex-wrap: wrap;
|
|
1102
|
+
gap: 8px;
|
|
1103
|
+
align-items: center;
|
|
1104
|
+
}
|
|
1105
|
+
.meta-chip { display: inline-flex; align-items: center; }
|
|
1106
|
+
.meta-item {
|
|
1107
|
+
font-size: 12px;
|
|
1108
|
+
color: var(--text-muted);
|
|
1109
|
+
background: var(--bg-elevated);
|
|
1110
|
+
padding: 3px 10px;
|
|
1111
|
+
border-radius: 20px;
|
|
1112
|
+
border: 1px solid var(--border);
|
|
1113
|
+
}
|
|
1114
|
+
.page-header-right { display: flex; gap: 8px; flex-shrink: 0; }
|
|
1115
|
+
|
|
1116
|
+
/* \u2500\u2500 Sections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1117
|
+
.section { margin-bottom: 32px; }
|
|
1118
|
+
.section-title-row {
|
|
1119
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
1120
|
+
margin-bottom: 14px;
|
|
1121
|
+
}
|
|
1122
|
+
.section-title {
|
|
1123
|
+
font-size: 17px;
|
|
1124
|
+
font-weight: 700;
|
|
1125
|
+
color: var(--text);
|
|
1126
|
+
display: flex; align-items: center; gap: 10px;
|
|
1127
|
+
}
|
|
1128
|
+
.section-count {
|
|
1129
|
+
font-size: 12px;
|
|
1130
|
+
font-weight: 700;
|
|
1131
|
+
padding: 2px 10px;
|
|
1132
|
+
border-radius: 20px;
|
|
1133
|
+
}
|
|
1134
|
+
.count-ok { background: #dcfce7; color: #15803d; }
|
|
1135
|
+
.count-warn { background: #fef3c7; color: #92400e; }
|
|
1136
|
+
.count-err { background: #fee2e2; color: #991b1b; }
|
|
1137
|
+
.count-neutral { background: var(--bg-elevated); color: var(--text-muted); }
|
|
1138
|
+
[data-theme="dark"] .count-ok { background: #14532d; color: #86efac; }
|
|
1139
|
+
[data-theme="dark"] .count-warn { background: #451a03; color: #fcd34d; }
|
|
1140
|
+
[data-theme="dark"] .count-err { background: #450a0a; color: #fca5a5; }
|
|
1141
|
+
|
|
1142
|
+
/* \u2500\u2500 Stat cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1143
|
+
.stats-grid {
|
|
1144
|
+
display: grid;
|
|
1145
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
1146
|
+
gap: 14px;
|
|
1147
|
+
margin-bottom: 20px;
|
|
1148
|
+
}
|
|
1149
|
+
.stat-card {
|
|
1150
|
+
background: var(--bg-surface);
|
|
1151
|
+
border: 1px solid var(--border);
|
|
1152
|
+
border-radius: var(--radius-lg);
|
|
1153
|
+
padding: 18px 16px 14px;
|
|
1154
|
+
box-shadow: var(--shadow-sm);
|
|
1155
|
+
position: relative;
|
|
1156
|
+
overflow: hidden;
|
|
1157
|
+
transition: box-shadow var(--transition), transform var(--transition);
|
|
1158
|
+
}
|
|
1159
|
+
.stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
|
|
1160
|
+
.stat-card::before {
|
|
1161
|
+
content: '';
|
|
1162
|
+
position: absolute;
|
|
1163
|
+
top: 0; left: 0; right: 0;
|
|
1164
|
+
height: 3px;
|
|
1165
|
+
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
1166
|
+
}
|
|
1167
|
+
.stat-ok::before { background: var(--ok); }
|
|
1168
|
+
.stat-warn::before { background: var(--warn); }
|
|
1169
|
+
.stat-err::before { background: var(--err); }
|
|
1170
|
+
.stat-info::before { background: var(--info); }
|
|
1171
|
+
.stat-neutral::before { background: var(--neutral); }
|
|
1172
|
+
|
|
1173
|
+
.stat-icon { font-size: 22px; margin-bottom: 8px; }
|
|
1174
|
+
.stat-value {
|
|
1175
|
+
font-size: 28px;
|
|
1176
|
+
font-weight: 800;
|
|
1177
|
+
line-height: 1;
|
|
1178
|
+
margin-bottom: 4px;
|
|
1179
|
+
color: var(--text);
|
|
1180
|
+
letter-spacing: -.03em;
|
|
1181
|
+
}
|
|
1182
|
+
.stat-ok .stat-value { color: var(--ok); }
|
|
1183
|
+
.stat-warn .stat-value { color: var(--warn); }
|
|
1184
|
+
.stat-err .stat-value { color: var(--err); }
|
|
1185
|
+
.stat-info .stat-value { color: var(--info); }
|
|
1186
|
+
.stat-neutral .stat-value { color: var(--neutral); }
|
|
1187
|
+
|
|
1188
|
+
.stat-label {
|
|
1189
|
+
font-size: 12px;
|
|
1190
|
+
font-weight: 700;
|
|
1191
|
+
color: var(--text);
|
|
1192
|
+
text-transform: uppercase;
|
|
1193
|
+
letter-spacing: .04em;
|
|
1194
|
+
margin-bottom: 3px;
|
|
1195
|
+
}
|
|
1196
|
+
.stat-hint { font-size: 11px; color: var(--text-subtle); }
|
|
1197
|
+
|
|
1198
|
+
/* \u2500\u2500 Asset summary row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1199
|
+
.asset-summary-row {
|
|
1200
|
+
display: grid;
|
|
1201
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
1202
|
+
gap: 12px;
|
|
1203
|
+
margin-bottom: 16px;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/* \u2500\u2500 Accordion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1207
|
+
.accordion {
|
|
1208
|
+
background: var(--bg-surface);
|
|
1209
|
+
border: 1px solid var(--border);
|
|
1210
|
+
border-radius: var(--radius);
|
|
1211
|
+
overflow: hidden;
|
|
1212
|
+
box-shadow: var(--shadow-sm);
|
|
1213
|
+
margin-bottom: 8px;
|
|
1214
|
+
}
|
|
1215
|
+
.accordion-trigger {
|
|
1216
|
+
display: flex;
|
|
1217
|
+
align-items: center;
|
|
1218
|
+
gap: 12px;
|
|
1219
|
+
width: 100%;
|
|
1220
|
+
padding: 14px 18px;
|
|
1221
|
+
background: none;
|
|
1222
|
+
border: none;
|
|
1223
|
+
cursor: pointer;
|
|
1224
|
+
text-align: left;
|
|
1225
|
+
transition: background var(--transition);
|
|
1226
|
+
}
|
|
1227
|
+
.accordion-trigger:hover { background: var(--bg-elevated); }
|
|
1228
|
+
.accordion-icon { font-size: 12px; flex-shrink: 0; }
|
|
1229
|
+
.severity-ok { color: var(--ok); }
|
|
1230
|
+
.severity-warn { color: var(--warn); }
|
|
1231
|
+
.severity-err { color: var(--err); }
|
|
1232
|
+
.severity-info { color: var(--info); }
|
|
1233
|
+
.accordion-title {
|
|
1234
|
+
font-size: 14px;
|
|
1235
|
+
font-weight: 600;
|
|
1236
|
+
color: var(--text);
|
|
1237
|
+
flex: 1;
|
|
1238
|
+
}
|
|
1239
|
+
.accordion-subtitle { font-size: 12px; color: var(--text-muted); margin-right: 12px; }
|
|
1240
|
+
.accordion-chevron {
|
|
1241
|
+
font-size: 18px;
|
|
1242
|
+
color: var(--text-muted);
|
|
1243
|
+
transition: transform var(--transition);
|
|
1244
|
+
}
|
|
1245
|
+
.accordion-open .accordion-chevron { transform: rotate(180deg); }
|
|
1246
|
+
.accordion-panel { border-top: 1px solid var(--border); }
|
|
1247
|
+
.accordion-body { padding: 18px; }
|
|
1248
|
+
|
|
1249
|
+
/* \u2500\u2500 Charts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1250
|
+
.charts-grid {
|
|
1251
|
+
display: grid;
|
|
1252
|
+
grid-template-columns: 180px 1fr 1fr;
|
|
1253
|
+
gap: 16px;
|
|
1254
|
+
margin-bottom: 4px;
|
|
1255
|
+
}
|
|
1256
|
+
@media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } }
|
|
1257
|
+
|
|
1258
|
+
.chart-card {
|
|
1259
|
+
background: var(--bg-surface);
|
|
1260
|
+
border: 1px solid var(--border);
|
|
1261
|
+
border-radius: var(--radius);
|
|
1262
|
+
padding: 18px;
|
|
1263
|
+
box-shadow: var(--shadow-sm);
|
|
1264
|
+
}
|
|
1265
|
+
.chart-title { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 14px; }
|
|
1266
|
+
.chart-body { min-height: 80px; }
|
|
1267
|
+
.chart-donut-wrap { display: flex; justify-content: center; align-items: center; }
|
|
1268
|
+
.chart-caption { font-size: 11px; color: var(--text-muted); margin-top: 10px; text-align: center; }
|
|
1269
|
+
.donut-chart { width: 120px; height: 120px; }
|
|
1270
|
+
|
|
1271
|
+
.bar-chart { display: flex; flex-direction: column; gap: 6px; }
|
|
1272
|
+
.bar-row { display: flex; align-items: center; gap: 8px; }
|
|
1273
|
+
.bar-label { font-size: 11px; color: var(--text-muted); width: 120px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1274
|
+
.bar-track { flex: 1; height: 8px; background: var(--bg-elevated); border-radius: 4px; overflow: hidden; }
|
|
1275
|
+
.bar-fill { height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; transition: width 0.5s ease; }
|
|
1276
|
+
.bar-value { font-size: 11px; font-weight: 700; color: var(--text-muted); width: 36px; text-align: right; flex-shrink: 0; }
|
|
1277
|
+
|
|
1278
|
+
/* \u2500\u2500 Info banners \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1279
|
+
.info-banner {
|
|
1280
|
+
border-radius: var(--radius);
|
|
1281
|
+
padding: 14px 18px;
|
|
1282
|
+
font-size: 13px;
|
|
1283
|
+
margin-bottom: 16px;
|
|
1284
|
+
border-left: 4px solid;
|
|
1285
|
+
}
|
|
1286
|
+
.info-banner ul { margin: 8px 0 0 18px; }
|
|
1287
|
+
.info-banner li { margin-bottom: 4px; }
|
|
1288
|
+
.info-banner-blue { background: #eff6ff; border-color: var(--info); color: #1e40af; }
|
|
1289
|
+
.info-banner-green { background: #f0fdf4; border-color: var(--ok); color: #166534; }
|
|
1290
|
+
[data-theme="dark"] .info-banner-blue { background: #1e3a5f; color: #bfdbfe; }
|
|
1291
|
+
[data-theme="dark"] .info-banner-green { background: #14532d; color: #bbf7d0; }
|
|
1292
|
+
|
|
1293
|
+
/* \u2500\u2500 Insight legend \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1294
|
+
.insight-legend {
|
|
1295
|
+
font-size: 13px;
|
|
1296
|
+
color: var(--text-muted);
|
|
1297
|
+
background: var(--bg-elevated);
|
|
1298
|
+
border-left: 3px solid var(--accent);
|
|
1299
|
+
padding: 10px 14px;
|
|
1300
|
+
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
|
1301
|
+
margin-bottom: 14px;
|
|
1302
|
+
}
|
|
1303
|
+
.insight-legend code {
|
|
1304
|
+
background: var(--bg-surface);
|
|
1305
|
+
border: 1px solid var(--border);
|
|
1306
|
+
padding: 1px 6px;
|
|
1307
|
+
border-radius: 4px;
|
|
1308
|
+
font-family: var(--font-mono);
|
|
1309
|
+
font-size: 12px;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/* \u2500\u2500 Chip filter row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1313
|
+
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; font-size: 12px; color: var(--text-muted); }
|
|
1314
|
+
.chip {
|
|
1315
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
1316
|
+
padding: 3px 10px; border-radius: 14px;
|
|
1317
|
+
font-size: 12px; font-weight: 600;
|
|
1318
|
+
cursor: pointer; border: 1px solid transparent;
|
|
1319
|
+
transition: box-shadow var(--transition);
|
|
1320
|
+
}
|
|
1321
|
+
.chip:hover { box-shadow: 0 0 0 2px var(--accent); }
|
|
1322
|
+
.chip-red { background: #fee2e2; color: #991b1b; }
|
|
1323
|
+
[data-theme="dark"] .chip-red { background: #450a0a; color: #fca5a5; }
|
|
1324
|
+
|
|
1325
|
+
/* \u2500\u2500 Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1326
|
+
.table-wrapper {
|
|
1327
|
+
background: var(--bg-surface);
|
|
1328
|
+
border: 1px solid var(--border);
|
|
1329
|
+
border-radius: var(--radius);
|
|
1330
|
+
overflow: hidden;
|
|
1331
|
+
box-shadow: var(--shadow-sm);
|
|
1332
|
+
}
|
|
1333
|
+
.table-controls {
|
|
1334
|
+
display: flex;
|
|
1335
|
+
align-items: center;
|
|
1336
|
+
gap: 10px;
|
|
1337
|
+
padding: 12px 16px;
|
|
1338
|
+
border-bottom: 1px solid var(--border);
|
|
1339
|
+
flex-wrap: wrap;
|
|
1340
|
+
}
|
|
1341
|
+
.search-box {
|
|
1342
|
+
display: flex;
|
|
1343
|
+
align-items: center;
|
|
1344
|
+
gap: 6px;
|
|
1345
|
+
background: var(--bg-elevated);
|
|
1346
|
+
border: 1px solid var(--border);
|
|
1347
|
+
border-radius: var(--radius-sm);
|
|
1348
|
+
padding: 5px 10px;
|
|
1349
|
+
flex: 1;
|
|
1350
|
+
min-width: 180px;
|
|
1351
|
+
max-width: 320px;
|
|
1352
|
+
}
|
|
1353
|
+
.search-icon { font-size: 13px; color: var(--text-subtle); flex-shrink: 0; }
|
|
1354
|
+
.table-search {
|
|
1355
|
+
border: none;
|
|
1356
|
+
background: transparent;
|
|
1357
|
+
outline: none;
|
|
1358
|
+
font-size: 13px;
|
|
1359
|
+
color: var(--text);
|
|
1360
|
+
width: 100%;
|
|
1361
|
+
font-family: var(--font);
|
|
1362
|
+
}
|
|
1363
|
+
.table-meta { font-size: 12px; color: var(--text-muted); flex: 1; min-width: 80px; }
|
|
1364
|
+
.table-actions { display: flex; gap: 6px; }
|
|
1365
|
+
.table-scroll { overflow-x: auto; }
|
|
1366
|
+
|
|
1367
|
+
table {
|
|
1368
|
+
width: 100%;
|
|
1369
|
+
border-collapse: collapse;
|
|
1370
|
+
font-size: 13px;
|
|
1371
|
+
}
|
|
1372
|
+
thead th {
|
|
1373
|
+
background: var(--bg-elevated);
|
|
1374
|
+
color: var(--text-muted);
|
|
1375
|
+
padding: 10px 14px;
|
|
1376
|
+
text-align: left;
|
|
1377
|
+
font-size: 11px;
|
|
1378
|
+
font-weight: 700;
|
|
1379
|
+
letter-spacing: .04em;
|
|
1380
|
+
text-transform: uppercase;
|
|
1381
|
+
border-bottom: 1px solid var(--border);
|
|
1382
|
+
white-space: nowrap;
|
|
1383
|
+
position: sticky;
|
|
1384
|
+
top: 0;
|
|
1385
|
+
z-index: 1;
|
|
1386
|
+
}
|
|
1387
|
+
th.sortable { cursor: pointer; user-select: none; }
|
|
1388
|
+
th.sortable:hover { background: var(--border); }
|
|
1389
|
+
.sort-icon { font-size: 11px; opacity: .5; }
|
|
1390
|
+
tbody td {
|
|
1391
|
+
padding: 9px 14px;
|
|
1392
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
1393
|
+
vertical-align: top;
|
|
1394
|
+
color: var(--text);
|
|
1395
|
+
}
|
|
1396
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
1397
|
+
tbody tr:hover td { background: var(--bg-elevated); }
|
|
1398
|
+
|
|
1399
|
+
.table-pagination {
|
|
1400
|
+
display: flex;
|
|
1401
|
+
align-items: center;
|
|
1402
|
+
gap: 4px;
|
|
1403
|
+
padding: 10px 16px;
|
|
1404
|
+
border-top: 1px solid var(--border);
|
|
1405
|
+
flex-wrap: wrap;
|
|
1406
|
+
}
|
|
1407
|
+
.pag-btn {
|
|
1408
|
+
background: var(--bg-elevated);
|
|
1409
|
+
border: 1px solid var(--border);
|
|
1410
|
+
border-radius: var(--radius-sm);
|
|
1411
|
+
padding: 4px 9px;
|
|
1412
|
+
font-size: 12px;
|
|
1413
|
+
cursor: pointer;
|
|
1414
|
+
color: var(--text);
|
|
1415
|
+
transition: background var(--transition);
|
|
1416
|
+
}
|
|
1417
|
+
.pag-btn:hover:not(.pag-disabled) { background: var(--accent); color: white; border-color: var(--accent); }
|
|
1418
|
+
.pag-active { background: var(--accent) !important; color: white !important; border-color: var(--accent) !important; font-weight: 700; }
|
|
1419
|
+
.pag-disabled { opacity: .4; cursor: not-allowed; }
|
|
1420
|
+
.pag-info { font-size: 11px; color: var(--text-muted); margin-left: 6px; }
|
|
1421
|
+
|
|
1422
|
+
/* \u2500\u2500 Code & path styles \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1423
|
+
.path-code {
|
|
1424
|
+
font-family: var(--font-mono);
|
|
1425
|
+
font-size: 11px;
|
|
1426
|
+
color: var(--text-muted);
|
|
1427
|
+
background: var(--bg-elevated);
|
|
1428
|
+
padding: 1px 6px;
|
|
1429
|
+
border-radius: 4px;
|
|
1430
|
+
word-break: break-all;
|
|
1431
|
+
}
|
|
1432
|
+
.key-code {
|
|
1433
|
+
font-family: var(--font-mono);
|
|
1434
|
+
font-size: 11px;
|
|
1435
|
+
color: var(--accent);
|
|
1436
|
+
background: var(--bg-elevated);
|
|
1437
|
+
padding: 1px 6px;
|
|
1438
|
+
border-radius: 4px;
|
|
1439
|
+
word-break: break-all;
|
|
1440
|
+
}
|
|
1441
|
+
.key-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
1442
|
+
.text-preview { font-size: 12px; word-break: break-word; max-width: 260px; display: inline-block; }
|
|
1443
|
+
.line-num {
|
|
1444
|
+
font-family: var(--font-mono);
|
|
1445
|
+
font-size: 12px;
|
|
1446
|
+
color: var(--text-subtle);
|
|
1447
|
+
background: var(--bg-elevated);
|
|
1448
|
+
padding: 1px 6px;
|
|
1449
|
+
border-radius: 4px;
|
|
1450
|
+
}
|
|
1451
|
+
.file-size { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); }
|
|
1452
|
+
.detail-text { font-size: 12px; color: var(--text-muted); }
|
|
1453
|
+
.muted { color: var(--text-subtle); font-size: 12px; }
|
|
1454
|
+
.cdn-link { font-size: 11px; color: var(--accent); text-decoration: none; }
|
|
1455
|
+
.cdn-link:hover { text-decoration: underline; }
|
|
1456
|
+
|
|
1457
|
+
/* \u2500\u2500 Badges \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1458
|
+
.badge {
|
|
1459
|
+
display: inline-flex;
|
|
1460
|
+
align-items: center;
|
|
1461
|
+
padding: 2px 8px;
|
|
1462
|
+
border-radius: 12px;
|
|
1463
|
+
font-size: 11px;
|
|
1464
|
+
font-weight: 600;
|
|
1465
|
+
white-space: nowrap;
|
|
1466
|
+
line-height: 1.5;
|
|
1467
|
+
}
|
|
1468
|
+
.badge-blue { background: #dbeafe; color: #1d4ed8; }
|
|
1469
|
+
.badge-red { background: #fee2e2; color: #b91c1c; }
|
|
1470
|
+
.badge-green { background: #dcfce7; color: #15803d; }
|
|
1471
|
+
.badge-orange { background: #ffedd5; color: #c2410c; }
|
|
1472
|
+
.badge-grey { background: var(--bg-elevated); color: var(--text-muted); border: 1px solid var(--border); }
|
|
1473
|
+
.badge-purple { background: #ede9fe; color: #6d28d9; }
|
|
1474
|
+
.badge-yellow { background: #fef9c3; color: #a16207; }
|
|
1475
|
+
.badge-teal { background: #ccfbf1; color: #0f766e; }
|
|
1476
|
+
[data-theme="dark"] .badge-blue { background: #1e3a5f; color: #93c5fd; }
|
|
1477
|
+
[data-theme="dark"] .badge-red { background: #450a0a; color: #fca5a5; }
|
|
1478
|
+
[data-theme="dark"] .badge-green { background: #14532d; color: #86efac; }
|
|
1479
|
+
[data-theme="dark"] .badge-orange { background: #431407; color: #fdba74; }
|
|
1480
|
+
[data-theme="dark"] .badge-purple { background: #2e1065; color: #c4b5fd; }
|
|
1481
|
+
[data-theme="dark"] .badge-yellow { background: #422006; color: #fde047; }
|
|
1482
|
+
[data-theme="dark"] .badge-teal { background: #042f2e; color: #5eead4; }
|
|
1483
|
+
|
|
1484
|
+
/* \u2500\u2500 Buttons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1485
|
+
.btn {
|
|
1486
|
+
display: inline-flex;
|
|
1487
|
+
align-items: center;
|
|
1488
|
+
gap: 5px;
|
|
1489
|
+
padding: 7px 14px;
|
|
1490
|
+
font-size: 13px;
|
|
1491
|
+
font-weight: 600;
|
|
1492
|
+
border-radius: var(--radius-sm);
|
|
1493
|
+
border: 1px solid var(--border);
|
|
1494
|
+
background: var(--bg-surface);
|
|
1495
|
+
color: var(--text);
|
|
1496
|
+
cursor: pointer;
|
|
1497
|
+
transition: background var(--transition), box-shadow var(--transition);
|
|
1498
|
+
font-family: var(--font);
|
|
1499
|
+
white-space: nowrap;
|
|
1500
|
+
}
|
|
1501
|
+
.btn:hover { background: var(--bg-elevated); box-shadow: var(--shadow-sm); }
|
|
1502
|
+
.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
|
|
1503
|
+
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
|
1504
|
+
.btn-sm { padding: 4px 10px; font-size: 11px; }
|
|
1505
|
+
|
|
1506
|
+
/* \u2500\u2500 AI Insights \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1507
|
+
.insight-banner {
|
|
1508
|
+
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
|
|
1509
|
+
border: 1px solid #c4b5fd;
|
|
1510
|
+
border-radius: var(--radius);
|
|
1511
|
+
padding: 12px 18px;
|
|
1512
|
+
font-size: 13px;
|
|
1513
|
+
color: #5b21b6;
|
|
1514
|
+
margin-bottom: 16px;
|
|
1515
|
+
}
|
|
1516
|
+
[data-theme="dark"] .insight-banner { background: linear-gradient(135deg, #1e1b4b 0%, #2e1065 100%); color: #c4b5fd; border-color: #4c1d95; }
|
|
1517
|
+
.insights-grid {
|
|
1518
|
+
display: grid;
|
|
1519
|
+
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
|
1520
|
+
gap: 16px;
|
|
1521
|
+
}
|
|
1522
|
+
.insight-card {
|
|
1523
|
+
background: var(--bg-surface);
|
|
1524
|
+
border: 1px solid var(--border);
|
|
1525
|
+
border-radius: var(--radius);
|
|
1526
|
+
padding: 18px;
|
|
1527
|
+
box-shadow: var(--shadow-sm);
|
|
1528
|
+
}
|
|
1529
|
+
.insight-heading {
|
|
1530
|
+
font-size: 14px;
|
|
1531
|
+
font-weight: 700;
|
|
1532
|
+
color: var(--text);
|
|
1533
|
+
margin-bottom: 4px;
|
|
1534
|
+
}
|
|
1535
|
+
.insight-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; }
|
|
1536
|
+
.insight-item { font-size: 13px; padding: 8px 0; }
|
|
1537
|
+
.insight-ok { color: var(--ok); font-weight: 500; }
|
|
1538
|
+
.insight-list { padding-left: 18px; list-style: disc; }
|
|
1539
|
+
.insight-list li { margin-bottom: 6px; font-size: 13px; color: var(--text-muted); }
|
|
1540
|
+
.insight-ns {
|
|
1541
|
+
font-family: var(--font-mono);
|
|
1542
|
+
font-size: 12px;
|
|
1543
|
+
color: var(--accent);
|
|
1544
|
+
background: var(--bg-elevated);
|
|
1545
|
+
padding: 1px 6px;
|
|
1546
|
+
border-radius: 4px;
|
|
1547
|
+
margin-right: 6px;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/* \u2500\u2500 Empty state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1551
|
+
.empty-state-card {
|
|
1552
|
+
background: var(--bg-elevated);
|
|
1553
|
+
border: 1px solid var(--border);
|
|
1554
|
+
border-radius: var(--radius);
|
|
1555
|
+
padding: 20px 24px;
|
|
1556
|
+
font-size: 13px;
|
|
1557
|
+
color: var(--text-muted);
|
|
1558
|
+
text-align: center;
|
|
1559
|
+
}
|
|
1560
|
+
.empty-state-card code {
|
|
1561
|
+
font-family: var(--font-mono);
|
|
1562
|
+
background: var(--bg-surface);
|
|
1563
|
+
border: 1px solid var(--border);
|
|
1564
|
+
padding: 1px 6px;
|
|
1565
|
+
border-radius: 4px;
|
|
1566
|
+
font-size: 12px;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/* \u2500\u2500 Overflow notice \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1570
|
+
.overflow-notice {
|
|
1571
|
+
font-size: 12px;
|
|
1572
|
+
color: var(--text-subtle);
|
|
1573
|
+
text-align: center;
|
|
1574
|
+
padding: 8px;
|
|
1575
|
+
background: var(--bg-elevated);
|
|
1576
|
+
border-top: 1px solid var(--border);
|
|
1577
|
+
border-radius: 0 0 var(--radius) var(--radius);
|
|
1578
|
+
font-style: italic;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/* \u2500\u2500 Footer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1582
|
+
.page-footer {
|
|
1583
|
+
margin-top: 48px;
|
|
1584
|
+
padding-top: 18px;
|
|
1585
|
+
border-top: 1px solid var(--border);
|
|
1586
|
+
font-size: 12px;
|
|
1587
|
+
color: var(--text-subtle);
|
|
1588
|
+
text-align: center;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/* \u2500\u2500 Print \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1592
|
+
@media print {
|
|
1593
|
+
.sidebar, .theme-toggle, .table-controls, .table-pagination, .page-header-right { display: none !important; }
|
|
1594
|
+
.main { margin-left: 0 !important; padding: 16px; }
|
|
1595
|
+
.accordion-panel { display: block !important; }
|
|
1596
|
+
.accordion-panel[hidden] { display: block !important; }
|
|
1597
|
+
.stat-card, .chart-card, .accordion, .table-wrapper { break-inside: avoid; }
|
|
1598
|
+
body { background: white; color: black; }
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/* \u2500\u2500 Responsive \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1602
|
+
@media (max-width: 768px) {
|
|
1603
|
+
body { flex-direction: column; }
|
|
1604
|
+
.sidebar { position: relative; width: 100%; height: auto; flex-direction: row; flex-wrap: wrap; overflow: visible; border-right: none; border-bottom: 1px solid var(--border); }
|
|
1605
|
+
.main { margin-left: 0; padding: 16px; }
|
|
1606
|
+
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
1607
|
+
.insights-grid { grid-template-columns: 1fr; }
|
|
1608
|
+
.page-header { flex-direction: column; }
|
|
1609
|
+
.theme-toggle { top: 8px; right: 8px; }
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/* \u2500\u2500 Scrollbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1613
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
1614
|
+
::-webkit-scrollbar-track { background: var(--bg); }
|
|
1615
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
1616
|
+
::-webkit-scrollbar-thumb:hover { background: var(--text-subtle); }
|
|
1617
|
+
|
|
1618
|
+
/* \u2500\u2500 Focus \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1619
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
215
1620
|
`;
|
|
216
1621
|
|
|
217
1622
|
// src/cli-reporter.ts
|
|
1623
|
+
var A = {
|
|
1624
|
+
reset: "\x1B[0m",
|
|
1625
|
+
bold: "\x1B[1m",
|
|
1626
|
+
dim: "\x1B[2m",
|
|
1627
|
+
italic: "\x1B[3m",
|
|
1628
|
+
underline: "\x1B[4m",
|
|
1629
|
+
// foreground
|
|
1630
|
+
black: "\x1B[30m",
|
|
1631
|
+
red: "\x1B[31m",
|
|
1632
|
+
green: "\x1B[32m",
|
|
1633
|
+
yellow: "\x1B[33m",
|
|
1634
|
+
blue: "\x1B[34m",
|
|
1635
|
+
magenta: "\x1B[35m",
|
|
1636
|
+
cyan: "\x1B[36m",
|
|
1637
|
+
white: "\x1B[37m",
|
|
1638
|
+
// bright foreground
|
|
1639
|
+
brightRed: "\x1B[91m",
|
|
1640
|
+
brightGreen: "\x1B[92m",
|
|
1641
|
+
brightYellow: "\x1B[93m",
|
|
1642
|
+
brightBlue: "\x1B[94m",
|
|
1643
|
+
brightMagenta: "\x1B[95m",
|
|
1644
|
+
brightCyan: "\x1B[96m",
|
|
1645
|
+
brightWhite: "\x1B[97m",
|
|
1646
|
+
// background
|
|
1647
|
+
bgRed: "\x1B[41m",
|
|
1648
|
+
bgGreen: "\x1B[42m",
|
|
1649
|
+
bgYellow: "\x1B[43m",
|
|
1650
|
+
bgBlue: "\x1B[44m",
|
|
1651
|
+
bgMagenta: "\x1B[45m",
|
|
1652
|
+
bgCyan: "\x1B[46m"
|
|
1653
|
+
};
|
|
1654
|
+
function c(color, text) {
|
|
1655
|
+
return A[color] + text + A.reset;
|
|
1656
|
+
}
|
|
1657
|
+
function bold(text) {
|
|
1658
|
+
return A.bold + text + A.reset;
|
|
1659
|
+
}
|
|
1660
|
+
function dim(text) {
|
|
1661
|
+
return A.dim + text + A.reset;
|
|
1662
|
+
}
|
|
1663
|
+
function supportsColor() {
|
|
1664
|
+
if (process.env.NO_COLOR || process.env.TERM === "dumb") return false;
|
|
1665
|
+
if (process.stdout && !process.stdout.isTTY) return false;
|
|
1666
|
+
return true;
|
|
1667
|
+
}
|
|
1668
|
+
function strip(text) {
|
|
1669
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1670
|
+
}
|
|
1671
|
+
function visLen(s) {
|
|
1672
|
+
return strip(s).length;
|
|
1673
|
+
}
|
|
1674
|
+
function padEnd(s, width) {
|
|
1675
|
+
const pad = width - visLen(s);
|
|
1676
|
+
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
1677
|
+
}
|
|
1678
|
+
function padStart(s, width) {
|
|
1679
|
+
const pad = width - visLen(s);
|
|
1680
|
+
return pad > 0 ? " ".repeat(pad) + s : s;
|
|
1681
|
+
}
|
|
1682
|
+
function termWidth() {
|
|
1683
|
+
return Math.min(process.stdout.columns || 100, 120);
|
|
1684
|
+
}
|
|
1685
|
+
function hr(char = "\u2500", color = "dim") {
|
|
1686
|
+
const line = char.repeat(termWidth());
|
|
1687
|
+
return supportsColor() ? A[color] + line + A.reset : line;
|
|
1688
|
+
}
|
|
1689
|
+
function centre(text) {
|
|
1690
|
+
const visible = visLen(text);
|
|
1691
|
+
const tw = termWidth();
|
|
1692
|
+
if (visible >= tw) return text;
|
|
1693
|
+
const pad = Math.floor((tw - visible) / 2);
|
|
1694
|
+
return " ".repeat(pad) + text;
|
|
1695
|
+
}
|
|
1696
|
+
function badge2(text, ok) {
|
|
1697
|
+
return ok ? c("bgGreen", c("black", ` ${text} `)) : c("bgRed", c("white", ` ${text} `));
|
|
1698
|
+
}
|
|
1699
|
+
function miniBar(value, max, width = 20, color = "cyan") {
|
|
1700
|
+
const filled = max > 0 ? Math.round(value / max * width) : 0;
|
|
1701
|
+
const empty = width - filled;
|
|
1702
|
+
const bar = c(color, "\u2588".repeat(filled)) + dim("\u2591".repeat(empty));
|
|
1703
|
+
return "[" + bar + "]";
|
|
1704
|
+
}
|
|
1705
|
+
function progressBar(pct, width = 24) {
|
|
1706
|
+
const filled = Math.round(pct / 100 * width);
|
|
1707
|
+
const empty = width - filled;
|
|
1708
|
+
let color = "brightGreen";
|
|
1709
|
+
if (pct < 50) color = "brightRed";
|
|
1710
|
+
else if (pct < 80) color = "brightYellow";
|
|
1711
|
+
const bar = c(color, "\u2588".repeat(filled)) + dim("\u2591".repeat(empty));
|
|
1712
|
+
return "[" + bar + "]";
|
|
1713
|
+
}
|
|
1714
|
+
function dot(ok, warn) {
|
|
1715
|
+
if (ok) return c("brightGreen", "\u25CF");
|
|
1716
|
+
if (warn) return c("brightYellow", "\u25CF");
|
|
1717
|
+
return c("brightRed", "\u25CF");
|
|
1718
|
+
}
|
|
1719
|
+
function fmt(n) {
|
|
1720
|
+
return n.toLocaleString();
|
|
1721
|
+
}
|
|
1722
|
+
function renderTable(columns, rows) {
|
|
1723
|
+
if (rows.length === 0) return "";
|
|
1724
|
+
const widths = columns.map((col, i) => {
|
|
1725
|
+
const headerW = visLen(col.header);
|
|
1726
|
+
const maxDataW = rows.reduce((m, row) => Math.max(m, visLen(row[i] ?? "")), 0);
|
|
1727
|
+
const w = Math.max(headerW, maxDataW);
|
|
1728
|
+
return col.maxWidth ? Math.min(w, col.maxWidth) : w;
|
|
1729
|
+
});
|
|
1730
|
+
const sepLine = "\u251C" + widths.map((w) => "\u2500".repeat(w + 2)).join("\u253C") + "\u2524";
|
|
1731
|
+
const topLine = "\u250C" + widths.map((w) => "\u2500".repeat(w + 2)).join("\u252C") + "\u2510";
|
|
1732
|
+
const botLine = "\u2514" + widths.map((w) => "\u2500".repeat(w + 2)).join("\u2534") + "\u2518";
|
|
1733
|
+
function renderRow(cells, isHeader = false) {
|
|
1734
|
+
const parts = cells.map((cell, i) => {
|
|
1735
|
+
const w = widths[i];
|
|
1736
|
+
const col = columns[i];
|
|
1737
|
+
let s = visLen(cell) > w ? strip(cell).slice(0, w - 1) + "\u2026" : cell;
|
|
1738
|
+
if (isHeader) s = bold(strip(s));
|
|
1739
|
+
if (col?.align === "right") s = padStart(s, w);
|
|
1740
|
+
else if (col?.align === "center") {
|
|
1741
|
+
const p = Math.floor((w - visLen(s)) / 2);
|
|
1742
|
+
s = " ".repeat(p) + s + " ".repeat(w - visLen(s) - p);
|
|
1743
|
+
} else {
|
|
1744
|
+
s = padEnd(s, w);
|
|
1745
|
+
}
|
|
1746
|
+
return " " + s + " ";
|
|
1747
|
+
});
|
|
1748
|
+
return "\u2502" + parts.join("\u2502") + "\u2502";
|
|
1749
|
+
}
|
|
1750
|
+
const lines = [];
|
|
1751
|
+
lines.push(dim(topLine));
|
|
1752
|
+
lines.push(renderRow(columns.map((c2) => c2.header), true));
|
|
1753
|
+
lines.push(dim(sepLine));
|
|
1754
|
+
for (const row of rows) {
|
|
1755
|
+
lines.push(renderRow(row));
|
|
1756
|
+
}
|
|
1757
|
+
lines.push(dim(botLine));
|
|
1758
|
+
return lines.join("\n");
|
|
1759
|
+
}
|
|
1760
|
+
function computeCliInsights(report) {
|
|
1761
|
+
const { details } = report;
|
|
1762
|
+
const totalUniqueKeys = new Set(details.detectedTexts.map((d) => d.suggestedKey)).size;
|
|
1763
|
+
const coveragePct = totalUniqueKeys > 0 ? Math.round((totalUniqueKeys - details.missingKeys.length) / totalUniqueKeys * 100) : 100;
|
|
1764
|
+
const textMap = /* @__PURE__ */ new Map();
|
|
1765
|
+
for (const dt of details.detectedTexts) {
|
|
1766
|
+
const t = dt.text.trim();
|
|
1767
|
+
if (!textMap.has(t)) textMap.set(t, /* @__PURE__ */ new Set());
|
|
1768
|
+
textMap.get(t).add(dt.suggestedKey);
|
|
1769
|
+
}
|
|
1770
|
+
let duplicateTextGroups = 0;
|
|
1771
|
+
for (const [, keys] of textMap) {
|
|
1772
|
+
if (keys.size > 1) duplicateTextGroups++;
|
|
1773
|
+
}
|
|
1774
|
+
const nsCounts = /* @__PURE__ */ new Map();
|
|
1775
|
+
for (const dt of details.detectedTexts) {
|
|
1776
|
+
const ns = dt.suggestedKey.split(".")[0] || "default";
|
|
1777
|
+
nsCounts.set(ns, (nsCounts.get(ns) || 0) + 1);
|
|
1778
|
+
}
|
|
1779
|
+
const topNamespaces = [...nsCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([ns, count]) => ({ ns, count }));
|
|
1780
|
+
const ctxCounts = /* @__PURE__ */ new Map();
|
|
1781
|
+
for (const dt of details.detectedTexts) {
|
|
1782
|
+
ctxCounts.set(dt.context, (ctxCounts.get(dt.context) || 0) + 1);
|
|
1783
|
+
}
|
|
1784
|
+
const topContexts = [...ctxCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([ctx, count]) => ({ ctx, count }));
|
|
1785
|
+
const byLang = /* @__PURE__ */ new Map();
|
|
1786
|
+
for (const mk of details.missingKeys) {
|
|
1787
|
+
if (mk.language) byLang.set(mk.language, (byLang.get(mk.language) || 0) + 1);
|
|
1788
|
+
}
|
|
1789
|
+
const topMissingLanguages = [...byLang.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([lang, count]) => ({ lang, count }));
|
|
1790
|
+
const unusedTopKeys = details.unusedKeysList.slice(0, 5);
|
|
1791
|
+
return {
|
|
1792
|
+
coveragePct,
|
|
1793
|
+
totalUniqueKeys,
|
|
1794
|
+
duplicateTextGroups,
|
|
1795
|
+
namespaceCount: nsCounts.size,
|
|
1796
|
+
topMissingLanguages,
|
|
1797
|
+
topNamespaces,
|
|
1798
|
+
topContexts,
|
|
1799
|
+
unusedTopKeys
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
function renderSectionHeader(icon, title, count, warn = false) {
|
|
1803
|
+
const countStr = count !== void 0 ? " " + (count === 0 ? c("brightGreen", `(${count})`) : warn ? c("brightYellow", `(${count})`) : c("brightRed", `(${count})`)) : "";
|
|
1804
|
+
return "\n" + bold(c("brightCyan", icon + " " + title)) + countStr;
|
|
1805
|
+
}
|
|
1806
|
+
function renderKeyValueSection(pairs, indent = " ") {
|
|
1807
|
+
const keyWidth = Math.max(...pairs.map(([k]) => k.length)) + 1;
|
|
1808
|
+
return pairs.map(
|
|
1809
|
+
([k, v]) => indent + dim(padEnd(k + ":", keyWidth + 1)) + " " + v
|
|
1810
|
+
).join("\n");
|
|
1811
|
+
}
|
|
218
1812
|
function printCliSummary(report) {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
1813
|
+
const {
|
|
1814
|
+
timestamp,
|
|
1815
|
+
framework,
|
|
1816
|
+
duration,
|
|
1817
|
+
filesScanned,
|
|
1818
|
+
hardcodedTexts,
|
|
1819
|
+
localeKeysGenerated: _localeKeysGenerated,
|
|
1820
|
+
unusedKeys,
|
|
1821
|
+
missingTranslations,
|
|
1822
|
+
assets,
|
|
1823
|
+
details
|
|
1824
|
+
} = report;
|
|
1825
|
+
const scanDate = new Date(timestamp).toLocaleString();
|
|
1826
|
+
const ins = computeCliInsights(report);
|
|
1827
|
+
const w = termWidth();
|
|
1828
|
+
const out = [];
|
|
1829
|
+
const ln = (s = "") => out.push(s);
|
|
1830
|
+
ln();
|
|
1831
|
+
ln(hr("\u2550", "cyan"));
|
|
1832
|
+
ln(centre(bold(c("brightCyan", " \u{1F310} ai-localize \xB7 Localization Report "))));
|
|
1833
|
+
ln(hr("\u2550", "cyan"));
|
|
1834
|
+
ln();
|
|
1835
|
+
ln(centre(
|
|
1836
|
+
dim("Framework: ") + c("brightWhite", framework) + dim(" \u2502 ") + dim("Scanned: ") + c("brightWhite", scanDate) + dim(" \u2502 ") + dim("Duration: ") + c("brightWhite", duration + "ms")
|
|
1837
|
+
));
|
|
1838
|
+
ln();
|
|
1839
|
+
ln(hr("\u2500"));
|
|
1840
|
+
ln(bold(c("white", " \u{1F4CA} Summary")));
|
|
1841
|
+
ln(hr("\u2500"));
|
|
1842
|
+
ln();
|
|
1843
|
+
function statLine(icon, label, value, statusOk, statusWarn = false, hint = "") {
|
|
1844
|
+
const valStr = typeof value === "number" ? statusOk ? c("brightGreen", fmt(value)) : statusWarn ? c("brightYellow", fmt(value)) : c("brightRed", fmt(value)) : c("brightWhite", String(value));
|
|
1845
|
+
const d = dot(statusOk, !statusOk && statusWarn);
|
|
1846
|
+
const left = " " + d + " " + padEnd(icon + " " + label, 26) + " " + padStart(valStr, 8);
|
|
1847
|
+
const right = hint ? dim(" \u2190 " + hint) : "";
|
|
1848
|
+
return left + right;
|
|
1849
|
+
}
|
|
1850
|
+
ln(statLine("\u{1F4C1}", "Files Scanned", filesScanned, true, false, "source files processed"));
|
|
1851
|
+
ln(statLine("\u{1F50D}", "Hardcoded Texts", hardcodedTexts, hardcodedTexts === 0, hardcodedTexts === 0, "raw strings not yet in t()"));
|
|
1852
|
+
ln(statLine("\u{1F511}", "Unique Keys Generated", ins.totalUniqueKeys, true, false, "deduplicated locale keys"));
|
|
1853
|
+
ln(statLine("\u274C", "Missing Translations", missingTranslations, missingTranslations === 0, false, "absent in target languages"));
|
|
1854
|
+
ln(statLine("\u{1F5D1} ", "Unused Keys", unusedKeys, unusedKeys === 0, true, "in locale but not in source"));
|
|
1855
|
+
ln(statLine("\u{1F4CB}", "Duplicate Text Groups", ins.duplicateTextGroups, ins.duplicateTextGroups === 0, true, "same text \u2192 multiple keys"));
|
|
1856
|
+
ln(statLine("\u{1F4E6}", "Total Assets", assets.totalAssets, true, false, "static asset references"));
|
|
1857
|
+
ln(statLine("\u2601 ", "Uploaded to CDN", assets.uploadedAssets, true, false, "pushed to S3/CloudFront"));
|
|
1858
|
+
ln(statLine("\u{1F517}", "Legacy CDN URLs", assets.legacyCdnUrls, assets.legacyCdnUrls === 0, true, "old CDN refs pending replace"));
|
|
1859
|
+
ln();
|
|
1860
|
+
ln(hr("\u2500"));
|
|
1861
|
+
const covColor = ins.coveragePct >= 80 ? "brightGreen" : ins.coveragePct >= 50 ? "brightYellow" : "brightRed";
|
|
1862
|
+
const covLabel = ins.coveragePct === 100 ? "\u2713 Fully Covered" : ins.coveragePct >= 80 ? "~ Good Coverage" : "\u2717 Low Coverage";
|
|
1863
|
+
ln(
|
|
1864
|
+
" " + bold("\u{1F3AF} Translation Coverage") + " " + progressBar(ins.coveragePct) + " " + c(covColor, ins.coveragePct + "%") + " " + dim(covLabel)
|
|
1865
|
+
);
|
|
1866
|
+
ln();
|
|
1867
|
+
if (details.detectedTexts.length > 0) {
|
|
1868
|
+
ln(hr("\u2500"));
|
|
1869
|
+
ln(renderSectionHeader("\u{1F50D}", "Hardcoded Texts", hardcodedTexts, true));
|
|
1870
|
+
ln(dim(" Strings detected in source that are not yet wrapped in a translation call."));
|
|
1871
|
+
ln();
|
|
1872
|
+
const maxCount = hardcodedTexts;
|
|
1873
|
+
const topFiles = /* @__PURE__ */ new Map();
|
|
1874
|
+
for (const dt of details.detectedTexts) {
|
|
1875
|
+
const f = dt.filePath.split("/").slice(-2).join("/");
|
|
1876
|
+
topFiles.set(f, (topFiles.get(f) || 0) + 1);
|
|
1877
|
+
}
|
|
1878
|
+
const topFilesSorted = [...topFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
1879
|
+
if (topFilesSorted.length > 0) {
|
|
1880
|
+
ln(" " + bold("Top files with hardcoded text:"));
|
|
1881
|
+
ln();
|
|
1882
|
+
for (const [file, cnt] of topFilesSorted) {
|
|
1883
|
+
ln(" " + padEnd(dim(file), 48) + " " + miniBar(cnt, maxCount, 16) + " " + c("brightYellow", String(cnt)));
|
|
1884
|
+
}
|
|
1885
|
+
ln();
|
|
1886
|
+
}
|
|
1887
|
+
const sample = details.detectedTexts.slice(0, 5);
|
|
1888
|
+
if (sample.length > 0) {
|
|
1889
|
+
ln(" " + bold("Sample detected strings:"));
|
|
1890
|
+
ln();
|
|
1891
|
+
const sampleRows = sample.map((t) => [
|
|
1892
|
+
c("cyan", t.filePath.split("/").slice(-2).join("/")),
|
|
1893
|
+
dim(String(t.line)),
|
|
1894
|
+
c("brightWhite", t.text.length > 50 ? t.text.slice(0, 50) + "\u2026" : t.text),
|
|
1895
|
+
dim(t.context)
|
|
1896
|
+
]);
|
|
1897
|
+
ln(renderTable(
|
|
1898
|
+
[
|
|
1899
|
+
{ header: "File", maxWidth: 36 },
|
|
1900
|
+
{ header: "Line", align: "right", maxWidth: 6 },
|
|
1901
|
+
{ header: "Text", maxWidth: 52 },
|
|
1902
|
+
{ header: "Context", maxWidth: 20 }
|
|
1903
|
+
],
|
|
1904
|
+
sampleRows
|
|
1905
|
+
).split("\n").map((l) => " " + l).join("\n"));
|
|
1906
|
+
if (details.detectedTexts.length > 5) {
|
|
1907
|
+
ln();
|
|
1908
|
+
ln(" " + dim(`\u2026 and ${details.detectedTexts.length - 5} more. Run `) + c("cyan", "ai-localize report") + dim(" for the full HTML report."));
|
|
1909
|
+
}
|
|
1910
|
+
ln();
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
if (missingTranslations > 0) {
|
|
1914
|
+
ln(hr("\u2500"));
|
|
1915
|
+
ln(renderSectionHeader("\u274C", "Missing Translations", missingTranslations));
|
|
1916
|
+
ln(dim(" Keys present in the default language but absent in one or more target language files."));
|
|
1917
|
+
ln();
|
|
1918
|
+
if (ins.topMissingLanguages.length > 0) {
|
|
1919
|
+
const maxMissing = ins.topMissingLanguages[0].count;
|
|
1920
|
+
ln(" " + bold("Missing keys by language:"));
|
|
1921
|
+
ln();
|
|
1922
|
+
for (const { lang, count } of ins.topMissingLanguages) {
|
|
1923
|
+
ln(" " + padEnd(c("brightMagenta", lang), 18) + " " + miniBar(count, maxMissing, 20, "brightRed") + " " + c("brightRed", String(count) + " key" + (count > 1 ? "s" : "")));
|
|
1924
|
+
}
|
|
1925
|
+
ln();
|
|
1926
|
+
}
|
|
1927
|
+
const missSample = details.missingKeys.slice(0, 5);
|
|
1928
|
+
if (missSample.length > 0) {
|
|
1929
|
+
ln(" " + bold("Sample missing keys:"));
|
|
1930
|
+
ln();
|
|
1931
|
+
const missRows = missSample.map((e) => [
|
|
1932
|
+
c("cyan", e.key.length > 42 ? e.key.slice(0, 42) + "\u2026" : e.key),
|
|
1933
|
+
e.language ? c("brightMagenta", e.language) : dim("\u2014"),
|
|
1934
|
+
dim(e.filePath ? e.filePath.split("/").slice(-2).join("/") : "\u2014")
|
|
1935
|
+
]);
|
|
1936
|
+
ln(renderTable(
|
|
1937
|
+
[
|
|
1938
|
+
{ header: "Key", maxWidth: 44 },
|
|
1939
|
+
{ header: "Language", maxWidth: 14 },
|
|
1940
|
+
{ header: "Locale File", maxWidth: 36 }
|
|
1941
|
+
],
|
|
1942
|
+
missRows
|
|
1943
|
+
).split("\n").map((l) => " " + l).join("\n"));
|
|
1944
|
+
if (details.missingKeys.length > 5) {
|
|
1945
|
+
ln();
|
|
1946
|
+
ln(" " + dim(`\u2026 and ${details.missingKeys.length - 5} more.`));
|
|
1947
|
+
}
|
|
1948
|
+
ln();
|
|
1949
|
+
}
|
|
1950
|
+
ln(" " + dim("\u{1F4A1} Tip: Run ") + c("cyan", "ai-localize extract") + dim(" to seed target language files with source values."));
|
|
1951
|
+
ln();
|
|
1952
|
+
} else {
|
|
1953
|
+
ln(hr("\u2500"));
|
|
1954
|
+
ln(" " + dot(true) + " " + bold(c("brightGreen", "Missing Translations")) + " " + c("brightGreen", "All translations present \u2713"));
|
|
1955
|
+
ln();
|
|
1956
|
+
}
|
|
1957
|
+
if (unusedKeys > 0) {
|
|
1958
|
+
ln(hr("\u2500"));
|
|
1959
|
+
ln(renderSectionHeader("\u{1F5D1} ", "Unused Keys", unusedKeys, true));
|
|
1960
|
+
ln(dim(" Translation keys in locale files that are not referenced anywhere in source code."));
|
|
1961
|
+
ln();
|
|
1962
|
+
if (ins.unusedTopKeys.length > 0) {
|
|
1963
|
+
ln(" " + bold("Examples:"));
|
|
1964
|
+
for (const key of ins.unusedTopKeys) {
|
|
1965
|
+
ln(" " + c("yellow", " \u26A0 ") + c("brightYellow", key));
|
|
1966
|
+
}
|
|
1967
|
+
if (unusedKeys > ins.unusedTopKeys.length) {
|
|
1968
|
+
ln(" " + dim(` \u2026 and ${unusedKeys - ins.unusedTopKeys.length} more.`));
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
ln();
|
|
1972
|
+
ln(" " + dim("\u{1F4A1} Tip: Run ") + c("cyan", "ai-localize cleanup") + dim(" to remove unused keys safely."));
|
|
1973
|
+
ln();
|
|
1974
|
+
} else {
|
|
1975
|
+
ln(hr("\u2500"));
|
|
1976
|
+
ln(" " + dot(true) + " " + bold(c("brightGreen", "Unused Keys")) + " " + c("brightGreen", "No unused keys \u2014 locale files are clean \u2713"));
|
|
1977
|
+
ln();
|
|
1978
|
+
}
|
|
1979
|
+
if (ins.topNamespaces.length > 0) {
|
|
1980
|
+
ln(hr("\u2500"));
|
|
1981
|
+
ln(renderSectionHeader("\u{1F4E6}", "Namespace Distribution", ins.namespaceCount));
|
|
1982
|
+
ln(dim(" How locale keys are distributed across namespaces."));
|
|
1983
|
+
ln();
|
|
1984
|
+
const maxNs = ins.topNamespaces[0]?.count ?? 1;
|
|
1985
|
+
for (const { ns, count } of ins.topNamespaces) {
|
|
1986
|
+
ln(" " + padEnd(c("brightBlue", ns), 26) + " " + miniBar(count, maxNs, 18, "blue") + " " + dim(String(count) + " key" + (count > 1 ? "s" : "")));
|
|
1987
|
+
}
|
|
1988
|
+
if (ins.namespaceCount > ins.topNamespaces.length) {
|
|
1989
|
+
ln(" " + dim(` \u2026 and ${ins.namespaceCount - ins.topNamespaces.length} more namespaces.`));
|
|
1990
|
+
}
|
|
1991
|
+
ln();
|
|
1992
|
+
}
|
|
1993
|
+
if (ins.topContexts.length > 0) {
|
|
1994
|
+
ln(hr("\u2500"));
|
|
1995
|
+
ln(renderSectionHeader("\u{1F3F7} ", "Text Context Distribution", void 0));
|
|
1996
|
+
ln(dim(" Where hardcoded strings were detected in the AST."));
|
|
1997
|
+
ln();
|
|
1998
|
+
const maxCtx = ins.topContexts[0]?.count ?? 1;
|
|
1999
|
+
for (const { ctx, count } of ins.topContexts) {
|
|
2000
|
+
ln(" " + padEnd(c("brightMagenta", ctx), 26) + " " + miniBar(count, maxCtx, 18, "magenta") + " " + dim(String(count)));
|
|
2001
|
+
}
|
|
2002
|
+
ln();
|
|
2003
|
+
}
|
|
2004
|
+
if (assets.totalAssets > 0 || assets.legacyCdnUrls > 0) {
|
|
2005
|
+
ln(hr("\u2500"));
|
|
2006
|
+
ln(renderSectionHeader("\u{1F4E6}", "CDN Assets", void 0));
|
|
2007
|
+
ln();
|
|
2008
|
+
ln(renderKeyValueSection([
|
|
2009
|
+
["Total Assets Found", c("brightWhite", fmt(assets.totalAssets))],
|
|
2010
|
+
["Uploaded to S3/CDN", c("brightGreen", fmt(assets.uploadedAssets))],
|
|
2011
|
+
["URLs Replaced", c("brightGreen", fmt(assets.replacedUrls))],
|
|
2012
|
+
["Legacy CDN URLs", assets.legacyCdnUrls > 0 ? c("brightYellow", fmt(assets.legacyCdnUrls)) + dim(" \u2190 run ai-localize replace-cdn") : c("brightGreen", "0")]
|
|
2013
|
+
]));
|
|
2014
|
+
ln();
|
|
2015
|
+
}
|
|
2016
|
+
const hasInsights = ins.duplicateTextGroups > 0 || ins.topMissingLanguages.length > 0 || unusedKeys > 0;
|
|
2017
|
+
ln(hr("\u2500"));
|
|
2018
|
+
ln(bold(c("brightMagenta", " \u{1F916} AI Insights") + dim(" (deterministic analysis)")));
|
|
2019
|
+
ln();
|
|
2020
|
+
if (ins.duplicateTextGroups > 0) {
|
|
2021
|
+
ln(" " + c("brightYellow", "\u26A0 ") + c("yellow", ins.duplicateTextGroups + " duplicate text group" + (ins.duplicateTextGroups > 1 ? "s" : "")) + dim(" \u2014 same string mapped to multiple keys. Consider consolidating."));
|
|
2022
|
+
} else {
|
|
2023
|
+
ln(" " + c("brightGreen", "\u2713 ") + dim("No duplicate texts detected."));
|
|
2024
|
+
}
|
|
2025
|
+
if (missingTranslations > 0) {
|
|
2026
|
+
const langList = ins.topMissingLanguages.slice(0, 3).map((l) => c("brightMagenta", l.lang)).join(", ");
|
|
2027
|
+
ln(" " + c("brightRed", "\u2717 ") + c("red", missingTranslations + " missing translation" + (missingTranslations > 1 ? "s" : "")) + dim(" in: ") + langList + (ins.topMissingLanguages.length > 3 ? dim(" + more") : ""));
|
|
2028
|
+
} else {
|
|
2029
|
+
ln(" " + c("brightGreen", "\u2713 ") + dim("All translations present across all languages."));
|
|
2030
|
+
}
|
|
2031
|
+
if (unusedKeys > 0) {
|
|
2032
|
+
ln(" " + c("brightYellow", "\u26A0 ") + c("yellow", unusedKeys + " unused key" + (unusedKeys > 1 ? "s" : "")) + dim(" bloating your locale bundles."));
|
|
2033
|
+
} else {
|
|
2034
|
+
ln(" " + c("brightGreen", "\u2713 ") + dim("No unused keys \u2014 locale files are lean."));
|
|
2035
|
+
}
|
|
2036
|
+
if (!hasInsights) {
|
|
2037
|
+
ln(" " + c("brightGreen", "\u2713 ") + dim("No issues detected. Your localization is in great shape!"));
|
|
2038
|
+
}
|
|
2039
|
+
ln();
|
|
2040
|
+
ln(hr("\u2550", "cyan"));
|
|
2041
|
+
const covBadge = ins.coveragePct === 100 ? badge2("COVERAGE 100%", true) : ins.coveragePct >= 80 ? c("bgYellow", c("black", ` COVERAGE ${ins.coveragePct}% `)) : badge2(`COVERAGE ${ins.coveragePct}%`, false);
|
|
2042
|
+
const allOk = missingTranslations === 0 && unusedKeys === 0 && hardcodedTexts === 0;
|
|
2043
|
+
const overallBadge = allOk ? badge2(" PASS ", true) : badge2(" ISSUES ", false);
|
|
2044
|
+
ln(centre(overallBadge + " " + covBadge));
|
|
2045
|
+
ln();
|
|
2046
|
+
ln(centre(dim("Generated by ") + bold("ai-localize-core") + dim(" \xB7 deterministic, offline-capable i18n tooling")));
|
|
2047
|
+
ln(hr("\u2550", "cyan"));
|
|
2048
|
+
ln();
|
|
2049
|
+
if (!allOk) {
|
|
2050
|
+
ln(bold(" \u26A1 Recommended next steps:"));
|
|
2051
|
+
ln();
|
|
2052
|
+
if (hardcodedTexts > 0) {
|
|
2053
|
+
ln("" + c("cyan", "1.") + " Run " + c("brightCyan", "ai-localize full-migrate") + dim(" to wrap hardcoded strings with translation calls."));
|
|
2054
|
+
}
|
|
2055
|
+
if (missingTranslations > 0) {
|
|
2056
|
+
ln(" " + c("cyan", "2.") + " Run " + c("brightCyan", "ai-localize extract") + dim(" to seed target language files."));
|
|
2057
|
+
}
|
|
2058
|
+
if (unusedKeys > 0) {
|
|
2059
|
+
ln(" " + c("cyan", "3.") + " Run " + c("brightCyan", "ai-localize cleanup") + dim(" to remove unused keys."));
|
|
2060
|
+
}
|
|
2061
|
+
if (assets.legacyCdnUrls > 0) {
|
|
2062
|
+
ln(" " + c("cyan", "4.") + " Run " + c("brightCyan", "ai-localize replace-cdn") + dim(" to migrate legacy CDN URLs to CloudFront."));
|
|
2063
|
+
}
|
|
2064
|
+
ln();
|
|
2065
|
+
}
|
|
2066
|
+
const output = out.join("\n");
|
|
2067
|
+
process.stdout.write(supportsColor() ? output : strip(output));
|
|
244
2068
|
}
|
|
245
2069
|
// Annotate the CommonJS export names for ESM import in node:
|
|
246
2070
|
0 && (module.exports = {
|