From 992e5ff2dffcadaa8d9a89bd32ec7f467a7ea28b Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:29:55 +0000 Subject: [PATCH 01/10] The map legend says what it shows, and opens there "Zone" was our internal word leaking into the UI. The thing the map paints IS a UPG-100 list -- 27 of them, one per printable packet -- so the tab is named for that, and the legend opens on it. Region stays, described as what it actually does: sort those lists by world area when you want that. ORDER IS DELIBERATELY UNCHANGED, and the comment now says why. Tab INDEX is coupled to tree DEPTH inside the shared legend -- `activeTab = Math.min(sel.depth, ...)` and again on breadcrumb -- and the tree nests region(0) -> zone(1) -> group(2). Moving the list tab to index 0 to make it open first would have made selecting a zone jump to the Region tab instead, and SEVEN profiles share that component. I started that way and backed it out. Opening on the list tab uses the mechanism already built for it: `:default-tab`, added for country-posters with the same need ("it opens to the country's semantic level in the legend"). One prop at the call site, no shared behaviour touched, and the component clamps an out-of-range value so it degrades to the top generation rather than blanking. LegendMobile takes neither tabs nor defaultTab -- it derives its own view -- so there is nothing to wire there. Card: BUILD-UPG100-MAP-LEGEND-LEADS-WITH-THE-UPG-100-LIST (the rename + default half; print-from-selection is still open and is the packet's entry point). --- .../my-upg-100-list/upg-100-list.vue | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) 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 3ce4a92bf..2c2ec1ad2 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -271,14 +271,35 @@ const LEGEND_TITLE = 'My UPG 100 List — Legend' // by coincidence — the catalog means the ROP-2.5 sense, not this bundle's). Prefixed ids own no // catalog entry, so the labels below are what render. If these tabs are ever translated, add // `upg100Region`/`upg100Zone`/`upg100Group` to tabs.json rather than reusing a generic key. +// ★ "Zone" RENAMED, ORDER DELIBERATELY UNCHANGED (2026-09-23). +// Driver: "the user does not know what zones means" — the thing the map paints IS +// a UPG-100 list (27 of them); "zone" was our internal word leaking into the UI. +// So the LABEL now names what it shows. Region stays as what it actually is: a way +// to SORT those lists by world area when you want that. +// +// ⚠ DO NOT REORDER THESE TO MAKE THE LIST TAB OPEN FIRST. Tab INDEX is coupled to +// tree DEPTH in the shared legend: `activeTab = Math.min(sel.depth, …)` and again +// on breadcrumb (SemanticTreeLegend ~231 and ~418). The tree nests region(0) → +// zone(1) → group(2), so moving zone to index 0 would make selecting a zone jump +// to the Region tab. Seven profiles share that component. +// Opening on the list tab is done the way the codebase already provides for it: +// `:default-tab="1"` at the call site — the prop country-posters added for exactly +// this ("it opens to the country's semantic level in the legend"). +// +// NOTE the warning above: these prefixed ids own no catalog entry, so THESE labels +// are what render. If the packet is ever translated, add upg100Zone / upg100Region +// / upg100Group to tabs.json rather than reusing a generic key. const LEGEND_TABS = [ { id: 'upg100Region', label: 'Region', - info: 'Region — the broadest grouping, one row per world area (Africa, India, Middle East, Deaf, …). Counts are the UUPGs and population summed across every zone beneath it.' }, - { id: 'upg100Zone', label: 'Zone', - info: 'Zone — the prayer zones inside a region (e.g. Frontier Asia (uncovered) within Asia, or the Indian states within India). This is the level the map paints and colours.' }, + info: 'Region — sort the UPG-100 lists by world area (Africa, India, Middle East, Deaf, …). Counts are the UUPGs and population summed across every list beneath it.' }, + { id: 'upg100Zone', label: 'UPG-100 Lists', + info: 'UPG-100 Lists — each one is a prayer list of the unreached people groups in an area, and is what the map paints and colours. Every list prints as its own packet.' }, { id: 'upg100Group', label: 'People Group', - info: 'People Group — the individual unreached groups inside a zone, largest prayer need first. Each map dot is one of these.' }, + info: 'People Group — the individual unreached groups inside a list, largest prayer need first. Each map dot is one of these.' }, ] +// The generation the legend OPENS on: index 1 = the UPG-100 Lists tab. A reader +// should meet the lists first and reach for Region only to sort them. +const LEGEND_DEFAULT_TAB = 1 const geocoderContext = 'people-groups' const geocoderPlaceholder = 'Search people group, zone, or territory…' @@ -1810,6 +1831,7 @@ onBeforeUnmount(() => { v-if="legendNodes.length" :nodes="legendNodes" :tabs="LEGEND_TABS" + :default-tab="LEGEND_DEFAULT_TAB" :columns="['count', 'pop']" :title="LEGEND_TITLE" :export-enabled="true" From cb5285017dde2dc9bcfc622518f36ec348aae812 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:53:21 +0000 Subject: [PATCH 02/10] test(packet): gate the prayer packet at the RENDERED layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bug this gate exists for produced a file of the right size, with no error, no broken image, and a generator reporting success. They were only visible once a browser laid the document out, so this drives a real browser and reads computed style rather than inspecting the HTML source. ★ The law it encodes: never print a number without asserting on it. The throwaway probe this replaces PRINTED "PDF pages: 16" for several runs while reporting "packet verified", because the failure counter never looked at that value. A number that is displayed but not asserted on is decoration. Two regressions it catches, both confirmed to FAIL before this was accepted: transform instead of zoom -> 16 PDF pages for 8 sheets map stylesheet removed -> unresolved --paper/--sea/--frameline; 660/661 map elements fill BLACK The second is the subtler one. The map SVG carries almost no presentation attributes; it is styled from the poster document's stylesheet. Lift the without those rules and SVG fill falls back to its initial value, which is black — the whole map paints as a solid rectangle with nothing to see in the DOM, no console error, and no broken image. It is asserted as a WHOLE-MAP property because the first attempt tested one hand-picked (the one just repaired) and went green while 647 others were still black. Page count is load-bearing rather than cosmetic: the packet is printed front-and-back, so "one sheet is exactly one page" is a correctness property, not a tidiness one. Counts are relative (sheets vs pages, cards vs photos) so the gate is not pinned to one zone's group count, and the packet path is an argument or PACKET_HTML rather than a hardcoded path. With no packet given it SKIPS rather than failing, since the packet is generated on demand. --- internal/check-packet-render.mjs | 126 +++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 127 insertions(+) create mode 100644 internal/check-packet-render.mjs diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs new file mode 100644 index 000000000..60a3df5db --- /dev/null +++ b/internal/check-packet-render.mjs @@ -0,0 +1,126 @@ +// check-packet-render — the UPG-100 prayer packet must RENDER, not merely generate. +// +// Every bug this gate exists for produced a file of the right size, with no error, no +// broken image, and a generator that reported success. They were only visible at the +// RENDERED layer, which is why this drives a real browser and reads computed style. +// +// ★ THE LAW THIS GATE ENCODES: never print a number without asserting on it. An earlier +// version of this probe PRINTED "PDF pages: 16" for several runs while reporting +// "✓ packet verified", because the failure counter never looked at that value. A number +// that is displayed but not asserted on is decoration, not verification. +// +// What it catches, each measured on a real regression: +// 1. PAGE COUNT. transform:scale() is PAINT-time, so a scaled sheet still RESERVES its +// full layout box and the paginator splits it — 8 sheets came out as 16 PDF pages, +// inking only the top of each. (zoom is LAYOUT-time and paginates correctly.) +// The packet is printed front-and-back, so an exact page count is load-bearing. +// 2. MAP PALETTE. The map SVG carries almost no presentation attributes; it is styled +// from the poster document's stylesheet. Lift the without those rules and SVG +// `fill` falls back to its INITIAL value, which is BLACK — 670 elements painted solid +// black with no error of any kind. Asserted as a WHOLE-MAP property, because the first +// attempt at this check tested one hand-picked (the one just repaired) and went +// green while 647 others were still black. +// 3. Content the Driver specified explicitly: Mark 5:1-20 in full, 12 wheel segments, +// the prayer rhythms, and exactly two minimal metacamp.org attributions. +// +// Usage: node internal/check-packet-render.mjs [path/to/packet.html] +// Default path is overridable with PACKET_HTML so this is not machine-specific. + +import { chromium } from 'playwright' +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const target = process.argv[2] || process.env.PACKET_HTML +if (!target) { + console.log('PACKET-RENDER: SKIP — no packet given (pass a path or set PACKET_HTML).') + console.log(' The packet is generated on demand, so this gate is opt-in rather than') + console.log(' failing a build on a file that is not expected to exist.') + process.exit(0) +} +const file = resolve(target) +if (!existsSync(file)) { + console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`) + process.exit(1) +} + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } }) +const errs = [] +page.on('pageerror', (e) => errs.push(String(e).slice(0, 120))) +await page.goto(pathToFileURL(file).href, { waitUntil: 'networkidle' }) + +const r = await page.evaluate(() => { + const body = document.body.innerText + const imgs = [...document.querySelectorAll('.card img')] + const RHYTHMS = ['PRAISE', 'WAIT', 'CONFESS', 'READ THE WORD', 'ASK', 'INTERCESSION', + 'PRAY THE WORD', 'THANK', 'SING', 'MEDITATE', 'LISTEN'] + + // Whole-map property, NOT one sampled element — see header note 2. + const mapFaults = (() => { + const svg = document.querySelector('.sheet svg') + if (!svg) return ['no map svg found'] + const faults = [] + const names = [...new Set((svg.outerHTML.match(/var\((--[a-z0-9-]+)/g) || []).map((v) => v.slice(4)))] + const cs = getComputedStyle(svg) + for (const n of names) if (!cs.getPropertyValue(n).trim()) faults.push(`unresolved ${n}`) + const all = [...svg.querySelectorAll('path,circle,text,rect,line')] + const black = all.filter((e) => getComputedStyle(e).fill === 'rgb(0, 0, 0)') + if (all.length && black.length > all.length * 0.25) { + faults.push(`${black.length}/${all.length} map elements fill BLACK`) + } + return faults + })() + + return { + sheets: document.querySelectorAll('.sheet').length, + cards: document.querySelectorAll('.card').length, + imgs: imgs.length, + broken: imgs.filter((i) => !i.complete || i.naturalWidth === 0).length, + nophoto: document.querySelectorAll('.nophoto').length, + rhythms: RHYTHMS.filter((w) => body.includes(w)).length, + rhythmsTotal: RHYTHMS.length, + wheelSegs: document.querySelectorAll('svg path[fill^="hsl"]').length, + hasMark5: body.includes('Gadarenes'), // a word unique to Mark 5:1-20 + metacamp: (body.match(/metacamp\.org/g) || []).length, + mapFaults, + } +}) + +const pdf = await page.pdf({ preferCSSPageSize: true }) +await browser.close() + +const raw = pdf.toString('latin1') +const boxes = [...raw.matchAll(/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/g)].map((m) => [+m[1], +m[2]]) +const pages = (raw.match(/\/Type\s*\/Page[^s]/g) || []).length +const landscape = boxes.filter(([w, h]) => h <= w).length + +// Counts are RELATIVE (sheets vs pages, cards vs photos) so this gate is not pinned to +// one zone's group count — a per-zone packet has a different number of both. +const fails = [] +if (!r.sheets) fails.push('no .sheet elements — the packet did not render') +if (!r.cards) fails.push('no people-group cards') +if (r.broken) fails.push(`${r.broken} broken photo(s)`) +if (r.wheelSegs !== 12) fails.push(`prayer wheel has ${r.wheelSegs} segments, expected 12`) +if (r.rhythms !== r.rhythmsTotal) fails.push(`only ${r.rhythms}/${r.rhythmsTotal} prayer rhythms present`) +if (!r.hasMark5) fails.push('Mark 5:1-20 is NOT printed in full (Driver requirement for point 4)') +if (r.metacamp !== 2) fails.push(`${r.metacamp} metacamp.org attributions, expected exactly 2`) +if (r.mapFaults.length) fails.push(`map: ${r.mapFaults.join('; ')}`) +if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`) +if (landscape) fails.push(`${landscape} landscape page(s) — the packet must be portrait`) +if (pages !== r.sheets) fails.push(`${pages} PDF pages for ${r.sheets} sheets — one sheet must be exactly one page`) + +console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`) +console.log(` people cards : ${r.cards} (${r.imgs} photos, ${r.nophoto} typographic)`) +console.log(` wheel segments : ${r.wheelSegs}`) +console.log(` Mark 5 in full : ${r.hasMark5}`) +console.log(` metacamp.org : ${r.metacamp}`) +console.log(` map : ${r.mapFaults.length ? r.mapFaults.join('; ') : 'palette resolves, not black'}`) +console.log(` page size : ${boxes[0] ? boxes[0].join(' x ') : '?'} pt`) + +if (fails.length) { + console.error(`\nPACKET-RENDER: FAIL — ${fails.length} problem(s)`) + for (const f of fails) console.error(` - ${f}`) + process.exit(1) +} +console.log(`\nPACKET-RENDER: PASS — ${r.sheets} sheets, ${r.sheets} pages, portrait, map painted, content intact.`) diff --git a/package.json b/package.json index ba06dc2dd..b5ef4f07b 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "check:heartbeat": "node internal/check-heartbeat-threshold.mjs # 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": "node internal/check-heartbeat-threshold.mjs --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.", "build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", + "check:packet-render": "node internal/check-packet-render.mjs", "check:template-literal": "node internal/template-literal-check.mjs", "verify:map-paint": "node internal/verify-map-paint.mjs", "check:sri": "node internal/sri-consistency-check.mjs", From 394741bf7afdd57e64e5cc4becab2ccc0e86e833 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:10:17 +0000 Subject: [PATCH 03/10] test(deck): click the REAL button and read what it prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other packet/card gate drives the RENDERERS. This one drives the UI: it finds the Card Deck button in the shadow root, clicks it, and captures the document that reaches the print frame. That is the only path proving the wiring — records provider, stock picker, language, shared composable, print frame — is connected end to end. A renderer can be perfect while the button calling it passes the wrong stock, or nothing at all. ⚠ It intercepts the printed HTML rather than reading the iframe back. openPrintFrame calls window.print(), which BLOCKS in headless Chromium, and the frame self-removes on afterprint — reading it back measures a frame that has already gone and reports 0 cards for a correct deck. Measured through the real UI: 116 cards at 2.5in 3.5in with 116 QR codes, and 3.5in 2.5in when the stock picker is switched to landscape. Confirmed to fail on a wrong expected page size. Also updates the share policy: country-posters moves from 'own-button' to 'header'. Its two-tab MFE now owns sharing, exactly as my-upg-100-list's does — the gate caught the bundle briefly carrying two affordances after the MFE landed, which is the regression it was written for. --- internal/check-deck-click.mjs | 99 +++++++++++++++++++++++++++++++++ internal/check-share-policy.mjs | 2 +- package.json | 1 + 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 internal/check-deck-click.mjs diff --git a/internal/check-deck-click.mjs b/internal/check-deck-click.mjs new file mode 100644 index 000000000..b9d3a80a6 --- /dev/null +++ b/internal/check-deck-click.mjs @@ -0,0 +1,99 @@ +// check-deck-click — click the REAL Card Deck button and read what it prints. +// +// Every other packet/card gate drives the RENDERERS directly. This one drives the +// UI: it finds the button in the shadow root, clicks it, and captures the document +// that reaches the print frame. That is the only path that proves the wiring — +// records provider, stock picker, language, shared composable, print frame — is +// connected end to end. A renderer can be perfect while the button that calls it +// passes the wrong stock, or nothing at all. +// +// ⚠ IT INTERCEPTS THE PRINTED HTML rather than reading the iframe back. +// openPrintFrame calls window.print(), which BLOCKS in headless Chromium, and the +// frame self-removes on afterprint — reading it back measures a frame that has +// already gone and reports 0 cards for a correct deck. +// +// Usage: node internal/check-deck-click.mjs [stockId] [expectedPage] +import { chromium } from 'playwright' + +const URL_ = process.argv[2] +if (!URL_) { console.error('usage: check-deck-click.mjs [stock] [expectedPage]'); process.exit(2) } +const STOCK = process.argv[3] || '' +const EXPECT_PAGE = process.argv[4] || (STOCK === 'pokerLandscape' ? '3.5in 2.5in' : '2.5in 3.5in') + +const b = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-unsafe-swiftshader'] }) +const pg = await b.newPage({ viewport: { width: 1280, height: 1000 } }) +const errs = [] +pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140))) +await pg.goto(URL_, { waitUntil: 'load', timeout: 45000 }) +await pg.waitForTimeout(9000) + +await pg.evaluate(() => { + window.__printed = '' + const real = document.createElement.bind(document) + document.createElement = (t) => { + const el = real(t) + if (t === 'iframe') { + Object.defineProperty(el, 'contentDocument', { + get: () => ({ open() {}, close() {}, write(h) { window.__printed += h } }), + }) + Object.defineProperty(el, 'contentWindow', { + get: () => ({ addEventListener() {}, focus() {}, print() {} }), + }) + } + return el + } +}) + +const r = await pg.evaluate(async (stock) => { + const host = [...document.querySelectorAll('*')] + .find((e) => e.shadowRoot && e.shadowRoot.querySelector('.pbtn')) + if (!host) return { error: 'no poster wall found on this page' } + const sr = host.shadowRoot + const btns = [...sr.querySelectorAll('.pbtn')] + const deck = btns.find((x) => /card deck/i.test(x.textContent)) + if (!deck) return { error: 'no Card Deck button', buttons: btns.map((x) => x.textContent.trim()) } + if (stock) { + const sel = sr.querySelector('.pstock') + if (!sel) return { error: 'no card-stock picker to drive' } + sel.value = stock + sel.dispatchEvent(new Event('change', { bubbles: true })) + await new Promise((res) => setTimeout(res, 400)) + } + deck.click() + await new Promise((res) => setTimeout(res, 9000)) + const doc = new DOMParser().parseFromString(window.__printed, 'text/html') + return { + cards: doc.querySelectorAll('.card').length, + qrs: doc.querySelectorAll('.qr svg').length, + imgs: doc.querySelectorAll('img.ph').length, + page: (window.__printed.match(/@page\{size:([^;}]*)/) || [])[1] || '', + } +}, STOCK) +await b.close() + +if (r.error) { + console.error(`DECK-CLICK: FAIL — ${r.error}` + + (r.buttons ? ` (buttons present: ${r.buttons.join(' | ')})` : '')) + process.exit(1) +} + +const fails = [] +if (!r.cards) fails.push('the click printed no cards at all — the button is not wired to the renderer') +if (r.page.trim() !== EXPECT_PAGE) { + fails.push(`@page size is "${r.page.trim()}", expected "${EXPECT_PAGE}"` + + (STOCK ? ' — the stock picker is not reaching the renderer' : '')) +} +if (r.qrs !== r.cards) fails.push(`${r.qrs} QR code(s) for ${r.cards} cards`) +if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`) + +console.log(` stock : ${STOCK || '(default)'}`) +console.log(` cards printed : ${r.cards} (${r.imgs} portraits, ${r.qrs} QR codes)`) +console.log(` @page size : ${r.page.trim()} (want ${EXPECT_PAGE})`) +console.log(` page errors : ${errs.length}`) + +if (fails.length) { + console.error(`\nDECK-CLICK: FAIL — ${fails.length} problem(s)`) + for (const f of fails) console.error(' - ' + f) + process.exit(1) +} +console.log(`\nDECK-CLICK: PASS — the real button printed ${r.cards} cards at ${r.page.trim()}.`) diff --git a/internal/check-share-policy.mjs b/internal/check-share-policy.mjs index 70ca86d36..5a86565e1 100644 --- a/internal/check-share-policy.mjs +++ b/internal/check-share-policy.mjs @@ -45,7 +45,7 @@ const DESIGNS = join(REPO, 'plugin-designs') const POLICY = { 'doxa-simple-map': 'own-button', // Prayer Progress — the embed flagship 'doxa-research-map': 'own-button', - 'country-posters': 'own-button', + 'country-posters': 'header', // country-posters-mfe header owns it 'cities-internet': 'own-button', 'gospel-resources': 'own-button', 'demo-map': 'own-button', diff --git a/package.json b/package.json index c033b877a..fa6e02008 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "build:unlocked": "bun run gates:pre && bun run library-index && bun run check:library-index && bun run clean && bun run token && bun run build:bundles && bun run tree && bun run gates:post && bun run gallery", "check:card-deck": "node internal/check-card-deck.mjs", "check:poster-raster": "node internal/check-poster-raster.mjs", + "check:deck-click": "node internal/check-deck-click.mjs", "check:poster-prints": "node internal/check-poster-prints.mjs", "check:provider-switch": "node internal/check-provider-switch.mjs", "check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs", From 248c2cc6d6fdc8ce9a8429be8d283181df07db38 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:22:17 +0000 Subject: [PATCH 04/10] test(poster): assert the shared PACKET action's shape, not just its bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packet is the product's core artefact, and it moved into the shared composable this tick. The gate now drives printPacket and asserts what came out: 4 sheets for 2 groups (map one-pager + one people page + prayer points + prayer wheel), 12 wheel segments, Mark 5:1-20 present. Confirmed to fail before acceptance — removing the wheel page reports 3 sheets and 0 segments. ⚠ THE HARNESS HAD TO RESOLVE THE RENDERERS, and the first run exposed why. The composable imports them by RELATIVE path; under a blob URL there is no directory to resolve '../packet/...' against, so the action failed with "Failed to resolve module specifier" and printed nothing — reported as 0 sheets, which reads like a product bug. It is not: Vite resolves them and the built bundle inlines both renderers (verified in the shipped my-upg-100-list.js). The harness now substitutes blob URLs for packet-render, card-render, qr and the strings catalog, so the gate exercises the REAL renderers rather than a stub. --- internal/check-poster-prints.mjs | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/internal/check-poster-prints.mjs b/internal/check-poster-prints.mjs index bc1f66657..f63988ab1 100644 --- a/internal/check-poster-prints.mjs +++ b/internal/check-poster-prints.mjs @@ -25,6 +25,15 @@ const REPO = process.env.DOXA_REPO || resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet') const src = readFileSync(resolve(REPO, 'library/composables/usePosterPrints.js'), 'utf8') const frame = readFileSync(resolve(REPO, 'library/composables/printInPlace.js'), 'utf8') +// The composable dynamically imports its renderers by RELATIVE path. Under a blob +// URL there is no directory to resolve '../packet/...' against, so those imports +// fail with "Failed to resolve module specifier" — a harness limitation, not a +// product bug (Vite resolves them, and the built bundle inlines both renderers). +// Read them here and substitute blob URLs so the gate exercises the REAL code. +const packetRender = readFileSync(resolve(REPO, 'library/packet/packet-render.js'), 'utf8') +const cardRender = readFileSync(resolve(REPO, 'library/packet/card-render.js'), 'utf8') +const qr = readFileSync(resolve(REPO, 'library/packet/qr.js'), 'utf8') +const strings = readFileSync(resolve(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8') const b = await chromium.launch() const pg = await b.newPage({ viewport: { width: 1000, height: 800 } }) @@ -32,16 +41,24 @@ const errs = [] pg.on('pageerror', (e) => errs.push(String(e).slice(0, 140))) await pg.setContent('') -const r = await pg.evaluate(async ({ src, frame }) => { +const r = await pg.evaluate(async ({ src, frame, packetRender, cardRender, qr, strings }) => { // Shim the two module deps so the composable can be evaluated standalone: a tiny // `ref`, and printInPlace's real source so the frame mechanism is the real one. const frameUrl = URL.createObjectURL(new Blob([frame], { type: 'text/javascript' })) const vueShim = URL.createObjectURL(new Blob([ 'export const ref = (v) => ({ value: v })', ], { type: 'text/javascript' })) + const blob = (code) => URL.createObjectURL(new Blob([code], { type: 'text/javascript' })) + const qrUrl = blob(qr) + const packetUrl = blob(packetRender.replace("from './qr.js'", `from '${qrUrl}'`)) + const cardUrl = blob(cardRender.replace("from './qr.js'", `from '${qrUrl}'`)) + const stringsUrl = URL.createObjectURL(new Blob([`export default ${strings}`], { type: 'text/javascript' })) const patched = src .replace("from 'vue'", `from '${vueShim}'`) .replace("from './printInPlace.js'", `from '${frameUrl}'`) + .replace("'../packet/packet-render.js'", `'${packetUrl}'`) + .replace("'../packet/card-render.js'", `'${cardUrl}'`) + .replace(/'\.\.\/scripts\/posters\/packet-strings\.json'/g, `'${stringsUrl}'`) const modUrl = URL.createObjectURL(new Blob([patched], { type: 'text/javascript' })) const { usePosterPrints } = await import(modUrl) @@ -78,13 +95,42 @@ const r = await pg.evaluate(async ({ src, frame }) => { out.listHasCheckbox = doc ? /□/.test(doc.body.innerText) : false out.listPhase = withProv.exportState.value?.phase ?? 'cleared' + // ── the PACKET action: same provider, the full booklet ─────────────────── + // Migrated from upg-100-list.vue, where it was a second hand-maintained copy of + // the same renderer call. Asserted here so the shared version cannot regress + // silently — the packet is the product's core artefact. + const PKT = [ + { slug: 'a', name: 'Aheu', country_code_label: 'Laos', population: 2700, image_url: 'x.jpg' }, + { slug: 'b', name: 'Akha', country_code_label: 'Laos', population: 113000, image_url: 'y.jpg' }, + ] + printed = '' + document.createElement = (tag) => { + const el = realCreate(tag) + if (tag === 'iframe') { + Object.defineProperty(el, 'contentDocument', { + get: () => ({ open() {}, close() {}, write(h) { printed += h } }), + }) + Object.defineProperty(el, 'contentWindow', { get: () => ({ addEventListener() {} }) }) + } + return el + } + const pk = usePosterPrints({ records: async () => PKT, label: (i) => i.label }) + await pk.printPacket({ id: 'laos', label: 'Laos' }) + await new Promise((r2) => setTimeout(r2, 600)) + document.createElement = realCreate + const pdoc = new DOMParser().parseFromString(printed, 'text/html') + out.packetSheets = pdoc.querySelectorAll('.sheet').length + out.packetCards = pdoc.querySelectorAll('.card').length + out.packetWheel = pdoc.querySelectorAll('svg path[fill^="hsl"]').length + out.packetMark5 = /Gadarenes/.test(pdoc.body.innerText) + // ── without a provider: must FAIL HONESTLY, not throw ──────────────────── const noProv = usePosterPrints({ label: (i) => i.label }) await noProv.printList({ id: 'x', label: 'X' }) out.noProvPhase = noProv.exportState.value?.phase out.noProvMsg = noProv.exportState.value?.message || '' return out -}, { src, frame }) +}, { src, frame, packetRender, cardRender, qr, strings }) await b.close() const fails = [] @@ -97,10 +143,19 @@ if (r.noProvPhase !== 'error') { if (!/no records provider/i.test(r.noProvMsg)) { fails.push(`the no-provider message was "${r.noProvMsg}" — it should name the missing provider`) } +// The packet is the product's core artefact: map page + people pages + points + +// wheel. Assert its SHAPE, not merely that bytes were produced. With 2 groups +// that is 1 map + 1 people page + points + wheel = 4 sheets. +if (r.packetSheets !== 4) fails.push(`the packet printed ${r.packetSheets} sheets, expected 4`) +if (r.packetCards !== 2) fails.push(`the packet printed ${r.packetCards} people cards, expected 2`) +if (r.packetWheel !== 12) fails.push(`the prayer wheel has ${r.packetWheel} segments, expected 12`) +if (!r.packetMark5) fails.push('the packet is missing Mark 5:1-20 in full') if (errs.length) fails.push(`${errs.length} page error(s): ${errs[0]}`) console.log(` list print : ${r.listRows} rows, checkbox=${r.listHasCheckbox}`) console.log(` without provider : phase=${r.noProvPhase} "${r.noProvMsg.slice(0, 60)}"`) +console.log(` packet : ${r.packetSheets} sheets, ${r.packetCards} cards, ` + + `${r.packetWheel} wheel segments, Mark5=${r.packetMark5}`) console.log(` page errors : ${errs.length}`) if (fails.length) { From d0ecd2f7803779320ba4ad18cae739229ea42b2f Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:34:47 +0000 Subject: [PATCH 05/10] test(i18n): every packet language must be SELECTABLE, not merely present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug this gates is unreachability, not incorrectness. The packet shipped 11 complete translations while detectLocale() read only , which every generated leaf page hardcodes to "en" — so ten of them could not be reached by any reader on the static tree. Nothing was wrong; nothing was visible. Two assertions, because they fail separately: RESOLUTION ?lang= / ?locale= selects a supported locale, an unsupported value falls back to English, a region tag (pt-BR) resolves to its base, and with no param the host page still wins. REACHABILITY the packet's language list and SUPPORTED_LOCALES cannot drift apart, and the leaf pages still declare a lang — so if someone later drops that attribute, the host-page path does not silently become unavailable while the tests stay green. ★ It evaluates the SHIPPED detectLocale rather than reimplementing it. A gate that reimplements the thing it tests only ever proves the copy agrees with itself — which is how a fix can be reverted in the source while the gate keeps passing. Confirmed to fail before acceptance: removing the URL branch reports all four param cases resolving to "en". --- internal/check-locale-reachable.mjs | 93 +++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 94 insertions(+) create mode 100644 internal/check-locale-reachable.mjs diff --git a/internal/check-locale-reachable.mjs b/internal/check-locale-reachable.mjs new file mode 100644 index 000000000..d3454e888 --- /dev/null +++ b/internal/check-locale-reachable.mjs @@ -0,0 +1,93 @@ +// check-locale-reachable — a reader must be able to CHOOSE one of the 11 locales. +// +// ★ THE BUG THIS EXISTS FOR IS UNREACHABILITY, not incorrectness. The packet ships +// in 11 languages and every string is translated — and detectLocale() read ONLY +// document.documentElement.lang, which EVERY generated leaf page in this repo +// hardcodes to "en" (measured: 20 of 20). On the built static tree — the jamstack +// case these bundles exist for — ten of the eleven translations could not be +// reached by any reader. Correct, complete, and invisible. +// +// So this asserts two separate things, because they fail separately: +// 1. RESOLUTION — ?lang= / ?locale= selects a supported locale, a bad value falls +// back to English, and a region tag (pt-BR) resolves to its base language. +// Tested against the SHIPPED source of detectLocale, not a copy. +// 2. REACHABILITY — the built leaf pages still declare a lang, so the host-page +// path (Polylang on WordPress) keeps working. If someone later makes the pages +// lang-less, the URL path alone would silently become the only way in. +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { resolve, join } from 'node:path' + +const REPO = process.env.DOXA_REPO + || resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet') +const src = readFileSync(join(REPO, 'library/i18n/index.js'), 'utf8') + +const SUPPORTED = (src.match(/SUPPORTED_LOCALES = \[([^\]]*)\]/) || [])[1] + ?.split(',').map((s) => s.trim().replace(/['"]/g, '')).filter(Boolean) || [] + +// Evaluate the SHIPPED detectLocale rather than reimplementing it — a gate that +// reimplements the thing it tests only ever proves the copy agrees with itself. +const body = src.slice(src.indexOf('export function detectLocale'), src.indexOf('export function createAppI18n')) +const make = new Function('SUPPORTED_LOCALES', 'window', 'document', + `${body.replace('export function', 'return function')}\n; return detectLocale;`) +const detect = (search, htmlLang) => + make(SUPPORTED, { location: { search } }, { documentElement: { lang: htmlLang } })() + +const CASES = [ + ['?lang=es', 'en', 'es', 'a lang param selects a supported locale'], + ['?locale=fr', 'en', 'fr', 'the locale spelling works too'], + ['?lang=pt-BR', 'en', 'pt', 'a region tag resolves to its base language'], + ['?lang=zz', 'en', 'en', 'an unsupported value falls back to English'], + ['?lang=', 'de', 'de', 'an empty param defers to the host page'], + ['', 'de', 'de', 'with no param the host page still wins'], + ['?lang=ar', 'en', 'ar', 'RTL locales are selectable like any other'], +] + +const fails = [] +for (const [q, htmlLang, want, why] of CASES) { + let got + try { got = detect(q, htmlLang) } catch (e) { got = `THREW: ${e.message}` } + if (got !== want) fails.push(`${q || '(no param)'} with html lang="${htmlLang}" -> "${got}", expected "${want}" (${why})`) +} + +// Every packet language must be reachable: the two lists cannot drift apart. +const strings = JSON.parse(readFileSync(join(REPO, 'library/scripts/posters/packet-strings.json'), 'utf8')) +const packetLangs = Object.keys(strings).filter((k) => !k.startsWith('_')) +const unreachable = packetLangs.filter((l) => !SUPPORTED.includes(l)) +if (unreachable.length) { + fails.push(`the packet ships ${unreachable.join(', ')} but SUPPORTED_LOCALES does not list them — ` + + 'those translations cannot be selected') +} + +// The host-page path must survive: leaf pages still declare a lang. +const leafRoot = join(REPO, 'public/doxa-maps') +let leaves = 0 +let withLang = 0 +if (existsSync(leafRoot)) { + for (const bundle of readdirSync(leafRoot)) { + const bdir = join(leafRoot, bundle) + let subs = [] + try { subs = readdirSync(bdir) } catch { continue } + for (const sub of subs) { + const page = join(bdir, sub, 'index.html') + if (!existsSync(page)) continue + leaves += 1 + if (/]*\blang=/.test(readFileSync(page, 'utf8'))) withLang += 1 + } + } +} +if (leaves && withLang !== leaves) { + fails.push(`${leaves - withLang} of ${leaves} leaf pages declare no lang — ` + + 'the host-page detection path is broken for them') +} + +console.log(` supported locales : ${SUPPORTED.length} (${SUPPORTED.join(' ')})`) +console.log(` packet languages : ${packetLangs.length} (${packetLangs.join(' ')})`) +console.log(` resolution cases : ${CASES.length - fails.filter((f) => f.includes('->')).length}/${CASES.length} correct`) +console.log(` leaf pages w/ lang: ${withLang}/${leaves}`) + +if (fails.length) { + console.error(`\nLOCALE-REACHABLE: FAIL — ${fails.length} problem(s)`) + for (const f of fails) console.error(' - ' + f) + process.exit(1) +} +console.log(`\nLOCALE-REACHABLE: PASS — all ${packetLangs.length} packet languages are selectable.`) diff --git a/package.json b/package.json index fa6e02008..a384b1efc 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "check:card-deck": "node internal/check-card-deck.mjs", "check:poster-raster": "node internal/check-poster-raster.mjs", "check:deck-click": "node internal/check-deck-click.mjs", + "check:locale-reachable": "node internal/check-locale-reachable.mjs", "check:poster-prints": "node internal/check-poster-prints.mjs", "check:provider-switch": "node internal/check-provider-switch.mjs", "check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs", From 986b83ee77a65fad2571806e7fa9d0df27467174 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:43:32 +0000 Subject: [PATCH 06/10] =?UTF-8?q?test(i18n):=20a=20key=20in=20English=20mu?= =?UTF-8?q?st=20exist=20in=20every=20locale=20=E2=80=94=20ratcheted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failure here is silent BY DESIGN. vue-i18n falls back to English for a missing key, which is the right runtime behaviour and exactly why catalogs drift unnoticed: the app looks fine, nothing errors, and a reader who chose Spanish just gets English words with no signal anything is missing. Measured when this was written: buttons.json had 21 keys in English and 19 in every other locale. The orphans were `share` and `shareSteps` — the Share button's title and its explainer, rendered on EVERY map. Ten locales had been shipping an English Share button since those keys landed. ★ IT FOUND MUCH MORE THAN THE BUG THAT PROMPTED IT: 429 missing keys across ten locales, with popup.json existing only in English and it/ro/zh also lacking regions.json and tabs.json entirely — 101 of 176 keys each. So it RATCHETS rather than blocks. Each locale has a ceiling; the gate fails when one gets WORSE and prints a nudge when one gets better so the ceiling can be lowered. Failing outright on 429 keys of debt this gate did not create would block every build, and a gate everyone disables protects nothing. A locale with no ceiling entry must be complete, so a NEW locale cannot be added half-done. Keys are compared FLATTENED, so a nested object or an array is checked key-by-key rather than counted as one present key. Confirmed to fail before acceptance: deleting two French keys reports "fr regressed: 8 missing key(s), was 6" and names them. --- internal/check-i18n-parity.mjs | 107 +++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 108 insertions(+) create mode 100644 internal/check-i18n-parity.mjs diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs new file mode 100644 index 000000000..4f9d4dd06 --- /dev/null +++ b/internal/check-i18n-parity.mjs @@ -0,0 +1,107 @@ +// check-i18n-parity — a key that exists in English must exist in every locale. +// +// ★ THE FAILURE IS SILENT BY DESIGN. vue-i18n falls back to English for a missing +// key, which is the right runtime behaviour and the reason this drifts unnoticed: +// the app looks fine, nothing errors, and a reader who chose Spanish simply gets +// English words with no indication anything is missing. +// +// Measured when this was written: buttons.json had 21 keys in English and 19 in +// every other locale. The two orphans were `share` and `shareSteps` — the Share +// button's own title and its five-step explainer, rendered on EVERY map. Ten +// locales had been shipping an English Share button since the key was added. +// +// It reports per-locale so a partial translation is visible as a number rather +// than as a vague sense that "some things aren't translated". +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { resolve, join } from 'node:path' + +const REPO = process.env.DOXA_REPO + || resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet') +const ROOT = join(REPO, 'library/i18n/locales') +if (!existsSync(ROOT)) { console.error(`I18N-PARITY: FAIL — no locales at ${ROOT}`); process.exit(1) } + +// Flatten so nested objects and arrays are compared key-by-key, not shallowly. +const flat = (obj, prefix = '', out = {}) => { + for (const [k, v] of Object.entries(obj || {})) { + const key = prefix ? `${prefix}.${k}` : k + if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out) + else out[key] = v + } + return out +} + +const read = (loc, area) => { + const p = join(ROOT, loc, area) + if (!existsSync(p)) return null + try { return flat(JSON.parse(readFileSync(p, 'utf8'))) } catch { return undefined } +} + +const locales = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d))) +if (!locales.includes('en')) { console.error('I18N-PARITY: FAIL — no English catalog to compare against'); process.exit(1) } +const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json')) + +// ── KNOWN DEBT, RATCHETED ──────────────────────────────────────────────────── +// Every locale already carries pre-existing gaps — popup.json exists only in +// English, and it/ro/zh also lack regions.json and tabs.json. Failing on all of +// that would block every build over debt this gate did not create, and a gate +// everyone disables protects nothing. +// +// So the numbers below are a CEILING, not a target: the gate fails when a locale +// gets WORSE, and prints a nudge when one gets better so the ceiling can be +// lowered. That makes drift impossible while leaving the backlog as backlog. +// Lower a number whenever you translate something; never raise one. +const DEBT = { + ar: 10, de: 30, es: 10, fr: 6, hi: 50, it: 101, pt: 10, ro: 101, ru: 10, zh: 101, +} + +const fails = [] +const improved = [] +const rows = [] +for (const loc of locales.filter((l) => l !== 'en').sort()) { + let missing = 0 + let total = 0 + const examples = [] + for (const area of areas) { + const en = read('en', area) + const other = read(loc, area) + total += Object.keys(en).length + if (other === null) { missing += Object.keys(en).length; examples.push(`${area} (whole file)`); continue } + if (other === undefined) { fails.push(`${loc}/${area} is not valid JSON`); continue } + for (const k of Object.keys(en)) { + if (!(k in other)) { missing += 1; if (examples.length < 3) examples.push(`${area}:${k}`) } + } + } + rows.push({ loc, missing, total, examples }) + const ceiling = DEBT[loc] + if (ceiling === undefined) { + if (missing) fails.push(`${loc} is missing ${missing}/${total} key(s) and has no DEBT entry — ` + + `e.g. ${examples.join(', ')}`) + } else if (missing > ceiling) { + fails.push(`${loc} regressed: ${missing} missing key(s), was ${ceiling} — ` + + `e.g. ${examples.join(', ')}`) + } else if (missing < ceiling) { + improved.push(`${loc}: ${missing} missing, ceiling still says ${ceiling} — lower it`) + } +} + +console.log(` locales : ${locales.length} (${locales.sort().join(' ')})`) +console.log(` areas : ${areas.length} catalog file(s), ${Object.keys(read('en', areas[0]) || {}).length}+ keys each`) +for (const r of rows) { + const c = DEBT[r.loc] + const tag = r.missing === 0 ? 'complete' + : (c !== undefined && r.missing <= c ? `${r.missing} missing (known debt, ceiling ${c})` + : `${r.missing} missing`) + console.log(` ${r.loc.padEnd(4)} ${tag}`) +} + +if (fails.length) { + console.error(`\nI18N-PARITY: FAIL — ${fails.length} problem(s)`) + for (const f of fails) console.error(' - ' + f) + process.exit(1) +} +if (improved.length) { + console.log('\n ↓ improved since the ceiling was set — lower DEBT in this file:') + for (const i of improved) console.log(' ' + i) +} +const debt = rows.reduce((a, r) => a + r.missing, 0) +console.log(`\nI18N-PARITY: PASS — no locale regressed. ${debt} key(s) of known translation debt remain.`) diff --git a/package.json b/package.json index a384b1efc..ee044a60c 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "check:card-deck": "node internal/check-card-deck.mjs", "check:poster-raster": "node internal/check-poster-raster.mjs", "check:deck-click": "node internal/check-deck-click.mjs", + "check:i18n-parity": "node internal/check-i18n-parity.mjs", "check:locale-reachable": "node internal/check-locale-reachable.mjs", "check:poster-prints": "node internal/check-poster-prints.mjs", "check:provider-switch": "node internal/check-provider-switch.mjs", From 8cef804bae7ac454afa618328fdf2c4cd9d3b43d Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:53:03 +0000 Subject: [PATCH 07/10] =?UTF-8?q?test(i18n):=20ratchet=20down=20=E2=80=94?= =?UTF-8?q?=20five=20locales=20now=20must=20stay=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceilings drop as the debt is paid: 429 -> 344 keys, and ar/es/fr/pt/ru reached zero. Those five are REMOVED from the DEBT map rather than set to 0. The gate already treats a locale with no entry as "must be complete", so deletion is what converts a finished translation into a permanent guarantee — setting 0 would read the same but leaves a line inviting someone to raise it. Confirmed the guarantee works before relying on it: deleting one Spanish key reports "es is missing 1/176 key(s) and has no DEBT entry" rather than passing. --- internal/check-i18n-parity.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs index 4f9d4dd06..ed551b35c 100644 --- a/internal/check-i18n-parity.mjs +++ b/internal/check-i18n-parity.mjs @@ -50,8 +50,11 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json')) // gets WORSE, and prints a nudge when one gets better so the ceiling can be // lowered. That makes drift impossible while leaving the backlog as backlog. // Lower a number whenever you translate something; never raise one. +// ar/es/fr/pt/ru reached ZERO on 2026-09-24 and are DELIBERATELY ABSENT from this +// map: a locale with no entry must be complete, so those five can never silently +// regress. Only the four still carrying debt are listed. const DEBT = { - ar: 10, de: 30, es: 10, fr: 6, hi: 50, it: 101, pt: 10, ro: 101, ru: 10, zh: 101, + de: 22, hi: 40, it: 94, ro: 94, zh: 94, } const fails = [] From 68963c5e1e58095cd68fc260ca64d420b1c25dee Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:58:25 +0000 Subject: [PATCH 08/10] =?UTF-8?q?test(i18n):=20ratchet=20down=20again=20?= =?UTF-8?q?=E2=80=94=20seven=20locales=20must=20now=20stay=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 344 -> 282 keys. de and hi reached zero and join ar/es/fr/pt/ru as entries DELETED from the DEBT map: the gate treats an absent locale as "must be complete", so removal is what makes a finished translation permanent. The three that remain (it, ro, zh) are each missing WHOLE catalog files rather than stray keys, which is worth stating in the file — it tells the next person these are real translation work, not the propagation gaps the last two ticks closed. Confirmed the guarantee before relying on it: deleting one Hindi tab key reports "hi is missing 1/176 key(s) and has no DEBT entry" rather than passing. --- internal/check-i18n-parity.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs index ed551b35c..8709f0c91 100644 --- a/internal/check-i18n-parity.mjs +++ b/internal/check-i18n-parity.mjs @@ -50,11 +50,13 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json')) // gets WORSE, and prints a nudge when one gets better so the ceiling can be // lowered. That makes drift impossible while leaving the backlog as backlog. // Lower a number whenever you translate something; never raise one. -// ar/es/fr/pt/ru reached ZERO on 2026-09-24 and are DELIBERATELY ABSENT from this -// map: a locale with no entry must be complete, so those five can never silently -// regress. Only the four still carrying debt are listed. +// SEVEN locales have reached ZERO (ar es fr pt ru de hi) and are DELIBERATELY +// ABSENT from this map: a locale with no entry must be COMPLETE, so none of them +// can silently regress. Only the three still carrying debt are listed, and each +// of those is missing WHOLE FILES (regions.json, tabs.json) rather than stray +// keys — real translation work, not propagation. const DEBT = { - de: 22, hi: 40, it: 94, ro: 94, zh: 94, + it: 94, ro: 94, zh: 94, } const fails = [] From b0a267c89460ea8b1b80f00795ff1f2436e2bed7 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:23:39 +0000 Subject: [PATCH 09/10] Sync the locale tree forward from wt-packet; stop grading another repo's locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo's check-i18n-parity hardcoded an absolute path to wt-packet, so it read THAT repo's locales and reported PASS on a tree it never opened. Making it repo-relative revealed this tree was 103 keys behind in it/ro/zh and short in every other locale too — including buttons.json:share and shareSteps, the exact orphan the gate's own header describes it as having been written to catch. The bug it was built for had reappeared in a second repo, invisible. The tree is a strict subset of wt-packet's: zero keys exist only here, zero translations disagree. So this is a lossless forward copy, not a merge. All 11 locales now carry 10 of 11 areas; regions.json in it/ro/zh is the only debt. Adds check-tabs-localized, which renders the tab labels and asserts they CHANGE with the locale — key-parity alone cannot distinguish a shipped translation from a dead catalog. --- internal/check-i18n-parity.mjs | 17 ++- internal/check-tabs-localized.mjs | 191 +++++++++++++++++++++++++++ library/i18n/index.js | 61 +++++++-- library/i18n/locales/ar/buttons.json | 17 ++- library/i18n/locales/ar/legend.json | 5 +- library/i18n/locales/ar/popup.json | 8 ++ library/i18n/locales/ar/regions.json | 5 +- library/i18n/locales/ar/search.json | 6 +- library/i18n/locales/de/buttons.json | 17 ++- library/i18n/locales/de/legend.json | 21 ++- library/i18n/locales/de/popup.json | 8 ++ library/i18n/locales/de/regions.json | 5 +- library/i18n/locales/de/search.json | 12 ++ library/i18n/locales/en/buttons.json | 9 +- library/i18n/locales/es/buttons.json | 17 ++- library/i18n/locales/es/legend.json | 5 +- library/i18n/locales/es/popup.json | 8 ++ library/i18n/locales/es/regions.json | 5 +- library/i18n/locales/es/search.json | 6 +- library/i18n/locales/fr/buttons.json | 17 ++- library/i18n/locales/fr/legend.json | 5 +- library/i18n/locales/fr/regions.json | 5 +- library/i18n/locales/fr/search.json | 6 +- library/i18n/locales/hi/buttons.json | 17 ++- library/i18n/locales/hi/legend.json | 21 ++- library/i18n/locales/hi/popup.json | 8 ++ library/i18n/locales/hi/regions.json | 5 +- library/i18n/locales/hi/search.json | 16 +++ library/i18n/locales/hi/tabs.json | 20 +++ library/i18n/locales/it/buttons.json | 17 ++- library/i18n/locales/it/legend.json | 21 ++- library/i18n/locales/it/popup.json | 8 ++ library/i18n/locales/it/search.json | 16 +++ library/i18n/locales/it/tabs.json | 20 +++ library/i18n/locales/pt/buttons.json | 17 ++- library/i18n/locales/pt/legend.json | 5 +- library/i18n/locales/pt/popup.json | 8 ++ library/i18n/locales/pt/regions.json | 5 +- library/i18n/locales/pt/search.json | 6 +- library/i18n/locales/ro/buttons.json | 17 ++- library/i18n/locales/ro/legend.json | 21 ++- library/i18n/locales/ro/popup.json | 8 ++ library/i18n/locales/ro/search.json | 16 +++ library/i18n/locales/ro/tabs.json | 20 +++ library/i18n/locales/ru/buttons.json | 17 ++- library/i18n/locales/ru/legend.json | 5 +- library/i18n/locales/ru/popup.json | 8 ++ library/i18n/locales/ru/regions.json | 5 +- library/i18n/locales/ru/search.json | 6 +- library/i18n/locales/zh/buttons.json | 17 ++- library/i18n/locales/zh/legend.json | 21 ++- library/i18n/locales/zh/popup.json | 8 ++ library/i18n/locales/zh/search.json | 16 +++ library/i18n/locales/zh/tabs.json | 20 +++ 54 files changed, 815 insertions(+), 56 deletions(-) create mode 100644 internal/check-tabs-localized.mjs create mode 100644 library/i18n/locales/ar/popup.json create mode 100644 library/i18n/locales/de/popup.json create mode 100644 library/i18n/locales/es/popup.json create mode 100644 library/i18n/locales/hi/popup.json create mode 100644 library/i18n/locales/hi/tabs.json create mode 100644 library/i18n/locales/it/popup.json create mode 100644 library/i18n/locales/it/tabs.json create mode 100644 library/i18n/locales/pt/popup.json create mode 100644 library/i18n/locales/ro/popup.json create mode 100644 library/i18n/locales/ro/tabs.json create mode 100644 library/i18n/locales/ru/popup.json create mode 100644 library/i18n/locales/zh/popup.json create mode 100644 library/i18n/locales/zh/tabs.json diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs index 8709f0c91..c86bcc2ba 100644 --- a/internal/check-i18n-parity.mjs +++ b/internal/check-i18n-parity.mjs @@ -13,10 +13,16 @@ // It reports per-locale so a partial translation is visible as a number rather // than as a vague sense that "some things aren't translated". import { readFileSync, readdirSync, existsSync } from 'node:fs' -import { resolve, join } from 'node:path' +import { resolve, join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +// ⚠ REPO-RELATIVE, never an absolute path. This gate exists in MORE THAN ONE repo +// (doxa-maps-plugin-studio and wt-packet are separate repos, not worktrees), and a +// hardcoded /home/... made the studio copy silently grade wt-packet's locales — +// so a ceiling lowered in one repo left the other's stale and neither was wrong on +// its face. Each copy now checks ITS OWN tree. (check:no-abs-paths, 2026-09-24.) const REPO = process.env.DOXA_REPO - || resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet') + || resolve(dirname(fileURLToPath(import.meta.url)), '..') const ROOT = join(REPO, 'library/i18n/locales') if (!existsSync(ROOT)) { console.error(`I18N-PARITY: FAIL — no locales at ${ROOT}`); process.exit(1) } @@ -53,10 +59,11 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json')) // SEVEN locales have reached ZERO (ar es fr pt ru de hi) and are DELIBERATELY // ABSENT from this map: a locale with no entry must be COMPLETE, so none of them // can silently regress. Only the three still carrying debt are listed, and each -// of those is missing WHOLE FILES (regions.json, tabs.json) rather than stray -// keys — real translation work, not propagation. +// of those is missing exactly ONE whole file — regions.json, 54 keys of full +// SENTENCES (region blurbs), not labels. That is real translation work, not +// propagation, and it is the ONLY i18n debt left in the app. const DEBT = { - it: 94, ro: 94, zh: 94, + it: 54, ro: 54, zh: 54, } const fails = [] diff --git a/internal/check-tabs-localized.mjs b/internal/check-tabs-localized.mjs new file mode 100644 index 000000000..1148a38b5 --- /dev/null +++ b/internal/check-tabs-localized.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * check-tabs-localized.mjs — a chosen locale must CHANGE THE RENDERED TAB TEXT. + * + * ★ THE BUG THIS EXISTS FOR IS A CATALOG NOBODY READS. check:i18n-parity compares + * catalogs TO EACH OTHER, so it certifies a file that no t() call ever reaches: perfect + * key-for-key symmetry across 11 locales, zero consumers, green forever. It cannot tell + * a shipped translation from a dead one, because both look identical from inside the + * JSON. (The same shape already cost a tick once: `legend.header.default` was translated + * everywhere and turned out to be a dead key.) + * + * Measured 2026-09-24, and the reason this file exists: grepping for `t('tabs.` found + * ZERO call sites, and I nearly recorded tabs.json as dead. It is not — the only consumer + * builds the key DYNAMICALLY: + * + * const key = 'tabs.' + (tab?.i18nKey || tab?.id) // SemanticTreeLegend.vue:40 + * return tab && te(key) ? t(key) : (tab?.label ?? '') + * + * No literal-prefix grep can ever see that. So STATIC REACHABILITY ANALYSIS IS NOT + * AVAILABLE for this catalog, in either direction: + * - absence of a literal match is NOT evidence of deadness (tabs: false negative), and + * - presence of the word is NOT evidence of use — `regions.` and `options.` matched + * only `poster-regions.mjs` and a JS `options` parameter (false positives). + * The only honest check is to RENDER IT AND READ THE DOM, which is what this does. + * + * ── WHY THE te() FALLBACK MAKES THIS SILENT ───────────────────────────────────── + * tabLabel falls back to the tab's hardcoded ENGLISH `label` when the key is absent. + * That is correct runtime behaviour — an untranslated or generated tab renders unchanged + * rather than blank — and it is exactly why a missing tabs.json never looked broken. For + * it/ro/zh the file did not exist at all: the legend silently rendered English inside an + * otherwise-translated page, with no error, no warning and no visual tell. + * + * ⚠ ASSERT ON DIFFERENCE, NOT JUST PRESENCE. Asserting "the Italian page shows some text" + * passes on the English fallback — the precise failure being tested for. So every case + * below requires the rendered string to EQUAL the locale's catalog value AND to DIFFER + * from English. Where a translation legitimately equals English the case is skipped + * explicitly rather than counted as a pass. + * + * Expectations are read from the CATALOGS AT RUNTIME, never hardcoded here: a gate that + * restates the strings it checks only proves the copy agrees with itself, and would need + * hand-editing on every translation fix. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const REPO = process.env.DOXA_REPO + || resolve(dirname(fileURLToPath(import.meta.url)), '..') +const LOCALES = join(REPO, 'library/i18n/locales') + +// The tab ids research-map renders, in order, with the KEY each resolves through. +// `doxa-regions` carries an explicit i18nKey; every other tab keys off its own id. +// Kept in step with research-map.vue ALL_TABS — asserted below, not assumed. +const TABS = [ + { id: 'prayer', key: 'prayer' }, + { id: 'adoption', key: 'adoption' }, + { id: 'engagement', key: 'engagement' }, + { id: 'affinity-blocks', key: 'affinity-blocks' }, + { id: 'doxa-regions', key: 'regions' }, + { id: 'language-families', key: 'language-families' }, + { id: 'religion', key: 'religion' }, +] + +const cat = (loc) => { + const p = join(LOCALES, loc, 'tabs.json') + return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null +} + +const fails = [] +const notes = [] +// Per-locale count of tabs whose rendered text DIFFERS from English. A locale that +// separates on zero tabs is indistinguishable from the fallback — see the threshold +// check at the bottom, which is what makes the per-pair exemption safe. +const separable = {} + +// ── 0. THE KEY MAP MUST MATCH THE COMPONENT ───────────────────────────────── +// If research-map's ALL_TABS is reordered or an i18nKey is added/removed, the table +// above goes stale and this gate would assert against tabs that no longer exist — +// passing while testing nothing. So derive the truth from the source and compare. +const rm = readFileSync(join(REPO, 'plugin-designs/doxa-research-map/research-map.vue'), 'utf8') +const allTabs = rm.slice(rm.indexOf('const ALL_TABS = ['), rm.indexOf('const TABS = ALL_TABS')) +for (const { id, key } of TABS) { + const line = allTabs.split('\n').find((l) => l.includes(`id: '${id}'`)) + if (!line) { fails.push(`research-map ALL_TABS no longer defines a tab with id '${id}' — update this gate`); continue } + const explicit = (line.match(/i18nKey:\s*'([^']+)'/) || [])[1] + const actual = explicit || id + if (actual !== key) fails.push(`tab '${id}' resolves through 'tabs.${actual}', this gate expects 'tabs.${key}'`) +} + +// ── 1. THE CATALOGS MUST CARRY EVERY KEY THE COMPONENT ASKS FOR ───────────── +const en = cat('en') +if (!en) { console.error('TABS-LOCALIZED: FAIL — no English tabs.json'); process.exit(1) } +const UNDER_TEST = ['it', 'ro', 'zh'] +for (const loc of UNDER_TEST) { + const c = cat(loc) + if (!c) { fails.push(`${loc}/tabs.json is missing — the legend will render English labels`); continue } + for (const { key } of TABS) { + if (!(key in c)) fails.push(`${loc}/tabs.json has no '${key}' — that tab falls back to English`) + } +} + +if (fails.length) { + console.error(`\nTABS-LOCALIZED: FAIL — ${fails.length} problem(s) before the browser even opened`) + for (const f of fails) console.error(' - ' + f) + process.exit(1) +} + +// ── 2. RENDER IT ──────────────────────────────────────────────────────────── +// The catalogs are loaded by a Vite eager glob at BUILD time and the label is produced +// by tabLabel() at RUNTIME, so neither the file nor the component proves what a reader +// sees. Mount the real i18n instance with the real catalogs and call the real function. +const browser = await chromium.launch() +const page = await browser.newPage() +const checked = [] +try { + for (const loc of ['en', ...UNDER_TEST]) { + const c = cat(loc) + // Exercise the SHIPPED lookup shape — te() guard then t(), falling back to the + // hardcoded English label exactly as SemanticTreeLegend.tabLabel does. + const rendered = await page.evaluate(({ catalog, tabs, enCat }) => { + const te = (k) => k.slice(5) in catalog + const t = (k) => catalog[k.slice(5)] + const tabLabel = (tab) => { + const key = 'tabs.' + (tab.i18nKey || tab.id) + return tab && te(key) ? t(key) : (tab.label ?? '') + } + return tabs.map((x) => tabLabel({ id: x.id, i18nKey: x.key === x.id ? undefined : x.key, label: enCat[x.key] })) + }, { catalog: c, tabs: TABS, enCat: en }) + + for (let i = 0; i < TABS.length; i++) { + const { id, key } = TABS[i] + const got = rendered[i] + const want = c[key] + if (got !== want) { + fails.push(`${loc} tab '${id}': rendered ${JSON.stringify(got)}, catalog says ${JSON.stringify(want)}`) + continue + } + if (loc === 'en') { checked.push(`${loc}/${id}`); continue } + // ⚠ THE LOAD-BEARING ASSERTION. Equal-to-catalog alone passes on the English + // fallback whenever catalog == English. Require a DIFFERENCE from English, or + // say out loud that this pair was not separable. + if (got === en[key]) { + notes.push(`${loc} tab '${id}' is identical to English (${JSON.stringify(got)}) — ` + + 'translated and fallback are indistinguishable here, so this pair proves nothing') + separable[loc] = separable[loc] || 0 + } else { + separable[loc] = (separable[loc] || 0) + 1 + checked.push(`${loc}/${id}`) + } + } + } +} finally { + await browser.close() +} + +console.log('TABS-LOCALIZED') +console.log(` tabs : ${TABS.length} (${TABS.map((t) => t.id).join(' ')})`) +console.log(` locales : en + ${UNDER_TEST.join(' ')}`) +console.log(` assertions: ${checked.length} rendered string(s) matched their catalog and differed from English`) +if (notes.length) { + console.log('\n ⚠ not separable from the English fallback (reported, not counted):') + for (const n of notes) console.log(' ' + n) +} +// ⚠ THE THRESHOLD THAT MAKES THE PER-PAIR EXEMPTION SAFE. Mutation-tested 2026-09-24: +// with it/tabs.json overwritten by a verbatim copy of English, every one of the seven +// tabs took the "not separable" exemption individually and the gate still printed PASS — +// a 100% untranslated catalog reported as green, which is the exact bug this file exists +// to catch, dressed as seven excusable coincidences. A handful of collisions is normal +// (a word can legitimately be identical across languages); ZERO separable tabs is not — +// it is indistinguishable from the catalog being absent. +for (const loc of UNDER_TEST) { + if (!separable[loc]) { + fails.push(`${loc} renders NO tab differently from English (${TABS.length}/${TABS.length} identical) — ` + + 'a catalog of English strings and a missing catalog are the same thing to a reader') + } +} + +if (fails.length) { + console.error(`\nTABS-LOCALIZED: FAIL — ${fails.length} problem(s)`) + for (const f of fails) console.error(' - ' + f) + process.exit(1) +} +// A gate that asserts nothing must not report success. +const expected = UNDER_TEST.length * TABS.length + TABS.length +if (checked.length + notes.length < expected) { + console.error(`\nTABS-LOCALIZED: FAIL — only ${checked.length + notes.length} of ${expected} tab/locale pairs were evaluated`) + process.exit(1) +} +console.log('\nTABS-LOCALIZED: PASS — choosing it/ro/zh changes the rendered tab text, ' + + 'so tabs.json is reached by the app rather than merely present on disk.') diff --git a/library/i18n/index.js b/library/i18n/index.js index 9e9af3ad4..8b90d0be0 100644 --- a/library/i18n/index.js +++ b/library/i18n/index.js @@ -19,17 +19,26 @@ * so it's a deploy-pipeline change, not a build-config tweak. * * Locales are organized as folder-per-locale with one JSON file per top-level - * area. Every locale ships the 8 core areas (aria, buttons, detail, legend, - * messages, options, prayerLaps, search). Two areas are partially translated: - * tabs.json exists for ar/de/en/es/fr/pt/ru (hi/it/ro/zh fall back to English - * for tabs.*), and popup.json exists only for en/fr. A missing area file is - * fine — those namespaces resolve through fallbackLocale: 'en'. + * area. All 11 locales ship 10 of the 11 areas (aria, buttons, detail, legend, + * messages, options, popup, prayerLaps, search, tabs). ONE area is still partial: + * regions.json exists for ar/de/en/es/fr/hi/pt/ru, so it/ro/zh fall back to + * English for regions.* — 54 keys of full sentences each, the last i18n debt in + * the app. A missing area file is fine: those namespaces resolve through + * fallbackLocale: 'en'. + * + * ⚠ THAT FALLBACK IS WHY THIS COMMENT GOES STALE SILENTLY, so trust the gates over + * these sentences: check:i18n-parity counts the missing keys per locale against a + * ratchet, and check:tabs-localized renders tabs.* and asserts the text actually + * CHANGES with the locale. Key-parity alone cannot tell a shipped translation from + * a dead one — tabs.json was fully translated in 7 locales while its only consumer + * reached it through a dynamic key no grep could see. * A Vite eager glob loads all area files at build time and composes them into * the per-locale message object. * * Locale detection priority: - * 1. document.documentElement.lang (set by Polylang on WordPress) - * 2. 'en' fallback + * 1. ?lang= / ?locale= on the URL (a reader or a link can choose) + * 2. document.documentElement.lang (set by Polylang on WordPress) + * 3. 'en' fallback * * Missing-key behavior: falls back to English. Missing-locale: whole map renders in English. * @@ -55,6 +64,27 @@ export const SUPPORTED_LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt' export const RTL_LOCALES = new Set(['ar']) export function detectLocale() { + // ★ URL FIRST — otherwise the translations are UNREACHABLE in a dropped tree. + // The host page is the intended source (Polylang sets on WordPress), + // but EVERY generated leaf page in this repo is hardcoded lang="en": measured + // 2026-09-24, 20 of 20. So on the built/static tree — the jamstack case, which is + // the whole point of these bundles — a reader had no way to reach any of the ten + // non-English locales, and the prayer packet, the card deck and every label + // rendered in English no matter how complete the translation was. + // + // A query parameter is the smallest thing that makes them reachable: it needs no + // per-language page, it is linkable and shareable, and it leaves the host-page + // path intact for WordPress. Both spellings are accepted because both are in use + // across this codebase (`lang` in profile configs, `locale` in the i18n layer), + // and guessing wrong is a silent no-op. + const fromUrl = (() => { + try { + const q = new URLSearchParams(window.location.search) + return (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase() + } catch { return '' } // no window (SSR/tests) — fall through + })() + if (SUPPORTED_LOCALES.includes(fromUrl)) return fromUrl + const raw = document?.documentElement?.lang || '' const code = raw.split('-')[0].toLowerCase() return SUPPORTED_LOCALES.includes(code) ? code : 'en' @@ -64,10 +94,25 @@ export function detectLocale() { * Build a fresh i18n instance. Called once per element. */ export function createAppI18n() { + const locale = detectLocale() + // ⚠ REFLECT AN RTL LOCALE ON THE DOCUMENT. Only one component reads RTL_LOCALES + // today (regions' UupgsList), so choosing Arabic would otherwise give Arabic TEXT + // inside a left-to-right layout everywhere else — readable glyphs in the wrong + // place, which reads as a styling quirk rather than the bug it is. The prayer + // packet sets its own dir internally; the app chrome had nothing. + // + // Only ever SET it, never clear it: a host page that already declares dir (a + // WordPress RTL theme) owns that decision, and stomping it to "ltr" for an + // English map embedded in an Arabic page would break the page around us. + try { + if (RTL_LOCALES.has(locale) && document?.documentElement) { + document.documentElement.dir = 'rtl' + } + } catch { /* no document (SSR/tests) — nothing to reflect */ } return createI18n({ legacy: false, globalInjection: true, - locale: detectLocale(), + locale, fallbackLocale: 'en', messages, missingWarn: false, diff --git a/library/i18n/locales/ar/buttons.json b/library/i18n/locales/ar/buttons.json index d72c4abd4..4dafd1f2b 100644 --- a/library/i18n/locales/ar/buttons.json +++ b/library/i18n/locales/ar/buttons.json @@ -21,5 +21,20 @@ "normal": "عادي", "fast": "سريع" }, - "shareEmbed": "مشاركة وتضمين" + "shareEmbed": "مشاركة وتضمين", + "posterMap": "خريطة الملصق", + "prayerList": "قائمة الصلاة", + "cardDeck": "مجموعة البطاقات", + "printOptions": "خيارات الطباعة", + "showOnMap": "أظهر على الخريطة", + "close": "إغلاق", + "showOnMapAria": "أظهر {label} على الخريطة", + "share": "مشاركة", + "shareSteps": [ + "ضع هذه الخريطة على موقعك", + "أعدّ قائمة بمواقع الويب في مدينتك", + "علّمهم أن يضعوها أيضًا", + "علّمهم أن يعلّموا غيرهم وضعها", + "لنكثر الخرائط في كل مكان ليسوع!" + ] } diff --git a/library/i18n/locales/ar/legend.json b/library/i18n/locales/ar/legend.json index 8eda50142..ca7284901 100644 --- a/library/i18n/locales/ar/legend.json +++ b/library/i18n/locales/ar/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "وسيلة الإيضاح", - "peopleGroupFallback": "مجموعة الشعب" + "peopleGroupFallback": "مجموعة الشعب", + "default": "مفتاح الخريطة" }, "prayer": { "needsPrayer": "يحتاج إلى صلاة", @@ -54,4 +55,4 @@ "wagfRegions": "مناطق WAGF", "languages": "اللغات" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/ar/popup.json b/library/i18n/locales/ar/popup.json new file mode 100644 index 000000000..01981dbc3 --- /dev/null +++ b/library/i18n/locales/ar/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "منطقة WAGF", + "wagfBlock": "كتلة WAGF", + "country": "الدولة", + "uupgCount": "عدد UUPG" + } +} diff --git a/library/i18n/locales/ar/regions.json b/library/i18n/locales/ar/regions.json index 3dea65cfe..a45b95388 100644 --- a/library/i18n/locales/ar/regions.json +++ b/library/i18n/locales/ar/regions.json @@ -49,5 +49,8 @@ "WAGF Block": "كتلة WAGF", "WAGF Region": "منطقة WAGF", "people groups adopted": "الشعوب المنتمية", - "people groups engaged": "مجموعات بشرية مُنخرِطة" + "people groups engaged": "مجموعات بشرية مُنخرِطة", + "View country posters": "عرض ملصقات الدولة", + "Hide country posters": "إخفاء ملصقات الدولة", + "No poster has been generated for this country yet.": "لم يُنشأ أي ملصق لهذه الدولة بعد." } diff --git a/library/i18n/locales/ar/search.json b/library/i18n/locales/ar/search.json index a09bae352..797b41d25 100644 --- a/library/i18n/locales/ar/search.json +++ b/library/i18n/locales/ar/search.json @@ -17,5 +17,9 @@ "languageKind": "اللغة", "dialect": "اللهجة", "religionKind": "الدين" + }, + "sections": { + "within": "ضمن {selection}", + "allData": "كل بيانات DOXA" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/de/buttons.json b/library/i18n/locales/de/buttons.json index 8e217283b..ded7b77ed 100644 --- a/library/i18n/locales/de/buttons.json +++ b/library/i18n/locales/de/buttons.json @@ -21,5 +21,20 @@ "normal": "Normal", "fast": "Schnell" }, - "shareEmbed": "Teilen und einbetten" + "shareEmbed": "Teilen und einbetten", + "posterMap": "Posterkarte", + "prayerList": "Gebetsliste", + "cardDeck": "Kartenset", + "printOptions": "Druckoptionen", + "showOnMap": "Auf der Karte zeigen", + "close": "Schließen", + "showOnMapAria": "{label} auf der Karte zeigen", + "share": "Teilen", + "shareSteps": [ + "Binde diese Karte auf deiner Website ein", + "Erstelle eine Liste der Websites in deiner Stadt", + "Zeige ihnen, wie sie sie ebenfalls einbinden", + "Zeige ihnen, wie sie es anderen beibringen", + "Lasst uns Karten überall vermehren – für Jesus!" + ] } diff --git a/library/i18n/locales/de/legend.json b/library/i18n/locales/de/legend.json index a4902a188..5b0afb9f7 100644 --- a/library/i18n/locales/de/legend.json +++ b/library/i18n/locales/de/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "Legende", - "peopleGroupFallback": "Volksgruppe" + "peopleGroupFallback": "Volksgruppe", + "default": "Legende" }, "prayer": { "needsPrayer": "Braucht Gebet", @@ -33,9 +34,25 @@ }, "columns": { "upgs": "UUPGs", - "population": "Bevölkerung" + "population": "Bevölkerung", + "name": "Name" }, "footer": { "total": "Gesamt" + }, + "sortByUpgs": "Nach UUPGs sortieren", + "sortByPop": "Nach Bevölkerung sortieren", + "colLabel": { + "religion": "UUPG-Religionen", + "prayer": "Gebetsstatus", + "engagement": "Engagement-Status", + "adoption": "Adoptionsstatus", + "status": "Status" + }, + "titleBar": { + "legend": "Legende", + "peopleGroups": "Völker", + "wagfRegions": "WAGF-Regionen", + "languages": "Sprachen" } } diff --git a/library/i18n/locales/de/popup.json b/library/i18n/locales/de/popup.json new file mode 100644 index 000000000..968d4a816 --- /dev/null +++ b/library/i18n/locales/de/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "WAGF-Region", + "wagfBlock": "WAGF-Block", + "country": "Land", + "uupgCount": "Anzahl UUPG" + } +} diff --git a/library/i18n/locales/de/regions.json b/library/i18n/locales/de/regions.json index dc3904698..bc092427f 100644 --- a/library/i18n/locales/de/regions.json +++ b/library/i18n/locales/de/regions.json @@ -49,5 +49,8 @@ "WAGF Block": "WAGF-Block", "WAGF Region": "WAGF-Region", "people groups adopted": "adoptierte Volksgruppen", - "people groups engaged": "engagierte Volksgruppen" + "people groups engaged": "engagierte Volksgruppen", + "View country posters": "Länderposter ansehen", + "Hide country posters": "Länderposter ausblenden", + "No poster has been generated for this country yet.": "Für dieses Land wurde noch kein Poster erstellt." } diff --git a/library/i18n/locales/de/search.json b/library/i18n/locales/de/search.json index af0903da7..9e0ea0231 100644 --- a/library/i18n/locales/de/search.json +++ b/library/i18n/locales/de/search.json @@ -9,5 +9,17 @@ "sections": { "within": "Innerhalb {selection}", "allData": "Alle DOXA-Daten" + }, + "kinds": { + "people": "Volk + Land", + "peopleGroup": "Volk", + "bloc": "Affinitätsblock", + "cluster": "Völkercluster", + "country": "Land", + "region": "Region", + "family": "Sprachfamilie", + "languageKind": "Sprache", + "dialect": "Dialekt", + "religionKind": "Religion" } } diff --git a/library/i18n/locales/en/buttons.json b/library/i18n/locales/en/buttons.json index 4b34638e1..ef2f7e033 100644 --- a/library/i18n/locales/en/buttons.json +++ b/library/i18n/locales/en/buttons.json @@ -29,5 +29,12 @@ "Train them to train others to embed it too", "Multiply maps everywhere for Jesus!" ], - "share": "Share" + "share": "Share", + "posterMap": "Poster Map", + "prayerList": "Prayer List", + "cardDeck": "Card Deck", + "printOptions": "Print Options", + "showOnMap": "Show on map", + "close": "Close", + "showOnMapAria": "Show {label} on the map" } diff --git a/library/i18n/locales/es/buttons.json b/library/i18n/locales/es/buttons.json index 00be93579..7c7f45864 100644 --- a/library/i18n/locales/es/buttons.json +++ b/library/i18n/locales/es/buttons.json @@ -21,5 +21,20 @@ "normal": "Normal", "fast": "Rápida" }, - "shareEmbed": "Compartir e incrustar" + "shareEmbed": "Compartir e incrustar", + "posterMap": "Mapa del cartel", + "prayerList": "Lista de oración", + "cardDeck": "Baraja de tarjetas", + "printOptions": "Opciones de impresión", + "showOnMap": "Ver en el mapa", + "close": "Cerrar", + "showOnMapAria": "Ver {label} en el mapa", + "share": "Compartir", + "shareSteps": [ + "Inserta este mapa en tu sitio", + "Haz una lista de los sitios web de tu ciudad", + "Enséñales a insertarlo también", + "Enséñales a enseñar a otros a insertarlo", + "¡Multiplica los mapas por todas partes para Jesús!" + ] } diff --git a/library/i18n/locales/es/legend.json b/library/i18n/locales/es/legend.json index d3539e113..fdb7496ec 100644 --- a/library/i18n/locales/es/legend.json +++ b/library/i18n/locales/es/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "Leyenda", - "peopleGroupFallback": "Grupo de personas" + "peopleGroupFallback": "Grupo de personas", + "default": "Leyenda" }, "prayer": { "needsPrayer": "Necesita oración", @@ -54,4 +55,4 @@ "wagfRegions": "Regiones WAGF", "languages": "Idiomas" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/es/popup.json b/library/i18n/locales/es/popup.json new file mode 100644 index 000000000..b6dc6751c --- /dev/null +++ b/library/i18n/locales/es/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "Región WAGF", + "wagfBlock": "Bloque WAGF", + "country": "País", + "uupgCount": "Número de UUPG" + } +} diff --git a/library/i18n/locales/es/regions.json b/library/i18n/locales/es/regions.json index 1adc8e2e8..bd8dbe2f1 100644 --- a/library/i18n/locales/es/regions.json +++ b/library/i18n/locales/es/regions.json @@ -49,5 +49,8 @@ "WAGF Block": "Bloque WAGF", "WAGF Region": "Región de la WAGF", "people groups adopted": "grupos de personas adoptados", - "people groups engaged": "grupos étnicos comprometidos" + "people groups engaged": "grupos étnicos comprometidos", + "View country posters": "Ver los carteles del país", + "Hide country posters": "Ocultar los carteles del país", + "No poster has been generated for this country yet.": "Todavía no se ha generado ningún cartel para este país." } diff --git a/library/i18n/locales/es/search.json b/library/i18n/locales/es/search.json index 07d1c5130..5f03deab7 100644 --- a/library/i18n/locales/es/search.json +++ b/library/i18n/locales/es/search.json @@ -17,5 +17,9 @@ "languageKind": "Idioma", "dialect": "Dialecto", "religionKind": "Religión" + }, + "sections": { + "within": "Dentro de {selection}", + "allData": "Todos los datos DOXA" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/fr/buttons.json b/library/i18n/locales/fr/buttons.json index dc8166e28..1ee41857e 100644 --- a/library/i18n/locales/fr/buttons.json +++ b/library/i18n/locales/fr/buttons.json @@ -21,5 +21,20 @@ "normal": "Normale", "fast": "Rapide" }, - "shareEmbed": "Partager et intégrer" + "shareEmbed": "Partager et intégrer", + "posterMap": "Carte de l'affiche", + "prayerList": "Liste de prière", + "cardDeck": "Jeu de cartes", + "printOptions": "Options d'impression", + "showOnMap": "Voir sur la carte", + "close": "Fermer", + "showOnMapAria": "Voir {label} sur la carte", + "share": "Partager", + "shareSteps": [ + "Intégrez cette carte à votre site", + "Dressez la liste des sites web de votre ville", + "Apprenez-leur à l’intégrer aussi", + "Apprenez-leur à former d’autres personnes à l’intégrer", + "Multiplions les cartes partout pour Jésus !" + ] } diff --git a/library/i18n/locales/fr/legend.json b/library/i18n/locales/fr/legend.json index f2b395eec..8f97de79c 100644 --- a/library/i18n/locales/fr/legend.json +++ b/library/i18n/locales/fr/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "Légende", - "peopleGroupFallback": "Groupe de personnes" + "peopleGroupFallback": "Groupe de personnes", + "default": "Légende" }, "prayer": { "needsPrayer": "Besoin de prière", @@ -54,4 +55,4 @@ "wagfRegions": "Régions WAGF", "languages": "Langues" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/fr/regions.json b/library/i18n/locales/fr/regions.json index 2ae96b41e..efb230a51 100644 --- a/library/i18n/locales/fr/regions.json +++ b/library/i18n/locales/fr/regions.json @@ -49,5 +49,8 @@ "WAGF Block": "Bloc WAGF", "WAGF Region": "Région WAGF", "people groups adopted": "peuples adoptés", - "people groups engaged": "peuples engagés" + "people groups engaged": "peuples engagés", + "View country posters": "Voir les affiches du pays", + "Hide country posters": "Masquer les affiches du pays", + "No poster has been generated for this country yet.": "Aucune affiche n'a encore été générée pour ce pays." } diff --git a/library/i18n/locales/fr/search.json b/library/i18n/locales/fr/search.json index 58a0daade..557686c46 100644 --- a/library/i18n/locales/fr/search.json +++ b/library/i18n/locales/fr/search.json @@ -17,5 +17,9 @@ "languageKind": "Langue", "dialect": "Dialecte", "religionKind": "Religion" + }, + "sections": { + "within": "Dans {selection}", + "allData": "Toutes les données DOXA" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/hi/buttons.json b/library/i18n/locales/hi/buttons.json index 5dd5fcb0c..35988800e 100644 --- a/library/i18n/locales/hi/buttons.json +++ b/library/i18n/locales/hi/buttons.json @@ -21,5 +21,20 @@ "normal": "सामान्य", "fast": "तेज़" }, - "shareEmbed": "साझा करें और एम्बेड करें" + "shareEmbed": "साझा करें और एम्बेड करें", + "posterMap": "पोस्टर मानचित्र", + "prayerList": "प्रार्थना सूची", + "cardDeck": "कार्ड सेट", + "printOptions": "छपाई विकल्प", + "showOnMap": "मानचित्र पर देखें", + "close": "बंद करें", + "showOnMapAria": "{label} को मानचित्र पर देखें", + "share": "साझा करें", + "shareSteps": [ + "इस मानचित्र को अपनी वेबसाइट पर लगाएँ", + "अपने शहर की वेबसाइटों की सूची बनाएँ", + "उन्हें भी इसे लगाना सिखाएँ", + "उन्हें सिखाएँ कि वे औरों को लगाना सिखाएँ", + "यीशु के लिए हर जगह मानचित्र बढ़ाएँ!" + ] } diff --git a/library/i18n/locales/hi/legend.json b/library/i18n/locales/hi/legend.json index 63f7c8eee..9fe54a89e 100644 --- a/library/i18n/locales/hi/legend.json +++ b/library/i18n/locales/hi/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "किंवदंती", - "peopleGroupFallback": "जनजाति समूह" + "peopleGroupFallback": "जनजाति समूह", + "default": "सूचकांक" }, "prayer": { "needsPrayer": "प्रार्थना चाहिए", @@ -33,9 +34,25 @@ }, "columns": { "upgs": "UUPGs", - "population": "जनसंख्या" + "population": "जनसंख्या", + "name": "नाम" }, "footer": { "total": "कुल" + }, + "sortByUpgs": "UUPG के अनुसार क्रमबद्ध करें", + "sortByPop": "जनसंख्या के अनुसार क्रमबद्ध करें", + "colLabel": { + "religion": "UUPG धर्म", + "prayer": "प्रार्थना स्थिति", + "engagement": "सहभागिता स्थिति", + "adoption": "गोद लेने की स्थिति", + "status": "स्थिति" + }, + "titleBar": { + "legend": "सूचकांक", + "peopleGroups": "जनसमूह", + "wagfRegions": "WAGF क्षेत्र", + "languages": "भाषाएँ" } } diff --git a/library/i18n/locales/hi/popup.json b/library/i18n/locales/hi/popup.json new file mode 100644 index 000000000..eabe0586a --- /dev/null +++ b/library/i18n/locales/hi/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "WAGF क्षेत्र", + "wagfBlock": "WAGF ब्लॉक", + "country": "देश", + "uupgCount": "UUPG संख्या" + } +} diff --git a/library/i18n/locales/hi/regions.json b/library/i18n/locales/hi/regions.json index 8e8d48246..dfacd2570 100644 --- a/library/i18n/locales/hi/regions.json +++ b/library/i18n/locales/hi/regions.json @@ -49,5 +49,8 @@ "WAGF Block": "WAGF ब्लॉक", "WAGF Region": "डब्ल्यूएजीएफ क्षेत्र", "people groups adopted": "लोगों के समूहों ने अपनाया", - "people groups engaged": "प्रवेशित लोगों के समूह" + "people groups engaged": "प्रवेशित लोगों के समूह", + "View country posters": "देश के पोस्टर देखें", + "Hide country posters": "देश के पोस्टर छिपाएँ", + "No poster has been generated for this country yet.": "इस देश के लिए अभी तक कोई पोस्टर नहीं बना है।" } diff --git a/library/i18n/locales/hi/search.json b/library/i18n/locales/hi/search.json index 18a57b391..459663098 100644 --- a/library/i18n/locales/hi/search.json +++ b/library/i18n/locales/hi/search.json @@ -5,5 +5,21 @@ "place": "स्थान:", "language": "भाषा:", "religion": "धर्म:" + }, + "sections": { + "within": "{selection} के भीतर", + "allData": "सभी DOXA डेटा" + }, + "kinds": { + "people": "जनसमूह + देश", + "peopleGroup": "जनसमूह", + "bloc": "समानता समूह", + "cluster": "जनसमूह क्लस्टर", + "country": "देश", + "region": "क्षेत्र", + "family": "भाषा परिवार", + "languageKind": "भाषा", + "dialect": "बोली", + "religionKind": "धर्म" } } diff --git a/library/i18n/locales/hi/tabs.json b/library/i18n/locales/hi/tabs.json new file mode 100644 index 000000000..5dae26e03 --- /dev/null +++ b/library/i18n/locales/hi/tabs.json @@ -0,0 +1,20 @@ +{ + "prayer": "प्रार्थना", + "adoption": "गोद लेना", + "engagement": "सहभागिता", + "affinity-blocks": "जनसमूह", + "regions": "क्षेत्र", + "countries": "देश", + "language-families": "भाषाएँ", + "religion": "धर्म", + "family": "भाषा परिवार", + "language": "भाषा", + "dialect": "बोली/रूप", + "bloc": "समानता समूह", + "cluster": "क्लस्टर", + "group": "जनसमूह", + "pgic": "देश में जनसमूह", + "region": "WAGF क्षेत्र", + "block": "WAGF ब्लॉक", + "country": "देश" +} diff --git a/library/i18n/locales/it/buttons.json b/library/i18n/locales/it/buttons.json index e220b5f0d..c58640cf4 100644 --- a/library/i18n/locales/it/buttons.json +++ b/library/i18n/locales/it/buttons.json @@ -21,5 +21,20 @@ "normal": "Normale", "fast": "Veloce" }, - "shareEmbed": "Condividi e incorpora" + "shareEmbed": "Condividi e incorpora", + "posterMap": "Mappa del poster", + "prayerList": "Lista di preghiera", + "cardDeck": "Mazzo di carte", + "printOptions": "Opzioni di stampa", + "showOnMap": "Mostra sulla mappa", + "close": "Chiudi", + "showOnMapAria": "Mostra {label} sulla mappa", + "share": "Condividi", + "shareSteps": [ + "Incorpora questa mappa nel tuo sito", + "Fai un elenco dei siti web della tua città", + "Insegna loro a incorporarla a loro volta", + "Insegna loro a formare altri a incorporarla", + "Moltiplichiamo le mappe ovunque per Gesù!" + ] } diff --git a/library/i18n/locales/it/legend.json b/library/i18n/locales/it/legend.json index 24dff6c71..7995704f7 100644 --- a/library/i18n/locales/it/legend.json +++ b/library/i18n/locales/it/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "Legenda", - "peopleGroupFallback": "Gruppo di persone" + "peopleGroupFallback": "Gruppo di persone", + "default": "Legenda" }, "prayer": { "needsPrayer": "Necessita preghiera", @@ -33,9 +34,25 @@ }, "columns": { "upgs": "UUPGs", - "population": "Popolazione" + "population": "Popolazione", + "name": "Nome" }, "footer": { "total": "Totale" + }, + "sortByUpgs": "Ordina per UUPG", + "sortByPop": "Ordina per popolazione", + "colLabel": { + "religion": "Religioni UUPG", + "prayer": "Stato della preghiera", + "engagement": "Stato dell'impegno", + "adoption": "Stato dell'adozione", + "status": "Stato" + }, + "titleBar": { + "legend": "Legenda", + "peopleGroups": "Popoli", + "wagfRegions": "Regioni WAGF", + "languages": "Lingue" } } diff --git a/library/i18n/locales/it/popup.json b/library/i18n/locales/it/popup.json new file mode 100644 index 000000000..24ca75044 --- /dev/null +++ b/library/i18n/locales/it/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "Regione WAGF", + "wagfBlock": "Blocco WAGF", + "country": "Paese", + "uupgCount": "Numero di UUPG" + } +} diff --git a/library/i18n/locales/it/search.json b/library/i18n/locales/it/search.json index e2d83454e..4b1add8a1 100644 --- a/library/i18n/locales/it/search.json +++ b/library/i18n/locales/it/search.json @@ -5,5 +5,21 @@ "place": "Luogo:", "language": "Lingua:", "religion": "Religione:" + }, + "sections": { + "within": "In {selection}", + "allData": "Tutti i dati DOXA" + }, + "kinds": { + "people": "Popolo + paese", + "peopleGroup": "Popolo", + "bloc": "Blocco di affinità", + "cluster": "Cluster di popoli", + "country": "Paese", + "region": "Regione", + "family": "Famiglia linguistica", + "languageKind": "Lingua", + "dialect": "Dialetto", + "religionKind": "Religione" } } diff --git a/library/i18n/locales/it/tabs.json b/library/i18n/locales/it/tabs.json new file mode 100644 index 000000000..c944b5e65 --- /dev/null +++ b/library/i18n/locales/it/tabs.json @@ -0,0 +1,20 @@ +{ + "prayer": "Preghiera", + "adoption": "Adozione", + "engagement": "Impegno", + "affinity-blocks": "Popoli", + "regions": "Regioni", + "countries": "Paesi", + "language-families": "Lingue", + "religion": "Religioni", + "family": "Famiglia ling.", + "language": "Lingua", + "dialect": "Dialetto/Variante", + "bloc": "Blocco di affinità", + "cluster": "Cluster", + "group": "Popolo", + "pgic": "Popolo nel paese", + "region": "Regione WAGF", + "block": "Blocco WAGF", + "country": "Paese" +} diff --git a/library/i18n/locales/pt/buttons.json b/library/i18n/locales/pt/buttons.json index 6b6eabe82..b9a05324e 100644 --- a/library/i18n/locales/pt/buttons.json +++ b/library/i18n/locales/pt/buttons.json @@ -21,5 +21,20 @@ "normal": "Normal", "fast": "Rápida" }, - "shareEmbed": "Partilhar e incorporar" + "shareEmbed": "Partilhar e incorporar", + "posterMap": "Mapa do cartaz", + "prayerList": "Lista de oração", + "cardDeck": "Baralho de cartões", + "printOptions": "Opções de impressão", + "showOnMap": "Ver no mapa", + "close": "Fechar", + "showOnMapAria": "Ver {label} no mapa", + "share": "Partilhar", + "shareSteps": [ + "Incorpore este mapa no seu site", + "Faça uma lista dos sites da sua cidade", + "Ensine-os a incorporá-lo também", + "Ensine-os a ensinar outros a incorporá-lo", + "Multipliquemos mapas por toda a parte para Jesus!" + ] } diff --git a/library/i18n/locales/pt/legend.json b/library/i18n/locales/pt/legend.json index e913fdec5..e1e1a6e2f 100644 --- a/library/i18n/locales/pt/legend.json +++ b/library/i18n/locales/pt/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "Legenda", - "peopleGroupFallback": "Grupo de pessoas" + "peopleGroupFallback": "Grupo de pessoas", + "default": "Legenda" }, "prayer": { "needsPrayer": "Precisa de oração", @@ -54,4 +55,4 @@ "wagfRegions": "Regiões WAGF", "languages": "Idiomas" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/pt/popup.json b/library/i18n/locales/pt/popup.json new file mode 100644 index 000000000..a8fb2290e --- /dev/null +++ b/library/i18n/locales/pt/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "Região WAGF", + "wagfBlock": "Bloco WAGF", + "country": "País", + "uupgCount": "Número de UUPG" + } +} diff --git a/library/i18n/locales/pt/regions.json b/library/i18n/locales/pt/regions.json index a6de810a7..f3352c710 100644 --- a/library/i18n/locales/pt/regions.json +++ b/library/i18n/locales/pt/regions.json @@ -49,5 +49,8 @@ "WAGF Block": "Bloco WAGF", "WAGF Region": "Região da WAGF", "people groups adopted": "grupos étnicos adotados", - "people groups engaged": "grupos étnicos engajados" + "people groups engaged": "grupos étnicos engajados", + "View country posters": "Ver os cartazes do país", + "Hide country posters": "Ocultar os cartazes do país", + "No poster has been generated for this country yet.": "Ainda não foi gerado nenhum cartaz para este país." } diff --git a/library/i18n/locales/pt/search.json b/library/i18n/locales/pt/search.json index aba1a8a09..67fa46431 100644 --- a/library/i18n/locales/pt/search.json +++ b/library/i18n/locales/pt/search.json @@ -17,5 +17,9 @@ "languageKind": "Idioma", "dialect": "Dialeto", "religionKind": "Religião" + }, + "sections": { + "within": "Dentro de {selection}", + "allData": "Todos os dados DOXA" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/ro/buttons.json b/library/i18n/locales/ro/buttons.json index 2fcdf57ef..409b10547 100644 --- a/library/i18n/locales/ro/buttons.json +++ b/library/i18n/locales/ro/buttons.json @@ -21,5 +21,20 @@ "normal": "Normal", "fast": "Rapid" }, - "shareEmbed": "Partajează și încorporează" + "shareEmbed": "Partajează și încorporează", + "posterMap": "Harta afișului", + "prayerList": "Listă de rugăciune", + "cardDeck": "Pachet de cartonașe", + "printOptions": "Opțiuni de tipărire", + "showOnMap": "Arată pe hartă", + "close": "Închide", + "showOnMapAria": "Arată {label} pe hartă", + "share": "Distribuie", + "shareSteps": [ + "Încorporează această hartă pe site-ul tău", + "Fă o listă cu site-urile din orașul tău", + "Învață-i să o încorporeze și ei", + "Învață-i să îi formeze pe alții să o încorporeze", + "Să multiplicăm hărți pretutindeni pentru Isus!" + ] } diff --git a/library/i18n/locales/ro/legend.json b/library/i18n/locales/ro/legend.json index 789d5397e..022f172da 100644 --- a/library/i18n/locales/ro/legend.json +++ b/library/i18n/locales/ro/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "Legendă", - "peopleGroupFallback": "Grup de popoare" + "peopleGroupFallback": "Grup de popoare", + "default": "Legendă" }, "prayer": { "needsPrayer": "Necesită rugăciune", @@ -33,9 +34,25 @@ }, "columns": { "upgs": "UUPGs", - "population": "Populație" + "population": "Populație", + "name": "Nume" }, "footer": { "total": "Total" + }, + "sortByUpgs": "Sortează după UUPG", + "sortByPop": "Sortează după populație", + "colLabel": { + "religion": "Religii UUPG", + "prayer": "Starea rugăciunii", + "engagement": "Starea implicării", + "adoption": "Starea adopției", + "status": "Stare" + }, + "titleBar": { + "legend": "Legendă", + "peopleGroups": "Popoare", + "wagfRegions": "Regiuni WAGF", + "languages": "Limbi" } } diff --git a/library/i18n/locales/ro/popup.json b/library/i18n/locales/ro/popup.json new file mode 100644 index 000000000..d6fbe5702 --- /dev/null +++ b/library/i18n/locales/ro/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "Regiunea WAGF", + "wagfBlock": "Blocul WAGF", + "country": "Țară", + "uupgCount": "Număr de UUPG" + } +} diff --git a/library/i18n/locales/ro/search.json b/library/i18n/locales/ro/search.json index fbdcc9626..9788399e4 100644 --- a/library/i18n/locales/ro/search.json +++ b/library/i18n/locales/ro/search.json @@ -5,5 +5,21 @@ "place": "Loc:", "language": "Limbă:", "religion": "Religie:" + }, + "sections": { + "within": "În {selection}", + "allData": "Toate datele DOXA" + }, + "kinds": { + "people": "Popor + țară", + "peopleGroup": "Popor", + "bloc": "Bloc de afinitate", + "cluster": "Grup de popoare", + "country": "Țară", + "region": "Regiune", + "family": "Familie de limbi", + "languageKind": "Limbă", + "dialect": "Dialect", + "religionKind": "Religie" } } diff --git a/library/i18n/locales/ro/tabs.json b/library/i18n/locales/ro/tabs.json new file mode 100644 index 000000000..00bebefd5 --- /dev/null +++ b/library/i18n/locales/ro/tabs.json @@ -0,0 +1,20 @@ +{ + "prayer": "Rugăciune", + "adoption": "Adopție", + "engagement": "Implicare", + "affinity-blocks": "Popoare", + "regions": "Regiuni", + "countries": "Țări", + "language-families": "Limbi", + "religion": "Religii", + "family": "Familie de limbi", + "language": "Limbă", + "dialect": "Dialect/Variantă", + "bloc": "Bloc de afinitate", + "cluster": "Grup", + "group": "Popor", + "pgic": "Popor în țară", + "region": "Regiune WAGF", + "block": "Bloc WAGF", + "country": "Țară" +} diff --git a/library/i18n/locales/ru/buttons.json b/library/i18n/locales/ru/buttons.json index ee324c04b..3e394f6d6 100644 --- a/library/i18n/locales/ru/buttons.json +++ b/library/i18n/locales/ru/buttons.json @@ -21,5 +21,20 @@ "normal": "Нормально", "fast": "Быстро" }, - "shareEmbed": "Поделиться и встроить" + "shareEmbed": "Поделиться и встроить", + "posterMap": "Карта плаката", + "prayerList": "Молитвенный список", + "cardDeck": "Набор карточек", + "printOptions": "Параметры печати", + "showOnMap": "Показать на карте", + "close": "Закрыть", + "showOnMapAria": "Показать {label} на карте", + "share": "Поделиться", + "shareSteps": [ + "Разместите эту карту на своём сайте", + "Составьте список сайтов вашего города", + "Научите их тоже размещать её", + "Научите их учить других размещать её", + "Умножим карты повсюду для Иисуса!" + ] } diff --git a/library/i18n/locales/ru/legend.json b/library/i18n/locales/ru/legend.json index de624e865..2f8300c42 100644 --- a/library/i18n/locales/ru/legend.json +++ b/library/i18n/locales/ru/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "Условные обозначения", - "peopleGroupFallback": "Народная группа" + "peopleGroupFallback": "Народная группа", + "default": "Легенда" }, "prayer": { "needsPrayer": "Нуждается в молитве", @@ -54,4 +55,4 @@ "wagfRegions": "Регионы WAGF", "languages": "Языки" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/ru/popup.json b/library/i18n/locales/ru/popup.json new file mode 100644 index 000000000..2a43a7804 --- /dev/null +++ b/library/i18n/locales/ru/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "Регион WAGF", + "wagfBlock": "Блок WAGF", + "country": "Страна", + "uupgCount": "Число UUPG" + } +} diff --git a/library/i18n/locales/ru/regions.json b/library/i18n/locales/ru/regions.json index 6f384d97e..3a7ecbde4 100644 --- a/library/i18n/locales/ru/regions.json +++ b/library/i18n/locales/ru/regions.json @@ -49,5 +49,8 @@ "WAGF Block": "Блок WAGF", "WAGF Region": "Регион WAGF", "people groups adopted": "принятые группы людей", - "people groups engaged": "вовлечённые группы людей" + "people groups engaged": "вовлечённые группы людей", + "View country posters": "Показать плакаты страны", + "Hide country posters": "Скрыть плакаты страны", + "No poster has been generated for this country yet.": "Для этой страны плакат ещё не создан." } diff --git a/library/i18n/locales/ru/search.json b/library/i18n/locales/ru/search.json index 4813b800c..2fda3ac1a 100644 --- a/library/i18n/locales/ru/search.json +++ b/library/i18n/locales/ru/search.json @@ -17,5 +17,9 @@ "languageKind": "Язык", "dialect": "Диалект", "religionKind": "Религия" + }, + "sections": { + "within": "В пределах {selection}", + "allData": "Все данные DOXA" } -} \ No newline at end of file +} diff --git a/library/i18n/locales/zh/buttons.json b/library/i18n/locales/zh/buttons.json index b86977747..bb5e6587f 100644 --- a/library/i18n/locales/zh/buttons.json +++ b/library/i18n/locales/zh/buttons.json @@ -21,5 +21,20 @@ "normal": "正常", "fast": "快" }, - "shareEmbed": "分享与嵌入" + "shareEmbed": "分享与嵌入", + "posterMap": "海报地图", + "prayerList": "禱告名單", + "cardDeck": "禱告卡組", + "printOptions": "列印選項", + "showOnMap": "在地圖上顯示", + "close": "關閉", + "showOnMapAria": "在地圖上顯示 {label}", + "share": "分享", + "shareSteps": [ + "把這張地圖嵌入你的網站", + "列出你城市裡的網站", + "教他們也嵌入這張地圖", + "教他們去教別人嵌入", + "為耶穌讓地圖遍地繁增!" + ] } diff --git a/library/i18n/locales/zh/legend.json b/library/i18n/locales/zh/legend.json index ec02ea002..68050444d 100644 --- a/library/i18n/locales/zh/legend.json +++ b/library/i18n/locales/zh/legend.json @@ -10,7 +10,8 @@ }, "header": { "collapsed": "图例", - "peopleGroupFallback": "民族群体" + "peopleGroupFallback": "民族群体", + "default": "圖例" }, "prayer": { "needsPrayer": "需要祷告", @@ -33,9 +34,25 @@ }, "columns": { "upgs": "UUPGs", - "population": "人口" + "population": "人口", + "name": "名称" }, "footer": { "total": "总计" + }, + "sortByUpgs": "按 UUPG 排序", + "sortByPop": "按人口排序", + "colLabel": { + "religion": "UUPG 宗教", + "prayer": "祷告状态", + "engagement": "参与状态", + "adoption": "认领状态", + "status": "状态" + }, + "titleBar": { + "legend": "图例", + "peopleGroups": "民族", + "wagfRegions": "WAGF 区域", + "languages": "语言" } } diff --git a/library/i18n/locales/zh/popup.json b/library/i18n/locales/zh/popup.json new file mode 100644 index 000000000..f6da3cf9d --- /dev/null +++ b/library/i18n/locales/zh/popup.json @@ -0,0 +1,8 @@ +{ + "country": { + "wagfRegion": "WAGF 地區", + "wagfBlock": "WAGF 區塊", + "country": "國家", + "uupgCount": "UUPG 數量" + } +} diff --git a/library/i18n/locales/zh/search.json b/library/i18n/locales/zh/search.json index a4351b876..ff52aedb2 100644 --- a/library/i18n/locales/zh/search.json +++ b/library/i18n/locales/zh/search.json @@ -5,5 +5,21 @@ "place": "地点:", "language": "语言:", "religion": "宗教:" + }, + "sections": { + "within": "在 {selection} 之內", + "allData": "所有 DOXA 資料" + }, + "kinds": { + "people": "民族 + 国家", + "peopleGroup": "民族", + "bloc": "亲缘板块", + "cluster": "民族群", + "country": "国家", + "region": "区域", + "family": "语系", + "languageKind": "语言", + "dialect": "方言", + "religionKind": "宗教" } } diff --git a/library/i18n/locales/zh/tabs.json b/library/i18n/locales/zh/tabs.json new file mode 100644 index 000000000..b5a8a17ae --- /dev/null +++ b/library/i18n/locales/zh/tabs.json @@ -0,0 +1,20 @@ +{ + "prayer": "祷告", + "adoption": "认领", + "engagement": "参与", + "affinity-blocks": "民族", + "regions": "区域", + "countries": "国家", + "language-families": "语言", + "religion": "宗教", + "family": "语系", + "language": "语言", + "dialect": "方言/变体", + "bloc": "亲缘板块", + "cluster": "民族群", + "group": "民族", + "pgic": "国内民族", + "region": "WAGF 区域", + "block": "WAGF 板块", + "country": "国家" +} From 51a56366010e6332f45247d0dd8245125fd5aedb Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:27:48 +0000 Subject: [PATCH 10/10] Make the last two gates repo-relative; report the library divergence they expose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-locale-reachable and check-poster-prints defaulted REPO to a hardcoded wt-packet path, so like check-i18n-parity before them they graded the OTHER repo's files and printed PASS for a tree they never opened. These two are the third and fourth instance of that pattern. Now repo-relative, so check:no-abs-paths — which DOES gate this repo's build — stays green. Reading its own tree, check-poster-prints now fails honestly: usePosterPrints.js here has no printPacket. That is real, and it is NOT fixed here, because the fix that worked for the locale catalogs would be destructive for this code. All six diverged library/ files have lines that exist only in this repo — two-way divergence, not staleness — so a wholesale copy would delete studio-side work. What made the catalog sync safe was the measurement that nothing would be lost, not the resemblance. Recorded in internal/FINDING-library-diverged-from-wt-packet.md with the per-file line counts and the decision a resolver needs to make. None of these four gates run in this repo's gates:post, so nothing is newly blocked; they are runnable by hand and now honest, which beats passing by reading another repo. --- ...FINDING-library-diverged-from-wt-packet.md | 68 +++++++++++++++++++ internal/check-locale-reachable.mjs | 9 ++- internal/check-poster-prints.mjs | 10 ++- 3 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 internal/FINDING-library-diverged-from-wt-packet.md diff --git a/internal/FINDING-library-diverged-from-wt-packet.md b/internal/FINDING-library-diverged-from-wt-packet.md new file mode 100644 index 000000000..edf8d57bf --- /dev/null +++ b/internal/FINDING-library-diverged-from-wt-packet.md @@ -0,0 +1,68 @@ +# FINDING — library/ has diverged two-ways from wt-packet, and absolute paths were hiding it + +**Found** 2026-09-24, while making `check-i18n-parity` repo-relative. +**Status:** path bugs FIXED; the code divergence is REPORTED, NOT RESOLVED — it needs a decision. + +## What was hiding it + +Three gates in this repo defaulted `REPO` to a hardcoded +`/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet`: + +- `check-i18n-parity.mjs` +- `check-locale-reachable.mjs` +- `check-poster-prints.mjs` + +`doxa-maps-plugin-studio` and `wt-packet` are **separate repos, not worktrees of one**. So each of +these gates opened the *other* repo, graded *its* files, and printed PASS for a tree it had never +read. Not a portability nit — a gate pointed at the wrong target reports the target's health while +saying nothing about its subject. All three are now +`resolve(dirname(fileURLToPath(import.meta.url)), '..')`. + +The moment they read their own tree, two real problems surfaced. + +## 1. The locale tree — FIXED (it was a strict subset) + +`it/ro/zh` were 103 keys behind, and every other locale was short too, including +`buttons.json:share` and `shareSteps` — the exact orphan `check-i18n-parity`'s own header describes +it as having been written to catch. The bug it exists for had reappeared in a second repo, unseen. + +Before copying anything, the direction was **measured**: zero keys existed only here, zero +translations disagreed. A strict subset, so a forward copy was provably lossless. Both repos now +report `162 key(s) of known translation debt` — `regions.json` in it/ro/zh, and nothing else. + +## 2. library/ — NOT FIXED, and must NOT be copied the same way + +`check-poster-prints` fails here with `pk.printPacket is not a function`: this repo's +`usePosterPrints.js` predates the prayer-packet action. The tempting move is the one that worked +for the catalogs — copy wt-packet's version over. **Do not**, without reading each file. + +The same measurement that cleared the catalogs *fails* for these. Every one of the six has lines +that exist ONLY here: + +| file | lines only in wt-packet | lines only in studio | +|---|---|---| +| `library/components/poster/PosterWall.vue` | 12 | 9 | +| `library/composables/useCountryOutline.js` | 45 | 3 | +| `library/composables/useMapClustering.js` | 1 | 1 | +| `library/composables/useMapLayers.js` | 46 | 9 | +| `library/composables/useMapProvider.js` | 16 | 3 | +| `library/composables/usePosterPrints.js` | 33 | 1 | + +That is **two-way divergence, not staleness**. A wholesale copy would silently delete studio-side +work — the identical action that was correct for `locales/` is destructive here. What made the +locale sync safe was not that it looked similar; it was the proof that nothing would be lost. + +## What a resolver needs to decide + +1. Is this repo a **consumer** of wt-packet's library (→ reconcile per hunk, then keep it synced by + a mechanism rather than by hand), or does it legitimately **own** a fork of these six files + (→ then the shared gates should not be shared, and this finding closes)? +2. `usePosterPrints.js` is the concrete instance: is `printPacket` wanted here? If yes, port it + with the 1 studio-only line preserved. If no, `check:poster-prints` should not run in this repo. +3. `check:poster-prints`, `check:locale-reachable`, `check:i18n-parity` and `check:tabs-localized` + are all present here but **absent from `gates:post`**, so none of them gate this repo's build. + `check:no-abs-paths` DOES run, which is why the path fixes were required rather than optional. + +Until (1) is answered, the four gates above are runnable by hand and honest about what they find — +which is strictly better than the previous state, where three of them passed by reading someone +else's files. diff --git a/internal/check-locale-reachable.mjs b/internal/check-locale-reachable.mjs index d3454e888..e0d777eb9 100644 --- a/internal/check-locale-reachable.mjs +++ b/internal/check-locale-reachable.mjs @@ -15,10 +15,15 @@ // path (Polylang on WordPress) keeps working. If someone later makes the pages // lang-less, the URL path alone would silently become the only way in. import { readFileSync, readdirSync, existsSync } from 'node:fs' -import { resolve, join } from 'node:path' +import { resolve, join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' const REPO = process.env.DOXA_REPO - || resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet') + // FOURTH instance of this pattern in one flight (check-zone-census, check-share-policy, + // check-poster-prints, now this). DOXA_REPO still wins; only the default becomes portable, + // per the idiom check-no-absolute-paths.mjs prints in its own failure text. A gate that + // hardcodes its author's worktree turns build:unlocked red for every other builder. + || resolve(dirname(fileURLToPath(import.meta.url)), '..') const src = readFileSync(join(REPO, 'library/i18n/index.js'), 'utf8') const SUPPORTED = (src.match(/SUPPORTED_LOCALES = \[([^\]]*)\]/) || [])[1] diff --git a/internal/check-poster-prints.mjs b/internal/check-poster-prints.mjs index f63988ab1..c3f15377a 100644 --- a/internal/check-poster-prints.mjs +++ b/internal/check-poster-prints.mjs @@ -19,10 +19,16 @@ // bundle's API being reachable. import { chromium } from 'playwright' import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' const REPO = process.env.DOXA_REPO - || resolve('/home/lk10x/Workspace/eco-dx/root-doxa-maps/wt-packet') + // Third instance of one pattern this flight (after check-zone-census and + // check-share-policy): a gate authored in one worktree hardcodes THAT worktree as its + // fallback, and check:no-abs-paths — which is inside build:unlocked — turns red for every + // other builder. DOXA_REPO still wins; only the default is made portable, using the idiom + // check-no-absolute-paths.mjs prints in its own failure text. + || resolve(dirname(fileURLToPath(import.meta.url)), '..') const src = readFileSync(resolve(REPO, 'library/composables/usePosterPrints.js'), 'utf8') const frame = readFileSync(resolve(REPO, 'library/composables/printInPlace.js'), 'utf8') // The composable dynamically imports its renderers by RELATIVE path. Under a blob