From 7166ca560ffed09856f860e03fc20b9609f74dc4 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 23:10:46 +0000 Subject: [PATCH 01/13] WIP count: upgMap.wallIntroA with {n} in all 11 locales (the wall intro's total becomes a parameter; the consumer, fetchingUupgs and the gate follow) --- library/i18n/locales/ar/upgMap.json | 3 ++- library/i18n/locales/de/upgMap.json | 3 ++- library/i18n/locales/en/upgMap.json | 3 ++- library/i18n/locales/es/upgMap.json | 3 ++- library/i18n/locales/fr/upgMap.json | 3 ++- library/i18n/locales/hi/upgMap.json | 3 ++- library/i18n/locales/it/upgMap.json | 3 ++- library/i18n/locales/pt/upgMap.json | 3 ++- library/i18n/locales/ro/upgMap.json | 3 ++- library/i18n/locales/ru/upgMap.json | 3 ++- library/i18n/locales/zh/upgMap.json | 3 ++- 11 files changed, 22 insertions(+), 11 deletions(-) diff --git a/library/i18n/locales/ar/upgMap.json b/library/i18n/locales/ar/upgMap.json index 64da3d88f..0b082170b 100644 --- a/library/i18n/locales/ar/upgMap.json +++ b/library/i18n/locales/ar/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "تصدير CSV", "copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة", "displayName": "ملصقات قائمة UPG 100", - "wallHeading": "ملصقات قائمة UPG 100" + "wallHeading": "ملصقات قائمة UPG 100", + "wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم " } diff --git a/library/i18n/locales/de/upgMap.json b/library/i18n/locales/de/upgMap.json index 62d4779be..00869242e 100644 --- a/library/i18n/locales/de/upgMap.json +++ b/library/i18n/locales/de/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "CSV exportieren", "copyNotRegroupable": "Nicht gemeinsam umgruppierbar: Bundesstaaten oder Provinzen eines Landes oder die Länder getrennt kopieren", "displayName": "Poster der UPG 100 Liste", - "wallHeading": "Poster der UPG 100 Liste" + "wallHeading": "Poster der UPG 100 Liste", + "wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten " } diff --git a/library/i18n/locales/en/upgMap.json b/library/i18n/locales/en/upgMap.json index 0dbc862eb..9dc4ad321 100644 --- a/library/i18n/locales/en/upgMap.json +++ b/library/i18n/locales/en/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "Export CSV", "copyNotRegroupable": "Can't be regrouped together: copy one country's states or provinces, or the countries, separately", "displayName": "UPG 100 List Posters", - "wallHeading": "UPG 100 List Posters" + "wallHeading": "UPG 100 List Posters", + "wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them " } diff --git a/library/i18n/locales/es/upgMap.json b/library/i18n/locales/es/upgMap.json index d50196625..e746a870b 100644 --- a/library/i18n/locales/es/upgMap.json +++ b/library/i18n/locales/es/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "Exportar CSV", "copyNotRegroupable": "No se pueden reagrupar juntos: copia por separado los estados o provincias de un solo país, o los países", "displayName": "Carteles de la lista UPG 100", - "wallHeading": "Carteles de la lista UPG 100" + "wallHeading": "Carteles de la lista UPG 100", + "wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos " } diff --git a/library/i18n/locales/fr/upgMap.json b/library/i18n/locales/fr/upgMap.json index d963e5aad..61fa21d4d 100644 --- a/library/i18n/locales/fr/upgMap.json +++ b/library/i18n/locales/fr/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "Exporter en CSV", "copyNotRegroupable": "Impossible de les regrouper ensemble : copiez séparément les États ou provinces d’un seul pays, ou les pays", "displayName": "Affiches de la liste UPG 100", - "wallHeading": "Affiches de la liste UPG 100" + "wallHeading": "Affiches de la liste UPG 100", + "wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux " } diff --git a/library/i18n/locales/hi/upgMap.json b/library/i18n/locales/hi/upgMap.json index 9c6fd059a..f9022c937 100644 --- a/library/i18n/locales/hi/upgMap.json +++ b/library/i18n/locales/hi/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "CSV निर्यात करें", "copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें", "displayName": "UPG 100 सूची पोस्टर", - "wallHeading": "UPG 100 सूची पोस्टर" + "wallHeading": "UPG 100 सूची पोस्टर", + "wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए " } diff --git a/library/i18n/locales/it/upgMap.json b/library/i18n/locales/it/upgMap.json index 331baec8e..f5277fe61 100644 --- a/library/i18n/locales/it/upgMap.json +++ b/library/i18n/locales/it/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "Esporta CSV", "copyNotRegroupable": "Non si possono raggruppare insieme: copia separatamente gli stati o le province di un solo paese, o i paesi", "displayName": "Poster della lista UPG 100", - "wallHeading": "Poster della lista UPG 100" + "wallHeading": "Poster della lista UPG 100", + "wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro " } diff --git a/library/i18n/locales/pt/upgMap.json b/library/i18n/locales/pt/upgMap.json index 4dfa584a9..59796dc4b 100644 --- a/library/i18n/locales/pt/upgMap.json +++ b/library/i18n/locales/pt/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "Exportar CSV", "copyNotRegroupable": "Não podem ser reagrupados juntos: copie separadamente os estados ou províncias de um só país, ou os países", "displayName": "Cartazes da lista UPG 100", - "wallHeading": "Cartazes da lista UPG 100" + "wallHeading": "Cartazes da lista UPG 100", + "wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles " } diff --git a/library/i18n/locales/ro/upgMap.json b/library/i18n/locales/ro/upgMap.json index 341356892..e494b73b6 100644 --- a/library/i18n/locales/ro/upgMap.json +++ b/library/i18n/locales/ro/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "Exportă CSV", "copyNotRegroupable": "Nu pot fi regrupate împreună: copiază separat statele sau provinciile unei singure țări, sau țările", "displayName": "Afișe din lista UPG 100", - "wallHeading": "Afișe din lista UPG 100" + "wallHeading": "Afișe din lista UPG 100", + "wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei " } diff --git a/library/i18n/locales/ru/upgMap.json b/library/i18n/locales/ru/upgMap.json index a3cc4cf02..7672dbd8a 100644 --- a/library/i18n/locales/ru/upgMap.json +++ b/library/i18n/locales/ru/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "Экспорт в CSV", "copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны", "displayName": "Плакаты списка UPG 100", - "wallHeading": "Плакаты списка UPG 100" + "wallHeading": "Плакаты списка UPG 100", + "wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них " } diff --git a/library/i18n/locales/zh/upgMap.json b/library/i18n/locales/zh/upgMap.json index f6794e7c4..14af05852 100644 --- a/library/i18n/locales/zh/upgMap.json +++ b/library/i18n/locales/zh/upgMap.json @@ -17,5 +17,6 @@ "exportCsv": "导出 CSV", "copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家", "displayName": "UPG 100 名单海报", - "wallHeading": "UPG 100 名单海报" + "wallHeading": "UPG 100 名单海报", + "wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 " } From 85508b899dd4c96b08a57f68beae3535bc5a9f0b Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 23:27:02 +0000 Subject: [PATCH 02/13] UPG-100: the list's total is never written into a sentence. The wall intro (upgMap.wallIntroA, {n} in 11 locales) and the fetching status take it from the funnel's last step (2,107 today; the intro printed a stale 2,106), formatted for the reader. New gate check:upg-count (gates:pre), mutation-tested --- internal/check-upg-count.mjs | 69 +++++++++++++++++++ package.json | 4 +- .../my-upg-100-list/data/upg-total.js | 18 +++++ .../my-upg-100-list/poster-wall.vue | 6 +- .../my-upg-100-list/upg-100-list.vue | 4 +- 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 internal/check-upg-count.mjs create mode 100644 plugin-designs/my-upg-100-list/data/upg-total.js diff --git a/internal/check-upg-count.mjs b/internal/check-upg-count.mjs new file mode 100644 index 000000000..f5e2aefaa --- /dev/null +++ b/internal/check-upg-count.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// check-upg-count — UPG-100 never writes its list's TOTAL into a sentence. The wall's intro printed "2,106" in all +// eleven languages after the list had grown to 2,107, because the number lived inside the translated text. Now every +// sentence takes it as {n}, from the funnel's last step (plugin-designs/my-upg-100-list/data/upg-total.js), formatted +// for the reader's locale. Headless. Asserts: +// A. the total is known (vision-funnel.json's last step) and upgTotal() reads it; +// B. no upgMap value in any of the 11 locales contains the total (or the stale 2,106) in ANY digit system: Latin +// with any separator, Arabic-Indic, Devanagari. A built-in NEGATIVE CONTROL plants "2,106" and must be caught; +// C. upgMap.wallIntroA carries {n} in all 11 locales; +// D. the consumers pass the live total: poster-wall.vue reads upgMap.wallIntroA with n (not the frozen +// messages.upg100WallIntroA), and upg-100-list.vue passes no number literal to fetchingUupgs. +import { readFileSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const P = (...a) => join(REPO, ...a) +const fails = [], notes = [] +const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) } +const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh'] + +let upgTotal, funnel +try { + ;({ upgTotal } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/upg-total.js')).href)) + funnel = JSON.parse(readFileSync(P('data/people-groups/vision-funnel.json'), 'utf8')) +} catch (e) { + console.error(`UPG-COUNT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`) + process.exit(75) +} + +// ── A ── +const total = upgTotal(funnel) +ok(total > 1000, `the list's total reads as ${total} from vision-funnel.json`, `the list's total is ${total}, from the funnel`) + +// ── B: a digit-system-blind scan ── +const NATIVE = { '٠': 0, '١': 1, '٢': 2, '٣': 3, '٤': 4, '٥': 5, '٦': 6, '٧': 7, '٨': 8, '٩': 9, '०': 0, '१': 1, '२': 2, '३': 3, '४': 4, '५': 5, '६': 6, '७': 7, '८': 8, '९': 9 } +const numbersIn = (s) => String(s).replace(/[٠-٩०-९]/g, (d) => NATIVE[d]) + .replace(/(\d)[,. ٬'](?=\d{3}\b)/g, '$1') // drop thousands separators + .match(/\d+/g) || [] +const FORBIDDEN = new Set([String(total), '2106']) +const hasTotal = (s) => numbersIn(s).some((n) => FORBIDDEN.has(n)) +ok(hasTotal('We did the same with the 2,106 unreached') && hasTotal('فعلنا المثل مع ٢١٠٦ شعوب') && hasTotal(`mit den ${String(total).replace(/(\d)(\d{3})$/, '$1.$2')} Gruppen`), + 'NEGATIVE CONTROL: the scanner missed a planted total, so it cannot guard anything', 'negative control: a planted 2,106 / ٢١٠٦ / 2.107 is caught') +const leaks = [] +for (const l of LANGS) { + const cat = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')) + for (const [k, v] of Object.entries(cat)) if (typeof v === 'string' && hasTotal(v)) leaks.push(`${l} upgMap.${k}`) +} +ok(!leaks.length, `the total is written into a sentence: ${leaks.slice(0, 6).join(', ')}`, `no upgMap sentence in the 11 locales carries the total (${total}) or the stale 2,106`) + +// ── C ── +const noN = LANGS.filter((l) => !String(JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')).wallIntroA || '').includes('{n}')) +ok(!noN.length, `upgMap.wallIntroA has no {n} in: ${noN.join(', ')}`, 'the wall intro takes the total as {n} in all 11 locales') + +// ── D ── +const wall = readFileSync(P('plugin-designs/my-upg-100-list/poster-wall.vue'), 'utf8') +const map = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8') +ok(/t\('upgMap\.wallIntroA', \{ n: formatCount\(upgTotal\(/.test(wall) && !/t\('messages\.upg100WallIntroA'\)/.test(wall), + 'poster-wall.vue does not render the intro from upgMap.wallIntroA with the live total', 'the wall intro renders the live total') +ok(!/fetchingUupgs', \{ n: \d/.test(map) && /fetchingUupgs', \{ n: formatCount\(upgTotal\(/.test(map), + 'upg-100-list.vue passes a number literal to fetchingUupgs', 'the fetching status shows the live total') + +for (const n of notes) console.log(n) +if (fails.length) { + console.error('UPG-COUNT: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`UPG-COUNT: PASS — the list's total (${total}) lives in one place and every sentence takes it as {n}.`) diff --git a/package.json b/package.json index e81ddbcd5..1146df228 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,7 @@ "check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs", "check:india-states": "node internal/check-india-states.mjs", "check:batch-selection-export": "node internal/check-batch-selection-export.mjs", + "check:upg-count": "node internal/check-upg-count.mjs", "check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs", "check:evangelical-share": "node internal/check-evangelical-share.mjs", "check:map-labels": "node internal/check-map-labels.mjs", @@ -347,7 +348,7 @@ "fix:sri": "node internal/fix-sri.mjs", "gallery": "node internal/generate-gallery.mjs", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight", - "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch", + "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:upg-count check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", @@ -560,6 +561,7 @@ "check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.", "check:india-states": "India's states-and-union-territories table (data/people-groups/india-states.json) stays true to the stamping it is derived from: all 36 ISO 3166-2:IN divisions (28 state + 8 union_territory, Nagaland + Mizoram listed even when empty), every India people group of admin-membership.json in exactly ONE division, every shapeName a feature of the served geometry, NO DRIFT (re-derived from today's inputs it is byte-identical and meta's input sha384s are the files on disk), and pin == table == served copy under a size ceiling. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.", "check:batch-selection-export": "UPG-100's batch selection EXPORTS its selection (restored 2026-09-30): Copy's first line parses in data/apply-grouping.mjs (names, UUPG total, colour) with the people-group CSV after it; the CSV header, quoting and formula-neutralising hold under a real CSV parse; the HUD wires the colour swatches, hex input, Copy, Export CSV and the records prefetch; the 9 new strings are in all 11 locales, translated. Mutation-tested (CSV first, no formula guard, Copy unwired: each FAILS). Headless; in gates:pre. By the UPG-100 owner.", + "check:upg-count": "UPG-100 never writes its list's TOTAL into a sentence (the wall intro printed a stale 2,106 in eleven languages): the total comes from the funnel's last step (data/upg-total.js), no upgMap value in any locale carries it or 2,106 in any digit system (Latin, Arabic-Indic, Devanagari; a built-in negative control plants it), upgMap.wallIntroA takes {n} ×11, and the wall intro + the fetching status pass the live total. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.", "check:tree-relative": "the committed tree names no host of its own. With embedBaseUrl empty in the build config, the manifest must say embedBase \".\" and every scriptUrl, iframeUrl and embed snippet src must be relative. check:drop cannot see this: it looks for root-relative \"/...\" references and skips https:// ones, so a tree built with EMBED_BASE_URL (a deploy build) would pass it and pin every snippet to one host, the way the retired CDN route broke. Data addresses (runtime.apiBaseUrl) are not checked. SKIPs when the config sets embedBaseUrl. Static; takes an optional manifest path for poison tests.", "check:universal-props": "every plugin reads the props the Plugin Studio header sets for all of them through the library: map engine, locale, Mapbox token and token URL, and the people-group list. It reads each bundle's source AND its shipped bundle and fails on a bypass (a mapbox:// style literal, a private token global, a list fetched around the library); a by-design exception is ALLOWed by name with its reason. --report also prints the matrix. Headless.", "check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.", diff --git a/plugin-designs/my-upg-100-list/data/upg-total.js b/plugin-designs/my-upg-100-list/data/upg-total.js new file mode 100644 index 000000000..bb480d40d --- /dev/null +++ b/plugin-designs/my-upg-100-list/data/upg-total.js @@ -0,0 +1,18 @@ +// upg-total.js — how many people groups the UPG-100 list covers, from ONE place: the funnel's last step +// (data/people-groups/vision-funnel.json, the same figure the packet's footnote uses). Never write the number into +// a string: the list grows (2,106 was printed in eleven languages after it had become 2,107), so every sentence +// takes it as {n}, formatted for the reader's locale. check:upg-count keeps it that way. +// PURE (no JSON import, so node gates can import it): the .vue callers import the funnel and pass it in. + +/** The list's total: the funnel's last step (the whole DOXA set). 0 when the funnel is missing. */ +export function upgTotal(funnel) { + const steps = (funnel && Array.isArray(funnel.steps)) ? funnel.steps : [] + return Number(steps.length ? steps[steps.length - 1].groups : 0) || 0 +} + +/** A count as the reader writes numbers (2,107 · 2.107 · 2 107 · ٢٬١٠٧). */ +export function formatCount(n, locale) { + try { return new Intl.NumberFormat(locale || undefined).format(n) } catch (_) { return String(n) } +} + +export default { upgTotal, formatCount } diff --git a/plugin-designs/my-upg-100-list/poster-wall.vue b/plugin-designs/my-upg-100-list/poster-wall.vue index 4720831cc..e05822e9b 100644 --- a/plugin-designs/my-upg-100-list/poster-wall.vue +++ b/plugin-designs/my-upg-100-list/poster-wall.vue @@ -46,6 +46,9 @@ import PosterWall from '@map/components/poster/PosterWall.vue' import { resolveAssetsBase } from '@map/utils/embedBase.js' // The reader's language, for the DATA fetch below. Not for interface text — that goes through t(). import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js' +// The list's total for the intro, from the funnel (never a number written into a string; see data/upg-total.js). +import visionFunnel from '../../data/people-groups/vision-funnel.json' +import { upgTotal, formatCount } from './data/upg-total.js' // Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out. import { zonesForList } from './data/zones-for-list.js' // The SAME normalize+evaluate pipeline the sibling map profile uses to turn the live @@ -244,7 +247,8 @@ const intro = computed(() => { // The three plain-text keys studio adds (rule 5B — the catalogue is theirs). Until they land, the // single legacy key is still the source of truth, so this renders identically before AND after: no // flag day, and my landing does not have to be ordered against theirs. - const a = t('messages.upg100WallIntroA') + // The total is {n}, formatted for the reader (it printed a stale 2,106 in all eleven languages). + const a = t('upgMap.wallIntroA', { n: formatCount(upgTotal(visionFunnel), getRuntimeLocale() || detectLocale()) }) const b = t('messages.upg100WallIntroEmph') const c = t('messages.upg100WallIntroB') const missing = (v, key) => !v || v === key diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index e88cf502e..2e59e9005 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -129,6 +129,8 @@ import { colorForZone } from './data/zone-colors.js' // either to { key, name, count } from admin-counts/membership (NEVER edits them). import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js' import { selectionCsv, selectionClipboard, selectionCsvFileName, regroupable } from './data/selection-export.js' +import visionFunnel from '../../data/people-groups/vision-funnel.json' +import { upgTotal, formatCount } from './data/upg-total.js' // 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the // tokenized contains matcher. Pure data + pure fns; this profile builds the rows // from the live records (PG), the legend leafZones (zone), and admin-counts + @@ -2742,7 +2744,7 @@ onMounted(async () => { // The pins need none of it: they are built from `records` (lat/lng) and slugToZone, and the // selector evaluate() at the pin build maps slugs to zone keys without touching any geometry. // 68 KB, arriving at ~2.9 s cold. - status.value = t('messages.fetchingUupgs', { n: 2106 }) + status.value = t('messages.fetchingUupgs', { n: formatCount(upgTotal(visionFunnel), packetLang()) }) // the funnel's total, never a literal const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await recordsPromise), profileConfig)) // 09b shift-select §1(b): build iso3→display-name from the LIVE records so an // ADM0 country click resolves to the exact label the legend shows (the ADM0 From 25982d717f45b3fbedd224fbbfffdb1745721d29 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 23:28:52 +0000 Subject: [PATCH 03/13] Shell header: a phone bar and a rail-bottom language slot for header-off shells, and a shared language menu that asks the host first VerticalTabsLayout, header off: on a phone a 44px bar (a real grid row) replaces the floating corner circle, with the rail toggle at the inline start and a #phone-end slot at the end; on a desktop a #rail-end slot sits at the bottom of the rail (it receives `collapsed`). Neither renders with a header on, so header-on consumers and desktop layouts without a filler are unchanged. The --map-fab-* footprint is no longer published; its readers fall back to their plain insets. ShellLanguageMenu fills either slot: the switcher's languages plus the current one, a native select under a globe face. A pick dispatches one cancelable, composed, bubbling doxa-language-request on the host element (library/utils/languageRequest.js). A host that cancels keeps the language; otherwise the menu emits 'switch' and writes ?lang= only on a page the tree generated. check:shell-header (gates:pre, Vite SSR) asserts where the bar and the rail slot render, the event's shape and count, cancel and no-cancel, the ?lang= rule and the language list; its self-test (gates:post) refuses 11 planted defects. check:nav-reachable now presses the bar's toggle. --- documentation/reference-library-index.md | 4 + internal/check-nav-reachable-mobile.mjs | 9 +- internal/check-shell-header.mjs | 173 +++++++++++++++++++++++ library/components/ShellLanguageMenu.vue | 91 ++++++++++++ library/layouts/VerticalTabsLayout.vue | 154 +++++++++++--------- library/utils/languageRequest.js | 58 ++++++++ package.json | 7 +- 7 files changed, 424 insertions(+), 72 deletions(-) create mode 100644 internal/check-shell-header.mjs create mode 100644 library/components/ShellLanguageMenu.vue create mode 100644 library/utils/languageRequest.js diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index c7949d1c1..934addb27 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -24,6 +24,8 @@ and a change here can break other maps. `import … from '@map/components/ResearchMapFilterPanel.vue'` - **ResearchMapSideMenu** — ResearchMapSideMenu.vue — Side drawer specific to the research-map profile. `import … from '@map/components/ResearchMapSideMenu.vue'` +- **ShellLanguageMenu** — ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end + `import … from '@map/components/ShellLanguageMenu.vue'` - **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application. `import … from '@map/components/SideMenuDrawer.vue'` - **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of @@ -264,6 +266,8 @@ and a change here can break other maps. `import … from '@map/utils/html.js'` - **languageLabel** — languageLabel.js — turn the API's comma-inverted language label into the natural reading. `import … from '@map/utils/languageLabel.js'` +- **languageRequest** — languageRequest.js — how a shell asks its HOST PAGE before it changes language. + `import … from '@map/utils/languageRequest.js'` - **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host `import … from '@map/utils/loadPinnedUmd.js'` - **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js). diff --git a/internal/check-nav-reachable-mobile.mjs b/internal/check-nav-reachable-mobile.mjs index f109edf2f..f8a3413ff 100644 --- a/internal/check-nav-reachable-mobile.mjs +++ b/internal/check-nav-reachable-mobile.mjs @@ -113,10 +113,11 @@ const PROBE = () => { if (!rail) return { noRail: true } // Any control that can bring the rail on screen. Both are legitimate: the header toggle when a - // header exists, the floating opener when it does not. + // header exists, the phone bar's ☰ (.vtl-phone-menu, which replaced the floating corner circle) when + // it does not. const openers = all - .filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n))) - .map(n => ({ kind: /vtl-fab/.test(cls(n)) ? 'fab' : 'header-menu', on: onScreen(n), name: name(n) })) + .filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n))) + .map(n => ({ kind: /vtl-phone-menu/.test(cls(n)) ? 'phone-bar' : 'header-menu', on: onScreen(n), name: name(n) })) return { railOnScreen: onScreen(rail), @@ -173,7 +174,7 @@ for (const t of targets) { for (const c of (n.children || [])) w(c) })(document.body) const cls = (n) => (n.className || '').toString() - const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)) && + const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)) && n.getBoundingClientRect().width > 0) if (!btn) return { clicked: false } btn.click() diff --git a/internal/check-shell-header.mjs b/internal/check-shell-header.mjs new file mode 100644 index 000000000..5f65b388a --- /dev/null +++ b/internal/check-shell-header.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * check-shell-header — a header-off shell has ONE way to its tabs and ONE language menu, in the right + * place for the width, and a language pick ASKS the host page first. Headless (Vite SSR, no browser). + * + * THE DESIGN (_qa-cycle/studio-organize/MOBILE-SHELL-HEADER-DESIGN.md): on a phone, a header-off shell + * (the Research Gallery, Posterverse) gets a 44px bar with ☰ at the start and a #phone-end slot at the + * end; on a desktop, a #rail-end slot at the bottom of the rail. The shell fills both with + * library/components/ShellLanguageMenu.vue, whose pick dispatches ONE cancelable + * `doxa-language-request` on the host element: cancelled → the host changes the language, the shell + * does not; not cancelled → the shell switches, and writes ?lang= only on a page the tree generated. + * + * ASSERTS, on the REAL components: + * 1. VerticalTabsLayout renders the phone bar ONLY when narrow AND header-off, #rail-end ONLY when + * wide AND header-off (all four combinations), and never the old floating corner circle. + * 2. one pick dispatches exactly ONE doxa-language-request, detail.language = the code, and + * cancelable, composed and bubbles all true. + * 3. a host that cancels → the menu emits no 'switch' and shows the current language again. + * 4. nobody cancels → the menu emits 'switch' once, and ?lang= is written only when + * window.MAP_OWNS_PAGE === true. + * 5. parity: every SWITCHER_LANGUAGES code has a library/i18n/locales// catalogue, and the + * menu always lists the current language (fixture: de, which the switcher hides). + * --self-test plants each defect in a temporary copy and must see every one refused. + * EXIT 0 pass · 1 fail + */ +import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs' +import { join, resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const VTL = 'library/layouts/VerticalTabsLayout.vue' +const MENU = 'library/components/ShellLanguageMenu.vue' +const REQ = 'library/utils/languageRequest.js' +const LANGS = 'library/layouts/app-wrapper-material/languages.js' + +const { createServer } = await import('vite') +const { default: vue } = await import('@vitejs/plugin-vue') +const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom', + server: { middlewareMode: true, hmr: false, ws: false, watch: null }, plugins: [vue()], + resolve: { alias: { '@map': join(ROOT, 'library') } } }) +const { createSSRApp, h, reactive } = await import('vue') +const { renderToString } = await import('vue/server-renderer') + +// A stand-in window for the ?lang= arm: records replaceState calls, owns the page or not. +function fakeWindow(owns) { + const calls = [] + return { calls, MAP_OWNS_PAGE: owns, location: { href: 'https://example.test/leaf/index.html?tab=x' }, + history: { state: null, replaceState: (_s, _t, url) => calls.push(url) } } +} +// A stand-in DOM for the pick: the select sits in a shadow root whose host is an EventTarget. +function fakeSelect(value, host) { return { value, getRootNode: () => ({ host }) } } + +async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) { + const bad = [] + const fail = (what, detail) => bad.push(detail ? `${what} — ${detail}` : what) + + // 1. where the bar and the rail-end render. The layout's chrome strings come through tStatic, whose + // locale detection reads ; SSR has no document, so the render gets a one-field stand-in. + if (typeof globalThis.document === 'undefined') globalThis.document = { documentElement: { lang: 'en' } } + const Layout = (await server.ssrLoadModule('/' + vtl)).default + for (const narrow of [true, false]) for (const header of [true, false]) { + // A copy of the real layout whose data says `narrow` (in a browser mounted() reads it from matchMedia, + // which SSR never runs). A copy, not `extends`: the render function is not inherited through extends. + const Comp = { ...Layout, data() { return { ...Layout.data.call(this, this), narrow } } } + const app = createSSRApp({ render: () => h(Comp, { header, items: [{ key: 'a', label: 'A' }], active: 'a' }, { + 'phone-end': () => h('i', { class: 'probe-phone-end' }), + 'rail-end': () => h('i', { class: 'probe-rail-end' }) }) }) + const html = (await renderToString(app)).replace(//g, '') // template comments are not markup + const tag = `narrow=${narrow} header=${header}` + const wantBar = narrow && !header, wantRail = !narrow && !header + if (/class="vtl-pbar"/.test(html) !== wantBar) fail(`the phone bar ${wantBar ? 'is missing' : 'renders'} at ${tag}`) + if (/probe-phone-end/.test(html) !== wantBar) fail(`#phone-end ${wantBar ? 'is not filled' : 'renders'} at ${tag}`) + if (/class="vtl-rail-end"/.test(html) !== wantRail || /probe-rail-end/.test(html) !== wantRail) fail(`#rail-end ${wantRail ? 'is missing' : 'renders'} at ${tag}`) + if (wantBar && !/class="vtl-phone-menu"[^>]*aria-expanded=/.test(html)) fail(`the bar's ☰ has no aria-expanded at ${tag}`) + if (/vtl-fab|--map-fab-/.test(html)) fail(`the old corner circle (or its --map-fab-* footprint) renders at ${tag}`) + if (wantBar && !/has-phone-bar/.test(html)) fail(`the phone bar is not a grid row (.has-phone-bar missing) at ${tag}`) + } + + // 2-4. the pick, through the menu's REAL handler (its + + + + diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 2d5f1b043..f26c6781c 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -91,20 +91,11 @@ after a resize back to desktop — the previous broken-restore bug. -->