diff --git a/internal/measure-map-load.mjs b/internal/measure-map-load.mjs
new file mode 100644
index 000000000..dce13dce3
--- /dev/null
+++ b/internal/measure-map-load.mjs
@@ -0,0 +1,109 @@
+#!/usr/bin/env node
+/**
+ * measure-map-load — what each production map fetches before first render, and how long it takes.
+ *
+ * USAGE: node internal/measure-map-load.mjs [--runs 3] [--mbps 10] [--rtt 60] [--only bundle/profile,…]
+ * --mbps 0 = no throttling. Serves the BUILT tree itself on a free port (serve-strict).
+ * Prints, per map: median time to the bundle, to the engine script (mapbox-gl.js), to the map's
+ * `load` event, and the serial chain — the requests that had to finish before the next could start.
+ *
+ * ★ WHY. The Driver: "Vue needs to be bundled inline into the maps so map load time is faster." Vue is
+ * already compiled into every IIFE (vite.config.js externalises only jspdf), so the measurement has
+ * to find what DOES wait on what. Every run is a FRESH browser context (cold HTTP cache) under
+ * Chrome DevTools network throttling, so a local server is not mistaken for a real connection.
+ */
+import { chromium } from 'playwright'
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import { readFileSync } from 'node:fs'
+
+const argv = process.argv.slice(2)
+const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
+const RUNS = Number(arg('--runs', 3)), MBPS = Number(arg('--mbps', 10)), RTT = Number(arg('--rtt', 60))
+// --embed: measure the PARTNER case — a page on another origin that pastes only the manifest's
+// embed snippet (no leaf page, so nothing preloads the engine). Default: the map's own leaf page.
+const EMBED = argv.includes('--embed')
+const DEFAULT = JSON.parse(readFileSync('plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json', 'utf8')).composes
+const ONLY = arg('--only', '') ? arg('--only', '').split(',') : DEFAULT
+const manifest = JSON.parse(readFileSync('public/doxa-maps/manifest.json', 'utf8'))
+
+const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
+const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(port)], { stdio: 'ignore' })
+const hostPort = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
+let hostHtml = ''
+const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); r.end(hostHtml) }).listen(hostPort, 'localhost')
+await new Promise((r) => setTimeout(r, 900))
+const T = `http://127.0.0.1:${port}/doxa-maps`
+
+const kind = (u) => /mapbox-gl(\.min)?\.js|maplibre-gl\.js/.test(u) ? 'engine' : /mapbox-gl\.css|maplibre-gl\.css/.test(u) ? 'engine-css'
+ : /\/styles\/v1\/|tiles\.openfreemap\.org\/styles/.test(u) ? 'style' : /\/doxa-maps\/[^/]+\/[^/]+\.js$/.test(u) ? 'bundle'
+ : /pray\.doxa\.life\/api/.test(u) ? 'api' : /\/assets\/.*\.(json|geojson)/.test(u) ? 'data' : /\.pbf|\.mvt|vector/.test(u) ? 'tile' : 'other'
+
+const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
+const rows = []
+for (const ref of ONLY) {
+ const [bundle, profile] = ref.split('/')
+ const b = manifest.bundles.find((x) => x.bundle === bundle)
+ const p = b && b.profiles.find((x) => x.profile === profile)
+ if (!p) { rows.push({ ref, missing: true }); continue }
+ let url = `${T}/${String(p.iframeUrl).replace(/^\.\//, '')}`
+ if (EMBED) {
+ const tok = (readFileSync(`public/doxa-maps/${String(p.iframeUrl).replace(/^\.\//, '')}`, 'utf8').match(/BAKED_TOKEN\s*=\s*"(pk\.[^"]+)"/) || [])[1] || ''
+ const snip = String((p.embed && p.embed.script) || '').replace(/src="\.\//g, `src="${T}/`).replace(/YOUR_MAPBOX_TOKEN/g, tok)
+ hostHtml = `
Partner${snip}
`
+ url = `http://localhost:${hostPort}/partner.html`
+ }
+ const runs = []
+ for (let r = 0; r < RUNS; r++) {
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
+ const page = await ctx.newPage()
+ if (MBPS > 0) {
+ const cdp = await ctx.newCDPSession(page)
+ await cdp.send('Network.enable')
+ await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: RTT, downloadThroughput: (MBPS * 1e6) / 8, uploadThroughput: (MBPS * 1e6) / 16 })
+ }
+ const reqs = new Map()
+ let t0 = 0
+ page.on('request', (q) => { reqs.set(q, { url: q.url(), kind: kind(q.url()), start: Date.now() - t0 }) })
+ page.on('requestfinished', (q) => { const x = reqs.get(q); if (x) x.end = Date.now() - t0 })
+ t0 = Date.now()
+ await page.goto(url, { waitUntil: 'commit' })
+ let loaded = null, styled = null
+ const deadline = Date.now() + 60000
+ while (Date.now() < deadline) {
+ const st = await page.evaluate(() => {
+ const find = (r) => { for (const e of r.querySelectorAll('*')) { if (e.__doxaMap) return e.__doxaMap; if (e.shadowRoot) { const m = find(e.shadowRoot); if (m) return m } } return null }
+ const m = find(document)
+ // FIRST RENDER = the style is loaded and the tiles in view are loaded. Not map.loaded(): it
+ // stays false while any animation runs, and several maps animate continuously (pulses, glow),
+ // so the first version of this probe waited out its full timeout on every run.
+ return { style: !!(m && m.isStyleLoaded && m.isStyleLoaded()), tiles: !!(m && m.isStyleLoaded && m.isStyleLoaded() && m.areTilesLoaded && m.areTilesLoaded()) }
+ }).catch(() => ({}))
+ if (st.style && styled === null) styled = Date.now() - t0
+ if (st.tiles) { loaded = Date.now() - t0; break }
+ await page.waitForTimeout(100)
+ }
+ const all = [...reqs.values()]
+ const first = (k) => all.filter((x) => x.kind === k).sort((a, c) => a.start - c.start)[0]
+ const bundleReq = all.find((x) => x.kind === 'bundle' && x.url.includes(`/${bundle}/${bundle}.js`)) || first('bundle')
+ const eng = first('engine'), sty = first('style'), dat = first('data')
+ runs.push({
+ loaded, styled, bundleEnd: bundleReq && bundleReq.end, engineStart: eng && eng.start, engineEnd: eng && eng.end,
+ styleStart: sty && sty.start, dataStart: dat && dat.start,
+ bytes: { bundle: 0 },
+ })
+ await ctx.close()
+ }
+ const med = (k) => { const v = runs.map((x) => x[k]).filter((x) => x != null).sort((a, c) => a - c); return v.length ? v[Math.floor(v.length / 2)] : null }
+ console.log(' measured ' + ref + ': ' + JSON.stringify(runs.map((x) => x.loaded)))
+ rows.push({ ref, styled: med('styled'), loaded: med('loaded'), bundleEnd: med('bundleEnd'), engineStart: med('engineStart'), engineEnd: med('engineEnd'), styleStart: med('styleStart'), dataStart: med('dataStart'), runs: runs.map((x) => x.loaded) })
+}
+await browser.close(); srv.kill(); host.close()
+
+const f = (x) => (x == null ? '—' : (x / 1000).toFixed(2) + 's')
+console.log(`\n${EMBED ? 'PARTNER EMBED (snippet on another origin)' : 'LEAF PAGE'} · throttle: ${MBPS > 0 ? MBPS + ' Mbps, ' + RTT + ' ms RTT' : 'none'} · ${RUNS} cold run(s) each · medians`)
+console.log('map'.padEnd(46) + 'bundle done engine start engine done style start data start BASEMAP UP TILES LOADED (runs)')
+for (const r of rows) {
+ if (r.missing) { console.log(r.ref.padEnd(46) + 'not in manifest'); continue }
+ console.log(r.ref.padEnd(46) + f(r.bundleEnd).padEnd(13) + f(r.engineStart).padEnd(14) + f(r.engineEnd).padEnd(13) + f(r.styleStart).padEnd(13) + f(r.dataStart).padEnd(12) + f(r.styled).padEnd(12) + f(r.loaded).padEnd(13) + JSON.stringify(r.runs.map((x) => (x == null ? null : +(x / 1000).toFixed(2)))))
+}
diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue
index 7f582659f..708794d04 100644
--- a/plugin-designs/plugin-studio/plugin-studio.vue
+++ b/plugin-designs/plugin-studio/plugin-studio.vue
@@ -367,6 +367,11 @@ function whenTagDefined(tag, ms) {
export default defineComponent({
name: 'PluginStudio',
+ // A host's attributes (style="height:…", class, id) belong on the HOST, not on the
+ // layout inside it. Falling through, a host's inline style also landed on the shell root — the trap
+ // that made the Prayer Walk Planner 76px tall in the gallery. Everything the layout needs (dir,
+ // title, labels) is bound explicitly below.
+ inheritAttrs: false,
components: { VerticalTabsLayout, StudioTree },
props: {
treeBase: { type: String, default: '' },