/** * staging-forge.js — the ONE-PAGE semantic staging environment (root + bundle pages). * * design brief (2026-07-11, the merger of all prior staging files): * - ONE page. No folder-hopping: the semantic tree IS the navigation. * - Desktop: hierarchical tree SIDEBAR on the left (bundle → profile → instance, * kind-badged, "where do I edit" hints) · selected map fills the stage on the right. * - Mobile: tree on top, map below — scroll down to the map. Native responsive, * no simulator toggle. * - Token UX: resolve ?tk= → localStorage → none. If none, show a token bar * (paste once, stored locally in the browser, never in files/links unless you * use ?tk= on purpose). Maps must Just Work after one paste. * - Auto-generated: whatever plugin-designs exist, the tree detects and shows them. * * file:// contract: classic script, manifest inlined at build (window.FORGE.manifest), * fetch only as dev fallback. No import/export. The DEV-HMR block at the bottom is * stripped by generate-tree.mjs for built pages. */ /* global window, document */ const FORGE = window.FORGE || {} const SCOPE = FORGE.scope || 'root' const MODE = FORGE.mode || 'built' const MANIFEST_URL = FORGE.manifestUrl || './manifest.json' const HREF_BASE = FORGE.hrefBase || './' const GEMS = ['#46d4ff', '#b9a6f5', '#ffc24b', '#5ee0a0', '#f97fb5', '#8ef0e4'] const $ = (sel) => document.querySelector(sel) let tree = null let flat = [] // flat list of selectable map nodes (profiles + instances) let activeKey = null // selected node key // ── Token resolution (maintainer ladder: ?tk= → localStorage → ask once) ───────── const TK_STORE = 'MAPS_STAGING_TK' function resolveToken() { const qp = new URLSearchParams(location.search).get('tk') if (qp) { try { localStorage.setItem(TK_STORE, qp) } catch (e) {} return qp } try { return localStorage.getItem(TK_STORE) || '' } catch (e) { return '' } } let TK = resolveToken() function withTk(href) { if (!TK) return href return href + (href.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK) } // Carry the page's ?provider= into the embedded map iframe so the engine choice propagates. function withProvider(href) { const p = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('provider') : null return p ? href + (href.includes('?') ? '&' : '?') + 'provider=' + encodeURIComponent(p) : href } // Carry the page's ?lang= (or ?locale=) into the embedded leaf the same way. The leaf pages bake // , so without this a staging page opened at ?lang=ar embedded every leaf in // English — measured: 0 of 1 iframes carried it. Only forwarded when present; the leaf's own // detectLocale() still decides what it supports. function withLang(href) { const q = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null const l = q && (q.get('lang') || q.get('locale')) return l ? href + (href.includes('?') ? '&' : '?') + 'lang=' + encodeURIComponent(l) : href } // ── Load the semantic tree (inline-first; fetch = dev fallback) ────────────── async function loadTree() { if (FORGE.manifest) return FORGE.manifest const res = await fetch(MANIFEST_URL, { cache: 'no-cache' }) if (!res.ok) throw new Error('manifest ' + res.status + ' from ' + MANIFEST_URL) return res.json() } const treeHref = (rel) => HREF_BASE + rel const hexRgb = (hex) => { const h = hex.replace('#', '') return parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16) } function profileKind(p) { if (Array.isArray(p.instances) && p.instances.length) return 'parameterized' if (p.nested || /nested|tabs|web components/i.test(p.description || '')) return 'nested' return 'individual' } // ── Build the flat selectable list + the sidebar tree ──────────────────────── // Selectable = anything that renders as ONE map page: individual/nested profiles // and each instance of a parameterized profile. function buildFlat() { flat = [] ;(tree.bundles || []).forEach((b, bi) => { b.__gem = GEMS[bi % GEMS.length] }) for (const b of tree.bundles || []) { for (const p of b.profiles || []) { const kind = profileKind(p) if (kind === 'parameterized') { for (const inst of p.instances) { flat.push({ key: b.bundle + '/' + p.profile + '/' + inst.id, kind: 'instance', label: inst.title || inst.id, sub: p.profile + ' · preset "' + inst.id + '"', edit: b.bundle + '/' + p.profile + '.instances.json', href: treeHref(b.bundle + '/' + p.profile + '/' + inst.id + '/index.html'), bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem, }) } } else { flat.push({ key: b.bundle + '/' + p.profile, kind: kind === 'nested' ? 'nested' : 'profile', label: p.profile, sub: p.description || (kind === 'nested' ? 'embeds child web components' : 'one web component = one map'), edit: 'plugin-designs/' + b.bundle + '/' + (p.file || p.profile + '.vue'), href: treeHref(b.bundle + '/' + p.profile + '/index.html'), bundle: b.bundle, element: p.element || (b.elements || [])[0], gem: b.__gem, }) } } } } function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) } // Sidebar: bundles are
groups (open by default), each map node a row. function renderSidebar() { const el = $('#treeNav') if (!el) return const parts = [] const bundles = tree.bundles || [] bundles.forEach((b, bi) => { const gem = b.__gem || GEMS[bi % GEMS.length] const rows = flat.filter((n) => n.bundle === b.bundle).map((n) => ` `).join('') parts.push(`
${esc(b.bundle)} ${(b.profiles || []).length} profile${(b.profiles || []).length === 1 ? '' : 's'} · one .js ${rows}
`) }) el.innerHTML = parts.join('') || '

no bundles built yet

' el.querySelectorAll('.node').forEach((btn) => btn.addEventListener('click', () => select(btn.dataset.key))) } // ── Stage: the ONE live map (iframe of the node's self-contained page) ─────── function renderStage() { const st = $('#stage') if (!st) return const nd = flat.find((n) => n.key === activeKey) || flat[0] if (!nd) { st.innerHTML = '

no maps in this tree

'; return } activeKey = nd.key const gem = nd.gem || '#46d4ff' st.style.setProperty('--gem', gem) st.style.setProperty('--gem-rgb', hexRgb(gem)) st.innerHTML = `
<${esc(nd.element || nd.label)}> ${esc(nd.kind)} open ↗
` // COPY EMBED — the partner handoff. The snippet is built AT CLICK TIME from // location.href, so it always carries the FULL URL of whatever host serves // this tree (localhost, doxa.life/embed/doxa-maps-build/…, any CDN) — embeds // work no matter where the build folder lives. utm_source placeholder = // per-partner traffic attribution (see the manifest snippets). const copyBtn = $('#copyEmbed') if (copyBtn) copyBtn.addEventListener('click', async () => { const abs = new URL(nd.href, location.href) abs.searchParams.set('utm_source', 'YOUR_SOURCE') const snip = '' let ok = false try { await navigator.clipboard.writeText(snip); ok = true } catch (_) { try { const ta = document.createElement('textarea') ta.value = snip; document.body.appendChild(ta); ta.select() ok = document.execCommand('copy'); ta.remove() } catch (_) {} } copyBtn.textContent = ok ? 'copied ✓' : 'copy failed' setTimeout(() => { copyBtn.textContent = 'copy embed' }, 1800) }) // keep the sidebar highlight in sync document.querySelectorAll('#treeNav .node').forEach((b) => b.classList.toggle('active', b.dataset.key === activeKey)) } function select(key) { activeKey = key renderStage() // Mobile: after picking from the tree, bring the map into view (scroll down to it). if (window.matchMedia('(max-width: 899px)').matches) { $('#stage')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) } } // ── Token bar: shown only while no token resolves; paste once, stored locally ─ function renderTokenBar() { const bar = $('#tokenBar') if (!bar) return if (TK) { bar.classList.add('hidden'); return } bar.classList.remove('hidden') bar.innerHTML = ` maps need a Mapbox token — ` $('#tkSave').addEventListener('click', () => { const v = $('#tkInput').value.trim() if (!v) return TK = v try { localStorage.setItem(TK_STORE, v) } catch (e) {} bar.classList.add('hidden') renderStage() }) } // ── Boot ───────────────────────────────────────────────────────────────────── async function boot() { const info = $('#frameInfo') try { tree = await loadTree() buildFlat() if (info) { info.innerHTML = 'semantic tree — ' + (tree.bundles || []).length + ' bundle(s) · ' + flat.length + ' map(s) · ' + '' + esc(MODE) + '' } renderSidebar() renderTokenBar() if (flat.length) { activeKey = flat[0].key; renderStage() } } catch (e) { if (info) info.innerHTML = 'failed to load semantic tree: ' + esc(e.message) + '' } } boot() /* DEV-HMR-START (stripped from built pages by generate-tree.mjs — import.meta is a parse error in the classic