From 5cf56a78eb8c97f4e3c68fa32d64394dc6f5456f Mon Sep 17 00:00:00 2001 From: snodel Date: Fri, 18 Sep 2026 14:25:44 -0700 Subject: [PATCH 1/6] feat(tracker): add and translate the translation editor dialog strings Add the context-column strings (where it lands, similar values, locales, all up to date) and translate the new editor, folder picker, tags and common action strings into es, fr-ca, ru, ja and de. Co-Authored-By: Claude Fable 5.1 --- .../folderPicker/resource_entries.json | 30 + .../browser/folderPicker/tracker_meta.json | 90 +++ .../conflict/resource_entries.json | 10 + .../conflict/tracker_meta.json | 30 + .../context/resource_entries.json | 192 ++++++ .../context/tracker_meta.json | 572 ++++++++++++++++++ .../error/resource_entries.json | 22 +- .../translationEditor/error/tracker_meta.json | 52 +- .../translationEditor/resource_entries.json | 208 +++++-- .../tab/resource_entries.json | 2 +- .../translationEditor/tab/tracker_meta.json | 12 +- .../tags/resource_entries.json | 32 +- .../translationEditor/tags/tracker_meta.json | 82 ++- .../translationEditor/tracker_meta.json | 528 +++++++++++++--- .../i18n/common/actions/resource_entries.json | 20 + .../src/i18n/common/actions/tracker_meta.json | 60 ++ 16 files changed, 1773 insertions(+), 169 deletions(-) create mode 100644 apps/tracker/src/i18n/browser/translationEditor/context/resource_entries.json create mode 100644 apps/tracker/src/i18n/browser/translationEditor/context/tracker_meta.json diff --git a/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json b/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json index 0b4dbcd..dc47de7 100644 --- a/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json +++ b/apps/tracker/src/i18n/browser/folderPicker/resource_entries.json @@ -128,5 +128,35 @@ "ru": "Дерево папок", "ja": "フォルダーツリー", "de": "Ordnerstruktur" + }, + "filterPlaceholder": { + "source": "Filter folders…", + "comment": "Placeholder of the filter field in the folder picker popover", + "tags": ["browser"], + "es": "Filtrar carpetas…", + "fr-ca": "Filtrer les dossiers…", + "ru": "Фильтровать папки…", + "ja": "フォルダを絞り込む…", + "de": "Ordner filtern…" + }, + "newFolder": { + "source": "New folder", + "comment": "Link that starts creating a folder from the folder picker popover", + "tags": ["browser"], + "es": "Nueva carpeta", + "fr-ca": "Nouveau dossier", + "ru": "Новая папка", + "ja": "新しいフォルダ", + "de": "Neuer Ordner" + }, + "useThisFolder": { + "source": "Use this folder", + "comment": "Primary button confirming the folder chosen in the picker popover", + "tags": ["browser"], + "es": "Usar esta carpeta", + "fr-ca": "Utiliser ce dossier", + "ru": "Использовать эту папку", + "ja": "このフォルダを使用", + "de": "Diesen Ordner verwenden" } } diff --git a/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json b/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json index a3057cc..1e321c4 100644 --- a/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/folderPicker/tracker_meta.json @@ -388,5 +388,95 @@ "baseChecksum": "3b3f435ac41d9af09975ca8fd7d9a957", "status": "translated" } + }, + "filterPlaceholder": { + "en": { + "checksum": "185a3079c0157a2e175bdeb506f59294" + }, + "es": { + "checksum": "8a00ff4c2e1c44e9f94bd68fd906e710", + "baseChecksum": "185a3079c0157a2e175bdeb506f59294", + "status": "translated" + }, + "fr-ca": { + "checksum": "78524c3df7eaa8722b943525f339a8a4", + "baseChecksum": "185a3079c0157a2e175bdeb506f59294", + "status": "translated" + }, + "ru": { + "checksum": "098c82b8a60a9ef425966c4871c6f74b", + "baseChecksum": "185a3079c0157a2e175bdeb506f59294", + "status": "translated" + }, + "ja": { + "checksum": "1695e39bed1920ec97e376a5be670258", + "baseChecksum": "185a3079c0157a2e175bdeb506f59294", + "status": "translated" + }, + "de": { + "checksum": "baeb0bffa29aeade4368f239fe048267", + "baseChecksum": "185a3079c0157a2e175bdeb506f59294", + "status": "translated" + } + }, + "newFolder": { + "en": { + "checksum": "0d0a9cfd8f8c6876dd0a4b27d18c388c" + }, + "es": { + "checksum": "fcb5e791aef83257b693ead0bbf74a19", + "baseChecksum": "0d0a9cfd8f8c6876dd0a4b27d18c388c", + "status": "translated" + }, + "fr-ca": { + "checksum": "cf7f99d5dd6d66bf38544f3ba74c8a46", + "baseChecksum": "0d0a9cfd8f8c6876dd0a4b27d18c388c", + "status": "translated" + }, + "ru": { + "checksum": "6513261fd0620d40b8ac1c1bb2e43101", + "baseChecksum": "0d0a9cfd8f8c6876dd0a4b27d18c388c", + "status": "translated" + }, + "ja": { + "checksum": "20c86957b39f4630a4dc9ec16f1f4f3b", + "baseChecksum": "0d0a9cfd8f8c6876dd0a4b27d18c388c", + "status": "translated" + }, + "de": { + "checksum": "a5480204e74bb13f0bf1812153a8d41b", + "baseChecksum": "0d0a9cfd8f8c6876dd0a4b27d18c388c", + "status": "translated" + } + }, + "useThisFolder": { + "en": { + "checksum": "2a4a3b26a41a58c0bd337f646c4478eb" + }, + "es": { + "checksum": "48f1df30bebe115481b9c92fa27c1fc6", + "baseChecksum": "2a4a3b26a41a58c0bd337f646c4478eb", + "status": "translated" + }, + "fr-ca": { + "checksum": "aafbd752c9e753ccf5d51c21e724a977", + "baseChecksum": "2a4a3b26a41a58c0bd337f646c4478eb", + "status": "translated" + }, + "ru": { + "checksum": "5762ac18a59942ddb9ffb1540c6b88cd", + "baseChecksum": "2a4a3b26a41a58c0bd337f646c4478eb", + "status": "translated" + }, + "ja": { + "checksum": "33e08bc9ef7f88121458cc0bd14519c2", + "baseChecksum": "2a4a3b26a41a58c0bd337f646c4478eb", + "status": "translated" + }, + "de": { + "checksum": "3421b9b345cd7a06e53e44b3959107f7", + "baseChecksum": "2a4a3b26a41a58c0bd337f646c4478eb", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json index 3566377..ac56d8d 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/conflict/resource_entries.json @@ -38,5 +38,15 @@ "ru": "Выберите другой ключ", "ja": "別のキーを選択してください", "de": "Anderen Schlüssel wählen" + }, + "openExisting": { + "source": "Open existing", + "comment": "Action beside the key error that opens the entry already holding the key", + "tags": ["browser"], + "es": "Abrir existente", + "fr-ca": "Ouvrir l’existant", + "ru": "Открыть существующий", + "ja": "既存を開く", + "de": "Vorhandenen öffnen" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json index aa16ebf..ff6b4b4 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/conflict/tracker_meta.json @@ -118,5 +118,35 @@ "baseChecksum": "5275ecd41038dd5cab36ac1d387e95aa", "status": "translated" } + }, + "openExisting": { + "en": { + "checksum": "5341c92b6f6e9239800702da7651c68d" + }, + "es": { + "checksum": "314dbace29b0543e97e054169601dd83", + "baseChecksum": "5341c92b6f6e9239800702da7651c68d", + "status": "translated" + }, + "fr-ca": { + "checksum": "f132bf4b0d46eb9f08e554d3e4c71a82", + "baseChecksum": "5341c92b6f6e9239800702da7651c68d", + "status": "translated" + }, + "ru": { + "checksum": "362ba0467e951fbb6505ffdd97d4efe6", + "baseChecksum": "5341c92b6f6e9239800702da7651c68d", + "status": "translated" + }, + "ja": { + "checksum": "b84dca699d4e4c5456ea7b02a9f7dd75", + "baseChecksum": "5341c92b6f6e9239800702da7651c68d", + "status": "translated" + }, + "de": { + "checksum": "8f07fcfa0b5fb4a320584669f275b0a9", + "baseChecksum": "5341c92b6f6e9239800702da7651c68d", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/context/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/context/resource_entries.json new file mode 100644 index 0000000..cc671e3 --- /dev/null +++ b/apps/tracker/src/i18n/browser/translationEditor/context/resource_entries.json @@ -0,0 +1,192 @@ +{ + "whereItLands": { + "source": "Where it lands", + "comment": "Heading of the context column block showing the folder tree", + "tags": ["browser"], + "es": "Dónde se guardará", + "fr-ca": "Où cela sera enregistré", + "ru": "Куда попадёт", + "ja": "保存先", + "de": "Wo es landet" + }, + "fullKey": { + "source": "Full key", + "comment": "Heading of the context column block showing the complete resource key", + "tags": ["browser"], + "es": "Clave completa", + "fr-ca": "Clé complète", + "ru": "Полный ключ", + "ja": "完全なキー", + "de": "Vollständiger Schlüssel" + }, + "available": { + "source": "Available", + "comment": "Status line saying the key is not taken in the target folder", + "tags": ["browser"], + "es": "Disponible", + "fr-ca": "Disponible", + "ru": "Свободно", + "ja": "使用可能", + "de": "Verfügbar" + }, + "similarValues": { + "source": "Similar values", + "comment": "Heading of the context column block listing similar existing translations", + "tags": ["browser"], + "es": "Valores similares", + "fr-ca": "Valeurs similaires", + "ru": "Похожие значения", + "ja": "類似の値", + "de": "Ähnliche Werte" + }, + "similarCaption": { + "source": "Pinned until the English text changes. Pick one to reuse its key instead of adding a duplicate.", + "comment": "Caption under the similar values block in the context column", + "tags": ["browser"], + "es": "Se mantiene fijo hasta que cambie el texto en inglés. Elija uno para reutilizar su clave en lugar de crear un duplicado.", + "fr-ca": "Épinglé jusqu’à ce que le texte anglais change. Choisissez-en un pour réutiliser sa clé au lieu d’ajouter un doublon.", + "ru": "Закреплено, пока не изменится английский текст. Выберите одно, чтобы повторно использовать его ключ вместо создания дубликата.", + "ja": "英語のテキストが変わるまで固定されます。重複を追加せず、いずれかを選んでそのキーを再利用してください。", + "de": "Bleibt angeheftet, bis sich der englische Text ändert. Wählen Sie einen Eintrag, um dessen Schlüssel wiederzuverwenden, statt ein Duplikat anzulegen." + }, + "locales": { + "source": "Locales", + "comment": "Heading of the context column block summarising locale values", + "tags": ["browser"], + "es": "Idiomas", + "fr-ca": "Langues", + "ru": "Локали", + "ja": "ロケール", + "de": "Sprachen" + }, + "missing": { + "source": "missing", + "comment": "Shown in the locale summary when a locale has no value", + "tags": ["browser"], + "es": "faltantes", + "fr-ca": "manquantes", + "ru": "отсутствуют", + "ja": "未設定", + "de": "fehlend" + }, + "here": { + "source": "here", + "comment": "Pill marking the current folder in the context column tree", + "tags": ["browser"], + "es": "aquí", + "fr-ca": "ici", + "ru": "здесь", + "ja": "ここ", + "de": "hier" + }, + "newEntry": { + "source": "new", + "comment": "Pill marking the entry about to be created in the context column tree", + "tags": ["browser"], + "es": "nueva", + "fr-ca": "nouvelle", + "ru": "новая", + "ja": "新規", + "de": "neu" + }, + "editingEntry": { + "source": "editing", + "comment": "Pill marking the entry being edited in the context column tree", + "tags": ["browser"], + "es": "editando", + "fr-ca": "modification", + "ru": "редактирование", + "ja": "編集中", + "de": "bearbeiten" + }, + "title": { + "source": "Context", + "comment": "Title of the disclosure that holds the context blocks on narrow viewports", + "tags": ["browser"], + "es": "Contexto", + "fr-ca": "Contexte", + "ru": "Контекст", + "ja": "コンテキスト", + "de": "Kontext" + }, + "similarCountX": { + "source": "{count} similar", + "comment": "Summary clause counting similar existing values", + "tags": ["browser"], + "es": "{count} similares", + "fr-ca": "{count} similaires", + "ru": "{count} похожих", + "ja": "類似 {count} 件", + "de": "{count} ähnliche" + }, + "autoAfterSave": { + "source": "auto after save", + "comment": "Locale summary note in create mode", + "tags": ["browser"], + "es": "automático al guardar", + "fr-ca": "auto après l’enregistrement", + "ru": "авто после сохранения", + "ja": "保存後に自動", + "de": "auto nach dem Speichern" + }, + "moreEntriesX": { + "source": "… {count} more", + "comment": "Row that stands for the entries the context tree does not list", + "tags": ["browser"], + "es": "… {count} más", + "fr-ca": "… {count} de plus", + "ru": "… ещё {count}", + "ja": "… 他 {count} 件", + "de": "… {count} weitere" + }, + "alreadyExists": { + "source": "Already exists in this folder", + "comment": "Context column line saying the full key is taken in the target folder", + "tags": ["browser"], + "es": "Ya existe en esta carpeta", + "fr-ca": "Existe déjà dans ce dossier", + "ru": "Уже существует в этой папке", + "ja": "このフォルダに既に存在します", + "de": "Existiert bereits in diesem Ordner" + }, + "existsEntry": { + "source": "exists", + "comment": "Pill marking an entry in the context tree whose key is already taken", + "tags": ["browser"], + "es": "existe", + "fr-ca": "existe", + "ru": "существует", + "ja": "既存", + "de": "existiert" + }, + "keyExists": { + "source": "key exists", + "comment": "Context disclosure summary clause when the key is already taken", + "tags": ["browser"], + "es": "clave existente", + "fr-ca": "clé existante", + "ru": "ключ существует", + "ja": "キーが存在", + "de": "Schlüssel existiert" + }, + "similarExactX": { + "source": "The exact text already exists under {key}. Reuse that key instead of adding a duplicate.", + "comment": "Caption under the similar values block when one hit carries the identical text", + "tags": ["browser"], + "es": "El texto exacto ya existe en {key}. Reutilice esa clave en lugar de crear un duplicado.", + "fr-ca": "Le texte exact existe déjà sous {key}. Réutilisez cette clé au lieu d’ajouter un doublon.", + "ru": "Точно такой же текст уже есть в {key}. Используйте этот ключ повторно вместо создания дубликата.", + "ja": "まったく同じテキストが {key} に存在します。重複を追加せず、そのキーを再利用してください。", + "de": "Der exakte Text existiert bereits unter {key}. Verwenden Sie diesen Schlüssel wieder, statt ein Duplikat anzulegen." + }, + "allUpToDate": { + "source": "All other locales are translated.", + "comment": "Muted line shown in the context column Locales block when no other locale needs work", + "tags": ["browser"], + "es": "Todos los demás idiomas están traducidos.", + "fr-ca": "Toutes les autres langues sont traduites.", + "ru": "Все остальные локали переведены.", + "ja": "他のすべてのロケールは翻訳済みです。", + "de": "Alle anderen Sprachen sind übersetzt." + } +} diff --git a/apps/tracker/src/i18n/browser/translationEditor/context/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/context/tracker_meta.json new file mode 100644 index 0000000..199d6a0 --- /dev/null +++ b/apps/tracker/src/i18n/browser/translationEditor/context/tracker_meta.json @@ -0,0 +1,572 @@ +{ + "whereItLands": { + "en": { + "checksum": "926fe7efcc3f1665f7939aee9af4b6c8" + }, + "es": { + "checksum": "c90f50e985e458c713f17ac230d9a015", + "baseChecksum": "926fe7efcc3f1665f7939aee9af4b6c8", + "status": "translated" + }, + "fr-ca": { + "checksum": "c5752bb35e8d595d6c4e4dfdae7ab006", + "baseChecksum": "926fe7efcc3f1665f7939aee9af4b6c8", + "status": "translated" + }, + "ru": { + "checksum": "cff9a30ee3af94c488504c6b150ebd1c", + "baseChecksum": "926fe7efcc3f1665f7939aee9af4b6c8", + "status": "translated" + }, + "ja": { + "checksum": "b221553a6c9bf89c68b67324d6522741", + "baseChecksum": "926fe7efcc3f1665f7939aee9af4b6c8", + "status": "translated" + }, + "de": { + "checksum": "c047fde78232ff9dcf2c51cc34cd35f9", + "baseChecksum": "926fe7efcc3f1665f7939aee9af4b6c8", + "status": "translated" + } + }, + "fullKey": { + "en": { + "checksum": "a8baa276ac7fbe74cec5d63c401d3e7c" + }, + "es": { + "checksum": "d2d3d5347d4c0039f092f0938b967131", + "baseChecksum": "a8baa276ac7fbe74cec5d63c401d3e7c", + "status": "translated" + }, + "fr-ca": { + "checksum": "8ed48f0dac519acaab45024b2837b8e6", + "baseChecksum": "a8baa276ac7fbe74cec5d63c401d3e7c", + "status": "translated" + }, + "ru": { + "checksum": "775d5167d79a915a226bf2a4dad4f8d9", + "baseChecksum": "a8baa276ac7fbe74cec5d63c401d3e7c", + "status": "translated" + }, + "ja": { + "checksum": "eb0814fca756089009bf269809f49238", + "baseChecksum": "a8baa276ac7fbe74cec5d63c401d3e7c", + "status": "translated" + }, + "de": { + "checksum": "0b7ead3c0fc36e6a75865b9660029bf2", + "baseChecksum": "a8baa276ac7fbe74cec5d63c401d3e7c", + "status": "translated" + } + }, + "available": { + "en": { + "checksum": "78945de8de090e90045d299651a68a9b" + }, + "es": { + "checksum": "4352a5342c0822e2b90b767c1a412fe2", + "baseChecksum": "78945de8de090e90045d299651a68a9b", + "status": "translated" + }, + "fr-ca": { + "checksum": "4352a5342c0822e2b90b767c1a412fe2", + "baseChecksum": "78945de8de090e90045d299651a68a9b", + "status": "translated" + }, + "ru": { + "checksum": "7e2efc7089ff560143eb3f671aad3344", + "baseChecksum": "78945de8de090e90045d299651a68a9b", + "status": "translated" + }, + "ja": { + "checksum": "d7be4b983ffaaf107814cb4c074cbee4", + "baseChecksum": "78945de8de090e90045d299651a68a9b", + "status": "translated" + }, + "de": { + "checksum": "c8f79ede7e5cf4015dc6e96c8adf4195", + "baseChecksum": "78945de8de090e90045d299651a68a9b", + "status": "translated" + } + }, + "similarValues": { + "en": { + "checksum": "ccfd1485e41b41a276b9d006455a46a3" + }, + "es": { + "checksum": "44cdbad9c91b18b4c322d16f83e56ca1", + "baseChecksum": "ccfd1485e41b41a276b9d006455a46a3", + "status": "translated" + }, + "fr-ca": { + "checksum": "a50055370e048b0ffff0632ca3259d08", + "baseChecksum": "ccfd1485e41b41a276b9d006455a46a3", + "status": "translated" + }, + "ru": { + "checksum": "eea55f490807f552f9ac9a52d8f4289f", + "baseChecksum": "ccfd1485e41b41a276b9d006455a46a3", + "status": "translated" + }, + "ja": { + "checksum": "76ddc72034771860494cbc2471ef9436", + "baseChecksum": "ccfd1485e41b41a276b9d006455a46a3", + "status": "translated" + }, + "de": { + "checksum": "5a137abaee27a6567bb3ef0fc51a9d4a", + "baseChecksum": "ccfd1485e41b41a276b9d006455a46a3", + "status": "translated" + } + }, + "similarCaption": { + "en": { + "checksum": "9030da3381d909b821b4f0124715bade" + }, + "es": { + "checksum": "234528dcc2ca0e37dc8d741969224c19", + "baseChecksum": "9030da3381d909b821b4f0124715bade", + "status": "translated" + }, + "fr-ca": { + "checksum": "43058c2e7a93c03e3ce284b6c35a83f0", + "baseChecksum": "9030da3381d909b821b4f0124715bade", + "status": "translated" + }, + "ru": { + "checksum": "80a0ff09f03b5b273d308a503c274ef2", + "baseChecksum": "9030da3381d909b821b4f0124715bade", + "status": "translated" + }, + "ja": { + "checksum": "01152a007911bd67b755e1e6069de8e0", + "baseChecksum": "9030da3381d909b821b4f0124715bade", + "status": "translated" + }, + "de": { + "checksum": "db0a70d187c6502c7e6c0de0fc8ae417", + "baseChecksum": "9030da3381d909b821b4f0124715bade", + "status": "translated" + } + }, + "locales": { + "en": { + "checksum": "52ec7aab68a6cdc237d9db8133bc815f" + }, + "es": { + "checksum": "c88b33a3250b4e28f34e1ececba69206", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "fr-ca": { + "checksum": "1ea21417b4fc870a18d6a244983be72b", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "ru": { + "checksum": "c98fc8570c98ecc0926915d287884bd2", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "ja": { + "checksum": "6740922e3a31d08d75cc90d75875e3ce", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "de": { + "checksum": "bf6c56085d5ba147fe0860211f201685", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + } + }, + "missing": { + "en": { + "checksum": "ea21841da70e6405af19fabc4ff8bdd9" + }, + "es": { + "checksum": "5dd823b8abb7cfea3952ab558cb97377", + "baseChecksum": "ea21841da70e6405af19fabc4ff8bdd9", + "status": "translated" + }, + "fr-ca": { + "checksum": "2862c09b1c72a63343a2b9e11b0e56ae", + "baseChecksum": "ea21841da70e6405af19fabc4ff8bdd9", + "status": "translated" + }, + "ru": { + "checksum": "95b363bebcd0561a20ccfc047b76ca08", + "baseChecksum": "ea21841da70e6405af19fabc4ff8bdd9", + "status": "translated" + }, + "ja": { + "checksum": "a8b53fac2b0ac2f8c2947e386824b74c", + "baseChecksum": "ea21841da70e6405af19fabc4ff8bdd9", + "status": "translated" + }, + "de": { + "checksum": "ef78683ca99a00ddce3344cfadf2ee71", + "baseChecksum": "ea21841da70e6405af19fabc4ff8bdd9", + "status": "translated" + } + }, + "here": { + "en": { + "checksum": "6c92285fa6d3e827b198d120ea3ac674" + }, + "es": { + "checksum": "27c55018e21f9781c8f4c4366153dddc", + "baseChecksum": "6c92285fa6d3e827b198d120ea3ac674", + "status": "translated" + }, + "fr-ca": { + "checksum": "9ee5923fc73ded1b84ba78a17e1282bc", + "baseChecksum": "6c92285fa6d3e827b198d120ea3ac674", + "status": "translated" + }, + "ru": { + "checksum": "6be4fb79ddd52b8701028aa4a4e0f2bb", + "baseChecksum": "6c92285fa6d3e827b198d120ea3ac674", + "status": "translated" + }, + "ja": { + "checksum": "844d12f0e6ca9faebb5ae92f0e5e24a0", + "baseChecksum": "6c92285fa6d3e827b198d120ea3ac674", + "status": "translated" + }, + "de": { + "checksum": "24072372acf322fe3f6b1ed476750b44", + "baseChecksum": "6c92285fa6d3e827b198d120ea3ac674", + "status": "translated" + } + }, + "newEntry": { + "en": { + "checksum": "22af645d1859cb5ca6da0c484f1f37ea" + }, + "es": { + "checksum": "7ad4109292636b7498a9f5042081becf", + "baseChecksum": "22af645d1859cb5ca6da0c484f1f37ea", + "status": "translated" + }, + "fr-ca": { + "checksum": "2a042ef4ab0c308a7dd7b37671994745", + "baseChecksum": "22af645d1859cb5ca6da0c484f1f37ea", + "status": "translated" + }, + "ru": { + "checksum": "8edb261f6e5330720e4f193fa83583b6", + "baseChecksum": "22af645d1859cb5ca6da0c484f1f37ea", + "status": "translated" + }, + "ja": { + "checksum": "ef5f9cf6987e3eef306718aa3c48c2f5", + "baseChecksum": "22af645d1859cb5ca6da0c484f1f37ea", + "status": "translated" + }, + "de": { + "checksum": "83e71d83b2dcea23dcab3b64182494cc", + "baseChecksum": "22af645d1859cb5ca6da0c484f1f37ea", + "status": "translated" + } + }, + "editingEntry": { + "en": { + "checksum": "3f86cc36e1df1334da973da6771b8160" + }, + "es": { + "checksum": "90204797fcae02a7f1134c98f6993eed", + "baseChecksum": "3f86cc36e1df1334da973da6771b8160", + "status": "translated" + }, + "fr-ca": { + "checksum": "9ec08e8c8ca99f38c2cd88d02dc9da87", + "baseChecksum": "3f86cc36e1df1334da973da6771b8160", + "status": "translated" + }, + "ru": { + "checksum": "6989862e9955f2ef94ff410d666984e7", + "baseChecksum": "3f86cc36e1df1334da973da6771b8160", + "status": "translated" + }, + "ja": { + "checksum": "43545398643694e183727414112d0edb", + "baseChecksum": "3f86cc36e1df1334da973da6771b8160", + "status": "translated" + }, + "de": { + "checksum": "7b77ba4ee8c2d1c9a348fee869eea6be", + "baseChecksum": "3f86cc36e1df1334da973da6771b8160", + "status": "translated" + } + }, + "title": { + "en": { + "checksum": "ad4e206408c1c59bfb8a057ba7857446" + }, + "es": { + "checksum": "d9a85c5b06f6c0879d508461ebaecf3c", + "baseChecksum": "ad4e206408c1c59bfb8a057ba7857446", + "status": "translated" + }, + "fr-ca": { + "checksum": "659579371c966749144498a7628d04c6", + "baseChecksum": "ad4e206408c1c59bfb8a057ba7857446", + "status": "translated" + }, + "ru": { + "checksum": "fa060ba533c175a3819d57a1e8772679", + "baseChecksum": "ad4e206408c1c59bfb8a057ba7857446", + "status": "translated" + }, + "ja": { + "checksum": "78f42ae7c68ca3c4a40e9255d50892ad", + "baseChecksum": "ad4e206408c1c59bfb8a057ba7857446", + "status": "translated" + }, + "de": { + "checksum": "ece41d76fcfc7abbdcc92dcb0f14fb17", + "baseChecksum": "ad4e206408c1c59bfb8a057ba7857446", + "status": "translated" + } + }, + "similarCountX": { + "en": { + "checksum": "c1bbdf646e56aecc144cb5a21f63d3ff" + }, + "es": { + "checksum": "4d3cde0b3dfcac2312c2eecf0e52c16c", + "baseChecksum": "c1bbdf646e56aecc144cb5a21f63d3ff", + "status": "translated" + }, + "fr-ca": { + "checksum": "aa914672b282d9cdbb5781ea811026cb", + "baseChecksum": "c1bbdf646e56aecc144cb5a21f63d3ff", + "status": "translated" + }, + "ru": { + "checksum": "42b371253e70e780ea3f160ddb7744fc", + "baseChecksum": "c1bbdf646e56aecc144cb5a21f63d3ff", + "status": "translated" + }, + "ja": { + "checksum": "1c2b668ad50f69e27e634962479025a5", + "baseChecksum": "c1bbdf646e56aecc144cb5a21f63d3ff", + "status": "translated" + }, + "de": { + "checksum": "588f6677110602a8374b7571dd9b3689", + "baseChecksum": "c1bbdf646e56aecc144cb5a21f63d3ff", + "status": "translated" + } + }, + "autoAfterSave": { + "en": { + "checksum": "8a59b5dedb036e9d188fb7d493a8399b" + }, + "es": { + "checksum": "f5c3aac729fae2159b3259ed8a394a52", + "baseChecksum": "8a59b5dedb036e9d188fb7d493a8399b", + "status": "translated" + }, + "fr-ca": { + "checksum": "6348cb804fac6653e57767f3788e9ff2", + "baseChecksum": "8a59b5dedb036e9d188fb7d493a8399b", + "status": "translated" + }, + "ru": { + "checksum": "c91ce325a54303e9490ad3abe4b54021", + "baseChecksum": "8a59b5dedb036e9d188fb7d493a8399b", + "status": "translated" + }, + "ja": { + "checksum": "48f128b0f16bff5863590d70c2db5b77", + "baseChecksum": "8a59b5dedb036e9d188fb7d493a8399b", + "status": "translated" + }, + "de": { + "checksum": "02198e67efd96cd9677ff4c6749e24ce", + "baseChecksum": "8a59b5dedb036e9d188fb7d493a8399b", + "status": "translated" + } + }, + "moreEntriesX": { + "en": { + "checksum": "202728286cbfd5fca0fb00ee7f096626" + }, + "es": { + "checksum": "7afc37e346271d59d53e8f6146f892a3", + "baseChecksum": "202728286cbfd5fca0fb00ee7f096626", + "status": "translated" + }, + "fr-ca": { + "checksum": "83c35606476cc6694df71004e096be49", + "baseChecksum": "202728286cbfd5fca0fb00ee7f096626", + "status": "translated" + }, + "ru": { + "checksum": "3502542358a12f30abf0f061d8cb7c44", + "baseChecksum": "202728286cbfd5fca0fb00ee7f096626", + "status": "translated" + }, + "ja": { + "checksum": "9b455e42059521ae81434fdf7e6559b6", + "baseChecksum": "202728286cbfd5fca0fb00ee7f096626", + "status": "translated" + }, + "de": { + "checksum": "0de089075cc1254b400da8990a129d69", + "baseChecksum": "202728286cbfd5fca0fb00ee7f096626", + "status": "translated" + } + }, + "alreadyExists": { + "en": { + "checksum": "d14907b4b74f981cb5e6fe8d9162c9ba" + }, + "es": { + "checksum": "870cb85155443209c31897a48e02c708", + "baseChecksum": "d14907b4b74f981cb5e6fe8d9162c9ba", + "status": "translated" + }, + "fr-ca": { + "checksum": "8b184e2095197c1c5eb898c4841cbfb8", + "baseChecksum": "d14907b4b74f981cb5e6fe8d9162c9ba", + "status": "translated" + }, + "ru": { + "checksum": "70b3c2f0a43ae1ecdca72e76d2d69678", + "baseChecksum": "d14907b4b74f981cb5e6fe8d9162c9ba", + "status": "translated" + }, + "ja": { + "checksum": "1fd1679b1d9603ea6880756561230f24", + "baseChecksum": "d14907b4b74f981cb5e6fe8d9162c9ba", + "status": "translated" + }, + "de": { + "checksum": "0e820b43a1525700b29522c54d26b41d", + "baseChecksum": "d14907b4b74f981cb5e6fe8d9162c9ba", + "status": "translated" + } + }, + "existsEntry": { + "en": { + "checksum": "e087923eb5dd1310f5f25ddd5ae5b580" + }, + "es": { + "checksum": "6e432137782303c9c10a7312f0b96670", + "baseChecksum": "e087923eb5dd1310f5f25ddd5ae5b580", + "status": "translated" + }, + "fr-ca": { + "checksum": "6e432137782303c9c10a7312f0b96670", + "baseChecksum": "e087923eb5dd1310f5f25ddd5ae5b580", + "status": "translated" + }, + "ru": { + "checksum": "f65c6bebeaac5c7dd457e578a8f22f08", + "baseChecksum": "e087923eb5dd1310f5f25ddd5ae5b580", + "status": "translated" + }, + "ja": { + "checksum": "4f640b87aa107afab3677bc3ffb3eab0", + "baseChecksum": "e087923eb5dd1310f5f25ddd5ae5b580", + "status": "translated" + }, + "de": { + "checksum": "f220c1c856369ce7eb5cd18ee9c4d999", + "baseChecksum": "e087923eb5dd1310f5f25ddd5ae5b580", + "status": "translated" + } + }, + "keyExists": { + "en": { + "checksum": "361d04114fa1374b9cef9a8734d7c242" + }, + "es": { + "checksum": "2c5792b366234820a57c5e4c132812ef", + "baseChecksum": "361d04114fa1374b9cef9a8734d7c242", + "status": "translated" + }, + "fr-ca": { + "checksum": "17a35397f8e76a6e772bbefc841ee8d6", + "baseChecksum": "361d04114fa1374b9cef9a8734d7c242", + "status": "translated" + }, + "ru": { + "checksum": "9bf6fb1bd5448d299cf8ea8138e17471", + "baseChecksum": "361d04114fa1374b9cef9a8734d7c242", + "status": "translated" + }, + "ja": { + "checksum": "4b9c525cfee3df49e623ce57183323b2", + "baseChecksum": "361d04114fa1374b9cef9a8734d7c242", + "status": "translated" + }, + "de": { + "checksum": "c64b5f3dcf86a6864cf80aeef2ae67de", + "baseChecksum": "361d04114fa1374b9cef9a8734d7c242", + "status": "translated" + } + }, + "similarExactX": { + "en": { + "checksum": "abbf2713d9b38e7112c1c6a73073335f" + }, + "es": { + "checksum": "a8e2e8f65d11cc529e25b08ee24ec161", + "baseChecksum": "abbf2713d9b38e7112c1c6a73073335f", + "status": "translated" + }, + "fr-ca": { + "checksum": "2088a062be0e20924ef085cb4c781554", + "baseChecksum": "abbf2713d9b38e7112c1c6a73073335f", + "status": "translated" + }, + "ru": { + "checksum": "18589c5cac5258f824420e70c424a25a", + "baseChecksum": "abbf2713d9b38e7112c1c6a73073335f", + "status": "translated" + }, + "ja": { + "checksum": "e43376b9f0850d5df6630b09afd577c0", + "baseChecksum": "abbf2713d9b38e7112c1c6a73073335f", + "status": "translated" + }, + "de": { + "checksum": "f60d4c1ca3d01abfa856b2b3e3eac952", + "baseChecksum": "abbf2713d9b38e7112c1c6a73073335f", + "status": "translated" + } + }, + "allUpToDate": { + "en": { + "checksum": "65ea91d58e9bcb385c864e0919b75372" + }, + "es": { + "checksum": "49fed9c6cac1385a6af013633ec56c05", + "baseChecksum": "65ea91d58e9bcb385c864e0919b75372", + "status": "translated" + }, + "fr-ca": { + "checksum": "35f52c90daac249509cb2baf5036bde9", + "baseChecksum": "65ea91d58e9bcb385c864e0919b75372", + "status": "translated" + }, + "ru": { + "checksum": "23b9fa897193034c5466eb378ff25875", + "baseChecksum": "65ea91d58e9bcb385c864e0919b75372", + "status": "translated" + }, + "ja": { + "checksum": "04c360bbe6d3b6b0e23738e9e0c66298", + "baseChecksum": "65ea91d58e9bcb385c864e0919b75372", + "status": "translated" + }, + "de": { + "checksum": "672973a51075d87cf588e90727f53141", + "baseChecksum": "65ea91d58e9bcb385c864e0919b75372", + "status": "translated" + } + } +} diff --git a/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json index feaab82..1b139c8 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/error/resource_entries.json @@ -10,14 +10,14 @@ "de": "Aktualisierung fehlgeschlagen: Ressourcendaten fehlen" }, "keyRenamingNotSupported": { - "source": "Key renaming is not yet supported. Please use the same key or create a new resource.", + "source": "Renaming isn't supported yet; use Move from the list to relocate.", "comment": "Error when user tries to change resource key", "tags": ["browser"], - "es": "Aún no se admite el cambio de nombre de clave. Utilice la misma clave o cree un nuevo recurso.", - "fr-ca": "Le renommage des clés n'est pas encore pris en charge. Veuillez utiliser la même clé ou créer une nouvelle ressource.", - "ru": "Функция переименования ключей пока не поддерживается. Пожалуйста, используйте тот же ключ или создайте новый ресурс.", - "ja": "キー名の変更はまだサポートされていません。同じキーを使用するか、新しいリソースを作成してください。", - "de": "Das Umbenennen von Schlüsseln wird noch nicht unterstützt. Bitte verwenden Sie denselben Schlüssel oder erstellen Sie eine neue Ressource." + "es": "Todavía no se admite el cambio de nombre; use Mover en la lista para reubicar.", + "fr-ca": "Le renommage n’est pas encore pris en charge; utilisez Déplacer dans la liste pour relocaliser.", + "ru": "Переименование пока не поддерживается; используйте «Переместить» в списке.", + "ja": "名前の変更はまだ対応していません。一覧の「移動」で移してください。", + "de": "Umbenennen wird noch nicht unterstützt; verwenden Sie „Verschieben“ in der Liste." }, "notFound": { "source": "Resource not found. It may have been deleted.", @@ -68,5 +68,15 @@ "ru": "Произошла непредвиденная ошибка.", "ja": "予期せぬエラーが発生しました", "de": "Es ist ein unerwarteter Fehler aufgetreten." + }, + "keyExistsInFolderX": { + "source": "{key} already exists in this folder. Pick another key, or open the existing entry.", + "comment": "Error under the key field when the typed key is already taken in the target folder", + "tags": ["browser"], + "es": "{key} ya existe en esta carpeta. Elija otra clave o abra la entrada existente.", + "fr-ca": "{key} existe déjà dans ce dossier. Choisissez une autre clé ou ouvrez l’entrée existante.", + "ru": "{key} уже существует в этой папке. Выберите другой ключ или откройте существующую запись.", + "ja": "{key} はこのフォルダに既に存在します。別のキーを選ぶか、既存のエントリを開いてください。", + "de": "{key} existiert bereits in diesem Ordner. Wählen Sie einen anderen Schlüssel oder öffnen Sie den vorhandenen Eintrag." } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json index 2d7c664..0a639c3 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/error/tracker_meta.json @@ -31,31 +31,31 @@ }, "keyRenamingNotSupported": { "en": { - "checksum": "575ba68788d69e22ad6e2d4ecdb01755" + "checksum": "e11fe5fb8a66d4ecc6bb4f98ddc2a174" }, "es": { - "checksum": "5b3ebac94b42ba189596e80457b94852", - "baseChecksum": "575ba68788d69e22ad6e2d4ecdb01755", + "checksum": "622043a00fb3621bc03aa4db2d25a81b", + "baseChecksum": "e11fe5fb8a66d4ecc6bb4f98ddc2a174", "status": "translated" }, "fr-ca": { - "checksum": "90d61297c3b4fbac388c4be2b71bcc64", - "baseChecksum": "575ba68788d69e22ad6e2d4ecdb01755", + "checksum": "aeeb9f2d4762a3054836dabf8201378d", + "baseChecksum": "e11fe5fb8a66d4ecc6bb4f98ddc2a174", "status": "translated" }, "ru": { - "checksum": "2bb377f62e8cd28ed1f101cf11565bb0", - "baseChecksum": "575ba68788d69e22ad6e2d4ecdb01755", + "checksum": "1c4ed40bacf41d7d83aeff77cd2907b8", + "baseChecksum": "e11fe5fb8a66d4ecc6bb4f98ddc2a174", "status": "translated" }, "ja": { - "checksum": "fb129524f433be0cb204402f3e85e9e1", - "baseChecksum": "575ba68788d69e22ad6e2d4ecdb01755", + "checksum": "3c90e9949d2068edf6f10183c142b8f4", + "baseChecksum": "e11fe5fb8a66d4ecc6bb4f98ddc2a174", "status": "translated" }, "de": { - "checksum": "440299bfba3af128ac1d34ee956a2488", - "baseChecksum": "575ba68788d69e22ad6e2d4ecdb01755", + "checksum": "99bfdba4b9384c681345682e3d12545e", + "baseChecksum": "e11fe5fb8a66d4ecc6bb4f98ddc2a174", "status": "translated" } }, @@ -208,5 +208,35 @@ "baseChecksum": "9789e322478c6f2d59e93d020808642a", "status": "translated" } + }, + "keyExistsInFolderX": { + "en": { + "checksum": "93bdfaa463db82f9696f5a0ca1a3a125" + }, + "es": { + "checksum": "3fc497f0f6a700f27a8572d7afaad38d", + "baseChecksum": "93bdfaa463db82f9696f5a0ca1a3a125", + "status": "translated" + }, + "fr-ca": { + "checksum": "a43f2935c5b55ae084c625609ce4d5ee", + "baseChecksum": "93bdfaa463db82f9696f5a0ca1a3a125", + "status": "translated" + }, + "ru": { + "checksum": "a343023ceddb68ac3409d12788552151", + "baseChecksum": "93bdfaa463db82f9696f5a0ca1a3a125", + "status": "translated" + }, + "ja": { + "checksum": "59f1e469cdaab1f4013902ebbf061ad6", + "baseChecksum": "93bdfaa463db82f9696f5a0ca1a3a125", + "status": "translated" + }, + "de": { + "checksum": "a18f6a39f7c6594694804aadf9a46ad7", + "baseChecksum": "93bdfaa463db82f9696f5a0ca1a3a125", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json index 3ba471c..e55ad7f 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/resource_entries.json @@ -10,24 +10,24 @@ "de": "Dialog schließen" }, "translationKey": { - "source": "Translation Key", + "source": "Key", "comment": "Label for the translation key input field", "tags": ["browser"], - "es": "Clave de traducción", - "fr-ca": "Clé de traduction", - "ru": "Ключ перевода", - "ja": "翻訳キー", - "de": "Übersetzungsschlüssel" + "es": "Clave", + "fr-ca": "Clé", + "ru": "Ключ", + "ja": "キー", + "de": "Schlüssel" }, "keyHint": { - "source": "Entry name only — paste a full dotted key and its folder part moves to Location", + "source": "Paste a dotted key and the folder part moves into Location.", "comment": "Hint under the translation key field in the editor dialog. Explains that only the last segment lives here, and that pasting a full dot-delimited key moves the prefix into the folder location.", "tags": ["browser"], - "es": "Solo el nombre de la entrada: pegue una clave completa con puntos y su carpeta pasará a Ubicación", - "fr-ca": "Nom de l'entrée seulement — collez une clé complète avec des points et son dossier ira dans Emplacement", - "ru": "Только имя записи — вставьте полный ключ с точками, и его папка перейдёт в поле «Папка»", - "ja": "エントリ名のみ。ドット区切りの完全なキーを貼り付けると、フォルダー部分は「フォルダー」に移ります", - "de": "Nur der Eintragsname — fügen Sie einen vollständigen Schlüssel mit Punkten ein, sein Ordnerteil wandert zu „Ordner“" + "es": "Pegue una clave con puntos y la parte de la carpeta pasará a Ubicación.", + "fr-ca": "Collez une clé avec des points et la partie dossier ira dans Emplacement.", + "ru": "Вставьте ключ с точками — часть с папкой перейдёт в «Расположение».", + "ja": "ドット区切りのキーを貼り付けると、フォルダ部分が「場所」に移動します。", + "de": "Fügen Sie einen Schlüssel mit Punkten ein; der Ordnerteil wandert in „Speicherort“." }, "baseBadge": { "source": "Base", @@ -90,24 +90,24 @@ "de": "Eine Basisübersetzung ist erforderlich." }, "commentLabel": { - "source": "Comment (Optional)", + "source": "Comment", "comment": "Label for optional comment field", "tags": ["browser"], - "es": "Comentario (opcional)", - "fr-ca": "Commentaire (facultatif)", - "ru": "Комментарий (необязательно)", - "ja": "コメント(任意)", - "de": "Kommentar (optional)" + "es": "Comentario", + "fr-ca": "Commentaire", + "ru": "Комментарий", + "ja": "コメント", + "de": "Kommentar" }, "commentPlaceholder": { - "source": "Add context for translators...", + "source": "Context or instructions, e.g. where this text appears", "comment": "Placeholder for comment input field", "tags": ["browser"], - "es": "Añadir contexto para los traductores...", - "fr-ca": "Ajouter du contexte pour les traducteurs...", - "ru": "Добавьте контекст для переводчиков...", - "ja": "翻訳者向けに背景情報を追加してください...", - "de": "Kontext für Übersetzer hinzufügen..." + "es": "Contexto o instrucciones, p. ej. dónde aparece este texto", + "fr-ca": "Contexte ou consignes, p. ex. où ce texte apparaît", + "ru": "Контекст или указания, например где появляется этот текст", + "ja": "文脈や指示(例:このテキストが表示される場所)", + "de": "Kontext oder Hinweise, z. B. wo dieser Text erscheint" }, "commentHint": { "source": "Provide context or instructions for translators", @@ -140,24 +140,24 @@ "de": "Speichern..." }, "editTitle": { - "source": "Edit Translation", + "source": "Edit translation", "comment": "Dialog title when editing an existing translation", "tags": ["browser"], "es": "Editar traducción", "fr-ca": "Modifier la traduction", "ru": "Редактировать перевод", - "ja": "翻訳を編集する", + "ja": "翻訳を編集", "de": "Übersetzung bearbeiten" }, "createTitle": { - "source": "Create Translation", + "source": "New translation", "comment": "Dialog title when creating a new translation", "tags": ["browser"], - "es": "Crear traducción", - "fr-ca": "Créer une traduction", - "ru": "Создать перевод", - "ja": "翻訳を作成する", - "de": "Übersetzung erstellen" + "es": "Nueva traducción", + "fr-ca": "Nouvelle traduction", + "ru": "Новый перевод", + "ja": "新しい翻訳", + "de": "Neue Übersetzung" }, "editSubtitle": { "source": "Update translation values and metadata", @@ -180,24 +180,24 @@ "de": "Füge deiner Sammlung einen neuen Übersetzungseintrag hinzu." }, "updateButton": { - "source": "Update Translation", + "source": "Save changes", "comment": "Submit button label in edit mode", "tags": ["browser"], - "es": "Actualizar traducción", - "fr-ca": "Mise à jour de la traduction", - "ru": "Обновить перевод", - "ja": "翻訳を更新", - "de": "Übersetzung aktualisieren" + "es": "Guardar cambios", + "fr-ca": "Enregistrer les modifications", + "ru": "Сохранить изменения", + "ja": "変更を保存", + "de": "Änderungen speichern" }, "saveButton": { - "source": "Save Translation", + "source": "Create translation", "comment": "Submit button label in create mode", "tags": ["browser"], - "es": "Guardar traducción", - "fr-ca": "Enregistrer la traduction", - "ru": "Сохранить перевод", - "ja": "翻訳を保存", - "de": "Übersetzung speichern" + "es": "Crear traducción", + "fr-ca": "Créer la traduction", + "ru": "Создать перевод", + "ja": "翻訳を作成", + "de": "Übersetzung erstellen" }, "keyCopied": { "source": "Similar resource key copied", @@ -318,5 +318,125 @@ "ru": "Папка задана как {folder} на основе введённого ключа", "ja": "入力されたキーからフォルダーを {folder} に設定しました", "de": "Ordner aus dem eingegebenen Schlüssel auf {folder} gesetzt" + }, + "createSubtitleX": { + "source": "Adds an entry to {collection}. Locales you leave empty are auto-translated after save.", + "comment": "Subtitle of the translation editor dialog in create mode; collection is the collection name", + "tags": ["browser"], + "es": "Añade una entrada a {collection}. Los idiomas que deje vacíos se traducen automáticamente al guardar.", + "fr-ca": "Ajoute une entrée à {collection}. Les langues laissées vides sont traduites automatiquement après l’enregistrement.", + "ru": "Добавляет запись в {collection}. Локали, оставленные пустыми, переводятся автоматически после сохранения.", + "ja": "{collection} にエントリを追加します。空のままのロケールは保存後に自動翻訳されます。", + "de": "Fügt {collection} einen Eintrag hinzu. Leer gelassene Sprachen werden nach dem Speichern automatisch übersetzt." + }, + "editSubtitleX": { + "source": "Saved to {collection} as a reviewable diff. The key can't be renamed here.", + "comment": "Subtitle of the translation editor dialog in edit mode; collection is the collection name", + "tags": ["browser"], + "es": "Se guarda en {collection} como una diferencia revisable. La clave no se puede renombrar aquí.", + "fr-ca": "Enregistré dans {collection} sous forme de différence révisable. La clé ne peut pas être renommée ici.", + "ru": "Сохраняется в {collection} как проверяемая разница. Ключ здесь переименовать нельзя.", + "ja": "{collection} にレビュー可能な差分として保存されます。ここではキー名を変更できません。", + "de": "Wird in {collection} als prüfbares Diff gespeichert. Der Schlüssel kann hier nicht umbenannt werden." + }, + "locationFieldLabel": { + "source": "Location", + "comment": "Label above the folder location pill in the translation editor dialog", + "tags": ["browser"], + "es": "Ubicación", + "fr-ca": "Emplacement", + "ru": "Расположение", + "ja": "場所", + "de": "Speicherort" + }, + "changeFolder": { + "source": "Change", + "comment": "Trigger inside the location pill that opens the folder picker popover", + "tags": ["browser"], + "es": "Cambiar", + "fr-ca": "Modifier", + "ru": "Изменить", + "ja": "変更", + "de": "Ändern" + }, + "keySegmentHint": { + "source": "one segment, no dots", + "comment": "Optional qualifier beside the Key label in the translation editor dialog", + "tags": ["browser"], + "es": "un solo segmento, sin puntos", + "fr-ca": "un seul segment, sans points", + "ru": "один сегмент, без точек", + "ja": "1 セグメント、ドットなし", + "de": "ein Segment, keine Punkte" + }, + "icuHintX": { + "source": "ICU placeholders like {placeholder} are preserved through translation.", + "comment": "Hint under the English value field; placeholder is a literal example such as {count}", + "tags": ["browser"], + "es": "Los marcadores ICU como {placeholder} se conservan durante la traducción.", + "fr-ca": "Les espaces réservés ICU comme {placeholder} sont conservés lors de la traduction.", + "ru": "Плейсхолдеры ICU, такие как {placeholder}, сохраняются при переводе.", + "ja": "{placeholder} のような ICU プレースホルダーは翻訳後も保持されます。", + "de": "ICU-Platzhalter wie {placeholder} bleiben bei der Übersetzung erhalten." + }, + "commentOptionalHint": { + "source": "for translators · asked to confirm if left empty", + "comment": "Qualifier beside the Comment label in the translation editor dialog", + "tags": ["browser"], + "es": "para traductores · se pedirá confirmación si se deja vacío", + "fr-ca": "pour les traducteurs · confirmation demandée si laissé vide", + "ru": "для переводчиков · при пустом поле запросим подтверждение", + "ja": "翻訳者向け · 空のままだと確認を求められます", + "de": "für Übersetzer · wird bei leerem Feld nachgefragt" + }, + "needWorkX": { + "source": "{count} need work", + "comment": "Summary of locales that are new or stale", + "tags": ["browser"], + "es": "{count} requieren trabajo", + "fr-ca": "{count} à retravailler", + "ru": "{count} требуют доработки", + "ja": "{count} 件が要対応", + "de": "{count} benötigen Nacharbeit" + }, + "autoTranslatedX": { + "source": "{count} · auto-translated", + "comment": "Summary of locales that will be auto-translated after save", + "tags": ["browser"], + "es": "{count} · traducidos automáticamente", + "fr-ca": "{count} · traduits automatiquement", + "ru": "{count} · переведено автоматически", + "ja": "{count} 件 · 自動翻訳", + "de": "{count} · automatisch übersetzt" + }, + "noTranslationYet": { + "source": "No translation yet", + "comment": "Placeholder in a locale value field that has no translation", + "tags": ["browser"], + "es": "Aún sin traducción", + "fr-ca": "Pas encore de traduction", + "ru": "Перевода пока нет", + "ja": "まだ翻訳がありません", + "de": "Noch keine Übersetzung" + }, + "leaveEmptyToAutoTranslate": { + "source": "Leave empty to auto-translate", + "comment": "Placeholder in a locale value field in create mode", + "tags": ["browser"], + "es": "Deje vacío para traducir automáticamente", + "fr-ca": "Laissez vide pour traduire automatiquement", + "ru": "Оставьте пустым для автоперевода", + "ja": "自動翻訳する場合は空のままにします", + "de": "Leer lassen für automatische Übersetzung" + }, + "autoTranslateNote": { + "source": "Empty locales are filled by auto-translation after you save. Protected terms and ICU placeholders stay untouched.", + "comment": "Note at the top of the other-locales drawer in create mode", + "tags": ["browser"], + "es": "Los idiomas vacíos se completan mediante traducción automática al guardar. Los términos protegidos y los marcadores ICU no se modifican.", + "fr-ca": "Les langues vides sont remplies par traduction automatique après l’enregistrement. Les termes protégés et les espaces réservés ICU restent intacts.", + "ru": "Пустые локали заполняются автоматическим переводом после сохранения. Защищённые термины и плейсхолдеры ICU остаются без изменений.", + "ja": "空のロケールは保存後に自動翻訳で補完されます。保護された用語と ICU プレースホルダーは変更されません。", + "de": "Leere Sprachen werden nach dem Speichern automatisch übersetzt. Geschützte Begriffe und ICU-Platzhalter bleiben unverändert." } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json index 46586e0..122651b 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tab/resource_entries.json @@ -20,7 +20,7 @@ "de": "Ordner ändern" }, "otherLocales": { - "source": "Other Locales", + "source": "Other locales", "comment": "Tab label for the other locales section in the editor dialog", "tags": ["browser"], "es": "Otros idiomas", diff --git a/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json index cda24e2..c8194ea 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tab/tracker_meta.json @@ -61,31 +61,31 @@ }, "otherLocales": { "en": { - "checksum": "aca1284fedfa7431bb8a9dc0b59fe6c6" + "checksum": "0d3490fe52087ff57f24fa23f82d930d" }, "es": { "checksum": "550c7cdfb92362bb233e70dfa2ad509c", - "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", + "baseChecksum": "0d3490fe52087ff57f24fa23f82d930d", "status": "translated" }, "fr-ca": { "checksum": "2e2ee859214fac995e01e6dfa885d4dc", - "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", + "baseChecksum": "0d3490fe52087ff57f24fa23f82d930d", "status": "translated" }, "ru": { "checksum": "acc46f241a646d68c8632fe59c2a2c00", - "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", + "baseChecksum": "0d3490fe52087ff57f24fa23f82d930d", "status": "translated" }, "ja": { "checksum": "2929efe49206febaad199b1c79eac0a9", - "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", + "baseChecksum": "0d3490fe52087ff57f24fa23f82d930d", "status": "translated" }, "de": { "checksum": "6313ed09e8f88fde09840484c4ac2b9f", - "baseChecksum": "aca1284fedfa7431bb8a9dc0b59fe6c6", + "baseChecksum": "0d3490fe52087ff57f24fa23f82d930d", "status": "translated" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tags/resource_entries.json b/apps/tracker/src/i18n/browser/translationEditor/tags/resource_entries.json index 6fd2b78..fa3e692 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tags/resource_entries.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tags/resource_entries.json @@ -10,14 +10,14 @@ "de": "Tags" }, "addPlaceholder": { - "source": "Add tag…", + "source": "Add a tag and press Enter", "comment": "Placeholder text inside the tag chip input in the translation editor dialog", "tags": ["browser"], - "es": "Agregar etiqueta…", - "fr-ca": "Ajouter une étiquette…", - "ru": "Добавить тег…", - "ja": "タグを追加…", - "de": "Tag hinzufügen…" + "es": "Agregue una etiqueta y presione Entrar", + "fr-ca": "Ajoutez une étiquette et appuyez sur Entrée", + "ru": "Добавьте тег и нажмите Enter", + "ja": "タグを入力して Enter キーを押します", + "de": "Tag eingeben und Eingabetaste drücken" }, "removeTagX": { "source": "Remove {tag}", @@ -48,5 +48,25 @@ "ru": "Используйте строчные буквы, дефисы разрешены (например, common-ui). Нажмите Enter или запятую для добавления.", "ja": "小文字、ハイフン可(例:common-ui)。Enterキーまたはカンマを押して追加してください。", "de": "Kleinbuchstaben, Bindestriche erlaubt (z. B. common-ui). Zum Hinzufügen die Eingabetaste oder ein Komma drücken." + }, + "inheritedHint": { + "source": "Inherited tags come from the collection and can't be removed here.", + "comment": "Hint under the tags field in the translation editor dialog", + "tags": ["browser"], + "es": "Las etiquetas heredadas provienen de la colección y no se pueden quitar aquí.", + "fr-ca": "Les étiquettes héritées proviennent de la collection et ne peuvent pas être retirées ici.", + "ru": "Унаследованные теги приходят из коллекции, их нельзя удалить здесь.", + "ja": "継承されたタグはコレクション由来のため、ここでは削除できません。", + "de": "Geerbte Tags stammen aus der Sammlung und können hier nicht entfernt werden." + }, + "addShort": { + "source": "Add tag", + "comment": "Short label inside the add-tag pill once the well already holds tags", + "tags": ["browser"], + "es": "Agregar etiqueta", + "fr-ca": "Ajouter une étiquette", + "ru": "Добавить тег", + "ja": "タグを追加", + "de": "Tag hinzufügen" } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json index d3681d7..aa5f008 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tags/tracker_meta.json @@ -31,31 +31,31 @@ }, "addPlaceholder": { "en": { - "checksum": "9c10f0e2ca34e83dd5048c7ea35bf8a6" + "checksum": "2a1f4e1edeb8ea99d0aa591022da9bd1" }, "es": { - "checksum": "decbb9c3ecc0b0333d907f1d00de679c", - "baseChecksum": "9c10f0e2ca34e83dd5048c7ea35bf8a6", + "checksum": "4ffe3472ff6c3e5ed602826047f16632", + "baseChecksum": "2a1f4e1edeb8ea99d0aa591022da9bd1", "status": "translated" }, "fr-ca": { - "checksum": "cf86b0a105a50a7de31eb46cdb8981f4", - "baseChecksum": "9c10f0e2ca34e83dd5048c7ea35bf8a6", + "checksum": "7e880d2c5a158aba0ca5b56aac7ebf1e", + "baseChecksum": "2a1f4e1edeb8ea99d0aa591022da9bd1", "status": "translated" }, "ru": { - "checksum": "61132e59266792930cefcdf6a3caa8aa", - "baseChecksum": "9c10f0e2ca34e83dd5048c7ea35bf8a6", + "checksum": "84db04e9983f8b34a1d305f6896d6e51", + "baseChecksum": "2a1f4e1edeb8ea99d0aa591022da9bd1", "status": "translated" }, "ja": { - "checksum": "fa35297e92a1654e5f93d119af9a9b41", - "baseChecksum": "9c10f0e2ca34e83dd5048c7ea35bf8a6", + "checksum": "2c239b335319e61d404489918b30b5a7", + "baseChecksum": "2a1f4e1edeb8ea99d0aa591022da9bd1", "status": "translated" }, "de": { - "checksum": "9f02ef565730c10882ff4258c7b2b91f", - "baseChecksum": "9c10f0e2ca34e83dd5048c7ea35bf8a6", + "checksum": "4da976a941cb3d41e253dbe8c5ac36be", + "baseChecksum": "2a1f4e1edeb8ea99d0aa591022da9bd1", "status": "translated" } }, @@ -118,5 +118,65 @@ "baseChecksum": "fbd3fc15976ff12c89e7c48584b388d6", "status": "translated" } + }, + "inheritedHint": { + "en": { + "checksum": "16d09ee60bf43105a45475bc908ff7d6" + }, + "es": { + "checksum": "4e452fc7dc42bab0b9249c307fd58dea", + "baseChecksum": "16d09ee60bf43105a45475bc908ff7d6", + "status": "translated" + }, + "fr-ca": { + "checksum": "d42573237a0763d278aaeea749c6a77b", + "baseChecksum": "16d09ee60bf43105a45475bc908ff7d6", + "status": "translated" + }, + "ru": { + "checksum": "43703a1d4c52ad81c8a36f6f5e3e1e3d", + "baseChecksum": "16d09ee60bf43105a45475bc908ff7d6", + "status": "translated" + }, + "ja": { + "checksum": "506a3c60f2964aab86c62acb2a3626d8", + "baseChecksum": "16d09ee60bf43105a45475bc908ff7d6", + "status": "translated" + }, + "de": { + "checksum": "ade1a19637ca89dd9da2b148cfdfd026", + "baseChecksum": "16d09ee60bf43105a45475bc908ff7d6", + "status": "translated" + } + }, + "addShort": { + "en": { + "checksum": "2efd89b3ccc76b0b03a34196fc6d1c8b" + }, + "es": { + "checksum": "6b67e53a1ebc84dfb3cfd86ac815835c", + "baseChecksum": "2efd89b3ccc76b0b03a34196fc6d1c8b", + "status": "translated" + }, + "fr-ca": { + "checksum": "a1906d0f7470f28198fccd827cb1811a", + "baseChecksum": "2efd89b3ccc76b0b03a34196fc6d1c8b", + "status": "translated" + }, + "ru": { + "checksum": "560be0e7f30a387ec7275e8018eed177", + "baseChecksum": "2efd89b3ccc76b0b03a34196fc6d1c8b", + "status": "translated" + }, + "ja": { + "checksum": "3378740420db21b9cf4dfd7d91c5566e", + "baseChecksum": "2efd89b3ccc76b0b03a34196fc6d1c8b", + "status": "translated" + }, + "de": { + "checksum": "f95c6a8d2d68a11c54e56cf198038e5a", + "baseChecksum": "2efd89b3ccc76b0b03a34196fc6d1c8b", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json b/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json index 2dbeefb..0ed4b19 100644 --- a/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json +++ b/apps/tracker/src/i18n/browser/translationEditor/tracker_meta.json @@ -31,61 +31,61 @@ }, "translationKey": { "en": { - "checksum": "482823ac52ef47827d3f18f259387366" + "checksum": "897356954c2cd3d41b221e3f24f99bba" }, "es": { - "checksum": "ef3ca378eddd213605ffaf744789d4e9", - "baseChecksum": "482823ac52ef47827d3f18f259387366", + "checksum": "6f507de48da41f024a19a92881b1494b", + "baseChecksum": "897356954c2cd3d41b221e3f24f99bba", "status": "translated" }, "fr-ca": { - "checksum": "92816cfd37b340742301c8b626623651", - "baseChecksum": "482823ac52ef47827d3f18f259387366", + "checksum": "0228e1c903e931672805d58f3edd296d", + "baseChecksum": "897356954c2cd3d41b221e3f24f99bba", "status": "translated" }, "ru": { - "checksum": "ad67eeaf79c8f882b6eb5cecf892322a", - "baseChecksum": "482823ac52ef47827d3f18f259387366", + "checksum": "6252727443e5a8de378aba95bd2405c8", + "baseChecksum": "897356954c2cd3d41b221e3f24f99bba", "status": "translated" }, "ja": { - "checksum": "ad653346ad1c2bf5be218a569429dafa", - "baseChecksum": "482823ac52ef47827d3f18f259387366", + "checksum": "8cde9c0ec496566aba27be98508c48a0", + "baseChecksum": "897356954c2cd3d41b221e3f24f99bba", "status": "translated" }, "de": { - "checksum": "70cd2f99392575c8bc14d4cf46d9ef9a", - "baseChecksum": "482823ac52ef47827d3f18f259387366", + "checksum": "0f511d362346c2f658bb594d5b04224e", + "baseChecksum": "897356954c2cd3d41b221e3f24f99bba", "status": "translated" } }, "keyHint": { "en": { - "checksum": "7d575a4bbda281c6c59b61a09c632b9f" + "checksum": "64849acf1c7f0076a5d402dcc3a1f8e9" }, "es": { - "checksum": "2bbaa6703d4bd47a4d89c1cae7e35776", - "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", + "checksum": "f5c45144a7b6e158ed79c717de1b4255", + "baseChecksum": "64849acf1c7f0076a5d402dcc3a1f8e9", "status": "translated" }, "fr-ca": { - "checksum": "b46deba29548de7d3902685aaa62c6ae", - "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", + "checksum": "15d1ce73764d76e9de37b6fc84e0b3c6", + "baseChecksum": "64849acf1c7f0076a5d402dcc3a1f8e9", "status": "translated" }, "ru": { - "checksum": "36b133727fa07e130f5d32c970b8e1bf", - "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", + "checksum": "76c82c2dab1d10ca581868121d797ca2", + "baseChecksum": "64849acf1c7f0076a5d402dcc3a1f8e9", "status": "translated" }, "ja": { - "checksum": "aaef9b27a6c779323e5d34aeac15558d", - "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", + "checksum": "8464048e1f8bf43cb8c95c8b5e531a2c", + "baseChecksum": "64849acf1c7f0076a5d402dcc3a1f8e9", "status": "translated" }, "de": { - "checksum": "3f7a97ba0dec4691bd25bbb5ada33e58", - "baseChecksum": "7d575a4bbda281c6c59b61a09c632b9f", + "checksum": "4e1b937cc120e930997b31d051e8fca6", + "baseChecksum": "64849acf1c7f0076a5d402dcc3a1f8e9", "status": "translated" } }, @@ -271,61 +271,61 @@ }, "commentLabel": { "en": { - "checksum": "3b45c3cd1875b697361bb48eee5c815d" + "checksum": "0be8406951cdfda82f00f79328cf4efc" }, "es": { - "checksum": "e406e85a02d776748c7451abf253887d", - "baseChecksum": "3b45c3cd1875b697361bb48eee5c815d", + "checksum": "a3b8e49703a26e07d26f7659f1b0f8e0", + "baseChecksum": "0be8406951cdfda82f00f79328cf4efc", "status": "translated" }, "fr-ca": { - "checksum": "91a81b9ea21a3a27b3ad7732cd9466be", - "baseChecksum": "3b45c3cd1875b697361bb48eee5c815d", + "checksum": "721a8dbd27fcbb90fa6551c25468bcb3", + "baseChecksum": "0be8406951cdfda82f00f79328cf4efc", "status": "translated" }, "ru": { - "checksum": "7d9f639233478a28d6bbedf16807a142", - "baseChecksum": "3b45c3cd1875b697361bb48eee5c815d", + "checksum": "8d7ae9e25c2fc07856a1406d06bfd3f4", + "baseChecksum": "0be8406951cdfda82f00f79328cf4efc", "status": "translated" }, "ja": { - "checksum": "6386c833772eb5bbd9b351816b8609ba", - "baseChecksum": "3b45c3cd1875b697361bb48eee5c815d", + "checksum": "2f02d461f795c97d1ab2a6f3bdf1f3a2", + "baseChecksum": "0be8406951cdfda82f00f79328cf4efc", "status": "translated" }, "de": { - "checksum": "4cac0bd545991c45f894df028deeb506", - "baseChecksum": "3b45c3cd1875b697361bb48eee5c815d", + "checksum": "cd05591e8d0d62d2c11ba7421d80cdba", + "baseChecksum": "0be8406951cdfda82f00f79328cf4efc", "status": "translated" } }, "commentPlaceholder": { "en": { - "checksum": "04627d882217f6ca0b5db70ab11645bd" + "checksum": "cc659007ecb6739317d7dd8b3f80bf32" }, "es": { - "checksum": "f92a4c4d72ba51c4cec3b0d3e7c22ffa", - "baseChecksum": "04627d882217f6ca0b5db70ab11645bd", + "checksum": "71ea457c54112619bccbf97ae78c8b8c", + "baseChecksum": "cc659007ecb6739317d7dd8b3f80bf32", "status": "translated" }, "fr-ca": { - "checksum": "cb30420c45287765b0c246e6b7b0f16f", - "baseChecksum": "04627d882217f6ca0b5db70ab11645bd", + "checksum": "dc87174d142b51c83516f36da99ac78a", + "baseChecksum": "cc659007ecb6739317d7dd8b3f80bf32", "status": "translated" }, "ru": { - "checksum": "e045e9980e2ca2330e2d9809884de776", - "baseChecksum": "04627d882217f6ca0b5db70ab11645bd", + "checksum": "298500d31d9095aebe3c15e008169254", + "baseChecksum": "cc659007ecb6739317d7dd8b3f80bf32", "status": "translated" }, "ja": { - "checksum": "35cd45233f06236bdf391a00c1fded6b", - "baseChecksum": "04627d882217f6ca0b5db70ab11645bd", + "checksum": "793fc37d465375be27e7f1f814899b36", + "baseChecksum": "cc659007ecb6739317d7dd8b3f80bf32", "status": "translated" }, "de": { - "checksum": "e43350b1f44582905f1cd89d1f94f9d6", - "baseChecksum": "04627d882217f6ca0b5db70ab11645bd", + "checksum": "2597ea01e38a25b6b9db72fb86add52e", + "baseChecksum": "cc659007ecb6739317d7dd8b3f80bf32", "status": "translated" } }, @@ -421,61 +421,61 @@ }, "editTitle": { "en": { - "checksum": "305253475cc868103805068f45a90468" + "checksum": "a7f6326fc128712ffaee83351ea67525" }, "es": { "checksum": "ced3578e5f666ec8ac6cd82a673d9213", - "baseChecksum": "305253475cc868103805068f45a90468", + "baseChecksum": "a7f6326fc128712ffaee83351ea67525", "status": "translated" }, "fr-ca": { "checksum": "faddb24e4a6d65187240a73f67279d41", - "baseChecksum": "305253475cc868103805068f45a90468", + "baseChecksum": "a7f6326fc128712ffaee83351ea67525", "status": "translated" }, "ru": { "checksum": "420f0d3ce399f2011c6ed08db0966aeb", - "baseChecksum": "305253475cc868103805068f45a90468", + "baseChecksum": "a7f6326fc128712ffaee83351ea67525", "status": "translated" }, "ja": { - "checksum": "68977133e365bb4aabaa769438bbd99b", - "baseChecksum": "305253475cc868103805068f45a90468", + "checksum": "523818d9859de1a8a74e9ab9fef29da6", + "baseChecksum": "a7f6326fc128712ffaee83351ea67525", "status": "translated" }, "de": { "checksum": "fe65d49ee802525c78ef798bbf7fa7ff", - "baseChecksum": "305253475cc868103805068f45a90468", + "baseChecksum": "a7f6326fc128712ffaee83351ea67525", "status": "translated" } }, "createTitle": { "en": { - "checksum": "dabf3cb410b22a2cd138d2ebe54717b1" + "checksum": "bd188cbd0337a4a79465d8732d7bb024" }, "es": { - "checksum": "5a1497d1e21d3c439ad011c2bb725a7c", - "baseChecksum": "dabf3cb410b22a2cd138d2ebe54717b1", + "checksum": "0af9b1aa7225a05a90efe157f872b7a2", + "baseChecksum": "bd188cbd0337a4a79465d8732d7bb024", "status": "translated" }, "fr-ca": { - "checksum": "6dace602a9413456da723a74ccea1247", - "baseChecksum": "dabf3cb410b22a2cd138d2ebe54717b1", + "checksum": "84e8ef72a7bbf0080c627e654642c3ae", + "baseChecksum": "bd188cbd0337a4a79465d8732d7bb024", "status": "translated" }, "ru": { - "checksum": "44dadb05d363f38d2b90a564e5aa5cde", - "baseChecksum": "dabf3cb410b22a2cd138d2ebe54717b1", + "checksum": "964c55147ab4d9b0b76db1e76ae2cd7c", + "baseChecksum": "bd188cbd0337a4a79465d8732d7bb024", "status": "translated" }, "ja": { - "checksum": "171e5294db84907dcb90354c51dda198", - "baseChecksum": "dabf3cb410b22a2cd138d2ebe54717b1", + "checksum": "59000eb8f1e10e071be95a5a545f0e81", + "baseChecksum": "bd188cbd0337a4a79465d8732d7bb024", "status": "translated" }, "de": { - "checksum": "52279682a6da0375eca2ee0189703aa1", - "baseChecksum": "dabf3cb410b22a2cd138d2ebe54717b1", + "checksum": "99c64e35785998cd30f6224b0587e771", + "baseChecksum": "bd188cbd0337a4a79465d8732d7bb024", "status": "translated" } }, @@ -541,61 +541,61 @@ }, "updateButton": { "en": { - "checksum": "e8d2af23aa47989c080ffc7252646485" + "checksum": "daa6b483e9f6ca081ec7e0b4a352f9e9" }, "es": { - "checksum": "b56d1fa3f55f78c62272471927fc626c", - "baseChecksum": "e8d2af23aa47989c080ffc7252646485", + "checksum": "7d8ea649aab655065a5d7ff51d961171", + "baseChecksum": "daa6b483e9f6ca081ec7e0b4a352f9e9", "status": "translated" }, "fr-ca": { - "checksum": "932e59c4532010533cca59bdfc93fc9f", - "baseChecksum": "e8d2af23aa47989c080ffc7252646485", + "checksum": "46e387f55d1180905f120ded42bad7b3", + "baseChecksum": "daa6b483e9f6ca081ec7e0b4a352f9e9", "status": "translated" }, "ru": { - "checksum": "e3301cba3a8328585a5d8da49efabae7", - "baseChecksum": "e8d2af23aa47989c080ffc7252646485", + "checksum": "8d0818b8ddb95a3787ce7fd99696d060", + "baseChecksum": "daa6b483e9f6ca081ec7e0b4a352f9e9", "status": "translated" }, "ja": { - "checksum": "1540d03eec10bf998ff2bb5e35a24073", - "baseChecksum": "e8d2af23aa47989c080ffc7252646485", + "checksum": "4fbfb5a9bba28d46e02b6d36d60a927a", + "baseChecksum": "daa6b483e9f6ca081ec7e0b4a352f9e9", "status": "translated" }, "de": { - "checksum": "18c445c51e69f22062658cebff3c2818", - "baseChecksum": "e8d2af23aa47989c080ffc7252646485", + "checksum": "2aecef96f81b160fed14d90375699a85", + "baseChecksum": "daa6b483e9f6ca081ec7e0b4a352f9e9", "status": "translated" } }, "saveButton": { "en": { - "checksum": "d503198287eeadd483ae40abef48027a" + "checksum": "2489d9ee925fbedaa2ae8f84e7347147" }, "es": { - "checksum": "a6f7cf37f42e078d713c9eb97270d44d", - "baseChecksum": "d503198287eeadd483ae40abef48027a", + "checksum": "5a1497d1e21d3c439ad011c2bb725a7c", + "baseChecksum": "2489d9ee925fbedaa2ae8f84e7347147", "status": "translated" }, "fr-ca": { - "checksum": "0a65eff2cde1b1c920bc8bfca7fb0a6f", - "baseChecksum": "d503198287eeadd483ae40abef48027a", + "checksum": "6847ba67b640f91cda4a21b4584189f1", + "baseChecksum": "2489d9ee925fbedaa2ae8f84e7347147", "status": "translated" }, "ru": { - "checksum": "56c170b801d292304f7d51848bbbb5b1", - "baseChecksum": "d503198287eeadd483ae40abef48027a", + "checksum": "44dadb05d363f38d2b90a564e5aa5cde", + "baseChecksum": "2489d9ee925fbedaa2ae8f84e7347147", "status": "translated" }, "ja": { - "checksum": "b0b41dcc457c3d6888856ad2784a5114", - "baseChecksum": "d503198287eeadd483ae40abef48027a", + "checksum": "96a0888d2c2b9a1b032cdd623047f705", + "baseChecksum": "2489d9ee925fbedaa2ae8f84e7347147", "status": "translated" }, "de": { - "checksum": "ccaa8b7bda6ec28440fd197f582fdb63", - "baseChecksum": "d503198287eeadd483ae40abef48027a", + "checksum": "52279682a6da0375eca2ee0189703aa1", + "baseChecksum": "2489d9ee925fbedaa2ae8f84e7347147", "status": "translated" } }, @@ -958,5 +958,365 @@ "baseChecksum": "e155c712f48c13481d3bea3a90d5db0b", "status": "translated" } + }, + "createSubtitleX": { + "en": { + "checksum": "0f808aa16beb51a8d44917264d270298" + }, + "es": { + "checksum": "755061cc8178197e8b6bda0d29e64515", + "baseChecksum": "0f808aa16beb51a8d44917264d270298", + "status": "translated" + }, + "fr-ca": { + "checksum": "bec32d8df981f66108d4d8ccc8462e05", + "baseChecksum": "0f808aa16beb51a8d44917264d270298", + "status": "translated" + }, + "ru": { + "checksum": "bdf59b68a1f1d94194de0ec49e48384a", + "baseChecksum": "0f808aa16beb51a8d44917264d270298", + "status": "translated" + }, + "ja": { + "checksum": "a6c998aebb62819a92f75eec5b980784", + "baseChecksum": "0f808aa16beb51a8d44917264d270298", + "status": "translated" + }, + "de": { + "checksum": "407cdee8f838b890423a87009a5df2f7", + "baseChecksum": "0f808aa16beb51a8d44917264d270298", + "status": "translated" + } + }, + "editSubtitleX": { + "en": { + "checksum": "261751b39410a3368a0bd5ad0d8b7362" + }, + "es": { + "checksum": "b3f42ee7011c529b1a7ec646e63c1b27", + "baseChecksum": "261751b39410a3368a0bd5ad0d8b7362", + "status": "translated" + }, + "fr-ca": { + "checksum": "c0a9930ff6a4d0b397d539f1c68d4cc4", + "baseChecksum": "261751b39410a3368a0bd5ad0d8b7362", + "status": "translated" + }, + "ru": { + "checksum": "295b6eb0a9389ff520d3e807d2c26690", + "baseChecksum": "261751b39410a3368a0bd5ad0d8b7362", + "status": "translated" + }, + "ja": { + "checksum": "f1cfcf351fce5b40d989305fc5b4c3b7", + "baseChecksum": "261751b39410a3368a0bd5ad0d8b7362", + "status": "translated" + }, + "de": { + "checksum": "2c914b2e6259540dec75c039fa6a2a73", + "baseChecksum": "261751b39410a3368a0bd5ad0d8b7362", + "status": "translated" + } + }, + "locationFieldLabel": { + "en": { + "checksum": "ce5bf551379459c1c61d2a204061c455" + }, + "es": { + "checksum": "a542b898626126e83b69f9fad8ab52f0", + "baseChecksum": "ce5bf551379459c1c61d2a204061c455", + "status": "translated" + }, + "fr-ca": { + "checksum": "b6a6e6ea381351bd7d7d2f56932ec804", + "baseChecksum": "ce5bf551379459c1c61d2a204061c455", + "status": "translated" + }, + "ru": { + "checksum": "06d897a2b68c63493b65390fe35e7a2a", + "baseChecksum": "ce5bf551379459c1c61d2a204061c455", + "status": "translated" + }, + "ja": { + "checksum": "8a2613179846640102496776dfc59b67", + "baseChecksum": "ce5bf551379459c1c61d2a204061c455", + "status": "translated" + }, + "de": { + "checksum": "601a9349ceab87966344647e5cae556e", + "baseChecksum": "ce5bf551379459c1c61d2a204061c455", + "status": "translated" + } + }, + "changeFolder": { + "en": { + "checksum": "f4ec5f57bd4d31b803312d873be40da9" + }, + "es": { + "checksum": "d1bdc329ca14541b3effb06126c5f12e", + "baseChecksum": "f4ec5f57bd4d31b803312d873be40da9", + "status": "translated" + }, + "fr-ca": { + "checksum": "898c079d2e62a4b1971dfbad9d860838", + "baseChecksum": "f4ec5f57bd4d31b803312d873be40da9", + "status": "translated" + }, + "ru": { + "checksum": "47e2436e5560837160a70c64466ea22b", + "baseChecksum": "f4ec5f57bd4d31b803312d873be40da9", + "status": "translated" + }, + "ja": { + "checksum": "fbcc6e3e9b8391b19413f917e0edb0b3", + "baseChecksum": "f4ec5f57bd4d31b803312d873be40da9", + "status": "translated" + }, + "de": { + "checksum": "59b0fe8d3fee044fb03d40984e6300ed", + "baseChecksum": "f4ec5f57bd4d31b803312d873be40da9", + "status": "translated" + } + }, + "keySegmentHint": { + "en": { + "checksum": "5aa7656c58062d199af023d9a642e2f3" + }, + "es": { + "checksum": "0f9071f42e328516e4db743dec5ee927", + "baseChecksum": "5aa7656c58062d199af023d9a642e2f3", + "status": "translated" + }, + "fr-ca": { + "checksum": "1f3a8ce45f0897696c8fa3ddc4c58f99", + "baseChecksum": "5aa7656c58062d199af023d9a642e2f3", + "status": "translated" + }, + "ru": { + "checksum": "f4aa923be60a6fa70deef931a43560a9", + "baseChecksum": "5aa7656c58062d199af023d9a642e2f3", + "status": "translated" + }, + "ja": { + "checksum": "ab12020543ab1e7fd5991a9ee4a6eed3", + "baseChecksum": "5aa7656c58062d199af023d9a642e2f3", + "status": "translated" + }, + "de": { + "checksum": "7ae65043ac8857589ecc85ebd18376a4", + "baseChecksum": "5aa7656c58062d199af023d9a642e2f3", + "status": "translated" + } + }, + "icuHintX": { + "en": { + "checksum": "87e983dd94cd30f65730764fc3069535" + }, + "es": { + "checksum": "389317e8dd8846a8b13f56c92eafbf64", + "baseChecksum": "87e983dd94cd30f65730764fc3069535", + "status": "translated" + }, + "fr-ca": { + "checksum": "b52c45b990e206db0e62b8d24139600b", + "baseChecksum": "87e983dd94cd30f65730764fc3069535", + "status": "translated" + }, + "ru": { + "checksum": "bb3871586790cd34bb35e2f0ff185bbc", + "baseChecksum": "87e983dd94cd30f65730764fc3069535", + "status": "translated" + }, + "ja": { + "checksum": "178183dc9268d61525096253b49cd1a8", + "baseChecksum": "87e983dd94cd30f65730764fc3069535", + "status": "translated" + }, + "de": { + "checksum": "90ea37818327ea35a7f812d1d5eda704", + "baseChecksum": "87e983dd94cd30f65730764fc3069535", + "status": "translated" + } + }, + "commentOptionalHint": { + "en": { + "checksum": "026ae704e37dad754f7fcc33a3665bf4" + }, + "es": { + "checksum": "7d968853ce90133dcc97306f15a41460", + "baseChecksum": "026ae704e37dad754f7fcc33a3665bf4", + "status": "translated" + }, + "fr-ca": { + "checksum": "821fbc672ffef736b4d7640087dc2285", + "baseChecksum": "026ae704e37dad754f7fcc33a3665bf4", + "status": "translated" + }, + "ru": { + "checksum": "19344daa7966c5d5a0a5014b76b9b651", + "baseChecksum": "026ae704e37dad754f7fcc33a3665bf4", + "status": "translated" + }, + "ja": { + "checksum": "faf6139d8890d7df2151ea09e7bdb7d8", + "baseChecksum": "026ae704e37dad754f7fcc33a3665bf4", + "status": "translated" + }, + "de": { + "checksum": "558516fbab74d0535d6082331770dbbc", + "baseChecksum": "026ae704e37dad754f7fcc33a3665bf4", + "status": "translated" + } + }, + "needWorkX": { + "en": { + "checksum": "6db7ff19a80f4a5dfa8d2ecca1fc6b82" + }, + "es": { + "checksum": "50f359daf3cc76f1551aa1fbc95134f0", + "baseChecksum": "6db7ff19a80f4a5dfa8d2ecca1fc6b82", + "status": "translated" + }, + "fr-ca": { + "checksum": "67ed7d5babce1f85e9be68a0f443d0f7", + "baseChecksum": "6db7ff19a80f4a5dfa8d2ecca1fc6b82", + "status": "translated" + }, + "ru": { + "checksum": "81fa7ecb5b5af5eec08907f764e53497", + "baseChecksum": "6db7ff19a80f4a5dfa8d2ecca1fc6b82", + "status": "translated" + }, + "ja": { + "checksum": "42fe51dbb7d6f26d36878b2a2523beff", + "baseChecksum": "6db7ff19a80f4a5dfa8d2ecca1fc6b82", + "status": "translated" + }, + "de": { + "checksum": "a6046ab4cc3e7f41233e7ac3571ed0f7", + "baseChecksum": "6db7ff19a80f4a5dfa8d2ecca1fc6b82", + "status": "translated" + } + }, + "autoTranslatedX": { + "en": { + "checksum": "c6bd3fa1cf4728ef1b8eff4e441010dc" + }, + "es": { + "checksum": "f17f8dd2fe3b2878e1e78365c2fa2266", + "baseChecksum": "c6bd3fa1cf4728ef1b8eff4e441010dc", + "status": "translated" + }, + "fr-ca": { + "checksum": "99d73d2000f1d9b36dd0c28aa3d9990e", + "baseChecksum": "c6bd3fa1cf4728ef1b8eff4e441010dc", + "status": "translated" + }, + "ru": { + "checksum": "0cf90d3d5f251bd49d06cfe120a9b428", + "baseChecksum": "c6bd3fa1cf4728ef1b8eff4e441010dc", + "status": "translated" + }, + "ja": { + "checksum": "5905712e0f387c296026c97e29a7ff2c", + "baseChecksum": "c6bd3fa1cf4728ef1b8eff4e441010dc", + "status": "translated" + }, + "de": { + "checksum": "7dac1f1b56de5527b196f8f7a731d46b", + "baseChecksum": "c6bd3fa1cf4728ef1b8eff4e441010dc", + "status": "translated" + } + }, + "noTranslationYet": { + "en": { + "checksum": "2428ef9dd518341cb380feffb7167089" + }, + "es": { + "checksum": "e3557d57fa5d56399488dd84784fa460", + "baseChecksum": "2428ef9dd518341cb380feffb7167089", + "status": "translated" + }, + "fr-ca": { + "checksum": "748dff376f3ef93ed31fe09087f535f6", + "baseChecksum": "2428ef9dd518341cb380feffb7167089", + "status": "translated" + }, + "ru": { + "checksum": "72181e18763c6dd137d37bec3e3c02f6", + "baseChecksum": "2428ef9dd518341cb380feffb7167089", + "status": "translated" + }, + "ja": { + "checksum": "bf0174fa4eb987f8c6d46bad847f817a", + "baseChecksum": "2428ef9dd518341cb380feffb7167089", + "status": "translated" + }, + "de": { + "checksum": "b6f87ab76bc68467ba9f89bf16461ce8", + "baseChecksum": "2428ef9dd518341cb380feffb7167089", + "status": "translated" + } + }, + "leaveEmptyToAutoTranslate": { + "en": { + "checksum": "2ead8c26f7723926197ca1745e5855ea" + }, + "es": { + "checksum": "31ad5de6965b8b81b12f609cd7addfb7", + "baseChecksum": "2ead8c26f7723926197ca1745e5855ea", + "status": "translated" + }, + "fr-ca": { + "checksum": "44481a490a3b8333ddd68dbcf98b97c9", + "baseChecksum": "2ead8c26f7723926197ca1745e5855ea", + "status": "translated" + }, + "ru": { + "checksum": "81f45ea4eb1944699983069de41f590e", + "baseChecksum": "2ead8c26f7723926197ca1745e5855ea", + "status": "translated" + }, + "ja": { + "checksum": "3059ee5e0aeb4b69745e49af92a6608d", + "baseChecksum": "2ead8c26f7723926197ca1745e5855ea", + "status": "translated" + }, + "de": { + "checksum": "f7449e17aaefe91c8e875515cfd0f4ac", + "baseChecksum": "2ead8c26f7723926197ca1745e5855ea", + "status": "translated" + } + }, + "autoTranslateNote": { + "en": { + "checksum": "1efa1e85283ed4a018441b35da7c49fb" + }, + "es": { + "checksum": "f53f9792d25a3c96b3e1201d6d2ea339", + "baseChecksum": "1efa1e85283ed4a018441b35da7c49fb", + "status": "translated" + }, + "fr-ca": { + "checksum": "795c4ba8c4771a5c97aa891a0808142b", + "baseChecksum": "1efa1e85283ed4a018441b35da7c49fb", + "status": "translated" + }, + "ru": { + "checksum": "9c32d338cdf666a5d7f9ee24b546cc68", + "baseChecksum": "1efa1e85283ed4a018441b35da7c49fb", + "status": "translated" + }, + "ja": { + "checksum": "5b5fbacd746cf1e1e34e4a134b4a5530", + "baseChecksum": "1efa1e85283ed4a018441b35da7c49fb", + "status": "translated" + }, + "de": { + "checksum": "2b5571e8841ef8250dbaeb8a5e9f530d", + "baseChecksum": "1efa1e85283ed4a018441b35da7c49fb", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/common/actions/resource_entries.json b/apps/tracker/src/i18n/common/actions/resource_entries.json index 83f3565..d4105ef 100644 --- a/apps/tracker/src/i18n/common/actions/resource_entries.json +++ b/apps/tracker/src/i18n/common/actions/resource_entries.json @@ -141,5 +141,25 @@ "ru": "View", "ja": "View", "de": "View" + }, + "done": { + "source": "Done", + "comment": "Generic action label that closes a panel and keeps the changes", + "tags": ["common"], + "es": "Listo", + "fr-ca": "Terminé", + "ru": "Готово", + "ja": "完了", + "de": "Fertig" + }, + "open": { + "source": "Open", + "comment": "Action label that leaves for an existing item", + "tags": ["common"], + "es": "Abrir", + "fr-ca": "Ouvrir", + "ru": "Открыть", + "ja": "開く", + "de": "Öffnen" } } diff --git a/apps/tracker/src/i18n/common/actions/tracker_meta.json b/apps/tracker/src/i18n/common/actions/tracker_meta.json index ca6b8e2..b7cf4c4 100644 --- a/apps/tracker/src/i18n/common/actions/tracker_meta.json +++ b/apps/tracker/src/i18n/common/actions/tracker_meta.json @@ -448,5 +448,65 @@ "baseChecksum": "4351cfebe4b61d8aa5efa1d020710005", "status": "new" } + }, + "done": { + "en": { + "checksum": "f92965e2c8a7afb3c1b9a5c09a263636" + }, + "es": { + "checksum": "8ddd8d88391896aa4d9e751dee817707", + "baseChecksum": "f92965e2c8a7afb3c1b9a5c09a263636", + "status": "translated" + }, + "fr-ca": { + "checksum": "da53818f7b27bf413d7edb1a7f19fe38", + "baseChecksum": "f92965e2c8a7afb3c1b9a5c09a263636", + "status": "translated" + }, + "ru": { + "checksum": "34e1413d659cf910494586bf9fbc727e", + "baseChecksum": "f92965e2c8a7afb3c1b9a5c09a263636", + "status": "translated" + }, + "ja": { + "checksum": "6b2dfe066533275f45f9b00f49e0bc5d", + "baseChecksum": "f92965e2c8a7afb3c1b9a5c09a263636", + "status": "translated" + }, + "de": { + "checksum": "4d79152b5d9f9fe8185bff13520ca9fa", + "baseChecksum": "f92965e2c8a7afb3c1b9a5c09a263636", + "status": "translated" + } + }, + "open": { + "en": { + "checksum": "c3bf447eabe632720a3aa1a7ce401274" + }, + "es": { + "checksum": "a5f9ee832b2be826fd154cc35fb47f3e", + "baseChecksum": "c3bf447eabe632720a3aa1a7ce401274", + "status": "translated" + }, + "fr-ca": { + "checksum": "08d2d273163ba1259374fb624a8e6ebc", + "baseChecksum": "c3bf447eabe632720a3aa1a7ce401274", + "status": "translated" + }, + "ru": { + "checksum": "e946df6c55054b21acc3a34853fef684", + "baseChecksum": "c3bf447eabe632720a3aa1a7ce401274", + "status": "translated" + }, + "ja": { + "checksum": "26860bfbfc74d8dd10b1a4dd6faf59a2", + "baseChecksum": "c3bf447eabe632720a3aa1a7ce401274", + "status": "translated" + }, + "de": { + "checksum": "9889e02833eeed68b3b3e5724e4390ca", + "baseChecksum": "c3bf447eabe632720a3aa1a7ce401274", + "status": "translated" + } } } From 0be732f315306be13bc5ba0c80dd877604030208 Mon Sep 17 00:00:00 2001 From: snodel Date: Fri, 18 Sep 2026 14:25:49 -0700 Subject: [PATCH 2/6] feat(tracker): redesign the translation editor dialog with a context column Lay the create/edit dialog out as an editor beside a context column that shows where the key lands, similar values and the locales that still need work, with an other-locales drawer over the column and a disclosure below 1100px. Comment and tags stay inline. - highlight the edited or created node in the location tree - make the footer key click-to-copy and show collisions there - add a chevron to the status pill and align the base locale chip - focus the comment field after the empty-comment confirmation - set a base text colour on body so dark mode no longer inherits black, and lift muted captions off the decorative tint - break suggested keys only after their dots Co-Authored-By: Claude Fable 5.1 --- .../folder-picker/folder-picker.html | 2 + .../folder-picker/folder-picker.scss | 13 + .../folder-picker/folder-picker.ts | 7 + .../similar-translations.html | 35 +- .../similar-translations.scss | 91 +- .../similar-translations.ts | 46 + .../similar-value-filter.ts | 46 + .../translation-editor-context.scss | 510 ++++++ .../translation-editor-dialog.html | 852 ++++++---- .../translation-editor-dialog.scss | 1403 ++++++++++------- .../translation-editor-dialog.spec.ts | 733 ++++++++- .../translation-editor-dialog.ts | 715 ++++++++- .../translation-editor-launcher.spec.ts | 155 ++ .../services/translation-editor-launcher.ts | 142 ++ .../header/translation-main-header.spec.ts | 46 +- .../header/translation-main-header.ts | 13 +- .../list/store/with-item-actions.feature.ts | 59 +- .../list/translation-list.spec.ts | 44 + apps/tracker/src/styles.scss | 151 +- 19 files changed, 4065 insertions(+), 998 deletions(-) create mode 100644 apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts create mode 100644 apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-context.scss create mode 100644 apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts create mode 100644 apps/tracker/src/app/browser/services/translation-editor-launcher.ts diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.html b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.html index abbb41b..5c40403 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.html +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.html @@ -1,5 +1,6 @@
+ @if (!hideHeader()) {
{{ displayPath() }} {{ chevronIcon() }}
+ } @if (isExpanded()) { diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss index 691cf04..5925f4d 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.scss @@ -1,5 +1,18 @@ .folder-picker { margin-bottom: var(--spacing-6); + + // Headerless: the picker is the whole body of a popover, so it owns no + // chrome of its own and gives the gutters back to the tree. + &:has(.folder-picker-content:first-child) { + margin-bottom: 0; + + .folder-picker-content { + border: 0; + border-radius: 0; + margin-top: 0; + padding: 0; + } + } } .folder-header { diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.ts index 862cdb4..77d16b2 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/folder-picker/folder-picker.ts @@ -60,6 +60,13 @@ export class FolderPicker implements OnInit { /** When true, the folder tree is shown immediately and the currentPath ancestors are expanded */ readonly initiallyExpanded = input(false); + /** + * Hides the picker's own "Folder: path" row. Inside the editor's location + * popover the pill that opened the popover already states the path, so a + * second copy of it above the tree is noise. + */ + readonly hideHeader = input(false); + readonly isExpanded = signal(false); readonly expandedPaths = signal>(new Set()); readonly selectedPath = signal(null); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html index e585b6e..06a04d4 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.html @@ -1,5 +1,10 @@ -
+
+ @if (!isContextVariant()) {
content_copy

{{ TOKENS.BROWSER.SIMILARTRANSLATIONS.TITLE | transloco }}

@@ -7,14 +12,15 @@

{{ TOKENS.BROWSER.SIMILARTRANSLATIONS.TITLE | transloco }

+ }
@switch (state()) { @case ('idle') { -

- {{ TOKENS.BROWSER.SIMILARTRANSLATIONS.IDLE | transloco }} -

+ @if (!isContextVariant()) { +

{{ TOKENS.BROWSER.SIMILARTRANSLATIONS.IDLE | transloco }}

+ } } @case ('loading') {

@@ -22,21 +28,28 @@

{{ TOKENS.BROWSER.SIMILARTRANSLATIONS.TITLE | transloco

} @case ('empty') { -

- {{ TOKENS.BROWSER.SIMILARTRANSLATIONS.EMPTY | transloco }} -

+ @if (!isContextVariant()) { +

{{ TOKENS.BROWSER.SIMILARTRANSLATIONS.EMPTY | transloco }}

+ } } @case ('results') {
- @for (result of displayedResults(); track result.key) { + @for (row of displayedRows(); track row.result.key) { }
diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss index 678b614..dd6f0ab 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.scss @@ -53,7 +53,9 @@ .state-message { margin: 0; font-size: var(--font-size-sm); - color: var(--color-text-tertiary); + // Secondary, not tertiary: tertiary is a decorative tint (2.3:1 on the card in + // light mode) and this is the "searching" / "no results" copy a user reads. + color: var(--color-text-secondary); &.empty { color: var(--color-text-secondary); @@ -96,7 +98,15 @@ font-family: var(--font-family-mono); font-weight: var(--font-weight-medium); color: var(--color-text-primary); - word-break: break-all; + // The key carries its own break opportunities (a after every dot), so + // wrapping only ever happens between segments — never mid-identifier. + word-break: normal; + overflow-wrap: anywhere; + + i { + font-style: normal; + color: var(--color-text-tertiary); + } } .result-value { @@ -106,6 +116,13 @@ text-overflow: ellipsis; white-space: nowrap; max-width: 100%; + + // Not similar — identical. The row carries the weight so the eye lands on it + // before it reads the caption underneath. + &--exact { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + } } // Show more button @@ -148,3 +165,73 @@ transition: none; } } + +// ───────────────────────────────────────────────────────────────────────────── +// Context variant +// +// Inside the translation editor's context column the block already carries the +// heading, the count and the caption, so the card gives up its own chrome and +// becomes a plain list of rows one hairline apart. +// ───────────────────────────────────────────────────────────────────────────── + +:host:has(.similar-translations-card--context) { + margin-top: 0; +} + +.similar-translations-card--context { + border-left-width: 1px; + border-left-color: var(--color-border); + background: var(--color-background); + + &.has-results { + border-left-color: var(--color-border); + } + + .card-content { + padding: 0; + } + + .state-message { + padding: var(--spacing-2) var(--spacing-3); + font-size: var(--font-size-xs); + } + + .results-list { + gap: 0; + } + + .result-item { + gap: 2px; + padding: var(--spacing-2) var(--spacing-3); + border: 0; + border-radius: 0; + background: transparent; + + + .result-item { + border-top: 1px solid var(--color-border-subtle); + } + + &:hover { + background: var(--color-background-subtle); + } + } + + .result-key { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + } + + .result-value { + font-size: 13px; + color: var(--color-text-primary); + white-space: normal; + overflow-wrap: anywhere; + } + + .show-more-button { + margin: 0; + border-top: 1px solid var(--color-border-subtle); + border-radius: 0; + font-size: var(--font-size-xs); + } +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts index 55dc484..f88f2ff 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-translations.ts @@ -9,6 +9,20 @@ import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; type ComponentState = 'idle' | 'loading' | 'empty' | 'results'; +/** + * A row ready to paint: the key already split so it can only break after a dot, + * exactly like the Full key line in the editor's context column. + */ +interface SimilarRow { + readonly result: SearchResultDto; + /** Every segment but the last — each one is followed by a dot and a break opportunity. */ + readonly parentSegments: readonly string[]; + /** The final segment, which never carries a trailing dot. */ + readonly leafSegment: string; + readonly value: string; + readonly isExact: boolean; +} + @Component({ standalone: true, selector: 'app-similar-translations', @@ -25,6 +39,17 @@ export class SimilarTranslations { isLoading = input(false); hasSearchQuery = input(false); baseLocale = input.required(); + /** + * The key of the hit whose base value is the typed value verbatim, if any. + * That row is not just similar — it is the same string already spoken for. + */ + exactKey = input(''); + /** + * `card` is the standalone card with its own header and idle/empty copy. + * `context` is the quiet list inside the editor's context column, where the + * block heading and caption already belong to the column. + */ + variant = input<'card' | 'context'>('card'); // Internal state readonly #displayLimit = signal(3); @@ -37,8 +62,25 @@ export class SimilarTranslations { return 'results'; }); + readonly isContextVariant = computed(() => this.variant() === 'context'); + readonly displayedResults = computed(() => this.results().slice(0, this.#displayLimit())); + readonly displayedRows = computed(() => + this.displayedResults().map((result) => { + const segments = result.key.split('.').filter((segment) => segment.length > 0); + const leafSegment = segments.length > 0 ? segments[segments.length - 1] : result.key; + + return { + result, + parentSegments: segments.slice(0, -1), + leafSegment, + value: this.getTranslationValue(result), + isExact: this.isExactMatch(result), + }; + }), + ); + readonly hasMore = computed(() => this.results().length > this.#displayLimit()); readonly remainingCount = computed(() => Math.max(0, this.results().length - this.#displayLimit())); @@ -55,6 +97,10 @@ export class SimilarTranslations { this.resourceClicked.emit(result); } + isExactMatch(result: SearchResultDto): boolean { + return this.exactKey() !== '' && result.key === this.exactKey(); + } + getTranslationValue(result: SearchResultDto): string { const locale = this.baseLocale(); return result.translations[locale] || Object.values(result.translations)[0] || ''; diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts new file mode 100644 index 0000000..21999d7 --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/similar-value-filter.ts @@ -0,0 +1,46 @@ +import type { SearchResultDto } from '@simoncodes-ca/data-transfer'; + +/** + * How many hits the dialog asks the API for before filtering. The collection + * search matches keys as well as values and reports a key match in preference to + * a value one, so a page sized to the display limit can come back entirely made + * of key hits and leave the list empty. Asking for more than we show buys the + * filter room to work. + */ +export const SIMILAR_SEARCH_MAX_RESULTS = 25; + +/** How many similar values the context column ever pins. */ +export const SIMILAR_DISPLAY_LIMIT = 10; + +/** + * Keeps only the hits whose base-locale text actually relates to what was typed. + * + * The block is titled "Similar values" and its caption offers the keys as + * something to reuse instead of writing a duplicate. A hit that matched only + * because the query appears in its key — `…translationEditor.saveButton` for + * "Save", whose value is "Create translation" — is a different string under a + * similarly named key, and reusing it would be wrong. Containment is tested both + * ways so a short existing value ("Save") still surfaces against a longer typed + * one ("Save draft"), which is exactly the duplicate worth catching. + */ +export function filterSimilarByValue( + results: readonly SearchResultDto[], + typedValue: string, + baseLocale: string, + limit: number = SIMILAR_DISPLAY_LIMIT, +): SearchResultDto[] { + const typed = typedValue.trim().toLowerCase(); + if (!typed) { + return []; + } + + const kept = results.filter((result) => { + const baseValue = (result.translations[baseLocale] ?? '').trim().toLowerCase(); + if (!baseValue) { + return false; + } + return baseValue.includes(typed) || typed.includes(baseValue); + }); + + return kept.slice(0, limit); +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-context.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-context.scss new file mode 100644 index 0000000..bbdca94 --- /dev/null +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-context.scss @@ -0,0 +1,510 @@ +// The context column, the other-locales drawer that slides over it, and the +// disclosure that replaces the column below 1100px. Split out of +// `translation-editor-dialog.scss` so each sheet stays inside the project's +// per-component style budget; the two are one stylesheet in every other sense +// and share the dialog's `--dialog-*` surface variables. + +// ───────────────────────────────────────────────────────────────────────────── +// Context column +// ───────────────────────────────────────────────────────────────────────────── + +.ctx { + display: flex; + flex-direction: column; + gap: var(--spacing-5); + padding: 18px var(--spacing-5) var(--spacing-5); + background: var(--dialog-aside); +} + +.blk { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + min-width: 0; + + h4 { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + + mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-tertiary); + } + } +} + +.blk-note { + margin-left: auto; + font-size: 11px; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.blk-cap { + margin: 0; + font-size: 11px; + line-height: 1.45; + // Secondary, not tertiary: tertiary is a decorative tint (2.3:1 on the aside + // in light, 4.1:1 in dark) and these captions are sentences a user reads. + color: var(--color-text-secondary); + + // The exact-duplicate caption is a warning, not a footnote. + &--warn { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + } +} + +// Mini folder tree: where the entry lands, and what is already beside it. +.ftree { + display: flex; + flex-direction: column; + gap: 1px; + padding: var(--spacing-1); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + background: var(--dialog-well); + font-family: var(--font-family-mono); + font-size: 13px; +} + +.ftree-n { + display: flex; + align-items: center; + gap: 6px; + padding: 5px var(--spacing-2); + border-radius: var(--border-radius-sm); + color: var(--color-text-primary); + + mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-tertiary); + } + + &--d1 { + padding-left: 26px; + } + &--d2 { + padding-left: 44px; + } + &--leaf { + color: var(--color-text-secondary); + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + + // The row that stands for the entries the window does not list. + &--more { + color: var(--color-text-secondary); + font-family: var(--font-family-body); + font-size: 12px; + } + + &--here { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + color: var(--color-primary-text); + font-weight: var(--font-weight-semibold); + + mat-icon { + color: var(--color-primary); + } + } + + // The entry this dialog is writing. The pill alone was too quiet, so the row + // wears the same tint and accent ink the target folder does: one system, + // read top to bottom — the folder it lands in, then the entry itself. + &--target { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + color: var(--color-primary-text); + font-weight: var(--font-weight-semibold); + + mat-icon { + color: var(--color-primary); + } + } + + // The key is already taken. Same emphasis, error ink: this is not the row + // being written, it is the one standing in the way. + &--taken { + background: color-mix(in srgb, var(--color-error) 10%, transparent); + // Error copy on a tinted row, mixed into ink the way the settings and bundle + // surfaces mix theirs: raw `--color-error` is a border and glyph colour and + // does not carry 13px label text at 4.5:1. + color: color-mix(in srgb, var(--color-error) 70%, var(--color-text-primary)); + font-weight: var(--font-weight-semibold); + + mat-icon { + color: var(--color-error); + } + } +} + +.ftree-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ftree-pill { + margin-left: auto; + padding: 0 7px; + border-radius: var(--border-radius-full); + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + color: var(--color-primary-text); + font-family: var(--font-family-body); + font-size: 10px; + font-weight: var(--font-weight-bold); + letter-spacing: 0.02em; + + // The key is taken: the leaf is not a new entry, it is the one already there. + &--err { + background: color-mix(in srgb, var(--color-error) 12%, transparent); + color: var(--color-error); + } +} + +// Locale summary +.lsum { + display: flex; + flex-direction: column; + gap: 4px; +} + +.lsum-r { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: var(--spacing-2); + align-items: center; + padding: 4px 6px; + border-radius: var(--border-radius-sm); + font-size: var(--font-size-xs); + + .lc { + justify-self: start; + font-size: 11px; + } + + &:hover { + background: var(--color-background-muted); + } +} + +.lsum-v { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-secondary); + + &--empty { + // Placeholder, not tertiary: this is the "nothing here yet" copy and has to + // be read, and --color-text-placeholder is the token measured for that job. + color: var(--color-text-placeholder); + font-style: italic; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Other-locales drawer: slides over the context column +// ───────────────────────────────────────────────────────────────────────────── + +.drawer { + box-sizing: border-box; + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 3; + width: 320px; + display: flex; + flex-direction: column; + gap: 14px; + padding: var(--spacing-4) var(--spacing-5) var(--spacing-5); + overflow-y: auto; + background: var(--dialog-surface); + border-left: 1px solid var(--color-border); + box-shadow: -12px 0 24px -16px color-mix(in srgb, var(--color-neutral-900) 25%, transparent); + animation: drawer-in var(--transition-normal) ease-out; +} + +@keyframes drawer-in { + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: none; + } +} + +.drawer-head { + display: flex; + align-items: center; + gap: var(--spacing-1); + + h3 { + flex: 1; + margin: 0; + font-size: 15px; + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + } +} + +.drawer-back { + flex: none; + color: var(--color-text-secondary); +} + +.base-ref { + display: flex; + // Baseline alignment pins the chip to the first line of the base value, which + // stays correct when that value wraps. + align-items: baseline; + gap: 10px; + padding: 10px var(--spacing-3); + border: 1px solid color-mix(in srgb, var(--color-secondary) 22%, var(--color-border)); + border-radius: var(--border-radius-md); + background: color-mix(in srgb, var(--color-secondary) 7%, var(--color-background-subtle)); + font-size: var(--font-size-sm); + + .base-tag { + flex: none; + } +} + +.base-ref-text { + flex: 1; + min-width: 0; + line-height: var(--line-height-normal); + overflow-wrap: anywhere; +} + +.auto-note { + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + margin: 0; + padding: 9px var(--spacing-3); + border: 1px solid color-mix(in srgb, var(--color-info) 30%, transparent); + border-radius: var(--border-radius-md); + background: var(--color-info-subtle); + color: color-mix(in srgb, var(--color-info) 55%, var(--color-text-primary)); + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); + + mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + } +} + +.lrows { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +// Name and status on the first line, the value well spanning both below it, so +// a long translation wraps instead of squeezing the name. +.lrow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px var(--spacing-2); + align-items: center; +} + +.lrow-who { + grid-column: 1; + grid-row: 1; + display: flex; + align-items: center; + // Name left, code chip against the status pill, as the comp has them. + justify-content: space-between; + gap: var(--spacing-2); + min-width: 0; + + b { + font-size: 13px; + font-weight: var(--font-weight-semibold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .lc { + font-size: 11px; + } +} + +.lrow .st, +.lrow .auto-tag { + grid-column: 2; + grid-row: 1; +} + +.lrow-value { + grid-column: 1 / -1; + grid-row: 2; +} + +.auto-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-text-secondary); + white-space: nowrap; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: var(--color-text-tertiary); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Context disclosure (only below 1100px) +// ───────────────────────────────────────────────────────────────────────────── + +.disclosure { + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + overflow: hidden; + + &--open { + border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-border)); + } +} + +.ctx-disc { + display: none; + margin-top: auto; +} + +.disclosure-toggle { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px var(--spacing-3) 10px 14px; + border: 0; + background: transparent; + color: var(--color-text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); + + &:hover { + background: var(--color-background-subtle); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; + } +} + +.disclosure-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); +} + +.disclosure-title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + line-height: 1.5; +} + +.disclosure-summary { + margin-left: auto; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.disclosure-chevron { + flex: none; + font-size: 20px; + width: 20px; + height: 20px; + color: var(--color-text-tertiary); + transition: transform var(--transition-normal); + + .disclosure--open & { + transform: rotate(180deg); + } +} + +.disclosure-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + padding: var(--spacing-3) 14px 14px; + border-top: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--color-background-subtle) 55%, transparent); + animation: disclosure-reveal var(--transition-normal) both; +} + +@keyframes disclosure-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Responsive +// +// These three rules live beside the classes they override rather than with the +// rest of the dialog's media queries: this sheet is loaded after the other one, +// so an override written there would be beaten by the plain rules here. +// ───────────────────────────────────────────────────────────────────────────── + +@media (max-width: 1099px) { + .ctx { + display: none; + } + + .ctx-disc { + display: block; + } + + // With no column to sit over, the drawer covers the form instead. + .drawer { + left: 0; + width: auto; + border-left: 0; + } +} diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html index b6fd898..b21b047 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html @@ -1,319 +1,635 @@ -
- -
-
-

{{ dialogTitle() | transloco }}

-

{{ dialogSubtitle() | transloco }}

+ + - -
-
- - folder_open - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.LOCATIONLABEL | transloco }} - - - folder - {{ selectedFolderPath() || (TOKENS.BROWSER.FOLDERPICKER.ROOTLABEL | transloco) }} - - {{ locationAbsorbedMessage() }} -
+
+ +
+ @if (isReadOnly()) { +
+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.READONLYNOTICE | transloco }} +
+ } - @if (isReadOnly()) { -
- lock - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.READONLYNOTICE | transloco }} -
- } + @if (errorMessage()) { + + } - @if (errorMessage()) { - - } -
- - - - - - - - - - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAB.BASEINFO | transloco }} - @if (baseTabHasError()) { - +
+ +
+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.LOCATIONFIELDLABEL | transloco }} + + + {{ locationAbsorbedMessage() }} +
+ + +
+ + @if (isEditMode()) { + +
+ + {{ form.controls.key.value }} +
+ } @else { +
- @if (form.controls.key.invalid && form.controls.key.touched) { - {{ getKeyErrorMessage() | transloco }} - } - - @if (isEditMode()) { - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.ERROR.KEYRENAMINGNOTSUPPORTED | transloco }} - } @else { - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.KEYHINT | transloco }} - } - - - - -
-
- language -

