diff --git a/internal/check-dev-leaf-escape.mjs b/internal/check-dev-leaf-escape.mjs new file mode 100644 index 000000000..e74478c02 --- /dev/null +++ b/internal/check-dev-leaf-escape.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens. + * + * THE DEFECT. The dev server's leaf route, /__leaf//[/] (and a production embed URL, + * /[/]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the + * and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path + * was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server. + * internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile + * id and instance id has) before either sink, and escapes the <title> as well. + * + * The plugin's own middleware answers each request (no server, no browser): + * A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with + * its element: the rule refuses no real page; + * B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or + * backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to + * Vite, never a page; + * C. escHtml leaves none of & < > " ' raw; + * D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title> + * escaped: the title has its own escaping, not only the rule in front of it. + * Response bodies are never printed: a leaf page carries the build config's token. + * `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses + * the quote must each FAIL. + */ +import { readFileSync, writeFileSync, rmSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { discoverSpaRegistry } from './plugin-gallery/discover.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs') +const SRC = readFileSync(PLUGIN, 'utf8') +const GUARD = 'parts.some(s => !LEAF_PART.test(s))' +const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))" +const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/' +const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}' +const ESC_CLASS = `/[&<>"']/g` + +// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the +// file is removed as soon as it is loaded. +let seq = 0 +async function load(src) { + if (src === SRC) return import(pathToFileURL(PLUGIN).href) + const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`) + writeFileSync(tmp, src) + try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) } +} + +// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would). +function client(mod) { + let mw = null + mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } }) + return (url) => new Promise((done) => { + const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) } + try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) } + catch (e) { done({ status: 'threw', body: String(e && e.message) }) } + }) +} + +// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount. +function advertised(mod) { + const out = [] + const walk = (o, tag) => { + if (!o || typeof o !== 'object') return + for (const v of Object.values(o)) { + if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag }) + else walk(v, tag) + } + } + for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0]) + return [...new Map(out.map((x) => [x.url, x])).values()] +} + +const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"] + +async function audit(src) { + const f = [] + let mod + try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] } + const get = client(mod) + const leaves = advertised(mod) + if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`) + for (const { url, tag } of leaves) { + for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) { + const r = await get(u) + if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`) + } + } + const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/') + for (const h of HOSTILE) { + const e = encodeURIComponent(h) + for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) { + const leaf = '/__leaf/' + parts.join('/') + const r = await get(leaf) + if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`) + const embed = '/' + parts.join('/') + '/index.html' + const r2 = await get(embed) + if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`) + } + } + const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null + if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`) + if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one') + else { + let loose + try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) } + if (loose) { + const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`) + const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1] + if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`) + else if (title.includes('<b>') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the <title> raw') + } + } + return f +} + +if (process.argv.includes('--self-test')) { + const poisons = [ + ['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /], + ['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /], + ['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /], + ['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /], + ] + let missed = 0 + for (const [name, src, rx] of poisons) { + if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue } + const f = await audit(src); const hit = f.find((x) => rx.test(x)) + console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`) + if (!hit) missed++ + } + if (missed) process.exitCode = 1 +} else { + const f = await audit(SRC) + if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 } + else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`) +} 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/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 34cece14a..bdc6fbf5a 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -145,6 +145,18 @@ export function galleryModel(reg) { } } +// ── ONE PART OF A LEAF URL ─────────────────────────────────────────────────────────────────── +// /__leaf/<bundle>/<profile>[/<instance>] parts are written INTO the leaf page: its <title> and the +// element's profile-config='…' attribute. Refusing only `..` and `/` let a quote or a tag through, so a +// crafted link broke out of the attribute on a publicly reachable dev server. Every bundle folder, +// profile id and instance id in the tree has this shape (the address keys' own pattern, +// library/composables/useViewAddress.js), so anything else is refused before it reaches either sink; +// it also rules out `..` and `/`. check:dev-leaf-escape proves both directions. +export const LEAF_PART = /^[A-Za-z0-9][\w-]{0,63}$/ + +// For an HTML text node or a quoted attribute value. +export const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) + // ── THE PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ───────────────────────────────────── // The manifest advertises, and a person copies, tree-relative embed URLs of the shape // `<bundle>/<profile>/index.html` and `<bundle>/<profile>/<instance>/index.html` — served in @@ -199,8 +211,8 @@ export function leafRewrite(path, treeName = TREE_NAME) { // urlOwner 'app' (library/composables/useViewAddress.js) for a dev page that is OURS, the rule generate-tree bakes // into the built leaf: its shell writes ?tab=, so a clicked tab survives a reload and a copied link opens it. A -// FRAMED page drops it: Plugin Studio iframes its map rows from /__leaf/, and a history push from an iframe lands -// in the top page's Back stack. Write the element unowned and put this classic script right after it; it runs +// FRAMED page drops it: a partner page may iframe a leaf (the iframe embed), and a history push from an iframe +// lands in the top page's Back stack. (Plugin Studio mounts its rows directly; its panes are unowned by paneConfig.) Write the element unowned and put this classic script right after it; it runs // before the deferred module script upgrades the element. `<` is escaped: profile/instance come from the URL. export function ownIfTopScript(cfg = {}) { const owned = JSON.stringify(JSON.stringify({ ...cfg, urlOwner: 'app' })).replace(/</g, '\\u003c') @@ -347,8 +359,8 @@ export function autoSpaDevPlugin(token = '') { try { return decodeURIComponent(s) } catch { return '' } }) const [bundle, profile, instance] = parts - // Path traversal guard: these land in a filesystem-ish URL below. - if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) { + // These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only. + if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) { res.statusCode = 400; return res.end('bad leaf path') } let reg @@ -387,7 +399,7 @@ export function autoSpaDevPlugin(token = '') { res.setHeader('Cache-Control', 'no-cache') return res.end([ '<!DOCTYPE html>', - `<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}`, + `${escHtml(bundle)} · ${escHtml(profile)}`, '', '', leafTok ? `` : '', 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) {