# 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 `app-profiles//`, 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 ` ``` `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:///doxa-maps///index.html ``` Link it, share it, or iframe it (perfect for CMSs and tools that only accept iframes, like Start.me or Notion): ```html ``` 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 `app-profiles/`; 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 app-profiles/template-bundle app-profiles/my-new-map ``` 2. **Rename the element tag.** In `app-profiles/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 ``. 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 `.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///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** (`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/.js` (e.g. `religion.js`) and change a hex in the `PALETTE`. The library auto-discovers every `.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 `app-profiles//src/colors/.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//`. 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= 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 `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: '', src: './doxa-maps//.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): `bunx cap add ios`, then `bun run build && bunx cap sync ios`, and open `capacitor/ios` in Xcode. Same `webDir: public`. > `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 https:///doxa-maps///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**. `app-profiles//` = the sandbox for **one** > map — **build everything here**. `internal/` = the build machine — **never touch it**. > To build a map: copy `app-profiles/template-bundle/`, read its README, pick reusable pieces > from `docs/reference.md`, then build the map's own code inside `app-profiles//`. > 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= for build` | Ran `vite build` with no bundle selected | `bun run build` (all) or `BUNDLE= 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 |