diff --git a/internal/check-i18n-parity.mjs b/internal/check-i18n-parity.mjs index 68d526324..32f6e2493 100644 --- a/internal/check-i18n-parity.mjs +++ b/internal/check-i18n-parity.mjs @@ -68,6 +68,10 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json')) // translations, add the locale here with its count and lower it as they arrive. // Never raise a number; never add a locale that is already complete. const DEBT = { + // 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3): localization is English-only for now. + // tStatic falls back to the English text, so these locales show English until the translations arrive; + // lower each to 0 then, and empty the map again. + ar: 10, de: 10, es: 10, fr: 10, hi: 10, it: 10, pt: 10, ro: 10, ru: 10, zh: 10, } const fails = [] diff --git a/internal/check-map-menu-anchor.mjs b/internal/check-map-menu-anchor.mjs index b25a89aab..0fb0e04e4 100644 --- a/internal/check-map-menu-anchor.mjs +++ b/internal/check-map-menu-anchor.mjs @@ -39,7 +39,19 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/) if (!m) { fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.') } else { - const expr = m[1] + let expr = m[1] + // The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button + // re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found. + const fn = expr.match(/^\s*(\w+)\(\)/) + if (fn) { + const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}')) + // every return that resolves a named area must carry its structural fallback on the SAME expression + // a same-line comment is not a fallback: strip `//…` before testing each return + const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1].replace(/\s*\/\/.*$/, '')) : [] + expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '') + if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`) + else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`) + } if (!/offsetParent|parentElement|document\.body/.test(expr)) { fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n' + ` ${expr.trim()}\n` diff --git a/library/components/map-controls/MapControlMenu.vue b/library/components/map-controls/MapControlMenu.vue index 4b667baa0..c04a04961 100644 --- a/library/components/map-controls/MapControlMenu.vue +++ b/library/components/map-controls/MapControlMenu.vue @@ -14,6 +14,11 @@ RIGHT of left-hand controls. The side is measured, so a right-to-left page that mirrors the toolbar mirrors the menu as well. • Width: as wide as fits between the toolbar and the opposite edge, capped at `width`. + • A STANDALONE anchor (ShareButton in a page header, no map toolbar: `standalone` on the + provided anchor) opens BELOW its button instead, toward the side with room, inside the + VIEWPORT, at `width` (the maps' readable 340) or the viewport minus its edges, whichever is + less. Where it lives and where it goes are resolved when it OPENS, and its coordinates are + converted into the block that really contains it (see placeBeside). • One z tier for menus (--z-menu, default 3000); only one menu is open per map at a time; closes on Escape or on a tap on the bare map canvas (never on legend/search). @@ -95,23 +100,17 @@ const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]' function place() { const tb = toolbar?.el?.value, area = host.value if (!tb || !area) { style.value = {}; return } + if (toolbar.standalone) return placeBeside(tb) const ar = area.getBoundingClientRect(), tr = tb.getBoundingClientRect() const onRight = (tr.left + tr.right) / 2 > (ar.left + ar.right) / 2 const gap = 8, edge = 12 const offset = onRight ? (ar.right - tr.left + gap) : (tr.right - ar.left + gap) const width = Math.max(200, Math.min(props.width, ar.width - offset - edge)) const top = Math.max(0, tr.top - ar.top) - // "Fit inside the map" is the rule ONLY for a real map area. When ShareButton mounted in a - // page HEADER, its fallback self-tagged that header band as the area (data-map-area= - // "share-fallback", see ShareButton.bindAnchor) -- and this line then dutifully clamped the - // menu INSIDE a ~56px band: measured on upg-100-mfe, a 291px share menu rendered with a 69px - // scrolling body (BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED). A menu must never - // outgrow a MAP's walls, but a header band is not a wall -- nothing clips below it -- so the - // fallback area bounds against the VIEWPORT instead and the menu opens at natural height. - const isFallbackArea = area.getAttribute && area.getAttribute('data-map-area') === 'share-fallback' - let maxHeight = isFallbackArea - ? Math.max(120, (window.innerHeight || 900) - tr.top - edge) // header host: the page below is available - : Math.max(120, ar.height - top - edge) // map host: fit inside the map + // A menu never outgrows the MAP's walls. (A header button is not placed here: placeBeside bounds it by the + // viewport, so it opens at natural height; measured on upg-100-mfe, a header band as the wall gave a 291px + // menu a 69px scrolling body, BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED.) + let maxHeight = Math.max(120, ar.height - top - edge) if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height)) if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight) style.value = { @@ -123,6 +122,45 @@ function place() { } } +// ★ BESIDE ITS BUTTON, INSIDE THE VIEWPORT (a standalone anchor: a Share button in a page header). Below the +// button, toward the side with room (a button right of centre opens leftward, its end edges aligned; RTL headers +// mirror with no special case, because the side is measured), at the readable width the maps use, clamped inside +// the viewport, as tall as its content up to the viewport's bottom edge. +// ★ THE BLOCK THAT CONTAINS IT IS MEASURED, NOT ASSUMED. The menu is position:absolute, so its coordinates are +// relative to its containing block, which the HOST's markup decides: an unpositioned wrapper hands it to the +// header above. Measuring against one element and positioning against another is what sent the header menu far +// to the left (the UPG-100 list page, 2026-10-03). So the target point is computed in viewport coordinates and converted into +// the containing block's own (its padding edge, scrolled), whatever element that turns out to be. +function placeBeside(tb) { + const vw = document.documentElement.clientWidth || window.innerWidth || 1024 + const vh = window.innerHeight || 800 + const r = tb.getBoundingClientRect(), gap = 8, edge = 12 + const width = Math.min(props.width, Math.max(160, vw - 2 * edge)) + const onRight = (r.left + r.right) / 2 > vw / 2 + const x = Math.max(edge, Math.min(onRight ? r.right - width : r.left, vw - edge - width)) + const y = r.bottom + gap + let maxHeight = Math.max(120, vh - y - edge) + if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight) + const o = blockOrigin(el.value) + style.value = { + top: `${Math.round(y - o.y)}px`, + left: `${Math.round(x - o.x)}px`, + width: `${Math.round(width)}px`, + maxHeight: `${Math.round(maxHeight)}px`, + ...(props.height ? { height: `${Math.round(Math.min(props.height, maxHeight))}px` } : {}), + } +} +// The viewport position of the origin an absolutely positioned element is placed from: its offsetParent's padding +// edge, scrolled. A static body (or none) means the initial containing block: the document origin. +function blockOrigin(m) { + const cb = m && m.offsetParent + if (!cb || ((cb === document.body || cb === document.documentElement) && getComputedStyle(cb).position === 'static')) { + return { x: -(window.scrollX || 0), y: -(window.scrollY || 0) } + } + const b = cb.getBoundingClientRect() + return { x: b.left + cb.clientLeft - cb.scrollLeft, y: b.top + cb.clientTop - cb.scrollTop } +} + // Only one menu open per map: opening this one closes any other. const OPEN_EVENT = 'doxa-map-menu-open' function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close') } @@ -139,6 +177,7 @@ const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PA watch(() => props.open, async (open, was) => { if (!open) { if (was) announceClose(); return } + if (toolbar?.standalone) host.value = resolveHost() // layout is known now: where the menu lives await nextTick() place() try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {} @@ -156,15 +195,27 @@ function onClickOutside(e) { if (path.some(n => n?.classList?.contains?.('mapboxgl-canvas') || n?.classList?.contains?.('maplibregl-canvas'))) emit('close') } +// Where the menu lives (the Teleport target). A map control: the map area, else the toolbar's offset parent. A +// standalone button: a named area if its host declares one, else its offset parent, kept in the button's own +// (shadow) root so the menu keeps this root's styles; else the root's top element. +function resolveHost() { + const tbEl = toolbar?.el?.value || null + if (!tbEl) return null + if (!toolbar.standalone) return tbEl.closest(AREA) || tbEl.offsetParent || tbEl.parentElement || null + const root = tbEl.getRootNode(), mine = (n) => n && n.getRootNode() === root ? n : null + let top = tbEl; while (top.parentElement) top = top.parentElement + return mine(tbEl.closest(AREA)) || mine(tbEl.offsetParent) || (root === document ? document.body : top) +} +// The "one menu at a time" listener follows the host, which a standalone button re-resolves on every open. +watch(host, (h, was) => { was?.removeEventListener(OPEN_EVENT, onOtherOpened); h?.addEventListener(OPEN_EVENT, onOtherOpened) }) + onMounted(() => { // ★ NEVER null. A named area is still preferred -- it is the MAP's walls, and a menu must not outgrow // them -- but when a plugin has not tagged one, the toolbar's own offsetParent is the containing block // this menu is absolutely positioned against, so placing against it is geometrically correct by // definition rather than by convention. A plugin that adopts a class or data-map-area later simply // gets the better anchor; nothing has to be registered anywhere for the menu to be on screen. - const tbEl = toolbar?.el?.value || null - host.value = tbEl?.closest(AREA) || tbEl?.offsetParent || tbEl?.parentElement || null - host.value?.addEventListener(OPEN_EVENT, onOtherOpened) + host.value = resolveHost() window.addEventListener('resize', onResize) document.addEventListener('click', onClickOutside, true) document.addEventListener('keydown', onKeyDown) diff --git a/library/components/map-controls/ShareButton.vue b/library/components/map-controls/ShareButton.vue index a34e6a376..c5bb319bb 100644 --- a/library/components/map-controls/ShareButton.vue +++ b/library/components/map-controls/ShareButton.vue @@ -2,10 +2,13 @@ ShareButton.vue — Non-technical share modal for the map toolbar. Redesigned to match consumer-grade UX from YouTube, Spotify, Canva, and - Google Maps. Two tabs: + Google Maps. Two tabs, share FIRST (shareable pages P3): - 1. "Share Link" — clean URL display + one-click copy + social share icons - 2. "Add to Your Website" — friendly embed guidance with size presets + 1. "Share this page" — the link to exactly what the reader is looking at (the page's own + address, with its tab, where the page owns its address) + one-click copy. It is what a + person who presses Share expects: something to send to someone. + 2. "Embed" — for putting the map on ANOTHER website: what embedding is and why it helps, the + multiplication steps, then the code with size presets. The word "iframe" never appears in any user-visible text. The embed tab leads with a plain-English description, then shows the code below. @@ -31,6 +34,7 @@ const T = (key, en) => tStatic(key) || en import { useI18n } from 'vue-i18n' import { useShadowStyles } from '../../composables/useShadowStyles.js' import { resolveShareUrl } from '../../utils/embedBase.js' +import { urlOwnerOf } from '../../composables/useViewAddress.js' import MapControlButton from './MapControlButton.vue' import MapControlMenu from './MapControlMenu.vue' @@ -51,7 +55,7 @@ useShadowStyles(` /* The ring itself rotates, so the colours travel around the button in every browser. */ @keyframes share-ring-turn{to{transform:rotate(360deg);}} @media(prefers-reduced-motion:reduce){.share-wrap::before{animation:none;}} -.share-steps{margin:10px 14px 6px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;} +.share-steps{margin:0 0 12px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;} .share-steps li{margin:0 0 3px;padding-inline-start:3px;} .share-steps li:last-child{font-weight:600;color:#92b195;} .share-pop.light .share-steps{color:#3b463d;} @@ -348,7 +352,8 @@ const rootEl = ref(null) const isOpen = ref(false) const popoverEl = ref(null) const btnEl = ref(null) -const activeTab = ref('link') // 'link' | 'embed' +const activeTab = ref('page') // 'page' | 'embed' +const pageUrl = ref('') // "Share this page": read when the menu opens (see pageLink) const linkCopied = ref(false) const embedCopied = ref(false) const embedSize = ref('medium') // 'small' | 'medium' | 'large' @@ -359,6 +364,9 @@ const sizePresets = { large: { label: 'Large', height: 800 } } +// The size names, each key written out so the catalogue gate sees it. +const sizeLabel = (k) => ({ small: T('share.size_small', 'Small'), medium: T('share.size_medium', 'Medium'), large: T('share.size_large', 'Large') })[k] || k + /* ---- computed ---- */ /* The standalone embed page this map is shared/iframed as — resolved by utils/embedBase.js (its header documents the full ladder). In short: @@ -387,11 +395,42 @@ const embedSnippet = computed(() => { return '' }) +/* ★ SHARE THIS PAGE. Where the page OWNS its address (useViewAddress: urlOwner 'app' on our own pages, + 'host' on DX Life), the address bar already names the exact view (the tab, and the filters), so the + link is the page's own URL, read when the menu opens and again at copy time, since the reader can + switch tabs in between. The owner is this map's own profile-config, or the nearest element above it, + across shadow roots, whose profile-config names one: a map nested in the gallery is told nothing, and + the gallery is the page. On a partner's page (no owner) the address is the partner's, so the link is + this map's own page, as before. */ +const cfgOf = (c) => { if (typeof c !== 'string') return c || null; try { return JSON.parse(c) } catch { return null } } +function pageOwner() { + if (urlOwnerOf(cfgOf(unref(profileConfig)))) return true + for (let n = rootEl.value; n; ) { + n = n.parentNode || n.host + if (n && n.nodeType === 1 && urlOwnerOf(cfgOf(n.profileConfig ?? n.getAttribute('profile-config')))) return true + } + return false +} +// A leaf page reads a token from ?tk= and leaves it in its address, so the page link drops it: a reader must +// never send a credential by pressing Share. The URL is rewritten only when tk is there, so every other link is +// the address exactly as it stands. +function pageLink() { + try { + if (pageOwner() && window.location.href) { + const u = new URL(window.location.href) + if (u.searchParams.has('tk')) { u.searchParams.delete('tk'); return u.href } + return window.location.href + } + } catch { /* no window */ } + return shareUrl.value +} + /* ---- actions ---- */ function toggle() { isOpen.value = !isOpen.value if (isOpen.value) { - activeTab.value = 'link' + activeTab.value = 'page' + pageUrl.value = pageLink() linkCopied.value = false embedCopied.value = false } @@ -425,7 +464,8 @@ async function copyText(text) { } async function copyLink() { - if (await copyText(shareUrl.value)) { + pageUrl.value = pageLink() + if (await copyText(pageUrl.value)) { linkCopied.value = true setTimeout(() => { linkCopied.value = false }, 2000) } @@ -477,39 +517,24 @@ async function copyEmbed() { const providedToolbar = inject('mapToolbar', null) const selfAnchor = ref(null) // Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when -// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. -provide('mapToolbar', providedToolbar || { el: selfAnchor }) +// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. `standalone` tells +// MapControlMenu this anchor is ONE button outside any map (a page header), placed beside the button +// inside the viewport, at open time (MapControlMenu.placeBeside). +provide('mapToolbar', providedToolbar || { el: selfAnchor, standalone: true }) const hasOwnAnchor = !providedToolbar /* - ★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming, - because the fix silently does NOTHING if it is written the obvious way. - - MapControlMenu latches its area ONCE, in its own onMounted: - host.value = toolbar?.el?.value?.closest(AREA) || null - and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and - Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor - one tick too late: the child would have already read `null`, latched `host = null`, and no later - assignment would revive it — the menu would go on opening at its static position and the fix - would look applied while changing nothing. - - A function ref is invoked during RENDER, before any mounted hook on either component, so the - anchor and the area tag are in place by the time the child looks for them. - - `data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET - PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the - menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans - the row, so the menu has the full width to open leftward into. + ★ THE ANCHOR IS THE BUTTON, AND NOTHING IS DECIDED AT RENDER TIME (shareable pages P3). This used to tag + `el.offsetParent || el.parentElement` as the menu's area DURING RENDER (a function ref, so it ran before + MapControlMenu latched its area). Before layout offsetParent is null, so the tag fell on the button's + immediate parent: in a header with a narrow, unpositioned wrapper (measured on the UPG-100 list page: .hdr-btns) the menu + measured against that wrapper, clamped to its 200px floor, and was positioned against the header above + it: far to the left, and narrow (reported 2026-10-03). Now the button only names itself; + MapControlMenu resolves where the menu lives and where it goes when it OPENS, with layout known. */ function bindAnchor(el) { rootEl.value = el || null - if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return } - const area = el.offsetParent || el.parentElement - // Never re-tag a host that already declares itself an area — that host owns the decision. - if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) { - area.setAttribute('data-map-area', 'share-fallback') - } - selfAnchor.value = el + selfAnchor.value = hasOwnAnchor && el ? el : null } /* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every @@ -517,8 +542,8 @@ function bindAnchor(el) {