From d533431997fe9c9d9da566764eaca736ef3c5a95 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Fri, 2 Oct 2026 05:27:37 +0000 Subject: [PATCH] Phones: one top bar on a website too; maps fill a sized host, so the legend is never cut off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WEB MODE GETS THE TOP BAR BACK. The corner ☰ with no header was rejected on a real phone. Both shell modes now show the one top bar on a phone, the ☰ at its start and the language at its end; web mode's bar takes no camera inset (the shell is not at the top of the screen in a website). The corner circle and the drawer-foot language are gone. check:shell-header renders both modes (13 planted defects). - THE FLOOR NEVER EXCEEDS A SIZED HOST. ProfileLoader's 100svh floor is for a host sized by its content (the scroll-page leaf). Inside a host with a definite height (DX Life's map slot, overflow hidden) it made the map taller than the host, which clipped the map's bottom and its legend footer, and tied the map's height to the window's. ProfileLoader now MEASURES the host (the floor at 0 and at a huge value: a sized host keeps its height) and drops the floor inside a sized one, re-measuring when the host resizes (a ResizeObserver on the host, not a window resize). - LegendDesktop caps itself at the map's height (100% - 80px), not the window's (100vh - 80px). --- internal/check-shell-header.mjs | 32 +++++----- library/ProfileLoader.vue | 39 +++++++++++- library/components/legends/LegendDesktop.vue | 3 +- library/layouts/VerticalTabsLayout.vue | 62 ++++++-------------- 4 files changed, 72 insertions(+), 64 deletions(-) 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 } })