Plugin Studio and the gallery split production from experimental, from each bundle's own stage
The Driver: a public plugin that is not yet a production map shows under Experimental until it is. A profile's .meta.json declares "stage": "production" | "experimental" (default experimental; a bundle is production if any profile says so) and "heavy": true for a live WebGL owner. generate-tree carries both into manifest.json, so the Studio and the gallery read the same field and neither keeps a list. The seven production bundles are marked: dashboard-plus-grid, research-map-plus-grid, regions-dashboard, my-upg-100-list, doxa-simple-map, cities-internet, gospel-resources. The Studio rail keeps its capability sections for them and puts every experimental bundle (gospel-internet among them) in one final Experimental section. The gallery's auto-discovered Experimental folder skips a production bundle. gospel-internet-board joins HEAVY_ELEMENTS (it can own two WebGL contexts). check:studio-rail asserts the split.
This commit is contained in:
parent
6cc5542b70
commit
24b9ada48e
22 changed files with 82 additions and 12 deletions
|
|
@ -5,7 +5,9 @@
|
|||
* The Driver's ask (2026-09-26): one FOLDER per BUNDLE with its ELEMENTS inside, bundles sorted by
|
||||
* capability, highest first. Runs the studio's REAL treeSections / compositionTrees / rowsFromManifest
|
||||
* (loaded through Vite, no browser) against the BUILT manifest and asserts:
|
||||
* - sections run CAP 5 → 1, and each bundle sits in the section of its HIGHEST capability, once;
|
||||
* - PRODUCTION bundles (manifest bundle.stage, from their .meta.json) sit in capability sections that run
|
||||
* CAP 5 → 1, each in the section of its HIGHEST capability, once; every EXPERIMENTAL bundle sits in one
|
||||
* final Experimental section, highest capability first (the Driver, 2026-09-27);
|
||||
* - bundles within a section are in name order;
|
||||
* - a bundle with several elements is ONE folder holding all of them; a single-element bundle is its element;
|
||||
* - every studio row has exactly ONE canonical node (one identity per plugin; parts only target it).
|
||||
|
|
@ -36,7 +38,11 @@ try {
|
|||
ctx.resolveRef = (ref, fb) => comp.methods.resolveRef.call(ctx, ref, fb)
|
||||
ctx.compositionTrees = comp.computed.compositionTrees.call(ctx)
|
||||
const secs = comp.computed.treeSections.call(ctx)
|
||||
const caps = secs.map((s) => s.cap)
|
||||
const stageOf = new Map(manifest.bundles.map((b) => [b.bundle, b.stage === 'production' ? 'production' : 'experimental']))
|
||||
const expIdx = secs.findIndex((s) => s.id === 'stage-experimental')
|
||||
if (expIdx !== -1 && expIdx !== secs.length - 1) fail('the Experimental section is not last')
|
||||
const capSecs = secs.filter((s) => s.id !== 'stage-experimental')
|
||||
const caps = capSecs.map((s) => s.cap)
|
||||
if (caps.join() !== [...caps].sort((a, b) => b - a).join()) fail(`sections not CAP 5 → 1: ${caps.join(',')}`)
|
||||
const byBundle = new Map()
|
||||
for (const r of ctx.rows) { if (!byBundle.has(r.bundle)) byBundle.set(r.bundle, []); byBundle.get(r.bundle).push(r) }
|
||||
|
|
@ -44,13 +50,21 @@ try {
|
|||
for (const s of secs) {
|
||||
const tops = s.nodes.filter((n) => n.depth === 0)
|
||||
const names = tops.map((n) => (n.folder ? n.label : ctx.rows.find((r) => r.key === n.target)?.bundle))
|
||||
if (names.join() !== [...names].sort((a, b) => a.localeCompare(b)).join()) fail(`CAP ${s.cap}: bundles not in name order (${names.join(', ')})`)
|
||||
const isExp = s.id === 'stage-experimental'
|
||||
const topCap = (b) => Math.max(...(byBundle.get(b) || []).map((r) => r.cap))
|
||||
const want = isExp ? [...names].sort((a, b) => topCap(b) - topCap(a) || a.localeCompare(b)) : [...names].sort((a, b) => a.localeCompare(b))
|
||||
if (names.join() !== want.join()) fail(`${isExp ? 'Experimental' : 'CAP ' + s.cap}: bundles not in order (${names.join(', ')})`)
|
||||
for (const b of names) {
|
||||
const st = stageOf.get(b)
|
||||
if (isExp && st === 'production') fail(`production bundle ${b} is filed under Experimental`)
|
||||
if (!isExp && st !== 'production') fail(`experimental bundle ${b} is filed under CAP ${s.cap}, not Experimental`)
|
||||
}
|
||||
for (const [i, n] of tops.entries()) {
|
||||
const b = names[i]; const rows = byBundle.get(b) || []
|
||||
if (seen.has(b)) fail(`bundle ${b} appears twice (CAP ${seen.get(b)} and CAP ${s.cap})`)
|
||||
seen.set(b, s.cap)
|
||||
const top = Math.max(...rows.map((r) => r.cap))
|
||||
if (top !== s.cap) fail(`bundle ${b} (highest CAP ${top}) filed under CAP ${s.cap}`)
|
||||
if (!isExp && top !== s.cap) fail(`bundle ${b} (highest CAP ${top}) filed under CAP ${s.cap}`)
|
||||
if (rows.length > 1) {
|
||||
if (!n.folder) fail(`bundle ${b} has ${rows.length} elements but is not a folder`)
|
||||
const kids = s.nodes.filter((k) => k.parent === n.id && k.canonical).map((k) => k.target).sort()
|
||||
|
|
@ -61,7 +75,8 @@ try {
|
|||
for (const b of byBundle.keys()) if (!seen.has(b)) fail(`bundle ${b} is missing from the rail`)
|
||||
const canon = secs.flatMap((s) => s.nodes.filter((n) => n.canonical))
|
||||
for (const r of ctx.rows) { const c = canon.filter((n) => n.target === r.key).length; if (c !== 1) fail(`${r.key} has ${c} canonical nodes (want 1)`) }
|
||||
console.log(`STUDIO-RAIL: ${byBundle.size} bundle(s), ${ctx.rows.length} element(s), ${secs.length} capability section(s); folders: ${secs.flatMap((s) => s.nodes.filter((n) => n.folder).map((n) => n.label + '(' + n.kids + ')')).join(', ')}`)
|
||||
const nProd = [...stageOf.values()].filter((v) => v === 'production').length
|
||||
console.log(`STUDIO-RAIL: ${byBundle.size} bundle(s) (${nProd} production, ${byBundle.size - nProd} experimental), ${ctx.rows.length} element(s), ${secs.length} section(s); folders: ${secs.flatMap((s) => s.nodes.filter((n) => n.folder).map((n) => n.label + '(' + n.kids + ')')).join(', ')}`)
|
||||
} finally { await server.close() }
|
||||
if (bad) { console.log(`STUDIO-RAIL: FAIL — ${bad} problem(s).`); process.exit(1) }
|
||||
console.log('STUDIO-RAIL: PASS — one folder per bundle with its elements inside, bundles grouped by their highest capability (5 → 1) and in name order, one identity per plugin.')
|
||||
console.log('STUDIO-RAIL: PASS — one folder per bundle with its elements inside; production bundles grouped by their highest capability (5 → 1) in name order, experimental bundles in one final Experimental section; one identity per plugin.')
|
||||
|
|
|
|||
|
|
@ -301,6 +301,22 @@ function profileReachesAMap(dir, file) {
|
|||
}
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* STAGE and HEAVY, read from <profile>.meta.json and carried into manifest.json (the Driver, 2026-09-27:
|
||||
* a public-but-unfinished plugin shows under EXPERIMENTAL in the Plugin Studio and the gallery until it works
|
||||
* as a production map). Both surfaces read the MANIFEST, so they cannot disagree, and neither keeps a list.
|
||||
* "stage": "production" | "experimental" default experimental; a bundle is production if ANY of its
|
||||
* profiles says so (a bundle is one folder in the studio)
|
||||
* "heavy": true the element holds live WebGL contexts, so a host must destroy
|
||||
* it when it leaves the screen (Plugin Studio's HEAVY rows)
|
||||
*/
|
||||
function metaStageHeavy(dir, profileName) {
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
|
||||
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true }
|
||||
} catch { return { stage: 'experimental', heavy: false } }
|
||||
}
|
||||
|
||||
function metaIsMap(dir, profileName) {
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
|
||||
|
|
@ -1286,8 +1302,11 @@ function scanBundles() {
|
|||
? composesAMapLeaf(dir, profileName, file)
|
||||
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
|
||||
|
||||
const { stage: profileStage, heavy: profileHeavy } = metaStageHeavy(dir, profileName)
|
||||
return {
|
||||
profile: profileName,
|
||||
stage: profileStage,
|
||||
...(profileHeavy ? { heavy: true } : {}),
|
||||
file,
|
||||
description: description || fileTag || '',
|
||||
element: tag,
|
||||
|
|
@ -1341,6 +1360,7 @@ function scanBundles() {
|
|||
integrity,
|
||||
elements: bundleElements,
|
||||
...(composesUnique.length ? { composes: composesUnique } : {}),
|
||||
stage: profiles.some((p) => p.stage === 'production') ? 'production' : 'experimental',
|
||||
profiles,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "متعدد العروض",
|
||||
"cap2": "إعداد مسبق",
|
||||
"cap1": "مفرد",
|
||||
"stageExperimental": "تجريبي",
|
||||
"toggleList": "إظهار القائمة أو إخفاؤها",
|
||||
"openViews": "فتح قائمة العروض",
|
||||
"expandList": "توسيع القائمة",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Mehrfachansicht",
|
||||
"cap2": "Voreinstellung",
|
||||
"cap1": "Einzeln",
|
||||
"stageExperimental": "Experimentell",
|
||||
"toggleList": "Liste ein-/ausblenden",
|
||||
"openViews": "Ansichtsliste öffnen",
|
||||
"expandList": "Liste ausklappen",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multi-view",
|
||||
"cap2": "Preset",
|
||||
"cap1": "Single",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Toggle list",
|
||||
"openViews": "Open the list of views",
|
||||
"expandList": "Expand list",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivista",
|
||||
"cap2": "Preajuste",
|
||||
"cap1": "Individual",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Mostrar u ocultar la lista",
|
||||
"openViews": "Abrir la lista de vistas",
|
||||
"expandList": "Expandir la lista",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivue",
|
||||
"cap2": "Préréglage",
|
||||
"cap1": "Simple",
|
||||
"stageExperimental": "Expérimental",
|
||||
"toggleList": "Afficher ou masquer la liste",
|
||||
"openViews": "Ouvrir la liste des vues",
|
||||
"expandList": "Déplier la liste",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "बहु-दृश्य",
|
||||
"cap2": "प्रीसेट",
|
||||
"cap1": "एकल",
|
||||
"stageExperimental": "प्रायोगिक",
|
||||
"toggleList": "सूची दिखाएँ या छिपाएँ",
|
||||
"openViews": "दृश्यों की सूची खोलें",
|
||||
"expandList": "सूची फैलाएँ",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivista",
|
||||
"cap2": "Preimpostato",
|
||||
"cap1": "Singolo",
|
||||
"stageExperimental": "Sperimentale",
|
||||
"toggleList": "Mostra o nascondi l’elenco",
|
||||
"openViews": "Apri l’elenco delle viste",
|
||||
"expandList": "Espandi l’elenco",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Multivisualização",
|
||||
"cap2": "Predefinição",
|
||||
"cap1": "Único",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Mostrar ou ocultar a lista",
|
||||
"openViews": "Abrir a lista de visualizações",
|
||||
"expandList": "Expandir a lista",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Vizualizări multiple",
|
||||
"cap2": "Presetat",
|
||||
"cap1": "Simplu",
|
||||
"stageExperimental": "Experimental",
|
||||
"toggleList": "Afișează sau ascunde lista",
|
||||
"openViews": "Deschide lista de vizualizări",
|
||||
"expandList": "Extinde lista",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "Несколько видов",
|
||||
"cap2": "Предустановка",
|
||||
"cap1": "Одиночный",
|
||||
"stageExperimental": "Экспериментальные",
|
||||
"toggleList": "Показать или скрыть список",
|
||||
"openViews": "Открыть список представлений",
|
||||
"expandList": "Развернуть список",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"cap3": "多视图",
|
||||
"cap2": "预设",
|
||||
"cap1": "单一",
|
||||
"stageExperimental": "实验性",
|
||||
"toggleList": "显示或隐藏列表",
|
||||
"openViews": "打开视图列表",
|
||||
"expandList": "展开列表",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"type": "single",
|
||||
"label": "Cities with Internet — connectivity map",
|
||||
"config": {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"_comment": "Capability-4 composite that BAKES the research-dashboard + people-groups-grid into ONE self-contained element (both components imported directly + compiled into a single bundle, NO runtime bundle injection — unlike stack-demo which composes via src). Rendered stacked via the shared StackLayout so you scroll through both. This is the 'two elements baked into one' element the Driver asked for.",
|
||||
"type": "nested-elements-mfe",
|
||||
"composes": ["research-dashboard", "people-groups-grid"],
|
||||
|
|
|
|||
|
|
@ -591,7 +591,7 @@ function experimentalTabsFromManifest(manifest) {
|
|||
const out = []
|
||||
for (const b of bundles) {
|
||||
const id = b && b.bundle
|
||||
if (!id || placed.has(id) || NOT_EXPERIMENTAL.has(id)) continue
|
||||
if (!id || placed.has(id) || NOT_EXPERIMENTAL.has(id) || b.stage === 'production') continue // stage: the bundle's .meta.json, same field the Plugin Studio reads
|
||||
// Pick the bundle's entry profile: prefer a real MAP profile, else the profile named like the
|
||||
// bundle, else simply the first one. A bundle contributes exactly ONE rail row (see dedupeRows),
|
||||
// so this chooses which of its profiles that row opens on.
|
||||
|
|
|
|||
4
plugin-designs/doxa-simple-map/doxa-simple-map.meta.json
Normal file
4
plugin-designs/doxa-simple-map/doxa-simple-map.meta.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"_comment": "Prayer Progress is a production map: the Plugin Studio and the gallery list it outside Experimental (stage is read into manifest.json by generate-tree)."
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"_comment": "Capability-4 composite that BAKES this bundle's own map and tables into ONE element, stacked to scroll. Both children are imported directly and compiled into the single gospel-resources bundle — no runtime injection, no second script, and no cross-bundle version skew: the tables you scroll to are built from the same pin index the map above them draws. This is the 'gospel resources dashboard' the Driver asked for.",
|
||||
"type": "nested-elements-mfe",
|
||||
"composes": ["resources-map", "data-tables"],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"type": "nested-elements-mfe",
|
||||
"label": "UPG-100 — Map + Prayer Lists",
|
||||
"composes": ["my-upg-100-list/upg-100-list", "my-upg-100-list/poster-wall"],
|
||||
|
|
|
|||
|
|
@ -386,7 +386,10 @@ const HEAVY_ELEMENTS = new Set([
|
|||
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa',
|
||||
// composites that NEST a map, so they own a context too (measured, see above)
|
||||
'dashboard-plus-grid', 'research-dashboard-map', 'demo-map', 'all-people-groups',
|
||||
'country-posters-map', 'key-cities-map', 'research-map-plus-grid'
|
||||
'country-posters-map', 'key-cities-map', 'research-map-plus-grid',
|
||||
// gospel-internet-board owns up to TWO contexts (its map and the language-tree whiteboard; go-int).
|
||||
// A profile can also declare "heavy": true in its .meta.json, which the manifest carries (below).
|
||||
'gospel-internet-board'
|
||||
])
|
||||
|
||||
// SUPERSEDED BUNDLES — built, still embeddable, but NOT shown in the studio. big-picture is a dark
|
||||
|
|
@ -574,9 +577,17 @@ export default defineComponent({
|
|||
const treeOf = new Map(this.compositionTrees.map(t => [t.nodes[0].selects, t]))
|
||||
const byBundle = new Map()
|
||||
for (const r of this.rows) { if (!byBundle.has(r.bundle)) byBundle.set(r.bundle, []); byBundle.get(r.bundle).push(r) }
|
||||
const bundles = [...byBundle.entries()].map(([bundle, rows]) => ({ bundle, rows, cap: Math.max(...rows.map(r => r.cap)) }))
|
||||
return [5, 4, 3, 2, 1].map(cap => {
|
||||
const group = bundles.filter(b => b.cap === cap).sort((x, y) => x.bundle.localeCompare(y.bundle))
|
||||
// ★ PRODUCTION vs EXPERIMENTAL (the Driver, 2026-09-27): a public plugin that does not yet work as a
|
||||
// production map is listed under Experimental until it does. The stage is the bundle's own
|
||||
// .meta.json "stage", carried by the manifest, so this file keeps no list and the gallery reads the
|
||||
// same field. Production bundles keep their capability sections; every experimental bundle goes in
|
||||
// one Experimental section at the end, highest capability first.
|
||||
const bundles = [...byBundle.entries()].map(([bundle, rows]) => ({ bundle, rows, cap: Math.max(...rows.map(r => r.cap)), stage: rows[0].stage }))
|
||||
const experimental = bundles.filter(b => b.stage !== 'production').sort((x, y) => y.cap - x.cap || x.bundle.localeCompare(y.bundle))
|
||||
const production = bundles.filter(b => b.stage === 'production')
|
||||
const groups = [5, 4, 3, 2, 1].map(cap => ({ cap, group: production.filter(b => b.cap === cap).sort((x, y) => x.bundle.localeCompare(y.bundle)) }))
|
||||
groups.push({ cap: 'experimental', group: experimental })
|
||||
return groups.map(({ cap, group }) => {
|
||||
if (!group.length) return null
|
||||
const nodes = []
|
||||
const pushElement = (r, depth, parent) => {
|
||||
|
|
@ -610,6 +621,7 @@ export default defineComponent({
|
|||
})
|
||||
for (const r of rows) pushElement(r, 1, fid)
|
||||
}
|
||||
if (cap === 'experimental') return { id: 'stage-experimental', cap: 0, label: this.tr('stageExperimental'), title: this.tr('stageExperimental'), icon: 'layers', nodes }
|
||||
return { id: 'cap-' + cap, cap, label: this.tr('cap' + cap), title: this.tr('cap' + cap) + ' · ' + (MACHINE_TYPE[cap] || ''), icon: CAP_ICON[cap] || 'embed', nodes }
|
||||
}).filter(Boolean)
|
||||
},
|
||||
|
|
@ -935,10 +947,12 @@ export default defineComponent({
|
|||
const cap = capFor(p.type || 'single')
|
||||
// A heavy row owns a live WebGL context, so it is torn down on deactivate rather than
|
||||
// hidden. Keyed by tag OR bundle so a bundle whose profiles differ still resolves.
|
||||
const heavy = HEAVY_ELEMENTS.has(tag) || HEAVY_ELEMENTS.has(b.bundle)
|
||||
const heavy = HEAVY_ELEMENTS.has(tag) || HEAVY_ELEMENTS.has(b.bundle) || p.heavy === true
|
||||
out.push({
|
||||
key: b.bundle + '/' + p.profile,
|
||||
bundle: b.bundle,
|
||||
// production | experimental, from the bundle's .meta.json via the manifest (never a list here)
|
||||
stage: b.stage === 'production' ? 'production' : 'experimental',
|
||||
label: DISPLAY_NAME[b.bundle + '/' + p.profile] || p.profile,
|
||||
// The REAL profile id, handed to the element in profile-config. Never the label: a display
|
||||
// name would mount <doxa-maps-spa> with profile "Research Gallery", which it does not have.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"type": "nested-elements-mfe",
|
||||
"label": "Regions — List + Dashboards",
|
||||
"composes": [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"stage": "production",
|
||||
"_comment": "Capability-4 composite: the monolithic doxa-research-map (cap-3) stacked above the people-groups-grid (cap-2), scrolled as one element. THE MOBILE SIBLING of dashboard-plus-grid — the Driver's 2026-09-22 ruling was two separate cap-4 plugins rather than one element that swaps its top half by aspect ratio ('that would be really complex'); the SPA picks which one to show. Composes by TAG + BUNDLE URL (the stack-demo element/src contract) rather than baking the children in, because importing the research map's .vue directly would bypass doxa-research-map/index.js and silently drop its bundle-private colour strategies, its profileModules registry, its Pinia/i18n pair and the RTL plugin. The grid's own search + filters are ON here (unlike the desktop sibling, where the dashboard header owns them).",
|
||||
"type": "nested-elements-mfe",
|
||||
"composes": ["doxa-research-map", "people-groups-grid"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue