The people-group list is a universal prop: the same maps can show any project's list

Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
  profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').

- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
  in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
  to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
  counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
  (71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
  The default list returns the same array, unfetched. A list whose membership cannot load shows no
  groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
  their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
  Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
  are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
  the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
  thread `list` to their children like `provider`.

Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.

Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
This commit is contained in:
Doxa Maps 2026-09-26 04:13:14 +00:00
parent 31ed8968d5
commit e3d25ff6db
38 changed files with 467 additions and 43 deletions

View file

@ -0,0 +1 @@
{"id":"frontier-500k","label":"Project 700: frontier people groups of 500,000 or more","definition":"IMB Lostness Priority 0 and Population of at least 500,000","filter":{"lostness":[0],"minPopulation":500000},"sources":{"imb":"data/people-groups/people_groups.csv","doxa":"https://pray.doxa.life/api/people-groups/list (fetched 2026-09-25)"},"counts":{"imb":737,"doxa":71},"slugs":["agasa","aiyarakulu","akhdam","alawites-turkiye","arab-bedouin","avar-kunsakh","badhai-chhattisgarhi","bairagi-gujarati","baloch-eastern","baloch-western","bania-agarwal-telegu","bederia","bedouin-arabs-saudi-arabia","dar-hamid","deaf-burmese","deaf-congolese-democratic-republic-of-the-congo","deaf-iranians","deaf-pakistanis","digil-rahanweyn","gaaliin","gawamaa","gazan-arabs","guhayna","ha-li","hadrami-arabs","hasania","jains-hindi","jat-muslim","jula-burkina-faso","kawahia","kotokoli-togo","kummara-kumbara","kurd-northern","kuria","laki","lebanese-shia-arabs","lebanese-sunni-arabs","libyan-arabs","luwu","makhuwa-shirima","makua","meadow-mari","mishaba-laluo","moor","mwera","namasudra-assamese","northern-dong","northern-yemeni-arabs","nuer","pila","pingdi-yao","rizeiqat","rufaa","sanusi-bedouins","shiites","shina-pakistan","shirazi","sudra-das","sutradhar","tigre","tihama-yemeni-arabs","tondman","turkmen","west-bank-arabs","western-baloch-pakistan","white-moor","xiaoliangshan-nosu","yao","yazeed","yinuo-nosu","zigua"]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,8 @@ This page lists each one: where it can come from (its **channels**), which chann
(**precedence**), and what happens when nothing sets it (**default**).
The set here is the one the header sets today: **engine, locale (with direction), Mapbox token and
token-service URL**. Whether anything else counts as universal is an open decision
token-service URL**, plus the **people-group list** (section 5), which every map must honour so the
same maps serve other projects' lists, not only DOXA's. Whether anything else counts as universal is an open decision
(DECIDE-UNIVERSAL-PROPS-SCOPE), not part of this contract.
`profile-config` is the element's JSON attribute (`<doxa-map profile-config='{"tk":"pk.…"}'>`).
@ -122,6 +123,34 @@ A URL that returns a public token as JSON (`{token|tk|accessToken}`) or as a bar
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and the Research Gallery
forward it, but no element consumes it (see the open points below).
## 5. People-group list: `'doxa'` | `'unreached'` | `'frontier'` | `'frontier-500k'` | `'unengaged'`
Which people groups a map shows. The lists are defined in `lists/lists.json`, each as a filter over
the IMB / peoplegroups.org dataset (never a hand-kept roster). Aliases name them by project:
`2000` = `doxa`, `7000` = `unreached`, `4700` = `frontier`, `700` = `frontier-500k`.
| Precedence | Channel | Where it is read |
|---|---|---|
| 1 | `profile-config.list` | `lists/peopleGroupList.js` `resolvePeopleGroupList` |
| 2 | `?list=` in the page URL | the same |
| 3 | `window.MAP_LIST` | the same |
| 4 | default `'doxa'` | DOXA's live list, exactly as before: nothing fetched, nothing filtered |
- **How a map honours it:** every people-group fetch passes its rows through
`applyPeopleGroupList(rows, profileConfig)`, which keeps the rows whose slug is in the list's
membership file (`data/people-groups/lists/<id>.json`, served as `assets/people-groups/lists/`).
`DataSourceManager` does it for maps that load through it (pass it `profileConfig`).
- **What a map shows (Tier A):** the DOXA groups in the list, because DOXA's API is where the rows
and the prayer fields come from. The list's groups that DOXA does not carry are Tier B, per map.
- **Composites** thread `list` to their children like `provider` (`INHERITED_PAGE_KEYS`, and the
stack composites' child `profile-config`).
- **Membership files** are generated: `bun run lists` (`scripts/people-groups/build-lists.mjs`,
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.
- **Guarded by** `check:universal-props` (LIST-FETCH, LIST-DSM, LIST-KEYS) and
`check:list-prop-unit`.
---
## Open points found while writing this down

View file

@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* check-list-prop-unit — the people-group LIST prop (library/lists/), measured WITHOUT a browser.
*
* Runs the real library module in Node against a stand-in window/location/fetch:
* - the ladder: profile-config `list` > ?list= > window.MAP_LIST > 'doxa'; ids and aliases ('700');
* an unknown value falls back to the default;
* - 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.
* 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 { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Vite imports JSON without an attribute; Node 20 does not. Teach this process the same thing.
register('data:text/javascript,' + encodeURIComponent(
"export async function load(url, ctx, next) { if (url.endsWith('.json')) return { format: 'json', shortCircuit: true, source: (await import('node:fs')).readFileSync(new URL(url)) }; return next(url, ctx) }"))
globalThis.window = globalThis
let search = ''
Object.defineProperty(globalThis, 'location', { configurable: true, get: () => ({ search, href: 'https://host.example/page' + search }) })
const fetched = []
let serve = () => ({ ok: false, status: 404 })
globalThis.fetch = async (url) => { fetched.push(String(url)); const r = serve(String(url)); return { ok: r.ok, status: r.status, json: async () => r.body } }
const L = await import('../library/lists/peopleGroupList.js')
const results = []
const check = (name, got, want) => {
const ok = JSON.stringify(got) === JSON.stringify(want)
results.push(ok)
console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(66)} ${JSON.stringify(got)}${ok ? '' : ' want ' + JSON.stringify(want)}`)
}
const reset = () => { search = ''; delete globalThis.MAP_LIST; fetched.length = 0 }
console.log('ladder')
reset(); check('nothing set -> the default', L.resolvePeopleGroupList(null), 'doxa')
reset(); check('profile-config list wins', (search = '?list=unreached', globalThis.MAP_LIST = 'frontier', L.resolvePeopleGroupList({ list: 'frontier-500k' })), 'frontier-500k')
reset(); check('?list= beats window.MAP_LIST', (search = '?list=frontier', globalThis.MAP_LIST = 'unreached', L.resolvePeopleGroupList({})), 'frontier')
reset(); check('window.MAP_LIST when nothing else', (globalThis.MAP_LIST = 'unreached', L.resolvePeopleGroupList(null)), 'unreached')
reset(); check('an alias: 700', L.resolvePeopleGroupList({ list: '700' }), 'frontier-500k')
reset(); check('an alias, any case: Project-7000', L.resolvePeopleGroupList({ list: 'Project-7000' }), 'unreached')
reset(); check('a JSON-string profile-config', L.resolvePeopleGroupList('{"list":"4700"}'), 'frontier')
reset(); check('a ref-shaped profile-config', L.resolvePeopleGroupList({ value: { list: '2000' } }), 'doxa')
reset(); check('unknown -> the default', L.resolvePeopleGroupList({ list: 'project-9999' }), 'doxa')
console.log('apply')
const rows = [{ slug: 'a' }, { slug: 'b' }, { slug: 'c' }]
reset(); { const out = await L.applyPeopleGroupList(rows, null); check('default: the same array, no fetch', [out === rows, fetched.length], [true, 0]) }
reset(); serve = (u) => (u.endsWith('/people-groups/lists/frontier.json') ? { ok: true, status: 200, body: { slugs: ['a', 'c'] } } : { ok: false, status: 404 })
{ const out = await L.applyPeopleGroupList(rows, { list: 'frontier', assetsBase: 'https://cdn.example/tree/assets' }); check('a list keeps exactly its members', out.map((r) => r.slug), ['a', 'c']) }
check('…fetched from the asset pool', fetched, ['https://cdn.example/tree/assets/people-groups/lists/frontier.json'])
reset(); serve = () => ({ ok: false, status: 404 })
{ const err = console.error; console.error = () => {}; const out = await L.applyPeopleGroupList(rows, { list: 'unreached', assetsBase: 'https://cdn.example/x/assets' }); console.error = err; check('an unloadable list FAILS CLOSED: no rows', out, []) }
console.log('membership files')
for (const l of L.peopleGroupLists().filter((x) => x.source === 'imb')) {
const f = join(ROOT, 'data/people-groups/lists', `${l.id}.json`)
if (!existsSync(f)) { check(`${l.id}: membership file exists`, false, true); continue }
const d = JSON.parse(readFileSync(f, 'utf8'))
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])
}
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`)
process.exit(failed ? 1 : 0)

View file

@ -2,7 +2,7 @@
/**
* check-universal-props.mjs — every plugin honours the props the Plugin Studio header sets for all
* of them (documentation/reference-universal-props.md): map ENGINE, LOCALE (+dir), Mapbox TOKEN and
* token-service URL. HEADLESS: no browser, no server. It reads what each bundle's source and SHIPPED
* token-service URL, and the people-group LIST (library/lists/). HEADLESS: no browser, no server. It reads what each bundle's source and SHIPPED
* bundle consume, and fails on a bypass: a literal or a private global where the library resolver
* belongs.
*
@ -24,6 +24,12 @@
* TOK-LITERAL token a `pk.ey…` token literal in code (comments excluded)
* LOC-ENUS locale toLocaleString('en-US') / Intl.NumberFormat('en-US') (use the active locale).
* WARN only: whether numerals follow the locale is a Driver decision
* LIST-FETCH list a file that fetches the people-group list (/api/people-groups/list, a
* source's endpoints.bulk, or the doxa-people-groups.json roster) without
* applyPeopleGroupList: that map would show DOXA's list whatever list was asked
* LIST-DSM list `new DataSourceManager(...)` without `profileConfig`: the manager applies the
* list itself, but without the embed's config only ?list= / window.MAP_LIST reach it
* LIST-KEYS list a composite's INHERITED_PAGE_KEYS without 'list': its panes never hear it
* Source = plugin-designs/<bundle>/**.{vue,js} minus index.html dev pages and scripts/. Shipped =
* public/<tree>/<bundle>/<bundle>.js, checked for the TOK-GLOBAL and TOK-LITERAL strings the
* minifier cannot rename.
@ -53,6 +59,7 @@ const BUNDLES = [
const ALLOW = {
'prayer-walk-planner:ENG-PIN': 'keyless by design: always MapLibre, needs no token (index.js header)',
'key-cities:ENG-STYLE': 'gated, Mapbox-only by design (mapbox:// styles, ensureEngineScript(\'mapbox\'))',
'all-people-groups:LIST-DSM': 'draws the IMB dataset (all 12,379 groups), not the DOXA list: a list there is Tier B (SPEC-PEOPLE-GROUP-LIST-PROP.md)',
}
const stripComments = (src) => src
@ -78,6 +85,12 @@ const RULES = [
{ id: 'TOK-GLOBAL', prop: 'token', re: /window\.DOXA_TOKEN\b/g, shipped: 'DOXA_TOKEN' },
{ id: 'TOK-LITERAL', prop: 'token', re: /['"`]pk\.ey[A-Za-z0-9_-]{20,}/g, shipped: /pk\.eyJ[A-Za-z0-9_-]{20,}/ },
{ id: 'LOC-ENUS', prop: 'locale', warn: true, re: /(?:toLocaleString|NumberFormat|DateTimeFormat)\s*\(\s*['"]en(?:-US)?['"]/g },
// A whole-file rule: the fetch and the helper need not be on the same line, only in the same file.
{ id: 'LIST-FETCH', prop: 'list', test(src) {
const m = /\/api\/people-groups\/list|endpoints\?\.bulk|doxa-people-groups\.json/.exec(src)
return m && !/applyPeopleGroupList\s*\(/.test(src) ? [m.index] : [] } },
{ id: 'LIST-DSM', prop: 'list', re: /new\s+DataSourceManager\s*\((?![^)]*\bprofileConfig\b)[^)]*\)/g },
{ id: 'LIST-KEYS', prop: 'list', re: /INHERITED_PAGE_KEYS\s*=\s*\[(?![^\]]*['"]list['"])[^\]]*\]/g },
]
const run = (rule, src) => rule.test ? rule.test(src) : [...src.matchAll(rule.re)].map((m) => m.index)
const lineOf = (src, idx) => src.slice(0, idx).split('\n').length
@ -90,12 +103,18 @@ const PLANTED = `
useMapInstance({ container: el, provider: 'mapbox' })
const tk = window.DOXA_TOKEN
const t = 'pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJjbGFudGVkIn0'
n.toLocaleString('en-US')`
n.toLocaleString('en-US')
const r = await fetch(base + '/api/people-groups/list?fields=slug')
const dsm = new DataSourceManager({ locale: 'en' })
const INHERITED_PAGE_KEYS = ['locale', 'provider']`
const missed = RULES.filter((r) => !run(r, stripComments(PLANTED)).length).map((r) => r.id)
const CLEAN = `map.setStyle(resolveNamedStyle('ink', p).style || 'mapbox://styles/mapbox/dark-v11')
useMapInstance({ container: el, style: cfg.style ?? 'mapbox://styles/mapbox/light-v11' })
// new mapboxgl.Popup() in a comment is not code
n.toLocaleString(locale.value)`
n.toLocaleString(locale.value)
const r2 = await applyPeopleGroupList((await fetch(base + '/api/people-groups/list')).posts, cfg)
const dsm2 = new DataSourceManager({ locale: 'en', profileConfig })
const INHERITED_PAGE_KEYS = ['locale', 'provider', 'list']`
const falsePos = RULES.filter((r) => run(r, stripComments(CLEAN)).length).map((r) => r.id)
if (missed.length || falsePos.length) {
console.error(`UNIVERSAL-PROPS: SELF-TEST FAILED — ${missed.length ? 'rules blind to a planted bypass: ' + missed.join(', ') : ''}${falsePos.length ? ' false positives on clean code: ' + falsePos.join(', ') : ''}`)
@ -119,7 +138,7 @@ const matrix = {}
for (const b of BUNDLES.filter((x) => !ONLY || ONLY.includes(x))) {
const dir = resolve(ROOT, 'plugin-designs', b)
if (!existsSync(dir)) continue
matrix[b] = { engine: 'ok', token: 'ok', locale: 'ok' }
matrix[b] = { engine: 'ok', token: 'ok', locale: 'ok', list: 'ok' }
for (const f of files(dir)) {
const src = stripComments(readFileSync(f, 'utf8'))
for (const r of RULES) for (const idx of run(r, src)) {
@ -140,8 +159,8 @@ for (const b of BUNDLES.filter((x) => !ONLY || ONLY.includes(x))) {
}
if (REPORT) {
console.log('bundle'.padEnd(24) + 'engine'.padEnd(10) + 'token'.padEnd(10) + 'locale')
for (const [b, m] of Object.entries(matrix)) console.log(b.padEnd(24) + m.engine.padEnd(10) + m.token.padEnd(10) + m.locale)
console.log('bundle'.padEnd(24) + 'engine'.padEnd(10) + 'token'.padEnd(10) + 'locale'.padEnd(10) + 'list')
for (const [b, m] of Object.entries(matrix)) console.log(b.padEnd(24) + m.engine.padEnd(10) + m.token.padEnd(10) + m.locale.padEnd(10) + m.list)
}
for (const f of findings) console.log(`${f.warn ? 'WARN ' : 'BYPASS '} ${f.b.padEnd(22)} ${f.rule.padEnd(11)} ${f.where} ${f.text}`)
const hard = findings.filter((f) => !f.warn)
@ -150,4 +169,4 @@ if (hard.length) {
console.log(`UNIVERSAL-PROPS: FAIL — ${hard.length} bypass(es) in ${new Set(hard.map((f) => f.b)).size} bundle(s). Read the prop through the library (documentation/reference-universal-props.md), or ALLOW it here with the reason it is by design.`)
process.exit(1)
}
console.log(`UNIVERSAL-PROPS: PASS — ${Object.keys(matrix).length} bundles read engine, token and locale through the library (self-test: ${RULES.length} rules each caught a planted bypass).`)
console.log(`UNIVERSAL-PROPS: PASS — ${Object.keys(matrix).length} bundles read engine, token, locale and list through the library (self-test: ${RULES.length} rules each caught a planted bypass).`)

View file

@ -24,6 +24,7 @@
import sourcesConfig from './sources.json'
import { getApiBaseUrl } from './apiBaseUrl.js'
import { applyPeopleGroupList } from '../lists/peopleGroupList.js'
export class DataSourceManager {
constructor(options = {}) {
@ -39,6 +40,10 @@ export class DataSourceManager {
// Unset → the source's own `lang` (sources.json defaults it to 'en').
this.locale = options.locale || null;
// The map's profile-config (object or ref), for its people-group LIST (library/lists/). Unset
// still honours ?list= and window.MAP_LIST; the default list keeps every row, unfetched.
this.profileConfig = options.profileConfig || null;
// CACHE: Store normalized data per source to avoid re-parsing on tab switch
this.cache = new Map();
this.cacheEnabled = true;
@ -226,7 +231,7 @@ export class DataSourceManager {
try {
const response = await fetch(sourceConfig.url || sourceConfig.endpoint);
const data = await response.json();
this.rawData = Array.isArray(data) ? data : (data.posts || data.data || data.peopleGroups || data.results || []);
this.rawData = await applyPeopleGroupList(Array.isArray(data) ? data : (data.posts || data.data || data.peopleGroups || data.results || []), this.profileConfig);
this.normalizeData(sourceConfig);
return this.normalizedData;
} catch (error) {
@ -272,7 +277,7 @@ export class DataSourceManager {
);
}
const data = await response.json();
this.rawData = Array.isArray(data) ? data : (data.posts || data.data || data.results || []);
this.rawData = await applyPeopleGroupList(Array.isArray(data) ? data : (data.posts || data.data || data.results || []), this.profileConfig);
this.normalizeData(sourceConfig);
return this.normalizedData;
} catch (error) {

45
library/lists/lists.json Normal file
View file

@ -0,0 +1,45 @@
{
"_about": "The people-group LISTS a map can be asked to show (profile-config `list`, ?list=, window.MAP_LIST; see peopleGroupList.js). Each list is a FILTER, never a hand-kept roster: `source: doxa` is DOXA's live list as the API serves it; `source: imb` is a filter over the IMB / peoplegroups.org dataset in data/people-groups/people_groups.csv, using its own columns (LostnessPriority, Population, EngagementStatus). library/scripts/people-groups/build-lists.mjs turns each imb filter into data/people-groups/lists/<id>.json. TIER A (today): a map shows the DOXA groups that belong to the list, because the DOXA API is where every map's rows and prayer fields come from. TIER B (later, per map): the list's groups that DOXA does not carry. Definitions and counts: _qa-cycle/03-deployment-cycle/SPEC-PEOPLE-GROUP-LIST-PROP.md.",
"default": "doxa",
"lists": [
{
"id": "doxa",
"aliases": ["2000", "project-2000"],
"label": "Project 2000: DOXA's people groups",
"source": "doxa",
"filter": null
},
{
"id": "unreached",
"aliases": ["7000", "project-7000"],
"label": "Project 7000: unreached people groups",
"definition": "IMB Lostness Priority 0-2 (Frontier, Pioneer or Expanding Unreached: under 2% evangelical)",
"source": "imb",
"filter": { "lostness": [0, 1, 2] }
},
{
"id": "frontier",
"aliases": ["4700", "project-4700"],
"label": "Project 4700: frontier people groups",
"definition": "IMB Lostness Priority 0 (Frontier Unreached: 0.1% evangelical or less)",
"source": "imb",
"filter": { "lostness": [0] }
},
{
"id": "frontier-500k",
"aliases": ["700", "project-700"],
"label": "Project 700: frontier people groups of 500,000 or more",
"definition": "IMB Lostness Priority 0 and Population of at least 500,000",
"source": "imb",
"filter": { "lostness": [0], "minPopulation": 500000 }
},
{
"id": "unengaged",
"aliases": [],
"label": "Unengaged unreached people groups",
"definition": "IMB Lostness Priority 0-2 and Engagement Status Unengaged",
"source": "imb",
"filter": { "lostness": [0, 1, 2], "engagement": ["Unengaged"] }
}
]
}

View file

@ -0,0 +1,97 @@
/**
* peopleGroupList.js — the people-group LIST, a universal prop like engine, locale and token.
*
* Every map shows people groups, and until now always DOXA's (the live API's 2,107). The same maps
* must work for any project's list: Project 7000 (unreached), 4700 (frontier), 700 (frontier of
* 500,000 or more), 2000 (DOXA). A list is chosen the way the engine is, first answer wins:
* 1. profile-config `list` — this embed, decided by whoever mounted the element
* 2. ?list= in the page URL — any generated leaf can be switched from the URL
* 3. window.MAP_LIST — a host global, for a host that sets it programmatically
* 4. 'doxa' — the default: exactly today's behaviour, nothing fetched, nothing filtered
* An id or an alias ('700', 'project-700') is accepted; anything unknown falls back to the default
* and says so once in the console.
*
* WHAT A MAP DOES WITH IT (Tier A). Rows keep coming from the DOXA API, because that is where the
* prayer fields live. applyPeopleGroupList() keeps the rows whose slug belongs to the list, from a
* membership file the build writes (data/people-groups/lists/<id>.json, served under the asset
* pool). So under Project 700 a map shows the DOXA groups that are Project 700 groups. The list's
* groups that DOXA does not carry are Tier B, per map (SPEC-PEOPLE-GROUP-LIST-PROP.md).
*
* FAILS CLOSED. If a non-default list's membership cannot be loaded, the map gets NO rows and the
* console names the list and the URL. A map that silently showed all 2,107 while an embed asked for
* Project 700 would be showing the wrong list with no sign of it.
*
* Pure except for the fetch; safe outside a Vue setup().
*/
import registry from './lists.json'
import { resolveAssetsBase } from '../utils/embedBase.js'
export const LIST_DEFAULT = registry.default
const byKey = new Map()
for (const l of registry.lists) {
byKey.set(l.id, l)
for (const a of l.aliases || []) byKey.set(String(a).toLowerCase(), l)
}
/** Every list the registry defines: { id, label, definition?, source, filter }. */
export function peopleGroupLists() { return registry.lists }
/** The registry entry for an id or alias, or null. */
export function listInfo(idOrAlias) {
return byKey.get(String(idOrAlias ?? '').toLowerCase().trim()) || null
}
/** Unwrap a ref/computed to its config object; parse a JSON string; pass plain objects through. */
function unwrapConfig(profileConfig) {
let cfg = profileConfig
if (cfg && typeof cfg === 'object' && 'value' in cfg && !('list' in cfg) && !('profile' in cfg)) cfg = cfg.value
if (typeof cfg === 'string') { try { cfg = JSON.parse(cfg) } catch { cfg = null } }
return cfg && typeof cfg === 'object' ? cfg : null
}
const warned = new Set()
/** The list this map should show, as a registry id. Always a known id. */
export function resolvePeopleGroupList(profileConfig) {
const cfg = unwrapConfig(profileConfig)
const fromConfig = cfg?.list
const fromUrl = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('list') : null
const fromWindow = typeof window !== 'undefined' ? window.MAP_LIST : undefined
const raw = fromConfig || fromUrl || fromWindow || LIST_DEFAULT
const hit = listInfo(raw)
if (hit) return hit.id
if (!warned.has(raw)) {
warned.add(raw)
console.warn(`[list] unknown people-group list "${raw}", showing "${LIST_DEFAULT}". Known: ${registry.lists.map((l) => l.id).join(', ')}`)
}
return LIST_DEFAULT
}
const membership = new Map()
/** The set of DOXA slugs in a list (fetched once per URL). Rejects if it cannot be loaded. */
export function loadListMembership(id, profileConfig) {
const url = `${resolveAssetsBase(unwrapConfig(profileConfig))}/people-groups/lists/${id}.json`
if (!membership.has(url)) {
membership.set(url, fetch(url)
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
.then((d) => new Set(Array.isArray(d?.slugs) ? d.slugs : []))
.catch((e) => { membership.delete(url); throw new Error(`list "${id}" membership ${url}: ${e.message}`) }))
}
return membership.get(url)
}
/**
* Keep the rows that belong to the resolved list. The default list returns `rows` itself, untouched
* and without a fetch. `key` reads a row's slug (default: row.slug).
*/
export async function applyPeopleGroupList(rows, profileConfig, { key = (r) => r?.slug } = {}) {
const id = resolvePeopleGroupList(profileConfig)
if (id === LIST_DEFAULT || !Array.isArray(rows)) return rows
try {
const slugs = await loadListMembership(id, profileConfig)
return rows.filter((r) => slugs.has(key(r)))
} catch (e) {
console.error(`[list] ${e.message}. Showing no people groups rather than the wrong list.`)
return []
}
}

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* build-lists.mjs — the membership of every people-group LIST, computed from data, never typed.
*
* node library/scripts/people-groups/build-lists.mjs
*
* READS library/lists/lists.json the lists, each a FILTER over IMB columns
* data/people-groups/people_groups.csv IMB / peoplegroups.org, one row per group × country
* data/people-groups/doxa-groups-snapshot.json DOXA's list (written by build-vision-funnel.mjs)
* WRITES data/people-groups/lists/<id>.json per non-default list:
* { id, label, definition, filter, counts: { imb, doxa }, slugs: [the DOXA slugs in the list] }
*
* A DOXA group belongs to a list when its IMB row (matched by ROP3 + country, the join the Vision
* Card funnel uses) passes the list's filter. `counts.imb` is the whole list as IMB counts it;
* `counts.doxa` is how many of those DOXA carries: what a Tier A map shows (peopleGroupList.js).
*
* FILTER VOCABULARY (all optional, ANDed):
* lostness: [0..6] IMB LostnessPriority (0 Frontier, 1 Pioneer, 2 Expanding Unreached, …)
* minPopulation: n IMB Population >= n
* engagement: [..] IMB EngagementStatus ('Engaged' | 'Unengaged')
* An unknown key FAILS the run: a filter nobody applies would silently widen the list.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const REGISTRY = JSON.parse(readFileSync(join(ROOT, 'library/lists/lists.json'), 'utf8'))
const CSV = join(ROOT, 'data/people-groups/people_groups.csv')
const SNAP = join(ROOT, 'data/people-groups/doxa-groups-snapshot.json')
const OUT = join(ROOT, 'data/people-groups/lists')
// Minimal RFC-4180 CSV parser (the same one build-vision-funnel.mjs uses).
function parseCsv(text) {
const rows = []; let row = []; let f = ''; let q = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (q) {
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++ } else q = false } else f += c
} else if (c === '"') q = true
else if (c === ',') { row.push(f); f = '' }
else if (c === '\n') { row.push(f); rows.push(row); row = []; f = '' }
else if (c !== '\r') f += c
}
if (f || row.length) { row.push(f); rows.push(row) }
const head = rows.shift().map((h) => h.replace(/^/, ''))
return rows.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i]])))
}
const KNOWN = new Set(['lostness', 'minPopulation', 'engagement'])
function predicate(filter, id) {
for (const k of Object.keys(filter || {})) if (!KNOWN.has(k)) { console.error(`list "${id}": unknown filter key "${k}"`); process.exit(1) }
return (r) =>
(!filter.lostness || filter.lostness.includes(Number(r.LostnessPriority))) &&
(!filter.minPopulation || (Number(r.Population) || 0) >= filter.minPopulation) &&
(!filter.engagement || filter.engagement.includes(r.EngagementStatus))
}
if (!existsSync(SNAP)) { console.error(`no DOXA snapshot at ${SNAP}: run build-vision-funnel.mjs first`); process.exit(1) }
const imb = parseCsv(readFileSync(CSV, 'utf8'))
const snapshot = JSON.parse(readFileSync(SNAP, 'utf8'))
const byKey = new Map(imb.map((r) => [r.ROP3Code + '|' + r.ISOAlpha3, r]))
mkdirSync(OUT, { recursive: true })
for (const l of REGISTRY.lists) {
if (l.source === 'doxa' && !l.filter) continue // the default list: every DOXA row, no file
if (l.source !== 'imb') { console.error(`list "${l.id}": unknown source "${l.source}"`); process.exit(1) }
const keep = predicate(l.filter, l.id)
const slugs = snapshot.groups.filter((g) => { const r = byKey.get(g.rop3 + '|' + g.country); return r && keep(r) }).map((g) => g.slug).sort()
const out = {
id: l.id, label: l.label, definition: l.definition || '', filter: l.filter,
sources: { imb: 'data/people-groups/people_groups.csv', doxa: `${snapshot.source} (fetched ${snapshot.fetched})` },
counts: { imb: imb.filter(keep).length, doxa: slugs.length },
slugs,
}
writeFileSync(join(OUT, `${l.id}.json`), JSON.stringify(out) + '\n')
console.log(l.id.padEnd(14), String(out.counts.imb).padStart(6), 'in the list ·', String(out.counts.doxa).padStart(5), 'of them DOXA groups')
}

