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)