A desktop-only composite blocks on a phone too: the phone rule is a composable its root can call

rega measured dashboard-plus-grid's own leaf at 390px: the page carried
"mobile": "desktop-only" in its profile-config, and the dashboard, the grid
and a MapLibre canvas mounted anyway. The rule lived in ProfileLoader, and
this composite defines its element without ProfileLoader (it bakes its
children in), so nothing on that page read the flag.

- library/composables/useScreenGate.js holds the rule (narrow screen, not
  inside the Gallery, dismissed for the session); ProfileLoader uses it,
  unchanged in behaviour.
- dashboard-plus-grid calls it: on a narrow screen the desktop-only message
  replaces the element, and the dashboard and grid never mount.
- check:mobile-gating (g): every flagged profile's element must mount
  through ProfileLoader (an import in its index.js, not a mention in a
  comment) or call useScreenGate itself. The pre-fix composite fails it.
This commit is contained in:
Doxa Maps 2026-09-29 09:07:40 +00:00
parent cafee0d432
commit f96fb4290f
5 changed files with 130 additions and 47 deletions

View file

@ -197,6 +197,8 @@ and a change here can break other maps.
`import … from '@map/composables/usePrayedLocally.js'`
- **usePrayerStatistics** — usePrayerStatistics.js — the ONE authoritative prayer-coverage source of truth.
`import … from '@map/composables/usePrayerStatistics.js'`
- **useScreenGate** — useScreenGate — the phone rule for one element (SPEC-MOBILE-FLAG).
`import … from '@map/composables/useScreenGate.js'`
- **useSearch** — useSearch.js — Local geocoder search composable
`import … from '@map/composables/useSearch.js'`
- **useSelectedPin** — useSelectedPin.js - Selected Pin Highlight + Animated GO Marker
@ -242,6 +244,8 @@ and a change here can break other maps.
`import … from '@map/utils/formatCount.js'`
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
`import … from '@map/utils/geoUtils.js'`
- **hostElement** — hostElement — the custom element a component is mounted inside.
`import … from '@map/utils/hostElement.js'`
- **html** — html.js — the two helpers every HTML string built in library/ must use (CRITIC-3, 2026-09-26).
`import … from '@map/utils/html.js'`
- **languageLabel** — languageLabel.js — turn the API's comma-inverted language label into the natural reading.
@ -250,6 +254,8 @@ and a change here can break other maps.
`import … from '@map/utils/loadPinnedUmd.js'`
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
`import … from '@map/utils/peopleGroupPoints.js'`
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.
`import … from '@map/utils/scrollport.js'`
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,
`import … from '@map/utils/viewportScrim.js'`

View file

