Phones: one header on a website, and maps that hold their size while you scroll

ONE HEADER ON A WEBSITE. Inside a host site that has its own header, the shell's 44px phone bar stacked a
second white bar under it. VerticalTabsLayout gains shellMode (prop, or the host element's shell-mode
attribute): web | app | auto.
- app (a Capacitor or standalone app, or a DX Maps leaf page): the phone bar, unchanged.
- web: no bar. The rail opens from a 40px corner ☰ at the pane's bottom-start, which publishes its footprint
  (--map-fab-clearance / --map-fab-beside) so the legend sheet, the pin hint and back-to-top step aside; the
  language menu moves to the drawer's foot.
- auto: the host's shell-mode attribute, else app for Capacitor, display-mode standalone or a leaf
  (window.MAP_OWNS_PAGE), else web.
Desktop is unchanged. check:shell-header renders both modes (13 planted defects); check:nav-reachable-mobile
accepts the corner ☰.

MAPS HOLD THEIR SIZE. ProfileLoader floored every map at min-height 100dvh, and the leaf pages' slot did the
same. dvh follows the mobile URL bar, so scrolling grew every map mid-scroll and moved its legend sheet. Both
now use 100svh (the viewport with the bar showing, which never changes on scroll), with 100vh where svh is
unsupported.
This commit is contained in:
Doxa Maps 2026-10-02 04:44:33 +00:00
parent d1536d94e3
commit 6032512edf
6 changed files with 117 additions and 39 deletions

View file

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

View file

