diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index e6e0d4abc..ef15162cd 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -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'` diff --git a/internal/check-mobile-gating.mjs b/internal/check-mobile-gating.mjs index b5c703a0a..a35d62d30 100644 --- a/internal/check-mobile-gating.mjs +++ b/internal/check-mobile-gating.mjs @@ -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 (!/ 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 .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 diff --git a/library/composables/useScreenGate.js b/library/composables/useScreenGate.js new file mode 100644 index 000000000..1e3289eb9 --- /dev/null +++ b/library/composables/useScreenGate.js @@ -0,0 +1,67 @@ +/** + * useScreenGate — the phone rule for one element (SPEC-MOBILE-FLAG). + * + * profile-config `mobile` comes from .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} 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 || '')), + } +} diff --git a/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue b/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue index dec237025..2dc479774 100644 --- a/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue +++ b/plugin-designs/dashboard-plus-grid/dashboard-plus-grid.vue @@ -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 = [