Capability is derived from what a plugin composes, and the preset-plugin standard is written down

generate-tree derives each profile's tier from its composes edges (composing
anything is capability 4; composing a capability-4 plugin is capability 5). An
explicit type is checked against the derivation and a contradiction fails the
build naming both. check:capability-derived asserts every profile is 1-5 and
agrees. documentation/PRESET-PLUGIN-STANDARD.md describes the one
*.instances.json file a preset plugin declares.
This commit is contained in:
Doxa Maps 2026-09-27 22:34:59 +00:00
parent 13dfe63a1f
commit 8afea17c6b
5 changed files with 217 additions and 3 deletions

View file

@ -0,0 +1,55 @@
# The preset plugin standard — how to ship named variants that appear by themselves
A **preset** is one profile rendered N ways by different props: doxa-simple-map ships `prayer`,
`engagement` and `adoption` from a single `<doxa-simple-map>` element. Presets are **discovered, never
registered** — nothing in Plugin Studio, the gallery or the manifest generator holds a list of them.
Verified on the tree as it stands: the only match for "prayer|engagement|adoption" anywhere in
`plugin-studio.vue` is an unrelated comment about a layout bug.
## Declare one file
Put `<name>.instances.json` beside your profile in `plugin-designs/<bundle>/`. That file's PRESENCE is
the whole registration: `discover.mjs` (`detectProfileType`) types a bundle **parameterized** — capability
2 — because it ships one, and `null` otherwise. Nothing else is consulted.
```
plugin-designs/doxa-simple-map/
doxa-simple-map.vue the profile
simple-map.instances.json the presets <- this is the registration
```
Two accepted shapes, both read by `discover.mjs`: a bare array `[{ id, title, props }]`, or an object
`{ profile, apiBaseUrl, instances: [...] }` when the presets need a base URL or name their profile
explicitly. Each instance needs `id` (url-safe, becomes the folder), `title` (what a reader sees) and
`props` (exactly what you would pass the element).
## What you get without asking
- **A standalone embeddable page per preset** at `<tree>/<bundle>/<instance>/index.html`, nested under
the profile, each with its own SRI-pinned script tag.
- **A manifest entry per preset** under the profile's `instances`, so every consumer sees them at once.
- **A rail row and a gallery card per preset**, from that same manifest.
- **Capability 2**, derived. Do not hand-declare `"type": "parameterized"` to get it.
## The rules that keep it automatic
1. **Never add your preset to a list elsewhere.** If a preset needs a change in Plugin Studio to show
up, that is a defect in the discovery path, not something to work around. The one bundle-name list
that used to exist is exactly the thing this standard replaced.
2. **The base profile still stands on its own.** Presets SPLIT one profile into named render targets;
they do not replace it. `doxa-simple-map/doxa-simple-map/` still accepts live props.
3. **`props` is the whole difference between presets.** If two presets differ by anything the element
cannot be told through props, they are two profiles, not two presets.
4. **Presets are not tabs.** A tab nests several components inside one element; a preset renders the
same element several ways. Choosing the wrong one shows up later as a capability that will not
derive: tabs make a bundle a composite (cap 4+), presets keep it parameterized (cap 2).
5. **An `id` is a URL.** It becomes a folder under the tree and is embedded in partner pages, so
renaming one breaks links already shared. Treat it like the share-link vocabulary: additive.
## How to check it worked
Build the tree, then read the manifest rather than the screen: your bundle's profile should carry an
`instances` array with one entry per preset, and `check:capability-derived` should report the profile as
a real tier. A preset that does not appear is almost always a filename that does not end
`.instances.json`, or an `instances` key that is not an array — `discover.mjs` treats both as "no
presets" and types the bundle `single`, silently.

View file

