Twenty-two keys the code asks for that no catalogue has ever had
A localized CALL SITE is not a localized STRING, and nothing in this tree was
watching the join between the two.
The dashboard's five resize handles read their names through
T('aria.resizeFiltersRail', 'Resize filters rail'). Reviewed, that looks
finished: the call goes to the catalogue and the English is only a fallback.
aria.json held eleven keys and not one of them was a resize key. Every handle
had been announcing itself in English in all eleven locales, and the fallback —
which is right to exist, because a missing key must degrade to readable English
rather than to "aria.resizeFiltersRail" on screen — is exactly what kept it
silent.
No existing gate can see this. check:i18n-parity is satisfied because the key is
missing from ALL locales equally. check:english-leaks and
check:composite-locale-leaks both need the catalogue to HOLD the string before
its appearance counts as a leak, and it does not. check:ui-strings-have-keys
reads templates for typed English. Each is looking at a different end of one
wire. check:i18n-keys-exist asks the question none of them asks: does the key
this code names exist?
It found TWENTY-TWO on its first run, all in this lane — the detail drawer's
five rows, the table's two selection toggles and their hints, the map's loading
line, the breadcrumb, the charts rail and the map legend's aria labels, the
empty-search message, and big-picture's footer. Six of them are the resize keys
on this branch's parent; seventeen are added here, English wording kept exactly
as each call site already reads it, in all eleven locales.
★ THE FIRST DRAFT OF THE GATE WOULD HAVE MISSED THE DEFECT IT WAS WRITTEN FOR.
It matched tStatic/$t/t and reported four missing keys while sailing past the ten
resize keys, because the house pattern is a file-local alias —
const T = (key, en) => tStatic(key) || en — and almost every call site in the
dashboard reads T(...). Matching T( took it from 4 to 22. Prefix helpers like
BpResearchHeader's L(), and any concatenated or template-literal key, are COUNTED
as unresolved and never guessed at: inventing the key set a runtime expression
might produce is how a gate starts reporting keys nobody wrote.
AND BIG-PICTURE, the last HOST-LOCALE failure fleet-wide, gets the two halves it
was still missing. Its title was a hardcoded English literal with no key at all —
the largest text on the element, sitting above an Arabic grid. And it never
reflected `dir`, alone among its siblings, so a host asking for Arabic got Arabic
WORDS in a left-to-right layout: a bug that reads as a styling quirk, which is
why it outlived every locale fix around it. The attribute goes on the
custom-element HOST, because `dir` takes effect through the inherited CSS
`direction` and inheritance crosses shadow boundaries — one attribute covers this
element's chrome and both nested elements beneath it. Only ever set, never
cleared: a host page that declares its own dir owns that decision.
The gate is green with this branch and its parent together, and red without
either. Negative-tested: deleting a key that exists fails naming the key and the
call site; a first argument whose area is not a catalogue file is correctly not
reported.
STUDIO: no rebuild needed for the catalogue, but big-picture must be rebuilt.
package.json adds two lines beside check:i18n-never-translated — a different
region from sr/dashboard-filter-a11y and sr/language-code-link, so the three
merge.
This commit is contained in:
parent
dc226c2e01
commit
7f9b0d8983
25 changed files with 381 additions and 25 deletions
|
|
@ -20,5 +20,9 @@
|
|||
"resizeTableHint": "اسحب أو استخدم مفاتيح الأسهم لتغيير حجم الجدول",
|
||||
"resizeDashboardHeight": "تغيير ارتفاع لوحة المعلومات",
|
||||
"resizeDashboardHeightHint": "اسحب أو استخدم مفاتيح الأسهم لتغيير ارتفاع لوحة المعلومات",
|
||||
"removeFilter": "إزالة عامل التصفية {name}"
|
||||
"removeFilter": "إزالة عامل التصفية {name}",
|
||||
"closeDetail": "إغلاق التفاصيل",
|
||||
"filterBreadcrumb": "مسار عوامل التصفية",
|
||||
"railViews": "طرق عرض الشريط",
|
||||
"upgCountLegend": "مفتاح عدد UPG"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "رفع الشعوب المحدَّدة إلى الأعلى مع الإبقاء على ترتيب الأعمدة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,5 +20,9 @@
|
|||
"resizeTableHint": "तालिका का आकार बदलने के लिए खींचें या तीर कुंजियों का उपयोग करें",
|
||||
"resizeDashboardHeight": "डैशबोर्ड की ऊँचाई बदलें",
|
||||
"resizeDashboardHeightHint": "डैशबोर्ड की ऊँचाई बदलने के लिए खींचें या तीर कुंजियों का उपयोग करें",
|
||||
"removeFilter": "फ़िल्टर {name} हटाएँ"
|
||||
"removeFilter": "फ़िल्टर {name} हटाएँ",
|
||||
"closeDetail": "विवरण बंद करें",
|
||||
"filterBreadcrumb": "फ़िल्टर पथ",
|
||||
"railViews": "पट्टी के दृश्य",
|
||||
"upgCountLegend": "UPG गणना की कुंजी"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "चयनित जनसमूहों को ऊपर लाएँ, आपका कॉलम क्रम बनाए रखते हुए"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,5 +20,9 @@
|
|||
"resizeTableHint": "Перетащите или используйте клавиши со стрелками, чтобы изменить размер таблицы",
|
||||
"resizeDashboardHeight": "Изменить высоту панели",
|
||||
"resizeDashboardHeightHint": "Перетащите или используйте клавиши со стрелками, чтобы изменить высоту панели",
|
||||
"removeFilter": "Удалить фильтр {name}"
|
||||
"removeFilter": "Удалить фильтр {name}",
|
||||
"closeDetail": "Закрыть подробности",
|
||||
"filterBreadcrumb": "Цепочка фильтров",
|
||||
"railViews": "Виды панели",
|
||||
"upgCountLegend": "Легенда количества UPG"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Поднять выбранные народы наверх, сохранив сортировку столбцов"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,5 +20,9 @@
|
|||
"resizeTableHint": "拖动或使用方向键调整表格大小",
|
||||
"resizeDashboardHeight": "调整仪表板高度",
|
||||
"resizeDashboardHeightHint": "拖动或使用方向键调整仪表板高度",
|
||||
"removeFilter": "移除筛选条件 {name}"
|
||||
"removeFilter": "移除筛选条件 {name}",
|
||||
"closeDetail": "关闭详情",
|
||||
"filterBreadcrumb": "筛选路径",
|
||||
"railViews": "栏视图",
|
||||
"upgCountLegend": "UPG 数量图例"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "将所选民族置顶,同时保留您的列排序"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <tree> 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 <bundle>.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.",
|
||||
|
|
|
|||
|
|
@ -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 <people-groups-grid> 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 }) : ''))
|
||||
|
|
|
|||
|
|
@ -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: "<area>.<path.to.key>" ───────────────────────
|
||||
// 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/*/<area>.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.`)
|
||||
Loading…
Reference in a new issue