Plugin studio: show what a composite is made of, in one panel
Some checks are pending
build-gate / build (push) Waiting to run

A "Composed of" panel now sits under the plugin title, above the live
preview. For a composite it lists one row per directly-nested child:
name, element tag, and a capability badge, each row a link to that
child's own entry. A leaf plugin shows no panel.

Composition is no longer navigation. The rail had carried a collapsible
folder per composite, which re-listed children that already had their
own home — people-groups-grid held six nav nodes, so there was no single
name to call it by when reporting a problem. The rail is back to one
node per plugin in its own capability tier (31 nodes, 31 manifest rows,
none repeated) and the relationship lives on the plugin's own page.

Depth is one. A nested composite shows as a single row and does not
expand its own children; its breakdown is one click away, on its page.

The badge reads the manifest's declared profile type, so retyping a
profile moves its badge with no code change here. A declared child that
is published nowhere is counted as "not published" beside the heading
rather than dropped in silence — big-picture names bp-research-header,
which is an internal part, not a plugin. The list caps its own height:
the layout's header row is auto-sized, so ten uncapped rows would have
taken height from the preview instead of overflowing.

Rows are buttons with descriptive aria-labels, reachable and activatable
by keyboard. No emoji glyph on a row: this box has no emoji font, so
every one rendered as an empty box, and the badge says it better.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 20:21:48 +00:00
parent a69386582c
commit afe6abe556

View file