@ -93,7 +93,7 @@ for (const bundle of readdirSync(DESIGNS)) {
// ── ★ THE HOST-SIZED SCROLLER IS A THREE-LINE SHAPE, AND IT MUST BE PORTED WHOLE ──────────────── // ── ★ THE HOST-SIZED SCROLLER IS A THREE-LINE SHAPE, AND IT MUST BE PORTED WHOLE ────────────────
// A pane that fills its host absolutely AND turns on overflow is also, necessarily, the thing that // A pane that fills its host absolutely AND turns on overflow is also, necessarily, the thing that
// scrolls whatever its child overflows by. library/ProfileLoader.vue floors EVERY profile at // scrolls whatever its child overflows by. library/ProfileLoader.vue floors EVERY profile at
// `min-height: var(--profile-min-h, 100dvh)` -- a VIEWPORT measurement -- while such a pane measures // `min-height: var(--profile-min-h, 100svh)` -- a VIEWPORT measurement -- while such a pane measures
// against its HOST. So on any page with chrome above it, the child is taller than the pane by exactly // against its HOST. So on any page with chrome above it, the child is taller than the pane by exactly
// the height of that chrome, and the pane scrolls the whole plugin, tab strip and all. // the height of that chrome, and the pane scrolls the whole plugin, tab strip and all.
// //
@ -124,7 +124,7 @@ for (const [file, label] of [
if (!/--profile-min-h:\s*0/.test(r)) { if (!/--profile-min-h:\s*0/.test(r)) {
const sel = r.slice(0, r.indexOf('{')).trim().split('\n').pop().trim() const sel = r.slice(0, r.indexOf('{')).trim().split('\n').pop().trim()
fails.push(`${file}: "${sel}" fills its host and owns the overflow but does NOT set --profile-min-h:0.\n` fails.push(`${file}: "${sel}" fills its host and owns the overflow but does NOT set --profile-min-h:0.\n`
+ ' ProfileLoader floors every child at 100dvh (the VIEWPORT), so on a page with a header the\n' + ' ProfileLoader floors every child at 100svh (the VIEWPORT), so on a page with a header the\n'
+ ' child is taller than this pane by the header height and this pane scrolls the whole plugin,\n' + ' child is taller than this pane by the header height and this pane scrolls the whole plugin,\n'
+ ' tab strip included. The three lines are one shape: fill, scroll, release the floor.') + ' tab strip included. The three lines are one shape: fill, scroll, release the floor.')
} }

View file

@ -58,22 +58,28 @@ async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) {
// locale detection reads <html lang>; SSR has no document, so the render gets a one-field stand-in. // 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' } } if (typeof globalThis.document === 'undefined') globalThis.document = { documentElement: { lang: 'en' } }
const Layout = (await server.ssrLoadModule('/' + vtl)).default const Layout = (await server.ssrLoadModule('/' + vtl)).default
for (const narrow of [true, false]) for (const header of [true, false]) { // shellMode (resolved into data.mode at mount): 'app' owns the screen and gets the bar; 'web' sits under a
// A copy of the real layout whose data says `narrow` (in a browser mounted() reads it from matchMedia, // host's own site header and gets NO bar, a corner ☰ (with its --map-fab-* footprint) and the language at
// which SSR never runs). A copy, not `extends`: the render function is not inherited through extends. // the drawer's foot. The drawer is shut (navOpen false), as mounted() leaves it on a phone.
const Comp = { ...Layout, data() { return { ...Layout.data.call(this, this), narrow } } } for (const mode of ['app', 'web']) for (const narrow of [true, false]) for (const header of [true, false]) {
// A copy of the real layout whose data says `narrow` and `mode` (in a browser mounted() reads them from
// matchMedia and the host, which SSR never runs). A copy, not `extends`: the render function is not inherited.
const Comp = { ...Layout, data() { return { ...Layout.data.call(this, this), narrow, mode, navOpen: !narrow } } }
const app = createSSRApp({ render: () => h(Comp, { header, items: [{ key: 'a', label: 'A' }], active: 'a' }, { const app = createSSRApp({ render: () => h(Comp, { header, items: [{ key: 'a', label: 'A' }], active: 'a' }, {
'phone-end': () => h('i', { class: 'probe-phone-end' }), 'phone-end': () => h('i', { class: 'probe-phone-end' }),
'rail-end': () => h('i', { class: 'probe-rail-end' }) }) }) 'rail-end': () => h('i', { class: 'probe-rail-end' }) }) })
const html = (await renderToString(app)).replace(/<!--[\s\S]*?-->/g, '') // template comments are not markup const html = (await renderToString(app)).replace(/<!--[\s\S]*?-->/g, '') // template comments are not markup
const tag = `narrow=${narrow} header=${header}` const tag = `mode=${mode} narrow=${narrow} header=${header}`
const wantBar = narrow && !header, wantRail = !narrow && !header const phoneNoHeader = narrow && !header
const wantBar = phoneNoHeader && mode === 'app', wantFab = phoneNoHeader && mode === 'web', wantRail = !narrow && !header
if (/class="vtl-pbar"/.test(html) !== wantBar) fail(`the phone bar ${wantBar ? 'is missing' : 'renders'} at ${tag}`) 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 (/probe-phone-end/.test(html) !== phoneNoHeader) fail(`#phone-end ${phoneNoHeader ? 'is not filled' : 'renders'} at ${tag}`)
if (/vtl-drawer-end/.test(html) !== wantFab) fail(`the language at the drawer's foot ${wantFab ? 'is missing' : '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 (/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 (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 (/class="vtl-fab"/.test(html) !== wantFab) fail(`the corner ☰ ${wantFab ? 'is missing' : '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}`) if (/--map-fab-/.test(html) !== wantFab) fail(`the corner ☰'s --map-fab-* footprint ${wantFab ? 'is not published' : 'is published'} at ${tag}`)
if (wantBar !== /has-phone-bar/.test(html)) fail(`the phone bar's grid row (.has-phone-bar) ${wantBar ? 'is missing' : 'is set'} at ${tag}`)
} }
// 2-4. the pick, through the menu's REAL handler (its <script setup> bindings; no DOM needed) // 2-4. the pick, through the menu's REAL handler (its <script setup> bindings; no DOM needed)
@ -134,9 +140,11 @@ function plant(rel, suffix, edit, redirect = []) {
} }
const PLANTED = [] // every temporary copy, removed in finally whatever happens const PLANTED = [] // every temporary copy, removed in finally whatever happens
const POISONS = [ 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 bar also shows with a header', files: () => ({ vtl: plant(VTL, 'bar-header', (s) => s.replace("phoneBarShown() { return this.narrow && !this.header && this.mode === 'app' }", "phoneBarShown() { return this.narrow && this.mode === 'app' }")) }) },
{ 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 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: 'web mode stacks the phone bar under the host header', files: () => ({ vtl: plant(VTL, 'webbar', (s) => s.replace("phoneBarShown() { return this.narrow && !this.header && this.mode === 'app' }", 'phoneBarShown() { return this.narrow && !this.header }')) }) },
{ name: 'web mode drops the language', files: () => ({ vtl: plant(VTL, 'weblang', (s) => s.replace("drawerEndShown() { return this.narrow && !this.header && this.mode === 'web' },", 'drawerEndShown() { return false },')) }) },
{ name: 'the corner circle in app mode (under the bar)', 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 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 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: '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()]]) } } },
@ -164,7 +172,7 @@ try {
} else { } else {
const bad = await audit() 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) } 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.') else console.log('SHELL-HEADER: PASS — app mode: the phone bar only on a narrow header-off layout; web mode: no bar, the corner ☰ + its footprint, the language at the drawer foot; #rail-end only on a wide one; 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 { } finally {
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true }) while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })

View file

@ -1024,9 +1024,10 @@ ${hintBlock} ${peerBlock}
min-height:100% here resolved against BODY's computed height, which is auto (body only has min-height:100% here resolved against BODY's computed height, which is auto (body only has
min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any min-height:100%, never a definite height) — and a percentage of auto collapses to 0. Any
element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane) element whose own chrome is position:absolute / height:100% (upg-100-mfe -> .pane-col -> .pane)
then measured 0 and the leaf looked empty. 100dvh depends on NO ancestor, keeps the page then measured 0 and the leaf looked empty. A viewport unit depends on NO ancestor and keeps the page
growable (it is a MIN), and is correct on mobile with dynamic toolbars. */ growable (it is a MIN). svh, not dvh: dvh follows the mobile URL bar, so the slot (and its map)
#slot, ${esc(tag)} { position: static; inset: auto; width: 100%; min-height: 100dvh; display: block; } resized while the reader scrolled; svh never moves. 100vh first for a browser without svh. */
#slot, ${esc(tag)} { position: static; inset: auto; width: 100%; min-height: 100vh; min-height: 100svh; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center; #status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8; padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; } font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }

