Bake profile type into manifest; plugin-studio cap-5 element; UPG-100 print+dots
This commit is contained in:
parent
2d64e21fbf
commit
84fe1da1ab
9 changed files with 615 additions and 12 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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="font:16px system-ui;padding:24px">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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
45
plugin-designs/plugin-studio/index.html
Normal file
45
plugin-designs/plugin-studio/index.html
Normal 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 5 — the <strong>developer studio</strong>. One element,
|
||||
<code><plugin-studio></code>, is a vertical-tab layout of EVERY plugin element grouped
|
||||
by <strong>capability 5 → 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>
|
||||
61
plugin-designs/plugin-studio/index.js
Normal file
61
plugin-designs/plugin-studio/index.js
Normal 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
|
||||
16
plugin-designs/plugin-studio/plugin-studio.instances.json
Normal file
16
plugin-designs/plugin-studio/plugin-studio.instances.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
18
plugin-designs/plugin-studio/plugin-studio.meta.json
Normal file
18
plugin-designs/plugin-studio/plugin-studio.meta.json
Normal 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)"
|
||||
}
|
||||
377
plugin-designs/plugin-studio/plugin-studio.vue
Normal file
377
plugin-designs/plugin-studio/plugin-studio.vue
Normal 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><{{ row.tag }}></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 }} · <{{ active.tag }}><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>
|
||||
14
public/assets/vendor/vue/vue.global.prod.js
vendored
Normal file
14
public/assets/vendor/vue/vue.global.prod.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue