diff --git a/internal/check-batch-selection-export.mjs b/internal/check-batch-selection-export.mjs index 0c75b4830..8ffa3d2d8 100644 --- a/internal/check-batch-selection-export.mjs +++ b/internal/check-batch-selection-export.mjs @@ -112,6 +112,45 @@ ok(!noCountry.length && resolveTerritory({ shapeName: 'Mahārāshtra' }).country ok(!disagree.length, `Copy's regroup warning disagrees with apply-grouping: ${disagree.join('; ')}`, `the regroup warning matches apply-grouping's own verdict in all ${Object.keys(REGROUP_CASES).length} cases (India, China, countries, and the three it refuses)`) +// ── A4. A STALLED people-group load can never hang Copy ───────────────────────────────────────────────── +// Copy races the people-group detection against COPY_WAIT_MS through settleWithin(); past it, it copies the territory +// lines alone (they need no network) and says why. The self-test: a promise that NEVER settles must still resolve to +// the marker in time; a value passes through; a failure still rejects (its own message is kept). +{ + const MARK = {}, t0 = Date.now() + // The gate's own backstop: if settleWithin waited for ever, this reads 'GATE-TIMEOUT' and FAILS by name. + const stalled = await Promise.race([X.settleWithin(new Promise(() => {}), 60, MARK), new Promise((r) => setTimeout(() => r('GATE-TIMEOUT'), 2000))]) + const quick = await X.settleWithin(Promise.resolve('v'), 1000, MARK) + const failed = await X.settleWithin(Promise.reject(new Error('down')), 1000, MARK).then(() => 'resolved', (e) => e.message) + ok(stalled === MARK && Date.now() - t0 < 1000 && quick === 'v' && failed === 'down', + `settleWithin: a stalled source gave ${stalled === MARK ? 'the marker' : JSON.stringify(stalled)}, a value gave ${quick}, a failure gave ${failed}`, + 'a NEVER-settling people-group load still lets Copy settle in time (and a value / a failure pass through)') +} + +// ── A5. A FAILED load never shows the reader a raw browser message ───────────────────────────────────── +// The second Copy after the 30 s abort flashed "signal is aborted without reason". The self-test aborts a REAL fetch +// (the signal is aborted before the request starts, so nothing is sent) and makes a real one fail (a refused +// localhost port, bounded at 2 s); both must read as the keyed stall text. A keyed error keeps its own text. The NEGATIVE CONTROL is +// the old path, errorText without asReaderError, which must still show the raw message, so this test can see the defect. +{ + const { keyedError, errorText } = await import(pathToFileURL(P('library/i18n/keyedError.js')).href) + const en = JSON.parse(readFileSync(P('library/i18n/locales/en/upgMap.json'), 'utf8')) + const tr = (k, p = {}) => { const v = k.startsWith('upgMap.') ? en[k.slice(7)] : undefined; return v == null ? k : v.replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m)) } + const stillLoading = () => keyedError('upgMap.peopleStillLoading', 'the people groups are still loading, try again in a moment') + const aborted = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.abort() }).then(() => null, (e) => e) + // Bounded: a host running the discard service ACCEPTS port 9 and would hang; a timeout is a failure too. + const refused = await fetch('http://127.0.0.1:9/', { signal: AbortSignal.timeout(2000) }).then(() => null, (e) => e) + const keyed = keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names}', { names: 'Yunnan' }) + const shown = (e) => errorText(X.asReaderError(e, stillLoading), tr) + const want = en.peopleStillLoading + ok(aborted && aborted.name === 'AbortError' && /abort/i.test(errorText(aborted, tr)), + `NEGATIVE CONTROL: an aborted fetch gave ${aborted && aborted.name} "${aborted && errorText(aborted, tr)}" on the old path, so the test cannot see the defect`, + 'negative control: on the old path an aborted fetch shows the raw "' + (aborted ? errorText(aborted, tr) : '') + '"') + ok(shown(aborted) === want && refused && shown(refused) === want && shown(keyed) === tr('upgMap.selectionAdm1NoData', { names: 'Yunnan' }), + `a failed load reads: aborted "${shown(aborted)}", refused "${refused && shown(refused)}", keyed "${shown(keyed)}"`, + 'an aborted or refused people-group fetch reads as the stall text; a keyed error keeps its own') +} + // ── A6. A STATE-ONLY selection titles its Prayer list / Card deck ─────────────────────────────────────── // Development threw "Reduce of empty array" for a state-only selection: the title named zones and countries only, so // a selection of Gujarāt alone had NO names. On the REAL served table: Gujarāt alone is titled "Gujarāt"; a state + @@ -180,8 +219,13 @@ const wired = { 'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue), 'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"), 'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue), - 'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue), + 'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /settleWithin\(selectionPeople\(\)/.test(vue), // India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected + 'Copy waits at most COPY_WAIT_MS for the people groups': /settleWithin\(selectionPeople\(\), COPY_WAIT_MS, STALLED\)/.test(vue) && /if \(p === STALLED\) \{ note = t\('upgMap\.peopleStillLoading'\); return selectionClipboard\(sel, \{ color \}\); \}/.test(vue), + 'the records fetch times out and leaves the cache': /setTimeout\(\(\) => ctrl\.abort\(\), RECORDS_TIMEOUT_MS\)/.test(vue) && /signal: ctrl \? ctrl\.signal : undefined/.test(vue), + 'every failed load reads as the stall, never a raw message': /const readerError = \(e\) => asReaderError\(e, stillLoading\)/.test(vue) + && /\}\)\(\)\.catch\(\(e\) => \{ throw readerError\(e\); \}\);/.test(vue) + && /note = errorText\(readerError\(e\), t\)/.test(vue) && /csvFailed', \{ reason: errorText\(readerError\(e\), t\)/.test(vue), // In batch mode a tap on a pin opens NO detail sheet (it covered the whole HUD on a phone); the tapestry // listener under it toggles the territory. The real-tap proof is a browser check; this pins the guard in the source. 'a pin tap in batch mode opens no sheet': /map\.on\('click', 'upg-pins-hit', \(e\) => \{[\s\S]{0,700}?if \(batchMode\.value\) return\s*\n\s*const f = e && e\.features/.test(vue), @@ -205,7 +249,7 @@ ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the // ── D. the strings, ×11 ────────────────────────────────────────────────────────────────────────── const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh'] const KEYS = { - upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] } + upgMap: ['copySelection', 'exportCsv', 'copyNotRegroupable', 'peopleStillLoading', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] } const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8')) const bad = [] for (const [f, keys] of Object.entries(KEYS)) { diff --git a/internal/check-poster-sizes.mjs b/internal/check-poster-sizes.mjs new file mode 100644 index 000000000..e989aab4b --- /dev/null +++ b/internal/check-poster-sizes.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * check-poster-sizes.mjs — the poster-size LADDER (library/constants/posterSizes.js) says what it promises. + * + * Every print surface offers these sizes through one picker, and each renderer maps a key to its own + * geometry. So the table itself must be right: a wrong sheet count or overlap prints a poster that does not + * glue up to its size, and a landscape size breaks the portrait-only rule. + * + * ASSERTS: the picker order lists exactly the ladder; no size is both offered and legacy; only 66x66 and 60x96 + * are glued from several sheets; every key is dot-free (i18n paths, URLs); every size is + * PORTRAIT (h ≥ w); the sheets glue up to the finished size (count × sheet − overlaps, in `direction`); a + * photo slot is allowed on exactly the large sizes (66x66, 60x96); paper prints through the browser and the + * rest through a print-shop PDF; every alias resolves; paperFor() picks Letter or A4 by region. + * --self-test feeds broken copies of the table and must see each one refused. + */ +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const S = await import(pathToFileURL(join(ROOT, 'library/constants/posterSizes.js')).href) + +const PHOTO_SIZES = ['66x66', '60x96'] +// Every size up to 36x66 prints on ONE sheet; only these two are glued from several. +const MULTI_SHEET = ['66x66', '60x96'] +function audit({ ladder, order, legacy, aliases }) { + const bad = [] + const keys = Object.keys(ladder) + if (order.join() !== keys.join()) bad.push(`the picker order [${order}] is not the ladder [${keys}]`) + for (const [k, e] of [...Object.entries(ladder), ...Object.entries(legacy)]) { + if (!/^[a-z0-9]+$/.test(k)) bad.push(`${k}: a key must be dot-free lowercase letters/digits`) + if (e.id !== k) bad.push(`${k}: its id is "${e.id}"`) + const { w, h } = e.inches + if (!(h >= w)) bad.push(`${k}: ${w}×${h} in is landscape; every poster is portrait`) + const s = e.sheets + const W = s.direction === 'horizontal' ? s.count * s.sheetW - (s.count - 1) * s.joinIn : s.sheetW + const H = s.direction === 'vertical' ? s.count * s.sheetH - (s.count - 1) * s.joinIn : s.sheetH + if (W !== w || H !== h) bad.push(`${k}: ${s.count} sheet(s) of ${s.sheetW}×${s.sheetH} (${s.direction}, ${s.joinIn} in overlap) glue up to ${W}×${H}, not ${w}×${h}`) + if (s.count === 1 && s.joinIn !== 0) bad.push(`${k}: one sheet cannot have a ${s.joinIn} in overlap`) + } + for (const k of Object.keys(legacy)) if (ladder[k]) bad.push(`${k} is both offered and legacy`) + const multi = keys.filter((k) => ladder[k].sheets.count > 1) + if (multi.join() !== MULTI_SHEET.join()) bad.push(`glued sizes are [${multi}], expected [${MULTI_SHEET}]; every other size is one sheet`) + const photos = keys.filter((k) => ladder[k].photos) + if (photos.join() !== PHOTO_SIZES.join()) bad.push(`photo slots are allowed on [${photos}], expected [${PHOTO_SIZES}] (the large sizes)`) + for (const k of keys) { + const want = ladder[k].paper ? 'print' : 'pdf' + if (ladder[k].path !== want) bad.push(`${k}: path "${ladder[k].path}", expected "${want}"`) + } + for (const [a, t] of Object.entries(aliases)) if (!ladder[t] && !legacy[t]) bad.push(`alias ${a} → ${t}, which is not a size`) + return bad +} +const table = { ladder: S.POSTER_LADDER, order: S.POSTER_LADDER_ORDER, legacy: S.POSTER_LADDER_LEGACY, aliases: S.POSTER_LADDER_ALIASES } +const problems = audit(table) +const paper = [['en-US', 'letter'], ['es-MX', 'letter'], ['fr-CA', 'letter'], ['en-GB', 'a4'], ['ar', 'a4'], ['zh-CN', 'a4'], ['en', 'letter']] +for (const [tag, want] of paper) if (S.paperFor(tag) !== want) problems.push(`paperFor('${tag}') is ${S.paperFor(tag)}, expected ${want}`) +if (S.resolvePosterSize('5x8ft')?.id !== '60x96' || S.resolvePosterSize('nope') !== null) problems.push('resolvePosterSize does not resolve an alias, or invents an unknown size') + +if (process.argv.includes('--self-test')) { + if (problems.length) { console.error('POSTER-SIZES SELF-TEST: FAIL — the real table already fails; fix that first.'); process.exit(1) } + const clone = () => JSON.parse(JSON.stringify(table)) + const poisons = [ + ['one sheet short', (t) => { t.ladder['60x96'].sheets.count = 2 }], + ['no overlap', (t) => { t.ladder['66x66'].sheets.joinIn = 0 }], + ['landscape', (t) => { t.ladder['36x60'].inches = { w: 60, h: 36 }; t.ladder['36x60'].sheets = { sheetW: 60, sheetH: 36, count: 1, direction: 'vertical', joinIn: 0 } }], + ['photos on a small size', (t) => { t.ladder['36x60'].photos = true }], + ['a dotted key', (t) => { t.ladder['5.5x5.5'] = { ...t.ladder['66x66'], id: '5.5x5.5' }; t.order.push('5.5x5.5') }], + ['order drift', (t) => { t.order.reverse() }], + ['a dangling alias', (t) => { t.aliases['4x6ft'] = '48x72' }], + ['paper through a PDF', (t) => { t.ladder.paper.path = 'pdf' }], + ['offered and legacy at once', (t) => { t.legacy['36x48'] = t.ladder['36x48'] }], + ['a single-sheet size split in two', (t) => { t.ladder['36x66'].sheets = { sheetW: 36, sheetH: 36, count: 2, direction: 'vertical', joinIn: 6 } }], + ] + let missed = 0 + for (const [name, poison] of poisons) { const t = clone(); poison(t); const caught = audit(t).length > 0; console.log(` ${name}: ${caught ? 'caught' : 'NOT CAUGHT'}`); if (!caught) missed++ } + if (missed) { console.error(`POSTER-SIZES SELF-TEST: FAIL — ${missed} poison(s) not caught.`); process.exit(1) } + console.log(`POSTER-SIZES SELF-TEST: PASS — all ${poisons.length} broken tables refused.`); process.exit(0) +} +if (problems.length) { console.error('POSTER-SIZES: FAIL\n' + problems.map((p) => ' ✗ ' + p).join('\n')); process.exit(1) } +console.log(`POSTER-SIZES: PASS — ${S.POSTER_LADDER_ORDER.length} sizes (${S.POSTER_LADDER_ORDER.join(' · ')}), portrait, sheets glue up, photos on ${PHOTO_SIZES.join(' + ')}, ${Object.keys(S.POSTER_LADDER_ALIASES).length} aliases resolve, paper by region.`) diff --git a/internal/check-upg-count.mjs b/internal/check-upg-count.mjs new file mode 100644 index 000000000..3a49c0030 --- /dev/null +++ b/internal/check-upg-count.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// check-upg-count — UPG-100 never writes its list's TOTAL into a sentence. The wall's intro printed "2,106" in all +// eleven languages after the list had grown to 2,107, because the number lived inside the translated text. Now every +// sentence takes it as {n}, from the funnel's last step (plugin-designs/my-upg-100-list/data/upg-total.js), formatted +// for the reader's locale. Headless. Asserts: +// A. the total is known (vision-funnel.json's last step) and upgTotal() reads it; +// B. no upgMap value in any of the 11 locales contains the total (or the stale 2,106) in ANY digit system: Latin +// with any separator, Arabic-Indic, Devanagari. A built-in NEGATIVE CONTROL plants "2,106" and must be caught; +// C. upgMap.wallIntroA carries {n} in all 11 locales; +// D. the consumers pass the live total: poster-wall.vue reads upgMap.wallIntroA with n (not the frozen +// messages.upg100WallIntroA), and upg-100-list.vue passes no number literal to fetchingUupgs; +// E. ONE SENTENCE, ONE DIGIT SYSTEM: the wall intro, rendered through each locale's real catalogue, writes its +// numbers in one digit system (ar: Arabic-Indic, so ٢٬١٠٧ beside ٢٧ and ٩:١٤). A NEGATIVE CONTROL renders ar the +// old way (plain Intl 'ar', which CLDR formats Western) and must be flagged as mixed. +import { readFileSync } from 'node:fs' +import { resolve, dirname, join } 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}`) } +const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh'] + +let upgTotal, formatCount, countInSentence, funnel +try { + ;({ upgTotal, formatCount, countInSentence } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/upg-total.js')).href)) + funnel = JSON.parse(readFileSync(P('data/people-groups/vision-funnel.json'), 'utf8')) +} catch (e) { + console.error(`UPG-COUNT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`) + process.exit(75) +} + +// ── A ── +const total = upgTotal(funnel) +ok(total > 1000, `the list's total reads as ${total} from vision-funnel.json`, `the list's total is ${total}, from the funnel`) + +// ── B: a digit-system-blind scan ── +const NATIVE = { '٠': 0, '١': 1, '٢': 2, '٣': 3, '٤': 4, '٥': 5, '٦': 6, '٧': 7, '٨': 8, '٩': 9, '०': 0, '१': 1, '२': 2, '३': 3, '४': 4, '५': 5, '६': 6, '७': 7, '८': 8, '९': 9 } +const numbersIn = (s) => String(s).replace(/[٠-٩०-९]/g, (d) => NATIVE[d]) + .replace(/(\d)[,.'\u0020\u00A0\u202F\u2009\u066C](?=\d{3}\b)/g, '$1') // drop thousands separators (fr U+202F, ru U+00A0, ar U+066C) + .match(/\d+/g) || [] +const FORBIDDEN = new Set([String(total), '2106']) +const hasTotal = (s) => numbersIn(s).some((n) => FORBIDDEN.has(n)) +ok(hasTotal('We did the same with the 2,106 unreached') && hasTotal('فعلنا المثل مع ٢١٠٦ شعوب') && hasTotal(`mit den ${String(total).replace(/(\d)(\d{3})$/, '$1.$2')} Gruppen`) + && hasTotal(`les ${new Intl.NumberFormat('fr').format(total)} groupes`) && hasTotal(`из ${new Intl.NumberFormat('ru').format(total)} групп`), + 'NEGATIVE CONTROL: the scanner missed a planted total, so it cannot guard anything', 'negative control: a planted 2,106 / ٢١٠٦ / 2.107 / fr 2 107 / ru 2 107 is caught') +const leaks = [] +for (const l of LANGS) { + const cat = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')) + for (const [k, v] of Object.entries(cat)) if (typeof v === 'string' && hasTotal(v)) leaks.push(`${l} upgMap.${k}`) +} +ok(!leaks.length, `the total is written into a sentence: ${leaks.slice(0, 6).join(', ')}`, `no upgMap sentence in the 11 locales carries the total (${total}) or the stale 2,106`) + +// ── C ── +const noN = LANGS.filter((l) => !String(JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')).wallIntroA || '').includes('{n}')) +ok(!noN.length, `upgMap.wallIntroA has no {n} in: ${noN.join(', ')}`, 'the wall intro takes the total as {n} in all 11 locales') + +// ── D ── +const wall = readFileSync(P('plugin-designs/my-upg-100-list/poster-wall.vue'), 'utf8') +const map = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8') +ok(/countInSentence\(t, 'upgMap\.wallIntroA', upgTotal\(/.test(wall) && !/t\('messages\.upg100WallIntroA'\)/.test(wall), + 'poster-wall.vue does not render the intro from upgMap.wallIntroA with the live total', 'the wall intro renders the live total') +ok(!/fetchingUupgs', \{ n: \d/.test(map) && /countInSentence\(t, 'messages\.fetchingUupgs', upgTotal\(/.test(map), + 'upg-100-list.vue passes a number literal to fetchingUupgs', 'the fetching status shows the live total') + +// ── E: one sentence, one digit system ── +const SYSTEMS = [['latn', /[0-9]/], ['arab', /[\u0660-\u0669]/], ['arabext', /[\u06F0-\u06F9]/], ['deva', /[\u0966-\u096F]/], ['beng', /[\u09E6-\u09EF]/]] +const systemsIn = (s) => SYSTEMS.filter(([, re]) => re.test(String(s))).map(([nu]) => nu) +const trFor = (l) => { + const c = JSON.parse(readFileSync(P('library/i18n/locales', l, 'upgMap.json'), 'utf8')) + return (k, p = {}) => String(c[k.replace(/^upgMap\./, '')] ?? k).replace(/\{(\w+)\}/g, (m, x) => (p[x] != null ? String(p[x]) : m)) +} +const arOld = trFor('ar')('upgMap.wallIntroA', { n: formatCount(total, 'ar') }) +ok(systemsIn(arOld).length > 1, `NEGATIVE CONTROL: the old ar render reads as ONE digit system (${systemsIn(arOld).join('+')}), so this check cannot see the defect`, + `negative control: the old ar render (plain Intl 'ar' = "${formatCount(total, 'ar')}") is flagged as mixed (${systemsIn(arOld).join('+')})`) +const mixed = [] +for (const l of LANGS) { + const s = countInSentence(trFor(l), 'upgMap.wallIntroA', total, l) + if (systemsIn(s).length > 1) mixed.push(`${l} ${systemsIn(s).join('+')}`) + if (!numbersIn(s).includes(String(total))) mixed.push(`${l} does not show ${total}`) +} +const arNew = countInSentence(trFor('ar'), 'upgMap.wallIntroA', total, 'ar') +ok(!mixed.length, `the wall intro: ${mixed.join('; ')}`, + `the wall intro shows ${total} in ONE digit system in all 11 locales (ar: "${(arNew.match(/[\u0660-\u0669٬]{3,}/) || ['?'])[0]}")`) + +for (const n of notes) console.log(n) +if (fails.length) { + console.error('UPG-COUNT: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`UPG-COUNT: PASS — the list's total (${total}) lives in one place and every sentence takes it as {n}.`) diff --git a/library/constants/posterSizes.js b/library/constants/posterSizes.js index 62db92686..b35222668 100644 --- a/library/constants/posterSizes.js +++ b/library/constants/posterSizes.js @@ -96,3 +96,65 @@ export function listPosterSizes() { export function listByCategory(category) { return listPosterSizes().filter((s) => s.category === category); } + +// ─── THE POSTER-SIZE LADDER (every print surface, 2026-09-30) ──────────────────────────────────── +// ONE table of the sizes a READER can pick for a poster, shared by every print surface (the poster +// walls, the map's export, Bless, the map-print template) through ONE print picker. It is separate from +// POSTER_SIZES above (the map exporter's US/ISO list), and written as plain literals, so a bundle that +// never imports it carries none of it. +// +// KEYS are WxH in INCHES (this file's existing convention: '24x36', '27x40'); they are dot-free, so +// they are safe in i18n key paths, URLs and stored choices. PORTRAIT ONLY: h ≥ w for every size (66x66 +// is square). `sheets` is what a printer actually feeds, named exactly as panelSheets.js's planPanels() +// parameters, so a consumer passes it straight in: `count` sheets of sheetW × sheetH, stacked in +// `direction`, each overlapping the next by `joinIn` (glued), which gives the finished w × h. +// `photos` means a photo slot beside each name is ALLOWED at this size (there is room); which photos +// print is the renderer's call. `path` is how the size reaches paper: 'print' = the browser's own print +// (any home printer; a phone prints Letter/A4 only), 'pdf' = a print-shop PDF, one page per sheet at +// exact inches, since no phone and few home printers take 3-8 ft paper. +export const POSTER_LADDER = { + paper: { id: 'paper', inches: { w: 8.5, h: 11 }, paper: true, + sheets: { sheetW: 8.5, sheetH: 11, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'print' }, + '18x24': { id: '18x24', inches: { w: 18, h: 24 }, + sheets: { sheetW: 18, sheetH: 24, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' }, + '36x36': { id: '36x36', inches: { w: 36, h: 36 }, + sheets: { sheetW: 36, sheetH: 36, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' }, + '36x48': { id: '36x48', inches: { w: 36, h: 48 }, + sheets: { sheetW: 36, sheetH: 48, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' }, + '36x60': { id: '36x60', inches: { w: 36, h: 60 }, + sheets: { sheetW: 36, sheetH: 60, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' }, + '36x66': { id: '36x66', inches: { w: 36, h: 66 }, + sheets: { sheetW: 36, sheetH: 66, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' }, + '66x66': { id: '66x66', inches: { w: 66, h: 66 }, + sheets: { sheetW: 36, sheetH: 66, count: 2, direction: 'horizontal', joinIn: 6 }, photos: true, path: 'pdf' }, + '60x96': { id: '60x96', inches: { w: 60, h: 96 }, + sheets: { sheetW: 60, sheetH: 36, count: 3, direction: 'vertical', joinIn: 6 }, photos: true, path: 'pdf' }, +} +/** The picker's order, smallest first. Every size up to 36x66 is one sheet; 66x66 and 60x96 are glued. */ +export const POSTER_LADDER_ORDER = ['paper', '18x24', '36x36', '36x48', '36x60', '36x66', '66x66', '60x96'] +/** A size that still resolves (stored choices, today's defaults) but is NOT offered in the picker. */ +export const POSTER_LADDER_LEGACY = { + '24x36': { id: '24x36', inches: { w: 24, h: 36 }, + sheets: { sheetW: 24, sheetH: 36, count: 1, direction: 'vertical', joinIn: 0 }, photos: false, path: 'pdf' }, +} +/** Older names for the same sizes (poster-render's FORMATS, Bless's PRESETS, the map exporter). */ +export const POSTER_LADDER_ALIASES = { + '3x3': '36x36', '3x3ft': '36x36', '3x5': '36x60', '3x5ft': '36x60', + '3x4': '36x48', '3x4ft': '36x48', '5x8': '60x96', '5x8ft': '60x96', + '2x3': '24x36', '2x3ft': '24x36', letter: 'paper', a4: 'paper', +} +/** A key, an alias or a legacy key → its ladder entry; null when unknown. */ +export function resolvePosterSize(key) { + const k = POSTER_LADDER_ALIASES[key] || key + return POSTER_LADDER[k] || POSTER_LADDER_LEGACY[k] || null +} +// Countries that print on US Letter; everywhere else "Standard printer paper" means A4. Keyed by the +// REGION of a locale tag (en-US, es-MX); a tag without a region falls back to its language's usual paper. +const LETTER_REGIONS = new Set(['US', 'CA', 'MX', 'PH', 'CL', 'CO', 'VE', 'CR', 'GT', 'PA', 'DO', 'PR', 'SV', 'NI', 'HN', 'BO']) +/** 'letter' or 'a4' for a locale tag (e.g. navigator.language). */ +export function paperFor(localeTag) { + const m = String(localeTag || '').match(/^([a-z]{2,3})(?:[-_]([A-Za-z]{2}))?/i) + if (!m) return 'letter' + if (m[2]) return LETTER_REGIONS.has(m[2].toUpperCase()) ? 'letter' : 'a4' + return m[1].toLowerCase() === 'en' ? 'letter' : 'a4' +} diff --git a/library/i18n/locales/ar/upgMap.json b/library/i18n/locales/ar/upgMap.json index 64da3d88f..bd9d8e2f5 100644 --- a/library/i18n/locales/ar/upgMap.json +++ b/library/i18n/locales/ar/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "تصدير CSV", "copyNotRegroupable": "لا يمكن إعادة تجميعها معًا: انسخ ولايات أو مقاطعات بلد واحد، أو البلدان، كلًّا على حدة", "displayName": "ملصقات قائمة UPG 100", - "wallHeading": "ملصقات قائمة UPG 100" + "wallHeading": "ملصقات قائمة UPG 100", + "wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ", + "peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة" } diff --git a/library/i18n/locales/de/upgMap.json b/library/i18n/locales/de/upgMap.json index 62d4779be..795efa24d 100644 --- a/library/i18n/locales/de/upgMap.json +++ b/library/i18n/locales/de/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "CSV exportieren", "copyNotRegroupable": "Nicht gemeinsam umgruppierbar: Bundesstaaten oder Provinzen eines Landes oder die Länder getrennt kopieren", "displayName": "Poster der UPG 100 Liste", - "wallHeading": "Poster der UPG 100 Liste" + "wallHeading": "Poster der UPG 100 Liste", + "wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ", + "peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal" } diff --git a/library/i18n/locales/en/upgMap.json b/library/i18n/locales/en/upgMap.json index 0dbc862eb..122cc67ae 100644 --- a/library/i18n/locales/en/upgMap.json +++ b/library/i18n/locales/en/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Export CSV", "copyNotRegroupable": "Can't be regrouped together: copy one country's states or provinces, or the countries, separately", "displayName": "UPG 100 List Posters", - "wallHeading": "UPG 100 List Posters" + "wallHeading": "UPG 100 List Posters", + "wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ", + "peopleStillLoading": "the people groups are still loading, try again in a moment" } diff --git a/library/i18n/locales/es/upgMap.json b/library/i18n/locales/es/upgMap.json index d50196625..396a3580f 100644 --- a/library/i18n/locales/es/upgMap.json +++ b/library/i18n/locales/es/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exportar CSV", "copyNotRegroupable": "No se pueden reagrupar juntos: copia por separado los estados o provincias de un solo país, o los países", "displayName": "Carteles de la lista UPG 100", - "wallHeading": "Carteles de la lista UPG 100" + "wallHeading": "Carteles de la lista UPG 100", + "wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ", + "peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento" } diff --git a/library/i18n/locales/fr/upgMap.json b/library/i18n/locales/fr/upgMap.json index d963e5aad..ffc650bfa 100644 --- a/library/i18n/locales/fr/upgMap.json +++ b/library/i18n/locales/fr/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exporter en CSV", "copyNotRegroupable": "Impossible de les regrouper ensemble : copiez séparément les États ou provinces d’un seul pays, ou les pays", "displayName": "Affiches de la liste UPG 100", - "wallHeading": "Affiches de la liste UPG 100" + "wallHeading": "Affiches de la liste UPG 100", + "wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ", + "peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant" } diff --git a/library/i18n/locales/hi/upgMap.json b/library/i18n/locales/hi/upgMap.json index 9c6fd059a..3931d5993 100644 --- a/library/i18n/locales/hi/upgMap.json +++ b/library/i18n/locales/hi/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "CSV निर्यात करें", "copyNotRegroupable": "इन्हें एक साथ पुनर्समूहित नहीं किया जा सकता: एक देश के राज्य या प्रांत, या देश, अलग-अलग कॉपी करें", "displayName": "UPG 100 सूची पोस्टर", - "wallHeading": "UPG 100 सूची पोस्टर" + "wallHeading": "UPG 100 सूची पोस्टर", + "wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ", + "peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें" } diff --git a/library/i18n/locales/it/upgMap.json b/library/i18n/locales/it/upgMap.json index 331baec8e..3035b23be 100644 --- a/library/i18n/locales/it/upgMap.json +++ b/library/i18n/locales/it/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Esporta CSV", "copyNotRegroupable": "Non si possono raggruppare insieme: copia separatamente gli stati o le province di un solo paese, o i paesi", "displayName": "Poster della lista UPG 100", - "wallHeading": "Poster della lista UPG 100" + "wallHeading": "Poster della lista UPG 100", + "wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ", + "peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento" } diff --git a/library/i18n/locales/pt/upgMap.json b/library/i18n/locales/pt/upgMap.json index 4dfa584a9..e7b76a6fc 100644 --- a/library/i18n/locales/pt/upgMap.json +++ b/library/i18n/locales/pt/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exportar CSV", "copyNotRegroupable": "Não podem ser reagrupados juntos: copie separadamente os estados ou províncias de um só país, ou os países", "displayName": "Cartazes da lista UPG 100", - "wallHeading": "Cartazes da lista UPG 100" + "wallHeading": "Cartazes da lista UPG 100", + "wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ", + "peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante" } diff --git a/library/i18n/locales/ro/upgMap.json b/library/i18n/locales/ro/upgMap.json index 341356892..abc4c3769 100644 --- a/library/i18n/locales/ro/upgMap.json +++ b/library/i18n/locales/ro/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Exportă CSV", "copyNotRegroupable": "Nu pot fi regrupate împreună: copiază separat statele sau provinciile unei singure țări, sau țările", "displayName": "Afișe din lista UPG 100", - "wallHeading": "Afișe din lista UPG 100" + "wallHeading": "Afișe din lista UPG 100", + "wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ", + "peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă" } diff --git a/library/i18n/locales/ru/upgMap.json b/library/i18n/locales/ru/upgMap.json index a3cc4cf02..204aa90bc 100644 --- a/library/i18n/locales/ru/upgMap.json +++ b/library/i18n/locales/ru/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "Экспорт в CSV", "copyNotRegroupable": "Нельзя перегруппировать вместе: копируйте отдельно штаты или провинции одной страны либо страны", "displayName": "Плакаты списка UPG 100", - "wallHeading": "Плакаты списка UPG 100" + "wallHeading": "Плакаты списка UPG 100", + "wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ", + "peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение" } diff --git a/library/i18n/locales/zh/upgMap.json b/library/i18n/locales/zh/upgMap.json index f6794e7c4..2be7bdaae 100644 --- a/library/i18n/locales/zh/upgMap.json +++ b/library/i18n/locales/zh/upgMap.json @@ -17,5 +17,7 @@ "exportCsv": "导出 CSV", "copyNotRegroupable": "无法一起重新分组:请分别复制同一国家的邦或省,或各个国家", "displayName": "UPG 100 名单海报", - "wallHeading": "UPG 100 名单海报" + "wallHeading": "UPG 100 名单海报", + "wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ", + "peopleStillLoading": "族群仍在加载中,请稍后再试" } diff --git a/package.json b/package.json index eca4af7d8..46a1bd674 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison", "check:csv-shape": "node internal/check-csv-export-shape.mjs", "check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test", + "check:poster-sizes:self-test": "node internal/check-poster-sizes.mjs --self-test", + "check:poster-sizes": "node internal/check-poster-sizes.mjs", "check:pin-hint-floor": "node internal/check-pin-hint-floor.mjs", "check:pin-hint-floor:self-test": "node internal/check-pin-hint-floor.mjs --self-test", "check:no-fleet-names": "node internal/check-no-fleet-names.mjs", @@ -226,6 +228,7 @@ "check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs", "check:india-states": "node internal/check-india-states.mjs", "check:batch-selection-export": "node internal/check-batch-selection-export.mjs", + "check:upg-count": "node internal/check-upg-count.mjs", "check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs", "check:evangelical-share": "node internal/check-evangelical-share.mjs", "check:map-labels": "node internal/check-map-labels.mjs", @@ -346,8 +349,8 @@ "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: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:script-flags 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: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 check:no-fleet-names:self-test check:pin-hint-floor:self-test", - "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-internal-role-nouns 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:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan 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:wall-photo-weight check:no-fleet-names check:pin-hint-floor", + "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:script-flags 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: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 check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test", + "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-internal-role-nouns 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:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan 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:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count", "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", @@ -456,6 +459,7 @@ "check:country-sheet-one-path:self-test": "plants each defect check:country-sheet-one-path guards (a direct generateCountrySheet call, makeCountrySheet without countries, the old guard that skipped India, a position given to an absent code) and requires each to FAIL; a comment naming generateCountrySheet must NOT.", "check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.", "check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.", + "check:poster-sizes": "the shared poster-size ladder (library/constants/posterSizes.js) is right: picker order = ladder, dot-free keys, portrait only, the sheets glue up to the finished size, photo slots on exactly 66x66 + 60x96, paper prints and the rest go to a print-shop PDF, every alias resolves, paperFor() picks Letter or A4 by region. Run the `check:poster-sizes:self-test` script for the NEGATIVE test", "check:pin-hint-floor": "the pin-hint pill sits at the map's bottom on a page that scrolls (the fold is not an obstacle there) and is still lifted above the fold on a full-screen page; the legend sheet and the licence credit still lift it; PinHint.vue takes its floor from pinHintFloor.js. Run the `check:pin-hint-floor:self-test` script for the NEGATIVE test", "check:no-fleet-names": "tracked source (not data/, public/ or the translation catalogues) names no build-team member, attributes no request to a user and carries no dated QA-round tag; an allowance is a named LINE, never a whole file. Run the `check:no-fleet-names:self-test` script for the NEGATIVE test", "check:dashboard-filter-a11y": "the dashboard's cross-filter is operable and audible: every filter chip is a