Plugin studio: collapsible composition sections per composite bundle
Some checks are pending
build-gate / build (push) Waiting to run

Every composite bundle (one whose manifest entry carries `composes`) now
gets its own collapsible folder in the rail, with the composite itself as
the top row and its composed children underneath, grouped/labelled the
same way the existing capability-tier rail already is. Reuses the
existing folder collapse/expand mechanism (VerticalTabsLayout, the same
one Experimental already uses) rather than inventing a second
interaction. Driven entirely from manifest.json's composes edges, so a
future composite needs no code change to appear. A child can legitimately
appear in more than one section (e.g. people-groups-grid nests into four
different composites) — that duplication is the point, not a bug.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 18:18:04 +00:00
parent eaa2c35286
commit cb07c556e1

View file

@ -356,7 +356,11 @@ export default defineComponent({
localeOpen: false,
switcherLangs: SWITCHER_LANGUAGES,
// rows keyed by "<bundle>/<profile>"; each carries its resolved nesting state.
rows: [] // flat list built from the manifest (grouped for display via `groups`)
rows: [], // flat list built from the manifest (grouped for display via `groups`)
// raw manifest bundles, kept ONLY so composeGroups() can read each bundle's `composes` list —
// rows[] already dropped that field on the way to a flat row, and re-fetching the manifest a
// second time just to read one property would be silly.
manifestBundles: []
}
},
computed: {
@ -379,24 +383,104 @@ export default defineComponent({
.filter(c => byCap[c] && byCap[c].length)
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
},
// The GROUPED rail model the shared VerticalTabsLayout paints. Each tier → a group with its CAP
// badge + count; each row keeps its `key` (so select(key) is unchanged) and shows the element's
// <tag> as its sub-label (v-html, so the monospace <code> renders — the tag text is our own,
// not user input). Emoji per capability tier.
// ★ COMPOSITION FOLDERS — one collapsible section per COMPOSITE bundle (any bundle whose manifest
// entry carries `composes`), built ENTIRELY from the manifest: no hardcoded bundle names, no
// hardcoded child lists. Driver's spec (verbatim): "that section sorts what plugins came together
// compositionally to create the highest-capability plugin per section... the capability-5 plugin
// itself as an element AT THE TOP, and underneath it every single plugin that was nested into it,
// organized by their capability. You can collapse that section to hide all of that."
//
// Each group uses VerticalTabsLayout's existing FOLDER mechanism (`folder:true`) — the same
// collapse/expand the Experimental section in doxa-maps-spa.vue already uses (chevron rotates,
// aria-expanded, click-to-toggle). No second interaction was invented; this reuses that one.
//
// ★ RESOLVING A `composes` ENTRY TO A ROW. Measured 2026-09-23: the manifest's `composes` arrays
// are NOT one consistent shape —
// - most entries are an ELEMENT TAG e.g. "people-groups-grid", "doxa-research-map"
// - two entries (my-upg-100-list's children) are a "<bundle>/<profile>" ref that happens to
// equal a ROW KEY verbatim: "my-upg-100-list/upg-100-list"
// - one entry ("research-dashboard") is a BUNDLE name whose element tag is actually
// "research-dashboard-map" — tag lookup misses, bundle lookup hits
// so resolution tries, in order: exact row KEY → row TAG → row BUNDLE. First match wins (a tag
// like "cities-internet-map" is shared by three profiles of one bundle; any one of them correctly
// represents that child here — resolveComposesRef documents why).
composeGroups() {
const out = []
for (const b of this.manifestBundles) {
const refs = b.composes
if (!refs || !refs.length) continue
// The composite's own row (its highest-capability profile) is the section's TOP element —
// rows are already sorted cap desc within a bundle by rowsFromManifest, so [0] is correct.
const ownRows = this.rows.filter(r => r.bundle === b.bundle)
// ★ THE STUDIO'S OWN COMPOSITE HAS NO OWN ROW. rowsFromManifest deliberately never lists
// plugin-studio as one of its own tabs (mounting <plugin-studio> inside itself is infinite
// regress). Without a fallback its whole composition section would silently vanish — the ONE
// bundle whose breakdown a developer most wants to see (it composes 10 other elements) would
// be the one missing, which is worse than any other row being absent. Build a SYNTHETIC,
// unselectable head from the manifest entry itself: real label/tag, but a key no real row can
// ever share, so select()'s dead-key guard (see above) makes clicking it a harmless no-op
// rather than blanking the pane.
const head = ownRows[0] || {
key: 'compose-head-' + b.bundle,
label: (b.profiles && b.profiles[0] && b.profiles[0].profile) || b.bundle,
tag: (b.profiles && b.profiles[0] && b.profiles[0].element) || b.bundle,
emoji: CAP_EMOJI[5] || '🔹'
}
// Returns null (dropped, never a broken row) if a ref cannot be matched to anything currently
// in `rows` — a manifest typo must not crash the rail, it should just render a section with
// one fewer child. Plain function, not a method: it is pure over `this.rows`, so a closure
// avoids the computed/method distinction entirely rather than risking it.
const resolveRef = (ref) => {
const byKey = this.rows.find(r => r.key === ref)
if (byKey) return byKey
const byTag = this.rows.find(r => r.tag === ref)
if (byTag) return byTag
const byBundle = this.rows.find(r => r.bundle === ref)
if (byBundle) return byBundle
return null
}
const children = refs.map(resolveRef).filter(Boolean)
out.push({
key: 'compose-' + b.bundle,
label: head.label + ' (composition)',
folder: true,
open: false,
// head first, then children — DUPLICATION ACROSS SECTIONS IS INTENDED: a child such as
// people-groups-grid legitimately reappears in every composite that nests it, because the
// question this section answers is "what composes THIS plugin", not "list each plugin once".
rows: [head, ...children]
})
}
return out
},
// The GROUPED rail model the shared VerticalTabsLayout paints: composition folders FIRST (the
// Driver's own section, one per composite, collapsible), then the existing capability-tier groups
// UNCHANGED for every bundle (composite or not) — a composite still shows in its CAP-N tier too,
// exactly as it did before this feature; the folder is additive, not a replacement view.
navGroups() {
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return this.groups.map(g => ({
const rowModel = (r) => ({
key: r.key,
label: r.label,
emoji: r.emoji,
sub: '<code>&lt;' + esc(r.tag) + '&gt;</code>'
})
const compose = this.composeGroups.map(g => ({
key: g.key,
label: g.label,
folder: true,
open: false,
count: g.rows.length,
rows: g.rows.map(rowModel)
}))
const tiers = this.groups.map(g => ({
key: 'cap-' + g.cap,
badge: 'CAP ' + g.cap,
label: g.label,
count: g.rows.length,
rows: g.rows.map(r => ({
key: r.key,
label: r.label,
emoji: r.emoji,
sub: '<code>&lt;' + esc(r.tag) + '&gt;</code>'
}))
rows: g.rows.map(rowModel)
}))
return [...compose, ...tiers]
},
active() { return this.rows.find(r => r.key === this.activeKey) || null },
// ★ SAY WHAT IS BEING COUNTED (2026-09-23). This read "18 bundles · 27 elements" while the
@ -457,7 +541,11 @@ export default defineComponent({
// 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 useModel = (m) => {
this.manifestBundles = (m && m.bundles) || []
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:') {
// Route through resolve() so the single-slash join + absolute-ref rule apply here too;
@ -539,6 +627,13 @@ export default defineComponent({
try { window.localStorage.setItem(this.storageKey(), key) } catch (_) { /* storage unavailable */ }
},
select(key) {
// ★ BAIL BEFORE TOUCHING STATE if `key` names no real row. A composition-folder's TOP row can be
// a SYNTHETIC head (see composeGroups) for a composite that cannot list itself as one of its own
// tabs (plugin-studio nesting <plugin-studio> would be infinite regress — same reasoning
// doxa-maps-spa's NOT_EXPERIMENTAL already documents). Setting activeKey to a dead key BEFORE this
// guard would blank the content pane (flatRows has no matching entry to render) with the "Choose
// an element" hint also gone — a real, if minor, regression a synthetic row must never cause.
if (!this.rows.some(r => r.key === key)) return
const prevKey = this.activeKey
this.activeKey = key
this.embedOpen = false