{{ data.baseLocale.toUpperCase() }}

- {{ TOKENS.BROWSER.TRANSLATIONEDITOR.BASEBADGE | transloco }} -
+
+ } +
+
- - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.LOCALETRANSLATIONX | transloco : { locale: data.baseLocale.toUpperCase() } }} - - @if (form.controls.baseValue.hasError('required') && form.controls.baseValue.touched) { - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.BASEVALUEREQUIRED | transloco }} - } - + @if (showKeyError()) { + + } @else if (keyCollision()) { + + } - - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.COMMENTLABEL | transloco }} - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.COMMENTHINT | transloco }} - + +
+ +
+ +
+ @if (showBaseValueError()) { + + } @else { +

+ + + +

+ } +
- - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAGS.LABEL | transloco }} - - @for (tag of inheritedTagsList(); track tag) { - - {{ tag }} - - } - @for (tag of tagsList(); track tag) { - - {{ tag }} - @if (!isReadOnly()) { - - } - - } - - - - @for (suggestion of filteredTagSuggestions(); track suggestion) { - {{ suggestion }} - } - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAGS.HINT | transloco }} - + +
+ +
+ +
+
- - +
+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAGS.LABEL | transloco }} + {{ TOKENS.BUNDLES.DIALOG.OPTIONAL | transloco }} + +
+ @for (tag of inheritedTagsList(); track tag) { + + + {{ tag }} + + } + @for (tag of tagsList(); track tag) { + + {{ tag }} + @if (!isReadOnly()) { + + } + + } + @if (!isReadOnly()) { + + @if (tagsList().length > 0) { + + } + @let tagPlaceholder = + (tagsList().length > 0 + ? TOKENS.BROWSER.TRANSLATIONEDITOR.TAGS.ADDSHORT + : TOKENS.BROWSER.TRANSLATIONEDITOR.TAGS.ADDPLACEHOLDER + ) | transloco; + -
-
- - - - - - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAB.OTHERLOCALES | transloco }} - -
- - @if (form.controls.baseValue.value) { -
- - language - {{ data.baseLocale.toUpperCase() }} - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.BASEBADGE | transloco }} - - {{ form.controls.baseValue.value }} -
- } + + @for (suggestion of filteredTagSuggestions(); track suggestion) { + {{ suggestion }} + } + + } +
+ @if (inheritedTagsList().length > 0) { +

+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAGS.INHERITEDHINT | transloco }} +

