diff --git a/internal/check-viewport-meta.mjs b/internal/check-viewport-meta.mjs
new file mode 100644
index 000000000..56ae06b82
--- /dev/null
+++ b/internal/check-viewport-meta.mjs
@@ -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
+ * . 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 = /]+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 — 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 = '
'
+ const cases = [
+ ['missing entirely', 'x', true],
+ ['present and correct', good, false],
+ ['single quotes + spacing', "", false],
+ ['uppercase tag', '', 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)', '', true],
+ ['escape hatch honoured', '', 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', '', false],
+ ['responsive stage NOT exempt', '', 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.`)
diff --git a/package.json b/package.json
index 8260da4c6..f39ddf8fb 100644
--- a/package.json
+++ b/package.json
@@ -233,6 +233,8 @@
"check:pin-selection": "node internal/check-pin-selection.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.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-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
@@ -328,7 +330,7 @@
"fix:sri": "node internal/fix-sri.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: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",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",