Some checks are pending
build-gate / build (push) Waiting to run
Both shells declared a `title` prop. defineCustomElement maps every prop to an attribute, and `title` already exists on every HTMLElement as the tooltip, so Vue warned on every single mount that it would overwrite the native property. Harmless in effect — nothing here wants a tooltip — but a warning repeated on every mount is where a real error goes to hide, and this repo has already paid for that once when a thrown setup() silently removed three buttons behind noise that looked ignorable. Renaming the prop was safe because nothing outside ever set it: no host passes title=, no profile-config reads it, and the value is only forwarded to the shared layout, which is an ordinary Vue component rather than a custom element and so has no collision of its own. Both shells carried the identical declaration — the same duplicate-implementation pattern that earlier turned one resolver assumption into two separate outages — so both were fixed together. The studio smoke check now samples several tabs instead of only the first, and measures node depth inside the mounted element rather than counting the slot's children. The old check read one child for every pane whether the element rendered a full dashboard or nothing at all, which is the "mounts is not works" trap its own header warns about. It polls for content rather than sleeping a fixed interval, because a fixed wait either runs too slow to use on every edit or marks a slow tab broken, and a gate that cries wolf gets switched off. Measured after: zero Vue warnings in the browser console.
592 lines
35 KiB
Vue
592 lines
35 KiB
Vue
<!--
|
|
plugin-studio.vue — the Category-5 (nested-mfe-spa / app-shell) DEVELOPER STUDIO.
|
|
|
|
* @description The developer's viewing surface: a sidebar that is the composability ladder
|
|
grouped by CAPABILITY (5 down to 1), listing every element each tier holds. Click a row and
|
|
that element mounts in the main slot with its embed snippet beside it — the one place to see
|
|
every plugin work, ordered by what it can do.
|
|
|
|
ONE element, <plugin-studio>, renders a VERTICAL-TAB layout whose sidebar is the composability
|
|
ladder grouped by CAPABILITY (5 → 1). Under each capability heading it lists every element that
|
|
tier holds; clicking a row nests that element in the main slot. This is the DX viewing surface:
|
|
the spot to SEE every plugin element work, in one place, ordered by capability, with the embed
|
|
snippet for each — so a developer (or the Driver) can review each element and give feedback.
|
|
|
|
★ WHY AN ELEMENT, NOT A PAGE. Every plugin in this system is a compiled Vue custom element, so
|
|
it supports all five deployment methods (iframe · script-tag/custom-element · direct-JS
|
|
props/events · nested-in-another-element · nested-SPA) and can compose. The studio is itself a
|
|
plugin, so it too is a real element (<plugin-studio>), NOT an index.html page. A page could
|
|
only be iframed (one method) and could never be nested as a tab of another cap-5 shell.
|
|
|
|
HOW IT NESTS EACH ELEMENT. ONE way, for every row: inject the bundle's .js once (guarded), then
|
|
create its REAL tag in an empty Vue-unmanaged slot. There is NO iframe mode. The studio protects
|
|
the bundles and shows the ELEMENTS — a framed page would be a different deployment method wearing
|
|
the studio's clothes, and could never demonstrate nesting.
|
|
|
|
★ THE IFRAME MODE THAT USED TO LIVE HERE, AND WHY IT IS GONE. Map rows were framed to dodge a
|
|
one-shot customElements.define collision. MEASURED 2026-09-22: 19 distinct tags across 19
|
|
bundles, ZERO claimed twice — the collision cannot occur in this tree, and two map elements were
|
|
proven to coexist in one document. What IS real is the browser's WebGL context budget, so that
|
|
is what the code now guards (see HEAVY_ELEMENTS): heavy rows are DESTROYED on deactivate
|
|
instead of being hidden, keeping at most one live map context.
|
|
|
|
The tab manifest is fetched at runtime from the built tree's manifest.json (so the studio stays
|
|
correct as bundles change), grouped by capability via the manifest's baked tier; a compact baked
|
|
fallback keeps it working under file:// or if the fetch fails. treeBase is derived by index.js
|
|
from the running script URL (two levels up), overridable via tree-base="" or profile-config.
|
|
|
|
★ SHARED SHELL. The top bar + capability-grouped vertical rail + content frame are the ONE shared
|
|
beautiful layout, VerticalTabsLayout (library/layouts) — the same shell doxa-maps-spa uses. The
|
|
studio supplies the grouped rail model + owns the content area (the embed toolbar + the panes
|
|
with their direct-element mount machinery).
|
|
|
|
NO re-import of any nested plugin's components and NO re-registration of a tag another bundle
|
|
owns — the only tag this bundle defines is <plugin-studio> (see index.js).
|
|
-->
|
|
<template>
|
|
<!-- The SHELL (top bar + capability-grouped vertical rail + content frame) is the ONE shared
|
|
beautiful layout, VerticalTabsLayout (library/layouts). The studio supplies the grouped rail
|
|
model + owns everything in the content area: the embed bar (as #toolbar) and the per-element
|
|
panes with their direct-element mount + heavy-row teardown machinery. -->
|
|
<VerticalTabsLayout
|
|
:title="heading"
|
|
nav-label="Plugin elements · by capability 5 → 1"
|
|
:header="true"
|
|
:groups="navGroups"
|
|
:active="activeKey"
|
|
@update:active="select">
|
|
|
|
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
|
|
Self-drawn SVG, green accent to match the admin shell; no external asset (offline safe). -->
|
|
<template #brand>
|
|
<span class="ps-logo" aria-hidden="true">
|
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#92b195" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M10 3v3M14 3v3M7 6h10v5a5 5 0 0 1-10 0V6Z" />
|
|
<path d="M12 16v5" />
|
|
</svg>
|
|
</span>
|
|
</template>
|
|
|
|
<!-- #meta: the bundle/element count + an i18n locale toggle. The studio previews LOCALISED
|
|
elements, so a locale switch lets a developer see each element in another language. Reuses
|
|
app-wrapper-material/languages.js (persisted, offline, no dep) — same switcher as the SPA. -->
|
|
<template #meta>
|
|
<span class="ps-count">{{ countLabel }}</span>
|
|
<span class="ps-locale">
|
|
<button type="button" class="ps-locale-btn" :aria-expanded="String(localeOpen)"
|
|
aria-label="Change language" @click="localeOpen = !localeOpen">
|
|
<span class="ps-flag">{{ activeLang.flag }}</span>
|
|
<span class="ps-locale-name">{{ activeLang.nativeName }}</span>
|
|
<span class="ps-dots" aria-hidden="true">⋮</span>
|
|
</button>
|
|
<ul class="ps-locale-menu" v-if="localeOpen" role="listbox">
|
|
<li v-for="l in switcherLangs" :key="l.code" role="option" :aria-selected="String(l.code === locale)">
|
|
<button type="button" class="ps-locale-opt" :class="{ active: l.code === locale }" @click="setLocale(l.code)">
|
|
<span class="ps-flag">{{ l.flag }}</span>
|
|
<span class="ps-locale-name">{{ l.nativeName }}</span>
|
|
<span class="ps-locale-en">{{ l.name }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</span>
|
|
</template>
|
|
|
|
<!-- Toolbar strip: the active element's identity + its copy-embed control. -->
|
|
<template #toolbar>
|
|
<div class="ps-toolbar" v-if="active">
|
|
<div class="ps-bar">
|
|
<div class="ps-bar-t">
|
|
<b>{{ active.label }}</b>
|
|
<span class="ps-bar-sub">{{ active.bundle }} · <{{ active.tag }}><span v-if="active.description"> · {{ active.description }}</span></span>
|
|
</div>
|
|
<button type="button" class="ps-btn" :aria-expanded="String(embedOpen)"
|
|
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
|
|
</div>
|
|
|
|
<div class="ps-embed" v-if="embedOpen && active.embed">
|
|
<label>script embed
|
|
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
|
|
</label>
|
|
<textarea readonly :value="active.embed"></textarea>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- EVERY pane mounts its plugin's REAL custom element — there is no iframe mode. Each plugin
|
|
is a compiled Vue custom element, so the studio embeds the element itself rather than a
|
|
page wrapped around it; that is what makes a row reviewable AND nestable.
|
|
|
|
TWO LIFETIMES, and the difference is the WebGL context budget, not cosmetics:
|
|
light rows (v-show) stay mounted once loaded, so switching back is instant and state
|
|
survives. This is deliberate — do not "simplify" it to v-if.
|
|
heavy rows (v-if, row.heavy — the map elements) are DESTROYED on deactivate. Removing
|
|
the host node runs the element's onBeforeUnmount → map.remove(), which releases the
|
|
GPU context (measured: canvas count 1→0 and webglcontextlost fired). Without this,
|
|
every map ever visited would hold a live context until the browser silently dropped
|
|
the OLDEST one and an old tab went blank with no error. -->
|
|
<div class="ps-stage">
|
|
<section
|
|
v-for="row in flatRows" :key="row.key" class="ps-pane"
|
|
v-show="row.key === activeKey" :hidden="row.key !== activeKey">
|
|
|
|
<template v-if="!row.heavy || row.key === activeKey">
|
|
<div class="ps-slot" :ref="el => setSlot(row.key, el)"></div>
|
|
<div v-if="row.state === 'loading'" class="ps-hint">Loading {{ row.label }}…</div>
|
|
<div v-else-if="row.state === 'error'" class="ps-hint err">
|
|
Could not load {{ row.label }}.<br /><small>{{ row.error }}</small>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
|
|
</div>
|
|
</VerticalTabsLayout>
|
|
</template>
|
|
|
|
<script>
|
|
import { defineComponent, inject } from 'vue'
|
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
|
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
|
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
|
|
|
// Only the studio's CONTENT-AREA styles remain here; the shell (top bar + rail + body) is now the
|
|
// shared VerticalTabsLayout (which self-injects its own CSS). Kept as an injected string so the studio
|
|
// content is styled even when this component is nested as a child element (see useShadowStyles).
|
|
const PS_SHADOW_CSS = `
|
|
.ps-logo { display:inline-flex; align-items:center; }
|
|
.ps-logo svg { display:block; }
|
|
.ps-count { color:#6d766e; }
|
|
.ps-locale { position:relative; display:inline-flex; }
|
|
.ps-locale-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:4px 9px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; }
|
|
.ps-locale-btn:hover { background:#d1d4cd; }
|
|
.ps-flag { font-size:13px; line-height:1; }
|
|
.ps-locale-name { max-width:11ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
.ps-dots { color:#6d766e; }
|
|
.ps-locale-menu { position:absolute; top:100%; right:0; margin-top:4px; z-index:30; min-width:180px; list-style:none; padding:6px; border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; box-shadow:0 12px 32px rgba(0,0,0,.5); max-height:320px; overflow:auto; }
|
|
.ps-locale-opt { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:7px 9px; border:0; border-radius:7px; background:none; color:#3b463d; cursor:pointer; }
|
|
.ps-locale-opt:hover { background:#d1d4cd; color:#3b463d; }
|
|
.ps-locale-opt.active { background:#d1d4cd; color:#3b463d; }
|
|
.ps-locale-opt.active .ps-locale-name { color:#92b195; }
|
|
.ps-locale-en { margin-left:auto; font-size:11px; color:#6d766e; }
|
|
.ps-toolbar { display:flex; flex-direction:column; gap:8px; padding:10px 12px 0; }
|
|
.ps-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
.ps-bar-t { flex:1 1 auto; min-width:0; }
|
|
.ps-bar-t b { font-size:15px; color:#3b463d; }
|
|
.ps-bar-sub { display:block; font-size:12px; color:#6d766e; }
|
|
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; white-space:nowrap; }
|
|
.ps-btn:hover { background:#d1d4cd; }
|
|
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; }
|
|
.ps-embed label { font-size:12px; color:#6d766e; display:flex; justify-content:space-between; align-items:center; }
|
|
.ps-embed textarea { width:100%; height:58px; font:11px/1.35 ui-monospace,Menlo,monospace; border:1px solid #d1d4cd; border-radius:6px; background:#ffffff; color:#3b463d; padding:6px; resize:vertical; }
|
|
.ps-stage { position:absolute; inset:10px 12px 12px; border:1px solid #d1d4cd; border-radius:10px; overflow:hidden; background:#f3f3f1; }
|
|
.ps-pane { position:absolute; inset:0; }
|
|
.ps-slot { position:absolute; inset:0; }
|
|
.ps-hint { position:absolute; inset:0; display:grid; place-items:center; text-align:center; color:#6d766e; font-size:13px; padding:20px; }
|
|
.ps-hint.err { color:#f87171; }
|
|
.ps-hint small { color:#6d766e; }
|
|
`
|
|
|
|
// Reader-facing capability names, most-composed first (5 → 1). Mirrors generate-gallery's
|
|
// CAP_LABEL so the studio speaks the same vocabulary as the built gallery + the docs.
|
|
const CAP_LABEL = {
|
|
5: 'App shell · nested-mfe-spa',
|
|
4: 'Composite element',
|
|
3: 'Multi-view element',
|
|
2: 'Preset element',
|
|
1: 'Single element'
|
|
}
|
|
// Composition tier (machine key) → capability number. 5 = app-shell … 1 = single.
|
|
const PT = { 'nested-mfe-spa': 0, 'nested-elements-mfe': 1, 'monolithic': 2, 'parameterized': 3, 'single': 4 }
|
|
const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
|
|
const CAP_EMOJI = { 5: '🧩', 4: '🔗', 3: '🗂️', 2: '🎛️', 1: '🔹' }
|
|
|
|
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
|
|
// deactivate (see HEAVY_ELEMENTS use in the template + select()). They are NOT iframed: every
|
|
// element in this tree mounts directly by its real tag, which is the whole point of the studio.
|
|
//
|
|
// ★ WHY THERE IS NO LONGER AN IFRAME MODE. Map rows used to render in a lazy iframe to dodge a
|
|
// one-shot customElements.define collision. That hazard was MEASURED away (2026-09-22): the tree
|
|
// has 19 distinct tags across 19 bundles and ZERO tags claimed by two bundles, so injecting every
|
|
// bundle into one document registers 19 distinct tags and collides with nothing. Two map elements
|
|
// were then proven to coexist in one real document (both defined, both populated, first map
|
|
// unaffected). The guard was right when written; the naming discipline made it unnecessary.
|
|
//
|
|
// ★ WHAT REPLACED IT. The real cost of direct mounting is the browser's WebGL context budget
|
|
// (commonly ~8-16). Exceeding it prints "Too many active WebGL contexts" and silently kills the
|
|
// OLDEST context — getContext() never returns null and no event fires on the NEW canvas, so a
|
|
// previously-visited map would go blank with no error anywhere. Panes are kept alive by v-show,
|
|
// so 13 directly-mounted maps would hit that ceiling. Hence these rows use v-if and are torn down
|
|
// on deactivate; everything else keeps v-show so tab switching stays instant and state survives.
|
|
// ★ THIS LIST IS MEASURED, NOT GUESSED — and it is NOT the old iframe list. The first version of
|
|
// this set was the old IFRAME_ELEMENTS copied across, which answered a DIFFERENT question: "which
|
|
// bundles wanted their own document?" A row is heavy if its element holds a live WebGL CONTEXT,
|
|
// which a tag name cannot tell you: six tags NOT in the old list still held a canvas after a full
|
|
// 29-tab sweep (dashboard-plus-grid, research-dashboard-map, demo-map, all-people-groups,
|
|
// country-posters-map, key-cities-map) because they embed maps as nested CHILDREN. Counting
|
|
// canvases only inside the ACTIVE pane said "1" and looked clean; counting them across the whole
|
|
// document said 7. Measure the document, not the pane.
|
|
// To re-derive after adding a bundle: sweep every tab, then count canvases per pane document-wide.
|
|
const HEAVY_ELEMENTS = new Set([
|
|
// map elements proper
|
|
'doxa-research-map', 'doxa-simple-map', 'cities-internet-map', 'gospel-resources-map',
|
|
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa',
|
|
// composites that NEST a map, so they own a context too (measured, see above)
|
|
'dashboard-plus-grid', 'research-dashboard-map', 'demo-map', 'all-people-groups',
|
|
'country-posters-map', 'key-cities-map', 'research-map-plus-grid'
|
|
])
|
|
|
|
// Load a bundle exactly once per URL; resolves after its define() has run.
|
|
//
|
|
// ★ A CLASSIC SCRIPT FIRES onload AND THEN FAILS, for a dev-server bundle. Measured 2026-09-22:
|
|
// pointing a plain <script> at the dev entry (/plugin-designs/<bundle>/index.js, a real ES
|
|
// module) fires onload — so the old loader resolved "success" — and only THEN throws
|
|
// "Cannot use import statement outside a module" as an async page error. The tag is never
|
|
// defined, createElement yields an inert HTMLUnknownElement, and the pane sits empty while its
|
|
// state reads 'ready'. onload is structurally incapable of seeing this failure, so it is not a
|
|
// usable success signal on its own. A built tree ships IIFEs, where a classic tag is correct, so
|
|
// this only ever bites under the dev server — exactly where the studio is developed.
|
|
// Rule: a rooted /plugin-designs/ entry is a dev ES module; everything else is a built IIFE.
|
|
const _loaded = {}
|
|
function loadScriptOnce(url, attrs) {
|
|
if (_loaded[url]) return _loaded[url]
|
|
_loaded[url] = new Promise((ok, fail) => {
|
|
const s = document.createElement('script')
|
|
s.src = url
|
|
if (/^\/plugin-designs\//.test(url) || /\/index\.js(\?|$)/.test(url)) s.type = 'module'
|
|
if (attrs) for (const k in attrs) if (attrs[k]) s.setAttribute(k, attrs[k])
|
|
s.onload = () => ok()
|
|
s.onerror = () => { delete _loaded[url]; fail(new Error('failed to load ' + url)) }
|
|
document.head.appendChild(s)
|
|
})
|
|
return _loaded[url]
|
|
}
|
|
|
|
// Wait for a tag to actually register. loadScriptOnce resolving is NOT proof the element exists
|
|
// (see above), and a module script's define() runs after its import graph settles, so the tag can
|
|
// appear a tick or two after onload. whenDefined is the browser's own authoritative answer; the
|
|
// timeout turns a never-registered tag into THIS row's error overlay instead of a silent blank pane.
|
|
function whenTagDefined(tag, ms) {
|
|
if (typeof customElements === 'undefined') return Promise.resolve()
|
|
if (customElements.get(tag)) return Promise.resolve()
|
|
return Promise.race([
|
|
customElements.whenDefined(tag),
|
|
new Promise((_, fail) => setTimeout(() => {
|
|
if (customElements.get(tag)) return
|
|
fail(new Error('<' + tag + '> never registered (bundle loaded but defined no element)'))
|
|
}, ms || 15000))
|
|
])
|
|
}
|
|
|
|
export default defineComponent({
|
|
name: 'PluginStudio',
|
|
components: { VerticalTabsLayout },
|
|
props: {
|
|
treeBase: { type: String, default: '' },
|
|
profileConfig: { type: [String, Object], default: '' },
|
|
// NOT `title` — that name collides with the NATIVE HTMLElement.title property.
|
|
// defineCustomElement maps every prop to an attribute, and `title` already exists on every
|
|
// element (the tooltip), so Vue warned on EVERY mount:
|
|
// [Vue warn]: Custom element prop "title" conflicts with an existing property on the
|
|
// element and will overwrite it.
|
|
// Benign in effect — nothing here wants a tooltip — but console noise is not free: a warning
|
|
// repeated on every mount is where a real error goes to hide, and this repo has already paid
|
|
// for that once (a thrown setup() silently removed three buttons behind ignorable noise).
|
|
// Renaming the PROP is safe because nothing outside ever set it: no host passes title=, no
|
|
// profile-config reads cfg.title, and the value is only forwarded to VerticalTabsLayout — an
|
|
// ordinary Vue component, never a custom element, so its own `title` prop has no collision.
|
|
heading: { type: String, default: 'Plugin Studio' },
|
|
tokenUrl: { type: String, default: '' },
|
|
tk: { type: String, default: '' }
|
|
},
|
|
setup() {
|
|
// Inject the studio's content-area CSS into the shadow root (the shell CSS now belongs to the
|
|
// shared VerticalTabsLayout, which self-injects). Idempotent-by-key; the shell + this content
|
|
// together render the full studio inside its shadow DOM.
|
|
useShadowStyles(PS_SHADOW_CSS, 'plugin-studio')
|
|
const injectedBase = inject('psTreeBase', '')
|
|
return { injectedBase }
|
|
},
|
|
data() {
|
|
return {
|
|
activeKey: '',
|
|
embedOpen: false,
|
|
// i18n locale switcher (reuses app-wrapper-material/languages.js — persisted, offline, no dep).
|
|
locale: loadLocale(),
|
|
localeOpen: false,
|
|
switcherLangs: SWITCHER_LANGUAGES,
|
|
// rows keyed by "<bundle>/<profile>"; each carries its resolved nesting state.
|
|
rows: [] // flat list built from the manifest (grouped for display via `groups`)
|
|
}
|
|
},
|
|
computed: {
|
|
cfg() {
|
|
let pc = this.profileConfig
|
|
if (typeof pc === 'string') { try { pc = pc ? JSON.parse(pc) : {} } catch { pc = {} } }
|
|
return pc && typeof pc === 'object' ? pc : {}
|
|
},
|
|
base() {
|
|
const raw = (this.treeBase || this.cfg.treeBase || this.injectedBase || '').trim()
|
|
if (!raw) return ''
|
|
return raw.replace(/\/+$/, '') + '/'
|
|
},
|
|
flatRows() { return this.rows },
|
|
// Group the flat rows by capability, ordered 5 → 1, dropping empty tiers.
|
|
groups() {
|
|
const byCap = {}
|
|
for (const r of this.rows) (byCap[r.cap] = byCap[r.cap] || []).push(r)
|
|
return [5, 4, 3, 2, 1]
|
|
.filter(c => byCap[c] && byCap[c].length)
|
|
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
|
|
},
|
|
// The GROUPED rail model the shared VerticalTabsLayout paints. Each tier → a group with its CAP
|
|
// badge + count; each row keeps its `key` (so select(key) is unchanged) and shows the element's
|
|
// <tag> as its sub-label (v-html, so the monospace <code> renders — the tag text is our own,
|
|
// not user input). Emoji per capability tier.
|
|
navGroups() {
|
|
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
return this.groups.map(g => ({
|
|
key: 'cap-' + g.cap,
|
|
badge: 'CAP ' + g.cap,
|
|
label: g.label,
|
|
count: g.rows.length,
|
|
rows: g.rows.map(r => ({
|
|
key: r.key,
|
|
label: r.label,
|
|
emoji: r.emoji,
|
|
sub: '<code><' + esc(r.tag) + '></code>'
|
|
}))
|
|
}))
|
|
},
|
|
active() { return this.rows.find(r => r.key === this.activeKey) || null },
|
|
countLabel() {
|
|
const nb = new Set(this.rows.map(r => r.bundle)).size
|
|
return nb + ' bundles · ' + this.rows.length + ' elements'
|
|
},
|
|
activeLang() { return getLanguage(this.locale) || { code: this.locale, flag: '🌐', nativeName: this.locale } }
|
|
},
|
|
mounted() {
|
|
this.loadManifest()
|
|
},
|
|
methods: {
|
|
setSlot(key, el) { if (el) this._slots = Object.assign(this._slots || {}, { [key]: el }) },
|
|
// Switch preview language. languages.js's contract: switching = re-setting profile-config.locale
|
|
// on the mounted element. Persist, then re-set it on the element this pane already holds. Every
|
|
// pane now carries a real element, so there is ONE path here — no per-mode branch.
|
|
setLocale(code) {
|
|
this.locale = code
|
|
this.localeOpen = false
|
|
saveLocale(code)
|
|
try {
|
|
const row = this.active
|
|
const slot = this._slots && this._slots[this.activeKey]
|
|
const el = slot && slot.firstElementChild
|
|
if (el && el.setAttribute && row) {
|
|
const pc = { profile: row.label, instanceId: 'ps-' + this.activeKey.replace(/\//g, '-'), locale: code }
|
|
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
|
el.setAttribute('profile-config', JSON.stringify(pc))
|
|
}
|
|
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
|
|
},
|
|
resolve(ref) {
|
|
const raw = String(ref)
|
|
// AN ALREADY-ABSOLUTE REF IS RETURNED UNTOUCHED — prefixing it produced a double slash and
|
|
// 404'd EVERY tab. Measured 2026-09-22 after tree-base was introduced for the dev server:
|
|
// 404 /__plugin-gallery//plugin-designs/big-picture/index.js
|
|
// ^^ base + an already-rooted path
|
|
// and the studio showed "Could not load <every plugin>" on all 29 tabs. The built tree hands
|
|
// RELATIVE refs (bundle/profile/index.html), so concatenation was always safe there and the
|
|
// assumption went unnoticed; the dev manifest hands ROOTED ones (/plugin-designs/..., /__leaf/...).
|
|
// A URL that already starts at the root needs no base by definition, so this is the correct
|
|
// rule for both trees rather than a dev-only special case.
|
|
if (/^(https?:)?\/\//.test(raw) || raw.startsWith('/')) return raw
|
|
const r = raw.replace(/^\.\//, '')
|
|
// Join through exactly one slash: `base` is normalised to a trailing "/" by base(), but a
|
|
// caller-supplied tree-base may not be, and that is precisely how the 404 above was born.
|
|
if (this.base) return this.base.replace(/\/+$/, '') + '/' + r
|
|
return '../' + r // sibling bundle folders are one level up from this bundle's folder
|
|
},
|
|
// Build the studio rows from the built tree's manifest.json. Each profile becomes one row,
|
|
// its capability read from the baked tier (manifest profile.type) → capFor. Falls back to the
|
|
// baked compact registry (index.js provides window.__psBaked) under file:// or on fetch fail.
|
|
loadManifest() {
|
|
const useModel = (m) => { this.rows = this.rowsFromManifest(m); this.route() }
|
|
const baked = (typeof window !== 'undefined' && window.__psBaked) || null
|
|
if (typeof fetch === 'function' && typeof location !== 'undefined' && location.protocol !== 'file:') {
|
|
// Route through resolve() so the single-slash join + absolute-ref rule apply here too;
|
|
// building this URL independently is how the two paths drifted apart in the first place.
|
|
const url = this.base ? this.resolve('manifest.json') : '../manifest.json'
|
|
fetch(url, { cache: 'no-cache' })
|
|
.then(r => (r.ok ? r.json() : null))
|
|
.then(m => useModel(m || baked || { bundles: [] }))
|
|
.catch(() => useModel(baked || { bundles: [] }))
|
|
} else {
|
|
useModel(baked || { bundles: [] })
|
|
}
|
|
},
|
|
rowsFromManifest(m) {
|
|
const out = []
|
|
for (const b of (m.bundles || [])) {
|
|
// never list the studio itself as one of its own tabs
|
|
if (b.bundle === 'plugin-studio') continue
|
|
for (const p of (b.profiles || [])) {
|
|
const tag = p.element || (b.elements && b.elements[0]) || b.bundle
|
|
const cap = capFor(p.type || 'single')
|
|
// A heavy row owns a live WebGL context, so it is torn down on deactivate rather than
|
|
// hidden. Keyed by tag OR bundle so a bundle whose profiles differ still resolves.
|
|
const heavy = HEAVY_ELEMENTS.has(tag) || HEAVY_ELEMENTS.has(b.bundle)
|
|
out.push({
|
|
key: b.bundle + '/' + p.profile,
|
|
bundle: b.bundle,
|
|
label: p.profile,
|
|
tag,
|
|
cap,
|
|
emoji: CAP_EMOJI[cap] || '🔹',
|
|
description: p.description || '',
|
|
heavy,
|
|
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
|
|
embed: (p.embed && p.embed.script) || '',
|
|
// runtime nesting state
|
|
mounted: false, state: 'idle', error: ''
|
|
})
|
|
}
|
|
}
|
|
// stable: capability desc, then bundle, then label
|
|
out.sort((a, b) => b.cap - a.cap || a.bundle.localeCompare(b.bundle) || a.label.localeCompare(b.label))
|
|
return out
|
|
},
|
|
route() {
|
|
const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '')
|
|
if (h && this.rows.some(r => r.key === h)) this.select(h)
|
|
},
|
|
select(key) {
|
|
const prevKey = this.activeKey
|
|
this.activeKey = key
|
|
this.embedOpen = false
|
|
// Release the outgoing HEAVY pane first, so at most one live map exists at a time. Its v-if
|
|
// subtree is about to be torn down by the re-render anyway; resetting the row's state here is
|
|
// what makes it re-mount correctly next time it is selected (a stale mounted:true would leave
|
|
// an empty slot forever, because mountElement is skipped for an already-mounted row).
|
|
if (prevKey && prevKey !== key) {
|
|
const prev = this.rows.find(r => r.key === prevKey)
|
|
if (prev && prev.heavy && prev.mounted) this.releaseRow(prev)
|
|
}
|
|
const row = this.rows.find(r => r.key === key)
|
|
if (!row) return
|
|
// A heavy row's slot only exists after the v-if subtree renders, so wait a tick before
|
|
// mounting into it — on the first activation the ref is null at this point.
|
|
if (!row.mounted) this.$nextTick(() => { if (this.activeKey === row.key) this.mountElement(row) })
|
|
},
|
|
// Destroy a pane's nested element. Removing the host node runs the element's disconnected
|
|
// lifecycle (onBeforeUnmount → map.remove()), which is what actually frees the GPU context —
|
|
// verified by canvas count 1→0 and a webglcontextlost event, not merely by the node vanishing.
|
|
releaseRow(row) {
|
|
try {
|
|
const slot = this._slots && this._slots[row.key]
|
|
if (slot) slot.innerHTML = ''
|
|
} catch { /* the row is reset either way; a failed cleanup must not wedge navigation */ }
|
|
if (this._slots) delete this._slots[row.key]
|
|
row.mounted = false
|
|
row.state = 'idle'
|
|
row.error = ''
|
|
},
|
|
mountElement(row) {
|
|
row.mounted = true
|
|
row.state = 'loading'
|
|
const jsUrl = this.resolve(row.bundleJs)
|
|
// Gate the mount on the TAG being registered, not on the script's onload — onload fires even
|
|
// when the bundle then fails to execute (see loadScriptOnce), which would hand us an inert
|
|
// HTMLUnknownElement and an empty pane reporting 'ready'.
|
|
loadScriptOnce(jsUrl).then(() => whenTagDefined(row.tag)).then(() => {
|
|
// Everything below is per-nested-plugin machinery; a throw here (bad tag name, a nested
|
|
// element that blows up in its connectedCallback, a serialise failure) must NOT crash the
|
|
// studio — it lands in THIS row's error pane. Hence the try/catch around the DOM mount, on
|
|
// top of the .catch() that already handles a failed SCRIPT load.
|
|
try {
|
|
// A heavy row may have been deactivated (and released) while its bundle loaded. Mounting
|
|
// into a torn-down pane would leak exactly the context this design exists to bound, so
|
|
// drop the mount and let the next select() redo it cleanly.
|
|
if (row.heavy && this.activeKey !== row.key) { row.mounted = false; row.state = 'idle'; return }
|
|
const slot = this._slots && this._slots[row.key]
|
|
if (!slot) { row.state = 'error'; row.error = 'no slot'; return }
|
|
slot.innerHTML = ''
|
|
const el = document.createElement(row.tag)
|
|
// Include the current preview locale so a pane mounted before any language switch already
|
|
// renders in the studio's active language (ProfileLoader reads profile-config.locale).
|
|
// tk/tokenUrl ride along here now that map rows mount directly — this is the token ladder
|
|
// the removed iframe src used to carry as a query param.
|
|
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale: this.locale }
|
|
if (this.tk || this.cfg.tk) pc.tk = this.tk || this.cfg.tk
|
|
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
|
|
// PASS THE TREE BASE DOWN — a nested cap-5 shell cannot guess it.
|
|
// doxa-maps-spa carries its OWN hardcoded, TREE-RELATIVE tab list (doxa-maps-spa.vue:265-312,
|
|
// e.g. 'doxa-research-map/research-map/index.html') and resolves it against ITS treeBase,
|
|
// which it derives from its own script URL when nobody supplies one. Mounted here that guess
|
|
// was empty, so resolve() took the '../' fallback and requested
|
|
// 404 /doxa-research-map/doxa-research-map.js
|
|
// — a path above the leaf, at the server root. Measured on the live studio 2026-09-22; three
|
|
// bundles 404'd this way. Non-fatal (the tab still rendered from its fallback list) which is
|
|
// exactly why it survived: a silent 404 behind a working screen.
|
|
// We know the correct base because it is the one WE resolved against, so hand it over rather
|
|
// than letting the child re-derive it. doxa-maps-spa reads cfg.treeBase (:519) ahead of its
|
|
// own guess, so this lands through the existing config path with no new prop.
|
|
if (this.base) pc.treeBase = this.base
|
|
el.setAttribute('profile-config', JSON.stringify(pc))
|
|
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
|
|
slot.appendChild(el)
|
|
row.state = 'ready'
|
|
} catch (e) {
|
|
row.state = 'error'
|
|
row.error = 'mount failed: ' + ((e && e.message) || String(e))
|
|
}
|
|
}).catch((e) => {
|
|
row.state = 'error'
|
|
row.error = (e && e.message) || String(e)
|
|
})
|
|
},
|
|
copyText(text, btn) {
|
|
const ok = () => { const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t }, 1200) }
|
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok, () => {})
|
|
else {
|
|
try { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); ok() } catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
/* The shell (top bar + rail + body) is the shared VerticalTabsLayout. Only the studio's own
|
|
content-area styles remain here; mirror of PS_SHADOW_CSS above. Keep the two in sync.
|
|
Reskinned to the dx-life admin tokens (neutral surfaces, green #92b195 accent) to match the shell. */
|
|
.ps-logo { display: inline-flex; align-items: center; }
|
|
.ps-logo svg { display: block; }
|
|
.ps-count { color: #6d766e; }
|
|
.ps-locale { position: relative; display: inline-flex; }
|
|
.ps-locale-btn { display: inline-flex; align-items: center; gap: 6px; font: inherit; font-size: 12px; padding: 4px 9px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; }
|
|
.ps-locale-btn:hover { background: #d1d4cd; }
|
|
.ps-flag { font-size: 13px; line-height: 1; }
|
|
.ps-locale-name { max-width: 11ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.ps-dots { color: #6d766e; }
|
|
.ps-locale-menu { position: absolute; top: 100%; right: 0; margin-top: 4px; z-index: 30; min-width: 180px; list-style: none; padding: 6px; border: 1px solid #d1d4cd; border-radius: 10px; background: #ffffff; box-shadow: 0 12px 32px rgba(0,0,0,.5); max-height: 320px; overflow: auto; }
|
|
.ps-locale-opt { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 7px 9px; border: 0; border-radius: 7px; background: none; color: #3b463d; cursor: pointer; }
|
|
.ps-locale-opt:hover { background: #d1d4cd; color: #3b463d; }
|
|
.ps-locale-opt.active { background: #d1d4cd; color: #3b463d; }
|
|
.ps-locale-opt.active .ps-locale-name { color: #92b195; }
|
|
.ps-locale-en { margin-left: auto; font-size: 11px; color: #6d766e; }
|
|
.ps-toolbar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px 0; }
|
|
.ps-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.ps-bar-t { flex: 1 1 auto; min-width: 0; }
|
|
.ps-bar-t b { font-size: 15px; color: #3b463d; }
|
|
.ps-bar-sub { display: block; font-size: 12px; color: #6d766e; }
|
|
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; white-space: nowrap; }
|
|
.ps-btn:hover { background: #d1d4cd; }
|
|
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #d1d4cd; border-radius: 8px; background: #f3f3f1; }
|
|
.ps-embed label { font-size: 12px; color: #6d766e; display: flex; justify-content: space-between; align-items: center; }
|
|
.ps-embed textarea { width: 100%; height: 58px; font: 11px/1.35 ui-monospace, Menlo, monospace; border: 1px solid #d1d4cd; border-radius: 6px; background: #ffffff; color: #3b463d; padding: 6px; resize: vertical; }
|
|
.ps-stage { position: absolute; inset: 10px 12px 12px; border: 1px solid #d1d4cd; border-radius: 10px; overflow: hidden; background: #f3f3f1; }
|
|
.ps-pane { position: absolute; inset: 0; }
|
|
.ps-slot { position: absolute; inset: 0; }
|
|
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #6d766e; font-size: 13px; padding: 20px; }
|
|
.ps-hint.err { color: #f87171; }
|
|
.ps-hint small { color: #6d766e; }
|
|
</style>
|