#!/usr/bin/env node /** * check-legend-mirrors.mjs — the legends must MIRROR, and their labels must not become values. * * rega's Arabic survey on main 52eeb0db: the research-dashboard legends sat on the wrong edge with * ENGLISH text — 70 of 85 letters Latin — in Big Picture and in the Research Gallery. Two faults * that look like one, and they fail in opposite directions if you fix them carelessly. * * ── 1. A PHYSICAL INSET DOES NOT MIRROR ────────────────────────────────────────────────────── * `right:8px` pins a box to the physical right edge in every locale, so in Arabic it stays where an * English reader expects it while everything around it flips. The logical property is the fix, and * `inset-inline-start:auto` / `inset-inline-end:auto` is part of the pair rather than decoration: * it cancels a physical inset inherited from a control container, which otherwise stretches the box * across the map once the other edge moves. * Two surfaces are asserted because they are the two the survey named, and one of them — the * research-map geocoder column — exists ONLY to share an edge with the legend beside it. A shared * edge that one of the pair honours and the other does not is worse than no alignment: it reads as * a broken layout rather than as a choice. * * ── 2. THE LABEL IS PROJECTED; THE VALUE IS NOT ────────────────────────────────────────────── * The English in that legend was NOT chrome. `s.key` is a facet VALUE, and for the religion donut * it is a family name DERIVED in English by library/colors/religion.js — the same table * people-groups-grid re-derives per row so the two ends byte-match. So the obvious fix, translating * the strings, is the dangerous one: it would work in English and match nothing in Arabic, * SILENTLY, because an empty intersection reads as "no results". That is the language-link defect * one facet over, and it cost a card to learn the first time. * So this gate asserts BOTH halves: that the visible text is projected, AND that the projection did * not leak into the value — data-key still carries the raw key, and RELIGION_FAMILIES is still the * English table every consumer compares against. * * NEGATIVE-TESTED: a physical inset on either surface fails 1; binding data-key to the projection, * or dropping the projection from the visible text, fails 2; translating a RELIGION_FAMILIES value * fails 2 naming the link it would break. */ import { readFileSync, existsSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const TAG = 'LEGEND-MIRRORS' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') const fails = [] const notes = [] const read = (rel) => { const p = join(ROOT, rel); return existsSync(p) ? readFileSync(p, 'utf8') : null } // ── 1. logical insets on the two surfaces the survey named ─────────────────────────────────── const SURFACES = [ { rel: 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue', sel: '.rd-legend-map', want: 'inset-inline-end', what: 'the map legend' }, { rel: 'plugin-designs/doxa-research-map/research-map.vue', sel: '.mapboxgl-ctrl-top-left', want: 'inset-inline-start', what: 'the geocoder column, which exists to share an edge with the legend below it' }, ] for (const { rel, sel, want, what } of SURFACES) { const src = read(rel) if (!src) { fails.push(`1 ${rel} is missing — this gate is blind to ${what}.`); continue } const rule = new RegExp(`\\${sel}\\s*\\{([^}]*)\\}`).exec(src) if (!rule) { fails.push(`1 ${rel}: no ${sel} rule found — it moved or was renamed, and this gate cannot see ${what}.`); continue } const body = rule[1] const physical = [...body.matchAll(/(^|[;{\s])(left|right)\s*:/g)].map((m) => m[2]) if (physical.length) { fails.push(`1 ${rel}: ${sel} uses the PHYSICAL inset(s) ${[...new Set(physical)].join(', ')} — ${what} will not mirror under ar, so it lands on the edge an English reader expects while everything around it flips.`) } if (!body.includes(want)) { fails.push(`1 ${rel}: ${sel} has no ${want} — ${what} has nothing anchoring it to the reading direction.`) } const other = want === 'inset-inline-end' ? 'inset-inline-start' : 'inset-inline-end' if (!new RegExp(`${other}\\s*:\\s*auto`).test(body)) { fails.push(`1 ${rel}: ${sel} sets ${want} without ${other}:auto. The pair is what cancels a physical inset inherited from a control container; without it the box stretches across the map once the anchored edge moves.`) } } if (!fails.some((f) => f.startsWith('1'))) notes.push(`insets: ${SURFACES.length} surface(s) anchored to the reading direction, both halves of the pair present`) // ── 2a. the visible text is projected, the wire is not ─────────────────────────────────────── { const rel = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue' const src = read(rel) if (!src) fails.push(`2 ${rel} is missing.`) else { const li = /\n/.exec(src) if (!li) fails.push(`2 ${rel}: the legend item markup moved — this gate is blind to what it renders.`) else { const m = li[0] if (!/class="rd-lt">\{\{ legendLabel\(/.test(m)) { fails.push(`2 ${rel}: the legend's visible text is not projected through legendLabel() — a derived English facet value renders untranslated in all eleven locales.`) } // ★ EVERY data-key IN THE FILE, not just this one. A first version checked only the legend // item, and a mutation test aimed at it silently hit a DIFFERENT :data-key — the donut // segment — and the gate stayed green while a projection sat on a wire. There are three // (segment, legend item, bar), all of them values the bus compares, and a check that guards // one of three is a check whose own negative test can miss. const keyBinds = [...src.matchAll(/:data-(?:key|facet)="([^"]*)"/g)] if (keyBinds.length < 3) fails.push(`2 ${rel}: expected at least 3 :data-key/:data-facet bindings (segment, legend item, bar) and found ${keyBinds.length} — the markup moved and this gate is guarding less than it thinks.`) for (const b of keyBinds) { if (/legendLabel\(|religionFamilyLabel\(|tStatic\(/.test(b[1])) { fails.push(`2 ${rel}: a PROJECTION is bound to a wire attribute (${b[0]}). The value is what the bus and the grid compare; a localized one works in English and matches NOTHING in Arabic, silently, because an empty intersection reads as "no results". Labels are for readers; values cross boundaries.`) } } if (!/:data-key="s\.key"/.test(m)) { fails.push(`2 ${rel}: the legend item's data-key no longer carries the RAW key.`) } if (!fails.some((f) => f.startsWith('2'))) notes.push('charts legend: text projected, data-key still the canonical value') } } } // ── 2b. the table itself was not translated ────────────────────────────────────────────────── { const rel = 'library/colors/religion.js' const src = read(rel) if (!src) fails.push(`2 ${rel} is missing.`) else { const tbl = /export const RELIGION_FAMILIES = \{([\s\S]*?)\}/.exec(src) if (!tbl) fails.push(`2 ${rel}: RELIGION_FAMILIES not found.`) else { const values = [...tbl[1].matchAll(/'[A-Z]':\s*'([^']+)'/g)].map((m) => m[1]) const nonAscii = values.filter((v) => /[^\x20-\x7E]/.test(v)) if (nonAscii.length) { fails.push(`2 ${rel}: RELIGION_FAMILIES has been TRANSLATED (${nonAscii.join(', ')}). These are VALUES: the dashboard puts them on the selection bus and people-groups-grid re-derives them per row. Localized, the link works in English and matches nothing elsewhere. Translate religionFamilyLabel's catalogue entries instead.`) } if (!/export function religionFamilyLabel/.test(src)) { fails.push(`2 ${rel}: religionFamilyLabel() is gone, so there is no way to show a family name to a reader without translating the value.`) } if (!fails.some((f) => f.includes('RELIGION_FAMILIES has been'))) notes.push(`religion table: ${values.length} values still canonical English, projection available`) } } } // ── 2c. the projection has something to project ────────────────────────────────────────────── { const LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh'] const en = read('library/i18n/locales/en/religion.json') if (!en) fails.push('2 library/i18n/locales/en/religion.json is missing — religionFamilyLabel() would fall back to English everywhere, which is the defect it exists to fix.') else { const names = Object.keys(JSON.parse(en).family || {}) if (!names.length) fails.push('2 en/religion.json has no `family` entries.') for (const loc of LOCALES) { const j = read(`library/i18n/locales/${loc}/religion.json`) if (!j) { fails.push(`2 ${loc}/religion.json is missing.`); continue } const missing = names.filter((n) => !(JSON.parse(j).family || {})[n]) if (missing.length) fails.push(`2 ${loc}/religion.json is missing ${missing.length} family name(s): ${missing.join(', ')}`) } if (!fails.some((f) => f.includes('religion.json'))) notes.push(`catalogue: ${names.length} family names in all ${LOCALES.length} locales`) } } for (const n of notes) console.log(' · ' + n) if (fails.length) { console.error(`\n${TAG}: FAIL`) for (const f of fails) console.error(' ✗ ' + f) process.exit(1) } console.log(`${TAG}: PASS — the legends mirror, and their labels never became their values.`)