View file

@ -102,6 +102,7 @@
"check:library-index": "node internal/check-library-index.mjs",
"check:link-rot": "node internal/check-link-rot-sample.mjs",
"check:linked-vocabulary": "node plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs",
"check:list-prop-unit": "node internal/check-list-prop-unit.mjs",
"check:locale-reachable": "node internal/check-locale-reachable.mjs",
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs",
"check:map-data-painted": "node internal/check-map-data-painted.mjs",
@ -192,9 +193,10 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"library-index": "node internal/generate-library-index.mjs",
"lists": "node library/scripts/people-groups/build-lists.mjs",
"packet-fixture": "node internal/generate-packet-fixture.mjs",
"packet:ci": "node internal/build-packet-fixtures.mjs",
"packets": "cd library/scripts/posters && VITE_API_BASE_URL=https://pray.doxa.life node packet.mjs",
@ -296,6 +298,7 @@
"check:library-index": "the DERIVED library menu must not DRIFT from library/. REGENERATES the index into a throwaway tree (the generator, unmodified, over a symlinked library/) and diffs the BYTES — it used to compare mtimes, so `touch` or a rebase turned it red on a byte-identical doc. Fix: bun run library-index",
"check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
"check:linked-vocabulary": "the two halves of research-map-plus-grid must mean the SAME THING by a religion family. The map filters by the legend's FIRST-LETTER key ('B'), the grid by a DERIVED family name ('Buddhism'), and both read ONE shared table (library/colors/religion.js) in opposite directions - so a wrong link stays invisible: both halves look filtered and show different peoples. Compares the total reached over the composite's wire against the total the grid gives for the same facet on its own, and runs the MUTANT first (the legend key instead of the name), failing as NO-TEETH if the wrong mapping scores the same as the right one.",
"check:list-prop-unit": "the people-group LIST prop, measured in Node with no browser: the ladder (profile-config list > ?list= > window.MAP_LIST > doxa) and aliases, the default list untouched and unfetched, a list keeping exactly its membership, an unloadable list failing closed (no rows, never DOXA's whole list), and every imb list having a membership file that matches its registry filter.",
"check:manifest-covers": "every DISCOVERED (non-gated) bundle must be in the built manifest",
"check:map-data-painted": "Every DECOUPLED map leaf must have OUR DATA on it, not just a basemap. verify-map-paint asserts a live GL context, attribution and zero console errors — all TRUE of a flawless basemap carrying none of our zones, polygons or pins, so every data-layer regression lands green. Measured 2026-09-26 by starve-probe (abort /assets/{boundaries,tables,people-groups}/ and the people-groups API, leave the basemap alone): 9 of the 13 leaves still report canvas, GL, attribution and 0 errors, so the hole is the MAJORITY of the suite; the other 4 lose their canvas entirely and the existing gate already fails them, so they are deliberately NOT listed — a leaf list is a claim about where a failure would otherwise go unseen. Each leaf is compared AGAINST ITSELF (healthy vs starved) because healthy leaves span 459..2821 colours and any global floor is vacuous or flaky: check:map-paints' 40-colour floor passes a starved upg-100-list at 306. Two measures, pass on EITHER — palette ratio <=0.55 OR >=3% of pixels changed — because each alone false-reds a real leaf (dashboard-plus-grid's 686x340 pane repaints 26.4% of the canvas at palette ratio 0.788; demo-map transforms the palette at ratio 0.196 while repainting 5.0%). A data-free map moves NEITHER. Uses page.screenshot({clip}), not elementHandle.screenshot(), whose stability wait never settles on an animating dashboard canvas (that cost 3 leaves a 20s timeout). Refuses to judge (exit 75) if either run has no canvas, if the starve aborted 0 requests, if the images cannot be compared, or if the gate itself throws. Run check:map-data-painted:poison to prove it can still fail. Browser gate, @browser-slot-max-hold 2700.",
"check:map-data-painted:poison": "The mutation arm for check:map-data-painted, and MANDATORY because that gate's assertion is a DISJUNCTION (palette moved OR pixels moved) — a weakened assertion that still passes everything reads as coverage while proving nothing. Starves BOTH runs of our data, so neither measure can move, and requires every leaf to go RED. A green poison run means the bars have drifted into vacuity. Browser gate, @browser-slot-max-hold 2700.",
@ -345,6 +348,7 @@
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
"library-index": "regenerate documentation/reference-library-index.md — the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
"lists": "regenerate data/people-groups/lists/<id>.json, the DOXA slugs in each people-group list of library/lists/lists.json, from the IMB CSV and the DOXA snapshot. Re-run after either changes.",
"packet-fixture": "regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.",
"packet:ci": "build EVERY committed fixture, no network, each renamed to its own output so they coexist. Was one fixture (tibet); packet.mjs names its output for the ZONE not the fixture, so sequential builds overwrote each other and check:packets-all saw a single SHAPE while reporting success. The fixtures are chosen at the PER_PAGE=20 page boundary — exactly 20, 21, and 1 group — because laos (95) and tibet (43) are both mid-range remainder cases and the boundary itself had never rendered.",
"packets": "the prayer packets. READS out/posters/<zone>.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG=<loc> for one language, ONLY=<zoneKey> for one zone, NOPHOTOS=1 to skip the photo fetch.",