+ } +
- -
- @for (localeControl of form.controls.translations.controls; track localeControl.value.locale; let i = $index) { -
-
- translate -

{{ getLocaleDisplayName(localeControl.value.locale) }}

- {{ localeControl.value.locale }} - @if (isEditMode()) { - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.STATUS | transloco }} - - @for (statusOption of translationStatusOptions; track statusOption) { - - {{ statusLabels[statusOption] | transloco }} - - } - - - } -
+ + - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.LOCALETRANSLATIONX | transloco : { locale: getLocaleDisplayName(localeControl.value.locale) } }} - - -
- } -
+ +
+ + @if (isContextOpen()) { +
+
- + } +
+
+ + + + + + @if (isLocalesDrawerOpen()) { +
+
+ +

+ {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAB.OTHERLOCALES | transloco }} +

+ +
- - - - - {{ TOKENS.BROWSER.TRANSLATIONEDITOR.TAB.CHANGELOCATION | transloco }} + @if (form.controls.baseValue.value) { +
+ + {{ data.baseLocale }} + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.BASEBADGE | transloco }} - -
- -

- {{ TOKENS.BROWSER.TRANSLATIONEDITOR.LOCATIONTABHINT | transloco }} -

+ {{ form.controls.baseValue.value }} +
+ } - -
- + @if (!isEditMode()) { +

+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.AUTOTRANSLATENOTE | transloco }} +

