Shell header: a phone bar and a rail-bottom language slot for header-off shells, and a shared language menu that asks the host first

VerticalTabsLayout, header off: on a phone a 44px bar (a real grid row) replaces the floating corner
circle, with the rail toggle at the inline start and a #phone-end slot at the end; on a desktop a
#rail-end slot sits at the bottom of the rail (it receives `collapsed`). Neither renders with a header
on, so header-on consumers and desktop layouts without a filler are unchanged. The --map-fab-* footprint
is no longer published; its readers fall back to their plain insets.

ShellLanguageMenu fills either slot: the switcher's languages plus the current one, a native select
under a globe face. A pick dispatches one cancelable, composed, bubbling doxa-language-request on the
host element (library/utils/languageRequest.js). A host that cancels keeps the language; otherwise the
menu emits 'switch' and writes ?lang= only on a page the tree generated.

check:shell-header (gates:pre, Vite SSR) asserts where the bar and the rail slot render, the event's
shape and count, cancel and no-cancel, the ?lang= rule and the language list; its self-test (gates:post)
refuses 11 planted defects. check:nav-reachable now presses the bar's toggle.
This commit is contained in:
Doxa Maps 2026-09-30 23:28:52 +00:00
parent 28cb482c79
commit 25982d717f
7 changed files with 424 additions and 72 deletions

View file

@ -24,6 +24,8 @@ and a change here can break other maps.
`import … from '@map/components/ResearchMapFilterPanel.vue'`
- **ResearchMapSideMenu** — ResearchMapSideMenu.vue — Side drawer specific to the research-map profile.
`import … from '@map/components/ResearchMapSideMenu.vue'`
- **ShellLanguageMenu** — ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
`import … from '@map/components/ShellLanguageMenu.vue'`
- **SideMenuDrawer** — SideMenuDrawer.vue — Slide-in side panel for the map application.
`import … from '@map/components/SideMenuDrawer.vue'`
- **ViewSelect** — ViewSelect.vue — the "SELECT VIEW" switcher: a fieldset + legend wrapping a row of
@ -264,6 +266,8 @@ and a change here can break other maps.
`import … from '@map/utils/html.js'`
- **languageLabel** — languageLabel.js — turn the API's comma-inverted language label into the natural reading.
`import … from '@map/utils/languageLabel.js'`
- **languageRequest** — languageRequest.js — how a shell asks its HOST PAGE before it changes language.
`import … from '@map/utils/languageRequest.js'`
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
`import … from '@map/utils/loadPinnedUmd.js'`
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).

View file

@ -113,10 +113,11 @@ const PROBE = () => {
if (!rail) return { noRail: true }
// Any control that can bring the rail on screen. Both are legitimate: the header toggle when a
// header exists, the floating opener when it does not.
// header exists, the phone bar's ☰ (.vtl-phone-menu, which replaced the floating corner circle) when
// it does not.
const openers = all
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)))
.map(n => ({ kind: /vtl-fab/.test(cls(n)) ? 'fab' : 'header-menu', on: onScreen(n), name: name(n) }))
.filter(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)))
.map(n => ({ kind: /vtl-phone-menu/.test(cls(n)) ? 'phone-bar' : 'header-menu', on: onScreen(n), name: name(n) }))
return {
railOnScreen: onScreen(rail),
@ -173,7 +174,7 @@ for (const t of targets) {
for (const c of (n.children || [])) w(c)
})(document.body)
const cls = (n) => (n.className || '').toString()
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-fab/.test(cls(n)) &&
const btn = all.find(n => n.tagName === 'BUTTON' && /vtl-menu|vtl-phone-menu/.test(cls(n)) &&
n.getBoundingClientRect().width > 0)
if (!btn) return { clicked: false }
btn.click()

View file

