Bake profile type into manifest; plugin-studio cap-5 element; UPG-100 print+dots

This commit is contained in:
driver 2026-09-19 06:56:27 +00:00
parent 2d64e21fbf
commit 84fe1da1ab
9 changed files with 615 additions and 12 deletions

View file

@ -37,6 +37,11 @@ import {
import { dirname, resolve, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
// Bake the composition tier (capability machine-key) INTO the manifest so ANY consumer —
// the built gallery AND the plugin-studio cap-5 element — can group by capability 5→1
// without a separate tierMap. Previously the manifest carried no `type`, so a manifest-fed
// consumer (plugin-studio) mis-tiered every profile as cap-1 (single). Single source of truth.
import { detectProfileType } from './plugin-gallery/discover.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const APP_PROFILES = resolve(__dirname, '..', 'plugin-designs')
@ -693,11 +698,17 @@ function scanBundles() {
// this splits one profile into many named, directly-embeddable variants.
const instances = readInstances(dir, profileName, entry, tag)
// Composition tier (capability machine-key), baked into the manifest so consumers group
// by capability without a side tierMap. Explicit <profile>.meta.json {"type"} wins; else
// an *.instances.json → parameterized; else single. (detectProfileType, same as the gallery.)
const { type: profileType } = detectProfileType(dir, profileName, tag, [], instances.length > 0)
return {
profile: profileName,
file,
description: description || fileTag || '',
element: tag,
type: profileType,
iframeUrl,
scriptUrl,
embed: { iframe: iframeEmbed, script: scriptEmbed },

View file

@ -272,6 +272,48 @@ function download(blob, name) {
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
}
// PRINT a poster: open it in a print-friendly preview window and raise the browser's
// print dialog, so the user can PREVIEW the poster in the browser and print it — not
// just get a file. (Driver: "clicking a poster should open a print dialog and show the
// poster in your browser to help you print it.") Download stays available as a fallback
// (the export button), and if the popup is blocked we fall back to opening the image tab.
async function printPoster(list) {
if (exportState.value) return // one at a time (shares the HUD)
exportState.value = { label: list.label, kind: 'Poster', phase: 'fetch',
message: 'preparing print preview', pct: 30 }
try {
const url = `${assetsBase.value}/posters/${list.id}.webp`
// Preview + print in a dedicated window: a single centered, page-fit image and an
// onload -> print() so the dialog opens on the poster the user is looking at. The
// poster is a 3ft square print (see the template) → @page portrait, image scaled to
// fit the sheet with a small margin.
const w = window.open('', '_blank', 'noopener,width=900,height=1000')
if (!w) { // popup blocked → open the image tab
window.open(url, '_blank', 'noopener')
exportState.value = { ...exportState.value, phase: 'done', message: 'opened poster', pct: 100 }
setTimeout(() => { exportState.value = null }, 900); return
}
const title = `Doxa UPG-100 poster — ${String(list.label || list.id).replace(/[<>&]/g, '')}`
w.document.open()
w.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
'<style>@page{size:auto;margin:12mm}html,body{margin:0;height:100%;background:#fff}' +
'body{display:flex;align-items:center;justify-content:center}' +
'img{max-width:100%;max-height:100vh;object-fit:contain;display:block}' +
'@media print{body{height:auto}img{max-height:none;width:100%}}</style></head>' +
'<body><img src="' + url + '" alt="' + title + '"' +
' onload="setTimeout(function(){window.focus();window.print()},150)"' +
' onerror="document.body.innerHTML=\'<p style=&quot;font:16px system-ui;padding:24px&quot;>Poster not available yet.</p>\'">' +
'</body></html>')
w.document.close()
exportState.value = { ...exportState.value, phase: 'done', message: 'print preview opened', pct: 100 }
setTimeout(() => { exportState.value = null }, 900)
} catch (e) {
exportState.value = { label: list.label, kind: 'Poster', phase: 'error',
message: String(e?.message || e).slice(0, 140), pct: 0 }
}
}
function dismissExport() { exportState.value = null }
useShadowStyles(
@ -375,6 +417,10 @@ useShadowStyles(
// box that sliced off the title and the footer scripture, which are two of the
// three things the template is built to make readable.
'.img{width:100%;height:100%;object-fit:contain;display:block}' +
'.img.is-clickable{cursor:zoom-in}' +
'.img.is-clickable:hover{outline:2px solid #38bdf8;outline-offset:-2px}' +
'.img.is-clickable:focus-visible{outline:2px solid #38bdf8;outline-offset:-2px}' +
'.pbtn-print{font-weight:600}' +
'.ph{padding:14px 10px;text-align:center}' +
'.ph-label{font-size:12.5px;font-weight:700;line-height:1.3}' +
'.ph-count{margin-top:5px;font-size:11px;opacity:.7}' +
@ -444,9 +490,15 @@ useShadowStyles(
<!-- The placeholder is now driven by a LOAD FAILURE rather than by a
missing inlined asset: with previews served from assetsBase we do
not know a poster is absent until its fetch 404s. -->
<img v-if="!missing[list.id]" class="img" :src="previewUrl(list.id)"
:alt="`${list.label} — UPG 100 list prayer poster`"
loading="lazy" decoding="async"
<!-- Click (or Enter/Space) the poster to PREVIEW + PRINT it in the browser —
not a silent file download. (Driver: show the poster in the browser to
help print it.) The Download button below stays the secondary save path. -->
<img v-if="!missing[list.id]" class="img is-clickable" :src="previewUrl(list.id)"
:alt="`${list.label} — UPG 100 list prayer poster (click to preview and print)`"
loading="lazy" decoding="async" role="button" tabindex="0"
:title="`Preview & print the ${list.label} poster`"
@click="printPoster(list)"
@keydown.enter.prevent="printPoster(list)" @keydown.space.prevent="printPoster(list)"
@error="missing[list.id] = true" />
<div v-else class="ph">
<div class="ph-label">{{ list.label }}</div>
@ -462,8 +514,13 @@ useShadowStyles(
a step for nothing. -->
<fieldset class="tape pfs">
<legend>Print Options</legend>
<!-- PRINT = open the in-browser preview + print dialog (primary). DOWNLOAD =
save the file (secondary). Prayer List = coming soon. -->
<button type="button" class="pbtn pbtn-print" @click="printPoster(list)"
:aria-label="`Preview and print the ${list.label} poster`">🖨 Print poster</button>
<button type="button" class="pbtn" @click="exportPoster(list)"
:aria-label="`Download the ${list.label} poster file`">Download</button>
<button type="button" class="pbtn" @click="downloadPacket(list)">Prayer List</button>
<button type="button" class="pbtn" @click="exportPoster(list)">Poster Map</button>
</fieldset>
</div>
</div>

View file

@ -1027,7 +1027,8 @@ function onLegendSelect(node) {
selectedKey.value = null
map.setFilter('tapestry-fill', null)
map.setFilter('tapestry-line', null)
map.setPaintProperty('upg-pins', 'circle-opacity', 0.85)
// restore FULL opacity to match the new visible-dots baseline (BUG-UPG100-DOTS 2026-09-19)
map.setPaintProperty('upg-pins', 'circle-opacity', 1)
return
}
_isolatedNode = node
@ -1466,13 +1467,16 @@ onMounted(async () => {
map.addSource('upg', { type: 'geojson', data: pinFC })
map.addLayer({
id: 'upg-pins', type: 'circle', source: 'upg', paint: {
// F2-D1: radius floor 2.5px at low zoom (was 1.6, nearly invisible).
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 2.5, 4, 4, 8, 6],
// circle-color resolves through the same match expr; its fallback is now the
// visible neutral #9ca3af (F2-D3), so unmatched pins are no longer invisible.
'circle-color': matchColorExpr, 'circle-opacity': 0.85,
// F2-D2: a light stroke reads on the dark basemap and still frames pins on light.
'circle-stroke-width': 0.6, 'circle-stroke-color': 'rgba(255,255,255,0.6)',
// BUG-UPG100-DOTS-HARD-TO-SEE (2026-09-19): the previous floor (2.5px @ low zoom,
// 0.6px semi-transparent WHITE stroke) was still hard to see — a translucent white
// halo disappears on a LIGHT basemap. Fix: larger radius floor + a SOLID DARK stroke
// halo (reads on both light and dark basemaps) + a thin white inner ring for contrast,
// and full fill opacity. Dots are now clearly legible at every zoom.
'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 4, 4, 6, 8, 9],
'circle-color': matchColorExpr, 'circle-opacity': 1,
// Solid dark outer halo = the visibility win (frames every dot on any basemap).
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 1, 1.2, 8, 2],
'circle-stroke-color': 'rgba(15,20,25,0.9)',
},
})
// slice 13 §1b: wire the pin-click + hover-cursor handlers HERE so they

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · plugin-studio staging</title>
<link rel="icon" href="data:,">
<!-- Capability-5 app-shell (nested-mfe-spa): ONE element, <plugin-studio>, is the DEVELOPER
STUDIO — a vertical-tab layout whose sidebar is the composability ladder grouped by
capability (5 -> 1). Each row nests one plugin element (token-free elements mounted by their
real tag + built .js; map elements nested as their own SPA in a lazy iframe) with its embed
snippet. No Mapbox peer is loaded HERE — each nested map's leaf page loads its own. -->
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #0b0f14; color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { display: flex; flex-direction: column; height: 100vh; }
.intro { flex: 0 0 auto; padding: 14px 18px; border-bottom: 1px solid #1f2937; }
.intro h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.02em; }
.intro p { color: #9ca3af; font-size: 12.5px; margin-top: 5px; max-width: 900px; }
.intro code { color: #c4b5fd; }
.frame { flex: 1 1 auto; position: relative; min-height: 0; }
plugin-studio { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
</style>
</head>
<body>
<div class="wrap">
<div class="intro">
<h1>plugin-studio · staging</h1>
<p>Capability&nbsp;5 — the <strong>developer studio</strong>. One element,
<code>&lt;plugin-studio&gt;</code>, is a vertical-tab layout of EVERY plugin element grouped
by <strong>capability&nbsp;5&nbsp;&rarr;&nbsp;1</strong>. Click any element to see it work in
the stage, copy its embed snippet, and review it. Token-free elements mount directly by their
real tag; map elements nest as their own SPA. Switch with no page reload.</p>
</div>
<div class="frame">
<plugin-studio id="ps" title="Plugin Studio"></plugin-studio>
</div>
</div>
<!-- Vite multi-page entry — emits the IIFE that registers <plugin-studio>. -->
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,61 @@
/**
* plugin-studio / index.js — Bundle entry (Capability-5 nested-mfe-spa / app-shell).
*
* ONE folder -> ONE bundle -> ONE custom element: <plugin-studio>.
*
* <plugin-studio> is the DEVELOPER STUDIO of the whole DX-maps system: a vertical-tab layout whose
* sidebar is the composability ladder grouped by CAPABILITY (5 -> 1); each row nests that element
* (token-free elements mounted directly by their real tag; map elements nested as their own SPA in
* a lazy iframe) with its embed snippet. It is the spot to SEE every plugin element work and review
* it. See plugin-studio.vue for the full why.
*
* ★ WHY AN ELEMENT (not an index.html page): every plugin in this system is a compiled Vue custom
* element so it supports all five deployment methods and can compose. The studio is a plugin, so
* it is a real element too. A page could only be iframed and could never be nested as a tab of
* another cap-5 shell.
*
* ★ ONE-SHOT-DEFINE SAFETY: the ONLY tag this bundle registers is <plugin-studio>. It NEVER
* imports another plugin's components and NEVER re-registers a tag another bundle owns. The
* nested elements register their own tags when THEIR IIFE loads (direct-mount rows) or run in
* their own document (iframe rows). So loading this bundle beside any other can never collide.
*
* ★ TREE BASE: to find each element's built .js / leaf page + the manifest, the shell needs the
* built tree's root URL. We derive it HERE, at IIFE load, from this script's own URL
* (.../doxa-maps/plugin-studio/plugin-studio.js) -> two levels up = the tree root. Provided to
* the Vue app; a host can override with tree-base="" or profile-config.treeBase.
*
* Usage from a host page (direct-JS, deploy-method-3):
* <script src="/doxa-maps/plugin-studio/plugin-studio.js"></script>
* <plugin-studio></plugin-studio>
* <!-- or pin the tree explicitly: -->
* <plugin-studio tree-base="https://your-host/doxa-maps"></plugin-studio>
*/
import { defineCustomElement } from 'vue'
import PluginStudio from './plugin-studio.vue'
// Capture the running <script>'s own URL synchronously at IIFE load, BEFORE any async runs, so
// document.currentScript is still this bundle's tag. Derive the tree root = two levels up
// (.../doxa-maps/plugin-studio/plugin-studio.js -> .../doxa-maps/). Guarded for non-browser.
function deriveTreeBase() {
try {
const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
if (!cur) return ''
const root = cur.replace(/[^/]+\/[^/]+$/, '')
return root.replace(/\/+$/, '') + '/'
} catch { return '' }
}
const TREE_BASE = deriveTreeBase()
const PluginStudioElement = defineCustomElement(PluginStudio, {
configureApp(app) {
// Hand the derived tree root to the component (props/attrs still override it).
app.provide('psTreeBase', TREE_BASE)
}
})
if (typeof customElements !== 'undefined' && !customElements.get('plugin-studio')) {
customElements.define('plugin-studio', PluginStudioElement)
}
export default PluginStudioElement

View file

@ -0,0 +1,16 @@
{
"_comment": "Preconfigured instance of the Cat-5 element <plugin-studio>. provider:'maplibre' flags the generated standalone leaf page as TOKEN-FREE so the element mounts INSTANTLY — correct because the studio SHELL paints no map (its nested tabs do, each in its own document/iframe with its own peer + token ladder). The meta.json type 'nested-mfe-spa' (Cat-5) still wins over the parameterized signal (explicit meta type takes precedence in discover.detectProfileType), so this stays a Cat-5 app-shell. tree-base is left to runtime derivation (the leaf page's own URL -> tree root), so no absolute host is baked in.",
"profile": "plugin-studio",
"instances": [
{
"id": "studio",
"title": "Plugin Studio — every element by capability 5 -> 1 (developer studio)",
"props": {
"profile": "plugin-studio",
"provider": "maplibre",
"instanceId": "plugin-studio",
"title": "Plugin Studio"
}
}
]
}

View file

@ -0,0 +1,18 @@
{
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is a real cap-5 element, <plugin-studio>, the DEVELOPER STUDIO. Its vertical-tab sidebar is the composability ladder grouped by capability 5 -> 1; each row nests one plugin element (token-free elements mounted directly by their real tag; map elements nested as their own SPA in a lazy iframe) with its embed snippet. Unlike a static index.html gallery page, this is a compiled Vue custom element, so it supports all 5 deployment methods and can itself be nested/placed. This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it). Filename must be <profile>.meta.json where profile = the root .vue name (plugin-studio). generate-gallery.readSourceTiers() reads this and bakes capability 5 for the plugin, so it appears in the studio manifest as the app-shell (nested-mfe-spa). 'composes' lists the element tags it can nest (best-effort; the runtime tab list is read live from the built manifest, grouped by capability).",
"type": "nested-mfe-spa",
"composes": [
"bp-big-picture",
"people-groups-grid",
"doxa-research-map",
"doxa-simple-map",
"cities-internet-map",
"gospel-resources-map",
"my-upg-100-list-map",
"doxa-vision-card",
"all-people-groups",
"unified-research-dashboard",
"research-dashboard"
],
"label": "Plugin Studio (cap-5 developer studio: every element by capability 5 -> 1)"
}

View file

@ -0,0 +1,377 @@
<!--
plugin-studio.vue — the Category-5 (nested-mfe-spa / app-shell) DEVELOPER STUDIO.
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.
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>
<div class="ps" :class="{ 'nav-open': navOpen }">
<header class="ps-top">
<button class="ps-menu" type="button" :aria-expanded="String(navOpen)" @click="navOpen = !navOpen"
aria-label="Toggle element list">☰</button>
<b class="ps-title">{{ title }}</b>
<span class="ps-count">{{ countLabel }}</span>
</header>
<div class="ps-body">
<nav class="ps-nav" aria-label="Plugin elements · by capability 5 → 1" v-show="navOpen">
<div class="ps-grp" v-for="grp in groups" :key="grp.cap">
<div class="ps-tier">
<span class="ps-cap">CAP {{ grp.cap }}</span>
<span class="ps-tl">{{ grp.label }}</span>
<span class="ps-td">{{ grp.rows.length }}</span>
</div>
<button
v-for="row in grp.rows" :key="row.key" type="button" class="ps-tab"
:class="{ active: row.key === activeKey }"
:aria-current="String(row.key === activeKey)"
@click="select(row.key)">
<span class="ps-tab-emoji" aria-hidden="true">{{ row.emoji }}</span>
<span class="ps-tab-text">
<span class="ps-tab-label">{{ row.label }}</span>
<span class="ps-tab-sub"><code>&lt;{{ row.tag }}&gt;</code></span>
</span>
</button>
</div>
</nav>
<main class="ps-main">
<div class="ps-bar" v-if="active">
<div class="ps-bar-t">
<b>{{ active.label }}</b>
<span class="ps-bar-sub">{{ active.bundle }} · &lt;{{ active.tag }}&gt;<template v-if="active.description"> · {{ active.description }}</template></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 && 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>
<!-- 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>
<iframe
v-else
class="ps-frame"
:src="row.loaded ? row.src : 'about:blank'"
:title="row.label"
loading="lazy"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"></iframe>
</section>
<div class="ps-hint" v-if="!activeKey">Choose an element from the list.</div>
</div>
</main>
</div>
</div>
</template>
<script>
import { defineComponent, inject } from 'vue'
// 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',
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() {
const injectedBase = inject('psTreeBase', '')
return { injectedBase }
},
data() {
return {
navOpen: true,
activeKey: '',
embedOpen: false,
// 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] }))
},
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'
}
},
mounted() {
this.loadManifest()
try {
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
} catch { /* ignore */ }
},
methods: {
setSlot(key, el) { if (el) this._slots = Object.assign(this._slots || {}, { [key]: el }) },
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 }
} else if (!row.mounted) {
this.mountElement(row)
}
try {
if (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) this.navOpen = false
} catch { /* ignore */ }
},
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))
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(() => {
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)
const pc = { profile: row.label, instanceId: 'ps-' + row.key.replace(/\//g, '-') }
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 = (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>
/* Scoped by the custom-element shadow root (defineCustomElement) — these styles do not leak. */
.ps {
position: absolute; inset: 0; display: flex; flex-direction: column;
height: 100%; width: 100%; min-height: 420px;
background: #0b0f14; color: #e5e7eb;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
.ps-top {
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-bottom: 1px solid #1f2937; background: #0e131a;
}
.ps-menu {
display: none; font: 16px/1 system-ui; color: #e5e7eb; background: #1f2937;
border: 1px solid #374151; border-radius: 8px; padding: 4px 9px; cursor: pointer;
}
.ps-title { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.ps-count { color: #9ca3af; font-size: 12px; margin-left: auto; }
.ps-body { flex: 1 1 auto; display: flex; min-height: 0; }
.ps-nav {
flex: 0 0 258px; overflow: auto; border-right: 1px solid #1f2937;
padding: 6px 0 20px; background: #0d1219;
}
.ps-tier { padding: 12px 14px 3px; display: flex; align-items: baseline; gap: 7px; }
.ps-cap { font: 700 10px/1 system-ui; letter-spacing: .5px; color: #0b0f14; background: #64748b; border-radius: 999px; padding: 3px 7px; }
.ps-tl { font-weight: 600; font-size: 12px; }
.ps-td { color: #64748b; font-size: 11px; margin-left: auto; }
.ps-tab {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
border: 0; background: none; color: #cbd5e1; padding: 8px 14px; cursor: pointer;
border-left: 3px solid transparent; font: inherit;
}
.ps-tab:hover { background: #131a24; color: #e5e7eb; }
.ps-tab.active { background: #131c28; color: #fff; border-left-color: #38bdf8; }
.ps-tab-emoji { font-size: 16px; line-height: 1; flex: 0 0 auto; }
.ps-tab-text { display: flex; flex-direction: column; min-width: 0; }
.ps-tab-label { font-size: 13px; font-weight: 600; }
.ps-tab-sub { font-size: 11px; color: #7c8794; }
.ps-tab-sub code { font-family: ui-monospace, Menlo, monospace; }
.ps-main { flex: 1 1 auto; position: relative; min-width: 0; display: flex; flex-direction: column; padding: 10px 12px 12px; gap: 8px; }
.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; }
.ps-bar-sub { display: block; font-size: 12px; color: #9ca3af; }
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #374151; border-radius: 8px; background: #1f2937; color: #e5e7eb; cursor: pointer; white-space: nowrap; }
.ps-btn:hover { background: #263243; }
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #1f2937; border-radius: 8px; background: #0e131a; }
.ps-embed label { font-size: 12px; color: #9ca3af; 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 #374151; border-radius: 6px; background: #0b0f14; color: #e5e7eb; padding: 6px; resize: vertical; }
.ps-stage { flex: 1 1 auto; position: relative; min-height: 260px; border: 1px solid #1f2937; border-radius: 10px; overflow: hidden; background: #0b0f14; }
.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: #0b0f14; }
.ps-hint { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; color: #94a3b8; font-size: 13px; padding: 20px; }
.ps-hint.err { color: #f87171; }
.ps-hint small { color: #64748b; }
@media (max-width: 760px) {
.ps-menu { display: inline-block; }
.ps-count { display: none; }
.ps-nav { position: absolute; z-index: 20; top: 0; bottom: 0; left: 0; box-shadow: 0 0 24px rgba(0,0,0,.5); }
.ps:not(.nav-open) .ps-nav { display: none; }
}
</style>

File diff suppressed because one or more lines are too long