diff --git a/library/i18n/locales/ar/aria.json b/library/i18n/locales/ar/aria.json index 739a5f566..1ed559c11 100644 --- a/library/i18n/locales/ar/aria.json +++ b/library/i18n/locales/ar/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "اسحب أو استخدم مفاتيح الأسهم لتغيير حجم الجدول", "resizeDashboardHeight": "تغيير ارتفاع لوحة المعلومات", "resizeDashboardHeightHint": "اسحب أو استخدم مفاتيح الأسهم لتغيير ارتفاع لوحة المعلومات", - "removeFilter": "إزالة عامل التصفية {name}" + "removeFilter": "إزالة عامل التصفية {name}", + "closeDetail": "إغلاق التفاصيل", + "filterBreadcrumb": "مسار عوامل التصفية", + "railViews": "طرق عرض الشريط", + "upgCountLegend": "مفتاح عدد UPG" } diff --git a/library/i18n/locales/ar/dashboard.json b/library/i18n/locales/ar/dashboard.json index d6fe26fb3..edd0999c2 100644 --- a/library/i18n/locales/ar/dashboard.json +++ b/library/i18n/locales/ar/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "إلغاء تحديد الكل", "results_live": "عرض {count} من أصل {total} {unit}", "filters_live": "عوامل التصفية: {list}", - "search_live": "البحث: {term}" + "search_live": "البحث: {term}", + "adopted_by_churches": "تم تبنّيها من قِبل الكنائس", + "bp_title": "الصورة الكاملة — لوحة البحث الموحّدة", + "bp_subtitle": "ابحث وصفِّ شعوب العالم · الأقل وصولاً إليها أولاً", + "export_selection": "تصدير هذا التحديد", + "map_loading": "جارٍ تحميل خريطة البحث…", + "no_match": "لا توجد شعوب تطابق ”{q}“.", + "no_photo": "لا توجد صورة", + "only_selected": "المحدَّدة فقط", + "only_selected_hint": "إظهار الشعوب المحدَّدة فقط", + "people_committed": "الأشخاص الملتزمون", + "people_praying": "الأشخاص المصلّون", + "selected_first": "المحدَّدة أولاً", + "selected_first_hint": "رفع الشعوب المحدَّدة إلى الأعلى مع الإبقاء على ترتيب الأعمدة" } diff --git a/library/i18n/locales/de/aria.json b/library/i18n/locales/de/aria.json index 7ae5cc5f7..9ef5fcb5c 100644 --- a/library/i18n/locales/de/aria.json +++ b/library/i18n/locales/de/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Ziehen oder Pfeiltasten verwenden, um die Tabelle in der Größe zu ändern", "resizeDashboardHeight": "Höhe des Dashboards ändern", "resizeDashboardHeightHint": "Ziehen oder Pfeiltasten verwenden, um die Höhe des Dashboards zu ändern", - "removeFilter": "Filter {name} entfernen" + "removeFilter": "Filter {name} entfernen", + "closeDetail": "Details schließen", + "filterBreadcrumb": "Filterpfad", + "railViews": "Ansichten der Leiste", + "upgCountLegend": "Legende der UPG-Anzahl" } diff --git a/library/i18n/locales/de/dashboard.json b/library/i18n/locales/de/dashboard.json index 648c63c05..71265b477 100644 --- a/library/i18n/locales/de/dashboard.json +++ b/library/i18n/locales/de/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Auswahl aufheben", "results_live": "{count} von {total} {unit} angezeigt", "filters_live": "Filter: {list}", - "search_live": "Suche: {term}" + "search_live": "Suche: {term}", + "adopted_by_churches": "Von Gemeinden übernommen", + "bp_title": "Big Picture — einheitliches Forschungs-Dashboard", + "bp_subtitle": "Die Volksgruppen der Welt suchen und filtern · am wenigsten erreichte zuerst", + "export_selection": "Diese Auswahl exportieren", + "map_loading": "Forschungskarte wird geladen…", + "no_match": "Keine Volksgruppen entsprechen „{q}“.", + "no_photo": "Kein Foto", + "only_selected": "Nur ausgewählte", + "only_selected_hint": "Nur die ausgewählten Volksgruppen anzeigen", + "people_committed": "Verbindlich zugesagte Personen", + "people_praying": "Betende Personen", + "selected_first": "Ausgewählte zuerst", + "selected_first_hint": "Die ausgewählten Volksgruppen nach oben holen, die Spaltensortierung bleibt erhalten" } diff --git a/library/i18n/locales/en/aria.json b/library/i18n/locales/en/aria.json index 9b947c5f4..ea333d8e8 100644 --- a/library/i18n/locales/en/aria.json +++ b/library/i18n/locales/en/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Drag or use arrow keys to resize the table", "resizeDashboardHeight": "Resize dashboard height", "resizeDashboardHeightHint": "Drag or use arrow keys to resize the dashboard's height", - "removeFilter": "Remove filter {name}" + "removeFilter": "Remove filter {name}", + "closeDetail": "Close detail", + "filterBreadcrumb": "Filter breadcrumb", + "railViews": "Rail views", + "upgCountLegend": "UPG count legend" } diff --git a/library/i18n/locales/en/dashboard.json b/library/i18n/locales/en/dashboard.json index a10dc01fc..e606331ad 100644 --- a/library/i18n/locales/en/dashboard.json +++ b/library/i18n/locales/en/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Deselect all", "results_live": "{count} of {total} {unit} shown", "filters_live": "Filters: {list}", - "search_live": "Search: {term}" + "search_live": "Search: {term}", + "adopted_by_churches": "Adopted by churches", + "bp_title": "Big Picture — Unified Research Dashboard", + "bp_subtitle": "Search and filter the world's people groups · least-reached shown first", + "export_selection": "Export this selection", + "map_loading": "Research map loading…", + "no_match": "No people groups match “{q}”.", + "no_photo": "No photo", + "only_selected": "Only selected", + "only_selected_hint": "Show only the selected people groups", + "people_committed": "People committed", + "people_praying": "People praying", + "selected_first": "Selected first", + "selected_first_hint": "Float the selected people groups to the top, keeping your column sort" } diff --git a/library/i18n/locales/es/aria.json b/library/i18n/locales/es/aria.json index b98cb0eb8..6b21458b6 100644 --- a/library/i18n/locales/es/aria.json +++ b/library/i18n/locales/es/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Arrastra o usa las teclas de flecha para cambiar el tamaño de la tabla", "resizeDashboardHeight": "Cambiar la altura del panel", "resizeDashboardHeightHint": "Arrastra o usa las teclas de flecha para cambiar la altura del panel", - "removeFilter": "Quitar el filtro {name}" + "removeFilter": "Quitar el filtro {name}", + "closeDetail": "Cerrar el detalle", + "filterBreadcrumb": "Ruta de filtros", + "railViews": "Vistas del panel", + "upgCountLegend": "Leyenda del recuento de UPG" } diff --git a/library/i18n/locales/es/dashboard.json b/library/i18n/locales/es/dashboard.json index 7cdda8d83..1ff973162 100644 --- a/library/i18n/locales/es/dashboard.json +++ b/library/i18n/locales/es/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Deseleccionar todo", "results_live": "Se muestran {count} de {total} {unit}", "filters_live": "Filtros: {list}", - "search_live": "Búsqueda: {term}" + "search_live": "Búsqueda: {term}", + "adopted_by_churches": "Adoptados por iglesias", + "bp_title": "Panorama general — panel de investigación unificado", + "bp_subtitle": "Busca y filtra los grupos de personas del mundo · primero los menos alcanzados", + "export_selection": "Exportar esta selección", + "map_loading": "Cargando el mapa de investigación…", + "no_match": "Ningún grupo de personas coincide con «{q}».", + "no_photo": "Sin foto", + "only_selected": "Solo los seleccionados", + "only_selected_hint": "Mostrar solo los grupos de personas seleccionados", + "people_committed": "Personas comprometidas", + "people_praying": "Personas que oran", + "selected_first": "Los seleccionados primero", + "selected_first_hint": "Llevar arriba los grupos de personas seleccionados, manteniendo el orden de las columnas" } diff --git a/library/i18n/locales/fr/aria.json b/library/i18n/locales/fr/aria.json index d25bc874a..c9d6eb616 100644 --- a/library/i18n/locales/fr/aria.json +++ b/library/i18n/locales/fr/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Faites glisser ou utilisez les touches fléchées pour redimensionner le tableau", "resizeDashboardHeight": "Redimensionner la hauteur du tableau de bord", "resizeDashboardHeightHint": "Faites glisser ou utilisez les touches fléchées pour redimensionner la hauteur du tableau de bord", - "removeFilter": "Supprimer le filtre {name}" + "removeFilter": "Supprimer le filtre {name}", + "closeDetail": "Fermer le détail", + "filterBreadcrumb": "Fil d’Ariane des filtres", + "railViews": "Vues du volet", + "upgCountLegend": "Légende du nombre d’UPG" } diff --git a/library/i18n/locales/fr/dashboard.json b/library/i18n/locales/fr/dashboard.json index 983b5080a..3258a0b77 100644 --- a/library/i18n/locales/fr/dashboard.json +++ b/library/i18n/locales/fr/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Tout désélectionner", "results_live": "{count} sur {total} {unit} affichés", "filters_live": "Filtres : {list}", - "search_live": "Recherche : {term}" + "search_live": "Recherche : {term}", + "adopted_by_churches": "Adoptés par des Églises", + "bp_title": "Vue d’ensemble — tableau de bord de recherche unifié", + "bp_subtitle": "Rechercher et filtrer les groupes de personnes du monde · les moins atteints en premier", + "export_selection": "Exporter cette sélection", + "map_loading": "Chargement de la carte de recherche…", + "no_match": "Aucun groupe de personnes ne correspond à « {q} ».", + "no_photo": "Aucune photo", + "only_selected": "Uniquement la sélection", + "only_selected_hint": "N’afficher que les groupes de personnes sélectionnés", + "people_committed": "Personnes engagées", + "people_praying": "Personnes qui prient", + "selected_first": "La sélection en premier", + "selected_first_hint": "Faire remonter les groupes de personnes sélectionnés, en conservant le tri des colonnes" } diff --git a/library/i18n/locales/hi/aria.json b/library/i18n/locales/hi/aria.json index 042ec2091..3329c2a91 100644 --- a/library/i18n/locales/hi/aria.json +++ b/library/i18n/locales/hi/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "तालिका का आकार बदलने के लिए खींचें या तीर कुंजियों का उपयोग करें", "resizeDashboardHeight": "डैशबोर्ड की ऊँचाई बदलें", "resizeDashboardHeightHint": "डैशबोर्ड की ऊँचाई बदलने के लिए खींचें या तीर कुंजियों का उपयोग करें", - "removeFilter": "फ़िल्टर {name} हटाएँ" + "removeFilter": "फ़िल्टर {name} हटाएँ", + "closeDetail": "विवरण बंद करें", + "filterBreadcrumb": "फ़िल्टर पथ", + "railViews": "पट्टी के दृश्य", + "upgCountLegend": "UPG गणना की कुंजी" } diff --git a/library/i18n/locales/hi/dashboard.json b/library/i18n/locales/hi/dashboard.json index fc1d188cd..3b364d1bc 100644 --- a/library/i18n/locales/hi/dashboard.json +++ b/library/i18n/locales/hi/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "सभी अचयनित करें", "results_live": "{total} में से {count} {unit} दिखाए गए", "filters_live": "फ़िल्टर: {list}", - "search_live": "खोज: {term}" + "search_live": "खोज: {term}", + "adopted_by_churches": "कलीसियाओं द्वारा अपनाए गए", + "bp_title": "बड़ी तस्वीर — एकीकृत शोध डैशबोर्ड", + "bp_subtitle": "दुनिया के जनसमूहों को खोजें और छाँटें · सबसे कम पहुँच वाले पहले", + "export_selection": "यह चयन निर्यात करें", + "map_loading": "शोध मानचित्र लोड हो रहा है…", + "no_match": "“{q}” से मेल खाने वाला कोई जनसमूह नहीं है।", + "no_photo": "कोई फ़ोटो नहीं", + "only_selected": "केवल चयनित", + "only_selected_hint": "केवल चयनित जनसमूह दिखाएँ", + "people_committed": "प्रतिबद्ध लोग", + "people_praying": "प्रार्थना करने वाले लोग", + "selected_first": "चयनित पहले", + "selected_first_hint": "चयनित जनसमूहों को ऊपर लाएँ, आपका कॉलम क्रम बनाए रखते हुए" } diff --git a/library/i18n/locales/it/aria.json b/library/i18n/locales/it/aria.json index 4176a0b59..72bf6f51d 100644 --- a/library/i18n/locales/it/aria.json +++ b/library/i18n/locales/it/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Trascina o usa i tasti freccia per ridimensionare la tabella", "resizeDashboardHeight": "Ridimensiona l'altezza della dashboard", "resizeDashboardHeightHint": "Trascina o usa i tasti freccia per ridimensionare l'altezza della dashboard", - "removeFilter": "Rimuovi il filtro {name}" + "removeFilter": "Rimuovi il filtro {name}", + "closeDetail": "Chiudi il dettaglio", + "filterBreadcrumb": "Percorso dei filtri", + "railViews": "Viste della barra", + "upgCountLegend": "Legenda del conteggio UPG" } diff --git a/library/i18n/locales/it/dashboard.json b/library/i18n/locales/it/dashboard.json index 4a3f9d45b..6d419ba50 100644 --- a/library/i18n/locales/it/dashboard.json +++ b/library/i18n/locales/it/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Deseleziona tutto", "results_live": "{count} di {total} {unit} mostrati", "filters_live": "Filtri: {list}", - "search_live": "Ricerca: {term}" + "search_live": "Ricerca: {term}", + "adopted_by_churches": "Adottati dalle chiese", + "bp_title": "Quadro generale — dashboard di ricerca unificata", + "bp_subtitle": "Cerca e filtra i gruppi di persone del mondo · prima i meno raggiunti", + "export_selection": "Esporta questa selezione", + "map_loading": "Caricamento della mappa di ricerca…", + "no_match": "Nessun gruppo di persone corrisponde a “{q}”.", + "no_photo": "Nessuna foto", + "only_selected": "Solo i selezionati", + "only_selected_hint": "Mostra solo i gruppi di persone selezionati", + "people_committed": "Persone impegnate", + "people_praying": "Persone che pregano", + "selected_first": "Prima i selezionati", + "selected_first_hint": "Porta in alto i gruppi di persone selezionati, mantenendo l’ordinamento delle colonne" } diff --git a/library/i18n/locales/pt/aria.json b/library/i18n/locales/pt/aria.json index 30385d5d0..bacff417d 100644 --- a/library/i18n/locales/pt/aria.json +++ b/library/i18n/locales/pt/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Arraste ou use as teclas de seta para redimensionar a tabela", "resizeDashboardHeight": "Redimensionar a altura do painel", "resizeDashboardHeightHint": "Arraste ou use as teclas de seta para redimensionar a altura do painel", - "removeFilter": "Remover o filtro {name}" + "removeFilter": "Remover o filtro {name}", + "closeDetail": "Fechar o detalhe", + "filterBreadcrumb": "Trilho de filtros", + "railViews": "Vistas do painel", + "upgCountLegend": "Legenda da contagem de UPG" } diff --git a/library/i18n/locales/pt/dashboard.json b/library/i18n/locales/pt/dashboard.json index 34e46366b..645a833a5 100644 --- a/library/i18n/locales/pt/dashboard.json +++ b/library/i18n/locales/pt/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Desmarcar tudo", "results_live": "A mostrar {count} de {total} {unit}", "filters_live": "Filtros: {list}", - "search_live": "Pesquisa: {term}" + "search_live": "Pesquisa: {term}", + "adopted_by_churches": "Adotados por igrejas", + "bp_title": "Visão geral — painel de investigação unificado", + "bp_subtitle": "Pesquise e filtre os grupos de pessoas do mundo · primeiro os menos alcançados", + "export_selection": "Exportar esta seleção", + "map_loading": "A carregar o mapa de investigação…", + "no_match": "Nenhum grupo de pessoas corresponde a “{q}”.", + "no_photo": "Sem foto", + "only_selected": "Apenas os selecionados", + "only_selected_hint": "Mostrar apenas os grupos de pessoas selecionados", + "people_committed": "Pessoas comprometidas", + "people_praying": "Pessoas a orar", + "selected_first": "Os selecionados primeiro", + "selected_first_hint": "Trazer para cima os grupos de pessoas selecionados, mantendo a ordenação das colunas" } diff --git a/library/i18n/locales/ro/aria.json b/library/i18n/locales/ro/aria.json index 92cdbe220..f68819c09 100644 --- a/library/i18n/locales/ro/aria.json +++ b/library/i18n/locales/ro/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Trage sau folosește tastele săgeată pentru a redimensiona tabelul", "resizeDashboardHeight": "Redimensionează înălțimea tabloului de bord", "resizeDashboardHeightHint": "Trage sau folosește tastele săgeată pentru a redimensiona înălțimea tabloului de bord", - "removeFilter": "Elimină filtrul {name}" + "removeFilter": "Elimină filtrul {name}", + "closeDetail": "Închide detaliul", + "filterBreadcrumb": "Traseul filtrelor", + "railViews": "Vizualizările barei", + "upgCountLegend": "Legenda numărului de UPG" } diff --git a/library/i18n/locales/ro/dashboard.json b/library/i18n/locales/ro/dashboard.json index 9f25b0d03..d823f4e01 100644 --- a/library/i18n/locales/ro/dashboard.json +++ b/library/i18n/locales/ro/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Deselectează tot", "results_live": "{count} din {total} {unit} afișate", "filters_live": "Filtre: {list}", - "search_live": "Căutare: {term}" + "search_live": "Căutare: {term}", + "adopted_by_churches": "Adoptate de biserici", + "bp_title": "Imaginea de ansamblu — tablou de bord unificat de cercetare", + "bp_subtitle": "Caută și filtrează grupurile de oameni din lume · cele mai puțin atinse primele", + "export_selection": "Exportă această selecție", + "map_loading": "Se încarcă harta de cercetare…", + "no_match": "Niciun grup de oameni nu se potrivește cu „{q}”.", + "no_photo": "Nicio fotografie", + "only_selected": "Doar cele selectate", + "only_selected_hint": "Arată doar grupurile de oameni selectate", + "people_committed": "Persoane angajate", + "people_praying": "Persoane care se roagă", + "selected_first": "Cele selectate primele", + "selected_first_hint": "Ridică grupurile de oameni selectate în partea de sus, păstrând sortarea coloanelor" } diff --git a/library/i18n/locales/ru/aria.json b/library/i18n/locales/ru/aria.json index 45d23751d..a839e6650 100644 --- a/library/i18n/locales/ru/aria.json +++ b/library/i18n/locales/ru/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "Перетащите или используйте клавиши со стрелками, чтобы изменить размер таблицы", "resizeDashboardHeight": "Изменить высоту панели", "resizeDashboardHeightHint": "Перетащите или используйте клавиши со стрелками, чтобы изменить высоту панели", - "removeFilter": "Удалить фильтр {name}" + "removeFilter": "Удалить фильтр {name}", + "closeDetail": "Закрыть подробности", + "filterBreadcrumb": "Цепочка фильтров", + "railViews": "Виды панели", + "upgCountLegend": "Легенда количества UPG" } diff --git a/library/i18n/locales/ru/dashboard.json b/library/i18n/locales/ru/dashboard.json index fda4230e0..39f01f844 100644 --- a/library/i18n/locales/ru/dashboard.json +++ b/library/i18n/locales/ru/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "Снять выделение", "results_live": "Показано {count} из {total} {unit}", "filters_live": "Фильтры: {list}", - "search_live": "Поиск: {term}" + "search_live": "Поиск: {term}", + "adopted_by_churches": "Взяты церквями", + "bp_title": "Общая картина — единая исследовательская панель", + "bp_subtitle": "Поиск и фильтрация народов мира · наименее охваченные первыми", + "export_selection": "Экспортировать этот выбор", + "map_loading": "Загрузка исследовательской карты…", + "no_match": "Нет народов, соответствующих «{q}».", + "no_photo": "Нет фото", + "only_selected": "Только выбранные", + "only_selected_hint": "Показывать только выбранные народы", + "people_committed": "Взявшие обязательство", + "people_praying": "Молящиеся", + "selected_first": "Выбранные сначала", + "selected_first_hint": "Поднять выбранные народы наверх, сохранив сортировку столбцов" } diff --git a/library/i18n/locales/zh/aria.json b/library/i18n/locales/zh/aria.json index e93560da9..66395a8ff 100644 --- a/library/i18n/locales/zh/aria.json +++ b/library/i18n/locales/zh/aria.json @@ -20,5 +20,9 @@ "resizeTableHint": "拖动或使用方向键调整表格大小", "resizeDashboardHeight": "调整仪表板高度", "resizeDashboardHeightHint": "拖动或使用方向键调整仪表板高度", - "removeFilter": "移除筛选条件 {name}" + "removeFilter": "移除筛选条件 {name}", + "closeDetail": "关闭详情", + "filterBreadcrumb": "筛选路径", + "railViews": "栏视图", + "upgCountLegend": "UPG 数量图例" } diff --git a/library/i18n/locales/zh/dashboard.json b/library/i18n/locales/zh/dashboard.json index 3b17e6ae4..36f9583e9 100644 --- a/library/i18n/locales/zh/dashboard.json +++ b/library/i18n/locales/zh/dashboard.json @@ -63,5 +63,18 @@ "deselect_all": "取消全选", "results_live": "已显示 {total} 个{unit}中的 {count} 个", "filters_live": "筛选条件:{list}", - "search_live": "搜索:{term}" + "search_live": "搜索:{term}", + "adopted_by_churches": "已被教会认领", + "bp_title": "全局视图 — 统一研究仪表板", + "bp_subtitle": "搜索并筛选世界各民族 · 最少被触及的优先显示", + "export_selection": "导出此选择", + "map_loading": "正在加载研究地图…", + "no_match": "没有与“{q}”匹配的民族。", + "no_photo": "无照片", + "only_selected": "仅所选", + "only_selected_hint": "仅显示所选民族", + "people_committed": "已承诺的人数", + "people_praying": "祷告的人数", + "selected_first": "所选优先", + "selected_first_hint": "将所选民族置顶,同时保留您的列排序" } diff --git a/package.json b/package.json index a262b7575..75cde5c71 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "check:heartbeat": "node internal/check-heartbeat-threshold.mjs", "check:heartbeat:self-test": "node internal/check-heartbeat-threshold.mjs --self-test", "check:host-locale-contract": "node internal/check-host-locale-contract.mjs", + "check:i18n-keys-exist": "node plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs", "check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs", "check:i18n-parity": "node internal/check-i18n-parity.mjs", "check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs", @@ -315,6 +316,7 @@ "check:heartbeat": "the HEARTBEAT ZONE threshold (the choropleth's top bin) must come from config AND the shipped config must still render today's ramp. Two sides: (B) a configured threshold must MOVE the edge — catches re-hard-coding; (A2) dashboard.config.js's own map.* must still resolve to 5/20/60/150/Infinity — catches the shipped default silently changing. A2 was MISSING at first and heartbeatThreshold:999 shipped past this gate printing 'default ramp unchanged'. A grep for '150' cannot do either job: it false-positives on the legitimate default and false-negatives on a re-hard-coding at 200. run check:heartbeat:self-test for the NEGATIVE tests (3 cases).", "check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.", "check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.", + "check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.", "check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.", "check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in .js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.", "check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.", diff --git a/plugin-designs/big-picture/big-picture.vue b/plugin-designs/big-picture/big-picture.vue index adfa8ea9d..a3fb020d5 100644 --- a/plugin-designs/big-picture/big-picture.vue +++ b/plugin-designs/big-picture/big-picture.vue @@ -48,9 +48,9 @@ * * Becomes tab-1 of the Cat-5 SPA. */ -import { computed, reactive, ref, watchEffect } from 'vue' +import { computed, reactive, ref, watch, watchEffect, onMounted, getCurrentInstance } from 'vue' import { tStatic } from '@map/i18n/index.js' -import { SUPPORTED_LOCALES, setRuntimeLocale } from '@map/i18n/index.js' +import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, getRuntimeLocale, detectLocale } from '@map/i18n/index.js' import BpResearchHeader from './src/components/BpResearchHeader.vue' // The REAL cap-2 grid — same component the standalone ships (no fork). import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue' @@ -64,7 +64,13 @@ const config = computed(() => { try { return JSON.parse(props.profileConfig) || {} } catch { return {} } }) const accent = computed(() => config.value.accent || '#8b5cf6') -const heading = computed(() => config.value.title || 'Big Picture — Unified Research Dashboard') +// ★ THE TITLE WAS THE LAST ENGLISH LITERAL IN THIS FILE, and it is the largest text on the element. +// Everything else here already reads the catalogue; this one fell back to a hardcoded string with no +// key at all, so a host asking for Arabic got an Arabic grid under an English headline. A host's own +// `title` still wins — that is what the config key is for — but an absent one now resolves through +// the catalogue rather than through a literal. The English wording is unchanged: this is the +// shepherd's standing rule that translating EXISTING wording is not a product decision. +const heading = computed(() => config.value.title || tStatic('dashboard.bp_title') || 'Big Picture — Unified Research Dashboard') // ★ THE LOCALE THE HOST ASKED FOR, AND THE ONE ELEMENT ON THE PAGE THAT CAN ACT ON IT. // MEASURED with check:host-locale-contract: this leaf drew 0 Arabic characters for // profile-config {"locale":"ar"} against 273 for the same page reached with ?lang=ar. Its siblings @@ -80,6 +86,30 @@ const hostLang = computed(() => { }) watchEffect(() => { if (hostLang.value) setRuntimeLocale(hostLang.value) }) +// ★ RTL IS A DIRECTION, NOT A TRANSLATION — the half this element was still missing. +// Its three sibling composites (dashboard-plus-grid, research-map-plus-grid) and the grid itself all +// reflect `dir` onto their custom-element host; this one did not, so a host asking for Arabic got +// Arabic WORDS in a left-to-right layout. That reads as a styling quirk rather than the bug it is, +// which is exactly why it outlived every locale fix around it. +// +// Set on the HOST rather than on .bp-root, deliberately: `dir` takes effect through the inherited +// CSS `direction`, and inheritance crosses shadow boundaries — so one attribute covers this +// element's own chrome AND both nested elements beneath it. Only ever SET, never cleared: a host +// page that already declares its own dir owns that decision. +const textDir = computed(() => { + const l = hostLang.value || getRuntimeLocale() || detectLocale() + return RTL_LOCALES.has(l) ? 'rtl' : 'ltr' +}) +const _self = getCurrentInstance() +function reflectDir() { + try { + const host = _self?.proxy?.$el?.getRootNode?.()?.host + if (host && textDir.value === 'rtl') host.setAttribute('dir', 'rtl') + } catch (_) { /* no host (SSR/tests) — nothing to reflect */ } +} +onMounted(reflectDir) +watch(textDir, reflectDir) + // The host's people-group LIST (library/lists/), handed to both children as the only key of their // profile-config, so nothing else about them changes. Absent = the default list. const listConfig = computed(() => (config.value.list ? JSON.stringify({ list: config.value.list }) : '')) diff --git a/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs b/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs new file mode 100644 index 000000000..333dff5ab --- /dev/null +++ b/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * check-i18n-keys-exist.mjs — a localized CALL SITE is not a localized STRING. + * + * THE FAILURE CLASS, found twice in one day and invisible both times. + * + * 1. The research dashboard's five resize handles read their accessible names through + * `T('aria.resizeFiltersRail', 'Resize filters rail')`. Reviewed, they look correct: the call + * goes to the catalogue, the fallback is only a fallback. But `aria.json` held ELEVEN keys and + * not one of them was a resize key — so every handle had been announcing itself in English in + * all eleven locales. Nothing was watching the JOIN between the call and the catalogue. + * 2. big-picture's footer reads `tStatic('dashboard.bp_subtitle') || "Search and filter the + * world's people groups · least-reached shown first"`. Same shape, same silence. + * + * ★ WHY NO EXISTING GATE SEES IT. check:i18n-parity asks whether every locale has the same keys as + * English — it does, because the key is missing from ALL of them equally. check:english-leaks and + * check:composite-locale-leaks both need the catalogue to HOLD the string before they can call its + * appearance a leak, and it does not. check:ui-strings-have-keys reads TEMPLATES for typed English. + * Every one of them is looking at a different end of the same wire. This asks the one question none + * of them asks: does the key this code names actually exist? + * + * THE FALLBACK IS WHAT MAKES IT SILENT, and the fallback is right to exist — a missing key must + * degrade to readable English, not to "dashboard.bp_subtitle" on screen. So the defect can only be + * seen by reading the two sides together, which is a gate's job and not a reviewer's. + * + * WHAT IT CHECKS. Every `tStatic('a.b')` / `T('a.b', …)` / `L('x', …)`-style literal key in + * plugin-designs/ and library/ resolves to a string in library/i18n/locales/en/. English is the + * reference because check:i18n-parity already guarantees the other ten match it — so one missing + * English key is eleven missing strings, and a key present in en is present everywhere. + * + * DYNAMIC KEYS ARE NOT GUESSED AT. `tStatic('dashboard.' + k)` and template literals are reported + * as UNRESOLVED and counted, never failed: this gate refuses to invent the key set a runtime + * expression might produce. A count that a reader can see is honest; a silent skip is not. + * + * NEGATIVE-TESTED 2026-09-26: deleting `aria.resetAllFilters` from en/aria.json makes it FAIL + * naming the key and the call site; restoring it returns PASS. It FAILS on today's tree. + */ +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' +import { resolve, dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TAG = 'I18N-KEYS-EXIST' +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const EN = join(ROOT, 'library/i18n/locales/en') +if (!existsSync(EN)) { console.log(`${TAG}: SKIP — no English catalogue at ${relative(ROOT, EN)}.`); process.exit(0) } + +// ── the English catalogue, as one flat key set: "." ─────────────────────── +// The area is the FILE NAME, because that is how every call site spells it: tStatic('aria.foo') +// reads aria.json. Nested objects flatten with dots, the way tStatic walks them. +const KEYS = new Set() +for (const f of readdirSync(EN)) { + if (!f.endsWith('.json')) continue + const area = f.replace(/\.json$/, '') + const walk = (node, path) => { + if (node && typeof node === 'object' && !Array.isArray(node)) { + for (const [k, v] of Object.entries(node)) walk(v, path ? path + '.' + k : k) + } else if (typeof node === 'string') { + KEYS.add(area + '.' + path) + } + } + try { walk(JSON.parse(readFileSync(join(EN, f), 'utf8')), '') } catch (e) { + console.error(`${TAG}: ${f} does not parse — ${e.message}`); process.exit(1) + } +} + +// ── every source file that could name a key ────────────────────────────────────────────────── +const SCAN = ['plugin-designs', 'library'] +const files = [] +// `scripts/` is build and gate TOOLING, not product UI. Skipping it is not a convenience: this +// gate's own header quotes two real call sites as examples, and scanning itself reported them as +// missing keys — a check that fails on its own documentation is a check nobody trusts. +const walkDir = (d) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name === 'locales' || e.name === 'scripts' || e.name.startsWith('.')) continue + const p = join(d, e.name) + if (e.isDirectory()) walkDir(p) + else if (/\.(vue|js|mjs)$/.test(e.name)) files.push(p) + } +} +for (const s of SCAN) { const p = join(ROOT, s); if (existsSync(p) && statSync(p).isDirectory()) walkDir(p) } + +// A literal key: the first argument is a single-quoted FULL dotted key. +// ★ `T(` IS IN THE LIST, AND LEAVING IT OUT WOULD HAVE MADE THIS GATE USELESS ON THE VERY DEFECT +// THAT PROMPTED IT. The house pattern in this repo is a file-local alias — +// `const T = (key, en) => tStatic(key) || en` — so almost every call site in the dashboard reads +// `T('aria.resizeFiltersRail', …)`, not `tStatic(...)`. A first draft matched only tStatic/$t/t and +// reported four missing keys while sailing past the ten resize keys it was written for. The alias +// takes a full key, exactly as tStatic does, so it belongs here. +const LITERAL = /(?:^|[^\w$.])(?:tStatic|\$t|t|T)\(\s*'([a-zA-Z_][\w]*(?:\.[\w-]+)+)'/g +// A DYNAMIC key: the first argument is a concatenation or a template literal. +// ★ `L(` IS DELIBERATELY NOT RESOLVED. BpResearchHeader declares +// `const L = (key, en) => tStatic('grid.' + key) || tStatic('dashboard.' + key) || en`, so its +// first argument is a BARE key with a file-local prefix that this gate would have to guess at — +// and guessing is how a gate starts reporting keys nobody wrote. Counted as unresolved, like any +// other runtime expression. +const DYNAMIC = /(?:^|[^\w$.])(?:tStatic|\$t|t|T|L)\(\s*(?:'[^']*'\s*\+|`|'[a-z_]+'\s*,)/g + +const missing = new Map() // key -> [call sites] +let literalCount = 0 +let dynamicCount = 0 +for (const f of files) { + const src = readFileSync(f, 'utf8') + const rel = relative(ROOT, f) + for (const m of src.matchAll(LITERAL)) { + const key = m[1] + literalCount++ + if (KEYS.has(key)) continue + // Only keys whose AREA is a real catalogue file are this gate's business: `props.t.total`, + // `config.map.provider` and friends are not i18n keys and must not be reported as missing. + const area = key.split('.')[0] + if (!existsSync(join(EN, area + '.json'))) continue + const line = src.slice(0, m.index).split('\n').length + if (!missing.has(key)) missing.set(key, []) + missing.get(key).push(`${rel}:${line}`) + } + for (const _ of src.matchAll(DYNAMIC)) dynamicCount++ +} + +console.log(` · ${KEYS.size} English keys across ${readdirSync(EN).filter((f) => f.endsWith('.json')).length} catalogue(s); ${literalCount} literal call site(s) in ${files.length} file(s)`) +if (dynamicCount) { + console.log(` · ${dynamicCount} DYNAMIC key expression(s) not checked — this gate will not invent the key set a runtime expression might produce.`) +} + +if (missing.size) { + console.error(`\n${TAG}: FAIL — ${missing.size} key(s) are named in code and exist in NO catalogue, so every locale renders the English fallback:`) + for (const [key, sites] of [...missing].sort()) { + console.error(` ✗ ${key}`) + for (const s of sites.slice(0, 4)) console.error(` ${s}`) + if (sites.length > 4) console.error(` … and ${sites.length - 4} more`) + } + console.error('\n The call sites are RIGHT; the catalogue never caught up. Add the key to') + console.error(' library/i18n/locales/*/.json in all eleven locales, keeping the English') + console.error(' wording exactly as the fallback reads it — translating existing wording is not a') + console.error(' product decision, but changing it is.') + process.exit(1) +} +console.log(`${TAG}: PASS — every literal catalogue key named in code exists in English.`)