+ } + +
+ @for (localeControl of form.controls.translations.controls; track localeControl.value.locale; let i = $index) { +
+ @let localeGroup = getLocaleFormGroup(i); + @let localeCode = localeGroup.controls.locale.value; + @let localeStatus = localeGroup.controls.status.value; +
+ {{ getLocaleDisplayName(localeCode) }} + {{ localeCode }} +
+ + @if (isEditMode()) { + + + @for (statusOption of translationStatusOptions; track statusOption) { + + } + + } @else { + + + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.AUTOAFTERSAVE | transloco }} + + } + +
+ +
-
- - - - + } +
+
+ } + - - - +
+ + @if (!isReadOnly()) { } - +
+ + + + + + + + +
+

+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.WHEREITLANDS | transloco }} + {{ data.collectionName }} +

+
+ @for (node of contextTree(); track node.path + node.kind) { +
+ @if (node.kind === 'folder') { + + + } @else if (node.kind === 'entry') { + + } + {{ node.name }} + @if (node.here) { + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.HERE | transloco }} + } @else if (node.mark === 'new') { + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.NEWENTRY | transloco }} + } @else if (node.mark === 'editing') { + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.EDITINGENTRY | transloco }} + } @else if (node.mark === 'exists') { + + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.EXISTSENTRY | transloco }} + + } +
+ } +
+
+ + @if (showSimilarContext()) { +
+

+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.SIMILARVALUES | transloco }} + {{ similarCount() }} +

+ + @if (exactMatchKey()) { +

+ {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.SIMILAREXACTX | transloco: { key: exactMatchKey() } }} +

+ } @else { +

{{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.SIMILARCAPTION | transloco }}

+ } +
+ } + + @if (isEditMode()) { +
+

+ + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.LOCALES | transloco }} + {{ otherLocalesSummary() }} +

