diff --git a/documentation/reference.md b/documentation/reference.md index d0926f210..fd9d70550 100644 --- a/documentation/reference.md +++ b/documentation/reference.md @@ -1,12 +1,25 @@ # Reference -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 (``, +``, …) 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 -(``, ``, …) 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//.js` | +| **Build just one bundle** | `BUNDLE= 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//` | — | 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//` | — | 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 ``; 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 ``: 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//` 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.): -`single-element` → `preset-element` → `multi-view-element` → `composite-element` → `app-shell`. +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 `.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 `.instances.json` — `{ "instances": [{ "id", "title", "props" }] }` or a bare array; the build writes one embed page per row at `///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: `.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: `.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 `.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 `.instances.json` — `{ "instances": [{ "id", "title", "props" }] }`; one page per row at `///index.html` | +| 3 | **multi-view-element** (monolithic) | `monolithic` | one profile holding several screens (tabs or steps), one shown at a time | declared: `.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: `.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 `.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 `#//[/]`), 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 │ ├── components/ ← map UI: legends/, map-controls/, poster/, dashboards/, drawers │ ├── composables/ ← useMapInstance, useMapData, useMapLayers, … (+ legends/, poster/) +│ ├── layouts/ ← VerticalTabsLayout, StackLayout — how composites compose │ ├── api/ ← DataSourceManager (csv | api | rest-api) + sources.json -│ ├── colors/ ← one color strategy per file, auto-registered by _registry.js +│ ├── colors/ ← one colour strategy per file, auto-registered by _registry.js │ ├── constants/ ← never-change values: mapDefaults.js, zoom.js, poster*.js │ ├── i18n/locales/ ← 11 base locales: ar de en es fr hi it pt ro ru zh │ ├── stores/ ← Pinia: dataStore, mapStore, uiStore (per-instance) │ └── utils/ ← pure helpers (geoUtils, clustering) │ -├── plugin-designs/ ← ALL bundles live here (each subfolder = 1 bundle) +├── plugin-designs/ ← ALL bundles live here (each subfolder = 1 bundle) │ ├── / -│ │ ├── index.js ← bundle ENTRY — globs ./*.vue, registers the web component -│ │ ├── index.html ← Vite html entry = the dev STAGING page +│ │ ├── index.js ← bundle ENTRY — registers the web component +│ │ ├── index.html ← the dev staging page (the build requires it) │ │ ├── .vue ← 1..N FLAT profile screens (no profiles/ subfolder) -│ │ └── src/ ← everything else the map needs (its own colors/, api/, …) +│ │ └── src/ ← everything else only this map needs │ └── template-bundle/ ← minimal copy-me reference bundle │ (folders starting with _ or . are SKIPPED by the build) │ -├── internal/ ← the bundler's own build scripts — never edited by map builders -├── deployment/capacitor/ ← native projects (android/, ios/) +├── internal/ ← the build generator and the verification gates +├── deployment/ ← capacitor/ (mobile) · wordpress/ · src-tauri/ (desktop) ├── public/ ← THE SERVED WEB-ROOT, committed: -│ ├── index.html the app shell -│ ├── doxa-maps// built bundles + manifest.json + staging HTML +│ ├── index.html the Plugin Studio host page +│ ├── doxa-maps// built bundles + manifest.json + leaf pages │ └── assets/ posters & data the maps fetch at runtime ├── vite.config.js ← multi-entry: discovers plugin-designs// folders └── vite.doxa-maps-build-config.json ← output tree name + copy destinations + token settings @@ -103,56 +96,41 @@ doxa-maps/ ## The `profile-config` runtime contract -A bundle exposes ONE attribute, `profile-config` — a JSON string parsed by -`ProfileLoader.vue` at runtime. It is the entire wiring between host page and map, so the same -built bundle re-points to different data/colors/language with **no rebuild**. +**A bundle exposes ONE attribute, `profile-config`** — a JSON string `ProfileLoader.vue` parses at runtime. +It is the entire wiring between host page and map, so the same built bundle re-points to different data, +colours or language with **no rebuild**. ```html ``` -- `assetsBase` — data-driven profiles (connectivity-map, resources-map, cities-map, the poster walls - and data tables) fetch `${assetsBase}/tables/*.json` and `${assetsBase}/boundaries/*.geojson` - **before** they create their map. Unset, it defaults to `./assets` **relative to the host page**; - if the host doesn't serve that path the fetch gets an HTML fallback, `JSON.parse` throws, and the - map is never created (no console error, only the profile's status line). The generated pages and - the shell set it for you (`../../../assets` or the absolute `assetsBaseUrl` from the build config); - any other host must point it at a copy of `public/assets/`. +| Field | What to know | +|---|---| +| `profile` | **must** match a flat `.vue` filename in the bundle; a mismatch shows an inline "Profile not found" box listing the names | +| `tk` | supplied at runtime; dev reads `.env`'s `VITE_MAPBOX_TOKEN` | +| `instanceId` | scopes cross-instance window events so several embeds never interfere | +| `dataSource` | handed to `DataSourceManager` (`csv` · `api` · `rest-api`) | +| `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 (`//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 `/.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 - instance-scoped Pinia stores `uiStore` / `mapStore` / `dataStore`. Descendants **inject**; - never call `useXxxStore()` directly (it would cross-bleed between embeds). - -Each bundle registers a **shadow-DOM** web component via `defineCustomElement`, with its own -ShadowRoot, styles, Pinia, and i18n. `customElements.define()` is one-shot per tag name, so -each bundle registers a **distinct** tag. +**What descendants receive.** `ProfileLoader` `provide()`s `mapboxToken` and `dataSource` (both **computed +refs** — `unref` before use), `colorSet`, `profileConfig`, `instanceId`, `lang`, and the instance-scoped Pinia +stores `uiStore` / `mapStore` / `dataStore`. Descendants **inject**; never call `useXxxStore()` directly (it +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 ` 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 --tag ` = 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 `; exit 1 on any failure — proves REQ-1 ("links work wherever the tree is hosted"). | -| `bun run check:public-hygiene [] [--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 [--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 ``), `__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=`). | -| `bun run build:watch` | Rebuilds into `public/` on every change (integration loop). | -| `bun run clean` | Wipes + recreates the `public//` 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= 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//` 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 [--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 [] [--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, -`#//[/]` 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//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=`) 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=`, 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//.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 `.instances.json` beside a parameterized profile -(`{ profile, apiBaseUrl?, instances: [{ id, title?, props }] }`) makes the build emit one -standalone page per instance at `//index.html`. Recipe: +**Where output goes.** One bundle → one file: `public/doxa-maps//.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: +[architecture/embeddability.md](./architecture/embeddability.md). Preset instances: [how-to.md](./how-to.md#ship-n-preset-variants-of-one-screen-instances). -**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 `` 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//` 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 ` 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 ` 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 ` 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). export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-maps-build-config.json'))['token'])") +``` -B=http://127.0.0.1:4950/doxa-maps -ARGS="" +**Share round-trip over every plugin with a Share button**, from the repo root: + +```sh +( node internal/serve-strict.mjs --port 4950 & ) # serve the built tree, loopback only +B=http://127.0.0.1:4950/doxa-maps; ARGS="" for p in demo-map/demo-map cities-internet/connectivity-map doxa-research-map/research-map \ country-posters/countries-map doxa-simple-map/doxa-simple-map \ gospel-resources/resources-map my-upg-100-list/upg-100-list my-upg-100-list/upg-100-mfe; do @@ -318,138 +209,42 @@ done node internal/share-roundtrip-smoke.mjs $ARGS --browser chromium ``` -> **The pass count alone proves nothing — check the reopened TAGS.** The failure this gate exists to catch -> (an SPA fallback, or a host that strips the `#` deep-link) renders *a* map for every share URL, so every -> target passes while every link is wrong. The discriminating evidence is that the run returned **six -> distinct custom-element tags** — `demo-map`, `cities-internet-map`, `doxa-research-map`, -> `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. -export MAPBOX_TOKEN=$(python3 -c "import json;print(json.load(open('vite.doxa-maps-build-config.json'))['token'])") -``` +### Writing a new gate -> **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 | +| the **`@map` alias** (`@map/ProfileLoader.vue`) | `Cannot find package '@map/ProfileLoader.vue'` | +| **`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 ) 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//` 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//` 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 | **Token resolution ladder:** per-map `tk` prop > `?tk=` > baked `token` > `window.MAP_TOKEN_URL` > `tokenUrl` > same-origin `/api/maps/token`. @@ -457,39 +252,7 @@ Edit this one file, then `bun run build`. ## Data sources -### ★ Persecution-safety — a rule about WHICH FIELDS a profile may carry - -Read this before adding a field or a link to any profile. **Some profiles are deliberately kept free of -gospel/church/believer data, and a profile failing a "should have resource links" check may be OBEYING -that rule rather than missing a feature.** - -The binding instance, stated in the code it governs (`plugin-designs/cities-internet/connectivity-list.vue`): - -> **PERSECUTION-SAFETY:** this profile carries connectivity + population + language only. It has **NO -> gospel-resource, church or believer field and must not gain one.** - -Measured 2026-09-22: all three `cities-internet` profiles have **zero** `href` bindings, and the live leaf -renders 1,567 nodes with **0 anchors**. That is correct. Its `source` field — -`{name: "World Bank Open Data", url: "https://api.worldbank.org/v2"}` — is **API provenance**, not a -user-facing destination, and it is surfaced as a citation paragraph when a region is expanded, which is the -separate promise that header makes ("every figure carries its source, so the numbers can be audited"). - -★ **Why this is written down here and not left in one file header.** A standing work item reads -*"gospel-resources **+ cities-internet** maps must render with REAL resource links"* — one line batching two -profiles whose contracts are opposite. `gospel-resources` genuinely renders 155 real eBible links; taking -the same line literally for `cities-internet` means **adding a gospel-resource field to a profile whose -header forbids exactly that**. A requirement that names two subjects deserves a per-subject check before -anyone drains it. - -> **"Persecution-safe" is used for three different things in this repo** — don't assume one rule. Here it -> means FIELD SEPARATION. In `key-cities/cities-list.vue` it means placename precision ("a wrong placename -> in a persecution-sensitive dataset is the dangerous kind of wrong"). In -> `research-dashboard/.../DashboardDataTable.vue` it means a dependency-free table (no third-party grid). -> Only the first governs what data a profile may hold. - ---- - -Maps read data through `@map/api/` driven by `library/api/sources.json`. Three source types: +Maps read data through `@map/api/`, driven by `library/api/sources.json`: | Type | What | Points at | |---|---|---| @@ -497,32 +260,36 @@ Maps read data through `@map/api/` driven by `library/api/sources.json`. Three s | `csv` | a local CSV parsed at load | the Joshua Project dataset (+ photo fallbacks) | | `api` | a single JSON URL | any plain JSON endpoint | -Country/region polygons are **not** a `sources.json` connection — they render from Mapbox's built-in -`country-boundaries-v1` tileset plus a lazy-loaded WAGF-regions GeoJSON. - -**Adding or changing a source?** Full contract + checklist: +Country and region polygons are not a `sources.json` connection: they come from Mapbox's +`country-boundaries-v1` tileset plus a lazy-loaded WAGF-regions GeoJSON. Adding a source: [architecture/data-sources.md](./architecture/data-sources.md). +### Persecution-safety — which fields a profile may carry + +**Some profiles are deliberately kept free of gospel, church and believer data.** A profile failing a +"should have resource links" check may be *obeying* this rule. The binding instance, in +`plugin-designs/cities-internet/connectivity-list.vue`: + +> **PERSECUTION-SAFETY:** this profile carries connectivity + population + language only. It has **NO +> gospel-resource, church or believer field and must not gain one.** + +Its `source` field (World Bank Open Data) is API provenance shown as a citation, not a user-facing link. +"Persecution-safe" means two other things elsewhere in the repo — placename precision in +`key-cities/cities-list.vue`, a dependency-free table in `research-dashboard` — and only this one governs +what data a profile may hold. + --- - -## Colors +## Colours **Reused by more than one map → shared `library/colors/`. Only your map uses it → your bundle's -`src/colors/`** (merges over the shared set, that bundle only). Filename derives the mode key -(`language-family.js` → `languageFamily`). - -**Writing or overriding a strategy?** Full contract + checklist: -[architecture/color-strategies.md](./architecture/color-strategies.md) · -two homes in depth: [../contributing/COLORS.md](./contributing/COLORS.md). - ---- +`src/colors/`** (merges over the shared set, for that bundle only). The filename sets the mode key +(`language-family.js` → `languageFamily`). Contract: [architecture/color-strategies.md](./architecture/color-strategies.md) · +the two homes in depth: [contributing/COLORS.md](./contributing/COLORS.md). ## Importing from `@map` -Everything under `library/` is importable via the `@map/…` alias (`vite.config.js` maps `@map` -→ `library/`). The import path mirrors the file path: a file at `library//` imports -as `@map//`. Examples: +Everything under `library/` imports via the `@map/…` alias, mirroring the file path: ```js import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue' @@ -531,47 +298,69 @@ import { useMapData } from '@map/composables/useMapData.js' import langFamilyByIso from '@map/data/langFamilyByIso.json' ``` -Prefer subpath imports over the thin `@map` barrel (which exports `MAP_CORE_VERSION` only) so -tree-shaking works. The per-file catalog in [architecture/file-map.md](./architecture/file-map.md) is the -authoritative menu — every `library/` file listed is importable this way. A generated one-line import index can also be produced with -`node internal/generate-library-index.mjs`. - ---- +Prefer subpath imports over the thin `@map` barrel so tree-shaking works. The menu of what exists: +[reference-library-index.md](./reference-library-index.md) (generated) and +[architecture/file-map.md](./architecture/file-map.md) (every file, one line each). ## Going deeper — the architecture folder -Simple lookups live here; the full contracts, catalogs, and wiring detail live in -[`architecture/`](./architecture/): - | Doc | What it holds | |---|---| | [file-map.md](./architecture/file-map.md) | every file, one line each, with the gotcha each carries | | [data-sources.md](./architecture/data-sources.md) | the `api/` seam: connection contract + checklist | | [color-strategies.md](./architecture/color-strategies.md) | the strategy contract + legend wiring | -| [components.md](./architecture/components.md) | shared component catalog + conventions | +| [components.md](./architecture/components.md) | shared component catalogue + conventions | | [i18n.md](./architecture/i18n.md) | the locale contract + add-a-language checklist | | [embeddability.md](./architecture/embeddability.md) | generated per-map pages + manifest — embeddable by default | -| [app-wrappers.md](./architecture/app-wrappers.md) | the SPA shell: `nav.js` + `PROFILES` contracts | +| [app-wrappers.md](./architecture/app-wrappers.md) | the app shell: `nav.js` + `PROFILES` contracts | | [poster-generation.md](./architecture/poster-generation.md) | the poster/preview generator pipeline | | [patterns-and-provenance.md](./architecture/patterns-and-provenance.md) | the two multi-map patterns + provenance | -The **Semantic Tree Legend** component (a reusable hierarchical legend) is documented in -[file-map.md](./architecture/file-map.md#the-semantic-tree-legend-mapcomponentslegendssemantictreelegendvue). - --- -## Quick reference — "I want to…" +## Notes and history -| I want to… | Do this | -|---|---| -| Add a new map | copy `plugin-designs/template-bundle/` → rename folder + 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 / control for ALL maps | edit it once in `library/` (`@map`); rebuild bundles | -| Add a color strategy | new file in `@map/colors/` (shared) or the bundle's `src/colors/` (map-private) | -| Build everything | `bun run build` → `public/doxa-maps//.js` | -| Build just one | `BUNDLE= bun run build:bundle` | -| Preview in isolation | `bun run dev` → `http://localhost:5173/plugin-designs//index.html` | -| See a change on the served site | `bun run build`, then refresh (NOT `dev`) | -| 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` | +The measurements behind the rules above, kept for whoever needs the reasoning. +- **Capability citations (2026-09-22).** "Which bundle is the capability-N example" was answered wrongly in + three places — a gate, the README's captions and an authoring skill — because `doxa-vision-card` looks + like the lightest plugin and is capability 2 (it ships an `*.instances.json`). Hence the generated + exemplars file and `check:capability-citations`. +- **Why gates live outside the build.** `check:composite` could never fail inside a full build (every + composite is fresh by construction); `check:library-index` compared mtimes, which a fresh CI checkout sets + equal; `check:shots` first compared dates, then git commit times, and now a recorded bundle hash, because + a byte-identical retake kept its old commit date and read as stale. +- **The Node-import trap (2026-09-22).** A new gate `import()`ed the dashboard config, which transitively + reached the colour registry's `import.meta.glob`, and crashed — while already wired into the build, so + `bun run build` failed for a reason unrelated to the code being built. 18 files used `import.meta.glob` + at the time. +- **Missing dependency names (2026-09-22).** A folder rename rewrote `@capacitor/{android,cli,core}` to a + path-like name, so `bun install` failed in a fresh clone while every existing checkout passed. Hence + `check:dep-names`, which also checks each installed package's own `name`. +- **Machine-specific paths (2026-09-22).** A planted `/home/…` line passed `check:public-hygiene` (which + scans the served tree, not source); `check:no-abs-paths` scans `git ls-files`. +- **Tokens (2026-09-23).** The `doxa.life` token endpoint serves a token restricted to that domain: tile + requests return 403 without its Referer, so shell-driven gates got blank basemaps. The build-config + `pk.` token returns 200 from any origin, which is why every command above reads it. Browser gates were + never affected: pages resolve their token through the ladder and the built bundle carries the working + one. `verify:map-paint` passes under both tokens (it checks GL + attribution, not tiles), so judge tile + loading by the tile response count, not a screenshot — the basemap is Mapbox Light, which looks bare. +- **Share round-trip (2026-09-22).** 8/8 passed with six distinct reopened tags, each matching its plugin + (the two UPG-100 profiles share one tag). A pass count alone cannot catch an SPA fallback that renders + *a* map for every URL. +- **Why SRI identity, not HTTP status.** A platform's app URL can return 200 with a sign-in page; comparing + each live bundle's bytes against the manifest's `integrity` hash is a cryptographic identity proof that + costs nothing to maintain. Re-run `bun run check:catalog-identity` for today's count rather than quoting + an old one. +- **Undocumented gates (2026-09-22).** 26 gates lived outside the build and only nine were listed; two + unlisted ones had been failing for two sessions. A standalone gate nobody knows about does not run — + which is why the table above is the list to run before publishing. +- **`check:dxlife`.** It exits 2 ("clone not found") on any machine without a sibling DX-life clone — a + deliberate third value that `bun run` reports as a failure. It is not in the build, so a clone still builds. +- **Screenshot distinctness.** Two README slots once showed the same view under two captions: the shot + chased a "Next" control that did not exist inside a `try/catch`, and the PNGs differed by 0.16% render + noise, so no checksum caught them. +- **Persecution-safety (2026-09-22).** All three `cities-internet` profiles had zero link bindings and the + live leaf rendered 0 anchors — correct. A work item once batched "gospel-resources + cities-internet must + render real resource links"; taken literally for `cities-internet` it would add exactly the field its + header forbids.