diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs
index 337fe7cf3..cb5d45d1d 100644
--- a/internal/plugin-gallery/dev-plugin.mjs
+++ b/internal/plugin-gallery/dev-plugin.mjs
@@ -60,7 +60,19 @@ export function resolveDevAsset(rel, root = REPO_ROOT) {
// integrity only exists after a build), so Copy embed previews what ships
export function galleryModel(reg) {
const iframe = (u) => ``
- const link = (...parts) => '/#/' + parts.map(encodeURIComponent).join('/')
+ // A REAL DEV LEAF, not a hash deep-link into the gallery.
+ //
+ // THIS USED TO BE `'/#/' + parts.join('/')` — a hash route into the gallery PAGE. That was
+ // coherent while `/` WAS the gallery, but the studio nests map profiles in a lazy iframe pointed
+ // at exactly this URL (plugin-studio.vue:25, mode 'iframe'), so every map tab was iframing the
+ // gallery. The Driver saw the staging blurb — "One shared core, one build, many paste-anywhere
+ // maps..." — rendered INSIDE each plugin pane, and reported that the studio "is still loading
+ // HTMLs for each plugin with this text." It was: the gallery, recursively, once per map tab.
+ //
+ // `/__leaf//[/]` instead serves a minimal page that mounts ONE element
+ // and nothing else — the dev equivalent of the built tree's //index.html, which
+ // is what this URL means in production. Same contract, so the studio behaves identically in both.
+ const link = (...parts) => '/__leaf/' + parts.map(encodeURIComponent).join('/')
const bundles = [...reg].sort((a, b) => a.bundle.localeCompare(b.bundle)).map((b) => {
const scriptUrl = `/plugin-designs/${b.bundle}/index.js`
const presets = new Map() // profile → its preset instances (empty for every other kind)
@@ -142,7 +154,112 @@ export function autoSpaDevPlugin(token = '') {
try { reg = discoverSpaRegistry() } catch (e) { return json(res, { error: String(e) }, 500) }
return json(res, path.endsWith('/manifest.json') ? galleryModel(reg) : reg)
}
- if (path !== '/' && path !== '/index.html') return next()
+ // ── `/__leaf//[/]` — ONE element, nothing else ────────────
+ // The dev stand-in for the built tree's //index.html. The studio's iframe
+ // panes point here (see the link() note above); without it they fell through to Vite's SPA
+ // fallback and rendered `/` — i.e. the whole studio/gallery — inside every map tab.
+ // Deliberately minimal: no chrome, no prose, no gallery. Just the tag, its bundle entry, and
+ // the baked token, so what mounts here is the element and only the element.
+ if (path.startsWith('/__leaf/')) {
+ const parts = path.slice('/__leaf/'.length).split('/').filter(Boolean).map(s => {
+ 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('/'))) {
+ res.statusCode = 400; return res.end('bad leaf path')
+ }
+ let reg
+ try { reg = discoverSpaRegistry() } catch (e) { return json(res, { error: String(e) }, 500) }
+ const entry = reg.find(b => b.bundle === bundle)
+ if (!entry) return json(res, { error: 'unknown bundle', bundle }, 404)
+ const tag = entry.tag
+ let leafTok = token
+ if (!leafTok) { try { leafTok = (readBuildConfig() || {}).token || '' } catch { leafTok = '' } }
+ // profile-config carries the same shape the built leaves pass, so a profile/instance
+ // renders the SAME view here as in production rather than the bundle's default.
+ const pc = JSON.stringify(instance ? { profile, instanceId: instance } : { profile })
+ res.statusCode = 200
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
+ res.setHeader('Cache-Control', 'no-cache')
+ return res.end([
+ '',
+ `${bundle} · ${profile}`,
+ '',
+ '',
+ leafTok ? `` : '',
+ '`,
+ '',
+ `<${tag} profile-config='${pc}'>${tag}>`,
+ ``,
+ '',
+ ].filter(Boolean).join('\n'))
+ }
+ // ── `/` MOUNTS THE PLUGIN STUDIO, NOT THE STAGING GALLERY ────────────────────────────
+ // DRIVER DIRECTION 2026-09-22: "when I run dev server on the dx maps it should just open up
+ // a plugin studio that gives you access to all the plugins in one place, without a lot of
+ // text garb that takes up so much space" — and, separately, "we do not need the extra
+ // generated staging environment because the plugin studio is replacing these index html."
+ //
+ // WHY THIS ROUTE HAD TO EXIST AT ALL. The dev server previously served ONLY the gallery at
+ // `/`, and had no route for a bundle leaf — so `bun run dev` could not show the cap-5 studio
+ // by any URL. The Driver opened `/`, saw plugins wrapped in per-bundle frames behind a wall
+ // of explanatory prose, and reasonably concluded "none of the elements are working". They
+ // were working; the page was a CHOOSER presenting itself as the product.
+ //
+ // WHY MOUNT THE TAG RATHER THAN REDIRECT TO A LEAF: plugin-studio is a real cap-5
+ // defineCustomElement element, so the honest dev surface is the element itself, loaded
+ // through its own index.js — the same path the built bundle takes. That keeps HMR working
+ // (Vite transforms the .vue on demand) and keeps registerStrategies/profileModules/Pinia
+ // intact, which baking or iframing would bypass.
+ //
+ // The gallery is NOT deleted — it is still generated for the built tree and is still
+ // load-bearing for the manifest. It simply stops being what a developer lands on. It stays
+ // reachable at an explicit URL for anyone who wants the bundle index.
+ if (path === '/' || path === '/index.html') {
+ // Same resolution order the gallery uses: the .env/VITE_MAPBOX_TOKEN value passed into
+ // autoSpaDevPlugin(token), else the build config's baked token. readBuildConfig() is
+ // called HERE rather than reusing page()'s `cfg` — that const lives inside the page()
+ // arrow function, so it is not in scope in this middleware (an earlier version referenced
+ // it and the dev server 500'd with "cfg is not defined").
+ let devTok = token
+ if (!devTok) { try { devTok = (readBuildConfig() || {}).token || '' } catch { devTok = '' } }
+ res.statusCode = 200
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
+ res.setHeader('Cache-Control', 'no-cache')
+ return res.end([
+ '',
+ '',
+ '',
+ 'doxa-maps plugin studio',
+ '',
+ // The token ladder's baked rung, exactly as the built leaves receive it, so maps paint
+ // in dev without anyone appending ?tk=.
+ devTok ? `` : "",
+ // No chrome, no prose: the element fills the viewport. "Without a lot of text garb
+ // that takes up so much space" is a layout requirement, so the host page contributes
+ // ZERO text — every pixel belongs to the studio element.
+ '',
+ '',
+ // tree-base IS REQUIRED IN DEV — without it the studio mounts EMPTY.
+ // The studio builds its tab list at runtime by fetching `manifest.json`, and with
+ // no base it falls back to '../manifest.json' RELATIVE TO ITS SCRIPT URL. In dev the
+ // script is /plugin-designs/plugin-studio/index.js, so that resolves to
+ // /plugin-designs/manifest.json — which does not exist. The shell then renders with zero
+ // rows: 31 shadow descendants, no nested plugin tags. (Measured exactly that before this
+ // line existed; the symptom reads as "no elements in the studio", not as a failed fetch.)
+ // The dev server's manifest lives on its own route instead, serving the SAME
+ // manifest-shaped model the built tree bakes (19 bundles / 30 profiles), so pointing the
+ // studio at it gives dev the same tab list production has.
+ '',
+ '',
+ '',
+ ].filter(Boolean).join('\n'))
+ }
+ // The staging gallery, kept but DEMOTED to an explicit opt-in URL.
+ if (path !== '/__gallery' && path !== '/__gallery/') return next()
let html
try { html = page() }
catch (e) { console.error(`[plugin-gallery] ${e && e.stack ? e.stack : e}`); return next() }