Plugin studio: collapsible composition sections per composite bundle
Some checks are pending
build-gate / build (push) Waiting to run
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:
parent
eaa2c35286
commit
cb07c556e1
1 changed files with 108 additions and 13 deletions
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return this.groups.map(g => ({
|
||||
const rowModel = (r) => ({
|
||||
key: r.key,
|
||||
label: r.label,
|
||||
emoji: r.emoji,
|
||||
sub: '<code><' + esc(r.tag) + '></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><' + esc(r.tag) + '></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
|
||||
|
|
|
|||
Loading…
Reference in a new issue