From 6032512edf375e6f969ec6d23eb6848bc0537986 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Fri, 2 Oct 2026 04:44:33 +0000 Subject: [PATCH] Phones: one header on a website, and maps that hold their size while you scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/check-nav-reachable-mobile.mjs | 12 ++-- internal/check-one-scroll-container.mjs | 4 +- internal/check-shell-header.mjs | 32 +++++---- internal/generate-tree.mjs | 7 +- library/ProfileLoader.vue | 15 +++-- library/layouts/VerticalTabsLayout.vue | 86 +++++++++++++++++++++---- 6 files changed, 117 insertions(+), 39 deletions(-) diff --git a/internal/check-nav-reachable-mobile.mjs b/internal/check-nav-reachable-mobile.mjs index f8a3413ff..acc737c35 100644 --- a/internal/check-nav-reachable-mobile.mjs +++ b/internal/check-nav-reachable-mobile.mjs @@ -112,12 +112,12 @@ const PROBE = () => { const rail = all.find(n => /vtl-nav/.test(cls(n))) 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 phone bar's ☰ (.vtl-phone-menu, which replaced the floating corner circle) when - // it does not. + // Any control that can bring the rail on screen. All are legitimate: the header toggle when a + // header exists; with none, the phone bar's ☰ (.vtl-phone-menu, app mode) or the corner ☰ (.vtl-fab, + // web mode: a host page with its own site header). const openers = all - .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) })) + .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' : /vtl-fab/.test(cls(n)) ? 'corner' : 'header-menu', on: onScreen(n), name: name(n) })) return { railOnScreen: onScreen(rail), @@ -174,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-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) if (!btn) return { clicked: false } btn.click() diff --git a/internal/check-one-scroll-container.mjs b/internal/check-one-scroll-container.mjs index 8aa4c0214..06a0392b2 100644 --- a/internal/check-one-scroll-container.mjs +++ b/internal/check-one-scroll-container.mjs @@ -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 ──────────────── // 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 -// `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 // 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)) { 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` - + ' 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' + ' tab strip included. The three lines are one shape: fill, scroll, release the floor.') } diff --git a/internal/check-shell-header.mjs b/internal/check-shell-header.mjs index 5f65b388a..a30723ff5 100644 --- a/internal/check-shell-header.mjs +++ b/internal/check-shell-header.mjs @@ -58,22 +58,28 @@ 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 - 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 } } } + // 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. + 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' }, { 'phone-end': () => h('i', { class: 'probe-phone-end' }), 'rail-end': () => h('i', { class: 'probe-rail-end' }) }) }) const html = (await renderToString(app)).replace(//g, '') // template comments are not markup - const tag = `narrow=${narrow} header=${header}` - const wantBar = narrow && !header, wantRail = !narrow && !header + const tag = `mode=${mode} narrow=${narrow} header=${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 (/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 (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}`) + 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 (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