diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md
index d2163e11f..7c33c5c3d 100644
--- a/documentation/reference-library-index.md
+++ b/documentation/reference-library-index.md
@@ -237,6 +237,8 @@ and a change here can break other maps.
`import … from '@map/composables/useSharedMapToken.js'`
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
`import … from '@map/composables/useTextHighlight.js'`
+- **useViewAddress** — useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
+ `import … from '@map/composables/useViewAddress.js'`
### Utilities (pure helper functions)
diff --git a/internal/check-dev-leaf-escape.mjs b/internal/check-dev-leaf-escape.mjs
new file mode 100644
index 000000000..e74478c02
--- /dev/null
+++ b/internal/check-dev-leaf-escape.mjs
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+/**
+ * check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens.
+ *
+ * THE DEFECT. The dev server's leaf route, /__leaf//[/] (and a production embed URL,
+ * /[/]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the
+ * and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path
+ * was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server.
+ * internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile
+ * id and instance id has) before either sink, and escapes the as well.
+ *
+ * The plugin's own middleware answers each request (no server, no browser):
+ * A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with
+ * its element: the rule refuses no real page;
+ * B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or
+ * backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to
+ * Vite, never a page;
+ * C. escHtml leaves none of & < > " ' raw;
+ * D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the
+ * escaped: the title has its own escaping, not only the rule in front of it.
+ * Response bodies are never printed: a leaf page carries the build config's token.
+ * `--self-test`: the old guard, a rule too strict for a real profile, an unescaped and an escHtml that misses
+ * the quote must each FAIL.
+ */
+import { readFileSync, writeFileSync, rmSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { discoverSpaRegistry } from './plugin-gallery/discover.mjs'
+
+const HERE = dirname(fileURLToPath(import.meta.url))
+const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs')
+const SRC = readFileSync(PLUGIN, 'utf8')
+const GUARD = 'parts.some(s => !LEAF_PART.test(s))'
+const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))"
+const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/'
+const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}'
+const ESC_CLASS = `/[&<>"']/g`
+
+// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the
+// file is removed as soon as it is loaded.
+let seq = 0
+async function load(src) {
+ if (src === SRC) return import(pathToFileURL(PLUGIN).href)
+ const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`)
+ writeFileSync(tmp, src)
+ try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) }
+}
+
+// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would).
+function client(mod) {
+ let mw = null
+ mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } })
+ return (url) => new Promise((done) => {
+ const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) }
+ try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) }
+ catch (e) { done({ status: 'threw', body: String(e && e.message) }) }
+ })
+}
+
+// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount.
+function advertised(mod) {
+ const out = []
+ const walk = (o, tag) => {
+ if (!o || typeof o !== 'object') return
+ for (const v of Object.values(o)) {
+ if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag })
+ else walk(v, tag)
+ }
+ }
+ for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0])
+ return [...new Map(out.map((x) => [x.url, x])).values()]
+}
+
+const HOSTILE = ["a'b", 'a"b', 'ab', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'>"]
+
+async function audit(src) {
+ const f = []
+ let mod
+ try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] }
+ const get = client(mod)
+ const leaves = advertised(mod)
+ if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`)
+ for (const { url, tag } of leaves) {
+ for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) {
+ const r = await get(u)
+ if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`)
+ }
+ }
+ const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/')
+ for (const h of HOSTILE) {
+ const e = encodeURIComponent(h)
+ for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) {
+ const leaf = '/__leaf/' + parts.join('/')
+ const r = await get(leaf)
+ if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`)
+ const embed = '/' + parts.join('/') + '/index.html'
+ const r2 = await get(embed)
+ if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`)
+ }
+ }
+ const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null
+ if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`)
+ if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one')
+ else {
+ let loose
+ try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) }
+ if (loose) {
+ const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('x')}`)
+ const title = (/([^]*?)<\/title>/.exec(r.body) || [])[1]
+ if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`)
+ else if (title.includes('') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the raw')
+ }
+ }
+ return f
+}
+
+if (process.argv.includes('--self-test')) {
+ const poisons = [
+ ['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /],
+ ['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /],
+ ['an unescaped ', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /],
+ ['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /],
+ ]
+ let missed = 0
+ for (const [name, src, rx] of poisons) {
+ if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue }
+ const f = await audit(src); const hit = f.find((x) => rx.test(x))
+ console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
+ if (!hit) missed++
+ }
+ if (missed) process.exitCode = 1
+} else {
+ const f = await audit(SRC)
+ if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 }
+ else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the escapes on its own`)
+}
diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs
index 68d526324..32f6e2493 100644
--- a/internal/check-i18n-parity.mjs
+++ b/internal/check-i18n-parity.mjs
@@ -68,6 +68,10 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
// translations, add the locale here with its count and lower it as they arrive.
// Never raise a number; never add a locale that is already complete.
const DEBT = {
+ // 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3): localization is English-only for now.
+ // tStatic falls back to the English text, so these locales show English until the translations arrive;
+ // lower each to 0 then, and empty the map again.
+ ar: 10, de: 10, es: 10, fr: 10, hi: 10, it: 10, pt: 10, ro: 10, ru: 10, zh: 10,
}
const fails = []
diff --git a/internal/check-map-menu-anchor.mjs b/internal/check-map-menu-anchor.mjs
index b25a89aab..0fb0e04e4 100644
--- a/internal/check-map-menu-anchor.mjs
+++ b/internal/check-map-menu-anchor.mjs
@@ -39,7 +39,19 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/)
if (!m) {
fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.')
} else {
- const expr = m[1]
+ let expr = m[1]
+ // The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button
+ // re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found.
+ const fn = expr.match(/^\s*(\w+)\(\)/)
+ if (fn) {
+ const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}'))
+ // every return that resolves a named area must carry its structural fallback on the SAME expression
+ // a same-line comment is not a fallback: strip `//…` before testing each return
+ const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1].replace(/\s*\/\/.*$/, '')) : []
+ expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '')
+ if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`)
+ else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`)
+ }
if (!/offsetParent|parentElement|document\.body/.test(expr)) {
fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n'
+ ` ${expr.trim()}\n`
diff --git a/internal/check-view-address.mjs b/internal/check-view-address.mjs
new file mode 100644
index 000000000..96a44083e
--- /dev/null
+++ b/internal/check-view-address.mjs
@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+// check:view-address — the page's address (library/composables/useViewAddress.js) and its ONE history write.
+// A. the pure helpers: the id whitelist + 64 cap; an unknown or malformed value reads as absent (the default opens);
+// the level rule (a tab change drops pane and place, a pane change drops place, unless the change sets them);
+// foreign params keep their exact text.
+// B. the writes, on a simulated browser history: an 'app' owner pushes once and keeps history.state with `current`
+// and the hash; an unchanged address writes nothing; a partner page (no owner) writes nothing; a 'host' owner
+// writes nothing and hands the host one dxm-address event; nested elements announce dxm-view; Back/Forward is
+// only read, never written.
+// C. COEXISTENCE with the filters (useShareableView, which writes through the same commitUrl): a filter write keeps
+// tab/pane/place, the hash or the query it does not own, and the router's history.state; a tab/pane/place write
+// keeps the filters (query AND hash form); both survive Back and Forward, and re-applying the old tab after Back
+// writes nothing, so Forward still exists.
+// D. THE CENSUS of history writers, enumerated from the tree: every history.pushState/replaceState call in library/
+// and plugin-designs/ is the helper's or a listed writer. A new one fails until it is listed here with its reason.
+// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser.
+import { execFileSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
+
+// The history writers that exist today besides the helper, by call count. Adding one means a row here with a reason.
+const KNOWN = {
+ 'library/composables/useViewAddress.js': [1, 'THE write (commitUrl)'],
+ 'library/utils/languageRequest.js': [1, '?lang= on a generated page only (MAP_OWNS_PAGE); keeps state and every other param'],
+ 'library/layouts/app-wrapper-material/index.html': [1, "the layout page's own #/
-
+
+
diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue
index 85b0ee19f..6258f9459 100644
--- a/plugin-designs/plugin-studio/plugin-studio.vue
+++ b/plugin-designs/plugin-studio/plugin-studio.vue
@@ -225,6 +225,7 @@ import StudioTree from './src/StudioTree.vue'
import { LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js'
import { publishMapToken, clearMapToken } from '@map/composables/useSharedMapToken.js'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
+import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, commitUrl } from '@map/composables/useViewAddress.js'
// The reader's own map token / token-service URL. localStorage only: never written into the build,
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
@@ -762,11 +763,20 @@ export default defineComponent({
.catch(() => {})
}
this.loadManifest()
+ // Back / Forward re-open the tab the address names; they never write (useViewAddress.js says why).
+ this._offAddress = onAddressChange(({ tab }) => {
+ const key = this.keyOfTab(tab)
+ if (key) this.select(key, { fromHistory: true })
+ else if (this.rows.length) this.select(this.rows[0].key, { fromHistory: true })
+ })
},
// The corner-widget hosts live on window.__doxaStudioWidgets so a HOT UPDATE remounts Studio onto
// the same hosts and a live widget (Rudder) keeps its state. A real unmount must release them, or
// the hosts and every widget element in them stay pinned on the page after Studio is gone.
beforeUnmount() {
+ // FIRST, before the hot-update return below: a remounted Studio adds its own listener, and a kept one would
+ // re-apply every Back twice.
+ if (this._offAddress) { this._offAddress(); this._offAddress = null }
if (import.meta.hot) return
const W = window.__doxaStudioWidgets
if (W && W.hosts) for (const h of Object.values(W.hosts)) { try { h.remove() } catch (_) {} }
@@ -1003,20 +1013,45 @@ export default defineComponent({
// list" instead of a tab. It opens the first tab, remembers in localStorage the tab you were on,
// and a reload goes back to that tab.
//
- // 1. the URL hash — an explicit, shareable request always wins over a remembered one
- // 2. localStorage — what you were last looking at, so a reload is not a fresh start
- // 3. the first row — never leave the pane empty
+ // 1. ?tab= — the view address (useViewAddress.js): a reload and a SENT link open it
+ // 2. the URL hash — the old shareable form (#bundle/profile); rewritten to ?tab= on first paint
+ // 3. localStorage — what you were last looking at, so a reload is not a fresh start
+ // 4. the first row — never leave the pane empty
+ //
+ // The first select() is a REPLACE (first paint); a reader's click is a PUSH (one Back step), written only
+ // where this page owns its address (urlOwner 'app', baked into our own leaf page; a partner's page and a
+ // framed leaf only read).
//
// Called once after the manifest lands, so `rows` is populated and every candidate can be
// checked against it. A remembered key whose bundle was renamed or removed simply fails that
// check and falls through to the first row — a stale key must never render an empty pane, which
// is the very state this replaces.
route() {
+ const first = { firstPaint: true }
+ const asked = this.keyOfTab(readViewKeys(typeof location !== 'undefined' ? location.search : '').tab)
+ if (asked) { this.select(asked, first); return }
const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '')
- if (h && this.rows.some(r => r.key === h)) { this.select(h); return }
+ if (h && this.rows.some(r => r.key === h)) {
+ this.select(h, first)
+ // The old #bundle/profile link now reads as ?tab=; its hash goes, so the address has ONE form.
+ if (urlOwnerOf(this.cfg) === 'app') commitUrl({ hash: '' })
+ return
+ }
const remembered = this.readLastTab()
- if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered); return }
- if (this.rows.length) this.select(this.rows[0].key)
+ if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered, first); return }
+ if (this.rows.length) this.select(this.rows[0].key, first)
+ },
+ // ★ A ROW'S TAB ID. A row key is "/", and the address takes plain ids only (no "/"), so the tab
+ // WRITTEN is "__": stable, since a profile name another bundle adopts later cannot make a sent
+ // link mean a different row. READ: that form, or a bare profile name while it names exactly one row (a typed
+ // ?tab=poster-styles), else '' (the default tab opens).
+ tabIdOf(key) { return String(key || '').replace('/', '__') },
+ keyOfTab(id) {
+ if (!id) return ''
+ const full = id.includes('__') ? id.replace('__', '/') : ''
+ if (full && this.rows.some(r => r.key === full)) return full
+ const byProfile = this.rows.filter(r => r.key.split('/')[1] === id)
+ return byProfile.length === 1 ? byProfile[0].key : ''
},
// Storage is keyed per HOST (pathname), because two cap-5 shells served from one origin would
// otherwise fight over a single key and each reload would yank the other's tab.
@@ -1034,7 +1069,7 @@ export default defineComponent({
writeLastTab(key) {
try { window.localStorage.setItem(this.storageKey(), key) } catch (_) { /* storage unavailable */ }
},
- select(key) {
+ select(key, { fromHistory = false, firstPaint = false } = {}) {
// ★ BAIL BEFORE TOUCHING STATE if `key` names no real row. A composition-folder's TOP row can be
// a SYNTHETIC head (see compositionTrees) for a composite that cannot list itself as one of its own
// tabs (plugin-studio nesting would be infinite regress — same reasoning
@@ -1062,6 +1097,8 @@ export default defineComponent({
// Remember it AFTER confirming the row exists, so a bad key is never written back — otherwise
// one stray select() would poison the next reload with a key that fails route()'s check.
this.writeLastTab(key)
+ // The view address: a reader's choice is a push, the first paint a replace, Back/Forward never writes.
+ if (!fromHistory) writeViewKeys({ tab: this.tabIdOf(key) }, { owner: urlOwnerOf(this.cfg), push: !firstPaint, el: this.$el })
// A heavy row's slot only exists after the v-if subtree renders, so wait a tick before
// mounting into it — on the first activation the ref is null at this point.
if (!row.mounted) this.$nextTick(() => { if (this.activeKey === row.key) this.mountElement(row) })
diff --git a/plugin-designs/poster-design/poster-sizes.vue b/plugin-designs/poster-design/poster-sizes.vue
index ec2c674b2..19f031522 100644
--- a/plugin-designs/poster-design/poster-sizes.vue
+++ b/plugin-designs/poster-design/poster-sizes.vue
@@ -20,19 +20,14 @@ import { POSTER_LADDER, POSTER_LADDER_ORDER, paperFor } from '@map/constants/pos
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { layoutSvgUrl } from './src/layoutSvg.js'
+import { num, dimsText } from './src/sizeText.js'
const t = useOptionalT('')
const { locale } = useOptionalI18n('')
-// A number as the sizes are spoken: whole, or one decimal place (5.5).
-const n = (x) => (Number.isInteger(x) ? String(x) : String(Math.round(x * 10) / 10))
-// Feet once a side reaches a yard and both sides are whole or half feet (3 × 5.5 ft); inches below that (18 × 24 in).
-function dims(w, h) {
- const ft = (x) => x / 12
- const halfFeet = (x) => Number.isInteger(ft(x) * 2)
- if (Math.min(w, h) >= 36 && halfFeet(w) && halfFeet(h)) return t('posterDesign.sizes.dimsFt', { w: n(ft(w)), h: n(ft(h)) })
- return t('posterDesign.sizes.dimsIn', { w: n(w), h: n(h) })
-}
+// Sizes are named the one way Poster Design names them (src/sizeText.js, shared with Styles).
+const n = num
+const dims = (w, h) => dimsText(w, h, t)
// Standard printer paper is Letter or A4 by the reader's region, as the print picker decides it.
const paperIsA4 = computed(() => {
const l = locale && (locale.value ?? locale)
diff --git a/plugin-designs/poster-design/poster-styles.vue b/plugin-designs/poster-design/poster-styles.vue
index 217031190..2c195b2be 100644
--- a/plugin-designs/poster-design/poster-styles.vue
+++ b/plugin-designs/poster-design/poster-styles.vue
@@ -32,6 +32,7 @@ import { resolvePosterSize } from '@map/constants/posterSizes.js'
import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js'
import FullDetailsDialog from './src/FullDetailsDialog.vue'
import { loadPluginDesigns } from './src/pluginDesigns.js'
+import { sizeText, declaredSizeText } from './src/sizeText.js'
const profileConfig = inject('profileConfig', ref(null))
const instanceId = computed(() => profileConfig.value?.instanceId || 'poster-design-styles')
@@ -49,7 +50,7 @@ function stockLabel(s) {
const dims = `${s.w} × ${s.h} in`
return s.side ? `${noun} ${dims} (${t('wall.stockLandscape')})` : `${noun} ${dims}`
}
-const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${c.size}`)
+const labelOf = (c) => (c.stock ? `${t(c.labelKey)}: ${stockLabel(c.stock)}` : `${t(c.labelKey)}: ${sizeText(c.size, t)}`)
// Designs other plugins declare (their meta's posterDesign, via manifest.json): the built tree's manifest, or the dev
// server's. A name already in templates.js stays the library's (the check refuses the clash; the page never doubles it).
@@ -60,17 +61,34 @@ onMounted(async () => {
const { designs } = await loadPluginDesigns(urls)
pluginDesigns.value = designs.filter((d) => !byName.has(d.name))
})
-const pluginLabel = (d) => {
+// The design's title in the reader's language: the build's per-locale titles (posterDesignLabels; any catalogue
+// area), then this bundle's own catalogue, then English, then the profile's label.
+const pluginTitle = (d) => {
+ const lang = String(getRuntimeLocale() || detectLocale() || 'en').split('-')[0]
+ if (d.titles && d.titles[lang]) return d.titles[lang]
const v = d.labelKey ? t(d.labelKey) : ''
- return `${v && v !== d.labelKey ? v : d.label}: ${d.size}`
+ if (v && v !== d.labelKey) return v
+ return (d.titles && d.titles.en) || d.label
+}
+const pluginLabel = (d) => {
+ return `${pluginTitle(d)}: ${declaredSizeText(d.size, t)}`
}
+// PRODUCTION designs first, by family. EXPERIMENTAL ones (marked in templates.js: still being designed, or no
+// preview) and every other plugin's design (made by its own plugin, so drawn nowhere here) go last, in ONE
+// Experimental group that starts collapsed.
+const isExperimental = (id) => byPluginName.value.has(id) || byName.get(id)?.experimental === true
+const experimentalHeading = computed(() => t('posterDesign.family.experimental'))
// No `count`: these are templates, not lists of people, so the sticker reads "Show full details" alone.
-const items = computed(() => [
- ...configs.map((c) => ({ id: c.name, label: labelOf(c) })),
- ...pluginDesigns.value.map((d) => ({ id: d.name, label: pluginLabel(d) })),
-])
-const groupBy = (item) => (byPluginName.value.has(item.id) ? t('posterDesign.family.plugins') : t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet))
+const items = computed(() => {
+ const all = [
+ ...configs.map((c) => ({ id: c.name, label: labelOf(c) })),
+ ...pluginDesigns.value.map((d) => ({ id: d.name, label: pluginLabel(d) })),
+ ]
+ return [...all.filter((x) => !isExperimental(x.id)), ...all.filter((x) => isExperimental(x.id))]
+})
+const groupBy = (item) => (isExperimental(item.id) ? experimentalHeading.value : t(FAMILY_KEYS[byName.get(item.id)?.family] || FAMILY_KEYS.mapSheet))
+const collapsedBy = (sec) => (sec && sec.name) === experimentalHeading.value // PosterWall passes the section
// ── drawing a configuration from the library ──────────────────────────────────────────────────
const countryById = (id) => (countryData.countries || []).find((c) => c.id === id)
@@ -133,7 +151,17 @@ function panels(sizeKey) {
const size = resolvePosterSize(sizeKey)
return size ? { image: panelsSvgUrl(size.sheets) } : null
}
-const ctx = { countrySheet, cardDeck, packetDocument, panels }
+// A UPG100 zone's sheet, from the library copy of UPG100's zone code: ALL the records (a zone's pins depend on which
+// earlier zone claimed a group first, as on UPG100's own wall), the list from this page's config. Loaded on first use.
+let zoneSheetFn = null
+async function zoneSheet(format, zoneId) {
+ if (!zoneSheetFn) {
+ const { makeZoneSheet } = await import('@map/packet/upg100Zones/zone-sheet-print.js')
+ zoneSheetFn = makeZoneSheet({ allRecords: () => recordsFor.all(), assetsBase: () => assetsBase.value, profileConfig })
+ }
+ return zoneSheetFn({ id: zoneId }, format)
+}
+const ctx = { countrySheet, cardDeck, packetDocument, panels, zoneSheet }
// The printed document's size, measured when it is drawn: what "file size" means in the details.
// render() answers a poster's HTML (a map sheet) or a document { html, page, width, height } (a deck, a packet).
@@ -175,15 +203,15 @@ async function sheet(item) {
// ── "Show full details" ───────────────────────────────────────────────────────────────────────
const open = ref(null)
-const openLabel = computed(() => (!open.value ? '' : open.value.design ? pluginLabel(open.value.design) : labelOf(open.value)))
+const openLabel = computed(() => (!open.value ? '' : open.value.design ? pluginLabel(open.value.design) : labelOf(byName.get(open.value.name))))
// A plugin's design opens in the same dialog, as a configuration: its renderer is the plugin's page, and the dialog
// shows that page live.
function showDetails(item) {
const d = byPluginName.value.get(item.id)
open.value = d
- ? { name: d.name, size: d.size, settings: { family: d.family, mobileFriendly: d.mobileFriendly, ...(d.instance ? { preset: d.instance } : {}) },
+ ? { name: d.name, size: declaredSizeText(d.size, t), settings: { family: d.family, mobileFriendly: d.mobileFriendly, ...(d.instance ? { preset: d.instance } : {}) },
renderer: `plugin-designs/${d.bundle}/${d.profile}.vue`, pageUrl: d.pageUrl, design: d }
- : byName.get(item.id) || null
+ : byName.has(item.id) ? { ...byName.get(item.id), size: sizeText(byName.get(item.id).size, t) } : null
}
const heading = computed(() => t('posterDesign.styles.heading'))
@@ -231,6 +259,7 @@ useShadowStyles(`
unit=""
item-count-key="posterDesign.templateCount"
:collapsible="true"
+ :collapsed-by="collapsedBy"
:header-action="{ labelKey: 'posterDesign.showFullDetails', ariaKey: 'posterDesign.showFullDetailsAria' }"
:preview-src="previewSrc"
:unsupported="withheld"
diff --git a/plugin-designs/poster-design/scripts/check-catalogue.mjs b/plugin-designs/poster-design/scripts/check-catalogue.mjs
index 7a4a7fe17..b020ec949 100644
--- a/plugin-designs/poster-design/scripts/check-catalogue.mjs
+++ b/plugin-designs/poster-design/scripts/check-catalogue.mjs
@@ -102,9 +102,12 @@ for (const c of configs) if (!cat.FAMILY_KEYS[c.family]) fail(`'${c.name}' has f
// 5. the samples
const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8'))
const samples = [...new Set([cat.SAMPLE_COUNTRY_ID, ...configs.map((c) => c.sample).filter(Boolean)])]
+// A UPG100 zone sample (a template with `plugin: 'my-upg-100-list'`) is one of the zones in the library copy's catalogue.
+const ZONE_IDS = (() => { try { const z = JSON.parse(readFileSync(join(ROOT, 'library/packet/upg100Zones/clusters.json'), 'utf8')); return new Set(['lists', 'asia_lists', 'uncovered_lists'].flatMap((k) => (z[k] || []).map((x) => x.id))) } catch { return new Set() } })()
function sampleArm(ids, countries, withheld) {
const fs = []
for (const id of ids) {
+ if (ZONE_IDS.has(id)) continue // a real UPG100 zone (drawn from the library copy)
const s = countries.find((c) => c.id === id)
if (!s) fs.push(`5: sample '${id}' is not in library/data/country-posters.json`)
else if (withheld(s)) continue // its card shows the withheld sentence; it is never drawn here
@@ -134,7 +137,7 @@ function gatherBlocks() {
}
return out
}
-function designArms({ names, blocks, ledger }) {
+function designArms({ names, blocks, ledger, drawnFor = new Map() }) {
const fs = []
for (const { where, block, presets } of blocks) {
const probs = PD.blockProblems(block)
@@ -143,9 +146,15 @@ function designArms({ names, blocks, ledger }) {
if (block.labelKey && !enKey(block.labelKey)) fs.push(`6: ${where} labelKey '${block.labelKey}' is not in the English catalogue`)
if (block.instance && !presets.includes(block.instance)) fs.push(`6: ${where} instance '${block.instance}' is not one of its presets`)
}
- const all = [...names, ...blocks.filter((b) => !PD.blockProblems(b.block).length).map((b) => b.block.name)]
- const seen = new Set()
- for (const n of all) { if (seen.has(n)) fs.push(`7: two designs are named '${n}'`); seen.add(n) }
+ // A template that DRAWS a plugin's design (`plugin: ''`) carries that plugin's own name, so the same name in that
+ // plugin's block is the same design, counted once. Anywhere else a repeated name is a clash.
+ const seen = new Set(names)
+ for (const n of names.filter((x, i) => names.indexOf(x) !== i)) fs.push(`7: two templates are named '${n}'`)
+ for (const b of blocks.filter((x) => !PD.blockProblems(x.block).length)) {
+ const n = b.block.name, plugin = String(b.where).split('/')[0]
+ if (seen.has(n) && drawnFor.get(n) !== plugin) fs.push(`7: two designs are named '${n}'`)
+ seen.add(n)
+ }
for (const n of ledger) if (!seen.has(n)) fs.push(`8: '${n}' is in the names ledger and no longer exists: a shipped name is never renamed or removed (keep it, as a legacy entry if retired)`)
return { fails: fs, fresh: [...seen].filter((n) => !ledger.includes(n)) }
}
@@ -153,7 +162,7 @@ const LEDGER_FILE = join(ROOT, 'plugin-designs/poster-design/src/catalogue-names
const ledger = existsSync(LEDGER_FILE) ? JSON.parse(readFileSync(LEDGER_FILE, 'utf8')).names || [] : []
if (!ledger.length) fail('8: the names ledger (src/catalogue-names.json) is missing or empty')
const blocks = gatherBlocks()
-const real = { names: configs.map((c) => c.name), blocks, ledger }
+const real = { names: configs.map((c) => c.name), blocks, ledger, drawnFor: new Map(configs.filter((c) => c.plugin).map((c) => [c.name, c.plugin])) }
if (process.argv.includes('--self-test')) {
const example = { family: 'regionPoster', name: 'region-poster.66x66', size: '66x66', mobileFriendly: true }
@@ -162,6 +171,7 @@ if (process.argv.includes('--self-test')) {
['an invalid block (mobileFriendly missing)', { ...real, blocks: [...blocks, B({ family: 'regionPoster', name: 'region-poster.x', size: '66x66' })] }, /^6: .*mobileFriendly/],
['a name clash with templates.js', { ...real, blocks: [...blocks, B({ ...example, name: real.names[0] })] }, /^7: /],
['a renamed ledger name', { ...real, names: real.names.map((n, i) => (i === 0 ? `${n}-renamed` : n)) }, /^8: /],
+ ['another plugin reusing a name a template draws for UPG100', { ...real, blocks: [...blocks, B({ ...example, name: 'upg100-territory-poster.3x3' })] }, /^7: /],
['a block naming a preset that does not exist', { ...real, blocks: [...blocks, B({ ...example, instance: 'nowhere' }, ['africa'])] }, /^6: .*instance/],
]
let ok = true
diff --git a/plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs b/plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs
new file mode 100644
index 000000000..ea8837194
--- /dev/null
+++ b/plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+/**
+ * check:upg100-zone-copy — library/packet/upg100Zones/ is UPG100's zone-poster code, copied so Poster Design (and any
+ * page) can draw a UPG100 zone poster from the library. Two modes, chosen by what is on the tree:
+ *
+ * COPY while UPG100's originals (plugin-designs/my-upg-100-list/data/) exist: every library file equals
+ * its original with ONLY the import specifiers normalised (the copy's library paths are shorter), and
+ * clusters.json byte for byte. A changed literal, a renamed variable, any edit in a function body FAILS:
+ * change both, or change the plugin and copy it again.
+ * ONE-PATH once UPG100 has switched to the library copy and deleted its originals: no plugin file may define the
+ * zone functions again (one definition, one owner), as check:country-sheet-one-path does for countries.
+ * Half the originals present = FAIL (a switch left halfway).
+ *
+ * --self-test: a one-character edit inside a function body, a changed clusters.json and a missing copy each FAIL; a
+ * change to an import specifier alone PASSES (the normalisation is no wider than the specifiers).
+ */
+import { readFileSync, existsSync, readdirSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
+const LIB = join(ROOT, 'library/packet/upg100Zones')
+const PLUGIN = join(ROOT, 'plugin-designs/my-upg-100-list')
+const FILES = ['zone-sheet-print.js', 'zone-sheet.js', 'legend-tree.js', 'selector-evaluator.js', 'zone-geometry.js', 'zones-for-list.js', 'clusters.json']
+const DEFINES = /export\s+(?:async\s+)?function\s+(makeZoneSheet|upg100Zones|assignPins|seriesFacts|resolveZoneGeometry|zonesForList|buildLegendNodes)\b/
+
+// Only the quoted specifier of a static import/export-from, a dynamic import() or a bare import is replaced.
+export const normalise = (s) => s
+ .replace(/(\bfrom\s*)(['"])[^'"\n]+\2/g, '$1$2§$2')
+ .replace(/(\bimport\(\s*)(['"])[^'"\n]+\2/g, '$1$2§$2')
+ .replace(/(^\s*import\s*)(['"])[^'"\n]+\2/gm, '$1$2§$2')
+
+export function arms(read) {
+ const fails = []
+ const originals = FILES.filter((f) => read('plugin', f) != null)
+ if (originals.length && originals.length < FILES.length) fails.push(`half switched: UPG100 still has ${originals.join(', ')} but not ${FILES.filter((f) => !originals.includes(f)).join(', ')}`)
+ for (const f of FILES) if (read('lib', f) == null) fails.push(`the library copy has no ${f}`)
+ if (originals.length === FILES.length) {
+ for (const f of FILES) {
+ const a = read('plugin', f), b = read('lib', f)
+ if (b == null) continue
+ const same = f.endsWith('.json') ? a === b : normalise(a) === normalise(b)
+ if (!same) fails.push(`${f}: the library copy differs from UPG100's original beyond its import paths (change both, or copy again)`)
+ }
+ return { mode: 'COPY', fails }
+ }
+ if (!originals.length) {
+ for (const f of read('pluginFiles')) { const m = (read('pluginAbs', f) || '').match(DEFINES); if (m) fails.push(`one path: ${f} defines ${m[1]} again; the library copy is the only one`) }
+ return { mode: 'ONE-PATH', fails }
+ }
+ return { mode: 'HALF', fails }
+}
+
+const disk = (overrides = {}) => (where, f) => {
+ const key = `${where}:${f}`
+ if (key in overrides) return overrides[key]
+ if (where === 'pluginFiles') {
+ const out = []; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (e.isDirectory()) { if (!/node_modules|assets/.test(e.name)) walk(p) } else if (/\.(m?js|vue)$/.test(e.name)) out.push(p) } }
+ walk(PLUGIN); return out
+ }
+ const p = where === 'pluginAbs' ? f : join(where === 'lib' ? LIB : join(PLUGIN, 'data'), f)
+ return existsSync(p) ? readFileSync(p, 'utf8') : null
+}
+
+if (process.argv.includes('--self-test')) {
+ const real = disk()
+ const body = real('lib', 'zone-sheet.js')
+ const at = body.indexOf('return ', body.indexOf('export function upg100Zones'))
+ const spec = real('lib', 'zone-sheet-print.js')
+ const cases = [
+ ['a one-character edit inside a function body', { 'lib:zone-sheet.js': body.slice(0, at) + 'return ' + body.slice(at + 7) }, true],
+ ['a changed clusters.json', { 'lib:clusters.json': real('lib', 'clusters.json').replace('"id"', '"id "') }, true],
+ ['a missing copy', { 'lib:selector-evaluator.js': null }, true],
+ // ONE-PATH (after UPG100 switches): no originals, and a plugin file that defines the zone code again must fail
+ ['one path: a plugin file defining makeZoneSheet again', { ...Object.fromEntries(FILES.map((f) => [`plugin:${f}`, null])), 'pluginFiles:undefined': ['/fake/again.js'], 'pluginAbs:/fake/again.js': 'export function makeZoneSheet() {}' }, true],
+ ['one path: no originals and no second definition', { ...Object.fromEntries(FILES.map((f) => [`plugin:${f}`, null])), 'pluginFiles:undefined': [] }, false],
+ ['half switched: some originals deleted', { 'plugin:zone-geometry.js': null }, true],
+ ['an import specifier changed alone', { 'lib:zone-sheet-print.js': spec.replace("from './zone-sheet.js'", "from './zone-sheet.js?v=2'") }, false],
+ ]
+ let ok = true
+ for (const [name, ov, shouldFail] of cases) {
+ const { fails } = arms(disk(ov))
+ const good = shouldFail ? fails.length > 0 : fails.length === 0
+ console.log(` ${good ? '✓' : '✗'} ${name}: ${fails.length ? 'FAILS' : 'passes'} (${shouldFail ? 'must fail' : 'must pass'})${fails[0] ? ' -> ' + fails[0] : ''}`)
+ if (!good) ok = false
+ }
+ console.log(ok ? 'UPG100-ZONE-COPY SELF-TEST: PASS' : 'UPG100-ZONE-COPY SELF-TEST: FAIL'); process.exit(ok ? 0 : 1)
+}
+const { mode, fails } = arms(disk())
+if (fails.length) { console.log(`UPG100-ZONE-COPY: FAIL (${mode}, ${fails.length})`); fails.forEach((f) => console.log(` - ${f}`)); process.exit(1) }
+console.log(`UPG100-ZONE-COPY: PASS (${mode}) — the ${FILES.length} files of library/packet/upg100Zones/ ${mode === 'COPY' ? "equal UPG100's originals beyond their import paths" : 'are the only definition of the zone code'}.`)
diff --git a/plugin-designs/poster-design/src/FullDetailsDialog.vue b/plugin-designs/poster-design/src/FullDetailsDialog.vue
index 06d73ab3a..cdf6d3e42 100644
--- a/plugin-designs/poster-design/src/FullDetailsDialog.vue
+++ b/plugin-designs/poster-design/src/FullDetailsDialog.vue
@@ -63,7 +63,7 @@ async function copyName() {