@ -92,7 +92,7 @@
</span>
</template>
<!-- Toolbar strip: the active element's identity + its copy-embed control. -->
<!-- Toolbar strip: the active element's identity, its COMPOSED-OF panel, and copy-embed. -->
<template #toolbar>
<div class="ps-toolbar" v-if="active">
<div class="ps-bar">
@ -104,6 +104,40 @@
@click="embedOpen = !embedOpen" v-if="active.embed">Copy embed</button>
</div>
<!-- ★ COMPOSED OF — the per-entity composition surface. Renders ONLY for a composite (a
bundle whose manifest entry carries `composes`); a leaf plugin shows nothing at all,
which is the honest answer and costs a newcomer no reading.
ONE ROW PER DIRECTLY-NESTED CHILD, maxDepth 1: name, capability badge, and a link to
that child's OWN canonical entry in the rail. It is a list of LINKS, never copies. -->
<div class="ps-composed" v-if="composedOf.length || composedUnresolved.length">
<h2 class="ps-composed-h" :id="composedHeadingId">
Composed of
<span class="ps-composed-n">{{ composedOf.length }} nested {{ composedOf.length === 1 ? 'element' : 'elements' }}</span>
<span class="ps-composed-warn" v-if="composedUnresolved.length"
:title="'Declared in the manifest but not published as a plugin: ' + composedUnresolved.join(', ')">
+{{ composedUnresolved.length }} not published
</span>
</h2>
<ul class="ps-composed-list" :aria-labelledby="composedHeadingId">
<li v-for="c in composedOf" :key="c.key">
<button type="button" class="ps-composed-row" :data-child-key="c.key"
:aria-label="'Open ' + c.label + ', capability ' + c.cap + ', ' + c.capLabel"
@click="select(c.key)">
<!-- ★ NO EMOJI GLYPH HERE, DELIBERATELY. MEASURED 2026-09-23 on this box: 🔗 and 🧩
measure EXACTLY the same width as U+10FFFD (10.2px, vs 11.6px for "A") — i.e. no
emoji font is installed and every CAP_EMOJI renders as an empty tofu box. The
rail has always had this (it is not caused by this panel and its fix belongs to
whoever owns CAP_EMOJI), but there is no reason to propagate a known-blank glyph
into a NEW surface: the capability badge on the right already carries the tier,
and carries it as readable text rather than as decoration. -->
<span class="ps-composed-name">{{ c.label }}</span>
<span class="ps-composed-tag">&lt;{{ c.tag }}&gt;</span>
<span class="ps-composed-badge" :data-cap="c.cap">CAP {{ c.cap }} · {{ c.capShort }}</span>
</button>
</li>
</ul>
</div>
<div class="ps-embed" v-if="embedOpen && active.embed">
<label>script embed
<button type="button" class="ps-btn" @click="copyText(active.embed, $event.target)">Copy</button>
@ -175,6 +209,25 @@ const PS_SHADOW_CSS = `
.ps-bar-sub { display:block; font-size:12px; color:#6d766e; }
.ps-btn { font:inherit; font-size:12px; padding:5px 10px; border:1px solid #d1d4cd; border-radius:8px; background:#d1d4cd; color:#3b463d; cursor:pointer; white-space:nowrap; }
.ps-btn:hover { background:#d1d4cd; }
.ps-composed { border:1px solid #d1d4cd; border-radius:10px; background:#ffffff; padding:9px 10px; }
.ps-composed-h { margin:0 0 7px; font-size:11px; font-weight:600; letter-spacing:.07em; text-transform:uppercase; color:#6d766e; display:flex; align-items:baseline; gap:8px; }
.ps-composed-n { font-weight:400; letter-spacing:0; text-transform:none; font-size:11px; color:#6d766e; }
.ps-composed-warn { font-weight:400; letter-spacing:0; text-transform:none; font-size:11px; color:#6d766e; border:1px dashed #d1d4cd; border-radius:999px; padding:1px 7px; cursor:help; }
/* ★ THE PANEL OWNS ITS OWN SCROLL, and this is load-bearing, not polish. VerticalTabsLayout is
grid-template-rows:auto 1fr — the header (with #toolbar inside it) is the AUTO row and the body
is 1fr — so a tall toolbar does not overflow, it SILENTLY SHRINKS THE PREVIEW. plugin-studio
composes 10 children and doxa-maps-spa 6; ten un-capped rows would take ~330px straight out of
the map below. Capping at ~4.5 rows keeps the panel a header, not a page: the count in the
heading always tells the true total even when the list is scrolled. */
.ps-composed-list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:4px; max-height:154px; overflow-y:auto; overscroll-behavior:contain; }
.ps-composed-row { display:flex; align-items:center; gap:8px; width:100%; text-align:left; font:inherit; font-size:13px; padding:6px 8px; border:1px solid transparent; border-radius:8px; background:#f3f3f1; color:#3b463d; cursor:pointer; }
.ps-composed-row:hover { background:#d1d4cd; border-color:#d1d4cd; }
.ps-composed-row:focus-visible { outline:2px solid #92b195; outline-offset:1px; }
.ps-composed-name { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ps-composed-tag { flex:1 1 auto; min-width:0; font:11px/1.35 ui-monospace,Menlo,monospace; color:#6d766e; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ps-composed-badge { flex:0 0 auto; font-size:10px; font-weight:600; letter-spacing:.04em; padding:3px 7px; border:1px solid #d1d4cd; border-radius:999px; background:#ffffff; color:#6d766e; white-space:nowrap; }
.ps-composed-badge[data-cap="5"] { border-color:#92b195; color:#3b463d; background:#f3f3f1; }
.ps-composed-badge[data-cap="4"] { border-color:#92b195; color:#3b463d; }
.ps-embed { display:flex; flex-direction:column; gap:6px; padding:8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; }
.ps-embed label { font-size:12px; color:#6d766e; 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 #d1d4cd; border-radius:6px; background:#ffffff; color:#3b463d; padding:6px; resize:vertical; }
@ -240,6 +293,10 @@ const CAP_LABEL = {
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: '🔹' }
// The same five names, shortened to fit a badge beside a child's name in the Composed-of panel.
// Same vocabulary as CAP_LABEL, just the distinguishing word — "App shell", not "App shell ·
// nested-mfe-spa" — because the badge sits inline and the full label would wrap every row.
const CAP_SHORT = { 5: 'App shell', 4: 'Composite', 3: 'Multi-view', 2: 'Preset', 1: 'Single' }
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
// deactivate (see HEAVY_ELEMENTS use in the template + select()). They are NOT iframed: every
@ -383,80 +440,107 @@ export default defineComponent({
.filter(c => byCap[c] && byCap[c].length)
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
},
// ★ 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."
// ★ COMPOSED OF — the composition model for the ACTIVE plugin only. One entry per DIRECTLY
// nested child. Empty for a leaf, which is how the panel disappears for a non-composite.
//
// 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.
// ★ WHY THIS IS A PANEL AND NOT A RAIL FOLDER (researched over Storybook, Bit, Backstage, Figma,
// Nx and Omlet, 2026-09-23; this replaces the collapsible composition folders that shipped in
// cb07c55). EVERY mature system SEPARATES THE NAVIGATION TREE FROM THE COMPOSITION GRAPH. None
// of the six encodes "what composes what" as folder nesting, for one reason: a tree answers
// WHERE DO I FIND IT, and it can only answer that if each thing lives in exactly one place.
// What is INSIDE an entity is a different question and gets its own per-entity surface.
// - Figma / Bit: a component has ONE canonical home; uses are references to it.
// - Backstage: composition renders as a RELATIONS panel on an entity page, never as a
// subfolder of the catalog tree.
// - Storybook: the sidebar is capped at Category / Group / Component — two levels, as ours is.
//
// ★ AND WHY NOT MIRROR A CHILD INTO ITS PARENT'S SECTION. The folders this replaces listed each
// child inside every composite that nested it: people-groups-grid appeared in FIVE rail folders
// plus its own capability tier — six nav nodes for one plugin. No code was duplicated, but
// IDENTITY was, which is precisely the "I can't name the plugin I'm reporting" problem. These
// rows are LINKS to the one canonical entry (they call select() on the child's real key), so
// the rail keeps exactly one node per plugin and this panel keeps the relationship.
//
// ★ maxDepth 1, deliberately. A nested cap-4 inside the cap-5 shell shows as ONE row here; it
// does NOT expand its own children. Its breakdown is one click away, on its own page, where it
// belongs. Anything deeper is a graph view, which is not a landing surface.
//
// ★ ONLY 8 of 21 bundles declare edges, and that is FINE — it is not a blocker and NOTHING here
// fabricates one. Backstage runs at Spotify scale on hand-declared relations and its docs say
// plainly that it "only displays relationships that have already been set up". A bundle with no
// `composes` simply has no panel.
//
// ★ 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"
// - some (my-upg-100-list's and regions-dashboard's children) are a "<bundle>/<profile>" ref
// that equals 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() {
// represents that child here). An unmatchable ref is DROPPED rather than rendered broken — a
// manifest typo must cost one row, never the panel.
composition() {
const a = this.active
const empty = { children: [], unresolved: [] }
if (!a) return empty
const b = this.manifestBundles.find(x => x.bundle === a.bundle)
const refs = b && b.composes
if (!refs || !refs.length) return empty
const unresolved = []
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 seen = new Set()
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)
for (const ref of refs) {
const row = resolveRef(ref)
// ★ AN UNRESOLVABLE REF IS COUNTED, NOT SILENTLY SWALLOWED. Measured 2026-09-23:
// big-picture declares composes:["bp-research-header","people-groups-grid"] but
// "bp-research-header" is not a bundle, not any profile's element, and not any row key —
// it is an internal sub-component of big-picture that was hand-declared here but never
// published as its own plugin. Dropping the row is right (a dead link is worse than no
// link), but rendering "1 nested element" under a manifest that declares 2 is a quiet
// lie, and a quiet lie on the studio's own legibility surface is the exact failure this
// panel exists to prevent. unresolved[] lets the heading say so out loud.
if (!row) { unresolved.push(ref); continue }
// Skip a self-reference and any ref that resolves to a row already listed: a panel that
// named the same child twice would re-introduce, inside one screen, the identity problem
// this whole surface exists to remove.
if (row.key === a.key || seen.has(row.key)) continue
seen.add(row.key)
// ★ THE BADGE READS THE MANIFEST'S DECLARED TYPE, NEVER A HARDCODED MAP. row.cap was
// produced by capFor(p.type) in rowsFromManifest straight from the manifest's profile
// `type`, so retyping a profile in the manifest moves its badge with zero edits here. This
// per-child badge is the one thing none of the six reference systems have — Backstage's
// relations panel names a child but not its tier — so it has to be derived, not asserted.
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]
key: row.key,
label: row.label,
tag: row.tag,
cap: row.cap,
capLabel: CAP_LABEL[row.cap] || ('capability ' + row.cap),
capShort: CAP_SHORT[row.cap] || ('cap ' + row.cap)
})
}
return out
return { children: out, unresolved }
},
// 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.
composedOf() { return this.composition.children },
// Refs the manifest declares that resolve to no published plugin (see composition). Named in
// the heading so the panel's count can never quietly disagree with the manifest.
composedUnresolved() { return this.composition.unresolved },
// Unique per active row so the list's aria-labelledby always points at THIS panel's heading.
composedHeadingId() { return 'ps-composed-h' },
// The GROUPED rail model the shared VerticalTabsLayout paints: the capability tiers, and ONLY
// the capability tiers. Every plugin has exactly ONE node, in its own tier. Composition is not
// navigation — it renders in the Composed-of panel above the preview (see composedOf).
navGroups() {
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const rowModel = (r) => ({
@ -465,22 +549,13 @@ export default defineComponent({
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 => ({
return this.groups.map(g => ({
key: 'cap-' + g.cap,
badge: 'CAP ' + g.cap,
label: g.label,
count: g.rows.length,
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
@ -758,6 +833,24 @@ export default defineComponent({
.ps-bar-sub { display: block; font-size: 12px; color: #6d766e; }
.ps-btn { font: inherit; font-size: 12px; padding: 5px 10px; border: 1px solid #d1d4cd; border-radius: 8px; background: #d1d4cd; color: #3b463d; cursor: pointer; white-space: nowrap; }
.ps-btn:hover { background: #d1d4cd; }
/* COMPOSED OF panel — mirror of the injected copy above. One row per directly-nested child, with a
capability badge derived from the manifest's declared profile type. Keep the two copies in sync. */
.ps-composed { border: 1px solid #d1d4cd; border-radius: 10px; background: #ffffff; padding: 9px 10px; }
.ps-composed-h { margin: 0 0 7px; font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: #6d766e; display: flex; align-items: baseline; gap: 8px; }
.ps-composed-n { font-weight: 400; letter-spacing: 0; text-transform: none; font-size: 11px; color: #6d766e; }
.ps-composed-warn { font-weight: 400; letter-spacing: 0; text-transform: none; font-size: 11px; color: #6d766e; border: 1px dashed #d1d4cd; border-radius: 999px; padding: 1px 7px; cursor: help; }
/* THE PANEL OWNS ITS OWN SCROLL — keep in sync with the injected copy above. The layout's header
row is `auto`, so an uncapped 10-row panel does not overflow, it shrinks the preview beneath it
by ~330px. The heading's count stays truthful when the list is scrolled. */
.ps-composed-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; max-height: 154px; overflow-y: auto; overscroll-behavior: contain; }
.ps-composed-row { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; font: inherit; font-size: 13px; padding: 6px 8px; border: 1px solid transparent; border-radius: 8px; background: #f3f3f1; color: #3b463d; cursor: pointer; }
.ps-composed-row:hover { background: #d1d4cd; border-color: #d1d4cd; }
.ps-composed-row:focus-visible { outline: 2px solid #92b195; outline-offset: 1px; }
.ps-composed-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-composed-tag { flex: 1 1 auto; min-width: 0; font: 11px/1.35 ui-monospace, Menlo, monospace; color: #6d766e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-composed-badge { flex: 0 0 auto; font-size: 10px; font-weight: 600; letter-spacing: .04em; padding: 3px 7px; border: 1px solid #d1d4cd; border-radius: 999px; background: #ffffff; color: #6d766e; white-space: nowrap; }
.ps-composed-badge[data-cap="5"] { border-color: #92b195; color: #3b463d; background: #f3f3f1; }
.ps-composed-badge[data-cap="4"] { border-color: #92b195; color: #3b463d; }
.ps-embed { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid #d1d4cd; border-radius: 8px; background: #f3f3f1; }
.ps-embed label { font-size: 12px; color: #6d766e; 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 #d1d4cd; border-radius: 6px; background: #ffffff; color: #3b463d; padding: 6px; resize: vertical; }