diff --git a/deployment/src-tauri/README.md b/deployment/src-tauri/README.md index 18ed859b4..3694170a8 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 f6428af7e..5c35681ba 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 7c018d261..ea279adf6 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 f7f7d32ec..ff06c2e55 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 3a533c23d..d64341bf2 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -117,6 +117,8 @@ and a change here can break other maps. `import … from '@map/composables/useAvatarGenerator.js'` - **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search. `import … from '@map/composables/useCountryOutline.js'` +- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid. + `import … from '@map/composables/useDashboardLayout.js'` - **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page. `import … from '@map/composables/useDocumentFont.js'` - **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips. diff --git a/internal/check-approx-pin-note.mjs b/internal/check-approx-pin-note.mjs index 3de198bcc..88f0eedee 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 3d6f072f8..e16a02a8a 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 000000000..8f7bc5168 --- /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-no-silent-engine-substitution.mjs b/internal/check-no-silent-engine-substitution.mjs new file mode 100644 index 000000000..73ebf951c --- /dev/null +++ b/internal/check-no-silent-engine-substitution.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * check-no-silent-engine-substitution — a profile must never fall back to a DIFFERENT map engine than + * the one its provider asked for. + * + * THE DEFECT, found by regions' C3 read and routed by dx-shepherd (END-SHEP-C3C, 2026-09-26): + * + * const gl = engineGlobal(provider) || window.mapboxgl + * + * That `||` looks like ordinary defensive coding and is not. engineGlobal() ALREADY resolves mapbox's own + * global for the mapbox case — useMapInstance.js:115 is + * `ENGINES[provider]?.global || ENGINES.mapbox.global`, which falls back to mapbox's NAME only for an + * UNKNOWN provider name, never for a missing engine. So the expression can fire in exactly ONE situation: + * the page asked for 'maplibre' and MapLibre failed to load. It then hands back MAPBOX — the token-gated + * engine — to a page that explicitly asked for the keyless one, breaking the keyless contract + * research-map.instances.json documents. + * + * ★ AND THE SUBSTITUTE IS BROKEN BY CONSTRUCTION, which is what makes this worth a gate rather than a + * one-line fix. These profiles gate `gl.accessToken = token` on `if (!isMaplibre)`, so a maplibre request + * that fell through to Mapbox got Mapbox WITHOUT a token: 401s on every tile instead of a legible failure. + * A fallback that cannot produce a working map even in principle is strictly worse than no fallback, + * because it converts a diagnosable error into a mysterious one. + * + * ⚠ WHY THE THREE EXISTING ENGINE GATES DID NOT CATCH IT, and why this is not a duplicate of them: + * check-leaf-engine-per-provider — a leaf loads the engine its provider asks for + * check-provider-switch — the page-level switch reaches a raw-built map + * check-auto-engine — 'auto' resolves to Mapbox with a token, MapLibre without + * All three assert the HAPPY PATH: what happens when engines load. This bug only appears when one does + * NOT load, so all three were green for as long as it existed. The property here is the negative one — + * "when the requested engine is absent, no other engine is substituted" — and nothing asserted it. + * + * ⚠ IT IS A SOURCE CHECK, ON PURPOSE. Reproducing it at the rendered layer means serving a leaf with + * MapLibre deliberately blocked, which is a browser run for a defect whose whole signature is one + * expression. The source is the authoritative place: the fallback either appears in the file or it does + * not. A browser gate here would cost a slot to measure something a grep decides. + * + * Usage: node internal/check-no-silent-engine-substitution.mjs + * node internal/check-no-silent-engine-substitution.mjs --self-test + */ +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO = process.env.DOXA_REPO || resolve(HERE, '..') +const ROOT = join(REPO, 'plugin-designs') + +// ⚠ FLAGS FILTERED FROM THE POSITIONAL READ. A bare `--self-test` was otherwise taken as a path, which +// has bitten this repo three times (--self-test read as a leaf, --emit read as a baseUrl, --poison read +// as a packet). +const SELF_TEST = process.argv.includes('--self-test') + +// The substitution to refuse: engineGlobal(...) OR-ed with a specific engine's global. Any of these +// named globals on the right of a `||` after engineGlobal is a cross-engine fallback by definition — +// engineGlobal already returns the RIGHT global for the requested provider, so the only thing an `||` +// can add is a DIFFERENT engine. +const ENGINE_GLOBALS = ['mapboxgl', 'maplibregl'] +const PATTERN = new RegExp( + String.raw`engineGlobal\s*\([^)]*\)\s*\|\|\s*(?:window\.)?(${ENGINE_GLOBALS.join('|')})\b`, + 'g', +) + +function scan(text) { + const hits = [] + const lines = text.split('\n') + lines.forEach((line, i) => { + // Skip comment lines: this file's own documentation quotes the bad pattern, and so does every fix + // that explains what it removed. A gate that fails on its own explanation teaches people to delete + // the explanation. + const t = line.trim() + if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return + PATTERN.lastIndex = 0 + let m + while ((m = PATTERN.exec(line)) !== null) hits.push({ line: i + 1, engine: m[1], text: t.slice(0, 110) }) + }) + return hits +} + +if (SELF_TEST) { + // ★ PROVE THE PATTERN FIRES BEFORE TRUSTING A GREEN RUN. A source gate that has never matched anything + // is indistinguishable from a broken regex, and this one's whole value is that it matches. + const should = [ + 'const gl = engineGlobal(upgProvider) || window.mapboxgl', + ' const gl = engineGlobal(p)||maplibregl', + 'let gl = engineGlobal( provider ) || window.maplibregl;', + ] + const shouldNot = [ + 'const gl = engineGlobal(upgProvider)', + '// const gl = engineGlobal(p) || window.mapboxgl <- the removed fallback', + 'const gl = engineGlobal(p) || null', + 'const token = mapboxToken?.value || profileConfig?.value?.tk || \'\'', + ] + let bad = 0 + for (const s of should) { + const n = scan(s).length + console.log(` ${n ? 'MATCHED ' : 'MISSED '} ${s.trim().slice(0, 62)}`) + if (!n) bad++ + } + for (const s of shouldNot) { + const n = scan(s).length + console.log(` ${n ? 'FALSE+ ' : 'ignored '} ${s.trim().slice(0, 62)}`) + if (n) bad++ + } + if (bad) { + console.error(`\nNO-SILENT-ENGINE-SUBSTITUTION --self-test: FAIL — ${bad} case(s) wrong.`) + process.exit(1) + } + console.log('\nNO-SILENT-ENGINE-SUBSTITUTION --self-test: PASS — the pattern matches every real form ' + + 'and ignores comments, a null fallback and an unrelated token chain.') + process.exit(0) +} + +if (!existsSync(ROOT)) { + console.log(`NO-SILENT-ENGINE-SUBSTITUTION: NOT MEASURED — no plugin-designs tree at ${ROOT}`) + process.exit(75) +} + +const findings = [] +let scanned = 0 +const walk = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name) + if (e.isDirectory()) { + if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue + walk(p) + } else if (/\.(vue|js|mjs)$/.test(e.name)) { + let text + try { text = readFileSync(p, 'utf8') } catch { continue } + if (!text.includes('engineGlobal')) continue + scanned++ + for (const h of scan(text)) findings.push({ file: p.slice(REPO.length + 1), ...h }) + } + } +} +walk(ROOT) + +console.log('NO-SILENT-ENGINE-SUBSTITUTION') +console.log(` ${scanned} file(s) call engineGlobal()`) + +// ⚠ REFUSE TO JUDGE rather than pass vacuously. If nothing in the tree calls engineGlobal at all, the +// pattern had nothing to match and a green result would mean "I found no files", not "the code is clean". +if (!scanned) { + console.log('NO-SILENT-ENGINE-SUBSTITUTION: NOT MEASURED — no file calls engineGlobal(), so there was ' + + 'nothing to check. Exit 75 rather than 0: "found nothing" is not "found nothing wrong".') + process.exit(75) +} + +if (findings.length) { + console.error(`\nNO-SILENT-ENGINE-SUBSTITUTION: FAIL — ${findings.length} silent engine substitution(s).`) + for (const f of findings) { + console.error(` ✗ ${f.file}:${f.line} falls back to ${f.engine}`) + console.error(` ${f.text}`) + } + console.error('\n engineGlobal() ALREADY returns the right global for the requested provider, so an `||`') + console.error(' after it can only supply a DIFFERENT engine — and it fires only when the page asked for') + console.error(' maplibre and maplibre failed to load. A page that asked for the KEYLESS engine then') + console.error(' receives the TOKEN-GATED one, and because accessToken is gated on !isMaplibre it') + console.error(' receives it WITHOUT a token: 401s instead of a legible error.') + console.error('\n FIX: delete the fallback. Keep the requested engine, and let the profile\'s existing') + console.error(' !gl branch fail visibly — console.warn plus the map\'s error/empty state, never a blank,') + console.error(' and never another engine. Name the engine in the message so a partner can tell an') + console.error(' honoured request from a silent downgrade.') + process.exit(1) +} +console.log('NO-SILENT-ENGINE-SUBSTITUTION: PASS — every engineGlobal() call keeps the engine its provider ' + + 'asked for.') diff --git a/library/colors/religion.js b/library/colors/religion.js index a3f6eb2b3..7a3a5459e 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 c60d75eaf..5b4064b62 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. @@ -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(`