View file

@ -424,10 +424,14 @@ provide('dataStore', dataStore)
composite scroll gets a spurious 131px scrollbar on every fixed-viewport map. composite scroll gets a spurious 131px scrollbar on every fixed-viewport map.
It cannot be fixed from the host side: this wrapper is inside the element's SHADOW ROOT, where It cannot be fixed from the host side: this wrapper is inside the element's SHADOW ROOT, where
no host-page selector reaches. Inherited properties DO cross that boundary, so a custom property no host-page selector reaches. Inherited properties DO cross that boundary, so a custom property
is the one lever a host has. Default is unchanged (100dvh) — every standalone leaf keeps the is the one lever a host has. Every standalone leaf keeps the floor that BUG-UPG100 needs. A host
floor that BUG-UPG100 needs. A host that has ALREADY given this element a definite height sets that has ALREADY given this element a definite height sets --profile-min-h:0 and opts out.
--profile-min-h:0 and opts out. */
min-height: var(--profile-min-h, 100dvh); ★ SMALL VIEWPORT (svh), NOT dvh (2026-10-02, measured on a phone). dvh FOLLOWS the mobile URL bar:
scrolling collapses the bar, 100dvh grows, every map grew with it mid-scroll and its bottom legend
sheet moved. svh is the viewport WITH the bar showing and never changes on scroll, so the floor is
stable and the legend stays on screen. (A browser without svh falls back to 100vh, below.) */
min-height: var(--profile-min-h, 100svh);
position: relative; position: relative;
} }
/* THE LARGER-SCREENS NOTICE SITS IN FLOW, so the plugin below it must give up exactly its height: /* THE LARGER-SCREENS NOTICE SITS IN FLOW, so the plugin below it must give up exactly its height:
@ -456,4 +460,7 @@ provide('dataStore', dataStore)
font-family: monospace; font-family: monospace;
font-size: 13px; font-size: 13px;
} }
@supports not (height: 100svh) {
.profile-loader { min-height: var(--profile-min-h, 100vh); }
}
</style> </style>

View file

@ -91,11 +91,12 @@
after a resize back to desktop — the previous broken-restore bug. after a resize back to desktop — the previous broken-restore bug.
--> -->
<template> <template>
<!-- ★ NO CORNER CIRCLE ANY MORE (the phone bar below replaced it). The layout used to publish <!-- ★ THE CORNER CIRCLE IS WEB MODE ONLY. In app mode the phone bar below carries the ☰ ABOVE the pane, so nothing
--map-fab-clearance / --map-fab-beside while a floating ☰ sat over the pane's bottom-start covers plugin content and --map-fab-clearance / --map-fab-beside are not set: every reader of them
corner; with the ☰ in a bar ABOVE the pane nothing covers plugin content, so neither is set and (BackToTopButton, PinHint, LegendMobile, the grid) takes its own fallback. In web mode there is no bar, the
every reader of them (BackToTopButton, PinHint, LegendMobile, the grid) takes its own fallback. --> ☰ is a circle over the pane's bottom-start corner, and the layout publishes both while the circle shows. -->
<div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header, 'has-phone-bar': phoneBarShown }"> <div class="vtl" :class="{ 'nav-open': navOpen, 'rail-collapsed': railCollapsed, 'no-header': !header, 'has-phone-bar': phoneBarShown }"
:style="fabShown ? { '--map-fab-clearance': 'calc(56px + env(safe-area-inset-bottom))', '--map-fab-beside': 'calc(56px + env(safe-area-inset-left))' } : null">
<header class="vtl-top" v-if="header"> <header class="vtl-top" v-if="header">
<button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen" <button class="vtl-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
:aria-label="label('toggleList')">☰</button> :aria-label="label('toggleList')">☰</button>
@ -121,8 +122,20 @@
<span class="vtl-pbar-end"><slot name="phone-end" /></span> <span class="vtl-pbar-end"><slot name="phone-end" /></span>
</div> </div>
<!-- Driving the shell's own scroller. It reads --map-fab-beside, which nothing sets now that the <!-- ★ WEB MODE (shellMode, below): ON A WEBSITE THE HOST'S HEADER IS THE ONLY HEADER. Inside a host page that
corner circle is gone, so it sits at its plain inset. --> has its own site header (DX Life), the phone bar above stacked a second white bar under it. So a phone in
web mode gets no bar: the rail opens from this corner circle (40px, 8px from the bottom-start corner,
over the content, never a row), and the language menu (#phone-end) moves into the drawer's foot.
The circle publishes its footprint (--map-fab-clearance / --map-fab-beside on .vtl, above), which the
legend sheet, the pin hint and back-to-top already read, so they step aside while it shows.
It hides while the drawer is open: a tab tap closes the drawer, and a circle over an open drawer would
only cover a row. -->
<button v-if="fabShown"
class="vtl-fab" type="button" :aria-expanded="String(navOpen)"
@click="navOpen = true" :aria-label="label('openViews')">☰</button>
<!-- Driving the shell's own scroller. It reads --map-fab-beside, which only web mode's circle sets; with no circle
it sits at its plain inset. -->
<BackToTopButton v-if="narrow" :target="activeHostEl" <BackToTopButton v-if="narrow" :target="activeHostEl"
:labels="{ backToTop: T('actions.backToTop', 'Back to top') }" /> :labels="{ backToTop: T('actions.backToTop', 'Back to top') }" />
@ -237,6 +250,10 @@
<div v-if="railEndShown && $slots['rail-end']" class="vtl-rail-end"> <div v-if="railEndShown && $slots['rail-end']" class="vtl-rail-end">
<slot name="rail-end" :collapsed="railCollapsed" /> <slot name="rail-end" :collapsed="railCollapsed" />
</div> </div>
<!-- Web mode on a phone: no bar, so the bar's #phone-end (the language menu) sits at the drawer's foot. -->
<div v-if="drawerEndShown && $slots['phone-end']" class="vtl-rail-end vtl-drawer-end">
<slot name="phone-end" />
</div>
</nav> </nav>
<main class="vtl-main"> <main class="vtl-main">
@ -350,6 +367,12 @@ const VTL_SHADOW_CSS = `
.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 { 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:hover { background:var(--vtl-accented); }
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; } .vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
/* WEB MODE's corner circle (fabShown = narrow AND header-off AND web AND the drawer shut). Fixed: 40px, 8px from the
bottom-start corner plus the safe area; above the content, below the drawer (.vtl-nav, z-index 20), so the drawer
covers it. It never rides a legend sheet: the sheet pads its start edge by --map-fab-beside instead. */
.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; padding:0; 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; }
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; } .vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
.vtl-menu:hover { background:#d1d4cd; } .vtl-menu:hover { background:#d1d4cd; }
.vtl-brand { display:inline-flex; align-items:center; } .vtl-brand { display:inline-flex; align-items:center; }
@ -575,6 +598,7 @@ const VTL_SHADOW_CSS = `
shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it. shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it.
translateX has no logical form, which is why this is a mirror block rather than logical properties. */ translateX has no logical form, which is why this is a mirror block rather than logical properties. */
.vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); } .vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); }
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
.vtl:dir(rtl) .vtl-main { left:0; right:256px; } .vtl:dir(rtl) .vtl-main { left:0; right:256px; }
.vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; } .vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; }
.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-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
@ -599,7 +623,7 @@ const VTL_SHADOW_CSS = `
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root, /* ★ 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. */ and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.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; } .vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-fab, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
} }
` `
@ -629,7 +653,13 @@ export default defineComponent({
// The HOST's homepage, shown as a link above Collapse (see the template). Empty = no link, which is // The HOST's homepage, shown as a link above Collapse (see the template). Empty = no link, which is
// every existing consumer. homeLabel is the host's own words; unset, labels.home, then English. // every existing consumer. homeLabel is the host's own words; unset, labels.home, then English.
homeHref: { type: String, default: '' }, homeHref: { type: String, default: '' },
homeLabel: { type: String, default: '' } homeLabel: { type: String, default: '' },
// ★ WHERE THIS SHELL LIVES, which decides its phone chrome when header is off:
// 'app' — it owns the screen (a Capacitor/standalone app, or a DX Maps leaf page): the 44px phone bar.
// 'web' — it sits in a website that has its own header: NO second bar; a corner ☰ + the language in the drawer.
// 'auto' — the host element's shell-mode attribute if it has one, else app for Capacitor, display-mode
// standalone or a leaf (window.MAP_OWNS_PAGE), else web.
shellMode: { type: String, default: 'auto' }
}, },
emits: ['update:active'], emits: ['update:active'],
setup() { setup() {
@ -655,7 +685,9 @@ export default defineComponent({
// railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly. // railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly.
narrow: false, _mql: null, _onMedia: null, narrow: false, _mql: null, _onMedia: null,
activatedKeys: {}, hostState: {}, hostError: {}, _hostSlots: {}, _hostMounted: {}, activatedKeys: {}, hostState: {}, hostError: {}, _hostSlots: {}, _hostMounted: {},
hostEls: {} hostEls: {},
// The resolved shellMode ('app' | 'web'), set at mount (it reads the host element and the platform).
mode: 'app'
} }
}, },
computed: { computed: {
@ -665,8 +697,11 @@ export default defineComponent({
* on a desktop, and neither when a header is on (its #toolbar carries the menu). The template, * 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. * the grid row (.has-phone-bar) and check:shell-header all read these two, never a copy.
*/ */
phoneBarShown() { return this.narrow && !this.header }, phoneBarShown() { return this.narrow && !this.header && this.mode === 'app' },
railEndShown() { return !this.narrow && !this.header }, railEndShown() { return !this.narrow && !this.header },
// Web mode on a phone: the corner circle (while the drawer is shut) and the language at the drawer's foot.
fabShown() { return this.narrow && !this.header && this.mode === 'web' && !this.navOpen },
drawerEndShown() { return this.narrow && !this.header && this.mode === 'web' },
/** /**
* The element the back-to-top arrow scrolls. .vtl-host is the shell's scroller * 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 * (overflow-y:auto) and there is one PER TAB, so this follows `active` rather than binding a
@ -708,6 +743,7 @@ export default defineComponent({
mountedElementRows() { return this.elementRows.filter(r => this.activatedKeys[r.key]) } mountedElementRows() { return this.elementRows.filter(r => this.activatedKeys[r.key]) }
}, },
watch: { watch: {
shellMode() { this.resolveShellMode() },
// Lazily activate an element-hosting tab the FIRST time it becomes active (mount on demand, // Lazily activate an element-hosting tab the FIRST time it becomes active (mount on demand,
// then keep alive). Non-element tabs are ignored here (the host renders those in #default). // then keep alive). Non-element tabs are ignored here (the host renders those in #default).
active: { active: {
@ -716,6 +752,7 @@ export default defineComponent({
} }
}, },
mounted() { mounted() {
this.resolveShellMode()
// Track the breakpoint reactively and RECONCILE state whenever we cross it — this is the fix for the // Track the breakpoint reactively and RECONCILE state whenever we cross it — this is the fix for the
// "extremely buggy" collapse: without it, `navOpen` (mobile drawer) and `railCollapsed` (desktop slim // "extremely buggy" collapse: without it, `navOpen` (mobile drawer) and `railCollapsed` (desktop slim
// rail) drifted out of the regime they were set in, and a mobile-closed drawer could hide the whole // rail) drifted out of the regime they were set in, and a mobile-closed drawer could hide the whole
@ -745,6 +782,24 @@ export default defineComponent({
this._mql = null; this._onMedia = null this._mql = null; this._onMedia = null
}, },
methods: { methods: {
resolveShellMode() {
const pick = (m) => (m === 'web' || m === 'app' ? m : null)
let m = pick(this.shellMode)
if (!m) {
try {
const root = this.$el && this.$el.getRootNode ? this.$el.getRootNode() : null
const host = root && root.host
m = pick(host && host.getAttribute ? host.getAttribute('shell-mode') : null)
} catch (_) { /* no host element */ }
}
if (!m) {
try {
const standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true
m = window.Capacitor || standalone || window.MAP_OWNS_PAGE === true ? 'app' : 'web'
} catch (_) { m = 'app' }
}
this.mode = m
},
registerTab(key, el) { if (el) this._tabEls[key] = el }, registerTab(key, el) { if (el) this._tabEls[key] = el },
// The rail toggle. On a narrow screen the rail is an off-canvas drawer, so the toggle closes the // The rail toggle. On a narrow screen the rail is an off-canvas drawer, so the toggle closes the
// drawer (navOpen). On desktop it collapses/expands the slim icon rail (railCollapsed, persisted // drawer (navOpen). On desktop it collapses/expands the slim icon rail (railCollapsed, persisted
@ -951,6 +1006,12 @@ export default defineComponent({
.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 { 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:hover { background:var(--vtl-accented); }
.vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; } .vtl-phone-menu:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
/* WEB MODE's corner circle (fabShown = narrow AND header-off AND web AND the drawer shut). Fixed: 40px, 8px from the
bottom-start corner plus the safe area; above the content, below the drawer (.vtl-nav, z-index 20), so the drawer
covers it. It never rides a legend sheet: the sheet pads its start edge by --map-fab-beside instead. */
.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; padding:0; 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; }
.vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; } .vtl-pbar-end { margin-inline-start:auto; display:inline-flex; align-items:center; min-width:0; }
.vtl-menu:hover { background:#d1d4cd; } .vtl-menu:hover { background:#d1d4cd; }
.vtl-brand { display:inline-flex; align-items:center; } .vtl-brand { display:inline-flex; align-items:center; }
@ -1176,6 +1237,7 @@ export default defineComponent({
shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it. shadow boundary, so either a consumer setting dir="rtl" on the layout or an RTL host page flips it.
translateX has no logical form, which is why this is a mirror block rather than logical properties. */ translateX has no logical form, which is why this is a mirror block rather than logical properties. */
.vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); } .vtl:dir(rtl) .vtl-nav { left:auto; right:0; border-right:0; border-left:1px solid var(--vtl-border); }
.vtl:dir(rtl) .vtl-fab { left:auto; right:calc(8px + env(safe-area-inset-right)); }
.vtl:dir(rtl) .vtl-main { left:0; right:256px; } .vtl:dir(rtl) .vtl-main { left:0; right:256px; }
.vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; } .vtl:dir(rtl) .vtl-tab, .vtl:dir(rtl) .vtl-tier-btn { text-align:right; }
.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-meta, .vtl:dir(rtl) .vtl-td, .vtl:dir(rtl) .vtl-chev { margin-left:0; margin-right:auto; }
@ -1200,6 +1262,6 @@ export default defineComponent({
/* ★ REDUCED MOTION, OWNED BY THIS COMPONENT. A page-level rule cannot reach into a shadow root, /* ★ 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. */ and no generated leaf ships one anyway (measured: 0 across the whole tree). Additive only. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.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; } .vtl-nav, .vtl-collapse-ic, .vtl-tab, .vtl-tier, .vtl-pbar, .vtl-phone-menu, .vtl-fab, .vtl-rail-end, .vtl-scrim { transition: none !important; animation: none !important; }
} }
</style> </style>