#!/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 // 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. 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 = `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 (/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}`) } // 2-4. the pick, through the menu's REAL handler (its