+ +
+ @for (locale of localesNeedingWork(); track locale.locale) { +
+ {{ locale.locale }} + @if (locale.value) { + {{ locale.value }} + } @else { + {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.MISSING | transloco }} + } + +
+ } @empty { +

+ {{ TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.ALLUPTODATE | transloco }} +

+ } +
+
+ } +
diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.scss b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.scss index 9c2f0d3..275201b 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.scss +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.scss @@ -1,64 +1,123 @@ -@use '../../../../styles/breakpoints' as bp; - -// ───────────────────────────────────────────────────────────────────────────── -// Host & dialog container -// ───────────────────────────────────────────────────────────────────────────── -// The outer box — width, max-height and the small-viewport full-screen mode — -// is owned by `.translation-editor-dialog-panel` in styles.scss, because the CDK -// appends the overlay pane outside this component. Nothing here restates those -// numbers; the height simply flows down the pane → container → host chain. +// The outer box (width, top rule, zero container padding, the <640px full-screen +// mode) is owned by `.translation-editor-dialog-panel` in styles.scss, because +// the CDK appends the overlay pane outside this component. Everything here is +// the inside, and it is the bundle dialog's anatomy with two columns instead of +// three: form column, then a 320px context column behind a left hairline. :host { display: block; - block-size: 100%; + max-height: inherit; + + // Same surface pairing as the bundle and collection dialogs: the panel is one + // step lighter than the page so the wells read as set into it, and the context + // column sits one step back so the form is the lit part of the room. + --dialog-surface: var(--color-background); + --dialog-well: var(--color-background); + --dialog-aside: var(--color-background-subtle); +} + +:host-context([data-theme='dark']) { + --dialog-surface: var(--color-background-subtle); + --dialog-well: var(--color-background); + --dialog-aside: var(--color-background); +} + +@media (prefers-color-scheme: dark) { + :host-context(:root:not([data-theme='light']):not([data-theme='dark'])) { + --dialog-surface: var(--color-background-subtle); + --dialog-well: var(--color-background); + --dialog-aside: var(--color-background); + } } -// Column layout: header, pre-tab block and actions take their natural height, -// and the tab content absorbs whatever is left. This replaces a hard-coded -// content height that overflowed the panel whenever the pre-tab block grew — -// an error banner or the read-only notice was enough to push the save button -// off the bottom of the screen. .dialog-wrapper { display: flex; flex-direction: column; - block-size: 100%; - max-block-size: 100%; + max-height: inherit; + background: var(--dialog-surface); +} + +.mono { + font-family: var(--font-family-mono); +} + +code { + font-family: var(--font-family-mono); + font-size: 0.95em; + color: var(--color-text-primary); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; } // ───────────────────────────────────────────────────────────────────────────── -// Dialog header +// Header // ───────────────────────────────────────────────────────────────────────────── .dialog-header { position: relative; flex-shrink: 0; + display: flex; + align-items: flex-start; + gap: 14px; padding: var(--spacing-5) var(--spacing-6) var(--spacing-4); border-bottom: 1px solid var(--color-border); } -.dialog-header-content { - // Reserve right-side space so text doesn't slide under the close button - padding-right: var(--spacing-10); +.dialog-header-tile { + display: grid; + place-items: center; + flex: none; + width: 38px; + height: 38px; + border-radius: var(--border-radius-lg); + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + color: var(--color-primary); + + mat-icon { + font-size: 19px; + width: 19px; + height: 19px; + } +} + +.dialog-header-text { + flex: 1; + min-width: 0; + padding-right: var(--spacing-8); } -.mat-dialog-title { - margin: 0 0 var(--spacing-1); +.dialog-title { + margin: 0 0 3px; font-size: var(--font-size-2xl); font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); line-height: var(--line-height-tight); + letter-spacing: -0.01em; + color: var(--color-text-primary); } .dialog-subtitle { margin: 0; - font-size: var(--font-size-sm); + font-size: 13px; + line-height: 1.45; color: var(--color-text-secondary); - line-height: var(--line-height-normal); } -.close-button { +.dialog-close { position: absolute; - top: var(--spacing-3); - right: var(--spacing-3); + // The comp's 32px glyph button sits 14px in; this one is Material's 40px box, + // so it insets 10px to put the X on the same centre. + top: 10px; + right: 10px; color: var(--color-text-secondary); transition: color var(--transition-fast); @@ -68,766 +127,906 @@ } // ───────────────────────────────────────────────────────────────────────────── -// Pre-tab strip: location pill + error banner +// Two-column body // ───────────────────────────────────────────────────────────────────────────── -.dialog-pretab { - flex-shrink: 0; - padding: var(--spacing-3) var(--spacing-6); - border-bottom: 1px solid var(--color-border); - background-color: var(--color-background-subtle); - display: flex; - flex-direction: column; - gap: var(--spacing-3); -} - -.location-row { - display: flex; - align-items: center; - gap: var(--spacing-3); - flex-wrap: wrap; -} - -.location-label { - display: inline-flex; - align-items: center; - gap: var(--spacing-1); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - white-space: nowrap; - flex-shrink: 0; -} - -.location-label-icon { - font-size: var(--font-size-base); - width: 1rem; - height: 1rem; - color: var(--color-text-tertiary); -} - -// Pill that shows the current folder path -.location-pill { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - padding: var(--spacing-1) var(--spacing-3); - background-color: var(--color-primary-subtle); - border: 1px solid color-mix(in srgb, var(--color-primary) 20%, transparent); - border-radius: var(--border-radius-full); - max-width: 100%; +.two { + position: relative; + // Takes the height the header and footer leave and no more: with both columns + // scrolling inside it, a long context column can never grow the panel past the + // viewport ceiling the panel class sets. + flex: 1 1 auto; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + grid-template-rows: minmax(0, 1fr); + // Tall enough that opening the drawer or pinning similar values never resizes + // the dialog under the user's hands. Capped against the viewport as well, or + // this floor would hold the body open on a short screen and push the footer + // past the bottom edge — the one thing the panel's max-height exists to stop. + min-height: min(460px, 60vh); overflow: hidden; + margin: 0; } -// A dotted key hands its prefix to this pill, several centimetres from the field -// the user is typing in. One authored beat so the hand-off is impossible to miss. -.location-pill--absorbed { - animation: location-absorbed 900ms cubic-bezier(0.22, 1, 0.36, 1); -} - -@keyframes location-absorbed { - from { - border-color: var(--color-primary); - background-color: color-mix(in srgb, var(--color-primary) 26%, transparent); - box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--color-primary) 70%, transparent); - transform: translateY(-2px); - } - to { - border-color: color-mix(in srgb, var(--color-primary) 20%, transparent); - background-color: var(--color-primary-subtle); - box-shadow: 0 0 0 transparent; - transform: none; - } -} - -// Visually hidden, still announced — the live region that says the same thing. -.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; +.bd, +.ctx { + min-height: 0; + overflow-y: auto; } -.location-pill-icon { - font-size: var(--font-size-sm); - width: 1rem; - height: 1rem; - color: var(--color-primary); - flex-shrink: 0; +.bd { + display: flex; + flex-direction: column; + gap: var(--spacing-5); + padding: 18px var(--spacing-6) var(--spacing-5); + border-right: 1px solid var(--color-border); } -.location-pill-path { - font-size: var(--font-size-sm); - font-family: var(--font-family-mono); - font-weight: var(--font-weight-medium); - color: var(--color-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.row-3-2 { + display: grid; + grid-template-columns: 3fr 2fr; + gap: var(--spacing-3); } // ───────────────────────────────────────────────────────────────────────────── -// Error banner +// Notices // ───────────────────────────────────────────────────────────────────────────── -.error-banner { +.notice { display: flex; align-items: flex-start; - gap: var(--spacing-3); - padding: var(--spacing-3) var(--spacing-4); - background-color: color-mix(in srgb, var(--color-error) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--color-error) 20%, transparent); - border-radius: var(--border-radius-md); - color: var(--color-error); -} - -.readonly-banner { - display: flex; - align-items: center; gap: var(--spacing-2); - padding: var(--spacing-2) var(--spacing-4); - background-color: color-mix(in srgb, var(--color-warning) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--color-warning) 24%, transparent); + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border); border-radius: var(--border-radius-md); - color: var(--color-warning-text); + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); - .readonly-icon { - font-size: var(--font-size-lg); - width: var(--font-size-lg); - height: var(--font-size-lg); - flex-shrink: 0; + mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; } - .readonly-text { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-normal); + &--lock { + border-color: color-mix(in srgb, var(--color-warning) 30%, transparent); + background: color-mix(in srgb, var(--color-warning) 9%, transparent); + color: var(--color-warning-text); } -} -.error-icon { - color: var(--color-error); - font-size: var(--font-size-lg); - flex-shrink: 0; - margin-top: 2px; -} - -.error-text { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-normal); + &--error { + border-color: color-mix(in srgb, var(--color-error) 30%, transparent); + background: color-mix(in srgb, var(--color-error) 8%, transparent); + color: var(--color-error); + } } // ───────────────────────────────────────────────────────────────────────────── -// Main dialog content — contains the tab group +// Fields (the label-above well; Material outline fields are not used here) // ───────────────────────────────────────────────────────────────────────────── -// `:host` prefix (0,2,0) outranks Material's own `.mat-mdc-dialog-content` -// (0,1,0) without an `!important`, which is why both overrides below are plain. -:host .dialog-tab-content { - // Override Material's default padding so the tab bar sits flush - padding: 0; - color: var(--color-text-primary); - background-color: var(--color-background); - // The height has to stay *definite*: the form, the tab group and Material's - // tab body all resolve `height: 100%` against it, and an auto height silently - // turns Tab 2's locale list from a scroll region into 1500px of clipped - // content. So it keeps a resolved height — but a viewport-relative one, and a - // shorter one for the tab that has less to say. The flat 570px it replaces - // made every tab reserve the tallest tab's box, painting 350px of nothing - // under the folder tree at 1440. - flex: 1 1 auto; - min-height: 0; - height: min(570px, 62vh); - - // Change Location holds one hint and a tree, and neither needs a percentage - // height to resolve against — so this tab sizes to its content and the panel - // stops reserving the locale list's box under a two-folder tree. - &.dialog-tab-content--short { - height: auto; - max-height: min(480px, 54vh); - } - // Disable Material's default overflow:auto so the tab group controls scrolling - // internally. Without this, the entire tab group (header + body) scrolls together, - // which causes the tab strip and the base locale banner to scroll away. - overflow: hidden; +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + + // The location field anchors the folder popover. + &--anchor { + position: relative; + } } -// The
inside .dialog-tab-content has no intrinsic height, which breaks -// the height chain for any descendant using height: 100%. Propagate the -// resolved height of the mat-dialog-content container downward so that the -// mat-tab-group (height: 100%) resolves to a real pixel value. -.dialog-tab-content form { - height: 100%; +.field-label { + display: flex; + // Baseline, not centre: the base-locale chip is taller than the label text, + // so centring it would drop its text below the label's baseline. + align-items: baseline; + gap: 6px; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-normal); + color: var(--color-text-primary); } -// ───────────────────────────────────────────────────────────────────────────── -// Tab group overrides -// ───────────────────────────────────────────────────────────────────────────── +.field-label-opt { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} -.editor-tabs { - // Fill the full height of the dialog content and establish a flex column so - // the tab header and tab body can each be individually sized and positioned. - width: 100%; - height: 100%; +.text-input { display: flex; - flex-direction: column; + align-items: center; + gap: var(--spacing-2); + min-height: 40px; + padding: 0 var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + // The location popover renders from this component's template but lives in the + // overlay container, where the :host custom properties do not inherit. + background: var(--dialog-well, var(--color-background)); + color: var(--color-text-primary); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); + + > mat-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); + } - // Tab header strip — must not participate in scrolling. flex-shrink: 0 - // prevents the header from being compressed when the body needs space. - // - // Material's answer to labels that outgrow the strip is pagination, which at - // 390px scrolled the *selected* tab out of view with nothing left to say - // which one it was. Three tabs is a strip, not a list: below 700px the type - // shrinks, MDC's 90px tab min-width and 24px side padding go, and the label - // is allowed a second line. All three then fit at 390px in every shipped - // locale — including "Basisinformationen" and "Базовая информация" — so the - // chevrons never appear and the active tab is always the underlined one. - ::ng-deep .mat-mdc-tab-header { - flex-shrink: 0; - border-bottom: 1px solid var(--color-border); - background-color: var(--color-background-subtle); - padding: 0 var(--spacing-4); - - @media (max-width: 700px) { - padding: 0; + input, + textarea { + flex: 1; + min-width: 0; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: 15px; + outline: none; + + &::placeholder { + color: var(--color-text-placeholder); } } - ::ng-deep .mat-mdc-tab { - @media (max-width: 700px) { - min-width: 0; - flex-grow: 1; - padding: 0 var(--spacing-2); - // Room for two lines without the ink bar riding the descenders. - height: 58px; - } + input { + height: 38px; } - // Individual tab labels - ::ng-deep .mat-mdc-tab .mdc-tab__text-label { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - letter-spacing: 0; - - @media (max-width: 700px) { - font-size: var(--font-size-xs); - line-height: var(--line-height-tight); - white-space: normal; - text-align: center; - overflow-wrap: anywhere; - } + textarea { + resize: none; + line-height: var(--line-height-normal); } - // Active tab label - ::ng-deep .mat-mdc-tab.mdc-tab--active .mdc-tab__text-label { - color: var(--color-primary); - font-weight: var(--font-weight-semibold); + &:focus-within { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); } - // Active indicator underline - ::ng-deep .mat-mdc-tab-indicator .mdc-tab-indicator__content--underline { - border-color: var(--color-primary); - border-top-width: 2px; + &--mono input, + &--mono > span { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); } - // Tab body wrapper — fills remaining height after the header. min-height: 0 - // is critical: without it, a flex child ignores overflow and expands past its - // parent, breaking the scroll containment chain. - ::ng-deep .mat-mdc-tab-body-wrapper { - flex: 1; - min-height: 0; - overflow: hidden; + // Multi-line wells grow with the text rather than scrolling a fixed box. + &--area { + align-items: flex-start; + padding: 9px var(--spacing-3); } - // Angular Material renders a mat-tab-body host element between - // .mat-mdc-tab-body-wrapper and .mat-mdc-tab-body-content. Without an explicit - // height it collapses, causing height: 100% on .mat-mdc-tab-body-content to - // resolve to zero and silently clipping the tab panel instead of scrolling it. - ::ng-deep .mat-mdc-tab-body { - height: 100%; + &--compact { + min-height: 34px; + + input { + height: 32px; + font-size: var(--font-size-sm); + } } - // Tab body content — the actual scroll container for most tabs. height: 100% - // ensures it fills the wrapper so nested panels using height:100% resolve correctly. - ::ng-deep .mat-mdc-tab-body-content { - height: 100%; - overflow-y: auto; - overflow-x: hidden; + &--invalid, + &--invalid:focus-within { + border-color: var(--color-error); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 18%, transparent); } -} -// ───────────────────────────────────────────────────────────────────────────── -// Tab panels — shared base -// ───────────────────────────────────────────────────────────────────────────── + &--locked { + border-style: dashed; + background: transparent; + color: var(--color-text-secondary); + } +} -.tab-panel { - padding: var(--spacing-5) var(--spacing-6); - display: flex; - flex-direction: column; - gap: var(--spacing-5); +.field-hint, +.field-error { + margin: 0; + font-size: var(--font-size-xs); + line-height: var(--line-height-normal); } -// ───────────────────────────────────────────────────────────────────────────── -// Utility -// ───────────────────────────────────────────────────────────────────────────── +.field-hint { + color: var(--color-text-secondary); -.full-width { - width: 100%; + &--icon { + display: flex; + align-items: flex-start; + gap: 5px; + + mat-icon { + flex: none; + font-size: 14px; + width: 14px; + height: 14px; + margin-top: 2px; + color: var(--color-text-tertiary); + } + } + + // Hints that belong to the location/key row sit under the whole row, not in + // one of its two columns, so they get the row's negative gap back. + &--row { + margin-top: calc(-1 * var(--spacing-4)); + } } -// ───────────────────────────────────────────────────────────────────────────── -// Tab 1 — Base locale info -// ───────────────────────────────────────────────────────────────────────────── +.field-error { + color: var(--color-error); -.tab-panel--base { - padding-top: var(--spacing-6); + &--row { + margin-top: calc(-1 * var(--spacing-4)); + } + + // The collision row carries a glyph and a way out, so it lays out as a line + // rather than a paragraph. + &--icon { + display: flex; + align-items: flex-start; + gap: 5px; + + mat-icon { + flex: none; + font-size: 15px; + width: 15px; + height: 15px; + } + } } -// Base locale card with primary-coloured left accent -.base-locale-card { - padding: var(--spacing-4); - background-color: var(--color-background-subtle); - border: 1px solid var(--color-border); - border-left: 3px solid var(--color-primary); - border-radius: var(--border-radius-lg); - transition: box-shadow var(--transition-fast); - display: flex; - flex-direction: column; - gap: var(--spacing-4); +// "Open existing": the second of the two ways out the error names. +.field-error-act { + display: inline-flex; + align-items: center; + gap: 2px; + flex: none; + // The message wraps; the way out rides its last line rather than floating + // beside the first one. + align-self: flex-end; + margin: 0 0 0 auto; + padding: 0; + border: 0; + background: none; + color: var(--color-error); + font-family: inherit; + font-size: inherit; + font-weight: var(--font-weight-semibold); + white-space: nowrap; + cursor: pointer; - &:focus-within { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus-ring-color) 25%, transparent); + mat-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + + &:hover, + &:focus-visible { + text-decoration: underline; } } -.locale-card-header { +// ───────────────────────────────────────────────────────────────────────────── +// Location pill +// ───────────────────────────────────────────────────────────────────────────── + +.loc-pill { display: flex; align-items: center; gap: var(--spacing-2); + width: 100%; + height: 40px; + padding: 0 var(--spacing-2) 0 var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--dialog-well); + color: var(--color-text-primary); + font: inherit; + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + text-align: left; + cursor: pointer; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); + + &:disabled { + cursor: default; + opacity: 0.7; + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } + + &--open { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); + } + + // The prefix of a pasted dotted key lands here; say so for a moment. + &--flash { + animation: location-absorbed var(--transition-slow) ease-out; + } } -.locale-icon { - color: var(--color-primary); - font-size: var(--font-size-xl); - flex-shrink: 0; +.loc-pill-lead { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); } -.locale-title { - margin: 0; - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); +.loc-pill-path { flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + i { + font-style: normal; + color: var(--color-text-tertiary); + padding: 0 3px; + } } -// "BASE" badge — pill style -.base-badge { - padding: var(--spacing-1) var(--spacing-3); - background-color: var(--color-primary); - color: var(--color-text-inverse); +.loc-pill-change { + flex: none; + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: var(--spacing-1); + padding: 4px 6px 4px 10px; + border-left: 1px solid var(--color-border); + font-family: var(--font-family-body); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); - border-radius: var(--border-radius-full); - text-transform: uppercase; - letter-spacing: 0.05em; - flex-shrink: 0; - white-space: nowrap; + color: var(--color-text-secondary); + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); + } } -// Zero out margin — parent card uses gap for spacing -.comment-field { - margin: 0; +@keyframes location-absorbed { + 0% { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 26%, transparent); + } + 100% { + border-color: var(--color-border-strong); + box-shadow: none; + } } // ───────────────────────────────────────────────────────────────────────────── -// Tab 2 — Other locales +// Base-locale tag, locale chip, status pill, status dot // ───────────────────────────────────────────────────────────────────────────── -// Fill the entire tab body so we can split scrolling between the fixed -// reference banner and the scrollable locale cards. The panel itself must not -// add to the outer scroll — overflow: hidden delegates scrolling inward to -// .other-locales-section only. -.tab-panel--locales { - height: 100%; - display: flex; - flex-direction: column; - padding: 0; - gap: 0; - overflow: hidden; -} - -// Read-only base value reference shown at the top of Tab 2. -// flex-shrink: 0 ensures it never collapses as a flex child of .tab-panel--locales. -// Scrolling is handled by .other-locales-section below, not this element. -.base-value-reference { - display: flex; +.base-tag { + display: inline-flex; + // The mono locale code and the small-caps BASE badge are different fonts and + // sizes, so they only line up when they share a baseline. align-items: baseline; - gap: var(--spacing-3); - padding: var(--spacing-4) var(--spacing-6); - margin: var(--spacing-4) var(--spacing-6) 0; - background-color: var(--color-background-subtle); - border: 1px solid var(--color-border); - border-left: 3px solid var(--color-primary); - border-radius: var(--border-radius-md); - flex-shrink: 0; - flex-wrap: wrap; - // Scroll-reveal shadow: transitions in when the locale cards list is scrolled, - // providing a visual separation between the fixed banner and scrolling content. - box-shadow: 0 2px 4px transparent; - transition: box-shadow var(--transition-fast); - - &.scrolled { - box-shadow: 0 3px 8px color-mix(in srgb, var(--color-text-primary) 16%, transparent); - } - - // On very narrow containers, stack label above text - @media (max-width: 480px) { - flex-direction: column; - gap: var(--spacing-1); + gap: 6px; + padding: 2px var(--spacing-2); + border-radius: var(--border-radius-full); + border: 1px solid color-mix(in srgb, var(--color-secondary) 35%, transparent); + background: color-mix(in srgb, var(--color-secondary) 16%, transparent); + color: color-mix(in srgb, var(--color-secondary) 45%, var(--color-text-primary)); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + line-height: 1.4; + + small { + font-family: var(--font-family-body); + font-size: 10px; + font-weight: var(--font-weight-bold); + // Match the code's line box so the badge cannot grow the chip off-centre. + line-height: 1.4; + letter-spacing: 0.05em; + text-transform: uppercase; + // The tracking above only sits between glyphs, so trim the trailing gap. + margin-right: -0.05em; } } -.base-value-reference__label { +.lc { display: inline-flex; align-items: center; - gap: var(--spacing-1); + padding: 0 6px; + border-radius: var(--border-radius-full); + background: var(--color-background-muted); + color: var(--color-text-secondary); + font-family: var(--font-family-mono); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); - color: var(--color-primary); - white-space: nowrap; - flex-shrink: 0; + line-height: 1.6; } -.base-value-reference__icon { - font-size: var(--font-size-sm); - width: 1rem; - height: 1rem; +.dot { + display: inline-block; + flex: none; + width: 7px; + height: 7px; + border-radius: var(--border-radius-full); + + &--new { + background: var(--color-status-new); + } + &--stale { + background: var(--color-status-stale); + } + &--translated { + background: var(--color-status-translated); + } + &--verified { + background: var(--color-status-verified); + } } -.base-value-reference__badge { - padding: 1px var(--spacing-2); - background-color: var(--color-primary); - color: var(--color-text-inverse); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); +.st { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 1px var(--spacing-2) 1px 6px; + border: 1px solid transparent; border-radius: var(--border-radius-full); - text-transform: uppercase; - letter-spacing: 0.05em; -} + font: inherit; + font-size: 11px; + font-weight: var(--font-weight-bold); + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + + &::before { + content: ''; + width: 6px; + height: 6px; + border-radius: var(--border-radius-full); + background: currentColor; + } -.base-value-reference__text { - font-size: var(--font-size-sm); - color: var(--color-text-primary); - line-height: var(--line-height-normal); - // Prevent very long strings from breaking layout - word-break: break-word; -} + // The chevron reads as padding, so trim the trailing space to keep the pill + // optically balanced against the leading dot. + &:has(.st-chev) { + padding-right: 4px; + } -// Stack of locale translation cards — the only scrollable region in Tab 2. -// flex: 1 + min-height: 0 is the canonical flexbox pattern for a child that -// must scroll within a fixed-height flex column parent. -.other-locales-section { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-4) var(--spacing-6) var(--spacing-5); - overflow-y: auto; - flex: 1; - min-height: 0; -} + // Only the menu trigger carries a chevron; the menu items reuse `.st` as + // plain spans and must stay chip-shaped. + .st-chev { + flex: none; + margin-left: -1px; + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; + opacity: 0.8; + } -// Individual locale card (non-base locales) -.locale-card { - padding: var(--spacing-4); - background-color: var(--color-background-subtle); - border: 1px solid var(--color-border); - border-left: 3px solid var(--color-border); - border-radius: var(--border-radius-lg); - transition: border-left-color var(--transition-fast), box-shadow var(--transition-fast); + &:disabled { + cursor: default; + // Nothing to open when read-only, so drop the affordance and rebalance. + padding-right: var(--spacing-2); - &:hover { - border-left-color: var(--color-text-tertiary); + .st-chev { + display: none; + } } - &:focus-within { - border-left-color: var(--color-primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus-ring-color) 25%, transparent); + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; } - // The locale value field reserves no subscript space of its own — that is - // `subscriptSizing="dynamic"` on the field, not a deep override here. - .full-width { - margin-top: var(--spacing-2); + &--new { + color: var(--color-status-new-ink); + background: var(--color-status-new-surface); + border-color: var(--color-status-new-border); + } + &--stale { + color: var(--color-status-stale-ink); + background: var(--color-status-stale-surface); + border-color: var(--color-status-stale-border); + } + &--translated { + color: var(--color-status-translated-ink); + background: var(--color-status-translated-surface); + border-color: var(--color-status-translated-border); + } + &--verified { + color: var(--color-status-verified-ink); + background: var(--color-status-verified-surface); + border-color: var(--color-status-verified-border); } } -// Status select — right-aligned, compact -:host .status-select { - width: 140px; - margin-left: auto; +// ───────────────────────────────────────────────────────────────────────────── +// Tags well (the house chip editor) +// ───────────────────────────────────────────────────────────────────────────── + +.chip-well { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 7px var(--spacing-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--border-radius-md); + background: var(--dialog-well); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); - ::ng-deep .mat-mdc-text-field-wrapper { - padding-top: 4px; - padding-bottom: 4px; + &:focus-within { + border-color: var(--color-secondary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 22%, transparent); } - ::ng-deep .mat-mdc-form-field-infix { - padding-top: 4px; - padding-bottom: 4px; - min-height: unset; + &--empty { + border-style: dashed; } } -// ───────────────────────────────────────────────────────────────────────────── -// Tab 3 — Change location -// ───────────────────────────────────────────────────────────────────────────── +.chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 6px 2px 10px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-full); + background: var(--color-background-subtle); + color: var(--color-text-primary); + font-size: 13px; + font-weight: var(--font-weight-medium); + line-height: 1.5; + + mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-tertiary); + } -.tab-panel--location { - // Slightly tighter top padding — the hint text already provides visual lead-in - padding-top: var(--spacing-4); - gap: var(--spacing-3); + // Inherited from the collection: present, explained, and not removable. + &--inherited { + padding: 2px 10px 2px 8px; + border-style: dashed; + color: var(--color-text-secondary); + } } -.location-tab-hint { - margin: 0; - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - line-height: var(--line-height-normal); +.chip-remove { + display: grid; + place-items: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: var(--border-radius-full); + background: transparent; + color: var(--color-text-tertiary); + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + color: inherit; + } + + &:hover { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 12%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } } -// The FolderPicker is `initiallyExpanded` but the toggle header is still -// rendered. Hide it so users can't collapse the tree inside this tab. -.location-folder-picker { - ::ng-deep app-folder-picker { - .folder-picker { - margin-bottom: 0; - } +.chip-add { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 1 1 140px; + min-width: 140px; + + mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-text-secondary); + } - // Hide the toggle header — the tab itself is the affordance for location - .folder-header { - display: none; - } + // Once there are chips the add row becomes one more pill in the row. + &--pill { + flex: 0 1 150px; + min-width: 120px; + padding: 1px 8px 1px 6px; + border: 1px dashed var(--color-border-strong); + border-radius: var(--border-radius-full); + color: var(--color-text-secondary); + } +} - // `initiallyExpanded` keeps this panel rendered, so it needs no forced - // display — only the framing the tab context gives it. - .folder-picker-content { - border: 1px solid var(--color-border); - border-radius: var(--border-radius-md); - margin-top: 0; - overflow-y: auto; - // Sized by the tree, between a floor that keeps a two-folder collection - // reading as a tree and a ceiling that scales with the viewport instead - // of a flat 320px. - min-height: 140px; - max-height: min(420px, 46vh); - padding: var(--spacing-3); - } +.chip-add-input { + flex: 1; + min-width: 0; + height: 26px; + border: 0; + padding: 0 4px; + background: transparent; + color: var(--color-text-primary); + font: inherit; + font-size: var(--font-size-sm); + outline: none; + + &::placeholder { + color: var(--color-text-placeholder); } } // ───────────────────────────────────────────────────────────────────────────── -// Dialog action bar +// Other-locales link row // ───────────────────────────────────────────────────────────────────────────── -mat-dialog-actions { - flex-shrink: 0; - padding: var(--spacing-4) var(--spacing-6); - border-top: 1px solid var(--color-border); - background-color: var(--color-background); +.linkrow { display: flex; - gap: var(--spacing-3); - justify-content: flex-end; align-items: center; - // Override Material's built-in min-height to keep it compact - min-height: unset; -} + gap: 10px; + width: 100%; + padding: 10px var(--spacing-3) 10px 14px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + background: transparent; + color: var(--color-text-primary); + font: inherit; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + text-align: left; + cursor: pointer; + transition: background var(--transition-fast); -.submit-button { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - transition: box-shadow var(--transition-fast), transform var(--transition-fast); + &:hover:not(:disabled) { + background: var(--color-background-subtle); + } - &:not(:disabled):hover { - box-shadow: var(--shadow-md); - transform: translateY(-1px); + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: -2px; } - &:not(:disabled):active { - transform: translateY(0); + &:disabled { + cursor: default; + opacity: 0.6; } } -.submit-spinner { - display: inline-block; +.linkrow-lead { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); } -.cancel-button { +.linkrow-sum { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); color: var(--color-text-secondary); - transition: color var(--transition-fast), background-color var(--transition-fast); + white-space: nowrap; - &:not(:disabled):hover { - color: var(--color-text-primary); - background-color: var(--color-background-muted); + .dots { + display: inline-flex; + gap: 3px; } } +.linkrow-go { + flex: none; + color: var(--color-text-tertiary); +} + // ───────────────────────────────────────────────────────────────────────────── -// Inherited tag chips +// Footer // ───────────────────────────────────────────────────────────────────────────── -:host ::ng-deep .chip--inherited { - opacity: 0.75; - border: 1px dashed currentColor; - cursor: default; +.dialog-footer { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 14px var(--spacing-6) 18px; + border-top: 1px solid var(--color-border); } -// ───────────────────────────────────────────────────────────────────────────── -// Reduced motion -// ───────────────────────────────────────────────────────────────────────────── +.footer-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); -@media (prefers-reduced-motion: reduce) { - .submit-button, - .cancel-button, - .close-button { - transition: none; + mat-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; + color: var(--color-success); } - // The pill still lands on its new value; only the beat is dropped. - .location-pill--absorbed { - animation: none; + // The affirmative glyph is earned: while the form is invalid it stays a quiet outline. + .footer-meta-icon--idle { + color: var(--color-text-tertiary); } -} -// ───────────────────────────────────────────────────────────────────────────── -// Tab labels — validity signal -// ───────────────────────────────────────────────────────────────────────────── + .mono { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-primary); + } -.tab-label { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); + // The key is a copy button, so it drops every scrap of button chrome and + // keeps the mono line it replaced. Only hover and focus say it is pressable. + .footer-key { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: 100%; + padding: 2px 4px; + margin: -2px 0; + border: 0; + border-radius: var(--border-radius-sm); + background: none; + font-size: inherit; + line-height: inherit; + cursor: pointer; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--color-text-primary) 8%, transparent); + + .footer-key-text { + text-decoration: underline; + text-underline-offset: 3px; + } + + .footer-key-icon { + opacity: 1; + } + } - @media (max-width: 700px) { - justify-content: center; - gap: var(--spacing-1); - text-align: center; + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; + } } -} -// Marks the tab holding a blocking error, so a rejected save is legible from -// whichever tab the user happens to be standing on. -.tab-error-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background-color: var(--color-error); - flex-shrink: 0; - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 18%, transparent); -} + .footer-key-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } -@media (prefers-reduced-motion: reduce) { - .locale-card, - .base-locale-card, - .base-value-reference { - transition: none; + .footer-key-icon { + font-size: 13px; + width: 13px; + height: 13px; + color: inherit; + opacity: 0.55; + } + + // The key is already taken in the target folder: the footer says so too, + // since it is now the only place the full key is spelled out. + .mono--dup { + color: var(--color-error); + text-decoration: underline wavy color-mix(in srgb, var(--color-error) 60%, transparent); + text-underline-offset: 3px; + } + + // A flex container does not pass its text-decoration down to its items, so the + // collision squiggle is drawn on the run of text inside the button. + .footer-key.mono--dup .footer-key-text { + text-decoration: underline wavy color-mix(in srgb, var(--color-error) 60%, transparent); + text-underline-offset: 3px; } } -// The locale name is now human-readable, so keep the raw code visible for the -// developer who thinks in `fr-ca` rather than "French (Canada)". -.locale-code { - font-family: var(--font-family-mono); - font-size: var(--font-size-xs); - color: var(--color-text-secondary); - background-color: color-mix(in srgb, var(--color-text-primary) 6%, transparent); - border-radius: var(--border-radius-sm); - padding: 0 var(--spacing-1); - letter-spacing: 0.02em; +.footer-spacer { + flex: 1; +} + +.submit-spinner { + margin-right: var(--spacing-2); } // ───────────────────────────────────────────────────────────────────────────── -// Narrow viewports -// -// Below the `sm` breakpoint the panel goes full-screen (see -// `.translation-editor-dialog-panel` in styles.scss) and every horizontal -// affordance in here has to survive ~390px of width holding German and Russian -// copy. Three things break at that width and are fixed together below: the tab -// strip paginates and hides its own active tab, the locale card header runs the -// status select off the edge, and the 24px gutters eat a sixth of the line. +// Responsive // ───────────────────────────────────────────────────────────────────────────── -@include bp.sm-max { - // Full-screen mode: the content area takes everything the chrome leaves, - // rather than stopping at the desktop ceiling and stranding blank space. - // Full-screen mode: the panel has a definite height of its own, so the flex - // column can hand the content area whatever the chrome leaves rather than - // stopping at the desktop ceiling and stranding blank space beneath it. - :host .dialog-tab-content, - :host .dialog-tab-content.dialog-tab-content--short { - height: auto; - max-height: none; +// Under 1100px the context column goes and its blocks reappear in the disclosure +// at the bottom of the form. The drawer then covers the whole body. +@media (max-width: 1099px) { + .two { + grid-template-columns: minmax(0, 1fr); } - .dialog-header { - padding: var(--spacing-4) var(--spacing-4) var(--spacing-3); + .bd { + border-right: 0; } - .mat-dialog-title { - font-size: var(--font-size-xl); - } +} - .dialog-pretab { - padding: var(--spacing-3) var(--spacing-4); - gap: var(--spacing-2); +@media (max-width: 639px) { + .dialog-header { + padding: var(--spacing-4) var(--spacing-4) var(--spacing-3); } - .tab-panel { + .bd { padding: var(--spacing-4); gap: var(--spacing-4); } - .base-value-reference { - padding: var(--spacing-3) var(--spacing-4); - margin: var(--spacing-3) var(--spacing-4) 0; + .row-3-2 { + grid-template-columns: minmax(0, 1fr); } - .other-locales-section { - padding: var(--spacing-3) var(--spacing-4) var(--spacing-4); - gap: var(--spacing-3); + .field-hint--row, + .field-error--row { + margin-top: calc(-1 * var(--spacing-2)); } - .locale-card, - .base-locale-card { - padding: var(--spacing-3); + .drawer { + padding: var(--spacing-3) var(--spacing-4) var(--spacing-4); } - // The status select is a peer of the locale name, not a right-hand rail: - // at this width it drops onto its own full-width line instead of squeezing - // "French (Canada)" into three characters. - .locale-card-header { - flex-wrap: wrap; - row-gap: var(--spacing-2); + .dialog-footer { + padding: var(--spacing-3) var(--spacing-4) var(--spacing-4); } +} - :host .status-select { - width: 100%; - margin-left: 0; +@media (prefers-reduced-motion: reduce) { + .drawer, + .disclosure-panel { + animation: none; } - // Both actions get equal weight and a full-width target — there is no room - // for a right-aligned pair, and neither is a thumb-friendly hit area at 64px. - mat-dialog-actions { - padding: var(--spacing-3) var(--spacing-4); + .loc-pill--flash { + animation: none; + } - .cancel-button, - .submit-button { - flex: 1 1 0; - min-height: 44px; - } + .disclosure-chevron, + .linkrow, + .text-input, + .chip-well { + transition: none; } } diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts index ae09861..a1e3de1 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.spec.ts @@ -5,14 +5,17 @@ import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dial import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; -import { of, throwError } from 'rxjs'; -import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { patchState } from '@ngrx/signals'; +import { of, Subject, throwError } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; import { NotificationService } from '../../../shared/notification'; import { BrowserApiService } from '../../services/browser-api.service'; +import { BrowserStore } from '../../store/browser.store'; import { TranslationEditorDialog, + TRANSLATION_EDITOR_TITLE_ID, type TranslationEditorDialogData, type TranslationEditorResult, } from './translation-editor-dialog'; @@ -27,6 +30,7 @@ describe('TranslationEditorDialog', () => { createResource: Mock; updateResource: Mock; searchTranslations: Mock; + getResourceTree: Mock; }; let mockNotifications: { success: Mock; info: Mock; warning: Mock; error: Mock }; @@ -41,6 +45,9 @@ describe('TranslationEditorDialog', () => { const dialogData = createMockData('create'); + /** Lets the deferred focus task the dialog queues after a confirmation run. */ + const flushFocus = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + const createDialog = createComponentFactory({ component: TranslationEditorDialog, imports: [BrowserAnimationsModule, getTranslocoTestingModule()], @@ -85,6 +92,7 @@ describe('TranslationEditorDialog', () => { createResource: vi.fn().mockReturnValue(of({})), updateResource: vi.fn().mockReturnValue(of({})), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; @@ -97,9 +105,17 @@ describe('TranslationEditorDialog', () => { expect(component).toBeTruthy(); }); + it('should render the heading the dialog container is labelled by', () => { + const heading = spectator.query(`#${TRANSLATION_EDITOR_TITLE_ID}`); + + expect(heading).toBeTruthy(); + expect(heading?.tagName).toBe('H2'); + expect(heading?.textContent?.trim()).toBeTruthy(); + }); + it('should display create mode title and subtitle', () => { expect(component.dialogTitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CREATETITLE); - expect(component.dialogSubtitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CREATESUBTITLE); + expect(component.dialogSubtitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CREATESUBTITLEX); }); it('should display edit mode title and subtitle', async () => { @@ -111,7 +127,7 @@ describe('TranslationEditorDialog', () => { renderDialog(editData); expect(component.dialogTitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITTITLE); - expect(component.dialogSubtitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITSUBTITLE); + expect(component.dialogSubtitle()).toBe(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITSUBTITLEX); }); it('should initialize form controls for all non-base locales', () => { @@ -390,6 +406,7 @@ describe('TranslationEditorDialog', () => { createResource: vi.fn().mockReturnValue(of({})), updateResource: vi.fn().mockReturnValue(of({})), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; renderDialog(dataWithoutFolder); @@ -544,6 +561,7 @@ describe('TranslationEditorDialog', () => { createResource: vi.fn().mockReturnValue(of({})), updateResource: vi.fn().mockReturnValue(of({})), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; renderDialog(editData); @@ -670,6 +688,7 @@ describe('TranslationEditorDialog', () => { createResource: vi.fn().mockReturnValue(of({})), updateResource: vi.fn().mockReturnValue(of({})), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockNotifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; renderDialog(createMockData('create')); @@ -767,6 +786,60 @@ describe('TranslationEditorDialog', () => { expect(dialogRef.close).not.toHaveBeenCalled(); }); + it('should focus the comment field when user clicks "Add Comment"', async () => { + mockDialog.open.mockReturnValue({ afterClosed: vi.fn().mockReturnValue(of(false)) }); + + component.form.controls.key.setValue('test_key'); + component.form.controls.baseValue.setValue('Test Value'); + component.form.controls.comment.setValue(''); + + await component.onSubmit(); + // Focus is deferred a task past afterClosed() so the confirmation's focus + // trap cannot restore focus to the Save button on top of it. + await flushFocus(); + + const commentField = spectator.query('#translation-editor-comment'); + expect(commentField).toBeTruthy(); + expect(document.activeElement).toBe(commentField); + }); + + it('should focus the comment field in edit mode when user clicks "Add Comment"', async () => { + renderDialog( + createMockData('edit', { + key: 'test_key', + translations: { en: 'Test Value' }, + status: {}, + comment: 'Existing comment', + }), + ); + mockDialog.open.mockReturnValue({ afterClosed: vi.fn().mockReturnValue(of(false)) }); + + component.form.controls.comment.setValue(''); + + await component.onSubmit(); + await flushFocus(); + + const commentField = spectator.query('#translation-editor-comment'); + expect(commentField).toBeTruthy(); + expect(document.activeElement).toBe(commentField); + }); + + it('should select any existing comment text when the field is focused', async () => { + mockDialog.open.mockReturnValue({ afterClosed: vi.fn().mockReturnValue(of(false)) }); + + component.form.controls.key.setValue('test_key'); + component.form.controls.baseValue.setValue('Test Value'); + component.form.controls.comment.setValue(' '); + spectator.detectChanges(); + + await component.onSubmit(); + await flushFocus(); + + const commentField = spectator.query('#translation-editor-comment'); + expect(commentField?.selectionStart).toBe(0); + expect(commentField?.selectionEnd).toBe(3); + }); + it('should not save when user cancels confirmation dialog', async () => { const mockConfirmationDialogRef = { afterClosed: vi.fn().mockReturnValue(of(undefined)), @@ -897,6 +970,7 @@ describe('TranslationEditorDialog', () => { .fn() .mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true, skippedLocales: ['es'] })), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockDialog = { open: vi.fn().mockReturnValue({ afterClosed: () => of(true) }), @@ -935,6 +1009,7 @@ describe('TranslationEditorDialog', () => { .fn() .mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true, skippedLocales: [] })), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockDialog = { open: vi.fn().mockReturnValue({ afterClosed: () => of(true) }), @@ -952,6 +1027,603 @@ describe('TranslationEditorDialog', () => { }); }); + describe('Location popover', () => { + it('should stay closed until the location pill is used', () => { + expect(component.isFolderPopoverOpen()).toBe(false); + expect(document.querySelector('.pop')).toBeNull(); + }); + + it('should open the popover from the location pill', () => { + spectator.click('[data-testid="location-pill"]'); + spectator.detectChanges(); + + expect(component.isFolderPopoverOpen()).toBe(true); + expect(document.querySelector('.pop')).not.toBeNull(); + }); + + it('should toggle the popover shut on a second click', () => { + component.openFolderPopover(); + spectator.detectChanges(); + + component.toggleFolderPopover(); + spectator.detectChanges(); + + expect(component.isFolderPopoverOpen()).toBe(false); + }); + + it('should stage a folder without committing it', () => { + component.openFolderPopover(); + component.onFolderStaged('common.errors'); + + expect(component.stagedFolderPath()).toBe('common.errors'); + expect(component.selectedFolderPath()).toBe('common.buttons'); + expect(component.popoverFolderPath()).toBe('common.errors'); + }); + + it('should commit the staged folder and close on confirm', () => { + component.openFolderPopover(); + component.onFolderStaged('common.errors'); + + component.confirmStagedFolder(); + + expect(component.selectedFolderPath()).toBe('common.errors'); + expect(component.isFolderPopoverOpen()).toBe(false); + expect(component.stagedFolderPath()).toBeNull(); + }); + + it('should keep the current folder when nothing was staged', () => { + component.openFolderPopover(); + + component.confirmStagedFolder(); + + expect(component.selectedFolderPath()).toBe('common.buttons'); + expect(component.isFolderPopoverOpen()).toBe(false); + }); + + it('should dismiss the popover instead of the dialog on cancel', async () => { + component.openFolderPopover(); + + await component.onCancel(); + + expect(component.isFolderPopoverOpen()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('should not open the popover in a read-only collection', () => { + renderDialog({ ...createMockData('create'), readOnly: true }); + + component.toggleFolderPopover(); + + expect(component.isFolderPopoverOpen()).toBe(false); + }); + }); + + describe('Other locales drawer', () => { + it('should stay closed until the Other locales row is used', () => { + expect(component.isLocalesDrawerOpen()).toBe(false); + expect(spectator.query('[data-testid="locales-drawer"]')).toBeNull(); + }); + + it('should open the drawer from the Other locales row', () => { + spectator.click('[data-testid="other-locales-row"]'); + spectator.detectChanges(); + + expect(component.isLocalesDrawerOpen()).toBe(true); + expect(spectator.query('[data-testid="locales-drawer"]')).not.toBeNull(); + }); + + it('should close the drawer from Done', () => { + component.openLocalesDrawer(); + spectator.detectChanges(); + + spectator.click('[data-testid="drawer-done"]'); + spectator.detectChanges(); + + expect(component.isLocalesDrawerOpen()).toBe(false); + expect(spectator.query('[data-testid="locales-drawer"]')).toBeNull(); + }); + + it('should dismiss the drawer instead of the dialog on cancel', async () => { + component.openLocalesDrawer(); + + await component.onCancel(); + + expect(component.isLocalesDrawerOpen()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('should not open when the collection has only the base locale', () => { + renderDialog({ ...createMockData('create'), availableLocales: ['en'] }); + + component.openLocalesDrawer(); + + expect(component.isLocalesDrawerOpen()).toBe(false); + }); + + it('should edit the same FormArray the save path reads', () => { + component.openLocalesDrawer(); + spectator.detectChanges(); + + component.setLocaleStatus(0, 'verified'); + + expect(component.form.controls.translations.at(0).value.status).toBe('verified'); + }); + }); + + describe('Context column', () => { + it('should split the folder path for the location pill', () => { + expect(component.folderSegments()).toEqual(['common', 'buttons']); + }); + + it('should mark the entry being created in the context tree', () => { + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + + const entry = component.contextTree().find((node) => node.kind === 'entry' && node.name === 'ok'); + expect(entry?.mark).toBe('new'); + }); + + it('should mark the target folder as the one the entry lands in', () => { + expect(component.contextTree().some((node) => node.here === true)).toBe(true); + }); + + it('should highlight the row of the entry being created, not just pill it', () => { + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + + const rows = spectator.queryAll('[data-testid="context-tree"] .ftree-n--target'); + expect(rows).toHaveLength(1); + expect(rows[0]?.textContent).toContain('ok'); + }); + + it('should highlight the row of the entry being edited', () => { + renderDialog(createMockData('edit', { key: 'ok', translations: { en: 'OK' }, status: {} })); + spectator.detectChanges(); + + const rows = spectator.queryAll('[data-testid="context-tree"] .ftree-n--target'); + expect(rows).toHaveLength(1); + expect(rows[0]?.textContent).toContain('ok'); + expect(rows[0]).not.toHaveClass('ftree-n--taken'); + }); + + it('should not claim a collision before a key is typed', () => { + expect(component.keyCollision()).toBe(false); + }); + + it('should not repeat the full key, which the footer already carries', () => { + expect(spectator.query('[data-testid="context-full-key"]')).toBeNull(); + expect(spectator.query('[data-testid="footer-key"]')).not.toBeNull(); + }); + + it('should summarise only the folder and the similar count', () => { + expect(component.contextSummary()).toBe('common.buttons'); + }); + + it('should list only the locales that are new or stale', () => { + renderDialog( + createMockData('edit', { + key: 'ok', + translations: { en: 'OK', fr: 'Oui', de: 'Ja' }, + status: { fr: 'stale', de: 'verified' }, + }), + ); + + expect(component.localesNeedingWork().map((locale) => locale.locale)).toEqual(['fr']); + const rows = spectator.queryAll('[data-testid="locale-summary"] .lsum-r'); + expect(rows).toHaveLength(1); + expect(rows[0]?.textContent).toContain('fr'); + expect(spectator.query('[data-testid="locales-all-up-to-date"]')).toBeNull(); + }); + + it('should show the caught-up line instead of an empty list', () => { + renderDialog( + createMockData('edit', { + key: 'ok', + translations: { en: 'OK', fr: 'Oui', de: 'Ja' }, + status: { fr: 'translated', de: 'verified' }, + }), + ); + + expect(component.localesNeedingWork()).toHaveLength(0); + expect(spectator.queryAll('[data-testid="locale-summary"] .lsum-r')).toHaveLength(0); + expect(spectator.query('[data-testid="locales-all-up-to-date"]')).not.toBeNull(); + }); + }); + + describe('Key collision', () => { + const entry = (key: string): ResourceSummaryDto => ({ key, translations: { en: key }, status: {} }); + + /** Puts entries in the folder the browser is showing, the cheapest source. */ + const seedBrowserFolder = (folderPath: string, keys: string[]): void => { + const store = spectator.inject(BrowserStore); + patchState(store, { currentFolderPath: folderPath, translations: keys.map(entry) }); + }; + + it('should detect a collision against the entries the browser already holds', () => { + seedBrowserFolder('common.buttons', ['ok', 'cancel']); + + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + + expect(component.keyCollision()).toBe(true); + expect(spectator.query('[data-testid="key-collision-error"]')).not.toBeNull(); + }); + + it('should ignore nested resources the browser folds into the folder listing', () => { + seedBrowserFolder('common.buttons', ['ok', 'confirm.dialog.title']); + + component.form.controls.key.setValue('confirm'); + spectator.detectChanges(); + + expect(component.keyCollision()).toBe(false); + expect(component.contextTree().some((node) => node.name === 'confirm.dialog.title')).toBe(false); + }); + + it('should compare keys exactly, so case alone is not a collision', () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('OK'); + spectator.detectChanges(); + + expect(component.keyCollision()).toBe(false); + }); + + it('should detect a collision in a folder chosen from the popover', () => { + mockBrowserApi.getResourceTree.mockImplementation((_collection: string, path: string) => + of({ path, resources: path === 'common.errors' ? [entry('notFound')] : [], children: [] }), + ); + + component.form.controls.key.setValue('notFound'); + spectator.detectChanges(); + expect(component.keyCollision()).toBe(false); + + component.openFolderPopover(); + component.onFolderStaged('common.errors'); + component.confirmStagedFolder(); + spectator.detectChanges(); + + expect(mockBrowserApi.getResourceTree).toHaveBeenCalledWith('test-collection', 'common.errors', false); + expect(component.keyCollision()).toBe(true); + }); + + it('should not re-fetch a folder it has already loaded', () => { + component.onFolderConfirmed('common.errors'); + component.onFolderConfirmed('common.buttons'); + component.onFolderConfirmed('common.errors'); + + const errorFolderLoads = mockBrowserApi.getResourceTree.mock.calls.filter((call) => call[1] === 'common.errors'); + expect(errorFolderLoads).toHaveLength(1); + }); + + it('should claim nothing while a folder is still loading', () => { + const pending = new Subject(); + mockBrowserApi.getResourceTree.mockReturnValue(pending); + + component.onFolderConfirmed('common.errors'); + component.form.controls.key.setValue('notFound'); + spectator.detectChanges(); + + expect(component.keyCollision()).toBe(false); + expect(component.contextTree().some((node) => node.kind === 'entry')).toBe(false); + + pending.next({ path: 'common.errors', resources: [entry('notFound')], children: [] }); + pending.complete(); + spectator.detectChanges(); + + expect(component.keyCollision()).toBe(true); + }); + + it('should never collide in edit mode, where the key is locked', () => { + renderDialog(createMockData('edit', entry('ok'))); + seedBrowserFolder('common.buttons', ['ok']); + spectator.detectChanges(); + + expect(component.keyCollision()).toBe(false); + expect(spectator.query('[data-testid="key-collision-error"]')).toBeNull(); + }); + + it('should mark the colliding leaf as an existing entry in the context tree', () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + + const leaf = component.contextTree().find((node) => node.kind === 'entry' && node.name === 'ok'); + expect(leaf?.mark).toBe('exists'); + expect(spectator.query('[data-testid="tree-exists-pill"]')).not.toBeNull(); + }); + + it('should mark the colliding row error-coloured rather than accented', () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + + expect(spectator.queryAll('[data-testid="context-tree"] .ftree-n--taken')).toHaveLength(1); + expect(spectator.queryAll('[data-testid="context-tree"] .ftree-n--target')).toHaveLength(0); + }); + + it('should turn the footer key the error colour', () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + + expect(spectator.query('[data-testid="footer-key"]')).toHaveClass('mono--dup'); + }); + + it('should leave the footer key unmarked while the key is free', () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('cancel'); + spectator.detectChanges(); + + expect(spectator.query('[data-testid="footer-key"]')).not.toHaveClass('mono--dup'); + }); + + it('should take the footer validity glyph back to idle', () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('ok'); + component.form.controls.baseValue.setValue('OK'); + spectator.detectChanges(); + + expect(component.form.valid).toBe(true); + expect(component.isFormValid()).toBe(false); + }); + + it('should close with shouldOpenEdit from "Open existing"', async () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + + spectator.click('[data-testid="open-existing"]'); + await Promise.resolve(); + await Promise.resolve(); + + const result = dialogRef.close.mock.calls.at(-1)?.[0] as TranslationEditorResult; + expect(result.shouldOpenEdit).toBe(true); + expect(result.existingResourceKey).toBe('common.buttons.ok'); + }); + + it('should offer the conflict dialog instead of saving when the key is taken', async () => { + seedBrowserFolder('common.buttons', ['ok']); + + component.form.controls.key.setValue('ok'); + component.form.controls.baseValue.setValue('OK'); + component.form.controls.comment.setValue('The affirmative button'); + spectator.detectChanges(); + + await component.onSubmit(); + + expect(mockBrowserApi.createResource).not.toHaveBeenCalled(); + expect(mockDialog.open).toHaveBeenCalled(); + const result = dialogRef.close.mock.calls.at(-1)?.[0] as TranslationEditorResult; + expect(result.shouldOpenEdit).toBe(true); + expect(result.existingResourceKey).toBe('common.buttons.ok'); + }); + }); + + describe('Sticky similar values', () => { + const hit = (key: string, value: string) => ({ key, translations: { en: value }, status: {} }); + + const searchReturns = (results: ReturnType[]): void => { + mockBrowserApi.searchTranslations.mockReturnValue( + of({ query: '', results, totalFound: results.length, limited: false }), + ); + }; + + /** Types a value and lets the 300ms debounce run out. */ + const typeAndSettle = (value: string): void => { + component.form.controls.baseValue.setValue(value); + vi.advanceTimersByTime(300); + spectator.detectChanges(); + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should take no space with zero hits', () => { + searchReturns([]); + typeAndSettle('Discard unsaved changes?'); + + expect(component.showSimilarContext()).toBe(false); + expect(spectator.query('app-similar-translations')).toBeNull(); + }); + + it('should show hits and pin them while the value is unchanged', () => { + searchReturns([hit('common.actions.save', 'Save')]); + typeAndSettle('Save changes'); + + expect(component.showSimilarContext()).toBe(true); + + // Work elsewhere in the form leaves the pinned block alone. + component.form.controls.comment.setValue('A comment'); + component.addTagValue('browser'); + vi.advanceTimersByTime(1000); + spectator.detectChanges(); + + expect(component.similarCount()).toBe(1); + expect(spectator.query('app-similar-translations')).not.toBeNull(); + }); + + it('should clear the pinned hits the moment the value changes', () => { + searchReturns([hit('common.actions.save', 'Save')]); + typeAndSettle('Save changes'); + expect(component.similarCount()).toBe(1); + + component.form.controls.baseValue.setValue('Save changes now'); + spectator.detectChanges(); + + // Before the debounce has even started to run out. + expect(component.similarCount()).toBe(0); + expect(component.showSimilarContext()).toBe(false); + }); + + it('should stay silent below the three-character floor', () => { + searchReturns([hit('common.actions.ok', 'OK')]); + typeAndSettle('OK'); + + expect(mockBrowserApi.searchTranslations).not.toHaveBeenCalled(); + expect(component.showSimilarContext()).toBe(false); + }); + + it('should show nothing in edit mode until the value differs, and clear again on revert', () => { + vi.useRealTimers(); + renderDialog( + createMockData('edit', { key: 'saveShortcutHint', translations: { en: 'Press Ctrl + Enter' }, status: {} }), + ); + vi.useFakeTimers(); + searchReturns([hit('common.actions.save', 'Press Ctrl + Enter')]); + + typeAndSettle('Press Ctrl + Enter'); + expect(mockBrowserApi.searchTranslations).not.toHaveBeenCalled(); + expect(component.showSimilarContext()).toBe(false); + + typeAndSettle('Press Ctrl + Enter to save'); + expect(component.showSimilarContext()).toBe(true); + + typeAndSettle('Press Ctrl + Enter'); + expect(component.showSimilarContext()).toBe(false); + }); + + it('should single out a hit carrying the identical text', () => { + searchReturns([hit('browser.search.clear', 'Clear Search'), hit('common.actions.clearAll', 'Clear All')]); + typeAndSettle('clear search'); + + expect(component.exactMatchKey()).toBe('browser.search.clear'); + expect(spectator.query('[data-testid="similar-exact-caption"]')).not.toBeNull(); + }); + + it('should break a suggested key only after its dots', () => { + searchReturns([hit('browser.translationEditor.saveShortcutHint', 'Press Ctrl + Enter to save')]); + typeAndSettle('Press Ctrl + Enter to save'); + + const key = spectator.query('app-similar-translations .result-key'); + expect(key).toBeDefined(); + expect(key?.textContent?.trim()).toBe('browser.translationEditor.saveShortcutHint'); + // One break opportunity per dot, and none inside a segment. + expect(key?.querySelectorAll('wbr').length).toBe(2); + }); + + it('should leave a merely similar hit unmarked', () => { + searchReturns([hit('common.actions.clearAll', 'Clear All')]); + typeAndSettle('Clear Search'); + + expect(component.exactMatchKey()).toBe(''); + expect(spectator.query('[data-testid="similar-exact-caption"]')).toBeNull(); + }); + + it('should drop hits that only matched on their key', () => { + searchReturns([ + hit('browser.translationEditor.saveButton', 'Create translation'), + hit('common.actions.save', 'Save'), + ]); + typeAndSettle('Save draft'); + + expect(component.similarResources().map((result) => result.key)).toEqual(['common.actions.save']); + expect(component.similarCount()).toBe(1); + }); + + it('should ask for more hits than it shows and keep at most ten', () => { + searchReturns(Array.from({ length: 25 }, (_, index) => hit(`common.actions.save${index}`, 'Save changes'))); + typeAndSettle('Save changes'); + + expect(mockBrowserApi.searchTranslations).toHaveBeenCalledWith('test-collection', 'Save changes', 25); + expect(component.similarCount()).toBe(10); + }); + + it('should count only the value matches, so the badge and the list agree', () => { + searchReturns([ + hit('browser.translationEditor.saveButton', 'Create translation'), + hit('browser.translationEditor.saveAnyway', 'Save Anyway'), + hit('common.actions.save', 'Save'), + ]); + typeAndSettle('Save'); + + expect(component.similarCount()).toBe(2); + expect(component.similarResources().map((result) => result.key)).toEqual([ + 'browser.translationEditor.saveAnyway', + 'common.actions.save', + ]); + }); + }); + + describe('Focus handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should move focus into the drawer and hand it back on Done', () => { + component.openLocalesDrawer(); + spectator.detectChanges(); + vi.advanceTimersByTime(0); + + const drawerField = spectator.query('[data-testid="locales-drawer"] textarea'); + expect(document.activeElement).toBe(drawerField); + + component.closeLocalesDrawer(); + spectator.detectChanges(); + vi.advanceTimersByTime(0); + + expect(document.activeElement).toBe(spectator.query('[data-testid="other-locales-row"]')); + }); + + it('should hand focus back to the Other locales row when Escape closes the drawer', async () => { + component.openLocalesDrawer(); + spectator.detectChanges(); + vi.advanceTimersByTime(0); + + await component.onCancel(); + spectator.detectChanges(); + vi.advanceTimersByTime(0); + + expect(component.isLocalesDrawerOpen()).toBe(false); + expect(document.activeElement).toBe(spectator.query('[data-testid="other-locales-row"]')); + }); + + it('should move focus to the popover filter and hand it back to the pill', () => { + component.openFolderPopover(); + spectator.detectChanges(); + vi.advanceTimersByTime(0); + + expect(document.activeElement).toBe(component.folderFilterInput?.nativeElement); + + component.confirmStagedFolder(); + spectator.detectChanges(); + vi.advanceTimersByTime(0); + + expect(document.activeElement).toBe(spectator.query('[data-testid="location-pill"]')); + }); + + it('should dismiss the popover before the drawer before the dialog', async () => { + component.openLocalesDrawer(); + component.openFolderPopover(); + spectator.detectChanges(); + + await component.onCancel(); + expect(component.isFolderPopoverOpen()).toBe(false); + expect(component.isLocalesDrawerOpen()).toBe(true); + + await component.onCancel(); + expect(component.isLocalesDrawerOpen()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); + + await component.onCancel(); + expect(dialogRef.close).toHaveBeenCalled(); + }); + }); + describe('Edit Mode API Integration', () => { it('should call updateResource API when submitting in edit mode', async () => { const mockResource: ResourceSummaryDto = { @@ -973,6 +1645,7 @@ describe('TranslationEditorDialog', () => { createResource: vi.fn().mockReturnValue(of({})), updateResource: vi.fn().mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockDialog = { open: vi.fn().mockReturnValue({ @@ -1017,6 +1690,7 @@ describe('TranslationEditorDialog', () => { createResource: vi.fn().mockReturnValue(of({})), updateResource: vi.fn().mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockDialog = { open: vi.fn().mockReturnValue({ @@ -1070,6 +1744,7 @@ describe('TranslationEditorDialog', () => { ), ), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockDialog = { open: vi.fn().mockReturnValue({ @@ -1105,6 +1780,7 @@ describe('TranslationEditorDialog', () => { createResource: vi.fn().mockReturnValue(of({})), updateResource: vi.fn().mockReturnValue(of({ resolvedKey: 'common.buttons.existing_key', updated: true })), searchTranslations: vi.fn().mockReturnValue(of({ results: [], total: 0 })), + getResourceTree: vi.fn().mockReturnValue(of({ path: '', resources: [], children: [] })), }; mockDialog = { open: vi.fn().mockReturnValue({ @@ -1124,4 +1800,53 @@ describe('TranslationEditorDialog', () => { expect(result.baseValue).toBe('Updated Value'); }); }); + + describe('Footer key copy', () => { + let mockClipboard: { writeText: Mock }; + + beforeEach(() => { + mockClipboard = { writeText: vi.fn(() => Promise.resolve()) }; + Object.defineProperty(navigator, 'clipboard', { + value: mockClipboard, + writable: true, + configurable: true, + }); + renderDialog(createMockData('create')); + component.form.controls.key.setValue('ok'); + spectator.detectChanges(); + }); + + it('should copy the full key and confirm it', async () => { + spectator.click('[data-testid="footer-key"]'); + await Promise.resolve(); + spectator.detectChanges(); + + expect(mockClipboard.writeText).toHaveBeenCalledWith('common.buttons.ok'); + expect(mockNotifications.success).toHaveBeenCalledWith('Copied to clipboard'); + expect(component.keyJustCopied()).toBe(true); + expect(spectator.query('[data-testid="footer-key"] .footer-key-icon')?.textContent?.trim()).toBe('check'); + }); + + it('should say so when the clipboard refuses', async () => { + mockClipboard.writeText = vi.fn(() => Promise.reject(new Error('denied'))); + + spectator.click('[data-testid="footer-key"]'); + await Promise.resolve(); + spectator.detectChanges(); + + expect(mockNotifications.error).toHaveBeenCalledWith('Failed to copy'); + expect(component.keyJustCopied()).toBe(false); + }); + + it('should keep the collision colour on the copy button', () => { + const store = spectator.inject(BrowserStore); + patchState(store, { + currentFolderPath: 'common.buttons', + translations: [{ key: 'ok', translations: { en: 'OK' }, status: {} }], + }); + spectator.detectChanges(); + + expect(spectator.query('[data-testid="footer-key"]')).toHaveClass('mono--dup'); + }); + }); }); diff --git a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts index f475592..6a94340 100644 --- a/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts +++ b/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts @@ -14,18 +14,14 @@ import { import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormGroup, FormControl, Validators, FormArray } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -import { MatTabsModule } from '@angular/material/tabs'; -import { MatSelectModule } from '@angular/material/select'; +import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatChipsModule, type MatChipInputEvent } from '@angular/material/chips'; import { MatAutocompleteModule, type MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { OverlayModule } from '@angular/cdk/overlay'; import { TextFieldModule } from '@angular/cdk/text-field'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { NotificationService } from '../../../shared/notification'; import type { ResourceSummaryDto, @@ -45,12 +41,20 @@ import type { ConfirmationDialogData } from '../../../shared/components/confirma import { TranslocoPipe, TranslocoService } from '@jsverse/transloco'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; import { SimilarTranslations } from './similar-translations'; +import { filterSimilarByValue, SIMILAR_SEARCH_MAX_RESULTS } from './similar-value-filter'; import { FolderPicker } from './folder-picker/folder-picker'; import { Subject } from 'rxjs'; import { debounceTime, distinctUntilChanged, switchMap, catchError, takeUntil, tap } from 'rxjs/operators'; import { of } from 'rxjs'; import { isValidSegment, normalizeTag } from '@simoncodes-ca/domain'; +/** + * The id of the dialog's heading. The MatDialog container is labelled by this id + * (`ariaLabelledBy`) and the template stamps it onto the `