@ -15,7 +15,10 @@
* 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.
* (f) ProfileLoader stops mounting the notice, or the rule stops staying quiet inside the Gallery;
* (g) a flagged profile's element is mounted by neither ProfileLoader nor its own useScreenGate —
* dashboard-plus-grid defines its element without ProfileLoader, so its leaf carried
* "desktop-only" and mounted the map on a phone anyway (rega, 390px).
* `--self-test` plants one bad case per rule and proves each can fail before a pass is trusted.
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs'
@ -28,10 +31,11 @@ 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 LOADER = join(ROOT, 'library/ProfileLoader.vue')
const GATE = join(ROOT, 'library/composables/useScreenGate.js')
const SPA_KEYS = ['mobileNotBuilt', 'dismiss']
const LIB_KEYS = ['largerScreenNotice', 'desktopOnlyNotice', 'noticeDismiss']
export function checkGating({ metas, leaves, spaSrc, spaLocales, libLocales, loaderSrc }) {
export function checkGating({ metas, leaves, spaSrc, spaLocales, libLocales, loaderSrc, gateSrc, mounts }) {
const errs = []
// (a) the vocabulary
for (const m of metas) {
@ -69,7 +73,14 @@ export function checkGating({ metas, leaves, spaSrc, spaLocales, libLocales, loa
// (f) the leaf's own notice
if (!/<LargerScreenNotice v-else-if="blockedOnThisScreen"/.test(loaderSrc)) errs.push('ProfileLoader no longer replaces a desktop-only profile with the notice on a phone')
if (!/<LargerScreenNotice v-if="adviseOnThisScreen"/.test(loaderSrc)) errs.push('ProfileLoader no longer shows the larger-screens notice')
if (!/hasAttribute\('data-doxa-gallery'\)/.test(loaderSrc)) errs.push('ProfileLoader no longer stays quiet inside the Gallery (data-doxa-gallery)')
if (!/useScreenGate\(config\)/.test(loaderSrc)) errs.push('ProfileLoader no longer applies the phone rule (useScreenGate)')
if (!/hasAttribute\('data-doxa-gallery'\)/.test(gateSrc)) errs.push('the phone rule no longer stays quiet inside the Gallery (data-doxa-gallery)')
// (g) every flagged profile's element actually applies the rule
for (const m of metas) {
if (!MOBILE.includes(m.mobile)) continue
const how = mounts[`${m.bundle}/${m.profile}`] || {}
if (!how.viaLoader && !how.ownGate) errs.push(`${m.bundle}/${m.profile} is flagged "${m.mobile}" but its element mounts through neither ProfileLoader nor useScreenGate — the leaf would carry the flag and ignore it`)
}
return errs
}
@ -115,6 +126,25 @@ function readLibLocales() {
return out
}
// How each profile's element is mounted: through ProfileLoader (the bundle's index.js), or by a root that
// calls useScreenGate itself (the profile's own .vue).
function readMounts(metas) {
const out = {}
for (const m of metas) {
const dir = join(ROOT, 'plugin-designs', m.bundle)
let entry = '', own = ''
try { entry = readFileSync(join(dir, 'index.js'), 'utf8') } catch { /* no entry */ }
try { own = readFileSync(join(dir, `${m.profile}.vue`), 'utf8') } catch { /* no single-file profile */ }
// An IMPORT, not a mention: dashboard-plus-grid's index.js names ProfileLoader in a comment, and a
// word match counted that as mounting through it.
out[`${m.bundle}/${m.profile}`] = {
viaLoader: /^\s*import\s[^;]*\b(?:ProfileLoader|defineMapElement)\b/m.test(entry),
ownGate: /^\s*import\s[^;]*\buseScreenGate\b/m.test(own) && /\buseScreenGate\(/.test(own),
}
}
return out
}
const metas = readMetas()
const real = {
metas,
@ -123,6 +153,8 @@ const real = {
spaLocales: await readSpaLocales(),
libLocales: readLibLocales(),
loaderSrc: readFileSync(LOADER, 'utf8'),
gateSrc: readFileSync(GATE, 'utf8'),
mounts: readMounts(metas),
}
if (process.argv.includes('--self-test')) {
@ -140,6 +172,7 @@ if (process.argv.includes('--self-test')) {
['(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],
['(g) a flagged composite drops its own gate', { ...real, mounts: { ...real.mounts, 'dashboard-plus-grid/dashboard-plus-grid': { viaLoader: false, ownGate: false } } }, 1],
]
let ok = true
for (const [name, input, want] of cases) {

View file

@ -14,7 +14,7 @@
* never interfere with each other.
*/
import { computed, provide, inject, watchEffect, watch, ref, onBeforeUnmount, useHost } from 'vue'
import { computed, provide, inject, watchEffect, watch, ref } from 'vue'
import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js'
import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js'
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, localeFromUrl, loadLocale, localeChromeReady } from './i18n/index.js'
@ -27,6 +27,7 @@ const QaCorner = __DOXA_QA__
: null
import PinHint from './components/hints/PinHint.vue'
import LargerScreenNotice from './components/LargerScreenNotice.vue'
import { useScreenGate } from './composables/useScreenGate.js'
import { useUIStore } from './stores/uiStore.js'
import { useMapStore } from './stores/mapStore.js'
import { useDataStore } from './stores/dataStore.js'
@ -217,48 +218,15 @@ const effectiveLocale = computed(() => lang.value || appI18n?.global?.locale?.va
const dirAttr = computed(() => (RTL_LOCALES.has(effectiveLocale.value) ? 'rtl' : 'ltr'))
// ─── On a phone (SPEC-MOBILE-FLAG) ───────────────────────────
// profile-config `mobile` comes from <profile>.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 || ''))
// The rule lives in useScreenGate, shared with the composites that define their element without
// ProfileLoader (dashboard-plus-grid): a desktop-only profile is never mounted on a narrow screen, a
// larger-screens one mounts under a dismissible notice, and neither shows inside the Gallery.
const {
blocked: blockedOnThisScreen,
advise: adviseOnThisScreen,
dismiss: dismissLargerScreenNotice,
label: mobileLabel,
} = useScreenGate(config)
// ── HOLD THE TEXT FOR AN RTL LOCALE, NEVER THE MAP ──────────────────────────
// When the catalogue is fetched rather than inlined, a non-English reader sees English until it

View file

@ -0,0 +1,67 @@
/**
* useScreenGate — the phone rule for one element (SPEC-MOBILE-FLAG).
*
* profile-config `mobile` comes from <profile>.meta.json through the leaf page (generate-tree):
* 'desktop-only' on a narrow screen the element is NOT mounted; a message says why
* 'larger-screens' the element 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.)
*
* WHY A COMPOSABLE. ProfileLoader applied the rule, and a baked composite that defines its element
* without ProfileLoader (dashboard-plus-grid) carried the flag on its leaf page and ignored it: at 390px
* the desktop-only map mounted anyway. Any root that is not under ProfileLoader calls this itself;
* check:mobile-gating holds every flagged profile to one or the other.
*
* The host is already in the document when setup runs, so "inside the Gallery" is known before anything
* mounts, and a blocked element is never mounted at all rather than mounted and then removed.
*
* @param {import('vue').Ref<object>} config the element's parsed profile-config
*/
import { computed, ref, watch, onBeforeUnmount, useHost } from 'vue'
export const NARROW_SCREEN = '(max-width: 639.98px)'
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
}
export function useScreenGate(config) {
const flag = computed(() => {
const m = config.value?.mobile
return m === 'desktop-only' || m === 'larger-screens' ? m : null
})
let host = null
try { host = useHost() } catch { host = null }
const hosted = host ? insideGallery(host) : false
const narrow = ref(false)
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const mq = window.matchMedia(NARROW_SCREEN)
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 dismissed = ref(false)
watch(dismissKey, (k) => {
try { dismissed.value = sessionStorage.getItem(k) === '1' } catch { dismissed.value = false }
}, { immediate: true })
function dismiss() {
dismissed.value = true
try { sessionStorage.setItem(dismissKey.value, '1') } catch { /* private mode: dismissed until reload */ }
}
return {
flag,
blocked: computed(() => flag.value === 'desktop-only' && narrow.value && !hosted),
advise: computed(() => flag.value === 'larger-screens' && narrow.value && !hosted && !dismissed.value),
dismiss,
label: computed(() => String(config.value?.mobileLabel || config.value?.profile || '')),
}
}

View file

@ -19,6 +19,8 @@ import { absoluteEmbedUrl } from '@map/utils/embedBase.js'
import { useSharedMapToken, publishMapToken } from '@map/composables/useSharedMapToken.js'
import { useHostTokenUrl } from '@map/composables/useHostTokenUrl.js'
import StackLayout from '@map/layouts/StackLayout.vue'
import LargerScreenNotice from '@map/components/LargerScreenNotice.vue'
import { useScreenGate } from '@map/composables/useScreenGate.js'
import ResearchDashboard from '../research-dashboard/research-dashboard.vue'
import PeopleGroupsGrid from '../people-groups-grid/people-groups-grid.vue'
@ -254,6 +256,11 @@ const hostToken = computed(() => {
})
if (hostToken.value) publishMapToken(hostToken.value)
// ★ THE PHONE RULE, HERE AS WELL. This element is defined without ProfileLoader (it bakes its children
// in), so the leaf's "mobile": "desktop-only" reached its profile-config and nothing read it: at 390px the
// dashboard and the grid mounted anyway. useScreenGate is the same rule ProfileLoader applies.
const { blocked: screenBlocked, advise: screenAdvise, dismiss: screenDismiss, label: screenLabel } = useScreenGate(hostConfig)
// The provider is a host knob as well: resolveMapProvider reads profile-config.provider, and the
// config the child sees is the one provided below. Dropping it left a host that asked for maplibre
// on mapbox — demanding a token for the one configuration that exists to need none.
@ -396,7 +403,9 @@ const sections = [
<template>
<!-- No `title` and no #meta: the layout's own top bar is omitted entirely when neither is used,
so the composite shows ONLY the nested elements' own chrome. -->
<StackLayout :sections="sections">
<LargerScreenNotice v-if="screenBlocked" mode="desktop-only" :label="screenLabel" :locale="hostConfig.locale || ''" />
<LargerScreenNotice v-else-if="screenAdvise" mode="larger-screens" :locale="hostConfig.locale || ''" @dismiss="screenDismiss" />
<StackLayout v-if="!screenBlocked" :sections="sections">
<template #section-dashboard>
<ResearchDashboard @dashboard-filter="onDashboardFilter" />
</template>