From 7ea66b21a272eea5ff932648bc76b8de40fea361 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 17:32:57 +0000 Subject: [PATCH] Phones: production maps not built for them stay closed and say why; experimental ones open with a notice --- internal/check-mobile-gating.mjs | 91 +++++++++++++++++++ package.json | 4 +- .../doxa-maps-spa/doxa-maps-spa.vue | 64 ++++++++++--- plugin-designs/doxa-maps-spa/src/strings.js | 33 ++++--- 4 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 internal/check-mobile-gating.mjs diff --git a/internal/check-mobile-gating.mjs b/internal/check-mobile-gating.mjs new file mode 100644 index 000000000..4bdce1203 --- /dev/null +++ b/internal/check-mobile-gating.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * check-mobile-gating.mjs — the SPA's device rules stay on the right shelves (DRV1650-MOBILE-GATING). + * + * DESKTOP_ONLY production rows BLOCKED on a narrow screen (disabled + "desktop only" + a message) + * MOBILE_NOTICE experimental rows that OPEN on a narrow screen, with a "not built for mobile" notice + * + * Headless: reads plugin-designs/doxa-maps-spa/doxa-maps-spa.vue and src/strings.js. It fails if: + * · a DESKTOP_ONLY id is not a curated (production) tab, or a MOBILE_NOTICE id is not an Experimental + * bundle (curated, excluded, or no plugin folder), or an id is in both sets; + * · the predicates stop reading their set and the narrow flag, or the pane notice stops rendering; + * · any locale lacks mobileNotBuilt / mobileNotice / dismiss / desktopNotice, or desktopNotice + * stops naming the map ({label}), which it must now that more than one map can be desktop-only. + * `--self-test` proves every rule can fail on a planted bad case before trusting a pass. + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const SPA = join(ROOT, 'plugin-designs/doxa-maps-spa/doxa-maps-spa.vue') +const STRINGS = join(ROOT, 'plugin-designs/doxa-maps-spa/src/strings.js') +const KEYS = ['mobileNotBuilt', 'mobileNotice', 'dismiss', 'desktopNotice'] + +const setOf = (src, name) => { + const m = src.match(new RegExp(`const ${name} = new Set\\(\\[([^\\]]*)\\]\\)`)) + return m ? new Set([...m[1].matchAll(/'([a-z0-9-]+)'/g)].map((x) => x[1])) : null +} + +export function checkGating(src, locales, hasPlugin) { + const errs = [] + const desk = setOf(src, 'DESKTOP_ONLY'), mob = setOf(src, 'MOBILE_NOTICE'), notExp = setOf(src, 'NOT_EXPERIMENTAL') + if (!desk) errs.push('DESKTOP_ONLY set not found') + if (!mob) errs.push('MOBILE_NOTICE set not found') + if (!notExp) errs.push('NOT_EXPERIMENTAL set not found') + if (errs.length) return errs + const curated = new Set([...src.matchAll(/bundle: '([a-z0-9-]+)'/g)].map((x) => x[1])) + for (const id of desk) if (!curated.has(id)) errs.push(`DESKTOP_ONLY "${id}" is not a curated production tab`) + for (const id of mob) { + if (curated.has(id)) errs.push(`MOBILE_NOTICE "${id}" is a curated production tab (production maps are blocked, not warned)`) + if (notExp.has(id)) errs.push(`MOBILE_NOTICE "${id}" is excluded from the Experimental shelf`) + if (!hasPlugin(id)) errs.push(`MOBILE_NOTICE "${id}" has no plugin-designs/${id} folder`) + if (desk.has(id)) errs.push(`"${id}" is in BOTH sets`) + } + if (!/isDesktopOnly\(id\)\s*\{\s*return DESKTOP_ONLY\.has\(id\) && this\.narrow/.test(src)) errs.push('isDesktopOnly no longer reads DESKTOP_ONLY + narrow') + if (!/showsMobileNotice\(id\)\s*\{\s*return MOBILE_NOTICE\.has\(id\) && this\.narrow/.test(src)) errs.push('showsMobileNotice no longer reads MOBILE_NOTICE + narrow') + if (!/v-if="showsMobileNotice\(t\.id\)"[^>]*role="status"/.test(src)) errs.push('the pane notice (showsMobileNotice, role=status) is not rendered') + for (const [loc, t] of Object.entries(locales || {})) { + for (const k of KEYS) if (!t || !String(t[k] || '').trim()) errs.push(`strings ${loc}: missing ${k}`) + if (t && t.desktopNotice && !t.desktopNotice.includes('{label}')) errs.push(`strings ${loc}: desktopNotice does not name the map ({label})`) + } + if (!locales || Object.keys(locales).length < 11) errs.push(`strings: ${Object.keys(locales || {}).length} locales, expected 11`) + return errs +} + +async function loadLocales() { + const mod = await import(pathToFileURL(STRINGS).href) + return Object.values(mod).find((v) => v && typeof v === 'object' && v.en) || {} +} + +if (process.argv.includes('--self-test')) { + const src = readFileSync(SPA, 'utf8'), locales = await loadLocales() + const yes = () => true + const cases = [ + ['a clean tree passes', src, locales, yes, 0], + ['a curated id in MOBILE_NOTICE', src.replace("new Set(['gospel-internet'])", "new Set(['gospel-internet', 'dashboard-plus-grid'])"), locales, yes, 1], + ['an experimental id in DESKTOP_ONLY', src.replace("new Set(['dashboard-plus-grid'])", "new Set(['dashboard-plus-grid', 'gospel-internet'])"), locales, yes, 1], + ['a MOBILE_NOTICE id with no plugin', src, locales, () => false, 1], + ['the predicate stops reading narrow', src.replace('return DESKTOP_ONLY.has(id) && this.narrow', 'return DESKTOP_ONLY.has(id)'), locales, yes, 1], + ['the pane notice is removed', src.replace('v-if="showsMobileNotice(t.id)"', 'v-if="false"'), locales, yes, 1], + ['a locale loses mobileNotice', src, { ...locales, ar: { ...locales.ar, mobileNotice: '' } }, yes, 1], + ['desktopNotice stops naming the map', src, { ...locales, zh: { ...locales.zh, desktopNotice: '仅支持桌面端' } }, yes, 1], + ] + let ok = true + for (const [name, s, l, hp, want] of cases) { + const n = checkGating(s, l, hp).length + const pass = want === 0 ? n === 0 : n >= want + if (!pass) ok = false + console.log(` ${pass ? '✓' : '✗'} ${name} -> ${n} error(s) (want ${want === 0 ? 0 : '≥' + want})`) + } + console.log(ok ? 'MOBILE-GATING SELF-TEST: PASS — every rule proven able to fail' : 'MOBILE-GATING SELF-TEST: FAIL') + process.exit(ok ? 0 : 1) +} + +const src = readFileSync(SPA, 'utf8') +const errs = checkGating(src, await loadLocales(), (id) => existsSync(join(ROOT, 'plugin-designs', id))) +if (errs.length) { + console.error('MOBILE-GATING: FAIL\n' + errs.map((e) => ' ✗ ' + e).join('\n')) + process.exit(1) +} +console.log(`MOBILE-GATING: PASS — desktop-only: ${[...setOf(src, 'DESKTOP_ONLY')].join(', ')} · mobile notice: ${[...setOf(src, 'MOBILE_NOTICE')].join(', ')} · 11 locales`) diff --git a/package.json b/package.json index 05bcf137b..98c82c19a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "cap:verify": "test -f deployment/capacitor/android/app/src/main/assets/public/index.html && echo \"OK webDir copied\" || (echo \"FAIL: empty assets/public -> blank white screen. Check webDir.\" && exit 1)", "capability-exemplars": "node internal/generate-capability-exemplars.mjs", "check:a11y-button-names": "node internal/check-a11y-button-names.mjs", + "check:mobile-gating": "node internal/check-mobile-gating.mjs", "check:app-start-page": "node internal/set-app-start-page.mjs --check", "check:approx-pin-note": "node internal/check-approx-pin-note.mjs", "check:auto-engine": "node internal/check-auto-engine.mjs", @@ -285,7 +286,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: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:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels", - "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:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist 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", + "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:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist 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", "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", @@ -338,6 +339,7 @@ "cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.", "capability-exemplars": "regenerate documentation/reference-capability-exemplars.md — the DERIVED answer to \"which bundle is the capability-N example?\", a question answered WRONGLY in three separate artifacts (a gate, the README, the /plugin-designer skill).", "check:a11y-button-names": "every VISIBLE button must have an accessible name (aria-label / aria-labelledby / visible text — a `title` is a TOOLTIP and does NOT count). Measures the RENDERED DOM through shadow roots: a source grep cannot answer this (132 `