doxa-maps/plugin-designs/plugin-studio/plugin-studio.vue
doxa-maps 344c4dc831
Some checks are pending
build-gate / build (push) Waiting to run
Describe every plugin: 15 of 26 profiles shipped with no description
More than half the catalog had an empty description in manifest.json, so the
plugin-studio — the surface a Doxa inheritor browses to learn what exists —
showed flagship plugins as a bare "bundle - <tag>" with no explanation, while
a demo bundle explained itself fully.

Cause: the generator reads a JSDoc @description TAG, and these files carried
only prose. Every description added here is a summary of what that file's own
header already documented, not new invention. Claims were checked against the
artifacts: doxa-simple-map's "three presets" was verified against
simple-map.instances.json (prayer, engagement, adoption).

The two cap-5 shells needed a second pass. Their headers are HTML comments
with no leading asterisk, and the real TAG_RE requires `* @tag value`, so the
first attempt extracted nothing.

Result: 26/26 profiles described, 0 empty; the built gallery went from 15
empty / 11 populated to 0 / 26. Verified in a browser, not just in the
manifest. Embed suite 40/40.
2026-09-22 03:11:07 +00:00

523 lines
28 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. Same two-mode strategy proven in doxa-maps-spa:
mode 'element' → inject the bundle's built .js once (guarded), then create its real tag in an
empty Vue-unmanaged slot. Used for token-free composites/elements (no Mapbox peer + no
one-shot-define collision risk when isolated).
mode 'iframe' → point a lazy iframe at that element's standalone leaf page (its own full SPA
with its own peer-engine + token ladder). Used for map elements to sidestep the one-shot
customElements.define hazard (each map owns its own document).
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 delicate direct-JS / lazy-iframe 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 delicate direct-JS / lazy-iframe mount machinery (UNCHANGED). -->
<VerticalTabsLayout
:title="title"
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 }} · &lt;{{ active.tag }}&gt;<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>
<!-- All panes stay mounted once loaded so switching back is instant + state survives. -->
<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.mode === 'element'">
<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>
<template v-else>
<!-- iframe panes: a broken leaf page (404 / script error inside the frame) used to show a
silent blank frame. @load flips a per-row flag; a short watchdog after the src is set
marks the pane errored if it never fired — so ONE bad nested plugin shows an error
overlay in ITS pane, never a blank studio. The @error alone is unreliable for cross-
document frames, hence the watchdog. -->
<iframe
class="ps-frame"
:src="row.loaded ? row.src : 'about:blank'"
:title="row.label"
loading="lazy"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
@load="onFrameLoad(row)"
@error="onFrameError(row)"></iframe>
<div v-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-frame { position:absolute; inset:0; width:100%; height:100%; border:0; display:block; background:#f3f3f1; }
.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 need the Mapbox peer + do their own one-shot customElements.define, so nest them
// as their own SPA in a lazy iframe (their leaf page). Everything else is token-free and safe to
// mount directly by its real tag. A profile whose element name matches one of these → iframe.
const IFRAME_ELEMENTS = new Set([
'doxa-research-map', 'doxa-simple-map', 'cities-internet-map', 'gospel-resources-map',
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa'
])
// Load a script exactly once per URL; resolves after the IIFE has executed (its define() ran).
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 (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]
}
export default defineComponent({
name: 'PluginStudio',
components: { VerticalTabsLayout },
props: {
treeBase: { type: String, default: '' },
profileConfig: { type: [String, Object], default: '' },
title: { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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>&lt;' + esc(r.tag) + '&gt;</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 + best-effort re-set the active element (direct-JS panes carry a
// real element; iframe panes carry their own locale ladder). No router, no dep.
setLocale(code) {
this.locale = code
this.localeOpen = false
saveLocale(code)
try {
const row = this.active
if (row && row.mode === 'iframe') {
// iframe panes carry locale as a URL param; re-point the src so the nested SPA re-mounts in
// the new language. Re-arm the load watchdog (via loaded=false → select) so a broken
// re-load still shows this pane's error overlay, not a blank frame.
row.loaded = false
this.select(this.activeKey)
} else {
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 r = String(ref).replace(/^\.\//, '')
if (this.base) return this.base + 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:') {
const url = this.base ? this.base + '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')
const iframe = IFRAME_ELEMENTS.has(tag) || IFRAME_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 || '',
mode: iframe ? 'iframe' : 'element',
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
leaf: p.iframeUrl || (b.bundle + '/' + p.profile + '/index.html'),
embed: (p.embed && p.embed.script) || '',
// runtime nesting state
loaded: false, mounted: false, state: 'idle', error: '', src: ''
})
}
}
// 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) {
this.activeKey = key
this.embedOpen = false
const row = this.rows.find(r => r.key === key)
if (!row) return
if (row.mode === 'iframe') {
if (!row.loaded) {
row.src = this.withToken(this.resolve(row.leaf))
row.loaded = true
row.state = 'loading'
// Watchdog: if no load event arrives in 20s, show this pane's error overlay rather than a
// silent blank frame. onFrameLoad clears it (and un-errors a late load).
if (row._frameTimer) clearTimeout(row._frameTimer)
row._frameTimer = setTimeout(() => {
if (row.state !== 'ready') { row.state = 'error'; row.error = 'nested plugin did not load in time' }
}, 20000)
}
} else if (!row.mounted) {
this.mountElement(row)
}
},
withToken(url) {
const tokenUrl = (this.tokenUrl || this.cfg.tokenUrl || '').trim()
const tk = (this.tk || this.cfg.tk || '').trim()
const q = []
if (tk) q.push('tk=' + encodeURIComponent(tk))
else if (tokenUrl) q.push('tokenUrl=' + encodeURIComponent(tokenUrl))
// Push the studio's current preview language down to the nested SPA as a URL param — the
// leaf pages read `?locale=` and inject it into their element's profile-config.locale. The
// studio is the "simulate DX-life" surface, so it always carries a concrete locale.
if (this.locale) q.push('locale=' + encodeURIComponent(this.locale))
if (!q.length) return url
return url + (url.indexOf('?') === -1 ? '?' : '&') + q.join('&')
},
mountElement(row) {
row.mounted = true
row.state = 'loading'
const jsUrl = this.resolve(row.bundleJs)
loadScriptOnce(jsUrl).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 {
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).
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
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)
})
},
// iframe panes: a leaf that 404s or errors inside its own document would otherwise show a
// silent blank frame. onload fires for BOTH a good load and an error document, so we can't tell
// them apart cross-origin — instead we arm a watchdog when the src is set (in select) and treat
// a load that arrives as success (clear the watchdog + any error). onFrameError catches the rare
// same-origin error event. Either way ONE bad nested plugin is contained to its own pane.
onFrameLoad(row) {
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
// A load event means the frame document resolved; leave any deeper in-frame failure to the
// leaf's own error UI. Only clear a watchdog-set error if the frame genuinely loaded late.
if (row.state === 'error' && row.error === 'nested plugin did not load in time') { row.state = 'ready'; row.error = '' }
else if (row.state !== 'error') row.state = 'ready'
},
onFrameError(row) {
if (row._frameTimer) { clearTimeout(row._frameTimer); row._frameTimer = null }
row.state = 'error'
row.error = 'the nested plugin failed to load'
},
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-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; background: #f3f3f1; }
.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>