Guard the viewport meta, because without it a "390" run is a 980px run

A browser given a 390px viewport still lays the page out at the 980px desktop
fallback unless the document declares <meta name="viewport">. Measured in
Chromium at 390x844 with isMobile:true:

  without the meta:  window.innerWidth = 980  (body 964px)
  with the meta:     window.innerWidth = 390  (body 374px)

Nothing warns. The page renders, the probe reads its numbers, and the check
goes green having measured a viewport no phone has. A full set of mobile runs
was taken this way before the layout width was ever compared to the requested
one, so the conclusions drawn from them described the wrong viewport.

Three gates already read window.innerWidth and none compares it to the width it
asked for, so all three would pass at 980 while reporting 390. This asserts the
property that makes their numbers mean what they say.

Checked on the built artifact rather than the generator template: a template
containing the right string proves nothing about the HTML that shipped. No
server needed.

width=device-width is required specifically, not just the tag: a meta carrying
only initial-scale still lays out at 980, so matching the tag name alone would
leave the gate unable to fail on its own founding bug.

The 28 sheets under assets/posters/ are exempt by their stated shape - a fixed
paper canvas (.stage{width:1500px;height:2000px}) has no mobile layout to get
wrong. Keyed on that property, not the directory, so a poster keeps its
exemption if it moves and an app page in that folder does not inherit one.

Mutation-tested against the real tree: stripping the meta from a shipped page
fails it, and replacing it with an initial-scale-only tag fails it. 8 self-tests
cover both, plus the exemption and its inverse.
This commit is contained in:
Doxa Maps 2026-09-29 11:10:55 +00:00
parent 18e5dc8fdf
commit eb3f48ec14
2 changed files with 127 additions and 1 deletions

View file

