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:
parent
cafee0d432
commit
f96fb4290f
5 changed files with 130 additions and 47 deletions
|
|
@ -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'`
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
67
library/composables/useScreenGate.js
Normal file
67
library/composables/useScreenGate.js
Normal 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 || '')),
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue