From 25982d717f45b3fbedd224fbbfffdb1745721d29 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 23:28:52 +0000 Subject: [PATCH] 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. --- documentation/reference-library-index.md | 4 + internal/check-nav-reachable-mobile.mjs | 9 +- internal/check-shell-header.mjs | 173 +++++++++++++++++++++++ library/components/ShellLanguageMenu.vue | 91 ++++++++++++ library/layouts/VerticalTabsLayout.vue | 154 +++++++++++--------- library/utils/languageRequest.js | 58 ++++++++ package.json | 7 +- 7 files changed, 424 insertions(+), 72 deletions(-) create mode 100644 internal/check-shell-header.mjs create mode 100644 library/components/ShellLanguageMenu.vue create mode 100644 library/utils/languageRequest.js diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index c7949d1c1..934addb27 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -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). diff --git a/internal/check-nav-reachable-mobile.mjs b/internal/check-nav-reachable-mobile.mjs index f109edf2f..f8a3413ff 100644 --- a/internal/check-nav-reachable-mobile.mjs +++ b/internal/check-nav-reachable-mobile.mjs @@ -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() diff --git a/internal/check-shell-header.mjs b/internal/check-shell-header.mjs new file mode 100644 index 000000000..5f65b388a --- /dev/null +++ b/internal/check-shell-header.mjs @@ -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// 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 ; 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(//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 + + + + diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 2d5f1b043..f26c6781c 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -91,20 +91,11 @@ after a resize back to desktop — the previous broken-restore bug. -->