Look-up tables and contracts for doxa-maps. For learning, see the
**[tutorial](./tutorial.md)**; for tasks, **[how-to.md](./how-to.md)**; for the *why*,
**[why.md](./why.md)**; for the short "what it is", **[explanation.md](./explanation.md)**.
**Look-up tables and contracts for doxa-maps: one shared library, many independent embeddable bundles.**
Each bundle is one self-contained `.js` file that registers a custom HTML element (`<doxa-map>`,
`<doxa-research-map>`, …) you can drop on any web page. For learning, see the
**[tutorial](./tutorial.md)**; for tasks, **[how-to.md](./how-to.md)**; for the *why*, **[why.md](./why.md)**.
`doxa-maps` is a Vite bundler: **one shared library, N independent embeddable bundles.** Each
bundle is a single self-contained `.js` file that registers a custom HTML element
(`<doxa-map>`, `<doxa-research-map>`, …) you can drop on any web page.
## Quick reference — "I want to…"
| I want to… | Do this |
|---|---|
| **Add a new map** | copy `plugin-designs/template-bundle/` → rename the folder and the tag in `index.js` |
| **Add a screen to an existing bundle** | drop a `.vue` flat in that bundle's folder; request it via `profile-config.profile` |
| **Fix a legend or control for ALL maps** | edit it once in `library/` (`@map`); rebuild |
| **Add a colour strategy** | a new file in `@map/colors/` (shared) or the bundle's `src/colors/` (map-private) |
| **See my change while working** | `bun run dev` → http://localhost:5173/ (the Plugin Studio) |
| **Build everything** | `BUILD_ALLOW=1 bun run build` → `public/doxa-maps/<bundle>/<bundle>.js` |
| **Build just one bundle** | `BUNDLE=<name> bun x vite build` |
| **See a change on the served site** | rebuild, then refresh (the dev server never writes `public/`) |
| **Repoint the API without a rebuild** | set `window.MAP_APP_API_URL` before the bundle loads |
| **Repoint the Mapbox token** | pass `tk` in `profile-config`, or set `window.MAP_TOKEN_URL` / build-config `tokenUrl` |
| **Run a map without a Mapbox key** | `"provider": "maplibre"` in `profile-config` (or `?provider=maplibre`) |
---
@ -14,53 +27,32 @@ bundle is a single self-contained `.js` file that registers a custom HTML elemen
| Zone | Path | Alias | License | You edit it… |
|---|---|---|---|---|
| **Shared library** | `library/` | `@map` | Apache-2.0 | …once; the fix reaches every bundle on next build |
| **Your app-profile** | `plugin-designs/<name>/` | — | yours | …freely; nothing here leaks to other maps |
| **The build machine** | `internal/` | — | Apache-2.0 | …never — it's the bundler's own scripts |
| **Shared library** | `library/` | `@map` | Apache-2.0 | …rarely, and only for reusable, parameterized code; a fix reaches every bundle on the next build |
| **Your plugin** | `plugin-designs/<name>/` | — | yours | …freely; nothing here leaks to other maps |
| **The build machine** | `internal/` | — | Apache-2.0 | …not as a map author — it is the bundler's own scripts and gates |
**Terminology.** "Profile" is used at two levels: a **bundle / app-profile** is a folder under
`plugin-designs/`; a **profile screen** is one `.vue` file sitting **flat** in that folder (no
`profiles/` subfolder — the entry globs `./*.vue`). A bundle can hold several screens; the host
picks which to render with the `profile` field of `profile-config`.
**Terminology.** An **element** is a custom HTML tag this repo defines, such as `<doxa-map>`; each pasted tag
is one live *instance*. A **bundle** is a folder under `plugin-designs/`. A **profile** (app profile) is one
`.vue` sitting **flat** in that folder; a bundle can hold several, and the host picks one with the `profile`
field of `profile-config`. In class-and-object terms: the element is the class, each tag an instance, the
profile the screen that instance loads.
**Two words first.** An **element** is a custom HTML tag this repo invents, such as `<doxa-map>`: the browser
learns it once, then you can paste it into any page as often as you like; each pasted tag is one live copy, an
*instance* (people also say "web component"). An **app profile** (this repo also says *mounting file*) is one
flat `.vue` in `plugin-designs/<bundle>/` that defines a whole map app — its main component; the settings on the
tag (`profile-config`) name which app profile to load and the shared `ProfileLoader` picks it. In class-and-object
terms: the element class is the class, each tag is an instance, the app profile is the screen that instance loads.
Most repos need one such file per app; doxa-maps builds hundreds of maps from one shared `library/`, so every map
gets its own app profile — and those files come in five shapes:
## The five capabilities (profile types)
**Profile types — the ladder**, smallest first (head words are established terms; the `-element` compounds are
this repo's own; the coined names stay as a.k.a.):
Smallest first. Which bundle is the current example of each: **[reference-capability-exemplars.md](./reference-capability-exemplars.md)**
(generated from `manifest.json`; cite it rather than memory).
| Type (a.k.a.) | Machine key | What it is | How the build recognises it |
|---|---|---|---|
| **single-element** (single) | `single` | one app profile, one map — paste its tag anywhere, set it up in `profile-config`; the default | a flat `.vue` with no `<profile>.meta.json` and no `*.instances.json` in the folder |
| **preset-element** (parameterized) | `parameterized` | one app profile + a small table of named presets; each row is a ready-made map with its settings baked in, with its **own embed page** and sidebar entry — same code, same tag | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }` or a bare array; the build writes one embed page per row at `<bundle>/<profile>/<id>/index.html` |
| **multi-view-element** (monolithic) | `monolithic` | one app profile holding several screens you switch between — tabs or steps — in one `.vue`, shown one screen at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` (or `"multi-view-element"`) |
| **composite-element** (nested-elements-mfe) | `nested-elements-mfe` | an app profile whose template places other app profiles' maps together (stacked or side by side, e.g. a map + a poster wall) and ships the set as one new element — one tag embeds it all | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` (or `"composite-element"`) |
| **app-shell** (nested-mfe-spa) | `nested-mfe-spa` | the outer frame — sidebar, header, navigation — that loads any of the other kinds and switches between them without a reload; the top rung, the full doxa-maps app | a compiled Vue custom element **today** — `plugin-designs/doxa-maps-spa/doxa-maps-spa.vue`, built via `defineCustomElement` like every other rung (its shell layout is `library/layouts/app-wrapper-material/`). `app-wrapper-material/index.html` is now only the dev-time plugin-gallery scaffold, not the shipped shell |
| # | Type (a.k.a.) | Machine key | What it is | How the build recognises it |
|---|---|---|---|---|
| 1 | **single-element** (single) | `single` | one profile, one map; the default | a flat `.vue` with no `<profile>.meta.json` and no `*.instances.json` in the folder |
| 2 | **preset-element** (prop-table) | `parameterized` | one profile + a table of named presets; each row is a ready-made map with its own embed page | the bundle ships `<name>.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `<bundle>/<profile>/<id>/index.html` |
| 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `<profile>.meta.json` → `{ "type": "monolithic" }` |
| 4 | **composite-element** (nested elements) | `nested-elements-mfe` | a profile that places other plugins together and ships the set as one new element | declared: `<profile>.meta.json` → `{ "type": "nested-elements-mfe", "composes": [ … ] }` |
| 5 | **app-shell** (nested SPAs) | `nested-mfe-spa` | the outer frame — rail, header, navigation — that mounts any of the others; e.g. the Research Gallery (`doxa-maps-spa`) and the Plugin Studio | declared the same way; a compiled Vue custom element like every other rung |
> **Which bundle is the capability-N example *right now*?** See
> **[reference-capability-exemplars.md](./reference-capability-exemplars.md)** — generated from
> `manifest.json` by `bun run capability-exemplars` and gated by `bun run check:capability-exemplars`.
> **Cite that file; never cite memory.** This one question has been answered wrongly in three separate
> places — a gate, this README's capability captions, and the `/plugin-designer` skill — because a
> folder's apparent simplicity does not decide its tier. `doxa-vision-card` looks like the lightest
> thing in the repo and is **capability 2**, since it ships an `*.instances.json`.
Composite kinds are always **declared** in `<profile>.meta.json` (`{ "type": …, "label": … }`, either spelling —
see `TYPE_ALIASES` in `internal/plugin-gallery/discover.mjs`) — the build never guesses them from source (tag-scanning
produced false positives). Discovery is one pure module,
`internal/plugin-gallery/discover.mjs`; `bun run dev` renders its result as the **plugin gallery** at `/__gallery`
(the same generated page the build writes to `public/__gallery/index.html` — see "Plugin gallery" under Build
scripts: a list grouped by bundle, every map deep-linkable at `#/<bundle>/<profile>[/<instance>]`), and
`bun run smoke:gallery` proves every element actually boots **inside that archived gallery page** — not in the
studio, which `bun run smoke:studio` covers in ~5s. **`/` itself serves the plugin studio** in both trees, not
the gallery.
**Tiers 3–5 are always declared, never guessed** (either spelling; see `TYPE_ALIASES` in
`internal/plugin-gallery/discover.mjs`). A folder's apparent simplicity does not decide its tier: an
`*.instances.json` makes a bundle capability 2 however light it looks.
---
@ -73,27 +65,28 @@ doxa-maps/
│ ├── ProfileLoader.vue ← the ONE component that reads profile-config & loads a screen
| `assetsBase` | data-driven profiles fetch `${assetsBase}/tables/*.json` and `${assetsBase}/boundaries/*.geojson`**before** creating their map. Unset, it is `./assets` relative to the host page; if the host doesn't serve that path the map is never created. Generated pages set it for you; any other host must point it at a copy of `public/assets/` |
| `shareUrl` / `embedBaseUrl` | the **Share ladder** (`library/utils/embedBase.js`): `shareUrl` > the profile's embed page joined to `embedBaseUrl` > `window.MAP_EMBED_BASE_URL` > the root derived from the bundle's own script URL (so a dropped tree works on any host) > the page's own location |
- **Share ladder** (`library/utils/embedBase.js`) — what the Share button links and iframes:
`shareUrl` (a whole url, e.g. the plugin gallery's own deep link) > the profile's **tree-relative**
embed page (`<bundle>/<profile>/index.html`, the `embedPath` prop or `shareEmbedPath`) joined to an
embed base — `embedBaseUrl` > `window.MAP_EMBED_BASE_URL` > the root the built bundle derives from
its **own script url** (two segments above `<bundle>/<bundle>.js`, so a dropped tree works on any
host/CDN with no config) > the page's own location. Never hardcode `/js/doxa-maps/…` in a profile.
- `profile`**must** match a flat `.vue` filename in the bundle. Mismatch → an inline
"Profile not found" error box listing the available names.
- `tk` is supplied at runtime so the token is never compiled in (dev uses `.env`'s
`VITE_MAPBOX_TOKEN`).
- `instanceId` scopes cross-instance window events so multiple embeds never interfere.
- `dataSource` is handed to `DataSourceManager` (`csv` | `api` | `rest-api`).
- `ProfileLoader``provide()`s to descendants: `mapboxToken`, `dataSource` (both **computed
refs** — `unref` before use), `colorSet`, `profileConfig`, `instanceId`, `lang`, and the
would bleed between embeds). Each bundle registers a **shadow-DOM** element with its own styles, Pinia and
i18n, under a **distinct** tag, since `customElements.define()` is one-shot per name.
---
@ -160,156 +138,69 @@ each bundle registers a **distinct** tag.
| Script | What it does |
|---|---|
| `bun run dev` | Vite dev server + HMR (default `http://localhost:5173`, or the next free port — read the terminal). `/` is the **plugin gallery** of every element — `internal/generate-gallery.mjs` in mode `dev` over `internal/plugin-gallery/{discover,dev-plugin}.mjs`; every bundle is also served at its own html path. Dev-only routes: `/__plugin-gallery/manifest.json` (the manifest-shaped model the page fetches), `/__plugin-gallery/registry.json` (the discovery registry the smoke tools read), `/assets/*` (served from `public/assets/`, else `plugin-designs/*/data/`). **Writes nothing to `public/`** — no HMR across that boundary. |
| `bun run smoke:gallery` | Opens every element by deep link in Firefox **on the ARCHIVED gallery page at `/__gallery`** and proves it boots there: element defined, shadow DOM populated, no error card, a WebGL canvas for map profiles (checked through nested shadow roots), no HTML-for-JSON data responses. `--json <file>` writes the evidence; exit 1 on any failure. It is the only gate watching the archived surface — it found the `tree` base-join bug (404 `/manifest.json`) that no other gate could see, because the studio carries `tree-base` and the gallery did not. **It does NOT cover the studio**: that is `smoke:studio` (~5s), and `/` serves the studio in both trees. |
| `bun run smoke:share` | The share round-trip gate (Firefox): opens a host page (`--plugin-gallery` = every canvas element of the dev plugin gallery; `--url <page> --tag <element>` = the website or any CDN page), clicks Share, reads the URL, fetches it, reopens it in a fresh page and asserts the SAME custom element mounts with a WebGL canvas. `--json <file>`; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). |
| `bun run check:public-hygiene [<dir>] [--strict]` | Proves a served folder is fit to publish: scans for local paths, internal folder names, dev-server URLs, JWT-shaped tokens, `.env`/token files, source maps, documentation and staging/preview pages, and prints an inventory (bundles, leaf pages, assets by folder). Under `--strict` any root entry outside the drop-in contract is a finding; the allowed set is READ FROM `vite.doxa-maps-build-config.json` → `dropin.include` (`index.html`, `__gallery/`, `assets/`, `doxa-maps/`) rather than restated here, so the gate cannot disagree with the contract. Exit 1 on a finding. Run it on the website's drop-in before any public push. |
| `bun run sync:dropin <dest> [--from-config \| --only a,b] [--prune]` / `bun run deploy:dev` | Syncs the built `public/` into a host checkout (e.g. the website's `public/embed/doxa-maps-build/`). **The drop-in contract** lives in ONE place — `vite.doxa-maps-build-config.json` → `dropin.include`, four root entries: `index.html` (the **plugin studio** host page generated by `internal/generate-tree.mjs`, which mounts `<plugin-studio>`), `__gallery/` (the archived bundle-index gallery generated by `internal/generate-gallery.mjs`, listing every bundle/profile/instance and mounting the chosen one), `assets/` (data) and `doxa-maps/` (the bundle folders + `manifest.json`). `--from-config` copies only those, `--prune` removes everything else in the destination, so a host carries exactly the served web-root and nothing else at its root. `dropin.exclude` (same file) lists built files inside the allowlist that no page fetches — the full people-groups CSV, the tables only internal scripts read, the table index; they stay in `public/` for the scripts, are skipped by `--from-config` (counted as "excluded (not shipped)") and removed from the destination if a copy is there. Without an allowlist it is a plain full mirror. Never copies credentials, docs or staging pages; `--protect a,b` keeps host-owned files; `--dry-run` first; prints a copied/unchanged/deleted/pruned/excluded summary and the resulting top-level tree. `deploy:dev` = `build` → sync `--from-config --prune` into the website (`DOXA_DROPIN_DEST` overrides the path) — the only sanctioned way a map changes on the site; then commit in both repos. |
| `bun run build` | `clean` → one `vite build` per `plugin-designs/*/` folder → `tree` → `check:drop` → `check:parse` → `verify` → `gallery` (writes `public/__gallery/index.html`, the archived bundle-index gallery, from the manifest). The root page `public/index.html` — the studio host — is written by `tree`. |
| `bun run build:bundle` | Builds ONE bundle (needs `BUNDLE=<name>`). |
| `bun run build:watch` | Rebuilds into `public/` on every change (integration loop). |
| `bun run clean` | Wipes + recreates the `public/<name>/` output tree. |
| `bun run tree` | Writes `manifest.json` + self-contained staging HTML, then copies the tree to every build-config `paths` destination. |
| `bun run verify` | Data-integrity + shipped-bundle + network-policy guards (fast, no browser). |
| `bun run verify:full` | + rendered posters and live apps in Firefox. |
| `bun run dev` | Vite dev server with hot reload at http://localhost:5173. **`/` is the Plugin Studio**; `/__gallery` is the archived bundle index. Serves every bundle at its own html path and `/assets/*` from `public/assets/`. **Writes nothing to `public/`.** |
| `BUILD_ALLOW=1 bun run build` | `clean` → one `vite build` per `plugin-designs/*/` folder → `tree` → static gates. Refuses (exit 75) without `BUILD_ALLOW=1`, because it wipes `public/doxa-maps/` first. Takes a lock; `BUILD_LOCK_WAIT=600` queues instead of failing |
| `BUNDLE=<name> bun x vite build` | builds ONE bundle without cleaning the rest of the tree |
| `bun run build:watch` | rebuilds into `public/` on every change |
| `bun run clean` | wipes and recreates the `public/<name>/` output tree |
| `bun run tree` | writes `manifest.json` + the leaf pages, then copies the tree to every build-config `paths` destination |
| `bun run verify` · `bun run verify:full` | data-integrity, shipped-bundle and network-policy guards (fast, no browser) · plus rendered posters and live apps in a browser |
| `bun run sync:dropin <dest> [--from-config] [--prune]` | mirrors the built `public/` into a host checkout. The allowlist lives in ONE place, `vite.doxa-maps-build-config.json` → `dropin.include`; `dropin.exclude` lists built files no page fetches. Never copies credentials, docs or staging pages; `--dry-run` first |
| `bun run check:public-hygiene [<dir>] [--strict]` | proves a served folder is fit to publish: no local paths, internal names, dev URLs, token-shaped secrets, source maps or staging pages. Run it on a drop-in before any public push |
**Plugin gallery.** One generator, two modes. `internal/generate-gallery.mjs` exports
`renderGallery(model, { mode })`, a pure function from a manifest-shaped model (bundles → profiles →
instances, with `scriptUrl` / `iframeUrl` / `embed` / `integrity`) to the whole page: the list, the stage,
`#/<bundle>/<profile>[/<instance>]` deep links, Open page, Copy embed, the token ladder, lazy engine load,
the phone layout and the baked fallback. Mode `build` (`bun run gallery`) renders
`public/doxa-maps/manifest.json` into `public/__gallery/index.html` — ARCHIVED beside the root, which is now
the plugin studio. Mode `dev` is what `bun run dev` serves at `/__gallery`: the
same page over `galleryModel(discoverSpaRegistry())` (`internal/plugin-gallery/dev-plugin.mjs`), differing
only in the loader (it imports `/plugin-designs/<bundle>/index.js` instead of adding the built IIFE), the live
model route (`/__plugin-gallery/manifest.json`), the `/assets/*` dev route and the composability ladder's
"?" popovers in the header — kept out of the built page to keep it lean. Nothing about the gallery is
written twice.
**How a build works.** `vite.config.js` registers every `plugin-designs/` subfolder that doesn't start with
`_`/`.` and has **both**`index.html` and `index.js`. One `vite build` builds **exactly one** bundle (selected
by `BUNDLE=<name>`) as a self-contained **IIFE**; the full build loops every folder. Sizes differ with what each
bundle imports (simple ≈ 420 KB, research ≈ 1.3 MB).
**How a build works.** `vite.config.js` is multi-entry by discovery: at build time it
registers every `plugin-designs/` subfolder that (a) doesn't start with `_`/`.` and (b) has
**both** `index.html` and `index.js`. One `vite build` builds **exactly one** bundle, selected
by `BUNDLE=<name>`, so the output is a self-contained **IIFE** (`inlineDynamicImports: true`).
`bun run build` loops every folder. Each bundle only bundles what its screens import, so sizes
differ (simple ≈ 420 KB, research ≈ 1.3 MB).
**Where output goes.** One bundle folder → one file: `public/doxa-maps/<bundle>/<bundle>.js`.
CSS is injected by JS (`cssCodeSplit: false`), so there is no separate `.css`. The tree root
name comes from `vite.doxa-maps-build-config.json``name` (default `doxa-maps`).
`emptyOutDir: false` keeps sibling bundles intact when you rebuild one.
**Shadow-style comments are stripped from production bundles.** Component CSS is injected
from `useShadowStyles(\`…\`)` template literals that ship verbatim, so `internal/vite-plugin-strip-shadow-css-comments.mjs`
(build only, `enforce: 'pre'`) removes the `/* … */` blocks inside those literals and collapses the blank lines
they leave; a literal with `${…}` is left untouched with a warning, and dev mode keeps every comment.
**What a built bundle folder contains.** Beyond the IIFE, `bun run tree` generates an
`index.html`**per bundle** (staging/showcase), **per profile**, and **per instance** —
each profile/instance page is a standalone, directly linkable/iframe-able map — plus
`public/doxa-maps/manifest.json` with ready-made `embed.iframe`/`embed.script` snippets for
every profile. Full design: [architecture/embeddability.md](./architecture/embeddability.md).
**Preset instances.** A `<profile>.instances.json` beside a parameterized profile
(`{ profile, apiBaseUrl?, instances: [{ id, title?, props }] }`) makes the build emit one
standalone page per instance at `<bundle>/<id>/index.html`. Recipe:
**Where output goes.** One bundle → one file: `public/doxa-maps/<bundle>/<bundle>.js`, with CSS injected by
JS (no separate `.css`). Beyond it, `tree` writes a standalone, iframe-able `index.html` per bundle, per
profile and per preset instance, plus `public/doxa-maps/manifest.json` with ready-made
`embed.iframe` / `embed.script` snippets for every profile. Full design:
**Bun is required.** `bun.lock` is the committed lockfile; `bun x vite` runs project-local
Vite. `npm run …` resolves the same scripts but Bun must be on PATH.
**Bun is required.** `bun.lock` is the committed lockfile; use `bun x vite`, not `bunx`.
### Standalone gates — the SILENT failure classes
### Standalone gates — run these before publishing
These are **not** in `build:unlocked`, deliberately: each checks something a fresh build would mask,
so running it inside the build would make it unfalsifiable. Run them before publishing.
These live **outside** the build on purpose: each checks something a fresh build would mask, or needs a
browser or network.
| Script | Catches | Why it cannot live in the build |
|---|---|---|
| `bun run check:composite` | a composite (cap-4/cap-5) still shipping an OLD copy of a child it nests — a one-bundle rebuild does NOT recompile its parents | a full build makes every composite fresh *by construction*, so it could never fail there |
| `bun run check:shots` | a README screenshot that predates the bundle it shows — **a stale screenshot looks exactly like a current one** | needs a browser to repair (`bun run shots`), which CI does not have |
| `bun run check:library-index` | `documentation/reference-library-index.md` fallen behind `library/` — its header promises it "can't drift", which is only true if regenerated (`bun run library-index`) | a fresh checkout gives every file the same mtime, so mtime comparison always passes in CI (CI regenerates + diffs instead) |
| `bun run check:done-evidence` | a backlog card marked `done` with no bluf **and** no fields — a status word the next team cannot reconcile against the code | reads `_qa-cycle/`, which is not in any repo CI clones |
| `bun run verify:gate1` | the Gate-1 chain end-to-end against LIVE data: header filter → composite props → real grid re-filter → count back up | needs network; it is an acceptance test, not a build step |
| `bun run check:catalog-identity` | the live server serving something **other than** the catalog — a stale CDN copy, a half-finished publish, or the login wall. Hashes each live bundle and compares sha384 against the manifest's own `integrity` | needs network egress, which CI cannot guarantee; it SKIPs when unreachable, and only "reached and wrong" fails |
| `bun run check:catalog-currency` | the live catalog being **an older build** than yours (identity passes on a catalog three publishes behind), **and** a locally-built bundle that is **absent** from the catalog — the "nothing was lost in the merge" guarantee | needs network egress; SKIPs when the catalog cannot be reached at all, because an offline box must not fail a build |
| `bun run smoke:embeds` | an element that **builds, lists in the manifest, and renders nothing** — a thrown `setup()` removes the subtree silently, so every build-time gate still passes. Mounts every profile page and asserts real shadow content | needs a browser; CI has none |
| `bun run verify:map-paint` | a map that **lays out but never paints** — Mapbox creates a full-size `<canvas>` and only *then* tries for a GL context, so "canvas is 1440×900" is evidence of layout, never of paint. Asserts a live GL context + attribution | needs a browser **launched with the SwiftShader flags** (the MCP/default browser here has no GL at all) |
| `bun run verify:5-caps` | a capability tier that no longer has a working exemplar — the 5×5 product claim going stale | needs a browser + a Mapbox token |
| `bun run check:quoted-scripts` | a **doc that quotes a command which has since changed** — an omitted step in a quoted chain is invisible: there is no wrong character to notice, just a check the reader believes is in the chain | compares docs against `package.json`; cheap, but it guards documentation rather than build output |
| `bun run check:capability-exemplars` | `documentation/reference-capability-exemplars.md` drifting from the manifest's real tiers (regenerate with `bun run capability-exemplars`) | the table is generated *from* the manifest, so running it mid-build would compare a file against the thing that just wrote it |
| `bun run check:dep-names` | a DECLARED DEPENDENCY THAT IS NOT A REAL PACKAGE NAME. In `build:unlocked`. Checks two things offline: the name resolves under `node_modules/`, AND the installed package's own `name` field equals the declared key — the second catches a name that resolves to a DIFFERENT package, which a path-only check misses. SKIPs honestly when `node_modules/` is absent. Found the hard way 2026-09-22: a folder rename rewrote `@capacitor/{android,cli,core}` to `@deployment/capacitor/*`, so **`bun install` failed in a fresh clone** while everything here passed, because the real packages were already installed from before the rename. |
| `bun run check:no-abs-paths` | a MACHINE-SPECIFIC PATH in tracked source — a `/home/<user>/` prefix, its macOS equivalent, or the Windows user-profile form. It is in `build:unlocked`, listed here because the failure it prevents is a HAND-OFF failure: an absolute path works perfectly for whoever wrote it and breaks only on the inheritor's machine, so no test run by its author can catch it. Scans `git ls-files` (the set a clone receives), which excludes node_modules and build output without an ignore list. Measured 2026-09-22: the repo was clean BY ACCIDENT — a planted `/home/…` line passed check:public-hygiene, check:parse and check:quoted-scripts, because public-hygiene scans the SERVED tree, not source. |
| `bun run check:settled` | measuring or publishing a tree **mid-build** — a build's `clean` step `rm -rf`s `public/doxa-maps`, so samples taken seconds apart give opposite answers | it exists to be run *around* builds, not inside one |
| Script | Catches |
|---|---|
| `bun run smoke:embeds` | an element that **builds, lists in the manifest, and renders nothing** — mounts every profile page in a browser |
| `bun run smoke:studio` | the Plugin Studio at `/` not mounting every plugin (~5 s, needs `bun run dev`) |
| `bun run check:composite` | a composite still shipping an OLD copy of a child it nests — a one-bundle rebuild does not recompile its parents |
| `bun run verify:map-paint` | a map that **lays out but never paints** — asserts a live GL context and attribution |
| `bun run verify:5-caps` · `bun run verify:interop` | a capability tier with no working exemplar · cap-4 plugins not interoperating through props and events |
| `bun run check:shots` | a README screenshot shot from a different bundle than the one built now (compares a recorded bundle hash) |
| `bun run check:shot-distinctness` · `check:shot-fidelity` | two screenshots showing the same view · a machine that cannot render what a shot must show (e.g. no emoji font) |
| `bun run check:library-index` · `check:capability-exemplars` · `check:quoted-scripts` | a generated doc drifting from `library/`, from the manifest, or from `package.json` |
| `bun run check:catalog-identity` · `check:catalog-currency` | a live host serving something other than your build (SRI hash comparison) · an older build, or a bundle missing from it |
| `bun run check:settled` | measuring or publishing a tree **mid-build** (a build's `clean` step empties it) |
| `bun run check:mobile` · `check:tauri` · `check:wp-embed` | the Capacitor sync, the Tauri config contract, the WordPress embed markup — deploy methods 2, 5 and 4 |
| `bun run smoke:gallery` · `smoke:share` | the archived `/__gallery` page · share links round-tripping to the same element |
| `bun run check:a11y-button-names` | an icon-only control with no accessible name |
### Before you run the list — three gates need a PRECONDITION, or they look broken
A bare `bun run <gate>` is not always meaningful, and the failure text does not always say so.
Measured 2026-09-22, by running each one:
**Some gates need a precondition, or they look broken.** Read the message before trusting the exit code:
| Gate | Needs | What a bare run does |
|---|---|---|
| `smoke:gallery` (and `smoke:playground`, its alias) | a dev server: `bun x vite` on **:5173** (`bun run dev`'s own port) | exits non-zero with *"cannot read http://localhost:5173/__plugin-gallery/registry.json — is `bun x vite` running?"* — a **precondition**, not a defect. It audits the ARCHIVED `/__gallery` page; `--page` re-aims it, `--url` stays at the server root where the registry lives |
| `smoke:share` | `--url <page>` arguments, a served tree, a token, and **`--browser chromium` on this box** | prints `usage:` and exits non-zero; with default Firefox it stops with "Verified on a BLANK page, so it is the engine/box, not the product" and names the chromium flag itself |
#### ★ WRITING A NEW GATE: you cannot `import()` plugin or library source into plain Node
This is the trap that will bite the next gate author, so it is written here rather than left to be
rediscovered. Plugin and library source is written for **Vite**, not for Node, and it fails two
different ways — counted 2026-09-22, not estimated:
| Vite-only feature | Where | What plain `node` does |
|---|---|---|
| the **`@map` alias** (`@map/ProfileLoader.vue`) | resolved by `vite.config`, not by Node | `Cannot find package '@map/ProfileLoader.vue'` — this is what you hit **first**, before any glob |
| **`import.meta.glob`** | **18 files**: 11 of the 20 plugin `index.js`, plus `library/ProfileLoader.vue`, `library/i18n/index.js`, `library/composables/useShadowStyles.js`, `library/colors/_registry.js`, and 3 more | `TypeError: (intermediate value).glob is not a function` |
`import.meta.glob` is a **build-time** feature: Vite statically replaces it with real imports while
bundling. Plain `node` leaves it undefined, so the module throws the instant it is evaluated:
```
TypeError: (intermediate value).glob is not a function
at library/colors/_registry.js:26
```
The glob failure is the nastier of the two because it can arrive **transitively**, from a module you
never named — you import a config, the config imports a colour strategy, and the registry throws.
Measured 2026-09-22: a new `check:facet-cascade` gate did `await import()` on the dashboard config,
which transitively reached the colour registry, and crashed — **while already wired into
`build:unlocked`**, so `bun run build` failed for a reason that had nothing to do with the code being
built. The library file was correct and pre-existing; the defect was entirely in the gate reaching it.
So when a gate needs to exercise real plugin logic, pick one:
| Approach | When | Cost |
|---|---|---|
| Import only the **leaf module** under test | the logic is in a plain `.js` with no Vite features (most composables are) | cheapest and most reliable — keep the dependency cone small |
| Test against the **BUILT bundle** over `bun run serve` | the logic only exists after bundling, or needs the DOM | needs a browser + a served tree, so it belongs in `verify:browser-all`, **not**`build:unlocked` |
| Parse the **source text** instead of executing it | you are asserting a structural property (a rule exists, a field is declared) | cannot catch runtime behaviour — say so in the gate's header |
**Two hard rules that follow.** A gate goes in `build:unlocked` only if it runs clean in plain Node
with no browser and no network — everything else is a standalone or browser gate, the way
`check:a11y-button-names` and `check:composite` are handled. And **run the gate before wiring it in**:
a broken gate inside the build chain is worse than no gate, because it goes red for the wrong reason
and trains whoever hits it to skip the step.
**Other runtime constraints on the build box**, all of which have produced a wrong answer at least
once: Node is **v20** (no `fs.glob`, which is 22+); `find` is **bfs**, which *rejects* a relative
`-newermt '-45 seconds'` and errors to stderr while printing nothing — use `bun run check:settled`,
or an absolute `$(date -u -d '-45 seconds' +%Y-%m-%dT%H:%M:%SZ)`; there is **no hardware WebGL** (judge
maps with `verify:map-paint`'s SwiftShader path, never by expecting a painted basemap); and `bunx`
does not exist here — use the spaced `bun x`.
**Copy-paste: the full share round-trip over every plugin that has a Share button.** Assembled and run
2026-09-22 — **8/8 pass**. Without this recipe an inheritor has to work out the server, the token and the
eight `--url` flags from scratch, which is why the gate had never been run across all of them.
| `smoke:gallery` | a dev server on :5173 (`bun run dev`) | exits non-zero: "cannot read …/registry.json — is `bun x vite` running?" |
| `smoke:share` | `--url <page>` arguments, a served tree, a token, `--browser chromium` | prints `usage:` and exits non-zero |
| `smoke:embeds` · `verify:map-paint` · `verify:5-caps` | `MAPBOX_TOKEN` in the environment (below) | **degrades rather than fails**: map profiles time out, which looks like broken plugins and is not |
```sh
cd doxa-maps-plugin-studio
( node internal/serve-strict.mjs --port 4950 & ) # serve the built tree, LOOPBACK ONLY
# ★ Take the token from the BUILD CONFIG, not from doxa.life/api/maps/token — see the note below.
# The token every map gate wants: read it from the build config (it works from any origin).
> `country-posters-map`, `doxa-map`, `gospel-resources-map` — each matching its own plugin, with
> `canvas:y` on all eight. (The two `my-upg-100-list` profiles legitimately share one tag.) Derive the
> plugin list the same way it was derived here rather than pasting it stale:
> `grep -rln ShareButton plugin-designs/*/*.vue`.
| `check:dxlife` | a sibling `root-dx-life/` clone beside this repo | exits **2** — *"clone not found — nothing to check"*. That is a deliberate THIRD value (`0` unchanged · `1` drift · `2` no clone), because this gate exists after a check that returned the PASS value BECAUSE IT FAILED. But `bun run` and any `&&` chain see only zero/non-zero, so it surfaces as FAIL. Measured 2026-09-22 inside a fresh clone: every other browser-free gate passed and only this one went red, for want of a directory an inheritor has no reason to have. It is NOT in `build:unlocked`, so a clone still builds. |
| `smoke:embeds` · `verify:map-paint` · `verify:5-caps` | `MAPBOX_TOKEN` in the environment | **does not fail — it DEGRADES.** Without a token map profiles cannot mount, and `smoke:embeds` reports them as TIMEOUTS. It once read 27/40 for this reason and 40/40 with a token. A timeout here means "no token" far more often than "broken plugin" |
Check the reopened **tags**, not just the pass count: each share URL must reopen its *own* element. List the
plugins fresh rather than trusting this one: `grep -rln ShareButton plugin-designs/*/*.vue`.
```sh
# The token every map gate wants. Read it from the build config: measured 2026-09-23, the config
# token serves tiles (200) from any origin, while the doxa.life endpoint's token returns 403 from
# anywhere but doxa.life. Both are 90-odd-char pk. public keys; only one of them works here.
> **THIS LIMITATION WAS SELF-INFLICTED, and it is fixed — corrected 2026-09-23.** This paragraph
> used to say a blank basemap was simply the cost of working here. It measured the right thing about
> the WRONG TOKEN: the one served by `https://doxa.life/api/maps/token` is URL-restricted to
> `doxa.life`, returning **403** from `127.0.0.1`, `localhost`, `nuxtbase.app` or no Referer. That is
> a property of that token, not of this box — and the command above used to fetch it, so the doc
> diagnosed the blank basemap and then kept prescribing its cause.
>
> The `token` field in `vite.doxa-maps-build-config.json` returns **200** on the identical request
> from any origin. It is a `pk.` public key baked deliberately (a project decision, 2026-09-21), protected
> by URL restrictions rather than secrecy; `check:public-hygiene` allows `pk.` and fails the build on
> `sk.`/`tk.`. So the SHELL COMMANDS above — the ones that curl a token and hand it to a gate as
> `MAPBOX_TOKEN` — now work where they previously fetched a token that 403s.
>
> **★ BE PRECISE ABOUT WHAT THIS FIXES, because an earlier draft of this very paragraph overstated
> it.** Measured 2026-09-23 with the doxa.life token: curl returns **403** with no Referer, **403**
> with `Referer: http://127.0.0.1:4173/`, and **200** with `Referer: https://doxa.life/`. So the
> restriction is real and it does block the curl path. But a browser gate loading a leaf still fetched
> **9 `/v4/` tiles with 0 failures** — because the page resolves its token through a LADDER and the
> built bundle carries the working baked token, so the `?tk=` rung is not necessarily what the map
> ends up using. The fix is to the SHELL commands; the browser gates were never the ones affected.
>
> Two corrections that came out of measuring this, both worth inheriting. First, the basemap here is
> **Mapbox Light** — pale land, grey water, minimal detail so the choropleth dominates. Looking at a
> screenshot I briefly called it "not painting" because I expected terrain and roads; the country
> labels and borders ARE the basemap. Judge tile loading by the `/v4/` response count, not by whether
> the image looks like a road map. Second, `verify:map-paint` passes under BOTH tokens, because it
> asserts a live GL context and attribution rather than loaded tiles — so it is not the signal to
> consult about whether tiles arrived.
**Plugin and library source is written for Vite, not Node** — `import()`ing it from a plain Node gate fails:
> **★ AN EXIT CODE IS NOT A VERDICT UNTIL YOU HAVE READ THE MESSAGE.** Two of these read as FAIL in a
> batch run and are neither passing nor failing — they never started. A checklist that turns a
> precondition into a red line trains its reader to ignore red lines, which is how a real failure
> gets skipped.
The rest, grouped by what they guard — same rule (outside the build, run before publishing):
| Script | Catches |
| Vite-only feature | What plain `node` does |
|---|---|
| `bun run smoke` · `bun run smoke:gallery` · `bun run smoke:share` | the built leaf pages, the ARCHIVED `/__gallery` page, and share-link round-tripping — each needs a browser. The studio at `/` is `bun run smoke:studio` |
| `bun run check:a11y-button-names` | an icon-only control with no accessible name (needs a browser to read the computed name) |
| `bun run check:capability-citations` | the README claiming a capability the manifest does not back |
| `bun run check:dxlife` | **any edit to the read-only DX-life clone** — the standing law that tree is never written |
| `bun run check:mobile` · `bun run check:tauri` · `bun run check:wp-embed` | the Capacitor sync, the Tauri config contract, and the WordPress PHP embed — deploy methods 2, 5 and 4 |
| `bun run verify:full` | the research corpus end-to-end |
| **`import.meta.glob`** (used across plugin entries and the library registries) | `TypeError: (intermediate value).glob is not a function` — often arriving **transitively**, from a module you never named |
> **★ A STANDALONE GATE ONLY FIRES IF SOMEONE REMEMBERS IT — so this table IS the mechanism.**
> Measured 2026-09-22: **26** gates live outside `build:unlocked`; only **nine** were listed here. Two
> of the unlisted ones (`check:quoted-scripts`, `check:capability-exemplars`) were already FAILING,
> and had been for two sessions, because a build step and a bundle were added without their docs
> being regenerated. Nothing was broken in the product — the gates were doing their job — but nobody
> ran them, because an inheritor reads this table as the list of what to run before publishing.
> **An undocumented standalone gate is indistinguishable from one that does not exist.**
| Approach | When |
|---|---|
| Import only the **leaf module** under test | the logic is in a plain `.js` with no Vite features (most composables) |
| Test the **built bundle** over `bun run serve` | the logic only exists after bundling, or needs the DOM — a browser gate, not a build step |
| Parse the **source text** | you are asserting a structural property; say in the gate's header that it cannot catch runtime behaviour |
**Why SRI and not an HTTP status.** `https://nuxtbase.app/app/doxa-maps/manifest.json` returns **200**
and is a 2,872-byte HTML **sign-in page** — the app-shell login wall. A status check reads that as
healthy. The public door is `/plugin-assets/doxa-maps/`. Every profile already carries an `integrity`
hash the build computed, so comparing it against the live bytes is a cryptographic identity proof
that costs nothing extra to maintain. Re-measured 2026-09-22: **18 of 18 bundles identity-proven**, and
the live manifest byte-identical to `platform/mfe-index`. (It read "17 of 17" on 2026-09-21; the catalog
has since gained `golden-path-check`.)
> **A dated measurement is not a stale claim — but it still misleads once the underlying number moves.**
> "17 of 17" was true when written and the gate reads the live manifest, so nothing was broken. The
> problem is what a READER concludes: seeing 17 beside a catalog of 18, they cannot tell whether a bundle
> went missing or the sentence simply aged. ★ If you quote a count here, re-run the gate and restate it —
> `bun run check:catalog-identity` prints the current figure in one line.
### The screenshot gates — three different lies a picture can tell
A README image can fail in three ways that all look identical to a passing shot run. `bun run shots`
reports `OK` for every one of them, because it proves a page *loaded* — never that it looks right.
| Script | Catches | In CI? |
|---|---|---|
| `bun run check:shots` | a PNG older than the bundle it shows — **a stale screenshot looks exactly like a current one** | report-only (repair needs a browser) |
| `bun run check:shot-distinctness` | two slots capturing the same view — shipped as "List — page 1"/"page 2", the same picture under two captions | **blocking** (parses one file) |
| `bun run check:shot-fidelity` | this machine cannot *render* what the shots must *show* (no emoji font ⇒ every icon a tofu box) | not yet — reports locally |
The distinctness case is the instructive one: the shot chased a "Next" control and a scroll that
**do not exist** (the list has no pagination), every attempt sat inside `try/catch` so the miss was
silent, and the two PNGs differed by 0.16% render noise — so they were *not* byte-identical and no
checksum would have caught them. Meanwhile a real third profile (`poster-wall`) had no shot at all.
**A slot faking a view it cannot reach, while a genuine artifact goes unshot, is the recurring
shape** — the same one that captioned the product SPA as the plugin-studio.
**`bun run check:quoted-scripts` is the exception that proves the rule above** — it is the one
doc-integrity gate that DOES run in CI (blocking), because it compares two files already in the
checkout: it needs no browser, no network, and no mtime. It asserts that the `scripts` block
quoted verbatim in `documentation/README-SECTION-3-cli-commands.md` still matches `package.json`. That doc
carries its own hand-written plea to re-transcribe after every edit, and drifted anyway — twice.
The second drift omitted `check:public-hygiene` (the secret scan) from the build chain it
documents, which is the dangerous shape: **an omission in a quoted list is invisible**, since
there is no wrong character to notice. `package.json` is what runs, so the fix is always to update
the doc.
`bun run verify:5-caps` proves all five capability tiers mount and render. It needs a token —
`MAPBOX_TOKEN=$(curl -s <token-endpoint>) bun run verify:5-caps` — and it sets **both**`?tk=`
(the top-level element) **and**`window.MAP_TOKEN` (a composite's nested children read the global,
not the query param). Without a token it reports the token outage rather than the capabilities.
**A gate goes inside the build only if it runs clean in plain Node, with no browser and no network — and run
it before wiring it in.** Build-box constraints worth knowing: Node **v20** (no `fs.glob`), and use
`bun x`, not `bunx`.
---
## `vite.doxa-maps-build-config.json` fields
Edit this one file, then `bun run build`.
Edit this one file, then rebuild.
| Field | What it does |
|---|---|
| `name` | Output folder name (default `doxa-maps`). |
| `paths` | **Extra** folders to copy the whole built tree into. Point at a host repo's `public/` → that host serves the maps and becomes the CDN. Empty (the default) ⇒ written to `public/<name>/` only, since `public/` is already the committed web-root. |
| `embedBaseUrl` | The public URL the tree is served from, used to generate **absolute, paste-anywhere** embed snippets. Empty ⇒ relative URLs (position-independent; keeps the drop-portability gate green; required for Capacitor, which loads from `https://localhost`). |
| `tokenUrl` | Endpoint each generated page fetches its Mapbox token from — a URL, **not** a secret. Set absolute (`https://…/api/maps/token`) so a CDN folder and the mobile app both work; endpoint must send CORS for the embedding origins. `window.MAP_TOKEN_URL` overrides at runtime. |
| `token` | Opt-in literal token baked into every page. **Leave empty** — prefer `tokenUrl`. |
| `assetsBaseUrl` | Where the shared asset tree (previews, posters, boundaries) is served from. Empty ⇒ a relative path per page depth (self-contained under one web-root). Set absolute for partner embeds and mobile, where there is no shared web-root to climb to. |
| `name` | output folder name (default `doxa-maps`) |
| `paths` | **extra** folders to copy the whole built tree into — point at a host repo's `public/` and that host serves the maps. Empty ⇒ `public/<name>/` only |
| `embedBaseUrl` | the public URL the tree is served from, for **absolute, paste-anywhere** snippets. Empty ⇒ relative URLs (position-independent; required for Capacitor) |
| `token` | a `pk.` public Mapbox token baked into every page, so a map works on any host. It is public by design — protected by URL restrictions in the Mapbox dashboard, not by secrecy; `check:public-hygiene` allows `pk.` and fails on `sk.` |
| `tokenUrl` | an endpoint each page may fetch its token from (a URL, not a secret); it must send CORS for the embedding origins. `window.MAP_TOKEN_URL` overrides at runtime |
| `assetsBaseUrl` | where the shared asset tree is served from. Empty ⇒ a relative path per page depth; set it absolute for partner embeds and mobile |
"description":"Eight tables covering both halves of one question: every gospel resource with its real URL and how much Scripture it is, and the 3,457 people groups where nothing was found with the pools searched recorded per row, plus bridge languages, city bridges, the deaf gap and access against population growth.",
"description":"Eleven tables covering both halves of one question: every gospel resource with its real URL and how much Scripture it is, and the 1,613 people groups where nothing was found with the pools searched recorded per row, plus translation priority, bridge languages, city bridges, the deaf gap and access against population growth.",
"description":"The gospel map and its tables as one scrolling element: 12,379 people-group pins with the real Scripture, film and Bible-story links for each group's own language, above every resource with its URL and the 3,457 groups where nothing was found, exportable as CSV.",
"element":"gospel-resources-dashboard",
"description":"The gospel map and its tables as one scrolling element: 12,379 people-group pins with the real Scripture, film and Bible-story links for each group's own language, above every resource with its URL and the 1,613 groups where nothing was found, exportable as CSV.",