@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* check-viewport-meta.mjs — every generated page must declare a viewport, because without one
* EVERY mobile measurement in this repo is silently a desktop measurement.
*
* ★ WHY THIS GATE EXISTS, AND WHY IT IS NOT A STYLE RULE. A browser told to use a 390px viewport
* still lays the page out at the 980px "desktop fallback" unless the document carries
* <meta name="viewport">. MEASURED here, Chromium, isMobile:true, viewport 390x844:
*
* without the meta: window.innerWidth = 980 (body 964px)
* with the meta: window.innerWidth = 390 (body 374px)
*
* So a page missing the tag does not fail, does not warn, and does not look broken to a probe.
* It quietly reports desktop numbers under a mobile label. That is the worst shape a defect can
* take: a green check that measured the wrong thing. It was found the expensive way — a full set
* of "390" runs against hand-written host pages turned out to be 980px layouts, so the mobile
* conclusions drawn from them described a viewport no phone has.
*
* ★ WHAT THIS GUARDS THAT THE OTHER GATES CANNOT. Three gates read window.innerWidth, but none
* compares it to the width it asked for, so all three would pass at 980 while claiming 390. The
* generator currently writes the tag at every call site, which is exactly why this is worth
* pinning: the tag is correct today, invisible when removed, and expensive to miss.
*
* THE CHECK IS ON THE BUILT ARTIFACT, not on the generator source. A template that contains the
* right string proves nothing about the HTML that shipped — only the shipped file does. No server
* is needed: this is a property of the bytes on disk.
*
* Escape hatch: a page that genuinely must render at a fixed desktop width may carry the comment
* `viewport-meta-ok:` followed by a reason on the same line.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const PUBLIC = join(ROOT, 'public')
const ALLOW = /viewport-meta-ok:/
// width=device-width is the part that actually defeats the 980px fallback. A tag that sets only
// initial-scale still lays out at 980, so matching the bare tag name would make this gate
// unfalsifiable against the very bug it guards.
const META_RE = /<meta[^>]+name=["']viewport["'][^>]*>/i
const DEVICE_WIDTH_RE = /content=["'][^"']*width\s*=\s*device-width/i
function htmlFiles(dir, out = []) {
let entries
try { entries = readdirSync(dir) } catch { return out }
for (const e of entries) {
const p = join(dir, e)
let st
try { st = statSync(p) } catch { continue }
if (st.isDirectory()) htmlFiles(p, out)
else if (e.endsWith('.html')) out.push(p)
}
return out
}
// ★ A FIXED-SIZE PRINT SHEET IS NOT A RESPONSIVE PAGE, and demanding width=device-width of one
// would be wrong rather than merely noisy. MEASURED: the 28 files under assets/posters/ are paper
// canvases — `.stage{width:1500px;height:2000px}` — drawn at an absolute size and then scaled for
// print. They have no mobile layout to get wrong, so they have nothing for this gate to protect.
// Keyed on the STATED property (a fixed-px stage) rather than on the directory name, so a poster
// that moves keeps its exemption and an app page that lands in that folder does not inherit one.
const PRINT_SHEET_RE = /\.stage\s*\{[^}]*\bwidth\s*:\s*\d{3,}px/i
/** @returns {null | {why: string}} null when the page is fine */
export function auditHtml(html) {
if (ALLOW.test(html)) return null
if (PRINT_SHEET_RE.test(html)) return null
const tag = html.match(META_RE)
if (!tag) return { why: 'no <meta name="viewport"> — lays out at 980px, so every mobile measurement of this page is a desktop measurement' }
if (!DEVICE_WIDTH_RE.test(tag[0])) return { why: `viewport meta without width=device-width, which does NOT defeat the 980px fallback: ${tag[0].trim()}` }
return null
}
if (process.env.VIEWPORT_META_SELFTEST === '1') {
const good = '<head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head>'
const cases = [
['missing entirely', '<head><title>x</title></head>', true],
['present and correct', good, false],
['single quotes + spacing', "<meta name='viewport' content='width = device-width'>", false],
['uppercase tag', '<META NAME="VIEWPORT" CONTENT="width=device-width">', false],
// The mutation that matters: the tag is THERE but useless. A gate matching only the tag name
// would call this a pass, and the page would still lay out at 980.
['initial-scale only (the silent one)', '<meta name="viewport" content="initial-scale=1.0">', true],
['escape hatch honoured', '<!-- viewport-meta-ok: fixed-width print sheet --><head></head>', false],
// The print-sheet exemption must be earned by a fixed-px stage, not by being HTML that
// happens to mention a stage. Otherwise it becomes a blanket excuse and the gate is theatre.
['print sheet exempt', '<style>.stage{width:1500px;height:2000px}</style>', false],
['responsive stage NOT exempt', '<style>.stage{width:100%}</style>', true],
]
let bad = 0
for (const [name, html, shouldFail] of cases) {
const got = auditHtml(html) !== null
const ok = got === shouldFail
if (!ok) bad++
console.log(` ${ok ? 'ok ' : 'FAIL'} ${name} — expected ${shouldFail ? 'fail' : 'pass'}, got ${got ? 'fail' : 'pass'}`)
}
console.log(bad ? `SELFTEST: ${bad} case(s) wrong` : `SELFTEST: ${cases.length}/${cases.length} ok`)
process.exit(bad ? 1 : 0)
}
const files = htmlFiles(PUBLIC)
if (!files.length) {
console.error('VIEWPORT-META: NOT MEASURED — no .html under public/. Build the tree first (bun run tree).')
console.error(' Reporting a pass here would mean "I checked nothing", which is the failure this gate exists to prevent.')
process.exit(2)
}
const bad = []
for (const f of files) {
const hit = auditHtml(readFileSync(f, 'utf8'))
if (hit) bad.push([relative(ROOT, f), hit.why])
}
if (bad.length) {
console.error(`VIEWPORT-META: FAIL — ${bad.length} of ${files.length} generated page(s) would lay out at 980px:\n`)
for (const [f, why] of bad) console.error(` ${f}\n ${why}`)
console.error('\nEvery "390" or "768" number taken against these pages describes a 980px desktop layout.')
console.error('Fix the generator call site that writes this page; do not edit the built file.')
process.exit(1)
}
console.log(`VIEWPORT-META: PASS — ${files.length} generated page(s) all declare width=device-width.`)

View file

@ -233,6 +233,8 @@
"check:pin-selection": "node internal/check-pin-selection.mjs", "check:pin-selection": "node internal/check-pin-selection.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.mjs", "check:poster-action-print": "node internal/check-poster-action-print.mjs",
"check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs", "check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs",
"check:viewport-meta": "node internal/check-viewport-meta.mjs",
"check:viewport-meta:self-test": "VIEWPORT_META_SELFTEST=1 node internal/check-viewport-meta.mjs",
"check:poster-attribution": "node internal/check-poster-attribution.mjs", "check:poster-attribution": "node internal/check-poster-attribution.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs", "check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs", "check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
@ -328,7 +330,7 @@
"fix:sri": "node internal/fix-sri.mjs", "fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs", "gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:snippet-engine check:snippet-engine:self-test", "gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:snippet-engine check:snippet-engine:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim", "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs", "gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs", "library-index": "node internal/generate-library-index.mjs",