@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* check-capability-derived — every bundle lands on a real capability, 1-5, never 0.
*
* ★ THE DEFECT. Tiers 3-5 used to be AUTHORED ONLY: discover.mjs infers `single` and `parameterized`
* and nothing else, so a composite whose author forgot a <profile>.meta.json shipped as capability 1.
* research-dashboard did exactly that while nesting ten views. A missing file is indistinguishable
* from a deliberate choice, so nothing failed and the understatement propagated into the manifest,
* the gallery and the studio rail — every surface that claims a capability reads this one field.
*
* WHAT THIS ASSERTS, and why each half is here:
* 1. Every profile resolves to a capability 1-5. A 0 or a null means some surface will render a
* tier that does not exist; the Driver saw exactly that as "the Research Gallery shows as
* capability 0".
* 2. A profile that composes anything derives cap-4, or cap-5 if it composes a cap-4 — and an
* EXPLICIT type must AGREE with that. The declaration stays authoritative; it is not
* overridden, it is checked. A meta that contradicts the structure means the meta or the
* composes list is stale, and choosing silently between them is how a tier rots.
*
* ★ TWO TRAPS THIS GATE EXISTS BECAUSE OF, both found by running the rule before writing it:
* - `composes` is PER-PROFILE at source and HOISTED TO THE BUNDLE in the manifest. Deriving from
* the bundle row applies a composite's edges to its plain sibling leaves: gospel-resources has one
* composite and four single leaves, and all four derived as cap-4. 11 false failures.
* - refs appear in TWO FORMS in the same field: 'bundle/profile' and a bare 'bundle'. Resolving
* only the first demoted plugin-studio from 5 to 4.
* With both handled: 10 composing profiles, 10 agree, 0 disagree, on the tree as it stands.
*
* An UNRESOLVED ref is REPORTED, never counted as "not cap-4": a typo and a genuine non-composite
* child look identical from here, and quietly treating one as the other is how a tier silently drops.
*
* Headless, no browser. Exit 0 pass · 1 fail.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const TAG = 'CAPABILITY-DERIVED'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const PT = { 'nested-mfe-spa': 5, 'nested-elements-mfe': 4, monolithic: 3, parameterized: 2, single: 1 }
const fails = []
const mfPath = join(ROOT, 'public', TREE_NAME, 'manifest.json')
if (!existsSync(mfPath)) { console.log(`${TAG}: SKIP — no built tree at ${TREE_NAME}.`); process.exit(0) }
const manifest = JSON.parse(readFileSync(mfPath, 'utf8'))
const bundles = manifest.bundles || []
// ── 1. every profile has a real capability ─────────────────────────────────────────────────────
let checked = 0
for (const b of bundles) for (const p of b.profiles || []) {
checked++
const cap = PT[p.type]
if (!cap) fails.push(`${b.bundle}/${p.profile}: type ${JSON.stringify(p.type)} is not one of the five tiers — every surface that shows a capability reads this.`)
}
// ── 2. a declared tier must agree with the edges it declares ────────────────────────────────────
const readMeta = (bundle, profile) => {
const f = join(ROOT, 'plugin-designs', bundle, `${profile}.meta.json`)
try { return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null } catch { return null }
}
const byKey = new Map(), byName = new Map()
for (const b of bundles) for (const p of b.profiles || []) {
byKey.set(`${b.bundle}/${p.profile}`, p.type || null)
if (!byName.has(b.bundle)) byName.set(b.bundle, p.type || null)
if (!byName.has(p.profile)) byName.set(p.profile, p.type || null)
}
const resolveRef = (ref) => (ref.includes('/') ? byKey.get(ref) : byName.get(ref))
const unresolved = []
let composing = 0
for (const b of bundles) for (const p of b.profiles || []) {
const meta = readMeta(b.bundle, p.profile)
const own = Array.isArray(meta?.composes) ? meta.composes.map(String).filter(Boolean) : []
if (!own.length) continue
composing++
const kinds = own.map((ref) => { const k = resolveRef(ref); if (k === undefined) unresolved.push(`${b.bundle}/${p.profile} -> ${ref}`); return k })
const derived = kinds.some((k) => k === 'nested-elements-mfe') ? 'nested-mfe-spa' : 'nested-elements-mfe'
if (p.type !== derived) {
fails.push(`${b.bundle}/${p.profile}: declares "${p.type}" but its own composes edges derive "${derived}" — one of the two is stale.`)
}
}
console.log(` · ${checked} profile(s) across ${bundles.length} bundle(s); ${composing} declare composes edges`)
if (unresolved.length) {
console.log(` · ${unresolved.length} composes ref(s) resolve to no known bundle/profile. Reported, NOT treated as "not a composite" — a typo and a plain child look the same from here:`)
for (const u of unresolved) console.log(` ${u}`)
}
if (fails.length) {
console.error(`\n${TAG}: FAIL`)
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`${TAG}: PASS — every profile lands on a real tier 1-5, and every declared tier agrees with its own composes edges.`)

View file