`, so the two can + * never drift apart. + */ +export const TRANSLATION_EDITOR_TITLE_ID = 'translation-editor-title'; + export interface TranslationEditorDialogData { mode: 'create' | 'edit'; resource?: ResourceSummaryDto; @@ -75,6 +79,19 @@ interface LocaleTranslation { status: TranslationStatus; } +/** One row of the context column's "Where it lands" tree. */ +export interface ContextTreeNode { + kind: 'folder' | 'entry' | 'more'; + name: string; + path: string; + depth: number; + /** The folder the entry lands in. */ + here?: boolean; + expanded?: boolean; + /** The entry this dialog is writing, and what it is doing to it. */ + mark?: 'new' | 'editing' | 'exists'; +} + export interface TranslationEditorResult { key: string; baseValue: string; @@ -93,21 +110,18 @@ export interface TranslationEditorResult { standalone: true, selector: 'app-translation-editor-dialog', templateUrl: './translation-editor-dialog.html', - styleUrls: ['./translation-editor-dialog.scss'], + styleUrls: ['./translation-editor-dialog.scss', './translation-editor-context.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, ReactiveFormsModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, MatButtonModule, MatIconModule, - MatTabsModule, - MatSelectModule, + MatMenuModule, MatProgressSpinnerModule, + OverlayModule, TextFieldModule, - MatChipsModule, MatAutocompleteModule, SimilarTranslations, FolderPicker, @@ -116,6 +130,9 @@ export interface TranslationEditorResult { ], }) export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit { + /** How many sibling entries the context tree lists before it counts the rest. */ + private static readonly CONTEXT_TREE_ENTRY_LIMIT = 8; + private readonly dialogRef = inject(MatDialogRef); private readonly dialog = inject(MatDialog); private readonly browserApi = inject(BrowserApiService); @@ -127,9 +144,20 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit readonly data = inject(MAT_DIALOG_DATA); readonly TOKENS = TRACKER_TOKENS; + /** Exposed to the template so the heading id matches the container's `aria-labelledby`. */ + readonly titleId = TRANSLATION_EDITOR_TITLE_ID; @ViewChild('keyInput') keyInput?: ElementRef; @ViewChild('baseValueInput') baseValueInput?: ElementRef; + /** The Comment field, so the empty-comment confirmation can hand the caret to it. */ + @ViewChild('commentInput') commentInput?: ElementRef; + /** The tree inside the location popover, so "New folder" can reuse its creation flow. */ + @ViewChild(FolderPicker) folderPicker?: FolderPicker; + /** Focus anchors: opening a panel moves focus in, closing it hands focus back. */ + @ViewChild('locationPill') locationPill?: ElementRef; + @ViewChild('otherLocalesRow') otherLocalesRow?: ElementRef; + @ViewChild('folderFilterInput') folderFilterInput?: ElementRef; + @ViewChild('drawerFirstControl') drawerFirstControl?: ElementRef; #commentConfirmationShown = false; #originalBaseValue = ''; @@ -142,23 +170,43 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit */ #folderFromKey: string | null = null; #locationFlashTimer: ReturnType | undefined; + #keyCopiedTimer: ReturnType | undefined; readonly isSubmitting = signal(false); readonly errorMessage = signal(null); readonly similarResources = signal([]); readonly isSearchingSimilar = signal(false); readonly baseValueLength = signal(0); - readonly isLocalesScrolled = signal(false); + /** The English value as typed, for the exact-match test against the pinned hits. */ + readonly baseValueText = signal(''); /** True while the location pill is highlighting a folder it just absorbed from the key field. */ readonly locationAbsorbedFlash = signal(false); /** Live-region text announcing the same move to a screen reader, which cannot see the flash. */ readonly locationAbsorbedMessage = signal(''); - /** Which tab is showing; owned here so validation can steer the user to the failure. */ - readonly selectedTabIndex = signal(0); /** Set once the user attempts to save, so errors surface on untouched fields too. */ readonly submitAttempted = signal(false); + /** True for a moment after the footer key is copied, so the button can confirm it. */ + readonly keyJustCopied = signal(false); + + /** The folder picker popover anchored to the location pill. */ + readonly isFolderPopoverOpen = signal(false); + /** The folder staged inside the popover; only committed by "Use this folder". */ + readonly stagedFolderPath = signal(null); + /** Filter text typed in the popover, matched against folder paths. */ + readonly folderFilter = signal(''); + /** The other-locales drawer sliding over the context column. */ + readonly isLocalesDrawerOpen = signal(false); + /** The context disclosure shown in place of the column below 1100px. */ + readonly isContextOpen = signal(false); + /** + * Entry keys per folder path, loaded once each and kept for the dialog's life. + * The browser's own folder is never re-fetched — the store already holds it — + * and picking a folder in the popover never moves the browser behind us. + */ + readonly #loadedFolderEntries = signal>(new Map()); + /** Folders whose entries are in flight. A folder in here claims no collision yet. */ + readonly #loadingFolders = signal>(new Set()); - readonly tagSeparatorKeyCodes = [ENTER, COMMA] as const; readonly tagInputText = signal(''); readonly tagsList = signal([]); readonly inheritedTagsList = computed(() => this.data.resource?.inheritedTags ?? []); @@ -204,8 +252,8 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit ); readonly dialogSubtitle = computed(() => this.isEditMode() - ? TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITSUBTITLE - : TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CREATESUBTITLE, + ? TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.EDITSUBTITLEX + : TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CREATESUBTITLEX, ); readonly saveButtonLabel = computed(() => this.isEditMode() @@ -221,15 +269,43 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit readonly formRevision = signal(0); /** - * The base-info tab owns both required controls, so it is the only tab that - * can hold a blocking error today. Reading `formRevision` keeps this live. + * Live "this key is already taken in the target folder" state. + * + * Matches the rule the writer uses: `addResource` resolves the key to a folder + * and a single entry key, then asks whether that entry key is already a + * property of the folder's `resource_entries.json` — an exact, case-sensitive + * string match. So does this. In edit mode the key is locked, so there is + * nothing to collide with; while a folder's entries are still loading nothing + * is claimed either way. */ - readonly baseTabHasError = computed(() => { + readonly keyCollision = computed(() => { this.formRevision(); - if (!this.submitAttempted()) { + if (this.isEditMode()) { return false; } - return this.form.controls.key.invalid || this.form.controls.baseValue.invalid; + const key = this.form.controls.key.value.trim(); + if (!key) { + return false; + } + return this.#folderEntryKeys(this.selectedFolderPath())?.has(key) === true; + }); + + /** Live form validity, for the footer's earned check glyph. */ + readonly isFormValid = computed(() => { + this.formRevision(); + return this.form.valid && !this.keyCollision(); + }); + + /** True once the key control is both invalid and worth complaining about. */ + readonly showKeyError = computed(() => { + this.formRevision(); + return this.form.controls.key.invalid && (this.form.controls.key.touched || this.submitAttempted()); + }); + + /** True once the English value is both missing and worth complaining about. */ + readonly showBaseValueError = computed(() => { + this.formRevision(); + return this.form.controls.baseValue.invalid && (this.form.controls.baseValue.touched || this.submitAttempted()); }); /** Localized label for a translation status, so the spine never shows raw enum text. */ @@ -240,15 +316,15 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit verified: TRACKER_TOKENS.BROWSER.STATUS.VERIFIED, }; - /** Explains a disabled Other Locales tab instead of leaving it silently grey. */ - readonly otherLocalesTabTooltip = computed(() => + /** Explains a disabled Other locales row instead of leaving it silently grey. */ + readonly otherLocalesDisabledTooltip = computed(() => this.otherLocales().length === 0 ? this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.NOOTHERLOCALESTOOLTIP) : '', ); - /** Explains a disabled Change Location tab instead of leaving it silently grey. */ - readonly locationTabTooltip = computed(() => + /** Explains a disabled location trigger instead of leaving it silently grey. */ + readonly locationDisabledTooltip = computed(() => this.isReadOnly() ? this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.READONLYTABTOOLTIP) : '', ); @@ -263,6 +339,167 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit return folder ? `${folder}.${key}` : key; }); + /** The base locale under a name a reader recognises ("English"), for the value label. */ + readonly baseLocaleName = computed(() => this.getLocaleDisplayName(this.data.baseLocale)); + + /** The target folder split into the segments the location pill renders with `›` between them. */ + readonly folderSegments = computed(() => + this.selectedFolderPath() + .split('.') + .filter((segment) => segment.length > 0), + ); + + /** Every non-base locale with the value and status the form currently holds. */ + readonly localeSummaries = computed(() => { + this.formRevision(); + return this.form.controls.translations.controls.map((group) => group.getRawValue()); + }); + + /** + * The locales a reviewer still owes work on. The context column lists these + * alone: a locale that is already translated or verified is not news. + */ + readonly localesNeedingWork = computed(() => + this.localeSummaries().filter((locale) => locale.status === 'new' || locale.status === 'stale'), + ); + + /** Locales that are new or stale: the ones a reviewer still owes work on. */ + readonly needWorkCount = computed(() => this.localesNeedingWork().length); + + /** The right-hand summary on the "Other locales" row, already localized. */ + readonly otherLocalesSummary = computed(() => + this.isEditMode() + ? this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.NEEDWORKX, { count: this.needWorkCount() }) + : this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.AUTOTRANSLATEDX, { + count: this.otherLocales().length, + }), + ); + + /** How many similar values are pinned in the context column right now. */ + readonly similarCount = computed(() => this.similarResources().length); + + /** + * The similar-values block only exists once the search has hits to show. It + * never stands in for a pending search: the results clear the moment the + * English value changes, so an empty block would be a placeholder, not news. + */ + readonly showSimilarContext = computed(() => this.similarCount() > 0); + + /** + * A pinned hit whose base value is the typed value, ignoring case. The entry + * is not merely similar — it is the same string under a key that already + * exists, which is the one case worth saying out loud. + */ + readonly exactMatch = computed(() => { + const typed = this.baseValueText().trim().toLowerCase(); + if (!typed) { + return undefined; + } + return this.similarResources().find( + (result) => (result.translations[this.data.baseLocale] ?? '').trim().toLowerCase() === typed, + ); + }); + + /** The key carrying the exact same text, or '' when no hit matches verbatim. */ + readonly exactMatchKey = computed(() => this.exactMatch()?.key ?? ''); + + /** The one-line summary the narrow "Context" disclosure carries. */ + readonly contextSummary = computed(() => { + // The key itself is not summarised here: the footer carries it in full, and + // the form's own key error carries the collision. + const parts = [ + this.selectedFolderPath() || this.transloco.translate(TRACKER_TOKENS.BROWSER.FOLDERPICKER.ROOTLABEL), + ]; + if (this.similarCount() > 0) { + parts.push( + this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.SIMILARCOUNTX, { + count: this.similarCount(), + }), + ); + } + return parts.join(' · '); + }); + + /** + * The mini tree in "Where it lands": the target folder's siblings under their + * shared parent, with the target expanded over the entries it already holds and + * the entry being written marked. Entries are only known for folders the tree + * has loaded; an unloaded folder shows as a folder node and nothing more. + */ + readonly contextTree = computed(() => { + this.formRevision(); + const roots = this.rootFolders(); + const segments = this.folderSegments(); + const targetPath = this.selectedFolderPath(); + const nodes: ContextTreeNode[] = []; + + if (segments.length === 0) { + roots.forEach((folder) => nodes.push({ kind: 'folder', name: folder.name, path: folder.fullPath, depth: 0 })); + nodes.push(...this.#entryNodes(targetPath, 0)); + return nodes; + } + + const parentPath = segments.slice(0, -1).join('.'); + const siblings = parentPath ? (this.#findFolder(roots, parentPath)?.tree?.children ?? []) : roots; + let depth = 0; + + if (parentPath) { + nodes.push({ + kind: 'folder', + name: segments[segments.length - 2], + path: parentPath, + depth: 0, + expanded: true, + }); + depth = 1; + } + + let placed = false; + for (const sibling of siblings) { + const here = sibling.fullPath === targetPath; + placed = placed || here; + nodes.push({ kind: 'folder', name: sibling.name, path: sibling.fullPath, depth, here, expanded: here }); + if (here) { + nodes.push(...this.#entryNodes(targetPath, depth + 1)); + } + } + + // The folder may not be in the tree yet — a path absorbed from a dotted key, + // or one the user has not expanded. It is still where the entry lands. + if (!placed) { + nodes.push({ + kind: 'folder', + name: segments[segments.length - 1], + path: targetPath, + depth, + here: true, + expanded: true, + }); + nodes.push(...this.#entryNodes(targetPath, depth + 1)); + } + + return nodes; + }); + + /** Root folders narrowed by the popover's filter, pruned to the matching subtrees. */ + readonly filteredRootFolders = computed(() => { + const filter = this.folderFilter().trim().toLowerCase(); + if (!filter) { + return this.rootFolders(); + } + return this.#filterFolders(this.rootFolders(), filter); + }); + + /** The folder the popover's primary button would commit. */ + readonly popoverFolderPath = computed(() => this.stagedFolderPath() ?? this.selectedFolderPath()); + + /** + * App-owned markup, never translator input, so the ICU hint can carry a + * run. The braces are HTML entities: a literal `{count}` handed to Transloco + * as a parameter is re-read as an ICU argument and resolves to `undefined`. + */ + readonly icuPlaceholderMarkup = '{count}'; + readonly allTagSuggestions = computed(() => { const seen = new Set(); for (const resource of this.browserStore.translations()) { @@ -281,7 +518,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit ngOnInit(): void { // Initialize folder path from dialog data - this.selectedFolderPath.set(this.data.folderPath || ''); + this.#setSelectedFolder(this.data.folderPath || ''); this.#initializeOtherLocaleFormControls(); if (this.isEditMode() && this.data.resource) { @@ -370,6 +607,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit ngOnDestroy(): void { clearTimeout(this.#locationFlashTimer); + clearTimeout(this.#keyCopiedTimer); this.destroy$.next(); this.destroy$.complete(); } @@ -423,11 +661,17 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit #setupSimilarResourcesSearch(): void { this.form.controls.baseValue.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { this.baseValueLength.set(value.trim().length); - const shouldSearch = this.#shouldSearchForSimilar(value); - if (shouldSearch) { + this.baseValueText.set(value); + + // Hits are pinned to the text that produced them. The moment that text + // changes they are stale, so they go now rather than after the debounce — + // a list that no longer describes the field is worse than no list. + this.similarResources.set([]); + + if (this.#shouldSearchForSimilar(value)) { this.baseValueSearch$.next(value); } else { - this.similarResources.set([]); + this.isSearchingSimilar.set(false); } }); @@ -446,7 +690,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit }); } - return this.browserApi.searchTranslations(this.data.collectionName, query, 10).pipe( + return this.browserApi.searchTranslations(this.data.collectionName, query, SIMILAR_SEARCH_MAX_RESULTS).pipe( catchError(() => of({ query: '', @@ -462,11 +706,17 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit ) .subscribe((searchResults) => { // Filter out current resource in edit mode - const filteredResults = + const withoutSelf = this.isEditMode() && this.data.resource ? searchResults.results.filter((r) => r.key !== this.#buildOriginalFullKey()) : searchResults.results; - this.similarResources.set(filteredResults); + + // The API matches keys too, and reports a key match ahead of a value one. + // Everything downstream — the count, the exact-duplicate caption, what + // stays pinned — reads this signal, so the key-only hits go before it. + this.similarResources.set( + filterSimilarByValue(withoutSelf, searchResults.query || this.baseValueText(), this.data.baseLocale), + ); }); } @@ -512,7 +762,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit const nextFolder = isContinuation ? `${this.#folderFromKey}.${prefix}` : prefix; this.#folderFromKey = nextFolder; - this.selectedFolderPath.set(nextFolder); + this.#setSelectedFolder(nextFolder); this.#announceLocationAbsorbed(nextFolder); } @@ -565,16 +815,283 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit void this.onSubmit(); } - onTabChange(index: number): void { - this.selectedTabIndex.set(index); - this.isLocalesScrolled.set(false); + /** The single writer of the target folder, so nothing can move it unseen. */ + #setSelectedFolder(folderPath: string): void { + this.selectedFolderPath.set(folderPath); + this.#ensureFolderEntries(folderPath); + } + + /** + * Fetches a folder's own entries once, so the collision check and the "Where + * it lands" tree work for any folder the user picks — not only the one the + * browser happens to be showing. Deliberately a plain read: the store's + * `selectFolder` would navigate the list behind the dialog. + */ + #ensureFolderEntries(folderPath: string): void { + if (this.isEditMode() || this.#folderEntryKeys(folderPath) || this.#loadingFolders().has(folderPath)) { + return; + } + + this.#loadingFolders.update((paths) => new Set(paths).add(folderPath)); + + this.browserApi + .getResourceTree(this.data.collectionName, folderPath, false) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (tree) => { + if ('resources' in tree) { + const keys = tree.resources.map((resource) => resource.key); + this.#loadedFolderEntries.update((entries) => new Map(entries).set(folderPath, keys)); + } + this.#finishFolderLoad(folderPath); + }, + // A folder we cannot read claims nothing. The save path still guards. + error: () => this.#finishFolderLoad(folderPath), + }); + } + + #finishFolderLoad(folderPath: string): void { + this.#loadingFolders.update((paths) => { + const next = new Set(paths); + next.delete(folderPath); + return next; + }); + this.formRevision.update((revision) => revision + 1); + } + + /** + * The entry keys known for a folder, or undefined when they are not known at + * all. Three sources, cheapest first: a folder already expanded in the tree, + * the folder the browser is showing, then anything this dialog fetched. + */ + #folderEntryKeys(folderPath: string): ReadonlySet | undefined { + const expanded = folderPath ? this.#findFolder(this.rootFolders(), folderPath)?.tree?.resources : undefined; + const known = + expanded ?? (this.browserStore.currentFolderPath() === folderPath ? this.browserStore.translations() : undefined); + + if (known) { + return this.#ownEntryKeys(known.map((resource) => resource.key)); + } + + const loaded = this.#loadedFolderEntries().get(folderPath); + return loaded ? this.#ownEntryKeys(loaded) : undefined; + } + + /** + * An entry key is a single segment. The browser lists a folder with its nested + * resources folded in, and those arrive under keys relative to the folder — + * `translationEditor.saveButton`, not `saveButton`. They live somewhere else, + * so they neither collide with this key nor belong in the folder's own row. + */ + #ownEntryKeys(keys: readonly string[]): ReadonlySet { + return new Set(keys.filter((key) => !key.includes('.'))); + } + + #findFolder(folders: FolderNodeDto[], path: string): FolderNodeDto | undefined { + for (const folder of folders) { + if (folder.fullPath === path) { + return folder; + } + if (path.startsWith(`${folder.fullPath}.`) && folder.tree?.children) { + const found = this.#findFolder(folder.tree.children, path); + if (found) { + return found; + } + } + } + return undefined; + } + + /** + * The entries already in a folder, plus the one this dialog is about to write. + * A folder can hold hundreds of keys and this is a glance, not a browser, so + * the list is a window around the entry being written; the rest is one count. + */ + #entryNodes(folderPath: string, depth: number): ContextTreeNode[] { + const key = this.form.controls.key.value.trim(); + const loaded = this.#folderEntryKeys(folderPath); + + // A folder still loading shows as a folder and nothing else: listing the new + // entry alone would claim the folder is empty before we know that it is. + if (!loaded && this.#loadingFolders().has(folderPath)) { + return []; + } + + const names = new Set(loaded ?? []); + const taken = key.length > 0 && names.has(key); + if (key) { + names.add(key); + } + + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + const window = TranslationEditorDialog.CONTEXT_TREE_ENTRY_LIMIT; + let shown = sorted; + if (sorted.length > window) { + const anchor = key ? Math.max(0, sorted.indexOf(key)) : 0; + const start = Math.min(Math.max(0, anchor - Math.floor(window / 2)), sorted.length - window); + shown = sorted.slice(start, start + window); + } + + const nodes: ContextTreeNode[] = shown.map((name) => ({ + kind: 'entry' as const, + name, + path: folderPath ? `${folderPath}.${name}` : name, + depth, + mark: + name === key && key.length > 0 + ? this.isEditMode() + ? ('editing' as const) + : taken || this.keyCollision() + ? ('exists' as const) + : ('new' as const) + : undefined, + })); + + const hidden = sorted.length - shown.length; + if (hidden > 0) { + nodes.push({ + kind: 'more', + name: this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.CONTEXT.MOREENTRIESX, { + count: hidden, + }), + path: `${folderPath}::more`, + depth, + }); + } + + return nodes; + } + + /** Keeps a folder when it or any loaded descendant matches, and prunes the rest. */ + #filterFolders(folders: FolderNodeDto[], filter: string): FolderNodeDto[] { + const kept: FolderNodeDto[] = []; + for (const folder of folders) { + const children = folder.tree?.children ? this.#filterFolders(folder.tree.children, filter) : []; + const selfMatches = folder.fullPath.toLowerCase().includes(filter); + if (selfMatches) { + kept.push(folder); + } else if (children.length > 0 && folder.tree) { + kept.push({ ...folder, tree: { ...folder.tree, children } }); + } + } + return kept; + } + + // ── Location popover ────────────────────────────────────────────────────── + + toggleFolderPopover(): void { + if (this.isReadOnly()) { + return; + } + if (this.isFolderPopoverOpen()) { + this.closeFolderPopover(); + return; + } + this.openFolderPopover(); + } + + openFolderPopover(): void { + if (this.isReadOnly()) { + return; + } + this.stagedFolderPath.set(null); + this.folderFilter.set(''); + this.isFolderPopoverOpen.set(true); + this.#focusOnceRendered(() => this.folderFilterInput?.nativeElement); + } + + /** + * Confirm, Escape and a backdrop click all land here, so focus comes back to + * the pill that opened the popover rather than the top of the dialog. The + * `(detach)` binding fires a second time after we have already closed; the + * `wasOpen` check keeps that from stealing focus from wherever it went next. + */ + closeFolderPopover(restoreFocus = true): void { + const wasOpen = this.isFolderPopoverOpen(); + this.isFolderPopoverOpen.set(false); + this.stagedFolderPath.set(null); + if (wasOpen && restoreFocus) { + this.#focusOnceRendered(() => this.locationPill?.nativeElement); + } + } + + /** Opens the picker's own inline folder-name field under the staged folder. */ + startNewFolder(): void { + this.folderPicker?.onAddFolder(this.popoverFolderPath()); + } + + onFolderFilterInput(event: Event): void { + this.folderFilter.set((event.target as HTMLInputElement).value); + } + + /** Confirms the folder staged in the popover and closes it. */ + confirmStagedFolder(): void { + const staged = this.stagedFolderPath(); + if (staged !== null) { + this.onFolderConfirmed(staged); + } + this.closeFolderPopover(); + } + + // ── Other-locales drawer ────────────────────────────────────────────────── + + openLocalesDrawer(): void { + if (this.otherLocales().length === 0) { + return; + } + this.isLocalesDrawerOpen.set(true); + this.#focusOnceRendered(() => this.drawerFirstControl?.nativeElement); } - onLocalesScroll(event: Event): void { - this.isLocalesScrolled.set((event.target as HTMLElement).scrollTop > 0); + /** Done, Escape and the back arrow all hand focus back to the row that opened it. */ + closeLocalesDrawer(restoreFocus = true): void { + const wasOpen = this.isLocalesDrawerOpen(); + this.isLocalesDrawerOpen.set(false); + if (wasOpen && restoreFocus) { + this.#focusOnceRendered(() => this.otherLocalesRow?.nativeElement); + } + } + + /** + * Focus after the view that holds the target exists. A microtask would run + * before change detection has rendered a panel that was just opened. + * + * `afterFocus` runs on the same element once it holds focus, for callers that + * also have a caret to place or a field to scroll into view. + */ + #focusOnceRendered(target: () => T | undefined, afterFocus?: (element: T) => void): void { + setTimeout(() => { + const element = target(); + if (!element) { + return; + } + element.focus(); + afterFocus?.(element); + }); + } + + toggleContext(): void { + this.isContextOpen.update((open) => !open); + } + + /** Writes a status from the per-locale pill menu into the same FormArray as before. */ + setLocaleStatus(index: number, status: TranslationStatus): void { + const group = this.getLocaleFormGroup(index); + group.controls.status.setValue(status); + group.controls.status.markAsDirty(); } async onCancel(): Promise { + // Escape and the close button reach here; an open panel is the nearest thing + // to dismiss, so it goes first and the form stays untouched. + if (this.isFolderPopoverOpen()) { + this.closeFolderPopover(); + return; + } + if (this.isLocalesDrawerOpen()) { + this.closeLocalesDrawer(); + return; + } if (this.hasUnsavedChanges() && !(await this.#confirmDiscard())) { return; } @@ -601,34 +1118,52 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit }); } + /** The picker inside the popover stages a folder; the popover's button commits it. */ + onFolderStaged(folderPath: string): void { + this.stagedFolderPath.set(folderPath); + } + onFolderConfirmed(folderPath: string): void { this.#folderFromKey = null; - this.selectedFolderPath.set(folderPath); + this.#setSelectedFolder(folderPath); } onFolderCreated(folder: FolderNodeDto): void { // Store's createFolderAt already updated rootFolders, just update selection this.#folderFromKey = null; - this.selectedFolderPath.set(folder.fullPath); + this.#setSelectedFolder(folder.fullPath); + this.stagedFolderPath.set(folder.fullPath); } - addTag(event: MatChipInputEvent): void { - const normalized = normalizeTag(event.value); - if (normalized && !this.tagsList().includes(normalized)) { - this.tagsList.update((tags) => [...tags, normalized]); + /** Enter and comma commit the typed tag; Backspace on an empty field removes the last one. */ + onTagKeydown(event: KeyboardEvent, input: HTMLInputElement): void { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + this.addTagValue(input.value); + input.value = ''; + return; + } + if (event.key === 'Backspace' && input.value === '') { + const tags = this.tagsList(); + if (tags.length > 0) { + this.removeTag(tags[tags.length - 1]); + } } - event.chipInput?.clear(); - this.tagInputText.set(''); } - addTagFromAutocomplete(event: MatAutocompleteSelectedEvent): void { - const normalized = normalizeTag(event.option.value as string); + addTagValue(rawValue: string): void { + const normalized = normalizeTag(rawValue); if (normalized && !this.tagsList().includes(normalized)) { this.tagsList.update((tags) => [...tags, normalized]); } this.tagInputText.set(''); } + addTagFromAutocomplete(event: MatAutocompleteSelectedEvent, input: HTMLInputElement): void { + this.addTagValue(event.option.value as string); + input.value = ''; + } + removeTag(tag: string): void { if (this.inheritedTagsList().includes(tag)) return; this.tagsList.update((tags) => tags.filter((t) => t !== tag)); @@ -638,12 +1173,53 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.tagInputText.set((event.target as HTMLInputElement).value); } + /** + * Leaves for the entry that already holds this key. Same close payload as the + * conflict dialog's "Edit existing", and the same unsaved-work guard as any + * other way out of the dialog. + */ + async openExistingResource(): Promise { + const existingKey = this.#buildFullKey(this.form.controls.key.value.trim()); + + if (this.hasUnsavedChanges() && !(await this.#confirmDiscard())) { + return; + } + + this.dialogRef.close({ + key: this.form.controls.key.value, + baseValue: this.form.controls.baseValue.value, + comment: this.form.controls.comment.value.trim() || undefined, + folderPath: this.selectedFolderPath(), + shouldOpenEdit: true, + existingResourceKey: existingKey, + }); + } + onSimilarResourceClick(result: SearchResultDto): void { const fullKey = result.key; this.#copyToClipboard(fullKey, this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.KEYCOPIED)); } - #copyToClipboard(text: string, successMessage: string): void { + /** + * The footer is the only place the full dotted key is spelled out, so it is + * also the place to take it from. Same clipboard path and same snackbar the + * row's key chip uses, plus a check glyph while the toast is still up. + */ + copyFullKey(): void { + this.#copyToClipboard( + this.fullKeyPreview(), + this.transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.COPIEDTOCLIPBOARD), + () => this.#flashKeyCopied(), + ); + } + + #flashKeyCopied(): void { + clearTimeout(this.#keyCopiedTimer); + this.keyJustCopied.set(true); + this.#keyCopiedTimer = setTimeout(() => this.keyJustCopied.set(false), 1500); + } + + #copyToClipboard(text: string, successMessage: string, onCopied?: () => void): void { const failedMessage = this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.COPYFAILED); if (!navigator.clipboard?.writeText) { @@ -655,6 +1231,7 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit .writeText(text) .then(() => { this.notifications.success(successMessage); + onCopied?.(); }) .catch(() => { this.notifications.error(failedMessage); @@ -673,6 +1250,14 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit return; } + // The key is already taken, and the writer would overwrite the entry rather + // than refuse it. Stop before the network and offer the same two ways out + // the save-time conflict offers, so both routes end in the same place. + if (this.keyCollision()) { + this.#showKeyConflictDialog(this.#buildFullKey(this.form.controls.key.value.trim())); + return; + } + const formValue = this.form.getRawValue() as TranslationFormValue; const commentValue = formValue.comment.trim(); @@ -692,8 +1277,8 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit } /** - * Names the problem, steers to the tab that holds it, and puts the caret in - * the offending field. Both required controls live on the base-info tab. + * Names the problem, dismisses anything covering the form, and puts the caret + * in the offending field. */ #revealValidationFailure(): void { this.submitAttempted.set(true); @@ -701,7 +1286,9 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit this.formRevision.update((revision) => revision + 1); this.errorMessage.set(this.transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.FIXERRORS)); - this.selectedTabIndex.set(0); + // Focus belongs to the offending field, not to whatever opened the panel. + this.closeFolderPopover(false); + this.closeLocalesDrawer(false); queueMicrotask(() => { const target = this.form.controls.key.invalid ? this.keyInput : this.baseValueInput; @@ -939,11 +1526,31 @@ export class TranslationEditorDialog implements OnInit, OnDestroy, AfterViewInit if (!confirmed) { this.#commentConfirmationShown = false; + this.#focusCommentField(); } return confirmed === true; } + /** + * "Add comment" asked for the Comment field, so put the caret in it. The + * confirmation's focus trap hands focus back to the Save button as it closes, + * and `afterClosed()` resolves in that same turn — deferring a task past it + * (the `#focusOnceRendered` pattern) is what keeps CDK from taking it back. + */ + #focusCommentField(): void { + this.#focusOnceRendered( + () => this.commentInput?.nativeElement, + (textarea) => { + // The field may be below the fold on a scrolled form. + textarea.scrollIntoView?.({ block: 'nearest' }); + // Selects whatever is there, so a rewrite types over it; an empty field + // just parks the caret. + textarea.setSelectionRange(0, textarea.value.length); + }, + ); + } + getLocaleFormGroup(index: number): FormGroup<{ locale: FormControl; value: FormControl; diff --git a/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts b/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts new file mode 100644 index 0000000..09e117d --- /dev/null +++ b/apps/tracker/src/app/browser/services/translation-editor-launcher.spec.ts @@ -0,0 +1,155 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { patchState } from '@ngrx/signals'; +import { of, throwError } from 'rxjs'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { getTranslocoTestingModule } from '../../../testing/transloco-testing.module'; +import { NotificationService } from '../../shared/notification'; +import { BrowserStore } from '../store/browser.store'; +import { BrowserApiService } from './browser-api.service'; +import { TRANSLATION_EDITOR_TITLE_ID } from '../dialogs/translation-editor'; +import { TranslationEditorLauncher } from './translation-editor-launcher'; + +describe('TranslationEditorLauncher', () => { + let launcher: TranslationEditorLauncher; + let store: InstanceType; + let mockDialog: { open: Mock }; + let mockApi: { getResourceTree: Mock }; + let notifications: { success: Mock; info: Mock; warning: Mock; error: Mock }; + + const resource = { key: 'backButton', translations: { en: 'Back' }, status: {} }; + + beforeEach(() => { + mockDialog = { open: vi.fn().mockReturnValue({ afterClosed: () => of(undefined) }) }; + mockApi = { getResourceTree: vi.fn().mockReturnValue(of({ resources: [resource], folders: [] })) }; + notifications = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; + + TestBed.configureTestingModule({ + imports: [getTranslocoTestingModule()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: MatDialog, useValue: mockDialog }, + { provide: BrowserApiService, useValue: mockApi }, + { provide: NotificationService, useValue: notifications }, + ], + }); + + launcher = TestBed.inject(TranslationEditorLauncher); + store = TestBed.inject(BrowserStore); + patchState(store, { availableLocales: ['en', 'fr'], baseLocale: 'en' }); + }); + + describe('openByFullKey', () => { + it('should open the editor in edit mode on the entry the key names', () => { + launcher.openByFullKey('browser.header.backButton', 'test-collection'); + + expect(mockApi.getResourceTree).toHaveBeenCalledWith('test-collection', 'browser.header', false); + expect(mockDialog.open).toHaveBeenCalledTimes(1); + + const config = mockDialog.open.mock.calls[0][1]; + expect(config.data).toMatchObject({ + mode: 'edit', + collectionName: 'test-collection', + folderPath: 'browser.header', + resource: { key: 'backButton' }, + }); + expect(config.panelClass).toBe('translation-editor-dialog-panel'); + expect(config.ariaLabelledBy).toBe(TRANSLATION_EDITOR_TITLE_ID); + }); + + it('should move the browser to the folder the entry lives in', () => { + launcher.openByFullKey('browser.header.backButton', 'test-collection'); + + expect(store.currentFolderPath()).toBe('browser.header'); + }); + + it('should leave search mode before navigating, so the list matches the dialog', () => { + patchState(store, { isSearchMode: true, searchQuery: 'back' }); + + launcher.openByFullKey('browser.header.backButton', 'test-collection'); + + expect(store.isSearchMode()).toBe(false); + }); + + it('should resolve a root-level key against the collection root', () => { + launcher.openByFullKey('backButton', 'test-collection'); + + expect(mockApi.getResourceTree).toHaveBeenCalledWith('test-collection', '', false); + expect(mockDialog.open).toHaveBeenCalledTimes(1); + }); + + it('should report a key the folder no longer holds instead of opening an empty editor', () => { + mockApi.getResourceTree.mockReturnValue(of({ resources: [], folders: [] })); + + launcher.openByFullKey('browser.header.backButton', 'test-collection'); + + expect(mockDialog.open).not.toHaveBeenCalled(); + expect(notifications.error).toHaveBeenCalledWith('Resource not found. It may have been deleted.'); + }); + + it('should report a failed lookup the same way', () => { + mockApi.getResourceTree.mockReturnValue(throwError(() => new Error('boom'))); + + launcher.openByFullKey('browser.header.backButton', 'test-collection'); + + expect(mockDialog.open).not.toHaveBeenCalled(); + expect(notifications.error).toHaveBeenCalledWith('Resource not found. It may have been deleted.'); + }); + }); + + describe('openEditor', () => { + it('should update the cache under the store key and flash the row after a save', () => { + patchState(store, { translations: [{ ...resource, key: 'backButton' }] }); + const onUpdated = vi.fn(); + mockDialog.open.mockReturnValue({ + afterClosed: () => + of({ + key: 'backButton', + baseValue: 'Back', + folderPath: 'browser.header', + success: true, + resource: { ...resource, translations: { en: 'Go back' } }, + }), + }); + + launcher.openEditor({ + resource, + collectionName: 'test-collection', + folderPath: 'browser.header', + storeKey: 'backButton', + onUpdated, + }); + + expect(store.translations()[0].translations['en']).toBe('Go back'); + expect(onUpdated).toHaveBeenCalledWith('backButton'); + expect(notifications.success).toHaveBeenCalled(); + }); + + it('should drop the entry from the cache when it was saved into another folder', () => { + patchState(store, { translations: [{ ...resource, key: 'backButton' }] }); + mockDialog.open.mockReturnValue({ + afterClosed: () => + of({ + key: 'backButton', + baseValue: 'Back', + folderPath: 'browser.footer', + success: true, + resource, + }), + }); + + launcher.openEditor({ + resource, + collectionName: 'test-collection', + folderPath: 'browser.header', + storeKey: 'backButton', + }); + + expect(store.translations()).toEqual([]); + expect(notifications.success).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/tracker/src/app/browser/services/translation-editor-launcher.ts b/apps/tracker/src/app/browser/services/translation-editor-launcher.ts new file mode 100644 index 0000000..ac78416 --- /dev/null +++ b/apps/tracker/src/app/browser/services/translation-editor-launcher.ts @@ -0,0 +1,142 @@ +import { inject, Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslocoService } from '@jsverse/transloco'; +import type { ResourceSummaryDto } from '@simoncodes-ca/data-transfer'; +import { NotificationService } from '../../shared/notification'; +import { TRACKER_TOKENS } from '../../../i18n-types/tracker-resources'; +import { + TranslationEditorDialog, + TRANSLATION_EDITOR_TITLE_ID, + type TranslationEditorDialogData, + type TranslationEditorResult, +} from '../dialogs/translation-editor'; +import { BrowserApiService } from './browser-api.service'; +import { BrowserStore } from '../store/browser.store'; +import { splitKey } from '../translations/list/store/key-resolution'; + +/** What the caller knows about the entry it wants opened in the editor. */ +export interface OpenEditorParams { + /** The resource as the dialog wants it: `key` is the entry name inside `folderPath`. */ + resource: ResourceSummaryDto; + collectionName: string; + /** Dot-delimited folder the entry lives in; '' for the collection root. */ + folderPath: string; + /** + * The key the browser store files this resource under — the list caches by the + * key it renders, which is relative in folder mode and full in search mode. + */ + storeKey: string; + /** + * The key to drop from the cache when the entry moves out of `folderPath`. + * Defaults to the saved key, which is what a same-folder rename produces. + */ + originalKey?: string; + /** Called with the store key after an in-place update, for the row's flash. */ + onUpdated?: (storeKey: string) => void; +} + +/** + * Opens the translation editor in edit mode, from wherever the request came. + * + * The list's row menu and the create dialog's "Open existing" both need the same + * dialog with the same post-save bookkeeping, and they sit in different injector + * branches — the list store is component-scoped, the header is its sibling. The + * launcher is the one place that knows the dialog's configuration, so neither + * call site carries a copy of it. + */ +@Injectable({ providedIn: 'root' }) +export class TranslationEditorLauncher { + readonly #dialog = inject(MatDialog); + readonly #api = inject(BrowserApiService); + readonly #browserStore = inject(BrowserStore); + readonly #notifications = inject(NotificationService); + readonly #transloco = inject(TranslocoService); + + /** Opens the editor for a resource the caller already holds. */ + openEditor(params: OpenEditorParams): void { + const { resource, collectionName, folderPath, storeKey, originalKey, onUpdated } = params; + + const dialogData: TranslationEditorDialogData = { + mode: 'edit', + resource, + collectionName, + folderPath, + availableLocales: this.#browserStore.availableLocales(), + baseLocale: this.#browserStore.baseLocale(), + readOnly: this.#browserStore.isReadOnly(), + }; + + const dialogRef = this.#dialog.open(TranslationEditorDialog, { + // Size, max-height and the small-viewport full-screen mode live in + // `.translation-editor-dialog-panel` (styles.scss) so the call sites + // don't each carry their own copy of the numbers. `maxWidth` is + // overridden only to lift the CDK's inline 80vw default, which would + // otherwise beat the stylesheet. + panelClass: 'translation-editor-dialog-panel', + maxWidth: '100vw', + data: dialogData, + autoFocus: false, + ariaLabelledBy: TRANSLATION_EDITOR_TITLE_ID, + restoreFocus: false, + }); + + dialogRef.afterClosed().subscribe((result: TranslationEditorResult | undefined) => { + if (!result?.success) return; + if (!result.resource) return; + + const cacheKey = originalKey ?? result.key; + if (result.folderPath !== folderPath) { + this.#browserStore.removeResourceFromCache(cacheKey); + return; + } + + this.#browserStore.updateTranslationInCache({ ...result.resource, key: storeKey }); + onUpdated?.(storeKey); + this.#notifications.success(this.#transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.TRANSLATIONUPDATED)); + + if (result.skippedLocales?.length) { + this.#notifications.warning( + this.#transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.SKIPPEDLOCALESX, { + locales: result.skippedLocales.join(', '), + }), + ); + } + }); + } + + /** + * Opens the editor for a full dot-delimited key, from a caller that has nothing + * but the key — the create dialog's "Open existing", which hands back the key of + * the entry the user collided with. + * + * The browser is moved to the entry's folder first, so the dialog closes onto the + * list the entry is actually in rather than back onto an unrelated folder. + */ + openByFullKey(fullKey: string, collectionName: string, onUpdated?: (storeKey: string) => void): void { + const { folderPath, entryKey } = splitKey(fullKey); + + this.#api.getResourceTree(collectionName, folderPath, false).subscribe({ + next: (tree) => { + const resource = 'resources' in tree ? tree.resources.find((item) => item.key === entryKey) : undefined; + if (!resource) { + this.#notifyNotFound(); + return; + } + + // A search result list would survive the folder change and leave the user + // looking at the wrong set of rows behind the dialog. + if (this.#browserStore.isSearchMode()) { + this.#browserStore.clearSearch(); + } + this.#browserStore.selectFolder(folderPath); + + this.openEditor({ resource, collectionName, folderPath, storeKey: entryKey, onUpdated }); + }, + error: () => this.#notifyNotFound(), + }); + } + + #notifyNotFound(): void { + this.#notifications.error(this.#transloco.translate(TRACKER_TOKENS.BROWSER.TRANSLATIONEDITOR.ERROR.NOTFOUND)); + } +} diff --git a/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts b/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts index 80201c1..14c7eea 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts +++ b/apps/tracker/src/app/browser/translations/header/translation-main-header.spec.ts @@ -3,12 +3,14 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'; import { MatDialog } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { createComponentFactory, type Spectator } from '@ngneat/spectator/vitest'; +import { patchState } from '@ngrx/signals'; import { of } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getTranslocoTestingModule } from '../../../../testing/transloco-testing.module'; import { NotificationService } from '../../../shared/notification'; -import type { TranslationEditorResult } from '../../dialogs/translation-editor'; +import { TRANSLATION_EDITOR_TITLE_ID, type TranslationEditorResult } from '../../dialogs/translation-editor'; import { BrowserStore } from '../../store/browser.store'; +import { TranslationEditorLauncher } from '../../services/translation-editor-launcher'; import { TranslationMainHeader } from './translation-main-header'; describe('TranslationMainHeader', () => { @@ -22,6 +24,7 @@ describe('TranslationMainHeader', () => { }; let mockDialogRef: { afterClosed: ReturnType }; let mockDialog: { open: ReturnType }; + let launcherSpy: { openEditor: ReturnType; openByFullKey: ReturnType }; const createComponent = createComponentFactory({ component: TranslationMainHeader, @@ -31,6 +34,7 @@ describe('TranslationMainHeader', () => { provideHttpClientTesting(), { provide: NotificationService, useFactory: () => notificationsSpy }, { provide: MatDialog, useFactory: () => mockDialog }, + { provide: TranslationEditorLauncher, useFactory: () => launcherSpy }, ], }); @@ -38,6 +42,7 @@ describe('TranslationMainHeader', () => { notificationsSpy = { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() }; mockDialogRef = { afterClosed: vi.fn() }; mockDialog = { open: vi.fn().mockReturnValue(mockDialogRef) }; + launcherSpy = { openEditor: vi.fn(), openByFullKey: vi.fn() }; spectator = createComponent(); component = spectator.component; @@ -54,6 +59,17 @@ describe('TranslationMainHeader', () => { expect(component).toBeTruthy(); }); + describe('handleAddTranslation — dialog configuration', () => { + it('should label the dialog container with the editor heading id', () => { + mockDialogRef.afterClosed.mockReturnValue(of(undefined)); + + component.handleAddTranslation(); + + const config = mockDialog.open.mock.calls[0][1]; + expect(config.ariaLabelledBy).toBe(TRANSLATION_EDITOR_TITLE_ID); + }); + }); + describe('handleAddTranslation — success notification', () => { it('should show success notification when translation is created', async () => { const result: TranslationEditorResult = { @@ -81,6 +97,34 @@ describe('TranslationMainHeader', () => { }); }); + describe('handleAddTranslation — "Open existing"', () => { + it('should open the editor on the existing key the create dialog handed back', () => { + const store = spectator.inject(BrowserStore); + patchState(store, { selectedCollection: 'test-collection' }); + const result: TranslationEditorResult = { + key: 'backButton', + baseValue: 'Back', + folderPath: 'browser.header', + shouldOpenEdit: true, + existingResourceKey: 'browser.header.backButton', + }; + mockDialogRef.afterClosed.mockReturnValue(of(result)); + + component.handleAddTranslation(); + + expect(launcherSpy.openByFullKey).toHaveBeenCalledWith('browser.header.backButton', 'test-collection'); + expect(notificationsSpy.success).not.toHaveBeenCalled(); + }); + + it('should not route anywhere when the dialog closes normally', () => { + mockDialogRef.afterClosed.mockReturnValue(of(undefined)); + + component.handleAddTranslation(); + + expect(launcherSpy.openByFullKey).not.toHaveBeenCalled(); + }); + }); + describe('handleAddTranslation — skippedLocales warning notification', () => { it('should show warning notification after success when skippedLocales is present', async () => { const result: TranslationEditorResult = { diff --git a/apps/tracker/src/app/browser/translations/header/translation-main-header.ts b/apps/tracker/src/app/browser/translations/header/translation-main-header.ts index c52fbb6..83ad677 100644 --- a/apps/tracker/src/app/browser/translations/header/translation-main-header.ts +++ b/apps/tracker/src/app/browser/translations/header/translation-main-header.ts @@ -10,9 +10,11 @@ import { TranslationSearch } from './translation-search/translation-search'; import { BrowserStore } from '../../store/browser.store'; import { TranslationEditorDialog, + TRANSLATION_EDITOR_TITLE_ID, type TranslationEditorDialogData, type TranslationEditorResult, } from '../../dialogs/translation-editor'; +import { TranslationEditorLauncher } from '../../services/translation-editor-launcher'; import { LocaleFilter } from './locale-filter/locale-filter'; import { StatusFilter } from './status-filter/status-filter'; import { TRACKER_TOKENS } from '../../../../i18n-types/tracker-resources'; @@ -47,6 +49,7 @@ export class TranslationMainHeader { readonly #dialog = inject(MatDialog); readonly #notifications = inject(NotificationService); readonly #transloco = inject(TranslocoService); + readonly #editorLauncher = inject(TranslationEditorLauncher); readonly TOKENS = TRACKER_TOKENS; /** Drives the icon flip animation — true for one animation cycle when toggled */ @@ -105,10 +108,18 @@ export class TranslationMainHeader { maxWidth: '100vw', data: dialogData, autoFocus: false, - ariaLabelledBy: 'dialog-title', + ariaLabelledBy: TRANSLATION_EDITOR_TITLE_ID, }); dialogRef.afterClosed().subscribe((result: TranslationEditorResult | undefined) => { + // "Open existing" is a hand-off, not a save: the user hit a key that is + // already taken and asked for that entry instead, so the create dialog + // closes and the editor opens on the entry they meant. + if (result?.shouldOpenEdit && result.existingResourceKey) { + this.#editorLauncher.openByFullKey(result.existingResourceKey, dialogData.collectionName); + return; + } + if (!result?.success) return; this.store.selectFolder(this.store.currentFolderPath()); diff --git a/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts b/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts index 1c2f009..da65457 100644 --- a/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts +++ b/apps/tracker/src/app/browser/translations/list/store/with-item-actions.feature.ts @@ -7,8 +7,7 @@ import { NotificationService } from '../../../../shared/notification'; import { BrowserApiService } from '../../../services/browser-api.service'; import { BrowserStore } from '../../../store/browser.store'; import { TRACKER_TOKENS } from '../../../../../i18n-types/tracker-resources'; -import { TranslationEditorDialog } from '../../../dialogs/translation-editor'; -import type { TranslationEditorDialogData, TranslationEditorResult } from '../../../dialogs/translation-editor'; +import { TranslationEditorLauncher } from '../../../services/translation-editor-launcher'; import { ConfirmationDialog } from '../../../../shared/components/confirmation-dialog/confirmation-dialog'; import type { ConfirmationDialogData } from '../../../../shared/components/confirmation-dialog/confirmation-dialog-data'; import type { ResourceSummaryDto, TranslateResourceResponseDto } from '@simoncodes-ca/data-transfer'; @@ -28,6 +27,7 @@ export function withItemActions() { const api = inject(BrowserApiService); const browserStore = inject(BrowserStore); const dialog = inject(MatDialog); + const launcher = inject(TranslationEditorLauncher); const destroyRef = inject(DestroyRef); const notifications = inject(NotificationService); const transloco = inject(TranslocoService); @@ -51,10 +51,8 @@ export function withItemActions() { browserStore.showNestedResources(), browserStore.currentFolderPath(), ); - const originalKey = browserStore.isSearchMode() ? translation.key : undefined; - const dialogData: TranslationEditorDialogData = { - mode: 'edit', + launcher.openEditor({ resource: resolveResourceForDialog( translation, browserStore.isSearchMode(), @@ -62,47 +60,20 @@ export function withItemActions() { ), collectionName, folderPath, - availableLocales: browserStore.availableLocales(), - baseLocale: browserStore.baseLocale(), - readOnly: browserStore.isReadOnly(), - }; - - const dialogRef = dialog.open(TranslationEditorDialog, { - // Size, max-height and the small-viewport full-screen mode live in - // `.translation-editor-dialog-panel` (styles.scss) so the two call - // sites don't each carry their own copy of the numbers. `maxWidth` - // is overridden only to lift the CDK's inline 80vw default, which - // would otherwise beat the stylesheet. - panelClass: 'translation-editor-dialog-panel', - maxWidth: '100vw', - data: dialogData, - autoFocus: false, - ariaLabelledBy: 'dialog-title', - restoreFocus: false, + storeKey: translation.key, + originalKey: browserStore.isSearchMode() ? translation.key : undefined, + onUpdated: (key) => store.flashRecentlyUpdated(key), }); + }, - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(destroyRef)) - .subscribe((result: TranslationEditorResult | undefined) => { - if (!result?.success) return; - if (!result.resource) return; - const cacheKey = originalKey ?? result.key; - if (result.folderPath !== folderPath) { - browserStore.removeResourceFromCache(cacheKey); - } else { - const storeResource = { ...result.resource, key: translation.key }; - browserStore.updateTranslationInCache(storeResource); - store.flashRecentlyUpdated(translation.key); - notifications.success(transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.TRANSLATIONUPDATED)); - if (result.skippedLocales?.length) { - const skippedList = result.skippedLocales.join(', '); - notifications.warning( - transloco.translate(TRACKER_TOKENS.BROWSER.TOAST.SKIPPEDLOCALESX, { locales: skippedList }), - ); - } - } - }); + /** + * Opens the editor for a full dot-delimited key, moving the browser to the + * folder that holds it. The create dialog's "Open existing" ends here: the + * user asked for the entry they collided with, not for the folder they + * happened to be standing in. + */ + openResourceByKey(fullKey: string, collectionName: string): void { + launcher.openByFullKey(fullKey, collectionName, (key) => store.flashRecentlyUpdated(key)); }, deleteTranslation(translation: ResourceSummaryDto, collectionName: string): void { diff --git a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts index 2cb04b3..e5e5cc0 100644 --- a/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts +++ b/apps/tracker/src/app/browser/translations/list/translation-list.spec.ts @@ -13,6 +13,7 @@ import { NotificationService } from '../../../shared/notification'; import type { TranslationEditorResult } from '../../dialogs/translation-editor'; import { BrowserApiService } from '../../services/browser-api.service'; import { BrowserStore } from '../../store/browser.store'; +import { TranslationEditorLauncher } from '../../services/translation-editor-launcher'; import { TranslationListStore } from './store/translation-list.store'; import { TranslationList } from './translation-list'; @@ -701,3 +702,46 @@ describe('TranslationList - handleTranslate', () => { expect(notificationsSpy.warning).toHaveBeenCalledWith(expectedSkippedMessage); }); }); + +describe('TranslationList - openResourceByKey', () => { + let fixture: ComponentFixture; + let launcherSpy: { openEditor: ReturnType; openByFullKey: ReturnType }; + + beforeEach(() => { + launcherSpy = { openEditor: vi.fn(), openByFullKey: vi.fn() }; + + fixture = renderList([ + { provide: NotificationService, useValue: { success: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn() } }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: TranslationEditorLauncher, useValue: launcherSpy }, + ]); + fixture.componentRef.setInput('collectionName', 'my-collection'); + fixture.detectChanges(); + }); + + it('should hand a full key to the launcher, with a flash callback for the row', () => { + const listStore = fixture.debugElement.injector.get(TranslationListStore); + + listStore.openResourceByKey('browser.header.backButton', 'my-collection'); + + expect(launcherSpy.openByFullKey).toHaveBeenCalledWith( + 'browser.header.backButton', + 'my-collection', + expect.any(Function), + ); + + const onUpdated = launcherSpy.openByFullKey.mock.calls[0][2] as (key: string) => void; + onUpdated('backButton'); + expect(listStore.recentlyUpdatedKey()).toBe('backButton'); + }); + + it('should open the row editor through the same launcher', () => { + const listStore = fixture.debugElement.injector.get(TranslationListStore); + + listStore.editTranslation({ key: 'backButton', translations: { en: 'Back' }, status: {} }, 'my-collection'); + + expect(launcherSpy.openEditor).toHaveBeenCalledWith( + expect.objectContaining({ collectionName: 'my-collection', storeKey: 'backButton' }), + ); + }); +}); diff --git a/apps/tracker/src/styles.scss b/apps/tracker/src/styles.scss index b2fbd17..d755d7c 100644 --- a/apps/tracker/src/styles.scss +++ b/apps/tracker/src/styles.scss @@ -23,6 +23,12 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: var(--color-background); + // The base ink for everything that does not name a colour of its own. Without + // it the inherited value is the user agent's black, which is close enough to + // --color-text-primary on parchment to pass unnoticed in light mode and is + // black-on-black in dark mode. The CDK overlay container is a child of , + // so dialogs, drawers and menus inherit this too. + color: var(--color-text-primary); // Watercolor paper grain texture overlay &::after { @@ -81,29 +87,51 @@ mark.search-highlight { // The CDK puts the panel class on the overlay pane, which is the only element // that can carry the dialog's width and height; both call sites now pass this // class and nothing else, so there is one place to change the numbers. -.translation-editor-dialog-panel { - width: 700px; - max-width: min(700px, calc(100vw - 2 * var(--spacing-8))); - max-height: 90vh; - - // Material's dialog container already carries `max-height: inherit`; the - // component host repeats the trick so the flex column inside can distribute - // the height instead of guessing at chrome heights. +// Doubled with the pane class, as the collection and bundle dialogs are, so this +// outranks the CDK's own `max-height: 100%` on `.cdk-overlay-pane`: that 100% has +// no definite height to resolve against, so without this the panel grows with its +// content and pushes the footer off the bottom of the viewport. +.cdk-overlay-pane.translation-editor-dialog-panel { + // 980px: the form column plus the 320px context column, as the approved comp. + width: 980px; + max-width: calc(100vw - 3rem); + // Named once here and spent on every layer below, because `inherit` would hand + // each layer the border-box ceiling of the one above and ignore the container's + // 3px top rule — three pixels of footer, hanging out under the rounded corner. + --editor-panel-max-height: min(90vh, calc(100dvh - 2 * var(--spacing-8))); + max-height: var(--editor-panel-max-height); + + // Every layer down to the component has to carry the viewport-based ceiling, + // or the panel grows past the footer and takes the primary action off screen. + // The two body columns inside the component are then the only things that scroll. + .mat-mdc-dialog-container { + max-height: var(--editor-panel-max-height); + } + + .mat-mdc-dialog-inner-container, + .mat-mdc-dialog-surface, + app-translation-editor-dialog { + max-height: calc(var(--editor-panel-max-height) - 3px); + } + app-translation-editor-dialog { display: block; - max-height: inherit; } - // Below the `sm` breakpoint a 700px dialog collapses to whatever 80vw allows + // Below the `sm` breakpoint a wide dialog collapses to whatever 80vw allows // — 312px at 390px of viewport, a column too narrow to read a translation in. // Material's standard answer at this size is a full-screen dialog, so take // the whole screen, drop the floating chrome, and let the editor's own flex // column own the height. @media (max-width: 639px) { + // Re-point the ceiling at the whole screen, so every layer below follows the + // panel down here too: they are capped against this variable, not against + // whatever the pane happens to measure. + --editor-panel-max-height: 100dvh; width: 100vw; + max-width: 100vw; height: 100dvh; - max-height: 100dvh; .mat-mdc-dialog-container:has(app-translation-editor-dialog) { height: 100%; @@ -118,6 +146,13 @@ mark.search-highlight { } } + // The top rule is gone at this size, so the layers below the container get + // the whole screen rather than the screen less a border that isn't there. + .mat-mdc-dialog-inner-container, + .mat-mdc-dialog-surface { + max-height: 100dvh; + } + app-translation-editor-dialog { height: 100%; max-height: 100%; @@ -181,6 +216,100 @@ mark.search-highlight { } } +// The translation editor's location popover. It renders from the editor's own +// , so its inner wells keep the component's styles; only the panel +// box lives here, because the CDK attaches it to the overlay container where a +// component's :host custom properties no longer inherit. +.cdk-overlay-pane.translation-editor-folder-popover { + max-width: calc(100vw - 2 * var(--spacing-6)); + + .pop { + display: flex; + flex-direction: column; + gap: 6px; + width: 360px; + max-width: 100%; + padding: var(--spacing-2); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + background: var(--color-background); + box-shadow: var(--shadow-lg); + animation: popover-in var(--transition-fast) ease-out; + } + + .pop-tree { + max-height: 320px; + overflow-y: auto; + padding: var(--spacing-1); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + } + + .pop-foot { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: 6px var(--spacing-1) 2px; + border-top: 1px solid var(--color-border-subtle); + } + + .pop-new { + display: inline-flex; + align-items: center; + gap: 4px; + padding: var(--spacing-1) var(--spacing-2); + border: 0; + border-radius: var(--border-radius-md); + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + cursor: pointer; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + color: var(--color-text-primary); + background: var(--color-background-subtle); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } + } + + .pop-confirm { + margin-left: auto; + } +} + +[data-theme='dark'] .cdk-overlay-pane.translation-editor-folder-popover .pop { + background: var(--color-background-muted); +} + +@keyframes popover-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .cdk-overlay-pane.translation-editor-folder-popover .pop { + animation: none; + } +} + // Make overlay backdrops slightly darker .cdk-overlay-backdrop, .cdk-overlay-dark-backdrop { From 30adf61b716c80b7cf72ea7da01dfa9b015a47d0 Mon Sep 17 00:00:00 2001 From: snodel Date: Fri, 18 Sep 2026 14:25:52 -0700 Subject: [PATCH 3/6] docs(tracker): record the translation editor dialog surface Co-Authored-By: Claude Fable 5.1 --- ...translation-editor-dialog-html-07d62026.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 apps/tracker/.impeccable/surfaces/ion-editor-translation-editor-dialog-html-07d62026.md diff --git a/apps/tracker/.impeccable/surfaces/ion-editor-translation-editor-dialog-html-07d62026.md b/apps/tracker/.impeccable/surfaces/ion-editor-translation-editor-dialog-html-07d62026.md new file mode 100644 index 0000000..d48d244 --- /dev/null +++ b/apps/tracker/.impeccable/surfaces/ion-editor-translation-editor-dialog-html-07d62026.md @@ -0,0 +1,30 @@ +--- +version: 1 +slug: "ion-editor-translation-editor-dialog-html-07d62026" +primary_target: "apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.html" +related_targets: ["apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts","apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.scss"] +--- + +# Surface: Translation editor dialog (create / edit resource) + +Scope: redesign of `apps/tracker/src/app/browser/dialogs/translation-editor/`, opened from "Add Translation" in the browser header and from a translation row's edit action. Mode: **Operate**. Existing surface inside the established world; converges on the collection/bundle dialog anatomy (coral top rule, header tile, one-line subtitle, hairlines, footer validity line + Cancel + primary) and drops Material outline fields and Material tabs. + +Audience and job: the developer mid-feature adding or fixing one key, then a translator/reviewer editing locale values and statuses. The dialog must always show where the entry lands (folder + full key), let the user move it without leaving, warn about duplicate values and colliding keys before save, and keep other-locale editing one step away. + +Content: location (folder path, changeable, folder creation), key (single segment, locked in edit), English base value, similar existing values (debounced search, appear-on-match, pinned until the value changes; hidden in edit until the value is edited), live key-collision state with "Open existing", other locales (value; status select in edit; auto-translation note in create), comment and tags inline at full weight (comment is near-required: saving without one prompts a confirmation; inherited collection tags read-only). + +Constraints: every string via Transloco resources. Key renaming unsupported (Move lives in the list). Read-only collections: inputs disabled, no primary. The 409 path stays as a fallback. Width up to ~1000px with a collapse below ~1100px viewport; <640px stays full-screen. Escape guard and Ctrl+Enter save unchanged. + +## Direction + +Chosen: **B — Editor with context column** (`.impeccable/mocks/resource-dialog/b-context.html`, sidecar `b-context.json` approved: true; approved comps `b-context-create-light.png` and `b-context-edit-light.png`). User amendment: comment and tags stay inline, never behind a disclosure. The three comps considered (see the mocks folder's `brief.md`): +- A `a-focused.html`: 640px single column, editable breadcrumb location with autocomplete, full-width result line, amber similar strip, disclosure rows for Other locales and Details. +- B `b-context.html`: 980px editor + 320px context column (mini tree with the new/editing entry, full key + availability, pinned similar values, locale summary); popover tree for location; drawer for other locales. +- C `c-rail.html`: 1000px sections rail (Location & key / English value / Other locales / Details) with per-section summaries and error dots; persistent folder tree in the Location pane. + +No concept-seed roll: the three structures were fixed in the user interview. A and C are declined. + +## Unresolved for the build round +- Similar-search contract: keep the 300ms debounce over `searchTranslations`; whether "Use key" closes the dialog and opens the existing entry or copies the key. +- Live collision check source: folder contents already in `BrowserStore` vs. an API probe. +- Behaviour of the breadcrumb/popover when the folder does not exist yet (create-on-save copy). From 13b9928ba8f0fb58e1522b4b791d0f45028be5de Mon Sep 17 00:00:00 2001 From: snodel Date: Fri, 18 Sep 2026 14:29:55 -0700 Subject: [PATCH 4/6] chore(tracker): drop stale impeccable critique and surface notes Co-Authored-By: Claude Fable 5.1 --- ...ion-editor-translation-editor-dialog-ts.md | 95 ------------- ...ion-editor-translation-editor-dialog-ts.md | 123 ----------------- ...st-translation-item-translation-item-ts.md | 120 ----------------- ...wser-translations-list-translation-item.md | 126 ------------------ ...tions-collections-manager-html-dafe3602.md | 37 ----- ...translation-editor-dialog-html-07d62026.md | 30 ----- ...og-collection-form-dialog-html-71ddd2b8.md | 32 ----- ...dialog-bundle-form-dialog-html-1bd13ed1.md | 38 ------ 8 files changed, 601 deletions(-) delete mode 100644 apps/tracker/.impeccable/critique/2026-09-06T04-11-52Z__gs-translation-editor-translation-editor-dialog-ts.md delete mode 100644 apps/tracker/.impeccable/critique/2026-09-06T05-01-25Z__gs-translation-editor-translation-editor-dialog-ts.md delete mode 100644 apps/tracker/.impeccable/critique/2026-09-06T22-33-09Z__slations-list-translation-item-translation-item-ts.md delete mode 100644 apps/tracker/.impeccable/critique/2026-09-07T06-19-22Z__src-app-browser-translations-list-translation-item.md delete mode 100644 apps/tracker/.impeccable/surfaces/app-collections-collections-manager-html-dafe3602.md delete mode 100644 apps/tracker/.impeccable/surfaces/ion-editor-translation-editor-dialog-html-07d62026.md delete mode 100644 apps/tracker/.impeccable/surfaces/n-form-dialog-collection-form-dialog-html-71ddd2b8.md delete mode 100644 apps/tracker/.impeccable/surfaces/undle-form-dialog-bundle-form-dialog-html-1bd13ed1.md diff --git a/apps/tracker/.impeccable/critique/2026-09-06T04-11-52Z__gs-translation-editor-translation-editor-dialog-ts.md b/apps/tracker/.impeccable/critique/2026-09-06T04-11-52Z__gs-translation-editor-translation-editor-dialog-ts.md deleted file mode 100644 index a10e981..0000000 --- a/apps/tracker/.impeccable/critique/2026-09-06T04-11-52Z__gs-translation-editor-translation-editor-dialog-ts.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -target: Create/Edit Translation dialog and all tabs -total_score: 19 -max_score: 40 -na_heuristics: -p0_count: 0 -p1_count: 1 -target_identity: "file:/Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts" -target_fingerprint: "sha256:6e9d1725f1b2dab364eaf6bb74c03242d9242907d2c9925a715d79a8db0a1892" -target_path: /Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts -timestamp: 2026-09-06T04-11-52Z -slug: gs-translation-editor-translation-editor-dialog-ts ---- -Method: dual-agent (A: design review · B: detector + evidence). Detector ran DEGRADED (HTML/CSS parser modules unavailable, regex fallback; no contrast/token/DOM rules). Findings are an undercount. - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|-----------|-------|-----------| -| 1 | Visibility of System Status | 3 | Save disabled (html:258) with no indication of what is invalid or where | -| 2 | Match System / Real World | 2 | Raw enum statuses; `otherLocales` mistranslated as geography in de/ru/ja | -| 3 | User Control and Freedom | 1 | No unsaved-changes guard; `onCancel()` bare close (ts:379) | -| 4 | Consistency and Standards | 2 | Statuses are localized pills in list view, bare English here | -| 5 | Error Prevention | 1 | Key renaming rejected after submit (ts:481); duplicates only as server 409 | -| 6 | Recognition Rather Than Recall | 2 | Full dotted key never shown as one string | -| 7 | Flexibility and Efficiency | 2 | Ctrl/Cmd+Enter (ts:364) undocumented; no auto-translate; no save-and-add-another | -| 8 | Aesthetic and Minimalist Design | 3 | Similar Translations holds permanent space in idle state | -| 9 | Error Recovery | 2 | Raw server messages piped to user, never mapped to a field | -| 10 | Help and Documentation | 1 | `keyHint` misinforms; neither disabled tab explains itself | -| **Total** | | **19/40** | **Below average — structurally sound, systemically leaky** | - -## Design Specificity Verdict - -Partly authored; the authored parts are chrome, not flow. SCSS is genuinely LingoTracker (parchment, coral accent rails scss:296, mono location pill, scroll-reveal shadow). Interaction model is stock Material — swap labels and it creates a CRM contact. - -Three tells: -- Dot-delimited key syntax is the product's own, and the key field forbids the dot (ts:151). -- Status is "the spine" and renders as raw English enum (html:193) though `browser.status.*` is localized in all six locales. -- Read-only banner uses `var(--color-warn, #b26a00)` (scss:140-143); `--color-warn` does not exist — tokens.scss:33 defines `--color-warning`. Hardcoded brown fires 100% of the time, both themes. - -Deterministic scan: 5 findings, one rule (`side-tab`) across 4 files; 3 are false positives (tree selection indicator, neutral-token state rails). Browser overlay unavailable — dev server on :4200 but no injection attempted; live contrast unverified, and the degraded detector also skipped computed contrast. - -i18n parity is perfect (49/49 editor keys, 13/13 picker keys, six locales). Every key exists; some hold the wrong words. - -## Overall Impression - -Craft is real, bones are sound; failures are at the seams where the dialog meets the product's own promises. Localized except the status control. Has a confirmation guard — pointed at an optional comment field while Escape vaporizes the form. Has a folder picker — hidden behind a third tab while the field that could accept the path rejects its syntax. Biggest opportunity: collapse the tab strip. - -## What's Working - -- Base-value reference banner on Tab 2 (html:169-178) — solves the working-memory problem the tab split creates; scroll-reveal shadow communicates rather than decorates. -- Similar Translations state machine (similar-translations.ts:33-38) — four states as one computed, debounced 300ms, self-filtering in edit mode. -- Inherited tags as dashed non-removable chips (html:117-121) — communicates inheritance without a word. - -## Priority Issues - -**[P1] Key field forbids the product's own key syntax.** ts:151 `/^[a-zA-Z0-9_-]+$/`. User arrives holding `apps.common.buttons.ok`; error mentions neither dots nor folders while `#buildFullKey()` (ts:578) joins with a dot. Fix: accept dots on paste, split at last `.`, set folder prefix, flash the pill. Command: /impeccable harden - -**[P2] Window-scoped Escape + no dirty guard destroys work.** ts:359 binds Escape to window; no `disableClose` at either call site (with-item-actions.feature.ts:70, translation-main-header.ts:101). Escape over the conflict dialog closes the editor beneath it. Fix: delete the HostListener, set disableClose, route backdropClick/keydownEvents through a form.dirty check. Command: /impeccable harden - -**[P3] Statuses render as untranslated raw enums.** html:191-194. Fix: map through TOKENS.BROWSER.STATUS.* and reuse the list view's status chip. Command: /impeccable clarify - -**[P4] Disabled Save with no diagnosis, no per-tab validity signal.** html:258 + html:69 (touched-gated errors). Fix: enable, validate on click, markAllAsTouched, jump to first invalid tab, add error dot to tab label. Command: /impeccable harden - -**[P5] Read-only styling references a nonexistent token.** scss:140-143. Never adapts to dark theme. PRODUCT.md makes read-only legibility binding. Fix: rename to --color-warning, extend lock state to pill and tab strip, tooltip the disabled Tab 3. Command: /impeccable polish - -**[P6] `otherLocales` mistranslated as geography in de/ru/ja; `readOnlyNotice` untranslated in all five non-English locales.** de "Andere Orte", ru "Другие населенные пункты" (other populated settlements), ja "その他の地域". Related: `stale` as de "Abgestanden", ru "Залежалый". Fix: retranslate as software-locale terms; fill readOnlyNotice. Command: /impeccable clarify - -## Persona Red Flags - -**Alex (power user):** key field rejects his clipboard. Ctrl+Enter is undocumented and rewarded with an unrequested comment nag (ts:681, disableClose:true). No save-and-add-another. Escape is a landmine. - -**Jordan (first-timer/translator):** keyHint never says what a key is or that it composes with the folder path. Location pill styled as static metadata — she'll never find Tab 3. Tab 2 disabled and unexplained. Status vocabulary in raw English without teaching color. Comment is a single-line input (html:103) though TextFieldModule is imported unused (ts:27). - -**i18n owner:** auto-translate absent from the dialog entirely (lives only at translation-item/item-header.ts:161). Create mode hardcodes non-base status to 'new' (ts:547) and hides the select. Key rename rejected only after a round-trip through the comment nag (ts:481). Full dotted key not copyable anywhere. - -## Minor Observations - -- Fixed 570px content height (scss:182) + `overflow: hidden !important` (scss:186) clips on an 800px viewport with no scroll escape. -- Responsive unaddressed: 700px both call sites, one @media in 603 lines. German/Russian tab labels force Material pagination arrows. -- `.location-pill-path` truncates with no tooltip (scss:110). -- Reduced-motion covers 3 of 16 transitions (scss:597 only); three sibling SCSS files have no such query. -- Folder picker add-subfolder button is opacity:0 until hover but stays focusable. -- Tree has no aria-activedescendant and no role="treeitem"; DOM focus never moves. -- Hand-rolled role="dialog" (html:1) nested inside MatDialog's own. -- getLocaleDisplayName() just uppercases — `fr-ca` renders FR-CA. -- Similar-translation cards copy to clipboard but are styled as navigation; the 409 flow already knows how to open the resource. -- 3 `!important` and 14 `::ng-deep` uses fighting Material internals. - -## Questions to Consider - -- Why is there a tab strip at all? One scrolling column kills the disabled-tab, cross-tab-validity, fixed-height, and label-overflow problems at once. -- Why does the dialog nag about a missing comment but not about unsaved work? -- Should the location pill be an input rather than a tab destination? -- `--color-warn` failed silently. What else references a token that doesn't exist? A CI check resolving every var(--color-*) against tokens.scss would catch it. diff --git a/apps/tracker/.impeccable/critique/2026-09-06T05-01-25Z__gs-translation-editor-translation-editor-dialog-ts.md b/apps/tracker/.impeccable/critique/2026-09-06T05-01-25Z__gs-translation-editor-translation-editor-dialog-ts.md deleted file mode 100644 index c0fc631..0000000 --- a/apps/tracker/.impeccable/critique/2026-09-06T05-01-25Z__gs-translation-editor-translation-editor-dialog-ts.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -target: Translation creation/edit dialog and all tabs (re-critique) -total_score: 26 -max_score: 40 -na_heuristics: -p0_count: 1 -p1_count: 2 -target_identity: "file:/Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts" -target_fingerprint: "sha256:28e1378efb682268e63c10874f4ee3ddddff09a4a6a16ae08c4efbd78e086fa3" -target_path: /Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/dialogs/translation-editor/translation-editor-dialog.ts -timestamp: 2026-09-06T05-01-25Z -slug: gs-translation-editor-translation-editor-dialog-ts ---- -Method: dual-agent (A: design review · B: detector + browser evidence, full parser mode, live browser on :4200). - -Re-critique of the same target assessed at 19/40 on 2026-09-06. **Now 26/40.** 4 of 6 P-issues fixed, 2 partial; 6 of 10 minors fixed. No regressions in the fixed set — but the two survivors are the two that define the product, and one *new* P0 arrived with the auto-translate feature. - -## Regression scorecard — how the changes held up - -| Prev | Issue | Verdict | Proof | -|---|---|---|---| -| P1 | Key field forbids dotted syntax | **PARTIAL** | `ts:159` validator unchanged, no paste handler exists. Only the error copy improved. | -| P2 | Window Escape + no dirty guard | **FIXED** | `ts:328` `disableClose`, `ts:330-345` keydown/backdrop routed, `ts:349-364` 3-source dirty check. | -| P3 | Raw English enum statuses | **FIXED mechanism / broken content** | `ts:227-232` + `html:249` localize it — onto four wrong translations. | -| P4 | Disabled Save, no diagnosis | **FIXED** | `html:319`, `ts:619-631`, tab error dot `html:66-72`. | -| P5 | `--color-warn` doesn't exist | **FIXED here, not repo-wide** | `scss:156-159` + `tokens.scss:36`. Detector found 3 surviving refs in `translation-browser.scss:88,89,91`. | -| P6 | `otherLocales` as geography | **FIXED** | de "Weitere Sprachen", ru "Другие локали", ja "他のロケール". | - -Minors: fixed — location tooltip, reduced-motion (16/18 transitions now covered), focusable hidden add-button, nested `role="dialog"` (B confirms exactly one at runtime), `getLocaleDisplayName` via `Intl.DisplayNames`. Not fixed — responsive (still one breakpoint in 690 lines), 3 `!important` / 14 `::ng-deep`, similar-translation cards styled as navigation, 570px fixed height still in the file. - -## Design Health Score - -| # | Heuristic | Score | Key issue | -|---|---|---|---| -| 1 | Visibility of System Status | 3 | `errorMessage` (`ts:623`) never clears as fields are fixed; no `aria-live` anywhere (B). | -| 2 | Match System / Real World | 2 | `stale` = de "Abgestanden", ru "Залежалый", es "Duro", fr-ca "Vicié". `saving` = es "Ahorro…". | -| 3 | User Control and Freedom | 3 | Dirty guard is excellent — but the discard dialog lies after auto-translate. | -| 4 | Consistency and Standards | 3 | Base value is a 4-row textarea (`html:110`); every locale value is a single-line input (`html:259`). | -| 5 | Error Prevention | 2 | Dots still rejected; duplicates still only a 409 (`ts:833`); no ICU-placeholder parity check. | -| 6 | Recognition Rather Than Recall | 2 | Full dotted key is tooltip-only (`html:28`). Tab 3 shows a tree with no key and no value. | -| 7 | Flexibility and Efficiency | 3 | Ctrl+Enter now documented — but hardcoded "Ctrl" in all six locales while `ts:485` binds `meta.enter`. | -| 8 | Aesthetic and Minimalist Design | 3 | Idle Similar-Translations card always painted; B measured 350px dead space on tab 3 at 1440. | -| 9 | Error Recovery | 2 | Raw `error.error.message` piped to banner (`ts:848`); API's 422 "auto-translation not enabled" collapsed into a generic toast (`ts:654`). | -| 10 | Help and Documentation | 3 | Both disabled tabs now explain themselves. Nothing explains the status lifecycle or what auto-translate writes. | -| **Total** | | **26/40** | **Up 7. Seams closed; the model underneath is unchanged.** | - -Cognitive load: **4 failures of 8** — single focus (Tab 1 holds five concerns), grouping (location pill and its control separated by a tab boundary and named differently), one-thing-at-a-time (`ts:600` comment nag interrupts every save), progressive disclosure (idle similar-translations card). - -## Design Specificity Verdict - -**Meaningfully more authored than last time; still a Material tab dialog wearing a LingoTracker coat.** - -Newly product-specific: `ts:238` `canAutoTranslate` with the `saveFirst` tooltip encodes a real architectural truth. `html:242`/`scss:686` pairs the human locale name with a mono `fr-ca` chip — the dual audience in one row, with a source comment naming the person it serves. `tokens.scss:34-36` fixes `--color-warning` *and* records why two tokens exist (2.1:1 contrast on parchment). - -Still category-interchangeable: the three-tab strip is the whole interaction model, and it costs ~120 lines of compensating machinery (`selectedTabIndex`, `formRevision`, `baseTabHasError`, the error dot, two disabled tooltips, the base-value reference banner, a fixed 570px height the new flex layout works around). The full dotted key — the deliverable — is uncopyable, while other people's suggested keys get a clipboard button (`ts:562`). - -**Deterministic scan:** 5 findings, one rule (`side-tab`), all in SCSS. Run in **full parser mode** (agent B installed the missing parser deps, then removed them) — identical to the degraded run, so unlike last time this is not an undercount. 3 genuine, 2 defensible false positives (`similar-translations.scss:9` and `dialog.scss:469` are state channels, not decoration). Notable pattern: the same 3px left-stripe idiom on four different card types in one dialog. **Zero hardcoded colors in the directory** — fully tokenized. - -**Token sweep:** 13 unresolved `var()` refs across `apps/tracker/src`. `--color-warn` survives at `translation-browser.scss:88,89,91` (with fallback). Worse: 7 `--mat-sys-*` refs in `collection-form-dialog.scss` (`:34,41,50,69,79,88,90`) with **no fallback** — `theme.scss` uses M2 APIs, which never emit `--mat-sys-*`. Same silent-failure class as the bug you just fixed. - -**Browser (live, both modes, light+dark, 1440/900/390, EN+DE):** 0 console errors. Focus trap correct, 7 stops, nothing escapes. The 570px height **does not clip** — `min-height: 0` lets it shrink (420px at 900×700, 529px at 390×844). Two real defects it exposed, below. - -## What's Working - -- **The unsaved-changes guard is architecturally right, not just present.** `ts:327-346` owns `disableClose` in the component rather than trusting call sites, routes Escape through `dialogRef.keydownEvents()` instead of `window`, and `hasUnsavedChanges()` compares baselines for three independent state sources — form, `selectedFolderPath`, `tagsList` — two of which live outside the form. Most implementations check `form.dirty` and ship the bug. -- **`--color-warning-text` is a token fix with a stated reason.** `tokens.scss:34-36` records *why* two tokens are needed and gives dark mode its own value. That comment is what stops the next developer re-collapsing them. -- **`getLocaleDisplayName()` serves both personas in one row.** `ts:955-968` resolves through `Intl.DisplayNames` *in the active UI language*, with an uppercase fallback and try/catch; `html:242` keeps the raw code beside it. Neither audience translates for the other. - -## Priority Issues - -**[P0] Auto-translate writes the repository from inside an unsaved form — and the discard dialog then lies about it.** -`ts:646-648` POSTs to `resources/translate`; `resources.controller.ts:80-105` persists to disk. `ts:673` then marks the form dirty, so Cancel shows "Closing now will lose them" — the locale values are already committed to disk and cannot be lost. No confirmation before the write, no undo after. This breaks PRODUCT.md principle 1 (files are the truth, every action a reviewable diff) *and* principle 4 (never let a destructive state be a surprise), then misreports what happened. -Fix: make the endpoint return candidates and let Save persist them; or keep the write, confirm before it ("writes N locales to `apps.common.buttons.ok` now"), and reset the dirty baselines after. → `/impeccable harden` - -**[P1] `stale` is mistranslated in four of six locales — on the control P3's fix just made prominent.** -de "Abgestanden" (beer), ru "Залежалый" (unsold goods), es "Duro" (hard bread), fr-ca "Vicié" (foul air). Plus `saving` = es "Ahorro…", fr-ca "Économie…" — the *money* sense, on the submit button. PRODUCT.md fixes the status lifecycle as non-renamable domain vocabulary and calls it "the spine". A translation tool shipping a wrong translation of its own core noun is the worst dogfooding failure available. -Fix: de "Veraltet", ru "Устарело", es "Desactualizado", fr-ca "Obsolète"; `saving` → "Guardando…" / "Enregistrement…". → `/impeccable clarify` - -**[P1] The key field still refuses the one string the primary user is holding.** -`ts:159` `/^[a-zA-Z0-9_-]+$/`, unchanged, no paste interception. `#buildFullKey` (`ts:811-817`) joins with `.` — the app writes the syntax it won't read. The copy improved; the rejection didn't. -Fix: intercept paste/input, split at the last `.`, set `selectedFolderPath` to the prefix, put the leaf in the control, flash the location pill. Keep the pattern as backstop, not front door. → `/impeccable harden` - -**[P2] Hint text overflows and paints over content in German at narrow widths (browser-measured).** -`.mat-mdc-form-field-subscript-wrapper` is 22px with `overflow: visible`. At 390px the key hint renders 42px, tags hint 62px; in German the key hint reaches **62px** and the tags hint **82px**, spilling onto `.base-locale-card` below. Cause: no `subscriptSizing="dynamic"` at `html:77, :127, :142`. Also at 390px the tab strip paginates and scrolls the *active* tab out of view with no indicator. PRODUCT.md makes long-DE/RU survival binding. -Fix: `subscriptSizing="dynamic"` on the three fields; add a real breakpoint (there is exactly one `@media` breakpoint in 690 lines, scoped to `.base-value-reference`) and drop the dialog to full-width below ~600px. → `/impeccable adapt` - -**[P2] Other-locale values are single-line inputs while the base value is a four-row textarea.** -`html:110-120` vs `html:259-265`. Exactly backwards: the translator, whose whole job is Tab 2, gets the worse field for the languages that run 30–40% longer. `TextFieldModule` is already imported (`ts:27`) — the fix costs nothing. → `/impeccable polish` - -**[P3] The full dotted key is uncopyable, and the error banner never clears.** -`fullKeyPreview()` (`ts:255-263`) renders only into `[matTooltip]` (`html:28`) — unreachable by keyboard, touch, and selection. Separately `errorMessage` (`ts:623`) persists after the user fixes the fields, so a stale "Fix the highlighted fields" banner sits over a now-valid form, undoing the credibility P4 just earned. → `/impeccable polish` - -## Persona Red Flags - -**Alex (power-user dev):** pasted key still rejected (`ts:159`); the key he came for is uncopyable (`html:28`); `saveShortcutHint` hardcodes "Ctrl + Enter" in all six locales while `ts:485` also binds `meta.enter`; the comment nag fires on every save (`ts:600`); still no save-and-add-another. - -**Jordan (first-time translator):** single-line input for every language she writes (`html:259`); a status dropdown offering "Abgestanden"/"Залежалый"/"Duro"; no status color reaches the select (`html:244-253`) so the list view's chip vocabulary doesn't transfer; auto-translate's tooltip describes the mechanism but never says it writes files. - -**i18n owner:** auto-translate is all-or-nothing on empty locales (`ts:665`) — no per-locale control, no preview, no re-run of one stale locale; the API's 422 "Auto-translation is not enabled for this collection" is swallowed into a generic toast (`ts:654`); create mode hardcodes non-base status to `'new'` (`ts:780`) so a verified import can't be seeded; no ICU-placeholder parity check, though `skippedLocales` (`ts:688-694`) proves the server already reasons about it. - -## Minor Observations - -- **20 dialog-scope i18n entries are `stale`, 2 are `new`** — concentrated in es (10) and fr-ca (9). Translations exist but no longer match the current base value. The tool is not clean against itself. -- `similarTranslations.showMoreX` is genuinely untranslated in fr-ca ("Show {count} more"). -- `otherLocalesToggleX` is referenced by nothing — six locales maintained for a dead string, in a product whose job is finding dead strings. -- `ts:711-720`'s `hasKeyChanged` rejection is now unreachable dead code (`html:85` makes the key readonly). -- `folder-picker.ts` sets no `aria-activedescendant` and never calls `.focus()` — the visual tree highlight moves, the screen reader does not. -- `html:5` uses `class="mat-dialog-title"`, not the directive; the accessible name works only because both call sites pass `ariaLabelledBy`. A third call site ships a nameless dialog. -- `scss:12` hardcodes `max-height: 90vh`, duplicating what both call sites already pass. `scss:8` `:host { max-height: inherit }` has no effect. -- `html:92` renders the *error* string `error.keyRenamingNotSupported` as a steady-state `mat-hint`. -- es/fr-ca `keyHint`/`keyPatternError` name the tab as "pestaña Ubicación"/"onglet Emplacement" but the real labels are "Cambiar ubicación"/"Changer d'emplacement". de/ru/ja got this right. -- fr-ca `error.createFailed` = "Échec de la traduction" (translation failed, not create failed). de `conflict.chooseDifferentKey` uses *Taste* (keyboard key) for *Schlüssel*. -- Reduced motion covers 16/18 transitions; uncovered: `picker-folder-node.scss:93` `.folder-icon`, `:104` `.folder-name`. -- `translation-main-header.ts:95-99` builds the dialog data without `readOnly` — only the button's disabled state keeps a read-only collection out of an editable create dialog. -- App-shell bug found in passing: `header.keysCountX` / `header.localesCountX` render as raw keys in German (2 console warnings). -- The 4 new tests cover only P2. The tab error dot, the readonly key, and auto-translate ship untested. - -## Questions to Consider - -1. **If auto-translate writes to disk, why is it inside a form with a Save button?** The dialog now has two meanings of "saved" and tells the user about only one. -2. **The tab strip survived this re-critique — did it earn that, or just outlast the reviewer?** One scrolling column deletes the disabled-tab, cross-tab-validity, fixed-height, dead-space, and label-pagination problems at once. -3. **What check would have caught `stale` = "Abgestanden"?** Key parity is 100%. A glossary term per lifecycle status, enforced across locales, is the product demonstrating its own glossary feature on itself. -4. **`--color-warn` is fixed here and still live three files away, and 7 `--mat-sys-*` refs resolve to nothing with no fallback.** Twenty lines of CI resolving every `var(--color-*)` against `tokens.scss` makes the fix permanent instead of local. diff --git a/apps/tracker/.impeccable/critique/2026-09-06T22-33-09Z__slations-list-translation-item-translation-item-ts.md b/apps/tracker/.impeccable/critique/2026-09-06T22-33-09Z__slations-list-translation-item-translation-item-ts.md deleted file mode 100644 index f3ecf33..0000000 --- a/apps/tracker/.impeccable/critique/2026-09-06T22-33-09Z__slations-list-translation-item-translation-item-ts.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -target: compact and full detail translation resource item in the main content area of the browser view -total_score: 14 -max_score: 36 -na_heuristics: 9 -p0_count: 2 -p1_count: 3 -target_identity: "file:/Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts" -target_fingerprint: "sha256:699cf174e39ea7423816a11af7c44a0c9d325911169d19dc7f1585703a7669ef" -target_path: /Users/simon/.supacode/repos/lingo-tracker/features/apps/tracker/src/app/browser/translations/list/translation-item/translation-item.ts -timestamp: 2026-09-06T22-33-09Z -slug: slations-list-translation-item-translation-item-ts -closed: true ---- -Method: dual-agent (A: design review · B: detector + browser evidence). Both ran isolated; no degradation. - -**Target:** `apps/tracker/src/app/browser/translations/list/translation-item/` (compact + full detail) · Mode: **Operate** - -## Design Health Score - -| # | Heuristic | Score | Key Issue | -|---|---|---|---| -| 1 | Visibility of System Status | 1 | Compact shows an unlabeled value and silently falls back to the base locale — an all-`new` key renders confident English (`translation-item.ts:187-199`, `.html:45`) | -| 2 | Match System / Real World | 2 | "base locale" never appears on the item; base value is unlabeled; `Comment: -` renders on every commentless card | -| 3 | User Control and Freedom | 1 | Locale list clipped to 4 rows; expand chevron gated on value *length*, not on content being hidden (`translation-item.ts:211-216` vs `.scss:190-192`) | -| 4 | Consistency and Standards | 2 | `new`/`stale` share a color in rows but differ in the rollup; rollup is a `div[role=button]` with `cursor: default` and no click action | -| 5 | Error Prevention | 2 | Bare `Delete` key deletes a focused row, no modifier (`translation-item.ts:292-296`); read-only produces no visual change to the item | -| 6 | Recognition Rather Than Recall | 1 | Full key can be copied but never read — truncated at 50 chars, tooltip says only "Click to copy" (`item-header.html:9`) | -| 7 | Flexibility and Efficiency | 2 | Double-click-to-edit, `E`, `Delete` all exist and are entirely undiscoverable | -| 8 | Aesthetic and Minimalist Design | 1 | ~200px of `CODE · icon · "Translated" · :` chrome before every value; a constant word rendered ~40x per screen | -| 9 | Error Recovery | n/a | The item renders no error state of its own; errors live at list level | -| 10 | Help and Documentation | 2 | Rollup tooltip is the only explainer, and it repeats "Translated" with the locale code demoted to the right edge | -| **Total** | | **14/36** | **Poor — significant rework needed** | - -## Design Specificity Verdict - -**LLM assessment: mostly generic, with one bespoke component that is off-brand.** A generic key/value inspector could ship this card with a find-and-replace. Nothing in the composition knows it is looking at translations. The one authored element — the segmented status ring — is drawn in raw Tailwind hex (`#f97316`, `#eab308`, `#3b82f6`, `#10b981`, `translation-rollup.ts:277-298`) with a hardcoded `#18181b` tooltip panel. The single component with a point of view is the one that abandons the binding watercolor identity. - -The hierarchy is inverted against the stated primary job: the **base value is the card's title** (18px bold coral) while the **key is a small grey chip**. The developer arrives holding a key, not an English string. - -**Deterministic scan:** `impeccable detect` returned **exit 0, zero findings** on the target directory and `translation-list.html`. Verified as a real clean result — the same binary returns 6 findings on `src/app/browser`, and `--no-config` gives the identical empty set. One adjacent true positive just outside scope: `side-tab` at `translation-list.scss:63` (`border-left: 4px solid var(--color-info)`). - -**In-page detector:** injection succeeded (mutation preflight passed; live server on :8400, since stopped). Console reported **67 anti-patterns**. **No user-visible overlay was confirmed** — the evidence is console output only. - -Convergence between the two assessments: - -| Finding | Design review | Measured evidence | -|---|---|---| -| `--color-primary` on item background fails AA | computed 2.68:1 | measured **2.68 light** (dark passes at 5.50) | -| Locales hidden with no affordance | `needsExpansion()` keyed on wrong signal | wrapper clientHeight **128px** vs scrollHeight **295px**, expand button **never rendered** | -| Compact value is unlabeled | code path at `:187` | live: every row rendered **Arabic RTL text in an LTR box**, no locale indicator | -| Rollup is an affordance lie | `role=button`, `cursor: default` | **40x40 `div`**, below the 44px target, not a ` @@ -87,6 +100,25 @@ } + @if (layout() === 'inline') { + + + } + + @if (layout() === 'band') { + + + } @if (translationEnabled()) {