diff --git a/library/composables/legends/useLegendData.js b/library/composables/legends/useLegendData.js index 53d71d2d7..cc96c32a3 100644 --- a/library/composables/legends/useLegendData.js +++ b/library/composables/legends/useLegendData.js @@ -22,7 +22,7 @@ import { ENGAGEMENT_COLORS } from '../../colors/engagement.js' import { ADOPTION_COLORS } from '../../colors/adoption.js' import { getLanguageFamilyColor } from '../../colors/language-family.js' import { getColorStrategy, COLOR_MODES } from '../../colors/_registry.js' -import { PALETTE as RELIGION_COLORS, RELIGION_FAMILIES, getReligionFamily } from '../../colors/religion.js' +import { PALETTE as RELIGION_COLORS, getReligionFamily, religionFamilyLabel } from '../../colors/religion.js' import { useLanguageFamilyLegendData } from './useLanguageFamilyLegendData.js' const COLUMNS_SIMPLE = [] // e.g. affinity-blocs — no numeric columns @@ -319,7 +319,11 @@ export function useLegendData(legendTypeRef, opts = {}) { }) return order.map(fam => ({ key: fam, - label: RELIGION_FAMILIES[fam] || 'Unknown', + // REGREL: the LABEL is localized, the VALUE is not. `key` and `filterKey` below stay the + // canonical family letter, which is what the filter and the map compare on; only this + // display string follows the reader's language. RELIGION_FAMILIES is no longer imported here: + // religionFamilyLabel() reads it internally, so this file's only use of it was this label. + label: religionFamilyLabel(fam) || 'Unknown', color: RELIGION_COLORS[fam] || '#94a3b8', count: familyAggregates[fam].count, population: familyAggregates[fam].population, diff --git a/library/composables/useSearch.js b/library/composables/useSearch.js index 0d0e357c6..8f3418f1d 100644 --- a/library/composables/useSearch.js +++ b/library/composables/useSearch.js @@ -32,7 +32,10 @@ import langFamilyByLanguage from '../data/langFamilyByLanguage.json' // Religion search must group by FAMILY (same 10 families the Religion legend shows), // not by raw sub-type names — otherwise search surfaces 'Animism'/'Romanism' rows // the legend can't select. Reuse the legend's exact family logic. -import { RELIGION_FAMILIES, getReligionFamily } from '../colors/religion.js' +// RELIGION_FAMILIES is no longer imported: its only use here was the label that now goes through +// religionFamilyLabel(), which reads the canonical map internally. The KEY still comes from +// getReligionFamily, so nothing that is compared or sent changed. +import { getReligionFamily, religionFamilyLabel } from '../colors/religion.js' // ── Result category ordering by active-tab context (Round 16) ──────────────── // The search bar is unified — every category is always searched — but the @@ -319,11 +322,19 @@ function buildAggregates(entries) { // SAME extraction the legend uses (getReligionFamily on the religion code), // so every search result corresponds to a selectable Religion-legend row. // The raw sub-type names ('Animism') still MATCH via religionHay, but collapse - // into their family row. label = family name so selection can resolve the - // family letter back (see research-map onGeocoderAggregateResult religion branch). + // into their family row. + // ✗ THIS COMMENT USED TO SAY: "label = family name so selection can resolve the family letter back + // (see research-map onGeocoderAggregateResult religion branch)". It was WRONG, and wrong in the + // direction that forbids the fix below: it presented an English label as a REQUIREMENT of + // selection. Measured 2026-09-27 — onGeocoderAggregateResult does not exist anywhere in this repo + // (three comments name it, nothing defines it), and selection resolves by VALUE: aggregate() emits + // `key` and `label` as SEPARATE fields (:262-263) with key from getReligionFamily, and the consumer + // reads detail.expectedValue, never the label (country-posters/countries-map.vue:1611). So the label + // is display-only and safe to localize. A stale comment that argues against a correct change is + // worse than no comment: the next reader either reverts it or refuses the next one like it. religions: aggregate( (e) => getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || ''), - (e) => RELIGION_FAMILIES[getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || '')] || 'Unknown' + (e) => religionFamilyLabel(getReligionFamily(e.feature.religion || e.feature._raw?.religion?.value || e.feature._raw?.religion || '')) || 'Unknown' ), // WAGF / DOXA regions (doxaRegion) — keyed by the region value so a search // for "Asia" collapses every pin in that region into one row. The click diff --git a/package.json b/package.json index 3cc02c42f..3db15d381 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "check:script-flags": "node internal/check-script-flags-reachable.mjs", "check:settled": "node internal/settled-tree-gate.mjs", "check:sfc-parse": "node internal/check-sfc-script-parses.mjs", + "check:religion-label-not-value": "node plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs", "check:share-policy": "node internal/check-share-policy.mjs", "check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs", "check:shell-scroll": "node internal/verify-shell-scroll.mjs", @@ -229,7 +230,7 @@ "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 check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight", + "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 check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value", "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", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "library-index": "node internal/generate-library-index.mjs", diff --git a/plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs b/plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs new file mode 100644 index 000000000..6316d4d05 --- /dev/null +++ b/plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/* check-religion-label-not-value.mjs — `bun run check:religion-label-not-value`. Source-only, headless. + * + * REGREL: two LIBRARY sites showed RELIGION_FAMILIES values as English in every locale. The fix is to + * project the LABEL through religionFamilyLabel() and leave the VALUE canonical English — because a + * TRANSLATED VALUE MATCHES NOTHING. That is the language-link bug class: the moment a localized string + * lands in a filter, a bus payload or an element attribute, the link works in English and silently + * fails in Arabic. + * + * So this gate asserts BOTH halves. Projecting the label is the easy half; the half worth a gate is + * that the value did NOT move with it. + */ +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { codeOnly } from './strip-code.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(HERE, '..', '..', '..') +let fails = 0 +const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ } +const read = (p) => readFileSync(resolve(ROOT, p), 'utf8') + +// ── 1. BEHAVIOUR, against the SHIPPED catalogues ──────────────────────────────────────────────── +// religion.js imports tStatic from library/i18n/index.js, which calls import.meta.glob — a Vite +// transform plain Node cannot run. So religionFamilyLabel's REAL SOURCE is extracted and executed with +// tStatic stubbed over the actual ar/ and en/ religion.json, rather than re-typed here: a copy would +// test the copy. Same approach as check-provider-for.mjs and check-optional-t-equivalence.mjs. +const relSrc = read('library/colors/religion.js') +const famBlock = /export const RELIGION_FAMILIES = \{[\s\S]*?\n\}/.exec(relSrc) +const labelFn = /export function religionFamilyLabel\(familyOrName\) \{[\s\S]*?\n\}/.exec(relSrc) +if (!famBlock || !labelFn) { + console.error(' FAIL - could not extract RELIGION_FAMILIES / religionFamilyLabel: NOT MEASURED, not passing') + process.exit(2) +} +const en = JSON.parse(read('library/i18n/locales/en/religion.json')) +const ar = JSON.parse(read('library/i18n/locales/ar/religion.json')) +const messages = { en: { religion: en }, ar: { religion: ar } } +const tStaticFor = (want) => (path) => { + const walk = (loc) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), messages[loc]) + const v = walk(want) + return typeof v === 'string' ? v : walk('en') +} +const build = (want) => new Function('tStatic', + famBlock[0].replace(/^export /, '') + ';\n' + labelFn[0].replace(/^export /, '') + + ';\nreturn { RELIGION_FAMILIES, religionFamilyLabel };')(tStaticFor(want)) + +const AR = build('ar'), EN = build('en') +ok(AR.religionFamilyLabel('C') === ar.family.Christianity, + `under ar the LABEL is Arabic ("${ar.family.Christianity}")`) +ok(AR.RELIGION_FAMILIES.C === 'Christianity', + 'while the VALUE stays canonical English ("Christianity") -- a translated value would match nothing') +ok(AR.religionFamilyLabel('C') !== AR.RELIGION_FAMILIES.C, + 'CONTROL: label and value really DIFFER under ar, so the projection is doing something') +ok(EN.religionFamilyLabel('C') === 'Christianity', + 'under en the label equals the canonical name, so nothing changes for an English reader') +ok(AR.religionFamilyLabel('M') === ar.family.Islam && AR.religionFamilyLabel('H') === ar.family.Hinduism, + 'and it holds for more than one family (Islam, Hinduism)') +ok(AR.religionFamilyLabel('') === '', 'an empty family gives an empty label, not the word "undefined"') +ok(AR.religionFamilyLabel('Christianity') === ar.family.Christianity, + 'it accepts a NAME as well as a letter, as its signature promises (familyOrName)') +ok(AR.religionFamilyLabel('ZZZ') === 'ZZZ', + 'an unknown family falls through to the input rather than blanking the row') + +// ── 2. THE HALF WORTH A GATE: the VALUE did not move with the label ───────────────────────────── +const legend = codeOnly(read('library/composables/legends/useLegendData.js')) +ok(/label:\s*religionFamilyLabel\(fam\)/.test(legend), 'useLegendData projects the LABEL through religionFamilyLabel(fam)') +ok(/key:\s*fam\b/.test(legend), 'and `key` is still the canonical family letter') +ok(/filterKey:\s*fam\b/.test(legend), 'and `filterKey` is still the canonical family letter -- this is what the filter compares') +ok(!/key:\s*religionFamilyLabel|filterKey:\s*religionFamilyLabel/.test(legend), + 'NEG: neither key nor filterKey is derived from the localized label') + +const search = codeOnly(read('library/composables/useSearch.js')) +ok(/religions:\s*aggregate\(/.test(search), 'useSearch still aggregates religions') +ok(/\(e\) => religionFamilyLabel\(getReligionFamily\(/.test(search), + 'its LABEL function goes through religionFamilyLabel()') +ok(/\(e\) => getReligionFamily\(/.test(search), + 'and its KEY function is still bare getReligionFamily() -- the value is untouched') +ok(/key,\n\s*label:/.test(search) || /\bkey,\s*$/m.test(search), + 'aggregate() still emits key and label as SEPARATE fields, so a consumer can choose') + +// ── 3. the consumer really does resolve by VALUE, which is why (2) is sufficient ──────────────── +const consumer = codeOnly(read('plugin-designs/country-posters/countries-map.vue')) +ok(/kind === 'religion'/.test(consumer) && /expectedValue = detail\.expectedValue/.test(consumer), + 'the religion consumer resolves by detail.expectedValue, never by the label (countries-map)') + +// ── 4. the stale comment that argued AGAINST this fix is gone ─────────────────────────────────── +// ★ THIS ASSERTION FAILED ON MY OWN RETRACTION, which is worth recording. The first version required +// that "onGeocoderAggregateResult religion branch" be ABSENT from the file — but the corrected comment +// QUOTES that phrase in order to record what it used to claim, so the string is still there, inside the +// retraction that fixes it. Requiring the absence of a phrase punishes documenting the error. What I +// actually care about is that the claim is no longer presented as CURRENT, so assert the retraction. +const searchRaw = read('library/composables/useSearch.js') +ok(/USED TO SAY/.test(searchRaw) && /does not exist anywhere in this repo/.test(searchRaw), + 'the stale claim is RETRACTED in place, with the measurement that falsified it (the cited handler does not exist)') +ok(!/\/\/ into their family row\. label = family name so selection/.test(searchRaw), + 'and it no longer reads as a live requirement -- the original standalone sentence is gone') + +// ── 5. NEG CONTROL: the behavioural half must be capable of failing ───────────────────────────── +const brokenLabel = (f) => ({ C: 'Christianity' })[f] || f // an English-only "projection" +ok(brokenLabel('C') !== ar.family.Christianity, + 'NEG CONTROL: an English-only projection FAILS the ar assertion, so passing it means something') + +console.log(fails ? `\nRELIGION-LABEL: ${fails} FAIL(s)` : '\nRELIGION-LABEL: the label follows the reader, the value stays canonical, and the filter still compares the value') +process.exit(fails ? 1 : 0)