View file

@ -79,6 +79,10 @@ const hostLang = computed(() => {
})
watchEffect(() => { if (hostLang.value) setRuntimeLocale(hostLang.value) })
// The host's people-group LIST (library/lists/), handed to both children as the only key of their
// profile-config, so nothing else about them changes. Absent = the default list.
const listConfig = computed(() => (config.value.list ? JSON.stringify({ list: config.value.list }) : ''))
const prayBaseUrl = computed(() => config.value.prayBaseUrl || '')
const languageCode = computed(() => config.value.languageCode || '')
@ -113,6 +117,7 @@ function onGroupsFiltered(payload) {
:count="matched"
:pray-base-url="prayBaseUrl"
:language-code="languageCode"
:profile-config="listConfig"
@research-filter="onResearchFilter"
/>
@ -138,6 +143,7 @@ function onGroupsFiltered(payload) {
:show-total="false"
:pray-base-url="prayBaseUrl"
:language-code="languageCode"
:profile-config="listConfig"
@groups-filtered="onGroupsFiltered"
/>
</div>

View file

@ -40,6 +40,7 @@ import { ref, reactive, watch, computed, onMounted, getCurrentInstance } from 'v
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import { tStatic } from '@map/i18n/index.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
// ★ THIS HEADER HAD NO i18n AT ALL, and it is the top of Big Picture. Every label below was an
// English literal passed as a PROP — FilterDropdown itself translates nothing, it renders what the
@ -61,6 +62,8 @@ const props = defineProps({
// injected data host so the header builds its dropdown options from the SAME source the grid uses.
prayBaseUrl: { type: String, default: '' },
languageCode: { type: String, default: '' },
// the composite's profile-config (JSON), for its people-group LIST (library/lists/).
profileConfig: { type: String, default: '' },
})
const emit = defineEmits(['research-filter'])
@ -131,7 +134,8 @@ async function loadOptions() {
try {
const res = await fetch(url)
const data = await res.json()
const list = Array.isArray(data.posts) ? data.posts : []
// Options come from the groups this page's people-group LIST shows (library/lists/).
const list = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], props.profileConfig)
options.wagf_region = extractFrom(list, 'wagf_region')
options.wagf_block = extractFrom(list, 'wagf_block')
options.rop1 = extractFrom(list, 'rop1')

View file

@ -88,7 +88,7 @@ const err = ref(null)
const PG_DOT = '#e11d48'
const uiStore = inject('uiStore', null)
const _lang = (() => { const v = inject('lang', null)?.value; return (v && v !== 'en') ? v : null })()
const dsm = new DataSourceManager({ locale: _lang })
const dsm = new DataSourceManager({ locale: _lang, profileConfig })
const groups = shallowRef([])
const dotCount = ref(0)
const selectedPin = useSelectedPin({ getMap: () => mapRef.value, getActivePinColor: () => PG_DOT })

View file

@ -616,7 +616,7 @@ const { map, isMapReady, initializeMap, destroy } = useMapInstance({
// ─── Data composable ─────────────────────────────────────────────────────────
// Lean instantiation — no lookups, no detectLocale. The shared DSM handles
// {value,label} from pray-tools natively; raw codes fall through unchanged.
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale })
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale, profileConfig })
const mapData = useMapData({
mapId,
dataSourceId: dataSource.value,

View file

@ -104,7 +104,7 @@ let offScroll = null
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
// before it: the inner map cannot honour a key the shell never passed down.
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider']
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)

View file

@ -316,6 +316,11 @@ const sharedToken = useSharedMapToken()
const mapboxToken = computed(() => hostToken.value || sharedToken.value || '')
provide('mapboxToken', mapboxToken)
// The host's people-group LIST (library/lists/), threaded like the engine: into the dashboard's
// provided config below, and to the grid as the only key of its profile-config.
const hostList = computed(() => (typeof hostConfig.value?.list === 'string' ? hostConfig.value.list : ''))
const gridConfig = computed(() => (hostList.value ? JSON.stringify({ list: hostList.value }) : ''))
const SHARE_LEAF = 'dashboard-plus-grid/dashboard-plus-grid/index.html'
const shareBase = resolveEmbedBase(null)
provide('profileConfig', computed(() => ({
@ -327,6 +332,7 @@ provide('profileConfig', computed(() => ({
...(hostToken.value ? { tk: hostToken.value } : {}),
...(hostProvider.value ? { provider: hostProvider.value } : {}),
...(hostLang.value ? { lang: hostLang.value } : {}),
...(hostList.value ? { list: hostList.value } : {}),
})))
const appI18n = inject('appI18n', null)
@ -397,7 +403,8 @@ const sections = [
:gospel-access="filter.gospelAccess"
:religion-family="filter.religionFamily"
:language-family="filter.languageFamily"
:show-search="false" :show-filters="false" :show-total="false" />
:show-search="false" :show-filters="false" :show-total="false"
:profile-config="gridConfig" />
</template>
</StackLayout>
</template>

View file

@ -701,7 +701,7 @@ const { map, isMapReady, initializeMap, destroy } = useMapInstance({
// ─── Data composable ─────────────────────────────────────────────────────────
// Lean instantiation — no lookups, no detectLocale. The shared DSM handles
// {value,label} from pray-tools natively; raw codes fall through unchanged.
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale })
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale, profileConfig })
const mapData = useMapData({
mapId,
dataSourceId: dataSource.value,

View file

@ -481,7 +481,7 @@ mapTheme.setLook('dark')
// search bar searches comes back in the selected language — the same `lang` param
// the people-group detail fetch uses (PeopleGroupDetail.vue). null → API default.
const _apiLocale = (() => { const v = inject('lang', null)?.value; return (v && v !== 'en') ? v : null })()
const dsm = new DataSourceManager({ locale: _apiLocale })
const dsm = new DataSourceManager({ locale: _apiLocale, profileConfig })
// ─── Map instance ─────────────────────────────────────────────────────────────
const { map, isMapReady, initializeMap, destroy } = useMapInstance({

View file

@ -72,6 +72,7 @@
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
@ -938,7 +939,8 @@ async function load() {
/* Same seven-slot shape the rest of this file already speaks, so narrowing the roster
changes WHICH pins exist and nothing about how they are drawn, clicked or filtered. */
const roster = (doxaData.rows || [])
// …and within that roster, only the groups of this page's people-group LIST (library/lists/).
const roster = await applyPeopleGroupList(doxaData.rows || [], profileConfig)
doxaTotal.value = roster.length
/**
* ═══ THE TIER COMES FROM THE INDEX, NOT FROM THE ROSTER'S COPY OF IT ════════════════════

View file

@ -40,6 +40,8 @@
*/
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
@ -131,7 +133,7 @@ async function fetchListRecords(list) {
+ 'image_url,primary_language'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
const records = normalizeRecords(extractRecords(await resp.json()))
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
// ★ pg_slugs BYPASSES the shared evaluator — MEASURED live (2026-09-23): 12 of the 27
// lists (the entire India + China buckets) carry `{ type:'pg_slugs', values:[...] }`,
// and selector-evaluator.js documents that it "intentionally THROWS" on that type

View file

@ -22,6 +22,8 @@
* via a single setStyle(style,{diff:false}).
*/
import { inject, provide, ref, shallowRef, markRaw, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
// The shared mobile bottom-sheet. Fed this profile's own tree through the `nodes`
@ -1243,7 +1245,7 @@ async function packetRecords(zone) {
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
const records = normalizeRecords(extractRecords(await resp.json()));
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
let slugs;
if (zone.selector && zone.selector.type === 'pg_slugs') {
slugs = new Set(zone.selector.values || []);
@ -1365,7 +1367,7 @@ async function selectionAsList() {
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
const records = normalizeRecords(extractRecords(await resp.json()));
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
const { evaluate } = await import('./data/selector-evaluator.js');
const slugs = new Set();
for (const l of lists) {
@ -1652,7 +1654,7 @@ async function enrichLegendFromRecords() {
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,latitude,longitude'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`list ${resp.status}`)
const records = normalizeRecords(extractRecords(await resp.json()))
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
countryNameMap = buildCountryNameMap(records)
legendNodes.value = buildLegendNodes(clusters, { deafCount: DEAF_COUNT, records })
} catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ }
@ -1903,7 +1905,7 @@ onMounted(async () => {
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,latitude,longitude'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`list ${resp.status}`)
const records = normalizeRecords(extractRecords(await resp.json()))
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
// 09b shift-select §1(b): build iso3→display-name from the LIVE records so an
// ADM0 country click resolves to the exact label the legend shows (the ADM0
// geojson features carry only iso3 — no name). Falls back to ISO3_NAME inside

View file

@ -140,7 +140,7 @@ const paneEls = {}
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
// before it: the inner map cannot honour a key the shell never passed down.
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider']
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)

View file

@ -45,6 +45,7 @@ import Fuse from '@map/components/search/fuzzy.js'
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { tStatic } from '@map/i18n/index.js'
import { countrySlug } from './src/countries.js'
// ── DERIVED family axes (BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ──
@ -774,8 +775,12 @@ async function getUUPGs() {
// success. Both looked identical to "your filter matched nothing".
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
total.value = data.total
uupgs.value = Array.isArray(data.posts) ? data.posts : []
// The people-group LIST this embed shows (library/lists/). The default keeps every row, so the
// API's total stands; any other list is counted from the rows it kept.
const rows = Array.isArray(data.posts) ? data.posts : []
const listed = await applyPeopleGroupList(rows, props.profileConfig)
total.value = listed === rows ? data.total : listed.length
uupgs.value = listed
extractFilterOptions()
filterUUPGs()
} catch (error) {

View file

@ -26,6 +26,7 @@
*/
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
// document.documentElement.dir, which belongs to the host page.
@ -653,7 +654,8 @@ async function loadRegions() {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], injectedCfg)
regions.value = summarizeRegions(posts)
points.value = posts.map((p) => ({
name: p.name || '', slug: p.slug || '',

View file

@ -40,6 +40,7 @@
*/
import { inject, ref, computed, onMounted, watch, getCurrentInstance } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
import { splitLastWord } from './src/useLastWordHighlight.js'
import { useRegionsI18n } from './src/useRegionsI18n.js'
@ -242,7 +243,8 @@ async function loadRegions() {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], injectedCfg)
regions.value = summarizeRegions(posts)
} catch (error) {
console.error('[regions-list] fetch error:', error)

View file

@ -96,7 +96,7 @@ let offScroll = null
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
// before it: the inner map cannot honour a key the shell never passed down.
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa', 'pinHint', 'urlSync', 'provider']
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa', 'pinHint', 'urlSync', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)

View file

@ -26,8 +26,9 @@
* the /api/people-groups/list response we consume carries has_photo + image_url, so the img is
* wired. Where a group has no photo the card reflows without one, as theirs does.
*/
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
// document.documentElement.dir, which belongs to the host page.
@ -132,6 +133,9 @@ const active = ref({ wagf_region: null, wagf_block: null, rop1: null, religion:
const label = (f) => (f && typeof f === 'object' ? f.label : f) || ''
const value = (f) => (f && typeof f === 'object' ? f.value : f) || ''
// The page's profile-config, for its people-group LIST; this list sits under the same ProfileLoader.
const listCfg = inject('profileConfig', null)
async function load() {
loading.value = true
failed.value = false
@ -139,7 +143,8 @@ async function load() {
const r = await fetch(`${props.prayBaseUrl}/api/people-groups/list?fields=${FIELDS}&lang=${props.languageCode}`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const d = await r.json()
const all = Array.isArray(d.posts) ? d.posts : []
// Only the groups of this page's people-group LIST (library/lists/).
const all = await applyPeopleGroupList(Array.isArray(d.posts) ? d.posts : [], listCfg)
rows.value = props.lockCountryCode
? all.filter((p) => value(p.country_code) === props.lockCountryCode)
: all

View file

@ -22,7 +22,9 @@
* (24 fields) rather than producing nothing — degraded, never broken.
*/
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { inject } from 'vue'
import sourcesConfig from '@map/api/sources.json'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
/**
* The full field set for the export, in a stable, human-readable order: identity first, then
@ -71,6 +73,9 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// slug -> wide API record, filled by the lazy all-fields fetch below.
let wideStore = null
let widePromise = null
// The map's people-group LIST (library/lists/). Every caller runs in setup, where inject works.
let profileConfig = dataOpts.profileConfig || null
if (!profileConfig) { try { profileConfig = inject('profileConfig', null) } catch (_) { /* outside setup */ } }
/**
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
@ -89,7 +94,8 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
const json = await res.json()
const raw = Array.isArray(json) ? json : (json.posts || json.data || json.results || [])
// Only the groups of this page's people-group LIST (library/lists/) are exported.
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
const map = new Map()
for (const r of raw) { const k = r && (r.slug || r.id); if (k) map.set(k, r) }
wideStore = map

View file

@ -11,9 +11,10 @@
*
* Shadow-DOM safe: pure data, no DOM/style. Reuses read-only @map config without editing it.
*/
import { ref, shallowRef } from 'vue'
import { ref, shallowRef, inject } from 'vue'
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import sourcesConfig from '@map/api/sources.json'
import { applyPeopleGroupList, resolvePeopleGroupList, LIST_DEFAULT } from '@map/lists/peopleGroupList.js'
export function useDashboardData(dataOpts = {}) {
const loading = ref(false)
@ -29,6 +30,9 @@ export function useDashboardData(dataOpts = {}) {
// SAME source's fieldMappings for the geocoder dataStore (rows + normalization stay
// in lockstep — no desync if a dashboard ever points at a non-active source).
const sourceId = dataOpts.source || sourcesConfig.activeSource
// The map's people-group LIST (library/lists/): read here, in setup, where inject works.
let profileConfig = null
try { profileConfig = inject('profileConfig', null) } catch (_) { /* called outside setup */ }
async function load() {
loading.value = true
@ -45,7 +49,7 @@ export function useDashboardData(dataOpts = {}) {
const res = await fetch(listUrl, { cache: 'no-store' })
if (!res.ok) throw new Error(`list HTTP ${res.status}`)
const json = await res.json()
const raw = Array.isArray(json) ? json : (json.posts || json.data || json.results || [])
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
const map = new Map()
for (const r of raw) {
const slug = r && (r.slug || r.id)
@ -57,7 +61,9 @@ export function useDashboardData(dataOpts = {}) {
// ── 2) /statistics seeds KPI baselines ──
const statEndpoint = src.endpoints?.statistics || '/api/people-groups/statistics'
try {
// /statistics describes DOXA's whole list, so under any other list its totals would be about
// the wrong set of groups. They are optional baselines; leave them out there.
if (resolvePeopleGroupList(profileConfig) === LIST_DEFAULT) try {
const sres = await fetch(`${base}${statEndpoint}?_=${Date.now()}`, { cache: 'no-store' })
if (sres.ok && (sres.headers.get('content-type') || '').includes('json')) {
stats.value = await sres.json()

View file

@ -24,7 +24,7 @@
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
*/
import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
import { provide, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from './useSelectionBus.js'
import { useDashboardData } from './useDashboardData.js'
import { useCsvExport } from './useCsvExport.js'
@ -330,7 +330,11 @@ export function useDashboardShell(config) {
// :data-source-id. Never desyncs the row source from the search source.
const searchSourceId = data.sourceId || sourcesConfig.activeSource
const searchSourceCfg = sourcesConfig.sources?.[searchSourceId] || {}
const dsm = new DataSourceManager()
// It only normalizes rows already narrowed to the page's list (useDashboardData), but it carries the
// config like every other manager, so nothing it ever loads can skip the list.
let _pc = null
try { _pc = inject('profileConfig', null) } catch (_) { /* outside setup */ }
const dsm = new DataSourceManager({ profileConfig: _pc })
const geoFeatures = computed(() => {
const rows = data.rows.value || []
if (!rows.length) return []

View file

@ -127,6 +127,10 @@ const hostLang = computed(() => {
})
watchEffect(() => { if (hostLang.value) setRuntimeLocale(hostLang.value) })
// The host's people-group LIST (library/lists/), threaded down like the provider: into the map's
// profile-config and as the only key of the grid's.
const hostList = computed(() => (typeof hostConfig.value?.list === 'string' ? hostConfig.value.list : ''))
// ★ RTL IS A DIRECTION, NOT A TRANSLATION, and nothing was setting it here.
// createAppI18n reflects an RTL locale onto document.documentElement — right for a leaf page it
// owns, useless for an element dropped into somebody else's page, which is the case this composite
@ -419,6 +423,7 @@ const sections = computed(() => [
...(hostToken.value ? { tk: hostToken.value } : {}),
...(hostProvider.value ? { provider: hostProvider.value } : {}),
...(hostLang.value ? { lang: hostLang.value } : {}),
...(hostList.value ? { list: hostList.value } : {}),
// The composite's filter state, in the map's inbound shape. Absent keys are ignored there.
externalFilters: { region: linked.region.value, religion: linked.religion, language: linked.language, fitSlugs: gridSlugs.value },
assetsBase: '../../assets',
@ -442,6 +447,7 @@ const sections = computed(() => [
src: bundleSrc('people-groups-grid', 'people-groups-grid'),
attrs: {
'show-search': 'true', 'show-filters': 'true', 'show-total': 'true',
...(hostList.value ? { 'profile-config': JSON.stringify({ list: hostList.value }) } : {}),
// The grid already accepts these as INPUTS (its header documents countryCode for exactly this
// reason); driving them from the composite is the same channel, not a new one.
'wagf-region': linked.region.value,

View file

@ -23,6 +23,7 @@
* token-less via the gated host hook). Lives inside the doxa-upg-neighbors merged profile
* as the "Flowers" view; reuses the granular per-PG coords from UPGN-MERGE.
*/
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { labelFont } from '@map/maps/label-font.js'
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
@ -268,7 +269,8 @@ async function fetchDoxa() {
const r = await fetch(base.replace(/\/?$/, '') + '/api/people-groups/list?fields=' + fields + '&lang=en', { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
const posts = j.posts || j.data || j.records || j.results || (Array.isArray(j) ? j : [])
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(j.posts || j.data || j.records || j.results || (Array.isArray(j) ? j : []), cfg.value)
// shape for the taxonomy composable: it reads item.id + item.primary_language/latitude/longitude/population
return posts.map(p => ({ ...p, id: p.slug }))
}

View file

@ -30,6 +30,7 @@
* Full field-by-field contract: ./STORE-API.md
*/
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { ref, computed, inject } from 'vue'
// ─────────────────────────────────────────────────────────────────────────────
@ -546,7 +547,8 @@ async function fetchReds(cfg) {
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
return j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : [])
// Only the groups of this page's people-group LIST (library/lists/).
return applyPeopleGroupList(j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : []), cfg)
}
/** Top-N category bars [{ key, label, value }] sorted desc, by an accessor. */

View file

@ -34,6 +34,7 @@
* "dataUrl":"<reached-neighbors.json>", "instanceId":"..." }
*/
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { labelFont } from '@map/maps/label-font.js'
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
@ -402,7 +403,8 @@ async function fetchDoxa() {
const r = await fetch(url, { cache: 'no-store' })
if (!r.ok) throw new Error('doxa list ' + r.status)
const j = await r.json()
return j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : [])
// Only the groups of this page's people-group LIST (library/lists/).
return applyPeopleGroupList(j.posts || j.data || j.records || j.results || j.items || (Array.isArray(j) ? j : []), cfg.value)
}
onMounted(async () => {

View file

@ -38,6 +38,8 @@ import { wallD3 } from '../wallD3.js'
// Wall of People Groups - D3 Zoomable Whiteboard
export default {
name: 'PeopleGroupsWall',
// The page's profile-config, for its people-group LIST (library/lists/); handed to the store.
inject: { profileConfig: { default: null } },
data() {
return {
svg: null,
@ -79,6 +81,7 @@ export default {
async fetchData() {
console.log('📊 Wall component fetching data...');
if (this.$store) {
this.$store.profileConfig = this.profileConfig;
await this.$store.fetchPeopleGroups();
console.log('🎨 Data fetched, rendering cards...');
this.renderCards();

View file

@ -3,12 +3,15 @@
// (it was the Pinia CDN global), localStorage keys carry a 'wall-pg:' prefix so an embedded wall
// cannot collide with the host page's own keys, and the source list is the one live API.
import { defineStore } from 'pinia'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
const LS = (k) => 'wall-pg:' + k
export const usePeopleGroupsStore = (pinia) => {
return defineStore('peopleGroups', {
state: () => ({
// The page's profile-config, set by the wall before it fetches: its people-group LIST.
profileConfig: null,
peopleGroups: [],
loading: false,
error: null,
@ -86,7 +89,8 @@ export const usePeopleGroupsStore = (pinia) => {
}
const data = await response.json();
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []), this.profileConfig);
if (posts.length === 0) {
console.error('Unexpected API response format:', data);
@ -186,7 +190,8 @@ export const usePeopleGroupsStore = (pinia) => {
const placeholderUrl = 'https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg';
// The API returns { "posts": [...] }
const posts = data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []);
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(data.posts && Array.isArray(data.posts) ? data.posts : (Array.isArray(data) ? data : []), this.profileConfig);
if (posts.length === 0) {
console.error('Unexpected API response format:', data);