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",