diff --git a/internal/check-shell-header.mjs b/internal/check-shell-header.mjs index bcd142fbd..63cb65584 100644 --- a/internal/check-shell-header.mjs +++ b/internal/check-shell-header.mjs @@ -58,9 +58,9 @@ async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) { // locale detection reads ; 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 - // shellMode (resolved into data.mode at mount): 'app' owns the screen and gets the bar; 'web' sits under a - // host's own site header and gets NO bar, a corner ☰ (with its --map-fab-* footprint) and the language at - // the drawer's foot. The drawer is shut (navOpen false), as mounted() leaves it on a phone. + // shellMode (resolved into data.mode at mount): BOTH modes get the one top bar on a phone (☰ at the start, the + // language at the end); web mode's bar takes no camera inset (.is-web). No corner circle in either mode + // (a corner ☰ with no header was rejected on a real phone). The drawer is shut, as on a phone at load. 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. @@ -70,17 +70,16 @@ async function audit({ vtl = VTL, menu = MENU, langs = LANGS } = {}) { 'rail-end': () => h('i', { class: 'probe-rail-end' }) }) }) const html = (await renderToString(app)).replace(//g, '') // template comments are not markup const tag = `mode=${mode} narrow=${narrow} header=${header}` + // The bar's class list, in whatever order Vue renders it (a bound class can come first). + const pbar = (html.match(/
]*aria-expanded=/.test(html)) fail(`the bar's ☰ has no aria-expanded at ${tag}`) - if (/class="vtl-fab"/.test(html) !== wantFab) fail(`the corner ☰ ${wantFab ? 'is missing' : 'renders'} at ${tag}`) - if (/--map-fab-/.test(html) !== wantFab) fail(`the corner ☰'s --map-fab-* footprint ${wantFab ? 'is not published' : 'is published'} at ${tag}`) + if (/vtl-fab|--map-fab-|vtl-drawer-end/.test(html)) fail(`a corner ☰ (or its --map-fab-* footprint, or the drawer-foot language) renders 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}`) } @@ -142,12 +141,11 @@ function plant(rel, suffix, edit, redirect = []) { } 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 && this.mode === 'app' }", "phoneBarShown() { return this.narrow && this.mode === 'app' }")) }) }, + { 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: '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 leaves the drawer a block (the language under the last row)', files: () => ({ vtl: plant(VTL, 'webfoot', (s) => s.replace(" || (drawerEndShown && !!$slots['phone-end'])", '')) }) }, - { 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('
', '
')) }) }, + { name: 'web mode drops the header (no bar)', files: () => ({ vtl: plant(VTL, 'webnobar', (s) => s.replace('phoneBarShown() { return this.narrow && !this.header }', "phoneBarShown() { return this.narrow && !this.header && this.mode === 'app' }")) }) }, + { name: 'web mode keeps the camera inset', files: () => ({ vtl: plant(VTL, 'webinset', (s) => s.replace(" :class=\"{ 'is-web': mode === 'web' }\"", '')) }) }, + { name: 'the corner circle is back', files: () => ({ vtl: plant(VTL, 'fab', (s) => s.replace('
☰
{ 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()]]) } } }, @@ -175,7 +173,7 @@ try { } 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 — 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.') + else console.log('SHELL-HEADER: PASS — one top bar on a narrow header-off layout in both modes (web: no camera inset), never a corner circle; #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 { while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true }) diff --git a/library/ProfileLoader.vue b/library/ProfileLoader.vue index be57eccb8..e87d8b1f4 100644 --- a/library/ProfileLoader.vue +++ b/library/ProfileLoader.vue @@ -14,7 +14,7 @@ * never interfere with each other. */ -import { computed, provide, inject, watchEffect, watch, ref } from 'vue' +import { computed, provide, inject, watchEffect, watch, ref, onMounted, onBeforeUnmount } from 'vue' import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js' import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js' import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, localeFromUrl, loadLocale, localeChromeReady } from './i18n/index.js' @@ -344,10 +344,45 @@ try { provide('uiStore', uiStore) provide('mapStore', mapStore) provide('dataStore', dataStore) + +// ★ THE FLOOR NEVER EXCEEDS A SIZED HOST (measured 2026-10-02 on a phone, DX Life's Home/Pray/Research). The +// viewport floor below (min-height: 100svh, see the style block) exists for a host whose height comes from its +// CONTENT (the scroll-page leaf, BUG-UPG100). A host that is already SIZED (DX Life's slot: a definite height, +// overflow hidden; a tab pane) must be filled exactly: there the floor made the map taller than its host, the +// host clipped its bottom, and the legend footer was cut off. It also tied the map's height to the window's, +// which is what moved the map as a phone's URL bar came and went. +// Which kind of host this is cannot be read from CSS, so it is MEASURED: with the floor at 0 and then at a huge +// value, a sized host keeps its height and a content-sized one grows. Sized → the floor is switched off +// (height:100% fills the host); content-sized → the floor stays. Both probes are undone before the frame paints. +// Re-measured whenever the host's size changes (a ResizeObserver on the HOST, never a window resize). +const loaderEl = ref(null) +let hostRO = null +function fitFloorToHost() { + const el = loaderEl.value + const host = el && el.getRootNode && el.getRootNode().host + if (!el || !host) return + const keep = el.style.minHeight + el.style.minHeight = '0px' + const h0 = host.getBoundingClientRect().height + el.style.minHeight = '100000px' + const h1 = host.getBoundingClientRect().height + const sized = h0 > 0 && Math.abs(h1 - h0) < 1 + el.style.minHeight = sized ? '0px' : (keep === '0px' ? '' : keep) + el.toggleAttribute('data-host-sized', sized) +} +onMounted(() => { + fitFloorToHost() + const host = loaderEl.value && loaderEl.value.getRootNode && loaderEl.value.getRootNode().host + if (host && typeof ResizeObserver !== 'undefined') { + hostRO = new ResizeObserver(() => fitFloorToHost()) + hostRO.observe(host) + } +}) +onBeforeUnmount(() => { if (hostRO) { hostRO.disconnect(); hostRO = null } })