The Plugin Studio header read "18 bundles · 27 elements" while the manifest holds 19 and 28. The arithmetic was always right: rowsFromManifest skips the studio itself, because it cannot be one of its own tabs, and 19-1 / 28-1 is exactly what the header showed. The problem was that nothing said so. The golden-path self-test — linked from the golden-path doc as of this flight — reports 19 bundles and 28 profiles from the same manifest. Two authoritative surfaces disagreeing by one, neither explaining why, is how a reader stops trusting both; and the studio is the first thing an admin agent opens, so a number that looks wrong there discredits everything beside it. "listed here" is the whole change. It names the population, so the difference from the catalog total reads as a deliberate exclusion rather than an off-by-one. The count stays derived from this.rows, which is the honest source — it is a statement about what the studio shows, and now it says that. Verified the exclusion accounts for the gap exactly before touching anything: manifest 19/28, minus the single plugin-studio bundle and its one profile, is 18/27. Then read the rendered header at 1600px — 220px wide, sitting beside the language selector without crowding it — and confirmed it in the regenerated screenshot. Nine gates pass, including check:synced-css: there are two .ps-count rules in this file, the synced copies that must stay byte-identical, and this change is JS rather than CSS so both are untouched. Same shape as the self-test scoping documented earlier today: a number that is correct for the question it answers, with nothing saying which question that is.
692 lines
44 KiB
Vue
692 lines
44 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; }
|
|
/* ★ THE PANE OWNS THE SCROLL (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID, measured 2026-09-22).
|
|
A scroll-stack composite (dashboard-plus-grid: an 88svh dashboard band + the people-groups grid
|
|
below it) mounted here measured scrollHeight 3380 / clientHeight 769 — 2611px of real, mounted,
|
|
populated content that NO element could scroll to, because this pane was position:absolute;
|
|
inset:0 with the default overflow:visible inside a .ps-stage that is overflow:hidden.
|
|
Document scrollHeight was 900, exactly === clientHeight, so a wheel gesture had nothing to move.
|
|
The identical composite is PERFECT standalone (document 2960/900, wheel reaches 2060) because its
|
|
generated leaf gives the DOCUMENT the scroll — a host that pins the pane to viewport height has
|
|
to own that scroll itself.
|
|
auto, not scroll: only a pane whose content really exceeds its box gets a scrollbar, so the map
|
|
tabs stay scrollbar-free (see the dvh floor note below, which is what makes that true). */
|
|
.ps-pane { position:absolute; inset:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; --profile-min-h:0; }
|
|
/* NOT absolute-inset: that pinned the mount point to the pane's height and re-created the same trap
|
|
one level down (an absolutely positioned child cannot grow a scroll container). min-height:100%
|
|
keeps a fixed-viewport map filling the pane while letting a taller composite grow it. */
|
|
.ps-slot { position:relative; min-height:100%; }
|
|
/* ★ THE dvh FLOOR, NEUTRALISED FOR HOSTED ELEMENTS (measured 2026-09-22, caught by the regression
|
|
check on this very fix — my first version of the rule above was WRONG about this).
|
|
I claimed a fixed-viewport map "fills this box exactly, so auto is a no-op for it". It is not.
|
|
doxa-research-map ships .profile-loader { height:100%; min-height:100dvh }, and those two resolve
|
|
against DIFFERENT bases: height:100% against the pane (769px here, because the pane sits inside a
|
|
.ps-stage inset by the studio chrome), min-height:100dvh against the VIEWPORT (900px). The floor
|
|
wins, the element is 131px taller than its container, and overflow:auto then correctly reported a
|
|
scrollbar on a map tab that should never have one — a real regression my reasoning had ruled out.
|
|
The dvh floor is deliberate and must stay for the STANDALONE leaf, where it fixes the opposite
|
|
bug (BUG-UPG100-NESTED-ELEMENTS-ZERO-HEIGHT: min-height:100% against a body with no definite
|
|
height collapses to 0). It is only wrong HERE, where the pane is a definite box shorter than the
|
|
viewport. So neutralise it on the hosted subtree rather than editing the map: a direct child of a
|
|
studio pane is bounded by the PANE. A taller-than-viewport scroll-stack composite is unaffected —
|
|
it grows past this floor on its own content, which is what makes the grid reachable.
|
|
|
|
★ AND --profile-min-h:0 ON THE PANE, which is the half that actually does the work. Sizing the
|
|
host is NOT enough, and measuring proved it: the host correctly became 769px while the pane still
|
|
overflowed by exactly 131px, because .profile-loader lives INSIDE the map's SHADOW ROOT at
|
|
min-height:100dvh and a host-page SELECTOR cannot reach into a shadow tree. Inherited properties
|
|
CAN, so ProfileLoader.vue now reads min-height:var(--profile-min-h, 100dvh) and this pane sets it
|
|
to 0 — the floor keeps its default everywhere else, including every standalone leaf where it
|
|
still fixes BUG-UPG100.
|
|
★ NOT overflow:clip, which I tried first and MEASURED as wrong: it bounded the map (good) but
|
|
ALSO bounded the scroll-stack composite, whose host box is the pane height while its content is
|
|
3380px — so the grid became unreachable again and the gate went straight back to 0px scrolled.
|
|
One blanket rule cannot serve both; the variable targets the actual culprit instead. */
|
|
.ps-slot > * { min-height:0; max-height:none; }
|
|
.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 },
|
|
// ★ SAY WHAT IS BEING COUNTED (2026-09-23). This read "18 bundles · 27 elements" while the
|
|
// manifest holds 19 and 28, because rowsFromManifest skips the studio itself — it cannot be one
|
|
// of its own tabs. The arithmetic was always right; the LABEL was ambiguous, and it sat beside a
|
|
// golden-path self-test that reports 19/28 from the same manifest. Two authoritative surfaces
|
|
// disagreeing by one, with nothing on either saying why, is how a reader stops trusting both.
|
|
// "listed here" is the whole fix: it names the population, so the difference from the catalog
|
|
// total reads as a deliberate exclusion rather than an off-by-one.
|
|
countLabel() {
|
|
const nb = new Set(this.rows.map(r => r.bundle)).size
|
|
return nb + ' bundles · ' + this.rows.length + ' elements listed here'
|
|
},
|
|
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
|
|
},
|
|
// ★ WHICH TAB OPENS, in priority order. Driver: "when the plugin studio opens, it doesn't by
|
|
// default select the first tab. It tells you to select an element from the list. It just needs
|
|
// to auto select the 1st tab and remember using local storage what tab I was on. If I reload
|
|
// the screen so it will go back to the previously selected tab."
|
|
//
|
|
// 1. the URL hash — an explicit, shareable request always wins over a remembered one
|
|
// 2. localStorage — what you were last looking at, so a reload is not a fresh start
|
|
// 3. the first row — never leave the pane empty
|
|
//
|
|
// Called once after the manifest lands, so `rows` is populated and every candidate can be
|
|
// checked against it. A remembered key whose bundle was renamed or removed simply fails that
|
|
// check and falls through to the first row — a stale key must never render an empty pane, which
|
|
// is the very state this replaces.
|
|
route() {
|
|
const h = (typeof location !== 'undefined' && location.hash || '').replace(/^#\/?/, '')
|
|
if (h && this.rows.some(r => r.key === h)) { this.select(h); return }
|
|
const remembered = this.readLastTab()
|
|
if (remembered && this.rows.some(r => r.key === remembered)) { this.select(remembered); return }
|
|
if (this.rows.length) this.select(this.rows[0].key)
|
|
},
|
|
// Storage is keyed per HOST (pathname), because two cap-5 shells served from one origin would
|
|
// otherwise fight over a single key and each reload would yank the other's tab.
|
|
// ★ EVERY ACCESS IS GUARDED: localStorage THROWS on access in private mode and when storage is
|
|
// disabled by policy, and an exception here would abort setup() and silently remove the whole
|
|
// subtree — the studio would render blank rather than degrade. Remembering a tab is a
|
|
// convenience; it must never be able to break the shell.
|
|
storageKey() {
|
|
const path = (typeof location !== 'undefined' && location.pathname) || ''
|
|
return 'dx-studio-tab:' + path
|
|
},
|
|
readLastTab() {
|
|
try { return window.localStorage.getItem(this.storageKey()) || '' } catch (_) { return '' }
|
|
},
|
|
writeLastTab(key) {
|
|
try { window.localStorage.setItem(this.storageKey(), key) } catch (_) { /* storage unavailable */ }
|
|
},
|
|
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
|
|
// Remember it AFTER confirming the row exists, so a bad key is never written back — otherwise
|
|
// one stray select() would poison the next reload with a key that fails route()'s check.
|
|
this.writeLastTab(key)
|
|
// 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; }
|
|
/* Keep in sync with the injected copy above (BUG-STILL-CANNOT-SCROLL-DOWN-TO-THE-GRID). THE PANE
|
|
OWNS THE SCROLL: a scroll-stack composite mounted here measured scrollHeight 3380 / clientHeight
|
|
769 — 2611px of mounted, populated content no element could scroll to, because this pane was
|
|
position:absolute; inset:0 with the default overflow:visible inside an overflow:hidden stage.
|
|
auto, not scroll: a fixed-viewport map fills the box exactly and gets NO scrollbar, so the map
|
|
tabs are untouched. */
|
|
.ps-pane { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; --profile-min-h: 0; }
|
|
/* NOT absolute-inset: that pinned the mount point to the pane's height and re-created the trap one
|
|
level down. min-height:100% keeps a map filling the pane while letting a taller composite grow it. */
|
|
.ps-slot { position: relative; min-height: 100%; }
|
|
/* THE dvh FLOOR, NEUTRALISED FOR HOSTED ELEMENTS — keep in sync with the injected copy above.
|
|
doxa-research-map ships .profile-loader { height:100%; min-height:100dvh }: height:100% resolves
|
|
against the 769px pane, min-height:100dvh against the 900px VIEWPORT, so the floor made a map tab
|
|
131px taller than its container and overflow:auto correctly grew a scrollbar on it. The floor is
|
|
right for the standalone leaf and wrong only here, where the pane is a definite shorter box.
|
|
--profile-min-h:0 on the pane is the half that does the work: the floor is inside the map's SHADOW
|
|
ROOT, which no host-page selector can reach, but a custom property DOES inherit across that
|
|
boundary. overflow:clip was tried and measured wrong — it also clipped the tall composite. */
|
|
.ps-slot > * { min-height: 0; max-height: none; }
|
|
.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>
|