@ -0,0 +1,173 @@
#!/usr/bin/env node
/**
* check-shell-header — a header-off shell has ONE way to its tabs and ONE language menu, in the right
* place for the width, and a language pick ASKS the host page first. Headless (Vite SSR, no browser).
*
* THE DESIGN (_qa-cycle/studio-organize/MOBILE-SHELL-HEADER-DESIGN.md): on a phone, a header-off shell
* (the Research Gallery, Posterverse) gets a 44px bar with ☰ at the start and a #phone-end slot at the
* end; on a desktop, a #rail-end slot at the bottom of the rail. The shell fills both with
* library/components/ShellLanguageMenu.vue, whose pick dispatches ONE cancelable
* `doxa-language-request` on the host element: cancelled → the host changes the language, the shell
* does not; not cancelled → the shell switches, and writes ?lang= only on a page the tree generated.
*
* ASSERTS, on the REAL components:
* 1. VerticalTabsLayout renders the phone bar ONLY when narrow AND header-off, #rail-end ONLY when
* wide AND header-off (all four combinations), and never the old floating corner circle.
* 2. one pick dispatches exactly ONE doxa-language-request, detail.language = the code, and
* cancelable, composed and bubbles all true.
* 3. a host that cancels → the menu emits no 'switch' and shows the current language again.
* 4. nobody cancels → the menu emits 'switch' once, and ?lang= is written only when
* window.MAP_OWNS_PAGE === true.
* 5. parity: every SWITCHER_LANGUAGES code has a library/i18n/locales/<code>/ catalogue, and the
* menu always lists the current language (fixture: de, which the switcher hides).
* --self-test plants each defect in a temporary copy and must see every one refused.
* EXIT 0 pass · 1 fail
*/
import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const VTL = 'library/layouts/VerticalTabsLayout.vue'
const MENU = 'library/components/ShellLanguageMenu.vue'
const REQ = 'library/utils/languageRequest.js'
const LANGS = 'library/layouts/app-wrapper-material/languages.js'
const { createServer } = await import('vite')
const { default: vue } = await import('@vitejs/plugin-vue')
const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, ws: false, watch: null }, plugins: [vue()],
resolve: { alias: { '@map': join(ROOT, 'library') } } })
const { createSSRApp, h, reactive } = await import('vue')
const { renderToString } = await import('vue/server-renderer')
// A stand-in window for the ?lang= arm: records replaceState calls, owns the page or not.
function fakeWindow(owns) {
const calls = []
return { calls, MAP_OWNS_PAGE: owns, location: { href: 'https://example.test/leaf/index.html?tab=x' },
history: { state: null, replaceState: (_s, _t, url) => calls.push(url) } }
}
// A stand-in DOM for the pick: the select sits in a shadow root whose host is an EventTarget.
function fakeSelect(value, host) { return { value, getRootNode: () => ({ host }) } }
async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) {
const bad = []
const fail = (what, detail) => bad.push(detail ? `${what} — ${detail}` : what)
// 1. where the bar and the rail-end render. The layout's chrome strings come through tStatic, whose
// locale detection reads <html lang>; SSR has no document, so the render gets a one-field stand-in.
if (typeof globalThis.document === 'undefined') globalThis.document = { documentElement: { lang: 'en' } }
const Layout = (await server.ssrLoadModule('/' + vtl)).default
for (const narrow of [true, false]) for (const header of [true, false]) {
// A copy of the real layout whose data says `narrow` (in a browser mounted() reads it from matchMedia,
// which SSR never runs). A copy, not `extends`: the render function is not inherited through extends.
const Comp = { ...Layout, data() { return { ...Layout.data.call(this, this), narrow } } }
const app = createSSRApp({ render: () => h(Comp, { header, items: [{ key: 'a', label: 'A' }], active: 'a' }, {
'phone-end': () => h('i', { class: 'probe-phone-end' }),
'rail-end': () => h('i', { class: 'probe-rail-end' }) }) })
const html = (await renderToString(app)).replace(/<!--[\s\S]*?-->/g, '') // template comments are not markup
const tag = `narrow=${narrow} header=${header}`
const wantBar = narrow && !header, wantRail = !narrow && !header
if (/class="vtl-pbar"/.test(html) !== wantBar) fail(`the phone bar ${wantBar ? 'is missing' : 'renders'} at ${tag}`)
if (/probe-phone-end/.test(html) !== wantBar) fail(`#phone-end ${wantBar ? 'is not filled' : 'renders'} at ${tag}`)
if (/class="vtl-rail-end"/.test(html) !== wantRail || /probe-rail-end/.test(html) !== wantRail) fail(`#rail-end ${wantRail ? 'is missing' : 'renders'} at ${tag}`)
if (wantBar && !/class="vtl-phone-menu"[^>]*aria-expanded=/.test(html)) fail(`the bar's ☰ has no aria-expanded at ${tag}`)
if (/vtl-fab|--map-fab-/.test(html)) fail(`the old corner circle (or its --map-fab-* footprint) renders at ${tag}`)
if (wantBar && !/has-phone-bar/.test(html)) fail(`the phone bar is not a grid row (.has-phone-bar missing) at ${tag}`)
}
// 2-4. the pick, through the menu's REAL handler (its <script setup> bindings; no DOM needed)
const Menu = (await server.ssrLoadModule('/' + menu)).default
// The menu's setup, run INSIDE an SSR render so its compiled wrapper finds the SSR context; in dev mode a
// <script setup> returns its bindings, so the gate calls the component's real onPick and options.
const mount = async (current) => {
const emitted = []; let b = null
const Probe = { setup() { b = Menu.setup(reactive({ current, collapsed: false }), { emit: (e, ...a) => emitted.push([e, ...a]), expose: () => {}, attrs: {}, slots: {} }); return () => null } }
await renderToString(createSSRApp(Probe))
if (!b || typeof b.onPick !== 'function') throw new Error('ShellLanguageMenu setup did not return its bindings (onPick)')
return { b, emitted }
}
const run = async ({ cancel, owns }) => {
const host = new EventTarget(); const seen = []
host.addEventListener('doxa-language-request', (e) => { seen.push(e); if (cancel) e.preventDefault() })
host.addEventListener('language-change', (e) => seen.push(e))
const win = fakeWindow(owns); const had = globalThis.window; globalThis.window = win
const { b, emitted } = await mount('en'); const sel = fakeSelect('ar', host)
try { b.onPick({ target: sel }) } finally { if (had === undefined) delete globalThis.window; else globalThis.window = had }
return { seen, emitted, sel, win }
}
const up = await run({ cancel: false, owns: true })
const reqs = up.seen.filter((e) => e.type === 'doxa-language-request')
if (up.seen.length !== 1 || reqs.length !== 1) fail('one pick must dispatch exactly ONE doxa-language-request', `saw ${up.seen.map((e) => e.type).join(', ') || 'none'}`)
const e = reqs[0]
if (e && !(e.detail && e.detail.language === 'ar')) fail('the event must carry detail.language', JSON.stringify(e.detail))
if (e && !(e.cancelable && e.composed && e.bubbles)) fail('the event must be cancelable, composed and bubbling', `cancelable=${e.cancelable} composed=${e.composed} bubbles=${e.bubbles}`)
if (up.emitted.filter(([n]) => n === 'switch').length !== 1 || (up.emitted[0] || [])[1] !== 'ar') fail('an uncancelled pick must emit switch("ar") once', JSON.stringify(up.emitted))
if (!(up.win.calls.length === 1 && /[?&]lang=ar\b/.test(up.win.calls[0]) && /[?&]tab=x\b/.test(up.win.calls[0]))) fail('an uncancelled pick on a tree-owned page must write ?lang= (keeping the query)', JSON.stringify(up.win.calls))
const notOwned = await run({ cancel: false, owns: undefined })
if (notOwned.win.calls.length) fail('?lang= was written on a page the tree does not own (MAP_OWNS_PAGE unset)', JSON.stringify(notOwned.win.calls))
const cancelled = await run({ cancel: true, owns: true })
if (cancelled.emitted.some(([n]) => n === 'switch')) fail('a cancelled request must NOT switch the shell', JSON.stringify(cancelled.emitted))
if (cancelled.win.calls.length) fail('a cancelled request must not write ?lang=', JSON.stringify(cancelled.win.calls))
if (cancelled.sel.value !== 'en') fail('after a cancel the menu must show the current language again', cancelled.sel.value)
// 5. parity + the current language
const L = await server.ssrLoadModule('/' + langs)
const missing = L.SWITCHER_LANGUAGES.map((l) => l.code).filter((c) => !existsSync(join(ROOT, 'library/i18n/locales', c)))
if (missing.length) fail('a switcher language has no catalogue', missing.join(', '))
const de = (await mount('de')).b.options
const codes = (de && de.value ? de.value : []).map((l) => l.code)
if (!codes.includes('de')) fail('the menu must list the current language even when the switcher hides it (de)', codes.join(' '))
const en = (await mount('en')).b.options
if ((en && en.value ? en.value : []).map((l) => l.code).join() !== L.SWITCHER_LANGUAGES.map((l) => l.code).join()) fail('with a listed language current, the menu must be exactly the switcher list')
return bad
}
// ── --self-test: each defect planted in a temporary copy must be refused ─────────────────────────────
function plant(rel, suffix, edit, redirect = []) {
const src = readFileSync(join(ROOT, rel), 'utf8')
let out = edit(src)
if (out === src) throw new Error(`poison "${suffix}" did not change ${rel}`)
for (const [from, to] of redirect) out = out.split(from).join(to)
const dot = rel.lastIndexOf('.'); const tmp = rel.slice(0, rel.lastIndexOf('/') + 1) + `.tmp-shellhdr-${suffix}` + rel.slice(dot)
writeFileSync(join(ROOT, tmp), out); PLANTED.push(tmp); return tmp
}
const PLANTED = [] // every temporary copy, removed in finally whatever happens
const POISONS = [
{ name: 'the bar also shows with a header', files: () => ({ vtl: plant(VTL, 'bar-header', (s) => s.replace('phoneBarShown() { return this.narrow && !this.header }', 'phoneBarShown() { return this.narrow }')) }) },
{ name: 'the rail-end also shows on a phone', files: () => ({ vtl: plant(VTL, 'rail-phone', (s) => s.replace('railEndShown() { return !this.narrow && !this.header }', 'railEndShown() { return !this.header }')) }) },
{ name: 'the corner circle is back', files: () => ({ vtl: plant(VTL, 'fab', (s) => s.replace('<div v-if="phoneBarShown" class="vtl-pbar">', '<button v-if="phoneBarShown" class="vtl-fab">☰</button><div v-if="phoneBarShown" class="vtl-pbar">')) }) },
{ name: 'the event is not cancelable', files: () => { const r = plant(REQ, 'nocancel', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, composed: true')); return { menu: plant(MENU, 'm-nocancel', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
{ name: 'the event does not leave the shadow root', files: () => { const r = plant(REQ, 'nocomposed', (s) => s.replace('bubbles: true, composed: true, cancelable: true', 'bubbles: true, cancelable: true')); return { menu: plant(MENU, 'm-nocomposed', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
{ name: 'the detail is Vue-shaped (an array)', files: () => { const r = plant(REQ, 'arr', (s) => s.replace('detail: { language: code }', 'detail: [{ language: code }]')); return { menu: plant(MENU, 'm-arr', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
{ name: 'a second event is fired', files: () => ({ menu: plant(MENU, 'twice', (s) => s.replace(" if (requestLanguage(sel, code)) {", " sel.getRootNode().host.dispatchEvent(new CustomEvent('language-change', { detail: { language: code } }))\n if (requestLanguage(sel, code)) {")) }) },
{ name: 'a cancelled pick still switches', files: () => ({ menu: plant(MENU, 'ignorecancel', (s) => s.replace(' if (requestLanguage(sel, code)) {', ' if (requestLanguage(sel, code) || true) {')) }) },
{ name: '?lang= is written on any page', files: () => { const r = plant(REQ, 'anypage', (s) => s.replace('win.MAP_OWNS_PAGE !== true || ', '')); return { menu: plant(MENU, 'm-anypage', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
{ name: 'the hidden current language is dropped', files: () => ({ menu: plant(MENU, 'nocurrent', (s) => s.replace(' if (cur) list.push(cur)', ' if (cur && !cur.hideFromSwitcher) list.push(cur)')) }) },
{ name: 'a switcher language has no catalogue', files: () => ({ langs: plant(LANGS, 'xx', (s) => s.replace("{ code: 'ru', name: 'Russian',", "{ code: 'xx', name: 'Nowhere', nativeName: 'Nowhere', flag: '' },\n { code: 'ru', name: 'Russian',")) }) },
]
let exit = 0
try {
if (process.argv.includes('--self-test')) {
let caught = 0
for (const p of POISONS) {
try {
const bad = await audit(p.files())
if (bad.length) { caught++; console.log(` ${p.name}: caught (${bad[0].slice(0, 90)})`) }
else { console.log(` ${p.name}: NOT CAUGHT`); exit = 1 }
} finally {
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
}
}
console.log(exit ? `SHELL-HEADER SELF-TEST: FAIL — ${POISONS.length - caught} of ${POISONS.length} defects passed.` : `SHELL-HEADER SELF-TEST: PASS — all ${POISONS.length} planted defects refused.`)
} else {
const bad = await audit()
if (bad.length) { exit = 1; console.log(`SHELL-HEADER: FAIL — ${bad.length} problem(s)`); for (const b of bad) console.log(' ✗ ' + b) }
else console.log('SHELL-HEADER: PASS — the phone bar only on a narrow header-off layout, #rail-end only on a wide one, no corner circle; one cancelable composed doxa-language-request per pick; cancelled → no switch; ?lang= only on tree-owned pages; the switcher has catalogues and the current language is always listed.')
}
} finally {
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
await server.close()
}
process.exit(exit)

View file

@ -0,0 +1,91 @@
<!--
ShellLanguageMenu — the language menu a header-off shell puts in VerticalTabsLayout's #phone-end
(the phone bar) and #rail-end (the bottom of the desktop rail) slots. ONE component for both shells
and both places, so the list, the host contract and the look cannot drift apart.
THE LIST: SWITCHER_LANGUAGES (app-wrapper-material/languages.js) PLUS the current language when it is
one of the hidden ones, so a reader on /de/… always sees Deutsch selected. hideFromSwitcher is the
product's choice and is not changed here.
THE CONTRACT (library/utils/languageRequest.js): a pick first asks the host page with a cancelable
doxa-language-request. Cancelled → nothing happens here; the host sets the shell's `language`, and
`current` follows. Not cancelled → emits 'switch' (the shell runs its own setLocale + saveLocale)
and writes ?lang= only on a page this tree generated.
A NATIVE <select>, laid transparent over its own face: the OS picker on a phone, the keyboard and
screen-reader behaviour of a real select everywhere, and the face (🌐 + the language's own name, or
its code in a collapsed rail) is only paint. The menu opens where the OS puts it (upward at the
bottom of the rail), so it needs no placement prop.
-->
<script setup>
import { computed } from 'vue'
import { SWITCHER_LANGUAGES, getLanguage } from '../layouts/app-wrapper-material/languages.js'
import { icon } from '../layouts/app-wrapper-material/icons.js'
import { requestLanguage, writeOwnedLangParam } from '../utils/languageRequest.js'
import { tStatic } from '../i18n/index.js'
const props = defineProps({
/** The shell's effective language code ('en', 'ar', …). */
current: { type: String, default: 'en' },
/** The desktop rail is collapsed to its icon strip: show the code, not the name. */
collapsed: { type: Boolean, default: false },
})
const emit = defineEmits(['switch'])
const options = computed(() => {
const list = SWITCHER_LANGUAGES.slice()
if (props.current && !list.some((l) => l.code === props.current)) {
const cur = getLanguage(props.current)
if (cur) list.push(cur)
}
return list
})
const currentLang = computed(() => getLanguage(props.current) || { code: props.current, nativeName: props.current })
// The menu's name, in the language the reader is reading (the catalogue's controls.language).
const ariaLabel = computed(() => tStatic('controls.language', props.current) || 'Language')
const globe = icon('globe')
function onPick(e) {
const sel = e.target
const code = sel.value
if (!code || code === props.current) return
if (requestLanguage(sel, code)) {
emit('switch', code)
writeOwnedLangParam(code)
} else {
// The host took the request; show the current language until the host changes it.
sel.value = props.current
}
}
</script>
<template>
<span class="slm" :class="{ collapsed }">
<span class="slm-face" aria-hidden="true">
<span class="slm-ic" v-html="globe"></span>
<span v-if="collapsed" class="slm-code">{{ String(current).toUpperCase() }}</span>
<span v-else class="slm-name" :lang="currentLang.code" :dir="currentLang.dir || null">{{ currentLang.nativeName }}</span>
<span v-if="!collapsed" class="slm-caret">▾</span>
</span>
<select class="slm-select" :value="current" :aria-label="ariaLabel" :title="ariaLabel" @change="onPick">
<option v-for="l in options" :key="l.code" :value="l.code" :lang="l.code" :dir="l.dir || null">{{ l.nativeName }}</option>
</select>
</span>
</template>
<style scoped>
/* 44px tall: the phone bar's end slot and the rail's bottom row are both tap targets. */
.slm { position: relative; display: inline-flex; align-items: center; min-height: 44px; max-width: 100%; box-sizing: border-box; border-radius: 8px; color: #3b463d; }
.slm-face { display: inline-flex; align-items: center; gap: 6px; padding-inline: 10px; min-width: 0; font-size: 13px; font-weight: 500; line-height: 1.2; }
.slm-ic { flex: 0 0 auto; display: inline-flex; color: var(--vtl-muted, #6d766e); }
.slm-ic :deep(svg) { width: 18px; height: 18px; }
.slm-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.slm-code { font-weight: 600; font-size: 12px; letter-spacing: .03em; }
.slm-caret { flex: 0 0 auto; color: var(--vtl-muted, #6d766e); font-size: 11px; }
.slm.collapsed .slm-face { flex-direction: column; gap: 2px; padding-inline: 4px; }
/* The real control: transparent, covering the face, so every tap and key lands on the select. */
.slm-select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; margin: 0; border: 0; cursor: pointer; font-size: 16px; }
.slm:hover { background: var(--vtl-accented, #d1d4cd); }
.slm:focus-within { outline: 2px solid var(--vtl-accent, #92b195); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) { .slm { transition: none; } }
</style>

View file

@ -91,20 +91,11 @@
after a resize back to desktop — the previous broken-restore bug.
-->
<template>
<!-- ★ THE FAB'S FOOTPRINT IS PUBLISHED, NOT GUESSED. .vtl-fab is a 44px circle pinned to the
bottom-left, and it lives in the LAYOUT — outside every plugin's shadow root — so it paints
over plugin content whatever z-index the plugin uses internally (the mobile legend sheet is
z-index:1000 and still loses, because they are different stacking contexts). A scrolled-to-
the-end legend therefore ends UNDER the circle.
A plugin cannot know whether the circle is there, so the layout says so: --map-fab-clearance
is set ONLY while the button is actually rendered, and custom properties inherit through the
flattened tree, so it reaches into each shadow root. No circle, no reserved space.
--map-fab-beside is the same fact for the OTHER axis: where a sibling control must start to
sit NEXT TO the circle rather than on top of it (12px inset + 44px button + 8px gap). A
consumer that hardcoded 64px would silently overlap the day the circle resizes; reading it
means the circle's geometry is stated once, here, by the component that owns it. -->
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header }"
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))', '--map-fab-beside': 'calc(56px + env(safe-area-inset-left))' } : null">
<!-- ★ NO CORNER CIRCLE ANY MORE (the phone bar below replaced it). The layout used to publish
--map-fab-clearance / --map-fab-beside while a floating ☰ sat over the pane's bottom-start
corner; with the ☰ in a bar ABOVE the pane nothing covers plugin content, so neither is set and
every reader of them (BackToTopButton, PinHint, LegendMobile, the grid) takes its own fallback. -->
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header, 'has-phone-bar': phoneBarShown }">
<header class="vtl-top" v-if="header">
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
:aria-label="label('toggleList')">☰</button>
@ -113,25 +104,25 @@
<span class="vtl-meta"><slot name="meta" /></span>
</header>
<!-- ★ HEADERLESS MOBILE ESCAPE HATCH — the ONLY way to reach the rail when there is no header.
FOUND 2026-09-22 by resizing the cap-5 SPA to a phone width. That SPA is header-OFF by
default (the no-stacked-headers rule), so the toggle above — which lives inside
`v-if="header"` — does not exist. On mobile the rail is an off-canvas drawer at
translateX(-100%) with `navOpen` starting false, and the only other toggle (.vtl-collapse)
sits INSIDE the drawer, measured off-screen at x:-244. Net effect: every tab except the one
that happened to open was UNREACHABLE. Measured, not inferred.
<!-- ★ THE PHONE BAR — the way to the rail AND to the language when there is no header.
Header-off shells (the Research Gallery, Posterverse) have no .vtl-top, so on a phone this slim
bar is their only chrome: ☰ at the inline START (right in ar, the edge the drawer opens from)
and a #phone-end slot at the inline END, which the shell fills with its own language menu
(library/components/ShellLanguageMenu.vue). It is a real grid row (.has-phone-bar), so the
drawer and the pane both start below it and it never covers plugin content.
44px: the ☰ and the menu ARE the bar's tap targets; a 40px bar would push their 44px hit
areas into the pane. Rendered ONLY when narrow AND header-off (phoneBarShown), so desktop and
every header-on consumer (plugin-studio) render exactly as before. It replaced the floating
corner circle that did this job before (found 2026-09-22: header-off on a phone, every tab
but the first was unreachable), and check:nav-reachable still presses it. -->
<div v-if="phoneBarShown" class="vtl-pbar">
<button class="vtl-phone-menu" type="button" :aria-expanded="String(navOpen)"
@click="navOpen = !navOpen" :aria-label="label('toggleList')">☰</button>
<span class="vtl-pbar-end"><slot name="phone-end" /></span>
</div>
Rendered ONLY when narrow AND headerless, so no existing consumer can see a change: every
other case is desktop (rail always visible) or header-on (the toggle above already works).
It hides itself while the drawer is open — tapping a tab closes the drawer anyway, and a
floating button over an open drawer would just cover a row. -->
<button v-if="fabShown"
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
<!-- Beside the circle, driving the shell's own scroller. Reads --map-fab-beside, which this
layout publishes only while the circle is up, so with no circle it falls back to the
plain inset instead of leaving a gap where a button used to be. -->
<!-- Driving the shell's own scroller. It reads --map-fab-beside, which nothing sets now that the
corner circle is gone, so it sits at its plain inset. -->
<BackToTopButton v-if="narrow" :target="activeHostEl"
:labels="{ backToTop: T('actions.backToTop', 'Back to top') }" />
@ -142,7 +133,7 @@
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)"
<nav class="vtl-nav" :class="{ 'has-rail-end': railEndShown && !!$slots['rail-end'] }" :aria-label="navLabel" :aria-hidden="String(!railShown)"
:role="$slots.rail ? null : 'tablist'" :aria-orientation="$slots.rail ? null : 'vertical'">
<!-- THE HOST'S WAY HOME (optional). A host page that embeds this layout full-page (DX Life's
/deep-research) has no header of its own there, so it hands in its homepage URL and label
@ -236,6 +227,16 @@
</span>
</button>
</template>
<!-- #rail-end: pinned to the BOTTOM of the desktop rail, where apps put account details. A
header-off shell puts its language menu here (the same ShellLanguageMenu as the phone bar),
so a desktop reader can change language with no header above the pane. Rendered only when
WIDE AND header-off: a header-on consumer keeps its #toolbar menu (never two), and on a
phone the bar's #phone-end carries it. `collapsed` tells the filler the rail is the 72px
icon strip, so it shows the language code instead of its name. -->
<div v-if="railEndShown && $slots['rail-end']" class="vtl-rail-end">
<slot name="rail-end" :collapsed="railCollapsed" />
</div>
</nav>
<main class="vtl-main">
@ -327,6 +328,8 @@ const VTL_SHADOW_CSS = `
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
With no header there is only one region, so declare a single 1fr row and the body fills. */
.vtl.no-header { grid-template-rows:1fr; }
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
@ -337,17 +340,17 @@ const VTL_SHADOW_CSS = `
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
indicator and a notch in landscape. */
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
.vtl-fab:hover { background:#d1d4cd; }
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
.vtl-phone-menu:hover { background:var(--vtl-accented); }
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
.vtl-menu:hover { background:#d1d4cd; }
.vtl-brand { display:inline-flex; align-items:center; }
.vtl-brand:empty { display:none; }
@ -381,6 +384,13 @@ const VTL_SHADOW_CSS = `
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
.vtl-home-ic svg { width:16px; height:16px; }
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
.vtl-nav.has-rail-end > * { flex-shrink:0; }
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
.vtl-grp { margin-bottom:2px; }
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
@ -506,6 +516,7 @@ const VTL_SHADOW_CSS = `
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
main left 72px the content pane starts exactly where the rail ends
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
Change one without the others and the icons drift out of the visible strip or the content
overlaps the rail. Old values were -196 / 60 / 196 / 200.
@ -514,6 +525,7 @@ const VTL_SHADOW_CSS = `
.vtl.rail-collapsed .vtl-main { left:72px; }
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
.vtl.rail-collapsed .vtl-tab-text { display:none; }
@ -572,12 +584,12 @@ const VTL_SHADOW_CSS = `
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
@media (max-width: 760px) {
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
@ -591,7 +603,7 @@ const VTL_SHADOW_CSS = `
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
@media (prefers-reduced-motion: reduce) {
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
}
`
@ -652,14 +664,13 @@ export default defineComponent({
},
computed: {
/**
* ★ ONE SOURCE OF TRUTH FOR THE FLOATING CIRCLE, because two copies of this condition is the
* bug waiting to happen. The button's v-if and the --map-fab-clearance that reserves room for
* it MUST agree: if they drift, you get either a legend whose last row sits under the circle
* (the reported case) or a strip of dead whitespace with no circle to justify it. Neither
* has a symptom a gate can see, so the fix is to make disagreement impossible rather than to
* test for it.
* ★ ONE SOURCE OF TRUTH FOR EACH SLOT'S PLACE. The phone bar and the rail's bottom slot are the
* same control in two places, and exactly one of them may show: the bar on a phone, the rail-end
* on a desktop, and neither when a header is on (its #toolbar carries the menu). The template,
* the grid row (.has-phone-bar) and check:shell-header all read these two, never a copy.
*/
fabShown() { return this.narrow && !this.header && !this.navOpen },
phoneBarShown() { return this.narrow && !this.header },
railEndShown() { return !this.narrow && !this.header },
/**
* The element the back-to-top arrow scrolls. .vtl-host is the shell's scroller
* (overflow-y:auto) and there is one PER TAB, so this follows `active` rather than binding a
@ -922,6 +933,8 @@ export default defineComponent({
content to size it and collapses to 0 (the 1fr row sits empty) → the same 0-height black screen.
With no header there is only one region, so declare a single 1fr row and the body fills. */
.vtl.no-header { grid-template-rows:1fr; }
/* ...except on a phone, where the header-off layout has its slim bar (.vtl-pbar) as a real first row. */
.vtl.no-header.has-phone-bar { grid-template-rows:auto 1fr; }
/* MATERIAL APP BAR = 64px desktop / 56px mobile (2026-09-21), was 51px from 11px padding.
Stated as an explicit min-height rather than left to padding arithmetic, so the number a
reader sees is the number that renders. min-height keeps a wrapping title from clipping.
@ -932,17 +945,17 @@ export default defineComponent({
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block); keep them identical. */
.vtl-top { flex:0 0 auto; display:flex; align-items:center; gap:10px; box-sizing:border-box; min-height:64px; padding:8px 16px; border-bottom:1px solid var(--vtl-border); background:#ffffff; }
.vtl-menu { display:none; font:16px/1 system-ui; color:#3b463d; background:#d1d4cd; border:1px solid #d1d4cd; border-radius:8px; padding:5px 10px; cursor:pointer; }
/* Headerless-mobile drawer opener. Only ever rendered when narrow AND header-off AND the drawer is
shut, so it cannot appear in any layout that already has a working toggle. z-index sits ABOVE the
content but BELOW the drawer (.vtl-nav is z-index:20) so the drawer covers it when it slides in.
44px is the minimum comfortable touch target; the safe-area insets keep it clear of an iOS home
indicator and a notch in landscape. */
/* THE CORNER CIRCLE NEVER MOVES: 40px, 8px from the bottom-start corner of the layout (plus the safe area). It does not
ride above a legend sheet or follow a scroll; it sits over the sheet's corner, and the collapsed sheet pads its
start edge by --map-fab-beside so its caret and title clear it. A fixed spot cannot fly or re-animate. */
.vtl-fab { position:absolute; z-index:15; left:calc(8px + env(safe-area-inset-left)); bottom:calc(8px + env(safe-area-inset-bottom)); width:40px; height:40px; display:flex; align-items:center; justify-content:center; font:18px/1 system-ui; color:#3b463d; background:#ffffff; border:1px solid var(--vtl-border); border-radius:50%; box-shadow:0 2px 10px rgba(0,0,0,.18); cursor:pointer; }
.vtl-fab:hover { background:#d1d4cd; }
.vtl-fab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:2px; }
/* THE PHONE BAR (phoneBarShown = narrow AND header-off; it replaced the floating corner circle).
A real grid row above .vtl-body (.has-phone-bar below), so the drawer and the pane both start under
it and it never covers plugin content. 44px + the top safe area; the rule line is an INSET SHADOW,
not a border, so the 44px is the whole bar and the ☰ is a full 44×44 target. Flex follows the
writing direction, so in ar the ☰ sits at the right (where the drawer opens) and the #phone-end
menu at the left, with no :dir override. THIS RULE EXISTS TWICE; keep the copies identical. */
.vtl-pbar { display:flex; align-items:center; gap:8px; box-sizing:border-box; height:calc(44px + env(safe-area-inset-top)); padding:env(safe-area-inset-top) calc(4px + env(safe-area-inset-right)) 0 calc(4px + env(safe-area-inset-left)); background:#ffffff; box-shadow:inset 0 -1px 0 var(--vtl-border); }
.vtl-phone-menu { flex:0 0 auto; width:44px; height:44px; display:inline-flex; align-items:center; justify-content:center; padding:0; margin:0; font:20px/1 system-ui; color:#3b463d; background:transparent; border:0; border-radius:8px; cursor:pointer; }
.vtl-phone-menu:hover { background:var(--vtl-accented); }
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
.vtl-menu:hover { background:#d1d4cd; }
.vtl-brand { display:inline-flex; align-items:center; }
.vtl-brand:empty { display:none; }
@ -976,6 +989,13 @@ export default defineComponent({
.vtl-home-ic { flex:0 0 auto; display:inline-flex; }
.vtl-home-ic svg { width:16px; height:16px; }
.vtl-home-tx { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* THE RAIL'S BOTTOM SLOT (#rail-end: railEndShown = wide AND header-off, and a filler present). The nav
becomes a column ONLY then, so every other rail lays out exactly as before. margin-top:auto parks the
slot at the bottom while the tabs are short; sticky keeps it in view once they scroll. The nav's own
bottom padding moves into the slot, so the sticky row sits flush with the rail's bottom edge. */
.vtl-nav.has-rail-end { display:flex; flex-direction:column; padding-bottom:0; }
.vtl-nav.has-rail-end > * { flex-shrink:0; }
.vtl-rail-end { margin-top:auto; position:sticky; bottom:0; padding:8px 0 12px; background:#ffffff; border-top:1px solid var(--vtl-border); }
.vtl-grp { margin-bottom:2px; }
.vtl-grp + .vtl-grp { margin-top:8px; padding-top:8px; border-top:1px solid var(--vtl-border); }
.vtl-tier { width:100%; padding:8px 8px 6px; display:flex; align-items:center; gap:8px; box-sizing:border-box; }
@ -1101,6 +1121,7 @@ export default defineComponent({
nav translate -184px the 256px rail slides left, leaving 256-184 = 72px visible
main left 72px the content pane starts exactly where the rail ends
collapse pad 184px pushes the toggle's icon into the 72px strip that stays on screen
(the #rail-end slot takes the same pad, so its menu sits in the strip too)
tab pad 188px the same for each row's icon (4px more, matching the old 196/200 pair)
Change one without the others and the icons drift out of the visible strip or the content
overlaps the rail. Old values were -196 / 60 / 196 / 200.
@ -1109,6 +1130,7 @@ export default defineComponent({
.vtl.rail-collapsed .vtl-main { left:72px; }
.vtl.rail-collapsed .vtl-collapse, .vtl.rail-collapsed .vtl-home { padding-left:184px; justify-content:flex-start; }
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(0deg); }
.vtl.rail-collapsed .vtl-rail-end { padding-left:184px; }
.vtl.rail-collapsed .vtl-collapse-tx, .vtl.rail-collapsed .vtl-home-tx { display:none; }
.vtl.rail-collapsed .vtl-tab { padding-left:188px; gap:0; }
.vtl.rail-collapsed .vtl-tab-text { display:none; }
@ -1167,12 +1189,12 @@ export default defineComponent({
.vtl:dir(rtl) .vtl-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
.vtl:dir(rtl) .vtl-chev:not(.open) { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-collapse-ic svg { transform:scaleX(-1); }
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
.vtl.rail-collapsed:dir(rtl) .vtl-nav { transform:translateX(184px); }
.vtl.rail-collapsed:dir(rtl) .vtl-main { left:0; right:72px; }
.vtl.rail-collapsed:dir(rtl) .vtl-collapse, .vtl.rail-collapsed:dir(rtl) .vtl-home { padding-left:10px; padding-right:184px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tab { padding-left:12px; padding-right:188px; }
.vtl.rail-collapsed:dir(rtl) .vtl-tier { padding-left:8px; padding-right:196px; }
.vtl.rail-collapsed:dir(rtl) .vtl-rail-end { padding-left:0; padding-right:184px; }
@media (max-width: 760px) {
.vtl:dir(rtl) .vtl-nav { transform:translateX(100%); }
.vtl.nav-open:dir(rtl) .vtl-nav { transform:translateX(0); }
@ -1186,6 +1208,6 @@ export default defineComponent({
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root,
and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
@media (prefers-reduced-motion: reduce) {
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-fab, .vtl-scrim { transition: none !important; animation: none !important; }
.vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
}
</style>

View file

@ -0,0 +1,58 @@
/**
* languageRequest.js — how a shell asks its HOST PAGE before it changes language.
*
* A shell (the Research Gallery, Posterverse) shows its own language menu, but the page around it may
* own the language: DX Life keeps it in the URL path (/ar/deep-research), and a shell that switched on
* its own would fight the page. So a pick is a REQUEST, sent as one native event on the shell's host
* element:
*
* doxa-language-request detail: { language: 'ar' } bubbles, composed, cancelable
*
* - A host that owns the language calls preventDefault() and changes it itself (it then sets the
* shell's `language` attribute). dispatchEvent returns false, and the shell does NOT switch.
* - Nobody cancels (a standalone leaf, or a partner page that set `language="es"` once and never
* listens): dispatchEvent returns true, and the shell switches itself. That is why the rule keys on
* the cancel and not on whether `language` was passed: a static attribute would otherwise leave a
* dropdown that does nothing.
*
* ★ A NATIVE CustomEvent, NOT Vue's emit. Vue's custom-element emit wraps detail in an ARRAY
* (detail[0]) and is never cancelable, so the host could neither read detail.language nor say no.
*
* The host is found the way library/utils/hostElement.js finds it (one hop out of the shadow root);
* that helper takes a Vue instance, this takes the DOM node the pick happened on.
*/
export const LANGUAGE_REQUEST_EVENT = 'doxa-language-request'
/** The custom element a node is rendered inside, or the node itself outside any shadow root. */
export function hostOf(el) {
try { const rn = el && el.getRootNode && el.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
return el || null
}
/**
* Ask the host page for `code`. Returns true when NOBODY cancelled (the shell should switch itself),
* false when a host took the request (the shell waits for the host to set `language`).
*/
export function requestLanguage(fromEl, code) {
const host = hostOf(fromEl)
if (!host || typeof host.dispatchEvent !== 'function') return true
return host.dispatchEvent(new CustomEvent(LANGUAGE_REQUEST_EVENT, {
detail: { language: code }, bubbles: true, composed: true, cancelable: true,
}))
}
/**
* Record an uncancelled pick in ?lang=, ONLY on a page this tree generated (window.MAP_OWNS_PAGE ===
* true, stamped by internal/generate-tree.mjs and honoured by library/i18n). A host's URL is the host's:
* a shell embedded anywhere else never writes it. Returns whether it wrote.
*/
export function writeOwnedLangParam(code, win = (typeof window !== 'undefined' ? window : null)) {
try {
if (!win || win.MAP_OWNS_PAGE !== true || !win.history || !win.location) return false
const url = new URL(win.location.href)
url.searchParams.set('lang', code)
win.history.replaceState(win.history.state, '', url.toString())
return true
} catch (_) { return false }
}

View file

@ -56,6 +56,8 @@
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
"check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test",
"check:shell-header": "node internal/check-shell-header.mjs",
"check:shell-header:self-test": "node internal/check-shell-header.mjs --self-test",
"check:dashboard-filter-a11y": "node plugin-designs/research-dashboard/scripts/check-dashboard-filter-a11y.mjs",
"check:dashboard-geocoder-provider": "node plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs",
"check:deck-click": "node internal/check-deck-click.mjs",
@ -346,8 +348,8 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:shell-header:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:shell-header",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -455,6 +457,7 @@
"check:country-sheet-one-path:self-test": "plants each defect check:country-sheet-one-path guards (a direct generateCountrySheet call, makeCountrySheet without countries, the old guard that skipped India, a position given to an absent code) and requires each to FAIL; a comment naming generateCountrySheet must NOT.",
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
"check:shell-header": "a header-off shell has ONE way to its tabs and ONE language menu, in the right place for the width: VerticalTabsLayout renders the phone bar (☰ + #phone-end) only when narrow AND header-off, #rail-end only when wide AND header-off, and no corner circle; ShellLanguageMenu's pick dispatches exactly one cancelable, composed, bubbling doxa-language-request (detail.language), does not switch when a host cancels, and writes ?lang= only on a tree-owned page; every switcher language has a catalogue and the current language is always listed. Headless (Vite SSR). Run the `check:shell-header:self-test` script for the NEGATIVE test (11 planted defects)",
"check:dashboard-filter-a11y": "the dashboard's cross-filter is operable and audible: every filter chip is a <button type=\"button\"> with an aria-label naming the ACTION (a filter that can be set by keyboard must be clearable by keyboard), the shell carries exactly ONE always-rendered polite role=\"status\" region so a brush that changes a number far from the control is announced once rather than six times or not at all, and no resize handle's accessible name is a raw English literal. AST over the parsed templates plus the built bundle — no browser (rega's alone).",
"check:dashboard-geocoder-provider": "the research dashboard leaf at ?provider=maplibre makes ZERO requests to api.mapbox.com, and its search bars still suggest (built tree; browser gate). It was kept out of gates:post while the generated leaf hard-linked the Mapbox engine; the leaf now loads only the engine it is asked for.",
"check:dep-names": "every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)",