From 93ca8dab914651e86dc5ff69e8d9e54df5d44290 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 11:59:17 +0000 Subject: [PATCH] Update from source d3d3c3f8 --- documentation/reference-library-index.md | 6 + documentation/reference-universal-props.md | 3 + internal/check-list-prop-unit.mjs | 30 ++- internal/check-map-attribution.mjs | 32 +++ internal/check-no-cross-plugin-imports.mjs | 59 +++++ internal/check-packet-render.mjs | 61 +++++ internal/readme-shots.mjs | 55 +++- .../src => library}/colors/affinity-block.js | 9 +- .../src => library}/colors/doxa-region.js | 0 .../src => library}/colors/resource.js | 0 library/colors/wagf-region.js | 2 +- library/components/poster/PosterDialog.vue | 47 ++-- .../legends/researchLegendOptions.js | 2 +- .../legends/useAffinityBlockLegendData.js | 9 +- library/composables/legends/useLegendData.js | 4 +- .../legends/useWagfRegionsLegendData.js | 2 +- library/composables/useMapAttribution.js | 31 ++- library/composables/useMapInstance.js | 28 ++ library/composables/useMapProvider.js | 55 ++++ library/i18n/locales/ar/lists.json | 22 ++ library/i18n/locales/ar/mapui.json | 19 ++ library/i18n/locales/ar/poster.json | 19 ++ library/i18n/locales/de/lists.json | 22 ++ library/i18n/locales/de/mapui.json | 19 ++ library/i18n/locales/de/poster.json | 19 ++ library/i18n/locales/en/lists.json | 22 ++ library/i18n/locales/en/mapui.json | 19 ++ library/i18n/locales/en/poster.json | 19 ++ library/i18n/locales/es/lists.json | 22 ++ library/i18n/locales/es/mapui.json | 19 ++ library/i18n/locales/es/poster.json | 19 ++ library/i18n/locales/fr/lists.json | 22 ++ library/i18n/locales/fr/mapui.json | 19 ++ library/i18n/locales/fr/poster.json | 19 ++ library/i18n/locales/hi/lists.json | 22 ++ library/i18n/locales/hi/mapui.json | 19 ++ library/i18n/locales/hi/poster.json | 19 ++ library/i18n/locales/it/lists.json | 22 ++ library/i18n/locales/it/mapui.json | 19 ++ library/i18n/locales/it/poster.json | 19 ++ library/i18n/locales/pt/lists.json | 22 ++ library/i18n/locales/pt/mapui.json | 19 ++ library/i18n/locales/pt/poster.json | 19 ++ library/i18n/locales/ro/lists.json | 22 ++ library/i18n/locales/ro/mapui.json | 19 ++ library/i18n/locales/ro/poster.json | 19 ++ library/i18n/locales/ru/lists.json | 22 ++ library/i18n/locales/ru/mapui.json | 19 ++ library/i18n/locales/ru/poster.json | 19 ++ library/i18n/locales/zh/lists.json | 22 ++ library/i18n/locales/zh/mapui.json | 19 ++ library/i18n/locales/zh/poster.json | 19 ++ library/lists/lists.json | 3 +- library/lists/peopleGroupList.js | 18 ++ library/packet/packet-render.js | 17 +- library/scripts/posters/packet-strings.json | 77 +++++- library/scripts/posters/packet.mjs | 32 ++- package.json | 7 +- .../all-people-groups/all-people-groups.vue | 4 +- .../country-posters/countries-map.vue | 4 +- plugin-designs/country-posters/index.js | 16 +- .../src/colors/affinity-block.js | 164 ------------ plugin-designs/dashboard-plus-grid/index.js | 9 +- plugin-designs/demo-map/demo-map.vue | 4 +- plugin-designs/doxa-research-map/index.js | 13 +- .../doxa-research-map/research-map.vue | 4 +- .../src/colors/doxa-region.js | 139 ---------- .../doxa-research-map/src/colors/resource.js | 139 ---------- .../doxa-simple-map/doxa-simple-map.vue | 4 +- .../my-upg-100-list/upg-100-list.vue | 59 +++-- .../pplr-data-maps/src/utils/pplrData.js | 12 +- .../src/RegionCountryMap.vue | 12 +- plugin-designs/research-dashboard/index.js | 9 +- .../dashboards/DashboardMapPanel.vue | 9 +- .../src/cockpit/FlowerClusters.vue | 3 +- .../upg-neighbor/src/cockpit/MapCell.vue | 3 +- .../upg-neighbor/src/lang-cluster.vue | 3 +- plugin-designs/upg-neighbor/src/send-map.vue | 3 +- .../all-people-groups/all-people-groups.js | 44 ++-- .../all-people-groups/index.html | 2 +- public/dx-maps/big-picture/big-picture.js | 26 +- .../big-picture/big-picture/index.html | 2 +- .../big-picture/frontier/index.html | 2 +- .../big-picture/big-picture/index.html | 2 +- .../cities-internet/cities-internet.js | 54 ++-- .../connectivity-list/index.html | 2 +- .../connectivity-map/index.html | 2 +- .../cities-internet/data-tables/index.html | 2 +- .../country-posters/countries-map/index.html | 2 +- .../country-poster-wall/index.html | 2 +- .../country-posters-mfe/index.html | 2 +- .../country-posters/country-posters.js | 246 +++++++++--------- .../dashboard-plus-grid.js | 64 ++--- .../dashboard-plus-grid/default/index.html | 2 +- .../dashboard-plus-grid/index.html | 2 +- public/dx-maps/demo-map/demo-map.js | 48 ++-- public/dx-maps/demo-map/demo-map/index.html | 2 +- .../demo-map/demo-map/maplibre/index.html | 2 +- public/dx-maps/doxa-maps-spa/manifest.json | 92 +++---- .../doxa-research-map/doxa-research-map.js | 98 +++---- .../doxa-research-map/research-map/index.html | 2 +- .../research-map/maplibre/index.html | 2 +- .../doxa-simple-map/doxa-simple-map.js | 70 ++--- .../doxa-simple-map/adoption/index.html | 2 +- .../doxa-simple-map/engagement/index.html | 2 +- .../doxa-simple-map/index.html | 2 +- .../doxa-simple-map/prayer/index.html | 2 +- .../gospel-resources/charts/index.html | 2 +- .../gospel-resources/data-tables/index.html | 2 +- .../gospel-dashboard/index.html | 2 +- .../gospel-resources/gospel-resources.js | 42 +-- .../resources-list/index.html | 2 +- .../gospel-resources/resources-map/index.html | 2 +- public/dx-maps/key-cities/key-cities.js | 152 +++++------ public/dx-maps/manifest.json | 92 +++---- .../my-upg-100-list/my-upg-100-list.js | 186 ++++++------- .../my-upg-100-list/poster-wall/index.html | 2 +- .../my-upg-100-list/upg-100-list/index.html | 2 +- .../my-upg-100-list/upg-100-mfe/index.html | 2 +- .../people-groups-grid/people-groups-grid.js | 22 +- .../people-groups-grid/driven/index.html | 2 +- .../people-groups-grid/index.html | 2 +- .../people-groups-grid/index.html | 2 +- .../dx-maps/pplr-data-maps/pplr-data-maps.js | 54 ++-- .../pplr-data-maps/pplr-data-maps/index.html | 2 +- .../prayer-walk-planner.js | 32 +-- .../prayer-walk-planner/index.html | 2 +- .../region-dashboards/index.html | 2 +- .../regions-dashboard/regions-dashboard.js | 206 +++++++-------- .../regions-dashboard/regions-list/index.html | 2 +- .../regions-dashboard/regions-mfe/index.html | 2 +- .../research-dashboard/research-dashboard.js | 64 ++--- .../research-dashboard/index.html | 2 +- .../template-bundle/template-bundle.js | 32 +-- public/dx-maps/upg-neighbor/upg-neighbor.js | 86 +++--- .../upg-neighbor/bloom-cockpit/index.html | 2 +- .../upg-neighbor/flower-mesh/index.html | 2 +- .../upg-neighbor/upg-neighbor/index.html | 2 +- .../upg-neighbor/lang-cluster/index.html | 2 +- .../upg-neighbor/send-map/index.html | 2 +- .../upg-neighbor/upg-neighbors/index.html | 2 +- .../wall-of-people-groups.js | 34 +-- .../wall-of-people-groups/index.html | 2 +- 143 files changed, 2197 insertions(+), 1497 deletions(-) create mode 100644 internal/check-map-attribution.mjs create mode 100644 internal/check-no-cross-plugin-imports.mjs rename {plugin-designs/doxa-research-map/src => library}/colors/affinity-block.js (89%) rename {plugin-designs/country-posters/src => library}/colors/doxa-region.js (100%) rename {plugin-designs/country-posters/src => library}/colors/resource.js (100%) create mode 100644 library/i18n/locales/ar/lists.json create mode 100644 library/i18n/locales/ar/mapui.json create mode 100644 library/i18n/locales/ar/poster.json create mode 100644 library/i18n/locales/de/lists.json create mode 100644 library/i18n/locales/de/mapui.json create mode 100644 library/i18n/locales/de/poster.json create mode 100644 library/i18n/locales/en/lists.json create mode 100644 library/i18n/locales/en/mapui.json create mode 100644 library/i18n/locales/en/poster.json create mode 100644 library/i18n/locales/es/lists.json create mode 100644 library/i18n/locales/es/mapui.json create mode 100644 library/i18n/locales/es/poster.json create mode 100644 library/i18n/locales/fr/lists.json create mode 100644 library/i18n/locales/fr/mapui.json create mode 100644 library/i18n/locales/fr/poster.json create mode 100644 library/i18n/locales/hi/lists.json create mode 100644 library/i18n/locales/hi/mapui.json create mode 100644 library/i18n/locales/hi/poster.json create mode 100644 library/i18n/locales/it/lists.json create mode 100644 library/i18n/locales/it/mapui.json create mode 100644 library/i18n/locales/it/poster.json create mode 100644 library/i18n/locales/pt/lists.json create mode 100644 library/i18n/locales/pt/mapui.json create mode 100644 library/i18n/locales/pt/poster.json create mode 100644 library/i18n/locales/ro/lists.json create mode 100644 library/i18n/locales/ro/mapui.json create mode 100644 library/i18n/locales/ro/poster.json create mode 100644 library/i18n/locales/ru/lists.json create mode 100644 library/i18n/locales/ru/mapui.json create mode 100644 library/i18n/locales/ru/poster.json create mode 100644 library/i18n/locales/zh/lists.json create mode 100644 library/i18n/locales/zh/mapui.json create mode 100644 library/i18n/locales/zh/poster.json delete mode 100644 plugin-designs/country-posters/src/colors/affinity-block.js delete mode 100644 plugin-designs/doxa-research-map/src/colors/doxa-region.js delete mode 100644 plugin-designs/doxa-research-map/src/colors/resource.js diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index 55aaf782..7e1bba4d 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -178,6 +178,10 @@ and a change here can break other maps. `import … from '@map/utils/embedBase.js'` - **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions `import … from '@map/utils/geoUtils.js'` +- **html** — html.js — the two helpers every HTML string built in library/ must use (CRITIC-3, 2026-09-26). + `import … from '@map/utils/html.js'` +- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host + `import … from '@map/utils/loadPinnedUmd.js'` - **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT, `import … from '@map/utils/viewportScrim.js'` @@ -192,6 +196,8 @@ and a change here can break other maps. ### Constants (never-change values) +- **cdn** — cdn.js — EVERY third-party script and stylesheet a plugin loads at runtime, pinned to an EXACT version + `import … from '@map/constants/cdn.js'` - **mapDefaults** — mapDefaults.js — Default map initialization settings `import … from '@map/constants/mapDefaults.js'` - **posterDefaults** — posterDefaults.js — default margins, bleed, slot sizes, and typography diff --git a/documentation/reference-universal-props.md b/documentation/reference-universal-props.md index 4d0aa435..b027adc0 100644 --- a/documentation/reference-universal-props.md +++ b/documentation/reference-universal-props.md @@ -148,6 +148,9 @@ the IMB / peoplegroups.org dataset (never a hand-kept roster). Aliases name them from the IMB CSV and the DOXA snapshot that `build-vision-funnel.mjs` saves). - **An unknown list** falls back to `'doxa'` with a console warning. **A list whose membership cannot load** shows no groups (fails closed), never DOXA's whole list under another list's name. +- **Showing a list's name:** `listLabel(id, t)` and `listDefinition(id, t)` read the reader's catalogue + (`i18n/locales//lists.json`, area `lists`) through the caller's translator, and fall back to the + registry's English. Never print `lists.json`'s `label` directly: it is the English fallback only. - **Guarded by** `check:universal-props` (LIST-FETCH, LIST-DSM, LIST-KEYS) and `check:list-prop-unit`. diff --git a/internal/check-list-prop-unit.mjs b/internal/check-list-prop-unit.mjs index 936671d2..d094f871 100644 --- a/internal/check-list-prop-unit.mjs +++ b/internal/check-list-prop-unit.mjs @@ -8,11 +8,14 @@ * - the default list returns the SAME array, untouched, with no fetch at all; * - a list keeps exactly the rows whose slug is in its membership file, fetched from the asset pool; * - a membership that cannot load FAILS CLOSED: no rows, never the whole DOXA list; - * - every registry list with an imb filter has a membership file, and its counts match the slugs. + * - every registry list with an imb filter has a membership file, and its counts match the slugs; + * - listLabel / listDefinition read the reader's catalogue (library/i18n/locales//lists.json), + * fall back to the registry's English, the English catalogue equals the registry, and every + * locale carries a label and a definition for every list. * USAGE node internal/check-list-prop-unit.mjs EXIT 0 pass · 1 fail */ import { register } from 'node:module' -import { readFileSync, existsSync } from 'node:fs' +import { readFileSync, existsSync, readdirSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -65,6 +68,27 @@ for (const l of L.peopleGroupLists().filter((x) => x.source === 'imb')) { check(`${l.id}: file matches its registry filter, counts match its slugs`, [JSON.stringify(d.filter) === JSON.stringify(l.filter), d.counts.doxa === d.slugs.length, d.counts.imb >= d.counts.doxa], [true, true, true]) } +console.log('labels in the reader\'s language') +const cat = (lc) => JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales', lc, 'lists.json'), 'utf8')) +const tFrom = (lc) => (key) => key.split('.').slice(1).reduce((o, k) => (o == null ? o : o[k]), cat(lc)) ?? key +check('no translator -> the registry English', L.listLabel('700'), 'Project 700: frontier people groups of 500,000 or more') +check('a translator that returns the key -> English', L.listLabel('frontier', (k) => k), 'Project 4700: frontier people groups') +check('Arabic catalogue, by alias', L.listLabel('4700', tFrom('ar')), cat('ar').frontier.label) +check('definition, German catalogue', L.listDefinition('unreached', tFrom('de')), cat('de').unreached.definition) +check('an unknown list -> empty, not a crash', L.listLabel('project-9999', tFrom('en')), '') +{ const en = cat('en'), bad = [] + for (const l of L.peopleGroupLists()) { + if (en[l.id]?.label !== l.label) bad.push(`${l.id}.label`) + if ((en[l.id]?.definition || '') !== (l.definition || '')) bad.push(`${l.id}.definition`) + } + check('the English catalogue matches the registry, word for word', bad, []) } +{ const missing = [] + for (const lc of readdirSync(join(ROOT, 'library/i18n/locales'))) { + const c = cat(lc) + for (const l of L.peopleGroupLists()) for (const f of ['label', 'definition']) if (!c[l.id]?.[f]) missing.push(`${lc}:${l.id}.${f}`) + } + check('every locale has a label and a definition for every list', missing, []) } + const failed = results.filter((x) => !x).length -console.log(failed ? `LIST-PROP: FAIL — ${failed} case(s)` : `LIST-PROP: PASS — ${results.length} cases: the ladder, aliases, the untouched default, membership filtering, fail-closed, and the membership files`) +console.log(failed ? `LIST-PROP: FAIL — ${failed} case(s)` : `LIST-PROP: PASS — ${results.length} cases: the ladder, aliases, the untouched default, membership filtering, fail-closed, the membership files, and labels in every locale`) process.exit(failed ? 1 : 0) diff --git a/internal/check-map-attribution.mjs b/internal/check-map-attribution.mjs new file mode 100644 index 00000000..285817f5 --- /dev/null +++ b/internal/check-map-attribution.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * check-map-attribution — the credit line names the basemap of the engine that actually drew the map. + * + * WHY (licence credit, rega's row 28 at ccc2b1bb): addMapAttribution always credited "Mapbox · + * © OpenStreetMap". Under MapLibre the tiles are OpenFreeMap's (OpenMapTiles on OpenStreetMap), so + * gospel-resources, key-cities and cities-internet credited Mapbox, which served nothing, and omitted + * the OpenMapTiles credit. Headless: the real module against stand-in map classes and a minimal DOM. + * EXIT 0 pass · 1 fail + */ +const made = [] +globalThis.window = globalThis +globalThis.document = { createElement: () => { const el = { style: {}, className: '', innerHTML: '' }; made.push(el); return el } } +class MapboxMap { addControl(c) { this.ctrl = c.onAdd() } } +class MaplibreMap { addControl(c) { this.ctrl = c.onAdd() } } +window.mapboxgl = { Map: MapboxMap } +window.maplibregl = { Map: MaplibreMap } +const { addMapAttribution, attributionText, ATTRIBUTION_TEXT } = await import('../library/composables/useMapAttribution.js') +let bad = 0 +const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + got}`) } +const ml = new MaplibreMap(); addMapAttribution(ml, { boundaries: true }) +check('MapLibre map credits OpenFreeMap, OpenMapTiles and OSM contributors', /OpenFreeMap/.test(ml.ctrl.innerHTML) && /OpenMapTiles/.test(ml.ctrl.innerHTML) && /OpenStreetMap contributors/.test(ml.ctrl.innerHTML), ml.ctrl.innerHTML) +check('MapLibre map does NOT credit Mapbox', !/Mapbox/.test(ml.ctrl.innerHTML), ml.ctrl.innerHTML) +const mb = new MapboxMap(); addMapAttribution(mb, { boundaries: true }) +check('Mapbox map credits Mapbox and OpenStreetMap', /Mapbox/.test(mb.ctrl.innerHTML) && /OpenStreetMap/.test(mb.ctrl.innerHTML), mb.ctrl.innerHTML) +check('both credit geoBoundaries', /geoBoundaries/.test(ml.ctrl.innerHTML) && /geoBoundaries/.test(mb.ctrl.innerHTML), '') +const nb = new MaplibreMap(); addMapAttribution(nb, { boundaries: true, basemap: false }) +check('basemap:false leaves only the boundaries credit', !/OpenFreeMap|Mapbox/.test(nb.ctrl.innerHTML), nb.ctrl.innerHTML) +check('print text for maplibre names OpenMapTiles, not Mapbox', /OpenMapTiles/.test(attributionText('maplibre')) && !/Mapbox/.test(attributionText('maplibre')), attributionText('maplibre')) +check('ATTRIBUTION_TEXT (existing importers) is unchanged', ATTRIBUTION_TEXT === 'Boundaries: geoBoundaries (geoboundaries.org) · © Mapbox © OpenStreetMap', ATTRIBUTION_TEXT) +if (bad) { console.log(`MAP-ATTRIBUTION: FAIL — ${bad} case(s).`); process.exit(1) } +console.log('MAP-ATTRIBUTION: PASS — the credit names the basemap of the engine that drew the map.') diff --git a/internal/check-no-cross-plugin-imports.mjs b/internal/check-no-cross-plugin-imports.mjs new file mode 100644 index 00000000..bba36558 --- /dev/null +++ b/internal/check-no-cross-plugin-imports.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * check-no-cross-plugin-imports — a plugin imports another plugin only through library/, never by reaching + * into its folder. + * + * WHY (2026-09-26, regions' C1a): research-dashboard and dashboard-plus-grid imported + * '../doxa-research-map/src/…' — another plugin's PRIVATE folder. That couples two owners' code invisibly + * (a refactor inside doxa-research-map/src breaks a bundle its owner never builds), and it is how two + * private copies of the same palette drifted apart. Shared code belongs in library/ (@map/…). + * + * RULE: in plugin-designs//**, a relative import (static, dynamic, export-from, import.meta.glob) + * must not resolve into plugin-designs// — except another bundle's PUBLIC profile component (a .vue at + * that bundle's root), which is how a capability-4 composite mounts it. Anything deeper (src/, utils) is refused. + * --self-test plants a crossing import and requires a FAIL. + * EXIT 0 pass · 1 fail + */ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, resolve, dirname, relative, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PD = join(ROOT, 'plugin-designs') +const SPEC = /(?:import|export)\s[^'"`;]*?from\s*['"]([^'"]+)['"]|import\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)|import\.meta\.glob\s*\(\s*['"]([^'"]+)['"]/g + +/** [{file, spec, target}] for every relative import in `src` (at `file`) that lands in another bundle. */ +export function crossings(file, src) { + const own = relative(PD, file).split(sep)[0] + const out = [] + for (const m of src.matchAll(SPEC)) { + const spec = m[1] || m[2] || m[3] || m[4] + if (!spec || !spec.startsWith('.')) continue + const abs = resolve(dirname(file), spec) + const rel = relative(PD, abs) + if (rel.startsWith('..')) continue // left plugin-designs/ (library/, data/): fine + const parts = rel.split(sep) + const target = parts[0] + if (!target || target === own) continue + // A composite mounting another bundle's PUBLIC profile (a .vue at that bundle's root) is composition, + // the documented capability-4 pattern. Reaching any deeper (src/, utils, data) is the private coupling. + if (parts.length === 2 && /\.vue$/.test(parts[1])) continue + out.push({ file: relative(ROOT, file), spec, target }) + } + return out +} + +if (process.argv.includes('--self-test')) { + const f = join(PD, 'research-dashboard', 'x.vue') + const hits = crossings(f, "import A from '../doxa-research-map/src/a.js'\nimport B from './src/b.js'\nimport C from '../../library/c.js'\nimport D from '../people-groups-grid/people-groups-grid.vue'") + if (hits.length === 1 && hits[0].target === 'doxa-research-map') { console.log('NO-CROSS-PLUGIN-IMPORTS: SELF-TEST PASS — a reach into another plugin\'s src/ is caught; its own src/, library/ and another bundle\'s public profile .vue are not.'); process.exit(0) } + console.error('NO-CROSS-PLUGIN-IMPORTS: SELF-TEST FAIL — ' + JSON.stringify(hits)); process.exit(1) +} + +const files = [] +const walk = (d) => { for (const e of readdirSync(d)) { if (e === 'node_modules' || e.startsWith('.')) continue; const p = join(d, e); const st = statSync(p); if (st.isDirectory()) walk(p); else if (/\.(vue|m?js)$/.test(e) && !/\/(scripts|data)\//.test(p.replace(/\\/g, '/'))) files.push(p) } } +walk(PD) +const bad = files.flatMap((f) => crossings(f, readFileSync(f, 'utf8'))) +for (const b of bad) console.log(` FAIL ${b.file}: imports '${b.spec}' from another plugin (${b.target}); move the shared code to library/ and import it as @map/…`) +if (bad.length) { console.log(`NO-CROSS-PLUGIN-IMPORTS: FAIL — ${bad.length} import(s) reach into another plugin's folder.`); process.exit(1) } +console.log(`NO-CROSS-PLUGIN-IMPORTS: PASS — ${files.length} plugin source file(s); none imports from another plugin's folder.`) diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs index 88e76c27..3bf40a46 100644 --- a/internal/check-packet-render.mjs +++ b/internal/check-packet-render.mjs @@ -80,6 +80,12 @@ const lang = await page.evaluate(() => document.documentElement.lang || 'en') // The ARTEFACT declares its own intent; the env vars below remain as an override for packets // generated before this stamp existed. const build = await page.evaluate(() => document.documentElement.getAttribute('data-packet-build') || '') +// ★ AND WHICH LIST IT HOLDS. A packet built under a non-default list is a DIFFERENT DOCUMENT from the +// same zone's default packet — different rows, a different funnel denominator, an extra funnel step — +// and out/packets/ is gitignored, so a sweep grades whatever files are there. Printing the list makes +// 'which packet is this?' answerable from the gate's own output rather than from whoever last ran the +// generator. Empty means the default list. +const packetList = await page.evaluate(() => document.documentElement.getAttribute('data-packet-list') || '') // RTL languages must actually be laid out RTL. Without dir="rtl" an Arabic packet // renders readable glyphs LEFT-aligned — it looks like a styling quirk rather than // the bug it is, and no other assertion here would notice. @@ -259,6 +265,32 @@ const r = await page.evaluate(() => { .filter((d) => /^0(\.0+)?\s+\D/.test(d.textContent.trim())).length })(), funnelShare: (document.querySelector('.funnel-share')?.textContent || '').trim().slice(0, 80), + // ★ LATIN RUNS ON A NON-LATIN SHEET, which is the one gap rega named and I left open: "the gate + // passed: it counts rows, not the row's language." It asserted the funnel page's STRUCTURE and + // nothing about whether its prose reached the reader's language — my own standing rule turned on + // my own gate. + // + // ⚠ IT ONLY WORKS IN A NON-LATIN-SCRIPT LOCALE, and that is why it is worth having rather than + // aspiring to a general "is this translated?" test, which is not decidable. On a hi/ar/ru/zh sheet + // a run of Latin letters is a proper name, a number, or an UNTRANSLATED STRING — three + // possibilities, two of which are enumerable. In de/fr/es an untranslated string looks like + // ordinary text and this measures nothing, so those locales are not covered and the gate does not + // pretend otherwise. + // + // MEASURED across every non-Latin packet in out/ (hi, ar, ru, zh): exactly TWO Latin runs appear, + // both the data source's name. Anything else is the defect. Verified against the real one — with + // the pre-fix English list label restored on the hi sheet it reports ['Project', 'frontier people + // groups'], and an untranslated key reports ['THIS packet']. + funnelLatinRuns: (() => { + const sheet = document.querySelector('.funnel-title')?.closest('.sheet') + if (!sheet) return [] + const txt = sheet.textContent || '' + // Digits break a run, so "Project 4700" surfaces as "Project" — deliberate: the designation is + // allowed by name below, and a bare "Project" beside a number is exactly what an untranslated + // label looks like. + const runs = txt.match(/[A-Za-z][A-Za-z.'/&-]{2,}(?:\s+[A-Za-z][A-Za-z.'/&-]*)*/g) || [] + return [...new Set(runs.map((r) => r.trim()))] + })(), // ★ A CARD MUST NAME ITS PEOPLE GROUP. Counting .card proves the element exists; the spec says // pages 2..n carry "the people groups, 20 per page, with photos" — a card with a photo and a // BLANK NAME satisfies every count here: the total holds, the photo/typographic split holds, @@ -644,6 +676,30 @@ if (r.funnel && r.funnelRows !== r.funnelBars) { // ★ AND NO STEP MAY PRINT A ZERO POPULATION. "0 people" appeared on the reader's row of every packet // because Number(null) is 0 and the renderer's guard tested finiteness rather than PRESENCE. A people // group with no people is not a fact; it is missing data wearing a number's clothes. +// ★ AND ON A NON-LATIN SHEET, THE FUNNEL'S PROSE MUST BE IN THE READER'S SCRIPT. See funnelLatinRuns +// above for why this is only meaningful in hi/ar/ru/zh. This is the gap rega named after the ?list= +// render: "the gate passed: it counts rows, not the row's language", and it was right — the English +// list label sat among Devanagari and every assertion here was green. +// +// ⚠ THE ALLOWLIST IS TWO ENTRIES AND BOTH ARE DECISIONS, NOT BOOKMARKS: +// IMB, peoplegroups.org — the publisher's name and its domain. A reader following the citation meets +// the English name at the destination, so translating it would hide the thing it identifies. Same +// reasoning the i18n allowlist uses for "Open Bible Stories" and "Scripture Earth". +// Project — the campaign designation ("Project 4700" / "مشروع 4700" / "4700 计划"). Digits break a run, +// so the numeral is not part of the match; the WORD is allowed because it is a name, while the +// description beside it ("frontier people groups") is prose and must be translated. +// If a third entry is ever needed, say in the entry whether it is settled or deferred — an allowlist +// that does not distinguish the two turns an open defect into a verdict. +const LATIN_OK = new Set(['IMB', 'peoplegroups.org', 'Project']) +const NON_LATIN_LOCALES = new Set(['hi', 'ar', 'ru', 'zh', 'he', 'fa', 'ur', 'bn', 'ta', 'th', 'ko', 'ja']) +if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) { + const stray = (r.funnelLatinRuns || []).filter((x) => !LATIN_OK.has(x)) + if (stray.length) { + fails.push(`the funnel page carries Latin-script prose in a ${lang} packet: ` + + `${stray.map((x) => JSON.stringify(x)).join(', ')} — an untranslated string, or a name that ` + + 'belongs in LATIN_OK with a reason') + } +} if (r.funnel && r.funnelZeroPop) { fails.push(`${r.funnelZeroPop} funnel row(s) print a ZERO population — absent data must be hidden, ` + 'not rendered as the number 0 (Number(null) === 0, so test presence, not finiteness)') @@ -704,6 +760,11 @@ if (r.ranges.length) { } console.log(` build : ${build || '(unstamped — generated before data-packet-build)'}`) +console.log(` people-group list: ${packetList || '(default)'}`) +if (r.funnel && NON_LATIN_LOCALES.has(String(lang).split('-')[0])) { + console.log(` funnel Latin runs: ${(r.funnelLatinRuns || []).length + ? r.funnelLatinRuns.map((x) => JSON.stringify(x)).join(', ') : '(none)'}`) +} console.log(` sheets/PDF pages : ${r.sheets} / ${pages}`) console.log(` printed artefact : ${(pdfBytes / 1024 / 1024).toFixed(1)}MB, ${pdfFonts} embedded font(s), ` + `${pdfImages} image(s) for ${r.distinctImgSrc} distinct source(s)`) diff --git a/internal/readme-shots.mjs b/internal/readme-shots.mjs index 154be237..5941f795 100644 --- a/internal/readme-shots.mjs +++ b/internal/readme-shots.mjs @@ -164,6 +164,25 @@ async function shot(name, path, { // need upstream tiles and have no iframe), the iframe-host shot opts out (it needs its local // frame and loads no tiles of its own). if (mapboxReferer) await ctx.setExtraHTTPHeaders({ Referer: 'https://doxa.life/' }) + // ★ WAIT ON THE MAP ITSELF, NOT A CLOCK (2026-09-26). Every map a page (or any frame in it) creates is + // recorded, so the capture can wait until each visible one has its style and tiles loaded AND has + // drawn features on OUR layers (sources that are not the basemap's). A fixed 7 s wait shot the + // upg-100-list counter over a bare basemap: that leaf paints 9.5-16.5 s in (rega, 3 cold loads at + // fc2fa2e4). Same predicate rega measures with (internal-tracker/rega/upglist-fc2fa2e4/upglist.mjs). + await ctx.addInitScript(() => { + window.__SHOT_MAPS = [] + const patch = (x) => { + if (!x || !x.Map || x.Map.__shotPatched) return + x.Map.__shotPatched = true + const ss = x.Map.prototype.setStyle + x.Map.prototype.setStyle = function (...a) { if (!window.__SHOT_MAPS.includes(this)) window.__SHOT_MAPS.push(this); return ss.apply(this, a) } + } + for (const g of ['maplibregl', 'mapboxgl']) { + let v = window[g] + try { patch(v) } catch (_) {} + try { Object.defineProperty(window, g, { configurable: true, get: () => v, set: (x) => { v = x; try { patch(x) } catch (_) {} } }) } catch (_) {} + } + }) const page = await ctx.newPage() // ★ WAIT FOR THE MAP TO FINISH LOADING, NOT JUST FOR THE PAGE TO EXIST. The element-count guard // below proves the UI rendered; it says nothing about map tiles and data, which arrive later. Shots @@ -178,7 +197,33 @@ async function shot(name, path, { await page.goto(url, { waitUntil: 'load', timeout: 45000 }) if (waitFor) await page.waitForSelector(waitFor, { timeout: 20000 }) if (before) await before(page) - await page.waitForTimeout(wait) + // `wait` is now a FLOOR for the UI to settle (at most 3 s); the map readiness below does the rest. + // A surface with no map (a card, a grid) waits its full `wait`, since there is nothing to ask. + await page.waitForTimeout(Math.min(wait, 3000)) + const mapState = (frame) => frame.evaluate(() => (window.__SHOT_MAPS || []).map((m) => { + try { + const c = m.getContainer().getBoundingClientRect() + if (c.width < 2 || c.height < 2) return { hidden: true } + const st = m.getStyle() || {} + // OUR data is GeoJSON; the basemap's sources are vector tiles. Type, not name: a source id can say anything. + const own = Object.entries(st.sources || {}).filter(([, src]) => src && src.type === 'geojson').map(([k]) => k) + const ids = (st.layers || []).filter((l) => own.includes(l.source) && (l.layout?.visibility ?? 'visible') !== 'none').map((l) => l.id) + let drawn = 0 + try { drawn = ids.length ? m.queryRenderedFeatures({ layers: ids }).length : 0 } catch (_) {} + return { style: m.isStyleLoaded(), tiles: m.areTilesLoaded(), ownLayers: ids.length, drawn } + } catch (e) { return { err: String(e).slice(0, 80) } } + })).catch(() => []) + const MAP_CAP_MS = 60000, tMap = Date.now() + let maps = [] + for (;;) { + maps = [] + for (const f of page.frames()) maps.push(...(await mapState(f))) + const live = maps.filter((m) => !m.hidden && !m.err) + if (!live.length) { if (Date.now() - tMap < 4000) { await page.waitForTimeout(500); continue } await page.waitForTimeout(Math.max(0, wait - 3000)); break } + if (live.every((m) => m.style && m.tiles && (m.ownLayers === 0 || m.drawn > 0))) break + if (Date.now() - tMap > MAP_CAP_MS) { console.log(` WARN ${name}: a map was not ready in ${MAP_CAP_MS / 1000}s ${JSON.stringify(live)} — check the image`); break } + await page.waitForTimeout(500) + } const QUIET_MS = 4000, QUIET_MAX_MS = 60000, t0 = Date.now() while (!(inflight <= 0 && Date.now() - lastNet >= QUIET_MS)) { if (Date.now() - t0 > QUIET_MAX_MS) { console.log(` WARN ${name}: network never went quiet in ${QUIET_MAX_MS / 1000}s (${inflight} in flight) — check the image`); break } @@ -255,7 +300,11 @@ await shot('cap3-monolithic-research', `/${TREE_NAME}/doxa-research-map/research // upg-100-mfe (the cap-4 composite) · upg-100-list (the list) · poster-wall. // There are no "list pages" — see the note below the second shot. ---- await shot('cap4-upg100-mfe', `/${TREE_NAME}/my-upg-100-list/upg-100-mfe/index.html`, { wait: 8000 }) -await shot('cap4-upg100-list', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html`, { wait: 7000 }) +// wait 20000: this leaf PAINTS 9.5-16.5 s after load under swiftshader (rega, 3 cold loads at fc2fa2e4, +// judged by rendered features on its own layers), and its "195 polys · 27/27 zones · 2107 pins" counter +// appears BEFORE the paint, so a 7 s wait shot the counter over a bare basemap. The shot now waits on the +// map itself (style + tiles + features drawn on our layers, see shot()); `wait` is only a fallback. +await shot('cap4-upg100-list', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html`, { wait: 20000 }) // ---- CAP4's THIRD profile: the poster wall ---- // ★ REPLACED 'cap4-upg100-list-page2' 2026-09-21. That shot loaded the SAME URL as page 1 and @@ -396,7 +445,7 @@ await shot('exp-wall-of-people-groups', `/${TREE_NAME}/wall-of-people-groups/wal await shot('exp-prayer-walk-planner', `/${TREE_NAME}/prayer-walk-planner/prayer-walk-planner/index.html`, { wait: 12000, minElements: 40 }) // ---- LANGUAGES: UPG-100 in Arabic (?lang=ar), right-to-left — the most completely translated map ---- -await shot('lang-arabic-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html?lang=ar`, { wait: 12000, minElements: 200 }) +await shot('lang-arabic-upg100', `/${TREE_NAME}/my-upg-100-list/upg-100-list/index.html?lang=ar`, { wait: 20000, minElements: 200 }) // same leaf, same paint time: see cap4-upg100-list // ---- DEPLOY2: mobile — the UPG-100 list as the phone app ---- // The mobile example is UPG-100 (ruled 2026-09-24; Simple Research was retired as the example because diff --git a/plugin-designs/doxa-research-map/src/colors/affinity-block.js b/library/colors/affinity-block.js similarity index 89% rename from plugin-designs/doxa-research-map/src/colors/affinity-block.js rename to library/colors/affinity-block.js index ca3b1476..f8b58120 100644 --- a/plugin-designs/doxa-research-map/src/colors/affinity-block.js +++ b/library/colors/affinity-block.js @@ -42,6 +42,14 @@ export const AFFINITY_BLOCK_NAMES = { } /** Primary mapping: ROP1 Code → Color */ +// ★ REJECTED CANDIDATE, RECORDED ABOVE THE PALETTE DELIBERATELY: #AF7AC5 was considered for A019 +// and scored 2.95 against the Material Light surface, just under WCAG 1.4.11's 3.0:1 non-text floor. +// It lives here rather than beside A019 because check:one-palette slices from `export const PALETTE` +// to END OF FILE and matches every hex it finds -- so a rejected colour mentioned BELOW the export +// would be demanded of every consumer bundle, and the gate would fail on a colour we refused on +// accessibility grounds. Keeping the note above the export preserves the reasoning without +// creating that obligation. (The gate's own comment says it reads "only the PALETTE export"; its +// implementation does not, which is reported separately.) export const PALETTE = { 'A001': '#E74C3C', // Arab World - Red/Orange 'A002': '#3498DB', // East Asian Peoples - Blue @@ -68,7 +76,6 @@ export const PALETTE = { // A013 #8E44AD 5.28 (unchanged) A018 #6C3483 7.69 A019 #A569BD 3.53 // Sibling separation so the three stay tellable apart: A018-vs-A019 2.61, // A013-vs-A018 1.46, A013-vs-A019 1.79. - // REJECTED #AF7AC5 for A019 — scored 2.95, just under the 3.0 floor. 'A018': '#6C3483', // Tibetan Peoples - deep purple (A013 family, darker) 'A019': '#A569BD' // Himalayan Peoples - light purple (A013 family, lighter) } diff --git a/plugin-designs/country-posters/src/colors/doxa-region.js b/library/colors/doxa-region.js similarity index 100% rename from plugin-designs/country-posters/src/colors/doxa-region.js rename to library/colors/doxa-region.js diff --git a/plugin-designs/country-posters/src/colors/resource.js b/library/colors/resource.js similarity index 100% rename from plugin-designs/country-posters/src/colors/resource.js rename to library/colors/resource.js diff --git a/library/colors/wagf-region.js b/library/colors/wagf-region.js index 91bb165e..3da5589b 100644 --- a/library/colors/wagf-region.js +++ b/library/colors/wagf-region.js @@ -6,7 +6,7 @@ * don tsee the you using htes colors on the reigosn dashbarod hwen you shoudl". Two surfaces * disagreeing about what Africa's colour means is a product defect whichever palette is prettier, * and he chose which one wins. Source of truth: - * plugin-designs/doxa-research-map/src/colors/doxa-region.js, which the research dashboard and + * library/colors/doxa-region.js (it lived at plugin-designs/doxa-research-map/src/colors/ until C1a), which the research dashboard and * three other consumers already paint from. * * WHAT THE CHANGE COST, measured rather than glossed (dataviz validate_palette.js, light mode, diff --git a/library/components/poster/PosterDialog.vue b/library/components/poster/PosterDialog.vue index 16919fe5..eac1640b 100644 --- a/library/components/poster/PosterDialog.vue +++ b/library/components/poster/PosterDialog.vue @@ -19,33 +19,33 @@