doxa-maps/internal/check-shell-header.mjs
Doxa Maps 326eccb465 Phone drawer: starts closed from the first paint; the language sits at its foot
- On a phone the drawer started OPEN (navOpen = initialNavOpen) and mounted() closed it, so every load drew it
  on screen and slid it away (measured: up to ~1.6 s, both shells, app and web mode). data() now reads the
  same (max-width: 760px) query, so the first render is already the phone's closed state; mounted() still
  tracks breakpoint crossings.
- In web mode the language at the drawer's foot sat right under the last row: margin-top:auto needs the nav's
  flex column (.has-rail-end), which only the desktop rail-end set. The phone drawer's foot sets it too.
- check:shell-header asserts the flex column in web mode (14 planted defects).
2026-10-02 05:04:36 +00:00

184 lines
16 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}`)
// The foot is margin-top:auto in a FLEX column (.has-rail-end); in a block nav it sits under the last row.
if (wantFab && !/<nav class="[^"]*\bhas-rail-end\b[^"]*"/.test(html)) fail(`the drawer is not a flex column, so its language sits under the last row, not at the foot, 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 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('<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)