diff --git a/documentation/reference-capability-exemplars.md b/documentation/reference-capability-exemplars.md index 886cc9f6f..d389b951d 100644 --- a/documentation/reference-capability-exemplars.md +++ b/documentation/reference-capability-exemplars.md @@ -13,7 +13,7 @@ > bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type` > declares tiers 3/4/5. -Generated from 28 bundle(s). +Generated from 29 bundle(s). ### One canonical exemplar per tier (generated — quote these) @@ -56,6 +56,7 @@ Generated from 28 bundle(s). | `poster-design` | `poster-sizes` | `single` | | `poster-design` | `poster-styles` | `single` | | `prayer-walk-planner` | `prayer-walk-planner` | `single` | +| `regions-dashboard` | `country-poster-sections` | `single` | | `regions-dashboard` | `region-dashboards` | `single` | | `regions-dashboard` | `regions-list` | `single` | @@ -67,6 +68,7 @@ Generated from 28 bundle(s). | `doxa-simple-map` | `doxa-simple-map` | `parameterized` | | `doxa-vision-card` | `doxa-vision-card` | `parameterized` | | `people-groups-grid` | `people-groups-grid` | `parameterized` | +| `poster-sections` | `poster-sections` | `parameterized` | ## Capability 3 — multi-view-element (monolithic) diff --git a/internal/check-host-floor.mjs b/internal/check-host-floor.mjs new file mode 100644 index 000000000..194dad2c7 --- /dev/null +++ b/internal/check-host-floor.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * check-host-floor.mjs — the profile floor is decided against the host WITHOUT disturbing live content. + * + * library/elements/hostFloor.js (used by ProfileLoader.vue) probes the host at mount, and after that re-probes ONLY + * when the host may have changed kind, saving and restoring every scrolled descendant. The regression this pins: + * the probe ran on EVERY host resize, collapsing the loader under live content; a scrolling child's scrollTop was + * clamped to 0 and the embedded UPG-100 app flipped from Map back to its first view whenever a phone's URL bar + * resized the host. + * + * A stub DOM models the layout that matters: the host's height from its mode and the loader's min-height, the + * floor (100svh) as a number the test moves like a window resize, and a scrolled CHILD whose scrollTop clamps to 0 + * whenever the loader collapses. + * A. after mount, a content-sized host following a window resize, and a sized host that keeps its size, never + * write the loader's min-height nor move the child's scrollTop; + * B. a sized host (523 px) is filled: the floor goes off at mount; + * C. a host styled SHORTER than the floor after mount flips to sized ONCE, then stays; + * D. a host capped by max-height (600 < floor) is found at mount: 0 flips and 0 re-probes over 20 resizes; + * E. a max-height cap that arrives AFTER mount: at most 1 flip and 1 re-probe over 20 resizes, ends content-sized; + * F. a sized host restyled to height:auto with NO content gets the floor back; + * H. a sized host restyled to height:auto WITH in-flow content (420) gets the floor back: at most 1 flip over 20 + * resizes, and the scrolled child keeps its scrollTop through the re-probe; + * G. an inline host is left alone after mount. + * `--self-test`: the old per-resize probe (A), a missing capped latch (D/E re-probe in a loop), a missing re-check + * of a sized host (H stays sized) and a missing scroll restore (H loses the child's scrollTop) must each FAIL. + */ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SRC = join(ROOT, 'library/elements/hostFloor.js') +const asModule = (src) => import('data:text/javascript;base64,' + Buffer.from(src).toString('base64')) + +// mode: 'content' (height = max(content, min-height)), 'sized' (a fixed height), 'capped' (content-sized up to a cap) +function world({ mode = 'content', fixed = 523, cap = 600, content = 0, floor = 844, display = 'block' } = {}) { + const w = { mode, fixed, cap, content, floor, display, writes: [], child: { scrollTop: 787 } } + const attrs = new Set() + const minPx = () => { + const v = loader.style._min + if (v === '' || v === undefined) return w.floor + return parseFloat(v) + } + w.hostH = () => { + if (w.mode === 'sized') return w.fixed + const natural = Math.max(w.content, minPx()) + return w.mode === 'capped' ? Math.min(natural, w.cap) : natural + } + const loader = { + style: { + _min: '', + get minHeight() { return this._min }, + set minHeight(v) { + this._min = v; w.writes.push(v) + if (v === '0px' && w.mode !== 'sized') w.child.scrollTop = 0 // the loader collapsed: the child clamps + }, + }, + hasAttribute: (n) => attrs.has(n), setAttribute: (n) => attrs.add(n), removeAttribute: (n) => attrs.delete(n), + } + const host = { getBoundingClientRect: () => ({ height: w.hostH() }) } + w.loader = loader; w.host = host + w.style = (n) => (n === host ? { display: w.display, minHeight: '0px' } : { display: 'block', minHeight: (loader.style._min === '' ? w.floor : parseFloat(loader.style._min)) + 'px' }) + w.sized = () => attrs.has('data-host-sized') + w.probes = () => w.writes.filter((v) => v === '100000px').length + return w +} + +async function audit(src) { + const { createHostFloor } = await asModule(src) + const fails = [] + let t = 0 + const make = (opts) => { const w = world(opts); w.f = createHostFloor(w.loader, w.host, { style: w.style, scrolled: () => (w.child.scrollTop ? [w.child] : []) }); return w } + + // A: content-sized host, a window resize moves the floor and the host with it + { const w = make({ mode: 'content', content: 0 }); w.f.probeOnce(); w.child.scrollTop = 787; w.writes.length = 0 + for (const fl of [830, 760, 700, 844]) { w.floor = fl; w.f.follow() } + if (w.child.scrollTop !== 787) fails.push(`A: a host resize after mount clamped a scrolled child (scrollTop 787 → ${w.child.scrollTop})`) + if (w.writes.length) fails.push(`A: a host resize after mount wrote the loader's min-height (${w.writes.join(', ')})`) + if (w.sized()) fails.push('A: a content-sized host was flipped to sized by a window resize') } + { const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.writes.length = 0; w.child.scrollTop = 300 + for (let i = 0; i < 4; i++) w.f.follow() + if (w.writes.length || w.child.scrollTop !== 300) fails.push('A: a sized host\'s resize callbacks touched the loader') } + // B + { const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce() + if (!w.sized() || w.loader.style.minHeight !== '0px') fails.push('B: a sized host (523) is not filled (the floor stayed on)') } + // C + { const w = make({ mode: 'content' }); w.f.probeOnce(); let flips = 0, was = w.sized() + w.mode = 'sized'; w.fixed = 600 + for (let i = 0; i < 10; i++) { t += 100; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } } + if (!w.sized() || flips !== 1) fails.push(`C: a host sized after mount should flip to sized exactly once (flips ${flips}, sized ${w.sized()})`) } + // D + { const w = make({ mode: 'capped', cap: 600, content: 0 }); w.f.probeOnce(); let flips = 0, was = w.sized() + for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } } + const probes = w.probes() - 1 + if (flips !== 0 || w.sized() || probes > 0) fails.push(`D: a max-height host flipped ${flips} time(s) / was re-probed ${probes} time(s) (it must be found at mount and left alone)`) } + // E + { const w = make({ mode: 'content', content: 0 }); w.f.probeOnce(); let flips = 0, was = w.sized(); const p0 = w.probes() + w.mode = 'capped'; w.cap = 600 + for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } } + const probes = w.probes() - p0 + if (flips > 1 || w.sized() || probes > 1) fails.push(`E: a max-height cap that arrived after mount LOOPS or stays sized (flips ${flips}, re-probes ${probes}, sized ${w.sized()})`) } + // H: sized → height:auto with in-flow content + { const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.child.scrollTop = 300; let flips = 0, was = w.sized() + w.mode = 'content'; w.content = 420 + for (let i = 0; i < 20; i++) { t += 50; w.f.follow(); if (w.sized() !== was) { flips++; was = w.sized() } } + if (w.sized()) fails.push('H: a sized host restyled to height:auto with content (420) stayed sized (the floor did not come back)') + if (flips > 1) fails.push(`H: the sized → auto host flipped ${flips} times`) + if (w.child.scrollTop !== 300) fails.push(`H: the re-probe moved a scrolled child (scrollTop 300 → ${w.child.scrollTop})`) } + // F + { const w = make({ mode: 'sized', fixed: 523 }); w.f.probeOnce(); w.mode = 'content'; w.content = 0; w.f.follow() + // with the floor off and no content the host is 0 high; follow() must bring the floor back + if (w.sized()) fails.push('F: a sized host that collapsed to 0 did not get the floor back') } + // G + { const w = make({ mode: 'content', display: 'inline' }); w.f.probeOnce(); w.writes.length = 0; w.floor = 700; w.f.follow() + if (w.writes.length) fails.push('G: an inline host was acted on after mount') } + return fails +} + +const src = readFileSync(SRC, 'utf8') +if (process.argv.includes('--self-test')) { + const poisons = [ + ['the old per-resize probe (follow() re-measures, no restore)', src.replace(/function follow\(\) \{/, 'function follow() { decide(); return;'), /^A: /], + ['no latch for a capped host', src.replace('if (capped) { if (h + 1 >= f) capped = false }\n else if', 'if'), /^[DE]: /], + ['no re-check of a sized host', src.replace('if (lastH === null || Math.abs(h - lastH) >= 1) reprobe()', '/* no re-check */'), /^H: .*stayed sized/], + ['no scroll restore in the re-probe', src.replace("for (const [e, top, left] of kept) { if (e.scrollTop !== top) e.scrollTop = top; if (e.scrollLeft !== left) e.scrollLeft = left }", ''), /^H: the re-probe moved/], + ] + let missed = 0 + for (const [name, poisoned, rx] of poisons) { + if (poisoned === src) { console.log(`NOT MEASURED: the poison anchor for "${name}" moved`); process.exitCode = 75; continue } + const f = await audit(poisoned); const hit = f.find((x) => rx.test(x)) + console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}`) + if (!hit) missed++ + } + if (missed) process.exitCode = 1 +} else { + const f = await audit(src) + if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:host-floor (${f.length})`); process.exitCode = 1 } + else console.log('PASS check:host-floor — probed at mount; after it, re-probed only on a change of kind and without moving a scrolled child; a window resize writes nothing; a sized host fills; late sizing flips once; a max-height cap never loops; a sized host restyled to auto (with or without content) gets the floor back; inline hosts are left alone') +} diff --git a/internal/check-poster-type-registry.mjs b/internal/check-poster-type-registry.mjs new file mode 100644 index 000000000..fde8823eb --- /dev/null +++ b/internal/check-poster-type-registry.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * check-poster-type-registry.mjs — the poster type registry is ONE per page, whichever bundle's copy asks. + * + * Every bundle inlines its own copy of library/components/poster/posterTypes.js, and is + * defined by whichever bundle loads first. A module-level registry made that element see only its own bundle's + * types: a type another bundle registered rendered "none". So the registry is page-global (globalThis). + * + * ASSERTS, with TWO independent copies of the module loaded side by side (as two bundles are on one page): + * A. a type registered through copy B is found through copy A (the copy that defined the element); + * B. the built-in 'country' is listed by both copies, once; + * C. a name registered twice keeps the FIRST registration (the second returns false); + * D. every registration is announced on window ('dxm-poster-type-registered', { name }), so a section that asked + * before its type arrived can render when it does; + * E. PosterSectionView.vue re-resolves its type on that event. + * `--self-test`: the per-bundle (module-level Map) registry must FAIL arm A. + */ +import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const SRC = join(ROOT, 'library/components/poster/posterTypes.js') +const VIEW = join(ROOT, 'library/components/poster/PosterSectionView.vue') + +// A stand-in window that records the announcements. +const events = [] +globalThis.window = { dispatchEvent: (e) => { events.push(e); return true } } +globalThis.CustomEvent = class { constructor(type, init) { this.type = type; this.detail = init && init.detail } } + +async function copies(source) { + // Two copies = two module instances (as two IIFE bundles each inline one). Written to a scratch dir with a stub + // for the lazily imported country adapter, so nothing else loads. + const dir = mkdtempSync(join(tmpdir(), 'dxm-pt-')) + mkdirSync(join(dir, 'types')) + writeFileSync(join(dir, 'types', 'countryPosterType.js'), 'export default { items: () => [], title: () => "", actions: () => ({}) }\n') + const code = source.replace(/import\.meta\.env\s*&&\s*import\.meta\.env\.DEV/g, 'false') + writeFileSync(join(dir, 'a.mjs'), code); writeFileSync(join(dir, 'b.mjs'), code) + const a = await import(pathToFileURL(join(dir, 'a.mjs')).href) + const b = await import(pathToFileURL(join(dir, 'b.mjs')).href) + return { a, b, done: () => rmSync(dir, { recursive: true, force: true }) } +} + +async function audit(source, viewSrc) { + const fails = [] + for (const k of Object.getOwnPropertyNames(globalThis)) if (k === '__DXM_POSTER_TYPES__') delete globalThis[k] + events.length = 0 + const { a, b, done } = await copies(source) + try { + const fixture = { items: () => [{ id: 'x' }], title: () => 'X', actions: () => ({}) } + b.registerPosterType('gate-b-type', fixture) + if (!(await a.posterType('gate-b-type'))) fails.push('A: a type registered through one bundle\'s copy is NOT found through another\'s (a per-bundle registry)') + const na = a.posterTypeNames(), nb = b.posterTypeNames() + if (na.filter((n) => n === 'country').length !== 1 || nb.filter((n) => n === 'country').length !== 1) fails.push(`B: 'country' is not listed exactly once by each copy (${na.join(',')} | ${nb.join(',')})`) + const second = a.registerPosterType('gate-b-type', { items: () => [], title: () => 'Y', actions: () => ({}) }) + const kept = await b.posterType('gate-b-type') + if (second !== false || !kept || kept.title() !== 'X') fails.push('C: a name registered twice did not keep the FIRST registration') + if (!events.some((e) => e.type === 'dxm-poster-type-registered' && e.detail && e.detail.name === 'gate-b-type')) fails.push('D: a registration is not announced on window (dxm-poster-type-registered)') + } finally { done() } + if (!/dxm-poster-type-registered|TYPE_REGISTERED_EVENT/.test(viewSrc)) fails.push('E: PosterSectionView.vue does not re-resolve its type when one is registered later') + return fails +} + +if (process.argv.includes('--self-test')) { + // The per-bundle registry this gate exists to stop: a module-level Map. + const perBundle = `const LOADERS = new Map([['country', () => import('./types/countryPosterType.js')]]) +const CACHE = new Map() +export function registerPosterType(name, a) { LOADERS.set(name, typeof a === 'function' ? a : () => Promise.resolve({ default: a })); CACHE.delete(name); return true } +export function posterType(name) { if (!LOADERS.has(name)) return Promise.resolve(null); if (!CACHE.has(name)) CACHE.set(name, LOADERS.get(name)().then((m) => m.default || m)); return CACHE.get(name) } +export const posterTypeNames = () => [...LOADERS.keys()] +` + const f = await audit(perBundle, readFileSync(VIEW, 'utf8')) + const ok = f.some((x) => x.startsWith('A:')) + console.log(ok ? `✓ caught: a per-bundle registry -> ${f.find((x) => x.startsWith('A:'))}` : '✗ MISSED: the per-bundle registry passed arm A') + process.exitCode = ok ? 0 : 1 +} else { + const f = await audit(readFileSync(SRC, 'utf8'), readFileSync(VIEW, 'utf8')) + if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:poster-type-registry (${f.length})`); process.exitCode = 1 } + else console.log('PASS check:poster-type-registry — one registry per page: a type registered through one bundle\'s copy is found through another\'s; the first registration wins; registrations are announced and the section re-resolves') +} diff --git a/internal/check-preview-flag-dev-only.mjs b/internal/check-preview-flag-dev-only.mjs index 1363c9f5d..5e5b4dc84 100644 --- a/internal/check-preview-flag-dev-only.mjs +++ b/internal/check-preview-flag-dev-only.mjs @@ -8,18 +8,19 @@ * landing and to DX Life's /dx-maps, so one assignment there would print India publicly. * * ASSERTS: - * A. no file under public/ ASSIGNS the flag (any spelling a minifier writes: = true, =!0, =1, …); reading it - * (=== true) is fine; - * B. every SOURCE line that assigns it (library/, plugin-designs/, outside scripts/ and tests) is behind - * import.meta.env.DEV on the same line. - * `--self-test` plants each defect in memory and exits 0 only if both are caught. + * A. NO file under public/ MENTIONS the flag at all. An assignment there would turn the preview on, and a READ + * there means the withheld path itself shipped, behind a global any embedding page or console can set (that + * IS shipping it). Every read is compiled away by its DEV guard, so the built tree never names the flag; + * B. every SOURCE line that mentions it, read OR write (library/, plugin-designs/, outside scripts/ and tests; + * comments aside), carries import.meta.env.DEV on the same line. + * `--self-test` plants each defect in memory and exits 0 only if all are caught. */ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, dirname, relative } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') -const ASSIGN = /DXM_PREVIEW_UNRELEASED\s*=(?!=)/ +const MENTION = /DXM_PREVIEW_UNRELEASED/ const TEXT = /\.(m?js|html|json|css|txt)$/ function* walk(dir) { @@ -37,9 +38,9 @@ function* walk(dir) { function auditBuilt(files) { const fails = [] for (const [rel, text] of files) { - if (!ASSIGN.test(text)) continue - const line = text.split('\n').findIndex((l) => ASSIGN.test(l)) + 1 - fails.push(`A ${rel}:${line} ASSIGNS DXM_PREVIEW_UNRELEASED in the built tree (it would show withheld content publicly)`) + if (!MENTION.test(text)) continue + const line = text.split('\n').findIndex((l) => MENTION.test(l)) + 1 + fails.push(`A ${rel}:${line} names DXM_PREVIEW_UNRELEASED in the built tree (a setter would show withheld content; a read means the withheld path shipped)`) } return fails } @@ -47,7 +48,7 @@ function auditSource(files) { const fails = [] for (const [rel, text] of files) { text.split('\n').forEach((l, i) => { - if (ASSIGN.test(l) && !/import\.meta\.env\.DEV/.test(l) && !/^\s*(\/\/|\*)/.test(l)) fails.push(`B ${rel}:${i + 1} assigns DXM_PREVIEW_UNRELEASED outside an import.meta.env.DEV guard`) + if (MENTION.test(l) && !/import\.meta\.env\.DEV/.test(l) && !/^\s*(\/\/|\*|\/\*)/.test(l)) fails.push(`B ${rel}:${i + 1} reads or writes DXM_PREVIEW_UNRELEASED outside an import.meta.env.DEV guard`) }) } return fails @@ -59,14 +60,16 @@ const source = () => [...walk(join(ROOT, 'library')), ...walk(join(ROOT, 'plugin .map((p) => [relative(ROOT, p), readFileSync(p, 'utf8')]) if (process.argv.includes('--self-test')) { - const a = auditBuilt([['public/dx-maps/x/x.js', 'var a=1;globalThis.DXM_PREVIEW_UNRELEASED=!0;'], ['public/dx-maps/x/ok.js', 'if(globalThis.DXM_PREVIEW_UNRELEASED===true)f()']]) - const b = auditSource([['library/x.js', 'globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/ok.js', 'if (import.meta.env.DEV) globalThis.DXM_PREVIEW_UNRELEASED = true']]) - const okA = a.length === 1 && /x\/x\.js/.test(a[0]), okB = b.length === 1 && /library\/x\.js/.test(b[0]) - console.log(okA ? '✓ caught: a built bundle that assigns the flag (minified =!0); a read (===) is not flagged' : '✗ MISSED: the built-tree assignment') - console.log(okB ? '✓ caught: a source assignment outside import.meta.env.DEV; the guarded one passes' : '✗ MISSED: the unguarded source assignment') + const a = auditBuilt([['public/dx-maps/x/set.js', 'var a=1;globalThis.DXM_PREVIEW_UNRELEASED=!0;'], ['public/dx-maps/x/read.js', 'if(globalThis.DXM_PREVIEW_UNRELEASED===!0)showIndia()'], ['public/dx-maps/x/ok.js', 'showOthers()']]) + const b = auditSource([['library/set.js', 'globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/read.js', "export const previewUnreleased = () => typeof globalThis !== 'undefined' && globalThis.DXM_PREVIEW_UNRELEASED === true"], + ['library/ok.js', 'if (import.meta.env.DEV) globalThis.DXM_PREVIEW_UNRELEASED = true'], ['library/okread.js', 'export const p = () => import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true'], ['library/c.js', '// DXM_PREVIEW_UNRELEASED is named in a comment']]) + const okA = a.length === 2 && a.some((x) => /set\.js/.test(x)) && a.some((x) => /read\.js/.test(x)) + const okB = b.length === 2 && b.some((x) => /library\/set\.js/.test(x)) && b.some((x) => /library\/read\.js/.test(x)) + console.log(okA ? '✓ caught: a built bundle that SETS the flag (=!0) and one that READS it (the withheld path shipped); a clean one passes' : '✗ MISSED: a built-tree setter or reader') + console.log(okB ? '✓ caught: an unguarded source SETTER and an unguarded source READ (the plain previewUnreleased()); the DEV-guarded ones and comments pass' : '✗ MISSED: an unguarded source setter or read') process.exitCode = okA && okB ? 0 : 1 } else { const fails = [...auditBuilt(built()), ...auditSource(source())] if (fails.length) { for (const f of fails) console.log('✗ ' + f); console.log(`FAIL check:preview-flag-dev-only (${fails.length})`); process.exitCode = 1 } - else console.log('PASS check:preview-flag-dev-only — no built file assigns DXM_PREVIEW_UNRELEASED; every source assignment is behind import.meta.env.DEV') + else console.log('PASS check:preview-flag-dev-only — no built file names DXM_PREVIEW_UNRELEASED; every source read and write is behind import.meta.env.DEV') } diff --git a/internal/profile-meta.mjs b/internal/profile-meta.mjs index d7e0fbe17..3da0f7b94 100644 --- a/internal/profile-meta.mjs +++ b/internal/profile-meta.mjs @@ -9,6 +9,9 @@ * widget { corner } for a corner widget the Plugin Studio mounts once, fixed * visibility "team" | "public" default team — see VISIBILITY below * mobile "desktop-only" | "larger-screens" absent = phone-ready — see MOBILE below + * posterDesign { family, name, size, mobileFriendly, labelKey?, instance? } or an array of them: the poster + * design(s) this profile shows, listed by Poster Design's Styles. Carried as written, ONLY when present + * (every other manifest keeps its bytes); check:poster-design-catalogue validates it. */ import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -69,8 +72,9 @@ export function readProfileMeta(dir, profileName) { ? null : String(meta.visibility), mobile: MOBILE.includes(meta?.mobile) ? meta.mobile : null, + posterDesign: meta?.posterDesign && typeof meta.posterDesign === 'object' ? meta.posterDesign : null, } - } catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null, visibility: 'team', visibilityInvalid: null, mobile: null } } + } catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null, visibility: 'team', visibilityInvalid: null, mobile: null, posterDesign: null } } } /** The optional manifest keys for one profile (stage always; heavy / widget only when set). */ @@ -78,7 +82,7 @@ export function profileMetaFields(dir, profileName) { const m = readProfileMeta(dir, profileName) // visibility is emitted ONLY when it is "public": absent means team, so every manifest in the // tree today keeps the bytes it has and nothing becomes visible by upgrading the generator. - return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}) } + return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}) } } /** A bundle is production if ANY of its profiles says so. */ diff --git a/library/ProfileLoader.vue b/library/ProfileLoader.vue index e87d8b1f4..78fc76a52 100644 --- a/library/ProfileLoader.vue +++ b/library/ProfileLoader.vue @@ -28,6 +28,7 @@ const QaCorner = __DOXA_QA__ import PinHint from './components/hints/PinHint.vue' import LargerScreenNotice from './components/LargerScreenNotice.vue' import { useScreenGate } from './composables/useScreenGate.js' +import { createHostFloor } from './elements/hostFloor.js' import { useUIStore } from './stores/uiStore.js' import { useMapStore } from './stores/mapStore.js' import { useDataStore } from './stores/dataStore.js' @@ -345,36 +346,20 @@ provide('uiStore', uiStore) provide('mapStore', mapStore) provide('dataStore', dataStore) -// ★ THE FLOOR NEVER EXCEEDS A SIZED HOST (measured 2026-10-02 on a phone, DX Life's Home/Pray/Research). The -// viewport floor below (min-height: 100svh, see the style block) exists for a host whose height comes from its -// CONTENT (the scroll-page leaf, BUG-UPG100). A host that is already SIZED (DX Life's slot: a definite height, -// overflow hidden; a tab pane) must be filled exactly: there the floor made the map taller than its host, the -// host clipped its bottom, and the legend footer was cut off. It also tied the map's height to the window's, -// which is what moved the map as a phone's URL bar came and went. -// Which kind of host this is cannot be read from CSS, so it is MEASURED: with the floor at 0 and then at a huge -// value, a sized host keeps its height and a content-sized one grows. Sized → the floor is switched off -// (height:100% fills the host); content-sized → the floor stays. Both probes are undone before the frame paints. -// Re-measured whenever the host's size changes (a ResizeObserver on the HOST, never a window resize). +// ★ THE FLOOR NEVER EXCEEDS A SIZED HOST, and it is decided WITHOUT disturbing live content: measured once at mount, +// then only followed (library/elements/hostFloor.js, which says why, and check:host-floor, which pins it). The +// viewport floor below (min-height: 100svh) is for a host whose height comes from its CONTENT; a host SIZED from +// outside (DX Life's slot) is filled exactly, so its bottom (a map's legend) is never clipped. const loaderEl = ref(null) let hostRO = null -function fitFloorToHost() { - const el = loaderEl.value - const host = el && el.getRootNode && el.getRootNode().host - if (!el || !host) return - const keep = el.style.minHeight - el.style.minHeight = '0px' - const h0 = host.getBoundingClientRect().height - el.style.minHeight = '100000px' - const h1 = host.getBoundingClientRect().height - const sized = h0 > 0 && Math.abs(h1 - h0) < 1 - el.style.minHeight = sized ? '0px' : (keep === '0px' ? '' : keep) - el.toggleAttribute('data-host-sized', sized) -} onMounted(() => { - fitFloorToHost() - const host = loaderEl.value && loaderEl.value.getRootNode && loaderEl.value.getRootNode().host - if (host && typeof ResizeObserver !== 'undefined') { - hostRO = new ResizeObserver(() => fitFloorToHost()) + const el = loaderEl.value + const host = el && el.getRootNode ? el.getRootNode().host : null + if (!el || !host) return + const floor = createHostFloor(el, host) + floor.probeOnce() + if (typeof ResizeObserver !== 'undefined') { + hostRO = new ResizeObserver(() => floor.follow()) hostRO.observe(host) } }) diff --git a/library/components/PeopleGroupDetail.vue b/library/components/PeopleGroupDetail.vue index 2279e605a..c16d26b0a 100644 --- a/library/components/PeopleGroupDetail.vue +++ b/library/components/PeopleGroupDetail.vue @@ -119,7 +119,7 @@ import { getApiBaseUrl } from '../utils/apiBaseUrl.js'; const { t, locale } = useI18n(); useShadowStyles(` -.people-group-detail{display:flex;flex-direction:column;height:100%;overflow-y:auto;} +.people-group-detail{display:flex;flex-direction:column;height:100%;overflow-y:auto;overscroll-behavior:contain;} .people-group-detail.dark-mode{background:#1a1a2e;color:#eee;} .detail-header{display:flex;justify-content:space-between;align-items:flex-start;padding:16px;border-bottom:1px solid #e0e0e0;background:white;position:sticky;top:0;z-index:10;} .dark-mode .detail-header{background:#16213e;border-bottom-color:#2d2d4e;} @@ -133,7 +133,7 @@ useShadowStyles(` .dark-mode .close-button{color:#aaa;} .close-button:hover{color:#333;} .dark-mode .close-button:hover{color:#fff;} -.detail-content{padding:6px 12px 12px;flex:1;overflow-y:auto;} +.detail-content{padding:6px 12px 12px;flex:1;overflow-y:auto;overscroll-behavior:contain;} .dark-mode .detail-content{background:#1a1a2e;} .quick-stats{display:flex;flex-direction:column;gap:4px;margin-bottom:8px;} .stat-item{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;} @@ -467,6 +467,8 @@ function handleImageError(event) { flex-direction: column; height: 100%; overflow-y: auto; + /* a scroll that reaches this panel's end never chains to the page (the mobile sheet's detail view) */ + overscroll-behavior: contain; } /* Header Section */ @@ -540,6 +542,7 @@ function handleImageError(event) { padding: 6px 12px 12px; flex: 1; overflow-y: auto; + overscroll-behavior: contain; } /* Quick Stats */ diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 71ae01970..bc353ee44 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -23,7 +23,7 @@ useShadowStyles(` mobile chrome (pull tab, collapse/expand caret, touch drag). ── */ /* bottom = the sheet's height (it shows only while the sheet is open, whose height fits its content) + a 16px gap. */ -.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(var(--legend-img-seat,var(--legend-sheet-h,30%)) + 16px);z-index:999;pointer-events:auto;cursor:pointer;} +.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(var(--legend-img-seat,var(--legend-sheet-h,30%)) + 16px);z-index:999;pointer-events:auto;cursor:pointer;touch-action:none;} /* The image SITS on --legend-img-seat, a copy of the sheet's height that moves only while the image shows (the sheet open, no finger on it; script: IMG_SEAT_VAR). Dragged up, and through the snap to full open, the sheet (z 1000) slides over the still image (z 999), so it goes INTO the legend instead of riding its top edge; dragged @@ -118,7 +118,7 @@ useShadowStyles(` max-height reads --legend-sheet-top-reserve, set on this element from the TOP_RESERVE_PX constant in the script — the SAME number the touch-drag clamp uses, so CSS and JS can never disagree about how tall the sheet may get. */ -.legend-mobile-sheet{position:absolute;bottom:0;inset-inline-start:0;inset-inline-end:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - var(--legend-sheet-top-reserve,80px));} +.legend-mobile-sheet{overscroll-behavior:contain;position:absolute;bottom:0;inset-inline-start:0;inset-inline-end:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - var(--legend-sheet-top-reserve,80px));} /* While a finger is resizing the sheet the height must be applied INSTANTLY — with the 0.3s transition left on, the measured height lagged the finger by 14-30px (mobile-sheet-probe baseline 2026-09-16) and the release logic read a @@ -964,12 +964,20 @@ onBeforeUnmount(() => { if (_creditTimer) { clearTimeout(_creditTimer); _creditT beside the legend, so it shows in no resting state (collapsed, or open with nothing selected). A tapped dot's detail view keeps its note inside the sheet, where it explains shared dots; the tree's own copy stays off (:pin-note="false" below). Desktop is unchanged. --> - +
{ if (_creditTimer) { clearTimeout(_creditTimer); _creditT v-if="legendState !== 'collapsed'" class="sheet-grab-lip" aria-hidden="true" - @touchstart="onSheetTouchStart" - @touchmove="onSheetTouchMove" - @touchend="onSheetTouchEnd" - @touchcancel="onSheetTouchEnd" + @touchstart.capture="onSheetTouchStart" + @touchmove.capture="onSheetTouchMove" + @touchend.capture="onSheetTouchEnd" + @touchcancel.capture="onSheetTouchEnd" >
-
{ if (_creditTimer) { clearTimeout(_creditTimer); _creditT class="legend-mobile-sheet" :class="[legendState, { 'sheet-dark': isDark, dragging: isDragging, fit: fitOpen }]" :style="{ height: fitOpen ? 'auto' : legendHeight, '--legend-sheet-top-reserve': TOP_RESERVE_PX + 'px' }" - @touchstart="onSheetTouchStart" - @touchmove="onSheetTouchMove" - @touchend="onSheetTouchEnd" - @touchcancel="onSheetTouchEnd" + @touchstart.capture="onSheetTouchStart" + @touchmove.capture="onSheetTouchMove" + @touchend.capture="onSheetTouchEnd" + @touchcancel.capture="onSheetTouchEnd" >
diff --git a/library/components/poster/PosterSectionView.vue b/library/components/poster/PosterSectionView.vue index 605dc54e6..19c8548f7 100644 --- a/library/components/poster/PosterSectionView.vue +++ b/library/components/poster/PosterSectionView.vue @@ -1,13 +1,13 @@ @@ -44,14 +71,20 @@ const noneLine = computed(() => t('poster.sectionNone', { scope: String(cfg.valu + * is defined by whichever bundle loads first. With a module-level Map, that element read only ITS bundle's types, + * so type="upg100-list" rendered "none" whenever the country-posters bundle had defined the element. So the + * registry lives on the page (globalThis[REGISTRY_KEY], created once): every copy registers into it and reads from + * it. A name registered twice keeps the FIRST (a dev warning says so; the built-in 'country' is registered by every + * copy and is silent). Every registration is announced (TYPE_REGISTERED_EVENT on window), so a section that asked + * before its type arrived renders when it does. */ -const LOADERS = new Map([ - ['country', () => import('./types/countryPosterType.js')], -]) -const CACHE = new Map() +export const REGISTRY_KEY = '__DXM_POSTER_TYPES__' +export const TYPE_REGISTERED_EVENT = 'dxm-poster-type-registered' +const VERSION = 1 -/** Register (or replace) a type: an adapter object, or a function that loads one (a dynamic import). */ +function registry() { + const g = typeof globalThis !== 'undefined' ? globalThis : {} + let r = g[REGISTRY_KEY] + if (!r || !(r.loaders instanceof Map) || !(r.cache instanceof Map)) { + r = { version: VERSION, loaders: new Map(), cache: new Map() } + try { Object.defineProperty(g, REGISTRY_KEY, { value: r, configurable: true, writable: false }) } catch (_) { g[REGISTRY_KEY] = r } + } + return r +} +const toLoader = (a) => (typeof a === 'function' ? a : () => Promise.resolve({ default: a })) +function announce(name) { + try { if (typeof window !== 'undefined' && typeof CustomEvent === 'function') window.dispatchEvent(new CustomEvent(TYPE_REGISTERED_EVENT, { detail: { name } })) } catch (_) { /* no window */ } +} + +/** Register a type: an adapter object, or a function that loads one (a dynamic import). The first registration wins. */ export function registerPosterType(name, adapterOrLoader) { - LOADERS.set(name, typeof adapterOrLoader === 'function' ? adapterOrLoader : () => Promise.resolve({ default: adapterOrLoader })) - CACHE.delete(name) + const r = registry() + if (r.loaders.has(name)) { + if (import.meta.env && import.meta.env.DEV && typeof console !== 'undefined') console.warn(`[dx-maps] poster type "${name}" is already registered on this page; the first registration is kept.`) + return false + } + r.loaders.set(name, toLoader(adapterOrLoader)) + announce(name) + return true } -/** The adapter for a type, or null for a type nobody registered. */ +/** The adapter for a type, or null for a type nobody on this page registered (yet). */ export function posterType(name) { - if (!LOADERS.has(name)) return Promise.resolve(null) - if (!CACHE.has(name)) CACHE.set(name, LOADERS.get(name)().then((m) => m.default || m)) - return CACHE.get(name) + const r = registry() + if (!r.loaders.has(name)) return Promise.resolve(null) + if (!r.cache.has(name)) r.cache.set(name, r.loaders.get(name)().then((m) => m.default || m)) + return r.cache.get(name) +} +export const posterTypeNames = () => [...registry().loaders.keys()] + +// The built-in type, from every copy: silent, first one kept. +if (!registry().loaders.has('country')) { + registry().loaders.set('country', () => import('./types/countryPosterType.js')) + announce('country') } -export const posterTypeNames = () => [...LOADERS.keys()] diff --git a/library/components/poster/types/countryPosterType.js b/library/components/poster/types/countryPosterType.js index 5c613bff8..bbe72d2b5 100644 --- a/library/components/poster/types/countryPosterType.js +++ b/library/components/poster/types/countryPosterType.js @@ -8,7 +8,7 @@ * a country with no region is 'other'. */ import data from '@map/data/country-posters.json' -import { makeCountrySheet, sheetWithheld } from '@map/packet/countrySheet.js' +import { makeCountrySheet, largeSheet, posterWithheld, rowsWithheld } from '@map/packet/countrySheet.js' import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js' import { keyedError } from '@map/i18n/keyedError.js' import { countryName } from '@map/utils/countryName.js' @@ -38,11 +38,11 @@ export default { const sheet = makeCountrySheet({ records: recordsFor, assetsBase, countries: data.countries }) const byId = new Map(data.countries.map((c) => [c.id, c])) return { - // a withheld whole-country poster withholds its list, packet and cards too (countrySheet.js WITHHELD) - records: (c) => (sheetWithheld(c) ? Promise.reject(keyedError('messages.countryPosterTooMany', TOO_MANY)) : recordsFor({ type: 'country', values: [c.iso3] })), + // a whole country's withheld rows (countrySheet.js WITHHELD: India's list, packet and cards) say so, unfetched + records: (c) => (rowsWithheld(c) ? Promise.reject(keyedError('messages.countryPosterTooMany', TOO_MANY)) : recordsFor({ type: 'country', values: [c.iso3] })), // the sheet gets the data's own record (English, the series index by id), as on the wall sheet: (card, format) => sheet(byId.get(card.id) || card, format), - unsupported: (c) => (sheetWithheld(c) || c.unsupportedReason ? t('messages.countryPosterTooMany') : null), + unsupported: (c) => (posterWithheld(c) || (c.unsupportedReason && !largeSheet(c)) ? t('messages.countryPosterTooMany') : null), unit: 'UPG', itemCountKey: 'regions.countryCount', eventName: 'doxa-country-posters:show-on-map', downloadPrefix: 'doxa-country', cardStocks: true, } }, diff --git a/library/data/country-posters.json b/library/data/country-posters.json index 798593038..cc8ca4c1c 100644 --- a/library/data/country-posters.json +++ b/library/data/country-posters.json @@ -960,7 +960,7 @@ "iso3": "USA", "label": "United States of America", "count": 3, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -178.228, 13.246, @@ -1152,7 +1152,7 @@ "iso3": "CCK", "label": "Cocos (Keeling) Islands", "count": 2, - "region": "Other", + "region": "Oceania", "bbox": [ 96.831418, -12.185468, @@ -1232,7 +1232,7 @@ "iso3": "GUF", "label": "French Guiana", "count": 2, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -54.601, 2.179, @@ -1344,7 +1344,7 @@ "iso3": "AND", "label": "Andorra", "count": 1, - "region": "Other", + "region": "Europe", "bbox": [ 1.412, 42.43, @@ -1424,7 +1424,7 @@ "iso3": "VIR", "label": "Virgin Islands, U.S.", "count": 1, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -64.904, 17.68, @@ -1536,7 +1536,7 @@ "iso3": "BMU", "label": "Bermuda", "count": 1, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -64.886, 32.248, @@ -1568,7 +1568,7 @@ "iso3": "VGB", "label": "Virgin Islands, British", "count": 1, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -64.716, 18.389, @@ -1648,7 +1648,7 @@ "iso3": "CXR", "label": "Christmas Island", "count": 1, - "region": "Other", + "region": "Oceania", "bbox": [ 105.672, -10.426, @@ -1744,7 +1744,7 @@ "iso3": "DMA", "label": "Dominica", "count": 1, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -61.48, 15.21, @@ -1888,7 +1888,7 @@ "iso3": "GLP", "label": "Guadeloupe", "count": 1, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -61.809, 15.947, @@ -2192,7 +2192,7 @@ "iso3": "MCO", "label": "Monaco", "count": 1, - "region": "Other", + "region": "Europe", "bbox": [ 7.416, 43.733, @@ -2208,7 +2208,7 @@ "iso3": "MSR", "label": "Montserrat", "count": 1, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -62.232, 16.675, @@ -2240,7 +2240,7 @@ "iso3": "NIU", "label": "Niue", "count": 1, - "region": "Other", + "region": "Oceania", "bbox": [ -169.949, -19.155, @@ -2272,7 +2272,7 @@ "iso3": "NFK", "label": "Norfolk Island", "count": 1, - "region": "Other", + "region": "Oceania", "bbox": [ 167.9544, -29.0338, @@ -2352,7 +2352,7 @@ "iso3": "BLM", "label": "Saint Barthélemy", "count": 1, - "region": "Other", + "region": "North America & Non-Spanish Caribbean", "bbox": [ -62.868, 17.879, @@ -2400,7 +2400,7 @@ "iso3": "SMR", "label": "San Marino", "count": 1, - "region": "Other", + "region": "Europe", "bbox": [ 12.404, 43.896, @@ -2544,7 +2544,7 @@ "iso3": "TKL", "label": "Tokelau", "count": 1, - "region": "Other", + "region": "Oceania", "bbox": [ -172.47, -8.557, @@ -2608,7 +2608,7 @@ "iso3": "WLF", "label": "Wallis and Futuna", "count": 1, - "region": "Other", + "region": "Oceania", "bbox": [ -176.251, -13.352, diff --git a/library/elements/hostFloor.js b/library/elements/hostFloor.js new file mode 100644 index 000000000..0460cec4a --- /dev/null +++ b/library/elements/hostFloor.js @@ -0,0 +1,82 @@ +/** + * hostFloor.js — whether a profile's viewport floor applies, decided against its HOST (library/ProfileLoader.vue). + * + * The loader floors every profile at min-height:100svh, for a host whose height comes from its CONTENT (the + * scroll-page leaf; BUG-UPG100). A host that is SIZED from outside (DX Life's map slot: a definite height, overflow + * hidden) must be filled exactly: there the floor made the map taller than its host and clipped its legend. + * + * ★ PROBED AT MOUNT; AFTER THAT, ONLY WHEN THE HOST CHANGES KIND, AND WITHOUT A TRACE. Which kind of host this is + * cannot be read from CSS, so it is MEASURED: the floor at 0, then at a huge value; a sized host keeps its height, + * a content-sized one grows (and one that stops growing below the floor is CAPPED, by max-height). At mount the + * profile has not rendered, so that probe touches no reader state. + * The regression this replaces: re-probing on EVERY host resize collapsed the loader under live content, clamped a + * scrolling child's scrollTop, and the embedded UPG-100 app (its view follows its column's scroll) flipped from Map + * back to its first view whenever a phone's URL bar resized the host. So after mount follow() re-probes ONLY when + * the reading says the kind may have changed, and that re-probe saves every scrolled descendant and puts it back + * before the frame paints: + * - floor on, host SHORTER than the floor: sized from outside (styled after mount), or capped. Re-probe decides; + * a capped host is latched until it reaches the floor again, so it is never re-probed in a loop; + * - floor off (sized), host CHANGED size: it may have stopped holding a size (restyled to height:auto, now + * following its content). Re-probe decides, and the floor comes back if so; + * - a window-height change on a content-sized host moves the host WITH the floor: no re-probe, nothing written. + * check:host-floor pins all of this, with the old per-resize probe, a missing latch, a missing sized re-check and a + * missing scroll restore as poisons. + */ +const scrolledUnder = (root) => { + const out = [] + const walk = (r) => { + for (const e of r.querySelectorAll ? r.querySelectorAll('*') : []) { + if (e.scrollTop || e.scrollLeft) out.push(e) + if (e.shadowRoot) walk(e.shadowRoot) + } + } + walk(root) + return out +} + +export function createHostFloor(loader, host, { style = (n) => getComputedStyle(n), scrolled = () => scrolledUnder(loader) } = {}) { + let capped = false, lastH = null + const isSized = () => loader.hasAttribute('data-host-sized') + const floorPx = () => parseFloat(style(loader).minHeight) || 0 + const hostH = () => host.getBoundingClientRect().height + function set(sized) { + loader.style.minHeight = sized ? '0px' : '' + if (sized) loader.setAttribute('data-host-sized', '') + else loader.removeAttribute('data-host-sized') + } + function decide() { + loader.style.minHeight = '0px' + const h0 = hostH() + loader.style.minHeight = '100000px' + const h1 = hostH() + const sized = h0 > 0 && Math.abs(h1 - h0) < 1 + set(sized) + const f = sized ? 0 : floorPx() + capped = !sized && f > 0 && h1 + 1 < f // grows with its content, but stops below the floor + } + /** At mount only, before the profile renders. */ + function probeOnce() { decide(); lastH = hostH() } + /** After mount: the same decision, leaving every scrolled descendant where it was. */ + function reprobe() { + const kept = scrolled().map((e) => [e, e.scrollTop, e.scrollLeft]) + decide() + for (const [e, top, left] of kept) { if (e.scrollTop !== top) e.scrollTop = top; if (e.scrollLeft !== left) e.scrollLeft = left } + } + /** On every host resize: reads, and re-probes only when the host may have changed kind. */ + function follow() { + const display = style(host).display + if (display === 'inline' || display === 'contents') return // no box of its own to read + const h = hostH() + if (isSized()) { + if (lastH === null || Math.abs(h - lastH) >= 1) reprobe() + } else { + const f = floorPx() + if (capped) { if (h + 1 >= f) capped = false } + else if (f > 0 && h + 1 < f) reprobe() + } + lastH = hostH() + } + return { probeOnce, follow } +} + +export default createHostFloor diff --git a/library/elements/posterSectionConfig.js b/library/elements/posterSectionConfig.js index fa84f784f..96867b9d3 100644 --- a/library/elements/posterSectionConfig.js +++ b/library/elements/posterSectionConfig.js @@ -1,5 +1,5 @@ // 's attributes → its profile-config. Pure (no Vue), so it is testable on its own. -export const ATTRS = ['type', 'scope', 'collapsible', 'collapsed', 'locale', 'assets-base', 'api-base-url', 'is-dark'] +export const ATTRS = ['type', 'scope', 'collapsible', 'collapsed', 'locale', 'assets-base', 'api-base-url', 'is-dark', 'columns', 'min-card-px', 'show-header', 'card-action'] export const TAG = 'dxm-poster-section' export function posterSectionConfig(el) { @@ -9,6 +9,19 @@ export function posterSectionConfig(el) { if (a('locale')) cfg.locale = a('locale') if (a('assets-base')) cfg.assetsBase = a('assets-base') if (a('api-base-url')) cfg.apiBaseUrl = a('api-base-url') + // posters per row (1-5); absent = the wall's own default. A reader's saved choice still wins (PosterWall). + const cols = Number(a('columns')) + if (el.hasAttribute('columns') && Number.isInteger(cols) && cols >= 1 && cols <= 5) cfg.columns = cols + // the narrowest a card may get before a row holds one fewer (PosterWall's minCardPx); absent = the wall's own default. + // A host that wants two posters a row on a phone sets it low enough to fit (the Regions list: 150). + const minCard = Number(a('min-card-px')) + if (el.hasAttribute('min-card-px') && Number.isInteger(minCard) && minCard >= 80 && minCard <= 600) cfg.minCardPx = minCard + // show-header="false": no section head, so the HOST's own layout is the header (a key only then: every other host's + // config is unchanged). The head is also the collapse toggle, so a headless section never collapses (PosterSectionView). + if (flag('show-header', true) === false) cfg.showHeader = false + // card-action="dashboard": each card's sticker reads "Show dashboard" and asks the HOST to open its own page + // (PosterSectionView dispatches dxm-poster-dashboard). Only a known value sets a key; absent = "Show on map". + if (a('card-action') === 'dashboard') cfg.cardAction = 'dashboard' return cfg } diff --git a/library/elements/posterSectionElement.js b/library/elements/posterSectionElement.js index fcdb52b37..7eb1fc7b4 100644 --- a/library/elements/posterSectionElement.js +++ b/library/elements/posterSectionElement.js @@ -4,7 +4,11 @@ * * * Attributes: type (default "country"), scope (a region / continent / list key), collapsible (default on; - * collapsible="false" turns it off), collapsed (start shut), locale, assets-base, api-base-url, is-dark. + * collapsible="false" turns it off), collapsed (start shut), locale, assets-base, api-base-url, is-dark, columns + * (posters per row, 1-5), min-card-px (the narrowest a card gets before a row holds one fewer), show-header (default + * on; show-header="false" hides the section's head so the host's layout is its header), card-action ("dashboard": each + * card reads "Show dashboard" and dispatches a bubbling, composed dxm-poster-dashboard { type, id, iso3, name, scope } for + * the host to open its own page; absent = "Show on map"). * They are mapped into the standard element's profile-config, so the element keeps defineMapElement's per-instance * i18n and store, and the profile (PosterSectionView.vue) reads them like every other profile reads its config. * Any bundle can call registerPosterSectionElement(); a second call is a no-op. diff --git a/library/i18n/locales/ar/posterCard.json b/library/i18n/locales/ar/posterCard.json new file mode 100644 index 000000000..a8cb18d1c --- /dev/null +++ b/library/i18n/locales/ar/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "عرض لوحة المعلومات", + "showDashboardAria": "عرض لوحة معلومات {label}" +} diff --git a/library/i18n/locales/ar/posterDesign.json b/library/i18n/locales/ar/posterDesign.json index 3cc3aee22..284c2d1cc 100644 --- a/library/i18n/locales/ar/posterDesign.json +++ b/library/i18n/locales/ar/posterDesign.json @@ -35,7 +35,8 @@ "packet": "حزمة الصلاة", "cards": "بطاقات الصلاة", "wall": "ألواح الجدار", - "mapPrint": "طباعة الخريطة" + "mapPrint": "طباعة الخريطة", + "plugins": "من إضافات أخرى" }, "tpl": { "mapSheetCountry": "صفحة خريطة الدولة", @@ -48,7 +49,8 @@ }, "missing": { "bless": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.", - "mapPrint": "يطبع هذا القالب الخريطة الحية التي ينظر إليها القارئ، لذا ليست له صورة نموذجية." + "mapPrint": "يطبع هذا القالب الخريطة الحية التي ينظر إليها القارئ، لذا ليست له صورة نموذجية.", + "noCountryAtSize": "لا تُطبع أي دولة بهذا الحجم اليوم: الهند، الدولة الوحيدة التي كانت تُطبع به، تُطبع الآن بحجم 5 × 8 أقدام فقط. نعرضه هنا بدولة نموذجية لإظهار القالب." }, "details": { "configName": "اسم الإعداد", @@ -60,7 +62,9 @@ "sample": "بيانات نموذجية", "renderer": "المولّد", "legacy": "مقاس قديم: ما زال يُطبع، لكن أداة اختيار الطباعة لم تعد تعرضه.", - "forAgents": "لاستخدام هذا القالب، قل للوكيل: استخدم الإعداد {name}." + "forAgents": "لاستخدام هذا القالب، قل للوكيل: استخدم الإعداد {name}.", + "openPage": "افتح صفحته" }, - "templateCount": "لا قوالب | قالب واحد | قالبان | {n} قوالب | {n} قالبًا | {n} قالب" + "templateCount": "لا قوالب | قالب واحد | قالبان | {n} قوالب | {n} قالبًا | {n} قالب", + "fromPlugin": "تصنعه إضافته الخاصة. اعرض كل التفاصيل لتراه مباشرة." } diff --git a/library/i18n/locales/ar/posterSections.json b/library/i18n/locales/ar/posterSections.json new file mode 100644 index 000000000..c600ab96d --- /dev/null +++ b/library/i18n/locales/ar/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "أقسام الملصقات", + "intro": "اختر الملصقات وشاهدها أدناه، ثم انسخ الرمز لوضعها على أي موقع.", + "type": "نوع الملصق", + "typeCountry": "الدول", + "region": "المنطقة", + "allRegions": "كل المناطق", + "language": "اللغة", + "startCollapsed": "تبدأ مطويّة", + "embed": "انسخ رمز التضمين", + "embedNote": "الصق هذا في أي صفحة ويب.", + "copy": "نسخ", + "copied": "تم النسخ" +} diff --git a/library/i18n/locales/de/posterCard.json b/library/i18n/locales/de/posterCard.json new file mode 100644 index 000000000..30bee41fb --- /dev/null +++ b/library/i18n/locales/de/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Dashboard anzeigen", + "showDashboardAria": "Dashboard anzeigen: {label}" +} diff --git a/library/i18n/locales/de/posterDesign.json b/library/i18n/locales/de/posterDesign.json index 76fd9c561..20a0879b7 100644 --- a/library/i18n/locales/de/posterDesign.json +++ b/library/i18n/locales/de/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Gebetsmappe", "cards": "Gebetskarten", "wall": "Wandtafeln", - "mapPrint": "Kartendruck" + "mapPrint": "Kartendruck", + "plugins": "Aus anderen Plugins" }, "tpl": { "mapSheetCountry": "Länder-Kartenblatt", @@ -48,7 +49,8 @@ }, "missing": { "bless": "Die Bögen, die Klebestreifen und die Lage der Kartenreihen stammen aus der gemeinsamen Tafelgeometrie; die Karten sind als Layout dargestellt, weil die Segnen-Wand ihre Karten in ihrem eigenen Plugin zeichnet.", - "mapPrint": "Diese Vorlage druckt die Live-Karte, die der Leser gerade ansieht, und hat deshalb kein Beispielbild." + "mapPrint": "Diese Vorlage druckt die Live-Karte, die der Leser gerade ansieht, und hat deshalb kein Beispielbild.", + "noCountryAtSize": "Heute wird kein Land in dieser Größe gedruckt: Indien, das einzige Land, das es wurde, wird jetzt nur noch in 5 × 8 ft gedruckt. Die Vorlage wird hier mit einem Beispielland gezeigt." }, "details": { "configName": "Name der Konfiguration", @@ -60,7 +62,9 @@ "sample": "Beispieldaten", "renderer": "Generator", "legacy": "Alte Größe: Sie wird noch gedruckt, aber die Druckauswahl bietet sie nicht mehr an.", - "forAgents": "Um diese Vorlage zu verwenden, sagen Sie einem Agenten: verwende die Konfiguration {name}." + "forAgents": "Um diese Vorlage zu verwenden, sagen Sie einem Agenten: verwende die Konfiguration {name}.", + "openPage": "Seite öffnen" }, - "templateCount": "{n} Vorlage | {n} Vorlagen" + "templateCount": "{n} Vorlage | {n} Vorlagen", + "fromPlugin": "Wird von seinem eigenen Plugin erstellt. „Alle Details anzeigen“ zeigt es live." } diff --git a/library/i18n/locales/de/posterSections.json b/library/i18n/locales/de/posterSections.json new file mode 100644 index 000000000..2a16d7121 --- /dev/null +++ b/library/i18n/locales/de/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Plakat-Abschnitte", + "intro": "Wähle die Plakate, sieh sie dir unten an und kopiere dann den Code für eine beliebige Website.", + "type": "Plakattyp", + "typeCountry": "Länder", + "region": "Region", + "allRegions": "Alle Regionen", + "language": "Sprache", + "startCollapsed": "Eingeklappt beginnen", + "embed": "Einbettung kopieren", + "embedNote": "Füge dies in eine beliebige Webseite ein.", + "copy": "Kopieren", + "copied": "Kopiert" +} diff --git a/library/i18n/locales/en/posterCard.json b/library/i18n/locales/en/posterCard.json new file mode 100644 index 000000000..3fc0c30ad --- /dev/null +++ b/library/i18n/locales/en/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Show dashboard", + "showDashboardAria": "Show the {label} dashboard" +} diff --git a/library/i18n/locales/en/posterDesign.json b/library/i18n/locales/en/posterDesign.json index dd7fb1f8b..b970eaa7e 100644 --- a/library/i18n/locales/en/posterDesign.json +++ b/library/i18n/locales/en/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Prayer packet", "cards": "Prayer cards", "wall": "Wall panels", - "mapPrint": "Map print" + "mapPrint": "Map print", + "plugins": "From other plugins" }, "tpl": { "mapSheetCountry": "Country map sheet", @@ -48,7 +49,8 @@ }, "missing": { "bless": "The sheets, the glue strips and where the card rows go come from the shared panel geometry; the cards are shown as a layout, because the Bless wall draws its cards in its own plugin.", - "mapPrint": "This template prints the live map the reader is looking at, so it has no sample picture." + "mapPrint": "This template prints the live map the reader is looking at, so it has no sample picture.", + "noCountryAtSize": "No country prints at this size today: India, the one country that did, now prints at 5 × 8 ft only. It is drawn here with a sample country to show the template." }, "details": { "configName": "Configuration name", @@ -60,7 +62,9 @@ "sample": "Sample data", "renderer": "Renderer", "legacy": "Legacy size: it still prints, but the print picker no longer offers it.", - "forAgents": "To use this template, tell an agent: use configuration {name}." + "forAgents": "To use this template, tell an agent: use configuration {name}.", + "openPage": "Open its page" }, - "templateCount": "{n} templates | {n} template | {n} templates" + "templateCount": "{n} templates | {n} template | {n} templates", + "fromPlugin": "Made by its own plugin. Show full details shows it live." } diff --git a/library/i18n/locales/en/posterSections.json b/library/i18n/locales/en/posterSections.json new file mode 100644 index 000000000..90714bcc1 --- /dev/null +++ b/library/i18n/locales/en/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Poster sections", + "intro": "Pick the posters, see them below, then copy the tag to put them on any website.", + "type": "Poster type", + "typeCountry": "Countries", + "region": "Region", + "allRegions": "All regions", + "language": "Language", + "startCollapsed": "Start collapsed", + "embed": "Copy embed", + "embedNote": "Paste this into any web page.", + "copy": "Copy", + "copied": "Copied" +} diff --git a/library/i18n/locales/es/posterCard.json b/library/i18n/locales/es/posterCard.json new file mode 100644 index 000000000..4c5842bb7 --- /dev/null +++ b/library/i18n/locales/es/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Ver el panel", + "showDashboardAria": "Ver el panel de {label}" +} diff --git a/library/i18n/locales/es/posterDesign.json b/library/i18n/locales/es/posterDesign.json index a63495e8f..2edc7e748 100644 --- a/library/i18n/locales/es/posterDesign.json +++ b/library/i18n/locales/es/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Paquete de oración", "cards": "Tarjetas de oración", "wall": "Paneles de pared", - "mapPrint": "Impresión del mapa" + "mapPrint": "Impresión del mapa", + "plugins": "De otros complementos" }, "tpl": { "mapSheetCountry": "Hoja de mapa del país", @@ -48,7 +49,8 @@ }, "missing": { "bless": "Las hojas, las franjas de pegado y el lugar de las filas de tarjetas vienen de la geometría de paneles compartida; las tarjetas se muestran como un diseño, porque el muro Bendecir dibuja sus tarjetas en su propio complemento.", - "mapPrint": "Esta plantilla imprime el mapa en vivo que está mirando el lector, así que no tiene imagen de ejemplo." + "mapPrint": "Esta plantilla imprime el mapa en vivo que está mirando el lector, así que no tiene imagen de ejemplo.", + "noCountryAtSize": "Hoy ningún país se imprime en este tamaño: la India, el único que lo hacía, ahora se imprime solo a 5 × 8 ft. Aquí se dibuja con un país de muestra para mostrar la plantilla." }, "details": { "configName": "Nombre de la configuración", @@ -60,7 +62,9 @@ "sample": "Datos de ejemplo", "renderer": "Generador", "legacy": "Tamaño heredado: todavía se imprime, pero el selector de impresión ya no lo ofrece.", - "forAgents": "Para usar esta plantilla, dile a un agente: usa la configuración {name}." + "forAgents": "Para usar esta plantilla, dile a un agente: usa la configuración {name}.", + "openPage": "Abrir su página" }, - "templateCount": "{n} plantilla | {n} de plantillas | {n} plantillas" + "templateCount": "{n} plantilla | {n} de plantillas | {n} plantillas", + "fromPlugin": "Lo crea su propio complemento. Mostrar todos los detalles lo muestra en vivo." } diff --git a/library/i18n/locales/es/posterSections.json b/library/i18n/locales/es/posterSections.json new file mode 100644 index 000000000..ffdebe79d --- /dev/null +++ b/library/i18n/locales/es/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Secciones de carteles", + "intro": "Elige los carteles, míralos abajo y copia la etiqueta para ponerlos en cualquier sitio web.", + "type": "Tipo de cartel", + "typeCountry": "Países", + "region": "Región", + "allRegions": "Todas las regiones", + "language": "Idioma", + "startCollapsed": "Empezar contraído", + "embed": "Copiar código para insertar", + "embedNote": "Pega esto en cualquier página web.", + "copy": "Copiar", + "copied": "Copiado" +} diff --git a/library/i18n/locales/fr/posterCard.json b/library/i18n/locales/fr/posterCard.json new file mode 100644 index 000000000..baad954bf --- /dev/null +++ b/library/i18n/locales/fr/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Voir le tableau de bord", + "showDashboardAria": "Voir le tableau de bord : {label}" +} diff --git a/library/i18n/locales/fr/posterDesign.json b/library/i18n/locales/fr/posterDesign.json index ff32e0e40..79c66a6b8 100644 --- a/library/i18n/locales/fr/posterDesign.json +++ b/library/i18n/locales/fr/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Dossier de prière", "cards": "Cartes de prière", "wall": "Panneaux muraux", - "mapPrint": "Impression de la carte" + "mapPrint": "Impression de la carte", + "plugins": "D’autres plugins" }, "tpl": { "mapSheetCountry": "Feuille de carte du pays", @@ -48,7 +49,8 @@ }, "missing": { "bless": "Les feuilles, les bandes de collage et l'emplacement des rangées de cartes viennent de la géométrie de panneaux partagée ; les cartes sont montrées comme une mise en page, car le mur Bénir dessine ses cartes dans son propre module.", - "mapPrint": "Ce modèle imprime la carte en direct que le lecteur regarde, il n'a donc pas d'image d'exemple." + "mapPrint": "Ce modèle imprime la carte en direct que le lecteur regarde, il n'a donc pas d'image d'exemple.", + "noCountryAtSize": "Aucun pays ne s’imprime à ce format aujourd’hui : l’Inde, le seul qui le faisait, s’imprime désormais uniquement en 5 × 8 ft. Le modèle est dessiné ici avec un pays d’exemple." }, "details": { "configName": "Nom de la configuration", @@ -60,7 +62,9 @@ "sample": "Données d'exemple", "renderer": "Générateur", "legacy": "Format ancien : il s'imprime encore, mais le sélecteur d'impression ne le propose plus.", - "forAgents": "Pour utiliser ce modèle, dites à un agent : utilise la configuration {name}." + "forAgents": "Pour utiliser ce modèle, dites à un agent : utilise la configuration {name}.", + "openPage": "Ouvrir sa page" }, - "templateCount": "{n} modèle | {n} de modèles | {n} modèles" + "templateCount": "{n} modèle | {n} de modèles | {n} modèles", + "fromPlugin": "Créé par son propre plugin. Afficher tous les détails le montre en direct." } diff --git a/library/i18n/locales/fr/posterSections.json b/library/i18n/locales/fr/posterSections.json new file mode 100644 index 000000000..e72ea5d73 --- /dev/null +++ b/library/i18n/locales/fr/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Sections d’affiches", + "intro": "Choisissez les affiches, regardez-les ci-dessous, puis copiez la balise pour les placer sur n’importe quel site.", + "type": "Type d’affiche", + "typeCountry": "Pays", + "region": "Région", + "allRegions": "Toutes les régions", + "language": "Langue", + "startCollapsed": "Commencer replié", + "embed": "Copier le code d’intégration", + "embedNote": "Collez ceci dans n’importe quelle page web.", + "copy": "Copier", + "copied": "Copié" +} diff --git a/library/i18n/locales/hi/posterCard.json b/library/i18n/locales/hi/posterCard.json new file mode 100644 index 000000000..576bade51 --- /dev/null +++ b/library/i18n/locales/hi/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "डैशबोर्ड देखें", + "showDashboardAria": "{label} डैशबोर्ड देखें" +} diff --git a/library/i18n/locales/hi/posterDesign.json b/library/i18n/locales/hi/posterDesign.json index 802ddc630..d17457e22 100644 --- a/library/i18n/locales/hi/posterDesign.json +++ b/library/i18n/locales/hi/posterDesign.json @@ -35,7 +35,8 @@ "packet": "प्रार्थना पैकेट", "cards": "प्रार्थना कार्ड", "wall": "दीवार पैनल", - "mapPrint": "मानचित्र प्रिंट" + "mapPrint": "मानचित्र प्रिंट", + "plugins": "अन्य प्लगइन से" }, "tpl": { "mapSheetCountry": "देश का मानचित्र पृष्ठ", @@ -48,7 +49,8 @@ }, "missing": { "bless": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।", - "mapPrint": "यह टेम्पलेट वह लाइव मानचित्र प्रिंट करता है जिसे पाठक देख रहा है, इसलिए इसकी कोई नमूना तस्वीर नहीं है।" + "mapPrint": "यह टेम्पलेट वह लाइव मानचित्र प्रिंट करता है जिसे पाठक देख रहा है, इसलिए इसकी कोई नमूना तस्वीर नहीं है।", + "noCountryAtSize": "आज कोई भी देश इस आकार में नहीं छपता: भारत, जो अकेला देश छपता था, अब केवल 5 × 8 फ़ुट में छपता है। टेम्पलेट दिखाने के लिए इसे यहाँ एक नमूना देश के साथ बनाया गया है।" }, "details": { "configName": "कॉन्फ़िगरेशन का नाम", @@ -60,7 +62,9 @@ "sample": "नमूना डेटा", "renderer": "जनरेटर", "legacy": "पुराना आकार: यह अब भी प्रिंट होता है, पर प्रिंट चयनकर्ता अब इसे नहीं दिखाता।", - "forAgents": "यह टेम्पलेट इस्तेमाल करने के लिए किसी एजेंट से कहें: कॉन्फ़िगरेशन {name} इस्तेमाल करो।" + "forAgents": "यह टेम्पलेट इस्तेमाल करने के लिए किसी एजेंट से कहें: कॉन्फ़िगरेशन {name} इस्तेमाल करो।", + "openPage": "इसका पेज खोलें" }, - "templateCount": "{n} टेम्पलेट | {n} टेम्पलेट" + "templateCount": "{n} टेम्पलेट | {n} टेम्पलेट", + "fromPlugin": "यह अपने ही प्लगइन से बनता है। पूरा विवरण दिखाएँ इसे लाइव दिखाता है।" } diff --git a/library/i18n/locales/hi/posterSections.json b/library/i18n/locales/hi/posterSections.json new file mode 100644 index 000000000..bc945ddd5 --- /dev/null +++ b/library/i18n/locales/hi/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "पोस्टर अनुभाग", + "intro": "पोस्टर चुनें, नीचे देखें, फिर किसी भी वेबसाइट पर लगाने के लिए टैग कॉपी करें।", + "type": "पोस्टर का प्रकार", + "typeCountry": "देश", + "region": "क्षेत्र", + "allRegions": "सभी क्षेत्र", + "language": "भाषा", + "startCollapsed": "बंद रूप में शुरू करें", + "embed": "एम्बेड कॉपी करें", + "embedNote": "इसे किसी भी वेब पेज में चिपकाएँ।", + "copy": "कॉपी करें", + "copied": "कॉपी हो गया" +} diff --git a/library/i18n/locales/it/posterCard.json b/library/i18n/locales/it/posterCard.json new file mode 100644 index 000000000..4924aeb41 --- /dev/null +++ b/library/i18n/locales/it/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Vedi il cruscotto", + "showDashboardAria": "Vedi il cruscotto di {label}" +} diff --git a/library/i18n/locales/it/posterDesign.json b/library/i18n/locales/it/posterDesign.json index a4c4e5dd7..4aec62b44 100644 --- a/library/i18n/locales/it/posterDesign.json +++ b/library/i18n/locales/it/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Fascicolo di preghiera", "cards": "Schede di preghiera", "wall": "Pannelli da parete", - "mapPrint": "Stampa della mappa" + "mapPrint": "Stampa della mappa", + "plugins": "Da altri plugin" }, "tpl": { "mapSheetCountry": "Foglio mappa del paese", @@ -48,7 +49,8 @@ }, "missing": { "bless": "I fogli, le strisce da incollare e la posizione delle file di schede vengono dalla geometria dei pannelli condivisa; le schede sono mostrate come impaginazione, perché il muro Benedici disegna le sue schede nel proprio plugin.", - "mapPrint": "Questo modello stampa la mappa dal vivo che il lettore sta guardando, quindi non ha un'immagine di esempio." + "mapPrint": "Questo modello stampa la mappa dal vivo che il lettore sta guardando, quindi non ha un'immagine di esempio.", + "noCountryAtSize": "Oggi nessun paese si stampa in questo formato: l’India, l’unico che lo faceva, ora si stampa solo a 5 × 8 ft. Qui il modello è disegnato con un paese di esempio." }, "details": { "configName": "Nome della configurazione", @@ -60,7 +62,9 @@ "sample": "Dati di esempio", "renderer": "Generatore", "legacy": "Formato precedente: si stampa ancora, ma il selettore di stampa non lo offre più.", - "forAgents": "Per usare questo modello, di' a un agente: usa la configurazione {name}." + "forAgents": "Per usare questo modello, di' a un agente: usa la configurazione {name}.", + "openPage": "Apri la sua pagina" }, - "templateCount": "{n} modello | {n} di modelli | {n} modelli" + "templateCount": "{n} modello | {n} di modelli | {n} modelli", + "fromPlugin": "Creato dal suo plugin. Mostra tutti i dettagli lo mostra dal vivo." } diff --git a/library/i18n/locales/it/posterSections.json b/library/i18n/locales/it/posterSections.json new file mode 100644 index 000000000..213a05354 --- /dev/null +++ b/library/i18n/locales/it/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Sezioni di poster", + "intro": "Scegli i poster, guardali qui sotto, poi copia il tag per metterli su qualsiasi sito.", + "type": "Tipo di poster", + "typeCountry": "Paesi", + "region": "Regione", + "allRegions": "Tutte le regioni", + "language": "Lingua", + "startCollapsed": "Inizia chiuso", + "embed": "Copia il codice da incorporare", + "embedNote": "Incolla questo in qualsiasi pagina web.", + "copy": "Copia", + "copied": "Copiato" +} diff --git a/library/i18n/locales/pt/posterCard.json b/library/i18n/locales/pt/posterCard.json new file mode 100644 index 000000000..3bfedd85f --- /dev/null +++ b/library/i18n/locales/pt/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Ver o painel", + "showDashboardAria": "Ver o painel: {label}" +} diff --git a/library/i18n/locales/pt/posterDesign.json b/library/i18n/locales/pt/posterDesign.json index 489b67ae0..69af6993a 100644 --- a/library/i18n/locales/pt/posterDesign.json +++ b/library/i18n/locales/pt/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Pacote de oração", "cards": "Cartões de oração", "wall": "Painéis de parede", - "mapPrint": "Impressão do mapa" + "mapPrint": "Impressão do mapa", + "plugins": "De outros plugins" }, "tpl": { "mapSheetCountry": "Folha de mapa do país", @@ -48,7 +49,8 @@ }, "missing": { "bless": "As folhas, as faixas de colagem e a posição das fileiras de cartões vêm da geometria de painéis compartilhada; os cartões aparecem como um layout, porque o mural Abençoar desenha os seus cartões no seu próprio plugin.", - "mapPrint": "Este modelo imprime o mapa ao vivo que o leitor está vendo, por isso não tem imagem de exemplo." + "mapPrint": "Este modelo imprime o mapa ao vivo que o leitor está vendo, por isso não tem imagem de exemplo.", + "noCountryAtSize": "Hoje nenhum país é impresso neste tamanho: a Índia, o único que era, agora é impressa apenas em 5 × 8 ft. Aqui o modelo é desenhado com um país de exemplo." }, "details": { "configName": "Nome da configuração", @@ -60,7 +62,9 @@ "sample": "Dados de exemplo", "renderer": "Gerador", "legacy": "Tamanho antigo: ainda imprime, mas o seletor de impressão já não o oferece.", - "forAgents": "Para usar este modelo, diga a um agente: use a configuração {name}." + "forAgents": "Para usar este modelo, diga a um agente: use a configuração {name}.", + "openPage": "Abrir a página" }, - "templateCount": "{n} modelo | {n} de modelos | {n} modelos" + "templateCount": "{n} modelo | {n} de modelos | {n} modelos", + "fromPlugin": "Feito pelo próprio plugin. Mostrar todos os detalhes o exibe ao vivo." } diff --git a/library/i18n/locales/pt/posterSections.json b/library/i18n/locales/pt/posterSections.json new file mode 100644 index 000000000..1f84b5c98 --- /dev/null +++ b/library/i18n/locales/pt/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Seções de cartazes", + "intro": "Escolha os cartazes, veja-os abaixo e copie a etiqueta para colocá-los em qualquer site.", + "type": "Tipo de cartaz", + "typeCountry": "Países", + "region": "Região", + "allRegions": "Todas as regiões", + "language": "Idioma", + "startCollapsed": "Começar recolhido", + "embed": "Copiar código de incorporação", + "embedNote": "Cole isto em qualquer página da web.", + "copy": "Copiar", + "copied": "Copiado" +} diff --git a/library/i18n/locales/ro/posterCard.json b/library/i18n/locales/ro/posterCard.json new file mode 100644 index 000000000..c23322a40 --- /dev/null +++ b/library/i18n/locales/ro/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Vezi panoul", + "showDashboardAria": "Vezi panoul {label}" +} diff --git a/library/i18n/locales/ro/posterDesign.json b/library/i18n/locales/ro/posterDesign.json index 7d864df8b..86183bb77 100644 --- a/library/i18n/locales/ro/posterDesign.json +++ b/library/i18n/locales/ro/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Pachet de rugăciune", "cards": "Cartonașe de rugăciune", "wall": "Panouri de perete", - "mapPrint": "Tipărirea hărții" + "mapPrint": "Tipărirea hărții", + "plugins": "Din alte pluginuri" }, "tpl": { "mapSheetCountry": "Foaie de hartă a țării", @@ -48,7 +49,8 @@ }, "missing": { "bless": "Colile, benzile de lipire și locul rândurilor de cartonașe vin din geometria comună a panourilor; cartonașele sunt arătate ca machetă, pentru că peretele Binecuvântează își desenează cartonașele în propriul modul.", - "mapPrint": "Acest șablon tipărește harta live pe care o privește cititorul, așa că nu are imagine de exemplu." + "mapPrint": "Acest șablon tipărește harta live pe care o privește cititorul, așa că nu are imagine de exemplu.", + "noCountryAtSize": "Astăzi nicio țară nu se tipărește la această dimensiune: India, singura care o făcea, se tipărește acum doar la 5 × 8 ft. Șablonul este desenat aici cu o țară exemplu." }, "details": { "configName": "Numele configurației", @@ -60,7 +62,9 @@ "sample": "Date de exemplu", "renderer": "Generator", "legacy": "Dimensiune veche: încă se tipărește, dar selectorul de tipărire nu o mai oferă.", - "forAgents": "Ca să folosești acest șablon, spune-i unui agent: folosește configurația {name}." + "forAgents": "Ca să folosești acest șablon, spune-i unui agent: folosește configurația {name}.", + "openPage": "Deschide pagina" }, - "templateCount": "{n} șablon | {n} șabloane | {n} de șabloane" + "templateCount": "{n} șablon | {n} șabloane | {n} de șabloane", + "fromPlugin": "Este realizat de propriul plugin. Afișează toate detaliile îl arată în direct." } diff --git a/library/i18n/locales/ro/posterSections.json b/library/i18n/locales/ro/posterSections.json new file mode 100644 index 000000000..5033b5186 --- /dev/null +++ b/library/i18n/locales/ro/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Secțiuni de postere", + "intro": "Alege posterele, vezi-le mai jos, apoi copiază eticheta ca să le pui pe orice site.", + "type": "Tipul posterului", + "typeCountry": "Țări", + "region": "Regiune", + "allRegions": "Toate regiunile", + "language": "Limbă", + "startCollapsed": "Începe restrâns", + "embed": "Copiază codul de încorporare", + "embedNote": "Lipește asta în orice pagină web.", + "copy": "Copiază", + "copied": "Copiat" +} diff --git a/library/i18n/locales/ru/posterCard.json b/library/i18n/locales/ru/posterCard.json new file mode 100644 index 000000000..cdd091303 --- /dev/null +++ b/library/i18n/locales/ru/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "Открыть панель", + "showDashboardAria": "Открыть панель: {label}" +} diff --git a/library/i18n/locales/ru/posterDesign.json b/library/i18n/locales/ru/posterDesign.json index 402a39b75..d5f05a992 100644 --- a/library/i18n/locales/ru/posterDesign.json +++ b/library/i18n/locales/ru/posterDesign.json @@ -35,7 +35,8 @@ "packet": "Молитвенный пакет", "cards": "Молитвенные карточки", "wall": "Настенные панели", - "mapPrint": "Печать карты" + "mapPrint": "Печать карты", + "plugins": "Из других плагинов" }, "tpl": { "mapSheetCountry": "Лист карты страны", @@ -48,7 +49,8 @@ }, "missing": { "bless": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.", - "mapPrint": "Этот шаблон печатает живую карту, на которую смотрит читатель, поэтому у него нет примерного изображения." + "mapPrint": "Этот шаблон печатает живую карту, на которую смотрит читатель, поэтому у него нет примерного изображения.", + "noCountryAtSize": "Сегодня ни одна страна не печатается в этом размере: Индия, единственная, что печаталась, теперь печатается только в 5 × 8 футов. Шаблон показан здесь на примере другой страны." }, "details": { "configName": "Название конфигурации", @@ -60,7 +62,9 @@ "sample": "Примерные данные", "renderer": "Генератор", "legacy": "Устаревший размер: он ещё печатается, но в выборе печати его больше нет.", - "forAgents": "Чтобы использовать этот шаблон, скажите агенту: используй конфигурацию {name}." + "forAgents": "Чтобы использовать этот шаблон, скажите агенту: используй конфигурацию {name}.", + "openPage": "Открыть страницу" }, - "templateCount": "{n} шаблон | {n} шаблона | {n} шаблонов | {n} шаблонов" + "templateCount": "{n} шаблон | {n} шаблона | {n} шаблонов | {n} шаблонов", + "fromPlugin": "Создаётся собственным плагином. «Показать все подробности» покажет его вживую." } diff --git a/library/i18n/locales/ru/posterSections.json b/library/i18n/locales/ru/posterSections.json new file mode 100644 index 000000000..38ebc6512 --- /dev/null +++ b/library/i18n/locales/ru/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "Разделы плакатов", + "intro": "Выберите плакаты, посмотрите их ниже, затем скопируйте тег, чтобы разместить их на любом сайте.", + "type": "Тип плаката", + "typeCountry": "Страны", + "region": "Регион", + "allRegions": "Все регионы", + "language": "Язык", + "startCollapsed": "Начинать свёрнутым", + "embed": "Скопировать код для вставки", + "embedNote": "Вставьте это на любую веб-страницу.", + "copy": "Копировать", + "copied": "Скопировано" +} diff --git a/library/i18n/locales/zh/posterCard.json b/library/i18n/locales/zh/posterCard.json new file mode 100644 index 000000000..dab3b8a31 --- /dev/null +++ b/library/i18n/locales/zh/posterCard.json @@ -0,0 +1,4 @@ +{ + "showDashboard": "查看仪表板", + "showDashboardAria": "查看 {label} 仪表板" +} diff --git a/library/i18n/locales/zh/posterDesign.json b/library/i18n/locales/zh/posterDesign.json index 2003add93..f54ea4e85 100644 --- a/library/i18n/locales/zh/posterDesign.json +++ b/library/i18n/locales/zh/posterDesign.json @@ -35,7 +35,8 @@ "packet": "祷告资料包", "cards": "祷告卡", "wall": "墙面展板", - "mapPrint": "地图打印" + "mapPrint": "地图打印", + "plugins": "来自其他插件" }, "tpl": { "mapSheetCountry": "国家地图页", @@ -48,7 +49,8 @@ }, "missing": { "bless": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。", - "mapPrint": "此模板打印读者正在查看的实时地图,因此没有示例图片。" + "mapPrint": "此模板打印读者正在查看的实时地图,因此没有示例图片。", + "noCountryAtSize": "如今没有国家以此尺寸印刷:唯一曾以此尺寸印刷的印度,现在只以 5 × 8 英尺印刷。这里用一个示例国家绘制,以展示该模板。" }, "details": { "configName": "配置名称", @@ -60,7 +62,9 @@ "sample": "示例数据", "renderer": "生成器", "legacy": "旧尺寸:仍可打印,但打印选择器已不再提供。", - "forAgents": "要使用此模板,请告诉代理:使用配置 {name}。" + "forAgents": "要使用此模板,请告诉代理:使用配置 {name}。", + "openPage": "打开其页面" }, - "templateCount": "{n} 个模板" + "templateCount": "{n} 个模板", + "fromPlugin": "由其自身插件生成。点击“显示全部详情”可实时查看。" } diff --git a/library/i18n/locales/zh/posterSections.json b/library/i18n/locales/zh/posterSections.json new file mode 100644 index 000000000..1b2819d80 --- /dev/null +++ b/library/i18n/locales/zh/posterSections.json @@ -0,0 +1,14 @@ +{ + "heading": "海报分区", + "intro": "选择海报,在下方查看,然后复制标签,放到任何网站上。", + "type": "海报类型", + "typeCountry": "国家", + "region": "地区", + "allRegions": "所有地区", + "language": "语言", + "startCollapsed": "默认折叠", + "embed": "复制嵌入代码", + "embedNote": "将其粘贴到任何网页中。", + "copy": "复制", + "copied": "已复制" +} diff --git a/library/layouts/pane-wrapper/pane-wrapper.js b/library/layouts/pane-wrapper/pane-wrapper.js index 8cb5013ab..4d61c1ff2 100644 --- a/library/layouts/pane-wrapper/pane-wrapper.js +++ b/library/layouts/pane-wrapper/pane-wrapper.js @@ -172,9 +172,14 @@ export function buildPaneColumn(spec, { host, mountPane }) { return { panes: column, /** Animate to a pane by id — this is what the View fieldset tabs call. */ + // ★ SCROLL THE COLUMN, NEVER THE PAGE. scrollIntoView() scrolls EVERY scrollable ancestor, the embedding + // page included: on another site, "Map" / "Show on map" moved the host's own page so the map's top met the + // window's top, and a host shorter than the window then showed its white space under the map. The column is + // the scroller (position:absolute; overflow-y:auto) and each pane's offsetParent, so its offsetTop is exactly + // the scroll position that puts the pane at the top. scrollTo(paneId) { const target = col.querySelector(`.pane[data-pane-id="${paneId}"]`) - if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }) + if (target) col.scrollTo({ top: target.offsetTop, behavior: 'smooth' }) }, /** Which pane is currently filling the viewport (for URL sync). */ current() { diff --git a/library/packet/countrySheet.js b/library/packet/countrySheet.js index 73ae1d46e..4d05f8c51 100644 --- a/library/packet/countrySheet.js +++ b/library/packet/countrySheet.js @@ -79,7 +79,8 @@ export function seriesFor(countries, item) { * packet and cards are offered on a desktop or laptop only (a phone cannot draw a sheet that big). * The data (country-posters.json) still records why no PAPER sheet fits; this table is what prints instead. */ -export const LARGE_SHEETS = { IND: { format: '60x96', formats: ['60x96', '66x66', '36x60'], desktopOnly: true } } +// India: 5 x 8 ft (60x96) ONLY, the size its whole-country layout is built and tuned for. +export const LARGE_SHEETS = { IND: { format: '60x96', formats: ['60x96'], desktopOnly: true } } const largeOf = (item) => (item && LARGE_SHEETS[String(item.iso3 || '').toUpperCase()]) || null /** * ★ WITHHELD: a whole-country poster that is NOT shipped yet, though the renderer can draw it. India's 855 groups do @@ -89,9 +90,21 @@ const largeOf = (item) => (item && LARGE_SHEETS[String(item.iso3 || '').toUpperC * packet and cards with the same sentence. India's STATE and union-territory posters are separate cards * (`indiaState`) and are not affected. check:country-sheet-one-path fails if India's poster comes back unannounced. */ -export const WITHHELD = new Set(['IND']) -export function sheetWithheld(item) { return Boolean(item && !item.indiaState && WITHHELD.has(String(item.iso3 || '').toUpperCase())) } -export function largeSheet(item) { return sheetWithheld(item) ? null : largeOf(item) } +// WHAT of a whole country is withheld: its POSTER, and/or its ROWS (its prayer list, packet and cards). +// India's whole-country poster is withheld EVERYWHERE BY DEFAULT (it is not released yet). It prints only in an +// explicit PREVIEW context: the Vite DEV server (the pilot's Plugin Studio, where it is reviewed), whose element +// bootstrap sets globalThis.DXM_PREVIEW_UNRELEASED. The read is gated on import.meta.env.DEV too, so every BUILT bundle +// compiles it away: no page, embed or console can set a global and print it from production. Never by a URL +// parameter. Its rows (about a thousand pages) stay withheld everywhere. +export const WITHHELD = { IND: { poster: true, rows: true, preview: true } } +export const previewUnreleased = () => Boolean(import.meta.env && import.meta.env.DEV && globalThis.DXM_PREVIEW_UNRELEASED === true) +const withheldOf = (item) => (item && !item.indiaState && WITHHELD[String(item.iso3 || '').toUpperCase()]) || null +// No caller can open it: the preview state is read HERE, never passed in (so a built bundle cannot print it). +export function posterWithheld(item) { const w = withheldOf(item); return Boolean(w?.poster) && !(w.preview && previewUnreleased()) } +export function rowsWithheld(item) { return Boolean(withheldOf(item)?.rows) } +/** The poster's withholding (the name Poster Design reads). */ +export function sheetWithheld(item) { return posterWithheld(item) } +export function largeSheet(item) { return posterWithheld(item) ? null : largeOf(item) } // The sheet format a request gets: a large-sheet country prints at the large size ASKED FOR when it has one (66x66, // 36x60), else at its default (60x96); a packet's page 1 stays a page; every other country gets exactly what it asked. export function sheetFormatFor(item, format) { @@ -115,7 +128,7 @@ export function makeCountrySheet({ records, assetsBase, countries = null }) { return async function countrySheet(item, format) { const iso3 = item && item.iso3 if (!iso3) return '' // a selection or a row without a country: no sheet - if (sheetWithheld(item)) return '' // withheld (above): nothing is fetched or drawn + if (posterWithheld(item)) return '' // a withheld poster (above): nothing is fetched or drawn // Lazy, like the zone sheet: the generator and the boundary loader are only pulled when a // reader actually prints, so they stay out of every wall's initial download. const { generateCountrySheet } = await import('@map/packet/posterSheets.js') diff --git a/package.json b/package.json index 856a7a813..5d76d14b0 100644 --- a/package.json +++ b/package.json @@ -226,7 +226,6 @@ "check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs", "check:packet-variants": "node internal/check-packet-variants.mjs", "check:pins-in-state": "node library/packet/scripts/check-pins-in-state.mjs", - "check:fork-leaders": "node library/packet/scripts/check-fork-leaders.mjs", "check:label-key-coverage": "node internal/check-label-key-coverage.mjs", "check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs", "check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs", @@ -260,6 +259,7 @@ "check:poster-card-fits": "node internal/check-poster-card-fits.mjs", "check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs", "check:poster-design-catalogue": "node plugin-designs/poster-design/scripts/check-catalogue.mjs", + "check:poster-design-catalogue:self-test": "node plugin-designs/poster-design/scripts/check-catalogue.mjs --self-test", "check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs", "check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs", "check:posters-exportable": "node internal/check-posters-exportable.mjs", @@ -354,8 +354,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 check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only: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:pins-in-state check:fork-leaders 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:side-lanes check:side-lanes:self-test check:poster-section check:poster-section: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 check:shell-header check:pg-name-gate check:preview-flag-dev-only", + "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 check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-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:pins-in-state 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:side-lanes check:side-lanes:self-test check:poster-section check:poster-section: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-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor", "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", @@ -424,8 +424,15 @@ "check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test", "check:poster-section": "node plugin-designs/country-posters/scripts/check-poster-section.mjs", "check:poster-section:self-test": "node plugin-designs/country-posters/scripts/check-poster-section.mjs --self-test", + "check:poster-sections": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs", + "check:poster-sections:self-test": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs --self-test", + "gen:poster-sections-presets": "node plugin-designs/poster-sections/scripts/gen-presets.mjs", "check:side-lane-photo-pixels": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs", - "check:side-lane-photo-pixels:self-test": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs --self-test" + "check:side-lane-photo-pixels:self-test": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs --self-test", + "check:poster-type-registry": "node internal/check-poster-type-registry.mjs", + "check:poster-type-registry:self-test": "node internal/check-poster-type-registry.mjs --self-test", + "check:host-floor": "node internal/check-host-floor.mjs", + "check:host-floor:self-test": "node internal/check-host-floor.mjs --self-test" }, "//scripts": { "_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.", @@ -473,7 +480,7 @@ "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:preview-flag-dev-only": "the flag that previews withheld content (globalThis.DXM_PREVIEW_UNRELEASED, e.g. the whole-India poster) never reaches a built bundle: no file under public/ assigns it in any spelling a minifier writes, and every source assignment is behind import.meta.env.DEV (set only by the Vite dev server, the pilot's hot-reload preview). The built tree goes to the research landing and DX Life's /dx-maps, where the flag would show India publicly. Run the `check:preview-flag-dev-only:self-test` script for the NEGATIVE test (2 planted defects).", + "check:preview-flag-dev-only": "the flag that previews withheld content (globalThis.DXM_PREVIEW_UNRELEASED, e.g. the whole-India poster) never reaches a built bundle: NO file under public/ names it at all (a setter would turn the preview on; a read would mean the withheld path itself shipped), and every source line that reads OR writes it carries import.meta.env.DEV (set only by the Vite dev server, the pilot's hot-reload preview). The built tree goes to the research landing and DX Life's /dx-maps, where the flag would show India publicly. Run the `check:preview-flag-dev-only:self-test` script for the NEGATIVE test (a built setter, a built read, an unguarded source setter and an unguarded source read).", "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