diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 848daae05..f0b8a474e 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -2168,10 +2168,39 @@ function emitFilterState() { // Report in the vocabulary a HOST speaks: the family NAME, not the legend's letter key. Same // shared RELIGION_FAMILIES table the inbound direction reads, used the other way round. const religionName = RELIGION_FAMILIES[uiStore?.religionFilter] || '' + // ★ LANGUAGE TRAVELS AS A CODE AS WELL AS A NAME, because the NAME moves with the locale. + // MEASURED against the live API: primary_language's LABEL is fully localized (864 names in en, + // 864 in ar, ZERO shared) while its VALUE is the stable ISO 639-3 code (864 shared, identical). + // Region already links on a slug and religion on a family letter; language was the one facet + // still linking on a display name, so the moment the grid's data followed the URL's language the + // link would have matched nothing in Arabic — silently, because an empty intersection looks like + // "no results" rather than a broken wire. + // + // This tree has paid for exactly this once before: deriveLanguageFamily in useMapData carries a + // comment about the 2026-06-22 locale colour bug, where deriving a family from the localized + // label collapsed every pin onto one bucket. Same trap, one facet over. + // + // The NAME still goes out because this map's own legend selects by name; the CODE goes out for + // anyone matching rows. Both halves get the form they can actually use. + const langName = mapStore?.selectedLanguage || '' + // Read the records here rather than assuming a `rows` in scope — check:no-free-identifiers caught + // exactly that on its first outing against this very edit, which is the class of bug it is for. + const langRows = mapData?.normalizedPeopleGroups?.value || [] + const langCode = langName + ? String( + (langRows.find((r) => { + const l = r && (r.language ?? r.primary_language) + const lab = l && typeof l === 'object' ? l.label : l + return String(lab || '').trim() === String(langName).trim() + }) || {})._raw?.primary_language?.value || '' + ).trim() + : '' + const detail = { region: mapStore?.selectedRegion || '', religion: religionName, - language: mapStore?.selectedLanguage || '', + language: langName, + languageCode: langCode, } host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true })) } diff --git a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue index e465b070c..c8ab9b21c 100644 --- a/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue +++ b/plugin-designs/research-map-plus-grid/research-map-plus-grid.vue @@ -230,7 +230,13 @@ const bundleSrc = (bundle, file) => // composite that let the grid hold all three would put the two halves into a disagreement the map // cannot resolve and the reader cannot see. So the composite mirrors the map's own exclusivity: a // new facet replaces the previous one, exactly as clicking a second legend row does. -const linked = reactive({ region: { value: '', label: '' }, religion: '', language: '' }) +// ★ LANGUAGE CARRIES TWO FORMS NOW, FOR THE SAME REASON REGION ALWAYS DID. +// MEASURED against the live API: primary_language's LABEL is fully localized (zero names shared +// between en and ar) while its VALUE is the stable ISO 639-3 code (all 864 identical). The map's +// legend selects by NAME; the grid matches rows by VALUE. Carrying one form would have meant the +// link matching nothing the moment the grid's data followed the URL's language — and an empty +// intersection reads as "no results", not as a broken wire. +const linked = reactive({ region: { value: '', label: '' }, religion: '', language: { value: '', label: '' } }) // ★ WHAT THE LIST IS SHOWING, so the map can go there (BUILD-GRID-SELECTION-FLIES-THE-MAP). // The link ran one way: the map narrowed the grid, and the grid moved nothing. The grid already @@ -248,7 +254,7 @@ const gridSlugs = ref([]) function clearExcept(keep) { if (keep !== 'region' && (linked.region.value || linked.region.label)) { linked.region.value = ''; linked.region.label = '' } if (keep !== 'religion' && linked.religion) linked.religion = '' - if (keep !== 'language' && linked.language) linked.language = '' + if (keep !== 'language' && (linked.language.value || linked.language.label)) { linked.language.value = ''; linked.language.label = '' } } const slugify = (label) => String(label || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') @@ -279,8 +285,14 @@ function adopt(next) { if (next.religion !== linked.religion) { linked.religion = next.religion; clearExcept('religion') } return } - if (next.language) { - if (next.language !== linked.language) { linked.language = next.language; clearExcept('language') } + if (next.language && (next.language.value || next.language.label)) { + const v = next.language.value || '' + const l = next.language.label || '' + if (v !== linked.language.value || l !== linked.language.label) { + linked.language.value = v + linked.language.label = l + clearExcept('language') + } return } // Nothing set anywhere in this report: the reader cleared whatever was active. @@ -306,7 +318,8 @@ function onGridFiltered(ev) { adopt({ region: { value: (entry && (entry.value ?? entry)) || '', label: (entry && entry.label) || '' }, religion: nameOf(active.__religion_family), - language: nameOf(active.primary_language), + // The grid reports {value,label} per facet, which is exactly the two forms the halves need. + language: { value: (active.primary_language && active.primary_language.value) || '', label: (active.primary_language && active.primary_language.label) || '' }, }) } function onMapFiltered(ev) { @@ -318,7 +331,8 @@ function onMapFiltered(ev) { adopt({ region: { value: label ? slugify(label) : '', label }, religion: ev?.detail?.religion || '', - language: ev?.detail?.language || '', + // The map sends both: the name its legend selected by, and the locale-stable ISO code. + language: { value: ev?.detail?.languageCode || '', label: ev?.detail?.language || '' }, }) } @@ -339,14 +353,17 @@ function pushToChildren() { if (map) { let cfg = {} try { cfg = JSON.parse(map.getAttribute('profile-config') || '{}') } catch (_) { cfg = {} } - cfg.externalFilters = { region: linked.region.value, religion: linked.religion, language: linked.language, fitSlugs: gridSlugs.value } + cfg.externalFilters = { region: linked.region.value, religion: linked.religion, language: linked.language.label, fitSlugs: gridSlugs.value } map.setAttribute('profile-config', JSON.stringify(cfg)) } const grid = rootEl.querySelector('people-groups-grid') if (grid) { grid.setAttribute('wagf-region', linked.region.value) grid.setAttribute('religion-family', linked.religion) - grid.setAttribute('primary-language', linked.language) + // ★ THE CODE, NOT THE NAME. valueMatches compares the normalized value as well as the label, + // so a stable ISO code matches in every locale while a display name only matches in the one it + // was fetched in. + grid.setAttribute('primary-language', linked.language.value || linked.language.label) } } watch(linked, pushToChildren, { deep: true }) @@ -425,7 +442,7 @@ const sections = computed(() => [ ...(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 }, + externalFilters: { region: linked.region.value, religion: linked.religion, language: linked.language.label, fitSlugs: gridSlugs.value }, assetsBase: '../../assets', shareUrl: SHARE_URL }) @@ -452,7 +469,7 @@ const sections = computed(() => [ // reason); driving them from the composite is the same channel, not a new one. 'wagf-region': linked.region.value, 'religion-family': linked.religion, - 'primary-language': linked.language, + 'primary-language': linked.language.value || linked.language.label, } } ]) diff --git a/plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs b/plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs index a39c2ad4d..1bb673bb1 100644 --- a/plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs +++ b/plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs @@ -89,11 +89,17 @@ const lines = [] // engine — a MapLibre-only paint or layout mistake drops a whole layer silently, which would show // up here as a link that delivers nothing while the Mapbox run stays green. const PROVIDERS = ['mapbox', 'maplibre'] +// ★ AND PER LOCALE, because the grid's DATA now follows the URL. The labels this link used to carry +// are fully localized — zero language names shared between en and ar — so a link proven only in +// English proves nothing about the language a reader actually asked for. en is the control; ar is +// the case that would have broken silently. +const LOCALES = (process.env.LINK_LOCALES || 'en,ar').split(',').map((s) => s.trim()).filter(Boolean) const results = {} try { + for (const locale of LOCALES) { for (const engine of PROVIDERS) { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }) - await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 }) + await page.goto(`${LEAF}?provider=${engine}&lang=${locale}`, { waitUntil: 'domcontentloaded', timeout: 60000 }) // Everything below runs in ONE evaluate: the totals have to be read from the same live page, and // a `groups-filtered` listener installed from Node would miss events fired between round trips. @@ -192,15 +198,52 @@ try { // ── THEN THE REAL THING ─────────────────────────────────────────────────────────────────── const linked = await driveLinked(family) - return { unfiltered, family, letter, direct, linked, mutant, mapVocab, gridVocab: gridVocab.slice(0, 12) } + // ★ AND THE LANGUAGE FACET, WHICH IS THE ONE THAT MOVES WITH THE LOCALE. + // MEASURED against the live API: primary_language's LABEL is fully localized (zero names shared + // between en and ar) while its VALUE is the stable ISO 639-3 code (all identical). So the link + // must travel as the CODE. This proves it does, and the poison case proves the code is what is + // doing the work rather than a name that happens to match in the locale under test. + let lang = null + const langVocab = (grid.__vocabulary || window.__peopleGroupsGridVocabulary)?.('primary_language') || [] + // Find a language whose own facet narrows the grid to a strict subset, and learn its CODE from + // the grid's rows rather than from a table. + for (const name of langVocab.slice(0, 40)) { + await clear() + await sleep(1500) + grid.setAttribute('primary-language', name) + await settle() + if (total > 0 && total < unfiltered) { lang = { name, direct: total }; break } + } + if (lang) { + // The map reports both forms on its own wire; drive that wire and read what the grid does. + await clear(); await sleep(1500) + const map = deep('doxa-research-map') || host + map.dispatchEvent(new CustomEvent('map-filtered', { + detail: { region: '', religion: '', language: lang.name, languageCode: '' }, + bubbles: true, composed: true, + })) + await settle() + lang.byName = total + // POISON: a code that cannot exist. If the grid still narrows the same way, the wire is not + // carrying what this gate thinks it is. + await clear(); await sleep(1500) + map.dispatchEvent(new CustomEvent('map-filtered', { + detail: { region: '', religion: '', language: 'zz-not-a-language', languageCode: 'zzz' }, + bubbles: true, composed: true, + })) + await settle() + lang.poison = total + } + + return { unfiltered, family, letter, direct, linked, mutant, mapVocab, gridVocab: gridVocab.slice(0, 12), lang, langVocabCount: langVocab.length } }) await page.close() - if (out.skip) { await browser.close(); cleanup(); skip(`${out.skip} (under ${engine})`) } - results[engine] = out - if (out.fail) { fails.push(`[${engine}] ${out.fail}`); continue } + if (out.skip) { await browser.close(); cleanup(); skip(`${out.skip} (under ${locale}/${engine})`) } + results[`${locale}/${engine}`] = out + if (out.fail) { fails.push(`[${locale}/${engine}] ${out.fail}`); continue } - lines.push(` ── ${engine}`) + lines.push(` ── ${locale} / ${engine}`) lines.push(` vocabulary — the map names ${out.mapVocab.length} religion families, the grid names ${out.gridVocab.length}+; '${out.family}' is shared`) lines.push(` direct — religion-family="${out.family}" set on the grid itself: ${out.direct} of ${out.unfiltered}`) lines.push(` linked — the same family over the composite's wire: ${out.linked}`) @@ -212,12 +255,26 @@ try { if (out.linked !== out.direct) { fails.push(`[${engine}] the link does not deliver the facet — '${out.family}' set on the grid gives ${out.direct} rows, the same family over the composite's wire gives ${out.linked}.`) } + if (out.lang) { + lines.push(` language — '${out.lang.name}' direct ${out.lang.direct}, over the wire ${out.lang.byName}, poison ${out.lang.poison} (of ${out.langVocabCount} names)`) + if (out.lang.byName !== out.lang.direct) { + fails.push(`[${engine}] the LANGUAGE link does not deliver the facet — '${out.lang.name}' set on the grid gives ${out.lang.direct} rows, the same language over the composite's wire gives ${out.lang.byName}.`) + } + if (out.lang.poison === out.lang.direct) { + fails.push(`[${engine}] NO TEETH on language — a language that does not exist narrowed the grid to the same ${out.lang.direct} rows. The wire is not carrying what this gate thinks it is.`) + } + } else { + lines.push(` language — SKIPPED (no single language narrows this grid to a strict subset)`) + } + } } - // ★ AND THE TWO ENGINES MUST AGREE. Each run above only proves the link works within one engine; - // identical COUNTS is what proves the contract is the same product on both. A per-engine pass with - // different numbers would mean the two builds narrow to different peoples for the same choice. - const [a, b] = PROVIDERS + // ★ THE TWO ENGINES MUST AGREE, WITHIN EACH LOCALE. Each run above only proves the link works in + // one build; identical COUNTS is what proves the contract is the same product on both. Comparing + // ACROSS locales would compare two different datasets, since the labels themselves change — so the + // comparison is per locale, and a per-engine pass with different numbers still fails. + for (const locale of LOCALES) { + const [a, b] = PROVIDERS.map((e) => `${locale}/${e}`) if (results[a] && results[b] && !results[a].fail && !results[b].fail) { if (results[a].unfiltered !== results[b].unfiltered) { fails.push(`the two engines start from different data — ${a} lists ${results[a].unfiltered} groups, ${b} lists ${results[b].unfiltered}.`) @@ -229,6 +286,7 @@ try { fails.push(`the link delivers ${results[a].linked} rows under ${a} and ${results[b].linked} under ${b} — interoperability that holds on one provider and not the other is not interoperability.`) } } + } } catch (err) { fails.push(`the measurement itself failed: ${err && err.message}`) } finally {