diff --git a/deployment/src-tauri/README.md b/deployment/src-tauri/README.md index 18ed859b..3694170a 100644 --- a/deployment/src-tauri/README.md +++ b/deployment/src-tauri/README.md @@ -49,9 +49,9 @@ the Tauri origin** — exactly the surprise `capacitor.config.json` documents fo **The fix is a response header on the token host, not a change in `tauri.conf.json`.** Measured 2026-09-21: `https://doxa.life/api/maps/token` → **200**; -`https://nuxtbase.app/api/maps/token` → **404**. A desktop build pointed at nuxtbase would launch +the test host's `/api/maps/token` → **404**. A desktop build pointed at that host would launch with blank basemaps even though the window opens and the element mounts correctly. Separately, the -Mapbox token is **URL-restricted**: with a `nuxtbase.app` Referer the style document returns 200 but +Mapbox token is **URL-restricted**: with the test host as Referer the style document returns 200 but every **tile 403s**, while the same token with a `doxa.life` Referer returns 200. Verify the token path before blaming the shell. diff --git a/deployment/wordpress/doxa-maps-embed/HOW-TO-TEST.md b/deployment/wordpress/doxa-maps-embed/HOW-TO-TEST.md index f6428af7..5c35681b 100644 --- a/deployment/wordpress/doxa-maps-embed/HOW-TO-TEST.md +++ b/deployment/wordpress/doxa-maps-embed/HOW-TO-TEST.md @@ -28,7 +28,7 @@ Then in wp-admin (http://localhost:8888/wp-admin, admin/password): 1. Plugins → activate **Doxa Maps Embed**. 2. Settings → **Doxa Maps** → set the catalog base to a real served tree, e.g. - `https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / doxa-base Forgejo host). + `https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / a Forgejo host you run). 3. Create a page and add the shortcode: ``` diff --git a/deployment/wordpress/tests/wp-embed-contract-test.php b/deployment/wordpress/tests/wp-embed-contract-test.php index 7c018d26..ea279adf 100644 --- a/deployment/wordpress/tests/wp-embed-contract-test.php +++ b/deployment/wordpress/tests/wp-embed-contract-test.php @@ -52,7 +52,7 @@ $out = do_shortcode_call('doxa_map', [ 'bundle' => $target['bundle'], 'element' => $prof['element'], 'profile' => $prof['id'] ?? $prof['name'] ?? $target['bundle'], - 'base' => 'https://nuxtbase.app/plugin-assets/'.$tree, + 'base' => 'https://cdn.example.org/plugin-assets/'.$tree, 'integrity' => $integrity, ]); global $SCRIPTS, $INLINE; @@ -76,7 +76,7 @@ $cfg = json_decode($json, true); $assert('profile-config is valid JSON', is_array($cfg), $json ? substr($json,0,60) : 'no match'); // 3. the script src equals the manifest's catalog path for this bundle $src = reset($SCRIPTS)['src'] ?? ''; -$want = "https://nuxtbase.app/plugin-assets/{$tree}/{$target['bundle']}/{$target['bundle']}.js"; +$want = "https://cdn.example.org/plugin-assets/{$tree}/{$target['bundle']}/{$target['bundle']}.js"; $assert('script src matches catalog path', $src === $want, $src); // 4. SRI: the filter must add integrity to the emitted ", $h2, 'x'); diff --git a/documentation/README-embed-gospel-resources.md b/documentation/README-embed-gospel-resources.md index f7f7d32e..ff06c2e5 100644 --- a/documentation/README-embed-gospel-resources.md +++ b/documentation/README-embed-gospel-resources.md @@ -9,7 +9,7 @@ again, so every parameter below is a promise kept by a gate style="width:100%;height:600px;border:0" title="Gospel access"> ``` -## The four addresses +## The five addresses | Address | What the reader gets | |---|---| @@ -17,6 +17,7 @@ again, so every parameter below is a promise kept by a gate | `?country=` | That country's sheet open, framed on its groups. The country's own name also works (`?country=India`). | | `?code=` | The people group's sheet, with its resources. `&name=` and `&country=` disambiguate when a language has many groups. | | `?view=zero` | The groups with **nothing found** — the question this map exists for. `?view=gap` is the same view. | +| `?tab=province&province=` | On the data tables: that state's row — groups, people, what has nothing, the local way in, and who nobody is praying for. Accents are folded, so `Arunachal Pradesh` finds `Arunāchal Pradesh`. | `?provider=maplibre` is not this map's parameter but the whole fleet's: it selects the keyless engine, and no map ever substitutes a different one (`bun run check:engine-contract`). diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index f79a1bde..798be755 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -147,6 +147,8 @@ and a change here can break other maps. `import … from '@map/composables/useMapSelection.js'` - **useMapTheme** — useMapTheme.js — shared light/dark theme for every map profile. `import … from '@map/composables/useMapTheme.js'` +- **useOptionalT** — ★ NO .-SHAPED EXAMPLES IN THIS FILE'S COMMENTS. check:i18n-keys-exist reads any such + `import … from '@map/composables/useOptionalT.js'` - **usePosterPrints** — usePosterPrints.js — THE print actions every poster wall shares. `import … from '@map/composables/usePosterPrints.js'` - **usePplrInstance** — usePplrInstance.js — per-map "instance store" mediator. diff --git a/internal/check-approx-pin-note.mjs b/internal/check-approx-pin-note.mjs index 3de198bc..88f0eede 100644 --- a/internal/check-approx-pin-note.mjs +++ b/internal/check-approx-pin-note.mjs @@ -29,6 +29,14 @@ check('three groups on one point find each other', colocatedWith(idx, rows[0]).s check('a GeoJSON feature is matched to rows on the same point', colocatedWith(idx, rows[2]).sort().join() === 'Godda,Koraga', colocatedWith(idx, rows[2])) check('a lone point shares with nobody', colocatedWith(idx, rows[3]).length === 0, colocatedWith(idx, rows[3])) check('a row without a position is ignored', colocatedWith(idx, rows[4]).length === 0, colocatedWith(idx, rows[4])) +// A click hands over the RENDERED feature: the engine rebuilt its geometry from tile coordinates, so it is +// near the roster point but not equal to it: ~0.001 degree at low zoom (rega, row 46, a shared point that never matched). +const rendered = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [75.2486, 12.8431] } } +check('a rendered (tile-rounded) feature still finds its co-located groups', colocatedWith(idx, rendered).sort().join() === 'Godda,Maila', colocatedWith(idx, rendered)) +// The same people in two countries: the nearest roster row wins, not the first. +const twin = buildColocationIndex([...rows, { name: 'Koraga', latitude: 40, longitude: 40 }, { name: 'Other', latitude: 40, longitude: 40 }]) +const renderedFar = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [40.0007, 39.9994] } } +check('a repeated name resolves to the nearest roster point', colocatedWith(twin, renderedFar).join() === 'Other', colocatedWith(twin, renderedFar)) const { createServer } = await import('vite') const { default: vue } = await import('@vitejs/plugin-vue') diff --git a/internal/check-gospel-embed-contract.mjs b/internal/check-gospel-embed-contract.mjs index 3d6f072f..e16a02a8 100644 --- a/internal/check-gospel-embed-contract.mjs +++ b/internal/check-gospel-embed-contract.mjs @@ -108,7 +108,43 @@ try { if (!c.mounted) fails.push('?country=ZZZ: the leaf did not mount — a bad parameter must never break the embed') else console.log(' ok ?country=ZZZ'.padEnd(34) + 'unknown country ignored, leaf still renders') - /* 4 — the view that already existed, kept honest */ + /* 4 — a province deep-links into the TABLE, diacritics folded. The map writes "Arunāchal + Pradesh" (geoBoundaries' shapeName); a partner or a reader types the plain ASCII, and a link + that only works for whoever copied it off the map is not an address. */ + const prov = JSON.parse(readFileSync(resolve(PUBLIC, TREE_NAME, 'assets/tables/gospel-by-province.json'), 'utf8')) + const accented = (prov.rows || []).map((r) => r.province).find((n) => /[^\x00-\x7F]/.test(n)) + if (!accented) fails.push('no accented province name in the table — the diacritic arm would pass vacuously') + else { + const plain = accented.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + const tbl = `${BASE}/${TREE_NAME}/gospel-resources/data-tables/index.html` + const readTable = async (query) => { + await page.goto(`${tbl}${query}`, { waitUntil: 'domcontentloaded', timeout: 60000 }) + await page.waitForTimeout(12000) + return page.evaluate((tag) => { + /* Every leaf in this bundle is served through the SAME custom element; the profile is what + differs. Guessing a per-leaf tag name here returned null and read as "0 rows". */ + const root = document.querySelector(tag)?.shadowRoot || document + return { + rows: root.querySelectorAll('.tabulator-row').length, + /* NOT sliced. The first version cut this to 300 characters and then asked whether the + province name was in it: the row was on screen, 400 characters down, and the gate + reported the product broken. A slice belongs in the MESSAGE, never in the predicate. */ + text: (root.textContent || '').replace(/\s+/g, ' ').trim(), + } + }, TAG) + } + const hit = await readTable(`?tab=province&province=${encodeURIComponent(plain)}`) + if (hit.rows !== 1) fails.push(`?province=${plain}: ${hit.rows} row(s) rendered, expected exactly 1 — the diacritic fold is not working`) + else if (!hit.text.includes(accented)) fails.push(`?province=${plain}: the row rendered but "${accented}" is not on screen`) + else console.log(` ok ?tab=province`.padEnd(34) + `"${plain}" found "${accented}", 1 row`) + + const miss = await readTable('?tab=province&province=Nowhereshire') + if (miss.rows !== 0) fails.push(`?province=Nowhereshire: ${miss.rows} row(s) — an unknown province must match nothing, not everything`) + else if (!/\b0\b/.test(miss.text)) fails.push('?province=Nowhereshire: nothing on screen tells the reader the filter matched nothing') + else console.log(' ok ?tab=province (unknown)'.padEnd(34) + 'matches nothing and says so') + } + + /* 5 — the view that already existed, kept honest */ const d = await read(page, '?view=zero') if (!d.mounted) fails.push('?view=zero: the leaf did not mount') else if (!d.text.length) fails.push('?view=zero: the leaf rendered nothing') @@ -125,7 +161,7 @@ try { for (const f of fails) console.error(` ${f}`) process.exit(1) } - console.log(`EMBED-CONTRACT: PASS — ${matched} of ${total} pins for a three-code link, a country opens from its ISO3 alone, an unknown one is ignored, and ?view=zero still renders.`) + console.log(`EMBED-CONTRACT: PASS — ${matched} of ${total} pins for a three-code link; a country opens from its ISO3 alone and an unknown one is ignored; a province deep-links into the table with its diacritics folded and an unknown one matches nothing; ?view=zero still renders.`) } finally { try { await browser?.close() } catch (_) {} try { srv?.close?.() } catch (_) {} diff --git a/internal/check-legend-logical-insets.mjs b/internal/check-legend-logical-insets.mjs new file mode 100644 index 00000000..8f7bc516 --- /dev/null +++ b/internal/check-legend-logical-insets.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * check-legend-logical-insets.mjs — the legend components position themselves with LOGICAL insets. + * + * WHY: the shared legend (.stl-panel, its reopen button, the detail buttons, the tree-guide hairlines) used + * `left: 8px`. MapToolbar uses `inset-inline-end`, which mirrors under dir="rtl"; a physical `left` does not. + * So in Arabic the controls moved left onto a legend that stayed left, on three production maps at once + * (Simple Research, Prayer Progress, UPG-100; rega's survey at 52eeb0db). The fix is inset-inline-start / + * inset-inline-end; this gate keeps a physical left/right from coming back. + * + * WHAT IT READS: every CSS declaration in library/components/legends/*.vue (comments stripped). A + * declaration `left:` / `right:` / `padding-left|right:` / `margin-left|right:` fails unless it is: + * - a centring or full-width rule, which is symmetric under RTL (`left:50%`, `left:0` beside `right:0`); + * - a reset to auto (`left:auto`), which undoes rather than places; + * - listed in ALLOWED below with the reason it may stay physical. + * Inline :style bindings computed in JS (the progress bar's popover) are not CSS declarations and are not read. + * + * --self-test poisons a copy of one rule and asserts the gate notices: a gate that cannot fail proves nothing. + */ +import { readFileSync, readdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const DIR = join(ROOT, 'library/components/legends') + +// file -> substrings of declarations that may stay physical, each with its reason. +const ALLOWED = { + 'LegendMobile.vue': [ + ['.mapboxgl-ctrl-attrib.mapboxgl-compact{margin-right', "the engine's own attribution control, which the engine places"], + ], + 'LegendProgressBar.vue': [ + ['.lpb-pop{', 'the popover is placed by JS (popLeft) over the bar segment it describes'], + ], +} + +const PHYSICAL = /(^|[;{\s])((?:padding|margin)-(?:left|right)|left|right)\s*:\s*([^;}]+)/g + +function findings(file, src) { + const out = [] + const styles = [...src.matchAll(/]*>([\s\S]*?)<\/style>|`([^`]*\{[^`]*\}[^`]*)`/g)].map((m) => m[1] ?? m[2]) + const allowed = ALLOWED[file] || [] + for (const css of styles) { + const clean = css.replace(/\/\*[\s\S]*?\*\//g, '') + for (const rule of clean.split('}')) { + const [sel = '', body = ''] = rule.split('{') + const decl = sel.trim() + '{' + body + for (const m of body.matchAll(PHYSICAL)) { + const prop = m[2], val = m[3].trim().replace(/!important$/, '').trim() + if (val === 'auto') continue + if ((prop === 'left' || prop === 'right') && /^50%$/.test(val)) continue + if (prop === 'left' && val === '0' && /right\s*:\s*0\b/.test(body)) continue + if (prop === 'right' && val === '0' && /left\s*:\s*0\b/.test(body)) continue + if (allowed.some(([s]) => decl.replace(/\s+/g, '').includes(s.replace(/\s+/g, '')))) continue + out.push(`${file}: ${sel.trim().replace(/\s+/g, ' ').slice(0, 70)} { ${prop}: ${val} }`) + } + } + } + return out +} + +function run(read) { + const all = [] + for (const f of readdirSync(DIR).filter((f) => f.endsWith('.vue')).sort()) all.push(...findings(f, read(f))) + return all +} + +const readReal = (f) => readFileSync(join(DIR, f), 'utf8') + +if (process.argv.includes('--self-test')) { + const poisoned = (f) => { + const s = readReal(f) + return f === 'SemanticTreeLegend.vue' ? s.replace('inset-inline-start: 8px; top: var(--stl-top', 'left: 8px; top: var(--stl-top') : s + } + const hit = run(poisoned) + if (!hit.some((h) => h.includes('.stl-panel') && h.includes('left: 8px'))) { + console.error('LEGEND-LOGICAL-INSETS self-test: FAIL — a poisoned `.stl-panel { left: 8px }` was not caught') + process.exit(1) + } + console.log('LEGEND-LOGICAL-INSETS self-test: PASS — a physical `left: 8px` on .stl-panel is caught') + process.exit(0) +} + +const bad = run(readReal) +if (bad.length) { + console.error(`LEGEND-LOGICAL-INSETS: FAIL — ${bad.length} physical left/right declaration(s) in the legend components:`) + for (const b of bad) console.error(' ✗ ' + b) + console.error('\n A physical inset does not mirror under dir="rtl" while the map controls (inset-inline-end) do,') + console.error(' so in Arabic they overlap. Use inset-inline-start / inset-inline-end (padding-/margin-inline-*),') + console.error(' or add the rule to ALLOWED in this file with the reason it must stay physical.') + process.exit(1) +} +console.log('LEGEND-LOGICAL-INSETS: PASS — every legend inset is logical, symmetric, or allowed with a reason.') diff --git a/internal/check-optional-t-equivalence.mjs b/internal/check-optional-t-equivalence.mjs new file mode 100644 index 00000000..7c1e1cf2 --- /dev/null +++ b/internal/check-optional-t-equivalence.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +/* check-optional-t-equivalence.mjs — `bun run check:optional-t-equivalence`. Source-only, headless. + EVERY LATER C4 WAVE EXTENDS THIS FILE (dx-shepherd, REGC4G): a wave that migrates a site without + adding its row here is a wave whose behaviour nobody re-checks. Sections 1-6 are the helper's own + contract; section 7 is the per-site register. */ +// C4: does useOptionalT() behave like the twenty hand-written guarded translators it replaces? +// +// Same condition the shepherd set for C2 and C3: old pattern vs new, every branch, negative-tested. +// The old shape, verbatim from usePplrT.js before this migration: +// const enLookup = (key) => String(key).replace(/^pplr\./,'').split('.').reduce(...enPplr) +// const fill = (str, params) => params ? str.replace(/\{(\w+)\}/g, ...) : str +// try { const i18n = useI18n(); return (k,p) => { const v = i18n.t(k,p||{}); return v===k ? fill(enLookup(k) ?? k, p) : v } } +// catch (_) { return (k,p) => fill(enLookup(k) ?? String(k), p) } +// +// ★ WHY THIS CANNOT JUST IMPORT THE MODULE. useOptionalT.js imports vue-i18n and @map/i18n/index.js, +// and that index calls `import.meta.glob` — a Vite transform plain Node cannot run. So the source is +// EXTRACTED AND EXECUTED with tStatic and useI18n stubbed, the same approach check-provider-for.mjs +// uses for engineGlobal, and for the same reason: re-typing the function into the gate would test the +// copy rather than the thing that ships. +import { readFileSync } from 'node:fs' +import { codeOnly } from '../plugin-designs/regions-dashboard/scripts/strip-code.mjs' + +let fails = 0 +const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ } + +const src = readFileSync(new URL('../library/composables/useOptionalT.js', import.meta.url), 'utf8') +const body = src + .replace(/^import .*$/gm, '') // drop the two un-runnable imports + .replace(/^export default .*$/gm, '') // and the default re-export (it is not a declaration) + .replace(/^export /gm, '') +const make = (tStatic, useI18n) => new Function('tStatic', 'useI18n', + body + '\nreturn { useOptionalT, useOptionalI18n, fillParams, qualify };')(tStatic, useI18n) + +// The English catalogue the old code imported, as a stub tree: messages.en.pplr equivalent. +const EN = { greeting: 'Hello {name}', nested: { deep: 'Deep value' } } +const tStatic = (path) => String(path).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN) +const throwingI18n = () => { throw new Error('Need to install with `app.use` function') } +const hostI18n = (table) => () => ({ t: (k) => (k in table ? table[k] : k) }) + +// ── 1. no i18n on the host — the case seven bundles are actually in ───────────────────────────── +{ + const { useOptionalT } = make(tStatic, throwingI18n) + const t = useOptionalT('pplr') + ok(t('greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: falls back to the catalogue AND interpolates') + ok(t('nested.deep') === 'Deep value', 'bare host: resolves a nested key') + ok(t('missing.key') === 'missing.key', 'bare host: a missing key renders as the KEY, never blank') + ok(t('pplr.greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: an already-qualified key is not double-prefixed') +} +// ── 2. host HAS i18n and has the string — the host must win ───────────────────────────────────── +{ + const { useOptionalT } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' })) + ok(useOptionalT('pplr')('greeting') === 'Bonjour', 'host i18n wins when it has the key -- a host managing locale keeps control') +} +// ── 3. host HAS i18n but LACKS the string: i18n.t returns the key unchanged ───────────────────── +{ + const { useOptionalT } = make(tStatic, hostI18n({})) + const t = useOptionalT('pplr') + ok(t('greeting', { name: 'Ada' }) === 'Hello Ada', + 'host i18n present but missing the key falls through to the catalogue -- the case every old copy handled with `v === key`') +} +// ── 4. the pure helpers ──────────────────────────────────────────────────────────────────────── +{ + const { fillParams, qualify } = make(tStatic, throwingI18n) + ok(fillParams('a {x} b', { x: 1 }) === 'a 1 b', 'fillParams interpolates') + ok(fillParams('a {x} b', {}) === 'a {x} b', 'fillParams leaves an unknown placeholder visible rather than blanking it') + ok(fillParams('a {x} b', { x: null }) === 'a {x} b', + 'fillParams leaves the placeholder for a NULL value -- a key-presence test would render the string "null" into the sentence') + ok(fillParams('a {x} b', { x: 0 }) === 'a 0 b', + 'but 0 is a real value and must still substitute (a truthiness test would drop it)') + ok(fillParams('plain') === 'plain', 'fillParams with no params is a no-op') + ok(qualify('pplr', 'x') === 'pplr.x' && qualify('pplr', 'pplr.x') === 'pplr.x', 'qualify prefixes once, never twice') +} +// ── 5. EQUIVALENCE with the old hand-written implementation, branch for branch ────────────────── +{ + const fill = (s, p) => (p ? String(s).replace(/\{(\w+)\}/g, (m, k) => (k in p ? p[k] : m)) : s) + const enLookup = (key) => String(key).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN) + const oldBare = (k, p) => fill(enLookup(k) ?? String(k), p) + const { useOptionalT } = make(tStatic, throwingI18n) + const neu = useOptionalT('pplr') + for (const [k, p] of [['greeting', { name: 'Ada' }], ['nested.deep', null], ['missing.key', null], ['greeting', null]]) { + ok(oldBare(k, p) === neu(k, p), `EQUIVALENT on the bare-host path for ${JSON.stringify(k)}: both give ${JSON.stringify(oldBare(k, p))}`) + } +} +// ── 6. NEG CONTROLS ──────────────────────────────────────────────────────────────────────────── +{ + const { useOptionalT } = make(() => undefined, throwingI18n) + ok(useOptionalT('pplr')('greeting') === 'greeting', + 'NEG CONTROL: with a catalogue that knows nothing, the key comes back -- so the passes above were really reading the catalogue') + const { useOptionalT: u2 } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' })) + ok(u2('pplr')('greeting') !== 'Hello Ada', 'NEG CONTROL: the host path and the catalogue path are distinguishable') +} +ok(/import \{ tStatic \}/.test(src), 'CONTROL: the shipped module really does route through tStatic (not a catalogue import of its own)') +// ★ codeOnly, because this assertion failed on its OWN DOCUMENTATION: useOptionalT.js's header QUOTES +// '@map/i18n/locales/en/pplr.json' as the import it removed, and a whole-file grep counted the prose as +// the thing it forbids. Fifth time this session that a detector has read a comment as code. +ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area English JSON in CODE -- the redundant second copy is gone') + + +// ── 7. THE PER-SITE REGISTER. One row per migrated site; later waves append here. ─────────────── +// Each row asserts the two things a migration can get wrong: that the site actually routes through +// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the +// catalogue. The second matters more than it looks: routing these through tStatic would change what +// seven bare-host bundles render, and would start translating strings that are English today. +import { readFileSync as rf } from 'node:fs' +const SITES = [ + // file, helper it must use, a fragment of the fallback it must still pass + ['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'], + ['library/components/search/FilterDropdown.vue', 'useOptionalT', 'FALLBACK[key]'], + ['library/components/legends/SemanticTreeLegend.vue', 'useOptionalI18n', 'humanizeKey(k)'], + ['library/components/poster/PosterWall.vue', 'useOptionalI18n', 'humanizeKey(k)'], + ['library/components/hints/PinHint.vue', 'useOptionalI18n', 'PINHINT_FALLBACK[key]'], + ['plugin-designs/pplr-data-maps/src/composables/usePplrT.js', 'useOptionalT', null], + ['plugin-designs/upg-neighbor/src/composables/useNeighborT.js', 'useOptionalT', null], + // wave 3a — gospel-resources (owner: gospel-plugin). All four fell back to enLookup, i.e. the + // SHIPPED ENGLISH, not the locale-aware catalogue; each passes that enLookup in, so a bare host + // still renders English. Switching them to the catalogue default would make a bare host honour + // ?lang=ar — a real improvement, and gospel-plugin's to make, not this migration's. + // ★ gospel-plugin CHOSE the locale-honouring catalogue fallback (2026-09-26) rather than keeping + // enLookup's English: "most partners will drop a custom element or an iframe and never install + // vue-i18n ... an Arabic ministry site embedding this map serves its readers English while the + // bundle already carries all 11 catalogues. That is a bug, not a decision." So these four take NO + // fallback, and row 7b proves the Arabic actually arrives. + ['plugin-designs/gospel-resources/charts.vue', 'useOptionalT', null], + ['plugin-designs/gospel-resources/data-tables.vue', 'useOptionalT', null], + ['plugin-designs/gospel-resources/resources-list.vue', 'useOptionalT', null], + // ★ resources-map passes NO NAMESPACE: it uses keys in TWO areas (gospel.* and pins.*) and its + // enLookup walks the catalogue ROOT to see both. useOptionalT('gospel', ...) would have qualified + // pins.approximate into gospel.pins.approximate and broken the pins sentence — studio caught that + // as a semantic conflict on main before it landed. The row asserts the '' explicitly so a later + // edit cannot quietly re-add a namespace. + ['plugin-designs/gospel-resources/resources-map.vue', "useOptionalT(''", null], + // wave 3b — cities-internet. All three were byte-identical in the guard block; each keeps its own + // enLookup fallback, so a bare host renders the same English. Their local fill() used `k in params`, + // so the shared fillParams also removes a way for them to print the literal "null". + // ★ exp-shepherd, as cities-internet's owner, took the locale-honouring default (2026-09-26): "the + // bundle already carries all 11 catalogues, so a bare host getting English would be a bug". So these + // three pass NO fallback, and their English-only enLookup + per-area catalogue import are deleted. + ['plugin-designs/cities-internet/connectivity-list.vue', 'useOptionalT', null], + ['plugin-designs/cities-internet/connectivity-map.vue', 'useOptionalT', null], + ['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null], +] +// ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived. +// resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the +// engine's language tag. That is a different fact from translating text: `locale` arrives as a ref OR +// a plain string depending on the host (PosterWall.vue records the same variance), so normalising it +// belongs in a deliberate helper change, not in a translator migration. Asserting EXACTLY 2 means a +// third bare call — a new unguarded translator — still fails this gate. +// ★ DELIBERATELY NOT IN THIS REGISTER, so nobody "completes" C4 by converting it: +// plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue +// It still hand-rolls the guard, and exp-shepherd asked for it to stay that way: the file is carded for +// retirement (RETIRE-PPLR-SEMANTICTREELEGEND) in favour of the library component, so converting it is +// work that gets thrown away. A migration whose value expires before it lands is not worth the diff. +// Two more sites are their owners' to cut, not absent by oversight: country-posters-mfe.vue +// (exp-shepherd, into its single posters landing) and key-cities/cities-map.vue (exp-shepherd, gated). + +const ALLOWED_BARE = { 'plugin-designs/gospel-resources/resources-map.vue': 2 } +for (const [file, helper, fallbackFrag] of SITES) { + // ★ STRIP HTML COMMENTS TOO, for .vue files. codeOnly is a JS scanner: it knows /* */ and //, not + // . resources-map.vue carries a long template comment that MENTIONS useI18n(), and counting + // it made this register read 3 bare calls where the code has 2. Same lesson as the CSS-in-a-template + // -literal case: the stripper has to match the LANGUAGE of the text it is reading. + const raw = rf(new URL('../' + file, import.meta.url), 'utf8') + const code = codeOnly(raw.replace(//g, ' ')) + const short = file.split('/').pop() + ok(helper.includes('(') ? code.includes(helper) : new RegExp(`\\b${helper}\\(`).test(code), + `${short}: routes through ${helper}${helper.includes('(') ? '' : '()'}`) + const bare = (code.match(/useI18n\(/g) || []).length + const allowed = ALLOWED_BARE[file] || 0 + ok(bare === allowed, allowed + ? `${short}: exactly ${allowed} bare useI18n() left, the documented locale read(s) -- found ${bare}` + : `${short}: no bare useI18n() left in CODE (comments explaining it are fine)`) + if (fallbackFrag) { + ok(code.includes(fallbackFrag), + `${short}: still passes its OWN fallback (${fallbackFrag}) -- migrating it to the catalogue would change what a bare host renders`) + } else { + // ★ NOT "as it always did" for all of them. The two composables always resolved through the + // catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their + // owner's decision. Saying "as it always did" for those four would be a false statement inside a + // gate message, which is the one place a reader has no reason to doubt. + const moved = file.includes('gospel-resources') + ok(!/fallback:/.test(code), moved + ? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)` + : `${short}: takes the CATALOGUE fallback, as it always did`) + } +} + +// ── 7b. gospel-plugin's ASKED-FOR ROW, against the REAL catalogue ─────────────────────────────── +// Their words: "a BARE host (no vue-i18n) with ?lang=ar must render pins.approximate in Arabic — not +// the key, not gospel.pins.approximate, not English." It covers their conditions 1 and 2 together: a +// namespace would corrupt the key, and an EN-only fallback would serve English to an Arabic reader. +// This reads the SHIPPED ar/ and en/ pins.json rather than a stub, and rebuilds tStatic's own +// resolution order (messages[want] ?? messages.en) over them, so it measures the real catalogue. +{ + const arPins = JSON.parse(rf(new URL('../library/i18n/locales/ar/pins.json', import.meta.url), 'utf8')) + const enPins = JSON.parse(rf(new URL('../library/i18n/locales/en/pins.json', import.meta.url), 'utf8')) + const messages = { ar: { pins: arPins }, en: { pins: enPins } } + const realish = (want) => (path) => { + const walk = (loc) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), messages[loc]) + const v = walk(want); return typeof v === 'string' ? v : walk('en') + } + const { useOptionalT } = make(realish('ar'), throwingI18n) + const t = useOptionalT('') + const got = t('pins.approximate') + ok(got === arPins.approximate, + 'GOSPEL ROW: a BARE host under ar renders pins.approximate in ARABIC from the real catalogue') + ok(got !== 'pins.approximate' && got !== 'gospel.pins.approximate', + 'and it is neither the raw key nor a namespace-mangled key') + ok(got !== enPins.approximate, + 'and it is NOT the English -- which is exactly what enLookup used to serve every partner host') + // the same call must still reach English when the locale has no entry + const tEnOnly = useOptionalT('') + ok(typeof arPins.approximate === 'string' && typeof enPins.approximate === 'string', + 'CONTROL: both catalogues really do carry the key, so the three assertions above are measuring a difference that exists') + const { useOptionalT: uMissing } = make(realish('zz'), throwingI18n) + ok(uMissing('')('pins.approximate') === enPins.approximate, + 'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required') +} + +// ── 8. the fallback option and te/locale, behaviourally ──────────────────────────────────────── +{ + const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n) + const mine = useOptionalT('pplr', { fallback: (k) => (k === 'pplr.greeting' ? 'MINE {name}' : undefined) }) + ok(mine('greeting', { name: 'Ada' }) === 'MINE Ada', + 'opts.fallback WINS over the catalogue -- that is what keeps each migrated site rendering its own English') + ok(mine('nested.deep') === 'nested.deep', + 'and when the site fallback has nothing, the KEY shows (it does NOT silently fall through to the catalogue)') + const { te, locale } = useOptionalI18n('pplr') + ok(te('greeting') === false, 'te() is FALSE on a bare host -- PinHint and SemanticTreeLegend depend on that to use their own defaults') + ok(locale === 'en', 'locale defaults to en on a bare host, as PosterWall had it') +} +{ + const { useOptionalI18n } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' })) + const { te } = useOptionalI18n('pplr') + ok(typeof te === 'function', 'te() is a function when the host HAS i18n') +} + +console.log(fails ? `\nC4-OPTIONAL-T: ${fails} FAIL(s)` : '\nC4-OPTIONAL-T: the shared translator matches the hand-written guards on every branch') +process.exit(fails ? 1 : 0) diff --git a/library/colors/religion.js b/library/colors/religion.js index a3f6eb2b..7a3a5459 100644 --- a/library/colors/religion.js +++ b/library/colors/religion.js @@ -31,6 +31,21 @@ const DEFAULT_COLOR = '#94a3b8' // soft slate gray (matches Unknown family) /** * Family-letter → display name. Used by the legend to render row labels. */ +// ★ THE NAMES BELOW ARE VALUES, NOT LABELS — do not translate this table. +// It is read in BOTH directions and on BOTH sides of a component boundary: the dashboard derives a +// family NAME from a religion code and puts it on the selection bus, and people-groups-grid runs +// the same derivation per row so the two byte-match. Localizing these strings would break that +// link in every locale but English — silently, because an empty intersection looks like "no +// results" rather than a broken wire. That is the same defect the language link cost us. +// To SHOW a family name to a reader, use religionFamilyLabel() below: the value stays English and +// canonical, the label is projected for display only. Exactly the rule the shareable URL follows, +// where the code is canonical and the slug is display-only. + +// Display-only projection (see religionFamilyLabel at the end of this file). tStatic and not +// useI18n: this module is imported by components that mount inside custom elements with no i18n +// plugin installed, where an unguarded useI18n() throws on the way in. +import { tStatic } from '../i18n/index.js' + export const RELIGION_FAMILIES = { 'C': 'Christianity', 'M': 'Islam', @@ -110,3 +125,22 @@ export default { buildColorExpression, fallback: DEFAULT_COLOR, } + +/** + * The DISPLAY name for a religion family, for a reader — never for a comparison. + * + * Takes either the family letter ('M') or the canonical English name ('Islam') so a caller can + * pass whichever it is holding, and falls back to the English name when a locale has no entry, so + * a missing key degrades to a readable word rather than to a key on screen. + * + * ★ THE RETURN VALUE MUST NOT BE STORED, COMPARED OR SENT. It is localized, so it differs per + * reader; the moment it lands in a filter, a bus payload or an element attribute, the link it + * crosses works in English and matches nothing in Arabic. If you need a value, use + * RELIGION_FAMILIES. + */ +export function religionFamilyLabel(familyOrName) { + const key = String(familyOrName || '') + const name = RELIGION_FAMILIES[key] || key + if (!name) return '' + return tStatic('religion.family.' + name) || name +} diff --git a/library/components/hints/PinHint.vue b/library/components/hints/PinHint.vue index fd4e67a5..5bf40bbd 100644 --- a/library/components/hints/PinHint.vue +++ b/library/components/hints/PinHint.vue @@ -25,7 +25,11 @@ * * TASTEFUL BY DESIGN (no dark patterns): * · SELF-GATING — shows only after a map actually exists in this - * custom element's shadow root, so list/table profiles never see it. + * custom element's shadow root, so list/table profiles never see it, AND only + * while that map is on screen. A composite (pane-wrapper) keeps the map pane + * mounted while the reader scrolls to another pane, so "a canvas exists" is not + * "the map is showing": without the second test the pill was clamped into view + * over the UPG-100 poster pane, where there is no map to tap. * · ONCE PER VISIT — it appears on every page load and stays until the reader closes * it with the ×. Nothing is remembered between loads: leaving and returning (or a * reload) shows it again, because the invitation is the point. @@ -44,7 +48,7 @@ import { tStatic } from '@map/i18n/index.js' // custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and // takes the subtree with it. The English stays as the fallback. const T = (key, en) => tStatic(key) || en -import { useI18n } from 'vue-i18n' +import { useOptionalI18n } from '@map/composables/useOptionalT.js' import { useShadowStyles } from '../../composables/useShadowStyles.js' // ★ i18n IS OPTIONAL HERE, NOT REQUIRED. useI18n() THROWS ("Need to install with `app.use` @@ -57,9 +61,9 @@ import { useShadowStyles } from '../../composables/useShadowStyles.js' // has them, the English literal otherwise. `te` becomes a "no, I don't have it" so every caller // falls through to its own default. const PINHINT_FALLBACK = { 'messages.pinHint.tapDot': 'Tap a dot to see who still needs prayer' } -let t = (key) => PINHINT_FALLBACK[key] || key -let te = () => false -try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ } +// C4: the guard is library/composables/useOptionalI18n now. PINHINT_FALLBACK is passed in unchanged +// and `te` still answers false on a bare host, so every caller keeps its own default. +const { t, te } = useOptionalI18n('', { fallback: (key) => PINHINT_FALLBACK[key] }) const uiStore = inject('uiStore', null) const profileConfig = inject('profileConfig', null) const isDark = computed(() => uiStore?.theme === 'dark') @@ -91,6 +95,10 @@ const label = computed(() => { }) const visible = ref(false) +// Whether the map box is actually in view (IntersectionObserver, below). Separate from `visible`, +// which only the reader's × turns off: scrolling away hides the pill, scrolling back shows it again. +const onScreen = ref(true) +let mapIO = null const hintEl = ref(null) let watchTimer = null let movedTo = null // the element we re-parented into, so we can put ourselves back @@ -202,7 +210,7 @@ function sheetFloor(pill) { function clampIntoView() { const el = hintEl.value - if (!el) return + if (!el || !onScreen.value) return try { // ★ ADJUST `bottom`, NEVER `transform`. My first attempt set el.style.transform and cleared it // between measurements — both wrong here, and the diagnosis took two failed builds: @@ -292,6 +300,19 @@ onMounted(() => { if (box && el && el.parentElement !== box) { try { box.appendChild(el); movedTo = box } catch (_) { /* keep the page anchor */ } } + // SHOW ONLY WHILE THE MAP IS IN VIEW. The observer's root is the viewport, and it clips by every + // scrolling ancestor, so a map pane scrolled out of a composite's column reads as not intersecting. + // Less than a fifth of the map on screen counts as away: the pill needs map under it. + if (box && typeof IntersectionObserver !== 'undefined') { + try { + mapIO = new IntersectionObserver((entries) => { + const e = entries[entries.length - 1] + const now = !!e && e.isIntersecting && e.intersectionRatio >= 0.2 + if (now !== onScreen.value) { onScreen.value = now; if (now) nextTick(clampIntoView) } + }, { threshold: [0, 0.2, 0.5] }) + mapIO.observe(box) + } catch (_) { /* no observer: behave as before, shown once a canvas exists */ } + } syncAttribClearance(root) clampIntoView() // The attribution ResizeObserver only fires when THAT control resizes, which need not @@ -318,6 +339,7 @@ onMounted(() => { }) onBeforeUnmount(() => { if (watchTimer) clearInterval(watchTimer) + if (mapIO) { try { mapIO.disconnect() } catch (_) {} mapIO = null } if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null if (hostRO) { try { hostRO.disconnect() } catch (_) {} hostRO = null } if (sheetRO) { try { sheetRO.disconnect() } catch (_) {} sheetRO = null } sheetWatched = null @@ -378,7 +400,7 @@ useShadowStyles(`