People-group names: one helper decides when a localized name can be shown

library/utils/peopleGroupName.js (pure) returns a group's name in the reader's
language only when the row's names are transliterations. When two or more of its
Latin-script names (fr pt es tl id de) are far from the English one (bigram Dice
< 0.5), the row was machine-translated as words (Balantak → "Balance",
Balo → "Sac à dos"), and every locale of that group shows English. That is
8,055 of 21,419 rows (37.6%). it and ro have no column and show English; zh
reads zh_CN. A fallback shows the caller's own English name.

check:pg-name-gate (gates:pre) proves it on the real table: the 4 known
translated rows show English in 7 locales, Balangao Bontoc keeps its names,
planted rows are two-sided, the whole-table fallback stays between 30% and 45%,
and no file outside the helper reads pg-names rows (pplr-data-maps is the
exception; the scanner is negative-controlled). Five mutants of the helper each
fail it. No consumer is wired yet.
This commit is contained in:
Doxa Maps 2026-10-01 05:52:31 +00:00
parent 8631748258
commit 6e80da212c
4 changed files with 177 additions and 1 deletions

View file

@ -266,6 +266,8 @@ and a change here can break other maps.
`import … from '@map/utils/languageLabel.js'`
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
`import … from '@map/utils/loadPinnedUmd.js'`
- **peopleGroupName** — peopleGroupName.js — a people group's name in the reader's language, or English if it was machine-translated.
`import … from '@map/utils/peopleGroupName.js'`
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
`import … from '@map/utils/peopleGroupPoints.js'`
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.

View file

