Compare commits

...

10 commits

Author SHA1 Message Date
Doxa Maps
0ed4c815b4 check:view-address: each shell's P2b wiring (addressDepth passed down, a claim with depth, its own subtab read), 56 poisons 2026-10-03 08:02:28 +00:00
Doxa Maps
c81e8af2eb Merge Plugin Studio: a shell in a tab learns its address depth (shareable pages P2b) 2026-10-03 08:00:36 +00:00
Doxa Maps
2757ef4302 Merge Posterverse: nested in another owner's page, its tab is in t (shareable pages P2b) 2026-10-03 08:00:35 +00:00
Doxa Maps
526d1414a7 Merge Research Gallery nested in an app that owns the address: its (shareable pages P2b) 2026-10-03 08:00:35 +00:00
Doxa Maps
aeb6317f40 Merge P2b: a refused tab claim (a hole) keeps the rest of its anno (shareable pages P2b) 2026-10-03 08:00:35 +00:00
Doxa Maps
728970b2bf P2b: a refused tab claim (a hole) keeps the rest of its announcement (studio's 5B note); 53 poisons 2026-10-03 08:00:32 +00:00
Doxa Maps
07c4deae26 Posterverse: nested in another owner's page, its tab is in the address as a ?subtab= segment
Nested (no urlOwner), the shell's pick is claimed with its depth, and the owner above records it as
this shell's ?subtab= segment. First paint claims the current tab as a replace, so the segment exists
before a deeper one; a link that names a row arriving with the manifest is still claimed only once the
row is known. The shell reads its tab from its own segment first, then ?tab= only when it names one of
its rows (a one-level partner page), on load and on Back/Forward. Each child is told its depth: 1 under
an owning shell, one deeper otherwise. Pages where the shell owns the address are unchanged.
2026-10-03 07:54:42 +00:00
Doxa Maps
b1e86c0973 Plugin Studio: a shell in a tab learns its address depth
Each pane's profile-config now carries addressDepth (1 below Studio while Studio owns the address), so a nested shell such as the Research Gallery or Posterverse writes its own tab as ?subtab= at that depth instead of losing it.
2026-10-03 07:54:14 +00:00
Doxa Maps
8b34ed86dc Research Gallery nested in an app that owns the address: its tab is that app's subtab segment (shareable pages P2b)
Not the owner: a rail pick announces the tab with this shell's depth (claimView),
and an outer owner such as Plugin Studio records it as its subtab segment; on a
partner's page nobody listens and nothing is written. Once the tab is settled
after the manifest (never at mount, so a link's Experimental tab is honoured),
the shell names it once with a replace, so a deeper shell's claim always has
this segment to sit under. On load it reads its own subtab segment when it names
one of its tabs, then tab when it does (a partner's page), else its default.
Back follows the segment only once the shell knows an owner records it.
Children are told their depth: 1 under an owner, else this shell's depth + 1.
As the owner (its own leaf, DX Life) the code path is unchanged.
2026-10-03 07:53:36 +00:00
Doxa Maps
00f7d349eb Shareable pages P2b: nested shells at any depth have their tab in the address (?subtab= path)
When a shell sits inside another plugin that owns the address (Plugin Studio → the Research Gallery → …), its own
tab had no key. P1 never wrote it from a nested shell, and P2's ownAddress dropped a nested `tab`, so every tab
inside Studio had the same URL.

`subtab` is a PATH, one segment per nested shell below the owner: ?subtab=regions-dashboard at depth 1, and
?subtab=posterverse.upg-100 when a shell sits inside a shell. There are at most 6 segments, each a plain id.
- A nested shell claims its tab with claimView({ tab }, { depth }), where depth comes from its profile-config
  addressDepth (depthOf; its parent passes it down).
- The owner records the claim as that segment: the segments above are kept, the deeper ones and pane/place dropped.
- The same tab again is a no-op (a shell's first-paint claim on a deep link keeps everything below it).
- A segment missing above the claimed depth (a hole) is refused.
- The owner's own tab change drops the whole path.
- subtabAt(search, depth) gives a shell its segment.
Standalone pages and DX Life never see subtab: there the shell is the owner and writes `tab`.

check:view-address H covers depth 1, 2 and 3: first-paint replace, pick push, a composite's pane/place beside
subtab, the same tab claimed again, Back, and the owner's tab change. It has 9 new poisons (52).
2026-10-03 07:50:25 +00:00
6 changed files with 196 additions and 38 deletions

View file

@ -22,6 +22,9 @@
// reader's scroll settles is a replace; Back/Forward scroll and never write; nested it announces; without the
// opt-in it never writes; a kept-alive column shown again claims its pane back (a replace), unless the address
// names one.
// H. NESTED SHELLS at any depth: a nested shell's tab is recorded as segment `depth` of ?subtab= (never as the
// owner's tab); a hole is refused; the same tab again is a no-op; a segment change drops the deeper segments and
// pane/place; depth 1, 2 and 3; Back steps a segment; the owner's tab change drops every segment.
// G. THE WIRING in Regions: regions-mfe opts its column in, owns its leaf, and carries the place with its pane;
// region-dashboards claims ?place= (replace at first paint, push on a choice) and re-applies it on Back. And
// each shell (Plugin Studio, the Research Gallery, Posterverse) records its nested composites' choices
@ -115,7 +118,13 @@ async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
for (const bad of ['a'.repeat(65), '<b>', 'a b', '-lead', '../x', 'x?y', 'x#y', '']) {
if (va.cleanKey(bad) !== '') fails.push(`A: the whitelist lets ${JSON.stringify(bad.slice(0, 20))} through`)
}
eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', pane: '', place: 'angola' })
eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', subtab: '', pane: '', place: 'angola' })
// the subtab PATH: plain-id segments joined by '.', at most 6; anything else reads as absent
eq('A: a subtab path reads segment by segment', ['?subtab=a', '?subtab=a.b-c.d_e', '?subtab=a..b', '?subtab=.a', '?subtab=a.', '?subtab=a.b%20c', '?subtab=1.2.3.4.5.6', '?subtab=1.2.3.4.5.6.7', '?subtab=-a'].map((q) => va.readViewKeys(q).subtab), ['a', 'a.b-c.d_e', '', '', '', '', '1.2.3.4.5.6', '', ''])
eq('A: subtabAt reads one depth', [va.subtabAt('?subtab=a.b.c', 1), va.subtabAt('?subtab=a.b.c', 3), va.subtabAt('?subtab=a.b.c', 4), va.subtabAt('', 1)], ['a', 'c', '', ''])
eq('A: depthOf', [{ addressDepth: 3 }, { addressDepth: '2' }, { addressDepth: 99 }, { addressDepth: 0 }, {}, null].map(va.depthOf), [3, 2, 6, 1, 1, 1])
eq('A: a tab change drops subtab, pane and place', va.viewSearch('?tab=a&subtab=x.y&pane=p&place=c', { tab: 'b' }), '?tab=b')
eq('A: a subtab change drops pane and place, keeps tab', va.viewSearch('?tab=a&subtab=x&pane=p&place=c', { subtab: 'z' }), '?tab=a&subtab=z')
eq('A: a tab change drops pane and place', va.viewSearch('?tab=a&pane=b&place=c&lang=ar', { tab: 'd' }), '?tab=d&lang=ar')
eq('A: a pane change keeps tab and place (siblings)', va.viewSearch('?tab=a&pane=b&place=c', { pane: 'e' }), '?tab=a&pane=e&place=c')
eq('A: a place change keeps tab and pane', va.viewSearch('?tab=a&pane=b&place=c', { place: 'f' }), '?tab=a&pane=b&place=f')
@ -246,15 +255,61 @@ async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
if (b.writes.push !== 1 || b.writes.replace) fails.push('E: ownAddress wrote while the page has no owner')
if (e2.cancelBubble) fails.push('E: an announcement an element does not own was stopped (the real owner further out never hears it)')
owner = 'app'
const before = b.at()
fire({ tab: 'posters' })
if (b.at() !== before) fails.push(`E: a nested announcement switched the OWNER's tab: ${b.at()}`)
off(); fire({ place: 'benin' })
if (b.writes.push !== 1) fails.push('E: ownAddress off() left its listener')
b.hist.back()
if (b.at() !== '/shell?tab=regions&lang=ar') fails.push(`E: Back after a pane + place click did not return in one step: ${b.at()}`)
}
// ── H. NESTED SHELLS at any depth: Plugin Studio owns the address; shells nested at depth 1, 2, 3 claim their tabs ──
{
const b = browser('https://x.test/?tab=doxa-maps-spa__doxa-maps-spa')
const el = new EventTarget()
const off = va.ownAddress(el, () => 'app')
const shell = (depth, tab, push = true) => { const e = new CustomEvent(va.VIEW_EVENT, { bubbles: true, composed: true, detail: { changes: { tab }, push, depth } }); el.dispatchEvent(e); return e }
const comp = (changes) => el.dispatchEvent(new CustomEvent(va.VIEW_EVENT, { bubbles: true, composed: true, detail: { changes, push: true } }))
const q = () => b.q()
shell(2, 'upg-100', false)
if (b.writes.push + b.writes.replace) fails.push(`H: a depth-2 claim with no depth-1 segment (a hole) was written: ${b.at()}`)
shell(1, 'big-picture', false)
if (q().get('tab') !== 'doxa-maps-spa__doxa-maps-spa' || q().get('subtab') !== 'big-picture' || b.writes.replace !== 1 || b.writes.push) fails.push(`H: a depth-1 first-paint claim is not ONE replace into subtab (the owner's tab kept): ${b.at()}`)
shell(1, 'regions-dashboard')
if (q().get('subtab') !== 'regions-dashboard' || b.writes.push !== 1) fails.push(`H: a depth-1 pick is not ONE push of subtab: ${b.at()}`)
comp({ pane: 'dashboard', place: 'angola' })
if (b.at() !== '/?tab=doxa-maps-spa__doxa-maps-spa&subtab=regions-dashboard&pane=dashboard&place=angola') fails.push(`H: a composite under a nested shell did not add pane + place beside subtab: ${b.at()}`)
shell(1, 'regions-dashboard', false)
if (b.at() !== '/?tab=doxa-maps-spa__doxa-maps-spa&subtab=regions-dashboard&pane=dashboard&place=angola') fails.push(`H: the same tab claimed again dropped deeper keys: ${b.at()}`)
shell(1, 'posterverse')
shell(2, 'country-posters', false)
if (q().get('subtab') !== 'posterverse.country-posters' || q().get('pane') || q().get('place')) fails.push(`H: depth 2 is not the second segment, or the depth-1 change kept pane/place: ${b.at()}`)
shell(2, 'upg-100')
shell(3, 'map', false)
if (q().get('subtab') !== 'posterverse.upg-100.map') fails.push(`H: depth 3 is not the third segment: ${b.at()}`)
// a deep link's outer shell claims its (same) tab at first paint: the deeper segments must survive
shell(1, 'posterverse', false); shell(2, 'upg-100', false)
if (q().get('subtab') !== 'posterverse.upg-100.map') fails.push(`H: the same tab claimed again dropped deeper segments: ${b.at()}`)
shell(2, 'poster-design')
if (q().get('subtab') !== 'posterverse.poster-design') fails.push(`H: a depth-2 change did not drop depth 3 (or touched depth 1): ${b.at()}`)
if (q().get('tab') !== 'doxa-maps-spa__doxa-maps-spa') fails.push(`H: a nested shell's claim changed the OWNER's tab: ${b.at()}`)
if (va.subtabAt(location.search, 1) !== 'posterverse' || va.subtabAt(location.search, 2) !== 'poster-design' || va.subtabAt(location.search, 3) !== '') fails.push('H: subtabAt does not give each depth its own segment')
const pushes = b.writes.push
b.hist.back()
if (q().get('subtab') !== 'posterverse.upg-100.map') fails.push(`H: Back did not return to the previous nested tab: ${b.at()}`)
b.hist.forward()
va.writeViewKeys({ tab: 'posterverse__posterverse' }, { owner: 'app' })
if (b.at() !== '/?tab=posterverse__posterverse' || b.writes.push !== pushes + 1) fails.push(`H: the owner's tab change did not drop every subtab segment: ${b.at()}`)
off()
}
{
// a tab claim that is refused (a hole) must not take the rest of its announcement with it
const b = browser('https://x.test/?tab=doxa-maps-spa__doxa-maps-spa')
const el = new EventTarget()
const off = va.ownAddress(el, () => 'app')
el.dispatchEvent(new CustomEvent(va.VIEW_EVENT, { bubbles: true, composed: true, detail: { changes: { tab: 'x', place: 'benin' }, push: false, depth: 3 } }))
if (b.q().get('place') !== 'benin' || b.q().get('subtab')) fails.push(`H: a refused tab claim dropped the pane/place announced with it: ${b.at()}`)
off()
}
// ── F. pane-wrapper's address, on a simulated column (the slow arm: it waits out the scroll debounce) ──
globalThis.document = { createElement: () => new FakeEl() }
if (!skipF) {
@ -384,6 +439,14 @@ async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
if (!/ownAddress\(stage\.value, owner\)/.test(t)) fails.push(`G: ${f} opts its column in but does not record its panes' choices (ownAddress)`)
else if (!/offOwn\?\.\(\)/.test(t)) fails.push(`G: ${f} never releases ownAddress`)
}
// P2b: every shell tells the shells it nests their depth below the address owner; a shell that can be nested
// (the Research Gallery, Posterverse) claims its tab WITH its depth and reads its own subtab segment.
for (const [f, t] of Object.entries(src.shells || {})) {
if (!/addressDepth = urlOwnerOf\(this\.cfg\) \? 1 : depthOf\(this\.cfg\) \+ 1/.test(t)) fails.push(`G: the shell ${f} does not pass addressDepth to the shells it nests`)
if (f.startsWith('plugin-studio/')) continue
if (!/claimView\(\{ tab: [^}]+\}, \{[^}]*\bdepth\b/.test(t)) fails.push(`G: the shell ${f} does not claim its tab with its depth`)
if (!/subtabAt\(location\.search, depthOf\(this\.cfg\)\)/.test(t)) fails.push(`G: the shell ${f} does not read its own subtab segment when nested`)
}
// Each shell that owns a page records its nested composites' choices, and releases the listener on unmount
// BEFORE any hot-update early return (or a hot remount stacks a second one).
for (const [f, t] of Object.entries(src.shells || {})) {
@ -419,6 +482,15 @@ const src = {
}
const counts = census()
const real = { va, sv, counts, pw, src }
// useViewAddress with one line changed (it has no imports, so a sibling copy loads as is).
async function vaVariant(from, to) {
const f0 = join(ROOT, 'library/composables/useViewAddress.js')
const s = readFileSync(f0, 'utf8')
if (!s.includes(from)) throw new Error('poison anchor not found in useViewAddress.js: ' + from)
const f = join(ROOT, `library/composables/.tmp-check-view-address-${process.pid}-${nth++}.js`)
writeFileSync(f, s.replace(from, to))
try { return await import(f) } finally { unlinkSync(f) }
}
// A pane-wrapper with one line changed, loaded from a sibling file so its relative import still resolves.
let nth = 0
async function pwVariant(from, to) {
@ -469,7 +541,17 @@ if (process.argv.includes('--self-test')) {
['pane-wrapper retrying a pending link by rAF only when there is no observer', { ...real, pw: await pwVariant("if (pending && typeof requestAnimationFrame !== 'undefined') {", "if (pending && typeof ResizeObserver === 'undefined' && typeof requestAnimationFrame !== 'undefined') {") }, /^F: with an observer that never fires again/],
['pane-wrapper ignoring a link', { ...real, pw: await pwVariant("let pending = known(linked) && linked !== column[0].id ? linked : ''", "let pending = ''") }, /^F: a link to \?pane=dashboard did not open/],
['pane-wrapper writing without the opt-in', { ...real, pw: await pwVariant("=> address && claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address.owner(),", "=> claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address ? address.owner() : 'app',") }, /^F: a column WITHOUT the address opt-in/],
['ownAddress that lets a nested element switch the tab', { ...real, va: { ...va, ownAddress: (el, g) => { const h = (e) => { const o = g(); if (o) { e.stopPropagation(); va.writeViewKeys(e.detail.changes, { owner: o, el, push: e.detail.push }) } }; el.addEventListener(va.VIEW_EVENT, h); return () => el.removeEventListener(va.VIEW_EVENT, h) } } }, /^E: a nested announcement switched the OWNER's tab/],
// P2b: nested shells at any depth
['a nested shell\'s tab dropped (the P2 behaviour)', { ...real, va: await vaVariant(' if (tab) {', ' if (false) {') }, /^H: a depth-1 first-paint claim/],
['a depth-2 claim that ignores its depth (overwrites segment 1)', { ...real, va: await vaVariant('path.slice(0, depth - 1).concat(tab)', '[tab]') }, /^H: depth 2 is not the second segment|^H: a depth-2 claim with no depth-1/],
['a hole that is not refused', { ...real, va: await vaVariant('if (path.length >= depth - 1 && path[depth - 1] !== tab)', 'if (path[depth - 1] !== tab)') }, /^H: a depth-2 claim with no depth-1 segment/],
['a refused tab claim that drops the rest of its announcement', { ...real, va: await vaVariant('if (path.length >= depth - 1 && path[depth - 1] !== tab) changes.subtab', 'if (path.length < depth - 1 || path[depth - 1] === tab) return; changes.subtab') }, /^H: a refused tab claim dropped/],
['the same tab claimed again is not a no-op', { ...real, va: await vaVariant('if (path.length >= depth - 1 && path[depth - 1] !== tab)', 'if (path.length >= depth - 1)') }, /^H: the same tab claimed again dropped deeper/],
['a subtab change that keeps pane and place', { ...real, va: await vaVariant('const LEVEL = { tab: 0, subtab: 1, pane: 2, place: 2 }', 'const LEVEL = { tab: 0, subtab: 2, pane: 2, place: 2 }') }, /^A: a subtab change drops pane and place|^H: depth 2 is not the second segment, or the depth-1 change kept/],
["an owner's tab change that keeps subtab", { ...real, va: await vaVariant('const LEVEL = { tab: 0, subtab: 1, pane: 2, place: 2 }', 'const LEVEL = { tab: 0, subtab: 0, pane: 2, place: 2 }') }, /^A: a tab change drops subtab|^H: the owner's tab change did not drop/],
['a subtab path longer than 6', { ...real, va: await vaVariant('return p.length <= MAX_DEPTH && p.every', 'return p.length <= 99 && p.every') }, /^A: a subtab path reads segment by segment/],
['a subtab path with an empty segment', { ...real, va: await vaVariant('p.every((x) => cleanKey(x) === x && x)', 'p.every((x) => !x || cleanKey(x) === x)') }, /^A: a subtab path reads segment by segment/],
['ownAddress that lets a nested element switch the tab', { ...real, va: { ...va, ownAddress: (el, g) => { const h = (e) => { const o = g(); if (o) { e.stopPropagation(); va.writeViewKeys(e.detail.changes, { owner: o, el, push: e.detail.push }) } }; el.addEventListener(va.VIEW_EVENT, h); return () => el.removeEventListener(va.VIEW_EVENT, h) } } }, /^H: /],
['pane-wrapper not claiming its pane when shown again', { ...real, pw: await pwVariant("if (typeof ResizeObserver !== 'undefined') {", 'if (false) {') }, /^F: a kept-alive column shown again/],
['pane-wrapper letting the screen beat an address that names a pane', { ...real, pw: await pwVariant('if (known(asked) && asked !== current()) show(asked, \'instant\')\n else if (!asked) claimPane', 'if (false) show(asked, \'instant\')\n else claimPane') }, /^F: shown again with an address that names a pane/],
['pane-wrapper whose scrollTo(id, null) throws (the old default)', { ...real, pw: await pwVariant('const { write = true, push = true, changes = {} } = o || {}', 'const { write = true, push = true, changes = {} } = o === undefined ? {} : o') }, /^F: scrollTo\(id, null\)|^THREW: .*null/],
@ -478,6 +560,9 @@ if (process.argv.includes('--self-test')) {
['regions-mfe without the address opt-in', { ...real, src: { ...src, mfe: src.mfe.replace('{ host: stage.value, address: { owner } }', '{ host: stage.value }') } }, /^G: regions-mfe does not opt its column in/],
['a composite that opts in without ownAddress', { ...real, src: { ...src, opted: { ...src.opted, 'plugin-designs/x/x-mfe.vue': "column = buildPaneColumn(spec.value, { host: stage.value, address: { owner } })" } } }, /^G: plugin-designs\/x\/x-mfe\.vue opts its column in but does not record/],
['no composite opted in at all', { ...real, src: { ...src, opted: {} } }, /^G: no composite opts its pane column/],
['Plugin Studio not passing addressDepth', { ...real, src: { ...src, shells: { ...src.shells, 'plugin-studio/plugin-studio.vue': src.shells['plugin-studio/plugin-studio.vue'].replace('pc.addressDepth = urlOwnerOf(this.cfg) ? 1 : depthOf(this.cfg) + 1', 'void 0') } } }, /^G: the shell plugin-studio\/plugin-studio\.vue does not pass addressDepth/],
['Posterverse claiming its tab without its depth', { ...real, src: { ...src, shells: { ...src.shells, 'posterverse/posterverse.vue': src.shells['posterverse/posterverse.vue'].replace(', depth: depthOf(this.cfg) })', ' })') } } }, /^G: the shell posterverse\/posterverse\.vue does not claim its tab with its depth/],
['the Research Gallery not reading its subtab segment', { ...real, src: { ...src, shells: { ...src.shells, 'doxa-maps-spa/doxa-maps-spa.vue': src.shells['doxa-maps-spa/doxa-maps-spa.vue'].split('subtabAt(location.search, depthOf(this.cfg))').join('readViewKeys(location.search).tab') } } }, /^G: the shell doxa-maps-spa\/doxa-maps-spa\.vue does not read its own subtab/],
['Plugin Studio without ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'plugin-studio/plugin-studio.vue': src.shells['plugin-studio/plugin-studio.vue'].replace('this._offOwn = ownAddress(this.$el, () => urlOwnerOf(this.cfg))', 'this._offOwn = null') } } }, /^G: the shell plugin-studio\/plugin-studio\.vue does not record/],
['Plugin Studio releasing ownAddress after its hot-update return', { ...real, src: { ...src, shells: { ...src.shells, 'plugin-studio/plugin-studio.vue': src.shells['plugin-studio/plugin-studio.vue'].replace(' if (this._offOwn) { this._offOwn(); this._offOwn = null }\n if (import.meta.hot) return', ' if (import.meta.hot) return\n if (this._offOwn) { this._offOwn(); this._offOwn = null }') } } }, /^G: the shell plugin-studio\/plugin-studio\.vue does not release/],
['the Research Gallery without ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'doxa-maps-spa/doxa-maps-spa.vue': src.shells['doxa-maps-spa/doxa-maps-spa.vue'].replace('this._offOwn = ownAddress(', 'this._offOwn = (') } } }, /^G: the shell doxa-maps-spa/],

View file

@ -4,8 +4,12 @@
* link on every host, while a partner's page is never written to.
*
* ★ THE SCHEME (query, not hash: a hash is a host page's anchor, and filters already live there).
* ?tab=<app tab>&pane=<view inside the tab>&place=<selection>
* Levels: changing `tab` drops `pane` and `place`, which belong to the tab. `pane` and `place` are siblings: a
* ?tab=<app tab>&subtab=<nested shells' tabs>&pane=<view inside the tab>&place=<selection>
* `subtab` is a PATH, one segment per shell NESTED below the owner (Plugin Studio → the Research Gallery → …):
* ?subtab=regions-dashboard at depth 1, ?subtab=posterverse.upg-100 when a shell sits inside a shell. A nested
* shell's depth comes from its profile-config `addressDepth` (depthOf), which its parent shell passes down.
* Levels: changing `tab` drops `subtab`, `pane` and `place`, which belong to the tab; changing a subtab segment drops
* the deeper segments and `pane` and `place`. `pane` and `place` are siblings: a
* composite keeps its selection while the reader looks at another of its panes (Regions keeps the region while the
* reader is on the list), so the address keeps it too. Every other parameter (a host's
* own, the filters useShareableView writes) and the hash are kept as they are. A value that is not a plain id
@ -34,8 +38,11 @@
* Pure helpers, no dependency. Safe under node and in a sandboxed frame (every access is guarded).
*/
export const VIEW_KEYS = ['tab', 'pane', 'place']
/** nested element → the address owner: { changes: { tab?, pane?, place? }, push }. Composed + bubbling, so it leaves
export const VIEW_KEYS = ['tab', 'subtab', 'pane', 'place']
const LEVEL = { tab: 0, subtab: 1, pane: 2, place: 2 }
const MAX_DEPTH = 6
/** nested element → the address owner: { changes: { tab?, pane?, place? }, push, depth }. A nested SHELL's tab comes
* with its depth and is recorded as that segment of `subtab`. Composed + bubbling, so it leaves
* shadow roots. One event per reader action, so one action is one Back step. */
export const VIEW_EVENT = 'dxm-view'
/** an owner with urlOwner 'host' → the host's router: { keys, href, push }; the host does router[push ? 'push' : 'replace'](href). */
@ -43,14 +50,22 @@ export const ADDRESS_EVENT = 'dxm-address'
/** A plain id (letters, digits, `-`, `_`, at most 64), or '' for anything else. */
export const cleanKey = (v) => { const s = String(v ?? '').trim(); return /^[A-Za-z0-9][\w-]{0,63}$/.test(s) ? s : '' }
/** A subtab PATH: plain-id segments joined by '.', at most MAX_DEPTH; '' if any segment is not a plain id. */
const cleanPath = (v) => { const p = String(v ?? '').trim().split('.'); return p.length <= MAX_DEPTH && p.every((x) => cleanKey(x) === x && x) ? p.join('.') : '' }
const clean = (k, v) => (k === 'subtab' ? cleanPath(v) : cleanKey(v))
/** A nested shell's depth below the address owner: profile-config addressDepth, 1 when absent. */
export const depthOf = (cfg) => Math.min(MAX_DEPTH, Math.max(1, Math.floor(Number(cfg && cfg.addressDepth)) || 1))
/** { tab, pane, place } from a search string; '' for a key that is absent or not a plain id. The FIRST of a repeated
* key decides, as URLSearchParams.get does for the shells that already read ?tab=; a malformed escape reads as absent. */
export function readViewKeys(search) {
const q = new URLSearchParams(search || '')
return Object.fromEntries(VIEW_KEYS.map((k) => [k, cleanKey(q.get(k))]))
return Object.fromEntries(VIEW_KEYS.map((k) => [k, clean(k, q.get(k))]))
}
/** The tab a shell nested at `depth` should open: that segment of ?subtab=, or ''. */
export const subtabAt = (search, depth = 1) => readViewKeys(search).subtab.split('.')[depth - 1] || ''
// Bubbling + composed, so the event leaves every shadow root on its way to the owner or the host.
const send = (el, type, detail) => !!el?.dispatchEvent && (el.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })), true)
@ -61,10 +76,10 @@ const send = (el, type, detail) => !!el?.dispatchEvent && (el.dispatchEvent(new
*/
export function viewSearch(search, changes = {}) {
const next = readViewKeys(search)
VIEW_KEYS.forEach((k, level) => {
if (!(k in changes) || cleanKey(changes[k]) === next[k]) return
next[k] = cleanKey(changes[k])
if (!level) VIEW_KEYS.slice(1).forEach((d) => { if (!(d in changes)) next[d] = '' })
VIEW_KEYS.forEach((k) => {
if (!(k in changes) || clean(k, changes[k]) === next[k]) return
next[k] = clean(k, changes[k])
VIEW_KEYS.forEach((d) => { if (LEVEL[d] > LEVEL[k] && !(d in changes)) next[d] = '' })
})
const all = VIEW_KEYS.filter((k) => next[k]).map((k) => k + '=' + next[k])
.concat((search || '').replace(/^\?/, '').split('&').filter((p) => p && !VIEW_KEYS.includes(p.split('=')[0])))
@ -108,28 +123,36 @@ export function writeViewKeys(changes, { owner = '', push = true, el = null } =
}
// Only the view keys, each value cleaned: what an announcement may carry.
const viewChanges = (changes) => Object.fromEntries(Object.entries(changes || {}).filter(([k]) => VIEW_KEYS.includes(k)).map(([k, v]) => [k, cleanKey(v)]))
const viewChanges = (changes) => Object.fromEntries(Object.entries(changes || {}).filter(([k]) => VIEW_KEYS.includes(k)).map(([k, v]) => [k, clean(k, v)]))
/** A nested element reports a reader's choice to whoever owns the address (it never writes itself). */
export function announceView(el, changes, { push = true } = {}) {
export function announceView(el, changes, { push = true, depth = 1 } = {}) {
const c = viewChanges(changes)
return Object.keys(c).length > 0 && send(el, VIEW_EVENT, { changes: c, push })
return Object.keys(c).length > 0 && send(el, VIEW_EVENT, { changes: c, push, depth })
}
/** THE call for a reader's choice: the owner writes it ('written' | 'announced' | 'unchanged' | 'none'); any other
* element announces it to the owner above it ('announced' | 'none'). */
export const claimView = (changes, { owner = '', el = null, push = true } = {}) =>
owner ? writeViewKeys(changes, { owner, el, push }) : (announceView(el, changes, { push }) ? 'announced' : 'none')
export const claimView = (changes, { owner = '', el = null, push = true, depth = 1 } = {}) =>
owner ? writeViewKeys(changes, { owner, el, push }) : (announceView(el, changes, { push, depth }) ? 'announced' : 'none')
/** An owner records what the elements inside it announce: their pane and place. `tab` is the owner's own key, so a
* nested element cannot switch it. getOwner() is read per event (a host may set profile-config after mount); while
* it is '' the event passes on to an owner further out. Returns off(). */
/** An owner records what the elements inside it announce. `tab` is the owner's own key, so a nested element's tab
* (a nested SHELL's) is recorded as segment `depth` of `subtab`, never as `tab`. The segments above it are kept and
* the deeper ones dropped. The same tab again changes nothing, which keeps the deeper segments, pane and place: a
* shell's first-paint claim on a deep link. A segment above it that is missing (a hole) is refused, so a path never
* names the wrong depth. getOwner() is read per event (a host may set profile-config after mount); while it is ''
* the event passes on to an owner further out. Returns off(). */
export function ownAddress(el, getOwner) {
const h = (e) => {
const owner = getOwner()
if (!owner || !e.detail) return
e.stopPropagation()
const { tab, ...changes } = viewChanges(e.detail.changes)
const { tab, subtab, ...changes } = viewChanges(e.detail.changes)
if (tab) {
const depth = Math.min(MAX_DEPTH, Math.max(1, Math.floor(e.detail.depth) || 1))
const path = readViewKeys(location.search).subtab.split('.').filter(Boolean)
if (path.length >= depth - 1 && path[depth - 1] !== tab) changes.subtab = path.slice(0, depth - 1).concat(tab).join('.')
}
if (Object.keys(changes).length) writeViewKeys(changes, { owner, el, push: e.detail.push !== false })
}
el?.addEventListener?.(VIEW_EVENT, h)

View file

@ -725,8 +725,8 @@
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
"check:poster-section": "the reusable poster section (<dxm-poster-section>): the type registry has 'country'; DX Life's /regions slugs (and every other spelling of a region) each show their countries, every country once; a withheld whole-country poster stays withheld through the type; the element's attributes map to its config; PosterSection.vue injects every name its template uses.",
"check:poster-section:self-test": "a registry without 'country', a region key without \"&\" → \"and\", and a section that uses a name it does not inject must each make check:poster-section fail.",
"check:view-address": "the page's address (library/composables/useViewAddress.js: ?tab= &pane= &place=) and its ONE history write, on a simulated browser history: the id whitelist + 64 cap and the level rule; an app owner's tab change is one push that keeps history.state with current (Vue Router's field) and the hash; nothing is written when unchanged, on a partner page (no urlOwner), or by a host owner (it hands the host one composed dxm-address). Coexistence with the filters (useShareableView writes through the same commitUrl): a filter write keeps tab/pane/place, a tab/pane/place write keeps the filters, both survive Back and Forward, and re-applying the tab after Back writes nothing. Nested choices (P2): claimView writes for the owner and announces for anyone else; ownAddress records announcements only while it owns, so a pane and its selection are one Back step; pane-wrapper's opt-in ?pane= (a link opens it, a click pushes, a settled scroll replaces, Back never writes; the default pane is never written); the Regions wiring (?pane= on regions-mfe, ?place= on region-dashboards instead of its #slug). Plus the census: every history.pushState/replaceState in library/ and plugin-designs/ source is the helper or a listed writer. Pure node, no build.",
"check:view-address:self-test": "29 poisons must each make check:view-address fail. P1: a write without history.state.current, a write when not the owner, a tab change that keeps a stale place, no length cap, a write when unchanged, a host owner writing history, a dxm-address that cannot leave a shadow root, a filter write that drops tab/pane/place, the old replaceState(null) filter write, a tab write that drops the filters, a tab change written as a replace, a popstate handler that writes, a new history writer, useShareableView or region-dashboards writing history itself again. P2: a pane change that drops place, a non-owner claim that writes, an announcement carrying any key, ownAddress writing with no owner or letting a recorded announcement bubble on, pane-wrapper writing the default pane / a click's selection separately / a push where a scroll settles / on Back / ignoring a link / without the opt-in, and regions-mfe or region-dashboards wired without the address.",
"check:view-address": "the page's address (library/composables/useViewAddress.js: ?tab= &pane= &place=) and its ONE history write, on a simulated browser history: the id whitelist + 64 cap and the level rule; an app owner's tab change is one push that keeps history.state with current (Vue Router's field) and the hash; nothing is written when unchanged, on a partner page (no urlOwner), or by a host owner (it hands the host one composed dxm-address). Coexistence with the filters (useShareableView writes through the same commitUrl): a filter write keeps tab/pane/place, a tab/pane/place write keeps the filters, both survive Back and Forward, and re-applying the tab after Back writes nothing. NESTED SHELLS at any depth (P2b): a nested shell's tab is recorded as segment `depth` of ?subtab= (a path), never as the owner's tab; holes refused, the same tab a no-op, depth 1/2/3. Nested choices (P2): claimView writes for the owner and announces for anyone else; ownAddress records announcements only while it owns, so a pane and its selection are one Back step; pane-wrapper's opt-in ?pane= (a link opens it, a click pushes, a settled scroll replaces, Back never writes; the default pane is never written); the Regions wiring (?pane= on regions-mfe, ?place= on region-dashboards instead of its #slug). Plus the census: every history.pushState/replaceState in library/ and plugin-designs/ source is the helper or a listed writer. Pure node, no build.",
"check:view-address:self-test": "56 poisons must each make check:view-address fail: P1 (a write without history.state.current, a non-owner write, a stale place, no length cap, a write when unchanged or judged by the string, a host owner writing history, an uncomposed dxm-address, filter writes that drop tab/pane/place or wipe the router state, a tab write that drops the filters, a tab change as a replace, a popstate that writes, new history writers); P2 (the old pane/place level rule, non-owner claims, announcements carrying any key, ownAddress misrecording or stacking, pane-wrapper writing the default pane / splitting a click / pushing on a settle / writing on Back / ignoring or losing a fresh-load link / writing without the opt-in / not reclaiming when shown again, the shells and composites wired without ownAddress); P2b (a nested shell's tab dropped, a depth ignored, a hole accepted, the same tab not a no-op, a refused claim dropping the rest, subtab changes keeping pane/place, an owner tab change keeping subtab, paths over 6 or with an empty segment, shells not passing addressDepth / claiming without depth / not reading their segment).",
"check:poster-sections": "the Poster Sections page (plugin-designs/poster-sections): every panel choice (each region or All regions, each language, open or collapsed, 1-5 per row) builds <dxm-poster-section> tags that the element's own attribute reader turns back into exactly those choices; All regions is one section per region; Copy embed is the bundle script plus those same tags. The presets file holds, per poster type, exactly an all-regions preset plus one per region its adapter's scopes() returns, so a region the data drops (or gains) cannot drift from the presets; a poster type not on the tree has no presets (its pages could show no posters); and the built preset pages are exactly the presets (no stale page).",
"check:poster-sections:self-test": "a tag builder that drops columns, one that drops collapsed, one that repeats a region under All regions, a preset for a region the type does not have, and a built page for a preset that no longer exists must each make check:poster-sections fail.",
"gen:poster-sections-presets": "writes plugin-designs/poster-sections/poster-sections.instances.json from each poster type's own scopes() (all regions + one preset per region, titled by the type's own section title, then configure); the list check:poster-sections holds the file to.",

View file

@ -216,7 +216,7 @@ import { helpFor } from './src/experimentalHelp.js'
import { TAB_INFO } from './src/tabInfo.js'
import MapInfo from '@map/components/info/MapInfo.vue'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, ownAddress } from '@map/composables/useViewAddress.js'
import { readViewKeys, onAddressChange, urlOwnerOf, ownAddress, claimView, subtabAt, depthOf } from '@map/composables/useViewAddress.js'
// THE HOST PAGE'S LANGUAGE, by the same ladder every nested map already uses (library/i18n
// detectLocale): ?lang= / ?locale= on the page URL, then <html lang>. '' when the page says nothing.
@ -1135,7 +1135,11 @@ export default defineComponent({
// its address: on a partner's page a popstate is the partner's own navigation, and re-reading the URL there
// would snap a host's fixed `tab` attribute back to the default. The owner is read per event: a host may set
// profile-config after mount.
this._offAddress = onAddressChange(({ tab }) => { if (urlOwnerOf(this.cfg)) this.openFromAddress(tab) })
this._offAddress = onAddressChange((k) => {
if (urlOwnerOf(this.cfg)) this.openFromAddress(k.tab)
// nested in an app that records this shell's tab as its `subtab` (Plugin Studio): Back re-opens that one
else if (this._underOwner) this.openFromAddress(subtabAt(location.search, depthOf(this.cfg)))
})
// A nested composite's choice (its open pane, a selected place) reaches the address through this shell, the
// page's one writer, while the page owns its address; a nested element can never switch this shell's tab.
this._offOwn = ownAddress(this.$el, () => urlOwnerOf(this.cfg))
@ -1454,13 +1458,27 @@ export default defineComponent({
if (w >= 0 && !this.isDesktopOnly(want) && !this._honouredTab) this.select(w)
else this.select(i >= 0 && !this.isDesktopOnly(activeId) ? i : this.firstVisibleIndex())
this._honouredTab = true
// NESTED (not the owner): name the settled tab in the owner's address once, as a replace, so a deeper shell's
// claim always has this shell's segment to sit under. Here, not at mount: a link's Experimental tab is only
// honoured once the manifest is in, and a claim at mount would overwrite it with the default.
if (!urlOwnerOf(this.cfg)) this.claimNested(false)
},
// The tab a HOST asked to open first: the `tab` attribute, then profile-config.tab, then ?tab= on
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked. The URL is read
// through useViewAddress: the first ?tab= wins, and a value that is not a plain id reads as absent.
requestedTab() {
let q = ''
try { q = readViewKeys(location.search).tab } catch { /* non-browser */ }
// NOT the owner: inside an app that owns the address (Plugin Studio) this shell's tab is that app's `subtab`, at
// this shell's depth (profile-config addressDepth); `tab` is the outer app's own (a Studio row). So the subtab
// segment first when it names one of these tabs, then `tab` when it does (a partner's page, one level), else
// the default.
try {
const k = readViewKeys(location.search), sub = subtabAt(location.search, depthOf(this.cfg))
const mine = (id) => !!id && this.tabs.some(t => t.id === id)
if (urlOwnerOf(this.cfg)) q = k.tab
else if (mine(sub)) { q = sub; this._underOwner = true }
else q = mine(k.tab) ? k.tab : ''
} catch { /* non-browser */ }
return String(this.tab || this.cfg.tab || q || '').trim()
},
// The index to open on mount: the requested tab when it exists and is enabled at this width,
@ -1523,7 +1541,19 @@ export default defineComponent({
onRailSelect(key) {
const before = this.activeKey
this.onSelect(key)
if (this.activeKey !== before) writeViewKeys({ tab: this.activeKey }, { owner: urlOwnerOf(this.cfg), push: true, el: this.$el })
if (this.activeKey === before) return
// The owner writes ?tab=. Not the owner: the pick is announced (claimNested).
const owner = urlOwnerOf(this.cfg)
if (owner) claimView({ tab: this.activeKey }, { owner, el: this.$el, push: true })
else this.claimNested(true)
},
// Not the owner: an outer owner (Plugin Studio) records this shell's tab as its `subtab` segment at this shell's
// depth; on a partner's page nobody listens and nothing is written. Seeing it recorded is how this shell learns
// that Back should follow the address.
claimNested(push) {
const depth = depthOf(this.cfg)
claimView({ tab: this.activeKey }, { owner: '', el: this.$el, push, depth })
try { if (subtabAt(location.search, depth) === this.activeKey) this._underOwner = true } catch { /* no window */ }
},
// Back / Forward: the tab the address names, else the page's configured first tab (profile-config.tab), else the
// device default. Not the `tab` attribute: a host with a router re-points it from this same popstate, and until
@ -1641,6 +1671,8 @@ export default defineComponent({
// #anchor and retitled the host's browser tab "Regions". A child that syncs its state into the
// URL standalone should read this and stand down; children that never write the URL ignore it.
pc.urlSync = false
// ...and how deep they sit below the address owner: a shell nested here records its tab as `subtab` at that depth
pc.addressDepth = urlOwnerOf(this.cfg) ? 1 : depthOf(this.cfg) + 1
// The MAP's own name for its print header (mapPrintTemplate.js): this tab's localized label. A plugin that
// does not print ignores it.
pc.mapTitle = this.tabLabel(t)

View file

@ -225,7 +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, ownAddress } from '@map/composables/useViewAddress.js'
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, commitUrl, ownAddress, depthOf } 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).
@ -909,6 +909,9 @@ export default defineComponent({
if (tokenUrl) pc.tokenUrl = tokenUrl
if (this.effectiveProvider) pc.provider = this.effectiveProvider
if (this.base) pc.treeBase = this.base
// A shell in a pane (the Research Gallery, Posterverse) writes its own tab one level below Studio's: ?subtab=,
// at this depth. Nothing else reads it.
pc.addressDepth = urlOwnerOf(this.cfg) ? 1 : depthOf(this.cfg) + 1
return pc
},
setLocale(code) {

View file

@ -74,7 +74,7 @@ import BackToTopButton from '@map/components/BackToTopButton.vue'
import ShellLanguageMenu from '@map/components/ShellLanguageMenu.vue'
import { saveLocale } from '@map/layouts/app-wrapper-material/languages.js'
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, ownAddress } from '@map/composables/useViewAddress.js'
import { readViewKeys, claimView, onAddressChange, urlOwnerOf, ownAddress, depthOf, subtabAt } from '@map/composables/useViewAddress.js'
import { translate, LOCALES } from './src/strings.js'
import { SECTIONS, TABS, resolveTabs } from './src/tabs.js'
@ -236,9 +236,11 @@ export default defineComponent({
// A nested plugin's choice inside a tab (?pane=, ?place=) reaches the address through the owner: recorded
// only while this page owns its address, never a tab switch, and the event stops here.
this._offOwn = ownAddress(this.$el, () => urlOwnerOf(this.cfg))
// Back / Forward re-apply the tab from the address and never write (writing would lose Forward).
// Back / Forward re-apply the tab from the address and never write (writing would lose Forward). Nested, this
// shell's tab is its own ?subtab= segment, never the owner's ?tab=.
this._offAddress = onAddressChange(({ tab }) => {
const i = this.tabs.findIndex((t) => t.id === tab)
const id = urlOwnerOf(this.cfg) ? tab : this.nestedTab()
const i = this.tabs.findIndex((t) => t.id === id)
this.select(i >= 0 ? i : 0, { fromHistory: true })
})
this.loadManifest()
@ -268,10 +270,20 @@ export default defineComponent({
let q = ''
try { q = readViewKeys(location.search).tab } catch { /* non-browser */ }
// On a page whose address this shell owns, the URL (a reload, a shared link) beats a baked cfg.tab, or the
// first-paint replace would overwrite the link with the default. Elsewhere the host's config leads.
const order = urlOwnerOf(this.cfg) ? [this.tab, q, this.cfg.tab] : [this.tab, this.cfg.tab, q]
// first-paint replace would overwrite the link with the default. Nested in another owner's page, this shell's
// tab is its ?subtab= segment (a deep link), and the host's config leads the rest.
const order = urlOwnerOf(this.cfg) ? [this.tab, q, this.cfg.tab] : [this.nestedTab(true), this.tab, this.cfg.tab, this.nestedTab()]
return String(order.find((v) => v) || '').trim()
},
// Nested (no owner here): this shell's segment of ?subtab=, else ?tab= when it names one of its own rows (a
// one-level partner page). Rows come from the full list, so a row that only arrives with the manifest still
// counts. An owner's own ?tab= (bundle__profile) never names one of them. segmentOnly: the segment alone.
nestedTab(segmentOnly = false) {
let seg = '', q = ''
try { seg = subtabAt(location.search, depthOf(this.cfg)); q = readViewKeys(location.search).tab } catch { /* non-browser */ }
if (seg || segmentOnly) return seg
return TABS.some((t) => t.id === q) ? q : ''
},
initialIndex() {
const i = this.tabs.findIndex((t) => t.id === this.requestedTab())
return i >= 0 ? i : 0
@ -344,8 +356,9 @@ export default defineComponent({
return '../' + r
},
// The tab is written to the address by the ONE owner (urlOwner in profile-config: 'app' on our own pages,
// 'host' when a host's router writes, absent on a partner's page = read only). A reader's choice is a push;
// first paint is a replace; Back / Forward never write.
// 'host' when a host's router writes). Nested, it is announced, and the owner above records it as this shell's
// ?subtab= segment; on a partner's page nothing above listens. A reader's choice is a push; first paint is a
// replace, and nested it is REQUIRED, so the segment exists before a deeper one; Back / Forward never write.
select(i, { fromHistory = false, firstPaint = false } = {}) {
const prevIndex = this.activeIndex
this.activeIndex = i
@ -358,7 +371,7 @@ export default defineComponent({
if (!t) return
// Tell the host which plugin is showing (CustomEvent 'tab-change', detail[0] = { id, label }).
if (prevIndex !== i || !this._announced) { this._announced = true; this.$emit('tab-change', { id: t.id, label: this.tabLabel(t) }) }
if (!fromHistory) writeViewKeys({ tab: t.id }, { owner: urlOwnerOf(this.cfg), push: !firstPaint, el: this.$el })
if (!fromHistory) claimView({ tab: t.id }, { owner: urlOwnerOf(this.cfg), push: !firstPaint, el: this.$el, depth: depthOf(this.cfg) })
if (!t.scriptUrl) return // applyManifest selects again once the manifest lands
// A heavy tab's slot exists only after its v-if subtree renders, so mount on the next tick.
if (!t.mounted) this.$nextTick(() => { if (this.activeIndex === i) this.mountElement(i) })
@ -380,6 +393,8 @@ export default defineComponent({
if (this.assetsBase) pc.assetsBase = this.assetsBase
// The host owns its URL, hash and title: a nested plugin must not write them.
pc.urlSync = false
// A shell nested in this tab claims the ?subtab= segment one below this shell's own.
pc.addressDepth = urlOwnerOf(this.cfg) ? 1 : depthOf(this.cfg) + 1
// The plugin's own name for its print header: this tab's localized label.
pc.mapTitle = this.tabLabel(t)
return pc