@wads.dev/i18n-editor 0.0.1-beta.0 → 0.0.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81,10 +81,21 @@ var init_en = __esm({
81
81
  updateUsages: "Update usages",
82
82
  loadingUsages: "Loading usages\u2026",
83
83
  updatingUsages: "Updating usages\u2026",
84
- unreferencedOnly: "Unreferenced only",
85
- visualizationLevels: "Visualization levels",
84
+ unreferencedOnly: "Show unreferenced only",
85
+ newKeysOnly: "Show only last changes",
86
+ filters: "Filters",
87
+ markCurrentReviewed: "Mark current keys as reviewed",
88
+ noReviewBaseline: "No review baseline yet.",
89
+ expandAll: "Expand all",
90
+ collapseAll: "Collapse all",
91
+ showToolbar: "Show toolbar",
92
+ hideToolbar: "Hide toolbar",
93
+ reviewSummary: (addedCount, removedCount) => `${addedCount} new and ${removedCount} removed key${addedCount + removedCount === 1 ? "" : "s"} since the last review.`,
94
+ visualizationLevels: "Levels",
95
+ visualizationLevelsHelp: "Choose how many hierarchy levels should be displayed as collapsible translation groups. Level 0 shows a single table; each additional level breaks the table into nested groups without changing the translation structure.",
86
96
  search: "Search",
87
- searchPlaceholder: "Key or translation",
97
+ searchPlaceholder: "Search by key or translation",
98
+ removed: "Removed",
88
99
  key: "Key",
89
100
  usages: "Usages",
90
101
  base: "Base",
@@ -133,6 +144,7 @@ var init_en = __esm({
133
144
  The change will remain in the in-memory bundle until you export it to the project.`,
134
145
  keyRemoved: (key) => `Key ${key} removed from every language. Export to apply the change to the project.`,
135
146
  translationUpdated: (language, key) => `Translation ${language}.${key} updated.`,
147
+ reviewBaselineSaved: (count) => `${count} key${count === 1 ? "" : "s"} marked as reviewed.`,
136
148
  jsonObjectExpected: (field) => `${field} must be a JSON object.`,
137
149
  couldNotSaveSettings: (error) => `Could not save settings: ${error}`,
138
150
  couldNotSaveLocalCopy: (error) => `Could not save the local copy: ${error}`,
@@ -144,12 +156,13 @@ The change will remain in the in-memory bundle until you export it to the projec
144
156
  exported: "Changes exported to the project files.",
145
157
  generatingBundle: "Generating the project bundle\u2026",
146
158
  generatedBundle: "Bundle generated automatically from the project catalog.",
159
+ couldNotGenerateBundle: (error) => `Could not generate the project bundle: ${error}`,
147
160
  restoredBundle: "The newest version was restored from the browser.",
148
161
  loadedBundle: "Project bundle loaded automatically.",
149
162
  newerBundle: "A newer bundle exists in the project. It was loaded and will replace the browser copy.",
150
163
  noUsageCache: "No usage cache was found. Analyzing typed references\u2026",
151
164
  unverifiedUsageCache: "Showing an unverified cached usage report while a fresh analysis runs\u2026",
152
- usageSummary: (used, uncertain, unreferenced, files) => `${used} used keys, ${uncertain} uncertain keys, and ${unreferenced} unreferenced keys across ${files} source files.`,
165
+ usageSummary: (used, uncertain, unreferenced) => `${used} used, ${uncertain} uncertain, and ${unreferenced} unreferenced.`,
153
166
  couldNotAnalyzeUsages: (error) => `Could not analyze usages: ${error}`,
154
167
  plannedChanges: (count) => `${count} file${count === 1 ? "" : "s"} with planned changes.`,
155
168
  projectMatchesPreview: "The project files already match the preview.",
@@ -259,10 +272,21 @@ var init_pt = __esm({
259
272
  updateUsages: "Atualizar usos",
260
273
  loadingUsages: "Carregando usos\u2026",
261
274
  updatingUsages: "Atualizando usos\u2026",
262
- unreferencedOnly: "Somente n\xE3o referenciadas",
263
- visualizationLevels: "N\xEDveis de visualiza\xE7\xE3o",
275
+ unreferencedOnly: "Mostrar somente n\xE3o referenciadas",
276
+ newKeysOnly: "Mostrar somente \xFAltimas altera\xE7\xF5es",
277
+ filters: "Filtros",
278
+ markCurrentReviewed: "Marcar chaves atuais como revisadas",
279
+ noReviewBaseline: "Ainda n\xE3o h\xE1 uma base de revis\xE3o.",
280
+ expandAll: "Expandir todos",
281
+ collapseAll: "Recolher todos",
282
+ showToolbar: "Mostrar barra de ferramentas",
283
+ hideToolbar: "Ocultar barra de ferramentas",
284
+ reviewSummary: (addedCount, removedCount) => `${addedCount} nova${addedCount === 1 ? "" : "s"} e ${removedCount} removida${removedCount === 1 ? "" : "s"} desde a \xFAltima revis\xE3o.`,
285
+ visualizationLevels: "N\xEDveis",
286
+ visualizationLevelsHelp: "Escolha quantos n\xEDveis hier\xE1rquicos devem aparecer como grupos de tradu\xE7\xF5es colaps\xE1veis. O n\xEDvel 0 mostra uma \xFAnica tabela; cada n\xEDvel adicional quebra a tabela em grupos aninhados, sem alterar a estrutura das tradu\xE7\xF5es.",
264
287
  search: "Pesquisar",
265
- searchPlaceholder: "Chave ou tradu\xE7\xE3o",
288
+ searchPlaceholder: "Pesquisar por chave ou tradu\xE7\xE3o",
289
+ removed: "Removida",
266
290
  key: "Chave",
267
291
  usages: "Usos",
268
292
  base: "Base",
@@ -311,6 +335,7 @@ var init_pt = __esm({
311
335
  A altera\xE7\xE3o permanecer\xE1 na bundle em mem\xF3ria at\xE9 que voc\xEA a exporte para o projeto.`,
312
336
  keyRemoved: (key) => `Chave ${key} removida de todos os idiomas. Exporte para aplicar a altera\xE7\xE3o ao projeto.`,
313
337
  translationUpdated: (language, key) => `Tradu\xE7\xE3o ${language}.${key} atualizada.`,
338
+ reviewBaselineSaved: (count) => `${count} chave${count === 1 ? "" : "s"} marcada${count === 1 ? "" : "s"} como revisada${count === 1 ? "" : "s"}.`,
314
339
  jsonObjectExpected: (field) => `${field} deve ser um objeto JSON.`,
315
340
  couldNotSaveSettings: (error) => `N\xE3o foi poss\xEDvel salvar as configura\xE7\xF5es: ${error}`,
316
341
  couldNotSaveLocalCopy: (error) => `N\xE3o foi poss\xEDvel salvar a c\xF3pia local: ${error}`,
@@ -322,12 +347,13 @@ A altera\xE7\xE3o permanecer\xE1 na bundle em mem\xF3ria at\xE9 que voc\xEA a ex
322
347
  exported: "Altera\xE7\xF5es exportadas para os arquivos do projeto.",
323
348
  generatingBundle: "Gerando a bundle do projeto\u2026",
324
349
  generatedBundle: "Bundle gerada automaticamente a partir do cat\xE1logo do projeto.",
350
+ couldNotGenerateBundle: (error) => `N\xE3o foi poss\xEDvel gerar a bundle do projeto: ${error}`,
325
351
  restoredBundle: "A vers\xE3o mais recente foi restaurada do navegador.",
326
352
  loadedBundle: "Bundle do projeto carregada automaticamente.",
327
353
  newerBundle: "Existe uma bundle mais recente no projeto. Ela foi carregada e substituir\xE1 a c\xF3pia do navegador.",
328
354
  noUsageCache: "Nenhum cache de usos foi encontrado. Analisando refer\xEAncias tipadas\u2026",
329
355
  unverifiedUsageCache: "Exibindo um cache de usos n\xE3o verificado enquanto uma nova an\xE1lise \xE9 executada\u2026",
330
- usageSummary: (used, uncertain, unreferenced, files) => `${used} chaves usadas, ${uncertain} incertas e ${unreferenced} n\xE3o referenciadas em ${files} arquivos de c\xF3digo-fonte.`,
356
+ usageSummary: (used, uncertain, unreferenced) => `${used} usadas, ${uncertain} incertas e ${unreferenced} n\xE3o referenciadas.`,
331
357
  couldNotAnalyzeUsages: (error) => `N\xE3o foi poss\xEDvel analisar os usos: ${error}`,
332
358
  plannedChanges: (count) => `${count} arquivo${count === 1 ? "" : "s"} com altera\xE7\xF5es planejadas.`,
333
359
  projectMatchesPreview: "Os arquivos do projeto j\xE1 correspondem \xE0 pr\xE9via.",
@@ -405,12 +431,64 @@ function withUpdatedAt(bundle, updatedAt = Date.now()) {
405
431
  };
406
432
  }
407
433
 
434
+ // src/web/review-baseline.ts
435
+ var REVIEW_BASELINE_FORMAT = "wads-i18n-review-baseline";
436
+ var REVIEW_BASELINE_VERSION = 1;
437
+ function isFunctionDescriptor(value) {
438
+ return value !== null && typeof value === "object" && value.$type === "function";
439
+ }
440
+ function appendSegment(keyPath, segment) {
441
+ return segment.startsWith("[") ? `${keyPath}${segment}` : keyPath ? `${keyPath}.${segment}` : segment;
442
+ }
443
+ function collectTranslationKeys(value, keyPath = "", keys = /* @__PURE__ */ new Set()) {
444
+ if (isFunctionDescriptor(value)) {
445
+ keys.add(keyPath);
446
+ return keys;
447
+ }
448
+ if (Array.isArray(value)) {
449
+ value.forEach((item, index) => collectTranslationKeys(item, appendSegment(keyPath, `[${index}]`), keys));
450
+ return keys;
451
+ }
452
+ if (value !== null && typeof value === "object") {
453
+ Object.entries(value).forEach(([key, nestedValue]) => {
454
+ collectTranslationKeys(nestedValue, appendSegment(keyPath, key), keys);
455
+ });
456
+ return keys;
457
+ }
458
+ if (keyPath) keys.add(keyPath);
459
+ return keys;
460
+ }
461
+ function getBundleTranslationKeys(bundle) {
462
+ const keys = /* @__PURE__ */ new Set();
463
+ Object.values(bundle.languages).forEach(({ translations }) => collectTranslationKeys(translations, "", keys));
464
+ return [...keys].sort((left, right) => left.localeCompare(right));
465
+ }
466
+ function createReviewBaseline(bundle) {
467
+ return {
468
+ format: REVIEW_BASELINE_FORMAT,
469
+ version: REVIEW_BASELINE_VERSION,
470
+ createdAt: Date.now(),
471
+ keys: getBundleTranslationKeys(bundle)
472
+ };
473
+ }
474
+ function getNewReviewKeys(bundle, baseline) {
475
+ if (!baseline) return [];
476
+ const reviewedKeys = new Set(baseline.keys);
477
+ return getBundleTranslationKeys(bundle).filter((key) => !reviewedKeys.has(key));
478
+ }
479
+ function getRemovedReviewKeys(bundle, baseline) {
480
+ if (!baseline) return [];
481
+ const currentKeys = new Set(getBundleTranslationKeys(bundle));
482
+ return baseline.keys.filter((key) => !currentKeys.has(key));
483
+ }
484
+
408
485
  // src/web/bundle-storage.ts
409
486
  var DATABASE_NAME = "wads-i18n-editor";
410
487
  var DATABASE_VERSION = 1;
411
488
  var STORE_NAME = "bundles";
412
489
  var CURRENT_BUNDLE_KEY = "current";
413
490
  var PROJECT_CONFIG_KEY = "project-config";
491
+ var REVIEW_BASELINE_KEY = "review-baseline";
414
492
  var LEGACY_VISUALIZATION_CONFIG_KEY = "visualization-config";
415
493
  function projectKey(projectDirectory, key) {
416
494
  return `${key}:${projectDirectory}`;
@@ -486,6 +564,34 @@ async function saveStoredProjectConfig(projectDirectory, config) {
486
564
  database.close();
487
565
  }
488
566
  }
567
+ async function loadStoredReviewBaseline(projectDirectory) {
568
+ const database = await openDatabase();
569
+ try {
570
+ const transaction = database.transaction(STORE_NAME, "readonly");
571
+ const record = await requestResult(
572
+ transaction.objectStore(STORE_NAME).get(projectKey(projectDirectory, REVIEW_BASELINE_KEY))
573
+ );
574
+ await transactionComplete(transaction);
575
+ const baseline = record?.baseline;
576
+ if (!baseline || baseline.format !== REVIEW_BASELINE_FORMAT || baseline.version !== REVIEW_BASELINE_VERSION || !Array.isArray(baseline.keys)) return null;
577
+ return baseline;
578
+ } finally {
579
+ database.close();
580
+ }
581
+ }
582
+ async function saveStoredReviewBaseline(projectDirectory, baseline) {
583
+ const database = await openDatabase();
584
+ try {
585
+ const transaction = database.transaction(STORE_NAME, "readwrite");
586
+ await requestResult(transaction.objectStore(STORE_NAME).put({
587
+ id: projectKey(projectDirectory, REVIEW_BASELINE_KEY),
588
+ baseline
589
+ }));
590
+ await transactionComplete(transaction);
591
+ } finally {
592
+ database.close();
593
+ }
594
+ }
489
595
 
490
596
  // src/core/keyPath.ts
491
597
  function isContainer(value) {
@@ -925,11 +1031,11 @@ function getTranslationOwnerChain(fullKey, config) {
925
1031
  void consumedSegments;
926
1032
  return owners;
927
1033
  }
928
- function isFunctionDescriptor(value) {
1034
+ function isFunctionDescriptor2(value) {
929
1035
  return value !== null && typeof value === "object" && "$type" in value && value.$type === "function";
930
1036
  }
931
- function collectTranslationKeys(value, path = "", keys = []) {
932
- if (isFunctionDescriptor(value)) {
1037
+ function collectTranslationKeys2(value, path = "", keys = []) {
1038
+ if (isFunctionDescriptor2(value)) {
933
1039
  keys.push(path);
934
1040
  return keys;
935
1041
  }
@@ -939,7 +1045,7 @@ function collectTranslationKeys(value, path = "", keys = []) {
939
1045
  }
940
1046
  if (value && typeof value === "object") {
941
1047
  Object.entries(value).forEach(([key, nestedValue]) => {
942
- collectTranslationKeys(nestedValue, path ? `${path}.${key}` : key, keys);
1048
+ collectTranslationKeys2(nestedValue, path ? `${path}.${key}` : key, keys);
943
1049
  });
944
1050
  return keys;
945
1051
  }
@@ -952,7 +1058,7 @@ function buildTranslationOwners(bundle, config) {
952
1058
  const owners = /* @__PURE__ */ new Map();
953
1059
  const referenceLanguage = Object.values(validBundle.languages)[0];
954
1060
  if (referenceLanguage) {
955
- collectTranslationKeys(referenceLanguage.translations).forEach((key) => {
1061
+ collectTranslationKeys2(referenceLanguage.translations).forEach((key) => {
956
1062
  getTranslationOwnerChain(key, validConfig).forEach((owner) => {
957
1063
  const existing = owners.get(owner.directory);
958
1064
  if (existing && existing.keyPath !== owner.keyPath) {
@@ -1182,7 +1288,7 @@ function getDisplayLevelName2(config, level) {
1182
1288
  const configured = getEditorLevelName(config, level);
1183
1289
  return configured === `Level ${level}` ? Lang.common.level(level) : configured;
1184
1290
  }
1185
- function isFunctionDescriptor2(value) {
1291
+ function isFunctionDescriptor3(value) {
1186
1292
  return value && typeof value === "object" && value.$type === "function";
1187
1293
  }
1188
1294
  function splitFunctionSource(source) {
@@ -1202,21 +1308,21 @@ function describeBase(entries) {
1202
1308
  }
1203
1309
  return { label: Lang.editor.inconsistent, type: "inconsistent" };
1204
1310
  }
1205
- function appendSegment(path, segment) {
1311
+ function appendSegment2(path, segment) {
1206
1312
  return segment.startsWith("[") ? `${path}${segment}` : path ? `${path}.${segment}` : segment;
1207
1313
  }
1208
1314
  function flatten(value, path = "", entries = /* @__PURE__ */ new Map()) {
1209
- if (isFunctionDescriptor2(value)) {
1315
+ if (isFunctionDescriptor3(value)) {
1210
1316
  const { signature, body } = splitFunctionSource(value.source);
1211
1317
  entries.set(path, { text: body, type: "function", signature });
1212
1318
  return entries;
1213
1319
  }
1214
1320
  if (Array.isArray(value)) {
1215
- value.forEach((item, index) => flatten(item, appendSegment(path, `[${index}]`), entries));
1321
+ value.forEach((item, index) => flatten(item, appendSegment2(path, `[${index}]`), entries));
1216
1322
  return entries;
1217
1323
  }
1218
1324
  if (value && typeof value === "object") {
1219
- Object.entries(value).forEach(([key, nestedValue]) => flatten(nestedValue, appendSegment(path, key), entries));
1325
+ Object.entries(value).forEach(([key, nestedValue]) => flatten(nestedValue, appendSegment2(path, key), entries));
1220
1326
  return entries;
1221
1327
  }
1222
1328
  entries.set(path, { text: value == null ? "" : String(value), type: "string" });
@@ -1352,18 +1458,27 @@ function beginTextEdit(cell, originalValue, onCommit) {
1352
1458
  input.focus();
1353
1459
  input.select();
1354
1460
  }
1355
- function createRows(bundle) {
1461
+ function createRows(bundle, removedKeys = []) {
1356
1462
  const languages = Object.entries(bundle.languages);
1357
1463
  const flattenedTranslations = Object.fromEntries(
1358
1464
  languages.map(([key, language]) => [key, flatten(language.translations)])
1359
1465
  );
1360
1466
  const keys = [...new Set(Object.values(flattenedTranslations).flatMap((entries) => [...entries.keys()]))].sort((left, right) => left.localeCompare(right));
1467
+ const currentRows = keys.map((fullKey) => ({
1468
+ fullKey,
1469
+ removed: false,
1470
+ languageEntries: languages.map(([languageKey]) => flattenedTranslations[languageKey].get(fullKey))
1471
+ }));
1472
+ const currentKeySet = new Set(keys);
1473
+ const removedRows = removedKeys.filter((fullKey) => !currentKeySet.has(fullKey)).sort((left, right) => left.localeCompare(right)).map((fullKey) => ({
1474
+ fullKey,
1475
+ removed: true,
1476
+ languageEntries: languages.map(() => void 0)
1477
+ }));
1361
1478
  return {
1362
1479
  languages,
1363
- rows: keys.map((fullKey) => ({
1364
- fullKey,
1365
- languageEntries: languages.map(([languageKey]) => flattenedTranslations[languageKey].get(fullKey))
1366
- }))
1480
+ rows: [...currentRows, ...removedRows],
1481
+ currentKeyCount: currentRows.length
1367
1482
  };
1368
1483
  }
1369
1484
  function createGroupTree(rows, levelCount) {
@@ -1413,23 +1528,26 @@ function createTranslationTable(document2, languages, rows, depth, { onMoveKey,
1413
1528
  const body = table.createTBody();
1414
1529
  rows.forEach((rowData) => {
1415
1530
  const row = body.insertRow();
1416
- const { fullKey, languageEntries } = rowData;
1531
+ const { fullKey, languageEntries, removed } = rowData;
1532
+ if (removed) row.classList.add("review-removed-row");
1533
+ row.dataset.translationKey = fullKey;
1534
+ row.dataset.reviewStatus = removed ? "removed" : "current";
1417
1535
  row.dataset.search = [
1418
1536
  fullKey,
1419
1537
  ...languageEntries.map((entry) => entry?.text ?? "")
1420
1538
  ].join(" ").toLocaleLowerCase();
1421
- row.dataset.usageStatus = usageReport?.entries[fullKey]?.status || "not-analyzed";
1539
+ row.dataset.usageStatus = removed ? "removed" : usageReport?.entries[fullKey]?.status || "not-analyzed";
1422
1540
  const keyCell = document2.createElement("td");
1423
1541
  keyCell.className = "key";
1424
1542
  const keyContent = document2.createElement("div");
1425
1543
  keyContent.className = "key-content";
1426
1544
  const keyLabel = document2.createElement("span");
1427
1545
  keyLabel.textContent = getRelativeKey(fullKey, depth);
1428
- if (onMoveKey) {
1546
+ if (!removed && onMoveKey) {
1429
1547
  keyCell.title = Lang.editor.doubleClickMove;
1430
1548
  keyCell.addEventListener("dblclick", () => onMoveKey(fullKey));
1431
1549
  }
1432
- if (onRemoveKey) {
1550
+ if (!removed && onRemoveKey) {
1433
1551
  const removeButton = document2.createElement("button");
1434
1552
  removeButton.type = "button";
1435
1553
  removeButton.className = [
@@ -1449,12 +1567,12 @@ function createTranslationTable(document2, languages, rows, depth, { onMoveKey,
1449
1567
  keyContent.append(keyLabel);
1450
1568
  keyCell.append(keyContent);
1451
1569
  row.append(keyCell);
1452
- row.append(createUsageCell(document2, usageReport?.entries[fullKey]));
1570
+ row.append(removed ? createCell(document2, Lang.editor.removed, "usage-cell review-removed-value") : createUsageCell(document2, usageReport?.entries[fullKey]));
1453
1571
  languages.forEach(([languageKey], index) => {
1454
1572
  const entry = languageEntries[index];
1455
1573
  const className = ["language-value", entry?.type === "function" ? "function-value" : ""].filter(Boolean).join(" ");
1456
- const valueCell = createCell(document2, entry?.text ?? Lang.editor.missing, className);
1457
- if (entry?.type === "string" && onEditValue) {
1574
+ const valueCell = createCell(document2, entry?.text ?? (removed ? Lang.editor.removed : Lang.editor.missing), className);
1575
+ if (!removed && entry?.type === "string" && onEditValue) {
1458
1576
  valueCell.title = Lang.editor.doubleClickEdit;
1459
1577
  valueCell.addEventListener("dblclick", () => {
1460
1578
  beginTextEdit(valueCell, entry.text, (value) => onEditValue({
@@ -1466,7 +1584,7 @@ function createTranslationTable(document2, languages, rows, depth, { onMoveKey,
1466
1584
  }
1467
1585
  row.append(valueCell);
1468
1586
  });
1469
- const base = describeBase(languageEntries);
1587
+ const base = removed ? { label: Lang.editor.removed, type: "removed" } : describeBase(languageEntries);
1470
1588
  row.append(createCell(document2, base.label, `base-value base-${base.type}`));
1471
1589
  });
1472
1590
  return table;
@@ -1513,16 +1631,16 @@ function appendTableGroup(document2, container, node, languages, callbacks, isRo
1513
1631
  }
1514
1632
  [...node.children.values()].sort((left, right) => left.name.localeCompare(right.name)).forEach((child) => appendTableGroup(document2, container, child, languages, callbacks));
1515
1633
  }
1516
- function renderTranslationTables(container, bundle, { projectConfig: projectConfig2, usageReport, onMoveKey, onRemoveKey, onEditValue }) {
1634
+ function renderTranslationTables(container, bundle, { projectConfig: projectConfig2, usageReport, onMoveKey, onRemoveKey, onEditValue, removedKeys }) {
1517
1635
  activeUsagePopover?.close();
1518
1636
  const document2 = container.ownerDocument;
1519
- const { languages, rows } = createRows(bundle);
1637
+ const { languages, rows, currentKeyCount } = createRows(bundle, removedKeys);
1520
1638
  const normalizedLevelCount = Math.max(0, Number.isInteger(projectConfig2?.levelCount) ? projectConfig2.levelCount : 0);
1521
1639
  const tree = createGroupTree(rows, normalizedLevelCount);
1522
1640
  const fragment = document2.createDocumentFragment();
1523
1641
  appendTableGroup(document2, fragment, tree, languages, { projectConfig: projectConfig2, usageReport, onMoveKey, onRemoveKey, onEditValue }, true);
1524
1642
  container.replaceChildren(fragment);
1525
- return rows.length;
1643
+ return currentKeyCount;
1526
1644
  }
1527
1645
 
1528
1646
  // src/web/view.ts
@@ -1532,12 +1650,17 @@ function createEditorView({ emptyState, tableWrap, tableContainer, summary, sear
1532
1650
  let projectConfig2 = createDefaultEditorProjectConfig();
1533
1651
  let usageReport = null;
1534
1652
  let showUnreferenced = false;
1653
+ let showNewKeys = false;
1654
+ let newKeys = /* @__PURE__ */ new Set();
1655
+ let removedKeys = /* @__PURE__ */ new Set();
1535
1656
  function applyFilters() {
1536
1657
  const query = search.value.trim().toLocaleLowerCase();
1537
1658
  currentRows.forEach((row) => {
1538
1659
  const matchesSearch = query === "" || row.dataset.search.includes(query);
1539
1660
  const matchesUsage = !showUnreferenced || row.dataset.usageStatus === "unreferenced";
1540
- row.hidden = !matchesSearch || !matchesUsage;
1661
+ const isRemoved = row.dataset.reviewStatus === "removed";
1662
+ const matchesReview = showNewKeys ? newKeys.has(row.dataset.translationKey) || removedKeys.has(row.dataset.translationKey) : !isRemoved;
1663
+ row.hidden = !matchesSearch || !matchesUsage || !matchesReview;
1541
1664
  });
1542
1665
  tableContainer.querySelectorAll(".table-group, .translation-group").forEach((group) => {
1543
1666
  group.hidden = group.querySelector("tbody tr:not([hidden])") === null;
@@ -1558,6 +1681,7 @@ function createEditorView({ emptyState, tableWrap, tableContainer, summary, sear
1558
1681
  const keyCount = renderTranslationTables(tableContainer, bundle, {
1559
1682
  projectConfig: projectConfig2,
1560
1683
  usageReport,
1684
+ removedKeys: [...removedKeys],
1561
1685
  onMoveKey,
1562
1686
  onRemoveKey,
1563
1687
  onEditValue
@@ -1579,6 +1703,23 @@ function createEditorView({ emptyState, tableWrap, tableContainer, summary, sear
1579
1703
  setShowUnreferenced(value) {
1580
1704
  showUnreferenced = value;
1581
1705
  applyFilters();
1706
+ },
1707
+ setReviewKeys(keys, removed) {
1708
+ newKeys = new Set(keys);
1709
+ removedKeys = new Set(removed);
1710
+ if (currentBundle) this.render(currentBundle);
1711
+ },
1712
+ setShowNewKeys(value) {
1713
+ showNewKeys = value;
1714
+ applyFilters();
1715
+ },
1716
+ toggleAllGroups() {
1717
+ const groups = [...tableContainer.querySelectorAll("details.translation-group")];
1718
+ const expand = groups.some((group) => !group.open);
1719
+ groups.forEach((group) => {
1720
+ group.open = expand;
1721
+ });
1722
+ return expand;
1582
1723
  }
1583
1724
  };
1584
1725
  }
@@ -1672,6 +1813,17 @@ function translateStaticDocument() {
1672
1813
  }
1673
1814
  translateStaticDocument();
1674
1815
  var state = createEditorState();
1816
+ var REVIEW_FILTER_STORAGE_KEY = "@wads.dev/i18n-editor/show-new-keys";
1817
+ var TOOLBAR_VISIBILITY_STORAGE_KEY = "@wads.dev/i18n-editor/translation-toolbar-visible";
1818
+ function getReviewFilterStorageKey(projectDirectory) {
1819
+ return `${REVIEW_FILTER_STORAGE_KEY}:${projectDirectory}`;
1820
+ }
1821
+ function loadShowNewKeysPreference(projectDirectory) {
1822
+ return localStorage.getItem(getReviewFilterStorageKey(projectDirectory)) === "true";
1823
+ }
1824
+ function saveShowNewKeysPreference(projectDirectory, value) {
1825
+ localStorage.setItem(getReviewFilterStorageKey(projectDirectory), String(value));
1826
+ }
1675
1827
  function getElement(selector) {
1676
1828
  const element = document.querySelector(selector);
1677
1829
  if (!element) throw new Error(`Required editor element not found: ${selector}`);
@@ -1710,6 +1862,8 @@ var elements = {
1710
1862
  summary: getElement("#bundle-summary"),
1711
1863
  settingsPanel: getElement("#settings-panel"),
1712
1864
  tableWrap: getElement("#table-wrap"),
1865
+ translationToolbar: getElement("#translation-toolbar"),
1866
+ toggleTranslationToolbar: getElement("#toggle-translation-toolbar"),
1713
1867
  toggleSettingsPanel: getElement("#toggle-settings-panel"),
1714
1868
  projectStatus: getElement("#project-status"),
1715
1869
  projectStatusText: getElement("#project-status-text"),
@@ -1717,15 +1871,33 @@ var elements = {
1717
1871
  analyzeUsage: getElement("#analyze-usage"),
1718
1872
  language: getElement("#editor-language"),
1719
1873
  showUnreferenced: getElement("#show-unreferenced"),
1874
+ showNewKeys: getElement("#show-new-keys"),
1875
+ newKeyCount: getElement("#new-key-count"),
1876
+ markCurrentReviewed: getElement("#mark-current-reviewed"),
1877
+ toggleAllGroups: getElement("#toggle-all-groups"),
1878
+ reviewSummary: getElement("#review-summary"),
1720
1879
  visualLevelCount: getElement("#visual-level-count")
1721
1880
  };
1881
+ function setIconButtonLabel(button, label) {
1882
+ button.title = label;
1883
+ button.setAttribute("aria-label", label);
1884
+ }
1885
+ function setAllGroupsToggleState(expanded) {
1886
+ const label = expanded ? Lang.editor.collapseAll : Lang.editor.expandAll;
1887
+ setIconButtonLabel(elements.toggleAllGroups, label);
1888
+ elements.toggleAllGroups.setAttribute("aria-pressed", String(expanded));
1889
+ elements.toggleAllGroups.classList.toggle("is-expanded", expanded);
1890
+ }
1722
1891
  var settingsPanelVisible = false;
1892
+ var translationToolbarVisible = localStorage.getItem(TOOLBAR_VISIBILITY_STORAGE_KEY) !== "false";
1723
1893
  var projectConfig = createDefaultEditorProjectConfig();
1724
1894
  var currentProjectDirectory = "";
1895
+ var canGenerateProjectBundle = false;
1725
1896
  var checkedExportChanges = null;
1726
1897
  var exportPreviewRequestVersion = 0;
1727
1898
  var visualLevelCountOverride = null;
1728
1899
  var currentUsageReport = null;
1900
+ var reviewBaseline = null;
1729
1901
  function replaceUsageReport(report) {
1730
1902
  currentUsageReport = report;
1731
1903
  view.setUsageReport(report);
@@ -1753,6 +1925,30 @@ function removeUsageKey(keyToRemove) {
1753
1925
  }));
1754
1926
  replaceUsageReport({ ...currentUsageReport, entries });
1755
1927
  }
1928
+ function updateReviewControls(bundle = state.getBundle()) {
1929
+ const newKeys = bundle ? getNewReviewKeys(bundle, reviewBaseline) : [];
1930
+ const removedKeys = bundle ? getRemovedReviewKeys(bundle, reviewBaseline) : [];
1931
+ view.setReviewKeys(newKeys, removedKeys);
1932
+ elements.markCurrentReviewed.disabled = !bundle;
1933
+ elements.toggleAllGroups.disabled = !bundle;
1934
+ if (!bundle) setAllGroupsToggleState(false);
1935
+ elements.showNewKeys.disabled = !bundle || reviewBaseline === null;
1936
+ elements.newKeyCount.textContent = reviewBaseline ? String(newKeys.length + removedKeys.length) : "";
1937
+ if (!bundle) {
1938
+ elements.showNewKeys.checked = false;
1939
+ view.setShowNewKeys(false);
1940
+ elements.reviewSummary.textContent = "";
1941
+ return;
1942
+ }
1943
+ if (!reviewBaseline) {
1944
+ elements.showNewKeys.checked = false;
1945
+ view.setShowNewKeys(false);
1946
+ elements.reviewSummary.textContent = Lang.editor.noReviewBaseline;
1947
+ return;
1948
+ }
1949
+ view.setShowNewKeys(elements.showNewKeys.checked);
1950
+ elements.reviewSummary.textContent = Lang.editor.reviewSummary(newKeys.length, removedKeys.length);
1951
+ }
1756
1952
  function renderCurrentExportPreview() {
1757
1953
  renderExportPreview(elements.exportPreview, state.getBundle(), projectConfig, checkedExportChanges);
1758
1954
  }
@@ -1769,7 +1965,7 @@ function invalidateExportPreview() {
1769
1965
  function invalidateUsageAnalysis(message = "") {
1770
1966
  view.setUsageReport(currentUsageReport);
1771
1967
  elements.analyzeUsage.disabled = !state.getBundle();
1772
- elements.analyzeUsage.textContent = currentUsageReport ? Lang.editor.updateUsages : Lang.editor.analyzeUsages;
1968
+ setIconButtonLabel(elements.analyzeUsage, currentUsageReport ? Lang.editor.updateUsages : Lang.editor.analyzeUsages);
1773
1969
  if (message) setFeedback(message);
1774
1970
  }
1775
1971
  var view = createEditorView({
@@ -1824,6 +2020,14 @@ function setSettingsPanelVisibility(visible) {
1824
2020
  );
1825
2021
  elements.toggleSettingsPanel.title = elements.toggleSettingsPanel.getAttribute("aria-label");
1826
2022
  }
2023
+ function setTranslationToolbarVisibility(visible, persist = true) {
2024
+ translationToolbarVisible = visible;
2025
+ elements.translationToolbar.hidden = !visible;
2026
+ elements.toggleTranslationToolbar.setAttribute("aria-expanded", String(visible));
2027
+ setIconButtonLabel(elements.toggleTranslationToolbar, visible ? Lang.editor.hideToolbar : Lang.editor.showToolbar);
2028
+ elements.toggleTranslationToolbar.classList.toggle("toolbar-collapsed", !visible);
2029
+ if (persist) localStorage.setItem(TOOLBAR_VISIBILITY_STORAGE_KEY, String(visible));
2030
+ }
1827
2031
  var projectConfigQueue = Promise.resolve();
1828
2032
  function parseJsonObject(value, fieldName) {
1829
2033
  const parsed = JSON.parse(value);
@@ -1893,6 +2097,8 @@ function persistBundle(bundle) {
1893
2097
  }
1894
2098
  state.subscribe((bundle) => {
1895
2099
  view.render(bundle);
2100
+ setAllGroupsToggleState(false);
2101
+ updateReviewControls(bundle);
1896
2102
  invalidateExportPreview();
1897
2103
  invalidateUsageAnalysis();
1898
2104
  if (bundle) persistBundle(bundle);
@@ -1901,13 +2107,19 @@ view.render(state.getBundle());
1901
2107
  elements.toggleSettingsPanel.addEventListener("click", () => {
1902
2108
  setSettingsPanelVisibility(!settingsPanelVisible);
1903
2109
  });
2110
+ elements.toggleTranslationToolbar.addEventListener("click", () => {
2111
+ setTranslationToolbarVisibility(!translationToolbarVisible);
2112
+ });
1904
2113
  elements.language.value = globalThis.CurrentLanguage;
1905
2114
  elements.language.addEventListener("change", async () => {
1906
2115
  await setLanguage(elements.language.value);
1907
2116
  translateStaticDocument();
2117
+ setTranslationToolbarVisibility(translationToolbarVisible, false);
1908
2118
  setSettingsPanelVisibility(settingsPanelVisible);
1909
2119
  applyProjectConfig(projectConfig, false);
1910
2120
  view.render(state.getBundle());
2121
+ setAllGroupsToggleState(false);
2122
+ updateReviewControls();
1911
2123
  invalidateExportPreview();
1912
2124
  invalidateUsageAnalysis();
1913
2125
  });
@@ -1916,7 +2128,7 @@ async function loadUsageAnalysis(wait) {
1916
2128
  if (!bundle) return;
1917
2129
  const config = projectConfig;
1918
2130
  elements.analyzeUsage.disabled = true;
1919
- elements.analyzeUsage.textContent = wait ? Lang.editor.updatingUsages : Lang.editor.loadingUsages;
2131
+ setIconButtonLabel(elements.analyzeUsage, wait ? Lang.editor.updatingUsages : Lang.editor.loadingUsages);
1920
2132
  try {
1921
2133
  const cached = await analyzeProjectUsage(bundle, config, wait);
1922
2134
  if (state.getBundle() !== bundle || projectConfig !== config) return;
@@ -1933,20 +2145,54 @@ async function loadUsageAnalysis(wait) {
1933
2145
  const used = usages.filter(({ status }) => status === "used").length;
1934
2146
  const uncertain = usages.filter(({ status }) => status === "uncertain").length;
1935
2147
  const unreferenced = usages.filter(({ status }) => status === "unreferenced").length;
1936
- setFeedback(Lang.messages.usageSummary(used, uncertain, unreferenced, report.sourceFileCount));
2148
+ setFeedback(Lang.messages.usageSummary(used, uncertain, unreferenced));
1937
2149
  } catch (error) {
1938
2150
  setFeedback(Lang.messages.couldNotAnalyzeUsages(error instanceof Error ? error.message : String(error)), true);
1939
2151
  } finally {
1940
2152
  elements.analyzeUsage.disabled = !state.getBundle();
1941
- elements.analyzeUsage.textContent = currentUsageReport ? Lang.editor.updateUsages : Lang.editor.analyzeUsages;
2153
+ setIconButtonLabel(elements.analyzeUsage, currentUsageReport ? Lang.editor.updateUsages : Lang.editor.analyzeUsages);
1942
2154
  }
1943
2155
  }
1944
2156
  elements.analyzeUsage.addEventListener("click", async () => {
2157
+ const bundleBeforeRefresh = state.getBundle();
2158
+ if (bundleBeforeRefresh && canGenerateProjectBundle) {
2159
+ elements.analyzeUsage.disabled = true;
2160
+ setIconButtonLabel(elements.analyzeUsage, Lang.messages.generatingBundle);
2161
+ await refreshBundleFromProject(bundleBeforeRefresh);
2162
+ }
1945
2163
  await loadUsageAnalysis(true);
1946
2164
  });
1947
2165
  elements.showUnreferenced.addEventListener("change", () => {
1948
2166
  view.setShowUnreferenced(elements.showUnreferenced.checked);
1949
2167
  });
2168
+ elements.showNewKeys.addEventListener("change", () => {
2169
+ view.setShowNewKeys(elements.showNewKeys.checked);
2170
+ if (!currentProjectDirectory) return;
2171
+ try {
2172
+ saveShowNewKeysPreference(currentProjectDirectory, elements.showNewKeys.checked);
2173
+ } catch (error) {
2174
+ setFeedback(Lang.messages.couldNotSaveLocalCopy(error instanceof Error ? error.message : String(error)), true);
2175
+ }
2176
+ });
2177
+ elements.markCurrentReviewed.addEventListener("click", async () => {
2178
+ const bundle = state.getBundle();
2179
+ if (!bundle || !currentProjectDirectory) return;
2180
+ const nextBaseline = createReviewBaseline(bundle);
2181
+ elements.markCurrentReviewed.disabled = true;
2182
+ try {
2183
+ await saveStoredReviewBaseline(currentProjectDirectory, nextBaseline);
2184
+ reviewBaseline = nextBaseline;
2185
+ updateReviewControls(bundle);
2186
+ setFeedback(Lang.messages.reviewBaselineSaved(nextBaseline.keys.length));
2187
+ } catch (error) {
2188
+ setFeedback(Lang.messages.couldNotSaveLocalCopy(error instanceof Error ? error.message : String(error)), true);
2189
+ } finally {
2190
+ elements.markCurrentReviewed.disabled = !state.getBundle();
2191
+ }
2192
+ });
2193
+ elements.toggleAllGroups.addEventListener("click", () => {
2194
+ setAllGroupsToggleState(view.toggleAllGroups());
2195
+ });
1950
2196
  elements.visualLevelCount.addEventListener("input", () => {
1951
2197
  const parsed = Number.parseInt(elements.visualLevelCount.value, 10);
1952
2198
  visualLevelCountOverride = Number.isFinite(parsed) ? Math.min(12, Math.max(0, parsed)) : 0;
@@ -2218,37 +2464,62 @@ function setProjectStatus(message, isError = false) {
2218
2464
  function hideProjectStatus() {
2219
2465
  elements.projectStatus.hidden = true;
2220
2466
  }
2467
+ async function refreshBundleFromProject(bundleBeforeRefresh, refreshUsage = false) {
2468
+ if (!canGenerateProjectBundle) return false;
2469
+ try {
2470
+ const generatedBundle = (await generateProjectBundle()).bundle;
2471
+ if (state.getBundle() !== bundleBeforeRefresh) return false;
2472
+ state.replaceBundle(generatedBundle);
2473
+ setFeedback(Lang.messages.generatedBundle);
2474
+ if (refreshUsage) void loadUsageAnalysis(false);
2475
+ return true;
2476
+ } catch (error) {
2477
+ setFeedback(
2478
+ Lang.messages.couldNotGenerateBundle(error instanceof Error ? error.message : String(error)),
2479
+ true
2480
+ );
2481
+ return false;
2482
+ }
2483
+ }
2221
2484
  async function loadProject() {
2222
2485
  setProjectStatus(Lang.messages.connecting);
2223
2486
  try {
2224
2487
  const info = await getProjectInfo();
2225
2488
  currentProjectDirectory = info.projectDirectory;
2226
- const [storedBundle, storedConfig] = await Promise.all([
2489
+ canGenerateProjectBundle = info.canGenerateBundle;
2490
+ const [storedBundle, storedConfig, storedReviewBaseline] = await Promise.all([
2227
2491
  loadStoredBundle(currentProjectDirectory),
2228
- loadStoredProjectConfig(currentProjectDirectory)
2492
+ loadStoredProjectConfig(currentProjectDirectory),
2493
+ loadStoredReviewBaseline(currentProjectDirectory)
2229
2494
  ]);
2495
+ reviewBaseline = storedReviewBaseline;
2496
+ elements.showNewKeys.checked = reviewBaseline !== null && loadShowNewKeysPreference(currentProjectDirectory);
2230
2497
  if (info.config) {
2231
2498
  applyProjectConfig(info.config, false);
2232
2499
  } else if (storedConfig && Array.isArray(storedConfig.levelImports)) {
2233
2500
  applyProjectConfig(storedConfig, false);
2234
2501
  }
2235
- let projectBundle = info.bundle;
2236
- let generated = false;
2237
- if (!projectBundle) {
2238
- if (!info.canGenerateBundle) {
2239
- throw new Error(Lang.messages.missingCatalog);
2240
- }
2241
- setProjectStatus(Lang.messages.generatingBundle);
2242
- projectBundle = (await generateProjectBundle()).bundle;
2243
- generated = true;
2502
+ const projectBundle = info.bundle;
2503
+ if (!projectBundle && !storedBundle && !info.canGenerateBundle) {
2504
+ throw new Error(Lang.messages.missingCatalog);
2244
2505
  }
2245
- const projectIsNewer = storedBundle && projectBundle.updatedAt > storedBundle.updatedAt;
2246
- const selectedBundle = !storedBundle || projectIsNewer ? projectBundle : storedBundle;
2247
- if (projectIsNewer && !generated) {
2506
+ const projectIsNewer = Boolean(storedBundle && projectBundle && projectBundle.updatedAt > storedBundle.updatedAt);
2507
+ const selectedBundle = !storedBundle || projectBundle && projectIsNewer ? projectBundle : storedBundle;
2508
+ if (projectIsNewer) {
2248
2509
  window.alert(Lang.messages.newerBundle);
2249
2510
  }
2250
- state.replaceBundle(selectedBundle);
2251
- setFeedback(generated ? Lang.messages.generatedBundle : selectedBundle === storedBundle ? Lang.messages.restoredBundle : Lang.messages.loadedBundle);
2511
+ if (selectedBundle) {
2512
+ state.replaceBundle(selectedBundle);
2513
+ setFeedback(selectedBundle === storedBundle ? Lang.messages.restoredBundle : Lang.messages.loadedBundle);
2514
+ hideProjectStatus();
2515
+ void loadUsageAnalysis(false);
2516
+ void refreshBundleFromProject(selectedBundle, true);
2517
+ return;
2518
+ }
2519
+ setProjectStatus(Lang.messages.generatingBundle);
2520
+ const generatedBundle = (await generateProjectBundle()).bundle;
2521
+ state.replaceBundle(generatedBundle);
2522
+ setFeedback(Lang.messages.generatedBundle);
2252
2523
  hideProjectStatus();
2253
2524
  void loadUsageAnalysis(false);
2254
2525
  } catch (error) {
@@ -2260,5 +2531,6 @@ async function loadProject() {
2260
2531
  }
2261
2532
  applyProjectConfig(projectConfig, false);
2262
2533
  setSettingsPanelVisibility(false);
2534
+ setTranslationToolbarVisibility(translationToolbarVisible, false);
2263
2535
  elements.retryProjectLoad.addEventListener("click", () => void loadProject());
2264
2536
  void loadProject();
@@ -9,22 +9,25 @@
9
9
  </head>
10
10
  <body>
11
11
  <main class="page">
12
- <header class="page-header">
13
- <p class="eyebrow" data-i18n="page.eyebrow">i18n bundle</p>
14
- <div class="page-title-row">
12
+ <header class="app-header">
13
+ <div class="app-identity">
14
+ <span class="eyebrow" data-i18n="page.eyebrow">i18n bundle</span>
15
15
  <h1 data-i18n="page.title">Translation Editor</h1>
16
- <label class="language-field" for="editor-language">
17
- <span data-i18n="language.label">Language</span>
16
+ </div>
17
+ <div class="app-actions">
18
+ <label class="compact-language-field" for="editor-language" data-i18n-title="language.label" title="Language">
18
19
  <select id="editor-language">
19
20
  <option value="en" data-i18n="language.english">English</option>
20
21
  <option value="pt" data-i18n="language.portuguese">Português</option>
21
22
  </select>
22
23
  </label>
24
+ <button id="toggle-translation-toolbar" class="icon-button" type="button" data-i18n-title="editor.hideToolbar" data-i18n-aria-label="editor.hideToolbar" aria-expanded="true" title="Hide toolbar" aria-label="Hide toolbar">
25
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 14 5-5 5 5" /></svg>
26
+ </button>
23
27
  <button id="toggle-settings-panel" class="icon-button" type="button" aria-label="Show settings" aria-expanded="false" title="Show settings">
24
28
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z"/><path d="m19.4 13.5 1.1.9-2 3.4-1.3-.5a8 8 0 0 1-1.8 1l-.2 1.4h-4l-.2-1.4a8 8 0 0 1-1.8-1l-1.3.5-2-3.4 1.1-.9a8.5 8.5 0 0 1 0-2l-1.1-.9 2-3.4 1.3.5a8 8 0 0 1 1.8-1l.2-1.4h4l.2 1.4a8 8 0 0 1 1.8 1l1.3-.5 2 3.4-1.1.9a8.5 8.5 0 0 1 0 2Z"/></svg>
25
29
  </button>
26
30
  </div>
27
- <p class="intro" data-i18n="page.intro">The editor detects the current project's configuration and bundle. When necessary, it generates the bundle directly from the TypeScript catalog.</p>
28
31
  </header>
29
32
 
30
33
  <section id="project-status" class="project-status loading" role="status" aria-live="polite">
@@ -155,26 +158,42 @@
155
158
  </section>
156
159
 
157
160
  <section class="report" aria-labelledby="report-title">
158
- <div class="report-header">
159
- <div>
161
+ <div id="translation-toolbar" class="report-header">
162
+ <div class="report-heading">
160
163
  <h2 id="report-title" data-i18n="editor.title">Translations</h2>
161
164
  <p id="bundle-summary" data-i18n="editor.emptySummary">Load a bundle to view translations.</p>
162
165
  <p id="editor-feedback" class="feedback" role="status" aria-live="polite"></p>
166
+ <p id="review-summary" class="review-summary" role="status" aria-live="polite"></p>
163
167
  </div>
164
168
  <div class="translation-actions">
165
- <button id="analyze-usage" class="secondary-button" type="button" data-i18n="editor.analyzeUsages" disabled>Analyze usages</button>
166
- <label class="usage-filter" for="show-unreferenced">
167
- <input id="show-unreferenced" type="checkbox" disabled>
168
- <span data-i18n="editor.unreferencedOnly">Unreferenced only</span>
169
- </label>
170
- <label class="visual-level-field" for="visual-level-count">
171
- <span data-i18n="editor.visualizationLevels">Visualization levels</span>
169
+ <section class="filter-box" aria-label="Filters" data-i18n-aria-label="editor.filters">
170
+ <label class="toolbar-toggle" for="show-unreferenced" data-i18n-title="editor.unreferencedOnly" title="Show unreferenced only">
171
+ <input id="show-unreferenced" type="checkbox" disabled>
172
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01M10.3 4.4 2.8 18a2 2 0 0 0 1.75 3h14.9A2 2 0 0 0 21.2 18L13.7 4.4a2 2 0 0 0-3.4 0Z" /></svg>
173
+ <span data-i18n="editor.unreferencedOnly">Show unreferenced only</span>
174
+ </label>
175
+ <label class="toolbar-toggle toolbar-toggle--with-count" for="show-new-keys" data-i18n-title="editor.newKeysOnly" title="Show new keys only">
176
+ <input id="show-new-keys" type="checkbox" disabled>
177
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v18M3 12h18" /><path d="m5 5 2 2m10-2-2 2m2 10-2-2M5 19l2-2" /></svg>
178
+ <span data-i18n="editor.newKeysOnly">Show new keys only</span>
179
+ <output id="new-key-count" class="filter-count"></output>
180
+ </label>
181
+ <input id="translation-search" class="search-field" type="search" data-i18n-placeholder="editor.searchPlaceholder" data-i18n-aria-label="editor.search" placeholder="Search by key or translation" aria-label="Search">
182
+ </section>
183
+ <label class="toolbar-field visual-level-field" for="visual-level-count" data-i18n-title="editor.visualizationLevelsHelp" title="Choose how many hierarchy levels become collapsible translation groups.">
184
+ <span data-i18n="editor.visualizationLevels">Levels</span>
185
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
172
186
  <input id="visual-level-count" type="number" min="0" max="12" step="1" value="2">
173
187
  </label>
174
- <label class="search-field" for="translation-search">
175
- <span data-i18n="editor.search">Search</span>
176
- <input id="translation-search" type="search" data-i18n-placeholder="editor.searchPlaceholder" placeholder="Key or translation">
177
- </label>
188
+ <button id="analyze-usage" class="secondary-button icon-button toolbar-icon-button" type="button" data-i18n-title="editor.analyzeUsages" data-i18n-aria-label="editor.analyzeUsages" title="Analyze usages" aria-label="Analyze usages" disabled>
189
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 1-2.34-5.66M20 4v7h-7" /></svg>
190
+ </button>
191
+ <button id="mark-current-reviewed" class="secondary-button icon-button toolbar-icon-button" type="button" data-i18n-title="editor.markCurrentReviewed" data-i18n-aria-label="editor.markCurrentReviewed" title="Mark current keys as reviewed" aria-label="Mark current keys as reviewed" disabled>
192
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6M5 6h7M5 18h7" /></svg>
193
+ </button>
194
+ <button id="toggle-all-groups" class="secondary-button icon-button toolbar-icon-button" type="button" data-i18n-title="editor.expandAll" data-i18n-aria-label="editor.expandAll" title="Expand all" aria-label="Expand all" aria-pressed="false" disabled>
195
+ <svg aria-hidden="true" viewBox="0 0 24 24"><path d="m8 14 4 4 4-4M8 10l4-4 4 4M12 6v12" /></svg>
196
+ </button>
178
197
  </div>
179
198
  </div>
180
199
  <div id="empty-state" class="empty-state" data-i18n="editor.noBundle">No bundle loaded.</div>
@@ -20,14 +20,14 @@ body {
20
20
 
21
21
  button, input, textarea { font: inherit; }
22
22
 
23
- .page { width: calc(100% - 40px); margin: 44px auto; }
24
- .page-header { max-width: 720px; margin-bottom: 28px; }
25
- .page-title-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
26
- .language-field { display: grid; gap: 3px; margin-left: auto; color: var(--muted); font-size: 10px; font-weight: 750; }
27
- .language-field select { padding: 6px 24px 6px 8px; border: 1px solid #c8cfdd; border-radius: 8px; color: var(--ink); background: #fff; font: inherit; font-size: 12px; }
28
- .eyebrow { margin: 0 0 5px; color: var(--accent); font-size: 12px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
23
+ .page { width: calc(100% - 24px); margin: 12px auto 24px; }
24
+ .app-header { display: flex; align-items: center; justify-content: space-between; min-height: 52px; margin-bottom: 10px; padding: 0 4px; }
25
+ .app-identity, .app-actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
26
+ .app-identity { gap: 9px; }
27
+ .compact-language-field select { height: 32px; padding: 5px 25px 5px 9px; border: 1px solid #c8cfdd; border-radius: 8px; color: var(--ink); background: #fff; font: inherit; font-size: 12px; font-weight: 700; }
28
+ .eyebrow { margin: 0; color: var(--accent); font-size: 10px; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; white-space: nowrap; }
29
29
  h1, h2, p { margin-top: 0; }
30
- h1 { margin-bottom: 8px; font-size: clamp(30px, 5vw, 46px); letter-spacing: -.045em; line-height: 1.05; }
30
+ h1 { margin: 0; font-size: clamp(22px, 2.25vw, 30px); letter-spacing: -.04em; line-height: 1.05; white-space: nowrap; }
31
31
  h2 { margin-bottom: 4px; font-size: 18px; }
32
32
  .intro, .report-header p { color: var(--muted); }
33
33
 
@@ -63,12 +63,22 @@ h2 { margin-bottom: 4px; font-size: 18px; }
63
63
  .export-actions { display: flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
64
64
  .export-delete-option { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 700; }
65
65
  .export-delete-option input { width: 15px; height: 15px; margin: 0; accent-color: var(--danger); }
66
- .translation-actions { display: flex; align-items: flex-end; gap: 10px; }
67
- .usage-filter { display: flex; align-items: center; gap: 7px; min-height: 38px; color: #34405b; font-size: 12px; font-weight: 750; white-space: nowrap; }
68
- .usage-filter input { width: 15px; height: 15px; margin: 0; accent-color: var(--accent); }
66
+ .translation-actions { display: flex; width: 100%; flex-wrap: wrap; align-items: flex-end; justify-content: flex-end; gap: 8px; }
67
+ .toolbar-toggle { min-height: 38px; }
68
+ .toolbar-icon-button { width: 38px; height: 38px; border-radius: 9px; }
69
+ .filter-box { display: flex; flex: 1 1 430px; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin-right: auto; padding: 0; }
70
+ .filter-box-title { align-self: center; padding: 0 3px; color: #52617c; font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
71
+ .toolbar-toggle { display: inline-flex; align-items: center; gap: 7px; padding: 8px 10px; border: 1px solid #c8cfdd; border-radius: 9px; color: #34405b; background: #fff; font-size: 12px; font-weight: 750; line-height: 1.25; white-space: nowrap; }
72
+ .toolbar-toggle:has(input:checked) { border-color: #a5b4fc; color: #3730a3; background: #eef2ff; }
73
+ .toolbar-toggle:has(input:disabled) { color: #94a3b8; background: #f8fafc; }
74
+ .toolbar-toggle input { width: 15px; height: 15px; margin: 0; accent-color: var(--accent); }
75
+ .toolbar-toggle svg, .visual-level-field svg { display: none; width: 16px; height: 16px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
76
+ .filter-count { min-width: 16px; padding-left: 2px; color: var(--accent); font-variant-numeric: tabular-nums; }
69
77
  .translation-group[hidden], .table-group[hidden] { display: none; }
70
- .visual-level-field { display: grid; gap: 4px; color: #52617c; font-size: 11px; font-weight: 750; white-space: nowrap; }
71
- .visual-level-field input { width: 86px; padding: 8px 9px; border: 1px solid #c8cfdd; border-radius: 8px; color: var(--ink); background: #fff; }
78
+ .toolbar-field { display: grid; gap: 4px; color: #52617c; font-size: 11px; font-weight: 750; white-space: nowrap; }
79
+ .toolbar-field > span { color: #52617c; font-size: 11px; font-weight: 750; }
80
+ .toolbar-field input { height: 38px; border: 1px solid #c8cfdd; border-radius: 8px; color: var(--ink); background: #fff; }
81
+ .visual-level-field input { width: 86px; padding: 8px 9px; }
72
82
  .usage-cell { min-width: 170px; font-size: 12px; }
73
83
  .usage-trigger { display: inline-flex; align-items: center; gap: 8px; padding: 4px 7px; border: 0; border-radius: 6px; color: inherit; background: transparent; font: inherit; font-weight: 800; text-align: left; cursor: pointer; }
74
84
  .usage-trigger:hover, .usage-trigger[aria-expanded="true"] { background: #f1f4fb; }
@@ -82,6 +92,10 @@ h2 { margin-bottom: 4px; font-size: 18px; }
82
92
  .usage-popover-uncertain { border-color: #f0c078; }
83
93
  .usage-popover-unreferenced { border-color: #f2b8b5; }
84
94
  .usage-empty { color: var(--muted); }
95
+ .review-removed-row { color: #9f1239; background: #fff1f2; }
96
+ .review-removed-row td { border-color: #fecdd3; }
97
+ .review-removed-row .key { color: #881337; font-weight: 800; text-decoration: line-through; text-decoration-color: #fb7185; }
98
+ .review-removed-value, .base-removed { color: #be123c; font-weight: 800; }
85
99
  .key-content { display: flex; align-items: center; justify-content: flex-start; gap: 8px; }
86
100
  .key-content > span { min-width: 0; overflow-wrap: anywhere; }
87
101
  .remove-key-button { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: 0 0 auto; padding: 0; border: 1px solid transparent; border-radius: 6px; color: #9f3a38; background: transparent; font: 700 18px/1 sans-serif; cursor: pointer; opacity: .22; }
@@ -111,9 +125,9 @@ h2 { margin-bottom: 4px; font-size: 18px; }
111
125
  .secondary-button { border: 1px solid #c8cfdd; color: #4c5770; background: #fff; white-space: nowrap; }
112
126
  .secondary-button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
113
127
  .secondary-button:disabled { cursor: default; opacity: .55; }
114
- button, .report-header { display: flex; align-items: center; gap: 14px; }
128
+ button { display: flex; align-items: center; gap: 8px; }
115
129
  button { cursor: pointer; border-radius: 9px; font-weight: 700; }
116
- .search-field > span { color: #34405b; font-size: 13px; font-weight: 700; }
130
+ .search-field > span { color: #52617c; font-size: 11px; font-weight: 750; }
117
131
  textarea, .search-field input { width: 100%; border: 1px solid #c8cfdd; border-radius: 9px; color: var(--ink); background: #fff; outline: none; }
118
132
  textarea:focus, .search-field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(79, 70, 229, .14); }
119
133
  textarea { min-height: 120px; padding: 12px; resize: vertical; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
@@ -122,12 +136,17 @@ button { border: 0; padding: 10px 14px; color: #fff; background: var(--accent);
122
136
  .feedback.error { color: var(--danger); }
123
137
 
124
138
  .report { overflow: hidden; }
125
- .report-header { justify-content: space-between; padding: 20px 22px; border-bottom: 1px solid var(--line); }
139
+ .report-header { display: grid; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
140
+ .report-heading { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 0; }
141
+ .report-heading h2 { margin: 0; font-size: 16px; white-space: nowrap; }
142
+ .report-heading p { display: inline; margin: 0; font-size: 12px; white-space: nowrap; }
143
+ .report-heading p:not(:empty)::before { content: '·'; margin: 0 7px; color: #a4afc1; }
144
+ .review-summary { color: #596780; }
126
145
  .icon-button { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid #c8cfdd; color: #4c5770; background: #fff; }
127
146
  .icon-button:hover { border-color: var(--accent); color: var(--accent); }
128
147
  .icon-button svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
129
- .search-field { width: min(340px, 100%); }
130
- .search-field input { margin-top: 5px; padding: 9px 11px; }
148
+ .search-field { width: min(220px, 100%); }
149
+ .search-field input { margin-top: 0; padding: 8px 10px; }
131
150
  .empty-state { padding: 56px 22px; color: var(--muted); text-align: center; }
132
151
  .table-wrap { width: 100%; max-width: 100%; overflow-x: auto; }
133
152
  .table-group { padding: 18px 22px; }
@@ -160,13 +179,123 @@ code { color: #4338ca; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Conso
160
179
  tr[hidden] { display: none; }
161
180
 
162
181
  @media (max-width: 720px) {
163
- .page { width: min(100% - 24px, 1500px); margin: 24px auto; }
164
- .report-header { align-items: flex-start; flex-direction: column; }
165
- .page-title-row { align-items: flex-start; flex-wrap: wrap; }
182
+ .page { width: calc(100% - 20px); margin: 8px auto 16px; }
183
+ .app-header { min-height: 44px; }
184
+ .eyebrow { display: none; }
185
+ h1 { font-size: 21px; }
186
+ .report-header { display: grid; }
166
187
  .translation-actions { width: 100%; align-items: stretch; flex-direction: column; }
188
+ .filter-box { width: 100%; }
189
+ .filter-box-title { width: 100%; }
190
+ .toolbar-toggle, .toolbar-field { width: 100%; }
191
+ .toolbar-icon-button { width: calc(50% - 5px); }
192
+ .visual-level-field input, .search-field { width: 100%; }
167
193
  .export-preview-header { flex-direction: column; }
168
194
  .export-diff-details { margin-left: 0; }
169
195
  .settings-fields, .level-replacers { grid-template-columns: 1fr; }
170
196
  .export-settings-fields, .code-format-fields, .deletion-settings-fields { grid-template-columns: 1fr; }
171
197
  .search-field { width: 100%; }
172
198
  }
199
+
200
+ @media (max-width: 1050px) {
201
+ .filter-box { flex: 0 1 auto; margin-right: auto; }
202
+ .filter-box-title, .toolbar-toggle > span, .visual-level-field > span { display: none; }
203
+ .toolbar-toggle { position: relative; width: 38px; height: 38px; justify-content: center; padding: 0; }
204
+ .toolbar-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
205
+ .toolbar-toggle svg, .visual-level-field svg { display: block; }
206
+ .visual-level-field { position: relative; display: flex; align-items: center; justify-content: center; width: 66px; height: 38px; padding: 0 7px; border: 1px solid #c8cfdd; border-radius: 9px; background: #fff; }
207
+ .visual-level-field input { width: 30px; height: 30px; padding: 0; border: 0; background: transparent; text-align: center; }
208
+ .search-field { width: min(210px, 32vw); }
209
+ .search-field > span { display: none; }
210
+ .search-field input { height: 38px; }
211
+ }
212
+
213
+ @media (max-width: 720px) {
214
+ .translation-actions { align-items: flex-end; flex-direction: row; }
215
+ .filter-box { width: auto; }
216
+ .toolbar-toggle { width: 38px; }
217
+ .toolbar-field { width: auto; }
218
+ .toolbar-icon-button { width: 38px; }
219
+ .search-field { width: min(180px, calc(100vw - 210px)); }
220
+ }
221
+
222
+ .report-header[hidden] { display: none; }
223
+
224
+ .toolbar-icon-button {
225
+ display: inline-grid;
226
+ width: 38px;
227
+ height: 38px;
228
+ flex: 0 0 38px;
229
+ place-items: center;
230
+ padding: 0;
231
+ }
232
+
233
+ .toolbar-icon-button.is-expanded svg { transform: rotate(180deg); }
234
+
235
+ .toolbar-icon-button svg {
236
+ width: 18px;
237
+ height: 18px;
238
+ fill: none;
239
+ stroke: currentColor;
240
+ stroke-linecap: round;
241
+ stroke-linejoin: round;
242
+ stroke-width: 1.8;
243
+ }
244
+
245
+ .search-field {
246
+ width: min(260px, 34vw);
247
+ height: 38px;
248
+ padding: 8px 10px;
249
+ border: 1px solid #c8cfdd;
250
+ border-radius: 9px;
251
+ color: var(--ink);
252
+ background: #fff;
253
+ outline: none;
254
+ }
255
+
256
+ .search-field:focus {
257
+ border-color: var(--accent);
258
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, .14);
259
+ }
260
+
261
+ .visual-level-field {
262
+ display: inline-flex;
263
+ width: auto;
264
+ min-width: 98px;
265
+ height: 38px;
266
+ align-items: center;
267
+ gap: 6px;
268
+ padding: 0 8px;
269
+ border: 1px solid #c8cfdd;
270
+ border-radius: 9px;
271
+ background: #fff;
272
+ }
273
+
274
+ .visual-level-field input {
275
+ width: 32px;
276
+ height: 30px;
277
+ padding: 0;
278
+ border: 0;
279
+ background: transparent;
280
+ text-align: center;
281
+ }
282
+
283
+ @media (max-width: 1050px), (max-aspect-ratio: 1 / 1) {
284
+ .filter-box { margin-right: auto; }
285
+ .toolbar-toggle svg, .visual-level-field svg { display: block; }
286
+ .toolbar-toggle > span, .visual-level-field > span { display: none; }
287
+ .toolbar-toggle { width: 38px; height: 38px; padding: 0; justify-content: center; }
288
+ .toolbar-toggle.toolbar-toggle--with-count { width: 62px; min-width: 62px; gap: 6px; }
289
+ .toolbar-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }
290
+ .visual-level-field { width: 68px; min-width: 68px; justify-content: center; padding: 0 7px; }
291
+ .visual-level-field input { display: block; }
292
+ .search-field { width: min(230px, 32vw); }
293
+ }
294
+
295
+ @media (max-width: 720px) {
296
+ .translation-actions { align-items: center; gap: 7px; }
297
+ .toolbar-icon-button, .toolbar-toggle { width: 38px; height: 38px; flex: 0 0 38px; }
298
+ .visual-level-field { width: 68px; min-width: 68px; height: 38px; flex: 0 0 68px; }
299
+ .toolbar-toggle.toolbar-toggle--with-count { width: 62px; min-width: 62px; flex-basis: 62px; }
300
+ .search-field { width: min(180px, calc(100vw - 230px)); }
301
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wads.dev/i18n-editor",
3
- "version": "0.0.1-beta.0",
3
+ "version": "0.0.1-rc.0",
4
4
  "description": "Local web editor for typed i18n projects.",
5
5
  "license": "MIT",
6
6
  "author": {