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.
181 lines
15 KiB
JavaScript
181 lines
15 KiB
JavaScript
#!/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/<code>/ 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 <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' } }
|
|
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.
|
|
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(/<!--[\s\S]*?-->/g, '') // template comments are not markup
|
|
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) !== 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 (/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 <script setup> bindings; no DOM needed)
|
|
const Menu = (await server.ssrLoadModule('/' + menu)).default
|
|
// The menu's setup, run INSIDE an SSR render so its compiled wrapper finds the SSR context; in dev mode a
|
|
// <script setup> returns its bindings, so the gate calls the component's real onPick and options.
|
|
const mount = async (current) => {
|
|
const emitted = []; let b = null
|
|
const Probe = { setup() { b = Menu.setup(reactive({ current, collapsed: false }), { emit: (e, ...a) => emitted.push([e, ...a]), expose: () => {}, attrs: {}, slots: {} }); return () => null } }
|
|
await renderToString(createSSRApp(Probe))
|
|
if (!b || typeof b.onPick !== 'function') throw new Error('ShellLanguageMenu setup did not return its bindings (onPick)')
|
|
return { b, emitted }
|
|
}
|
|
const run = async ({ cancel, owns }) => {
|
|
const host = new EventTarget(); const seen = []
|
|
host.addEventListener('doxa-language-request', (e) => { seen.push(e); if (cancel) e.preventDefault() })
|
|
host.addEventListener('language-change', (e) => seen.push(e))
|
|
const win = fakeWindow(owns); const had = globalThis.window; globalThis.window = win
|
|
const { b, emitted } = await mount('en'); const sel = fakeSelect('ar', host)
|
|
try { b.onPick({ target: sel }) } finally { if (had === undefined) delete globalThis.window; else globalThis.window = had }
|
|
return { seen, emitted, sel, win }
|
|
}
|
|
const up = await run({ cancel: false, owns: true })
|
|
const reqs = up.seen.filter((e) => e.type === 'doxa-language-request')
|
|
if (up.seen.length !== 1 || reqs.length !== 1) fail('one pick must dispatch exactly ONE doxa-language-request', `saw ${up.seen.map((e) => e.type).join(', ') || 'none'}`)
|
|
const e = reqs[0]
|
|
if (e && !(e.detail && e.detail.language === 'ar')) fail('the event must carry detail.language', JSON.stringify(e.detail))
|
|
if (e && !(e.cancelable && e.composed && e.bubbles)) fail('the event must be cancelable, composed and bubbling', `cancelable=${e.cancelable} composed=${e.composed} bubbles=${e.bubbles}`)
|
|
if (up.emitted.filter(([n]) => n === 'switch').length !== 1 || (up.emitted[0] || [])[1] !== 'ar') fail('an uncancelled pick must emit switch("ar") once', JSON.stringify(up.emitted))
|
|
if (!(up.win.calls.length === 1 && /[?&]lang=ar\b/.test(up.win.calls[0]) && /[?&]tab=x\b/.test(up.win.calls[0]))) fail('an uncancelled pick on a tree-owned page must write ?lang= (keeping the query)', JSON.stringify(up.win.calls))
|
|
const notOwned = await run({ cancel: false, owns: undefined })
|
|
if (notOwned.win.calls.length) fail('?lang= was written on a page the tree does not own (MAP_OWNS_PAGE unset)', JSON.stringify(notOwned.win.calls))
|
|
const cancelled = await run({ cancel: true, owns: true })
|
|
if (cancelled.emitted.some(([n]) => n === 'switch')) fail('a cancelled request must NOT switch the shell', JSON.stringify(cancelled.emitted))
|
|
if (cancelled.win.calls.length) fail('a cancelled request must not write ?lang=', JSON.stringify(cancelled.win.calls))
|
|
if (cancelled.sel.value !== 'en') fail('after a cancel the menu must show the current language again', cancelled.sel.value)
|
|
|
|
// 5. parity + the current language
|
|
const L = await server.ssrLoadModule('/' + langs)
|
|
const missing = L.SWITCHER_LANGUAGES.map((l) => l.code).filter((c) => !existsSync(join(ROOT, 'library/i18n/locales', c)))
|
|
if (missing.length) fail('a switcher language has no catalogue', missing.join(', '))
|
|
const de = (await mount('de')).b.options
|
|
const codes = (de && de.value ? de.value : []).map((l) => l.code)
|
|
if (!codes.includes('de')) fail('the menu must list the current language even when the switcher hides it (de)', codes.join(' '))
|
|
const en = (await mount('en')).b.options
|
|
if ((en && en.value ? en.value : []).map((l) => l.code).join() !== L.SWITCHER_LANGUAGES.map((l) => l.code).join()) fail('with a listed language current, the menu must be exactly the switcher list')
|
|
return bad
|
|
}
|
|
|
|
// ── --self-test: each defect planted in a temporary copy must be refused ─────────────────────────────
|
|
function plant(rel, suffix, edit, redirect = []) {
|
|
const src = readFileSync(join(ROOT, rel), 'utf8')
|
|
let out = edit(src)
|
|
if (out === src) throw new Error(`poison "${suffix}" did not change ${rel}`)
|
|
for (const [from, to] of redirect) out = out.split(from).join(to)
|
|
const dot = rel.lastIndexOf('.'); const tmp = rel.slice(0, rel.lastIndexOf('/') + 1) + `.tmp-shellhdr-${suffix}` + rel.slice(dot)
|
|
writeFileSync(join(ROOT, tmp), out); PLANTED.push(tmp); return tmp
|
|
}
|
|
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 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 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 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: 'a second event is fired', files: () => ({ menu: plant(MENU, 'twice', (s) => s.replace(" if (requestLanguage(sel, code)) {", " sel.getRootNode().host.dispatchEvent(new CustomEvent('language-change', { detail: { language: code } }))\n if (requestLanguage(sel, code)) {")) }) },
|
|
{ name: 'a cancelled pick still switches', files: () => ({ menu: plant(MENU, 'ignorecancel', (s) => s.replace(' if (requestLanguage(sel, code)) {', ' if (requestLanguage(sel, code) || true) {')) }) },
|
|
{ name: '?lang= is written on any page', files: () => { const r = plant(REQ, 'anypage', (s) => s.replace('win.MAP_OWNS_PAGE !== true || ', '')); return { menu: plant(MENU, 'm-anypage', (s) => s + ' ', [['../utils/languageRequest.js', '../utils/' + r.split('/').pop()]]) } } },
|
|
{ name: 'the hidden current language is dropped', files: () => ({ menu: plant(MENU, 'nocurrent', (s) => s.replace(' if (cur) list.push(cur)', ' if (cur && !cur.hideFromSwitcher) list.push(cur)')) }) },
|
|
{ name: 'a switcher language has no catalogue', files: () => ({ langs: plant(LANGS, 'xx', (s) => s.replace("{ code: 'ru', name: 'Russian',", "{ code: 'xx', name: 'Nowhere', nativeName: 'Nowhere', flag: '' },\n { code: 'ru', name: 'Russian',")) }) },
|
|
]
|
|
|
|
let exit = 0
|
|
try {
|
|
if (process.argv.includes('--self-test')) {
|
|
let caught = 0
|
|
for (const p of POISONS) {
|
|
try {
|
|
const bad = await audit(p.files())
|
|
if (bad.length) { caught++; console.log(` ${p.name}: caught (${bad[0].slice(0, 90)})`) }
|
|
else { console.log(` ${p.name}: NOT CAUGHT`); exit = 1 }
|
|
} finally {
|
|
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
|
}
|
|
}
|
|
console.log(exit ? `SHELL-HEADER SELF-TEST: FAIL — ${POISONS.length - caught} of ${POISONS.length} defects passed.` : `SHELL-HEADER SELF-TEST: PASS — all ${POISONS.length} planted defects refused.`)
|
|
} 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.')
|
|
}
|
|
} finally {
|
|
while (PLANTED.length) rmSync(join(ROOT, PLANTED.pop()), { force: true })
|
|
await server.close()
|
|
}
|
|
process.exit(exit)
|