@ -0,0 +1,97 @@
#!/usr/bin/env node
// check-pg-name-gate — a people group's localized name is shown only when it is a transliteration, never a word
// that a machine translated (Balantak → "Balance", Balo → "Sac à dos"). The rule lives in ONE place,
// library/utils/peopleGroupName.js; this gate holds it there and proves it on the REAL table. Headless. Asserts:
// A. the table loads (data/pplr/pg-names.json, the file the tree serves as assets/pplr/pg-names.json);
// B. REAL ROWS: Balantak, Balo, Abaga and Abai Sungai (translated as words) show English in every locale;
// Balangao Bontoc (a transliteration) shows its own names; it and ro, which have no column, show English;
// C. TWO-SIDED on planted rows: a transliteration with its Latin names replaced by words falls back, and the
// same row with one far locale still passes (the rule is "two or more");
// D. the whole table falls back between 30% and 45% (measured 37.6%, 8,055 of 21,419). A similarity that
// always says "same" (0%) or always "different" (100%) fails here even if B and C were tuned around;
// E. THE ONE-READER RULE: a file in library/ or plugin-designs/ that names pg-names must take its names through
// peopleGroupName.js. pplr-data-maps is the carded exception (its coverage views read raw rows on purpose).
// A NEGATIVE CONTROL runs the scanner on a planted raw reader and must catch it.
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { resolve, dirname, join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const P = (...a) => join(REPO, ...a)
const fails = [], notes = []
const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) }
let H, table
try {
H = await import(pathToFileURL(P('library/utils/peopleGroupName.js')).href)
table = JSON.parse(readFileSync(P('data/pplr/pg-names.json'), 'utf8'))
} catch (e) {
console.error(`PG-NAME-GATE: NOT MEASURED (exit 75) — an input did not load: ${e.message}`)
process.exit(75)
}
const { locales, names } = table
const name = (code, lang) => H.peopleGroupName(names[code], locales, lang)
// ── A ──
ok(Array.isArray(locales) && locales.includes('en') && Object.keys(names).length > 20000,
`the table has ${Object.keys(names || {}).length} rows and locales ${JSON.stringify(locales)}`,
`the table loads: ${Object.keys(names).length} groups × ${locales.length} locales`)
// ── B: real rows ──
const TRANSLATED = { 220002: 'Balantak', 220017: 'Balo', 220044: 'Abaga', 220045: 'Abai Sungai' }
const wrong = []
for (const [code, en] of Object.entries(TRANSLATED)) {
if (!names[code] || names[code][locales.indexOf('en')] !== en) { wrong.push(`${code} is not ${en} in this table`); continue }
for (const l of ['fr', 'es', 'de', 'ar', 'hi', 'ru', 'zh']) if (name(code, l) !== en) wrong.push(`${en} in ${l} shows "${name(code, l)}"`)
}
ok(!wrong.length, `translated rows leak: ${wrong.slice(0, 5).join('; ')}`,
`the 4 known translated rows (${Object.values(TRANSLATED).join(', ')}) show English in fr es de ar hi ru zh`)
const bb = names[220001], at = (l) => bb[locales.indexOf(l)]
ok(bb && name(220001, 'ar') === at('ar') && /[\u0600-\u06FF]/.test(name(220001, 'ar')) && name(220001, 'zh') === at('zh_CN') && name(220001, 'hi') === at('hi'),
`Balangao Bontoc lost its own names: ar "${bb && name(220001, 'ar')}", zh "${bb && name(220001, 'zh')}"`,
`Balangao Bontoc keeps its own names (ar "${name(220001, 'ar')}", zh "${name(220001, 'zh')}")`)
ok(name(220001, 'it') === 'Balangao Bontoc' && name(220001, 'ro') === 'Balangao Bontoc' && H.peopleGroupName(bb, locales, 'ar', 'Bontoc, Balangao') === at('ar')
&& H.peopleGroupName(names[220002], locales, 'fr', 'Balantak (Sulawesi)') === 'Balantak (Sulawesi)',
'it/ro or the caller\'s English name are not honoured', 'it and ro show English; a fallback shows the CALLER\'s English name')
// ── C: planted rows, two-sided ──
const planted = (fn) => { const r = bb.slice(); fn(r); return r }
const words = planted((r) => { r[locales.indexOf('fr')] = 'Sac à dos'; r[locales.indexOf('es')] = 'Mochila'; r[locales.indexOf('de')] = 'Rucksack' })
const oneFar = planted((r) => { r[locales.indexOf('fr')] = 'Sac à dos' })
ok(!H.isTransliterated(words, locales) && H.peopleGroupName(words, locales, 'ar') === 'Balangao Bontoc' && H.isTransliterated(oneFar, locales),
`planted rows: three words → ${H.isTransliterated(words, locales) ? 'KEPT' : 'fell back'}, one word → ${H.isTransliterated(oneFar, locales) ? 'kept' : 'FELL BACK'}`,
'planted: a row with three Latin names replaced by words falls back (ar too); one far locale alone is kept')
// ── D: the whole table ──
let back = 0
for (const row of Object.values(names)) if (!H.isTransliterated(row, locales)) back++
const share = back / Object.keys(names).length
ok(share > 0.30 && share < 0.45, `${back} rows (${(share * 100).toFixed(1)}%) fall back; expected 30-45% (measured 37.6%)`,
`${back} of ${Object.keys(names).length} rows (${(share * 100).toFixed(1)}%) fall back to English`)
// ── E: one reader ──
const EXEMPT = ['plugin-designs/pplr-data-maps/']
const rawReaders = (files) => files.filter(({ path, src }) => /pg-names/.test(src) && !/peopleGroupName(\.js)?['"]/.test(src) && !EXEMPT.some((x) => path.startsWith(x)))
const walk = (dir, out = []) => {
for (const f of readdirSync(dir)) {
if (f === 'node_modules' || f.startsWith('.')) continue
const p = join(dir, f), s = statSync(p)
if (s.isDirectory()) walk(p, out)
else if (/\.(m?js|vue)$/.test(f)) out.push({ path: relative(REPO, p).split('\\').join('/'), src: readFileSync(p, 'utf8') })
}
return out
}
const sources = [...walk(P('library')), ...walk(P('plugin-designs'))].filter((f) => f.path !== 'library/utils/peopleGroupName.js')
ok(rawReaders([{ path: 'plugin-designs/x/x.vue', src: "fetch(base + '/pplr/pg-names.json').then((t) => t.names[code][3])" }]).length === 1,
'NEGATIVE CONTROL: the one-reader scan missed a planted raw reader', 'negative control: a planted raw pg-names reader is caught')
const raw = rawReaders(sources).map((f) => f.path)
ok(!raw.length, `pg-names read outside peopleGroupName.js: ${raw.join(', ')}`,
`no file outside peopleGroupName.js reads pg-names rows (${sources.length} sources; pplr-data-maps carded)`)
for (const n of notes) console.log(n)
if (fails.length) {
console.error('PG-NAME-GATE: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('PG-NAME-GATE: PASS — a localized people-group name is shown only when it is a transliteration, decided in one place.')

View file

@ -0,0 +1,75 @@
/**
* peopleGroupName.js — a people group's name in the reader's language, or English if it was machine-translated.
*
* The source is assets/pplr/pg-names.json: { locales: [17 codes], names: { <ROP3>: [17 names in that order] } },
* 21,419 groups. About a third of its rows were MACHINE-TRANSLATED AS WORDS instead of transliterated:
* 220002 Balantak fr "Balance" es "Balanza" de "Waage" (scales)
* 220017 Balo fr "Sac à dos" es "Mochila" de "Rucksack" (backpack)
* 220045 Abai Sungai fr "Ignorer la rivière" de "Ignoriere den Fluss"
* A proper name rendered as a dictionary word is worse than English: the reader cannot tell it is wrong.
*
* THE GATE, per row. A real transliteration of a proper name stays close to the English spelling in every
* Latin-script locale; a translated word does not. So each Latin locale's name is compared with the English one
* (bigram Dice similarity, accents folded). When TWO OR MORE of them fall below 0.5, the row is a translation, and
* EVERY locale of that group shows English, because the non-Latin names (ar, hi, ru, zh) came from the same
* process and cannot be checked directly.
* Measured on the whole table (2026-09-30): 8,055 of 21,419 rows (37.6%) fall back. Balantak, Balo, Abaga and Abai
* Sungai fall back; Balangao Bontoc keeps its names. The 0.4-0.5 band (1,406 rows) is about half wrong (Bawaria →
* "Bavière", Ager → "Agriculteur"), so 0.5 is the safer side: a wrongly kept translation is the worse error.
*
* ★ THE ONE-READER RULE. This file is the only code that interprets a pg-names row. A caller fetches the table
* (it is 7.9 MB, served, never bundled) and passes the row in; check:pg-name-gate fails on any other reader, with
* pplr-data-maps as the carded exception (its coverage views read the raw rows on purpose).
*
* PURE: no fetch, no JSON import, no i18n framework. It takes the locale string the caller already has.
*/
// The Latin-script locales of pg-names.json: the ones whose spelling can be compared with English.
export const LATIN_LOCALES = ['fr', 'pt', 'es', 'tl', 'id', 'de']
export const MIN_SIMILARITY = 0.5
export const MAX_FAR_LOCALES = 1 // two or more far locales → the row is a translation
// The app's locales → pg-names.json's. it and ro have no column, so they show English.
const LOCALE_COLUMN = { en: 'en', es: 'es', fr: 'fr', pt: 'pt', de: 'de', ru: 'ru', ar: 'ar', hi: 'hi', zh: 'zh_CN' }
const fold = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z]/g, '')
/** Bigram Dice similarity of two names, 0..1, accents and punctuation ignored. */
export function nameSimilarity(a, b) {
const x = fold(a), y = fold(b)
if (!x || !y) return 0
if (x === y) return 1
if (x.length < 2 || y.length < 2) return 0
const grams = (s) => { const m = new Map(); for (let i = 0; i < s.length - 1; i++) { const g = s.slice(i, i + 2); m.set(g, (m.get(g) || 0) + 1) } return m }
const gx = grams(x), gy = grams(y)
let shared = 0
for (const [g, n] of gx) shared += Math.min(n, gy.get(g) || 0)
return (2 * shared) / (x.length - 1 + y.length - 1)
}
/** True when the row's names are transliterations (keep them), false when they were translated as words. */
export function isTransliterated(row, locales) {
if (!Array.isArray(row) || !Array.isArray(locales)) return false
const en = row[locales.indexOf('en')]
if (!en) return false
let far = 0
for (const l of LATIN_LOCALES) {
const v = row[locales.indexOf(l)]
if (v && nameSimilarity(v, en) < MIN_SIMILARITY) far++
}
return far <= MAX_FAR_LOCALES
}
/**
* The name to show for a group: its `lang` name when the row is a transliteration and has that column, else
* `english` (the caller's own English name; the row's English column when the caller has none).
*/
export function peopleGroupName(row, locales, lang, english) {
const en = english || (Array.isArray(row) && Array.isArray(locales) ? row[locales.indexOf('en')] : '') || ''
const col = LOCALE_COLUMN[String(lang || 'en').toLowerCase().split(/[-_]/)[0]]
if (!col || col === 'en' || !isTransliterated(row, locales)) return en
const v = row[locales.indexOf(col)]
return (typeof v === 'string' && v.trim()) ? v : en
}
export default { peopleGroupName, isTransliterated, nameSimilarity, LATIN_LOCALES, MIN_SIMILARITY, MAX_FAR_LOCALES }

View file

@ -237,6 +237,7 @@
"check:ported-globals-identical": "node internal/check-ported-globals-identical.mjs",
"check:one-scroll-container": "node internal/check-one-scroll-container.mjs",
"check:list-pattern-doc": "node internal/check-list-pattern-doc.mjs",
"check:pg-name-gate": "node internal/check-pg-name-gate.mjs",
"check:pin-selection": "node internal/check-pin-selection.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.mjs",
"check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs",
@ -348,7 +349,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived 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:zoning-declared 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:auto-engine check:engine-contract 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 check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight",
"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 check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:wall-photo-weight check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue",
"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 check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:wall-photo-weight check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:pg-name-gate",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -576,6 +577,7 @@
"check:pinned-umd": "library/utils/loadPinnedUmd.js (d3 for PPLR and the Wall) loads a pinned UMD with SRI and never touches the host page's global of the same name: a host's own d3 gains no keys, a failed load leaves it in place. Headless.",
"check:popup-escaping": "the library's pin and region popups render hostile data (an <img onerror>, a <script>, a javascript: URL) as text, never markup: every field goes through escapeHtml/safeUrl (library/utils/html.js). Headless.",
"check:ported-globals-identical": "the DX Life base/_globals.scss rules that shadow-root components used to copy now come from one module (library/styles/portedGlobals.js), and the CSS each component actually INJECTS must be byte-identical to the pre-refactor baseline: it runs each component's setup with stubbed imports and hashes the real useShadowStyles argument, not the source text. Deduplication with a proof that no pixel can change. Node only, no browser.",
"check:pg-name-gate": "a people group's localized name (assets/pplr/pg-names.json) is shown only when it is a transliteration: library/utils/peopleGroupName.js falls back to English for EVERY locale of a row whose Latin-script names are far from the English one in two or more locales (bigram Dice < 0.5), because about a third were machine-translated as words (Balantak → 'Balance', Balo → 'Sac à dos'). Proved on the real table: the 4 known translated rows show English in 7 locales, Balangao Bontoc keeps its names, planted rows two-sided, the whole-table fallback stays 30-45% (37.6%), and no file outside the helper reads pg-names rows (pplr-data-maps carded; the scanner is negative-controlled). Headless.",
"check:pin-selection": "the shared source reader understands BOTH engines: mapbox-gl stores a GeoJSON source _data as the FeatureCollection, maplibre-gl wraps it as { geojson } (or { url } when remote). Reading only the Mapbox shape returned an EMPTY list on MapLibre — the keyless engine public builds use — which silently killed zoomToLegendRow camera fits on three maps and the research packet row selection, with no error because every caller no-ops on no features. Asserts both shapes, that a remote source returns [] AND warns, the serialize fallback, and that selectedPinIds agrees with the reader. Headless, no map. Exit 0 pass, 1 fail.",
"check:popup-href-escaped": "a URL interpolated into an HTML string is escaped before it lands in href= or src=. Written for RegionCountryMap's popup, which escaped every value except its two link URLs -- both built from joinBase(hostAttribute, apiSlug), so a quote in either escaped the attribute. Data URLs, blob URLs, encodeURIComponent and safeUrl() are recognised as safe; anything else needs an href-escaped-ok: reason. 6 self-tests. Source-only, headless.",
"check:poster-action-print": "a poster button opens the print dialog (the rule since 2026-09-28: never a direct download). Two arms: PosterWall's posterAction defaults to 'print' and any consumer opting into 'download' carries a posterAction-download: comment saying why; and no shipped file hand-rolls an <a download> for a poster IMAGE (svg/png/webp/pdf) without first attempting to print, which the prop arm cannot see. Data exports (csv/txt/json) are out of scope. 6 self-tests. Source-only, headless.",