From 51ef6f7ec4b98088f3eefe6c8987fbc22b396590 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 06:16:03 +0000 Subject: [PATCH] One phone flag per map, and the leaf page follows it as well as the Gallery A map says how it behaves on a narrow screen once, in its .meta.json: "mobile": "desktop-only" (blocked on a phone) or "larger-screens" (opens, under a dismissible notice). Absent means phone-ready. Until now the rule lived only in the Research Gallery, as two hard-coded id sets, so a map opened standalone on its own leaf page on a phone got neither the block nor the notice. - profile-meta.mjs knows the vocabulary; generate-tree and the dev manifest carry the flag into each profile's manifest entry, and generate-tree into the leaf page's BASE_CONFIG (with the map's name for the desktop-only sentence). - ProfileLoader mounts library/components/LargerScreenNotice.vue on a screen under 640px: for desktop-only the profile is never mounted and a centred message says why; for larger-screens a slim in-flow bar sits above the map (it cannot cover the map's controls), dismissible per profile for the session, in the element's own locale and direction. Inside the Gallery it stays quiet: every Gallery pane now carries data-doxa-gallery. - One text: the Gallery's two sentences moved into the library catalogue as messages.largerScreenNotice / desktopOnlyNotice (+ noticeDismiss), all 11 locales moved as they were, and the Gallery reads them from there. - The Gallery derives its rules from the manifest's flags. On a phone it mounts nothing until the manifest has landed: its baked first tab (Big Picture) would otherwise mount there before the flags say it is desktop-only. - dashboard-plus-grid is desktop-only and gospel-internet/board is larger-screens, the two sets the Gallery held; nothing changes for either. - check:mobile-gating now asserts the vocabulary, that each flagged leaf page carries its flag, the Gallery's derivation and its closed-before-manifest rule, that Big Picture stays desktop-only, the 11 locales, and the notice's mounting. Its self-test plants one failure per rule. --- documentation/reference-library-index.md | 2 + internal/check-mobile-gating.mjs | 179 ++++++++++++------ .../fixtures/driver-references-baseline.json | 2 - internal/generate-tree.mjs | 16 +- internal/profile-meta.mjs | 19 +- library/ProfileLoader.vue | 70 ++++++- library/components/LargerScreenNotice.vue | 94 +++++++++ library/i18n/locales/ar/messages.json | 5 +- library/i18n/locales/de/messages.json | 5 +- library/i18n/locales/en/messages.json | 5 +- library/i18n/locales/es/messages.json | 5 +- library/i18n/locales/fr/messages.json | 5 +- library/i18n/locales/hi/messages.json | 5 +- library/i18n/locales/it/messages.json | 5 +- library/i18n/locales/pt/messages.json | 5 +- library/i18n/locales/ro/messages.json | 5 +- library/i18n/locales/ru/messages.json | 5 +- library/i18n/locales/zh/messages.json | 5 +- package.json | 2 +- .../dashboard-plus-grid.meta.json | 1 + .../doxa-maps-spa/doxa-maps-spa.vue | 79 +++++--- plugin-designs/doxa-maps-spa/src/strings.js | 33 ++-- .../gospel-internet/board.meta.json | 1 + .../dashboard-plus-grid/default/index.html | 2 +- .../dashboard-plus-grid/index.html | 2 +- public/dx-maps/doxa-maps-spa/manifest.json | 2 + .../gospel-internet/board/board/index.html | 2 +- .../dx-maps/gospel-internet/board/index.html | 2 +- .../board/india-china/index.html | 2 +- .../board/language-tree/index.html | 2 +- .../board/platforms/index.html | 2 +- .../board/project-700/index.html | 2 +- .../gospel-internet/board/zero/index.html | 2 +- public/dx-maps/manifest.json | 2 + 34 files changed, 444 insertions(+), 131 deletions(-) create mode 100644 library/components/LargerScreenNotice.vue diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index 3fd0bb2e5..8c485f744 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -16,6 +16,8 @@ and a change here can break other maps. - **BackToTopButton** — BackToTopButton — a ↑ that appears once a long list has been scrolled, and sits NEXT TO the `import … from '@map/components/BackToTopButton.vue'` +- **LargerScreenNotice** — LargerScreenNotice — what a map says on a phone when its .meta.json declares mobile + `import … from '@map/components/LargerScreenNotice.vue'` - **PeopleGroupDetail** — (no description) `import … from '@map/components/PeopleGroupDetail.vue'` - **ResearchMapFilterPanel** — ResearchMapFilterPanel.vue — Multi-field filter UI for the research-map. diff --git a/internal/check-mobile-gating.mjs b/internal/check-mobile-gating.mjs index 4bdce1203..b5c703a0a 100644 --- a/internal/check-mobile-gating.mjs +++ b/internal/check-mobile-gating.mjs @@ -1,79 +1,150 @@ #!/usr/bin/env node /** - * check-mobile-gating.mjs — the SPA's device rules stay on the right shelves (DRV1650-MOBILE-GATING). + * check-mobile-gating.mjs — one phone flag per map, and every surface follows it (SPEC-MOBILE-FLAG, + * the per-plugin half of 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. + * A map declares how it behaves on a narrow screen ONCE, in .meta.json: + * "mobile": "desktop-only" blocked on a phone (the Gallery disables its row; the leaf shows why) + * "mobile": "larger-screens" opens on a phone, under a dismissible notice + * absent phone-ready + * generate-tree carries it into the manifest and the leaf page's BASE_CONFIG; the Research Gallery + * derives its rules from the manifest; ProfileLoader shows the leaf's notice. This fails if: + * (a) a meta.json declares a value outside that vocabulary (a typo must not read as phone-ready); + * (b) a flagged profile's leaf page does not carry the flag, or a leaf carries one its meta dropped; + * (c) the Gallery hard-codes an id set again, stops deriving from the manifest, or stops failing + * closed on a phone before the manifest lands (its baked first tab would mount there); + * (d) dashboard-plus-grid stops being desktop-only — the migration must not soften that gate; + * (e) a locale loses one of the notice sentences, or the desktop-only one stops naming the map; + * (f) ProfileLoader stops mounting the notice, or stops staying quiet inside the Gallery. + * `--self-test` plants one bad case per rule and proves each can fail before a pass is trusted. */ -import { readFileSync, existsSync } from 'node:fs' +import { readFileSync, existsSync, readdirSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { MOBILE } from './profile-meta.mjs' +import { TREE_NAME } from './_tree-name.mjs' 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 LOADER = join(ROOT, 'library/ProfileLoader.vue') +const SPA_KEYS = ['mobileNotBuilt', 'dismiss'] +const LIB_KEYS = ['largerScreenNotice', 'desktopOnlyNotice', 'noticeDismiss'] -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) { +export function checkGating({ metas, leaves, spaSrc, spaLocales, libLocales, loaderSrc }) { 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`) + // (a) the vocabulary + for (const m of metas) { + if (m.mobile !== undefined && !MOBILE.includes(m.mobile)) errs.push(`${m.bundle}/${m.profile}.meta.json: mobile "${m.mobile}" is not one of ${MOBILE.join(' | ')}`) } - 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})`) + // (b) the leaf pages carry exactly what the metas declare + for (const m of metas) { + const key = `${m.bundle}/${m.profile}` + if (!(key in leaves)) continue // no leaf page in this tree: nothing to carry + const got = leaves[key] ? leaves[key].mobile : undefined + const want = MOBILE.includes(m.mobile) ? m.mobile : undefined + if (got !== want) errs.push(`${key}: the leaf page says mobile ${JSON.stringify(got)}, its meta.json says ${JSON.stringify(want)} (regenerate the tree)`) } - if (!locales || Object.keys(locales).length < 11) errs.push(`strings: ${Object.keys(locales || {}).length} locales, expected 11`) + // (c) the Gallery derives, and fails closed + if (/const (DESKTOP_ONLY|MOBILE_NOTICE) = new Set\(/.test(spaSrc)) errs.push('the Gallery hard-codes a DESKTOP_ONLY / MOBILE_NOTICE id set again; the flag lives in meta.json') + if (!/this\.mobileFlags = mobileFlagsFromManifest\(m\)/.test(spaSrc)) errs.push('applyManifest no longer records the flags from the manifest') + if (!/isDesktopOnly\(id\)\s*\{\s*return this\.narrow && this\.mobileFlagOf\(id\) === 'desktop-only'/.test(spaSrc)) errs.push("isDesktopOnly no longer reads narrow + the 'desktop-only' flag") + if (!/showsMobileNotice\(id\)\s*\{\s*return this\.narrow && this\.mobileFlagOf\(id\) === 'larger-screens'/.test(spaSrc)) errs.push("showsMobileNotice no longer reads narrow + the 'larger-screens' flag") + if (!/if \(this\.narrow && this\.mobileFlags === null\) return/.test(spaSrc)) errs.push('select() no longer waits for the flags on a phone: the baked first tab would mount before the manifest says it is desktop-only') + if (!/v-if="showsMobileNotice\(t\.id\)"[^>]*role="status"/.test(spaSrc)) errs.push('the pane notice (showsMobileNotice, role=status) is not rendered') + if (!/data-doxa-gallery=""/.test(spaSrc)) errs.push('Gallery panes no longer carry data-doxa-gallery: a map inside would show a second notice') + // (d) the one gate the migration must not soften + const dpg = metas.find((m) => m.bundle === 'dashboard-plus-grid' && m.profile === 'dashboard-plus-grid') + if (!dpg || dpg.mobile !== 'desktop-only') errs.push('dashboard-plus-grid is no longer "mobile": "desktop-only" (Big Picture is blocked on phones)') + // (e) the sentences + for (const [loc, t] of Object.entries(spaLocales || {})) { + for (const k of SPA_KEYS) if (!t || !String(t[k] || '').trim()) errs.push(`Gallery strings ${loc}: missing ${k}`) + } + if (Object.keys(spaLocales || {}).length < 11) errs.push(`Gallery strings: ${Object.keys(spaLocales || {}).length} locales, expected 11`) + for (const [loc, t] of Object.entries(libLocales || {})) { + for (const k of LIB_KEYS) if (!t || !String(t[k] || '').trim()) errs.push(`library messages ${loc}: missing ${k}`) + if (t && t.desktopOnlyNotice && !t.desktopOnlyNotice.includes('{label}')) errs.push(`library messages ${loc}: desktopOnlyNotice does not name the map ({label})`) + } + if (Object.keys(libLocales || {}).length < 11) errs.push(`library messages: ${Object.keys(libLocales || {}).length} locales, expected 11`) + // (f) the leaf's own notice + if (!/ f.endsWith('.meta.json')) } catch { continue } + for (const f of files) { + try { + const meta = JSON.parse(readFileSync(join(base, bundle, f), 'utf8')) + out.push({ bundle, profile: f.replace(/\.meta\.json$/, ''), mobile: meta.mobile }) + } catch { /* an unreadable meta is other gates' business */ } + } + } + return out +} + +// A leaf page's BASE_CONFIG, or null when the page has none. Keyed "bundle/profile"; only pages that exist. +function readLeaves(metas) { + const out = {} + for (const m of metas) { + const page = join(ROOT, 'public', TREE_NAME, m.bundle, m.profile, 'index.html') + if (!existsSync(page)) continue + const hit = readFileSync(page, 'utf8').match(/var BASE_CONFIG = (\{.*?\});/) + try { out[`${m.bundle}/${m.profile}`] = hit ? JSON.parse(hit[1]) : null } catch { out[`${m.bundle}/${m.profile}`] = null } + } + return out +} + +async function readSpaLocales() { const mod = await import(pathToFileURL(STRINGS).href) return Object.values(mod).find((v) => v && typeof v === 'object' && v.en) || {} } +function readLibLocales() { + const out = {} + const base = join(ROOT, 'library/i18n/locales') + for (const lc of readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)) { + try { out[lc] = JSON.parse(readFileSync(join(base, lc, 'messages.json'), 'utf8')) } catch { out[lc] = null } + } + return out +} + +const metas = readMetas() +const real = { + metas, + leaves: readLeaves(metas), + spaSrc: readFileSync(SPA, 'utf8'), + spaLocales: await readSpaLocales(), + libLocales: readLibLocales(), + loaderSrc: readFileSync(LOADER, 'utf8'), +} + if (process.argv.includes('--self-test')) { - const src = readFileSync(SPA, 'utf8'), locales = await loadLocales() - const yes = () => true + const flagged = real.metas.find((m) => m.mobile && `${m.bundle}/${m.profile}` in real.leaves) + const withMeta = (fn) => ({ ...real, metas: real.metas.map((m) => fn({ ...m })) }) 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], + ['a clean tree passes', real, 0], + ['(a) a typo in the vocabulary', withMeta((m) => (m.bundle === 'gospel-internet' && m.profile === 'board' ? { ...m, mobile: 'larger-screen' } : m)), 1], + ['(b) a flagged leaf page without the flag', flagged ? { ...real, leaves: { ...real.leaves, [`${flagged.bundle}/${flagged.profile}`]: {} } } : null, 1], + ['(b) a leaf page carrying a flag its meta dropped', flagged ? withMeta((m) => (m === flagged || (m.bundle === flagged.bundle && m.profile === flagged.profile) ? { ...m, mobile: undefined } : m)) : null, 1], + ['(c) the id set comes back', { ...real, spaSrc: real.spaSrc + "\nconst DESKTOP_ONLY = new Set(['dashboard-plus-grid'])" }, 1], + ['(c) select() stops waiting for the flags', { ...real, spaSrc: real.spaSrc.replace('if (this.narrow && this.mobileFlags === null) return', '') }, 1], + ['(c) the pane notice is removed', { ...real, spaSrc: real.spaSrc.replace('v-if="showsMobileNotice(t.id)"', 'v-if="false"') }, 1], + ['(d) Big Picture is softened', withMeta((m) => (m.bundle === 'dashboard-plus-grid' ? { ...m, mobile: 'larger-screens' } : m)), 1], + ['(e) a locale loses the notice', { ...real, libLocales: { ...real.libLocales, ar: { ...real.libLocales.ar, largerScreenNotice: '' } } }, 1], + ['(e) the desktop-only sentence stops naming the map', { ...real, libLocales: { ...real.libLocales, zh: { ...real.libLocales.zh, desktopOnlyNotice: '仅支持桌面端' } } }, 1], + ['(f) ProfileLoader stops blocking', { ...real, loaderSrc: real.loaderSrc.replace('v-else-if="blockedOnThisScreen"', 'v-else-if="false"') }, 1], ] let ok = true - for (const [name, s, l, hp, want] of cases) { - const n = checkGating(s, l, hp).length + for (const [name, input, want] of cases) { + if (!input) { console.log(` - ${name}: no flagged leaf page in this tree to plant it on`); ok = false; continue } + const n = checkGating(input).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})`) @@ -82,10 +153,10 @@ if (process.argv.includes('--self-test')) { process.exit(ok ? 0 : 1) } -const src = readFileSync(SPA, 'utf8') -const errs = checkGating(src, await loadLocales(), (id) => existsSync(join(ROOT, 'plugin-designs', id))) +const errs = checkGating(real) 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`) +const list = (flag) => real.metas.filter((m) => m.mobile === flag).map((m) => `${m.bundle}/${m.profile}`).join(', ') || '(none)' +console.log(`MOBILE-GATING: PASS — desktop-only: ${list('desktop-only')} · larger-screens: ${list('larger-screens')} · leaf pages carry the flags · 11 locales`) diff --git a/internal/fixtures/driver-references-baseline.json b/internal/fixtures/driver-references-baseline.json index bb8260887..d2ebf2a12 100644 --- a/internal/fixtures/driver-references-baseline.json +++ b/internal/fixtures/driver-references-baseline.json @@ -26,7 +26,6 @@ "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// Driver set for the data bundler: handle every plugin generically, or the next author gets": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// RENAMED (Driver): \"Prayer Map\" → \"Prayer Progress\". His words: \"the prayer maps can be renamed": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// THE ONE DASHBOARD (the Driver's \"Big Picture\"): research-dashboard + people-groups-grid compiled": 1, - "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// THE ONE ROW THE DEVICE RULE DISABLES. Driver, verbatim: \"it shouldn't change the tab order, it": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// The Driver's section/folder nav model, ported from app-wrapper-material/nav.js (the verified": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// The GROUPED rail model the shared VerticalTabsLayout paints, built from NAV_MODEL (the Driver's": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// Whether the standalone #toolbar locale dropdown renders. The Driver's rule: the cap-5 SPA has": 1, @@ -41,7 +40,6 @@ "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// source of the exact sections the Driver wants). Each entry is a GROUP for VerticalTabsLayout; each": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// stack-demo is broken and Driver-DEFERRED; a broken tab in the giveaway shelf reads as": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// the Driver has been looking at: not the plugin's content scroller, the PANE scrolling the": 1, - "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// ── DEVICE RULES (Driver, 2026-09-28 16:50Z: DRV1650-MOBILE-GATING) ─────────────────────────": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// ★ FAIL-SOFT IS NOT FAIL-SILENT (BUG-SIMPLE-RESEARCH-GREY-PANE, Driver reported 4x).": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// ★ MOUNT THE COMPOSITE, NOT THE LEAF (Driver 2026-09-23: \"the Regions tab is supposed to be a": 1, "plugin-designs/doxa-maps-spa/doxa-maps-spa.vue\u0000// ★ THE 404 CLASS THIS REPLACED (measured 2026-09-22, hit by the Driver in his own browser). Every": 1, diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 09a50beee..184f1082e 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -490,6 +490,18 @@ function readComposes(dir, profileName) { * `profile`/`tk`/`assetsBase`/`instanceId` are generator-owned and cannot be overridden. */ const RESERVED_CONFIG_KEYS = new Set(['profile', 'tk', 'assetsBase', 'instanceId', 'dataSource']) + +/** + * The phone rule for a standalone leaf (SPEC-MOBILE-FLAG): the manifest entry's `mobile` flag, and the + * name the desktop-only message gives the map. ProfileLoader reads both; absent for a phone-ready map, + * so every other leaf page keeps the bytes it has. The name is the profile's display label without a + * trailing "(…)" note, which is written for the Studio rail rather than for a reader. + */ +function mobileConfig(profile) { + if (!profile.mobile) return {} + const name = String(profile.label || '').replace(/\s*\([^)]*\)\s*$/, '').trim() + return { mobile: profile.mobile, ...(name ? { mobileLabel: name } : {}) } +} function readProfileConfig(dir, profileName) { try { const metaPath = join(dir, `${profileName}.meta.json`) @@ -1150,7 +1162,7 @@ function profileHtml(b, profile, tokenUrlExpr, bakedToken) { // Profile-declared keys first, so the generator-owned ones always win (they are // also filtered as reserved at read time — belt and braces, since a leaf whose // `profile` or `assetsBase` got overwritten would silently render the wrong thing). - config: { ...(profile.extraConfig || {}), profile: profile.profile, dataSource: 'pray-tools', instanceId: `${b.bundle}-${profile.profile}`, assetsBase: assetsBaseFor(3) }, + config: { ...(profile.extraConfig || {}), ...mobileConfig(profile), profile: profile.profile, dataSource: 'pray-tools', instanceId: `${b.bundle}-${profile.profile}`, assetsBase: assetsBaseFor(3) }, jsSrc: `../${b.bundle}.js`, apiBaseUrl: DEST_CONFIG.apiBaseUrl, tokenUrlExpr, @@ -1184,7 +1196,7 @@ function instanceHtml(b, profile, inst, tokenUrlExpr, bakedToken) { // MERGE, never replace (go-int: a preset lost its profile's pinHint:false): the profile's // .meta.json `config` first, then the instance's props over it. The rule is stated in // documentation/PRESET-PLUGIN-STANDARD.md and asserted by check:preset-config-merge. - config: { assetsBase: assetsBaseFor(4), ...(profile.extraConfig || {}), ...inst.props }, + config: { assetsBase: assetsBaseFor(4), ...(profile.extraConfig || {}), ...mobileConfig(profile), ...inst.props }, jsSrc: `../../${b.bundle}.js`, apiBaseUrl: inst.apiBaseUrl || DEST_CONFIG.apiBaseUrl, tokenUrlExpr, diff --git a/internal/profile-meta.mjs b/internal/profile-meta.mjs index 5d3b3a02a..e6fe0f948 100644 --- a/internal/profile-meta.mjs +++ b/internal/profile-meta.mjs @@ -8,6 +8,7 @@ * heavy true when the element holds live WebGL contexts * widget { corner } for a corner widget the Plugin Studio mounts once, fixed * visibility "team" | "public" default team — see VISIBILITY below + * mobile "desktop-only" | "larger-screens" absent = phone-ready — see MOBILE below */ import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -35,6 +36,19 @@ const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'] */ export const VISIBILITY = ['team', 'public'] +/** + * ★ HOW A MAP BEHAVES ON A PHONE, declared once per profile (SPEC-MOBILE-FLAG). The Research Gallery + * and the standalone leaf page both read it from here, through the manifest and the leaf's config: + * + * "desktop-only" BLOCKS on a narrow screen: the map is not mounted, a message says why + * "larger-screens" ADVISES: the map opens, with a dismissible notice at its top + * ABSENT phone-ready: nothing is shown + * + * Any other value is not coerced: check:mobile-gating fails on it, by profile, so a typo cannot + * quietly turn a desktop-only map into a phone-ready one. + */ +export const MOBILE = ['desktop-only', 'larger-screens'] + export function readProfileMeta(dir, profileName) { try { const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8')) @@ -54,8 +68,9 @@ export function readProfileMeta(dir, profileName) { visibilityInvalid: meta?.visibility === undefined || VISIBILITY.includes(meta?.visibility) ? null : String(meta.visibility), + mobile: MOBILE.includes(meta?.mobile) ? meta.mobile : null, } - } catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null, visibility: 'team', visibilityInvalid: null } } + } catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null, visibility: 'team', visibilityInvalid: null, mobile: null } } } /** The optional manifest keys for one profile (stage always; heavy / widget only when set). */ @@ -63,7 +78,7 @@ export function profileMetaFields(dir, profileName) { const m = readProfileMeta(dir, profileName) // visibility is emitted ONLY when it is "public": absent means team, so every manifest in the // tree today keeps the bytes it has and nothing becomes visible by upgrading the generator. - return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}) } + return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}) } } /** A bundle is production if ANY of its profiles says so. */ diff --git a/library/ProfileLoader.vue b/library/ProfileLoader.vue index 9b38546ef..72f666ac7 100644 --- a/library/ProfileLoader.vue +++ b/library/ProfileLoader.vue @@ -14,7 +14,7 @@ * never interfere with each other. */ -import { computed, provide, inject, watchEffect, watch } from 'vue' +import { computed, provide, inject, watchEffect, watch, ref, onBeforeUnmount, useHost } from 'vue' import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js' import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js' import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, localeFromUrl } from './i18n/index.js' @@ -26,6 +26,7 @@ const QaCorner = __DOXA_QA__ ? retryableAsyncComponent(() => import('./components/qa/QaCorner.vue')) : null import PinHint from './components/hints/PinHint.vue' +import LargerScreenNotice from './components/LargerScreenNotice.vue' import { useUIStore } from './stores/uiStore.js' import { useMapStore } from './stores/mapStore.js' import { useDataStore } from './stores/dataStore.js' @@ -207,6 +208,50 @@ const effectiveLocale = computed(() => lang.value || appI18n?.global?.locale?.va // the failure above, so it never touches the document at all. const dirAttr = computed(() => (RTL_LOCALES.has(effectiveLocale.value) ? 'rtl' : 'ltr')) +// ─── On a phone (SPEC-MOBILE-FLAG) ─────────────────────────── +// profile-config `mobile` comes from .meta.json through the leaf page (generate-tree): +// 'desktop-only' on a narrow screen the profile is NOT mounted; a message says why +// 'larger-screens' the profile mounts under a dismissible notice +// Neither shows inside the Research Gallery, which keeps its own rail badge and pane notice: every +// Gallery pane carries data-doxa-gallery, so a reader never gets two. (Not data-doxa-help: the Gallery +// sets that only on panes whose map has help, so it would miss the rest.) The host is already in the +// document when this runs, so that is known before anything mounts, and a blocked profile is never +// mounted at all rather than mounted and then removed. +const NARROW = '(max-width: 639.98px)' +const mobileFlag = computed(() => { + const m = config.value?.mobile + return m === 'desktop-only' || m === 'larger-screens' ? m : null +}) +let ceHost = null +try { ceHost = useHost() } catch { ceHost = null } +function insideGallery(node) { + for (let n = node; n; n = n.parentNode || n.host) { + if (n.nodeType === 1 && n.hasAttribute('data-doxa-gallery')) return true + } + return false +} +const hostedInGallery = ceHost ? insideGallery(ceHost) : false +const narrow = ref(false) +if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + const mq = window.matchMedia(NARROW) + narrow.value = mq.matches + const onChange = (e) => { narrow.value = e.matches } + mq.addEventListener?.('change', onChange) + onBeforeUnmount(() => mq.removeEventListener?.('change', onChange)) +} +const dismissKey = computed(() => `dx-maps:larger-screens-dismissed:${config.value?.profile || ''}`) +const noticeDismissed = ref(false) +watch(dismissKey, (k) => { + try { noticeDismissed.value = sessionStorage.getItem(k) === '1' } catch { noticeDismissed.value = false } +}, { immediate: true }) +function dismissLargerScreenNotice() { + noticeDismissed.value = true + try { sessionStorage.setItem(dismissKey.value, '1') } catch { /* private mode: dismissed until reload */ } +} +const blockedOnThisScreen = computed(() => mobileFlag.value === 'desktop-only' && narrow.value && !hostedInGallery) +const adviseOnThisScreen = computed(() => mobileFlag.value === 'larger-screens' && narrow.value && !hostedInGallery && !noticeDismissed.value) +const mobileLabel = computed(() => String(config.value?.mobileLabel || config.value?.profile || '')) + // ─── Instance ID ───────────────────────────────────────────────────────────── // Optional in profile-config: { "instanceId": "my-map" } // If not provided, a random ID is generated (stable for the element's lifetime). @@ -282,7 +327,7 @@ provide('dataStore', dataStore)