Compare commits

...

2 commits

Author SHA1 Message Date
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
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
3 changed files with 115 additions and 24 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,52 @@ 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()
}
// ── 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) {
@ -419,6 +465,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 +524,16 @@ 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) return', 'if (path[depth - 1] === tab) return') }, /^H: a depth-2 claim with no depth-1 segment/],
['the same tab claimed again is not a no-op', { ...real, va: await vaVariant('if (path.length < depth - 1 || path[depth - 1] === tab) return', 'if (path.length < depth - 1) return') }, /^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/],

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,37 @@ 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) return
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

@ -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) {