doxa-maps/docs/how-to.md
doxa-maps-88655c70 2fa4bf992a point docs at library/layouts and fix the stale bunx and provider-ladder claims
The top-level layouts/ directory does not exist; the wrappers live in library/layouts/.
Six references across four docs named the phantom path, including a copy-pasteable
cd command and a README-longform link that resolved to docs/layouts/README.md (a 404).

how-to.md taught bunx for the iOS scaffold. bunx is not on PATH here, which is the
exact failure package.json already documents in cap:sync; point both at the local bin.

useMapProvider.js's own header listed three resolution rungs, omitting ?provider= and
misnumbering window.MAP_PROVIDER. The function implements four. how-to.md was correct.
2026-09-21 10:43:11 +00:00

360 lines
19 KiB
Markdown

# How-to — task recipes
Short recipes for specific tasks. New to the project? Read the **[tutorial](./tutorial.md)**
first, then use this page to look up a task. For contracts and field tables, see
**[reference.md](./reference.md)**.
The three zones govern everything: **reuse `library/` (`@map`), build in
`plugin-designs/<your-map>/`, never touch `internal/`.**
**Pick your task:**
[embed on a site](#embed-a-map-on-a-partner-site) · [link/iframe directly](#link-or-iframe-a-map-directly-zero-effort-embed) ·
[add a map](#add-a-new-map-profile) · [preset variants](#ship-n-preset-variants-of-one-screen-instances) · [imitate a profile](#imitate-an-existing-profile-dont-re-derive-it) ·
[change colors](#change-colors) · [choose a CDN](#where-do-the-maps-get-served-from) ·
[build a SPA](#compose-maps-into-a-spa-the-app-shell) · [mobile app](#build-the-android--ios-app-optional) ·
[swap engine](#swap-the-map-engine-mapbox--maplibre) · [API + token](#set-the-api--mapbox-token) · [agent guardrail](#give-your-agent-the-guardrail) ·
[troubleshooting](#troubleshooting)
---
## Embed a map on a partner site
A built bundle is a plain `<script src>` plus a custom element — no framework, no build step
on the partner's end. A partner page loads the Mapbox peer, then the bundle, then drops in the
element with its own `profile-config`.
```html
<!-- Mapbox peer (once per page, before the bundle) -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- The bundle (relative path = host serves it; absolute https = external CDN) -->
<script src="/doxa-maps/my-map/my-map.js"></script>
<!-- The map. `profile` = your .vue filename. `tk` = the Mapbox token. -->
<my-map profile-config='{"profile":"my-map","tk":"pk.eyJ..."}'></my-map>
```
`profile-config` is read at **runtime**, so the same bundle re-points at different data,
colors, or language with **no rebuild** — just change the JSON on the page. Without a valid
`tk` the bundle loads but the basemap stays blank (see *Set the API + Mapbox token* below).
---
## Link or iframe a map directly (zero-effort embed)
You don't have to use the script tag. **Every profile also builds its own standalone page** —
a bare, full-screen map at:
```
https://<your-cdn>/doxa-maps/<bundle>/<profile>/index.html
```
Link it, share it, or iframe it (perfect for CMSs and tools that only accept iframes, like
Start.me or Notion):
```html
<iframe src="https://<your-cdn>/doxa-maps/doxa-research-map/research-map/index.html"
width="100%" height="600" style="border:none;border-radius:12px;" allowfullscreen></iframe>
```
You never write these snippets by hand — `public/doxa-maps/manifest.json` lists a ready-made
`embed.iframe` and `embed.script` snippet for every profile, and the staging pages show them
copy-paste ready. Design details: [architecture/embeddability.md](./architecture/embeddability.md).
---
## Add a new map profile
The fastest path is **copy `template-bundle` and rename it.** A bundle is one folder under
`plugin-designs/`; its name becomes the bundle name and the output filename. Don't prefix it
with `_` or `.` — those are skipped by the build.
1. **Copy the template.**
```bash
cp -r plugin-designs/template-bundle plugin-designs/my-new-map
```
2. **Rename the element tag.** In `plugin-designs/my-new-map/index.js`, change the
`customElements.define('…', …)` tag to your map's tag. The entry must glob the bundle's
flat profiles and hand them to the loader — it already does this; keep the shape:
```js
const profileModules = import.meta.glob('./*.vue') // FLAT — matches only this bundle's screens
// …app.provide('profileModules', profileModules) inside configureApp…
customElements.define('my-new-map', buildElement())
```
3. **Keep `index.html`.** Vite needs it to discover the bundle; it's also the dev staging
page. At minimum it ends with `<script type="module" src="./index.js"></script>`.
4. **Write your profile screen** `my-new-map.vue`, **flat** in the folder (no `profiles/`
subfolder — the glob is `./*.vue`). The filename (without `.vue`) is the profile name the
host requests via `profile-config.profile`. Compose it from `@map` blocks and read host
inputs with `inject` (`mapboxToken`, `dataSource`, `profileConfig`, `instanceId`, and the
instance-scoped Pinia stores) — never prop-drill, never call `useXxxStore()` directly.
5. **Pick reusable pieces** from the `@map` library index in **[reference.md](./reference.md)**.
Import each with the `@map/…` alias; do not copy their code into your folder.
6. **Build and verify.**
```bash
BUNDLE=my-new-map bun run build:bundle # builds just yours → public/doxa-maps/my-new-map/my-new-map.js
bun run verify # data guards (fast, no browser)
```
A green build is not a working map — the build proves the bundle *compiles*; the guards
prove the data is right and the app renders it. Confirm the pane actually paints; a profile
that fails to mount looks identical to one that mounted and rendered nothing.
> **Two ways to ship several maps from one bundle:** *(A) Parameterized* — one `.vue`, mounted
> many times with different `profile-config` (same `profile`, different data/colors). *(B)
> Multi-profile* — several `.vue` files in one folder, selected by the `profile` field. Both
> ship one script tag. Pick A when maps differ only by config, B when they're genuinely
> different map types that share the same slice of the library.
---
## Ship N preset variants of one screen (instances)
One parameterized profile can emit **many standalone embed pages** — no extra `.vue`. Drop a
`<profile>.instances.json` next to the profile:
```jsonc
{
"profile": "doxa-simple-map",
"apiBaseUrl": "https://…", // optional
"instances": [
{ "id": "prayer", "title": "Prayer Map",
"props": { "instanceId": "embed-prayer", "dataSource": "pray-tools",
"tabs": [{ "id": "prayer", "colorStrategy": "prayer" }] } }
]
}
```
`bun run build` then emits a page **per instance** at `doxa-maps/<bundle>/<id>/index.html` —
each directly linkable/iframe-able with its preset props baked into the page (the base profile
still accepts live props). Instances *split* one profile into many render targets; `tabs`
*nest* many views into one element — different tools.
---
## Imitate an existing profile (don't re-derive it)
You have a map that works and want a second one that behaves almost the same. **Copy the
profile and change parameters in the copy** — do not build it from scratch, and do not add a
`mode` flag to the original.
> **The rule: PROFILES duplicate. COMPONENTS get promoted and parameterized.**
> A profile is cheap — a duplicated profile that reads top-to-bottom beats one profile with a
> flag threaded through 40 branches. A *component* used by two profiles is different: promote
> it to `library/components/` and give it props. Duplicating a profile duplicates
> *configuration* (the part that's supposed to differ); duplicating a component duplicates
> *behaviour* (the part that shouldn't).
Steps:
1. **Find the profile you're imitating — including nested ones.** A `.vue` profile can contain
another full profile inside a tab. Search for the *behaviour* you want, not the app name; if
you copy the wrong level you inherit the wrong thing.
2. **Copy the folder, then the profile file.** `profile` is the **filename**, not the folder.
One folder = one bundle = one element; each flat `.vue` inside is one profile.
3. **Change ONLY named parameters.** If you're typing new logic, you've stopped imitating — go
find the parameter in the original that already does what you're about to write. Count
parameter *sites* by diffing the two files, not by memory.
> **Trap: `onMounted` runs last and overwrites your default.** If the source initializes a
> value in its mount handler, setting it at declaration is not enough — one constant must
> drive both the initial `ref` and the `onMounted` path.
4. **Wire it into the shell registry** (`library/layouts/app-wrapper-material/index.html`, the
`PROFILES` object). A nav entry with no registry entry renders a silent placeholder. Take
`src` from the folder, `tag` from `customElements.define` in `index.js`, `profile` from the
`.vue` filename — never from a guess.
5. **Load data per tab with `fetch`, never `import`.** The single-file IIFE inlines every
import, so a static `import` of a data table inlines it into the bundle (one such import
took two bundles to 2.0 MB and 2.9 MB; per-tab `fetch` brought them to 308 KB and 647 KB).
6. **Verify it mounts.** Open it and confirm the pane paints — don't infer it from a clean
build.
**One-line test:** are you changing a named constant, or writing logic? Constant → you're
imitating. Logic → stop.
---
## Change colors
Colors are **values, not logic** — you edit values, the library owns the mechanism. Which of
two places a color lives follows one rule: **reused by more than one map → shared library;
only your map uses it → your profile.**
**Path A — a shared color (affects every map that uses it).** Edit
`library/colors/<name>.js` (e.g. `religion.js`) and change a hex in the `PALETTE`. The library
auto-discovers every `<name>.js` in that folder, so there's no registration step. Be
deliberate — this changes *every* map using that mode.
**Path B — a color private to your map.** Create
`plugin-designs/<your-map>/src/colors/<name>.js` (copy `template-bundle/src/colors/example-mode.js`
as a starter). Your bundle's `index.js` runs `registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))`
at startup, merging your local files **over** the shared set. The **filename** derives the
mode key (`language-family.js` → `languageFamily`):
- **Same key as a shared strategy → your file overrides it** (for your map only).
- **A new key → your file adds a private strategy** (only your map sees it).
Because each bundle is its own self-contained IIFE with its own copy of the registry, a local
strategy can never leak into another map. The required `export default` shape (the strategy
contract) is in [architecture/color-strategies.md](./architecture/color-strategies.md);
`religion.js` is the canonical example.
---
## Where do the maps get served from?
Your built maps are just files. To show them on other websites, the files need a **public home**
on the internet that any site can pull from — that's what a "CDN" is. **You choose the home, and
there are options:**
- **Your own site (doxa-life).** Put the built maps in doxa-life's `public/` folder — its Nuxt
site serves them, so **doxa-life is the CDN**. No third party.
- **GitHub.** Push the built maps to a public GitHub repo and serve them free through
**GitHub Pages or jsDelivr** — **GitHub is the CDN**.
- **doxa-base's built-in home (Forgejo).** doxa-base ships **Forgejo as a CDN** — push the maps
there and it serves them, with no extra service to set up.
- **Anywhere else.** Any static host works — Cloudflare, an S3-style bucket, etc.
Pick one home and **every partner site embeds the same map from it** — change the map once and it
updates everywhere.
**How it's wired** (one file: `vite.doxa-maps-build-config.json`; full field table in
**[reference.md](./reference.md)**): `bun run build` always writes to `public/doxa-maps/<bundle>/`.
Set `paths` to also copy the tree into another host's folder (e.g. doxa-life's `public/`). Set
`embedBaseUrl` to the public URL of your chosen home — it generates absolute, paste-anywhere embed
snippets (leave it empty for relative URLs that work wherever the folder is dropped).
> **`dev` never updates a built `public/`.** Run `bun run build` (or `BUNDLE=<name> bun run
> build:bundle`) and refresh to see a change on the served site. `bun run dev` is for iterating
> on a map in isolation.
---
## Compose maps into a SPA (the app shell)
Several maps + one layout = a single-page app, served from the same `public/` tree.
1. **Pick a wrapper** in `library/layouts/` (`app-wrapper-material` is the full shell: sidebar,
lazy-loading, i18n).
2. **Register your map** in two places inside the wrapper:
- `nav.js` — add a sidebar entry (`{ kind, id, label, icon, target }`; `target` controls
desktop/mobile packaging).
- the `PROFILES` object in its `index.html` — tell the shell what to mount:
`{ tag: '<your-element>', src: './doxa-maps/<bundle>/<bundle>.js', views: ['map'] }`.
3. **Install the shell**: `bun run shell` — copies the wrapper to `public/index.html`, so the
site root IS the SPA and the bundles sit beneath it. (Also required for the mobile app.)
4. **Build & serve**: `bun run build`, then serve `public/` — the shell lazy-loads each bundle
the first time you open its nav entry.
Contracts and gotchas (PROFILES shape, tag-vs-bundle names, `--no-token`):
[architecture/app-wrappers.md](./architecture/app-wrappers.md).
---
## Build the Android / iOS app (optional)
**Mobile is separate from `bun run build`.** The default build produces **only** the `doxa-maps`
web folder — the embeddable maps (and the SPA, if configured). It never touches Capacitor. The
Android/iOS app is an **opt-in** step that *reuses* that web build: Capacitor's `webDir` points at
`public/` (`appId: life.doxa.maps`, `appName: "Doxa Maps"`), so `cap sync` wraps the already-built
folder into a native app. You never need to build mobile to ship the web maps.
**Android** (project is committed; needs Android Studio + a JDK):
```bash
bun run apk:debug # build web → cap sync → verify → assemble a debug APK
bun run apk:install # adb install onto a connected device/emulator
```
Step by step instead: `bun run build` → `bun run cap:sync` → open `capacitor/android` in Android Studio.
**iOS** (scaffold once on a Mac): `./node_modules/.bin/cap add ios`, then `bun run build &&
./node_modules/.bin/cap sync ios`, and open `capacitor/ios` in Xcode. Same `webDir: public`.
> **Invoke the local bin, not `bunx`.** `bunx` is not on PATH in every environment (it exits 127,
> *command not found*), which is what broke `cap:sync` → `apk:debug` → `phone` at step one. The
> Capacitor CLI is installed locally, so `./node_modules/.bin/cap` always resolves. Elsewhere this
> repo uses the spaced `bun x` form for the same reason.
> `cap sync` copies `public/` into the native project's assets. A wrong `webDir` gives a **blank
> white screen** at launch (empty assets), not an error — `bun run cap:verify` guards against it.
---
## Swap the map engine (Mapbox ↔ MapLibre)
The engine is a **runtime parameter** — same bundle, no rebuild. Resolution order
(first defined wins): `profile-config.provider` → `?provider=` in the page URL →
`window.MAP_PROVIDER` → default `mapbox`.
```html
<!-- per embed -->
<my-map profile-config='{"profile":"my-map","provider":"maplibre"}'></my-map>
<!-- or flip any generated page from the URL -->
https://<cdn>/doxa-maps/<bundle>/<profile>/index.html?provider=maplibre
```
`maplibre` runs token-free on the OpenFreeMap style (the right GL script/CSS is injected
automatically); the geocoder search bar keeps working in both engines. `mapbox` (the default)
behaves exactly as before — existing embeds never change.
> Status: **on `main`.** Generated pages honor `provider:'maplibre'` end-to-end — the page
> loads the MapLibre peer instead of Mapbox and **skips the token ladder entirely** (no
> account, no key). Living demo: `demo-map/demo-map/maplibre/index.html` (a preconfigured
> instance); the embed smoke (`bun run smoke:embeds`) verifies it mounts with a live canvas.
---
## Set the API + Mapbox token
Both the data backend and the Mapbox token are **runtime inputs**, never baked into a bundle.
**Data (the API).** Maps read data through `@map/api/`, driven by `library/api/sources.json`,
which lists every connection by id, picks one `activeSource`, and maps each source's raw field
names onto the system field names components expect (`fieldMappings`). Three connection types:
`rest-api` (the default — the live people-group API), `csv` (a local file), `api` (a single
JSON URL). To add or change a source, edit `sources.json` — components stay source-agnostic.
The base URL resolves at runtime from `window.MAP_APP_API_URL` → `VITE_API_BASE_URL` → `''`
(same-origin), so a host repoints the API with **no rebuild**.
**The Mapbox token.** Supply it per embed as `tk` in `profile-config`, or point
`window.MAP_TOKEN_URL` (or the build-config `tokenUrl`) at an endpoint that returns it. The
token stays remote — only the URL ships. Set `tokenUrl` **absolute**
(`https://…/api/maps/token`) so a CDN-hosted folder and the mobile app both work; the endpoint
must send CORS headers permitting the embedding origins. In dev, the token comes from `.env`'s
`VITE_MAPBOX_TOKEN`. A Mapbox `pk.` token is designed to be client-visible — protect it with
URL restrictions at Mapbox, not by hiding it.
---
## Give your agent the guardrail
If you point an AI agent at this repo, paste this first, before it reads any code:
> This is the doxa-maps bundler. Three zones: `library/` (`@map`) = reusable shared blocks —
> **reuse via the `@map/…` alias, never edit**. `plugin-designs/<map>/` = the sandbox for **one**
> map — **build everything here**. `internal/` = the build machine — **never touch it**.
> To build a map: copy `plugin-designs/template-bundle/`, read its README, pick reusable pieces
> from `docs/reference.md`, then build the map's own code inside `plugin-designs/<the-map>/`.
> Change colors by editing **values** in that map's `src/colors/` folder — not logic in
> `library/`. Then run `bun run build`. If a change seems to belong in `library/`, stop and
> ask — that's an expert move that can break every other map.
Optional power-up: Mapbox publishes official agent skills (cartography, style patterns,
performance, integration) that make an agent much better at map work —
`/plugin marketplace add mapbox/mapbox-agent-skills` then `/plugin install mapbox`, or
`npx skills add mapbox/mapbox-agent-skills`.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Changed a `.vue`, served site shows old map | You ran `dev`, or didn't rebuild | `bun run build`, then refresh |
| `Set BUNDLE=<name> for build` | Ran `vite build` with no bundle selected | `bun run build` (all) or `BUNDLE=<name> bun run build:bundle` |
| New bundle doesn't appear | Missing `index.html` or `index.js` in the folder | Vite discovers a bundle only when both exist |
| Old bundle still loads after a build | Browser cached the static `.js` | Hard-refresh / disable cache in devtools |
| Map is blank on a partner page | Mapbox peer not loaded, or no token | Load `mapbox-gl.js` + geocoder **before** the bundle; pass a valid `tk` |
| `Profile not found` error box | `profile` doesn't match a flat `.vue` filename in the bundle | Fix the `profile` field to match a filename |