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:
Doxa Maps 2026-09-27 05:14:34 +00:00
parent 6cc5542b70
commit 24b9ada48e
22 changed files with 82 additions and 12 deletions

View file

@ -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.')

View file

@ -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,
})
}

View file

@ -21,6 +21,7 @@
"cap3": "متعدد العروض",
"cap2": "إعداد مسبق",
"cap1": "مفرد",
"stageExperimental": "تجريبي",
"toggleList": "إظهار القائمة أو إخفاؤها",
"openViews": "فتح قائمة العروض",
"expandList": "توسيع القائمة",

View file

@ -21,6 +21,7 @@
"cap3": "Mehrfachansicht",
"cap2": "Voreinstellung",
"cap1": "Einzeln",
"stageExperimental": "Experimentell",
"toggleList": "Liste ein-/ausblenden",
"openViews": "Ansichtsliste öffnen",
"expandList": "Liste ausklappen",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -21,6 +21,7 @@
"cap3": "बहु-दृश्य",
"cap2": "प्रीसेट",
"cap1": "एकल",
"stageExperimental": "प्रायोगिक",
"toggleList": "सूची दिखाएँ या छिपाएँ",
"openViews": "दृश्यों की सूची खोलें",
"expandList": "सूची फैलाएँ",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -21,6 +21,7 @@
"cap3": "Несколько видов",
"cap2": "Предустановка",
"cap1": "Одиночный",
"stageExperimental": "Экспериментальные",
"toggleList": "Показать или скрыть список",
"openViews": "Открыть список представлений",
"expandList": "Развернуть список",

View file

@ -21,6 +21,7 @@
"cap3": "多视图",
"cap2": "预设",
"cap1": "单一",
"stageExperimental": "实验性",
"toggleList": "显示或隐藏列表",
"openViews": "打开视图列表",
"expandList": "展开列表",

View file

@ -1,4 +1,5 @@
{
"stage": "production",
"type": "single",
"label": "Cities with Internet — connectivity map",
"config": {

View file

@ -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"],

View file

@ -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.

View 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)."
}

View file

@ -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"],

View file

@ -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"],

View file

@ -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.

View file

@ -1,4 +1,5 @@
{
"stage": "production",
"type": "nested-elements-mfe",
"label": "Regions — List + Dashboards",
"composes": [

View file

@ -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"],