@ -396,6 +396,48 @@ function composesAMapLeaf(dir, profileName, file) {
* composesAMapLeaf reads (see its point 1) but returns the author's data itself rather than
* a map-ness boolean derived from it — the manifest needs the edges, not just the verdict.
*/
/**
* Derive the composition tier from the `composes` edges, and refuse to disagree with an author.
*
* ★ WHY DERIVE AT ALL (Driver, 2026-09-27). Tiers 3-5 were AUTHORED ONLY — discover.mjs infers just
* `single` and `parameterized`, so a composite with no meta.json shipped as capability 1. That is not
* hypothetical: research-dashboard did exactly that while nesting ten views, and the warning in
* detectProfileType exists because of it. A missing file is indistinguishable from a deliberate choice.
*
* THE RULE, measured against all 25 bundles before it was written: a profile that composes anything is
* cap-4 (nested-elements-mfe); if any of the things it composes is itself cap-4, it is cap-5
* (nested-mfe-spa). Keyed per PROFILE, and refs are accepted in BOTH published forms — 'bundle/profile'
* (doxa-maps-spa) and a bare 'bundle' (plugin-studio). Getting either of those wrong is not academic:
* keying on the bundle derived 11 false tiers, and resolving only the slashed form demoted plugin-studio
* from 5 to 4. With both handled: 10 composing profiles, 10 agree, 0 disagree.
*
* AN EXPLICIT type STAYS AUTHORITATIVE AND MUST AGREE. It is not overridden — it is CHECKED. A
* declaration that contradicts the structure is the interesting case: either the meta is stale or the
* composes list is, and both are worth a loud build failure rather than a silent pick between them.
*
* An UNRESOLVED ref is reported separately and never silently treated as "not cap-4", because a typo in
* composes and a genuinely non-composite child look identical from here.
*/
export function deriveComposedTiers(bundles) {
const byKey = new Map(), byBundle = new Map()
for (const b of bundles) for (const p of b.profiles || []) {
byKey.set(`${b.bundle}/${p.profile}`, p.type || null)
if (!byBundle.has(b.bundle)) byBundle.set(b.bundle, p.type || null)
if (!byBundle.has(p.profile)) byBundle.set(p.profile, p.type || null)
}
const resolve = (ref) => (ref.includes('/') ? byKey.get(ref) : byBundle.get(ref))
const conflicts = [], unresolved = []
for (const b of bundles) for (const p of b.profiles || []) {
const own = p.composesOwn
if (!own || !own.length) continue
const kinds = own.map((ref) => { const k = resolve(ref); if (k === undefined) unresolved.push(`${b.bundle}/${p.profile} -> ${ref}`); return k })
const derived = kinds.some((k) => k === 'nested-elements-mfe') ? 'nested-mfe-spa' : 'nested-elements-mfe'
if (!p.type) { p.type = derived; continue }
if (p.type !== derived) conflicts.push(`${b.bundle}/${p.profile}: meta.json declares "${p.type}" but its composes edges derive "${derived}"`)
}
return { conflicts, unresolved }
}
function readComposes(dir, profileName) {
try {
const metaPath = join(dir, `${profileName}.meta.json`)
@ -1316,6 +1358,11 @@ function scanBundles() {
// GENERATOR-INTERNAL: stripped before the manifest is written (see writeManifestAndHtml)
// so the published manifest shape is unchanged.
extraConfig: readProfileConfig(dir, profileName),
// Generator-internal, stripped with extraConfig at manifest assembly: the profile's OWN
// composes. The published manifest hoists composes to the BUNDLE, which is right for its
// consumers but useless for deriving a tier — a bundle's composite and its plain leaves would
// all inherit the same edges. The tier is a property of the profile that declares them.
composesOwn: readComposes(dir, profileName),
...rest,
}
})
@ -1362,6 +1409,24 @@ function readDestinations() {
}
function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
// ── DERIVE THE COMPOSITION TIER FROM THE EDGES, BEFORE ANYTHING READS THE MANIFEST ──────────
// Every consumer downstream — the gallery's capFor, the studio rail, check:composite-embed-knobs —
// trusts profile.type. Deriving here means a composite can no longer ship understated because its
// author forgot a meta.json, and a stale meta can no longer disagree with the structure in silence.
{
const { conflicts, unresolved } = deriveComposedTiers(bundles)
if (unresolved.length) {
console.warn(`[tree] ${unresolved.length} composes ref(s) resolve to no known bundle/profile — a typo here silently weakens a tier:`)
for (const u of unresolved) console.warn(` ${u}`)
}
if (conflicts.length) {
console.error('[tree] composition tier CONFLICT — a profile\'s meta.json disagrees with its own composes edges:')
for (const c of conflicts) console.error(` ${c}`)
console.error(' Fix the meta.json or the composes list; the generator will not choose between them.')
process.exit(1)
}
}
const manifest = {
schema: 'doxa-maps-manifest@1',
generatedFrom: 'plugin-designs/*/*.vue',
@ -1386,7 +1451,7 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
// Consumers read the manifest as a contract; a new key would be a silent schema change.
bundles: bundles.map((b) => ({
...b,
profiles: b.profiles.map(({ extraConfig, ...p }) => p),
profiles: b.profiles.map(({ extraConfig, composesOwn, ...p }) => p),
})),
}
mkdirSync(root, { recursive: true })

View file

@ -25,6 +25,7 @@
"check:backlog-status": "node internal/check-backlog-status-enum.mjs",
"check:basemap-labels": "node internal/check-basemap-labels-localized.mjs",
"check:capability-citations": "node internal/check-capability-citations.mjs",
"check:capability-derived": "node internal/check-capability-derived.mjs",
"check:capability-exemplars": "node internal/check-capability-exemplars.mjs",
"check:card-deck": "node internal/check-card-deck.mjs",
"check:catalog-currency": "node internal/check-catalog-currency.mjs",
@ -245,7 +246,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
@ -303,6 +304,7 @@
"check:backlog-refs": "a card may not depend on a card that does not exist. A dangling depends_on can never be done, so the card can never become ready and nothing says so — it reads as ordinary blocked work forever. FAILS only for depends_on on an OPEN card; a dangling parent or a stale link on a done card is REPORTED, because failing on filing debt teaches people to delete the reference rather than restore the card. Reads all three phase backlogs — references legitimately cross phases. SKIPs when _qa-cycle is absent.",
"check:basemap-labels": "the BASEMAP's place names follow the reader's language on BOTH engines (library/maps/localize-labels.js): reads the RENDERED country labels at ?lang=en and ?lang=ru — Mapbox via the Standard basemap's place-labels featureset, MapLibre via each symbol's evaluated text — and requires them to flip. Serves this checkout on an OS-assigned port; --dir <tree> for a negative control. Negative-tested: a bundle built before 319064a1 FAILS on both engines.",
"check:capability-citations": "a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
"check:capability-derived": "every bundle lands on a real capability 1-5, never 0, and a DECLARED tier must agree with the composes edges it declares. Tiers 3-5 used to be authored only - discover.mjs infers single and parameterized and nothing else - so a composite whose author forgot a meta.json shipped as capability 1; research-dashboard did exactly that while nesting ten views, and a missing file is indistinguishable from a deliberate choice, so nothing failed. The declaration stays authoritative: it is CHECKED, not overridden, because a meta that contradicts the structure means one of the two is stale and picking silently is how a tier rots. Two traps found by running the rule BEFORE writing it: composes is per-PROFILE at source but hoisted to the BUNDLE in the manifest, so deriving from the bundle row gave a composite's edges to its plain sibling leaves (11 false failures on correct data); and refs come in two forms in one field, 'bundle/profile' and a bare 'bundle', so resolving only the first demoted plugin-studio from 5 to 4. With both handled: 10 composing profiles, 10 agree, 0 disagree. An unresolved ref is REPORTED, never counted as 'not a composite' - a typo and a genuine plain child look identical from here. Negative-tested on the research-dashboard shape (a composite declared single).",
"check:capability-exemplars": "REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",
"check:catalog-identity": "proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
"check:cdn-pins": "every runtime third-party script/stylesheet in shipped code is an exact version listed in library/constants/cdn.js with its SRI hash (no floating ranges). --online re-fetches each and compares the hash.",

View file

@ -1,5 +1,5 @@
{
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element <doxa-maps-spa> renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's \"Inside doxa-maps-spa\" tree shows it). Bundles not placed in a section appear under Experimental automatically. This explicit meta is the ONLY way the Cat-5 tier is declared (discover.mjs never infers it).",
"_comment": "Declares the composability tier. 'nested-mfe-spa' (display alias 'app-shell') = Capability-5: this plugin is the OUTER app shell of the dx-maps app, the Research Gallery. Its single element <doxa-maps-spa> renders a vertical-tab rail whose tabs each mount a REAL, independently embeddable plugin: the shell loads that bundle's script (URL from manifest.json) and creates its registered custom element in the pane. There is no iframe mode. Tab order and grouping live in NAV_MODEL/TAB_DEFS in doxa-maps-spa.vue; `composes` below lists the seven curated tabs as bundle/profile and must be kept in step with TAB_DEFS (check:composite reads it; the studio's \"Inside doxa-maps-spa\" tree shows it). Bundles not placed in a section appear under Experimental automatically. The tier is now DERIVED from the `composes` edges below at tree generation (a profile that composes anything is cap-4; one that composes a cap-4 is cap-5), and this explicit `type` is CHECKED against that derivation rather than being the only source: if the two disagree the build fails, because one of them is stale. discover.mjs still never infers tiers 3-5 on its own.",
"type": "nested-mfe-spa",
"composes": [
"dashboard-plus-grid/dashboard-plus-grid",