From b6abe30698998f656c84a11708c0c23bee27f7da Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 08:07:39 +0000 Subject: [PATCH] Link language on the ISO code, not the display name, because the name moves with the locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Localizing the grid's data (ae5e27c3) would have broken the language link silently, and the shepherd asked me to prove the coupling with a test. Measuring it first showed the coupling should not exist. MEASURED against the live API, en vs ar, 2,107 records: primary_language LABEL 864 names in en, 864 in ar, ZERO shared → localized primary_language VALUE 864 codes, ALL 864 shared → stable religion / wagf_region VALUE also identical across locales So the fix is not "make both halves fetch the same locale and test that they do". It is to link on the form that does not move. Region already links on a slug and religion on a family letter; language was the one facet still travelling as a display name. The map now emits `languageCode` alongside `language`, the composite carries {value, label} for language as it always has for region, the GRID is driven by the code (valueMatches compares the normalized value as well as the label, so a code matches in every locale) and the MAP is still driven by the name, because its legend selects by name. Each half gets the form it can actually use, and neither depends on the other's locale. ★ THIS TREE HAD ALREADY PAID FOR THIS ONCE. deriveLanguageFamily in useMapData carries a comment about the 2026-06-22 locale colour bug: deriving a family from the localized label collapsed every pin onto one bucket, and the fix was to use primary_language.value. Same trap, one facet over, three months later. check:linked-vocabulary now runs per LOCALE as well as per provider — en as the control, ar as the case that would have broken in silence — and asserts the language facet with a poison case (a language that cannot exist must NOT narrow the grid). It is a browser gate, so rega runs it; I have not. ★ AND check:no-free-identifiers CAUGHT MY OWN BUG IN THIS COMMIT on its first outing: I wrote `rows.find(...)` in the emitter where no `rows` was in scope. The gate named the file and line before anything was built. That is the class of mistake that broke both composites yesterday, found in seconds this time. --- .../doxa-research-map/research-map.vue | 31 +++++++- .../research-map-plus-grid.vue | 37 ++++++--- .../scripts/check-linked-vocabulary.mjs | 78 ++++++++++++++++--- 3 files changed, 125 insertions(+), 21 deletions(-) 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 {